Skip to main content

max / makenotwork

Style the rebuilt discover sidebar from existing primitives Composes what the design charter already provides rather than inventing a parallel set: .filter-item carries each row and its .is-selected recipe, the typeahead folds into the .search-suggestions / .suggestion-item dropdown component, crumbs follow the .breadcrumb idiom, chips use the canonical color-mix selected tint, and spacing comes from --space-*. New rules are the tag spine, the drill-down rungs, the refine block, and the price buckets. Fixes two things the styling pass surfaced. .visually-hidden does not exist in this codebase; the utility is .sr-only. The tag typeahead's label carried the undefined class, so it rendered as visible body text. A test now asserts every class the sidebar uses has a rule, and that .visually-hidden stays absent, since reaching for it is the natural mistake. The mobile filter panel closed itself on every interaction. The toggle set .show on the sidebar, and the sidebar is replaced wholesale by the out-of-band swap, so the class went with it. State moves to .discover-layout, which the swap does not touch. The toggle also gains the aria-expanded it never had, and its .active class finally has a rule. Retires three rules the rebuild left dead: .filter-btn (every filter is a native control now), .filter-browse-link, and .price-distribution. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-18 19:49 UTC
Commit: 28f99fad6107121c7d8487a129a9f51f1733df59
Parent: 54809bb
6 files changed, +125 insertions, -20 deletions
@@ -17,8 +17,15 @@ window.onRemoveCartGroup = function () {
17 17
18 18 // discover.html: onclick="document.querySelector('.discover-sidebar').classList.toggle('show'); this.classList.toggle('active');"
19 19 window.toggleDiscoverFilters = function () {
20 - document.querySelector('.discover-sidebar').classList.toggle('show');
21 - this.classList.toggle('active');
20 + // The state lives on .discover-layout, not on the sidebar itself: the
21 + // sidebar is replaced wholesale by an out-of-band swap on every filter
22 + // change, which would drop a class set on it and snap the mobile panel
23 + // shut mid-interaction.
24 + var layout = document.querySelector('.discover-layout');
25 + if (!layout) return;
26 + var open = layout.classList.toggle('filters-open');
27 + this.classList.toggle('active', open);
28 + this.setAttribute('aria-expanded', open ? 'true' : 'false');
22 29 };
23 30
24 31 // index.html: onsubmit="return submitNotify(event)" (data-submit auto-prevents;
@@ -22,7 +22,7 @@
22 22 active = index;
23 23 items.forEach(function(el, i) {
24 24 var on = i === index;
25 - el.classList.toggle('is-active', on);
25 + el.classList.toggle('highlighted', on);
26 26 el.setAttribute('aria-selected', on ? 'true' : 'false');
27 27 });
28 28 if (index >= 0 && items[index]) {
@@ -60,7 +60,7 @@
60 60 li.id = 'tag-suggest-option-' + i;
61 61 li.setAttribute('role', 'option');
62 62 li.setAttribute('aria-selected', i === active ? 'true' : 'false');
63 - li.className = 'tag-suggest-item' + (i === active ? ' is-active' : '');
63 + li.className = 'suggestion-item tag-suggest-item' + (i === active ? ' highlighted' : '');
64 64 li.dataset.slug = item.slug;
65 65 var label = document.createElement('span');
66 66 label.className = 'tag-suggest-label';
@@ -68,7 +68,7 @@
68 68 li.appendChild(label);
69 69 if (item.context) {
70 70 var ctx = document.createElement('span');
71 - ctx.className = 'tag-suggest-context';
71 + ctx.className = 'suggestion-category tag-suggest-context';
72 72 ctx.textContent = item.context;
73 73 li.appendChild(ctx);
74 74 }
@@ -7450,7 +7450,6 @@ textarea:focus-visible {
7450 7450 .discover-sidebar { font-size: 0.85rem; }
7451 7451 .filter-section { margin-bottom: 1.25rem; }
7452 7452 .filter-title { font-family: var(--font-heading); font-weight: bold; font-size: 0.85rem; margin-bottom: 0.5rem; color: var(--content); }
7453 - .filter-browse-link { font-size: 0.75rem; opacity: 0.5; font-weight: normal; }
7454 7453 .filter-list { list-style: none; }
7455 7454 /* The row carries selection (`.is-selected` recipe) and hover; the filter itself
7456 7455 is a real <button> child so Enter/Space work without scripting. */
@@ -7458,11 +7457,6 @@ textarea:focus-visible {
7458 7457 .filter-item:hover { opacity: 0.6; }
7459 7458 .filter-item.is-selected { font-weight: bold; }
7460 7459 .filter-item .count { opacity: 0.4; font-size: 0.8rem; }
7461 - /* Strip the bare-button chrome: filters switch the view rather than commit
7462 - anything, so they carry no fill, border, or depth (charter: docs/design-system.md). */
7463 - .filter-btn { flex: 1; display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; padding: 0.25rem 0; background: none; border: none; box-shadow: none; font: inherit; color: inherit; text-align: left; cursor: pointer; }
7464 - .filter-btn:hover { box-shadow: none; }
7465 - .filter-btn:active { box-shadow: none; transform: none; }
7466 7460 .tag-follow-btn { background: none; border: 1px solid var(--border); cursor: pointer; font-family: var(--font-mono); font-size: 0.7rem; opacity: 0.55; padding: 0.1rem 0.45rem; color: var(--content); }
7467 7461 .tag-follow-btn:hover { opacity: 1; }
7468 7462 .tag-follow-btn.is-selected { opacity: 0.85; border-color: var(--focus-ring); background: color-mix(in oklch, var(--action) 8%, transparent); }
@@ -7471,9 +7465,68 @@ textarea:focus-visible {
7471 7465 .price-inputs { display: flex; gap: 0.25rem; align-items: center; margin-bottom: 0.5rem; }
7472 7466 .price-inputs input { width: 60px; padding: 0.3rem; font-size: 0.8rem; background: var(--surface-sunken); border: none; }
7473 7467 .price-inputs span { opacity: 0.4; }
7474 - .price-distribution { margin-top: 0.5rem; }
7475 - .price-distribution-item { font-size: 0.8rem; opacity: 0.6; padding: 0.1rem 0; }
7476 7468 .discover-main { min-width: 0; }
7469 +
7470 + /* ---- Discover sidebar: tag spine ----------------------------------------
7471 + Tags are the sidebar's structure, not one facet among several, so the spine
7472 + reads as the primary surface and .filter-refine below it is subordinate.
7473 + Everything here composes existing primitives: .filter-item carries the row
7474 + and its .is-selected recipe, .search-suggestions carries the dropdown shell,
7475 + .breadcrumb carries the crumb idiom. */
7476 + .tag-spine { padding-bottom: var(--space-4); border-bottom: 1px solid var(--border); }
7477 +
7478 + /* Selected tags. Also the active-filter summary, so they use the canonical
7479 + selected tint rather than a new colour. */
7480 + .tag-chips { list-style: none; display: flex; flex-wrap: wrap; gap: var(--space-1); margin-bottom: var(--space-3); }
7481 + .tag-chip { display: flex; align-items: center; gap: var(--space-1); padding: 0.15rem 0.4rem; font-size: 0.8rem; background: color-mix(in oklch, var(--action) 8%, transparent); border: 1px solid var(--focus-ring); }
7482 + .tag-chip-label { overflow-wrap: anywhere; }
7483 + .tag-chip-remove { text-decoration: none; color: var(--content-secondary); font-size: 1rem; line-height: 1; padding: 0 0.1rem; }
7484 + .tag-chip-remove:hover { color: var(--content); }
7485 + .tag-chip-clear { background: none; border: 1px dashed var(--border-strong); }
7486 + .tag-chip-clear a { text-decoration: none; color: var(--content-secondary); font-size: 0.8rem; }
7487 + .tag-chip-clear a:hover { color: var(--content); }
7488 +
7489 + /* Typeahead. Shares the .search-suggestions shell; it toggles with the
7490 + `hidden` attribute rather than an inline display, so the shared rule's
7491 + display:none has to be overridden when open. */
7492 + .tag-combobox { position: relative; margin-bottom: var(--space-3); }
7493 + .tag-combobox-input { width: 100%; padding: 0.3rem; font-size: 0.8rem; background: var(--surface-sunken); border: none; }
7494 + .tag-suggest-list { list-style: none; }
7495 + .tag-suggest-list:not([hidden]) { display: block; }
7496 + .tag-suggest-item { gap: var(--space-2); }
7497 + .tag-suggest-label { overflow-wrap: anywhere; }
7498 +
7499 + /* Drill-down. A rung either selects (assignable leaf) or navigates (category);
7500 + rows with both put the chevron after the label. */
7501 + .tag-crumbs { list-style: none; display: flex; flex-wrap: wrap; font-family: var(--font-mono); font-size: 0.75rem; color: var(--content-muted); margin-bottom: var(--space-2); }
7502 + .tag-crumbs li + li::before { content: "/"; opacity: 0.4; margin: 0 0.3rem; }
7503 + .tag-crumbs a { color: var(--content); text-decoration: none; }
7504 + .tag-crumbs a:hover { text-decoration: underline; }
7505 + .tag-drill-select { flex: 1; display: flex; align-items: center; gap: var(--space-2); cursor: pointer; padding: 0.15rem 0; }
7506 + .tag-drill-select input { width: auto; margin: 0; }
7507 + .tag-drill-select span:first-of-type { flex: 1; }
7508 + .tag-drill-into { display: flex; align-items: center; gap: var(--space-2); flex: 1; text-decoration: none; color: inherit; padding: 0.15rem 0; }
7509 + .tag-drill-into span:first-child { flex: 1; }
7510 + .tag-drill-chevron { opacity: 0.4; }
7511 +
7512 + /* ---- Discover sidebar: refine block -------------------------------------
7513 + Subordinate to the spine: same controls, quieter. */
7514 + .filter-refine { margin-top: var(--space-4); }
7515 + .filter-fieldset { border: none; padding: 0; margin: 0; min-width: 0; }
7516 + .filter-check { flex: 1; display: flex; align-items: center; gap: var(--space-2); cursor: pointer; padding: 0.15rem 0; }
7517 + .filter-check input { width: auto; margin: 0; }
7518 + .filter-check span:first-of-type { flex: 1; }
7519 +
7520 + /* A zero-count facet stays visible but inert, so the shape of the catalog
7521 + stays legible as filters narrow. No existing rule covers a disabled input
7522 + inside a label, so this is the checkbox counterpart of the button rule. */
7523 + .filter-item:has(input:disabled) { opacity: 0.5; }
7524 + .filter-item:has(input:disabled) .filter-check { cursor: not-allowed; }
7525 +
7526 + /* Price buckets are links carrying a range, not form state. */
7527 + .price-buckets { margin-top: var(--space-2); }
7528 + .price-bucket { flex: 1; display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); text-decoration: none; color: inherit; padding: 0.15rem 0; }
7529 +
7477 7530 .mode-toggle { display: flex; gap: 0.5rem; margin-bottom: 0.75rem; }
7478 7531 .toggle-btn { background: var(--surface-sunken); border: none; padding: 0.4rem 0.8rem; font-size: 0.85rem; font-family: inherit; cursor: pointer; transition: background 0.1s ease, opacity 0.1s ease; }
7479 7532 .toggle-btn:hover { opacity: 0.7; }
@@ -7544,6 +7597,8 @@ textarea:focus-visible {
7544 7597 margin-bottom: 1rem;
7545 7598 }
7546 7599
7600 + .discover-filter-toggle.active { background: var(--surface-sunken); border-color: var(--border-strong); }
7601 +
7547 7602 .discover-filter-toggle .filter-count {
7548 7603 background: var(--action);
7549 7604 color: var(--primary-light);
@@ -7557,7 +7612,9 @@ textarea:focus-visible {
7557 7612 .discover-filter-toggle { display: inline-block; }
7558 7613 .discover-layout { grid-template-columns: 1fr; }
7559 7614 .discover-sidebar { display: none; }
7560 - .discover-sidebar.show { display: block; margin-bottom: 1rem; }
7615 + /* Open state lives on the layout, not the sidebar: the sidebar is replaced
7616 + by an out-of-band swap on every filter change and would lose the class. */
7617 + .discover-layout.filters-open .discover-sidebar { display: block; margin-bottom: 1rem; }
7561 7618 .table-header, .table-row { grid-template-columns: 40px 1fr 60px; }
7562 7619 .table-row-item { grid-template-columns: 1fr 30px; }
7563 7620 .table-row-item .table-row-link { grid-template-columns: 40px 1fr 60px; }
@@ -7706,7 +7763,8 @@ textarea:focus-visible {
7706 7763 display: block;
7707 7764 }
7708 7765
7709 - .suggestion-item {
7766 + .suggestion-item,
7767 + .tag-suggest-item {
7710 7768 padding: 0.5rem 0.75rem;
7711 7769 cursor: pointer;
7712 7770 font-size: 0.9rem;
@@ -7850,7 +7908,8 @@ button.saved { border-color: var(--action); color: var(--action); }
7850 7908 =========================================== */
7851 7909
7852 7910 .search-wrapper { position: relative; flex: 1; min-width: 0; }
7853 - .search-suggestions {
7911 + .search-suggestions,
7912 + .tag-suggest-list {
7854 7913 display: none; position: absolute; left: 0; right: 0; top: 100%; z-index: 50;
7855 7914 background: var(--surface-page); border: 1px solid var(--border); border-top: none;
7856 7915 box-shadow: 0 4px 12px rgba(0,0,0,0.1); max-height: 280px; overflow-y: auto;
@@ -7860,7 +7919,8 @@ button.saved { border-color: var(--action); color: var(--action); }
7860 7919 padding: 0.5rem 0.75rem; text-decoration: none; color: var(--content);
7861 7920 font-size: 0.85rem; cursor: pointer;
7862 7921 }
7863 - .suggestion-item:hover, .suggestion-item.highlighted { background: var(--surface-sunken); }
7922 + .suggestion-item:hover, .suggestion-item.highlighted,
7923 + .tag-suggest-item:hover, .tag-suggest-item.highlighted { background: var(--surface-sunken); }
7864 7924 .suggestion-category { font-size: 0.75rem; opacity: 0.5; text-transform: uppercase; letter-spacing: 0.05em; }
7865 7925
7866 7926 /* ===========================================
@@ -11,7 +11,7 @@
11 11 <div class="discover-layout{% if mode == "projects" && sidebar.category_filters.is_empty() %} no-sidebar{% endif %}">
12 12 {% include "partials/discover_sidebar.html" %}
13 13
14 - <button type="button" class="discover-filter-toggle" data-action="toggleDiscoverFilters" aria-label="Toggle filters">
14 + <button type="button" class="discover-filter-toggle" data-action="toggleDiscoverFilters" aria-expanded="false" aria-controls="discover-sidebar" aria-label="Toggle filters">
15 15 Filters{% if sidebar.active_filter_count > 0 %} <span class="filter-count">{{ sidebar.active_filter_count }}</span>{% endif %}
16 16 </button>
17 17
@@ -79,12 +79,12 @@
79 79 makes a taxonomy of arbitrary size navigable and what the old
80 80 capped ten-tag list plus "Browse all" could not do. #}
81 81 <div class="tag-combobox">
82 - <label class="visually-hidden" for="tag-search">Find a tag</label>
82 + <label class="sr-only" for="tag-search">Find a tag</label>
83 83 <input type="search" id="tag-search" class="tag-combobox-input" hx-preserve="true"
84 84 placeholder="Find a tag" autocomplete="off"
85 85 role="combobox" aria-expanded="false"
86 86 aria-controls="tag-suggest-list" aria-autocomplete="list">
87 - <ul id="tag-suggest-list" class="tag-suggest-list" role="listbox" hidden></ul>
87 + <ul id="tag-suggest-list" class="search-suggestions tag-suggest-list" role="listbox" hidden></ul>
88 88 </div>
89 89
90 90 {# Drill-down. `browse` moves the cursor, `tag` selects; a
@@ -442,6 +442,44 @@ async fn price_filter_round_trips_into_its_inputs() {
442 442 }
443 443
444 444 // ---------------------------------------------------------------------------
445 + // Markup/stylesheet contract.
446 + // ---------------------------------------------------------------------------
447 +
448 + /// Sidebar markup only uses class hooks the stylesheet defines.
449 + ///
450 + /// A class with no rule is invisible in review and silently unstyled in
451 + /// production. `.visually-hidden` shipped this way: it does not exist in this
452 + /// codebase (the utility is `.sr-only`), so the "Find a tag" label rendered as
453 + /// visible body text until someone read the stylesheet.
454 + #[tokio::test]
455 + async fn sidebar_uses_only_defined_style_hooks() {
456 + let css = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/static/style.css"))
457 + .expect("read style.css");
458 +
459 + // Every class the rebuilt sidebar introduces. Kept explicit rather than
460 + // scraped from the template so adding a hook is a deliberate act.
461 + for class in [
462 + "tag-spine", "tag-chips", "tag-chip", "tag-chip-label", "tag-chip-remove",
463 + "tag-chip-clear", "tag-combobox", "tag-combobox-input", "tag-suggest-list",
464 + "tag-suggest-item", "tag-suggest-label", "tag-crumbs", "tag-drill-select",
465 + "tag-drill-into", "tag-drill-chevron", "filter-refine", "filter-fieldset",
466 + "filter-check", "price-buckets", "price-bucket", "sr-only",
467 + ] {
468 + assert!(
469 + css.contains(&format!(".{class}")),
470 + "class {class} is used by the sidebar but has no rule in style.css"
471 + );
472 + }
473 +
474 + // The utility this codebase does not have. Guarding the name directly
475 + // because reaching for it is the natural mistake.
476 + assert!(
477 + !css.contains(".visually-hidden"),
478 + "use .sr-only; .visually-hidden is not this codebase's utility"
479 + );
480 + }
481 +
482 + // ---------------------------------------------------------------------------
445 483 // Form/handler name contract.
446 484 // ---------------------------------------------------------------------------
447 485