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 += `
── ${g.label} ──
`; + 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 += ``; + actions += ``; + } else { + actions += ``; + } + actions += ``; + + pane.innerHTML = ` +
+ ${glyph} + ${shortId} + ${e.time_anchor ? `@${e.time_anchor}` : ''} +
+
${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 `
+ ${data.slots.map(s => ` +
+ \${${s.name}} + +
+ `).join('')} + +
`; + + 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 `
+ +
`; + } + 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 + + + +
+
+
+

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); } +}