-
+
+
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 `")
}
// 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 `