From b0a2dcbc210a06ec3a7bd98f4be5c43249f9945a Mon Sep 17 00:00:00 2001 From: Niels Mokkenstorm Date: Mon, 10 Aug 2026 12:04:01 +0200 Subject: [PATCH 01/27] fix: migrate the at-record theme key and split meta theme-color for dark mode --- web/priv/static/index.html | 23 +++++++++++++++++++++-- web/priv/static/manifest.webmanifest | 4 ++-- web/src/crate_web.gleam | 2 +- web/src/crate_web/ffi.ts | 8 ++++++++ web/src/crate_web/prefs.gleam | 25 +++++++++++++++++++++++++ 5 files changed, 57 insertions(+), 5 deletions(-) diff --git a/web/priv/static/index.html b/web/priv/static/index.html index 14bcf6a..5748841 100644 --- a/web/priv/static/index.html +++ b/web/priv/static/index.html @@ -3,12 +3,31 @@ - + + crate diff --git a/web/src/crate_web.gleam b/web/src/crate_web.gleam index 493fe9b..a95e1d7 100644 --- a/web/src/crate_web.gleam +++ b/web/src/crate_web.gleam @@ -141,7 +141,7 @@ fn initial_display() -> model.Display { } fn initial_theme() -> model.Theme { - prefs.get_theme() + prefs.get(prefs.theme_key) |> result.map(model.theme_from_string) |> result.unwrap(model.System) } diff --git a/web/src/crate_web/ffi.ts b/web/src/crate_web/ffi.ts index 4f22d4c..1ac100c 100644 --- a/web/src/crate_web/ffi.ts +++ b/web/src/crate_web/ffi.ts @@ -19,9 +19,26 @@ export function applyTheme(theme: string): void { } if (theme === "light" || theme === "dark") { root.setAttribute("data-theme", theme); + syncThemeColor(theme); return; } root.removeAttribute("data-theme"); + syncThemeColor(theme); +} + +// The browser chrome follows the app's own theme, not the OS: an explicit +// light/dark choice has to beat prefers-color-scheme, which a media-keyed +// pair cannot express. +function syncThemeColor(theme: string): void { + const meta = globalThis.document?.getElementById("theme-color"); + if (!meta) { + return; + } + const prefersDark = + !!globalThis.matchMedia && + globalThis.matchMedia("(prefers-color-scheme: dark)").matches; + const dark = theme === "dark" || (theme !== "light" && prefersDark); + meta.setAttribute("content", dark ? "#191008" : "#f1e7d2"); } // A real full-page navigation, since modem would otherwise intercept a same-origin click. @@ -157,14 +174,6 @@ export function prefsSet(key: string, value: string): void { } } -export function prefsRemove(key: string): void { - try { - globalThis.localStorage?.removeItem(key); - } catch { - // best-effort - } -} - // The page origin, so a relative BFF path can be made absolute for the // gleam_http request the async xrpc transport builds (fetch resolves relative // urls itself, but request.to() needs an absolute one). diff --git a/web/src/crate_web/prefs.gleam b/web/src/crate_web/prefs.gleam index 925b4f1..ea983e3 100644 --- a/web/src/crate_web/prefs.gleam +++ b/web/src/crate_web/prefs.gleam @@ -16,18 +16,11 @@ fn ffi_get(key: String) -> Dynamic @external(javascript, "./ffi.ts", "prefsSet") fn ffi_set(key: String, value: String) -> Nil -@external(javascript, "./ffi.ts", "prefsRemove") -fn ffi_remove(key: String) -> Nil - /// The crate page's grid/rows toggle. pub const display_key = "display" pub const theme_key = "theme" -/// Every key not-theme started fresh under "crate:"; theme is the only one -/// that shipped real user data under the pre-rename "at-record:" namespace. -const legacy_theme_key = "at-record:theme" - /// Locally-ignored `catalog.edit` proposal uri+cid pairs, capped and JSON-encoded. pub const ignored_proposals_key = "ignored-proposals" @@ -51,24 +44,6 @@ pub fn set(key: String, value: String) -> Nil { ffi_set(namespaced(key), value) } -/// Reads the theme, migrating a value left under the pre-rename key on first -/// read: adopt it under the new key, then drop the old one so this only -/// costs a lookup once per user. -pub fn get_theme() -> Result(String, Nil) { - case get(theme_key) { - Ok(value) -> Ok(value) - Error(Nil) -> - ffi_get(legacy_theme_key) - |> decode.run(decode.string) - |> result.map(fn(value) { - set(theme_key, value) - ffi_remove(legacy_theme_key) - value - }) - |> result.replace_error(Nil) - } -} - pub fn encode_ignored_proposals(ignored: List(#(String, String))) -> String { ignored |> json.array(fn(pair) { -- 2.51.2 From 685e2d2bffc39bb746f93ba3d718128226291ce5 Mon Sep 17 00:00:00 2001 From: Niels Mokkenstorm Date: Mon, 10 Aug 2026 13:47:23 +0200 Subject: [PATCH 17/27] fix: stop a bare RETRY button double-submitting the add form --- web/src/crate_web/pages/add.gleam | 3 +- web/src/crate_web/pages/login.gleam | 3 +- web/src/crate_web/ui/controls.gleam | 21 +++++++++++- web/src/crate_web/update/add.gleam | 4 +++ web/test/add_test.gleam | 53 +++++++++++++++++++++++++---- 5 files changed, 73 insertions(+), 11 deletions(-) diff --git a/web/src/crate_web/pages/add.gleam b/web/src/crate_web/pages/add.gleam index f94aec8..90bb60d 100644 --- a/web/src/crate_web/pages/add.gleam +++ b/web/src/crate_web/pages/add.gleam @@ -54,8 +54,7 @@ fn add_form_view(model: Model) -> Element(Msg) { frm.field("FOLDER", "Rock A-M", form.folder, FormFolder, "text"), acquisition_view(form), form_error_region(model.form_error), - ctl.button(save_label(model.busy), ctl.Primary, [ - attr.type_("submit"), + ctl.submit_button(save_label(model.busy), ctl.Primary, [ attr.disabled(model.busy), attr.class("btn--block"), ]), diff --git a/web/src/crate_web/pages/login.gleam b/web/src/crate_web/pages/login.gleam index 2c3b0c6..cd9543e 100644 --- a/web/src/crate_web/pages/login.gleam +++ b/web/src/crate_web/pages/login.gleam @@ -161,9 +161,8 @@ fn login_action(handle: String) -> Element(Msg) { // FFI. An anchor would be hijacked by modem (same-origin) and never reach // the server. Submission lives on the form so Enter works too. let disabled = string.trim(handle) == "" - ctl.button("LOG IN WITH ATPROTO →", ctl.Primary, [ + ctl.submit_button("LOG IN WITH ATPROTO →", ctl.Primary, [ attr.disabled(disabled), attr.class("btn--block"), - attr.type_("submit"), ]) } diff --git a/web/src/crate_web/ui/controls.gleam b/web/src/crate_web/ui/controls.gleam index 5a00257..90b8e43 100644 --- a/web/src/crate_web/ui/controls.gleam +++ b/web/src/crate_web/ui/controls.gleam @@ -76,12 +76,31 @@ pub fn status_badge(status: String) -> Element(msg) { } } +/// Always `type="button"`: inside a `
` a bare button is a submit +/// button, so an `on_click` sticker would fire its message and submit the +/// form. Form submits go through `submit_button`, which is the only way to +/// get `type="submit"` here; passing one in `attrs` would emit a duplicate +/// `type` attribute rather than override it. pub fn button( label: String, variant: Btn, attrs: List(attr.Attribute(msg)), ) -> Element(msg) { - html.button([attr.class(btn_class(variant)), ..attrs], [text(label)]) + html.button([attr.class(btn_class(variant)), attr.type_("button"), ..attrs], [ + text(label), + ]) +} + +/// The one button that submits its enclosing form; everything else is a +/// `button`. +pub fn submit_button( + label: String, + variant: Btn, + attrs: List(attr.Attribute(msg)), +) -> Element(msg) { + html.button([attr.class(btn_class(variant)), attr.type_("submit"), ..attrs], [ + text(label), + ]) } pub fn link_button(label: String, variant: Btn, href: String) -> Element(msg) { diff --git a/web/src/crate_web/update/add.gleam b/web/src/crate_web/update/add.gleam index f77c236..7561527 100644 --- a/web/src/crate_web/update/add.gleam +++ b/web/src/crate_web/update/add.gleam @@ -12,6 +12,7 @@ import crate_web/msg.{ type Msg, ArtistSearch, DisarmDiscogsDisconnect, DiscogsSearch, } import crate_web/update/common.{failed, succeeded, write_error} +import gleam/bool import gleam/int import gleam/list import gleam/option.{type Option, None, Some} @@ -105,7 +106,10 @@ pub fn form_counterparty(model: Model, value: String) -> #(Model, Effect(Msg)) { ) } +/// Guarded on `busy`, so a double dispatch (a stray form submit racing the +/// button's own click) can never produce two `add_item` writes. pub fn submit_add(model: Model) -> #(Model, Effect(Msg)) { + use <- bool.guard(when: model.busy, return: #(model, effect.none())) case string.trim(model.form.title), string.trim(model.form.artist), diff --git a/web/test/add_test.gleam b/web/test/add_test.gleam index 6a9664e..e91c890 100644 --- a/web/test/add_test.gleam +++ b/web/test/add_test.gleam @@ -8,10 +8,12 @@ import crate_web/msg.{ } import crate_web/pages/add import crate_web/update.{update} +import gleam/list import gleam/option.{None, Some} +import gleam/result import gleam/string import lustre/element -import support.{base, logged_in} +import support.{base, empty_effect, logged_in} // Validation failures are page-local: they never touch the entered fields, // and they show as an inline region above SAVE TO CRATE rather than the @@ -167,11 +169,50 @@ pub fn no_form_error_renders_nothing_extra_test() { assert !string.contains(html, "class=\"error-state\"") } -pub fn segment_buttons_never_submit_the_form_test() { +/// Inside a `` a bare `", - ) + let back = "aria-label=\"Back\"" + assert string.contains(tag_with(html, "button", back), "class=\"icon-btn\"") + assert list.filter(open_tags(html, "a"), string.contains(_, back)) == [] } pub fn every_route_change_bumps_nav_depth_test() { diff --git a/web/test/record_amend_test.gleam b/web/test/record_amend_test.gleam index 3f22d81..9bbfac8 100644 --- a/web/test/record_amend_test.gleam +++ b/web/test/record_amend_test.gleam @@ -140,7 +140,12 @@ pub fn record_amend_page_titles_itself_amend_regardless_of_provenance_test() { ShelfLoaded(Own, [an_entry()]), ) let html = view.view(seeded) |> element.to_string - assert string.contains(html, "AMEND") + // "AMEND" alone is always present: the submit button reads PUBLISH + // AMENDMENT. Assert the app bar's own title instead. + assert string.contains( + html, + "AMEND", + ) }) } diff --git a/web/test/record_test.gleam b/web/test/record_test.gleam index c29127b..ac5b76e 100644 --- a/web/test/record_test.gleam +++ b/web/test/record_test.gleam @@ -14,7 +14,7 @@ import gleam/dict import gleam/option.{type Option, None, Some} import gleam/string import lustre/element -import support.{a_detail, an_entry, logged_in} +import support.{a_detail, an_entry, logged_in, tag_with} /// `a_detail()` with its folded entry swapped for `entry`. fn detail(entry: Entry) -> EntryDetail { @@ -188,10 +188,8 @@ pub fn record_view_shows_a_copy_share_link_button_with_the_public_path_test() { // screen, not a button dispatching a toggle message. pub fn record_view_renders_the_amend_action_as_a_link_to_its_own_route_test() { let html = record.view(logged_in(), a_detail()) |> element.to_string - assert string.contains( - html, - "AMEND", - ) + assert string.contains(amend_link(html), "class=\"btn btn--ghost\"") + assert string.contains(html, ">AMEND") } // AMEND is the one canonical label for the action regardless of who minted @@ -201,13 +199,15 @@ pub fn record_view_labels_the_amend_link_as_amend_for_an_adopted_entry_too_test( let html = record.view(Model(..logged_in(), via_handles:), a_detail()) |> element.to_string - assert string.contains( - html, - "AMEND", - ) + assert string.contains(amend_link(html), "class=\"btn btn--ghost\"") + assert string.contains(html, ">AMEND") assert !string.contains(html, "SUGGEST A FIX") } +fn amend_link(html: String) -> String { + tag_with(html, "a", "href=\"/record/e1/amend\"") +} + pub fn record_view_labels_move_to_history_not_remove_test() { let html = record.view(logged_in(), a_detail()) |> element.to_string assert string.contains(html, "MOVE TO HISTORY") diff --git a/web/test/states_test.gleam b/web/test/states_test.gleam index 03a724d..0fea21c 100644 --- a/web/test/states_test.gleam +++ b/web/test/states_test.gleam @@ -1,38 +1,53 @@ //// Shared async-state primitives: the ARIA live regions on loading/error, -//// and that the retry variants wire RETRY without forcing it on callers -//// that have nothing to retry. +//// which sticker headline each failure earns, and which of them offer a +//// RETRY the visitor can actually act on. import crate_web/ui/states +import gleam/list import gleam/string -import lustre/element +import lustre/element.{type Element} pub type TestMsg { Retry } -pub fn loading_page_announces_as_status_test() { - let html = states.loading_page() |> element.to_string - assert string.contains(html, "role=\"status\"") +fn rendered(el: Element(TestMsg)) -> String { + element.to_string(el) |> string.replace("'", "'") } -pub fn failed_page_announces_as_alert_test() { - let html = states.failed_page("Couldn't load.") |> element.to_string - assert string.contains(html, "role=\"alert\"") +/// A RETRY only belongs on a state where re-running the same action could +/// land differently: a fetch or a write, never client-side validation. +pub fn each_state_announces_itself_and_offers_retry_only_where_it_helps_test() { + [ + #(states.loading_page(), "status", False), + #(states.failed_page("Couldn't load."), "alert", False), + #(states.failed_page_retry("Couldn't load.", Retry), "alert", True), + #(states.error_sticker("Nope."), "alert", False), + #(states.error_sticker_retry("Nope.", Retry), "alert", True), + #(states.write_error_sticker("Nope.", Retry), "alert", True), + #(states.invalid_input_sticker("Nope."), "alert", False), + ] + |> list.each(fn(row) { + let #(el, role, retryable) = row + let html = rendered(el) + assert string.contains(html, "role=\"" <> role <> "\"") + assert string.contains(html, "RETRY") == retryable + }) } -pub fn failed_page_has_no_retry_action_by_default_test() { - let html = states.failed_page("Couldn't load.") |> element.to_string - assert !string.contains(html, "RETRY") -} - -pub fn failed_page_retry_wires_the_retry_message_test() { - let html = - states.failed_page_retry("Couldn't load.", Retry) |> element.to_string - assert string.contains(html, "RETRY") - assert string.contains(html, "role=\"alert\"") -} - -pub fn error_sticker_retry_wires_the_retry_message_test() { - let html = states.error_sticker_retry("Nope.", Retry) |> element.to_string - assert string.contains(html, "RETRY") +/// The headline names what actually went wrong: a failed write never loaded +/// anything, and a rejected input is not a failure of the app at all. +pub fn each_sticker_names_its_own_kind_of_failure_test() { + [ + #(states.error_sticker("x"), "✕ COULDN'T LOAD"), + #(states.error_sticker_retry("x", Retry), "✕ COULDN'T LOAD"), + #(states.write_error_sticker("x", Retry), "✕ COULDN'T SAVE"), + #(states.invalid_input_sticker("x"), "✕ CHECK YOUR ENTRIES"), + ] + |> list.each(fn(row) { + let #(el, sticker) = row + let html = rendered(el) + assert string.contains(html, sticker) + assert string.contains(html, "x") + }) } diff --git a/web/test/support.gleam b/web/test/support.gleam index e62802d..94a8883 100644 --- a/web/test/support.gleam +++ b/web/test/support.gleam @@ -13,7 +13,10 @@ import crate_web/model.{ import crate_web/msg.{type ApiError} import crate_web/pages/crate import gleam/dict +import gleam/list import gleam/option.{None, Some} +import gleam/result +import gleam/string import lustre/effect.{type Effect} import lustre/element @@ -164,3 +167,24 @@ pub fn discogs_result(id: Int) -> DiscogsResult { cover_url: None, ) } + +/// Every `` open tag in `html`, attributes only. Assertions use +/// this instead of a literal tag string so a Lustre release that reorders +/// attribute serialisation can't red the suite over nothing. +pub fn open_tags(html: String, name: String) -> List(String) { + string.split(html, "<" <> name) + |> list.drop(1) + |> list.filter(fn(rest) { + string.starts_with(rest, " ") || string.starts_with(rest, ">") + }) + |> list.map(fn(rest) { + string.split(rest, ">") |> list.first |> result.unwrap("") + }) +} + +/// The first `name` open tag carrying `needle`, or "" when there is none. +pub fn tag_with(html: String, name: String, needle: String) -> String { + open_tags(html, name) + |> list.find(string.contains(_, needle)) + |> result.unwrap("") +} -- 2.51.2 From 8de47ea5fd61077ab1e83c11a76aa9a13b20bb82 Mon Sep 17 00:00:00 2001 From: Niels Mokkenstorm Date: Mon, 10 Aug 2026 13:55:04 +0200 Subject: [PATCH 21/27] fix: light the add slot on the scan flow it links into --- web/src/crate_web/route.gleam | 10 +++++----- web/test/nav_test.gleam | 23 +++++++++++++++++++---- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/web/src/crate_web/route.gleam b/web/src/crate_web/route.gleam index 9343604..4f18f60 100644 --- a/web/src/crate_web/route.gleam +++ b/web/src/crate_web/route.gleam @@ -49,15 +49,15 @@ pub fn to_path(route: Route) -> String { /// Which bottom-tab section a route belongs to, so drill-downs off a tab /// (a record, the scan flow, a pressing) still show that tab active instead -/// of none. `Add` owns its own section rather than folding into `Crate`, so -/// the CRATE tab doesn't light up while the visitor is on the add form; the -/// centre "+" slot lights up instead, see `nav.add_tab`. +/// of none. The centre "+" slot owns the whole add flow, scan included: it +/// links into `Scan`, so anything less would have it go dark on its own +/// destination while CRATE lit up instead (see `nav.add_tab`). /// `PublicCrate`/`PublicRecord` map to themselves since they're reached /// outside the tab bar entirely. pub fn section(route: Route) -> Route { case route { - Crate | Scan | ScanReview | ScanDone | Record(_) | RecordAmend(_) -> Crate - Add -> Add + Crate | Record(_) | RecordAmend(_) -> Crate + Add | Scan | ScanReview | ScanDone -> Add Browse | PressingDetail(_, _) -> Browse Feed -> Feed Settings | EditInbox | EditProposalDetail(_) -> Settings diff --git a/web/test/nav_test.gleam b/web/test/nav_test.gleam index 971bc1a..cc2da9f 100644 --- a/web/test/nav_test.gleam +++ b/web/test/nav_test.gleam @@ -19,7 +19,7 @@ import gleam/string import lustre/element import support.{an_entry, empty_effect, logged_in, open_tags, tag_with} -/// Every drill-down maps to its tab's own section (crate/browse/settings +/// Every drill-down maps to its tab's own section (crate/add/browse/settings /// each own several, feed maps only to itself); public routes are the one /// family left unmapped (each maps to itself instead of a real section). pub fn route_section_maps_every_drilldown_to_its_tab_test() { @@ -28,9 +28,9 @@ pub fn route_section_maps_every_drilldown_to_its_tab_test() { #(Record("e1"), Crate), #(RecordAmend("e1"), Crate), #(Add, Add), - #(Scan, Crate), - #(ScanReview, Crate), - #(ScanDone, Crate), + #(Scan, Add), + #(ScanReview, Add), + #(ScanDone, Add), #(Browse, Browse), #(PressingDetail("did:plc:abc", "3jz"), Browse), #(Feed, Feed), @@ -141,3 +141,18 @@ pub fn every_route_change_clears_the_page_scoped_state_test() { assert after.discogs.confirm_disconnect == False }) } + +/// The "+" slot links into the scan flow, so it has to light up there: a +/// control that goes dark on its own destination (and hands the highlight +/// to CRATE) is telling the visitor they went somewhere else. +pub fn the_add_slot_lights_up_on_its_own_destination_test() { + [Scan, ScanReview, ScanDone, Add] + |> list.each(fn(destination) { + let html = + view.view(Model(..logged_in(), route: destination)) |> element.to_string + let slot = tag_with(html, "a", "aria-label=\"Add a record\"") + assert string.contains(slot, "class=\"tab-add is-active\"") + assert string.contains(slot, "aria-current=\"page\"") + assert !string.contains(tag_with(html, "a", "href=\"/\""), "is-active") + }) +} -- 2.51.2 From 9cfe70954cffd10fcf40e46d3f8b4c4042e09052 Mon Sep 17 00:00:00 2001 From: Niels Mokkenstorm Date: Mon, 10 Aug 2026 14:02:26 +0200 Subject: [PATCH 22/27] fix: make the tap-target halo opt-in and stop it stealing taps --- web/css/03-settings.css | 12 ++++--- web/css/04-controls.css | 7 ++-- web/css/06-crate.css | 27 ++++++++++++---- web/css/07-browse.css | 6 ++-- web/css/10-edit-inbox.css | 6 ++-- web/css/13-scan.css | 3 +- web/css/14-windowing.css | 9 ++++-- web/css/15-misc.css | 6 ++-- web/css/19-edit-proposal.css | 4 +-- web/css/90-interaction.css | 62 ++++++++++++++++-------------------- 10 files changed, 71 insertions(+), 71 deletions(-) diff --git a/web/css/03-settings.css b/web/css/03-settings.css index 837b3db..d62bb2b 100644 --- a/web/css/03-settings.css +++ b/web/css/03-settings.css @@ -35,12 +35,14 @@ font-size: 18px; line-height: 1; } -/* The theme segment is flex-shrunk to fit the row, leaving a sliver of the - row's own right padding between the segment's border and the row's - border. With the segment's own right border and shadow still drawn there, - that sliver reads as a clipped fourth segment; drop both so the segment's - fill runs straight into the row's border instead. */ +/* The segment closes the row's right edge rather than floating 14px inside it, + so the row's own border is the active option's ink edge; its right border + there would double the row's, and its shadow would fall outside the row. */ +.settings-row:has(> .segment) { + padding-right: 0; +} .settings-row .segment { + align-self: stretch; border-right: none; box-shadow: none; } diff --git a/web/css/04-controls.css b/web/css/04-controls.css index bc41c58..987972f 100644 --- a/web/css/04-controls.css +++ b/web/css/04-controls.css @@ -118,11 +118,8 @@ .btn--block { width: 100%; } -/* A smaller footprint for buttons sitting inline in chrome rows (e.g. the - public-crate hero bar) rather than a form/card's own action row. Still a - real 44px box, not a halo: `.btn` shows up in rows an author can pack - tightly, so its tap target is never allowed to depend on empty neighbour - space. 12px font + 14px×2 padding + 2px×2 border = 44px. */ +/* Inline in chrome rows rather than a form's action row, and still a real 44px + box with no halo: 12px font + 14px×2 padding + 2px×2 border. */ .btn--compact { padding: 14px 14px; font-size: 12px; diff --git a/web/css/06-crate.css b/web/css/06-crate.css index 276abf3..a273a35 100644 --- a/web/css/06-crate.css +++ b/web/css/06-crate.css @@ -5,7 +5,12 @@ .hero { position: relative; flex-shrink: 0; - height: 132px; + display: flex; + flex-direction: column; + /* min- rather than a fixed height so the public variant's in-flow bar can + grow the band when a long handle wraps; the crate variant's children are + all absolute, so it stays exactly 132px. */ + min-height: 132px; border-bottom: 3px solid var(--ink); overflow: hidden; /* Greenhouse art; the gradient beneath paints while (or if) the photo never loads. */ @@ -39,17 +44,23 @@ letter-spacing: 1.5px; padding: 2px 0; } -/* Public crate: back button and handle cluster left instead of space-between. */ +/* Public crate: back button and handle cluster left instead of space-between. + In flow, unlike the base bar, so a wrapped handle grows the hero instead of + printing over the overlap panel and count below it. */ .hero__bar--public { + position: static; justify-content: flex-start; } -/* The handle is the identity, not decoration: it wraps instead of ellipsing. - overflow-wrap covers the no-spaces case (a handle breaks only at its own - dots by nature, which isn't always enough room). */ +/* The handle is the identity, not decoration: it wraps instead of ellipsing, + anywhere rather than at its own dots, which are too sparse to rely on. The + 22px display face is sized for the 5-letter wordmark and wraps a median + handle to two lines, so this takes the same mono as .settings-account__handle. */ .hero__brand--handle { text-transform: uppercase; min-width: 0; overflow-wrap: anywhere; + font: 700 16px/1.2 var(--mono); + letter-spacing: 0.3px; } /* Pushed to the row's far end regardless of how little space the handle needs. */ .hero__follow { @@ -81,7 +92,7 @@ /* Bottom-aligned flow stack, so an optional taste-overlap panel can grow it without hand-tuned absolute offsets. */ .hero__content { position: relative; - height: 100%; + flex: 1; display: flex; flex-direction: column; align-items: flex-start; @@ -139,9 +150,11 @@ justify-content: space-between; margin-bottom: 14px; } +/* 10px so the filter chips' 5px halos meet without overlapping, which would + hand the shared strip to whichever chip paints last. */ .tags { display: flex; - gap: 8px; + gap: 10px; flex-wrap: wrap; } diff --git a/web/css/07-browse.css b/web/css/07-browse.css index 5147835..a8661c6 100644 --- a/web/css/07-browse.css +++ b/web/css/07-browse.css @@ -88,10 +88,8 @@ display: flex; gap: 6px; } -/* Carries `.btn`, so it opts out of the shared halo (90-interaction.css); - the two actions sit only 6px apart, too tight to trust a halo not to - bleed onto the neighbour. 11px font + 15px×2 padding + 2px×2 border = - 45px, earned for real. */ +/* 45px in the box itself (11px font + 15px×2 padding + 2px×2 border); the two + actions sit 6px apart, too tight for a halo to grow into. */ .browse-card__actions .btn { flex: 1; padding: 15px 8px; diff --git a/web/css/10-edit-inbox.css b/web/css/10-edit-inbox.css index 225101f..d1290f2 100644 --- a/web/css/10-edit-inbox.css +++ b/web/css/10-edit-inbox.css @@ -77,10 +77,8 @@ display: flex; gap: 12px; } -/* Vertical padding is redundant now that the `.btn` base already reaches - 47px on its own; only the extra horizontal room and the heavier - border/shadow (approve/reject deserve more visual weight) are this - row's own. */ +/* Approve and reject carry more consequence than a normal action, so they get + more visual weight: a heavier border, a deeper shadow, wider flanks. */ .actions .btn { border-width: 2.5px; box-shadow: var(--shadow-md); diff --git a/web/css/13-scan.css b/web/css/13-scan.css index a63b82f..294fcaf 100644 --- a/web/css/13-scan.css +++ b/web/css/13-scan.css @@ -56,8 +56,7 @@ text-overflow: ellipsis; white-space: nowrap; } -/* Carries `.btn`, so it opts out of the shared halo (90-interaction.css) - and earns 44px for real: 13px font + 14px×2 padding + 2px×2 border. */ +/* 45px in the box itself, no halo: 13px font + 14px×2 padding + 2px×2 border. */ .scan-row__dismiss { padding: 14px 12px; } diff --git a/web/css/14-windowing.css b/web/css/14-windowing.css index 296bdd8..f618051 100644 --- a/web/css/14-windowing.css +++ b/web/css/14-windowing.css @@ -1,6 +1,7 @@ /* --- crate windowing (LOAD MORE) --------------------------------------- */ +/* 1px, because .load-more-count's own 15px of top padding carries the rest. */ .load-more-block { - margin-top: 16px; + margin-top: 1px; } .load-more-count { display: block; @@ -12,8 +13,10 @@ font: 700 11px/1.3 var(--mono); letter-spacing: 0.5px; color: var(--ink-muted); - margin: 0 0 10px; - padding: 0; + margin: 0; + /* 14px text + 15px×2 = 44px in the box itself: a full-width block inside an + overflow:auto list would turn a halo into real horizontal scroll. */ + padding: 15px 0; font-variant-numeric: tabular-nums; } .load-more-count:disabled { diff --git a/web/css/15-misc.css b/web/css/15-misc.css index 6824773..a223ba9 100644 --- a/web/css/15-misc.css +++ b/web/css/15-misc.css @@ -49,8 +49,8 @@ flex: 1; min-width: 0; } -/* 34px visual (14px font + 10px×2 padding) clears the shared halo's +5px - sides (90-interaction.css) up to the 44px floor. */ +/* 34px tall and wide once the glyph's ~8px is padded out, which the halo in + 90-interaction.css then takes to 44px on both axes. */ .notice__dismiss { flex-shrink: 0; width: auto; @@ -59,7 +59,7 @@ color: inherit; cursor: pointer; font: 700 14px/1 var(--mono); - padding: 10px 12px; + padding: 10px 13px; } .notice--success { background: var(--owned); diff --git a/web/css/19-edit-proposal.css b/web/css/19-edit-proposal.css index 33b593c..a3d3663 100644 --- a/web/css/19-edit-proposal.css +++ b/web/css/19-edit-proposal.css @@ -67,9 +67,7 @@ gap: 12px; margin-bottom: 8px; } -/* Vertical padding is redundant now that the `.btn` base already reaches - 47px on its own; only the extra horizontal room and the heavier - border/shadow are this row's own. */ +/* The one consequential action on the page: heavier border, deeper shadow. */ .proposal-detail-actions .btn--primary { border-width: 2.5px; box-shadow: var(--shadow-md); diff --git a/web/css/90-interaction.css b/web/css/90-interaction.css index 70bb283..3da9cc2 100644 --- a/web/css/90-interaction.css +++ b/web/css/90-interaction.css @@ -24,50 +24,42 @@ textarea:focus-visible { outline-offset: 2px; } -/* Tap-target floor (44px), structural rather than a maintained list: a - plain `