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 (
"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
}
+2 -1
View File
@@ -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
+1
View File
@@ -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 {
+2
View File
@@ -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) {
+61 -4
View File
@@ -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(':');
@@ -664,12 +664,65 @@
actions += `<button class="action-btn primary" onclick="event.stopPropagation();nibApp.showPromote('${e.id}')">promote</button>`;
}
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>`;
} else {
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">
<div class="exp-body md">${renderMd(e.body || '')}</div>
${content}
${tags ? `<div class="exp-tags">${tags}</div>` : ''}
<div class="exp-acts">${actions}</div>
<div class="exp-toolbar">
@@ -1317,9 +1370,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');
},
+33 -19
View File
@@ -44,31 +44,45 @@
<h3>promote to card</h3>
<div class="modal-sub" id="promote-sub"></div>
<div class="type-picker">
<button data-type="snippet" class="type-btn">
<span class="type-glyph glyph-snippet"></span>
<span class="type-name">snippet</span>
<span class="type-hint">quick reference, command, code</span>
</button>
<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 with ${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 process</span>
</button>
<button data-type="decision" class="type-btn">
<span class="type-glyph glyph-decision"></span>
<span class="type-name">decision</span>
<span class="type-hint">record a choice + rationale</span>
<div class="type-col">
<div class="type-col-lbl">read</div>
<button data-type="note" class="type-btn">
<span class="type-glyph glyph-note"></span>
<span class="type-name">note</span>
<span class="type-hint">markdown content</span>
</button>
<button data-type="link" class="type-btn">
<span class="type-glyph glyph-link"></span>
<span class="type-name">link</span>
<span class="type-hint">reference URL</span>
</button>
<button data-type="decision" class="type-btn">
<span class="type-glyph glyph-decision"></span>
<span class="type-name">decision</span>
<span class="type-hint">choice + rationale</span>
</button>
</div>
<div class="type-col">
<div class="type-col-lbl">grab</div>
<button data-type="snippet" class="type-btn">
<span class="type-glyph glyph-snippet"></span>
<span class="type-name">snippet</span>
<span class="type-hint">code, command, text</span>
</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>
<button class="modal-close">esc to cancel</button>
</div>
+55 -25
View File
@@ -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;
}
@@ -461,6 +462,7 @@ main.focus-peek .resize-handle { visibility: hidden; }
.glyph-checklist { color: var(--remind); }
.glyph-decision { color: var(--note); }
.glyph-link { color: var(--event); }
.glyph-note { color: var(--note); }
.entity-content {
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.md {
.peek-body.md,
.exp-body.md {
font-family: var(--sans);
font-size: 13px;
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 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 +1014,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 +1024,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 +1033,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;
@@ -1368,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);
}
@@ -1391,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);
@@ -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.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);
@@ -1516,6 +1545,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;