Skip to main content

max / balanced_breakfast

Split styles.css along the seven bands its own header declares styles.css was 2026 lines with a FILE STRUCTURE map in its header declaring seven bands over forty-two numbered sections. A file that declares seven bands is describing seven files, and the map exists because 2026 lines cannot be navigated without one. The split follows the map rather than imposing a new one. styles.css keeps the charter and FOUNDATIONS at 277 lines; shell, components, features, mobile and responsive take the rest. All five cuts sit at brace depth 0 on section boundaries, and concatenating the six files reproduces the original byte for byte. No build step and no concatenation at runtime: this is a Tauri webview reading local files, so six link tags cost what one costs. index.html gains five, in cascade order, after the existing styles.css link. The header now names the file for each band. Stripping comments from both sides shows the CSS rules are identical, so the only change outside the header is which file a rule lives in. README.md and docs/frontend_architecture.md asserted a single stylesheet in three places; corrected.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01EEmeiSJnmyL98QzA5Dwsvz
Author: Max Johnson <me@maxj.phd> · 2026-09-04 15:13 UTC
Signed with PGP, not checked
Commit: 1f7871262fc5e25754f4779bb5b8bccdcee1e755
Parent: aca795f
9 files changed, +1485 insertions, -438 deletions
M README.md +1 -1
@@ -98,7 +98,7 @@
98 98 | Database layer | `crates/bb-db/src/` |
99 99 | Tauri commands | `src-tauri/src/commands/` |
100 100 | Frontend JS | `src-tauri/frontend/js/` |
101 - | Styles | `src-tauri/frontend/css/styles.css` |
101 + | Styles | `src-tauri/frontend/css/` (styles, shell, components, features, mobile, responsive) |
102 102 | Bundled plugins | `plugins/` |
103 103 | Plugin authoring guide | `docs/plugin_authoring.md` |
104 104 | Architecture | `docs/architecture.md` |
@@ -255,7 +255,7 @@
255 255
256 256 ## CSS
257 257
258 - Single stylesheet at `frontend/css/styles.css`. Uses CSS variables set by themes.js at runtime. Theme colors come from the TOML themes `makeover` ships, materialized into `src-tauri/themes/` by `build.rs` and loaded via the `get_theme` Tauri command.
258 + Six hand-written stylesheets in `frontend/css/`, one per band of the structure map in `styles.css`: styles (foundations), shell, components, features, mobile, responsive. They are linked in that order by `index.html` and the cascade depends on it. Uses CSS variables set by themes.js at runtime. Theme colors come from the TOML themes `makeover` ships, materialized into `src-tauri/themes/` by `build.rs` and loaded via the `get_theme` Tauri command.
259 259
260 260 Theme-derived CSS variables:
261 261
@@ -294,6 +294,6 @@
294 294 | Sync settings | `src-tauri/frontend/js/settings-sync.js` |
295 295 | OTA updater | `src-tauri/frontend/js/updater.js` |
296 296 | App bootstrap | `src-tauri/frontend/js/app.js` |
297 - | Styles | `src-tauri/frontend/css/styles.css` |
297 + | Styles | `src-tauri/frontend/css/` (six files, one per band) |
298 298 | Entry point | `src-tauri/frontend/index.html` |
299 299 | JS tests | `src-tauri/frontend/js/tests/` |
@@ -29,6 +29,11 @@
29 29 <link rel="stylesheet" href="css/timing.css">
30 30 <link rel="stylesheet" href="css/layout.css">
31 31 <link rel="stylesheet" href="css/styles.css">
32 + <link rel="stylesheet" href="css/shell.css">
33 + <link rel="stylesheet" href="css/components.css">
34 + <link rel="stylesheet" href="css/features.css">
35 + <link rel="stylesheet" href="css/mobile.css">
36 + <link rel="stylesheet" href="css/responsive.css">
32 37 </head>
33 38 <body>
34 39 <div id="app">
@@ -38,6 +38,10 @@
38 38
39 39 ----------------------------------------------------------------------------
40 40 FILE STRUCTURE: search by BAND name (uppercase anchors) to navigate.
41 + Each band names the file it lives in. The bands were declared here long
42 + before they were separate files; splitting them was following this map,
43 + not imposing a new one. index.html links them in this order and the
44 + cascade depends on it.
41 45 Per-section titles use `=== N. Title ===` headers; numbered for stability,
42 46 not because order matters. Bands group related sections; cascade order is
43 47 load-bearing only inside the RESPONSIVE LAYERS and TOUCH OVERRIDES bands.
@@ -46,21 +50,21 @@
46 50 Gaps in the numbering (3, 21, 25) are sections not yet needed; add them in
47 51 their numbered slot. Keep new sections in order.
48 52
49 - BAND: FOUNDATIONS
53 + BAND: FOUNDATIONS styles.css (this file)
50 54 1. Design System Variables: tokens (:root)
51 55 2. CSS Reset & Base
52 56 2b. Utility Classes: layout primitives,
53 57 margin, text
54 58 3. Keyframes & Animation
55 59
56 - BAND: APP SHELL & CHROME
60 + BAND: APP SHELL & CHROME shell.css
57 61 4. Layout shell: #app, .main
58 62 5. Header
59 63 6. Sidebar
60 64 7. Items panel
61 65 8. Detail panel
62 66
63 - BAND: COMPONENT PRIMITIVES
67 + BAND: COMPONENT PRIMITIVES components.css
64 68 9. Buttons: .button family
65 69 10. Forms: .form-group, .field
66 70 11. Modal: .modal-overlay/.modal-content
@@ -73,7 +77,7 @@
73 77 18. Update banner: .update-banner
74 78 19. Health popover: .health-popover
75 79
76 - BAND: FEATURE SURFACES
80 + BAND: FEATURE SURFACES features.css
77 81 20. Sources list / sidebar entries
78 82 21. Items list / read indicator / star button
79 83 22. Reader-expanded mode
@@ -86,11 +90,11 @@
86 90 29. Sync settings (utility classes)
87 91 30. Reading list / Bookmarks
88 92
89 - BAND: UTILITIES
93 + BAND: UTILITIES features.css
90 94 31. Screen-reader only (.sr-only)
91 95 32. Focus indicators
92 96
93 - BAND: MOBILE PRIMITIVES
97 + BAND: MOBILE PRIMITIVES mobile.css
94 98 33. Mobile tab bar
95 99 34. Mobile more-popover
96 100 35. Mobile search bar
@@ -98,12 +102,13 @@
98 102 37. Pull-to-refresh indicator
99 103 38. Source-badge on items (mobile-only display)
100 104
101 - BAND: RESPONSIVE LAYERS: cascade-ordered
105 + BAND: RESPONSIVE LAYERS: responsive.css,
106 + cascade-ordered
102 107 39. @media (max-width: 599px): mobile layout
103 108 40. @media (hover: none): touch overrides
104 109 41. @media (prefers-reduced-motion: reduce)
105 110
106 - BAND: SCROLLBAR
111 + BAND: SCROLLBAR responsive.css
107 112 42. Webkit scrollbar
108 113 ============================================================================ */
109 114
@@ -275,1752 +280,3 @@
275 280 .text-center { text-align: center; }
276 281 .text-left { text-align: left; }
277 282
278 - /* === 4. Layout shell: body & #app ==================================== */
279 - body {
280 - font-family: var(--font-sans);
281 - background-color: var(--surface-page);
282 - color: var(--content);
283 - line-height: 1.5;
284 - overflow: hidden;
285 - }
286 -
287 - #app {
288 - display: flex;
289 - flex-direction: column;
290 - height: 100vh;
291 - }
292 -
293 - /* === 4. Layout shell: main =========================================== */
294 - .main {
295 - display: flex;
296 - flex: 1;
297 - overflow: hidden;
298 - }
299 -
300 - /* === 5. Header ========================================================== */
301 - .header {
302 - display: flex;
303 - justify-content: space-between;
304 - align-items: center;
305 - padding: var(--gap-peer) var(--gap-section);
306 - background-color: var(--surface-overlay);
307 - border-bottom: var(--border-width) solid var(--border);
308 - -webkit-user-select: none;
309 - user-select: none;
310 - position: relative;
311 - }
312 -
313 - .header-actions {
314 - display: flex;
315 - gap: var(--gap-peer);
316 - align-items: center;
317 - }
318 -
319 - /* Carries `.field` in the markup: shape is below, fill and inset edge come
320 - from the generated layout.css. Width is the only thing that made this a
321 - different rule from a form field, and it is all that is left here. */
322 - .search-input {
323 - width: 180px;
324 - }
325 -
326 - /* `.search-spinner` and `@keyframes spin` are gone (2026-08-26). They were the
327 - only rotation left in this file, and a spinner turns at a rate it invented:
328 - it reads as progress on a wait that has none to report. Search here is a
329 - local query behind a 300ms debounce, so under rule 4 of wiki
330 - `loading-and-progress-standard` there was nothing for it to say. */
331 -
332 - .search-input::placeholder { color: var(--content-muted); }
333 -
334 - /* `.sort-select` is gone: every property it set is `.field` plus the
335 - `select.field` cursor, and its `:focus { outline: none }` was killing the
336 - generated `.field:focus-visible` ring on the one control that had no ring of
337 - its own to replace it with. The call sites carry `.field`. */
338 -
339 - /* === 6. Sidebar ======================================================== */
340 - .sidebar {
341 - width: 220px;
342 - min-width: 220px;
343 - background-color: var(--surface-overlay);
344 - border-right: var(--border-width) solid var(--border);
345 - display: flex;
346 - flex-direction: column;
347 - overflow-y: auto;
348 - scrollbar-gutter: stable;
349 - }
350 -
351 - .sidebar h2 {
352 - padding: var(--gap-peer) var(--gap-group);
353 - font-size: var(--font-size-sm);
354 - font-weight: 700;
355 - color: var(--content-muted);
356 - text-transform: uppercase;
357 - letter-spacing: 0.5px;
358 - border-bottom: var(--border-width) solid var(--border);
359 - }
360 -
361 - /* === 7. Items panel ==================================================== */
362 - .items-panel {
363 - flex: 1;
364 - display: flex;
365 - flex-direction: column;
366 - overflow-y: auto;
367 - background-color: var(--surface-page);
368 - }
369 -
370 - /* `.items-list`, `.plugin-list` and `.sources-list` each wrote
371 - `list-style: none` by hand. All three carry `.list` now, which is the
372 - generated reset; `.sources-list` keeps its padding and the other two had
373 - nothing else to keep. */
374 -
375 - /* Article rows: feed items + reading-list bookmarks. Uses the renderRow .row
376 - layout (icon / content / actions, flex + gap) plus article appearance +
377 - read/unread/selected/pinned state. Replaces the legacy .item-* layout. */
378 - .row--article {
379 - padding: var(--gap-peer) var(--gap-section);
380 - border-bottom: var(--border-width) solid var(--border);
381 - /* 3px, wider than a frame on purpose: this edge is the state marker. */
382 - border-left: 3px solid transparent;
383 - cursor: pointer;
384 - }
385 - .row--article:hover { background-color: var(--surface-overlay); }
386 - .row--article.is-unread {
387 - border-left-color: var(--danger);
388 - background-color: color-mix(in srgb, var(--danger) 6%, var(--surface-page));
389 - }
390 - .row--article.is-read { color: var(--content-secondary); }
391 - .row--article.is-selected {
392 - background-color: var(--surface-sunken);
393 - border-left-color: var(--category-four);
394 - }
395 - .row--article.is-unread.is-selected {
396 - border-left-color: var(--danger);
397 - background-color: color-mix(in srgb, var(--danger) 12%, var(--surface-page));
398 - }
399 - .row--article.is-pinned { border-left-color: var(--action); }
400 - .empty-icon {
401 - display: flex;
402 - justify-content: center;
403 - margin-bottom: var(--gap-section);
404 - color: var(--content-muted);
405 - }
406 - .empty-icon svg { width: 2.5rem; height: 2.5rem; }
407 -
408 - /* Standalone empty-state block. The items/bookmarks empty rows use
409 - `<li class="empty-state">`; new surfaces use BB.ui.renderEmptyState().
410 - <div class="empty-state">
411 - <div class="empty-state-icon">...</div>
412 - <p class="empty-state-text">No items</p>
413 - <button class="button button--primary">Add one</button>
414 - </div>
415 - */
416 - .empty-state {
417 - display: flex;
418 - flex-direction: column;
419 - align-items: center;
420 - justify-content: center;
421 - text-align: center;
422 - padding: var(--gap-page) var(--gap-pane);
423 - color: var(--content-muted);
424 - line-height: 1.6;
425 - gap: var(--gap-section);
426 - }
427 - .empty-state-icon {
428 - font-size: 2.5rem;
429 - opacity: 0.6;
430 - }
431 - .empty-state-text {
432 - font-size: var(--font-size-base);
433 - color: var(--content-secondary);
434 - margin: 0;
435 - }
436 - .empty-state--compact {
437 - padding: var(--gap-pane) var(--gap-group);
438 - gap: var(--gap-peer);
439 - }
440 - .empty-state--compact .empty-state-icon { font-size: var(--font-size-3xl); }
441 -
442 - /* Control stack (star / pin + read dot) passed into renderRow's icon slot.
443 - The .row layout provides the gap to the content; no margin needed here. */
444 - .article-controls {
445 - display: flex;
446 - flex-direction: column;
447 - align-items: center;
448 - min-width: 24px;
449 - }
450 -
451 - .star-btn {
452 - background: none;
453 - border: none;
454 - cursor: pointer;
455 - font-size: var(--font-size-xl);
456 - color: var(--content-muted);
457 - padding: var(--step-tight) var(--step-snug);
458 - margin: calc(-1 * var(--step-tight)) calc(-1 * var(--step-snug));
459 - line-height: 1;
460 - }
461 - .star-btn:hover { color: var(--category-four); transform: scale(1.1); }
462 - .star-btn.starred { color: var(--category-four); }
463 -
464 - .read-indicator {
465 - width: 7px;
466 - height: 7px;
467 - border-radius: var(--radius-full);
468 - margin-top: var(--gap-bound);
469 - }
470 - .read-indicator.unread { background-color: var(--danger); }
471 -
472 - /* Content column is renderRow's .row-content (flex:1; min-width:0). */
473 -
474 - /* Header slot holds the author/time meta line (+ mobile source badge). */
475 - .article-meta {
476 - display: flex;
477 - justify-content: space-between;
478 - align-items: flex-start;
479 - margin-bottom: var(--step-hair);
480 - }
481 - .article-author {
482 - font-weight: 700;
483 - color: var(--danger);
484 - font-size: var(--font-size-md);
485 - }
486 -
487 - /* Title: two-line clamp (overrides .row-primary's single flex line). */
488 - .row--article .row-primary {
489 - display: -webkit-box;
490 - -webkit-line-clamp: 2;
491 - -webkit-box-orient: vertical;
492 - overflow: hidden;
493 - font-weight: 400;
494 - line-height: 1.4;
495 - font-size: var(--font-size-base);
496 - }
497 - .row--article .row-secondary {
498 - font-size: var(--font-size-md);
499 - margin-top: var(--step-hair);
500 - }
501 - /* .article-source-badge (mobile-only) lives in section 38. */
502 -
503 - .load-more { padding: var(--gap-group); text-align: center; }
504 -
505 - /* === 8. Detail panel =================================================== */
506 - .detail-panel {
507 - width: 400px;
508 - min-width: 400px;
509 - background-color: var(--surface-overlay);
510 - border-left: var(--border-width) solid var(--border);
511 - display: none;
512 - flex-direction: column;
513 - overflow-y: auto;
514 - }
515 -
516 - .detail-header {
517 - display: flex;
518 - justify-content: flex-end;
519 - gap: var(--gap-bound);
520 - padding: var(--gap-peer);
521 - border-bottom: var(--border-width) solid var(--border);
522 - background-color: var(--surface-sunken);
523 - }
524 -
525 - .item-detail { padding: var(--gap-group); }
526 -
527 - .detail-title {
528 - font-size: var(--font-size-xl);
529 - margin-bottom: var(--gap-section);
530 - color: var(--content);
531 - font-weight: 700;
532 - line-height: 1.4;
533 - }
534 -
535 - .detail-meta {
536 - display: flex;
537 - flex-wrap: wrap;
538 - gap: var(--gap-peer);
539 - margin-bottom: var(--gap-peer);
540 - color: var(--content-secondary);
541 - font-size: var(--font-size-md);
542 - padding-bottom: var(--gap-section);
543 - border-bottom: var(--border-width) solid var(--border);
544 - }
545 -
546 - .detail-tags {
547 - display: flex;
548 - flex-wrap: wrap;
549 - gap: var(--gap-peer);
550 - margin-bottom: var(--gap-section);
551 - }
552 -
553 - /* === 9. Buttons ========================================================
554 - Canonical: .button (+ .button--primary, .button--secondary, .button--success, .button--danger,
555 - .button--icon, .button--text, .button--link; size .button--sm; state .button--loading).
556 - Charter target: collapse remaining one-off *-btn classes onto this family.
557 - ========================================================================== */
558 - /* The fill and the bevel are gone from here, along with the whole
559 - :active rule: .button, .button:hover and .button:active come from
560 - makeover-webview, which emits the fill and the edge together so they
561 - cannot disagree. What stays is everything that is not depth -- box
562 - model, type, cursor -- plus the disabled overrides below.
563 -
564 - Two visible consequences, both the description's answer rather than
565 - this file's: hover reads as --hover-surface instead of
566 - --surface-sunken, and pressing reads as a well. */
567 - .button {
568 - padding: var(--gap-peer) var(--gap-group);
569 - border: var(--border-width) solid var(--border);
570 - border-radius: var(--radius-sm);
571 - color: var(--content);
572 - cursor: pointer;
573 - font-weight: 600;
574 - font-size: var(--font-size-base);
575 - }
576 - .button:hover {
577 - border-color: var(--border-strong);
578 - }
579 - /* Disabled keeps its bevel. The object is still there; greying the content
580 - is what says the action is unavailable -- and the greying is the generated
581 - sheet's, along with the cursor. Only the edge is this app's. */
582 - .button:disabled {
583 - border-color: var(--content-muted);
584 - }
585 - /* The `.button:disabled:hover, .button:disabled:active` pair that used to
586 - sit here is gone. It existed to out-specify the generated hover and
587 - pressed rules, in those words, and makeover-webview 0.10.0 emits
588 - disabled after both and restates the rest depth, so source order settles
589 - it at equal specificity. Nothing has to reach (0,3,0) to win any more,
590 - which matters because raising a selector is what the cascade layer would
591 - have had to unpick.
592 -
593 - The colour, border colour and cursor above stay, and are not a candidate
594 - for the same deletion. Layers resolve before specificity, so the
595 - generated `.button:disabled` in `makeover` loses to `.button` in this
596 - file whatever its specificity. An app has to state the disabled variant
597 - of every property it sets at rest; makeover can only supply what the app
598 - is silent about. */
599 - .button--primary {
600 - background-color: var(--action);
601 - border-color: var(--action);
602 - color: var(--content-on-action);
603 - }
604 - .button--primary:hover { background-color: var(--action-hover); border-color: var(--action-hover); }
605 - .button--success {
606 - background-color: var(--category-four);
607 - border-color: var(--category-four);
608 - color: var(--content-on-action);
609 - }
610 - .button--success:hover { background-color: var(--accent-yellow-light); border-color: var(--accent-yellow-light); }
611 - /* .button--sm: canonical small-button size (see wiki note `bb-design-system`). */
612 - .button--sm { padding: var(--gap-bound) var(--gap-peer); font-size: var(--font-size-md); }
613 - .button--sm.active { background: var(--category-four); color: var(--content-on-action); border-color: var(--category-four); }
614 -
615 - /* .button--danger: destructive action. Consumes the previously-unused
616 - --accent-red-hover token. Replaces .hp-action-danger in F5. */
617 - .button--danger {
618 - background-color: var(--danger);
619 - border-color: var(--danger);
620 - color: var(--content-on-action);
621 - }
622 - .button--danger:hover { background-color: var(--accent-red-hover); border-color: var(--accent-red-hover); }
623 -
624 - /* === N. Row primitive (F2-sub addition, 2026-06-02) ===================
625 - Canonical slot layout: [icon] [primary · badges] [meta] [actions].
626 - Used via BB.ui.renderRow(model). Per-surface classes (.plugin-item,
627 - .source-item, .item, .bookmark-item, etc.) sit alongside .row on the
628 - outer element and add their own padding / border / hover treatment.
629 - F5 retires per-surface name/desc classes (.plugin-name, .plugin-desc)
630 - that duplicate .row-primary / .row-secondary.
631 - ========================================================================== */
632 - .row {
633 - display: flex;
634 - align-items: flex-start;
635 - gap: var(--gap-bound);
636 - min-width: 0;
637 - }
638 - .row-icon {
639 - flex-shrink: 0;
640 - display: flex;
641 - align-items: center;
642 - justify-content: center;
643 - }
644 - .row-content {
645 - flex: 1;
646 - min-width: 0;
647 - }
648 - /* The three row text colours come from makeover-webview, which derives
649 - them from the same content ramp this file used to spell out by hand.
650 - Identical values, so nothing moves; what changes is that there is one
651 - place to change them. */
652 - .row-primary {
653 - font-weight: 600;
654 - font-size: var(--font-size-base);
655 - line-height: 1.3;
656 - display: flex;
657 - align-items: center;
658 - gap: var(--gap-bound);
659 - flex-wrap: wrap;
660 - }
661 - .row-secondary {
662 - font-size: var(--font-size-md);
663 - line-height: 1.4;
664 - margin-top: var(--step-hair);
665 - }
666 - .row-meta {
667 - flex-shrink: 0;
668 - font-size: var(--font-size-sm);
669 - align-self: center;
670 - }
671 - .row-actions {
672 - flex-shrink: 0;
673 - display: flex;
674 - align-items: center;
675 - gap: var(--gap-peer);
676 - }
677 - /* The hover-reveal comes from makeover-webview now. It also toggles
678 - pointer-events, which the hand-written pair did not: a hidden action
679 - used to be invisible and still clickable, so the row carried dead
680 - targets a cursor could find. The `.always` escape hatch went with the
681 - rules it was written against; nothing ever set it. */
682 -
683 - /* F2-sub addition: form-hint error variant (used by renderFormField when
684 - passed a `field.error`). */
685 - .form-hint--error {
686 - color: var(--danger);
687 - font-weight: 600;
688 - }
689 -
690 - /* === 10. Forms =========================================================
691 - Canonical: .form-group + .field.
692 -
693 - One class for every field kind, with the kind riding on the element
694 - (`select.field`, `textarea.field`) rather than on a second class. The F2
695 - split into .form-input / .form-select / .form-textarea is retired: all
696 - twenty-odd call sites had .form-input on an <input>, .form-select on a
697 - <select> and .form-textarea on a <textarea>, with no exceptions, so the
698 - class was saying what the tag already said, and an element selector cannot
699 - be forgotten at a call site the way a modifier class can.
700 -
701 - Fill and inset edge now come from the generated layout.css, where a field
702 - is a Depth::Well. Every rule here used to write `background-color:
703 - var(--surface-page)` under `box-shadow: var(--bevel-inset)` -- a page fill
704 - beneath a recessed edge -- and so did .search-input, .sort-select and (with
Lines truncated
@@ -1,0 +1,785 @@
1 + /* === 9. Buttons ========================================================
2 + Canonical: .button (+ .button--primary, .button--secondary, .button--success, .button--danger,
3 + .button--icon, .button--text, .button--link; size .button--sm; state .button--loading).
4 + Charter target: collapse remaining one-off *-btn classes onto this family.
5 + ========================================================================== */
6 + /* The fill and the bevel are gone from here, along with the whole
7 + :active rule: .button, .button:hover and .button:active come from
8 + makeover-webview, which emits the fill and the edge together so they
9 + cannot disagree. What stays is everything that is not depth -- box
10 + model, type, cursor -- plus the disabled overrides below.
11 +
12 + Two visible consequences, both the description's answer rather than
13 + this file's: hover reads as --hover-surface instead of
14 + --surface-sunken, and pressing reads as a well. */
15 + .button {
16 + padding: var(--gap-peer) var(--gap-group);
17 + border: var(--border-width) solid var(--border);
18 + border-radius: var(--radius-sm);
19 + color: var(--content);
20 + cursor: pointer;
21 + font-weight: 600;
22 + font-size: var(--font-size-base);
23 + }
24 + .button:hover {
25 + border-color: var(--border-strong);
26 + }
27 + /* Disabled keeps its bevel. The object is still there; greying the content
28 + is what says the action is unavailable -- and the greying is the generated
29 + sheet's, along with the cursor. Only the edge is this app's. */
30 + .button:disabled {
31 + border-color: var(--content-muted);
32 + }
33 + /* The `.button:disabled:hover, .button:disabled:active` pair that used to
34 + sit here is gone. It existed to out-specify the generated hover and
35 + pressed rules, in those words, and makeover-webview 0.10.0 emits
36 + disabled after both and restates the rest depth, so source order settles
37 + it at equal specificity. Nothing has to reach (0,3,0) to win any more,
38 + which matters because raising a selector is what the cascade layer would
39 + have had to unpick.
40 +
41 + The colour, border colour and cursor above stay, and are not a candidate
42 + for the same deletion. Layers resolve before specificity, so the
43 + generated `.button:disabled` in `makeover` loses to `.button` in this
44 + file whatever its specificity. An app has to state the disabled variant
45 + of every property it sets at rest; makeover can only supply what the app
46 + is silent about. */
47 + .button--primary {
48 + background-color: var(--action);
49 + border-color: var(--action);
50 + color: var(--content-on-action);
51 + }
52 + .button--primary:hover { background-color: var(--action-hover); border-color: var(--action-hover); }
53 + .button--success {
54 + background-color: var(--category-four);
55 + border-color: var(--category-four);
56 + color: var(--content-on-action);
57 + }
58 + .button--success:hover { background-color: var(--accent-yellow-light); border-color: var(--accent-yellow-light); }
59 + /* .button--sm: canonical small-button size (see wiki note `bb-design-system`). */
60 + .button--sm { padding: var(--gap-bound) var(--gap-peer); font-size: var(--font-size-md); }
61 + .button--sm.active { background: var(--category-four); color: var(--content-on-action); border-color: var(--category-four); }
62 +
63 + /* .button--danger: destructive action. Consumes the previously-unused
64 + --accent-red-hover token. Replaces .hp-action-danger in F5. */
65 + .button--danger {
66 + background-color: var(--danger);
67 + border-color: var(--danger);
68 + color: var(--content-on-action);
69 + }
70 + .button--danger:hover { background-color: var(--accent-red-hover); border-color: var(--accent-red-hover); }
71 +
72 + /* === N. Row primitive (F2-sub addition, 2026-06-02) ===================
73 + Canonical slot layout: [icon] [primary · badges] [meta] [actions].
74 + Used via BB.ui.renderRow(model). Per-surface classes (.plugin-item,
75 + .source-item, .item, .bookmark-item, etc.) sit alongside .row on the
76 + outer element and add their own padding / border / hover treatment.
77 + F5 retires per-surface name/desc classes (.plugin-name, .plugin-desc)
78 + that duplicate .row-primary / .row-secondary.
79 + ========================================================================== */
80 + .row {
81 + display: flex;
82 + align-items: flex-start;
83 + gap: var(--gap-bound);
84 + min-width: 0;
85 + }
86 + .row-icon {
87 + flex-shrink: 0;
88 + display: flex;
89 + align-items: center;
90 + justify-content: center;
91 + }
92 + .row-content {
93 + flex: 1;
94 + min-width: 0;
95 + }
96 + /* The three row text colours come from makeover-webview, which derives
97 + them from the same content ramp this file used to spell out by hand.
98 + Identical values, so nothing moves; what changes is that there is one
99 + place to change them. */
100 + .row-primary {
101 + font-weight: 600;
102 + font-size: var(--font-size-base);
103 + line-height: 1.3;
104 + display: flex;
105 + align-items: center;
106 + gap: var(--gap-bound);
107 + flex-wrap: wrap;
108 + }
109 + .row-secondary {
110 + font-size: var(--font-size-md);
111 + line-height: 1.4;
112 + margin-top: var(--step-hair);
113 + }
114 + .row-meta {
115 + flex-shrink: 0;
116 + font-size: var(--font-size-sm);
117 + align-self: center;
118 + }
119 + .row-actions {
120 + flex-shrink: 0;
121 + display: flex;
122 + align-items: center;
123 + gap: var(--gap-peer);
124 + }
125 + /* The hover-reveal comes from makeover-webview now. It also toggles
126 + pointer-events, which the hand-written pair did not: a hidden action
127 + used to be invisible and still clickable, so the row carried dead
128 + targets a cursor could find. The `.always` escape hatch went with the
129 + rules it was written against; nothing ever set it. */
130 +
131 + /* F2-sub addition: form-hint error variant (used by renderFormField when
132 + passed a `field.error`). */
133 + .form-hint--error {
134 + color: var(--danger);
135 + font-weight: 600;
136 + }
137 +
138 + /* === 10. Forms =========================================================
139 + Canonical: .form-group + .field.
140 +
141 + One class for every field kind, with the kind riding on the element
142 + (`select.field`, `textarea.field`) rather than on a second class. The F2
143 + split into .form-input / .form-select / .form-textarea is retired: all
144 + twenty-odd call sites had .form-input on an <input>, .form-select on a
145 + <select> and .form-textarea on a <textarea>, with no exceptions, so the
146 + class was saying what the tag already said, and an element selector cannot
147 + be forgotten at a call site the way a modifier class can.
148 +
149 + Fill and inset edge now come from the generated layout.css, where a field
150 + is a Depth::Well. Every rule here used to write `background-color:
151 + var(--surface-page)` under `box-shadow: var(--bevel-inset)` -- a page fill
152 + beneath a recessed edge -- and so did .search-input, .sort-select and (with
153 + no bevel at all) the two mobile fields. Five hand-written answers to one
154 + question. goingson had the same pair and retired it the same way; its
155 + .field block carries the long form of this note.
156 + ========================================================================== */
157 + .form-group { margin-bottom: var(--gap-section); }
158 +
159 + /* Shape only. The wider padding is what .search-input and .sort-select
160 + carried; a field inside a form group keeps the tighter one, below. */
161 + .field {
162 + padding: var(--gap-peer) var(--gap-group);
163 + border: var(--border-width) solid var(--border);
164 + border-radius: var(--radius-sm);
165 + color: var(--content);
166 + font-size: var(--font-size-base);
167 + }
168 +
169 + /* A field in a form group fills it. The three fields outside one carry their
170 + own width, which is why this is not on .field itself. */
171 + .form-group .field {
172 + width: 100%;
173 + padding: var(--gap-peer);
174 + }
175 +
176 + select.field { cursor: pointer; }
177 +
178 + /* layout.css draws the disabled field -- muted content, not-allowed cursor --
179 + but the two rules above set colour and cursor at rest in an unlayered sheet,
180 + so the resting rules win and the generated disabled arm never applies. An
181 + app must state the disabled variant of every property it sets at rest.
182 + Found by adopting the generated states, not reported. The fill and the edge
183 + come from layout.css and are not restated here. */
184 + .field:disabled,
185 + .field[aria-disabled="true"] {
186 + color: var(--content-muted);
187 + cursor: not-allowed;
188 + }
189 +
190 + textarea.field {
191 + font-family: inherit;
192 + resize: vertical;
193 + min-height: 4rem;
194 + }
195 +
196 + /* .form-label replaces the unclassed `<label>` under `.form-group`. */
197 + .form-group label,
198 + .settings-content .form-group label,
199 + .form-label {
200 + display: block;
201 + margin-bottom: var(--gap-bound);
202 + color: var(--content-secondary);
203 + font-size: var(--font-size-md);
204 + font-weight: 700;
205 + }
206 + /* .form-row: horizontal grouping of two related fields (e.g. label + button) */
207 +
208 + .form-actions {
209 + display: flex;
210 + justify-content: flex-end;
211 + gap: var(--gap-peer);
212 + margin-top: var(--gap-section);
213 + }
214 +
215 + .form-hint {
216 + font-size: var(--font-size-xs);
217 + color: var(--content-muted);
218 + margin-top: var(--gap-bound);
219 + }
220 +
221 + /* === 11. Modal =========================================================
222 + Canonical: .modal-overlay (single global at #modal-overlay) +
223 + .modal-content (will rename to .modal-container for parity in F5).
224 + .tag block (13. Tag) lives below this block in file order.
225 + ========================================================================== */
226 + .modal-overlay {
227 + position: fixed;
228 + top: 0; left: 0; right: 0; bottom: 0;
229 + background-color: color-mix(in srgb, var(--content) 40%, transparent);
230 + display: none;
231 + align-items: center;
232 + justify-content: center;
233 + z-index: 1000;
234 + }
235 +
236 + .modal-content {
237 + background-color: var(--surface-raised);
238 + border-radius: var(--radius-lg);
239 + width: 90%;
240 + max-width: 500px;
241 + max-height: 80vh;
242 + overflow-y: auto;
243 + /* Floating surfaces carry both: the bevel says the panel is a lit
244 + object, the drop shadow says it is above the page. */
245 + box-shadow: var(--bevel-raised), var(--shadow-brutal-lg);
246 + }
247 +
248 + .modal-header {
249 + display: flex;
250 + justify-content: space-between;
251 + align-items: center;
252 + padding: var(--gap-group) var(--gap-pane);
253 + border-bottom: var(--border-width) solid var(--border);
254 + background-color: var(--surface-overlay);
255 + border-radius: var(--radius-lg) var(--radius-lg) 0 0;
256 + }
257 + .modal-header h2 { font-size: var(--font-size-xl); font-weight: 700; color: var(--content); }
258 +
259 + /* F3 (2026-06-02): step indicator for multi-step modal flows (OAuth,
260 + plugin import, encryption setup). Set via BB.ui.setModalStep(n, of). */
261 + .modal-step-indicator {
262 + margin-left: var(--gap-bound);
263 + padding: var(--step-hair) var(--step-base);
264 + background-color: var(--surface-sunken);
265 + border: var(--border-width) solid var(--border);
266 + border-radius: var(--radius-xl);
267 + font-size: var(--font-size-xs);
268 + font-weight: 600;
269 + color: var(--content-muted);
270 + white-space: nowrap;
271 + }
272 +
273 + .modal-body { padding: var(--gap-pane); }
274 +
275 +
276 +
277 + /* Carries `.card`: a pressable raised object with a hover fill and an inset
278 + pressed state, which is what these three rules wrote by hand. The fill, the
279 + bevel, the hover and the press all come from layout.css now, wrapped in
280 + (hover: hover) and (pointer: fine) there so a fingertip gets no phantom
281 + hover. What is left here is the box and the accent border. */
282 + .plugin-item {
283 + padding: var(--gap-group);
284 + border: var(--border-width) solid var(--border);
285 + border-radius: var(--radius-sm);
286 + margin-bottom: var(--gap-peer);
287 + cursor: pointer;
288 + }
289 + .plugin-item:hover {
290 + border-color: var(--category-four);
291 + }
292 +
293 + /* F5 (2026-06-02): dropped .plugin-name and .plugin-desc; plugin-list
294 + migrated to BB.ui.renderRow in F2-sub; .row-primary / .row-secondary
295 + carry the typography now. */
296 +
297 + /* F2-sub (2026-06-02): intro paragraph at the top of multi-step modals
298 + (replaces inline `style="margin-bottom:1rem;color:var(--content-secondary)"`
299 + in feeds.js plugin picker). */
300 + .modal-intro {
301 + margin-bottom: var(--gap-section);
302 + color: var(--content-secondary);
303 + font-size: var(--font-size-base);
304 + }
305 +
306 + /* F4 additions (2026-06-02): replace inline styles previously hard-coded
307 + in JS render paths. Each rule below retires a specific style="..." or
308 + style.cssText violation flagged in the F4 catalogue. */
309 +
310 + /* sources.js empty-state (no feeds yet). Was inline cssText + emoji
311 + wrapper. F5 may further consolidate onto the .empty-state block once
312 + the row primitive migration lands; this fix lives in the sidebar list
313 + so it stays a styled <li>. */
314 + .source-item--empty {
315 + cursor: default;
316 + flex-direction: column;
317 + align-items: center;
318 + text-align: center;
319 + padding: var(--gap-pane) var(--gap-group);
320 + color: var(--content-muted);
321 + font-size: var(--font-size-md);
322 + line-height: 1.5;
323 + }
324 + .source-item--empty:hover { background-color: transparent; }
325 + .source-item-empty-icon {
326 + display: flex;
327 + justify-content: center;
328 + margin-bottom: var(--gap-peer);
329 + color: var(--content-muted);
330 + }
331 + .source-item-empty-icon svg { width: 1.5rem; height: 1.5rem; }
332 + .source-item-empty-strong { color: var(--category-four); font-weight: 700; }
333 + .source-item-empty-link { color: var(--category-four); }
334 +
335 + /* sources.js "+ Saved Filter" button label. Was inline color. */
336 +
337 + /* settings-sync.js renderConnect block. Was three inline styles. */
338 + .sync-connect {
339 + text-align: center;
340 + padding: var(--gap-pane) 0;
341 + }
342 + .sync-connect p { margin-bottom: var(--gap-section); }
343 + .sync-connect p + p { margin-bottom: var(--gap-pane); color: var(--content-secondary); }
344 +
345 + /* settings-sync.js showCodeEntry spinner. Was three inline styles. */
346 + .sync-auth-spinner {
347 + text-align: center;
348 + padding: var(--gap-group) 0;
349 + color: var(--content-secondary);
350 + }
351 +
352 + /* settings-sync.js pricing fine-print. Class already existed but carried
353 + inline styles; rules moved here. */
354 + .sync-sub-fine-print {
355 + font-size: var(--font-size-base);
356 + opacity: 0.75;
357 + margin-top: var(--gap-bound);
358 + }
359 +
360 + /* settings-sync.js subscribe button row. Layout-only; class-ifying for
361 + consistency. */
362 + .sync-sub-actions {
363 + display: flex;
364 + gap: var(--gap-peer);
365 + margin-top: var(--gap-peer);
366 + }
367 +
368 + /* mobile.js action sheets: stacked full-width buttons. Replaces inline
369 + `style.display='block'; style.width='100%'; style.marginBottom='0.5rem'`. */
370 + .button--stacked {
371 + display: block;
372 + width: 100%;
373 + margin-bottom: var(--gap-peer);
374 + }
375 + .button--stacked:last-child { margin-bottom: 0; }
376 +
377 + /* app.js settings theme-import/export row. Layout-only; class-ifying. */
378 + .theme-actions {
379 + display: flex;
380 + gap: var(--gap-peer);
381 + margin-top: var(--gap-peer);
382 + }
383 +
384 + /* Default body paragraph rhythm: retires the per-`<p>` inline
385 + `margin-bottom: 0.75rem` pattern in feeds.js plugin-warning. */
386 + .modal-body p + p { margin-top: var(--gap-section); }
387 +
388 + /* === 12. Toast =========================================================
389 + Canonical: .toast (+ .toast.success / .toast.error today;
390 + rename to .toast-success / .toast-error in F5, add .toast-info,
391 + .toast-undo). Helper: BB.ui.showToast; must inject zero style.cssText.
392 + ========================================================================== */
393 + .toast-container {
394 + position: fixed;
395 + bottom: 1rem;
396 + right: 1rem;
397 + z-index: 1001;
398 + }
399 +
400 + .toast {
401 + background-color: var(--surface-raised);
402 + border: var(--border-width) solid var(--border);
403 + border-radius: var(--radius-sm);
404 + padding: var(--gap-peer) var(--gap-group);
405 + margin-top: var(--gap-peer);
406 + box-shadow: var(--bevel-raised), var(--shadow-brutal);
407 + animation: slideIn 0.3s ease-out;
408 + font-weight: 500;
409 + font-size: var(--font-size-base);
410 + }
411 + .toast.success { border-left: 4px solid var(--success); background-color: color-mix(in srgb, var(--success) 8%, var(--surface-page)); }
412 + .toast.error { border-left: 4px solid var(--danger); background-color: color-mix(in srgb, var(--danger) 8%, var(--surface-page)); }
413 + .toast-undo {
414 + border-left: 4px solid var(--category-four);
415 + background-color: color-mix(in srgb, var(--category-four) 10%, var(--surface-page));
416 + display: flex;
417 + align-items: center;
418 + gap: var(--gap-bound);
419 + }
420 + .undo-message { flex: 1; }
421 + .undo-btn {
422 + background: none;
423 + border: var(--border-width) solid currentColor;
424 + border-radius: var(--radius-sm);
425 + color: var(--content);
426 + cursor: pointer;
427 + padding: var(--step-tight) var(--step-roomy);
428 + font-size: var(--font-size-md);
429 + font-weight: 600;
430 + }
431 + .undo-btn:hover { background-color: color-mix(in srgb, var(--category-four) 20%, transparent); }
432 + .undo-countdown {
433 + font-size: var(--font-size-xs);
434 + color: var(--content-muted);
435 + min-width: 1.5rem;
436 + text-align: right;
437 + font-variant-numeric: tabular-nums;
438 + }
439 +
440 + @keyframes slideIn {
441 + from { transform: translateX(100%); opacity: 0; }
442 + to { transform: translateX(0); opacity: 1; }
443 + }
444 +
445 + /* === 13. Tag ===========================================================
446 + Canonical: .tag[data-color], display chip (detail panel, source/bookmark
447 + tags). Size modifier .tag--xs. .tag-chip is the separate interactive
448 + filter-chip (tag bars): it toggles (cursor + .active), unlike a display tag.
449 + Source tags use .tag.tag--xs[data-color="muted"]; .bookmark-tag composes on
450 + .tag with a click-to-filter modifier.
451 + ========================================================================== */
452 + /* Carries `.raised`, which is where the fill and the bevel come from now.
453 + Left standing on purpose: a tag is a label and not pressable, which is the
454 + argument that flattened `.badge` on 2026-08-05, so `.tag` being an object at
455 + all is worth a second look. That is a decision about how the app should
456 + look, not a place the design system was being restated, so it is recorded
457 + rather than taken here. */
458 + .tag {
459 + border: var(--border-width) solid var(--border);
460 + border-radius: var(--radius-sm);
461 + padding: var(--step-hair) var(--step-base);
462 + font-size: var(--font-size-sm);
463 + color: var(--content-secondary);
464 + }
465 +
466 + /* F2 additions (2026-06-02): color variants via [data-color] attribute.
467 + Consumes --category-five and --category-six.
468 +
469 + .badge dropped off these selectors when badges moved to [data-tone]:
470 + nothing sets [data-color] on a badge any more, so the badge halves were
471 + matching nothing. .tag keeps the axis, since a tag names a category
472 + rather than a state and green-because-green is the right contract for
473 + it. That is also why the two stopped sharing: they were never saying
474 + the same thing, only spelling it the same way. */
475 + .tag[data-color="green"] { border-color: var(--category-two); color: var(--category-two); background-color: color-mix(in srgb, var(--category-two) 10%, transparent); }
476 + .tag[data-color="yellow"] { border-color: var(--category-four); color: var(--content); background-color: color-mix(in srgb, var(--category-four) 12%, transparent); }
477 + .tag[data-color="red"] { border-color: var(--category-one); color: var(--category-one); background-color: color-mix(in srgb, var(--category-one) 10%, transparent); }
478 + .tag[data-color="blue"] { border-color: var(--category-three); color: var(--category-three); background-color: color-mix(in srgb, var(--category-three) 10%, transparent); }
479 + .tag[data-color="cyan"] { border-color: var(--category-six); color: var(--category-six); background-color: color-mix(in srgb, var(--category-six) 10%, transparent); }
480 + .tag[data-color="purple"] { border-color: var(--category-five); color: var(--category-five); background-color: color-mix(in srgb, var(--category-five) 10%, transparent); }
481 + .tag[data-color="muted"] { border-color: var(--border); color: var(--content-muted); background-color: var(--surface-sunken); }
482 + .tag--xs { font-size: var(--font-size-xxs); padding: 0 var(--step-snug); } /* mirrors .badge--xs */
483 +
484 + /* .badge: smaller and more compact than .tag; used for counts and inline
485 + status indicators.
486 +
487 + The colour axis is [data-tone], which makeover-webview emits, replacing
488 + the [data-color] axis this file used to carry. The old axis named the
489 + paint (green, yellow, red, blue) and the new one names the meaning, so
490 + a badge now says what it is rather than what colour it came out. The
491 + swap is close to free here because three of the four category tokens
492 + were already the same value under a second name: category-one is
493 + danger, category-two is success, category-four is warning.
494 +
495 + The base text colour is the generated one. What stays below is the box:
496 + the border and the type.
497 +
498 + The raised fill and bevel this rule used to add are gone (2026-08-05).
499 + Token::Badge is Depth::Flat because a label is not pressable, GoingsOn's
500 + badges are flat, and BB was the only app dressing one as an object. A
Lines truncated
@@ -1,0 +1,396 @@
1 + /* === 20. Sources list: sidebar entries ================================
2 + Charter target (F5): .source-item collapses onto .card --row.
3 + ========================================================================== */
4 + .sources-list { padding: var(--gap-bound) 0; }
5 +
6 + .source-item {
7 + display: flex;
8 + justify-content: space-between;
9 + align-items: center;
10 + padding: var(--gap-peer) var(--gap-group);
11 + cursor: pointer;
12 + min-height: 2.5rem;
13 + /* 3px, wider than a frame on purpose: this edge is the state marker. */
14 + border-left: 3px solid transparent;
15 + }
16 + .source-item:hover { background-color: var(--surface-sunken); }
17 + .source-item.active {
18 + background-color: var(--surface-sunken);
19 + border-left-color: var(--category-four);
20 + }
21 +
22 + .source-name {
23 + flex: 1;
24 + white-space: nowrap;
25 + overflow: hidden;
26 + text-overflow: ellipsis;
27 + font-weight: 600;
28 + font-size: var(--font-size-base);
29 + }
30 +
31 +
32 + /* Carries `.raised`; same as `.tag`, the fill and the bevel are the design
33 + system's now. */
34 + .source-count {
35 + padding: var(--step-hair) var(--step-snug);
36 + border-radius: var(--radius-xl);
37 + font-size: var(--font-size-xs);
38 + color: var(--content-muted);
39 + border: var(--border-width) solid var(--border);
40 + min-width: 1.8rem;
41 + text-align: center;
42 + }
43 + .source-count.all-read {
44 + color: var(--success);
45 + border-color: var(--success);
46 + }
47 +
48 + .source-delete {
49 + display: none;
50 + background: none;
51 + border: none;
52 + color: var(--content-muted);
53 + cursor: pointer;
54 + font-size: var(--font-size-base);
55 + padding: 0 var(--step-tight);
56 + line-height: 1;
57 + }
58 + .source-delete:hover { color: var(--danger); }
59 + .source-item:hover .source-delete { display: inline; }
60 +
61 + /* === 20. Sources: tag filter bar ====================================== */
62 + .tag-filter-bar {
63 + display: flex;
64 + flex-wrap: wrap;
65 + gap: var(--gap-peer);
66 + padding: var(--gap-peer) var(--gap-group);
67 + border-bottom: var(--border-width) solid var(--border);
68 + background-color: var(--surface-overlay);
69 + }
70 + /* Carries `.chip`, which is what a filter chip is: raised at rest, hover,
71 + press, and a `.latched` state for the one that is on. The fill and both
72 + bevels come from layout.css; the pill shape, the type and the accent are
73 + this app's. */
74 + .tag-chip {
75 + border: var(--border-width) solid var(--border);
76 + border-radius: var(--radius-xl);
77 + padding: var(--step-hair) var(--step-base);
78 + font-size: var(--font-size-xs);
79 + color: var(--content-secondary);
80 + cursor: pointer;
81 + white-space: nowrap;
82 + }
83 + .tag-chip:hover { border-color: var(--category-four); color: var(--content); }
84 + /* An active filter chip is latched down, same inversion as a pressed button.
85 + The inset bevel comes from `.latched`, which the markup sets alongside
86 + `.active`; the accent fill stays here, because which colour a latched chip
87 + takes is this app's call and the depth is not. */
88 + .tag-chip.active {
89 + background-color: var(--category-four);
90 + border-color: var(--category-four);
91 + color: var(--content);
92 + font-weight: 600;
93 + }
94 + .source-tags {
95 + display: flex;
96 + flex-wrap: wrap;
97 + gap: var(--gap-peer);
98 + margin-top: var(--step-hair);
99 + }
100 + /* Source-row tags use .tag.tag--xs[data-color="muted"] (see § Tag). */
101 +
102 + /* === 20. Sources: edit button ========================================= */
103 + .source-edit {
104 + display: inline;
105 + background: none;
106 + border: none;
107 + color: var(--content-muted);
108 + cursor: pointer;
109 + font-size: var(--font-size-md);
110 + padding: 0 var(--step-tight);
111 + line-height: 1;
112 + opacity: 0.4;
113 + }
114 + .source-edit:hover { color: var(--category-four); opacity: 1; }
115 +
116 + /* === 22. Reader-expanded mode =========================================
117 + Hides items panel and expands detail to full width. */
118 + .main.reader-expanded .items-panel {
119 + display: none;
120 + }
121 +
122 + .main.reader-expanded .detail-panel {
123 + flex: 1;
124 + width: auto;
125 + min-width: 0;
126 + border-left: none;
127 + }
128 +
129 + .main.reader-expanded .item-detail {
130 + max-width: 800px;
131 + margin: 0 auto;
132 + }
133 +
134 + .reader-back-btn {
135 + margin-right: auto;
136 + }
137 +
138 + /* === 23. Query feeds: sidebar divider ================================ */
139 + .source-divider {
140 + padding: var(--gap-peer) var(--gap-group) var(--gap-bound);
141 + font-size: var(--font-size-xxs);
142 + font-weight: 600;
143 + color: var(--content-muted);
144 + text-transform: uppercase;
145 + letter-spacing: 0.5px;
146 + border-top: var(--border-width) solid var(--border);
147 + margin-top: var(--gap-bound);
148 + cursor: default;
149 + }
150 +
151 + /* Query feed items: subtle visual distinction */
152 + .source-item.query-feed .source-name {
153 + font-style: italic;
154 + }
155 +
156 + /* Add query feed button */
157 + .source-item.add-query-feed-btn {
158 + padding: var(--gap-peer) var(--gap-group);
159 + border-top: var(--border-width) solid var(--border);
160 + }
161 + .source-item.add-query-feed-btn:hover {
162 + background-color: var(--surface-sunken);
163 + }
164 +
165 + /* === 23. Query feeds: builder =========================================
166 + `.condition-row` is the canonical NAMED LAYOUT for mixed-intent rows
167 + (see compose-first rule, named-layout exception).
168 + ========================================================================== */
169 + .qf-builder .form-group { margin-bottom: var(--gap-section); }
170 +
171 + .condition-row {
172 + display: flex;
173 + align-items: center;
174 + gap: var(--gap-bound);
175 + margin-bottom: var(--gap-peer);
176 + }
177 + .condition-row .field {
178 + flex: 1;
179 + min-width: 0;
180 + font-size: var(--font-size-md);
181 + padding: var(--gap-bound) var(--gap-peer);
182 + }
183 + .condition-row .cond-field { flex: 0 0 auto; width: 90px; }
184 + .condition-row .cond-op { flex: 0 0 auto; width: 120px; }
185 + .condition-row .cond-val { flex: 1; }
186 + .condition-row .cond-remove {
187 + flex-shrink: 0;
188 + padding: var(--step-tight) var(--step-snug);
189 + font-size: var(--font-size-lg);
190 + line-height: 1;
191 + }
192 +
193 + .add-condition-btn {
194 + margin-top: var(--gap-peer);
195 + }
196 +
197 + .match-preview {
198 + padding: var(--gap-peer) 0;
199 + font-size: var(--font-size-md);
200 + color: var(--content-muted);
201 + }
202 +
203 + .modal-actions {
204 + display: flex;
205 + justify-content: flex-end;
206 + gap: var(--gap-peer);
207 + margin-top: var(--gap-section);
208 + padding-top: var(--gap-section);
209 + border-top: var(--border-width) solid var(--border);
210 + }
211 +
212 + /* === 24. Sidebar saved articles ======================================== */
213 + .sidebar-saved {
214 + display: flex;
215 + justify-content: space-between;
216 + align-items: center;
217 + padding: var(--gap-peer) var(--gap-group);
218 + cursor: pointer;
219 + border-top: var(--border-width) solid var(--border);
220 + /* 3px, wider than a frame on purpose: this edge is the state marker. */
221 + border-left: 3px solid transparent;
222 + min-height: 2.5rem;
223 + font-size: var(--font-size-base);
224 + font-weight: 600;
225 + }
226 + .sidebar-saved:hover { background-color: var(--surface-sunken); }
227 + .sidebar-saved.active {
228 + background-color: var(--surface-sunken);
229 + border-left-color: var(--category-four);
230 + }
231 +
232 + /* Sidebar footer */
233 + .sidebar-footer {
234 + margin-top: auto;
235 + padding: var(--gap-peer) var(--gap-group);
236 + border-top: var(--border-width) solid var(--border);
237 + }
238 +
239 + /* F2 additions (2026-06-02): utility primitives.
240 + .hidden: visibility toggle utility (replaces ad-hoc style.display = 'none').
241 + .confirm-message: retires the inline-style violation in
242 + components.js confirmAction (was setting marginBottom, fontSize, color
243 + via style.cssText). */
244 + .hidden { display: none !important; }
245 + .confirm-message {
246 + margin-bottom: var(--gap-section);
247 + font-size: var(--font-size-base);
248 + color: var(--content);
249 + }
250 +
251 + /* === 26. Help shortcuts ================================================ */
252 + .help-shortcuts {
253 + display: grid;
254 + grid-template-columns: 1fr 1fr;
255 + gap: var(--gap-pane);
256 + }
257 + .help-shortcuts .help-section:last-child {
258 + grid-column: 1 / -1;
259 + }
260 + .help-section h3 {
261 + font-size: var(--font-size-sm);
262 + font-weight: 600;
263 + color: var(--content-muted);
264 + text-transform: uppercase;
265 + letter-spacing: 0.5px;
266 + margin-bottom: var(--gap-peer);
267 + }
268 + .help-row {
269 + display: flex;
270 + align-items: center;
271 + gap: var(--gap-bound);
272 + margin-bottom: var(--gap-peer);
273 + font-size: var(--font-size-base);
274 + }
275 + .help-row kbd {
276 + background: var(--surface-raised);
277 + border: var(--border-width) solid var(--border);
278 + border-radius: var(--radius-sm);
279 + box-shadow: var(--bevel-raised);
280 + padding: var(--step-hair) var(--step-snug);
281 + font-family: inherit;
282 + font-size: var(--font-size-md);
283 + min-width: 1.4rem;
284 + text-align: center;
285 + color: var(--content);
286 + }
287 + .help-row span { color: var(--content-secondary); margin-left: auto; }
288 +
289 + /* === 27. Welcome modal ================================================= */
290 + .welcome-content h3 {
291 + font-size: var(--font-size-base);
292 + font-weight: 600;
293 + color: var(--content);
294 + margin-top: var(--gap-section);
295 + margin-bottom: var(--gap-bound);
296 + }
297 + .welcome-content h3:first-child { margin-top: 0; }
298 + .welcome-content p {
299 + font-size: var(--font-size-base);
300 + color: var(--content-secondary);
301 + line-height: 1.5;
302 + }
303 + .welcome-cta {
304 + display: flex;
305 + justify-content: center;
306 + gap: var(--gap-peer);
307 + margin-top: var(--gap-section);
308 + padding-top: var(--gap-section);
309 + border-top: var(--border-width) solid var(--border);
310 + }
311 +
312 + /* === 28. Settings modal ================================================ */
313 + .settings-content .form-group { margin-bottom: var(--gap-section); }
314 + .sync-manual-entry { margin-top: var(--gap-section); }
315 + .sync-manual-form { margin-top: var(--gap-peer); }
316 + .sync-action-spaced { margin-bottom: var(--gap-section); }
317 + .sync-disconnect { margin-top: var(--gap-section); }
318 +
319 + /* === 30. Reading list / Bookmarks ====================================== */
320 +
321 + .bookmark-tag-bar {
322 + display: flex;
323 + gap: var(--gap-peer);
324 + padding: var(--gap-peer) var(--gap-group);
325 + border-bottom: var(--border-width) solid var(--border);
326 + overflow-x: auto;
327 + flex-shrink: 0;
328 + }
329 + /* Bookmark Open/more actions use renderRow's .row-actions (hover-revealed);
330 + pinned state uses .row--article.is-pinned. */
331 + .bookmark-tags {
332 + display: flex;
333 + gap: var(--gap-peer);
334 + flex-wrap: wrap;
335 + margin-top: var(--gap-bound);
336 + }
337 + .bookmark-tag {
338 + cursor: pointer;
339 + font-size: var(--font-size-xs);
340 + }
341 + .bookmark-tag:hover {
342 + background: var(--action);
343 + color: white;
344 + }
345 + .context-menu-item.danger,
346 + .context-menu-item--danger {
347 + /* F2 (2026-06-02): added --danger parity name; .danger kept as legacy
348 + alias until F5 retires call sites. Inline #dc3545 fallback removed;
349 + --accent-red is themed and always defined. */
350 + color: var(--danger);
351 + }
352 +
353 + /* === 31. Screen-reader only (.sr-only) ================================ */
354 + .sr-only {
355 + position: absolute;
356 + width: 1px;
357 + height: 1px;
358 + padding: 0;
359 + margin: -1px;
360 + overflow: hidden;
361 + clip: rect(0, 0, 0, 0);
362 + white-space: nowrap;
363 + border: 0;
364 + }
365 +
366 + /* === 32. Focus indicators ==============================================
367 + The design system's ring for BB's own clickable elements; the same colour
368 + as a box-shadow ring for inputs, which avoids the layout shift an outline
369 + causes on a bevelled field. Rule: every new interactive primitive must
370 + appear in one of these selector lists.
371 +
372 + makeover's own primitives are absent on purpose. `.button` and `.tab` take
373 + an identical ring from layout.css, and restating it here unlayered would
374 + override the design system with a copy of itself.
375 + ========================================================================== */
376 + .source-item:focus-visible,
377 + .star-btn:focus-visible,
378 + .source-delete:focus-visible,
379 + .row--article:focus-visible,
380 + .plugin-item:focus-visible,
381 + .toast-action:focus-visible {
382 + outline: 2px solid var(--focus-ring);
383 + outline-offset: 2px;
384 + }
385 +
386 + /* A field's focus ring comes from layout.css now. What stood here was this
387 + app's own third answer to the same question: `outline: none` plus a composed
388 + box-shadow that had to restate the bevel beside it. `outline: none` also
389 + killed the generated `.field:focus-visible`, so keeping this rule would have
390 + left fields with no ring rather than with two. Same 2px, and --focus-ring
391 + resolves to the same colour; the generated ring sits inside the well
392 + (`outline-offset: calc(-1 * 2px)`), which is where a well's ring belongs.
393 +
394 + `:focus-visible` rather than `:focus` is not a behaviour change for a text
395 + field: a focused text input always matches `:focus-visible`. */
396 +
@@ -1,0 +1,126 @@
1 + /* === 33. Mobile tab bar ================================================ */
2 + .mobile-tab-bar {
3 + display: none;
4 + position: fixed;
5 + bottom: 0;
6 + left: 0;
7 + right: 0;
8 + z-index: 1100;
9 + height: calc(52px + env(safe-area-inset-bottom));
10 + padding-bottom: env(safe-area-inset-bottom);
11 + background-color: var(--surface-overlay);
12 + border-top: var(--border-width) solid var(--border);
13 + align-items: center;
14 + justify-content: space-around;
15 + }
16 +
17 + .mobile-tab {
18 + flex: 1;
19 + display: flex;
20 + align-items: center;
21 + justify-content: center;
22 + height: 52px;
23 + background: none;
24 + border: none;
25 + color: var(--content-muted);
26 + font-family: inherit;
27 + font-size: var(--font-size-xs);
28 + font-weight: 600;
29 + text-transform: uppercase;
30 + letter-spacing: 0.5px;
31 + cursor: pointer;
32 + }
33 + .mobile-tab.active { color: var(--action); }
34 +
35 + .mobile-tab-create {
36 + font-size: 1.4rem;
37 + font-weight: 400;
38 + color: var(--success);
39 + text-transform: none;
40 + letter-spacing: 0;
41 + }
42 +
43 + /* === 34. Mobile more-popover =========================================== */
44 + .mobile-more-popover {
45 + display: none;
46 + position: fixed;
47 + bottom: calc(52px + env(safe-area-inset-bottom));
48 + right: 0.5rem;
49 + z-index: 1101;
50 + background-color: var(--surface-raised);
51 + border: var(--border-width) solid var(--border);
52 + border-radius: var(--radius-sm);
53 + box-shadow: var(--bevel-raised), 0 calc(-1 * var(--shadow-offset)) 0 var(--shadow-color);
54 + padding: var(--gap-bound);
55 + min-width: 160px;
56 + }
57 + .mobile-more-popover.visible { display: block; }
58 + .mobile-more-popover button {
59 + display: block;
60 + width: 100%;
61 + padding: var(--gap-peer) var(--gap-group);
62 + border: none;
63 + border-radius: var(--radius-sm);
64 + background: none;
65 + color: var(--content);
66 + font-family: inherit;
67 + font-size: var(--font-size-base);
68 + text-align: left;
69 + cursor: pointer;
70 + }
71 + .mobile-more-popover button:hover {
72 + background-color: var(--surface-sunken);
73 + }
74 +
75 + /* === 35. Mobile search bar ============================================= */
76 + .mobile-search-bar {
77 + display: none;
78 + position: sticky;
79 + top: 0;
80 + z-index: 10;
81 + padding: var(--gap-peer) var(--gap-group);
82 + gap: var(--gap-peer);
83 + background-color: var(--surface-page);
84 + border-bottom: var(--border-width) solid var(--border);
85 + }
86 + /* Both carry `.field`. They were the only two fields in the app with no bevel
87 + at all, filled with --surface-overlay against the mobile bar's own page
88 + fill, so mobile and desktop disagreed about what an input looks like. They
89 + agree now, and the fill and edge come from the same place as everywhere
90 + else. */
91 + .mobile-search-input {
92 + flex: 1;
93 + font-family: inherit;
94 + }
95 + .mobile-sort-select {
96 + font-family: inherit;
97 + font-size: var(--font-size-md);
98 + }
99 +
100 + /* === 36. Mobile back button ============================================ */
101 + .mobile-back-btn { display: none; }
102 +
103 + /* === 37. Pull-to-refresh indicator ===================================== */
104 + .pull-to-refresh-indicator {
105 + display: none;
106 + text-align: center;
107 + padding: var(--gap-group);
108 + font-size: var(--font-size-md);
109 + color: var(--content-muted);
110 + }
111 + .pull-to-refresh-indicator.visible {
112 + display: block;
113 + }
114 +
115 + /* === 38. Source-badge on items (mobile-only display) =================
116 + Hidden on desktop; shown inside the @media (max-width: 599px) block below.
117 + ========================================================================== */
118 + .article-source-badge { display: none; }
119 +
120 + /* === 39. @media (max-width: 599px): mobile layout ====================
121 + 599px is SizeClass::Compact's upper edge, not a number chosen here. It was
122 + 768px until 2026-08-11, which is a boundary no size class has: a window
123 + between 600 and 767 got the single-pane shell while every generated
124 + stylesheet beside it was still laying out the wide one. build.rs checks this
125 + against makeover-geometry now, so the two cannot part again.
126 + ========================================================================== */
@@ -1,0 +1,167 @@
1 + @media (max-width: 599px) {
2 + body {
3 + padding-top: env(safe-area-inset-top);
4 + padding-bottom: calc(52px + env(safe-area-inset-bottom));
5 + }
6 +
7 + .mobile-tab-bar { display: flex; }
8 + .header { display: none; }
9 + .main { flex-direction: column; }
10 +
11 + .sidebar {
12 + display: none;
13 + width: 100%;
14 + min-width: 0;
15 + border-right: none;
16 + }
17 + .sidebar.mobile-visible {
18 + display: flex;
19 + flex: 1;
20 + }
21 +
22 + .items-panel { width: 100%; }
23 + .items-panel.mobile-hidden { display: none; }
24 +
25 + .detail-panel {
26 + position: fixed;
27 + top: 0;
28 + left: 0;
29 + right: 0;
30 + bottom: 0;
31 + width: 100%;
32 + min-width: 0;
33 + z-index: 1050;
34 + border-left: none;
35 + }
36 +
37 + body.mobile-detail-open .mobile-tab-bar { display: none; }
38 + body.mobile-detail-open { padding-bottom: 0; }
39 + body.mobile-detail-open .detail-panel {
40 + padding-top: env(safe-area-inset-top);
41 + padding-bottom: env(safe-area-inset-bottom);
42 + }
43 +
44 + .mobile-back-btn {
45 + display: inline-flex;
46 + margin-right: auto;
47 + font-size: var(--font-size-md);
48 + }
49 +
50 + .mobile-search-bar { display: flex; }
51 +
52 + /* Larger tap targets. Spacing is not listed here: the touch preset in
53 + css/geometry.css opens every gap at once under (hover: none). What
54 + remains is the parts a density preset cannot express. */
55 + .star-btn { font-size: var(--font-size-2xl); }
56 + .source-item { min-height: 3rem; }
57 +
58 + /* Modal as bottom sheet */
59 + .modal-overlay { align-items: flex-end; }
60 + .modal-content {
61 + width: 100%;
62 + max-width: 100%;
63 + border-radius: var(--radius-lg) var(--radius-lg) 0 0;
64 + max-height: 85vh;
65 + }
66 + .modal-content::before {
67 + content: '';
68 + display: block;
69 + width: 36px;
70 + height: 4px;
71 + background-color: var(--border-strong);
72 + border-radius: var(--radius-xs);
73 + margin: var(--gap-peer) auto;
74 + }
75 + .modal-header { border-radius: 0; }
76 +
77 + /* Toast above tab bar */
78 + .toast-container {
79 + bottom: calc(60px + env(safe-area-inset-bottom));
80 + right: 0.75rem;
81 + left: 0.75rem;
82 + }
83 +
84 + /* Update banner above tab bar */
85 + .update-banner {
86 + bottom: calc(60px + env(safe-area-inset-bottom));
87 + }
88 +
89 + /* Source badge on items */
90 + .article-source-badge {
91 + display: inline-block;
92 + background-color: var(--surface-sunken);
93 + color: var(--content-secondary);
94 + font-size: var(--font-size-xxs);
95 + font-weight: 600;
96 + padding: 0 var(--step-snug);
97 + border-radius: var(--radius-sm);
98 + margin-top: var(--step-hair);
99 + }
100 +
101 + /* Bold unread item titles */
102 + .row--article.is-unread .row-primary { font-weight: 700; }
103 +
104 + /* Sticky detail header */
105 + .detail-header {
106 + position: sticky;
107 + top: 0;
108 + z-index: 5;
109 + }
110 + .detail-panel { -webkit-overflow-scrolling: touch; }
111 + .detail-body { line-height: 1.8; }
112 +
113 + /* Horizontal-scroll tag filter bar */
114 + .tag-filter-bar {
115 + flex-wrap: nowrap;
116 + overflow-x: auto;
117 + -webkit-overflow-scrolling: touch;
118 + scrollbar-width: none;
119 + }
120 + .tag-filter-bar::-webkit-scrollbar { display: none; }
121 +
122 + /* Help shortcuts single column */
123 + .help-shortcuts { grid-template-columns: 1fr; }
124 + }
125 +
126 + /* === 40. @media (hover: none): touch overrides ====================== */
127 + @media (hover: none) {
128 + .row--article:hover { background-color: transparent; }
129 + .row--article.is-unread:hover { background-color: color-mix(in srgb, var(--danger) 6%, var(--surface-page)); }
130 + .row--article.is-selected:hover { background-color: var(--surface-sunken); }
131 + .row--article.is-unread.is-selected:hover { background-color: color-mix(in srgb, var(--danger) 12%, var(--surface-page)); }
132 + .source-item:hover { background-color: transparent; }
133 + .source-item.active:hover { background-color: var(--surface-sunken); }
134 + .sidebar-saved:hover { background-color: transparent; }
135 + .sidebar-saved.active:hover { background-color: var(--surface-sunken); }
136 + /* Border only. The `background-color` half was taking back the generated
137 + hover fill, and makeover-webview wraps its own hover in
138 + (hover: hover) and (pointer: fine) now, so on a fingertip there is
139 + nothing left to take back. What remains undoes this file's own
140 + `.button:hover { border-color: var(--border-strong) }` above, which
141 + makeover has no opinion about. */
142 + .button:hover { border-color: var(--border); }
143 + .button--primary:hover { background-color: var(--action); border-color: var(--action); }
144 + .button--success:hover { background-color: var(--category-four); border-color: var(--category-four); }
145 + .star-btn:hover { color: var(--content-muted); transform: none; }
146 + .star-btn.starred:hover { color: var(--category-four); }
147 + .tag-chip:hover { border-color: var(--border); color: var(--content-secondary); }
148 + .tag-chip.active:hover { border-color: var(--category-four); }
149 + .plugin-item:hover { border-color: var(--border); }
150 + .context-menu-item:hover { background-color: transparent; }
151 + }
152 +
153 + /* === 41. @media (prefers-reduced-motion: reduce) ====================== */
154 + @media (prefers-reduced-motion: reduce) {
155 + *, *::before, *::after {
156 + animation-duration: 0.01ms !important;
157 + animation-iteration-count: 1 !important;
158 + transition-duration: 0.01ms !important;
159 + }
160 + }
161 +
162 + /* === 42. Webkit scrollbar ============================================== */
163 + ::-webkit-scrollbar { width: 6px; height: 6px; }
164 + /* The clearest place in the app to read the bevel pair against each other. */
165 + ::-webkit-scrollbar-track { background: var(--surface-overlay); box-shadow: var(--bevel-inset); }
166 + ::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: var(--radius-xs); box-shadow: var(--bevel-raised); }
167 + ::-webkit-scrollbar-thumb:hover { background: var(--content-muted); }
@@ -1,0 +1,275 @@
1 + /* === 4. Layout shell: body & #app ==================================== */
2 + body {
3 + font-family: var(--font-sans);
4 + background-color: var(--surface-page);
5 + color: var(--content);
6 + line-height: 1.5;
7 + overflow: hidden;
8 + }
9 +
10 + #app {
11 + display: flex;
12 + flex-direction: column;
13 + height: 100vh;
14 + }
15 +
16 + /* === 4. Layout shell: main =========================================== */
17 + .main {
18 + display: flex;
19 + flex: 1;
20 + overflow: hidden;
21 + }
22 +
23 + /* === 5. Header ========================================================== */
24 + .header {
25 + display: flex;
26 + justify-content: space-between;
27 + align-items: center;
28 + padding: var(--gap-peer) var(--gap-section);
29 + background-color: var(--surface-overlay);
30 + border-bottom: var(--border-width) solid var(--border);
31 + -webkit-user-select: none;
32 + user-select: none;
33 + position: relative;
34 + }
35 +
36 + .header-actions {
37 + display: flex;
38 + gap: var(--gap-peer);
39 + align-items: center;
40 + }
41 +
42 + /* Carries `.field` in the markup: shape is below, fill and inset edge come
43 + from the generated layout.css. Width is the only thing that made this a
44 + different rule from a form field, and it is all that is left here. */
45 + .search-input {
46 + width: 180px;
47 + }
48 +
49 + /* `.search-spinner` and `@keyframes spin` are gone (2026-08-26). They were the
50 + only rotation left in this file, and a spinner turns at a rate it invented:
51 + it reads as progress on a wait that has none to report. Search here is a
52 + local query behind a 300ms debounce, so under rule 4 of wiki
53 + `loading-and-progress-standard` there was nothing for it to say. */
54 +
55 + .search-input::placeholder { color: var(--content-muted); }
56 +
57 + /* `.sort-select` is gone: every property it set is `.field` plus the
58 + `select.field` cursor, and its `:focus { outline: none }` was killing the
59 + generated `.field:focus-visible` ring on the one control that had no ring of
60 + its own to replace it with. The call sites carry `.field`. */
61 +
62 + /* === 6. Sidebar ======================================================== */
63 + .sidebar {
64 + width: 220px;
65 + min-width: 220px;
66 + background-color: var(--surface-overlay);
67 + border-right: var(--border-width) solid var(--border);
68 + display: flex;
69 + flex-direction: column;
70 + overflow-y: auto;
71 + scrollbar-gutter: stable;
72 + }
73 +
74 + .sidebar h2 {
75 + padding: var(--gap-peer) var(--gap-group);
76 + font-size: var(--font-size-sm);
77 + font-weight: 700;
78 + color: var(--content-muted);
79 + text-transform: uppercase;
80 + letter-spacing: 0.5px;
81 + border-bottom: var(--border-width) solid var(--border);
82 + }
83 +
84 + /* === 7. Items panel ==================================================== */
85 + .items-panel {
86 + flex: 1;
87 + display: flex;
88 + flex-direction: column;
89 + overflow-y: auto;
90 + background-color: var(--surface-page);
91 + }
92 +
93 + /* `.items-list`, `.plugin-list` and `.sources-list` each wrote
94 + `list-style: none` by hand. All three carry `.list` now, which is the
95 + generated reset; `.sources-list` keeps its padding and the other two had
96 + nothing else to keep. */
97 +
98 + /* Article rows: feed items + reading-list bookmarks. Uses the renderRow .row
99 + layout (icon / content / actions, flex + gap) plus article appearance +
100 + read/unread/selected/pinned state. Replaces the legacy .item-* layout. */
101 + .row--article {
102 + padding: var(--gap-peer) var(--gap-section);
103 + border-bottom: var(--border-width) solid var(--border);
104 + /* 3px, wider than a frame on purpose: this edge is the state marker. */
105 + border-left: 3px solid transparent;
106 + cursor: pointer;
107 + }
108 + .row--article:hover { background-color: var(--surface-overlay); }
109 + .row--article.is-unread {
110 + border-left-color: var(--danger);
111 + background-color: color-mix(in srgb, var(--danger) 6%, var(--surface-page));
112 + }
113 + .row--article.is-read { color: var(--content-secondary); }
114 + .row--article.is-selected {
115 + background-color: var(--surface-sunken);
116 + border-left-color: var(--category-four);
117 + }
118 + .row--article.is-unread.is-selected {
119 + border-left-color: var(--danger);
120 + background-color: color-mix(in srgb, var(--danger) 12%, var(--surface-page));
121 + }
122 + .row--article.is-pinned { border-left-color: var(--action); }
123 + .empty-icon {
124 + display: flex;
125 + justify-content: center;
126 + margin-bottom: var(--gap-section);
127 + color: var(--content-muted);
128 + }
129 + .empty-icon svg { width: 2.5rem; height: 2.5rem; }
130 +
131 + /* Standalone empty-state block. The items/bookmarks empty rows use
132 + `<li class="empty-state">`; new surfaces use BB.ui.renderEmptyState().
133 + <div class="empty-state">
134 + <div class="empty-state-icon">...</div>
135 + <p class="empty-state-text">No items</p>
136 + <button class="button button--primary">Add one</button>
137 + </div>
138 + */
139 + .empty-state {
140 + display: flex;
141 + flex-direction: column;
142 + align-items: center;
143 + justify-content: center;
144 + text-align: center;
145 + padding: var(--gap-page) var(--gap-pane);
146 + color: var(--content-muted);
147 + line-height: 1.6;
148 + gap: var(--gap-section);
149 + }
150 + .empty-state-icon {
151 + font-size: 2.5rem;
152 + opacity: 0.6;
153 + }
154 + .empty-state-text {
155 + font-size: var(--font-size-base);
156 + color: var(--content-secondary);
157 + margin: 0;
158 + }
159 + .empty-state--compact {
160 + padding: var(--gap-pane) var(--gap-group);
161 + gap: var(--gap-peer);
162 + }
163 + .empty-state--compact .empty-state-icon { font-size: var(--font-size-3xl); }
164 +
165 + /* Control stack (star / pin + read dot) passed into renderRow's icon slot.
166 + The .row layout provides the gap to the content; no margin needed here. */
167 + .article-controls {
168 + display: flex;
169 + flex-direction: column;
170 + align-items: center;
171 + min-width: 24px;
172 + }
173 +
174 + .star-btn {
175 + background: none;
176 + border: none;
177 + cursor: pointer;
178 + font-size: var(--font-size-xl);
179 + color: var(--content-muted);
180 + padding: var(--step-tight) var(--step-snug);
181 + margin: calc(-1 * var(--step-tight)) calc(-1 * var(--step-snug));
182 + line-height: 1;
183 + }
184 + .star-btn:hover { color: var(--category-four); transform: scale(1.1); }
185 + .star-btn.starred { color: var(--category-four); }
186 +
187 + .read-indicator {
188 + width: 7px;
189 + height: 7px;
190 + border-radius: var(--radius-full);
191 + margin-top: var(--gap-bound);
192 + }
193 + .read-indicator.unread { background-color: var(--danger); }
194 +
195 + /* Content column is renderRow's .row-content (flex:1; min-width:0). */
196 +
197 + /* Header slot holds the author/time meta line (+ mobile source badge). */
198 + .article-meta {
199 + display: flex;
200 + justify-content: space-between;
201 + align-items: flex-start;
202 + margin-bottom: var(--step-hair);
203 + }
204 + .article-author {
205 + font-weight: 700;
206 + color: var(--danger);
207 + font-size: var(--font-size-md);
208 + }
209 +
210 + /* Title: two-line clamp (overrides .row-primary's single flex line). */
211 + .row--article .row-primary {
212 + display: -webkit-box;
213 + -webkit-line-clamp: 2;
214 + -webkit-box-orient: vertical;
215 + overflow: hidden;
216 + font-weight: 400;
217 + line-height: 1.4;
218 + font-size: var(--font-size-base);
219 + }
220 + .row--article .row-secondary {
221 + font-size: var(--font-size-md);
222 + margin-top: var(--step-hair);
223 + }
224 + /* .article-source-badge (mobile-only) lives in section 38. */
225 +
226 + .load-more { padding: var(--gap-group); text-align: center; }
227 +
228 + /* === 8. Detail panel =================================================== */
229 + .detail-panel {
230 + width: 400px;
231 + min-width: 400px;
232 + background-color: var(--surface-overlay);
233 + border-left: var(--border-width) solid var(--border);
234 + display: none;
235 + flex-direction: column;
236 + overflow-y: auto;
237 + }
238 +
239 + .detail-header {
240 + display: flex;
241 + justify-content: flex-end;
242 + gap: var(--gap-bound);
243 + padding: var(--gap-peer);
244 + border-bottom: var(--border-width) solid var(--border);
245 + background-color: var(--surface-sunken);
246 + }
247 +
248 + .item-detail { padding: var(--gap-group); }
249 +
250 + .detail-title {
251 + font-size: var(--font-size-xl);
252 + margin-bottom: var(--gap-section);
253 + color: var(--content);
254 + font-weight: 700;
255 + line-height: 1.4;
256 + }
257 +
258 + .detail-meta {
259 + display: flex;
260 + flex-wrap: wrap;
261 + gap: var(--gap-peer);
262 + margin-bottom: var(--gap-peer);
263 + color: var(--content-secondary);
264 + font-size: var(--font-size-md);
265 + padding-bottom: var(--gap-section);
266 + border-bottom: var(--border-width) solid var(--border);
267 + }
268 +
269 + .detail-tags {
270 + display: flex;
271 + flex-wrap: wrap;
272 + gap: var(--gap-peer);
273 + margin-bottom: var(--gap-section);
274 + }
275 +