From 156ea6ea1ce27b75231f16a9757c5ba3cee1bc51 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 16 May 2026 09:29:51 -0400 Subject: [PATCH] =?UTF-8?q?feat(ui):=20phase=202=20=E2=80=94=20card=20rows?= =?UTF-8?q?,=20affordance=20badges,=20cards=20sub-header?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rich card row rendering: title — preview — affordance badges — tags — pin — use count - Affordance detection (code, fill, steps, decide, link) from entity shape - Cards sub-header with scope label, count, sort dropdown - Section labels (★ pinned / recent) in cards view - Flash animation on copy (--a-str pulse) - Tag pill styling for card rows - Progress bar mini-display for checklists in card preview --- web/app.js | 116 ++++++++++++++++++++++++++++++++-- web/style.css | 172 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 284 insertions(+), 4 deletions(-) diff --git a/web/app.js b/web/app.js index 4b3970f..40ed2b0 100644 --- a/web/app.js +++ b/web/app.js @@ -27,6 +27,7 @@ hasMore: false, activeMonth: null, intent: 'grab', + flashId: null, }; const $ = (sel) => document.querySelector(sel); @@ -214,6 +215,42 @@ return null; } + function detectAffordances(entity) { + const affs = []; + const body = entity.body || ''; + const data = entity.card_data ? (() => { try { return JSON.parse(entity.card_data); } catch { return {}; } })() : {}; + if (data.lang || entity.card_type === 'snippet') affs.push('code'); + if (/\$\{[^}]+\}/.test(body)) affs.push('fill'); + if (data.steps && data.steps.length) affs.push('steps'); + if (data.chose != null || entity.card_type === 'decision') affs.push('decide'); + if (data.url || entity.card_type === 'link') affs.push('link'); + return affs; + } + + const AFF_LABELS = { code: 'code', fill: 'tpl', steps: 'steps', decide: 'dec', link: 'link' }; + const AFF_CLASSES = { code: 'aff-code', fill: 'aff-fill', steps: 'aff-steps', decide: 'aff-decide', link: 'aff-link' }; + + function cardPreview(entity) { + const data = entity.card_data ? (() => { try { return JSON.parse(entity.card_data); } catch { return {}; } })() : {}; + if (data.chose) return `▸ ${escHtml(data.chose)}`; + if (data.steps && data.steps.length) { + const done = data.steps.filter(s => s.done).length; + const total = data.steps.length; + const pct = total > 0 ? Math.round(done / total * 100) : 0; + return ` ${done}/${total} steps`; + } + if (/\$\{[^}]+\}/.test(entity.body || '')) { + const slots = []; + const re = /\$\{([^}]+)\}/g; + let m; + while ((m = re.exec(entity.body)) && slots.length < 2) slots.push(m[1]); + return slots.map(s => `\${${escHtml(s)}}`).join(' '); + } + if (data.url) return `${escHtml(data.url.replace(/^https?:\/\//, ''))}`; + const first = (entity.body || '').split('\n')[0] || ''; + return escHtml(first.slice(0, 60)); + } + // ========== Rendering ========== function displayGlyph(entity) { @@ -368,11 +405,13 @@ if (state.entities.length === 0) { list.innerHTML = '
no entities yet
'; + renderCardsHeader(false); return; } let html = ''; if (state.view === 'stream') { + renderCardsHeader(false); const groups = groupByDate(state.entities); let idx = 0; for (const g of groups) { @@ -383,9 +422,22 @@ } } } else { - state.entities.forEach((e, idx) => { - html += renderEntityItem(e, idx); - }); + renderCardsHeader(true); + const pinned = state.entities.filter(e => e.pinned); + const rest = state.entities.filter(e => !e.pinned); + let idx = 0; + if (pinned.length) { + html += '
★ pinned
'; + for (const e of pinned) { + html += renderCardRow(e, state.entities.indexOf(e)); + } + } + if (rest.length) { + if (pinned.length) html += '
recent
'; + for (const e of rest) { + html += renderCardRow(e, state.entities.indexOf(e)); + } + } } if (state.hasMore) { @@ -400,10 +452,63 @@ }); }); + list.querySelectorAll('.card-row').forEach(el => { + el.addEventListener('click', (ev) => { + if (!ev.target.closest('.aff')) { + selectEntity(parseInt(el.dataset.index)); + } + }); + }); + const loadMoreBtn = list.querySelector('.load-more-btn'); if (loadMoreBtn) loadMoreBtn.addEventListener('click', loadMore); } + function renderCardsHeader(show) { + let hdr = $('#cards-hdr'); + if (!show) { + if (hdr) hdr.remove(); + return; + } + if (!hdr) { + hdr = document.createElement('div'); + hdr.id = 'cards-hdr'; + hdr.className = 'cards-hdr'; + const panel = $('#entity-panel'); + const list = $('#entity-list'); + panel.insertBefore(hdr, list); + } + const scope = state.activeTag ? `${state.intent} · #${state.activeTag}` : state.intent; + hdr.innerHTML = ` + ${scope} + ${state.entities.length} cards + + `; + } + + function renderCardRow(e, idx) { + const selected = idx === state.selectedIndex ? ' selected' : ''; + const pinCls = e.pinned ? ' pinned' : ''; + const flashCls = state.flashId === e.id ? ' flashing' : ''; + const title = e.title || (e.body || '').split('\n')[0].slice(0, 50); + const affs = detectAffordances(e); + const preview = cardPreview(e); + const tags = (e.tags || []).slice(0, 2).map(t => `#${t}`).join(''); + const affHtml = affs.map(a => `${AFF_LABELS[a]}`).join(''); + + return `
+ ${escHtml(title)} + + ${preview} +
+ ${affHtml} + ${tags} + ${e.pinned ? '' : ''} + ${e.use_count > 0 ? `${e.use_count}×` : ''} +
+
`; + } + function renderEntityItem(e, idx) { const glyph = displayGlyph(e); const gc = glyphClass(e); @@ -652,7 +757,8 @@ function renderMonthNav() { const nav = $('#month-nav'); - if (state.view !== 'stream') { nav.innerHTML = ''; return; } + if (state.view !== 'stream') { nav.innerHTML = ''; nav.style.display = 'none'; return; } + nav.style.display = ''; const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']; const label = state.activeMonth @@ -720,8 +826,10 @@ try { await navigator.clipboard.writeText(e.body); await api.useEntity(id); + state.flashId = id; await loadEntities(); showToast('copied'); + setTimeout(() => { state.flashId = null; renderEntityList(); }, 360); } catch (err) { console.error('clipboard:', err); } diff --git a/web/style.css b/web/style.css index e9f73fa..9f37905 100644 --- a/web/style.css +++ b/web/style.css @@ -436,6 +436,178 @@ main { font-size: 9px; } +/* ── CARDS SUB-HEADER ──────────────────────────────── */ +.cards-hdr { + display: flex; + align-items: center; + gap: 10px; + padding: 7px 14px; + border-bottom: 1px solid var(--soft); + background: var(--surf); + flex-shrink: 0; +} + +.cards-scope { + font-family: var(--sans); + font-size: 12px; + font-weight: 500; + color: var(--text); +} + +.cards-count { + font-family: var(--mono); + font-size: 10px; + color: var(--dim); + margin-left: auto; +} + +.cards-sort { + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--r2); + padding: 3px 6px; + font-family: var(--mono); + font-size: 11px; + color: var(--muted); + outline: none; +} + +/* ── 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-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); } + +@keyframes card-flash { 0%,100%{} 50%{ background: var(--a-str); } } +.card-row.flashing { animation: card-flash .3s ease; } + +.card-row-title { + font-family: var(--sans); + font-size: 12px; + font-weight: 500; + color: var(--text); + flex-shrink: 0; + max-width: 145px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.card-row-dash { + color: var(--dim); + font-family: var(--mono); + font-size: 10px; + flex-shrink: 0; +} + +.card-row-preview { + flex: 1; + min-width: 0; + font-family: var(--mono); + font-size: 11px; + color: var(--muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.card-row-preview .slot { color: var(--lineage); } +.card-row-preview .link { color: var(--event); } +.card-row-preview .choice { color: var(--text); } + +.card-row-right { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + margin-left: auto; +} + +.card-row-pin { + color: var(--accent); + font-size: 10px; +} + +.card-row-use { + font-family: var(--mono); + font-size: 9px; + color: var(--todo); +} + +/* progress bar in card row */ +.card-row-prog { + display: inline-block; + width: 36px; + height: 3px; + background: var(--border); + border-radius: 2px; + overflow: hidden; + vertical-align: middle; +} + +.card-row-prog span { + display: block; + height: 100%; + background: var(--ok); + border-radius: 2px; +} + +/* ── SECTION LABELS ─────────────────────────────────── */ +.list-sec-lbl { + font-family: var(--mono); + font-size: 9px; + text-transform: uppercase; + letter-spacing: .16em; + color: var(--dim); + padding: 8px 14px 4px; +} + +/* ── AFFORDANCE BADGES ──────────────────────────────── */ +.aff { + font-family: var(--mono); + font-size: 9px; + letter-spacing: .04em; + padding: 1px 6px; + border-radius: var(--r1); + border: 1px solid; + flex-shrink: 0; +} + +.aff.clickable { cursor: pointer; transition: opacity var(--t-fast); } +.aff.clickable:hover { opacity: .7; } + +.aff-code { color: var(--accent); border-color: rgba(200,148,42,.38); background: var(--a-bg); } +.aff-fill { color: var(--lineage); border-color: rgba(152,120,188,.38); background: rgba(152,120,188,.06); } +.aff-steps { color: var(--ok); border-color: rgba(122,171,114,.38); background: rgba(122,171,114,.06); } +.aff-decide { color: var(--note); border-color: rgba(106,184,176,.38); background: rgba(106,184,176,.06); } +.aff-link { color: var(--event); border-color: rgba(104,152,200,.38); background: rgba(104,152,200,.06); } + +/* ── TAG PILLS ──────────────────────────────────────── */ +.tag-pill { + font-family: var(--mono); + font-size: 10px; + color: var(--accent); + border: 1px solid rgba(200,148,42,.35); + background: var(--a-bg); + padding: 2px 7px; + border-radius: var(--r1); + flex-shrink: 0; +} + /* ── CAPTURE BAR ────────────────────────────────────── */ #capture-bar { border-top: 1px solid var(--border);