feat(ui): phase 1 — layout, tokens, header, rail redesign

- Switch mono font from Monaspace Neon to JetBrains Mono
- Grid layout 192px | 1fr | 400px (was 180/320)
- Move capture bar from header to bottom of center panel
- Add search input to header center
- Redesign tag rail: grid items with arrow/dot/name/count
- Add intent section (grab/read/fill) in cards view rail
- Add --a-str token, toast component
- Logo 16px 700 weight
This commit is contained in:
2026-05-16 09:25:35 -04:00
parent f4e178e3ee
commit dda8426113
4 changed files with 495 additions and 208 deletions
+159 -62
View File
@@ -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,7 @@
activeTag: null,
hasMore: false,
activeMonth: null,
intent: 'grab',
};
const $ = (sel) => document.querySelector(sel);
@@ -223,32 +226,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 = `<div class="tag-item ${!state.activeTag ? 'active' : ''}" data-tag="">
<span class="tag-name" style="font-style: italic">all</span>
</div>`;
const total = state.tags.reduce((s, t) => s + t.count, 0);
rail.innerHTML = allItem + state.tags.map(t =>
`<div class="tag-item ${state.activeTag === t.tag ? 'active' : ''}" data-tag="${t.tag}">
<span class="tag-name">${t.tag}</span>
<span class="tag-count">${t.count}</span>
</div>`
).join('');
let html = `<div class="rail-head"><span class="rail-brand">nib</span></div>`;
html += '<div class="rail-scroll">';
rail.querySelectorAll('.tag-item').forEach(el => {
if (state.view === 'cards') {
html += '<div class="rail-sec">';
html += '<div class="rail-lbl">intent</div>';
for (const k of ['grab', 'read', 'fill']) {
const on = state.intent === k ? ' on' : '';
const count = k === 'grab' ? state.entities.length : k === 'read' ? state.entities.filter(e => e.card_data).length : state.entities.filter(e => e.body && /\$\{.+\}/.test(e.body)).length;
html += `<button class="rail-item${on}" data-intent="${k}">`;
html += `<span class="rail-arrow">${state.intent === k ? '▸' : ''}</span>`;
html += '<span class="rail-dot"></span>';
html += `<span class="rail-name">${k}</span>`;
html += `<span class="rail-count">${count}</span>`;
if (state.intent === k) html += `<span class="rail-hint">${INTENT_HINTS[k]}</span>`;
html += '</button>';
}
html += '</div>';
}
html += '<div class="rail-sec">';
html += '<div class="rail-lbl">tags</div>';
const allOn = !state.activeTag ? ' on' : '';
html += `<button class="rail-item${allOn}" data-tag="">`;
html += `<span class="rail-arrow">${!state.activeTag ? '▸' : ''}</span>`;
html += '<span class="rail-dot"></span>';
html += `<span class="rail-name">#all</span>`;
html += `<span class="rail-count">${total}</span>`;
html += '</button>';
for (const t of state.tags) {
const on = state.activeTag === t.tag ? ' on' : '';
html += `<button class="rail-item${on}" data-tag="${t.tag}">`;
html += `<span class="rail-arrow">${state.activeTag === t.tag ? '▸' : ''}</span>`;
html += '<span class="rail-dot"></span>';
html += `<span class="rail-name">#${t.tag}</span>`;
html += `<span class="rail-count">${t.count}</span>`;
html += '</button>';
}
html += '</div></div>';
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 = `
<div class="cap-row">
<span class="cap-prompt"></span>
<textarea id="capture-input" rows="1" placeholder="${placeholder}" spellcheck="false"></textarea>
<span class="cap-hint">⏎ save</span>
</div>
`;
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;
@@ -308,10 +407,12 @@
function renderEntityItem(e, idx) {
const glyph = displayGlyph(e);
const gc = glyphClass(e);
const selected = idx === state.selectedIndex ? 'selected' : '';
const tags = (e.tags || []).map(t => `<span class="entity-tag">${t}</span>`).join('');
const selected = idx === state.selectedIndex ? ' selected' : '';
const isCard = e.card_type ? ' is-card' : '';
const tags = (e.tags || []).slice(0, 2).map(t => `<span class="entity-tag">${t}</span>`).join('');
const time = e.time_anchor ? `<span class="entity-time">@${e.time_anchor}</span>` : '';
const useBadge = e.use_count > 0 ? `<span class="use-badge">${e.use_count}×</span>` : '';
const cardBadge = e.card_type ? `<span class="card-badge">${e.card_type}</span>` : '';
let label;
if (e.title) {
@@ -321,11 +422,11 @@
label = `<span class="entity-body">${escHtml(e.body)}</span>`;
}
return `<div class="entity-item ${selected}" data-index="${idx}" data-id="${e.id}">
return `<div class="entity-item${selected}${isCard}" data-index="${idx}" data-id="${e.id}">
<span class="entity-glyph ${gc}">${glyph}</span>
${label}
${time}
<span class="entity-tags">${tags}</span>
<span class="entity-tags">${tags}${cardBadge}</span>
<span class="entity-meta">${useBadge}</span>
</div>`;
}
@@ -351,10 +452,10 @@
if (e.card_type) {
cardContent = renderCardContent(e);
actions += `<button class="action-btn" onclick="nibApp.copyEntity('${e.id}')">copy</button>`;
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 {
actions += `<button class="action-btn primary" onclick="nibApp.showPromote('${e.id}')">promote</button>`;
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>`;
@@ -363,17 +464,19 @@
const titleHtml = e.title ? `<h2 class="detail-title" data-id="${e.id}">${escHtml(e.title)}</h2>` : '';
pane.innerHTML = `
<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>` : ''}
<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>` : ''}
</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>
</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>
`;
const titleEl = pane.querySelector('.detail-title');
@@ -560,13 +663,10 @@
<button class="month-nav-btn" id="month-prev">◂</button>
<span class="month-nav-label">${label}</span>
<button class="month-nav-btn" id="month-next">▸</button>
${state.activeMonth ? '<button class="month-nav-clear">clear</button>' : ''}
`;
$('#month-prev').addEventListener('click', () => shiftMonth(-1));
$('#month-next').addEventListener('click', () => shiftMonth(1));
const clearBtn = nav.querySelector('.month-nav-clear');
if (clearBtn) clearBtn.addEventListener('click', () => { state.activeMonth = null; loadEntities(); renderMonthNav(); });
}
function shiftMonth(dir) {
@@ -590,10 +690,25 @@
function switchView(view) {
state.view = view;
state.activeMonth = null;
state.selectedIndex = -1;
$$('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === view));
window.location.hash = view === 'cards' ? '/cards' : '/';
loadEntities();
renderMonthNav();
renderTagRail();
renderCaptureBar();
}
// ========== Toast ==========
function showToast(msg) {
let el = $('.toast');
if (el) el.remove();
el = document.createElement('div');
el.className = 'toast';
el.textContent = msg;
document.body.appendChild(el);
setTimeout(() => el.remove(), 1600);
}
// ========== Public API (for inline handlers) ==========
@@ -606,6 +721,7 @@
await navigator.clipboard.writeText(e.body);
await api.useEntity(id);
await loadEntities();
showToast('copied');
} catch (err) {
console.error('clipboard:', err);
}
@@ -630,12 +746,14 @@
await api.demoteEntity(id);
await loadEntities();
await loadTags();
showToast('demoted');
},
async deleteEntity(id) {
await api.deleteEntity(id);
await loadEntities();
await loadTags();
showToast('deleted');
},
async resolveTemplate(id) {
@@ -651,6 +769,7 @@
await navigator.clipboard.writeText(resolved);
await api.useEntity(id);
await loadEntities();
showToast('copied');
} catch (err) {
console.error('clipboard:', err);
}
@@ -688,6 +807,7 @@
await loadTags();
const idx = state.entities.findIndex(x => x.id === targetId);
if (idx >= 0) selectEntity(idx);
showToast('absorbed');
});
});
@@ -706,33 +826,6 @@
},
};
// ========== Capture bar ==========
$('#capture-bar').addEventListener('submit', async (ev) => {
ev.preventDefault();
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();
});
// ========== Promote modal ==========
$$('.type-btn').forEach(btn => {
@@ -745,6 +838,7 @@
await api.promoteEntity(id, btn.dataset.type);
await loadEntities();
await loadTags();
showToast('promoted → ' + btn.dataset.type);
});
});
@@ -761,12 +855,11 @@
// ========== Keyboard shortcuts ==========
let lastDTime = 0;
const captureInput = $('#capture-input');
document.addEventListener('keydown', (ev) => {
if (document.activeElement === captureInput ||
document.activeElement.classList.contains('detail-body-edit')) {
if (ev.key === 'Escape') document.activeElement.blur();
const tag = (ev.target.tagName || '').toLowerCase();
if (tag === 'input' || tag === 'textarea') {
if (ev.key === 'Escape') ev.target.blur();
return;
}
@@ -789,7 +882,7 @@
break;
case 'n':
ev.preventDefault();
captureInput.focus();
$('#capture-input').focus();
break;
case 'p': {
const e = state.entities[state.selectedIndex];
@@ -848,6 +941,9 @@
}
$$('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === state.view));
loadEntities();
renderMonthNav();
renderTagRail();
renderCaptureBar();
}
window.addEventListener('hashchange', handleHash);
@@ -884,6 +980,7 @@
// ========== Init ==========
async function init() {
renderCaptureBar();
await Promise.all([loadEntities(), loadTags()]);
handleHash();
renderMonthNav();