From 13d4d8566605a6aa182162e103823bc5fc6e6fb2 Mon Sep 17 00:00:00 2001 From: Summer Schrader Date: Wed, 29 Jul 2026 11:05:48 -0400 Subject: [PATCH] Storybook: single dark/light theme toggle Replaces the three-item Theme dropdown and the backgrounds-addon color picker with one toolbar button that switches the story canvas and Docs pages between light and dark by emulating prefers-color-scheme. Co-Authored-By: Claude Opus 4.8 (1M context) --- .storybook/main.js | 6 +++- .storybook/manager.js | 36 ++++++++++++++++++++ .storybook/preview.js | 77 +++++++++++++++++++++++++++++++++++++++++++ package-lock.json | 37 ++++++++++++++++++++- package.json | 2 ++ 5 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 .storybook/manager.js diff --git a/.storybook/main.js b/.storybook/main.js index bbcd5a5af..456e33f10 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -3,7 +3,11 @@ module.exports = { "addons": [ "@storybook/addon-links", - "@storybook/addon-essentials", + { + // backgrounds only recolors the canvas; the theme button drives the real surfaces. + name: "@storybook/addon-essentials", + options: { backgrounds: false } + }, "@storybook/addon-interactions", "@storybook/addon-webpack5-compiler-babel", "@chromatic-com/storybook" diff --git a/.storybook/manager.js b/.storybook/manager.js new file mode 100644 index 000000000..1af2d0e40 --- /dev/null +++ b/.storybook/manager.js @@ -0,0 +1,36 @@ +import React from 'react'; +import { addons, types, useGlobals } from '@storybook/manager-api'; +import { IconButton } from '@storybook/components'; + +// Single-click light/dark toggle. "system" (follow the OS) applies until the first +// click; the pinned choice then persists in the URL. Binary by design — no third +// "System" state. Background: see the PR description. +const ADDON_ID = 'mdhui/theme-toggle'; + +const ThemeToggle = () => { + const [globals, updateGlobals] = useGlobals(); + const theme = globals.theme; + const effective = theme === 'light' || theme === 'dark' + ? theme + : (typeof window !== 'undefined' && window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); + const next = effective === 'dark' ? 'light' : 'dark'; + return React.createElement( + IconButton, + { + key: 'mdhui-theme-toggle', + title: 'Theme: ' + effective + ' — click to switch to ' + next, + onClick: () => updateGlobals({ theme: next }), + }, + React.createElement('span', { style: { fontSize: 14, lineHeight: 1 } }, effective === 'dark' ? '☾' : '☀'), + React.createElement('span', { style: { marginLeft: 6 } }, effective === 'dark' ? 'Dark' : 'Light') + ); +}; + +addons.register(ADDON_ID, () => { + addons.add(ADDON_ID + '/tool', { + type: types.TOOL, + title: 'Theme', + match: ({ viewMode }) => viewMode === 'story' || viewMode === 'docs', + render: ThemeToggle, + }); +}); diff --git a/.storybook/preview.js b/.storybook/preview.js index 07452cddd..344f095f2 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -1,6 +1,42 @@ +import React, { useEffect, useState } from "react"; +import { DocsContainer } from "@storybook/blocks"; +import { themes } from "@storybook/theming"; import myDataHelps from "@careevolution/mydatahelps-js"; +// Docs-page chrome is themed by a docs theme, not story decorators; the decorator +// publishes the `theme` global into this subscription for the docs container. +let currentTheme = "system"; +const themeSubscribers = new Set(); +const publishTheme = (theme) => { + if (theme === currentTheme) return; + currentTheme = theme; + themeSubscribers.forEach((fn) => fn(theme)); +}; +// Seed from a pinned `&globals=theme:...` URL — the decorator hasn't run at first mount. +if (typeof window !== "undefined" && window.location) { + const m = /[?&]globals=([^&]*)/.exec(window.location.search); + const pin = m && /(?:^|;)theme:(dark|light)/.exec(decodeURIComponent(m[1])); + if (pin) currentTheme = pin[1]; +} + +const effectiveDark = (theme) => + theme === "dark" || (theme !== "light" && typeof window !== "undefined" && window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches); + +const ThemedDocsContainer = (props) => { + const [theme, setTheme] = useState(currentTheme); + useEffect(() => { + themeSubscribers.add(setTheme); + return () => themeSubscribers.delete(setTheme); + }, []); + return React.createElement(DocsContainer, { ...props, theme: effectiveDark(theme) ? themes.dark : themes.light }); +}; + export const globalTypes = { + theme: { + name: 'Theme', + description: 'App color scheme (emulates prefers-color-scheme); toggled by the manager.js button', + defaultValue: 'system', + }, language: { name: 'Language', description: 'Language', @@ -33,7 +69,45 @@ export const globalTypes = { }, }; +// Layout resolves colorScheme="auto" from matchMedia, so the toggle emulates the OS +// preference; "system" restores the real matchMedia. +const realMatchMedia = typeof window !== "undefined" && window.matchMedia ? window.matchMedia.bind(window) : undefined; + +function applyThemeGlobal(theme) { + if (!realMatchMedia) return; + if (theme !== 'light' && theme !== 'dark') { + window.matchMedia = realMatchMedia; + return; + } + const wantsDark = theme === 'dark'; + window.matchMedia = (query) => { + if (/prefers-color-scheme/.test(query)) { + // Listener hooks are no-ops: components read the scheme at render (the toggle + // re-renders every story); a 'change' subscriber would not hear toolbar toggles. + return { + matches: /dark/.test(query) ? wantsDark : !wantsDark, + media: query, + onchange: null, + addEventListener() { }, + removeEventListener() { }, + addListener() { }, + removeListener() { }, + dispatchEvent() { return false; }, + }; + } + return realMatchMedia(query); + }; +} + export const decorators = [(story, context) => { + publishTheme(context.globals.theme); + applyThemeGlobal(context.globals.theme); + // Paint the canvas behind the story too (semantic token; fallback = shipped dark bg). + if (typeof document !== "undefined") { + const wantsDark = context.globals.theme === "dark" + || (context.globals.theme !== "light" && window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches); + document.body.style.backgroundColor = wantsDark ? "var(--mdhui-background-color-1, #1d1c22)" : ""; + } myDataHelps.setParticipantAccessToken({ "access_token": process.env.STORYBOOK_PARTICIPANT_ACCESS_TOKEN, "expires_in": 21600, "token_type": "Bearer" }, process.env.STORYBOOK_PARTICIPANT_ENVIRONMENT_API ? process.env.STORYBOOK_PARTICIPANT_ENVIRONMENT_API : "https://mydatahelps.org/"); if (context.globals.language) { myDataHelps.setCurrentLanguage(context.globals.language); @@ -52,5 +126,8 @@ export const parameters = { date: /Date$/, }, }, + docs: { + container: ThemedDocsContainer, + }, } export const tags = ["autodocs"]; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 7959035cc..38ea6e7f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,10 +48,12 @@ "@storybook/addon-interactions": "^8.6.17", "@storybook/addon-links": "^8.6.17", "@storybook/addon-webpack5-compiler-babel": "^3.0.3", + "@storybook/blocks": "^8.6.18", "@storybook/react": "^8.6.17", "@storybook/react-webpack5": "^8.6.17", "@storybook/test": "^8.6.17", "@storybook/test-runner": "^0.22.0", + "@storybook/theming": "^8.6.18", "@testing-library/react": "^16.3.2", "@types/jest": "^29.5.14", "@types/lodash": "^4.17.7", @@ -135,6 +137,7 @@ "integrity": "sha512-QdxmAo/ikZqqRGA8s43ww8lcql6naWRvEz0FFrl6MIlc7Gi6TroXnSdWa5U/kq6fzcpqpHesicQxFZIieZbyIA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.6", @@ -2069,6 +2072,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -2092,6 +2096,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -2676,6 +2681,7 @@ "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.5.1.tgz", "integrity": "sha512-MfRCYlQPXoLlpem+egxjfkEuP9UQswTrlCOsknus/NcMoblTH2g0jPrapbcIb04KGA7E2GZxbAccGZfWoYgsrQ==", "hasInstallScript": true, + "peer": true, "dependencies": { "@fortawesome/fontawesome-common-types": "6.5.1" }, @@ -3568,6 +3574,7 @@ "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.2.3.tgz", "integrity": "sha512-F+L5SsciykwDl7eDxacnhDTcWe1IF6jetzfkvI5PPfq6ogWHO7xcjU90SGh/3lqbbS0tgun+qF01KIqxawrCsA==", "license": "MIT", + "peer": true, "dependencies": { "@cfworker/json-schema": "^4.0.2", "@standard-schema/spec": "^1.1.0", @@ -3832,6 +3839,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5172,6 +5180,7 @@ "dev": true, "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.19" @@ -5819,6 +5828,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.15.tgz", "integrity": "sha512-oEjE7TQt1fFTFSbf8kkNuc798ahTUzn3Le67/PWjE8MAfYAD/qB7O8hSTcromLFqHCt9bcdOg5GXMokzTjJ5SA==", "devOptional": true, + "peer": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -6175,6 +6185,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6225,6 +6236,7 @@ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -6535,6 +6547,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", "dev": true, + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -6975,6 +6988,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -8505,6 +8519,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -10424,6 +10439,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -10466,6 +10482,7 @@ "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/environment": "^29.7.0", "@jest/expect": "^29.7.0", @@ -11003,6 +11020,7 @@ "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/environment": "^29.7.0", "@jest/fake-timers": "^29.7.0", @@ -11397,6 +11415,7 @@ "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/console": "^29.7.0", "@jest/environment": "^29.7.0", @@ -11862,6 +11881,7 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -12976,6 +12996,7 @@ "resolved": "https://registry.npmjs.org/openai/-/openai-6.48.0.tgz", "integrity": "sha512-KhVp+FyV50QrXNextvL9hIU5l6ox5HYuKQjGVk7lIqprgJol90+dQXWONV6S1lRWsKA1bXjrow8RsUT14M1hNA==", "license": "Apache-2.0", + "peer": true, "peerDependencies": { "@aws-sdk/credential-provider-node": ">=3.972.0 <4", "@smithy/hash-node": ">=4.3.0 <5", @@ -13464,6 +13485,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -14094,6 +14116,7 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -14315,6 +14338,7 @@ "version": "18.2.0", "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -14374,6 +14398,7 @@ "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.0" @@ -14980,6 +15005,7 @@ "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -15562,6 +15588,7 @@ "integrity": "sha512-p8seiSI6FiVY6P3V0pG+5v7c8pDMehMAFRWEhG5XqIBSQszzOjDnW2rNvm3odoLKfo3V3P6Cs6Hv9ILzymULyQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@storybook/core": "8.6.18" }, @@ -15945,6 +15972,7 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -16173,7 +16201,8 @@ "version": "2.4.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", - "dev": true + "dev": true, + "peer": true }, "node_modules/tsx": { "version": "4.19.4", @@ -16267,6 +16296,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "dev": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16651,6 +16681,7 @@ "integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -16729,6 +16760,7 @@ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -16815,6 +16847,7 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -17004,6 +17037,7 @@ "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", "devOptional": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -17110,6 +17144,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index e9304fbf4..117f8e6ca 100644 --- a/package.json +++ b/package.json @@ -57,10 +57,12 @@ "@storybook/addon-interactions": "^8.6.17", "@storybook/addon-links": "^8.6.17", "@storybook/addon-webpack5-compiler-babel": "^3.0.3", + "@storybook/blocks": "^8.6.18", "@storybook/react": "^8.6.17", "@storybook/react-webpack5": "^8.6.17", "@storybook/test": "^8.6.17", "@storybook/test-runner": "^0.22.0", + "@storybook/theming": "^8.6.18", "@testing-library/react": "^16.3.2", "@types/jest": "^29.5.14", "@types/lodash": "^4.17.7",