From 4ec876b2d25f1c7e4ffd000dab0434e6d1098457 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sun, 17 May 2026 10:40:04 -0400 Subject: [PATCH 1/4] fix(ui): mobile capture sticky, post-delete focus, inline markdown - Capture bar stays visible on mobile via sticky positioning (#25) - Cursor moves to adjacent entry after delete instead of resetting (#24) - Inline expansion renders styled markdown via .exp-body.md selectors (#23) --- web/app.js | 4 ++++ web/style.css | 47 +++++++++++++++++++++++++++++------------------ 2 files changed, 33 insertions(+), 18 deletions(-) diff --git a/web/app.js b/web/app.js index 5d2904e..9d63a27 100644 --- a/web/app.js +++ b/web/app.js @@ -1317,9 +1317,13 @@ }, async deleteEntity(id) { + const prevIdx = state.selectedIndex; await api.deleteEntity(id); await loadEntities(); await loadTags(); + if (state.entities.length > 0) { + selectEntity(Math.min(prevIdx, state.entities.length - 1)); + } showToast('deleted'); }, diff --git a/web/style.css b/web/style.css index 07fb835..4618915 100644 --- a/web/style.css +++ b/web/style.css @@ -961,7 +961,8 @@ kbd { background: var(--raised); border: 1px solid var(--border); border-radius: .peek-body:hover { background: var(--raised); } -.peek-body.md { +.peek-body.md, +.exp-body.md { font-family: var(--sans); font-size: 13px; line-height: 1.65; @@ -969,35 +970,40 @@ kbd { background: var(--raised); border: 1px solid var(--border); border-radius: } .peek-body.md h1, .peek-body.md h2, .peek-body.md h3, -.peek-body.md h4, .peek-body.md h5, .peek-body.md h6 { +.peek-body.md h4, .peek-body.md h5, .peek-body.md h6, +.exp-body.md h1, .exp-body.md h2, .exp-body.md h3, +.exp-body.md h4, .exp-body.md h5, .exp-body.md h6 { font-weight: 600; color: var(--text); margin: 14px 0 6px; line-height: 1.3; } -.peek-body.md h1 { font-size: 17px; } -.peek-body.md h2 { font-size: 15px; } -.peek-body.md h3 { font-size: 14px; } +.peek-body.md h1, .exp-body.md h1 { font-size: 17px; } +.peek-body.md h2, .exp-body.md h2 { font-size: 15px; } +.peek-body.md h3, .exp-body.md h3 { font-size: 14px; } -.peek-body.md p { margin: 0 0 10px; } -.peek-body.md p:last-child { margin-bottom: 0; } +.peek-body.md p, .exp-body.md p { margin: 0 0 10px; } +.peek-body.md p:last-child, .exp-body.md p:last-child { margin-bottom: 0; } -.peek-body.md ul, .peek-body.md ol { +.peek-body.md ul, .peek-body.md ol, +.exp-body.md ul, .exp-body.md ol { padding-left: 20px; margin: 0 0 10px; } -.peek-body.md li { margin-bottom: 3px; } +.peek-body.md li, .exp-body.md li { margin-bottom: 3px; } -.peek-body.md blockquote { +.peek-body.md blockquote, +.exp-body.md blockquote { border-left: 2px solid var(--accent); padding-left: 12px; color: var(--muted); margin: 0 0 10px; } -.peek-body.md code { +.peek-body.md code, +.exp-body.md code { font-family: var(--mono); font-size: 11px; background: var(--bg); @@ -1006,7 +1012,8 @@ kbd { background: var(--raised); border: 1px solid var(--border); border-radius: padding: 1px 5px; } -.peek-body.md pre { +.peek-body.md pre, +.exp-body.md pre { background: var(--bg); border: 1px solid var(--border); border-radius: var(--r2); @@ -1015,7 +1022,8 @@ kbd { background: var(--raised); border: 1px solid var(--border); border-radius: margin: 0 0 10px; } -.peek-body.md pre code { +.peek-body.md pre code, +.exp-body.md pre code { background: none; border: none; padding: 0; @@ -1023,18 +1031,20 @@ kbd { background: var(--raised); border: 1px solid var(--border); border-radius: line-height: 1.6; } -.peek-body.md a { +.peek-body.md a, +.exp-body.md a { color: var(--event); text-decoration: none; border-bottom: 1px solid rgba(104,152,200,.3); } -.peek-body.md a:hover { border-bottom-color: var(--event); } +.peek-body.md a:hover, .exp-body.md a:hover { border-bottom-color: var(--event); } -.peek-body.md strong { font-weight: 600; } -.peek-body.md em { font-style: italic; color: var(--muted); } +.peek-body.md strong, .exp-body.md strong { font-weight: 600; } +.peek-body.md em, .exp-body.md em { font-style: italic; color: var(--muted); } -.peek-body.md hr { +.peek-body.md hr, +.exp-body.md hr { border: none; border-top: 1px solid var(--border); margin: 14px 0; @@ -1516,6 +1526,7 @@ kbd { background: var(--raised); border: 1px solid var(--border); border-radius: #tag-rail { display: none !important; } .resize-handle { display: none !important; } #entity-panel { overflow: auto; } + #capture-bar { position: sticky; bottom: 0; z-index: 10; } #detail-pane { position: fixed; inset: 0; -- 2.52.0 From 840084fbb00dbdc0527a630c369ff8e44f1fc7d4 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sun, 17 May 2026 11:05:10 -0400 Subject: [PATCH 2/4] fix(ui): render full card content in mobile inline expansion Promoted cards now show decision/steps/link/body sections in inline detail instead of just a body preview. Fullscreen removes line clamp. --- web/app.js | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++- web/style.css | 3 ++- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/web/app.js b/web/app.js index 9d63a27..dfaa3bb 100644 --- a/web/app.js +++ b/web/app.js @@ -664,12 +664,65 @@ actions += ``; } if (e.card_type) { + actions += ``; actions += ``; } else { actions += ``; } + + let content = ''; + if (e.card_type) { + const data = e.card_data ? (() => { try { return JSON.parse(e.card_data); } catch { return {}; } })() : {}; + const hasDecision = data.chose != null; + const hasSteps = data.steps && data.steps.length; + const hasLink = !!data.url; + const hasFill = /\$\{[^}]+\}/.test(e.body || ''); + + if (hasDecision) { + const rejected = (data.rejected || []).map(r => `${escHtml(r)}`).join(''); + content += `
+
decision${data.status || 'decided'}
+
+
${escHtml(data.chose)}
+
why${escHtml(data.why || '')}
+ ${rejected ? `
considered
${rejected}
` : ''} +
+
`; + } + if (hasLink && !hasDecision) { + content += `
+
link
+
+
`; + } + if (hasSteps) { + const steps = data.steps.map(s => `
${escHtml(s.text || s)}
`).join(''); + content += `
+
steps · ${data.steps.length}
+
${steps}
+
`; + actions += ``; + } + if (hasFill) { + actions += ``; + } + if (!hasDecision && e.body) { + const lang = data.lang || ''; + const isCode = lang || e.card_type === 'snippet'; + const bodyHtml = isCode + ? `
${escHtml(e.body)}
` + : `
${renderMd(e.body)}
`; + content += `
+
content${lang ? `${lang}` : ''}
+
${bodyHtml}
+
`; + } + } else { + content = `
${renderMd(e.body || '')}
`; + } + return `
-
${renderMd(e.body || '')}
+ ${content} ${tags ? `
${tags}
` : ''}
${actions}
diff --git a/web/style.css b/web/style.css index 4618915..55df904 100644 --- a/web/style.css +++ b/web/style.css @@ -414,7 +414,8 @@ main.focus-peek .resize-handle { visibility: hidden; } overflow: hidden; } -.entity-item.exp-full .exp-body { +.entity-item.exp-full .exp-body, +.card-row.exp-full .exp-body { -webkit-line-clamp: unset; overflow: visible; } -- 2.52.0 From 2b177eeae9d304ba0ef6430ce5ec864ffa5537a4 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sun, 17 May 2026 12:49:43 -0400 Subject: [PATCH 3/4] feat(cards): add 'note' card type for readable markdown content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New card type renders body as styled markdown with no copy/fill/run affordance. Glyph: ¶, color: --note. Migration uses transaction to safely rebuild table constraint. Checks both 'note' presence and modified_at column to catch partial migration state. --- internal/db/db.go | 56 ++++++++++++++++++++++++++++++++++++++- internal/db/entities.go | 3 ++- internal/display/glyph.go | 1 + internal/parse/grammar.go | 2 ++ web/app.js | 6 ++--- web/index.html | 5 ++++ web/style.css | 1 + 7 files changed, 69 insertions(+), 5 deletions(-) diff --git a/internal/db/db.go b/internal/db/db.go index 15530d8..fc389ea 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -3,8 +3,10 @@ package db import ( "database/sql" "errors" + "fmt" "os" "path/filepath" + "strings" _ "modernc.org/sqlite" ) @@ -63,7 +65,7 @@ func (s *Store) migrate() error { pinned INTEGER NOT NULL DEFAULT 0, deleted_at TEXT, card_type TEXT - CHECK (card_type IN ('snippet', 'template', 'checklist', 'decision', 'link') + CHECK (card_type IN ('snippet', 'template', 'checklist', 'decision', 'link', 'note') OR card_type IS NULL), card_data TEXT, use_count INTEGER NOT NULL DEFAULT 0, @@ -91,6 +93,58 @@ func (s *Store) migrate() error { s.db.Exec(`ALTER TABLE entities ADD COLUMN title TEXT`) s.db.Exec(`ALTER TABLE entities ADD COLUMN description TEXT`) + // Migrate CHECK constraint to include 'note' card type + var needsMigrate bool + row := s.db.QueryRow(`SELECT sql FROM sqlite_master WHERE type='table' AND name='entities'`) + var ddl string + if row.Scan(&ddl) == nil { + hasNote := strings.Contains(ddl, "'link', 'note'") + hasModified := strings.Contains(ddl, "modified_at") + needsMigrate = !hasNote || !hasModified + } + if needsMigrate { + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + if _, err := tx.Exec(`ALTER TABLE entities RENAME TO _entities_migrate`); err != nil { + return fmt.Errorf("migrate rename: %w", err) + } + if _, err := tx.Exec(`CREATE TABLE entities ( + id TEXT PRIMARY KEY, + created_at TEXT NOT NULL, + modified_at TEXT NOT NULL, + body TEXT NOT NULL, + glyph TEXT NOT NULL + CHECK (glyph IN ('todo', 'event', 'note')), + time_anchor TEXT, + completed_at TEXT, + pinned INTEGER NOT NULL DEFAULT 0, + deleted_at TEXT, + card_type TEXT + CHECK (card_type IN ('snippet', 'template', 'checklist', 'decision', 'link', 'note') + OR card_type IS NULL), + card_data TEXT, + use_count INTEGER NOT NULL DEFAULT 0, + last_used_at TEXT, + title TEXT, + description TEXT + )`); err != nil { + return fmt.Errorf("migrate create: %w", err) + } + if _, err := tx.Exec(`INSERT INTO entities SELECT * FROM _entities_migrate`); err != nil { + return fmt.Errorf("migrate copy: %w", err) + } + if _, err := tx.Exec(`DROP TABLE _entities_migrate`); err != nil { + return fmt.Errorf("migrate drop: %w", err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("migrate commit: %w", err) + } + } + return nil } diff --git a/internal/db/entities.go b/internal/db/entities.go index 4669416..c257d7a 100644 --- a/internal/db/entities.go +++ b/internal/db/entities.go @@ -27,6 +27,7 @@ const ( CardChecklist CardType = "checklist" CardDecision CardType = "decision" CardLink CardType = "link" + CardNote CardType = "note" ) func ValidGlyph(s string) bool { @@ -39,7 +40,7 @@ func ValidGlyph(s string) bool { func ValidCardType(s string) bool { switch CardType(s) { - case CardSnippet, CardTemplate, CardChecklist, CardDecision, CardLink: + case CardSnippet, CardTemplate, CardChecklist, CardDecision, CardLink, CardNote: return true } return false diff --git a/internal/display/glyph.go b/internal/display/glyph.go index 9ba2bfc..e3cb21d 100644 --- a/internal/display/glyph.go +++ b/internal/display/glyph.go @@ -15,6 +15,7 @@ var cardGlyphMap = map[db.CardType]string{ db.CardChecklist: "☐", db.CardDecision: "⚖", db.CardLink: "↗", + db.CardNote: "¶", } func DisplayGlyph(glyph db.Glyph, cardType *db.CardType) string { diff --git a/internal/parse/grammar.go b/internal/parse/grammar.go index ee30ea0..0556320 100644 --- a/internal/parse/grammar.go +++ b/internal/parse/grammar.go @@ -27,6 +27,8 @@ var validCardTypes = map[string]string{ "checklist": "checklist", "decision": "decision", "link": "link", + "note": "note", + "n": "note", } func Parse(input string) (*Result, error) { diff --git a/web/app.js b/web/app.js index dfaa3bb..adbe789 100644 --- a/web/app.js +++ b/web/app.js @@ -4,14 +4,14 @@ const GLYPHS = { note: '—', todo: '○', event: '◇', reminder: '△', snippet: '◆', template: '◈', checklist: '☐', - decision: '⚖', link: '↗', + decision: '⚖', link: '↗', note: '¶', }; const GLYPH_CLASSES = { note: 'glyph-note', todo: 'glyph-todo', event: 'glyph-event', reminder: 'glyph-reminder', snippet: 'glyph-snippet', template: 'glyph-template', checklist: 'glyph-checklist', decision: 'glyph-decision', - link: 'glyph-link', + link: 'glyph-link', note: 'glyph-note', }; const PAGE_SIZE = 50; @@ -121,7 +121,7 @@ // ========== Grammar parser (mirrors Go parser) ========== - const VALID_CARDS = { card: 'snippet', c: 'snippet', snippet: 'snippet', template: 'template', checklist: 'checklist', decision: 'decision', link: 'link' }; + const VALID_CARDS = { card: 'snippet', c: 'snippet', snippet: 'snippet', template: 'template', checklist: 'checklist', decision: 'decision', link: 'link', note: 'note', n: 'note' }; function validateTime(s) { const parts = s.split(':'); diff --git a/web/index.html b/web/index.html index f54638d..9794a4b 100644 --- a/web/index.html +++ b/web/index.html @@ -64,6 +64,11 @@ decision record a choice + rationale + - - - - - +
+
read
+ + + +
+
+
grab
+ +
+
+
fill
+ + +
diff --git a/web/style.css b/web/style.css index e3393dc..3a07d22 100644 --- a/web/style.css +++ b/web/style.css @@ -1380,7 +1380,8 @@ kbd { background: var(--raised); border: 1px solid var(--border); border-radius: border-radius: var(--r3); padding: 22px; z-index: 101; - min-width: 300px; + min-width: 380px; + max-width: 90vw; box-shadow: 0 20px 60px rgba(0,0,0,.5); } @@ -1403,16 +1404,32 @@ kbd { background: var(--raised); border: 1px solid var(--border); border-radius: } .type-picker { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 10px; +} + +.type-col { display: flex; flex-direction: column; gap: 4px; } +.type-col-lbl { + font-family: var(--mono); + font-size: 9px; + text-transform: uppercase; + letter-spacing: .14em; + color: var(--dim); + padding: 0 4px 4px; +} + .type-btn { display: flex; - align-items: center; - gap: 10px; - padding: 8px 12px; + flex-direction: column; + align-items: flex-start; + gap: 3px; + padding: 8px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--r2); @@ -1426,8 +1443,8 @@ kbd { background: var(--raised); border: 1px solid var(--border); border-radius: .type-btn:hover { border-color: var(--accent); background: var(--raised); } .type-btn.suggested { border-color: var(--accent); background: var(--a-bg); } -.type-glyph { font-size: 13px; width: 16px; flex-shrink: 0; } -.type-name { font-family: var(--mono); font-size: 12px; color: var(--text); min-width: 72px; } +.type-glyph { font-size: 13px; flex-shrink: 0; } +.type-name { font-family: var(--mono); font-size: 11px; color: var(--text); } .type-hint { font-family: var(--sans); -- 2.52.0