fix: UI issues #23-25 + note card type + promote modal #26

Merged
lerko merged 4 commits from fix/ui-issues-23-24-25 into main 2026-05-17 17:04:17 +00:00
7 changed files with 215 additions and 56 deletions
+55 -1
View File
@@ -3,8 +3,10 @@ package db
import ( import (
"database/sql" "database/sql"
"errors" "errors"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
@@ -63,7 +65,7 @@ func (s *Store) migrate() error {
pinned INTEGER NOT NULL DEFAULT 0, pinned INTEGER NOT NULL DEFAULT 0,
deleted_at TEXT, deleted_at TEXT,
card_type 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), OR card_type IS NULL),
card_data TEXT, card_data TEXT,
use_count INTEGER NOT NULL DEFAULT 0, 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 title TEXT`)
s.db.Exec(`ALTER TABLE entities ADD COLUMN description 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 return nil
} }
+2 -1
View File
@@ -27,6 +27,7 @@ const (
CardChecklist CardType = "checklist" CardChecklist CardType = "checklist"
CardDecision CardType = "decision" CardDecision CardType = "decision"
CardLink CardType = "link" CardLink CardType = "link"
CardNote CardType = "note"
) )
func ValidGlyph(s string) bool { func ValidGlyph(s string) bool {
@@ -39,7 +40,7 @@ func ValidGlyph(s string) bool {
func ValidCardType(s string) bool { func ValidCardType(s string) bool {
switch CardType(s) { switch CardType(s) {
case CardSnippet, CardTemplate, CardChecklist, CardDecision, CardLink: case CardSnippet, CardTemplate, CardChecklist, CardDecision, CardLink, CardNote:
return true return true
} }
return false return false
+1
View File
@@ -15,6 +15,7 @@ var cardGlyphMap = map[db.CardType]string{
db.CardChecklist: "☐", db.CardChecklist: "☐",
db.CardDecision: "⚖", db.CardDecision: "⚖",
db.CardLink: "↗", db.CardLink: "↗",
db.CardNote: "¶",
} }
func DisplayGlyph(glyph db.Glyph, cardType *db.CardType) string { func DisplayGlyph(glyph db.Glyph, cardType *db.CardType) string {
+2
View File
@@ -27,6 +27,8 @@ var validCardTypes = map[string]string{
"checklist": "checklist", "checklist": "checklist",
"decision": "decision", "decision": "decision",
"link": "link", "link": "link",
"note": "note",
"n": "note",
} }
func Parse(input string) (*Result, error) { func Parse(input string) (*Result, error) {
+61 -4
View File
@@ -4,14 +4,14 @@
const GLYPHS = { const GLYPHS = {
note: '—', todo: '○', event: '◇', reminder: '△', note: '—', todo: '○', event: '◇', reminder: '△',
snippet: '◆', template: '◈', checklist: '☐', snippet: '◆', template: '◈', checklist: '☐',
decision: '⚖', link: '↗', decision: '⚖', link: '↗', note: '¶',
}; };
const GLYPH_CLASSES = { const GLYPH_CLASSES = {
note: 'glyph-note', todo: 'glyph-todo', event: 'glyph-event', reminder: 'glyph-reminder', note: 'glyph-note', todo: 'glyph-todo', event: 'glyph-event', reminder: 'glyph-reminder',
snippet: 'glyph-snippet', template: 'glyph-template', snippet: 'glyph-snippet', template: 'glyph-template',
checklist: 'glyph-checklist', decision: 'glyph-decision', checklist: 'glyph-checklist', decision: 'glyph-decision',
link: 'glyph-link', link: 'glyph-link', note: 'glyph-note',
}; };
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
@@ -121,7 +121,7 @@
// ========== Grammar parser (mirrors Go parser) ========== // ========== 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) { function validateTime(s) {
const parts = s.split(':'); const parts = s.split(':');
@@ -664,12 +664,65 @@
actions += `<button class="action-btn primary" onclick="event.stopPropagation();nibApp.showPromote('${e.id}')">promote</button>`; actions += `<button class="action-btn primary" onclick="event.stopPropagation();nibApp.showPromote('${e.id}')">promote</button>`;
} }
if (e.card_type) { if (e.card_type) {
actions += `<button class="action-btn primary" onclick="event.stopPropagation();nibApp.copyEntity('${e.id}')">copy</button>`;
actions += `<button class="action-btn danger" onclick="event.stopPropagation();nibApp.demoteEntity('${e.id}')">demote</button>`; actions += `<button class="action-btn danger" onclick="event.stopPropagation();nibApp.demoteEntity('${e.id}')">demote</button>`;
} else { } else {
actions += `<button class="action-btn danger" onclick="event.stopPropagation();nibApp.deleteEntity('${e.id}')">delete</button>`; actions += `<button class="action-btn danger" onclick="event.stopPropagation();nibApp.deleteEntity('${e.id}')">delete</button>`;
} }
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 => `<span class="peek-dec-rej">${escHtml(r)}</span>`).join('');
content += `<div class="peek-sec">
<div class="peek-sec-lbl">decision<span class="peek-sec-status">${data.status || 'decided'}</span></div>
<div class="peek-sec-inner peek-decision">
<div class="peek-dec-choice">${escHtml(data.chose)}</div>
<div class="peek-dec-why"><span class="peek-dec-key">why</span>${escHtml(data.why || '')}</div>
${rejected ? `<div><span class="peek-dec-key">considered</span><div class="peek-dec-rejected">${rejected}</div></div>` : ''}
</div>
</div>`;
}
if (hasLink && !hasDecision) {
content += `<div class="peek-sec">
<div class="peek-sec-lbl">link</div>
<div class="peek-sec-inner"><div class="peek-link-url">↗ ${escHtml(data.url)}</div></div>
</div>`;
}
if (hasSteps) {
const steps = data.steps.map(s => `<div class="peek-step"><span class="peek-step-mark" style="color:var(--dim)">○</span><span class="peek-step-text">${escHtml(s.text || s)}</span></div>`).join('');
content += `<div class="peek-sec">
<div class="peek-sec-lbl">steps · ${data.steps.length}<button class="peek-sec-run" onclick="event.stopPropagation();nibApp.enterMode('run')">▶ run</button></div>
<div class="peek-sec-inner"><div class="peek-steps">${steps}</div></div>
</div>`;
actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.enterMode('run')">run</button>`;
}
if (hasFill) {
actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.enterMode('fill')">fill</button>`;
}
if (!hasDecision && e.body) {
const lang = data.lang || '';
const isCode = lang || e.card_type === 'snippet';
const bodyHtml = isCode
? `<div class="peek-code"><pre><code>${escHtml(e.body)}</code></pre></div>`
: `<div class="exp-body md">${renderMd(e.body)}</div>`;
content += `<div class="peek-sec">
<div class="peek-sec-lbl">content${lang ? `<span class="peek-sec-lang">${lang}</span>` : ''}</div>
<div class="peek-sec-inner">${bodyHtml}</div>
</div>`;
}
} else {
content = `<div class="exp-body md">${renderMd(e.body || '')}</div>`;
}
return `<div class="exp-inner"> return `<div class="exp-inner">
<div class="exp-body md">${renderMd(e.body || '')}</div> ${content}
${tags ? `<div class="exp-tags">${tags}</div>` : ''} ${tags ? `<div class="exp-tags">${tags}</div>` : ''}
<div class="exp-acts">${actions}</div> <div class="exp-acts">${actions}</div>
<div class="exp-toolbar"> <div class="exp-toolbar">
@@ -1317,9 +1370,13 @@
}, },
async deleteEntity(id) { async deleteEntity(id) {
const prevIdx = state.selectedIndex;
await api.deleteEntity(id); await api.deleteEntity(id);
await loadEntities(); await loadEntities();
await loadTags(); await loadTags();
if (state.entities.length > 0) {
selectEntity(Math.min(prevIdx, state.entities.length - 1));
}
showToast('deleted'); showToast('deleted');
}, },
+39 -25
View File
@@ -44,31 +44,45 @@
<h3>promote to card</h3> <h3>promote to card</h3>
<div class="modal-sub" id="promote-sub"></div> <div class="modal-sub" id="promote-sub"></div>
<div class="type-picker"> <div class="type-picker">
<button data-type="snippet" class="type-btn"> <div class="type-col">
<span class="type-glyph glyph-snippet"></span> <div class="type-col-lbl">read</div>
<span class="type-name">snippet</span> <button data-type="note" class="type-btn">
<span class="type-hint">quick reference, command, code</span> <span class="type-glyph glyph-note"></span>
</button> <span class="type-name">note</span>
<button data-type="template" class="type-btn"> <span class="type-hint">markdown content</span>
<span class="type-glyph glyph-template"></span> </button>
<span class="type-name">template</span> <button data-type="link" class="type-btn">
<span class="type-hint">fillable with ${slot}s</span> <span class="type-glyph glyph-link"></span>
</button> <span class="type-name">link</span>
<button data-type="checklist" class="type-btn"> <span class="type-hint">reference URL</span>
<span class="type-glyph glyph-checklist"></span> </button>
<span class="type-name">checklist</span> <button data-type="decision" class="type-btn">
<span class="type-hint">step-by-step process</span> <span class="type-glyph glyph-decision"></span>
</button> <span class="type-name">decision</span>
<button data-type="decision" class="type-btn"> <span class="type-hint">choice + rationale</span>
<span class="type-glyph glyph-decision"></span> </button>
<span class="type-name">decision</span> </div>
<span class="type-hint">record a choice + rationale</span> <div class="type-col">
</button> <div class="type-col-lbl">grab</div>
<button data-type="link" class="type-btn"> <button data-type="snippet" class="type-btn">
<span class="type-glyph glyph-link"></span> <span class="type-glyph glyph-snippet"></span>
<span class="type-name">link</span> <span class="type-name">snippet</span>
<span class="type-hint">reference URL</span> <span class="type-hint">code, command, text</span>
</button> </button>
</div>
<div class="type-col">
<div class="type-col-lbl">fill</div>
<button data-type="template" class="type-btn">
<span class="type-glyph glyph-template"></span>
<span class="type-name">template</span>
<span class="type-hint">fillable ${slot}s</span>
</button>
<button data-type="checklist" class="type-btn">
<span class="type-glyph glyph-checklist"></span>
<span class="type-name">checklist</span>
<span class="type-hint">step-by-step</span>
</button>
</div>
</div> </div>
<button class="modal-close">esc to cancel</button> <button class="modal-close">esc to cancel</button>
</div> </div>
+55 -25
View File
@@ -414,7 +414,8 @@ main.focus-peek .resize-handle { visibility: hidden; }
overflow: 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; -webkit-line-clamp: unset;
overflow: visible; overflow: visible;
} }
@@ -461,6 +462,7 @@ main.focus-peek .resize-handle { visibility: hidden; }
.glyph-checklist { color: var(--remind); } .glyph-checklist { color: var(--remind); }
.glyph-decision { color: var(--note); } .glyph-decision { color: var(--note); }
.glyph-link { color: var(--event); } .glyph-link { color: var(--event); }
.glyph-note { color: var(--note); }
.entity-content { .entity-content {
flex: 1; flex: 1;
@@ -961,7 +963,8 @@ kbd { background: var(--raised); border: 1px solid var(--border); border-radius:
.peek-body:hover { background: var(--raised); } .peek-body:hover { background: var(--raised); }
.peek-body.md { .peek-body.md,
.exp-body.md {
font-family: var(--sans); font-family: var(--sans);
font-size: 13px; font-size: 13px;
line-height: 1.65; line-height: 1.65;
@@ -969,35 +972,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 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; font-weight: 600;
color: var(--text); color: var(--text);
margin: 14px 0 6px; margin: 14px 0 6px;
line-height: 1.3; line-height: 1.3;
} }
.peek-body.md h1 { font-size: 17px; } .peek-body.md h1, .exp-body.md h1 { font-size: 17px; }
.peek-body.md h2 { font-size: 15px; } .peek-body.md h2, .exp-body.md h2 { font-size: 15px; }
.peek-body.md h3 { font-size: 14px; } .peek-body.md h3, .exp-body.md h3 { font-size: 14px; }
.peek-body.md p { margin: 0 0 10px; } .peek-body.md p, .exp-body.md p { margin: 0 0 10px; }
.peek-body.md p:last-child { margin-bottom: 0; } .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; padding-left: 20px;
margin: 0 0 10px; 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); border-left: 2px solid var(--accent);
padding-left: 12px; padding-left: 12px;
color: var(--muted); color: var(--muted);
margin: 0 0 10px; margin: 0 0 10px;
} }
.peek-body.md code { .peek-body.md code,
.exp-body.md code {
font-family: var(--mono); font-family: var(--mono);
font-size: 11px; font-size: 11px;
background: var(--bg); background: var(--bg);
@@ -1006,7 +1014,8 @@ kbd { background: var(--raised); border: 1px solid var(--border); border-radius:
padding: 1px 5px; padding: 1px 5px;
} }
.peek-body.md pre { .peek-body.md pre,
.exp-body.md pre {
background: var(--bg); background: var(--bg);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--r2); border-radius: var(--r2);
@@ -1015,7 +1024,8 @@ kbd { background: var(--raised); border: 1px solid var(--border); border-radius:
margin: 0 0 10px; margin: 0 0 10px;
} }
.peek-body.md pre code { .peek-body.md pre code,
.exp-body.md pre code {
background: none; background: none;
border: none; border: none;
padding: 0; padding: 0;
@@ -1023,18 +1033,20 @@ kbd { background: var(--raised); border: 1px solid var(--border); border-radius:
line-height: 1.6; line-height: 1.6;
} }
.peek-body.md a { .peek-body.md a,
.exp-body.md a {
color: var(--event); color: var(--event);
text-decoration: none; text-decoration: none;
border-bottom: 1px solid rgba(104,152,200,.3); 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 strong, .exp-body.md strong { font-weight: 600; }
.peek-body.md em { font-style: italic; color: var(--muted); } .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: none;
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
margin: 14px 0; margin: 14px 0;
@@ -1368,7 +1380,8 @@ kbd { background: var(--raised); border: 1px solid var(--border); border-radius:
border-radius: var(--r3); border-radius: var(--r3);
padding: 22px; padding: 22px;
z-index: 101; z-index: 101;
min-width: 300px; min-width: 380px;
max-width: 90vw;
box-shadow: 0 20px 60px rgba(0,0,0,.5); box-shadow: 0 20px 60px rgba(0,0,0,.5);
} }
@@ -1391,16 +1404,32 @@ kbd { background: var(--raised); border: 1px solid var(--border); border-radius:
} }
.type-picker { .type-picker {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 10px;
}
.type-col {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; 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 { .type-btn {
display: flex; display: flex;
align-items: center; flex-direction: column;
gap: 10px; align-items: flex-start;
padding: 8px 12px; gap: 3px;
padding: 8px 10px;
background: var(--bg); background: var(--bg);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--r2); border-radius: var(--r2);
@@ -1414,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:hover { border-color: var(--accent); background: var(--raised); }
.type-btn.suggested { border-color: var(--accent); background: var(--a-bg); } .type-btn.suggested { border-color: var(--accent); background: var(--a-bg); }
.type-glyph { font-size: 13px; width: 16px; flex-shrink: 0; } .type-glyph { font-size: 13px; flex-shrink: 0; }
.type-name { font-family: var(--mono); font-size: 12px; color: var(--text); min-width: 72px; } .type-name { font-family: var(--mono); font-size: 11px; color: var(--text); }
.type-hint { .type-hint {
font-family: var(--sans); font-family: var(--sans);
@@ -1516,6 +1545,7 @@ kbd { background: var(--raised); border: 1px solid var(--border); border-radius:
#tag-rail { display: none !important; } #tag-rail { display: none !important; }
.resize-handle { display: none !important; } .resize-handle { display: none !important; }
#entity-panel { overflow: auto; } #entity-panel { overflow: auto; }
#capture-bar { position: sticky; bottom: 0; z-index: 10; }
#detail-pane { #detail-pane {
position: fixed; position: fixed;
inset: 0; inset: 0;