From 180757827b91d5dd9ffb49f5b31ce052d816e4b8 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 16 May 2026 21:53:12 -0400 Subject: [PATCH 1/5] fix(ui): mobile breakpoint layout and peek interactions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grid forced to single-column at ≤900px for all panel states. Resize handles hidden, transitions killed to prevent slivers. Peek pane gets mobile toolbar (expand/dismiss buttons). Escape dismisses peek at any viewport. Z toggles full-screen peek at mobile instead of no-op zen toggle. --- web/app.js | 56 +++++++++++++++++++++++++++++++++++++++++---------- web/style.css | 38 ++++++++++++++++++++++++++++++++-- 2 files changed, 81 insertions(+), 13 deletions(-) diff --git a/web/app.js b/web/app.js index a34351d..be5246c 100644 --- a/web/app.js +++ b/web/app.js @@ -694,16 +694,21 @@ pane.classList.add('visible'); + const mobileBar = `
+ + +
`; + if (state.peekMode === 'edit') { - pane.innerHTML = renderEditMode(e); + pane.innerHTML = mobileBar + renderEditMode(e); } else if (state.view === 'stream' || !e.card_type) { - pane.innerHTML = renderStreamPeek(e); + pane.innerHTML = mobileBar + renderStreamPeek(e); } else if (state.peekMode === 'run') { - pane.innerHTML = renderRunMode(e); + pane.innerHTML = mobileBar + renderRunMode(e); } else if (state.peekMode === 'fill') { - pane.innerHTML = renderFillMode(e); + pane.innerHTML = mobileBar + renderFillMode(e); } else { - pane.innerHTML = renderCardPeek(e); + pane.innerHTML = mobileBar + renderCardPeek(e); } bindPeekEvents(e); @@ -1401,6 +1406,20 @@ if (idx >= 0) { state.selectedIndex = idx; renderEntityList(); renderDetailPane(); } showToast(e.pinned ? 'unpinned' : 'pinned'); }, + + togglePeekFull() { + const pane = $('#detail-pane'); + pane.classList.toggle('peek-full'); + const btn = pane.querySelector('.peek-mobile-btn'); + if (btn) btn.textContent = pane.classList.contains('peek-full') ? '↓' : '↑'; + }, + + dismissPeek() { + const pane = $('#detail-pane'); + pane.classList.remove('visible', 'peek-full'); + state.selectedIndex = -1; + renderEntityList(); + }, }; // ========== Promote modal ========== @@ -1451,12 +1470,18 @@ return; } - if (ev.key === 'Escape' && $('main').classList.contains('focus-peek')) { - exitFocusPeek(); - state.selectedIndex = -1; - renderEntityList(); - renderDetailPane(); - return; + if (ev.key === 'Escape') { + const pane = $('#detail-pane'); + if ($('main').classList.contains('focus-peek')) { + exitFocusPeek(); + } + if (pane.classList.contains('visible')) { + pane.classList.remove('visible', 'peek-full'); + state.selectedIndex = -1; + renderEntityList(); + renderDetailPane(); + return; + } } const sel = state.entities[state.selectedIndex]; @@ -1540,7 +1565,16 @@ localStorage.setItem('nib:' + cls, m.classList.contains(cls) ? '1' : ''); } + function isMobileBreakpoint() { + return window.matchMedia('(max-width: 900px)').matches; + } + function toggleZen() { + if (isMobileBreakpoint()) { + if (state.selectedIndex >= 0) nibApp.togglePeekFull(); + return; + } + const m = $('main'); if (m.classList.contains('focus-peek')) { diff --git a/web/style.css b/web/style.css index 2e3863a..fd71a34 100644 --- a/web/style.css +++ b/web/style.css @@ -785,6 +785,28 @@ main.focus-peek .resize-handle { visibility: hidden; } overflow: hidden; } +.peek-mobile-bar { + display: none; + justify-content: space-between; + padding: 6px 12px; + border-bottom: 1px solid var(--border); +} + +.peek-mobile-btn { + font-family: var(--mono); + font-size: 14px; + color: var(--muted); + background: none; + border: 1px solid var(--border); + border-radius: 4px; + width: 28px; + height: 28px; + cursor: pointer; + transition: color var(--t-fast), border-color var(--t-fast); +} + +.peek-mobile-btn:hover { color: var(--accent); border-color: var(--accent); } + .peek-scroll { flex: 1; overflow-y: auto; @@ -1443,8 +1465,17 @@ kbd { background: var(--raised); border: 1px solid var(--border); border-radius: /* ── RESPONSIVE ─────────────────────────────────────── */ @media (max-width: 900px) { - main { grid-template-columns: 1fr; } - #tag-rail { display: none; } + main, + main.hide-rail, + main.hide-peek, + main.hide-rail.hide-peek, + main.focus-peek { + grid-template-columns: 1fr !important; + transition: none !important; + } + #tag-rail { display: none !important; } + .resize-handle { display: none !important; } + #entity-panel { overflow: auto; } #detail-pane { position: fixed; inset: 0; @@ -1458,4 +1489,7 @@ kbd { background: var(--raised); border: 1px solid var(--border); border-radius: z-index: 50; } #detail-pane.visible { transform: translateY(0); } + #detail-pane.peek-full { height: 100vh; height: 100dvh; top: 0; } + .peek-mobile-bar { display: flex; } + main.focus-peek #entity-panel { display: block; overflow: auto; min-width: 0; } } -- 2.52.0 From 694dfe1c89964ab68b7f960c01ad38e1a3028532 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 16 May 2026 22:32:41 -0400 Subject: [PATCH 2/5] feat(ui): inline expansion for mobile stream entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- web/app.js | 96 +++++++++++++++++++++++++++++++++++++++++++-------- web/style.css | 80 ++++++++++++++++++++++++++++++++++-------- 2 files changed, 147 insertions(+), 29 deletions(-) diff --git a/web/app.js b/web/app.js index be5246c..42078f6 100644 --- a/web/app.js +++ b/web/app.js @@ -648,6 +648,30 @@ `; } + function renderInlineDetail(e) { + const tags = (e.tags || []).map(t => `#${t}`).join(''); + let actions = ''; + actions += ``; + if (!e.card_type) { + actions += ``; + actions += ``; + } + if (e.card_type) { + actions += ``; + } else { + actions += ``; + } + return `
+
${escHtml(e.body || '')}
+ ${tags ? `
${tags}
` : ''} +
${actions}
+
+ + +
+
`; + } + function renderEntityItem(e, idx) { const glyph = displayGlyph(e); const gc = glyphClass(e); @@ -668,11 +692,18 @@ } return `
- ${glyph} - ${label} - ${time} - ${tags}${cardBadge} - ${useBadge} +
+ ${glyph} + ${label} + ${time} + ${tags}${cardBadge} + ${useBadge} +
+
+
+ ${renderInlineDetail(e)} +
+
`; } @@ -694,21 +725,16 @@ pane.classList.add('visible'); - const mobileBar = `
- - -
`; - if (state.peekMode === 'edit') { - pane.innerHTML = mobileBar + renderEditMode(e); + pane.innerHTML = renderEditMode(e); } else if (state.view === 'stream' || !e.card_type) { - pane.innerHTML = mobileBar + renderStreamPeek(e); + pane.innerHTML = renderStreamPeek(e); } else if (state.peekMode === 'run') { - pane.innerHTML = mobileBar + renderRunMode(e); + pane.innerHTML = renderRunMode(e); } else if (state.peekMode === 'fill') { - pane.innerHTML = mobileBar + renderFillMode(e); + pane.innerHTML = renderFillMode(e); } else { - pane.innerHTML = mobileBar + renderCardPeek(e); + pane.innerHTML = renderCardPeek(e); } bindPeekEvents(e); @@ -1105,6 +1131,20 @@ // ========== Actions ========== 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.peekMode = 'preview'; state.runChecked = new Set(); @@ -1408,13 +1448,31 @@ }, togglePeekFull() { + if (isMobileBreakpoint()) { + this.expandInline(); + return; + } const pane = $('#detail-pane'); pane.classList.toggle('peek-full'); const btn = pane.querySelector('.peek-mobile-btn'); 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() { + if (isMobileBreakpoint()) { + const sel = $(`.entity-item.selected`); + if (sel) sel.classList.remove('selected', 'exp-full'); + state.selectedIndex = -1; + return; + } const pane = $('#detail-pane'); pane.classList.remove('visible', 'peek-full'); state.selectedIndex = -1; @@ -1471,6 +1529,14 @@ } 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'); if ($('main').classList.contains('focus-peek')) { exitFocusPeek(); diff --git a/web/style.css b/web/style.css index fd71a34..fb7636f 100644 --- a/web/style.css +++ b/web/style.css @@ -375,28 +375,72 @@ main.focus-peek .resize-handle { visibility: hidden; } } .entity-item { + cursor: pointer; + border-left: 2px solid transparent; + transition: background var(--t-fast), border-left-color var(--t-fast); +} + +.entity-head { display: flex; align-items: center; gap: 8px; padding: 5px 16px 5px 20px; - cursor: pointer; - border-left: 2px solid transparent; min-height: 32px; - transition: background var(--t-fast), border-left-color var(--t-fast); } .entity-item:hover { background: var(--surf); } .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 { background: var(--surf); margin: 2px 10px; border-radius: var(--r2); border: 1px solid var(--border); 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.selected { border-color: var(--accent); background: var(--a-bg); } @@ -785,13 +829,6 @@ main.focus-peek .resize-handle { visibility: hidden; } overflow: hidden; } -.peek-mobile-bar { - display: none; - justify-content: space-between; - padding: 6px 12px; - border-bottom: 1px solid var(--border); -} - .peek-mobile-btn { font-family: var(--mono); font-size: 14px; @@ -1488,8 +1525,23 @@ kbd { background: var(--raised); border: 1px solid var(--border); border-radius: transition: transform var(--t-base); z-index: 50; } - #detail-pane.visible { transform: translateY(0); } - #detail-pane.peek-full { height: 100vh; height: 100dvh; top: 0; } - .peek-mobile-bar { display: flex; } + #detail-pane { display: none !important; } + .entity-exp { + 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; } } -- 2.52.0 From 35df7dcb69e4c025dc479a1fb3dca5cd4b1d1e32 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 16 May 2026 22:35:43 -0400 Subject: [PATCH 3/5] fix(ui): card fullscreen transparency in mobile stream Explicit .is-card.exp-full selector overrides card background/border/margin so fullscreen overlay is fully opaque. --- web/style.css | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/web/style.css b/web/style.css index fb7636f..647de4a 100644 --- a/web/style.css +++ b/web/style.css @@ -1533,13 +1533,16 @@ kbd { background: var(--raised); border: 1px solid var(--border); border-radius: } .entity-item.selected .entity-exp { grid-template-rows: 1fr; } .entity-item.selected .exp-toolbar { display: flex; } - .entity-item.exp-full { + .entity-item.exp-full, + .entity-item.is-card.exp-full { position: fixed; inset: 0; z-index: 60; background: var(--bg); overflow-y: auto; - border-left: none; + border: none; + margin: 0; + border-radius: 0; } .entity-item.exp-full .entity-exp { grid-template-rows: 1fr; } .entity-item.exp-full .exp-inner { padding-top: 1rem; padding-bottom: 2rem; } -- 2.52.0 From ad44d35d9b54d56e4a9eb7ed305b8c325b72a4f3 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 16 May 2026 22:38:38 -0400 Subject: [PATCH 4/5] fix(ui): render markdown in mobile inline expansion Use renderMd instead of escHtml for exp-body content. Add .md class for consistent markdown styling. --- web/app.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/app.js b/web/app.js index 42078f6..249a830 100644 --- a/web/app.js +++ b/web/app.js @@ -662,7 +662,7 @@ actions += ``; } return `
-
${escHtml(e.body || '')}
+
${renderMd(e.body || '')}
${tags ? `
${tags}
` : ''}
${actions}
-- 2.52.0 From fa960ec204ea859e7cb8aa48717211ca4e6eaff8 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 16 May 2026 22:46:01 -0400 Subject: [PATCH 5/5] feat(ui): inline expansion for cards view at mobile Same accordion pattern as stream: card-row gets entity-exp markup, selectEntity/expandInline/dismissPeek/Escape all handle .card-row. Fullscreen expand works for both views. --- web/app.js | 33 ++++++++++++++++++++------------- web/style.css | 28 ++++++++++++++++++---------- 2 files changed, 38 insertions(+), 23 deletions(-) diff --git a/web/app.js b/web/app.js index 249a830..5d2904e 100644 --- a/web/app.js +++ b/web/app.js @@ -636,14 +636,21 @@ const affHtml = affs.map(a => `${AFF_LABELS[a]}`).join(''); return `
- ${escHtml(title)} - - ${preview} -
- ${affHtml} - ${tags} - ${e.pinned ? '' : ''} - ${e.use_count > 0 ? `${e.use_count}×` : ''} +
+ ${escHtml(title)} + + ${preview} +
+ ${affHtml} + ${tags} + ${e.pinned ? '' : ''} + ${e.use_count > 0 ? `${e.use_count}×` : ''} +
+
+
+
+ ${renderInlineDetail(e)} +
`; } @@ -1138,9 +1145,9 @@ } else { state.selectedIndex = idx; } - $$('.entity-item.selected').forEach(el => el.classList.remove('selected')); + $$('.entity-item.selected, .card-row.selected').forEach(el => el.classList.remove('selected')); if (state.selectedIndex >= 0) { - const target = $(`.entity-item[data-index="${state.selectedIndex}"]`); + const target = $(`.entity-item[data-index="${state.selectedIndex}"], .card-row[data-index="${state.selectedIndex}"]`); if (target) target.classList.add('selected'); } return; @@ -1459,7 +1466,7 @@ }, expandInline() { - const sel = $(`.entity-item.selected`); + const sel = $(`.entity-item.selected, .card-row.selected`); if (!sel) return; sel.classList.toggle('exp-full'); const btn = sel.querySelector('.exp-toolbar .peek-mobile-btn'); @@ -1468,7 +1475,7 @@ dismissPeek() { if (isMobileBreakpoint()) { - const sel = $(`.entity-item.selected`); + const sel = $(`.entity-item.selected, .card-row.selected`); if (sel) sel.classList.remove('selected', 'exp-full'); state.selectedIndex = -1; return; @@ -1531,7 +1538,7 @@ if (ev.key === 'Escape') { if (isMobileBreakpoint()) { if (state.selectedIndex >= 0) { - const sel = $(`.entity-item.selected`); + const sel = $(`.entity-item.selected, .card-row.selected`); if (sel) sel.classList.remove('selected', 'exp-full'); state.selectedIndex = -1; return; diff --git a/web/style.css b/web/style.css index 647de4a..07fb835 100644 --- a/web/style.css +++ b/web/style.css @@ -600,20 +600,23 @@ main.focus-peek .resize-handle { visibility: hidden; } /* ── CARD ROWS ──────────────────────────────────────── */ .card-row { - display: flex; - align-items: center; - gap: 7px; - padding: 9px 12px 9px 14px; margin: 2px 10px; background: var(--surf); border: 1px solid var(--border); border-radius: var(--r2); cursor: pointer; - min-height: 40px; position: relative; transition: border-color var(--t-fast), background var(--t-fast); } +.card-head { + display: flex; + align-items: center; + gap: 7px; + padding: 9px 12px 9px 14px; + min-height: 40px; +} + .card-row:hover { border-color: var(--muted); } .card-row.selected { border-color: var(--accent); background: var(--a-bg); } .card-row.pinned { border-left: 2px solid var(--accent); } @@ -1531,10 +1534,13 @@ kbd { background: var(--raised); border: 1px solid var(--border); border-radius: 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.selected .entity-exp, + .card-row.selected .entity-exp { grid-template-rows: 1fr; } + .entity-item.selected .exp-toolbar, + .card-row.selected .exp-toolbar { display: flex; } .entity-item.exp-full, - .entity-item.is-card.exp-full { + .entity-item.is-card.exp-full, + .card-row.exp-full { position: fixed; inset: 0; z-index: 60; @@ -1544,7 +1550,9 @@ kbd { background: var(--raised); border: 1px solid var(--border); border-radius: margin: 0; border-radius: 0; } - .entity-item.exp-full .entity-exp { grid-template-rows: 1fr; } - .entity-item.exp-full .exp-inner { padding-top: 1rem; padding-bottom: 2rem; } + .entity-item.exp-full .entity-exp, + .card-row.exp-full .entity-exp { grid-template-rows: 1fr; } + .entity-item.exp-full .exp-inner, + .card-row.exp-full .exp-inner { padding-top: 1rem; padding-bottom: 2rem; } main.focus-peek #entity-panel { display: block; overflow: auto; min-width: 0; } } -- 2.52.0