Skip to content
Hive

Complete light and dark themes

add-complete-light-and-dark-260816-5829

screenote · #42620 · Coding

Stage: Plan Needs your input Review plan draft
On this page
  1. Overview
  2. High-Level Technical Design
  3. Assumptions
  4. Requirements Trace
  5. Scope Boundaries
  6. Key Technical Decisions
  7. Implementation Units
  8. System-Wide Impact
  9. Risks
  10. Open Questions
  11. Verification Contract
  12. Definition of Done

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. A theme cookie (1 year, path=/, SameSite=Lax, Secure on HTTPS, not HttpOnly) 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. A localStorage implementation 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_preference is 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 the 0 default, so no nullable column is needed.
  • A-3. The switcher is a native <select> labelled "Theme" with options Light / 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 demonstrates color-scheme working. See Q2.
  • A-4. Applying a theme never reloads. Stimulus writes data-theme, the cookie, and the theme-color meta synchronously on change; the account write is a fire-and-forget fetch PATCH that the UI does not await.
  • A-5. Account preference system counts as "no preference" for the merge rule, per the brainstorm's "absence of a stored preference is system".
  • 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_screenshot artifacts for human review. See Q4.
  • A-7. theme.css is a new sixth stylesheet, not an edit to an existing one. Propshaft's :app glob 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, so data-theme survives 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 using layouts/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 (all layouts/application).
  • All four stylesheets and the new theme.css.
  • The users schema 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 hardcoded color-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.html into a themed page. It already has a prefers-color-scheme block and is served pre-app.
  • Fixing the CI system-test job. It runs bin/rails test:system with no dev server on :3005 and no playwright install step, 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.erb carries "theme_color": "red" and "name": "ScreenoteTmp" — placeholder scaffolding, unrouted, unrelated to this feature.
  • Deduplicating the redundant stylesheet_link_tag calls in layouts/auth.html.erb, layouts/landing.html.erb, and app/views/screenshots/show.html.erb (all already covered by the :app glob). 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 --brand gold (#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: --overlay for the landing nav's backdrop-filter scrim

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=dark stored, a fresh load of the dashboard has data-theme="dark" on <html> at the first paint — asserted by sampling the resolved background-color of <body> immediately after domcontentloaded, 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 to dark; emulating light resolves to light.
  • With a cookie holding a garbage value (theme=purple), resolution falls through to the OS query rather than setting data-theme="purple".
  • With cookies disabled entirely, the page still renders and resolves from the OS query without a console error.
  • The emitted <script> carries a nonce attribute 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 system and 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_preference system; User.new.theme_preference_system? is true with nothing assigned.
  • Assigning an unknown value raises rather than silently persisting.
  • Covers AE4. Cookie dark, account light → after sign-in the account stays light and the cookie is rewritten to light.
  • Covers AE5. Cookie dark, account system → after sign-in the account is dark and the cookie stays dark.
  • Cookie absent, account light → after sign-in the cookie is written as light.
  • Cookie absent, account system → after sign-in neither changes and no write is issued.
  • Cookie dark, account dark → idempotent; no redundant database write.
  • Cookie holds a garbage value and the account is system → the account is left at system rather 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, then DELETE /session → the theme cookie is still present and still light in the response.
  • PATCH /theme signed in with dark → 204, user.reload.theme_preference is dark, response sets the cookie.
  • PATCH /theme signed in with an invalid value → rejected, the stored preference is unchanged.
  • PATCH /theme signed out → redirected to sign-in, no record touched.
  • PATCH /theme without 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 is system, attach a change listener to matchMedia("(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, set data-theme on <html>, update the theme-color meta, and re-evaluate the media listener. If signed in, fire a fetch PATCH to the preference route with the csrf-token meta as X-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 in layouts/landing.html.erb as the brainstorm assumed.
  • layouts/auth.html.erb — in .auth-shell__main, above the yielded form, so it survives the max-width: 860px breakpoint 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 Dark stored and the OS emulating light, select System → the UI returns to light without a reload.
  • Covers AE7. With System selected, flipping the emulated OS scheme flips the UI live, with no navigation and with page state (e.g. a partially typed comment) intact.
  • With Light explicitly 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 Dark issues exactly one PATCH to the preference route and the UI does not wait on it.
  • Signed in, if the PATCH fails (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 /help while 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-nav uses background: rgba(10,10,15,0.8) with backdrop-filter: blur(12px) — becomes the --overlay token so the scrim inverts with the theme.
  • .landing-nav__cta sets color: #0a0a0f on the gold accent. Gold-on-near-black is the brand button; it needs --text-on-accent so it stays legible if the accent shifts.
  • .auth-shell__side uses background: #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-canvas background, 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__image has no filter, no non-default opacity, and no mix-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: #4a4a5e on #13131a is 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-client 1.58 exposes Page#emulate_media(colorScheme:), and capybara-playwright-driver 0.5.7's page_options.rb also accepts colorScheme: and viewport: for driven_by when a whole class needs a different default.
  • Read <html data-theme> and the resolved background-color of 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 theme cookie.

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 #767676 on white ≈ 4.54:1) — a self-check so a broken helper cannot silently pass everything.
  • The contrast helper resolves an element with background: transparent to its nearest painted ancestor rather than reporting a false pass.
  • The scheme-emulation helper actually changes what matchMedia reports inside the page.
  • The first-paint helper reports the initial theme, not the post-settle theme — verify by storing dark and 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 choose System with OS light → returns to light.
  • Covers AE4. Signed out with Dark, sign in as a user whose account prefers Light → UI becomes light, switcher reads Light.
  • 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. System selected, 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. Dark stored, 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. users gains 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 theme cookie 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.rb gains two callbacks. A defect there could affect sign-in, which is why U3 requires the Honeybadger-guarded rescue the 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.rb asserts .landing-nav is 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 new theme_preference coverage.
  • bin/rails test:system — passes locally with bin/dev running, 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

  1. Every requirement R1–R30 is implemented or explicitly deferred with a recorded reason.
  2. Every acceptance example AE1–AE11 has a passing automated test.
  3. No hardcoded color values remain in application.css, landing.css, or auth.css outside theme.css's semantic definitions.
  4. Both themes pass the contrast sweep on every in-scope surface, with zero known-failing exemptions.
  5. Uploaded screenshot pixels are provably identical across themes.
  6. All five open questions are answered and the plan reflects the answers.
  7. The wiki log fragment and affected wiki pages are updated per AGENTS.md.

HiveDev, when it’s ready.

HiveDev is the hosted product being built around open-source Hive. It isn’t available yet; join the waitlist to hear when it is. We’ll use your email for HiveDev availability updates.

Read our privacy notice.