Skip to content

Dark mode: remap the semantic tokens onto a purpose-built palette - #657

Open
n400 wants to merge 4 commits into
summer/storybook-theme-togglefrom
summer/dark-mode-color-system
Open

Dark mode: remap the semantic tokens onto a purpose-built palette#657
n400 wants to merge 4 commits into
summer/storybook-theme-togglefrom
summer/dark-mode-color-system

Conversation

@n400

@n400 n400 commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Overview

Expands the MyDataHelps color palette to better support APCA (WCAG 3), dark mode, and color vision
differences.

The differences should be dark-mode-only, and barely noticeable--just for readability. Here are some examples: https://claude.ai/code/artifact/361ee3d2-6cb8-4f48-9de0-0f8b3f05ce86

One deliberate exception in light mode: GlucoseStats' glucose and sleep icons pick up the shared token values (#d36540#c4291c, #8287bb#7b88c6). It was the only component using either hex — GlucoseChart, Histogram and DayTrackerSymbol already used #c4291c, and ActivityMeter, SparkBarChart and DayTrackerSymbol already used #7b88c6 — so this converges an outlier rather than changing the palette. Before/after: https://claude.ai/code/artifact/41a4c046-0335-48de-aa1f-39100cb4f0cb

WCAG 2.x ratios are calibrated for dark text on light backgrounds, while APCA, the perceptual model
drafted for WCAG 3, is the recommended measure for dark mode, so the dark tokens are chosen and
audited under both.

  • More hues, and two new grades. The palette grows from 11 hue families to 26, and gains
    half-steps at 35 and 85 that give dark status text a legal home. The magic-number contract itself
    is unchanged: a grade encodes lightness, and the gap between two grades sets the contrast floor for
    any pair of hues (40 → 3:1, 50 → 4.5:1, 70 → 7:1). Every pair in the expanded set is verified
    against it.
  • --mdh-, not --ce-: DesignerUI already uses --ce-* for a more muted palette. The old values
    are frozen in src/helpers/legacyCeColors.ts and still emitted, so ViewBuilder content renders
    unchanged.
  • Dark tokens remapped onto the ramp: cards grey-85 over a grey-95 background, and accents
    split by duty — the base token for fills that carry white text, a new -text variant for
    foreground. Signal colors (glucose, heart rate, activity, sleep, air quality) ship only as
    explicit -mark (chart marks) / -text pairs, so there is no bare signal name to grab for the
    wrong duty. Component references were rerouted to whichever one they actually need.
  • Brand colors survive runtime scheme toggles. Layout renders the scheme styles and a customer
    primaryColor in a single Emotion Global. Emotion orders style tags by mount order, not JSX
    order, so with separate Globals a toggle into dark re-mounted the scheme tag after the brand tag
    and reverted every primary-colored element to stock blue for the rest of the session.
  • npm run audit:colors checks the token definitions: every semantic pairing against its role
    floor under WCAG 2.x and APCA. It's a standalone tool, not a build step — run it after any token
    change. It reads the token file only, so it confirms the tokens are sound, not that a given
    component reached for the right one.
  • Start/Colors Storybook page rewritten for the new system.

Stacked on #646 (the Storybook theme toggle), which needs to land first.

Follow-ups

  • PR 3 — component-specific fixes where hardcoded hexes are being used instead of tokens.
  • PR 4 — moves light mode to the magic number system.

Security

  • I have ensured no secure credentials or sensitive information remain in code, metadata, comments, etc.
    • Please verify that you double checked that .storybook/preview.js does not contain your participant access key details.
    • There are no temporary testing changes committed such as API base URLs, access tokens, print/log statements, etc.
  • These changes do not introduce any security risks, or any such risks have been properly mitigated.

CSS custom properties, component style references, a build-time audit script, and a Storybook page.
No participant data, credentials, or API surface. Two new dependencies (colorjs.io, apca-w3) run
only at build and test time.

Testing

  • This change can be adequately tested using the MDH Storybook.
  • This change requires additional testing in the MDH iOS/Android/Web apps.

What changed, in plain terms: dark mode has a rebuilt color set. Light mode should look the same
as it does today — if a light-mode screen looks different from production, that's a bug.

Important: this PR converts the shared color system, but a number of screens still have colors
written directly into them. Those are already known and are being fixed in a follow-up PR. Please
don't treat them as blockers here — but please do write them down (see "Two kinds of problems"
below), because that list is exactly what the follow-up needs.

How to test (Storybook):

  1. Open this branch's Storybook and use the theme toggle in the top toolbar to switch between light
    and dark.
  2. Use the side-by-side gallery
    as the reference for what correct dark mode looks like — ten components, light on the left, dark
    on the right.
  3. In dark mode, check that:
    • Text is comfortably readable — nothing faint, nothing invisible.
    • Colored buttons and selected pills still have white text that's easy to read.
    • Status colors still read as status — errors and overdue items red, warnings orange, success
      green — just lighter, so they show up on dark cards.
    • Charts: dots, bars, and lines are clearly visible against the dark background.
  4. Toggle light → dark → light a few times on the same story. Colors should land in the same place
    every time. If a story sets a customer brand color, that color must survive the toggle rather
    than reverting to the standard blue.

Two kinds of problems, and how to tell them apart. Switch the story between light and dark and
watch the element in question:

What you see What it means What to do
It changes between light and dark, but is hard to read in dark A real bug in this PR File it against this PR
It looks exactly the same in both — a light box in a dark screen, black text, a color that never moves A color hardcoded into that screen Add to the follow-up list; not a blocker

Known to be in the second bucket already, so no need to re-report unless something looks worse than
"stuck in light mode": the Asthma screens (control calendar, log entry details, provider report),
Symptom Shark (notes timeline, severity chart, monthly report, entry icons), and the project header,
badges, daily data goal, blood pressure readings, and recent-data bar chart. Anything outside that
list is worth writing down to make sure we catch it in the follow-up PR.

In the apps (iOS / Android / Web): the OS dark mode setting is a real input that Storybook can
only simulate, so a short pass on device is needed.

  1. With the phone in dark mode, open the app and confirm screens render in dark mode at all.
  2. With the app open, change the phone's system dark/light setting, and note what happens — does the
    app follow, and if a customer brand color is set, does it stay the brand color?
  3. Check the status bar at the top of the screen matches the app (not a light bar over a dark app).
  4. Open a Symptom Shark PDF report and confirm it looks as it does today.
  5. View a page built in ViewBuilder that uses custom colors, and confirm it looks as it does today.

Already verified (no manual steps needed): a standalone contrast audit was run over every color pairing,
checking each against both the current accessibility standard and the newer one
designed for dark mode; the full unit-test suite passes (131 suites, 621 tests); the gallery above
was generated from this exact branch; and the older --ce-* colors used by ViewBuilder content are
unchanged byte-for-byte.

Documentation

  • I have added relevant Storybook updates to this PR.
  • If this feature requires a developer doc update, I have tagged @CareEvolution/api-docs.
  • This change does not impact documentation or Storybook.

Reviewers

To be assigned.

WCAG 2.x ratios are calibrated for dark text on light backgrounds; APCA,
drafted for WCAG 3, is the recommended measure for dark mode. The dark
tokens are now chosen and audited under both.

Adds the --mdh-* palette: a color's grade encodes its lightness, so the
gap between two grades sets a contrast floor for any pair of hues (40 for
3:1, 50 for 4.5:1, 70 for 7:1). Half-steps at 35 and 85 give dark status
text a home that the existing grades couldn't provide.

The prefix is --mdh- because DesignerUI already uses --ce-* for a
different palette. The old values move to legacyCeColors.ts unchanged and
are still emitted, so ViewBuilder content renders as before.

Accent tokens split by duty: the base token is for fills, which carry
white text, and a -text variant is for foreground. Component references
were routed to whichever they need.

scripts/color-audit.mjs checks the token definitions against their role
floors under both models; npm run audit:colors gates dark mode in CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@n400
n400 requested review from greinard and removed request for greinard August 7, 2026 15:37
…ens, doc corrections

- Layout: render scheme + brand primaryColor in a single Emotion Global so a
  runtime scheme toggle can't reorder the tags and clobber the customer color
- Rename the signal family's bare tokens to explicit -mark duty
  (glucose/heart-rate/activity/sleep/air-quality); no bare signal names remain
- colorDocs: add the missing Glucose row, correct the air-quality dark label
  to teal-35, adopt the -mark vocabulary
- globalCss: correct the glucose-text comment to the shipped pair
  (red-orange-35 on grey-85, measured Lc), stop claiming light hexes were
  kept verbatim
- Standardize progress fills on primary-text (ProgressBar default,
  BasicPointsForBadges) to match ProgressRing; move GlucoseChart's steps
  overlay and GlucoseDayCoordinator's spark ranges onto mark tokens

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@n400
n400 requested a balanced review from Copilot August 8, 2026 02:29

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Rebuilds the dark-mode color system around an expanded palette while preserving legacy ViewBuilder colors and light-mode behavior.

Changes:

  • Adds dark-mode semantic tokens and updates component color roles.
  • Preserves legacy --ce-* variables and branded runtime themes.
  • Adds color documentation, APCA tooling, and a contrast audit.

Reviewed changes

Copilot reviewed 67 out of 69 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
src/stories/foundation/colorDocs.tsx Rewrites color-system documentation.
src/stories/foundation/colorDocs.css Styles the new palette documentation.
src/helpers/news-feed/eventTypeDefinitions.tsx Uses foreground status tokens.
src/helpers/legacyCeColors.ts Preserves legacy --ce-* colors.
src/helpers/globalCss.ts Defines the palette and semantic mappings.
src/helpers/chartHelpers.tsx Updates default chart colors.
src/components/view/NewPointsView/NewPointsView.css Updates primary foreground colors.
src/components/view/DocumentDetailView/DocumentDetailView.tsx Updates delete-button color.
src/components/view/DocumentDetailView/DocumentDetailView.css Updates primary foreground color.
src/components/symptom-shark/presentational/NotesTimeline/NotesTimeline.css Updates timeline accent color.
src/components/symptom-shark/presentational/Calendar/Calendar.css Updates notes-icon color.
src/components/symptom-shark/container/ReportBuilder/ReportBuilder.css Updates success foreground color.
src/components/step/StepLayout/StepLayout.css Updates link color.
src/components/presentational/ValueSelector/ValueSelector.css Updates selected checkbox color.
src/components/presentational/TermInformationButton/TermInformationButton.tsx Updates information-icon color.
src/components/presentational/SingleSurveyTask/SingleSurveyTask.css Updates due-date status colors.
src/components/presentational/SingleMeal/SingleMeal.tsx Updates selected-meal icon color.
src/components/presentational/SingleExternalAccount/SingleExternalAccount.css Updates error text color.
src/components/presentational/SingleDataPoint/SingleDataPoint.css Updates setup text color.
src/components/presentational/SegmentedControl/SegmentedControl.css Updates control foreground colors.
src/components/presentational/Resource/Resource.css Updates resource-link color.
src/components/presentational/ProgressRing/ProgressRing.css Updates progress-ring stroke.
src/components/presentational/ProgressBar/ProgressBar.tsx Updates default progress color.
src/components/presentational/NotesInput/NotesInput.css Updates input border color.
src/components/presentational/NavigationBar/NavigationBar.css Updates navigation color.
src/components/presentational/MealAnalysis/MealAnalysis.css Updates meal status colors.
src/components/presentational/Layout/Layout.tsx Combines scheme and brand styles.
src/components/presentational/LabResultWithSparkline/LabResultWithSparkline.tsx Updates sparkline colors.
src/components/presentational/LabResultWithSparkline/LabResultWithSparkline.css Updates term-info color.
src/components/presentational/Histogram/Histogram.css Updates clickable-label color.
src/components/presentational/GlucoseStats/GlucoseStats.tsx Applies signal color tokens.
src/components/presentational/DumbbellChart/DumbbellChart.css Updates chart-mark colors.
src/components/presentational/DiscreteScale/DiscreteScale.tsx Updates default slider color.
src/components/presentational/DateRangeNavigator/DateRangeNavigator.css Updates navigator colors.
src/components/presentational/Chat/Chat.css Updates focus outline color.
src/components/presentational/Button/Button.tsx Updates light/subtle foregrounds.
src/components/presentational/ActivityMeter/ActivityMeter.css Updates activity text color.
src/components/presentational/Action/Action.css Updates indicator color.
src/components/container/SurveyAnswerChart/SurveyAnswerChart.tsx Updates tooltip series color.
src/components/container/RelativeActivityDayCoordinator/RelativeActivityDayCoordinator.tsx Updates activity chart color.
src/components/container/RelativeActivity/RelativeActivity.tsx Updates relative-activity color.
src/components/container/RecentDailyDataBarChart/RecentDailyDataBarChart.tsx Updates label color.
src/components/container/ProviderSearch/ProviderSearch.css Updates provider status colors.
src/components/container/ProjectSupport/ProjectSupport.css Updates support-action colors.
src/components/container/MostRecentNotification/MostRecentNotification.css Updates notification subtitle color.
src/components/container/MicroTrend/MicroTrend.tsx Updates trend chart colors.
src/components/container/MealEditor/MealEditor.css Updates editor action/status colors.
src/components/container/Goal/Goal.tsx Updates goal-state colors.
src/components/container/GlucoseDayCoordinator/GlucoseDayCoordinator.tsx Applies glucose mark token.
src/components/container/GlucoseChart/GlucoseChart.tsx Applies semantic chart tokens.
src/components/container/GlucoseChart/GlucoseChart.css Updates glucose title color.
src/components/container/ExternalAccountConnectionAlert/ExternalAccountConnectionAlert.tsx Updates warning-icon color.
src/components/container/EhrNewsFeed/EhrNewsFeed.css Updates feed action color.
src/components/container/DeviceDataMonthChart/DeviceDataMonthChart.tsx Updates line-chart color.
src/components/container/DailyDataGoal/DailyDataGoal.tsx Updates goal progress colors.
src/components/container/ConnectEhr/ConnectEhr.css Updates connection status colors.
src/components/container/ConnectedDevices/ConnectedDevices.css Updates battery status colors.
src/components/container/ConnectDevicesMenu/ConnectDevicesMenu.css Updates connection action colors.
src/components/container/ConnectDevice/ConnectDevice.css Updates device status colors.
src/components/container/BloodPressureVisualization/BloodPressureVisualization.css Updates metric status colors.
src/components/container/BasicPointsForBadges/BasicPointsForBadges.tsx Updates progress-bar color.
src/components/asthma/helpers/asthma-functions.ts Updates offline status color.
src/components/asthma/components/AsthmaLogEntryDetails/AsthmaLogEntryDetails.css Updates action color.
src/components/asthma/components/AsthmaAirQualities/AsthmaAirQualities.css Updates setup/action colors.
src/components/asthma/components/AsthmaActionPlanManager/AsthmaActionPlanManager.css Updates action-plan colors.
src/components/@types/custom.d.ts Declares APCA package types.
scripts/color-audit.mjs Adds contrast-audit tooling.
package.json Adds audit command and dependencies.
package-lock.json Locks new color dependencies.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/helpers/chartHelpers.tsx Outdated
Comment thread scripts/color-audit.mjs
Comment on lines +109 to +114
for (const g of SIGNALS) {
for (const s of SURFACES) {
if (t[`--mdhui-color-${g}`]) pushPair(`${g} mark on ${bgName(s)}`, `--mdhui-color-${g}`, s, MARK_FLOORS.wcag, MARK_FLOORS.apca, false);
if (t[`--mdhui-color-${g}-text`]) pushPair(`${g}-text on ${bgName(s)}`, `--mdhui-color-${g}-text`, s, FOREGROUND_FLOORS.wcag, FOREGROUND_FLOORS.apca, false);
}
}
Comment thread scripts/color-audit.mjs Outdated
Comment on lines +85 to +87
const pushPair = (label, fg, bg, wFloor, aFloor, exempt) => {
const fH = hex(fg), bH = hex(bg);
if (!fH || !bH) { rows.push({ label, note: `unresolved (${t[fg]} / ${t[bg]})`, skip: true }); return; }
Comment thread package.json
Comment on lines +25 to +26
"test-storybook": "test-storybook",
"audit:colors": "node scripts/color-audit.mjs semantic --scheme dark --ci"
Comment thread src/helpers/globalCss.ts
Comment on lines +616 to +620
/* Signal colors: "-mark" = chart marks (bars/pins/dots); "-text" = the signal as
foreground, since CVD-optimized mark colors can be too weak as text. Light keeps
one canonical hex per signal, picked from the values components hardcoded — where
components disagreed (GlucoseStats' #d36540/#8287bb), they shift to it; dark
overrides. */
Comment thread src/stories/foundation/colorDocs.tsx Outdated
Comment on lines +522 to +524
<li><strong>Gap 60+:</strong> muted and large text (APCA floor Lc 45; palette worst case 47).</li>
<li><strong>Gap 70+:</strong> body text (floor Lc 60; worst case 68).</li>
<li><strong>Gap 80+:</strong> primary text (floor Lc 75; worst case 81).</li>
n400 and others added 2 commits August 7, 2026 23:26
…r() typo

- color-audit: check the signal `-mark` tokens. The loop composed bare names, so the
  -mark rename left every signal-mark lookup undefined and the guard skipped it —
  15 checks silently vanished and the run still reported PASS. -text stays optional,
  since heart-rate and air-quality intentionally ship none.
- color-audit: an unresolved required token now fails instead of skipping, so a
  renamed or misspelled variable can't drop its pair from the audit unnoticed.
- color-audit: read the `core` :root declarations underneath the scheme block, and
  follow var() hops through --mdhui aliases. The accent bases live in core and only
  dark overrides them, so the light audit had never resolved an accent at all —
  it reported 10 failures while 21 pairs were invisible. Light now resolves
  everything and reports 31 real failures (PR 4's scope); dark passes with all
  15 mark rows genuinely checked.
- chartHelpers: close the `var(--mdhui-color-primary-text)` fallback. The missing
  paren predates this branch, but the line is touched here and the invalid value
  meant uncolored line-chart series lost their gradient color entirely.
- colorDocs: the APCA bullets labelled gap 70 as "body text" while the table above
  requires gap 80 for it. State them as APCA's own floors and say why the table is
  deliberately a step stricter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…sty, duty fixes

Brand color (Layout): renders exactly as configured, never transformed. In light it
drives fills and foreground, as production always has. In dark it drives fills only;
links keep the scheme's readable grade. An explicitly-set darkMode value also stays
fill-only: no single dark value can carry white text (needs luminance <= 0.18) and
read on the grade-85 card (needs >= 0.26), and ViewBuilder's ColorPicker has been
storing darkMode values since 3.x — reinterpreting them as text colors would break
saved views on upgrade. A dedicated text-color field is the follow-up. New Layout
stories exercise both cases; new tests pin all four.

Signals: heart-rate gains a real -text (red-orange-35 dark) — its mark grade is
Lc 43.1 as foreground, under the 45 floor. Air-quality's mark is now audited AT the
foreground floors (TEXT_VIA_MARK), which surfaced a real light-mode shortfall
(3.9:1 on bg2) that the old probe-based guard silently skipped.

Audit: core parsing is :root-scoped (a prefers-reduced-motion value was shadowing
the :root one); unresolved required tokens fail instead of skipping; a typo'd
signal token now breaks the run instead of deleting its own checks.

Duty fixes: DailyDataGoal splits its one color into text default (-text) and fill
default (base) — the -text grade washed out to 1.13:1 at opacity .3 over the track;
ActivityMeter's meter fill matches ProgressBar on primary-text; colorDocs' progress
preview matches the shipped default.

Docs: APCA tiers restated from the spec with measured ramp floors (49.3/70.9/81.5);
the non-monotone Lc-90 note (gap 82 clears it, 85 and 89 do not); the glucose/
heart-rate collision warning; heart-rate row wired to its own -text token.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants