Skip to main content

max / balanced_breakfast

wip: frontend parity F2-F7 in-progress — checkpoint before pop-os switch Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-03 17:24 UTC
Commit: 3c5768fceb78e00fef345136de9ed14f5b152f18
Parent: 0178333
15 files changed, +1190 insertions, -181 deletions
M Cargo.lock +4 -2
@@ -1045,7 +1045,7 @@ dependencies = [
1045 1045
1046 1046 [[package]]
1047 1047 name = "docengine"
1048 - version = "0.3.4"
1048 + version = "0.3.5"
1049 1049 dependencies = [
1050 1050 "ammonia",
1051 1051 "pulldown-cmark",
@@ -3635,6 +3635,8 @@ dependencies = [
3635 3635 [[package]]
3636 3636 name = "pter"
3637 3637 version = "0.1.0"
3638 + source = "registry+https://github.com/rust-lang/crates.io-index"
3639 + checksum = "ecc3629f628e0e8d975cd206ef331ce598dd457d94cd609c96a6809934732859"
3638 3640 dependencies = [
3639 3641 "scraper",
3640 3642 ]
@@ -5100,7 +5102,7 @@ dependencies = [
5100 5102
5101 5103 [[package]]
5102 5104 name = "synckit-client"
5103 - version = "0.3.1"
5105 + version = "0.4.0"
5104 5106 dependencies = [
5105 5107 "argon2",
5106 5108 "base64 0.22.1",
@@ -40,7 +40,7 @@ ureq = { version = "2.12.1", features = ["json"] }
40 40 roxmltree.workspace = true
41 41
42 42 # HTML to markdown conversion
43 - pter = { path = "../../../../pter" }
43 + pter = "0.1"
44 44
45 45 # URL parsing for tracker stripping
46 46 url = "2"
@@ -0,0 +1,101 @@
1 + #!/bin/bash
2 + # Frontend design-system lint guards.
3 + # See _private/docs/balanced_breakfast/design-system.md "Inline-style rules"
4 + # and "Cross-cutting rules" for the source of truth.
5 + # Exit 0 = clean. Exit non-zero = violations found (printed with file:line).
6 +
7 + set -u
8 + ROOT="$(cd "$(dirname "$0")/.." && pwd)"
9 + FRONTEND="$ROOT/src-tauri/frontend"
10 + SRC_JS="$FRONTEND/js"
11 + SRC_HTML="$FRONTEND/index.html"
12 + SRC_CSS="$FRONTEND/css/styles.css"
13 +
14 + violations=0
15 +
16 + report() {
17 + local rule="$1"; shift
18 + local msg="$1"; shift
19 + if [ -n "$*" ]; then
20 + echo
21 + echo "[$rule] $msg"
22 + echo "$*"
23 + violations=$((violations + 1))
24 + fi
25 + }
26 +
27 + # 1. no-raw-hex
28 + # No raw hex literals in JS or source HTML. HTML entities (&#NNNN;) and the
29 + # themes.js theme engine are exempt — themes.js is the only place that
30 + # builds CSS values via string concatenation, by charter.
31 + hits=$(grep -rnE '#[0-9a-fA-F]{3,8}\b' "$SRC_JS" "$SRC_HTML" 2>/dev/null \
32 + | grep -vE '&#[0-9]+;' \
33 + | grep -v 'js/themes.js' \
34 + | grep -v 'js/tests/' \
35 + || true)
36 + report "no-raw-hex" "Raw hex literal in JS/HTML — use a CSS class or themed token." "$hits"
37 +
38 + # 2. no-csstext
39 + # style.cssText injection is forbidden — it usually means color/border/font
40 + # values are being set from JS and the result is unthemeable.
41 + hits=$(grep -rn 'cssText' "$SRC_JS" 2>/dev/null | grep -v 'js/tests/' || true)
42 + report "no-csstext" "style.cssText injection — move styles into a CSS class." "$hits"
43 +
44 + # 3. no-var-fallback-hex
45 + # No var(--token, #fallback). Fallback hex bypasses the theme contract.
46 + hits=$(grep -rnE 'var\(--[a-z-]+,\s*#' "$FRONTEND" \
47 + --include='*.js' --include='*.html' --include='styles.css' 2>/dev/null || true)
48 + report "no-var-fallback-hex" "var(--token, #fallback) — drop the fallback; it bypasses themes." "$hits"
49 +
50 + # 4. no-styled-attrs
51 + # No inline style="..." that touches color / background / border / shadow /
52 + # font / padding values. Layout-only (display / gap / flex / margin) is
53 + # tolerated; the goal is zero on the color and typography axes.
54 + hits=$(grep -rnE 'style="[^"]*(color|background|border|shadow|font-size|font-family|padding)' \
55 + "$SRC_JS" "$SRC_HTML" 2>/dev/null || true)
56 + report "no-styled-attrs" "Inline style= with color/background/border/shadow/font/padding — use a class." "$hits"
57 +
58 + # 5. no-style-color-from-js
59 + # No .style.<color-axis-property> assignments. Dynamic positioning
60 + # (.style.left / .top / .width / .transform) and display toggles are fine;
61 + # color, background, border, font, padding values must come from CSS.
62 + hits=$(grep -rnE '\.style\.(color|background|backgroundColor|borderColor|font|fontFamily|fontSize|paddingTop|paddingBottom|paddingLeft|paddingRight|margin|marginTop|marginBottom|marginLeft|marginRight|gap|opacity)\b' \
63 + "$SRC_JS" 2>/dev/null | grep -v 'js/tests/' || true)
64 + report "no-style-color-from-js" "JS-set color/background/border/font/margin/padding/gap/opacity — use a class." "$hits"
65 +
66 + # 6. no-native-dialogs
67 + # window.confirm / window.prompt / window.alert are banned — they're
68 + # unstyled on every platform and disabled in iOS WKWebView. Use the
69 + # BB.ui.show{Confirm,Prompt}Dialog / BB.ui.showToast helpers instead.
70 + hits=$(grep -rnE '\b(window\.)?(confirm|prompt|alert)\s*\(' "$SRC_JS" 2>/dev/null \
71 + | grep -vE 'showConfirmDialog|showPromptDialog|confirmAction|confirmDelete|confirmBtn|\.confirm-message|/\*|\*\s|// ' \
72 + | grep -v 'js/tests/' || true)
73 + report "no-native-dialogs" "window.confirm/prompt/alert are banned — use BB.ui.show{Confirm,Prompt}Dialog or showToast." "$hits"
74 +
75 + # 7. theme-token-coverage
76 + # Every :root token in styles.css must either be mapped by themes.js
77 + # COLOR_MAP, derived in applyTheme, or carry an /* invariant */ or
78 + # /* composition */ annotation on the same line. Catches dangling
79 + # tokens that silently fall back to the hardcoded :root value.
80 + root_tokens=$(awk '/^:root \{/{flag=1; next} /^\}/{flag=0} flag' "$SRC_CSS" \
81 + | grep -oE -- '--[a-z-]+' | sort -u || true)
82 + unmapped=""
83 + for tok in $root_tokens; do
84 + # Allow tokens marked invariant or composition on the declaration line.
85 + if grep -E -- "$tok:[^;]*;.*(invariant|composition)" "$SRC_CSS" >/dev/null 2>&1; then continue; fi
86 + # Mapped directly or referenced as a property in themes.js?
87 + if grep -F -- "'$tok'" "$SRC_JS/themes.js" >/dev/null 2>&1; then continue; fi
88 + unmapped="$unmapped$tok"$'\n'
89 + done
90 + if [ -n "$unmapped" ]; then
91 + report "theme-token-coverage" "Token in :root is neither themed nor marked invariant/composition." "$unmapped"
92 + fi
93 +
94 + if [ $violations -eq 0 ]; then
95 + echo "frontend lint: clean"
96 + exit 0
97 + else
98 + echo
99 + echo "frontend lint: $violations rule(s) failed"
100 + exit 1
101 + fi
@@ -1,39 +1,157 @@
1 - /* CSS Variables - set dynamically by themes.js from shared TOML files */
2 - /* Fallback values for initial render before JS loads (flatwhite palette) */
1 + /* ============================================================================
2 + Balanced Breakfast — Neobrute-lite Theme
3 +
4 + Build new UI by COMPOSING the existing vocabulary, in this order of preference:
5 +
6 + 1. Use a UTILITY class for one-off adjustments (.hidden, .sr-only,
7 + .mb-1, .flex-1 — most
8 + still to be added)
9 + 2. Use a LAYOUT PRIMITIVE to position content (.row-flex, .row,
10 + .stack-{2,3,4} —
11 + to be added in F1/F2)
12 + 3. Use a COMPONENT PRIMITIVE for a UI element (.btn, .card, .modal,
13 + .form-input,
14 + .form-select, .badge,
15 + .tag, .toast)
16 + 4. Extend a primitive with a modifier (.btn-primary, .btn-sm,
17 + .form-select--ghost)
18 + 5. ONLY THEN consider a new class — and add it to the right
19 + BAND below
20 +
21 + A new class is a smell, not a goal. Before writing one:
22 + • grep this file for the visual shape you want; almost everything is here
23 + • check whether an existing primitive + modifier composes to your shape
24 + • page-scoped rules under a feature class (`.bookmarks .foo`) are a last
25 + resort, not a first move
26 +
27 + NAMED-LAYOUT EXCEPTION: A row of mixed-intent elements (one fills, others
28 + size to content) belongs in a NAMED LAYOUT with an explicit
29 + `grid-template-columns`, not in `.row-flex` + utilities. Smell test: if you
30 + find yourself reaching for `--compact`, `style="width: auto;"`, or
31 + `min-width: 0 !important` to make a row lay out right, the layout itself
32 + wants a name. See `.condition-row` for the canonical form.
33 +
34 + See `_private/docs/balanced_breakfast/design-system.md` for the primitive
35 + inventory and rules; `styleguide.md` for visual specs.
36 +
37 + ----------------------------------------------------------------------------
38 + FILE STRUCTURE — search by BAND name (uppercase anchors) to navigate.
39 + Per-section titles use `=== N. Title ===` headers; numbered for stability,
40 + not because order matters. Bands group related sections; cascade order is
41 + load-bearing only inside the RESPONSIVE LAYERS and TOUCH OVERRIDES bands.
42 +
43 + Note (2026-06-02): the BAND map below is the TARGET layout. Today's file
44 + still has rules grouped by their original feature-add order. Phase F5 (dedup
45 + sweep) moves rules into the correct BAND. Until then, the numbered section
46 + markers below are stable anchors regardless of file position.
47 +
48 + BAND: FOUNDATIONS
49 + 1. Design System Variables — tokens (:root)
50 + 2. CSS Reset & Base
51 + 3. Keyframes & Animation
52 +
53 + BAND: APP SHELL & CHROME
54 + 4. Layout shell — #app, .main
55 + 5. Header
56 + 6. Sidebar
57 + 7. Items panel
58 + 8. Detail panel
59 +
60 + BAND: COMPONENT PRIMITIVES
61 + 9. Buttons — .btn family
62 + 10. Forms — .form-group, .form-input
63 + 11. Modal — .modal-overlay/.modal-content
64 + 12. Toast — .toast
65 + 13. Tag — .tag
66 + 14. Context menu — .context-menu
67 + 15. Health indicator — .health-dot (state-by-color, F3 fix pending)
68 + 16. Skeleton & loading — .skeleton-item, .skeleton-line, spinner
69 + 17. Progress bar — .progress-bar-container
70 + 18. Update banner — .update-banner
71 + 19. Health popover — .health-popover
72 +
73 + BAND: FEATURE SURFACES
74 + 20. Sources list / sidebar entries
75 + 21. Items list / read indicator / star button
76 + 22. Reader-expanded mode
77 + 23. Query feeds (builder + sidebar)
78 + 24. Sidebar saved articles
79 + 25. Plugin list
80 + 26. Help shortcuts
81 + 27. Welcome modal
82 + 28. Settings modal
83 + 29. Sync settings (utility classes)
84 + 30. Reading list / Bookmarks
85 +
86 + BAND: UTILITIES
87 + 31. Screen-reader only (.sr-only)
88 + 32. Focus indicators
89 +
90 + BAND: MOBILE PRIMITIVES
91 + 33. Mobile tab bar
92 + 34. Mobile more-popover
93 + 35. Mobile search bar
94 + 36. Mobile back button
95 + 37. Pull-to-refresh indicator
96 + 38. Source-badge on items (mobile-only display)
97 +
98 + BAND: RESPONSIVE LAYERS — cascade-ordered
99 + 39. @media (max-width: 768px) — mobile layout
100 + 40. @media (hover: none) — touch overrides
101 + 41. @media (prefers-reduced-motion: reduce)
102 +
103 + BAND: SCROLLBAR
104 + 42. Webkit scrollbar
105 + ============================================================================ */
106 +
107 + /* === 1. Design System Variables =========================================
108 + Color tokens are themed via `js/themes.js` (loads shared TOML palettes
109 + from MNW/shared/themes/). The fallback hex values below are the
110 + `flatwhite` light theme palette, used only for initial render before
111 + `themes.js` runs. Annotation legend:
112 +
113 + themed — set by themes.js COLOR_MAP from TOML
114 + derived — computed in themes.js applyTheme() from a themed token
115 + invariant — fixed; never changes with theme
116 + unused — declared but no CSS rule consumes it; see design-system.md
117 + token audit. Phase F1 cleanup: consume or remove.
118 + ========================================================================== */
3 119 :root {
4 - --bg-primary: #faf8f5;
5 - --bg-secondary: #f5f0e8;
6 - --bg-tertiary: #ebe4d8;
7 - --bg-surface: #f0ebe0;
8 - --text-primary: #3d3225;
9 - --text-secondary: #6b5d4d;
10 - --text-muted: #9a8b78;
11 - --accent-red: #c94b4b;
12 - --accent-red-hover: #d65d5d;
13 - --accent-green: #6b9b5a;
14 - --accent-blue: #4a8ebd;
15 - --accent-blue-hover: #5e9dca;
16 - --accent-yellow: #e8a841;
17 - --accent-yellow-light: #f4c56d;
18 - --accent-purple: #8b6bbf;
19 - --accent-cyan: #5ab5b5;
20 - --border: #e0d6c8;
21 - --border-dark: #d4c8b5;
22 - --shadow: rgba(61, 50, 37, 0.08);
23 - --shadow-hover: rgba(61, 50, 37, 0.12);
24 - --text-on-accent: #ffffff;
25 - --border-width: 2px;
26 - --radius-sm: 5px;
27 - --radius-md: 5px;
28 - --radius-lg: 10px;
29 - --radius-xl: 20px;
30 - --shadow-offset: 3px;
31 - --shadow-color: #6b5d4d;
32 - --shadow-brutal: var(--shadow-offset) var(--shadow-offset) 0 var(--shadow-color);
33 - }
34 -
120 + --bg-primary: #faf8f5; /* themed: background.primary */
121 + --bg-secondary: #f5f0e8; /* themed: background.secondary */
122 + --bg-tertiary: #ebe4d8; /* themed: background.tertiary */
123 + --text-primary: #3d3225; /* themed: foreground.primary */
124 + --text-secondary: #6b5d4d; /* themed: foreground.secondary */
125 + --text-muted: #9a8b78; /* themed: foreground.muted */
126 + --accent-red: #c94b4b; /* themed: accent.red */
127 + --accent-red-hover: #d65d5d; /* derived in themes.js — consumed by .btn-danger:hover (F2) */
128 + --accent-green: #6b9b5a; /* themed: accent.green */
129 + --accent-blue: #4a8ebd; /* themed: accent.blue */
130 + --accent-blue-hover: #5e9dca; /* derived in themes.js */
131 + --accent-yellow: #e8a841; /* themed: accent.yellow */
132 + --accent-yellow-light: #f4c56d; /* derived in themes.js */
133 + --accent-purple: #8b6bbf; /* themed: accent.purple — consumed by .tag/.badge[data-color="purple"] (F2) */
134 + --accent-cyan: #5ab5b5; /* themed: accent.cyan — consumed by .tag/.badge[data-color="cyan"] (F2) */
135 + --border: #e0d6c8; /* themed: border.default */
136 + --border-dark: #d4c8b5; /* derived in themes.js */
137 + --text-on-accent: #ffffff; /* derived in themes.js (contrast vs accent.blue) */
138 + --border-width: 2px; /* invariant */
139 + --radius-sm: 5px; /* invariant */
140 + --radius-lg: 10px; /* invariant */
141 + --radius-xl: 20px; /* invariant */
142 + --shadow-offset: 3px; /* invariant — BB neobrute signature */
143 + --shadow-color: #6b5d4d; /* derived in themes.js (darken --text-muted) */
144 + --shadow-brutal: var(--shadow-offset) var(--shadow-offset) 0 var(--shadow-color); /* invariant composition */
145 + }
146 + /* F5 cleanup (2026-06-02): dropped --bg-surface (themed but never consumed),
147 + --radius-md (duplicate of --radius-sm), --shadow / --shadow-hover (derived
148 + but never consumed). themes.js still sets --bg-surface / --shadow / --shadow-hover
149 + in applyTheme — these will be removed in the F5 themes.js cleanup. */
150 +
151 + /* === 2. CSS Reset & Base ================================================ */
35 152 * { margin: 0; padding: 0; box-sizing: border-box; }
36 153
154 + /* === 4. Layout shell — body & #app ==================================== */
37 155 body {
38 156 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
39 157 background-color: var(--bg-primary);
@@ -48,7 +166,7 @@ body {
48 166 height: 100vh;
49 167 }
50 168
51 - /* Header */
169 + /* === 5. Header ========================================================== */
52 170 .header {
53 171 display: flex;
54 172 justify-content: space-between;
@@ -120,7 +238,11 @@ body {
120 238 }
121 239 .sort-select:focus { outline: none; border-color: var(--accent-yellow); }
122 240
123 - /* Buttons */
241 + /* === 9. Buttons ========================================================
242 + Canonical: .btn (+ .btn-primary, .btn-success, .btn-small).
243 + Charter target: collapse bespoke *-btn classes (.tag-filter-btn,
244 + .toast-action, .hp-action, .mobile-tab, etc.) onto this family in F5.
245 + ========================================================================== */
124 246 .btn {
125 247 padding: 0.4rem 0.75rem;
126 248 border: var(--border-width) solid var(--border);
@@ -151,14 +273,246 @@ body {
151 273 .btn-small { padding: 0.2rem 0.5rem; font-size: 0.8rem; }
152 274 .btn-small.active { background: var(--accent-yellow); color: var(--text-on-accent); border-color: var(--accent-yellow); }
153 275
154 - /* Main layout */
276 + /* F2 additions (2026-06-02) — see design-system.md §Button.
277 + .btn-sm is the parity name; .btn-small kept as alias until F5 dedup. */
278 + .btn-sm { padding: 0.2rem 0.5rem; font-size: 0.8rem; }
279 + .btn-sm.active { background: var(--accent-yellow); color: var(--text-on-accent); border-color: var(--accent-yellow); }
280 +
281 + /* .btn-secondary — explicit neutral; today's default `.btn` already plays this
282 + role, but having a named modifier lets future surfaces declare intent. */
283 + .btn-secondary {
284 + background-color: var(--bg-secondary);
285 + border-color: var(--border);
286 + color: var(--text-primary);
287 + }
288 + .btn-secondary:hover { background-color: var(--bg-tertiary); border-color: var(--border-dark); }
289 +
290 + /* .btn-danger — destructive action. Consumes the previously-unused
291 + --accent-red-hover token. Replaces .hp-action-danger in F5. */
292 + .btn-danger {
293 + background-color: var(--accent-red);
294 + border-color: var(--accent-red);
295 + color: var(--text-on-accent);
296 + }
297 + .btn-danger:hover { background-color: var(--accent-red-hover); border-color: var(--accent-red-hover); }
298 +
299 + /* .btn-icon — square, no padding label space; used for close buttons,
300 + icon-only toolbar buttons (gear, star, more). */
301 + .btn-icon {
302 + padding: 0.25rem;
303 + line-height: 1;
304 + min-width: 1.75rem;
305 + min-height: 1.75rem;
306 + display: inline-flex;
307 + align-items: center;
308 + justify-content: center;
309 + }
310 +
311 + /* .btn-text — borderless, no background. Used for inline actions inside
312 + text or as a low-emphasis alternative to .btn-secondary. */
313 + .btn-text {
314 + background: none;
315 + border: none;
316 + padding: 0.25rem 0.5rem;
317 + color: var(--text-primary);
318 + }
319 + .btn-text:hover { background-color: var(--bg-secondary); border-color: transparent; }
320 +
321 + /* .btn-link — looks like an anchor, behaves like a button. */
322 + .btn-link {
323 + background: none;
324 + border: none;
325 + padding: 0;
326 + color: var(--accent-blue);
327 + font-weight: 600;
328 + text-decoration: underline;
329 + text-underline-offset: 2px;
330 + }
331 + .btn-link:hover { color: var(--accent-blue-hover); background: none; border-color: transparent; }
332 +
333 + /* .btn-loading — spinner-replacement state. Pair with disabled attr. */
334 + .btn-loading {
335 + color: transparent !important;
336 + pointer-events: none;
337 + position: relative;
338 + }
339 + .btn-loading::after {
340 + content: '';
341 + position: absolute;
342 + top: 50%; left: 50%;
343 + width: 0.875rem; height: 0.875rem;
344 + margin: -0.4375rem 0 0 -0.4375rem;
345 + border: 2px solid currentColor;
346 + border-top-color: transparent;
347 + border-radius: 50%;
348 + animation: spin 0.6s linear infinite;
349 + color: var(--text-primary);
350 + opacity: 0.6;
351 + }
352 + .btn-primary.btn-loading::after,
353 + .btn-success.btn-loading::after,
354 + .btn-danger.btn-loading::after { color: var(--text-on-accent); opacity: 1; }
355 +
356 + /* === N. Card (F2 addition, 2026-06-02) ================================
357 + Canonical: .card. Charter target — F5 collapses .source-item,
358 + .plugin-item, .sidebar-saved, .bookmark-item onto .card with the
359 + modifier variants below.
360 + .card — default: white-ish surface, 2px border, 3px brutal shadow
361 + .card--row — row-shaped, no shadow, used in sidebars and lists
362 + .card--shell — frame-only, no fill (for grouping)
363 + .card--muted — secondary-surface variant
364 + .card--clickable — adds hover lift
365 + Container: .cards-grid for grid layouts.
366 + ========================================================================== */
367 + .card {
368 + background-color: var(--bg-primary);
369 + border: var(--border-width) solid var(--border);
370 + border-radius: var(--radius-sm);
371 + padding: 0.75rem;
372 + box-shadow: var(--shadow-brutal);
373 + }
374 + .card--row {
375 + border-radius: 0;
376 + border-left: 0;
377 + border-right: 0;
378 + border-top: 0;
379 + box-shadow: none;
380 + padding: 0.6rem 1rem;
381 + display: flex;
382 + align-items: center;
383 + gap: 0.75rem;
384 + }
385 + .card--shell {
386 + background: none;
387 + box-shadow: none;
388 + }
389 + .card--muted {
390 + background-color: var(--bg-secondary);
391 + }
392 + .card--clickable {
393 + cursor: pointer;
394 + transition: transform 0.15s, box-shadow 0.15s, border-color 0.15s;
395 + }
396 + .card--clickable:hover {
397 + border-color: var(--accent-yellow);
398 + transform: translate(-1px, -1px);
399 + box-shadow: calc(var(--shadow-offset) + 1px) calc(var(--shadow-offset) + 1px) 0 var(--shadow-color);
400 + }
401 +
402 + .card-header {
403 + display: flex;
404 + justify-content: space-between;
405 + align-items: center;
406 + margin-bottom: 0.5rem;
407 + }
408 + .card-title {
409 + font-size: 0.95rem;
410 + font-weight: 700;
411 + color: var(--text-primary);
412 + margin: 0;
413 + }
414 + .card-description {
415 + font-size: 0.85rem;
416 + color: var(--text-secondary);
417 + line-height: 1.4;
418 + }
419 + .card-meta {
420 + display: flex;
421 + flex-wrap: wrap;
422 + gap: 0.4rem;
423 + margin-top: 0.5rem;
424 + font-size: 0.75rem;
425 + color: var(--text-muted);
426 + }
427 + .card-badge {
428 + flex-shrink: 0;
429 + }
430 +
431 + .cards-grid {
432 + display: grid;
433 + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
434 + gap: 0.75rem;
435 + }
436 +
437 + /* === N. Row primitive (F2-sub addition, 2026-06-02) ===================
438 + Canonical slot layout: [icon] [primary · badges] [meta] [actions].
439 + Used via BB.ui.renderRow(model). Per-surface classes (.plugin-item,
440 + .source-item, .item, .bookmark-item, etc.) sit alongside .row on the
441 + outer element and add their own padding / border / hover treatment.
442 + F5 retires per-surface name/desc classes (.plugin-name, .plugin-desc)
443 + that duplicate .row-primary / .row-secondary.
444 + ========================================================================== */
445 + .row {
446 + display: flex;
447 + align-items: flex-start;
448 + gap: 0.75rem;
449 + min-width: 0;
450 + }
451 + .row-icon {
452 + flex-shrink: 0;
453 + display: flex;
454 + align-items: center;
455 + justify-content: center;
456 + }
457 + .row-content {
458 + flex: 1;
459 + min-width: 0;
460 + }
461 + .row-primary {
462 + font-weight: 600;
463 + color: var(--text-primary);
464 + font-size: 0.9rem;
465 + line-height: 1.3;
466 + display: flex;
467 + align-items: center;
468 + gap: 0.4rem;
469 + flex-wrap: wrap;
470 + }
471 + .row-secondary {
472 + font-size: 0.8rem;
473 + color: var(--text-secondary);
474 + line-height: 1.4;
475 + margin-top: 0.15rem;
476 + }
477 + .row-meta {
478 + flex-shrink: 0;
479 + font-size: 0.75rem;
480 + color: var(--text-muted);
481 + align-self: center;
482 + }
483 + .row-actions {
484 + flex-shrink: 0;
485 + display: flex;
486 + align-items: center;
487 + gap: 0.25rem;
488 + }
489 + /* Hover-reveal pattern: actions appear when the row (or any descendant)
490 + is hovered or focused. Per-surface override with `.row-actions.always`
491 + keeps them visible. */
492 + .row .row-actions:not(.always) {
493 + opacity: 0;
494 + transition: opacity 0.15s;
495 + }
496 + .row:hover .row-actions:not(.always),
497 + .row:focus-within .row-actions:not(.always) {
498 + opacity: 1;
499 + }
500 +
501 + /* F2-sub addition: form-hint error variant (used by renderFormField when
502 + passed a `field.error`). */
503 + .form-hint--error {
504 + color: var(--accent-red);
505 + font-weight: 600;
506 + }
507 +
508 + /* === 4. Layout shell — main =========================================== */
155 509 .main {
156 510 display: flex;
157 511 flex: 1;
158 512 overflow: hidden;
159 513 }
160 514
161 - /* Sidebar */
515 + /* === 6. Sidebar ======================================================== */
162 516 .sidebar {
163 517 width: 220px;
164 518 min-width: 220px;
@@ -180,6 +534,9 @@ body {
180 534 border-bottom: var(--border-width) solid var(--border);
181 535 }
182 536
537 + /* === 20. Sources list — sidebar entries ================================
538 + Charter target (F5): .source-item collapses onto .card --row.
539 + ========================================================================== */
183 540 .sources-list { list-style: none; padding: 0.25rem 0; }
184 541
185 542 .source-item {
@@ -241,7 +598,7 @@ body {
241 598 .source-delete:hover { color: var(--accent-red); }
242 599 .source-item:hover .source-delete { display: inline; }
243 600
244 - /* Items panel */
601 + /* === 7. Items panel ==================================================== */
245 602 .items-panel {
246 603 flex: 1;
247 604 display: flex;
@@ -289,6 +646,41 @@ body {
289 646 opacity: 0.6;
290 647 }
291 648
649 + /* F2 addition (2026-06-02) — .empty-state as its own block. Today's
650 + .item.empty-state (modifier-on-row) stays as legacy until F5 retires
651 + call sites. Use this block + BB.ui.renderEmptyState() for new surfaces.
652 + <div class="empty-state">
653 + <div class="empty-state-icon">...</div>
654 + <p class="empty-state-text">No items</p>
655 + <button class="btn btn-primary">Add one</button>
656 + </div>
657 + */
658 + .empty-state {
659 + display: flex;
660 + flex-direction: column;
661 + align-items: center;
662 + justify-content: center;
663 + text-align: center;
664 + padding: 3rem 2rem;
665 + color: var(--text-muted);
666 + line-height: 1.6;
667 + gap: 0.75rem;
668 + }
669 + .empty-state-icon {
670 + font-size: 2.5rem;
Lines truncated
@@ -117,6 +117,7 @@
117 117 <script src="js/utils.js"></script>
118 118 <script src="js/state.js"></script>
119 119 <script src="js/api.js"></script>
120 + <script src="js/query-state.js"></script>
120 121 <script src="js/components.js"></script>
121 122 <script src="js/query-feeds.js"></script>
122 123 <script src="js/sources.js"></script>
@@ -148,6 +148,9 @@
148 148 * @param {KeyboardEvent} e - The keydown event.
149 149 */
150 150 function handleKeyboard(e) {
151 + // F3 justified touch branch: keyboard shortcuts are skipped on
152 + // touch devices (no physical keyboard, and the on-screen keyboard
153 + // would inadvertently trigger j/k/s/r on every letter typed).
151 154 if (BB.state.isTouchDevice) return;
152 155
153 156 // Don't handle when typing in inputs
@@ -362,10 +365,9 @@
362 365 themeGroup.appendChild(themeLabel);
363 366 themeGroup.appendChild(themeContainer);
364 367
368 + // F4 (2026-06-02): inline layout retired in favor of .theme-actions class.
365 369 const themeActions = document.createElement('div');
366 - themeActions.style.display = 'flex';
367 - themeActions.style.gap = '0.5rem';
368 - themeActions.style.marginTop = '0.5rem';
370 + themeActions.className = 'theme-actions';
369 371
370 372 const themeImportBtn = document.createElement('button');
371 373 themeImportBtn.className = 'btn btn-small';
@@ -388,9 +390,9 @@
388 390 const dataLabel = document.createElement('label');
389 391 dataLabel.textContent = 'Data';
390 392 dataGroup.appendChild(dataLabel);
393 + // F4 (2026-06-02): inline layout retired in favor of .form-row primitive.
391 394 const dataActions = document.createElement('div');
392 - dataActions.style.display = 'flex';
393 - dataActions.style.gap = '0.5rem';
395 + dataActions.className = 'form-row';
394 396
395 397 const importBtn = document.createElement('button');
396 398 importBtn.className = 'btn btn-small';
@@ -163,49 +163,18 @@
163 163
164 164 /**
165 165 * Show a context menu with actions for a bookmark.
166 + * F5 (2026-06-02): delegated to BB.ui.showContextMenu — the canonical
167 + * helper was factored from this file's original pattern.
166 168 * @param {Event} event - Click event for positioning.
167 169 * @param {string} id - Bookmark ID.
168 170 */
169 171 function showContextMenu(event, id) {
170 - event.stopPropagation();
171 -
172 - const old = document.getElementById('bookmark-context-menu');
173 - if (old) old.remove();
174 -
175 - const menu = document.createElement('div');
176 - menu.id = 'bookmark-context-menu';
177 - menu.className = 'context-menu';
178 - menu.style.left = event.clientX + 'px';
179 - menu.style.top = event.clientY + 'px';
180 -
181 - const items = [
182 - { label: 'Edit', fn: () => openEditModal(id) },
183 - { label: 'Set Tags', fn: () => openTagsModal(id) },
172 + BB.ui.showContextMenu(event, [
173 + { label: 'Edit', fn: () => openEditModal(id) },
174 + { label: 'Set Tags', fn: () => openTagsModal(id) },
184 175 { label: 'Export as HTML', fn: () => exportHtml(id) },
185 - { label: 'Delete', fn: () => deleteBookmark(id), className: 'danger' },
186 - ];
187 -
188 - for (const item of items) {
189 - const btn = document.createElement('button');
190 - btn.className = 'context-menu-item' + (item.className ? ' ' + item.className : '');
191 - btn.textContent = item.label;
192 - btn.onclick = () => { menu.remove(); item.fn(); };
193 - menu.appendChild(btn);
194 - }
195 -
196 - document.body.appendChild(menu);
197 -
198 - // Clamp to viewport
199 - const rect = menu.getBoundingClientRect();
200 - if (rect.right > window.innerWidth) menu.style.left = (window.innerWidth - rect.width - 4) + 'px';
201 - if (rect.bottom > window.innerHeight) menu.style.top = (window.innerHeight - rect.height - 4) + 'px';
202 -
203 - requestAnimationFrame(() => {
204 - document.addEventListener('click', function dismiss() {
205 - menu.remove();
206 - document.removeEventListener('click', dismiss);
207 - }, { once: true });
208 - });
176 + { label: 'Delete', fn: () => deleteBookmark(id), danger: true },
177 + ]);
209 178 }
210 179
211 180 /**
@@ -82,53 +82,7 @@
82 82 form.className = 'modal-form';
83 83
84 84 (opts.fields || []).forEach(field => {
85 - const group = document.createElement('div');
86 - group.className = 'form-group';
87 -
88 - const label = document.createElement('label');
89 - label.textContent = field.label + (field.required ? ' *' : '');
90 - label.setAttribute('for', field.name);
91 - group.appendChild(label);
92 -
93 - // Branch on field type to create the appropriate input element.
94 - // 'secret' maps to password input; unrecognized types fall through to text.
95 - let input;
96 - if (field.type === 'select') {
97 - input = document.createElement('select');
98 - input.className = 'form-input';
99 - (field.options || []).forEach(opt => {
100 - const option = document.createElement('option');
101 - option.value = typeof opt === 'object' ? opt.value : opt;
102 - option.textContent = typeof opt === 'object' ? opt.label : opt;
103 - if (field.value && option.value === field.value) option.selected = true;
104 - input.appendChild(option);
105 - });
106 - } else if (field.type === 'textarea') {
107 - input = document.createElement('textarea');
108 - input.className = 'form-input';
109 - input.value = field.value || '';
110 - input.rows = 4;
111 - } else {
112 - input = document.createElement('input');
113 - input.className = 'form-input';
114 - input.type = field.type === 'secret' ? 'password' : (field.type || 'text');
115 - input.value = field.value || '';
116 - }
117 -
118 - input.name = field.name;
119 - input.id = field.name;
120 - if (field.required) input.required = true;
121 - if (field.placeholder) input.placeholder = field.placeholder;
122 - group.appendChild(input);
123 -
124 - if (field.description) {
125 - const hint = document.createElement('div');
126 - hint.className = 'form-hint';
127 - hint.textContent = field.description;
128 - group.appendChild(hint);
129 - }
130 -
131 - form.appendChild(group);
85 + form.appendChild(renderFormField(field));
132 86 });
133 87
134 88 const actions = document.createElement('div');
@@ -213,9 +167,7 @@
213 167 body.innerHTML = '';
214 168
215 169 const msg = document.createElement('p');
216 - msg.style.marginBottom = '1rem';
217 - msg.style.fontSize = '0.9rem';
218 - msg.style.color = 'var(--text-primary)';
170 + msg.className = 'confirm-message';
219 171 msg.textContent = message;
220 172 body.appendChild(msg);
221 173
@@ -242,5 +194,452 @@
242 194 });
243 195 }
244 196
245 - BB.ui = { showToast, showProgress, openModal, closeModal, openFormModal, showErrorWithRetry, confirmAction };
197 + /**
198 + * Show a confirmation dialog with a title. Parity-named alias for
199 + * `confirmAction` that accepts a separate title.
200 + * @param {string} title - Modal heading.
201 + * @param {string} message - The question to display.
202 + * @param {Object} [opts] - Same as confirmAction.
203 + * @returns {Promise<boolean>}
204 + */
205 + function showConfirmDialog(title, message, opts) {
206 + opts = opts || {};
207 + const confirmLabel = opts.confirmLabel || 'Delete';
208 + const danger = opts.danger !== false;
209 +
210 + return new Promise((resolve) => {
211 + const overlay = document.getElementById('modal-overlay');
212 + const titleEl = document.getElementById('modal-title');
213 + const body = document.getElementById('modal-body');
214 +
215 + titleEl.textContent = title;
216 + body.innerHTML = '';
217 +
218 + const msg = document.createElement('p');
219 + msg.className = 'confirm-message';
220 + msg.textContent = message;
221 + body.appendChild(msg);
222 +
223 + const actions = document.createElement('div');
224 + actions.className = 'form-actions';
225 +
226 + const cancelBtn = document.createElement('button');
227 + cancelBtn.type = 'button';
228 + cancelBtn.className = 'btn';
229 + cancelBtn.textContent = 'Cancel';
230 + cancelBtn.onclick = () => { overlay.style.display = 'none'; resolve(false); };
231 + actions.appendChild(cancelBtn);
232 +
233 + const confirmBtn = document.createElement('button');
234 + confirmBtn.type = 'button';
235 + confirmBtn.className = danger ? 'btn btn-danger' : 'btn btn-primary';
236 + confirmBtn.textContent = confirmLabel;
237 + confirmBtn.onclick = () => { overlay.style.display = 'none'; resolve(true); };
238 + actions.appendChild(confirmBtn);
239 +
240 + body.appendChild(actions);
241 + overlay.style.display = 'flex';
242 + cancelBtn.focus();
243 + });
244 + }
245 +
246 + /**
247 + * Render the canonical empty-state block into a container.
248 + * @param {HTMLElement} container - Target element (cleared before render).
249 + * @param {string} message - Empty-state message.
250 + * @param {Object} [opts] - Optional config.
251 + * @param {string} [opts.icon] - Icon text/emoji shown above the message.
252 + * @param {string} [opts.buttonLabel] - If set with onClick, renders a primary CTA.
253 + * @param {function} [opts.onClick] - Click handler for the CTA button.
254 + * @param {boolean} [opts.compact=false] - Use the compact size variant.
255 + */
256 + function renderEmptyState(container, message, opts) {
257 + opts = opts || {};
258 + container.innerHTML = '';
259 + const el = document.createElement('div');
260 + el.className = 'empty-state' + (opts.compact ? ' empty-state--compact' : '');
261 +
262 + if (opts.icon) {
263 + const icon = document.createElement('div');
264 + icon.className = 'empty-state-icon';
265 + icon.textContent = opts.icon;
266 + el.appendChild(icon);
267 + }
268 +
269 + const text = document.createElement('p');
270 + text.className = 'empty-state-text';
271 + text.textContent = message;
272 + el.appendChild(text);
273 +
274 + if (opts.buttonLabel && opts.onClick) {
275 + const btn = document.createElement('button');
276 + btn.className = 'btn btn-primary';
277 + btn.textContent = opts.buttonLabel;
278 + btn.onclick = opts.onClick;
279 + el.appendChild(btn);
280 + }
281 +
282 + container.appendChild(el);
283 + }
284 +
285 + /**
286 + * Show a context menu at the click position with a list of items.
287 + * Auto-dismisses on the next document click. Clamps to viewport.
288 + * @param {Event} event - Click event (used for position; stopPropagation is called).
289 + * @param {Array<{label: string, fn: function, danger?: boolean}>} items - Menu entries.
290 + */
291 + function showContextMenu(event, items) {
292 + event.stopPropagation();
293 +
294 + // Single global menu — remove any prior instance.
295 + const old = document.getElementById('bb-context-menu');
296 + if (old) old.remove();
297 +
298 + const menu = document.createElement('div');
299 + menu.id = 'bb-context-menu';
300 + menu.className = 'context-menu visible';
301 + menu.style.left = event.clientX + 'px';
302 + menu.style.top = event.clientY + 'px';
303 +
304 + for (const item of items) {
305 + const btn = document.createElement('button');
306 + btn.className = 'context-menu-item' + (item.danger ? ' context-menu-item--danger' : '');
307 + btn.textContent = item.label;
308 + btn.onclick = () => { menu.remove(); item.fn(); };
309 + menu.appendChild(btn);
310 + }
311 +
312 + document.body.appendChild(menu);
313 +
314 + // Clamp to viewport.
315 + const rect = menu.getBoundingClientRect();
316 + if (rect.right > window.innerWidth) menu.style.left = (window.innerWidth - rect.width - 4) + 'px';
317 + if (rect.bottom > window.innerHeight) menu.style.top = (window.innerHeight - rect.height - 4) + 'px';
318 +
319 + requestAnimationFrame(() => {
320 + document.addEventListener('click', function dismiss() {
321 + menu.remove();
322 + document.removeEventListener('click', dismiss);
323 + }, { once: true });
324 + });
325 + }
326 +
327 + /**
328 + * Show an undo toast that resolves the inverse action if the user
329 + * doesn't dismiss within the duration.
330 + *
331 + * Pair with the F3 "bulk operations always undoable" rule — every bulk
332 + * mutation should route through this helper with its inverse captured.
333 + *
334 + * @param {string} message - What just happened (e.g. "Deleted 3 bookmarks").
335 + * @param {function} undoFn - Called if the user clicks Undo.
336 + * @param {Object} [opts] - Optional config.
337 + * @param {number} [opts.duration=5000] - Auto-dismiss in ms (also undo window).
338 + */
339 + function showUndoToast(message, undoFn, opts) {
340 + opts = opts || {};
341 + const duration = opts.duration || 5000;
342 + const container = document.getElementById('toast-container');
343 + const toast = document.createElement('div');
344 + toast.className = 'toast toast-undo';
345 +
346 + const msgEl = document.createElement('span');
347 + msgEl.className = 'undo-message';
348 + msgEl.textContent = message;
349 + toast.appendChild(msgEl);
350 +
351 + const btn = document.createElement('button');
352 + btn.className = 'undo-btn';
353 + btn.textContent = 'Undo';
354 + toast.appendChild(btn);
355 +
356 + const countdown = document.createElement('span');
357 + countdown.className = 'undo-countdown';
358 + toast.appendChild(countdown);
359 +
360 + let remaining = Math.ceil(duration / 1000);
361 + countdown.textContent = remaining + 's';
362 + const tick = setInterval(() => {
363 + remaining -= 1;
364 + countdown.textContent = Math.max(0, remaining) + 's';
365 + }, 1000);
366 +
367 + const dismiss = () => { clearInterval(tick); toast.remove(); };
368 + btn.onclick = () => { dismiss(); undoFn(); };
369 +
370 + container.appendChild(toast);
371 + setTimeout(dismiss, duration);
372 + }
373 +
374 + /**
375 + * Render a row primitive with the canonical slot layout:
376 + *
377 + * [icon] [primary · badges] [meta] [actions]
378 + * [secondary ]
379 + *
380 + * Returns the constructed element; the caller appends it. Slots are
381 + * optional — omit and they don't render. Use this for any horizontal
382 + * list-item shape (item rows, source rows, bookmark rows, plugin items,
383 + * query-feed entries). Bespoke per-surface markup is the smell; per-
384 + * surface CSS classes on the outer element are fine.
385 + *
386 + * @param {Object} model
387 + * @param {string} [model.className] - Outer-element class (added to base).
388 + * @param {string} [model.tag='div'] - Outer element tag (e.g. 'li' for lists).
389 + * @param {HTMLElement|string} [model.icon] - Left slot (string is set as textContent).
390 + * @param {HTMLElement|string} [model.primary] - Required-ish top line. String = textContent.
391 + * @param {HTMLElement|string} [model.secondary] - Sub-line.
392 + * @param {HTMLElement|string} [model.meta] - Small right-aligned label (date, count).
393 + * @param {Array<{label: string, color?: string, filled?: boolean}>} [model.badges]
394 + * - Rendered as `.badge[data-color]` next to primary.
395 + * @param {Array<HTMLElement>} [model.actions] - Right-side buttons (hover-revealed via CSS).
396 + * @param {function} [model.onClick] - Click handler on the outer element.
397 + * @param {Object<string,string>} [model.attrs] - Extra HTML attributes (data-*, aria-*).
398 + * @returns {HTMLElement}
399 + */
400 + function renderRow(model) {
401 + const el = document.createElement(model.tag || 'div');
402 + el.className = 'row' + (model.className ? ' ' + model.className : '');
403 +
404 + if (model.attrs) {
405 + for (const [k, v] of Object.entries(model.attrs)) el.setAttribute(k, v);
406 + }
407 +
408 + const appendSlot = (slotClass, value) => {
409 + if (value == null) return null;
410 + const slot = document.createElement('div');
411 + slot.className = slotClass;
412 + if (typeof value === 'string') slot.textContent = value;
413 + else slot.appendChild(value);
414 + el.appendChild(slot);
415 + return slot;
416 + };
417 +
418 + if (model.icon != null) appendSlot('row-icon', model.icon);
419 +
420 + const content = document.createElement('div');
421 + content.className = 'row-content';
422 +
423 + const primaryLine = document.createElement('div');
424 + primaryLine.className = 'row-primary';
425 + if (typeof model.primary === 'string') primaryLine.textContent = model.primary;
426 + else if (model.primary) primaryLine.appendChild(model.primary);
427 +
428 + if (Array.isArray(model.badges) && model.badges.length > 0) {
429 + for (const b of model.badges) {
430 + const badge = document.createElement('span');
431 + badge.className = 'badge' + (b.filled ? ' badge--filled' : '');
432 + if (b.color) badge.setAttribute('data-color', b.color);
433 + badge.textContent = b.label;
434 + primaryLine.appendChild(badge);
435 + }
436 + }
437 + content.appendChild(primaryLine);
438 +
439 + if (model.secondary != null) {
440 + const sec = document.createElement('div');
441 + sec.className = 'row-secondary';
442 + if (typeof model.secondary === 'string') sec.textContent = model.secondary;
443 + else sec.appendChild(model.secondary);
444 + content.appendChild(sec);
445 + }
446 + el.appendChild(content);
447 +
448 + if (model.meta != null) appendSlot('row-meta', model.meta);
449 +
450 + if (Array.isArray(model.actions) && model.actions.length > 0) {
451 + const actions = document.createElement('div');
452 + actions.className = 'row-actions';
453 + for (const a of model.actions) actions.appendChild(a);
454 + el.appendChild(actions);
455 + }
456 +
457 + if (model.onClick) {
458 + el.onclick = model.onClick;
459 + el.style.cursor = 'pointer';
460 + }
461 +
462 + return el;
463 + }
464 +
465 + /**
466 + * Build a single form field group (label + input + hint).
467 + * Used internally by openFormModal; also exposed so per-surface forms
468 + * (settings, sync, query-feed builder) build fields consistently.
469 + *
470 + * @param {Object} field
471 + * @param {string} field.name - Form field name + id.
472 + * @param {string} field.label - Visible label text.
473 + * @param {'text'|'select'|'textarea'|'secret'|'number'|'email'|'url'} [field.type='text']
474 + * @param {boolean} [field.required=false]
475 + * @param {string} [field.value=''] - Initial value.
476 + * @param {Array<string|{value, label}>} [field.options] - For type='select'.
477 + * @param {string} [field.placeholder]
478 + * @param {string} [field.description] - Help text shown as .form-hint.
479 + * @param {string} [field.error] - Error message shown as .form-hint with error color.
480 + * @returns {HTMLElement} The `.form-group` element.
481 + */
482 + function renderFormField(field) {
483 + const group = document.createElement('div');
484 + group.className = 'form-group';
485 +
486 + const label = document.createElement('label');
487 + label.className = 'form-label';
488 + label.textContent = field.label + (field.required ? ' *' : '');
489 + label.setAttribute('for', field.name);
490 + group.appendChild(label);
491 +
492 + // Pick the kind-specific input element so its visual treatment is
493 + // targetable via .form-select / .form-textarea (vs the generic
494 + // .form-input). 'secret' maps to password input.
495 + let input;
496 + if (field.type === 'select') {
497 + input = document.createElement('select');
498 + input.className = 'form-select';
499 + (field.options || []).forEach(opt => {
500 + const option = document.createElement('option');
501 + option.value = typeof opt === 'object' ? opt.value : opt;
502 + option.textContent = typeof opt === 'object' ? opt.label : opt;
503 + if (field.value && option.value === field.value) option.selected = true;
504 + input.appendChild(option);
505 + });
506 + } else if (field.type === 'textarea') {
507 + input = document.createElement('textarea');
508 + input.className = 'form-textarea';
509 + input.value = field.value || '';
510 + input.rows = 4;
511 + } else {
512 + input = document.createElement('input');
513 + input.className = 'form-input';
514 + input.type = field.type === 'secret' ? 'password' : (field.type || 'text');
515 + input.value = field.value || '';
516 + }
517 +
518 + input.name = field.name;
519 + input.id = field.name;
520 + if (field.required) input.required = true;
521 + if (field.placeholder) input.placeholder = field.placeholder;
522 + group.appendChild(input);
523 +
524 + if (field.description) {
525 + const hint = document.createElement('div');
526 + hint.className = 'form-hint';
527 + hint.textContent = field.description;
528 + group.appendChild(hint);
529 + }
530 +
531 + if (field.error) {
532 + const err = document.createElement('div');
533 + err.className = 'form-hint form-hint--error';
534 + err.textContent = field.error;
535 + group.appendChild(err);
536 + }
537 +
538 + return group;
539 + }
540 +
541 + /**
542 + * Render N skeleton rows into a container (for first-paint loading state).
543 + * Replaces ad-hoc `.skeleton-item` blocks in items / sources / bookmarks
544 + * surfaces.
545 + *
546 + * @param {HTMLElement} container - Target (cleared before render).
547 + * @param {Object} [opts]
548 + * @param {number} [opts.rows=6] - Number of skeleton rows.
549 + * @param {boolean} [opts.indicators=true] - Whether to render the left indicator block.
550 + */
551 + function renderSkeleton(container, opts) {
552 + opts = opts || {};
553 + const rows = opts.rows || 6;
554 + const indicators = opts.indicators !== false;
555 + container.innerHTML = '';
556 + for (let i = 0; i < rows; i++) {
557 + const row = document.createElement('div');
558 + row.className = 'skeleton-item';
559 + if (indicators) {
560 + const ind = document.createElement('div');
561 + ind.className = 'skeleton-indicators';
562 + row.appendChild(ind);
563 + }
564 + const content = document.createElement('div');
565 + content.className = 'skeleton-content';
566 + const short = document.createElement('div');
567 + short.className = 'skeleton-line short';
568 + const long = document.createElement('div');
569 + long.className = 'skeleton-line long';
570 + content.appendChild(short);
571 + content.appendChild(long);
572 + row.appendChild(content);
573 + container.appendChild(row);
574 + }
575 + }
576 +
577 + /**
578 + * Run a bulk mutation and surface the undo affordance. Enforces the
579 + * F3 rule that every bulk operation must be reversible from the toast.
580 + *
581 + * @param {Object} opts
582 + * @param {string} opts.label - Toast text (e.g. "Deleted 3 bookmarks").
583 + * @param {function} opts.doAction - The forward action; awaited.
584 + * @param {function} opts.undoAction - The inverse action; called on undo.
585 + * @param {number} [opts.duration=5000] - Undo window in ms.
586 + * @returns {Promise<void>}
587 + */
588 + async function bulkActionWithUndo(opts) {
589 + await opts.doAction();
590 + showUndoToast(opts.label, async () => {
591 + try {
592 + await opts.undoAction();
593 + showToast('Undone');
594 + } catch (e) {
595 + showToast('Undo failed: ' + (e.message || e), 'error');
596 + }
597 + }, { duration: opts.duration });
598 + }
599 +
600 + /**
601 + * Show a "Step N of M" indicator in the modal header. Apply to any
602 + * flow with more than two sequential modal steps (OAuth, plugin
603 + * import wizards, encryption setup). Call with (null) to clear.
604 + *
605 + * @param {number|null} step - Current step number (1-indexed), or null to hide.
606 + * @param {number} [of] - Total step count.
607 + */
608 + function setModalStep(step, of) {
609 + const header = document.querySelector('#modal-overlay .modal-header');
610 + if (!header) return;
611 + let indicator = header.querySelector('.modal-step-indicator');
612 + if (step == null) {
613 + if (indicator) indicator.remove();
614 + return;
615 + }
616 + if (!indicator) {
617 + indicator = document.createElement('span');
618 + indicator.className = 'modal-step-indicator';
619 + // Place after the title (which is currently <h2> with id="modal-title").
620 + const title = header.querySelector('#modal-title');
621 + if (title && title.nextSibling) header.insertBefore(indicator, title.nextSibling);
622 + else header.appendChild(indicator);
623 + }
624 + indicator.textContent = 'Step ' + step + ' of ' + of;
625 + }
626 +
627 + BB.ui = {
628 + showToast,
Lines truncated
@@ -29,12 +29,15 @@
29 29 const body = document.getElementById('modal-body');
30 30
31 31 title.textContent = 'Before You Add a Plugin';
32 + // F4 (2026-06-02): per-`<p>` margins now come from .modal-body
33 + // `p + p` default; the trailing fine-print uses .modal-intro
34 + // for the muted color treatment.
32 35 body.innerHTML =
33 - '<p style="margin-bottom: 0.75rem;">Plugins are Rhai scripts that run inside a sandboxed environment. ' +
36 + '<p>Plugins are Rhai scripts that run inside a sandboxed environment. ' +
34 37 'They <strong>cannot</strong> access your filesystem or run programs, but they <strong>can</strong> make HTTP requests to fetch feed data.</p>' +
35 - '<p style="margin-bottom: 0.75rem;">Only add plugins from sources you trust. A malicious plugin could ' +
38 + '<p>Only add plugins from sources you trust. A malicious plugin could ' +
36 39 'send your configured API keys to a third-party server.</p>' +
37 - '<p style="margin-bottom: 1rem; color: var(--text-secondary);">This warning only appears once.</p>';
40 + '<p class="modal-intro">This warning only appears once.</p>';
38 41
39 42 const actions = document.createElement('div');
40 43 actions.className = 'form-actions';
@@ -83,7 +86,12 @@
83 86 const body = document.getElementById('modal-body');
84 87
85 88 title.textContent = 'Add Feed';
86 - body.innerHTML = '<p style="margin-bottom: 1rem; color: var(--text-secondary);">Select a source type:</p>';
89 + body.innerHTML = '';
90 +
91 + const intro = document.createElement('p');
92 + intro.className = 'modal-intro';
93 + intro.textContent = 'Select a source type:';
94 + body.appendChild(intro);
87 95
88 96 const list = document.createElement('ul');
89 97 list.className = 'plugin-list';
@@ -97,17 +105,17 @@
97 105 });
98 106
99 107 sorted.forEach(plugin => {
100 - const li = document.createElement('li');
101 - li.className = 'plugin-item';
102 - const badge = recommended.has(plugin.id)
103 - ? '<span style="font-size:0.7rem; padding:0.1rem 0.4rem; background:var(--accent-yellow); color:var(--text-on-accent); border-radius:3px; margin-left:0.5rem; vertical-align:middle;">Recommended</span>'
104 - : '';
105 - li.innerHTML = `
106 - <div class="plugin-name">${BB.utils.escapeHtml(plugin.name)}${badge}</div>
107 - ${plugin.description ? `<div class="plugin-desc">${BB.utils.escapeHtml(plugin.description)}</div>` : ''}
108 - `;
109 - li.onclick = () => selectPlugin(plugin.id);
110 - list.appendChild(li);
108 + const row = BB.ui.renderRow({
109 + tag: 'li',
110 + className: 'plugin-item',
111 + primary: plugin.name,
112 + secondary: plugin.description || null,
113 + badges: recommended.has(plugin.id)
114 + ? [{ label: 'Recommended', color: 'yellow', filled: true }]
115 + : null,
116 + onClick: () => selectPlugin(plugin.id),
117 + });
118 + list.appendChild(row);
111 119 });
112 120
113 121 body.appendChild(list);
@@ -8,6 +8,9 @@
8 8 (function() {
9 9 'use strict';
10 10
11 + // F3 justified touch branch: this module wires gesture handlers
12 + // (swipe, pull-to-refresh, long-press) that have no CSS equivalent.
13 + // No-op on non-touch devices avoids loading listener overhead.
11 14 if (!BB.touch?.isTouchDevice) return;
12 15
13 16 // ============ Swipe Delegation ============
@@ -269,14 +272,13 @@
269 272 { label: 'Edit Tags', action: () => BB.sources.editTags(source) },
270 273 { label: 'Delete', action: () => BB.sources.deleteFeed(source), danger: true },
271 274 ];
275 + // F4 (2026-06-02): inline styles + JS-set color retired in favor
276 + // of .btn-stacked utility and .btn-danger modifier (charter rule:
277 + // destructive intent expressed via class, not inline color).
272 278 for (const a of actions) {
273 279 const btn = document.createElement('button');
274 - btn.className = 'btn';
275 - btn.style.display = 'block';
276 - btn.style.width = '100%';
277 - btn.style.marginBottom = '0.5rem';
280 + btn.className = 'btn btn-stacked' + (a.danger ? ' btn-danger' : '');
278 281 btn.textContent = a.label;
279 - if (a.danger) btn.style.color = 'var(--accent-red)';
280 282 btn.addEventListener('click', () => { BB.ui.closeModal(); a.action(); });
281 283 body.appendChild(btn);
282 284 }
@@ -303,12 +305,11 @@
303 305 actions.push({ label: 'Open in Browser', action: () => BB.detail.openUrl() });
304 306 }
305 307
308 + // F4 (2026-06-02): .btn-stacked utility retires the inline
309 + // style.display/width/marginBottom triple.
306 310 for (const a of actions) {
307 311 const btn = document.createElement('button');
308 - btn.className = 'btn';
309 - btn.style.display = 'block';
310 - btn.style.width = '100%';
311 - btn.style.marginBottom = '0.5rem';
312 + btn.className = 'btn btn-stacked';
312 313 btn.textContent = a.label;
313 314 btn.addEventListener('click', () => { BB.ui.closeModal(); a.action(); });
314 315 body.appendChild(btn);
@@ -0,0 +1,142 @@
1 + /**
2 + * Balanced Breakfast — URL Query State Helper (F3 cross-cutting rule)
3 + *
4 + * Mirrors filter, sort, and view-mode state into `location.search` so reload
5 + * and deep-link restore the user's view. Filter state must not live only in
6 + * the DOM or in module-level JS.
7 + *
8 + * Usage:
9 + *
10 + * // Read
11 + * const tag = BB.queryState.get('tag'); // string|null
12 + * const tags = BB.queryState.getAll('tag'); // string[]
13 + *
14 + * // Write (replaces by default; pass {push:true} to add a history entry)
15 + * BB.queryState.set('tag', 'rust');
16 + * BB.queryState.set('tag', null); // remove
17 + * BB.queryState.setMany({tag:'rust', sort:'newest'});
18 + *
19 + * // Subscribe to changes (popstate, back/forward, programmatic set)
20 + * const off = BB.queryState.subscribe('tag', value => { ... });
21 + *
22 + * // Bootstrap a surface
23 + * BB.queryState.init({tag: 'all', sort: 'newest'});
24 + *
25 + * The helper is purely a URL-layer primitive — it does NOT trigger renders.
26 + * Per-surface code subscribes to the keys it cares about and triggers its
27 + * own render in the subscriber.
28 + */
29 + (function() {
30 + 'use strict';
31 +
32 + /** Map of key → Set<callback> for change subscriptions. */
33 + const subscribers = new Map();
34 +
35 + /** Last-known value per key, used to compute deltas on popstate. */
36 + const lastSeen = new Map();
37 +
38 + function readParams() {
39 + return new URLSearchParams(window.location.search);
40 + }
41 +
42 + function writeParams(params, opts) {
43 + const url = new URL(window.location.href);
44 + url.search = params.toString();
45 + const target = url.pathname + (url.search ? url.search : '') + url.hash;
46 + if (opts && opts.push) {
47 + window.history.pushState({}, '', target);
48 + } else {
49 + window.history.replaceState({}, '', target);
50 + }
51 + }
52 +
53 + function get(key) {
54 + return readParams().get(key);
55 + }
56 +
57 + function getAll(key) {
58 + return readParams().getAll(key);
59 + }
60 +
61 + function set(key, value, opts) {
62 + const params = readParams();
63 + if (value == null || value === '') {
64 + params.delete(key);
65 + } else if (Array.isArray(value)) {
66 + params.delete(key);
67 + for (const v of value) params.append(key, v);
68 + } else {
69 + params.set(key, value);
70 + }
71 + writeParams(params, opts);
72 + notify(key);
73 + }
74 +
75 + function setMany(map, opts) {
76 + const params = readParams();
77 + for (const [k, v] of Object.entries(map)) {
78 + if (v == null || v === '') params.delete(k);
79 + else if (Array.isArray(v)) {
80 + params.delete(k);
81 + for (const item of v) params.append(k, item);
82 + } else params.set(k, v);
83 + }
84 + writeParams(params, opts);
85 + for (const k of Object.keys(map)) notify(k);
86 + }
87 +
88 + function subscribe(key, fn) {
89 + if (!subscribers.has(key)) subscribers.set(key, new Set());
90 + subscribers.get(key).add(fn);
91 + // Seed lastSeen so popstate diffs work.
92 + lastSeen.set(key, get(key));
93 + return () => {
94 + const set = subscribers.get(key);
95 + if (set) set.delete(fn);
96 + };
97 + }
98 +
99 + function notify(key) {
100 + const value = get(key);
101 + lastSeen.set(key, value);
102 + const subs = subscribers.get(key);
103 + if (subs) for (const fn of subs) {
104 + try { fn(value); }
105 + catch (e) { console.error('[queryState] subscriber error for ' + key + ':', e); }
106 + }
107 + }
108 +
109 + /**
110 + * Initialise a surface's defaults. If the URL has no value for a key,
111 + * the default is written without pushing a history entry. Existing
112 + * URL values are left alone (the user navigated here for a reason).
113 + */
114 + function init(defaults) {
115 + const params = readParams();
116 + let changed = false;
117 + for (const [k, v] of Object.entries(defaults || {})) {
118 + if (!params.has(k) && v != null && v !== '') {
119 + params.set(k, v);
120 + changed = true;
121 + }
122 + }
123 + if (changed) writeParams(params, { push: false });
124 + }
125 +
126 + // popstate fires on back/forward — re-notify every key whose value changed.
127 + window.addEventListener('popstate', () => {
128 + for (const key of subscribers.keys()) {
129 + const next = get(key);
130 + if (next !== lastSeen.get(key)) notify(key);
131 + }
132 + });
133 +
134 + BB.queryState = {
135 + get,
136 + getAll,
137 + set,
138 + setMany,
139 + subscribe,
140 + init,
141 + };
142 + })();
@@ -55,13 +55,12 @@
55 55 * @param {HTMLElement} container - Modal body element.
56 56 */
57 57 function renderConnect(container) {
58 + // F4 (2026-06-02): inline styles moved into .sync-connect rules.
58 59 const div = document.createElement('div');
59 60 div.className = 'sync-connect';
60 - div.style.textAlign = 'center';
61 - div.style.padding = '2rem 0';
62 61 div.innerHTML =
63 - '<p style="margin-bottom: 1rem;">Sync your feeds and preferences across devices via Makenot.work.</p>' +
64 - '<p style="margin-bottom: 1.5rem; color: var(--text-secondary);">All data is encrypted on your device before it leaves.</p>';
62 + '<p>Sync your feeds and preferences across devices via Makenot.work.</p>' +
63 + '<p>All data is encrypted on your device before it leaves.</p>';
65 64 const btn = document.createElement('button');
66 65 btn.className = 'btn btn-primary';
67 66 btn.textContent = 'Connect to Makenot.work';
@@ -108,11 +107,9 @@
108 107 div.innerHTML =
109 108 '<p>Waiting for authentication in your browser...</p>';
110 109
110 + // F4 (2026-06-02): inline styles moved into .sync-auth-spinner rules.
111 111 const spinner = document.createElement('div');
112 112 spinner.className = 'sync-auth-spinner';
113 - spinner.style.textAlign = 'center';
114 - spinner.style.padding = '1rem 0';
115 - spinner.style.color = 'var(--text-secondary)';
116 113 spinner.textContent = 'Polling for callback...';
117 114 div.appendChild(spinner);
118 115
@@ -407,7 +404,14 @@
407 404 disconnectBtn.className = 'btn sync-disconnect';
408 405 disconnectBtn.textContent = 'Disconnect';
409 406 disconnectBtn.onclick = async () => {
410 - if (!confirm('Disconnect from cloud sync? Local data will be preserved.')) return;
407 + // F6 fix (2026-06-02): was native confirm() — replaced per
408 + // charter "no-native-dialogs" rule.
409 + const ok = await BB.ui.showConfirmDialog(
410 + 'Disconnect from sync',
411 + 'Disconnect from cloud sync? Local data will be preserved.',
412 + { confirmLabel: 'Disconnect', danger: true },
413 + );
414 + if (!ok) return;
411 415 try {
412 416 await BB.api.sync.disconnect();
413 417 BB.ui.showToast('Disconnected', 'success');
@@ -447,14 +451,16 @@
447 451 const annual = '$' + (t.annual_price_cents / 100);
448 452 const monthlyCost = (t.monthly_price_cents / 100) * 12;
449 453 const savings = monthlyCost - (t.annual_price_cents / 100);
454 + // F4 (2026-06-02): inline styles moved into
455 + // .sync-sub-fine-print and .sync-sub-actions rules.
450 456 pricingHtml =
451 457 '<p><strong>' + annual + '/year</strong>' + (savings > 0 ? ' (saves $' + savings + ' vs monthly)' : '') + ' or ' + monthly + '/month.</p>' +
452 - '<p class="sync-sub-fine-print" style="font-size: 0.85rem; opacity: 0.75; margin-top: 0.4rem;">' +
458 + '<p class="sync-sub-fine-print">' +
453 459 'Annual is cheaper because Stripe charges a fixed ~$0.30 + 2.9% fee per transaction. ' +
454 460 'On a monthly plan we pay that fee twelve times a year; annual pays it once. ' +
455 461 'We pass the difference back to you rather than pocket it.' +
456 462 '</p>' +
457 - '<div style="display:flex;gap:0.5rem;margin-top:0.5rem;">' +
463 + '<div class="sync-sub-actions">' +
458 464 '<button class="btn btn-primary" id="sync-sub-annual">Subscribe (' + annual + '/year)</button>' +
459 465 '<button class="btn" id="sync-sub-monthly">' + monthly + '/month</button>' +
460 466 '</div>';
@@ -72,12 +72,14 @@
72 72 });
73 73 frag.appendChild(allLi);
74 74
75 - // Empty-state onboarding when no feeds exist
75 + // Empty-state onboarding when no feeds exist.
76 + // F4 (2026-06-02): inline styles + broken `var(--yolk)` token
77 + // retired in favor of .source-item--empty + .source-item-empty-*
78 + // classes.
76 79 if (sources.length === 0) {
77 80 const emptyLi = document.createElement('li');
78 - emptyLi.className = 'source-item';
79 - emptyLi.style.cssText = 'cursor:default; flex-direction:column; align-items:center; text-align:center; padding:1.5rem 1rem; color:var(--text-muted); font-size:0.8rem; line-height:1.5;';
80 - emptyLi.innerHTML = '<div style="font-size:1.5rem; margin-bottom:0.5rem; opacity:0.5;">\uD83C\uDF73</div>Add your first feed to get started.<br>Click <strong style="color:var(--yolk)">+ Add Feed</strong> above,<br>or <a href="#" onclick="event.preventDefault(); BB.feeds.importOpml();" style="color:var(--accent-yellow);">import an OPML file</a>.';
81 + emptyLi.className = 'source-item source-item--empty';
82 + emptyLi.innerHTML = '<div class="source-item-empty-icon">\uD83C\uDF73</div>Add your first feed to get started.<br>Click <strong class="source-item-empty-strong">+ Add Feed</strong> above,<br>or <a href="#" onclick="event.preventDefault(); BB.feeds.importOpml();" class="source-item-empty-link">import an OPML file</a>.';
81 83 frag.appendChild(emptyLi);
82 84 }
83 85
@@ -101,8 +103,19 @@
101 103 ? `${source.unreadCount}/${source.totalCount}`
102 104 : source.totalCount > 0 ? `\u2713 ${source.totalCount}` : `${source.totalCount}`;
103 105
106 + const healthLabels = {
107 + yellow: 'Feed has warnings',
108 + red: 'Feed has errors',
109 + circuit_broken: 'Feed disabled by circuit breaker',
110 + auth_error: 'Feed authentication failed',
111 + config_error: 'Feed configuration error',
112 + rate_limited: 'Feed rate-limited; will retry',
113 + };
104 114 const healthDot = source.health && source.health !== 'green'
105 - ? `<span class="health-dot health-${escapeHtml(source.health)}" title="${escapeHtml(source.lastError || 'Feed has errors')}"></span>`
115 + // F3: state-by-color paired with shape via [data-health] CSS
116 + // rules + aria-label for screen readers. F5: dropped the
117 + // legacy `health-${health}` class (now handled by [data-health]).
118 + ? `<span class="health-dot" data-health="${escapeHtml(source.health)}" aria-label="${escapeHtml(source.lastError || healthLabels[source.health] || 'Feed status')}" title="${escapeHtml(source.lastError || healthLabels[source.health] || 'Feed has errors')}"></span>`
106 119 : '';
107 120
108 121 const tagChips = (source.tags || [])
@@ -180,7 +193,7 @@
180 193 // "+ Saved Filter" button
181 194 const addQfBtn = document.createElement('li');
182 195 addQfBtn.className = 'source-item add-query-feed-btn';
183 - addQfBtn.innerHTML = '<span class="source-name" style="color:var(--text-muted)" title="Create a dynamic feed from search filters">+ Saved Filter</span>';
196 + addQfBtn.innerHTML = '<span class="source-name source-name--muted" title="Create a dynamic feed from search filters">+ Saved Filter</span>';
184 197 addQfBtn.onclick = () => BB.queryFeeds.openBuilder(null);
185 198 frag.appendChild(addQfBtn);
186 199
@@ -469,7 +469,7 @@ describe('BB.sources.render', () => {
469 469 BB.sources.render(sources);
470 470 const list = document.getElementById('sources-list');
471 471 const sourceItem = list.children[1]; // children[0] is "All", sources start at [1]
472 - assert(sourceItem.innerHTML.includes('health-yellow'), 'Should have health-yellow class');
472 + assert(sourceItem.innerHTML.includes('data-health="yellow"'), 'Should have data-health="yellow" attribute');
473 473 });
474 474
475 475 test('source with unreadCount=0 shows checkmark and total (no slash)', () => {
@@ -67,12 +67,14 @@
67 67 return luminance > 0.5 ? '#000000' : '#ffffff';
68 68 }
69 69
70 - // Direct mappings: TOML dot-path → CSS custom property (all 14 slots)
70 + // Direct mappings: TOML dot-path → CSS custom property.
71 + // F5 (2026-06-02): dropped 'background.surface' → '--bg-surface' (no CSS
72 + // rule consumed it). TOML files may still declare background.surface; it's
73 + // silently ignored here. Down to 13 mapped slots.
71 74 const COLOR_MAP = {
72 75 'background.primary': '--bg-primary',
73 76 'background.secondary': '--bg-secondary',
74 77 'background.tertiary': '--bg-tertiary',
75 - 'background.surface': '--bg-surface',
76 78 'foreground.primary': '--text-primary',
77 79 'foreground.secondary': '--text-secondary',
78 80 'foreground.muted': '--text-muted',
@@ -110,9 +112,9 @@
110 112 // Derived: border
111 113 root.style.setProperty('--border-dark', darken(colors['border.default'], 10));
112 114
113 - // Derived: shadow from foreground.primary at opacity
114 - root.style.setProperty('--shadow', `color-mix(in srgb, ${colors['foreground.primary']} 8%, transparent)`);
115 - root.style.setProperty('--shadow-hover', `color-mix(in srgb, ${colors['foreground.primary']} 12%, transparent)`);
115 + // F5 (2026-06-02): dropped --shadow and --shadow-hover derivations
116 + // (no CSS rule consumed them). --shadow-color (used by --shadow-brutal)
117 + // stays.
116 118
117 119 // Derived: neobrute shadow color from muted foreground
118 120 root.style.setProperty('--shadow-color', darken(colors['foreground.muted'], 10));