max / balanced_breakfast
15 files changed,
+1190 insertions,
-181 deletions
| @@ -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)); |