From 4f0468c2dc4741de36e24aa6362c4c593ffb1c67 Mon Sep 17 00:00:00 2001 From: John Taylor Date: Fri, 24 Jul 2026 20:34:56 -0400 Subject: [PATCH] fix(theme): primary button wears the accent in dark mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browser verification of the design system caught a real defect the green suite could not see: in dark mode the primary button rendered as a pale peach with dark text. Material derives a light `primary` for dark schemes, so the app's most important control was the one element on screen not wearing Lamplight Terracotta — a flat break of DESIGN.md's Rubrication Rule. Light mode had hidden it, because the amber seed's light `primary` happens to land near the token by luck. The fill is now set from JuiceTokens at the theme in both brightnesses, with the label flipping per mode: near-white ink on the deep light clay, page-dark ink on the lifted dark clay. Also ships the primary lift, the second and last shadow DESIGN.md sanctions and the only one it had left unimplemented. It rides Material elevation with a terracotta shadowColor rather than a literal BoxShadow, so the button lifts without spending the One Shadow Rule's budget, and no call site hand-rolls it. Warm-tinted per The Warm Shadow Rule — a neutral shadow on cream paper reads as plastic. Verified in the browser at both brightnesses, alongside the rewarmed campaign identity spines from #320, which render distinct in light and dark with Umber separating from Terracotta by value as intended. Co-Authored-By: Claude --- .impeccable/design.json | 8 ++++---- DESIGN.md | 19 +++++++++++++------ lib/shared/theme.dart | 21 ++++++++++++++++++++- 3 files changed, 37 insertions(+), 11 deletions(-) diff --git a/.impeccable/design.json b/.impeccable/design.json index f1c9cd8..fe6a91c 100644 --- a/.impeccable/design.json +++ b/.impeccable/design.json @@ -407,8 +407,8 @@ }, { "name": "primary-lift", - "value": "0 6px 16px rgba(154, 74, 34, 0.32)", - "purpose": "Sanctioned for the primary button by the original handoff spec; specified but not yet implemented. The only permitted addition to the shadow vocabulary." + "value": "FilledButton elevation: 3, shadowColor: terracotta @ 32%", + "purpose": "The primary button, applied at the theme so no call site hand-rolls it. Uses Material elevation rather than a literal BoxShadow, so the One Shadow Rule's count is untouched." } ], "motion": [ @@ -466,9 +466,9 @@ "name": "Primary Button", "kind": "button", "refersTo": "button-primary", - "description": "The commitment. Terracotta fill, 48px minimum height, full-width by default — override minimumSize when it shares a Row.", + "description": "The commitment. Terracotta fill in both brightnesses (set at the theme, not derived from the M3 scheme), 48px minimum height, a warm-tinted lift.", "html": "", - "css": ".ds-btn-primary { background: #9A4A22; color: #FFFBF9; font-family: 'HankenGrotesk', Inter, system-ui, sans-serif; font-size: 14px; font-weight: 500; letter-spacing: 0.1px; border: none; border-radius: 12px; padding: 12px 24px; min-height: 48px; cursor: pointer; transition: background 180ms ease, box-shadow 180ms ease; } .ds-btn-primary:hover { background: #8A4020; } .ds-btn-primary:focus-visible { outline: 2px solid #9A4A22; outline-offset: 2px; } .ds-btn-primary:active { background: #7C3A1A; }" + "css": ".ds-btn-primary { background: #9A4A22; color: #FFFBF9; font-family: 'HankenGrotesk', Inter, system-ui, sans-serif; font-size: 14px; font-weight: 500; letter-spacing: 0.1px; border: none; border-radius: 12px; padding: 12px 24px; min-height: 48px; cursor: pointer; box-shadow: 0 6px 16px rgba(154,74,34,0.32); transition: background 180ms ease, box-shadow 180ms ease; } .ds-btn-primary:hover { background: #8A4020; } .ds-btn-primary:focus-visible { outline: 2px solid #9A4A22; outline-offset: 2px; } .ds-btn-primary:active { background: #7C3A1A; }" }, { "name": "Secondary Button", diff --git a/DESIGN.md b/DESIGN.md index c6492c2..27cd133 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -446,16 +446,18 @@ one `BoxShadow` in the entire codebase, and it belongs to the expanded oracle result card. That scarcity is doctrine, not an oversight: the shadow *is* the punctuation on the moment the story turns. -The one sanctioned expansion is the primary button lift, specified in the -original handoff and not yet implemented. It may be added; nothing else may. +The one sanctioned expansion is the primary button lift, now shipped as a +themed `elevation: 3` with a terracotta `shadowColor` rather than a literal +`BoxShadow` — so the button lifts without spending the system's one-shadow +budget. Nothing else may cast. ### Shadow Vocabulary - **Answer lift** (`BoxShadow(color: terracotta @ 16%, blurRadius: 22, offset: (0, 8))`): the expanded result card only. Tinted with the accent rather than black, so it reads as lamplight rather than a drop shadow. -- **Primary lift** (`BoxShadow(color: rgba(154,74,34,.32), blurRadius: 16, offset: (0, 6))`): - sanctioned for the primary button. Specified, not yet shipped. +- **Primary lift** (`FilledButton` `elevation: 3`, `shadowColor: terracotta @ 32%`): + the primary button only, applied at the theme so no call site hand-rolls it. ### Named Rules @@ -500,8 +502,13 @@ page, not something floating above it. ### Buttons - **Shape:** softly rounded (12px primary, 14px secondary). -- **Primary** (`FilledButton`): terracotta fill, near-white label, themed to a - 48px minimum height so it reads as a full-width commitment by default. +- **Primary** (`FilledButton`): terracotta fill in **both** brightnesses, set at + the theme rather than left to the seeded `ColorScheme` — M3 derives a *light* + `primary` for dark schemes, which made the app's most important control a pale + peach in dark mode, the one thing on screen not wearing the accent. The label + flips per brightness (near-white ink on the deep light clay, page-dark ink on + the lifted dark clay). Themed to a 48px minimum height so it reads as a + full-width commitment by default, and carries the primary lift. **Gotcha:** the theme sets `minimumSize: Size.fromHeight(48)`, which forces infinite width under loose constraints — inside a `Wrap`, or beside a flex sibling in a `Row`, override with `minimumSize: Size(0, 44)` or wrap in diff --git a/lib/shared/theme.dart b/lib/shared/theme.dart index c8c4a7c..fe1100f 100644 --- a/lib/shared/theme.dart +++ b/lib/shared/theme.dart @@ -27,8 +27,27 @@ class AppTheme { return base.copyWith( textTheme: tt, extensions: >[tokens], + // The primary lift — the second and last shadow the system sanctions + // (DESIGN.md -> Elevation & Depth). Warm-tinted per The Warm Shadow Rule: + // a neutral shadow on cream paper reads as plastic. Elevation, not a + // BoxShadow, so The One Shadow Rule's count is untouched. filledButtonTheme: FilledButtonThemeData( - style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(48)), + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(48), + // The primary action wears the accent in BOTH brightnesses. Left to + // the seeded ColorScheme this was a pale peach in dark mode (M3 + // derives a light `primary` for dark schemes), so the app's most + // important control was the one thing not wearing Lamplight + // Terracotta — a Rubrication Rule break that light mode hid, because + // the amber seed's light `primary` happens to land near the token. + backgroundColor: tokens.terracotta, + // Label reads against the accent per brightness: near-white ink on + // the deep light clay, page-dark ink on the lifted dark clay. + foregroundColor: + brightness == Brightness.light ? tokens.raised : tokens.cream, + elevation: 3, + shadowColor: tokens.terracotta.withValues(alpha: 0.32), + ), ), // Modal containers. Sheets and dialogs are this app's second-most-common // container after the card (60+ call sites) and were rendering at raw M3