feat: Complete Light and Dark Themes
Target repo: screenote (Rails 8.1, Propshaft, importmap, Stimulus, MiniTest + Capybara/Playwright)
Product Contract preservation: Product Contract unchanged. Planning discovered five places where the brainstorm's stated implementation assumptions do not match the current codebase (token island count, mobile drawer, shared-Screenote surface, error pages, ApplicationSystemTestCase fixtures). None of these change product intent — each is recorded as an assumption or an open question below, not a scope rewrite.
Overview
Problem frame
Screenote today has no theme system. It has three visually distinct, independently hardcoded color regimes that were authored separately and never reconciled:
- the dashboard/app shell (
application.css, light,:root { --color-* }), - the marketing landing page (
landing.css, dark,.landing-body { --landing-* }), - the auth shell (
auth.css, dark,.auth-body { --dark-* }).
A visitor's operating-system preference is ignored everywhere. There is no control to change appearance. The result is that a user on a light OS gets a dark marketing page and a light dashboard, and a user on a dark OS gets a blinding dashboard — with no recourse.
This work replaces all of that with one semantic token set, driven by a data-theme attribute on <html>, resolved before first paint from an explicit light/dark preference or (by default) from prefers-color-scheme. A three-state switcher lets any visitor override. Signed-in users' explicit choices sync to their account.
What "done" looks like
Every in-scope surface renders correctly in both themes, at WCAG 2.2 AA, with no wrong-theme flash on first paint, no page reload on switch, and no change to user-authored content or uploaded imagery.
Planning-time findings that shape this plan
Five facts discovered in the codebase materially change the shape of the work versus what the brainstorm assumed. Each is called out at the point of use, and the two that need a decision are in Open Questions.
| # | Brainstorm assumed | Codebase reality | Effect |
|---|---|---|---|
| 1 | Three token islands |
Four. application.css lines ~1337–1900 contain a hardcoded dark region (.guide__terminal, .guide__step-number, .billing-plan--pro) using #0b0f1a / #13131a / #1e1e2a / #e5a31e — deliberate dark accents living inside the light dashboard sheet |
U7 exists as its own unit; these are intentional dark-on-light elements, not bugs |
| 2 | "the corresponding navigation drawer/menu" on mobile; "the dashboard user/settings menu" |
Neither exists. .landing-nav__links and .header__nav are flat flex rows. No hamburger, no drawer, no user menu anywhere |
Building one is a layout change, which the brainstorm puts out of scope. See Q2 |
| 3 | Shared/public Screenote views are in scope |
No such surface exists. Only static_pages, oauth_metadata, and invitation_acceptances skip authentication; screenshots#show requires sign-in |
Scope substitutes the real public surfaces: landing, help/terms/privacy, auth flows, invitation acceptance |
| 4 | Error pages "when they use the application shell" |
public/{400,404,422,500,406-unsupported-browser}.html are static files with inline <style>, rendered layout: false. They never touch the app shell and cannot read app CSS |
U10 gives them a self-contained prefers-color-scheme treatment; they cannot honor an explicit override. See Q3 |
| 5 | System tests use fixtures |
Capybara.run_server = false + app_host :3005 — system tests drive the dev server against the dev database. Fixtures do not apply; db/seeds.rb users do (<email> / password) |
U12 uses seeded users, never fixtures |
A sixth fact is load-bearing but uncontroversial: stylesheet_link_tag :app in layouts/_head.html.erb is Propshaft's bulk-include, which emits every app/assets/**/*.css file, alphabetically sorted, on every page. So annotorious.css, application.css, auth.css, and landing.css all load everywhere already; landing.html.erb and auth.html.erb then link their sheet a second time. A new theme.css is picked up automatically and sorts last — which is exactly where the token definitions want to be.
High-Level Technical Design
Resolution and persistence
flowchart TD
A["Browser requests page"] --> B["Server renders HTML<br/>NO data-theme attribute"]
B --> C["Inline nonce'd script in head<br/>(blocking, pre-paint)"]
C --> D{"cookie 'theme'"}
D -- "light | dark" --> F["explicit"]
D -- "absent | system" --> E{"matchMedia<br/>prefers-color-scheme: dark"}
E -- yes --> G["dark"]
E -- "no / unsupported" --> H["light"]
F --> I["set html[data-theme]<br/>set meta theme-color"]
G --> I
H --> I
I --> J["First paint — already correct"]
J --> K{"preference == system?"}
K -- yes --> L["Stimulus attaches<br/>matchMedia change listener<br/>→ live flip, no reload"]
K -- no --> M["No listener"]
The client is the sole authority for writing data-theme. The server never emits it — not even when it could — because when the preference is system the server cannot know the OS scheme, and two resolution paths would eventually disagree. One path, one authority.
Sign-in merge
sequenceDiagram
participant B as Browser
participant S as SessionsController
participant Y as ThemePreferenceSync
participant U as users row
B->>S: POST /session (cookie theme=dark)
S->>S: create_session_for(user)
S->>Y: after_sign_in_callback(user, controller)
Y->>U: read theme_preference
alt account is explicit (light/dark)
Y->>B: overwrite cookie from account
Note over Y,B: account wins
else account is system AND cookie is explicit
Y->>U: persist cookie value to account
Note over Y,U: browser choice adopted
else both system
Y->>Y: no-op
end
S->>B: redirect (correct theme on the very first painted frame)
Doing the merge server-side, inside the sign-in request, means the post-sign-in redirect already carries the settled cookie. There is no client round-trip, no window in which the UI shows the losing theme, and — critically — the rule is coverable by an ordinary MiniTest integration test, which a localStorage-based merge would not be.
Token layering
theme.css (sorts last under :app glob — no manifest edit needed)
├── :root semantic tokens, light values
│ color-scheme: light
├── :root[data-theme="dark"] semantic token overrides
│ color-scheme: dark
└── legacy aliases --color-bg: var(--surface); etc.
(temporary bridge, deleted in U13)
Custom properties resolve at use time, not parse time, so load order never affects var() lookups — it only decides which duplicate declaration on an equal-specificity selector wins. :root and .landing-body have identical specificity (0,1,0), so a theme.css :root block would beat a landing.css .landing-body block on source order alone. The plan does not rely on that: U6 deletes the competing islands outright.
Assumptions
Decisions taken so the work can proceed. Each is reversible; flag during review if any is wrong.
-
A-1. Cookie, not
localStorage, is the browser store. The brainstorm says local storage. Athemecookie (1 year,path=/,SameSite=Lax,Secureon HTTPS, notHttpOnly) is functionally identical to the user, and is readable by both the pre-paint script and the server. That server-readability is what makes the sign-in merge a server-side operation and therefore MiniTest-integration-testable — which the brainstorm explicitly requires. AlocalStorageimplementation would force the merge into a post-sign-in client round-trip, which is both a wrong-theme window and untestable at the integration layer. Two stores (localStorage + mirror cookie) were rejected as a drift source. See Q1. -
A-2.
users.theme_preferenceis an integer-backed enum (system: 0, light: 1, dark: 2,default: 0, null: false), matching CLAUDE.md's "integer-backed enums for all status fields". "Absence of a preference is system" maps to the0default, so no nullable column is needed. -
A-3. The switcher is a native
<select>labelled "Theme" with optionsLight/Dark/System. It shows the current selection as text, is keyboard-operable and AT-announced with zero custom key handling, occupies almost no width (so it survives a 375px nav without a drawer), and its own dropdown chrome demonstratescolor-schemeworking. See Q2. -
A-4. Applying a theme never reloads. Stimulus writes
data-theme, the cookie, and thetheme-colormeta synchronously onchange; the account write is a fire-and-forgetfetchPATCH that the UI does not await. -
A-5. Account preference
systemcounts as "no preference" for the merge rule, per the brainstorm's "absence of a stored preference issystem". -
A-6. Visual regression means token-and-contrast assertions, not screenshot baselines. The repo has no baseline-image infrastructure and adding one is a separate capability. U11/U12 assert computed-style contrast ratios and theme invariants instead, and keep
take_screenshotartifacts for human review. See Q4. -
A-7.
theme.cssis a new sixth stylesheet, not an edit to an existing one. Propshaft's:appglob picks it up with no manifest change. -
A-8. The switcher block is
.theme-switcher, deliberately distinct from the existing.viewport-switcher(the desktop/tablet/mobile device control on the screenshot page) to avoid a confusing near-collision. -
A-9. The
<html lang="en">element is not replaced by Turbo Drive, sodata-themesurvives Turbo navigation without re-running the bootstrap. The bootstrap runs on full page loads only.
Requirements Trace
Requirement IDs are minted here from the brainstorm's Requirements section; acceptance-example IDs follow its bullet order.
Actors
| ID | Actor | Relevance |
|---|---|---|
| A1 | Signed-out visitor | Cookie-only preference; landing, help/terms/privacy, auth, invitation acceptance |
| A2 | Signed-in user | Account-synced preference; full dashboard |
| A3 | Shared-link viewer | No such surface exists today (finding #3). Nearest real equivalent: invitation acceptance, treated as A1 |
| A4 | Operating system | Supplies prefers-color-scheme; may change mid-session |
Requirements
| ID | Requirement | Units |
|---|---|---|
| R1 | Preference is one of light / dark / system; absence is system
|
U1, U3 |
| R2 | Effective theme resolves from explicit choice, else prefers-color-scheme, else light
|
U2, U3 |
| R3 | No grandfathering — today's public-dark/dashboard-light split is replaced by OS-driven resolution | U5, U6 |
| R4 | One semantic token set; the existing token islands collapse into it | U1, U5, U6, U7 |
| R5 | Semantic token names (surface, elevated, border, text, muted, accent, danger/success/warning, focus ring) — no literal color names | U1 |
| R6 | CSS color-scheme set alongside tokens; native controls, scrollbars, and <meta name="theme-color"> follow |
U1, U2 |
| R7 | Blocking, nonce-carrying inline script in layouts/_head.html.erb sets the theme attribute before first paint |
U2 |
| R8 | Signed-out explicit choice persists in the browser across reload and all three layouts | U3, U4 |
| R9 | Signed-in explicit choice persists on the user record and applies on every device | U3 |
| R10 | Sign-in merge: account preference wins; if account has none, adopt and save the browser choice | U3 |
| R11 | Sign-out retains the effective theme in that browser | U3 |
| R12 |
system follows a live OS change with no reload and no loss of page state |
U2, U4 |
| R13 | Server-rendered pages never emit a theme that contradicts the client-resolved one | U2 |
| R14 | One three-state control, available signed-out and signed-in | U4 |
| R15 | Desktop placement: public nav and the dashboard header. Narrow placement: the same nav in its narrow form (finding #2) | U4 |
| R16 | Control shows the current selection as text, is keyboard-operable, has an accessible name, exposes selection to AT | U4, U9 |
| R17 | Selecting an option applies immediately, no reload | U4 |
| R18 | Surfaces: marketing, sign-in/sign-up/password-reset/magic-link, full dashboard, invitation acceptance, help/terms/privacy | U5, U6, U7 |
| R19 | Every component family tokenized: nav/header, forms + validation, dialogs, tables, cards, annotation editor + canvas chrome, comments, answer/resolution, flash, badges, banners, empty/loading/error | U5, U7, U8 |
| R20 | Semantic recoloring only — identity and layout preserved; shadows/borders/elevation may be refined | U5, U6, U7 |
| R21 | Reuse existing brand assets; theme-specific variants only where an asset is unreadable | U6 |
| R22 | Uploaded screenshots and user content render unchanged; no filtering, inversion, or dimming | U8 |
| R23 | Editors, diffs, and rich-text surfaces may use theme-aware palettes but never rewrite stored content | U8 |
| R24 | WCAG 2.2 AA in both themes: text, controls, non-text UI, focus indicators, disabled states, validation | U9 |
| R25 | Focus visibility verified against both themes' backgrounds | U9 |
| R26 | No exemption for a failing color merely because it ships today | U9 |
| R27 | MiniTest coverage for resolution, merge, sign-out retention, account persistence | U3 |
| R28 | Playwright system coverage for flash, switching, persistence, live OS change, keyboard/AT, contrast | U11, U12 |
| R29 | CI baseline: Chromium desktop plus a mobile viewport; standards-compatible with Firefox/Safari | U11, U12 |
| R30 | Follow conventions: data-testid, BEM, Stimulus, no inline styles, no npm build |
all |
Acceptance examples
| ID | Example | Covered by |
|---|---|---|
| AE1 | First-time visitor, OS dark → landing and dashboard dark; switcher reads System
|
U12 |
| AE2 | First-time visitor, OS light → landing light (a change from today), dashboard light; switcher reads System
|
U12 |
| AE3 | Signed-out picks Dark, reloads → still dark; picks System with OS light → light |
U12 |
| AE4 | Signed-out Dark signs into an account preferring Light → light, switcher reads Light
|
U3, U12 |
| AE5 | Signed-out Dark signs into an account with no preference → stays dark, dark saved to account, second device loads dark |
U3, U12 |
| AE6 | Signed-in sets Light, signs out → browser stays light |
U3, U12 |
| AE7 |
System selected, OS flips → UI updates live, no reload |
U12 |
| AE8 | Reload with Dark stored → first painted frame is already dark |
U12 |
| AE9 | Keyboard user reaches the switcher, changes it by keyboard, accessible name conveys current selection | U12 |
| AE10 | Screenshot with annotation pins renders identical image pixels in both themes while chrome adapts | U8, U12 |
| AE11 | Every in-scope page passes automated contrast checks in both themes | U11, U12 |
Scope Boundaries
In scope
- Landing page (
root→static_pages#landing, the only action usinglayouts/landing). - Auth flows via
rails_simple_auth: sign-in, sign-up, password reset, magic link, OAuth consent (layouts/auth). - The complete signed-in dashboard: projects, pages, screenshots, annotations, comments, members, invitations, API keys, billing/subscription, admin dashboard.
- Public app-shell pages:
/help,/terms,/privacy, invitation acceptance (alllayouts/application). - All four stylesheets and the new
theme.css. - The
usersschema change, the preference endpoint, and the sign-in merge. - MiniTest and Playwright coverage per R27–R29.
Out of scope (from the brainstorm)
- Transactional emails and
layouts/mailer.html.erb— the mailer layout keeps its hardcodedcolor-scheme: dark. - Exported files and print stylesheets.
- Third-party and embed surfaces.
- Any redesign, layout change, or new visual direction beyond recoloring and contrast polish.
Out of scope (added by this plan, with reason)
- A mobile navigation drawer or dashboard user/settings menu. Neither exists (finding #2). Building one is a layout change, which the brainstorm excludes. The switcher is placed inline in the existing navs instead. See Q2.
- Shared/public Screenote views. No such surface exists (finding #3). Nothing to theme.
-
Turning
public/406-unsupported-browser.htmlinto a themed page. It already has aprefers-color-schemeblock and is served pre-app. -
Fixing the CI
system-testjob. It runsbin/rails test:systemwith no dev server on:3005and noplaywright installstep, so it cannot pass today. Pre-existing and independent of this feature. See Q5 and R-3 in Risks.
Deferred to follow-up work
-
app/views/pwa/manifest.json.erbcarries"theme_color": "red"and"name": "ScreenoteTmp"— placeholder scaffolding, unrouted, unrelated to this feature. - Deduplicating the redundant
stylesheet_link_tagcalls inlayouts/auth.html.erb,layouts/landing.html.erb, andapp/views/screenshots/show.html.erb(all already covered by the:appglob). U13 removes them only because it is already editing those files; if it turns out to be riskier than it looks, drop it.
Key Technical Decisions
KTD-1 — Client-only theme resolution. The server renders no data-theme. Rationale: satisfies R13 by construction, avoids two divergent resolution paths, and keeps system (where the server is blind) and explicit modes on one code path. Cost: a <html> with no theme attribute for the microseconds before the inline script runs — invisible, because paint is blocked on stylesheets that load after it.
KTD-2 — Cookie as the browser store. See A-1. The decisive factor is R27's demand for MiniTest coverage of the merge rule (R10), which is unreachable if the browser value lives only in localStorage.
KTD-3 — Merge runs in after_sign_in_callback / after_sign_up_callback. config/initializers/rails_simple_auth.rb already configures after_confirmation_callback, so the extension point is established. These two hooks cover password sign-in, magic-link login, OAuth callback (OmniauthCallbacksController#create calls run_after_sign_in_callback), and registration — every entry path, with no gem controller override.
KTD-4 — Legacy token aliases as a migration bridge. U1 defines --color-bg: var(--surface) and friends so that the moment theme.css lands, every existing rule already responds to the theme without a single edit elsewhere. U5–U8 then replace call sites deliberately, and U13 deletes the bridge. This makes the recolor incrementally landable and independently reviewable instead of one 3,000-line commit.
KTD-5 — Contrast checking in Ruby over getComputedStyle. Injecting axe-core would mean either a CSP fight (script_src :self) or bypassCSP: true plus a vendored ~500KB file, against a "no npm build step" convention. Instead, page.evaluate returns resolved foreground/background pairs for a curated selector list, and a Ruby helper computes the WCAG 2.2 relative-luminance ratio. No new dependency, deterministic, and it fails with a readable message naming the selector and ratio.
KTD-6 — Native <select> for the switcher. See A-3. A segmented radiogroup would look better on desktop but needs roving-tabindex handling, more horizontal space than the 375px nav has, and custom AT wiring. The <select> gets R16 for free.
Implementation Units
Phase 1 — Foundation
U1. Semantic token set and theme scaffolding
Goal: One semantic token set, defined once, that drives both themes — plus a compatibility bridge so nothing breaks before the recolor units land.
Requirements: R1, R4, R5, R6
Dependencies: none
Files:
- create
app/assets/stylesheets/theme.css
Approach:
Define tokens on :root (light values) and override on :root[data-theme="dark"]. Set color-scheme: light / dark in the respective blocks so native form controls, scrollbars, and the <select> dropdown follow the theme (R6).
Token families, named semantically per R5 — never --dark-bg in a light context:
- Surfaces: base, elevated (cards/dialogs), sunken (page background), inset (code/terminal blocks)
- Borders: default, strong, subtle
- Text: default, muted, subtle, on-accent
- Accent: base, hover, contrast — plus a
--brandgold (#e5a31e), which is Screenote's identity color and appears in both themes - Status: danger / success / warning / info, each with a border, a surface tint, and a text value, so the flash/badge/banner families stop hardcoding pairs like
#dcfce7+#166534 - Interaction:
--focus-ring,--focus-ring-offset - Elevation:
--shadow-sm,--shadow-md— dark themes need lower-alpha, larger-spread shadows to read at all, so these are tokens rather than literals - Chrome:
--overlayfor the landing nav'sbackdrop-filterscrim
Derive the light values from today's :root block in application.css so the light dashboard is visually near-identical to today. Derive the dark values from the existing --landing-* / --dark-* palettes so the dark theme inherits the marketing site's established look. Both derivations are starting points; U9 adjusts any that fail AA.
At the bottom, a clearly commented LEGACY ALIASES — remove in U13 block maps every existing custom property (--color-bg, --color-text, --color-border, --landing-bg, --dark-surface, …) onto its semantic equivalent (KTD-4).
Patterns to follow: the existing :root block in app/assets/stylesheets/application.css; BEM and comment-banner style from landing.css.
Test scenarios: Test expectation: none — this unit is pure token declaration with no behavior. Its effect is verified through U5–U9 and the contrast assertions in U12.
Verification: theme.css appears in the rendered <link> set on every layout (it is picked up by the :app glob with no manifest edit) and sorts after landing.css. Manually toggling data-theme="dark" on <html> in devtools visibly changes surfaces on a dashboard page even before U5 runs, proving the alias bridge works.
U2. Pre-paint bootstrap, <html> attribute, and theme-color
Goal: The first painted frame is already the correct theme, on every layout, for every visitor.
Requirements: R2, R6, R7, R12, R13
Dependencies: U1
Files:
- modify
app/views/layouts/_head.html.erb
Approach:
Add a blocking inline script as the first element the partial emits, using javascript_tag nonce: true — the CSP is script_src :self with a nonce generator (config/initializers/content_security_policy.rb), and the Honeybadger block in this same file already establishes the nonce: true idiom.
The script: read the theme cookie; if it is light or dark use it; otherwise consult matchMedia("(prefers-color-scheme: dark)"), falling back to light when the query is unsupported or reports no preference (R2). Write the result to document.documentElement.dataset.theme and set the content of the theme-color meta from the resolved surface color. Wrap cookie access in try/catch — the same defensive posture dismissible_controller.js uses around localStorage.
Also add a single <meta name="theme-color"> with a light default. A media-query pair (<meta ... media="(prefers-color-scheme: dark)">) is deliberately not used: it cannot honor an explicit override, so the script owns it.
Because _head.html.erb is rendered by all three layouts, one edit covers landing, auth, and application. The server emits no data-theme (KTD-1, R13).
Patterns to follow: the javascript_tag nonce: true block already in app/views/layouts/_head.html.erb.
Test scenarios:
- Covers AE8. With
theme=darkstored, a fresh load of the dashboard hasdata-theme="dark"on<html>at the first paint — asserted by sampling the resolvedbackground-colorof<body>immediately afterdomcontentloaded, before network idle, and confirming it is the dark surface (full assertion lives in U12). - Covers AE1/AE2. With no cookie and the browser emulating
prefers-color-scheme: dark,<html>resolves todark; emulating light resolves tolight. - With a cookie holding a garbage value (
theme=purple), resolution falls through to the OS query rather than settingdata-theme="purple". - With cookies disabled entirely, the page still renders and resolves from the OS query without a console error.
- The emitted
<script>carries anonceattribute and produces no CSP violation in the browser console on all three layouts.
Verification: Loading the landing page, an auth page, and a dashboard page each show data-theme set on <html> in the DOM, with no CSP error logged, and <meta name="theme-color"> matching the active theme.
U3. Persistence: account column, endpoint, and the merge rule
Goal: Explicit choices survive reload signed-out, sync across devices signed-in, merge correctly at sign-in, and persist through sign-out.
Requirements: R1, R8, R9, R10, R11, R27
Dependencies: U2
Files:
- create
db/migrate/<ts>_add_theme_preference_to_users.rb - modify
db/schema.rb(generated) - modify
app/models/user.rb - create
app/services/theme_preference_sync.rb - create
app/controllers/theme_preferences_controller.rb - modify
config/routes.rb - modify
config/initializers/rails_simple_auth.rb - create
test/models/user_theme_preference_test.rb - create
test/services/theme_preference_sync_test.rb - create
test/integration/theme_preference_test.rb - modify
test/fixtures/users.yml
Approach:
Migration adds theme_preference as an integer, default: 0, null: false. User declares enum :theme_preference, { system: 0, light: 1, dark: 2 }, prefix: true (prefix avoids colliding with the existing system/light/dark word space and with User#admin?-style predicates). Per A-2 this matches the codebase's integer-enum convention; per A-5, system is "no preference".
ThemePreferenceSync (instance-based with a class convenience method, per the app/services/ convention) owns the merge (R10) and takes the user plus the controller so it can read and write cookies:
- account explicit → write the account value to the cookie (account wins)
- account
systemand cookie explicit → persist the cookie value to the account - both
system→ no-op
Wire it into config.after_sign_in_callback and config.after_sign_up_callback in the initializer, alongside the existing after_confirmation_callback (KTD-3). Follow that callback's rescue StandardError => e; Honeybadger.notify(...) shape — a theme sync must never be able to break sign-in.
Sign-out (R11) requires no code: SessionsController#destroy does not touch cookies, so the theme cookie simply survives. The tests assert that this stays true.
ThemePreferencesController#update accepts a preference param, validates it against the enum's keys, persists it to Current.user, refreshes the cookie server-side, and responds head :no_content. It inherits ApplicationController, so require_authentication applies — signed-out visitors never call it (their choice is cookie-only, written client-side in U4). Route: patch "theme", to: "theme_preferences#update", as: :theme_preference.
Add a theme_preference value to one or two test/fixtures/users.yml entries so integration tests can exercise "account has an explicit preference" without inline setup.
Execution note: Implement the merge rule test-first. It has four distinct branches and the brainstorm's acceptance examples specify all of them; writing them first is cheaper than reverse-engineering the branch table from an implementation.
Patterns to follow: app/services/annotation_crop_service.rb for the service shape; the after_confirmation_callback lambda in config/initializers/rails_simple_auth.rb for callback registration and error handling; test/controllers/*_controller_test.rb and the SignInHelper#sign_in helper in test/test_helper.rb.
Test scenarios:
- A newly created user has
theme_preferencesystem;User.new.theme_preference_system?is true with nothing assigned. - Assigning an unknown value raises rather than silently persisting.
- Covers AE4. Cookie
dark, accountlight→ after sign-in the account stayslightand the cookie is rewritten tolight. - Covers AE5. Cookie
dark, accountsystem→ after sign-in the account isdarkand the cookie staysdark. - Cookie absent, account
light→ after sign-in the cookie is written aslight. - Cookie absent, account
system→ after sign-in neither changes and no write is issued. - Cookie
dark, accountdark→ idempotent; no redundant database write. - Cookie holds a garbage value and the account is
system→ the account is left atsystemrather than crashing or persisting garbage. - The merge fires on magic-link login and on the OAuth callback, not only on password sign-in.
- The merge raising internally does not break sign-in — the user still lands signed in, and the error reaches Honeybadger.
- Covers AE6. Signed-in user with
light, thenDELETE /session→ the theme cookie is still present and stilllightin the response. -
PATCH /themesigned in withdark→204,user.reload.theme_preferenceisdark, response sets the cookie. -
PATCH /themesigned in with an invalid value → rejected, the stored preference is unchanged. -
PATCH /themesigned out → redirected to sign-in, no record touched. -
PATCH /themewithout a CSRF token → rejected.
Verification: bin/rails db:migrate produces the column; the model, service, and integration tests pass; sign-in through each of the four entry paths (password, magic link, OAuth, registration) lands on the dashboard in the expected theme.
U4. Three-state switcher
Goal: One accessible control, present on every in-scope surface for both signed-out and signed-in visitors, that applies the theme instantly.
Requirements: R8, R12, R14, R15, R16, R17, R30
Dependencies: U1, U2, U3
Files:
- create
app/views/shared/_theme_switcher.html.erb - create
app/javascript/controllers/theme_controller.js - modify
app/views/layouts/application.html.erb - modify
app/views/layouts/auth.html.erb - modify
app/views/static_pages/landing.html.erb - modify
app/assets/stylesheets/theme.css
Approach:
The partial renders a <label> + <select> inside a .theme-switcher block (A-8), with data-controller="theme", data-action="change->theme#select", data-testid="theme-switcher", and a data-theme-signed-in-value boolean. Three <option>s: Light, Dark, System. The server marks the correct option selected from Current.user&.theme_preference when signed in; when signed out it emits System selected and the controller corrects it on connect() from the cookie. This keeps the server free of resolved-theme output (R13) while still giving signed-in users a correct first render.
theme_controller.js:
-
connect()— sync the<select>to the stored preference, then, if the preference issystem, attach achangelistener tomatchMedia("(prefers-color-scheme: dark)")so a live OS flip re-resolves in place (R12).disconnect()removes it. -
select()— write the cookie, recompute the effective theme, setdata-themeon<html>, update thetheme-colormeta, and re-evaluate the media listener. If signed in, fire afetchPATCH to the preference route with thecsrf-tokenmeta asX-CSRF-Token, and do not await it (A-4, R17).
Registration is automatic: app/javascript/controllers/index.js uses eagerLoadControllersFrom, and config/importmap.rb has pin_all_from "app/javascript/controllers".
Placement (R15):
-
layouts/application.html.erb— inside.header__nav, in both the signed-in and signed-out branches, so it covers the dashboard and/help,/terms,/privacy, and invitation acceptance for anonymous visitors. -
static_pages/landing.html.erb— inside.landing-nav__links. Note the landing nav lives in the view, not inlayouts/landing.html.erbas the brainstorm assumed. -
layouts/auth.html.erb— in.auth-shell__main, above the yielded form, so it survives themax-width: 860pxbreakpoint that hides.auth-shell__side.
Styling goes in theme.css as a .theme-switcher block, token-driven, with a narrow-viewport rule that keeps it visible and non-overflowing at 375px. No drawer is built (finding #2, Q2).
Patterns to follow: app/javascript/controllers/dismissible_controller.js for controller shape and try/catch-guarded storage access; existing data-testid conventions in layouts/application.html.erb.
Test scenarios:
- Covers AE3. Signed out, select
Dark→<html data-theme="dark">immediately, no navigation; reload → still dark. - Covers AE3. With
Darkstored and the OS emulating light, selectSystem→ the UI returns to light without a reload. - Covers AE7. With
Systemselected, flipping the emulated OS scheme flips the UI live, with no navigation and with page state (e.g. a partially typed comment) intact. - With
Lightexplicitly selected, flipping the emulated OS scheme changes nothing. - Covers AE9. Tab reaches the switcher, it has an accessible name of "Theme", the current option is exposed as selected, and changing it by keyboard applies the theme.
- Signed in, selecting
Darkissues exactly onePATCHto the preference route and the UI does not wait on it. - Signed in, if the
PATCHfails (simulated network error), the local theme still applies and no user-facing error appears. - The switcher is present and operable on the landing page, on the sign-in page, on the dashboard while signed in, and on
/helpwhile signed out. - At a 375px viewport the switcher is visible in both the landing nav and the app header, and neither nav overflows horizontally.
Verification: The control appears in all four placements at both desktop and mobile widths, changes the theme with no reload, and — signed in on a second browser session — the chosen theme is already active on first load.
Phase 2 — Recolor
U5. Dashboard and app shell
Goal: Every dashboard component family renders correctly in both themes, driven only by tokens.
Requirements: R3, R4, R18, R19, R20, R30
Dependencies: U1
Files:
- modify
app/assets/stylesheets/application.css(lines ~1–1340 and ~1900–2020)
Approach:
Delete the :root { --color-* } island (R4) — the aliases in theme.css already carry those names — then replace every remaining hardcoded color with a semantic token. Component families in this unit: header/nav, flash, buttons, forms and validation, cards, project/page/screenshot grids, tables and lists, empty states, role badges, MCP/upgrade banners, autocomplete suggestions, API key list, admin stats.
The concrete targets are the ~40 hardcoded values outside the token block: #166534 / #991b1b (flash text), the several #ffffff / #fff button labels (→ --text-on-accent), rgba(37, 99, 235, 0.2) focus shadows (→ --focus-ring), rgba(0,0,0,0.08) / (0,0,0,0.1) card shadows (→ --shadow-*), the rgba(22,163,74,0.1) / rgba(217,119,6,0.05|0.1) status tints (→ status surface tokens), and #fef3c7 + #f59e0b (→ warning surface + border).
Per R20 this is recoloring only: no selector restructuring, no layout edits, no spacing changes. Shadow and border values may be tuned per theme via tokens, since a shadow tuned for white reads as nothing on a dark surface.
Patterns to follow: existing BEM structure and section comment banners in application.css.
Test scenarios: Test expectation: none in this unit — pure styling with no behavioral change. Coverage lands in U12's per-surface contrast and theme-invariant assertions, which exercise the dashboard, table/card, dialog, and empty-state surfaces this unit recolors.
Verification: grep -E '#[0-9a-fA-F]{3,8}|rgba?\(' over lines 1–1340 and 1900–2020 of application.css returns nothing outside comments. Every dashboard route renders legibly in both themes with no unreadable text and no invisible borders.
U6. Marketing and auth shells
Goal: The landing page and auth shell follow the theme instead of being permanently dark, with the visual identity preserved.
Requirements: R3, R4, R18, R20, R21
Dependencies: U1
Files:
- modify
app/assets/stylesheets/landing.css - modify
app/assets/stylesheets/auth.css - possibly modify
app/views/shared/_logo_icon.html.erb
Approach:
Delete the .landing-body { --landing-* } and .auth-body { --dark-* } islands (R4) and repoint every consumer at semantic tokens. This is the unit that most visibly changes today's behavior: the landing page becomes light for a light-OS visitor (AE2, R3).
Careful cases, all of which currently assume a dark background:
-
.landing-navusesbackground: rgba(10,10,15,0.8)withbackdrop-filter: blur(12px)— becomes the--overlaytoken so the scrim inverts with the theme. -
.landing-nav__ctasetscolor: #0a0a0fon the gold accent. Gold-on-near-black is the brand button; it needs--text-on-accentso it stays legible if the accent shifts. -
.auth-shell__sideusesbackground: #0d0d14— a deliberately darker panel than the page. It becomes a surface token pair, staying distinct from the base surface in both themes rather than collapsing into it. -
.hero__grid,.hero__glow,.auth-shell__grid, and the.hero__anno*decorations are dark-background-dependent gradients and low-alpha strokes. They will need light-theme values; a decorative glow tuned for near-black is either invisible or muddy on white. -
--landing-accent-glow/--dark-accent-glow(rgba(229,163,30,0.15)) need a lower alpha or a different blend on light surfaces.
Per R21, check _logo_icon.html.erb renders legibly in both themes. If it uses currentColor it needs nothing; if it hardcodes a light fill, add a theme-aware variant — that is the only asset change this unit is authorized to make.
Test scenarios: Test expectation: none in this unit — pure styling. Covered by U12's landing and authentication surface contrast assertions, and by AE2's "landing renders light" system test.
Verification: No --landing-* or --dark-* declarations remain. Landing and every auth page render correctly in both themes; the auth side panel remains visually distinct from the form panel in both; the hero decorations read as intentional in light mode.
U7. Embedded dark-accent surfaces
Goal: The intentionally-dark elements inside the light dashboard — the help page terminal block and the Pro billing card — work as deliberate accents in both themes rather than disappearing into a dark background.
Requirements: R4, R18, R19, R20
Dependencies: U1
Files:
- modify
app/assets/stylesheets/application.css(lines ~1337–1900)
Approach:
This is finding #1 — a fourth token island the brainstorm did not account for. Lines ~1337–1900 hold .guide__* (help page) and .billing-plan--pro (billing) rules using #0b0f1a, #13131a, #1e1e2a, #1e293b, #e5a31e, #e8e8ed, #94a3b8, #cbd5e1, #555570, #64748b, and #d4901a. These are not mistakes: they are dark terminal and premium-card treatments deliberately placed on a light page.
The design decision this unit must make: in dark mode a #0b0f1a terminal block sits on an already-near-black surface and vanishes. Resolve it with a dedicated --surface-inset token plus --border-strong, so the block reads as recessed in light mode and as bordered and slightly lifted in dark mode. Same treatment for .billing-plan--pro, which must stay visually distinct from .billing-plan--current in both themes — that distinction is the card's entire job.
Also in range: .guide__terminal-dot (#2a2a3a), .guide__terminal-prompt / .guide__tool-name (#e5a31e, the brand gold — keep it, it survives both themes), .legal__*, .app-footer, and .admin-stats__*.
Test scenarios: Test expectation: none in this unit — pure styling. U12's help-page and billing-page contrast assertions cover the outcome, including that the terminal block's border contrast against its surrounding surface meets the non-text 3:1 bar in both themes.
Verification: /help and /subscription in both themes: the terminal block is clearly delineated from the page in each, the Pro card is clearly distinguishable from the current-plan card in each, and the gold accent remains legible against both.
U8. Annotation canvas, editor chrome, and comments
Goal: The annotation workspace adapts while uploaded imagery stays byte-identical.
Requirements: R19, R22, R23
Dependencies: U1
Files:
- modify
app/assets/stylesheets/application.css(annotation and screenshot blocks) - modify
app/assets/stylesheets/annotorious.css - modify
app/views/screenshots/show.html.erb - modify
app/javascript/controllers/annotorious_controller.js(only if the wrapper needs a theme attribute)
Approach:
Two separable concerns.
Screenote's own chrome — .screenshot-canvas, .screenshot-workspace, .screenshot-header, .annotation-sidebar, .annotation-item, .annotation-thread, .annotation-form, .annotation-filter, .annotation-pin, .viewport-switcher — is an ordinary token migration, same as U5. Note .annotation-pin and the resolved/open status colors are non-text UI and fall under the 3:1 bar in U9.
Annotorious's vendored CSS is the interesting part. annotorious.css is minified upstream v3.0.10 containing paired div[data-theme=dark] and div[data-theme=light] selectors. Because the light branch is written last in the minified source and both branches also match the bare .a9s-annotationlayer selector, the light variant currently wins unconditionally — annotation shapes always render with a white stroke and a dark halo, regardless of anything. On a dark canvas surface that is arguably still fine (the halo is drawn over the image, not the app background), so the first thing to establish is whether it actually needs to change at all.
If it does: Annotorious keys off a data-theme attribute on a wrapping div. Setting data-theme on <html> will not match div[data-theme=...], so the vendored file is unaffected by the app's theme attribute — a benign non-collision, but worth knowing before someone assumes otherwise. Mirroring the theme onto the .screenshot-canvas wrapper div would activate Annotorious's own palette. Prefer that over editing vendored minified CSS; if a mirror is added, it belongs in screenshots/show.html.erb or annotorious_controller.js, not in the vendored file.
R22 is a hard constraint and mostly a matter of restraint: no filter, opacity, mix-blend-mode, or background may be applied to .screenshot-canvas__image or any uploaded media. R23 likewise: theme-aware presentation only; the comment and answer/resolution flows never rewrite stored text.
Test scenarios:
- Covers AE10. Load the same screenshot in light and in dark, draw each
<img data-testid="screenshot-image">to a canvas, and assert the pixel data hashes are identical across themes. - Covers AE10. In the same pair of loads, the surrounding
.screenshot-canvasbackground, the annotation sidebar, and the pin chrome all resolve to different computed colors — proving the chrome adapted while the image did not. - The computed style of
.screenshot-canvas__imagehas nofilter, no non-defaultopacity, and nomix-blend-mode, in both themes. - Annotation pins remain distinguishable from the underlying screenshot in both themes for a light screenshot and for a dark screenshot.
- Drawing a new annotation region works in dark mode and the shape handles are visible against the image.
- An existing comment's stored text is byte-identical after a theme switch (R23).
Verification: The screenshot review page is fully usable in both themes; the image-hash test passes; no filter or blend property reaches uploaded media.
Phase 3 — Accessibility
U9. Focus visibility and WCAG 2.2 AA remediation
Goal: Both themes meet WCAG 2.2 AA, with focus visible on every interactive element against both backgrounds.
Requirements: R16, R24, R25, R26
Dependencies: U5, U6, U7, U8
Files:
- modify
app/assets/stylesheets/theme.css - modify
app/assets/stylesheets/application.css - modify
app/assets/stylesheets/auth.css - modify
app/assets/stylesheets/landing.css
Approach:
There is a real pre-existing gap here, not just a theme concern. application.css and auth.css set outline: none on .form__input, .form__textarea, .annotation-thread__textarea, .annotation-form__textarea, and .auth-card__input, replacing it with a box-shadow — and there is no :focus-visible rule anywhere in the codebase. Buttons, links, and nav items rely entirely on the UA default ring, which is low-contrast on the dark surfaces this feature is about to make ubiquitous.
Add a single token-driven :focus-visible rule covering links, buttons, button_to submits, form controls, and the theme <select>, using --focus-ring with an offset so the ring reads against both the element and its surrounding surface. Keep the existing box-shadow treatments where they are the established idiom, but tokenize them and ensure they meet the 3:1 non-text bar rather than the ~1.2:1 that rgba(37,99,235,0.2) gives on white today.
Then sweep both themes for AA compliance across body and secondary text, button labels on accent, muted/placeholder text, disabled states, validation and error messaging, badges and role pills, status tints, and non-text UI (borders, focus rings, annotation pins, chart-free iconography). Adjust token values — not selectors or layout — to close failures. R26 is explicit: a color failing today gets fixed, not grandfathered.
Record the final token values and their measured ratios in a comment block at the top of theme.css so the next person changing a color knows what they are trading against.
Execution note: Run U11's contrast helper against every in-scope route in both themes first, and let the failing list drive this unit. Eyeballing 60+ token pairs is exactly the kind of work that should be measured rather than judged.
Test scenarios:
- Covers AE11. Every in-scope route passes the contrast helper in both themes: text at 4.5:1 (3:1 for large text), UI components and focus indicators at 3:1.
- Covers AE11/AE9. Every interactive element type — link, primary button, secondary button, danger button, text input, textarea, the theme
<select>— shows a focus indicator meeting 3:1 against its adjacent background in both themes. - Disabled buttons and disabled inputs are visually distinguishable from enabled ones in both themes without relying on color alone.
- A form submitted with invalid input shows validation messaging that meets the text bar in both themes.
- Flash notice and flash alert both meet the text bar in both themes.
- Placeholder and muted secondary text meet 4.5:1 in both themes — today's
--dark-text-placeholder: #4a4a5eon#13131ais a likely failure. - Role badges and status tints meet the bar in both themes.
Verification: The full contrast suite passes on every in-scope route in both themes, with zero known-failing exemptions.
U10. Static error pages
Goal: The framework error pages follow the OS preference instead of being permanently dark.
Requirements: R18 (partial — see the stated limitation)
Dependencies: U1
Files:
- modify
public/404.html - modify
public/400.html - modify
public/422.html - modify
public/500.html
Approach:
Finding #4: these are static files with inline <style> blocks, hardcoded to #0a0a0f / #e0e0e0, served either by the static-file middleware or via ApplicationController#not_found's render html: File.read(...), layout: false. They never touch the app shell, cannot load theme.css, and — because a real 500 or a middleware-served 404 may not run app code at all — cannot be made to read the cookie without inline script, which script_src :self would need a nonce for and a static file cannot get one.
So: keep the existing dark values as the prefers-color-scheme: dark branch and add a light branch, using the same self-contained media-query pattern public/406-unsupported-browser.html already uses. Mirror the semantic token values from theme.css as literals — these files are intentionally dependency-free and that is the trade.
Stated limitation: these pages follow the OS only. A visitor who explicitly chose Light while their OS is dark will see a dark 404. The brainstorm scopes error pages in only "when they use the application shell", which these do not, so this unit is strictly an improvement over permanent-dark. See Q3.
Test scenarios:
- Requesting a nonexistent path with the browser emulating light renders a light 404; emulating dark renders a dark 404.
- Both branches meet the text contrast bar.
- The pages remain fully self-contained — no external stylesheet, no script, no asset-pipeline reference.
Verification: Each of the four pages, opened directly and via a triggering request, renders correctly under both emulated schemes.
Phase 4 — Coverage and cleanup
U11. Test infrastructure
Goal: System tests can control the emulated OS scheme, assert contrast, assert first-paint theme, and run at a mobile viewport.
Requirements: R28, R29
Dependencies: U2
Files:
- modify
test/system/application_system_test_case.rb - create
test/system/support/theme_helpers.rb - create
test/system/support/contrast_helpers.rb
Approach:
Theme helpers wrap with_playwright_page (already present on the base case):
- Set the emulated scheme via
emulate_media(colorScheme: "dark" | "light" | "no-preference"). Verified available:playwright-ruby-client1.58 exposesPage#emulate_media(colorScheme:), andcapybara-playwright-driver0.5.7'spage_options.rbalso acceptscolorScheme:andviewport:fordriven_bywhen a whole class needs a different default. - Read
<html data-theme>and the resolvedbackground-colorof a given selector. - Assert first-paint theme without a race: emulation persists across navigation, so set the scheme, then
visit, then sample the computed background before the page settles. This is the AE8 mechanism. - Set a narrow viewport for the mobile-viewport requirement (R29).
- Seed and clear the
themecookie.
Contrast helpers implement KTD-5: page.evaluate walks a curated selector list and returns resolved foreground/background pairs (resolving transparent by walking up ancestors); Ruby computes the WCAG 2.2 relative-luminance ratio and asserts against 4.5:1 / 3:1 with a failure message naming the selector, the two colors, and the measured ratio.
Note for the implementer (finding #5): these tests drive the dev server on :3005 against the dev database. test/fixtures/users.yml does not apply here. Use the seeded <email> / password and <email> users from db/seeds.rb, exactly as the existing login_as_test_user helper does.
Patterns to follow: the existing with_playwright_page helper and test/system/pages/*.rb page objects.
Test scenarios:
- The contrast helper returns a known-correct ratio for a known pair (black on white = 21:1; mid-gray
#767676on white ≈ 4.54:1) — a self-check so a broken helper cannot silently pass everything. - The contrast helper resolves an element with
background: transparentto its nearest painted ancestor rather than reporting a false pass. - The scheme-emulation helper actually changes what
matchMediareports inside the page. - The first-paint helper reports the initial theme, not the post-settle theme — verify by storing
darkand asserting the sampled value is dark rather than a transient light. - The mobile-viewport helper produces the requested
innerWidth.
Verification: The helper self-checks pass, and a throwaway test can load the landing page under emulated dark and read back data-theme="dark".
U12. System tests for theme behavior
Goal: Every acceptance example is enforced by an automated test.
Requirements: R28, R29, and AE1–AE11
Dependencies: U2, U3, U4, U5, U6, U7, U8, U9, U11
Files:
- create
test/system/theming_test.rb - create
test/system/theme_contrast_test.rb - create
test/system/theme_mobile_test.rb - create
test/system/pages/theme_switcher_page.rb
Approach:
Three files split by concern: behavior (theming_test.rb), the per-surface contrast sweep (theme_contrast_test.rb, slowest), and the mobile-viewport subset (theme_mobile_test.rb, its own class so it can carry a narrow driven_by). A Pages::ThemeSwitcherPage page object holds the selectors, matching the existing test/system/pages/ convention.
Surfaces the contrast sweep must cover, per R28's "representative states": landing; sign-in, sign-up, password reset, magic link; dashboard project list; a project with pages; the screenshot review page with annotations, comments, and the answer/resolution flow; a turbo_confirm dialog; a table/card-dense view (members, API keys); /help; /subscription (both plan cards); an empty state; and a validation-error state.
Keep PLAYWRIGHT_BROWSER untouched — it already parameterizes the browser (R29). Add Firefox/Safari-specific tests only if theme bootstrapping or matchMedia behavior actually diverges; do not add them speculatively.
Test scenarios:
- Covers AE1. No cookie, OS emulated dark → landing and dashboard both dark, switcher reads
System. - Covers AE2. No cookie, OS emulated light → landing light and dashboard light, switcher reads
System. - Covers AE3. Signed out, choose
Dark, reload → still dark. Then chooseSystemwith OS light → returns to light. - Covers AE4. Signed out with
Dark, sign in as a user whose account prefersLight→ UI becomes light, switcher readsLight. - Covers AE5. Signed out with
Dark, sign in as a user with no account preference → stays dark; a fresh session for the same user loads dark. - Covers AE6. Signed in with
Light, sign out → the browser stays light. - Covers AE7.
Systemselected, flip the emulated OS scheme with the page open → UI updates live; assert no navigation occurred and that a partially typed comment is still present. - Covers AE8.
Darkstored, reload → the first sampled frame is already dark, never light. - Covers AE9. Tab to the switcher, confirm the accessible name and the exposed current selection, change by keyboard, confirm the theme applied.
- Covers AE10. Screenshot image pixel hash is identical across themes while surrounding chrome differs (the assertion detailed in U8).
- Covers AE11. The contrast sweep passes on every listed surface in both themes.
- Mobile viewport (390×844): the switcher is reachable and operable in the landing nav and the app header; neither nav overflows; the dashboard and the screenshot page pass contrast in both themes.
- The dashboard renders correctly in dark mode with an empty project list (empty state) and with a validation error displayed.
Verification: bin/rails test:system passes locally with bin/dev running, in both Chromium desktop and the mobile-viewport class.
U13. Retire the compatibility bridge
Goal: One token vocabulary, no aliases, no duplicate stylesheet links.
Requirements: R4, R5, R30
Dependencies: U5, U6, U7, U8, U9
Files:
- modify
app/assets/stylesheets/theme.css - modify
app/views/layouts/auth.html.erb - modify
app/views/layouts/landing.html.erb - modify
app/views/screenshots/show.html.erb - create
wiki/log.d/<timestamp>-theme-system.md - modify
wiki/pages affected (architecture, technical-debt, testing-and-ci as applicable)
Approach:
Delete the LEGACY ALIASES block from theme.css (KTD-4) and confirm nothing references --color-*, --landing-*, or --dark-* any more. If a stray reference turns up, fix it at the call site rather than reinstating the alias.
Remove the now-redundant stylesheet_link_tag "auth", stylesheet_link_tag "landing", and stylesheet_link_tag "annotorious" calls — all three files are already emitted by the :app glob in _head.html.erb, so these are duplicate <link> elements. This is deliberately last and is the one part of this plan safe to drop if it misbehaves.
Per AGENTS.md, add a wiki/log.d/ fragment describing the theme system and update the affected wiki pages. Do not edit the compiled wiki/log.md.
Test scenarios: Test expectation: none — this unit removes dead declarations and duplicate link tags. The existing U12 suite is the regression net: it must pass unchanged after the aliases are removed, which is the whole point of running it last.
Verification: No alias tokens remain; grep for --color-, --landing-, --dark- returns nothing outside theme.css's semantic definitions; the full test suite passes; each layout emits each stylesheet exactly once.
System-Wide Impact
-
Schema.
usersgains one integer column with a non-null default — additive, backward-compatible, no backfill. -
Every rendered page gains one small inline script and one
<html>attribute. -
Every request now carries a
themecookie once set (a few bytes, including on asset requests). It is a strictly-necessary functional cookie; no consent surface is implied. -
config/initializers/rails_simple_auth.rbgains two callbacks. A defect there could affect sign-in, which is why U3 requires the Honeybadger-guardedrescuethe existing callback already models. -
Existing system tests run under Playwright's default
colorScheme: light, so they continue to see the light theme. The dashboard stays light by default for them; the landing page does not —test/system/auth_test.rbasserts.landing-navis present after sign-out, which is a structural assertion and unaffected by color. - No new runtime dependency, no npm, no build step (R30).
Risks
| ID | Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|---|
| R-1 | Wrong-theme flash if the bootstrap script is placed after a render-blocking resource, or a browser paints before it runs | Medium | High — it is the most visible possible defect | Script is the first element _head.html.erb emits, before stylesheet_link_tag; AE8 is tested by sampling the initial frame, not the settled one |
| R-2 |
Sign-in breaks if ThemePreferenceSync raises |
Low | Critical | Guarded rescue StandardError + Honeybadger, mirroring the existing after_confirmation_callback; a dedicated test asserts sign-in still succeeds when the sync raises |
| R-3 |
CI cannot run the new system tests. The system-test job runs bin/rails test:system with no dev server on :3005 and no playwright install, while Capybara.run_server = false. This is pre-existing and independent of this feature, but it means R28/R29 coverage will not actually execute in CI as things stand |
High | Medium | Out of scope per Scope Boundaries; surfaced as Q5. Tests are written to pass locally against bin/dev regardless |
| R-4 | Recolor scope is larger than it looks. ~110 hardcoded color values across four files, plus the fourth island in U7 | High | Medium | Split into four independently reviewable units (U5–U8); the alias bridge (KTD-4) means each lands working rather than requiring a big-bang cutover |
| R-5 |
Dark-background-dependent decorations — .hero__glow, .hero__grid, .auth-shell__grid, accent glows — have no obvious light equivalent and may look wrong rather than merely different |
Medium | Medium | Called out explicitly in U6; if a decoration cannot be made to work in light, hiding it in light mode is preferable to shipping something muddy, and is still "recoloring", not redesign |
| R-6 |
Annotorious vendored CSS interacts confusingly with a data-theme attribute — div[data-theme=...] selectors that look like they should match but do not |
Medium | Low | U8 establishes the actual behavior first and prefers a wrapper-div mirror over editing minified vendored CSS |
| R-7 | Cookie/account drift — a stale cookie contradicting the account on a device where the account changed elsewhere | Low | Low | The sign-in merge rewrites the cookie from the account whenever the account is explicit; PATCH /theme refreshes it server-side on every change |
| R-8 |
theme.css load-order assumption. Correct today (theme sorts last under Propshaft's alphabetical glob), but a future ui.css or zebra.css would sort after it |
Low | Low | U6/U13 delete the competing islands rather than relying on order; a comment in theme.css records the dependency |
| R-9 | Contrast remediation cascades into visible identity change. If today's brand gold fails AA on a light surface, fixing it changes the brand color | Medium | Medium | R26 is explicit that failing colors get fixed, and R20 permits accent refinement. Surface any change to the brand gold specifically for review rather than deciding it inside U9 |
| R-10 |
:app glob means all four stylesheets load everywhere, so a poorly scoped new rule in theme.css can affect surfaces it was never tested against |
Medium | Medium | Tokens are scoped to :root; only .theme-switcher adds new component rules, and U12's contrast sweep covers every layout |
Open Questions
Answer these during plan review; each changes real work.
Q1 — Cookie instead of localStorage? The brainstorm specifies local storage. This plan uses a cookie (A-1, KTD-2) because the brainstorm also requires MiniTest integration coverage of the sign-in merge rule, which is only reachable if the server can read the browser's value. Confirm the substitution, or accept that the merge moves to a client round-trip after sign-in — with a brief wrong-theme window and system-test-only coverage of R10.
Q2 — Switcher shape and mobile placement. No mobile drawer and no dashboard user menu exist (finding #2). This plan places a native <select> inline in the existing navs and builds no drawer, since a drawer is a layout change the brainstorm excludes. Confirm, or expand scope to build a mobile drawer and a dashboard user menu — a materially larger piece of work with its own design decisions.
Q3 — Static error pages. U10 gives public/{400,404,422,500}.html an OS-following light branch, which cannot honor an explicit override. Confirm that this partial improvement is wanted, or drop U10 and leave them permanently dark (defensible: the brainstorm scopes error pages in only when they use the app shell, and these do not).
Q4 — Visual regression depth. No screenshot-baseline infrastructure exists. This plan asserts computed contrast and theme invariants instead (A-6). Confirm, or add baseline-image regression as its own scoped piece of work.
Q5 — CI system-test job. It cannot pass today (R-3), so the new theme system tests will not actually run in CI as things stand. Confirm this stays out of scope, or add a unit to start the dev server and install Playwright browsers in that job.
Verification Contract
-
bin/rails test— all MiniTest unit, model, service, controller, and integration tests pass, including the newtheme_preferencecoverage. -
bin/rails test:system— passes locally withbin/devrunning, in Chromium desktop and at the mobile viewport. -
bin/rubocop— clean. -
bin/brakeman -q— no new warnings (the preference endpoint is a new authenticated write path). -
bin/importmap audit— unchanged; no new pins. - Manual: every in-scope route loads correctly in Light, Dark, and System, under both emulated OS schemes, with no wrong-theme flash and no reload on switch.
Definition of Done
- Every requirement R1–R30 is implemented or explicitly deferred with a recorded reason.
- Every acceptance example AE1–AE11 has a passing automated test.
- No hardcoded color values remain in
application.css,landing.css, orauth.cssoutsidetheme.css's semantic definitions. - Both themes pass the contrast sweep on every in-scope surface, with zero known-failing exemptions.
- Uploaded screenshot pixels are provably identical across themes.
- All five open questions are answered and the plan reflects the answers.
- The wiki log fragment and affected wiki pages are updated per
AGENTS.md.