diff --git a/cmd/serve.go b/cmd/serve.go
index 591893d..8305433 100644
--- a/cmd/serve.go
+++ b/cmd/serve.go
@@ -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{
diff --git a/internal/api/router.go b/internal/api/router.go
index 3507e0d..f06b503 100644
--- a/internal/api/router.go
+++ b/internal/api/router.go
@@ -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")
diff --git a/main.go b/main.go
index fada07d..450a085 100644
--- a/main.go
+++ b/main.go
@@ -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)
diff --git a/web.go b/web.go
new file mode 100644
index 0000000..70644f9
--- /dev/null
+++ b/web.go
@@ -0,0 +1,6 @@
+package main
+
+import "embed"
+
+//go:embed web/*
+var WebFS embed.FS
diff --git a/web/app.js b/web/app.js
new file mode 100644
index 0000000..80ee598
--- /dev/null
+++ b/web/app.js
@@ -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 = `
+ all
+
`;
+
+ rail.innerHTML = allItem + state.tags.map(t =>
+ `
+ ${t.tag}
+ ${t.count}
+
`
+ ).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 = 'no entities yet
';
+ return;
+ }
+
+ let html = '';
+ if (state.view === 'stream') {
+ const groups = groupByDate(state.entities);
+ let idx = 0;
+ for (const g of groups) {
+ html += ``;
+ 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 => `${t} `).join('');
+ const time = e.time_anchor ? `@${e.time_anchor} ` : '';
+ const useBadge = e.use_count > 0 ? `${e.use_count}× ` : '';
+
+ return `
+ ${glyph}
+ ${escHtml(e.body)}
+ ${time}
+ ${tags}
+ ${useBadge}
+
`;
+ }
+
+ function renderDetailPane() {
+ const pane = $('#detail-pane');
+ const e = state.entities[state.selectedIndex];
+
+ if (!e) {
+ pane.innerHTML = 'select an entity
';
+ pane.classList.remove('visible');
+ return;
+ }
+
+ pane.classList.add('visible');
+ const glyph = displayGlyph(e);
+ const gc = glyphClass(e);
+ 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 += `delete `;
+
+ pane.innerHTML = `
+
+ ${escHtml(e.body)}
+ ${tags ? `${tags}
` : ''}
+ ${cardContent}
+ ${actions}
+ `;
+ }
+
+ 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 ``;
+
+ case 'checklist':
+ if (!data.steps || !data.steps.length) return '';
+ return `
+ ${data.steps.map((s, i) => `
+
+
+ ${escHtml(s.text)}
+
+ `).join('')}
+
`;
+
+ case 'decision':
+ return `
+
chose
${escHtml(data.chose || '—')}
+
why
${escHtml(data.why || '—')}
+ ${data.rejected && data.rejected.length ? `
rejected
${data.rejected.map(escHtml).join(', ') || '—'}
` : ''}
+
`;
+
+ case 'link':
+ if (data.url) {
+ return `
+ open link
+
`;
+ }
+ 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, '&').replace(//g, '>').replace(/"/g, '"');
+ }
+
+ function escAttr(s) {
+ return escHtml(s).replace(/'/g, ''');
+ }
+
+ // ========== Init ==========
+
+ async function init() {
+ await Promise.all([loadEntities(), loadTags()]);
+ handleHash();
+ }
+
+ init();
+})();
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..df2ae4e
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,64 @@
+
+
+
+
+
+ nib
+
+
+
+
+
+
+
+
+
+
diff --git a/web/style.css b/web/style.css
new file mode 100644
index 0000000..719fe04
--- /dev/null
+++ b/web/style.css
@@ -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); }
+}