feat: UI redesign, capture grammar, demo command #14

Merged
lerko merged 23 commits from develop into main 2026-05-16 20:07:28 +00:00
2 changed files with 686 additions and 153 deletions
Showing only changes of commit 1c95902e2b - Show all commits
+417 -96
View File
@@ -28,6 +28,10 @@
activeMonth: null,
intent: 'grab',
flashId: null,
peekMode: 'preview',
runChecked: new Set(),
fillValues: {},
fillActive: 0,
};
const $ = (sel) => document.querySelector(sel);
@@ -536,111 +540,350 @@
</div>`;
}
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 = '<div class="detail-empty">select an entity</div>';
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 `<div class="peek-idle">
<div class="peek-idle-eyebrow">peek</div>
<div class="peek-idle-title">Select ${v === 'cards' ? 'a card' : 'an entry'}.</div>
<div class="peek-idle-sub">${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.'}</div>
<div class="peek-shortcuts">
<div class="peek-sc-sec">
<div class="peek-sc-lbl">navigate</div>
<div class="peek-sc-row"><kbd>j</kbd><kbd>k</kbd><span>next / prev</span></div>
<div class="peek-sc-row"><kbd>1</kbd><kbd>2</kbd><span>stream / cards</span></div>
</div>
${v === 'stream' ? `<div class="peek-sc-sec">
<div class="peek-sc-lbl">stream grammar</div>
<div class="peek-sc-code">(bare text) = thought</div>
<div class="peek-sc-hint">- todo · @time event · !time reminder</div>
<div class="peek-sc-hint">#tag · |title · // desc · !pin</div>
</div>` : `<div class="peek-sc-sec">
<div class="peek-sc-lbl">act</div>
<div class="peek-sc-row"><kbd>⏎</kbd><span>copy</span></div>
<div class="peek-sc-row"><kbd>r</kbd><span>run checklist</span></div>
<div class="peek-sc-row"><kbd>f</kbd><span>fill template</span></div>
<div class="peek-sc-row"><kbd>e</kbd><span>edit</span></div>
<div class="peek-sc-row"><kbd>p</kbd><span>pin</span></div>
</div>`}
</div>
</div>`;
}
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 => `<span class="detail-tag">#${t}</span>`).join('');
const shortId = e.id.slice(0, 12);
let cardContent = '';
let actions = '';
if (e.card_type) {
cardContent = renderCardContent(e);
actions += `<button class="action-btn primary" onclick="nibApp.copyEntity('${e.id}')">copy</button>`;
actions += `<button class="action-btn" onclick="nibApp.demoteEntity('${e.id}')">demote</button>`;
} else {
if (!e.card_type) {
actions += `<button class="action-btn primary" onclick="nibApp.showPromote('${e.id}')">promote →</button>`;
actions += `<button class="action-btn" onclick="nibApp.showAbsorb('${e.id}')">absorb</button>`;
}
actions += `<button class="action-btn danger" onclick="nibApp.deleteEntity('${e.id}')">delete</button>`;
const descHtml = e.description ? `<div class="detail-desc" data-id="${e.id}">${escHtml(e.description)}</div>` : '';
const titleHtml = e.title ? `<h2 class="detail-title" data-id="${e.id}">${escHtml(e.title)}</h2>` : '';
pane.innerHTML = `
<div class="detail-scroll">
<div class="detail-header">
<span class="detail-glyph ${gc}">${glyph}</span>
<span class="detail-id">${shortId}</span>
${e.time_anchor ? `<span class="entity-time">@${e.time_anchor}</span>` : ''}
return `<div class="peek-scroll">
<div class="peek-brow">
<span class="peek-brow-g ${gc}">${glyph}</span>
<span class="peek-brow-kind">${kindLbl}</span>
<span class="peek-brow-sep">·</span>
<span class="peek-brow-id">${e.id.slice(-10)}</span>
<span class="peek-brow-ts">${fmtDateLong(e.created_at)}</span>
</div>
${descHtml}
${titleHtml}
<div class="detail-body" data-id="${e.id}">${escHtml(e.body)}</div>
${tags ? `<div class="detail-tags">${tags}</div>` : ''}
${cardContent}
<div class="detail-actions">${actions}</div>
${e.title ? `<div class="peek-title" data-id="${e.id}">${escHtml(e.title)}</div>` : ''}
<div class="peek-body" data-id="${e.id}">${escHtml(e.body)}</div>
${tags ? `<div class="peek-sec"><div class="peek-sec-lbl">tags</div><div class="peek-sec-inner tag-pills">${tags}</div></div>` : ''}
<div class="peek-sec">
<div class="peek-sec-lbl">context</div>
<div class="peek-sec-inner peek-ctx">
<span><span class="peek-ctx-lbl">created</span>${fmtDateLong(e.created_at)}</span>
${e.time_anchor ? `<span><span class="peek-ctx-lbl">time</span>@${e.time_anchor}</span>` : ''}
${e.card_type ? `<span><span class="peek-ctx-lbl">status</span><span class="peek-ctx-promoted">promoted → ${e.card_type}</span></span>` : ''}
</div>
`;
</div>
<div class="peek-acts">${actions}</div>
</div>`;
}
const titleEl = pane.querySelector('.detail-title');
function renderCardPeek(e) {
const glyph = GLYPHS[e.card_type] || '◆';
const gc = GLYPH_CLASSES[e.card_type] || 'glyph-snippet';
const affs = detectAffordances(e);
const data = e.card_data ? (() => { try { return JSON.parse(e.card_data); } catch { return {}; } })() : {};
const tags = (e.tags || []).map(t => `<span class="detail-tag">#${t}</span>`).join('');
const affHtml = affs.map(a => `<span class="aff ${AFF_CLASSES[a]}">${AFF_LABELS[a]}</span>`).join('');
const hasSteps = data.steps && data.steps.length;
const hasDecision = data.chose != null;
const hasFill = /\$\{[^}]+\}/.test(e.body || '');
const hasLink = !!data.url;
let sections = '';
if (hasDecision) {
const rejected = (data.rejected || []).map(r => `<span class="peek-dec-rej">${escHtml(r)}</span>`).join('');
sections += `<div class="peek-sec">
<div class="peek-sec-lbl">decision<span class="peek-sec-status">${data.status || 'decided'}</span></div>
<div class="peek-sec-inner peek-decision">
<div class="peek-dec-choice">${escHtml(data.chose)}</div>
<div class="peek-dec-why"><span class="peek-dec-key">why</span>${escHtml(data.why || '')}</div>
${rejected ? `<div><span class="peek-dec-key">considered</span><div class="peek-dec-rejected">${rejected}</div></div>` : ''}
</div>
</div>`;
}
if (hasLink && !hasDecision) {
sections += `<div class="peek-sec">
<div class="peek-sec-lbl">link</div>
<div class="peek-sec-inner"><div class="peek-link-url">↗ ${escHtml(data.url)}</div></div>
</div>`;
}
if (hasSteps) {
const steps = data.steps.map((s, i) => `<div class="peek-step"><span class="peek-step-mark" style="color:var(--dim)">○</span><span class="peek-step-text">${escHtml(s.text || s)}</span></div>`).join('');
sections += `<div class="peek-sec">
<div class="peek-sec-lbl">steps · ${data.steps.length}<button class="peek-sec-run" onclick="nibApp.enterMode('run')">▶ run</button></div>
<div class="peek-sec-inner"><div class="peek-steps">${steps}</div></div>
</div>`;
}
if (!hasDecision && e.body) {
const lang = data.lang || '';
sections += `<div class="peek-sec">
<div class="peek-sec-lbl">content${lang ? `<span class="peek-sec-lang">${lang}</span>` : ''}${hasFill ? `<button class="peek-sec-run" onclick="nibApp.enterMode('fill')">⤓ fill</button>` : ''}</div>
<div class="peek-sec-inner"><div class="peek-code"><pre><code>${escHtml(e.body)}</code></pre></div></div>
</div>`;
}
let actions = `<button class="action-btn primary" onclick="nibApp.copyEntity('${e.id}')">copy <kbd>⏎</kbd></button>`;
if (hasFill) actions += `<button class="action-btn" onclick="nibApp.enterMode('fill')">fill <kbd>f</kbd></button>`;
if (hasSteps) actions += `<button class="action-btn" onclick="nibApp.enterMode('run')">run <kbd>r</kbd></button>`;
actions += `<button class="action-btn" onclick="nibApp.enterMode('edit')">edit <kbd>e</kbd></button>`;
actions += `<button class="action-btn" onclick="nibApp.togglePin('${e.id}')">${e.pinned ? 'unpin' : 'pin'} <kbd>p</kbd></button>`;
actions += `<button class="action-btn danger" onclick="nibApp.deleteEntity('${e.id}')">delete</button>`;
return `<div class="peek-scroll">
<div class="peek-card">
<div class="peek-card-head">
<div class="peek-brow" style="padding:14px 16px 0">
<span class="peek-brow-g ${gc}">${glyph}</span>
<span class="peek-brow-kind">${e.card_type}</span>
<span class="peek-brow-sep">·</span>
<span class="peek-brow-id">${e.id.slice(-10)}</span>
${e.use_count > 0 ? `<span class="peek-brow-ts">${e.use_count}× used</span>` : ''}
</div>
<div class="peek-title" style="padding:9px 16px 4px">${escHtml(e.title || '')}</div>
${e.description ? `<div class="peek-desc" style="padding:0 16px 10px">${escHtml(e.description)}</div>` : ''}
<div class="peek-meta" style="padding:0 16px 12px">${affHtml}${tags}${e.pinned ? '<span class="peek-pin">★</span>' : ''}</div>
</div>
${sections}
</div>
<div class="peek-acts">${actions}</div>
</div>`;
}
function renderRunMode(e) {
const data = e.card_data ? (() => { try { return JSON.parse(e.card_data); } catch { return {}; } })() : {};
if (!data.steps) return renderCardPeek(e);
const total = data.steps.length;
const checked = state.runChecked || new Set();
const done = checked.size;
const pct = total > 0 ? Math.round(done / total * 100) : 0;
const steps = data.steps.map((s, i) => {
const isDone = checked.has(i);
const text = s.text || s;
return `<div class="peek-run-step${isDone ? ' done' : ''}" data-step="${i}">
<span class="peek-run-mark" style="color:${isDone ? 'var(--ok)' : 'var(--dim)'}">${isDone ? '●' : '○'}</span>
<span class="peek-run-text">${escHtml(text)}</span>
</div>`;
}).join('');
return `<div class="peek-scroll">
<div class="peek-brow">
<span class="peek-run-pill">▶ running</span>
<span class="peek-brow-ts">${done}/${total} done</span>
</div>
<div class="peek-title">${escHtml(e.title || '')}</div>
${e.description ? `<div class="peek-desc">${escHtml(e.description)}</div>` : ''}
<div class="peek-run-prog-wrap">
<div class="peek-run-prog-track"><div class="peek-run-prog" style="width:${pct}%"></div></div>
<span class="peek-run-pct">${pct}%</span>
</div>
<div class="peek-run-steps">${steps}</div>
<div class="peek-hints"><span><kbd>Space</kbd> toggle</span><span><kbd>r</kbd> reset</span><span><kbd>Esc</kbd> exit</span></div>
<div class="peek-acts">
<button class="action-btn primary" onclick="nibApp.exitMode()">done</button>
<button class="action-btn" onclick="nibApp.resetRun()">reset</button>
</div>
</div>`;
}
function renderFillMode(e) {
const slots = [];
const re = /\$\{([^}]+)\}/g;
let m;
const seen = new Set();
while ((m = re.exec(e.body || '')) !== null) {
const name = m[1].trim();
if (!seen.has(name)) { seen.add(name); slots.push(name); }
}
if (!slots.length) return renderCardPeek(e);
const fill = state.fillValues || {};
const active = state.fillActive || 0;
let content = escHtml(e.body);
for (const name of slots) {
const val = fill[name] || '';
const idx = slots.indexOf(name);
const cls = idx === active ? 'fill-slot active' : (val ? 'fill-slot filled' : 'fill-slot');
const width = Math.max(name.length, val.length, 4) * 8 + 16;
content = content.replace(`\${${name}}`, `<span class="${cls}"><input type="text" data-slot="${name}" data-idx="${idx}" placeholder="${escHtml(name)}" value="${escHtml(val)}" style="width:${width}px"></span>`);
}
const allFilled = slots.every(s => fill[s]);
return `<div class="peek-scroll">
<div class="peek-brow">
<span class="peek-fill-pill">⤓ filling</span>
<span class="peek-brow-ts">slot ${active + 1} / ${slots.length}</span>
</div>
<div class="peek-title">${escHtml(e.title || '')}</div>
${e.description ? `<div class="peek-desc">${escHtml(e.description)}</div>` : ''}
<div class="peek-fill-canvas"><code>${content}</code></div>
<div class="peek-hints"><span><kbd>Tab</kbd> next</span><span><kbd>⇧Tab</kbd> prev</span><span><kbd>⏎</kbd> copy</span><span><kbd>Esc</kbd> cancel</span></div>
<div class="peek-acts">
<button class="action-btn primary${allFilled ? '' : ' dim'}" onclick="nibApp.completeFill()">copy resolved</button>
<button class="action-btn" onclick="nibApp.exitMode()">cancel</button>
</div>
</div>`;
}
function renderEditMode(e) {
return `<div class="peek-scroll">
<div class="peek-brow"><span class="peek-edit-pill">✎ editing</span></div>
<div class="peek-title" style="opacity:.45">${escHtml(e.title || 'untitled')}</div>
<div class="peek-edit-fields">
<div class="peek-edit-field"><label class="peek-edit-lbl">title</label>
<input class="peek-edit-in" id="edit-title" value="${escAttr(e.title || '')}"></div>
<div class="peek-edit-field"><label class="peek-edit-lbl">description</label>
<input class="peek-edit-in" id="edit-desc" value="${escAttr(e.description || '')}"></div>
<div class="peek-edit-field"><label class="peek-edit-lbl">content</label>
<textarea class="peek-edit-ta" id="edit-body" rows="7">${escHtml(e.body || '')}</textarea></div>
<div class="peek-edit-field"><label class="peek-edit-lbl">tags</label>
<input class="peek-edit-in" id="edit-tags" value="${escAttr((e.tags || []).join(' '))}" placeholder="space-separated"></div>
</div>
<div class="peek-hints"><span><kbd>⌘⏎</kbd> save</span><span><kbd>Esc</kbd> cancel</span></div>
<div class="peek-acts">
<button class="action-btn primary" onclick="nibApp.saveEdit('${e.id}')">save</button>
<button class="action-btn" onclick="nibApp.exitMode()">cancel</button>
</div>
</div>`;
}
function bindPeekEvents(e) {
const pane = $('#detail-pane');
if (!e) return;
if (state.peekMode === 'run') {
pane.querySelectorAll('.peek-run-step').forEach(el => {
el.addEventListener('click', () => {
const idx = parseInt(el.dataset.step);
if (!state.runChecked) state.runChecked = new Set();
if (state.runChecked.has(idx)) state.runChecked.delete(idx);
else state.runChecked.add(idx);
renderDetailPane();
});
});
}
if (state.peekMode === 'fill') {
pane.querySelectorAll('.fill-slot input').forEach(input => {
input.addEventListener('input', () => {
if (!state.fillValues) state.fillValues = {};
state.fillValues[input.dataset.slot] = input.value;
});
input.addEventListener('focus', () => {
state.fillActive = parseInt(input.dataset.idx);
});
input.addEventListener('keydown', (ev) => {
if (ev.key === 'Tab') {
ev.preventDefault();
const slots = pane.querySelectorAll('.fill-slot input');
const cur = parseInt(input.dataset.idx);
const next = ev.shiftKey ? Math.max(0, cur - 1) : Math.min(slots.length - 1, cur + 1);
state.fillActive = next;
renderDetailPane();
setTimeout(() => {
const el = pane.querySelector(`.fill-slot input[data-idx="${next}"]`);
if (el) el.focus();
}, 0);
} else if (ev.key === 'Enter' && !ev.shiftKey) {
ev.preventDefault();
nibApp.completeFill();
} else if (ev.key === 'Escape') {
ev.preventDefault();
nibApp.exitMode();
}
});
});
setTimeout(() => {
const el = pane.querySelector(`.fill-slot input[data-idx="${state.fillActive || 0}"]`);
if (el) el.focus();
}, 0);
}
if (state.peekMode === 'edit') {
const bodyTa = pane.querySelector('#edit-body');
if (bodyTa) {
bodyTa.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter' && (ev.metaKey || ev.ctrlKey)) { ev.preventDefault(); nibApp.saveEdit(e.id); }
if (ev.key === 'Escape') { ev.preventDefault(); nibApp.exitMode(); }
});
}
}
// Double-click to edit (stream peek)
const titleEl = pane.querySelector('.peek-title[data-id]');
if (titleEl) titleEl.addEventListener('dblclick', () => startEditField('title'));
const descEl = pane.querySelector('.detail-desc');
if (descEl) descEl.addEventListener('dblclick', () => startEditField('description'));
const bodyEl = pane.querySelector('.detail-body');
const bodyEl = pane.querySelector('.peek-body[data-id]');
if (bodyEl) bodyEl.addEventListener('dblclick', startEditBody);
}
function renderCardContent(e) {
if (!e.card_data) return '';
let data;
try { data = JSON.parse(e.card_data); } catch { return ''; }
switch (e.card_type) {
case 'template':
if (!data.slots || !data.slots.length) return '';
return `<div class="slot-form">
${data.slots.map(s => `
<div class="slot-field">
<span class="slot-label">\${${s.name}}</span>
<input class="slot-input" data-slot="${s.name}" placeholder="${s.default || s.name}" value="${s.default || ''}">
</div>
`).join('')}
<button class="action-btn primary" onclick="nibApp.resolveTemplate('${e.id}')">resolve & copy</button>
</div>`;
case 'checklist':
if (!data.steps || !data.steps.length) return '';
return `<div class="checklist">
${data.steps.map((s, i) => `
<div class="checklist-step ${s.done ? 'done' : ''}">
<input type="checkbox" ${s.done ? 'checked' : ''} onchange="nibApp.toggleStep('${e.id}', ${i})">
<span>${escHtml(s.text)}</span>
</div>
`).join('')}
</div>`;
case 'decision':
return `<div>
<div class="decision-field"><div class="decision-label">chose</div><div class="decision-value">${escHtml(data.chose || '—')}</div></div>
<div class="decision-field"><div class="decision-label">why</div><div class="decision-value">${escHtml(data.why || '—')}</div></div>
${data.rejected && data.rejected.length ? `<div class="decision-field"><div class="decision-label">rejected</div><div class="decision-value">${data.rejected.map(escHtml).join(', ') || '—'}</div></div>` : ''}
</div>`;
case 'link':
if (data.url && isSafeUrl(data.url)) {
return `<div style="margin-bottom:12px">
<button class="action-btn" onclick="window.open('${escAttr(data.url)}', '_blank')">open link</button>
</div>`;
}
return '';
default:
return '';
}
}
// ========== Inline edit ==========
function startEditBody() {
@@ -713,6 +956,10 @@
function selectEntity(idx) {
state.selectedIndex = idx;
state.peekMode = 'preview';
state.runChecked = new Set();
state.fillValues = {};
state.fillActive = 0;
renderEntityList();
renderDetailPane();
}
@@ -932,6 +1179,67 @@
await loadEntities();
selectEntity(state.entities.findIndex(x => x.id === id));
},
enterMode(mode) {
state.peekMode = mode;
if (mode === 'run') state.runChecked = new Set();
if (mode === 'fill') { state.fillValues = {}; state.fillActive = 0; }
renderDetailPane();
},
exitMode() {
state.peekMode = 'preview';
renderDetailPane();
},
resetRun() {
state.runChecked = new Set();
renderDetailPane();
},
async completeFill() {
const e = state.entities[state.selectedIndex];
if (!e) return;
let resolved = e.body || '';
const fill = state.fillValues || {};
for (const [name, val] of Object.entries(fill)) {
resolved = resolved.replace(new RegExp('\\$\\{' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\}', 'g'), val);
}
try {
await navigator.clipboard.writeText(resolved);
await api.useEntity(e.id);
state.peekMode = 'preview';
await loadEntities();
showToast('copied resolved');
} catch (err) {
console.error('clipboard:', err);
}
},
async saveEdit(id) {
const title = ($('#edit-title') || {}).value || null;
const desc = ($('#edit-desc') || {}).value || null;
const body = ($('#edit-body') || {}).value || '';
const tagsStr = ($('#edit-tags') || {}).value || '';
const tags = tagsStr.split(/\s+/).filter(Boolean);
await api.updateEntity(id, { body, title, description: desc, tags });
state.peekMode = 'preview';
await loadEntities();
await loadTags();
const idx = state.entities.findIndex(x => x.id === id);
if (idx >= 0) selectEntity(idx);
showToast('saved');
},
async togglePin(id) {
const e = state.entities.find(x => x.id === id);
if (!e) return;
await api.updateEntity(id, { pinned: !e.pinned });
await loadEntities();
const idx = state.entities.findIndex(x => x.id === id);
if (idx >= 0) { state.selectedIndex = idx; renderEntityList(); renderDetailPane(); }
showToast(e.pinned ? 'unpinned' : 'pinned');
},
};
// ========== Promote modal ==========
@@ -977,6 +1285,13 @@
return;
}
if (state.peekMode !== 'preview' && ev.key === 'Escape') {
nibApp.exitMode();
return;
}
const sel = state.entities[state.selectedIndex];
switch (ev.key) {
case 'j':
ev.preventDefault();
@@ -992,36 +1307,42 @@
ev.preventDefault();
$('#capture-input').focus();
break;
case 'p': {
const e = state.entities[state.selectedIndex];
if (e && !e.card_type) nibApp.showPromote(e.id);
break;
case 'p':
if (sel && sel.card_type && state.view === 'cards') {
nibApp.togglePin(sel.id);
} else if (sel && !sel.card_type) {
nibApp.showPromote(sel.id);
}
case 'Enter': {
const e = state.entities[state.selectedIndex];
if (e) nibApp.copyEntity(e.id);
break;
case 'Enter':
if (sel) nibApp.copyEntity(sel.id);
break;
case 'r':
if (sel && sel.card_type && state.view === 'cards') nibApp.enterMode('run');
break;
case 'f':
if (sel && sel.card_type && state.view === 'cards') nibApp.enterMode('fill');
break;
case 'e':
if (sel && sel.card_type && state.view === 'cards') {
nibApp.enterMode('edit');
} else {
startEditBody();
}
break;
case 'd': {
const now = Date.now();
if (now - lastDTime < 400) {
const e = state.entities[state.selectedIndex];
if (e) nibApp.deleteEntity(e.id);
if (sel) nibApp.deleteEntity(sel.id);
lastDTime = 0;
} else {
lastDTime = now;
}
break;
}
case 'e': {
startEditBody();
case 'a':
if (sel && !sel.card_type) nibApp.showAbsorb(sel.id);
break;
}
case 'a': {
const e = state.entities[state.selectedIndex];
if (e && !e.card_type) nibApp.showAbsorb(e.id);
break;
}
case '1': switchView('stream'); break;
case '2': switchView('cards'); break;
}
+269 -57
View File
@@ -660,7 +660,7 @@ main {
letter-spacing: .04em;
}
/* ── DETAIL PANE ────────────────────────────────────── */
/* ── PEEK PANE ──────────────────────────────────────── */
#detail-pane {
background: var(--surf);
border-left: 1px solid var(--border);
@@ -669,10 +669,11 @@ main {
overflow: hidden;
}
.detail-scroll {
.peek-scroll {
flex: 1;
overflow-y: auto;
padding: 20px;
display: flex;
flex-direction: column;
}
.detail-empty {
@@ -683,49 +684,283 @@ main {
font-family: var(--mono);
}
.detail-header {
/* peek idle */
.peek-idle {
padding: 18px;
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
flex-direction: column;
gap: 14px;
flex: 1;
overflow-y: auto;
}
.detail-glyph { font-size: 16px; }
.detail-id {
.peek-idle-eyebrow {
font-family: var(--mono);
font-size: 10px;
color: var(--dim);
font-size: 9px;
text-transform: uppercase;
letter-spacing: .16em;
color: var(--accent);
margin-bottom: 6px;
}
.detail-desc {
font-family: var(--sans);
font-size: 11px;
color: var(--muted);
margin-bottom: 4px;
cursor: text;
padding: 2px 6px;
margin-left: -6px;
border-radius: var(--r2);
transition: background var(--t-fast);
}
.detail-desc:hover { background: var(--raised); }
.detail-title {
.peek-idle-title {
font-family: var(--sans);
font-size: 15px;
font-weight: 600;
margin-bottom: 12px;
color: var(--text);
margin-bottom: 4px;
}
.peek-idle-sub {
font-family: var(--sans);
font-size: 12px;
color: var(--muted);
line-height: 1.55;
}
.peek-shortcuts { display: flex; flex-direction: column; gap: 10px; }
.peek-sc-sec { margin-bottom: 2px; }
.peek-sc-lbl { font-family: var(--mono); font-size: 9px; text-transform: uppercase; letter-spacing: .14em; color: var(--dim); margin-bottom: 5px; }
.peek-sc-row { display: flex; align-items: center; gap: 5px; padding: 2px 0; font-family: var(--mono); font-size: 11px; color: var(--muted); }
.peek-sc-row span { color: var(--dim); margin-left: 2px; }
.peek-sc-code { font-family: var(--mono); font-size: 10px; color: var(--accent); background: var(--bg); border: 1px solid var(--border); border-radius: var(--r2); padding: 4px 8px; margin-bottom: 5px; }
.peek-sc-hint { font-family: var(--mono); font-size: 9px; color: var(--dim); padding-bottom: 3px; }
kbd { background: var(--raised); border: 1px solid var(--border); border-radius: 2px; padding: 1px 4px; font-size: 9px; font-family: var(--mono); color: var(--muted); display: inline-block; line-height: 1.4; }
/* peek eyebrow */
.peek-brow {
padding: 14px 20px 0;
display: flex;
align-items: center;
gap: 7px;
flex-shrink: 0;
font-family: var(--mono);
font-size: 9px;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--dim);
}
.peek-brow-g { font-size: 13px; margin-right: 1px; flex-shrink: 0; }
.peek-brow-kind { color: var(--muted); }
.peek-brow-sep { color: var(--dim); opacity: .4; }
.peek-brow-id { color: var(--dim); }
.peek-brow-ts { margin-left: auto; color: var(--dim); letter-spacing: 0; text-transform: none; white-space: nowrap; }
/* peek title / desc / body */
.peek-title {
padding: 9px 20px 4px;
font-family: var(--sans);
font-size: 15px;
font-weight: 600;
color: var(--text);
line-height: 1.3;
flex-shrink: 0;
}
.peek-desc {
padding: 0 20px 10px;
font-family: var(--sans);
font-size: 12px;
color: var(--muted);
line-height: 1.55;
flex-shrink: 0;
}
.peek-body {
padding: 10px 20px 14px;
font-family: var(--mono);
font-size: 13px;
line-height: 1.72;
color: var(--text);
white-space: pre-wrap;
word-break: break-word;
flex-shrink: 0;
cursor: text;
padding: 2px 6px;
margin-left: -6px;
border-radius: var(--r2);
transition: background var(--t-fast);
}
.detail-title:hover { background: var(--raised); }
.peek-body:hover { background: var(--raised); }
.peek-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 5px;
padding: 0 20px 12px;
flex-shrink: 0;
}
.peek-pin { color: var(--accent); font-size: 11px; }
/* peek sections */
.peek-sec { border-top: 1px solid var(--soft); flex-shrink: 0; }
.peek-sec-lbl {
padding: 8px 20px 5px;
font-family: var(--mono);
font-size: 9px;
text-transform: uppercase;
letter-spacing: .16em;
color: var(--dim);
display: flex;
align-items: center;
gap: 6px;
}
.peek-sec-lang { color: var(--accent); letter-spacing: 0; text-transform: none; font-size: 9px; }
.peek-sec-status {
color: var(--ok);
letter-spacing: 0;
text-transform: none;
font-size: 9px;
border: 1px solid rgba(122,171,114,.4);
background: rgba(122,171,114,.06);
padding: 0 6px;
border-radius: var(--r1);
}
.peek-sec-run {
margin-left: auto;
font-family: var(--mono);
font-size: 9px;
color: var(--ok);
border: 1px solid rgba(122,171,114,.4);
padding: 1px 8px;
border-radius: var(--r1);
transition: background var(--t-fast);
}
.peek-sec-run:hover { background: rgba(122,171,114,.1); }
.peek-sec-inner { padding: 0 20px 14px; }
.tag-pills { display: flex; flex-wrap: wrap; gap: 5px; }
/* peek context */
.peek-ctx { display: flex; flex-direction: column; gap: 5px; font-family: var(--mono); font-size: 11px; color: var(--muted); }
.peek-ctx-lbl { font-size: 9px; text-transform: uppercase; letter-spacing: .1em; color: var(--dim); margin-right: 5px; }
.peek-ctx-promoted { color: var(--ok); }
/* peek card container */
.peek-card {
margin: 12px;
border: 1px solid var(--border);
border-radius: var(--r3);
overflow: hidden;
flex-shrink: 0;
}
.peek-card-head {
background: var(--bg);
border-bottom: 1px solid var(--soft);
padding-bottom: 0;
}
.peek-card .peek-sec { border-top-color: var(--border); }
.peek-card .peek-sec-inner { padding: 0 16px 14px; }
.peek-card .peek-sec-lbl { padding: 8px 16px 5px; }
/* peek code block */
.peek-code {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--r2);
padding: 10px 12px;
overflow-x: auto;
}
.peek-code pre {
font-family: var(--mono);
font-size: 11px;
line-height: 1.65;
color: var(--text);
white-space: pre-wrap;
word-break: break-word;
}
/* peek steps */
.peek-steps { display: flex; flex-direction: column; gap: 3px; }
.peek-step { display: flex; align-items: flex-start; gap: 8px; padding: 3px 0; font-family: var(--mono); font-size: 11px; line-height: 1.45; }
.peek-step-mark { flex-shrink: 0; margin-top: 1px; }
.peek-step-text { color: var(--text); }
/* peek decision */
.peek-decision { padding: 0; }
.peek-dec-choice { font-family: var(--sans); font-size: 15px; font-weight: 600; color: var(--text); margin-bottom: 6px; }
.peek-dec-choice::before { content: '▸ '; color: var(--accent); }
.peek-dec-why { font-family: var(--sans); font-size: 12px; color: var(--muted); line-height: 1.55; margin-bottom: 8px; }
.peek-dec-key { color: var(--dim); font-size: 9px; text-transform: uppercase; letter-spacing: .1em; font-family: var(--mono); margin-right: 5px; }
.peek-dec-rejected { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; }
.peek-dec-rej { font-family: var(--mono); font-size: 10px; color: var(--muted); border: 1px solid var(--border); padding: 1px 6px; border-radius: var(--r1); text-decoration: line-through; opacity: .6; }
/* peek link */
.peek-link-url {
display: flex;
align-items: flex-start;
gap: 6px;
font-family: var(--mono);
font-size: 11px;
color: var(--event);
border: 1px solid var(--soft);
padding: 8px 10px;
border-radius: var(--r2);
background: var(--bg);
word-break: break-all;
line-height: 1.5;
}
/* peek actions */
.peek-acts {
display: flex;
gap: 5px;
flex-wrap: wrap;
padding: 12px 20px 18px;
border-top: 1px solid var(--soft);
flex-shrink: 0;
}
/* peek mode pills */
.peek-run-pill { font-family: var(--mono); font-size: 9px; color: var(--ok); border: 1px solid rgba(122,171,114,.4); background: rgba(122,171,114,.06); padding: 1px 7px; border-radius: var(--r1); }
.peek-fill-pill { font-family: var(--mono); font-size: 9px; color: var(--lineage); border: 1px solid rgba(152,120,188,.4); background: rgba(152,120,188,.06); padding: 1px 7px; border-radius: var(--r1); }
.peek-edit-pill { font-family: var(--mono); font-size: 9px; color: var(--todo); border: 1px solid rgba(212,168,75,.4); background: rgba(212,168,75,.06); padding: 1px 7px; border-radius: var(--r1); }
/* run mode */
.peek-run-prog-wrap { display: flex; align-items: center; gap: 10px; padding: 0 20px 14px; flex-shrink: 0; }
.peek-run-prog-track { flex: 1; height: 3px; background: var(--border); border-radius: 2px; overflow: hidden; }
.peek-run-prog { height: 100%; background: var(--ok); border-radius: 2px; transition: width var(--t-base); }
.peek-run-pct { font-family: var(--mono); font-size: 10px; color: var(--ok); min-width: 28px; }
.peek-run-steps { flex-shrink: 0; }
.peek-run-step { display: flex; align-items: flex-start; gap: 10px; padding: 7px 20px; cursor: pointer; border-left: 2px solid transparent; transition: background var(--t-fast), border-left-color var(--t-fast); }
.peek-run-step:hover { background: var(--raised); }
.peek-run-step.done .peek-run-text { text-decoration: line-through; color: var(--dim); }
.peek-run-mark { font-family: var(--mono); font-size: 12px; flex-shrink: 0; margin-top: 1px; }
.peek-run-text { font-family: var(--sans); font-size: 12px; line-height: 1.5; color: var(--text); }
/* fill mode */
.peek-fill-canvas { padding: 14px 20px; flex-shrink: 0; }
.peek-fill-canvas code { font-family: var(--mono); font-size: 12px; line-height: 2; color: var(--text); white-space: pre-wrap; word-break: break-word; }
.fill-slot { display: inline-block; border-bottom: 1.5px solid var(--lineage); }
.fill-slot.active { border-color: var(--accent); border-bottom-width: 2px; }
.fill-slot.filled { border-color: var(--ok); }
.fill-slot input { background: transparent; border: none; outline: none; color: var(--lineage); font-family: var(--mono); font-size: 12px; padding: 0 2px; min-width: 30px; line-height: 2; }
.fill-slot.active input { color: var(--text); }
.fill-slot.filled input { color: var(--ok); }
/* edit mode */
.peek-edit-fields { padding: 12px 20px; display: flex; flex-direction: column; gap: 12px; flex-shrink: 0; }
.peek-edit-field { display: flex; flex-direction: column; gap: 4px; }
.peek-edit-lbl { font-family: var(--mono); font-size: 9px; text-transform: uppercase; letter-spacing: .14em; color: var(--dim); }
.peek-edit-in { background: var(--bg); border: 1px solid var(--border); border-radius: var(--r2); padding: 6px 9px; font-family: var(--mono); font-size: 12px; color: var(--text); outline: none; transition: border-color var(--t-fast); }
.peek-edit-in:focus { border-color: var(--accent); }
.peek-edit-ta { background: var(--bg); border: 1px solid var(--border); border-radius: var(--r2); padding: 6px 9px; font-family: var(--mono); font-size: 12px; color: var(--text); outline: none; resize: vertical; min-height: 100px; line-height: 1.55; transition: border-color var(--t-fast); }
.peek-edit-ta:focus { border-color: var(--accent); }
/* hints bar */
.peek-hints { display: flex; gap: 12px; padding: 10px 20px; font-family: var(--mono); font-size: 9px; color: var(--dim); border-top: 1px solid var(--soft); flex-shrink: 0; }
.peek-hints span { display: flex; align-items: center; gap: 3px; }
/* legacy detail support (inline edit) */
.detail-field-edit {
display: block;
width: 100%;
@@ -740,22 +975,6 @@ main {
outline: none;
}
.detail-body {
font-family: var(--mono);
font-size: 13px;
line-height: 1.72;
margin-bottom: 16px;
white-space: pre-wrap;
word-break: break-word;
cursor: text;
border-radius: var(--r2);
padding: 4px 6px;
margin-left: -6px;
transition: background var(--t-fast);
}
.detail-body:hover { background: var(--raised); }
.detail-body-edit {
display: block;
width: 100%;
@@ -777,9 +996,8 @@ main {
.detail-tags {
display: flex;
gap: 6px;
gap: 5px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.detail-tag {
@@ -792,14 +1010,6 @@ main {
border-radius: var(--r1);
}
.detail-actions {
display: flex;
gap: 5px;
flex-wrap: wrap;
border-top: 1px solid var(--soft);
padding-top: 12px;
}
.action-btn {
font-family: var(--sans);
font-size: 11px;
@@ -816,8 +1026,10 @@ main {
.action-btn:hover { color: var(--accent); border-color: var(--accent); }
.action-btn.primary { color: var(--accent); border-color: var(--accent); background: var(--a-bg); }
.action-btn.dim { opacity: .45; }
.action-btn.danger { color: var(--danger); border-color: rgba(184,88,88,.4); }
.action-btn.danger:hover { border-color: var(--danger); }
.action-btn kbd { font-size: 9px; background: rgba(0,0,0,.2); border: 1px solid rgba(0,0,0,.3); border-radius: 2px; padding: 0 3px; opacity: .65; }
/* ── TEMPLATE SLOTS ─────────────────────────────────── */
.slot-form { margin: 16px 0; }