feat(ui): inline expansion for mobile stream entries

Replace bottom-sheet peek with inline accordion at ≤900px.
Entries expand in-place with grid-template-rows animation (0.2s).
Body clamped to 3 lines; fullscreen uncaps it.
Selection toggles DOM classes instead of re-rendering for fluid j/k nav.
This commit is contained in:
2026-05-16 22:32:41 -04:00
parent 180757827b
commit 694dfe1c89
2 changed files with 147 additions and 29 deletions
+76 -10
View File
@@ -648,6 +648,30 @@
</div>`; </div>`;
} }
function renderInlineDetail(e) {
const tags = (e.tags || []).map(t => `<span class="detail-tag">#${t}</span>`).join('');
let actions = '';
actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.enterMode('edit')">edit</button>`;
if (!e.card_type) {
actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.showAbsorb('${e.id}')">absorb</button>`;
actions += `<button class="action-btn primary" onclick="event.stopPropagation();nibApp.showPromote('${e.id}')">promote</button>`;
}
if (e.card_type) {
actions += `<button class="action-btn danger" onclick="event.stopPropagation();nibApp.demoteEntity('${e.id}')">demote</button>`;
} else {
actions += `<button class="action-btn danger" onclick="event.stopPropagation();nibApp.deleteEntity('${e.id}')">delete</button>`;
}
return `<div class="exp-inner">
<div class="exp-body">${escHtml(e.body || '')}</div>
${tags ? `<div class="exp-tags">${tags}</div>` : ''}
<div class="exp-acts">${actions}</div>
<div class="exp-toolbar">
<button class="peek-mobile-btn" onclick="event.stopPropagation();nibApp.expandInline()">↑</button>
<button class="peek-mobile-btn" onclick="event.stopPropagation();nibApp.dismissPeek()">×</button>
</div>
</div>`;
}
function renderEntityItem(e, idx) { function renderEntityItem(e, idx) {
const glyph = displayGlyph(e); const glyph = displayGlyph(e);
const gc = glyphClass(e); const gc = glyphClass(e);
@@ -668,11 +692,18 @@
} }
return `<div class="entity-item${selected}${isCard}" data-index="${idx}" data-id="${e.id}"> return `<div class="entity-item${selected}${isCard}" data-index="${idx}" data-id="${e.id}">
<div class="entity-head">
<span class="entity-glyph ${gc}">${glyph}</span> <span class="entity-glyph ${gc}">${glyph}</span>
${label} ${label}
${time} ${time}
<span class="entity-tags">${tags}${cardBadge}</span> <span class="entity-tags">${tags}${cardBadge}</span>
<span class="entity-meta">${useBadge}</span> <span class="entity-meta">${useBadge}</span>
</div>
<div class="entity-exp">
<div class="entity-exp-clip">
${renderInlineDetail(e)}
</div>
</div>
</div>`; </div>`;
} }
@@ -694,21 +725,16 @@
pane.classList.add('visible'); pane.classList.add('visible');
const mobileBar = `<div class="peek-mobile-bar">
<button class="peek-mobile-btn" onclick="nibApp.togglePeekFull()">↑</button>
<button class="peek-mobile-btn" onclick="nibApp.dismissPeek()">×</button>
</div>`;
if (state.peekMode === 'edit') { if (state.peekMode === 'edit') {
pane.innerHTML = mobileBar + renderEditMode(e); pane.innerHTML = renderEditMode(e);
} else if (state.view === 'stream' || !e.card_type) { } else if (state.view === 'stream' || !e.card_type) {
pane.innerHTML = mobileBar + renderStreamPeek(e); pane.innerHTML = renderStreamPeek(e);
} else if (state.peekMode === 'run') { } else if (state.peekMode === 'run') {
pane.innerHTML = mobileBar + renderRunMode(e); pane.innerHTML = renderRunMode(e);
} else if (state.peekMode === 'fill') { } else if (state.peekMode === 'fill') {
pane.innerHTML = mobileBar + renderFillMode(e); pane.innerHTML = renderFillMode(e);
} else { } else {
pane.innerHTML = mobileBar + renderCardPeek(e); pane.innerHTML = renderCardPeek(e);
} }
bindPeekEvents(e); bindPeekEvents(e);
@@ -1105,6 +1131,20 @@
// ========== Actions ========== // ========== Actions ==========
function selectEntity(idx) { function selectEntity(idx) {
if (isMobileBreakpoint()) {
const prev = state.selectedIndex;
if (prev === idx) {
state.selectedIndex = -1;
} else {
state.selectedIndex = idx;
}
$$('.entity-item.selected').forEach(el => el.classList.remove('selected'));
if (state.selectedIndex >= 0) {
const target = $(`.entity-item[data-index="${state.selectedIndex}"]`);
if (target) target.classList.add('selected');
}
return;
}
state.selectedIndex = idx; state.selectedIndex = idx;
state.peekMode = 'preview'; state.peekMode = 'preview';
state.runChecked = new Set(); state.runChecked = new Set();
@@ -1408,13 +1448,31 @@
}, },
togglePeekFull() { togglePeekFull() {
if (isMobileBreakpoint()) {
this.expandInline();
return;
}
const pane = $('#detail-pane'); const pane = $('#detail-pane');
pane.classList.toggle('peek-full'); pane.classList.toggle('peek-full');
const btn = pane.querySelector('.peek-mobile-btn'); const btn = pane.querySelector('.peek-mobile-btn');
if (btn) btn.textContent = pane.classList.contains('peek-full') ? '↓' : '↑'; if (btn) btn.textContent = pane.classList.contains('peek-full') ? '↓' : '↑';
}, },
expandInline() {
const sel = $(`.entity-item.selected`);
if (!sel) return;
sel.classList.toggle('exp-full');
const btn = sel.querySelector('.exp-toolbar .peek-mobile-btn');
if (btn) btn.textContent = sel.classList.contains('exp-full') ? '↓' : '↑';
},
dismissPeek() { dismissPeek() {
if (isMobileBreakpoint()) {
const sel = $(`.entity-item.selected`);
if (sel) sel.classList.remove('selected', 'exp-full');
state.selectedIndex = -1;
return;
}
const pane = $('#detail-pane'); const pane = $('#detail-pane');
pane.classList.remove('visible', 'peek-full'); pane.classList.remove('visible', 'peek-full');
state.selectedIndex = -1; state.selectedIndex = -1;
@@ -1471,6 +1529,14 @@
} }
if (ev.key === 'Escape') { if (ev.key === 'Escape') {
if (isMobileBreakpoint()) {
if (state.selectedIndex >= 0) {
const sel = $(`.entity-item.selected`);
if (sel) sel.classList.remove('selected', 'exp-full');
state.selectedIndex = -1;
return;
}
}
const pane = $('#detail-pane'); const pane = $('#detail-pane');
if ($('main').classList.contains('focus-peek')) { if ($('main').classList.contains('focus-peek')) {
exitFocusPeek(); exitFocusPeek();
+66 -14
View File
@@ -375,28 +375,72 @@ main.focus-peek .resize-handle { visibility: hidden; }
} }
.entity-item { .entity-item {
cursor: pointer;
border-left: 2px solid transparent;
transition: background var(--t-fast), border-left-color var(--t-fast);
}
.entity-head {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 5px 16px 5px 20px; padding: 5px 16px 5px 20px;
cursor: pointer;
border-left: 2px solid transparent;
min-height: 32px; min-height: 32px;
transition: background var(--t-fast), border-left-color var(--t-fast);
} }
.entity-item:hover { background: var(--surf); } .entity-item:hover { background: var(--surf); }
.entity-item.selected { background: var(--surf); border-left-color: var(--accent); } .entity-item.selected { background: var(--surf); border-left-color: var(--accent); }
.entity-exp { display: none; }
.entity-exp-clip { overflow: hidden; }
.exp-inner {
padding: .6rem 1rem .7rem calc(20px + 14px + 8px);
border-top: 1px solid var(--border);
}
.exp-body {
font-family: var(--mono);
font-size: 11px;
color: var(--text);
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
margin-bottom: .5rem;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.entity-item.exp-full .exp-body {
-webkit-line-clamp: unset;
overflow: visible;
}
.exp-tags { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: .5rem; }
.exp-acts { display: flex; flex-wrap: wrap; gap: 4px; }
.exp-toolbar {
display: none;
justify-content: space-between;
margin-top: .5rem;
padding-top: .5rem;
border-top: 1px solid var(--border);
}
.entity-item.is-card { .entity-item.is-card {
background: var(--surf); background: var(--surf);
margin: 2px 10px; margin: 2px 10px;
border-radius: var(--r2); border-radius: var(--r2);
border: 1px solid var(--border); border: 1px solid var(--border);
border-left-width: 1px; border-left-width: 1px;
padding: 7px 12px;
} }
.entity-item.is-card .entity-head { padding: 7px 12px; }
.entity-item.is-card:hover { border-color: var(--muted); } .entity-item.is-card:hover { border-color: var(--muted); }
.entity-item.is-card.selected { border-color: var(--accent); background: var(--a-bg); } .entity-item.is-card.selected { border-color: var(--accent); background: var(--a-bg); }
@@ -785,13 +829,6 @@ main.focus-peek .resize-handle { visibility: hidden; }
overflow: hidden; overflow: hidden;
} }
.peek-mobile-bar {
display: none;
justify-content: space-between;
padding: 6px 12px;
border-bottom: 1px solid var(--border);
}
.peek-mobile-btn { .peek-mobile-btn {
font-family: var(--mono); font-family: var(--mono);
font-size: 14px; font-size: 14px;
@@ -1488,8 +1525,23 @@ kbd { background: var(--raised); border: 1px solid var(--border); border-radius:
transition: transform var(--t-base); transition: transform var(--t-base);
z-index: 50; z-index: 50;
} }
#detail-pane.visible { transform: translateY(0); } #detail-pane { display: none !important; }
#detail-pane.peek-full { height: 100vh; height: 100dvh; top: 0; } .entity-exp {
.peek-mobile-bar { display: flex; } display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows .2s ease;
}
.entity-item.selected .entity-exp { grid-template-rows: 1fr; }
.entity-item.selected .exp-toolbar { display: flex; }
.entity-item.exp-full {
position: fixed;
inset: 0;
z-index: 60;
background: var(--bg);
overflow-y: auto;
border-left: none;
}
.entity-item.exp-full .entity-exp { grid-template-rows: 1fr; }
.entity-item.exp-full .exp-inner { padding-top: 1rem; padding-bottom: 2rem; }
main.focus-peek #entity-panel { display: block; overflow: auto; min-width: 0; } main.focus-peek #entity-panel { display: block; overflow: auto; min-width: 0; }
} }