Skip to main content

max / balanced_breakfast

Adopt the generated primitives in BB: field, card, chip, raised, list Five hand-written answers to "what does an input look like" become one. .form-input / .form-select / .form-textarea, .search-input, .sort-select and the two mobile fields each wrote a fill under a bevel by hand, three of them pairing a page fill with a recessed edge and two carrying no bevel at all. They all carry .field now, with the kind on the element (select.field, textarea.field) rather than on a second class, and the fill, the inset edge, the focus ring, the disabled arm and the invalid arm come from layout.css. The app's own focus rule went with them: `outline: none` plus a composed box-shadow was killing the generated ring rather than adding to it. Same move on four more: .plugin-item is a .card (raised, hover, pressed), .tag-chip is a .chip with .latched for the one that is on, .tag and .source-count take .raised, and the three lists that each wrote `list-style: none` take .list. .field's resting colour and the select cursor join .button's in REVIEWED_OVERLAPS: this app draws a field at rest and makeover draws it disabled, so both arms are stated rather than one being left to a layer that cannot reach it. Deliberately not taken, both because they change colour rather than remove a duplicate: the progress bar (its track is --border and its fill --category-four, against the generated well and --action), and the empty states (.placeholder supplies a muted text colour BB already sets on the container).
Author: Max Johnson <me@maxj.phd> · 2026-08-17 23:13 UTC
Signed with PGP, not checked
Commit: 36b55d10de6df17e117aa2d67e5ef41cbac537f2
Parent: e244a47
11 files changed, +158 insertions, -147 deletions
@@ -75,7 +75,18 @@
75 75 ///
76 76 /// An entry that stops colliding fails the build, so this list cannot outlive
77 77 /// what it describes.
78 - const REVIEWED_OVERLAPS: &[(&str, &str)] = &[("button", "color"), ("button", "cursor")];
78 + /// `.field` is the same arrangement, arrived at the same way when the forms
79 + /// adopted the generated well: this app draws a field at rest (content colour,
80 + /// pointer cursor on a select) and makeover draws it disabled (muted colour,
81 + /// not-allowed cursor). Both arms are stated here rather than one of them
82 + /// being left to a layer that cannot reach it -- see `.field:disabled` in
83 + /// styles.css. goingson records the same two pairs for the same reason.
84 + const REVIEWED_OVERLAPS: &[(&str, &str)] = &[
85 + ("button", "color"),
86 + ("button", "cursor"),
87 + ("field", "color"),
88 + ("field", "cursor"),
89 + ];
79 90
80 91 /// How many generated classes may go unused before the build fails.
81 92 ///
@@ -34,11 +34,11 @@
34 34 <header class="header">
35 35 <div class="header-actions">
36 36 <label for="search-input" class="sr-only">Search items</label>
37 - <input type="text" id="search-input" placeholder="Search... (/) | ? for help" class="search-input">
37 + <input type="text" id="search-input" placeholder="Search... (/) | ? for help" class="field search-input">
38 38 <span id="search-spinner" class="search-spinner" aria-hidden="true"></span>
39 39 <label for="sort-select" class="sr-only">Sort order</label>
40 40 <button id="unread-toggle" class="button button--sm" title="Show unread only (U)" aria-pressed="false">Unread</button>
41 - <select id="sort-select" class="sort-select" title="Sort order (S)">
41 + <select id="sort-select" class="field" title="Sort order (S)">
42 42 <option value="chronological">Newest First</option>
43 43 <option value="score">By Score</option>
44 44 <option value="unread">Unread First</option>
@@ -58,16 +58,16 @@
58 58 <aside class="sidebar">
59 59 <h2>Sources</h2>
60 60 <nav aria-label="Feed sources">
61 - <ul id="sources-list" class="sources-list well">
61 + <ul id="sources-list" class="list well sources-list">
62 62 <li class="source-item active" data-source="">
63 63 <span class="source-name">All</span>
64 - <span class="source-count">0</span>
64 + <span class="raised source-count">0</span>
65 65 </li>
66 66 </ul>
67 67 </nav>
68 68 <div id="saved-articles-btn" class="sidebar-saved" title="Saved Articles">
69 69 <span class="source-name">Saved Articles</span>
70 - <span id="saved-count" class="source-count">0</span>
70 + <span id="saved-count" class="raised source-count">0</span>
71 71 </div>
72 72 <div class="sidebar-footer">
73 73 <button id="sync-settings-btn" class="button button--sm" title="Cloud Sync Settings">Sync</button>
@@ -77,15 +77,15 @@
77 77 <!-- Items list -->
78 78 <section class="items-panel">
79 79 <div id="mobile-search-bar" class="mobile-search-bar">
80 - <input type="text" id="mobile-search-input" placeholder="Search..." class="mobile-search-input">
81 - <select id="mobile-sort-select" class="mobile-sort-select">
80 + <input type="text" id="mobile-search-input" placeholder="Search..." class="field mobile-search-input">
81 + <select id="mobile-sort-select" class="field mobile-sort-select">
82 82 <option value="chronological">Newest</option>
83 83 <option value="score">Score</option>
84 84 <option value="unread">Unread</option>
85 85 <option value="starred">Starred</option>
86 86 </select>
87 87 </div>
88 - <ul id="items-list" class="items-list well" aria-live="polite"></ul>
88 + <ul id="items-list" class="list well items-list" aria-live="polite"></ul>
89 89 <div id="load-more" class="load-more" style="display: none;">
90 90 <button id="load-more-btn" class="button">Load More</button>
91 91 </div>
@@ -13,12 +13,11 @@
13 13 section}, .stack +
14 14 .stack-{peer,group,
15 15 section})
16 - 3. Use a COMPONENT PRIMITIVE for a UI element (.button, .card, .modal,
17 - .form-input,
18 - .form-select, .badge,
16 + 3. Use a COMPONENT PRIMITIVE for a UI element (.button, .field,
17 + .raised, .well,
18 + .modal, .badge,
19 19 .tag, .toast)
20 - 4. Extend a primitive with a modifier (.button--primary, .button--sm,
21 - .form-select--ghost)
20 + 4. Extend a primitive with a modifier (.button--primary, .button--sm)
22 21 5. ONLY THEN consider a new class, and add it to the right BAND below
23 22
24 23 A new class is a smell, not a goal. Before writing one:
@@ -63,7 +62,7 @@
63 62
64 63 BAND: COMPONENT PRIMITIVES
65 64 9. Buttons: .button family
66 - 10. Forms: .form-group, .form-input
65 + 10. Forms: .form-group, .field
67 66 11. Modal: .modal-overlay/.modal-content
68 67 12. Toast: .toast
69 68 13. Tag: .tag
@@ -317,15 +316,11 @@
317 316 align-items: center;
318 317 }
319 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. */
320 322 .search-input {
321 - padding: var(--gap-peer) var(--gap-group);
322 - border: var(--border-width) solid var(--border);
323 - border-radius: var(--radius-sm);
324 - background-color: var(--surface-page);
325 - color: var(--content);
326 323 width: 180px;
327 - font-size: var(--font-size-base);
328 - box-shadow: var(--bevel-inset);
329 324 }
330 325
331 326 /* Search spinner */
@@ -346,17 +341,10 @@
346 341
347 342 .search-input::placeholder { color: var(--content-muted); }
348 343
349 - .sort-select {
350 - padding: var(--gap-peer) var(--gap-group);
351 - border: var(--border-width) solid var(--border);
352 - border-radius: var(--radius-sm);
353 - background-color: var(--surface-page);
354 - color: var(--content);
355 - cursor: pointer;
356 - font-size: var(--font-size-base);
357 - box-shadow: var(--bevel-inset);
358 - }
359 - .sort-select:focus { outline: none; border-color: var(--focus-ring); }
344 + /* `.sort-select` is gone: every property it set is `.field` plus the
345 + `select.field` cursor, and its `:focus { outline: none }` was killing the
346 + generated `.field:focus-visible` ring on the one control that had no ring of
347 + its own to replace it with. The call sites carry `.field`. */
360 348
361 349 /* === 6. Sidebar ======================================================== */
362 350 .sidebar {
@@ -389,7 +377,10 @@
389 377 background-color: var(--surface-page);
390 378 }
391 379
392 - .items-list { list-style: none; }
380 + /* `.items-list`, `.plugin-list` and `.sources-list` each wrote
381 + `list-style: none` by hand. All three carry `.list` now, which is the
382 + generated reset; `.sources-list` keeps its padding and the other two had
383 + nothing else to keep. */
393 384
394 385 /* Article rows: feed items + reading-list bookmarks. Uses the renderRow .row
395 386 layout (icon / content / actions, flex + gap) plus article appearance +
@@ -707,29 +698,64 @@
707 698 }
708 699
709 700 /* === 10. Forms =========================================================
710 - Canonical: .form-group + .form-input. Charter target (F5): split
711 - .form-input into .form-input / .form-select / .form-textarea so visual
712 - treatment per kind is targetable; add .form-label class on labels;
713 - add BB.ui.renderFormField helper.
701 + Canonical: .form-group + .field.
702 +
703 + One class for every field kind, with the kind riding on the element
704 + (`select.field`, `textarea.field`) rather than on a second class. The F2
705 + split into .form-input / .form-select / .form-textarea is retired: all
706 + twenty-odd call sites had .form-input on an <input>, .form-select on a
707 + <select> and .form-textarea on a <textarea>, with no exceptions, so the
708 + class was saying what the tag already said, and an element selector cannot
709 + be forgotten at a call site the way a modifier class can.
710 +
711 + Fill and inset edge now come from the generated layout.css, where a field
712 + is a Depth::Well. Every rule here used to write `background-color:
713 + var(--surface-page)` under `box-shadow: var(--bevel-inset)` -- a page fill
714 + beneath a recessed edge -- and so did .search-input, .sort-select and (with
715 + no bevel at all) the two mobile fields. Five hand-written answers to one
716 + question. goingson had the same pair and retired it the same way; its
717 + .field block carries the long form of this note.
714 718 ========================================================================== */
715 719 .form-group { margin-bottom: var(--gap-section); }
716 720
717 - .form-input {
718 - width: 100%;
719 - padding: var(--gap-peer);
721 + /* Shape only. The wider padding is what .search-input and .sort-select
722 + carried; a field inside a form group keeps the tighter one, below. */
723 + .field {
724 + padding: var(--gap-peer) var(--gap-group);
720 725 border: var(--border-width) solid var(--border);
721 726 border-radius: var(--radius-sm);
722 - background-color: var(--surface-page);
723 727 color: var(--content);
724 728 font-size: var(--font-size-base);
725 - box-shadow: var(--bevel-inset);
726 729 }
727 730
728 - /* F2 additions (2026-06-02): split .form-input into kind-specific
729 - primitives so visual treatment per kind is targetable. .form-select and
730 - .form-textarea inherit .form-input today; future surface-specific tweaks
731 - land on the kind-specific class. .form-label replaces the unclassed
732 - `<label>` under `.form-group`. */
731 + /* A field in a form group fills it. The three fields outside one carry their
732 + own width, which is why this is not on .field itself. */
733 + .form-group .field {
734 + width: 100%;
735 + padding: var(--gap-peer);
736 + }
737 +
738 + select.field { cursor: pointer; }
739 +
740 + /* layout.css draws the disabled field -- muted content, not-allowed cursor --
741 + but the two rules above set colour and cursor at rest in an unlayered sheet,
742 + so the resting rules win and the generated disabled arm never applies. An
743 + app must state the disabled variant of every property it sets at rest.
744 + Found by adopting the generated states, not reported. The fill and the edge
745 + come from layout.css and are not restated here. */
746 + .field:disabled,
747 + .field[aria-disabled="true"] {
748 + color: var(--content-muted);
749 + cursor: not-allowed;
750 + }
751 +
752 + textarea.field {
753 + font-family: inherit;
754 + resize: vertical;
755 + min-height: 4rem;
756 + }
757 +
758 + /* .form-label replaces the unclassed `<label>` under `.form-group`. */
733 759 .form-group label,
734 760 .settings-content .form-group label,
735 761 .form-label {
@@ -739,30 +765,6 @@
739 765 font-size: var(--font-size-md);
740 766 font-weight: 700;
741 767 }
742 - .form-select {
743 - width: 100%;
744 - padding: var(--gap-peer);
745 - border: var(--border-width) solid var(--border);
746 - border-radius: var(--radius-sm);
747 - background-color: var(--surface-page);
748 - color: var(--content);
749 - font-size: var(--font-size-base);
750 - cursor: pointer;
751 - box-shadow: var(--bevel-inset);
752 - }
753 - .form-textarea {
754 - width: 100%;
755 - padding: var(--gap-peer);
756 - border: var(--border-width) solid var(--border);
757 - border-radius: var(--radius-sm);
758 - background-color: var(--surface-page);
759 - color: var(--content);
760 - font-size: var(--font-size-base);
761 - font-family: inherit;
762 - resize: vertical;
763 - min-height: 4rem;
764 - box-shadow: var(--bevel-inset);
765 - }
766 768 /* .form-row: horizontal grouping of two related fields (e.g. label + button) */
767 769
768 770 .form-actions {
@@ -832,23 +834,22 @@
832 834
833 835 .modal-body { padding: var(--gap-pane); }
834 836
835 - .plugin-list { list-style: none; }
836 837
838 +
839 + /* Carries `.card`: a pressable raised object with a hover fill and an inset
840 + pressed state, which is what these three rules wrote by hand. The fill, the
841 + bevel, the hover and the press all come from layout.css now, wrapped in
842 + (hover: hover) and (pointer: fine) there so a fingertip gets no phantom
843 + hover. What is left here is the box and the accent border. */
837 844 .plugin-item {
838 845 padding: var(--gap-group);
839 846 border: var(--border-width) solid var(--border);
840 847 border-radius: var(--radius-sm);
841 848 margin-bottom: var(--gap-peer);
842 849 cursor: pointer;
843 - background-color: var(--surface-raised);
844 - box-shadow: var(--bevel-raised);
845 850 }
846 851 .plugin-item:hover {
847 852 border-color: var(--category-four);
848 - background-color: var(--surface-overlay);
849 - }
850 - .plugin-item:active {
851 - box-shadow: var(--bevel-inset);
852 853 }
853 854
854 855 /* F5 (2026-06-02): dropped .plugin-name and .plugin-desc; plugin-list
@@ -1010,14 +1011,18 @@
1010 1011 Source tags use .tag.tag--xs[data-color="muted"]; .bookmark-tag composes on
1011 1012 .tag with a click-to-filter modifier.
1012 1013 ========================================================================== */
1014 + /* Carries `.raised`, which is where the fill and the bevel come from now.
1015 + Left standing on purpose: a tag is a label and not pressable, which is the
1016 + argument that flattened `.badge` on 2026-08-05, so `.tag` being an object at
1017 + all is worth a second look. That is a decision about how the app should
1018 + look, not a place the design system was being restated, so it is recorded
1019 + rather than taken here. */
1013 1020 .tag {
1014 - background-color: var(--surface-raised);
1015 1021 border: var(--border-width) solid var(--border);
1016 1022 border-radius: var(--radius-sm);
1017 1023 padding: var(--step-hair) var(--step-base);
1018 1024 font-size: var(--font-size-sm);
1019 1025 color: var(--content-secondary);
1020 - box-shadow: var(--bevel-raised);
1021 1026 }
1022 1027
1023 1028 /* F2 additions (2026-06-02): color variants via [data-color] attribute.
@@ -1332,7 +1337,7 @@
1332 1337 /* === 20. Sources list: sidebar entries ================================
1333 1338 Charter target (F5): .source-item collapses onto .card --row.
1334 1339 ========================================================================== */
1335 - .sources-list { list-style: none; padding: var(--gap-bound) 0; }
1340 + .sources-list { padding: var(--gap-bound) 0; }
1336 1341
1337 1342 .source-item {
1338 1343 display: flex;
@@ -1360,14 +1365,14 @@
1360 1365 }
1361 1366
1362 1367
1368 + /* Carries `.raised`; same as `.tag`, the fill and the bevel are the design
1369 + system's now. */
1363 1370 .source-count {
1364 - background-color: var(--surface-raised);
1365 1371 padding: var(--step-hair) var(--step-snug);
1366 1372 border-radius: var(--radius-xl);
1367 1373 font-size: var(--font-size-xs);
1368 1374 color: var(--content-muted);
1369 1375 border: var(--border-width) solid var(--border);
1370 - box-shadow: var(--bevel-raised);
1371 1376 min-width: 1.8rem;
1372 1377 text-align: center;
1373 1378 }
@@ -1398,8 +1403,11 @@
1398 1403 border-bottom: var(--border-width) solid var(--border);
1399 1404 background-color: var(--surface-overlay);
1400 1405 }
1406 + /* Carries `.chip`, which is what a filter chip is: raised at rest, hover,
1407 + press, and a `.latched` state for the one that is on. The fill and both
1408 + bevels come from layout.css; the pill shape, the type and the accent are
1409 + this app's. */
1401 1410 .tag-chip {
1402 - background-color: var(--surface-raised);
1403 1411 border: var(--border-width) solid var(--border);
1404 1412 border-radius: var(--radius-xl);
1405 1413 padding: var(--step-hair) var(--step-base);
@@ -1407,16 +1415,17 @@
1407 1415 color: var(--content-secondary);
1408 1416 cursor: pointer;
1409 1417 white-space: nowrap;
1410 - box-shadow: var(--bevel-raised);
1411 1418 }
1412 1419 .tag-chip:hover { border-color: var(--category-four); color: var(--content); }
1413 - /* An active filter chip is latched down, same inversion as a pressed button. */
1420 + /* An active filter chip is latched down, same inversion as a pressed button.
1421 + The inset bevel comes from `.latched`, which the markup sets alongside
1422 + `.active`; the accent fill stays here, because which colour a latched chip
1423 + takes is this app's call and the depth is not. */
1414 1424 .tag-chip.active {
1415 1425 background-color: var(--category-four);
1416 1426 border-color: var(--category-four);
1417 1427 color: var(--content);
1418 1428 font-weight: 600;
1419 - box-shadow: var(--bevel-inset);
1420 1429 }
1421 1430 .source-tags {
1422 1431 display: flex;
@@ -1501,8 +1510,7 @@
1501 1510 gap: var(--gap-bound);
1502 1511 margin-bottom: var(--gap-peer);
1503 1512 }
1504 - .condition-row .sort-select,
1505 - .condition-row .search-input {
1513 + .condition-row .field {
1506 1514 flex: 1;
1507 1515 min-width: 0;
1508 1516 font-size: var(--font-size-md);
@@ -1706,23 +1714,21 @@
1706 1714 .source-delete:focus-visible,
1707 1715 .row--article:focus-visible,
1708 1716 .plugin-item:focus-visible,
1709 - .sort-select:focus-visible,
1710 1717 .toast-action:focus-visible {
1711 1718 outline: 2px solid var(--focus-ring);
1712 1719 outline-offset: 2px;
1713 1720 }
1714 1721
1715 - /* One focus treatment for every field kind and both focus states.
1716 - Was six identical rules scattered across the sheet. */
1717 - .search-input:focus, .search-input:focus-visible,
1718 - .form-input:focus, .form-input:focus-visible,
1719 - .form-select:focus, .form-select:focus-visible,
1720 - .form-textarea:focus, .form-textarea:focus-visible,
1721 - .mobile-search-input:focus, .mobile-search-input:focus-visible {
1722 - outline: none;
1723 - border-color: var(--focus-ring);
1724 - box-shadow: var(--bevel-inset), 0 0 0 2px var(--focus-ring);
1725 - }
1722 + /* A field's focus ring comes from layout.css now. What stood here was this
1723 + app's own third answer to the same question: `outline: none` plus a composed
1724 + box-shadow that had to restate the bevel beside it. `outline: none` also
1725 + killed the generated `.field:focus-visible`, so keeping this rule would have
1726 + left fields with no ring rather than with two. Same 2px, and --focus-ring
1727 + resolves to the same colour; the generated ring sits inside the well
1728 + (`outline-offset: calc(-1 * 2px)`), which is where a well's ring belongs.
1729 +
1730 + `:focus-visible` rather than `:focus` is not a behaviour change for a text
1731 + field: a focused text input always matches `:focus-visible`. */
1726 1732
1727 1733 /* === 33. Mobile tab bar ================================================ */
1728 1734 .mobile-tab-bar {
@@ -1809,22 +1815,16 @@
1809 1815 background-color: var(--surface-page);
1810 1816 border-bottom: var(--border-width) solid var(--border);
1811 1817 }
1818 + /* Both carry `.field`. They were the only two fields in the app with no bevel
1819 + at all, filled with --surface-overlay against the mobile bar's own page
1820 + fill, so mobile and desktop disagreed about what an input looks like. They
1821 + agree now, and the fill and edge come from the same place as everywhere
1822 + else. */
1812 1823 .mobile-search-input {
1813 1824 flex: 1;
1814 - padding: var(--gap-peer) var(--gap-group);
1815 - border: var(--border-width) solid var(--border);
1816 - border-radius: var(--radius-sm);
1817 - background-color: var(--surface-overlay);
1818 - color: var(--content);
1819 1825 font-family: inherit;
1820 - font-size: var(--font-size-base);
1821 1826 }
1822 1827 .mobile-sort-select {
1823 - padding: var(--gap-peer);
1824 - border: var(--border-width) solid var(--border);
1825 - border-radius: var(--radius-sm);
1826 - background-color: var(--surface-overlay);
1827 - color: var(--content);
1828 1828 font-family: inherit;
1829 1829 font-size: var(--font-size-md);
1830 1830 }
@@ -2004,7 +2004,7 @@
2004 2004 .star-btn.starred:hover { color: var(--category-four); }
2005 2005 .tag-chip:hover { border-color: var(--border); color: var(--content-secondary); }
2006 2006 .tag-chip.active:hover { border-color: var(--category-four); }
2007 - .plugin-item:hover { border-color: var(--border); background-color: var(--surface-raised); box-shadow: var(--bevel-raised); }
2007 + .plugin-item:hover { border-color: var(--border); }
2008 2008 .context-menu-item:hover { background-color: transparent; }
2009 2009 }
2010 2010
@@ -162,14 +162,14 @@
162 162
163 163 // "All" button
164 164 const allBtn = document.createElement('button');
165 - allBtn.className = 'tag-chip' + (!currentTag ? ' active' : '');
165 + allBtn.className = 'chip tag-chip' + (!currentTag ? ' latched active' : '');
166 166 allBtn.textContent = 'All';
167 167 allBtn.onclick = () => load(null);
168 168 bar.appendChild(allBtn);
169 169
170 170 for (const tag of tags) {
171 171 const button = document.createElement('button');
172 - button.className = 'tag-chip' + (currentTag === tag ? ' active' : '');
172 + button.className = 'chip tag-chip' + (currentTag === tag ? ' latched active' : '');
173 173 button.textContent = tag;
174 174 button.onclick = () => load(tag);
175 175 bar.appendChild(button);
@@ -228,19 +228,19 @@
228 228 form.innerHTML = `
229 229 <div class="form-group">
230 230 <label>URL</label>
231 - <input type="url" id="bm-url" class="form-input" placeholder="https://..." value="${escapeAttr(prefill.url || '')}">
231 + <input type="url" id="bm-url" class="field" placeholder="https://..." value="${escapeAttr(prefill.url || '')}">
232 232 </div>
233 233 <div class="form-group">
234 234 <label>Title</label>
235 - <input type="text" id="bm-title" class="form-input" placeholder="Page title" value="${escapeAttr(prefill.title || '')}">
235 + <input type="text" id="bm-title" class="field" placeholder="Page title" value="${escapeAttr(prefill.title || '')}">
236 236 </div>
237 237 <div class="form-group">
238 238 <label>Tags (comma-separated)</label>
239 - <input type="text" id="bm-tags" class="form-input" placeholder="reading, tools, reference">
239 + <input type="text" id="bm-tags" class="field" placeholder="reading, tools, reference">
240 240 </div>
241 241 <div class="form-group">
242 242 <label>Notes</label>
243 - <textarea id="bm-notes" class="form-input" rows="3" placeholder="Personal notes..."></textarea>
243 + <textarea id="bm-notes" class="field" rows="3" placeholder="Personal notes..."></textarea>
244 244 </div>
245 245 <div class="form-actions">
246 246 <button class="button" id="bm-cancel">Cancel</button>
@@ -303,15 +303,15 @@
303 303 form.innerHTML = `
304 304 <div class="form-group">
305 305 <label>Title</label>
306 - <input type="text" id="bm-edit-title" class="form-input" value="${escapeAttr(bm.title)}">
306 + <input type="text" id="bm-edit-title" class="field" value="${escapeAttr(bm.title)}">
307 307 </div>
308 308 <div class="form-group">
309 309 <label>Description</label>
310 - <textarea id="bm-edit-desc" class="form-input" rows="2">${escapeHtml(bm.description)}</textarea>
310 + <textarea id="bm-edit-desc" class="field" rows="2">${escapeHtml(bm.description)}</textarea>
311 311 </div>
312 312 <div class="form-group">
313 313 <label>Notes</label>
314 - <textarea id="bm-edit-notes" class="form-input" rows="3">${escapeHtml(bm.notes)}</textarea>
314 + <textarea id="bm-edit-notes" class="field" rows="3">${escapeHtml(bm.notes)}</textarea>
315 315 </div>
316 316 <div class="form-group">
317 317 <label><input type="checkbox" id="bm-edit-pinned" ${bm.isPinned ? 'checked' : ''}> Pin to top</label>
@@ -367,7 +367,7 @@
367 367 form.innerHTML = `
368 368 <div class="form-group">
369 369 <label>Tags (comma-separated)</label>
370 - <input type="text" id="bm-tags-input" class="form-input" value="${escapeAttr(bm.tags.join(', '))}">
370 + <input type="text" id="bm-tags-input" class="field" value="${escapeAttr(bm.tags.join(', '))}">
371 371 </div>
372 372 <div class="form-actions">
373 373 <button class="button" id="bm-tags-cancel">Cancel</button>
@@ -528,13 +528,13 @@
528 528 label.setAttribute('for', field.name);
529 529 group.appendChild(label);
530 530
531 - // Pick the kind-specific input element so its visual treatment is
532 - // targetable via .form-select / .form-textarea (vs the generic
533 - // .form-input). 'secret' maps to password input.
531 + // Every kind is a `.field`, and the kind rides on the element:
532 + // `select.field` and `textarea.field` carry what differs. 'secret'
533 + // maps to password input.
534 534 let input;
535 535 if (field.type === 'select') {
536 536 input = document.createElement('select');
537 - input.className = 'form-select';
537 + input.className = 'field';
538 538 (field.options || []).forEach(opt => {
539 539 const option = document.createElement('option');
540 540 option.value = typeof opt === 'object' ? opt.value : opt;
@@ -544,12 +544,12 @@
544 544 });
545 545 } else if (field.type === 'textarea') {
546 546 input = document.createElement('textarea');
547 - input.className = 'form-textarea';
547 + input.className = 'field';
548 548 input.value = field.value || '';
549 549 input.rows = 4;
550 550 } else {
551 551 input = document.createElement('input');
552 - input.className = 'form-input';
552 + input.className = 'field';
553 553 input.type = field.type === 'secret' ? 'password' : (field.type || 'text');
554 554 input.value = field.value || '';
555 555 }
@@ -88,7 +88,7 @@
88 88 function renderDetail(item) {
89 89 const detail = document.getElementById('item-detail');
90 90 const tags = item.tags && item.tags.length > 0
91 - ? `<div class="detail-tags">${item.tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('')}</div>`
91 + ? `<div class="detail-tags">${item.tags.map(t => `<span class="tag raised">${escapeHtml(t)}</span>`).join('')}</div>`
92 92 : '';
93 93
94 94 detail.innerHTML = `
@@ -94,7 +94,7 @@
94 94 body.appendChild(intro);
95 95
96 96 const list = document.createElement('ul');
97 - list.className = 'plugin-list';
97 + list.className = 'list plugin-list';
98 98
99 99 // Sort recommended plugins first
100 100 const recommended = new Set(['rss', 'mastodon', 'reddit']);
@@ -107,7 +107,7 @@
107 107 sorted.forEach(plugin => {
108 108 const row = BB.ui.renderRow({
109 109 tag: 'li',
110 - className: 'plugin-item',
110 + className: 'card plugin-item',
111 111 primary: plugin.name,
112 112 secondary: plugin.description || null,
113 113 badges: recommended.has(plugin.id)
@@ -63,7 +63,7 @@
63 63 <div class="qf-builder">
64 64 <div class="form-group">
65 65 <label for="qf-name">Name</label>
66 - <input type="text" id="qf-name" class="search-input" value="${escapeAttr(name)}" placeholder="My filter" maxlength="200" style="width:100%">
66 + <input type="text" id="qf-name" class="field" value="${escapeAttr(name)}" placeholder="My filter" maxlength="200" style="width:100%">
67 67 </div>
68 68 <div class="form-group">
69 69 <label>Conditions (all must match)</label>
@@ -98,28 +98,28 @@
98 98 row.className = 'condition-row';
99 99
100 100 const fields = Object.keys(OPERATORS);
101 - const fieldSelect = `<select class="sort-select cond-field" data-idx="${idx}">
101 + const fieldSelect = `<select class="field cond-field" data-idx="${idx}">
102 102 ${fields.map(f => `<option value="${f}" ${f === condition.field ? 'selected' : ''}>${escapeHtml(f)}</option>`).join('')}
103 103 </select>`;
104 104
105 105 const ops = OPERATORS[condition.field] || ['contains'];
106 - const opSelect = `<select class="sort-select cond-op" data-idx="${idx}">
106 + const opSelect = `<select class="field cond-op" data-idx="${idx}">
107 107 ${ops.map(o => `<option value="${o}" ${o === condition.operator ? 'selected' : ''}>${escapeHtml(OP_LABELS[o] || o)}</option>`).join('')}
108 108 </select>`;
109 109
110 110 let valueInput;
111 111 if (condition.field === 'starred' || condition.field === 'unread') {
112 - valueInput = `<select class="sort-select cond-val" data-idx="${idx}">
112 + valueInput = `<select class="field cond-val" data-idx="${idx}">
113 113 <option value="true" ${condition.value === 'true' ? 'selected' : ''}>true</option>
114 114 <option value="false" ${condition.value === 'false' ? 'selected' : ''}>false</option>
115 115 </select>`;
116 116 } else if (condition.field === 'source') {
117 117 const sources = BB.state.sources || [];
118 - valueInput = `<select class="sort-select cond-val" data-idx="${idx}">
118 + valueInput = `<select class="field cond-val" data-idx="${idx}">
119 119 ${sources.map(s => `<option value="${escapeAttr(s.id)}" ${s.id === condition.value ? 'selected' : ''}>${escapeHtml(s.name)}</option>`).join('')}
120 120 </select>`;
121 121 } else {
122 - valueInput = `<input type="text" class="search-input cond-val" data-idx="${idx}" value="${escapeAttr(condition.value)}" placeholder="value">`;
122 + valueInput = `<input type="text" class="field cond-val" data-idx="${idx}" value="${escapeAttr(condition.value)}" placeholder="value">`;
123 123 }
124 124
125 125 row.innerHTML = `${fieldSelect} ${opSelect} ${valueInput}
@@ -126,7 +126,7 @@
126 126 const label = document.createElement('label');
127 127 label.textContent = 'Authorization Code';
128 128 const input = document.createElement('input');
129 - input.className = 'form-input';
129 + input.className = 'field';
130 130 input.type = 'text';
131 131 input.name = 'code';
132 132 input.required = true;
@@ -258,7 +258,7 @@
258 258 const label = document.createElement('label');
259 259 label.textContent = 'Encryption Password';
260 260 const input = document.createElement('input');
261 - input.className = 'form-input';
261 + input.className = 'field';
262 262 input.type = 'password';
263 263 input.name = 'password';
264 264 input.required = true;
@@ -383,7 +383,7 @@
383 383 const intervalLabel = document.createElement('label');
384 384 intervalLabel.textContent = 'Sync interval';
385 385 const intervalSelect = document.createElement('select');
386 - intervalSelect.className = 'form-input';
386 + intervalSelect.className = 'field';
387 387 [5, 15, 30, 60].forEach(min => {
388 388 const opt = document.createElement('option');
389 389 opt.value = min;
@@ -34,7 +34,7 @@
34 34 /** Unread/total count pill as a `.source-count` span. */
35 35 function countEl(text, allRead) {
36 36 const el = document.createElement('span');
37 - el.className = 'source-count' + (allRead ? ' all-read' : '');
37 + el.className = 'raised source-count' + (allRead ? ' all-read' : '');
38 38 el.textContent = text;
39 39 return el;
40 40 }
@@ -77,7 +77,7 @@
77 77 wrap.className = 'source-tags';
78 78 tags.forEach(t => {
79 79 const chip = document.createElement('span');
80 - chip.className = 'tag tag--xs';
80 + chip.className = 'raised tag tag--xs';
81 81 chip.setAttribute('data-color', 'muted');
82 82 chip.textContent = t;
83 83 wrap.appendChild(chip);
@@ -454,14 +454,14 @@
454 454 bar.innerHTML = '';
455 455
456 456 const allBtn = document.createElement('button');
457 - allBtn.className = 'tag-chip' + (currentTag === '' ? ' active' : '');
457 + allBtn.className = 'chip tag-chip' + (currentTag === '' ? ' latched active' : '');
458 458 allBtn.textContent = 'All';
459 459 allBtn.addEventListener('click', () => selectTag(''));
460 460 bar.appendChild(allBtn);
461 461
462 462 allTags.forEach(t => {
463 463 const button = document.createElement('button');
464 - button.className = 'tag-chip' + (currentTag === t ? ' active' : '');
464 + button.className = 'chip tag-chip' + (currentTag === t ? ' latched active' : '');
465 465 button.textContent = t;
466 466 button.addEventListener('click', () => selectTag(t));
467 467 bar.appendChild(button);
@@ -142,7 +142,7 @@
142 142
143 143 const select = document.createElement('select');
144 144 select.id = 'theme-selector';
145 - select.className = 'form-input';
145 + select.className = 'field';
146 146
147 147 const addGroup = (label, items) => {
148 148 const group = document.createElement('optgroup');