fix: address code review findings across backend and frontend
CI / test (pull_request) Successful in 2m13s

Fix goroutine-unsafe ULID entropy by wrapping in LockedMonotonicReader.
Move PRAGMA foreign_keys outside transaction in v3 migration where
SQLite was silently ignoring it. Escape LIKE wildcards in link
resolution to prevent false matches. Add non-localhost binding warning,
log writeJSON encoder errors, add ?permanent=true for explicit hard
delete, preserve title/description during absorb, use millisecond
backup timestamps, add path.Clean to spaHandler. Frontend gains
checkedJSON() for resp.ok validation, consistent stopPropagation, and
shared renderCardSections() to eliminate duplicate rendering.
This commit is contained in:
2026-05-21 16:01:43 -04:00
parent 8426c2fbc1
commit e9ecc4c1f7
12 changed files with 240 additions and 153 deletions
+65 -22
View File
@@ -1,27 +1,70 @@
# Code Hardening — Senior Dev Audit Fixes # Code Review Fixes
## Phase 1: Quick Wins (safety + correctness) ## Phase 1: Critical — Concurrency & Data Safety
- [x] Cap API list limit at 200
- [x] Fix markdown XSS — add DOMPurify to sanitize marked output
- [x] Add missing DB indexes (deleted_at, modified_at) via v4 migration
- [x] Fix v2 migration error handling (swallowed ALTER TABLE errors)
- [x] Fix ~/.nib directory permissions (0o755 → 0o700)
## Phase 2: CI Pipeline ### 1. ULID entropy not goroutine-safe
- [x] Gitea Actions workflow: test + lint on PR - [x] Wrap MonotonicEntropy in LockedMonotonicReader (`internal/ulid/ulid.go`)
- [x] Add concurrent uniqueness test (100 goroutines)
- [x] Verify `go test -race ./internal/ulid/...`
## Phase 3: context.Context in Store ### 2. Migration PRAGMA bug
- [x] Thread context.Context through all Store methods - [x] Move `PRAGMA foreign_keys = OFF` before transaction in v3 migration (`internal/db/db.go`)
- [x] Use context in API handlers (from r.Context()) - [x] Re-enable after commit
- [x] Use context in CLI commands (cobra context) - [x] Remove dead `currentSchema` constant while here
## Phase 4: cmd/ Tests ### 3. LIKE wildcard injection in link resolution
- [x] Test add command - [x] Escape `%` and `_` in link text before LIKE query (`internal/db/links.go`)
- [x] Test ls command - [x] Add `ESCAPE '\'` clause
- [x] Test promote/demote commands - [ ] Test with `[[%]]` and `[[_]]` link text
- [x] Test delete command
- [x] Test absorb command
## Phase 5: Backup/Export ## Phase 2: High — Security & API Hygiene
- [x] nib export — dump entities to JSON
- [x] nib backup — safe SQLite backup (handles WAL) ### 4. No auth warning for non-localhost binding
- [x] Print loud warning when `--host != 127.0.0.1` and no auth configured (`cmd/serve.go`)
- [ ] Consider `--no-auth` flag requirement for non-localhost
### 5. writeJSON ignores encoder errors
- [x] Log error from `json.Encoder.Encode()` in `writeJSON` (`internal/api/helpers.go`)
### 6. DELETE endpoint semantics
- [x] Add `?permanent=true` query param for hard delete (`internal/api/entities.go`)
- [x] Add `HardDelete` store method (`internal/db/entities.go`)
- [ ] Update frontend and CLI to match
- [x] Keep backward compat: double-delete still works without param
## Phase 3: Medium — Frontend Robustness
### 7. No resp.ok checks on fetch calls
- [x] Add `checkedJSON()` wrapper with error extraction (`web/app.js`)
- [x] All API methods use `checkedJSON(resp)` instead of `resp.json()`
- [ ] Surface API errors to user via notification/toast
### 8. Inconsistent event.stopPropagation
- [x] Add stopPropagation to all renderStreamPeek action buttons
- [x] Add stopPropagation to all renderCardPeek action buttons and inline section buttons
### 9. Duplicate section rendering
- [x] Extract `renderCardSections()` shared function (`web/app.js`)
- [x] Refactor `renderInlineDetail` to use shared function
- [x] Refactor `renderCardPeek` to use shared function
## Phase 4: Medium — Data Integrity
### 10. Absorb discards source title/description
- [x] If target has no title, inherit from source (`internal/db/entities.go`)
- [x] If target has no description, inherit from source
- [ ] Test title/description preservation
## Phase 5: Low — Housekeeping & UX
### 11. Backup path collision
- [x] Use millisecond-precision timestamps (`cmd/backup.go`)
### 12. spaHandler path safety
- [x] Add explicit `path.Clean` in spaHandler (`internal/api/router.go`)
### 13. Focus-peek escape hatch
- [ ] Add visible close/back button in focus-peek mode (`web/app.js`)
### 14. Hard delete confirmation
- [ ] Add confirmation step or undo toast before permanent delete (`web/app.js`)
+1 -1
View File
@@ -26,7 +26,7 @@ func runBackup(cmd *cobra.Command, args []string) error {
return err return err
} }
dst := fmt.Sprintf("%s.backup-%s", srcPath, time.Now().Format("20060102-150405")) dst := fmt.Sprintf("%s.backup-%s", srcPath, time.Now().Format("20060102-150405.000"))
if len(args) > 0 { if len(args) > 0 {
dst = args[0] dst = args[0]
} }
+3
View File
@@ -90,6 +90,9 @@ func runServe(_ *cobra.Command, _ []string) error {
if serveDev { if serveDev {
fmt.Println(" CORS enabled (dev mode)") fmt.Println(" CORS enabled (dev mode)")
} }
if serveHost != "127.0.0.1" && serveHost != "localhost" && serveHost != "::1" {
fmt.Fprintln(os.Stderr, " WARNING: binding to non-localhost with no authentication — API is open to the network")
}
var listenErr error var listenErr error
if useTLS { if useTLS {
+14
View File
@@ -272,6 +272,20 @@ type DeleteResponse struct {
func deleteEntity(store *db.Store) http.HandlerFunc { func deleteEntity(store *db.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id") id := chi.URLParam(r, "id")
if r.URL.Query().Get("permanent") == "true" {
if err := store.HardDelete(r.Context(), id); err != nil {
if err == db.ErrNotFound {
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
return
}
writeInternalError(w, err)
return
}
writeJSON(w, http.StatusOK, DeleteResponse{Result: "hard"})
return
}
result, err := store.SoftDelete(r.Context(), id) result, err := store.SoftDelete(r.Context(), id)
if err != nil { if err != nil {
if err == db.ErrNotFound { if err == db.ErrNotFound {
+3 -1
View File
@@ -38,7 +38,9 @@ type EntityResponse struct {
func writeJSON(w http.ResponseWriter, status int, v any) { func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status) w.WriteHeader(status)
json.NewEncoder(w).Encode(v) if err := json.NewEncoder(w).Encode(v); err != nil {
log.Printf("writeJSON encode error: %v", err)
}
} }
func writeError(w http.ResponseWriter, status int, code, message string) { func writeError(w http.ResponseWriter, status int, code, message string) {
+1 -1
View File
@@ -47,7 +47,7 @@ func spaHandler(fsys fs.FS) http.HandlerFunc {
indexHTML, _ := fs.ReadFile(fsys, "index.html") indexHTML, _ := fs.ReadFile(fsys, "index.html")
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path p := path.Clean(r.URL.Path)
if p == "/" || path.Ext(p) == "" { if p == "/" || path.Ext(p) == "" {
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(indexHTML) w.Write(indexHTML)
+12 -10
View File
@@ -56,8 +56,6 @@ func (s *Store) Backup(dst string) error {
return err return err
} }
const currentSchema = 5
var migrations = []func(db *sql.DB) error{ var migrations = []func(db *sql.DB) error{
// v1: initial schema // v1: initial schema
func(db *sql.DB) error { func(db *sql.DB) error {
@@ -108,17 +106,18 @@ var migrations = []func(db *sql.DB) error{
// v3: rebuild table with CHECK constraints (card_type 'note', glyph 'reminder') // v3: rebuild table with CHECK constraints (card_type 'note', glyph 'reminder')
func(db *sql.DB) error { func(db *sql.DB) error {
// PRAGMA foreign_keys must be set outside a transaction (SQLite ignores it inside one)
if _, err := db.Exec(`PRAGMA foreign_keys = OFF`); err != nil {
return fmt.Errorf("migrate fk off: %w", err)
}
tx, err := db.Begin() tx, err := db.Begin()
if err != nil { if err != nil {
db.Exec(`PRAGMA foreign_keys = ON`)
return err return err
} }
defer tx.Rollback() defer tx.Rollback()
// Disable FK checks during rebuild to avoid dangling references
if _, err := tx.Exec(`PRAGMA foreign_keys = OFF`); err != nil {
return fmt.Errorf("migrate fk off: %w", err)
}
if _, err := tx.Exec(`ALTER TABLE entities RENAME TO _entities_migrate`); err != nil { if _, err := tx.Exec(`ALTER TABLE entities RENAME TO _entities_migrate`); err != nil {
return fmt.Errorf("migrate rename: %w", err) return fmt.Errorf("migrate rename: %w", err)
} }
@@ -169,11 +168,14 @@ var migrations = []func(db *sql.DB) error{
return fmt.Errorf("migrate tags drop: %w", err) return fmt.Errorf("migrate tags drop: %w", err)
} }
if _, err := tx.Exec(`PRAGMA foreign_keys = ON`); err != nil { if err := tx.Commit(); err != nil {
db.Exec(`PRAGMA foreign_keys = ON`)
return err
}
if _, err := db.Exec(`PRAGMA foreign_keys = ON`); err != nil {
return fmt.Errorf("migrate fk on: %w", err) return fmt.Errorf("migrate fk on: %w", err)
} }
return nil
return tx.Commit()
}, },
// v4: add indexes for common query filters // v4: add indexes for common query filters
+24 -2
View File
@@ -464,6 +464,18 @@ func (s *Store) SoftDelete(ctx context.Context, id string) (DeleteResult, error)
return DeletedSoft, err return DeletedSoft, err
} }
func (s *Store) HardDelete(ctx context.Context, id string) error {
res, err := s.db.ExecContext(ctx, "DELETE FROM entities WHERE id = ?", id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) Absorb(ctx context.Context, targetID, sourceID string) error { func (s *Store) Absorb(ctx context.Context, targetID, sourceID string) error {
target, err := s.Get(ctx, targetID) target, err := s.Get(ctx, targetID)
if err != nil { if err != nil {
@@ -487,8 +499,18 @@ func (s *Store) Absorb(ctx context.Context, targetID, sourceID string) error {
now := time.Now().UTC().Format(time.RFC3339) now := time.Now().UTC().Format(time.RFC3339)
merged := target.Body + "\n" + source.Body merged := target.Body + "\n" + source.Body
if _, err := tx.ExecContext(ctx, "UPDATE entities SET body = ?, modified_at = ? WHERE id = ?", title := target.Title
merged, now, targetID); err != nil { if title == nil {
title = source.Title
}
desc := target.Description
if desc == nil {
desc = source.Description
}
if _, err := tx.ExecContext(ctx,
"UPDATE entities SET body = ?, title = ?, description = ?, modified_at = ? WHERE id = ?",
merged, title, desc, now, targetID); err != nil {
return err return err
} }
+7 -2
View File
@@ -15,6 +15,11 @@ type Backlink struct {
LinkText string LinkText string
} }
func escapeLike(s string) string {
r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
return r.Replace(s)
}
func (s *Store) resolveLink(ctx context.Context, tx *sql.Tx, linkText string, excludeID string) *string { func (s *Store) resolveLink(ctx context.Context, tx *sql.Tx, linkText string, excludeID string) *string {
lower := strings.ToLower(linkText) lower := strings.ToLower(linkText)
@@ -29,8 +34,8 @@ func (s *Store) resolveLink(ctx context.Context, tx *sql.Tx, linkText string, ex
err = tx.QueryRowContext(ctx, ` err = tx.QueryRowContext(ctx, `
SELECT id FROM entities SELECT id FROM entities
WHERE LOWER(body) LIKE ? AND id != ? AND deleted_at IS NULL WHERE LOWER(body) LIKE ? ESCAPE '\' AND id != ? AND deleted_at IS NULL
ORDER BY created_at DESC LIMIT 1`, "%"+lower+"%", excludeID).Scan(&id) ORDER BY created_at DESC LIMIT 1`, "%"+escapeLike(lower)+"%", excludeID).Scan(&id)
if err == nil { if err == nil {
return &id return &id
} }
+4 -2
View File
@@ -8,13 +8,15 @@ import (
) )
var ( var (
entropy *ulid.MonotonicEntropy entropy *ulid.LockedMonotonicReader
entropyOnce sync.Once entropyOnce sync.Once
) )
func New() string { func New() string {
entropyOnce.Do(func() { entropyOnce.Do(func() {
entropy = ulid.Monotonic(rand.Reader, 0) entropy = &ulid.LockedMonotonicReader{
MonotonicReader: ulid.Monotonic(rand.Reader, 0),
}
}) })
return ulid.MustNew(ulid.Now(), entropy).String() return ulid.MustNew(ulid.Now(), entropy).String()
} }
+23
View File
@@ -1,6 +1,7 @@
package ulid package ulid
import ( import (
"sync"
"testing" "testing"
) )
@@ -26,3 +27,25 @@ func TestNew_Sortable(t *testing.T) {
t.Errorf("expected b >= a for sequential calls: a=%s b=%s", a, b) t.Errorf("expected b >= a for sequential calls: a=%s b=%s", a, b)
} }
} }
func TestNew_ConcurrentUnique(t *testing.T) {
const n = 100
ids := make([]string, n)
var wg sync.WaitGroup
wg.Add(n)
for i := 0; i < n; i++ {
go func(idx int) {
defer wg.Done()
ids[idx] = New()
}(i)
}
wg.Wait()
seen := make(map[string]struct{}, n)
for _, id := range ids {
if _, dup := seen[id]; dup {
t.Fatalf("duplicate ULID under concurrency: %s", id)
}
seen[id] = struct{}{}
}
}
+83 -112
View File
@@ -44,6 +44,15 @@
// ========== API ========== // ========== API ==========
async function checkedJSON(resp) {
if (!resp.ok) {
const body = await resp.json().catch(() => ({}));
const msg = body.message || body.error || `HTTP ${resp.status}`;
throw new Error(msg);
}
return resp.json();
}
const api = { const api = {
async listEntities(params = {}) { async listEntities(params = {}) {
const q = new URLSearchParams(); const q = new URLSearchParams();
@@ -57,7 +66,7 @@
if (params.limit) q.set('limit', String(params.limit)); if (params.limit) q.set('limit', String(params.limit));
if (params.offset) q.set('offset', String(params.offset)); if (params.offset) q.set('offset', String(params.offset));
const resp = await fetch('/api/entities?' + q); const resp = await fetch('/api/entities?' + q);
return resp.json(); return checkedJSON(resp);
}, },
async createEntity(data) { async createEntity(data) {
const resp = await fetch('/api/entities', { const resp = await fetch('/api/entities', {
@@ -65,11 +74,11 @@
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
return resp.json(); return checkedJSON(resp);
}, },
async getEntity(id) { async getEntity(id) {
const resp = await fetch('/api/entities/' + id); const resp = await fetch('/api/entities/' + id);
return resp.json(); return checkedJSON(resp);
}, },
async updateEntity(id, data) { async updateEntity(id, data) {
const resp = await fetch('/api/entities/' + id, { const resp = await fetch('/api/entities/' + id, {
@@ -77,10 +86,11 @@
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
return resp.json(); return checkedJSON(resp);
}, },
async deleteEntity(id) { async deleteEntity(id) {
return fetch('/api/entities/' + id, { method: 'DELETE' }); const resp = await fetch('/api/entities/' + id, { method: 'DELETE' });
return checkedJSON(resp);
}, },
async promoteEntity(id, cardType, cardData) { async promoteEntity(id, cardType, cardData) {
const resp = await fetch('/api/entities/' + id + '/promote', { const resp = await fetch('/api/entities/' + id + '/promote', {
@@ -88,7 +98,7 @@
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ card_type: cardType, card_data: cardData }), body: JSON.stringify({ card_type: cardType, card_data: cardData }),
}); });
return resp.json(); return checkedJSON(resp);
}, },
async demoteEntity(id) { async demoteEntity(id) {
const resp = await fetch('/api/entities/' + id + '/demote', { const resp = await fetch('/api/entities/' + id + '/demote', {
@@ -96,7 +106,7 @@
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}), body: JSON.stringify({}),
}); });
return resp.json(); return checkedJSON(resp);
}, },
async useEntity(id) { async useEntity(id) {
const resp = await fetch('/api/entities/' + id + '/use', { const resp = await fetch('/api/entities/' + id + '/use', {
@@ -104,7 +114,7 @@
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}), body: JSON.stringify({}),
}); });
return resp.json(); return checkedJSON(resp);
}, },
async absorbEntity(targetId, sourceId) { async absorbEntity(targetId, sourceId) {
const resp = await fetch('/api/entities/' + targetId + '/absorb', { const resp = await fetch('/api/entities/' + targetId + '/absorb', {
@@ -112,13 +122,13 @@
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source_id: sourceId }), body: JSON.stringify({ source_id: sourceId }),
}); });
return resp.json(); return checkedJSON(resp);
}, },
async listTags(params = {}) { async listTags(params = {}) {
const q = new URLSearchParams(); const q = new URLSearchParams();
if (params.cards_only) q.set('cards_only', 'true'); if (params.cards_only) q.set('cards_only', 'true');
const resp = await fetch('/api/tags?' + q); const resp = await fetch('/api/tags?' + q);
return resp.json(); return checkedJSON(resp);
}, },
}; };
@@ -677,6 +687,52 @@
</div>`; </div>`;
} }
function renderCardSections(e, bodyClass) {
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 || '');
let sections = '';
if (hasDecision) {
const rejected = (data.rejected || []).map(r => `<span class="peek-dec-rej">${escHtml(r)}</span>`).join('');
sections += `<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) {
sections += `<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('');
sections += `<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>`;
}
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="${bodyClass} md">${renderMd(e.body)}</div>`;
sections += `<div class="peek-sec">
<div class="peek-sec-lbl">content${lang ? `<span class="peek-sec-lang">${lang}</span>` : ''}${hasFill ? `<button class="peek-sec-run" onclick="event.stopPropagation();nibApp.enterMode('fill')">⤓ fill</button>` : ''}</div>
<div class="peek-sec-inner">${bodyHtml}</div>
</div>`;
}
return { sections, data, hasDecision, hasSteps, hasLink, hasFill };
}
function renderInlineDetail(e) { function renderInlineDetail(e) {
const tags = (e.tags || []).map(t => `<span class="detail-tag">#${t}</span>`).join(''); const tags = (e.tags || []).map(t => `<span class="detail-tag">#${t}</span>`).join('');
let actions = ''; let actions = '';
@@ -694,51 +750,10 @@
let content = ''; let content = '';
if (e.card_type) { if (e.card_type) {
const data = e.card_data ? (() => { try { return JSON.parse(e.card_data); } catch { return {}; } })() : {}; const cs = renderCardSections(e, 'exp-body');
const hasDecision = data.chose != null; content = cs.sections;
const hasSteps = data.steps && data.steps.length; if (cs.hasSteps) actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.enterMode('run')">run</button>`;
const hasLink = !!data.url; if (cs.hasFill) actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.enterMode('fill')">fill</button>`;
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 { } else {
content = `<div class="exp-body md">${renderMd(e.body || '')}</div>`; content = `<div class="exp-body md">${renderMd(e.body || '')}</div>`;
} }
@@ -861,15 +876,15 @@
const tags = (e.tags || []).map(t => `<span class="detail-tag">#${t}</span>`).join(''); const tags = (e.tags || []).map(t => `<span class="detail-tag">#${t}</span>`).join('');
let actions = ''; let actions = '';
actions += `<button class="action-btn" onclick="nibApp.enterMode('edit')">edit <kbd>e</kbd></button>`; actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.enterMode('edit')">edit <kbd>e</kbd></button>`;
if (!e.card_type) { if (!e.card_type) {
actions += `<button class="action-btn" onclick="nibApp.showAbsorb('${e.id}')">absorb <kbd>a</kbd></button>`; actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.showAbsorb('${e.id}')">absorb <kbd>a</kbd></button>`;
actions += `<button class="action-btn primary" onclick="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 danger" onclick="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="nibApp.deleteEntity('${e.id}')">delete</button>`; actions += `<button class="action-btn danger" onclick="event.stopPropagation();nibApp.deleteEntity('${e.id}')">delete</button>`;
} }
return `<div class="peek-scroll"> return `<div class="peek-scroll">
@@ -899,61 +914,17 @@
const glyph = GLYPHS[e.card_type] || '◆'; const glyph = GLYPHS[e.card_type] || '◆';
const gc = GLYPH_CLASSES[e.card_type] || 'glyph-snippet'; const gc = GLYPH_CLASSES[e.card_type] || 'glyph-snippet';
const affs = detectAffordances(e); const affs = detectAffordances(e);
const data = e.card_data ? (() => { try { return JSON.parse(e.card_data); } catch { return {}; } })() : {};
const tags = (e.tags || []).map(t => `<span class="detail-tag">#${t}</span>`).join(''); const tags = (e.tags || []).map(t => `<span class="detail-tag">#${t}</span>`).join('');
const affHtml = affs.map(a => `<span class="aff ${AFF_CLASSES[a]}">${AFF_LABELS[a]}</span>`).join(''); const affHtml = affs.map(a => `<span class="aff ${AFF_CLASSES[a]}">${AFF_LABELS[a]}</span>`).join('');
const hasSteps = data.steps && data.steps.length;
const hasDecision = data.chose != null;
const hasFill = /\$\{[^}]+\}/.test(e.body || '');
const hasLink = !!data.url;
let sections = ''; const cs = renderCardSections(e, 'peek-body');
if (hasDecision) { let actions = `<button class="action-btn primary" onclick="event.stopPropagation();nibApp.copyEntity('${e.id}')">copy <kbd>⏎</kbd></button>`;
const rejected = (data.rejected || []).map(r => `<span class="peek-dec-rej">${escHtml(r)}</span>`).join(''); if (cs.hasFill) actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.enterMode('fill')">fill <kbd>f</kbd></button>`;
sections += `<div class="peek-sec"> if (cs.hasSteps) actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.enterMode('run')">run <kbd>r</kbd></button>`;
<div class="peek-sec-lbl">decision<span class="peek-sec-status">${data.status || 'decided'}</span></div> actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.enterMode('edit')">edit <kbd>e</kbd></button>`;
<div class="peek-sec-inner peek-decision"> actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.togglePin('${e.id}')">${e.pinned ? 'unpin' : 'pin'} <kbd>p</kbd></button>`;
<div class="peek-dec-choice">${escHtml(data.chose)}</div> actions += `<button class="action-btn danger" onclick="event.stopPropagation();nibApp.demoteEntity('${e.id}')">demote</button>`;
<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) {
sections += `<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, i) => `<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('');
sections += `<div class="peek-sec">
<div class="peek-sec-lbl">steps · ${data.steps.length}<button class="peek-sec-run" onclick="nibApp.enterMode('run')">▶ run</button></div>
<div class="peek-sec-inner"><div class="peek-steps">${steps}</div></div>
</div>`;
}
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="peek-body md">${renderMd(e.body)}</div>`;
sections += `<div class="peek-sec">
<div class="peek-sec-lbl">content${lang ? `<span class="peek-sec-lang">${lang}</span>` : ''}${hasFill ? `<button class="peek-sec-run" onclick="nibApp.enterMode('fill')">⤓ fill</button>` : ''}</div>
<div class="peek-sec-inner">${bodyHtml}</div>
</div>`;
}
let actions = `<button class="action-btn primary" onclick="nibApp.copyEntity('${e.id}')">copy <kbd>⏎</kbd></button>`;
if (hasFill) actions += `<button class="action-btn" onclick="nibApp.enterMode('fill')">fill <kbd>f</kbd></button>`;
if (hasSteps) actions += `<button class="action-btn" onclick="nibApp.enterMode('run')">run <kbd>r</kbd></button>`;
actions += `<button class="action-btn" onclick="nibApp.enterMode('edit')">edit <kbd>e</kbd></button>`;
actions += `<button class="action-btn" onclick="nibApp.togglePin('${e.id}')">${e.pinned ? 'unpin' : 'pin'} <kbd>p</kbd></button>`;
actions += `<button class="action-btn danger" onclick="nibApp.demoteEntity('${e.id}')">demote</button>`;
return `<div class="peek-scroll"> return `<div class="peek-scroll">
<div class="peek-card"> <div class="peek-card">
@@ -969,7 +940,7 @@
${e.description ? `<div class="peek-desc" style="padding:0 16px 10px">${escHtml(e.description)}</div>` : ''} ${e.description ? `<div class="peek-desc" style="padding:0 16px 10px">${escHtml(e.description)}</div>` : ''}
<div class="peek-meta" style="padding:0 16px 12px">${affHtml}${tags}${e.pinned ? '<span class="peek-pin">★</span>' : ''}</div> <div class="peek-meta" style="padding:0 16px 12px">${affHtml}${tags}${e.pinned ? '<span class="peek-pin">★</span>' : ''}</div>
</div> </div>
${sections} ${cs.sections}
</div> </div>
<div class="peek-acts">${actions}</div> <div class="peek-acts">${actions}</div>
</div>`; </div>`;