feat(web): add vanilla JS/CSS SPA with embed.FS

Stream view with date grouping, card view sorted by usage, capture
bar with client-side grammar parsing, tag rail filter, detail pane
with card affordances (template slot fill, checklist toggle, link
open), promote modal with auto-detect, keyboard shortcuts (j/k/n/p/
Enter/dd/1/2). Dark theme, responsive layout. Embedded in Go binary.
This commit is contained in:
2026-05-14 11:38:45 -04:00
parent 6de174e474
commit 5b0d0a8f33
7 changed files with 1170 additions and 2 deletions
+7 -1
View File
@@ -3,6 +3,7 @@ package cmd
import (
"context"
"fmt"
"io/fs"
"net/http"
"os"
"os/signal"
@@ -14,6 +15,8 @@ import (
"github.com/spf13/cobra"
)
var WebFS fs.FS
var (
servePort int
serveDev bool
@@ -51,7 +54,10 @@ func runServe(_ *cobra.Command, _ []string) error {
}
defer store.Close()
router := api.NewRouter(store, serveDev)
var router = api.NewRouter(store, serveDev)
if WebFS != nil {
router = api.NewRouter(store, serveDev, WebFS)
}
addr := fmt.Sprintf(":%d", port)
srv := &http.Server{
+21 -1
View File
@@ -1,6 +1,7 @@
package api
import (
"io/fs"
"net/http"
"github.com/go-chi/chi/v5"
@@ -8,7 +9,7 @@ import (
"github.com/lerko/nib/internal/db"
)
func NewRouter(store *db.Store, devMode bool) chi.Router {
func NewRouter(store *db.Store, devMode bool, webFS ...fs.FS) chi.Router {
r := chi.NewRouter()
r.Use(middleware.Logger)
@@ -32,9 +33,28 @@ func NewRouter(store *db.Store, devMode bool) chi.Router {
r.Get("/tags", listTags(store))
})
if len(webFS) > 0 && webFS[0] != nil {
r.Get("/*", spaHandler(webFS[0]))
}
return r
}
func spaHandler(fsys fs.FS) http.HandlerFunc {
fileServer := http.FileServer(http.FS(fsys))
indexHTML, _ := fs.ReadFile(fsys, "index.html")
return func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
if path == "/" || path == "/cards" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(indexHTML)
return
}
fileServer.ServeHTTP(w, r)
}
}
func jsonContentType(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
+6
View File
@@ -2,12 +2,18 @@ package main
import (
"fmt"
"io/fs"
"os"
"github.com/lerko/nib/cmd"
)
func main() {
webContent, err := fs.Sub(WebFS, "web")
if err == nil {
cmd.WebFS = webContent
}
if err := cmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
+6
View File
@@ -0,0 +1,6 @@
package main
import "embed"
//go:embed web/*
var WebFS embed.FS
+597
View File
@@ -0,0 +1,597 @@
(function () {
'use strict';
const GLYPHS = {
note: '◦', todo: '▸', event: '◇',
snippet: '◆', template: '◈', checklist: '☐',
decision: '⚖', link: '🔗',
};
const GLYPH_CLASSES = {
note: 'glyph-note', todo: 'glyph-todo', event: 'glyph-event',
snippet: 'glyph-snippet', template: 'glyph-template',
checklist: 'glyph-checklist', decision: 'glyph-decision',
link: 'glyph-link',
};
const state = {
view: 'stream',
entities: [],
tags: [],
selectedIndex: -1,
activeTag: null,
};
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => document.querySelectorAll(sel);
// ========== API ==========
const api = {
async listEntities(params = {}) {
const q = new URLSearchParams();
if (params.tag) q.set('tag', params.tag);
if (params.date) q.set('date', params.date);
if (params.cards_only) q.set('cards_only', 'true');
if (params.sort) q.set('sort', params.sort);
if (params.order) q.set('order', params.order);
const resp = await fetch('/api/entities?' + q);
return resp.json();
},
async createEntity(data) {
const resp = await fetch('/api/entities', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return resp.json();
},
async getEntity(id) {
const resp = await fetch('/api/entities/' + id);
return resp.json();
},
async updateEntity(id, data) {
const resp = await fetch('/api/entities/' + id, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return resp.json();
},
async deleteEntity(id) {
return fetch('/api/entities/' + id, { method: 'DELETE' });
},
async promoteEntity(id, cardType, cardData) {
const resp = await fetch('/api/entities/' + id + '/promote', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ card_type: cardType, card_data: cardData }),
});
return resp.json();
},
async demoteEntity(id) {
const resp = await fetch('/api/entities/' + id + '/demote', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
return resp.json();
},
async useEntity(id) {
const resp = await fetch('/api/entities/' + id + '/use', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
return resp.json();
},
async listTags() {
const resp = await fetch('/api/tags');
return resp.json();
},
};
// ========== Grammar parser (mirrors Go parser) ==========
const VALID_CARDS = { card: 'snippet', c: 'snippet', snippet: 'snippet', template: 'template', checklist: 'checklist', decision: 'decision', link: 'link' };
function parseInput(input) {
input = input.trim();
if (!input) return null;
const tokens = input.split(/\s+/);
let glyph = 'note';
const first = tokens[0];
if (first === '-' || first === '▸') { glyph = 'todo'; tokens.shift(); }
else if (first === '*' || first === '◇') { glyph = 'event'; tokens.shift(); }
const bodyParts = [];
let timeAnchor = null;
const tags = [];
const seenTags = {};
let cardSuffix = null;
for (const tok of tokens) {
if (tok.startsWith('@') && tok.length > 1) {
timeAnchor = tok.slice(1);
} else if (tok.startsWith('#') && tok.length > 1) {
const tag = tok.slice(1);
if (!seenTags[tag]) { tags.push(tag); seenTags[tag] = true; }
} else if (tok.startsWith('^') && tok.length > 1) {
const suffix = tok.slice(1);
if (VALID_CARDS[suffix]) cardSuffix = VALID_CARDS[suffix];
} else {
bodyParts.push(tok);
}
}
const body = bodyParts.join(' ');
if (!body) return null;
return { body, glyph, timeAnchor, tags, cardSuffix };
}
function detectCardType(body) {
if (/\$\{.+\}/.test(body)) return 'template';
if (/\bchose:|why:/.test(body)) return 'decision';
if (/\[ \]|\d+\./.test(body)) return 'checklist';
if (/https?:\/\//.test(body)) return 'link';
return null;
}
// ========== Rendering ==========
function displayGlyph(entity) {
return entity.card_type ? GLYPHS[entity.card_type] : GLYPHS[entity.glyph];
}
function glyphClass(entity) {
return entity.card_type ? GLYPH_CLASSES[entity.card_type] : GLYPH_CLASSES[entity.glyph];
}
function formatDate(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();
}
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>`;
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('');
rail.querySelectorAll('.tag-item').forEach(el => {
el.addEventListener('click', () => {
state.activeTag = el.dataset.tag || null;
loadEntities();
renderTagRail();
});
});
}
function groupByDate(entities) {
const groups = [];
let current = null;
for (const e of entities) {
const label = formatDate(e.created_at);
if (!current || current.label !== label) {
if (current) groups.push(current);
current = { label, entities: [] };
}
current.entities.push(e);
}
if (current) groups.push(current);
return groups;
}
function renderEntityList() {
const list = $('#entity-list');
if (state.entities.length === 0) {
list.innerHTML = '<div class="detail-empty" style="margin-top:40px">no entities yet</div>';
return;
}
let html = '';
if (state.view === 'stream') {
const groups = groupByDate(state.entities);
let idx = 0;
for (const g of groups) {
html += `<div class="date-header">── ${g.label} ──</div>`;
for (const e of g.entities) {
html += renderEntityItem(e, idx);
idx++;
}
}
} else {
state.entities.forEach((e, idx) => {
html += renderEntityItem(e, idx);
});
}
list.innerHTML = html;
list.querySelectorAll('.entity-item').forEach(el => {
el.addEventListener('click', () => {
selectEntity(parseInt(el.dataset.index));
});
});
}
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 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>` : '';
return `<div class="entity-item ${selected}" data-index="${idx}" data-id="${e.id}">
<span class="entity-glyph ${gc}">${glyph}</span>
<span class="entity-body">${escHtml(e.body)}</span>
${time}
<span class="entity-tags">${tags}</span>
<span class="entity-meta">${useBadge}</span>
</div>`;
}
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.classList.remove('visible');
return;
}
pane.classList.add('visible');
const glyph = displayGlyph(e);
const gc = glyphClass(e);
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" 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 danger" onclick="nibApp.deleteEntity('${e.id}')">delete</button>`;
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>
<div class="detail-body">${escHtml(e.body)}</div>
${tags ? `<div class="detail-tags">${tags}</div>` : ''}
${cardContent}
<div class="detail-actions">${actions}</div>
`;
}
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) {
return `<div style="margin-bottom:12px">
<button class="action-btn" onclick="window.open('${escAttr(data.url)}', '_blank')">open link</button>
</div>`;
}
return '';
default:
return '';
}
}
// ========== Actions ==========
function selectEntity(idx) {
state.selectedIndex = idx;
renderEntityList();
renderDetailPane();
}
async function loadEntities() {
const params = {};
if (state.activeTag) params.tag = state.activeTag;
if (state.view === 'cards') {
params.cards_only = true;
params.sort = 'use_count';
params.order = 'desc';
} else {
params.sort = 'created';
params.order = 'desc';
}
state.entities = await api.listEntities(params);
state.selectedIndex = -1;
renderEntityList();
renderDetailPane();
}
async function loadTags() {
state.tags = await api.listTags();
renderTagRail();
}
function switchView(view) {
state.view = view;
$$('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === view));
window.location.hash = view === 'cards' ? '/cards' : '/';
loadEntities();
}
// ========== Public API (for inline handlers) ==========
window.nibApp = {
async copyEntity(id) {
const e = state.entities.find(x => x.id === id);
if (!e) return;
try {
await navigator.clipboard.writeText(e.body);
await api.useEntity(id);
await loadEntities();
} catch (err) {
console.error('clipboard:', err);
}
},
showPromote(id) {
const e = state.entities.find(x => x.id === id);
if (!e || e.card_type) return;
const modal = $('#promote-modal');
modal.classList.remove('hidden');
modal.classList.add('visible');
modal.dataset.entityId = id;
const suggested = detectCardType(e.body);
$$('.type-btn').forEach(btn => {
btn.classList.toggle('suggested', btn.dataset.type === suggested);
});
},
async demoteEntity(id) {
await api.demoteEntity(id);
await loadEntities();
await loadTags();
},
async deleteEntity(id) {
await api.deleteEntity(id);
await loadEntities();
await loadTags();
},
async resolveTemplate(id) {
const e = state.entities.find(x => x.id === id);
if (!e) return;
let resolved = e.body;
$$('.slot-input').forEach(input => {
const name = input.dataset.slot;
const val = input.value || input.placeholder;
resolved = resolved.replace(new RegExp('\\$\\{' + name + '\\}', 'g'), val);
});
try {
await navigator.clipboard.writeText(resolved);
await api.useEntity(id);
await loadEntities();
} catch (err) {
console.error('clipboard:', err);
}
},
async toggleStep(id, stepIdx) {
const e = state.entities.find(x => x.id === id);
if (!e || !e.card_data) return;
const data = JSON.parse(e.card_data);
data.steps[stepIdx].done = !data.steps[stepIdx].done;
await api.updateEntity(id, { card_data: JSON.stringify(data) });
await loadEntities();
selectEntity(state.entities.findIndex(x => x.id === id));
},
};
// ========== 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.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 => {
btn.addEventListener('click', async () => {
const modal = $('#promote-modal');
const id = modal.dataset.entityId;
modal.classList.add('hidden');
modal.classList.remove('visible');
await api.promoteEntity(id, btn.dataset.type);
await loadEntities();
await loadTags();
});
});
$('.modal-backdrop').addEventListener('click', closeModal);
$('.modal-close').addEventListener('click', closeModal);
function closeModal() {
const modal = $('#promote-modal');
modal.classList.add('hidden');
modal.classList.remove('visible');
}
// ========== Keyboard shortcuts ==========
let lastDTime = 0;
const captureInput = $('#capture-input');
document.addEventListener('keydown', (ev) => {
if (document.activeElement === captureInput) {
if (ev.key === 'Escape') captureInput.blur();
return;
}
if ($('#promote-modal').classList.contains('visible')) {
if (ev.key === 'Escape') closeModal();
return;
}
switch (ev.key) {
case 'j':
ev.preventDefault();
selectEntity(Math.min(state.selectedIndex + 1, state.entities.length - 1));
scrollSelectedIntoView();
break;
case 'k':
ev.preventDefault();
selectEntity(Math.max(state.selectedIndex - 1, 0));
scrollSelectedIntoView();
break;
case 'n':
ev.preventDefault();
captureInput.focus();
break;
case 'p': {
const e = state.entities[state.selectedIndex];
if (e && !e.card_type) nibApp.showPromote(e.id);
break;
}
case 'Enter': {
const e = state.entities[state.selectedIndex];
if (e) nibApp.copyEntity(e.id);
break;
}
case 'd': {
const now = Date.now();
if (now - lastDTime < 400) {
const e = state.entities[state.selectedIndex];
if (e) nibApp.deleteEntity(e.id);
lastDTime = 0;
} else {
lastDTime = now;
}
break;
}
case '1': switchView('stream'); break;
case '2': switchView('cards'); break;
}
});
function scrollSelectedIntoView() {
const el = $(`.entity-item[data-index="${state.selectedIndex}"]`);
if (el) el.scrollIntoView({ block: 'nearest' });
}
// ========== View nav buttons ==========
$$('.nav-btn').forEach(btn => {
btn.addEventListener('click', () => switchView(btn.dataset.view));
});
// ========== Hash routing ==========
function handleHash() {
const hash = window.location.hash;
if (hash === '#/cards') {
state.view = 'cards';
} else {
state.view = 'stream';
}
$$('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === state.view));
loadEntities();
}
window.addEventListener('hashchange', handleHash);
// ========== Utils ==========
function escHtml(s) {
if (!s) return '';
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function escAttr(s) {
return escHtml(s).replace(/'/g, '&#39;');
}
// ========== Init ==========
async function init() {
await Promise.all([loadEntities(), loadTags()]);
handleHash();
}
init();
})();
+64
View File
@@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>nib</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div id="app">
<header>
<div class="header-left">
<h1 class="logo">nib</h1>
<nav>
<button data-view="stream" class="nav-btn active">stream</button>
<button data-view="cards" class="nav-btn">cards</button>
</nav>
</div>
<form id="capture-bar" autocomplete="off">
<input type="text" id="capture-input" placeholder="capture... (n to focus)" spellcheck="false">
</form>
</header>
<main>
<aside id="tag-rail"></aside>
<section id="entity-list"></section>
<aside id="detail-pane">
<div class="detail-empty">select an entity</div>
</aside>
</main>
</div>
<div id="promote-modal" class="modal hidden">
<div class="modal-backdrop"></div>
<div class="modal-content">
<h3>promote to card</h3>
<div class="type-picker">
<button data-type="snippet" class="type-btn">
<span class="type-glyph"></span>
<span>snippet</span>
</button>
<button data-type="template" class="type-btn">
<span class="type-glyph"></span>
<span>template</span>
</button>
<button data-type="checklist" class="type-btn">
<span class="type-glyph"></span>
<span>checklist</span>
</button>
<button data-type="decision" class="type-btn">
<span class="type-glyph"></span>
<span>decision</span>
</button>
<button data-type="link" class="type-btn">
<span class="type-glyph">🔗</span>
<span>link</span>
</button>
</div>
<button class="modal-close">esc to cancel</button>
</div>
</div>
<script src="/app.js"></script>
</body>
</html>
+469
View File
@@ -0,0 +1,469 @@
:root {
--bg: #1a1b26;
--bg-surface: #24283b;
--bg-hover: #292e42;
--bg-selected: #33394d;
--text: #c0caf5;
--text-dim: #565f89;
--text-muted: #3b4261;
--accent: #7aa2f7;
--accent-dim: #3d59a1;
--green: #9ece6a;
--red: #f7768e;
--yellow: #e0af68;
--orange: #ff9e64;
--purple: #bb9af7;
--cyan: #7dcfff;
--border: #292e42;
--radius: 6px;
--font-mono: 'JetBrains Mono', 'Fira Code', 'SF Mono', Menlo, monospace;
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: var(--font-sans);
background: var(--bg);
color: var(--text);
font-size: 14px;
line-height: 1.5;
height: 100vh;
overflow: hidden;
}
#app {
display: flex;
flex-direction: column;
height: 100vh;
}
/* Header */
header {
display: flex;
align-items: center;
gap: 16px;
padding: 12px 20px;
border-bottom: 1px solid var(--border);
background: var(--bg-surface);
flex-shrink: 0;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
flex-shrink: 0;
}
.logo {
font-family: var(--font-mono);
font-size: 18px;
font-weight: 700;
color: var(--accent);
letter-spacing: -0.5px;
}
nav {
display: flex;
gap: 4px;
}
.nav-btn {
background: none;
border: 1px solid transparent;
color: var(--text-dim);
padding: 4px 12px;
border-radius: var(--radius);
cursor: pointer;
font-size: 13px;
font-family: var(--font-mono);
transition: all 0.15s;
}
.nav-btn:hover { color: var(--text); background: var(--bg-hover); }
.nav-btn.active { color: var(--accent); border-color: var(--accent-dim); background: var(--bg); }
#capture-bar {
flex: 1;
max-width: 600px;
}
#capture-input {
width: 100%;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
padding: 8px 12px;
border-radius: var(--radius);
font-family: var(--font-mono);
font-size: 13px;
outline: none;
transition: border-color 0.15s;
}
#capture-input:focus {
border-color: var(--accent);
}
#capture-input::placeholder {
color: var(--text-muted);
}
/* Main layout */
main {
display: grid;
grid-template-columns: 180px 1fr 320px;
flex: 1;
overflow: hidden;
}
/* Tag rail */
#tag-rail {
border-right: 1px solid var(--border);
padding: 12px 0;
overflow-y: auto;
}
.tag-item {
display: flex;
justify-content: space-between;
padding: 6px 16px;
cursor: pointer;
font-size: 13px;
color: var(--text-dim);
transition: all 0.1s;
}
.tag-item:hover { background: var(--bg-hover); color: var(--text); }
.tag-item.active { color: var(--accent); background: var(--bg-selected); }
.tag-name { font-family: var(--font-mono); }
.tag-name::before { content: '#'; color: var(--text-muted); }
.tag-count {
font-size: 11px;
color: var(--text-muted);
min-width: 20px;
text-align: right;
}
/* Entity list */
#entity-list {
overflow-y: auto;
padding: 8px 0;
}
.date-header {
padding: 8px 20px 4px;
font-size: 11px;
font-family: var(--font-mono);
color: var(--text-muted);
text-transform: lowercase;
letter-spacing: 0.5px;
}
.entity-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 20px;
cursor: pointer;
transition: background 0.1s;
border-left: 2px solid transparent;
}
.entity-item:hover { background: var(--bg-hover); }
.entity-item.selected {
background: var(--bg-selected);
border-left-color: var(--accent);
}
.entity-glyph {
font-size: 14px;
width: 20px;
text-align: center;
flex-shrink: 0;
}
.glyph-note { color: var(--text-dim); }
.glyph-todo { color: var(--green); }
.glyph-event { color: var(--yellow); }
.glyph-snippet { color: var(--accent); }
.glyph-template { color: var(--purple); }
.glyph-checklist { color: var(--orange); }
.glyph-decision { color: var(--cyan); }
.glyph-link { color: var(--red); }
.entity-body {
flex: 1;
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.entity-time {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-dim);
flex-shrink: 0;
}
.entity-tags {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.entity-tag {
font-family: var(--font-mono);
font-size: 10px;
color: var(--accent-dim);
background: rgba(122, 162, 247, 0.1);
padding: 1px 6px;
border-radius: 3px;
}
.entity-meta {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-muted);
flex-shrink: 0;
display: flex;
gap: 8px;
}
.use-badge {
color: var(--yellow);
font-size: 10px;
}
/* Detail pane */
#detail-pane {
border-left: 1px solid var(--border);
padding: 20px;
overflow-y: auto;
}
.detail-empty {
color: var(--text-muted);
font-size: 13px;
text-align: center;
margin-top: 40px;
}
.detail-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
}
.detail-glyph { font-size: 20px; }
.detail-id {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-muted);
}
.detail-body {
font-size: 14px;
line-height: 1.7;
margin-bottom: 16px;
white-space: pre-wrap;
word-break: break-word;
}
.detail-tags {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.detail-tag {
font-family: var(--font-mono);
font-size: 12px;
color: var(--accent);
background: rgba(122, 162, 247, 0.1);
padding: 2px 8px;
border-radius: 4px;
}
.detail-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.action-btn {
background: var(--bg-hover);
border: 1px solid var(--border);
color: var(--text);
padding: 6px 14px;
border-radius: var(--radius);
cursor: pointer;
font-size: 12px;
font-family: var(--font-mono);
transition: all 0.15s;
}
.action-btn:hover { border-color: var(--accent); color: var(--accent); }
.action-btn.primary { background: var(--accent-dim); border-color: var(--accent); color: white; }
.action-btn.danger { border-color: var(--red); color: var(--red); }
.action-btn.danger:hover { background: rgba(247, 118, 142, 0.1); }
/* Template slot form */
.slot-form { margin: 16px 0; }
.slot-field {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.slot-label {
font-family: var(--font-mono);
font-size: 12px;
color: var(--purple);
min-width: 80px;
}
.slot-input {
flex: 1;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
padding: 4px 8px;
border-radius: 4px;
font-family: var(--font-mono);
font-size: 12px;
outline: none;
}
.slot-input:focus { border-color: var(--purple); }
/* Checklist */
.checklist-step {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
}
.checklist-step input[type="checkbox"] {
accent-color: var(--green);
}
.checklist-step.done span {
text-decoration: line-through;
color: var(--text-dim);
}
/* Decision card */
.decision-field {
margin-bottom: 12px;
}
.decision-label {
font-family: var(--font-mono);
font-size: 11px;
color: var(--cyan);
margin-bottom: 4px;
}
.decision-value {
font-size: 13px;
color: var(--text);
}
/* Modal */
.modal { display: none; }
.modal.visible { display: flex; }
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 100;
}
.modal-content {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 24px;
z-index: 101;
min-width: 320px;
}
.modal-content h3 {
font-family: var(--font-mono);
font-size: 14px;
color: var(--text);
margin-bottom: 16px;
font-weight: 500;
}
.type-picker {
display: flex;
flex-direction: column;
gap: 6px;
}
.type-btn {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
border-radius: var(--radius);
cursor: pointer;
font-size: 13px;
transition: all 0.15s;
}
.type-btn:hover { border-color: var(--accent); background: var(--bg-hover); }
.type-btn.suggested { border-color: var(--accent-dim); background: rgba(122, 162, 247, 0.05); }
.type-glyph { font-size: 16px; width: 24px; text-align: center; }
.modal-close {
display: block;
width: 100%;
margin-top: 12px;
padding: 6px;
background: none;
border: none;
color: var(--text-muted);
font-size: 11px;
cursor: pointer;
font-family: var(--font-mono);
}
/* Responsive */
@media (max-width: 900px) {
main { grid-template-columns: 1fr; }
#tag-rail { display: none; }
#detail-pane {
position: fixed;
inset: 0;
top: auto;
height: 50vh;
background: var(--bg-surface);
border-top: 1px solid var(--border);
border-left: none;
transform: translateY(100%);
transition: transform 0.2s;
z-index: 50;
}
#detail-pane.visible { transform: translateY(0); }
}