diff --git a/TODO.md b/TODO.md
new file mode 100644
index 0000000..e532696
--- /dev/null
+++ b/TODO.md
@@ -0,0 +1,37 @@
+# UI Redesign — Design Handoff Implementation
+
+## Phase 1: Layout + Tokens + Header + Rail ✓
+- [x] Update CSS tokens (add --a-str, switch mono font to JetBrains Mono)
+- [x] Fix grid dimensions (192px rail, 400px peek)
+- [x] Move capture bar from header to bottom of center panel
+- [x] Add search bar to header (centered, max-width 400px)
+- [x] Redesign tag rail: grid layout (arrow ▸ + dot + name + count)
+- [x] Add intent section (grab/read/fill) for cards view in rail
+
+## Phase 2: Stream + Cards Views ✓
+- [x] Stream rows: promoted entries get card-style border/radius + card-type badge
+- [x] Card rows: rich single-line with title — preview — affordance badges — tag pills — pin — use count
+- [x] Affordance detection client-side (fill, steps, decide, link, code)
+- [x] Affordance badge components
+- [x] Cards sub-header (scope label + card count + sort dropdown)
+- [x] Section labels (★ pinned, recent)
+- [x] Flash animation on copy
+- [x] Bottom capture bar styling per view (different placeholders)
+
+## Phase 3: Peek Pane + Modes ✓
+- [x] Idle state with keyboard shortcuts display
+- [x] Stream entry peek: eyebrow, body, tags, context, actions
+- [x] Card peek: card container with eyebrow, title, desc, meta, content sections
+- [x] Code block with content display
+- [x] Decision section display
+- [x] Steps section display
+- [x] Link section display
+- [x] Run mode (interactive checklist with progress bar)
+- [x] Fill mode (inline slot editor with tab navigation)
+- [x] Edit mode (form fields)
+- [x] Toast notifications
+
+## Phase 4: Polish ✓
+- [x] Promote modal enhancement (add hint text per type, show entry body preview)
+- [x] Keyboard shortcuts (r=run, f=fill, p=pin in cards view)
+- [x] Escape exits active modes
diff --git a/web/app.js b/web/app.js
index 10c1ec6..9ece617 100644
--- a/web/app.js
+++ b/web/app.js
@@ -16,6 +16,8 @@
const PAGE_SIZE = 50;
+ const INTENT_HINTS = { grab: 'scan + copy', read: 'expand + study', fill: 'templates only' };
+
const state = {
view: 'stream',
entities: [],
@@ -24,6 +26,13 @@
activeTag: null,
hasMore: false,
activeMonth: null,
+ intent: 'grab',
+ flashId: null,
+ peekMode: 'preview',
+ runChecked: new Set(),
+ fillValues: {},
+ fillActive: 0,
+ searchQuery: '',
};
const $ = (sel) => document.querySelector(sel);
@@ -211,6 +220,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) {
@@ -223,32 +268,128 @@
function formatDate(dateStr) {
const d = new Date(dateStr);
- const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
+ const months = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'];
return months[d.getMonth()] + ' ' + d.getDate();
}
+ // ── Tag Rail ──
+
function renderTagRail() {
const rail = $('#tag-rail');
- const allItem = `
- all
-
`;
+ const total = state.tags.reduce((s, t) => s + t.count, 0);
- rail.innerHTML = allItem + state.tags.map(t =>
- `
- ${t.tag}
- ${t.count}
-
`
- ).join('');
+ let html = `nib
`;
+ html += '';
+ rail.innerHTML = html;
+
+ rail.querySelectorAll('.rail-item[data-tag]').forEach(el => {
el.addEventListener('click', () => {
state.activeTag = el.dataset.tag || null;
loadEntities();
renderTagRail();
});
});
+
+ rail.querySelectorAll('.rail-item[data-intent]').forEach(el => {
+ el.addEventListener('click', () => {
+ state.intent = el.dataset.intent;
+ renderTagRail();
+ });
+ });
}
+ // ── Capture Bar ──
+
+ function renderCaptureBar() {
+ const bar = $('#capture-bar');
+ const placeholder = state.view === 'stream'
+ ? 'capture · - todo @time event !time reminder #tag |title'
+ : '|title // desc #tag ${slot} 1. step';
+
+ bar.innerHTML = `
+
+ ›
+
+ ⏎ save
+
+ `;
+
+ const input = $('#capture-input');
+ input.addEventListener('keydown', (ev) => {
+ if (ev.key === 'Enter' && !ev.shiftKey) {
+ ev.preventDefault();
+ handleCapture();
+ }
+ });
+ }
+
+ async function handleCapture() {
+ const input = $('#capture-input');
+ const val = input.value.trim();
+ if (!val) return;
+
+ const parsed = parseInput(val);
+ if (!parsed) return;
+
+ const data = {
+ body: parsed.body,
+ glyph: parsed.glyph,
+ tags: parsed.tags,
+ };
+ if (parsed.title) data.title = parsed.title;
+ if (parsed.description) data.description = parsed.description;
+ if (parsed.timeAnchor) data.time_anchor = parsed.timeAnchor;
+ if (parsed.cardSuffix) data.card_type = parsed.cardSuffix;
+
+ await api.createEntity(data);
+ input.value = '';
+ await loadEntities();
+ await loadTags();
+ showToast('captured');
+ }
+
+ // ── Entity List ──
+
function groupByDate(entities) {
const groups = [];
let current = null;
@@ -266,27 +407,43 @@
function renderEntityList() {
const list = $('#entity-list');
+ const filtered = filterBySearch(state.entities);
- if (state.entities.length === 0) {
- list.innerHTML = 'no entities yet
';
+ if (filtered.length === 0) {
+ list.innerHTML = `${state.searchQuery ? 'no matches' : 'no entities yet'}
`;
+ renderCardsHeader(state.view === 'cards');
return;
}
let html = '';
if (state.view === 'stream') {
- const groups = groupByDate(state.entities);
+ renderCardsHeader(false);
+ const groups = groupByDate(filtered);
let idx = 0;
for (const g of groups) {
html += ``;
for (const e of g.entities) {
- html += renderEntityItem(e, idx);
+ const realIdx = state.entities.indexOf(e);
+ html += renderEntityItem(e, realIdx);
idx++;
}
}
} else {
- state.entities.forEach((e, idx) => {
- html += renderEntityItem(e, idx);
- });
+ renderCardsHeader(true);
+ const pinned = filtered.filter(e => e.pinned);
+ const rest = filtered.filter(e => !e.pinned);
+ 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) {
@@ -301,17 +458,72 @@
});
});
+ 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
+ newest most used
+ `;
+ }
+
+ 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);
- const selected = idx === state.selectedIndex ? 'selected' : '';
- const tags = (e.tags || []).map(t => `${t} `).join('');
+ const selected = idx === state.selectedIndex ? ' selected' : '';
+ const isCard = e.card_type ? ' is-card' : '';
+ const tags = (e.tags || []).slice(0, 2).map(t => `${t} `).join('');
const time = e.time_anchor ? `@${e.time_anchor} ` : '';
const useBadge = e.use_count > 0 ? `${e.use_count}× ` : '';
+ const cardBadge = e.card_type ? `${e.card_type} ` : '';
let label;
if (e.title) {
@@ -321,116 +533,357 @@
label = `${escHtml(e.body)} `;
}
- return `
+ return `
${glyph}
${label}
${time}
- ${tags}
+ ${tags}${cardBadge}
${useBadge}
`;
}
+ function fmtDateLong(dateStr) {
+ const d = new Date(dateStr);
+ const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
+ return `${months[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()} · ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
+ }
+
function renderDetailPane() {
const pane = $('#detail-pane');
const e = state.entities[state.selectedIndex];
if (!e) {
- pane.innerHTML = '
select an entity
';
+ pane.innerHTML = renderPeekIdle();
pane.classList.remove('visible');
return;
}
pane.classList.add('visible');
+
+ if (state.view === 'stream' || !e.card_type) {
+ pane.innerHTML = renderStreamPeek(e);
+ } else if (state.peekMode === 'run') {
+ pane.innerHTML = renderRunMode(e);
+ } else if (state.peekMode === 'fill') {
+ pane.innerHTML = renderFillMode(e);
+ } else if (state.peekMode === 'edit') {
+ pane.innerHTML = renderEditMode(e);
+ } else {
+ pane.innerHTML = renderCardPeek(e);
+ }
+
+ bindPeekEvents(e);
+ }
+
+ function renderPeekIdle() {
+ const v = state.view;
+ return `
+
peek
+
Select ${v === 'cards' ? 'a card' : 'an entry'}.
+
${v === 'cards'
+ ? 'Full detail lives here. Run checklists, fill templates, edit in place.'
+ : 'Entry detail lives here. Promote any capture to a card when it earns a permanent home.'}
+
+
+
navigate
+
j k next / prev
+
1 2 stream / cards
+
+ ${v === 'stream' ? `
+
stream grammar
+
(bare text) = thought
+
- todo · @time event · !time reminder
+
#tag · |title · // desc · !pin
+
` : `
+
act
+
⏎ copy
+
r run checklist
+
f fill template
+
e edit
+
p pin
+
`}
+
+
`;
+ }
+
+ function renderStreamPeek(e) {
+ const kind = e.card_type || e.glyph;
const glyph = displayGlyph(e);
const gc = glyphClass(e);
+ const kindLbl = { note: 'thought', todo: 'todo', event: 'event', snippet: 'snippet', template: 'template', checklist: 'checklist', decision: 'decision', link: 'link' }[kind] || kind;
const tags = (e.tags || []).map(t => `
#${t} `).join('');
- const shortId = e.id.slice(0, 12);
- let cardContent = '';
let actions = '';
-
- if (e.card_type) {
- cardContent = renderCardContent(e);
- actions += `
copy `;
- actions += `
demote `;
- } else {
- actions += `
promote `;
- actions += `
absorb `;
+ if (!e.card_type) {
+ actions += `
promote → `;
}
actions += `
delete `;
- const descHtml = e.description ? `
${escHtml(e.description)}
` : '';
- const titleHtml = e.title ? `
${escHtml(e.title)} ` : '';
-
- pane.innerHTML = `
-