diff --git a/.changeset/config.json b/.changeset/config.json index de483e8..48a14ed 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -1,6 +1,6 @@ { "$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json", - "changelog": ["@changesets/changelog-github", { "repo": "variableland/theme-toggle" }], + "changelog": ["@changesets/changelog-github", { "repo": "withvariablelab/theme-toggle" }], "commit": true, "fixed": [], "linked": [], diff --git a/.changeset/renamed-to-variablelab.md b/.changeset/renamed-to-variablelab.md new file mode 100644 index 0000000..d92e67d --- /dev/null +++ b/.changeset/renamed-to-variablelab.md @@ -0,0 +1,15 @@ +--- +"@variablelab/theme-toggle": major +--- + +Renamed from `@vlandoss/theme-toggle`. The package now lives at [withvariablelab/theme-toggle](https://github.com/withvariablelab/theme-toggle); `@vlandoss/theme-toggle` will not receive further releases. + +**Breaking changes** + +- Install and import `@variablelab/theme-toggle` instead of `@vlandoss/theme-toggle` (the inline script moves to `https://unpkg.com/@variablelab/theme-toggle/dist/initial.js`). +- The `localStorage` key is now `@variablelab/theme-toggle.colorMode` (was `@vlandoss/theme-toggle.colorMode`). +- The change event is now `@variablelab/theme-toggle.colorModeChange` (was `@vlandoss/theme-toggle.colorModeChange`). Update any `document.addEventListener(...)` listeners, or read the name from `window.__COLOR_MODE__.CUSTOM_EVENT`. + +**Automatic migration** + +Users keep their saved color mode. On init, both the inline script and `useColorMode` check for the 1.x key: if `@variablelab/theme-toggle.colorMode` is absent and `@vlandoss/theme-toggle.colorMode` exists, its value is copied to the new key. A value already stored under the new key always wins. Either way, the old key is removed. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 363f3c8..46c91c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,9 @@ on: branches: - main +permissions: + contents: read # checkout + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true @@ -15,15 +18,18 @@ jobs: runs-on: ubuntu-latest steps: - name: ⬇️ Checkout repo - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false - name: 🟢 Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: node-version-file: .node-version - name: ⌛ Setup pnpm - uses: variableland/gh-actions/actions/setup-pnpm@main + # First-party action, deliberately tracked at @main. + uses: withvariablelab/gh-actions/actions/setup-pnpm@main # zizmor: ignore[unpinned-uses] - name: 💅 Check run: pnpm rr check @@ -36,3 +42,20 @@ jobs: - name: 🔨 Build run: pnpm rr pack + + zizmor: + runs-on: ubuntu-latest + steps: + - name: ⬇️ Checkout repo + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + + # Audits .github/ (the repo has no other workflows or actions). Passing the + # repo root keeps the annotation paths repo-relative so they land on the diff. + - name: 🌈 Audit workflows (zizmor) + env: + # Read-only token so zizmor also runs its online audits + # (known-vulnerable-actions, impostor-commit, ref-confusion). + GH_TOKEN: ${{ github.token }} + run: pipx run --spec zizmor==1.30.1 zizmor --format=github . diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 55b2baf..ed68c36 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,6 +9,9 @@ on: branches: - main +# The org default GITHUB_TOKEN is read-only; each job asks for exactly what it needs. +permissions: {} + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true @@ -17,68 +20,103 @@ jobs: release: if: ${{ github.event_name == 'push' }} runs-on: ubuntu-latest + # Pushes, the version PR and the GitHub releases go through the vland-bot + # token minted below, not GITHUB_TOKEN. permissions: - id-token: write - contents: write - packages: write - pull-requests: write + id-token: write # npm trusted publishing (OIDC) + contents: read # checkout steps: - - name: 🔑 Mint vland-bot token - id: app-token - uses: actions/create-github-app-token@v1 - with: - app-id: ${{ secrets.VLAND_BOT_APP_ID }} - private-key: ${{ secrets.VLAND_BOT_PRIVATE_KEY }} - - name: ⬇️ Checkout repo - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: - token: ${{ steps.app-token.outputs.token }} + # changesets/action writes ~/.netrc with its GITHUB_TOKEN (the + # vland-bot token below) and pushes through it. + persist-credentials: false - name: 🟢 Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: node-version-file: .node-version - name: ⌛ Setup pnpm - uses: variableland/gh-actions/actions/setup-pnpm@main + # First-party action, deliberately tracked at @main. + uses: withvariablelab/gh-actions/actions/setup-pnpm@main # zizmor: ignore[unpinned-uses] + + - name: 🔑 Mint vland-bot token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.VLAND_BOT_CLIENT_ID }} + private-key: ${{ secrets.VLAND_BOT_PRIVATE_KEY }} + permission-contents: write + permission-pull-requests: write - name: 🧑‍💻 Configure git user + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + APP_SLUG: ${{ steps.app-token.outputs.app-slug }} run: | - git config user.name "vland-bot[bot]" - git config user.email "282456776+vland-bot[bot]@users.noreply.github.com" + user_id="$(gh api "/users/${APP_SLUG}[bot]" --jq .id)" + git config user.name "${APP_SLUG}[bot]" + git config user.email "${user_id}+${APP_SLUG}[bot]@users.noreply.github.com" - name: 🚀 Create release PR or publish versions - uses: changesets/action@v1 + uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1.9.0 with: commit: "chore: update versions" title: "chore: update versions" publish: pnpm changeset publish setupGitUser: false env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + # The bot opens the version PR, so CI runs on it. + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} LEFTHOOK: 0 + # Runs the PR's code and publishes the preview to npm via OIDC. It never sees + # the vland-bot key; it only hands the published package list to the next job. preview: if: ${{ github.event_name == 'pull_request' && (startsWith(github.head_ref, 'feat') || startsWith(github.head_ref, 'fix') || contains(github.event.pull_request.labels.*.name, 'preview')) }} runs-on: ubuntu-latest permissions: - id-token: write - contents: read + id-token: write # npm trusted publishing (OIDC) + contents: read # checkout + outputs: + packages: ${{ steps.preview.outputs.packages }} steps: - name: ⬇️ Checkout repo - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false - name: 🟢 Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: node-version-file: .node-version - name: ⌛ Setup CI - uses: variableland/gh-actions/actions/setup-pnpm@main + # First-party action, deliberately tracked at @main. + uses: withvariablelab/gh-actions/actions/setup-pnpm@main # zizmor: ignore[unpinned-uses] - - name: 🚀 Preview release - uses: variableland/gh-actions/actions/preview-release@main + - name: 🚀 Publish preview + id: preview + # First-party action, deliberately tracked at @main. + uses: withvariablelab/gh-actions/actions/preview-release@main # zizmor: ignore[unpinned-uses] with: - npm_token: ${{ secrets.NPM_TOKEN }} + mode: publish + # Keep this explicit while the repo has a project .npmrc: preview-release + # auto-detects any .npmrc as token-only, and there is no NPM_TOKEN here. + publish_mode: oidc-only + + # Never checks out or runs PR code: the reusable workflow mints the vland-bot + # token (pull-requests: write, this repo only) and upserts the PR comment. + preview-comment: + needs: preview + if: ${{ needs.preview.result == 'success' }} + permissions: {} + # First-party reusable workflow, deliberately tracked at @main. + uses: withvariablelab/gh-actions/.github/workflows/preview-comment.yml@main # zizmor: ignore[unpinned-uses] + with: + action: preview-release + packages: ${{ needs.preview.outputs.packages }} + secrets: + VLAND_BOT_PRIVATE_KEY: ${{ secrets.VLAND_BOT_PRIVATE_KEY }} diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..36f3e84 --- /dev/null +++ b/.npmrc @@ -0,0 +1,18 @@ +# Supply-chain guard: never resolve a dependency version published less than +# 24 h ago (most malicious releases are pulled within the first hour). Requires +# pnpm >= 10.16. +# +# No exclude list on purpose: our own scopes (@variablelab/*, @rrlab/*) wait +# 24 h too. In .npmrc, `minimum-release-age-exclude[]` breaks whenever pnpm is +# launched through npm (`npx pnpm`, `npm exec`, `npm run`). npm re-exports the +# array as a single string, and pnpm 10.30.3 then exempts every package. To +# adopt a fresh own release right away, pass +# `--config.minimum-release-age=0` to that one `pnpm add`. +# +# This lives here instead of pnpm-workspace.yaml because @rrlab/cli (through +# @vlandoss/clibuddy 0.7.1) treats any repo with a pnpm-workspace.yaml as a +# monorepo, and `rr tsc` then fails ("Invalid workspace patterns") or, with +# `packages: ["."]`, silently skips this package. Once +# https://github.com/withvariablelab/dx/issues/249 ships, move it there with +# minimumReleaseAgeExclude, and delete this file. +minimum-release-age=1440 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c0963d..b4ea43a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ -# @vlandoss/theme-toggle +# @variablelab/theme-toggle ## 1.0.0 ### Major Changes -- [#2](https://github.com/variableland/theme-toggle/pull/2) [`dd42ff8`](https://github.com/variableland/theme-toggle/commit/dd42ff8bd1ca2b96c6484f8a323d17aa916e158a) Thanks [@rqbazan](https://github.com/rqbazan)! - Initial stable release. Ships the `useColorMode` React hook and an inline pre-paint script for `light` / `dark` / `system` color modes. The `system` mode resolves against the OS `prefers-color-scheme` and tracks it live, the choice is persisted to `localStorage`, a `dark` class is toggled on ``, and a `CustomEvent` is dispatched on every change. +- [#2](https://github.com/withvariablelab/theme-toggle/pull/2) [`dd42ff8`](https://github.com/withvariablelab/theme-toggle/commit/dd42ff8bd1ca2b96c6484f8a323d17aa916e158a) Thanks [@rqbazan](https://github.com/rqbazan)! - Initial stable release. Ships the `useColorMode` React hook and an inline pre-paint script for `light` / `dark` / `system` color modes. The `system` mode resolves against the OS `prefers-color-scheme` and tracks it live, the choice is persisted to `localStorage`, a `dark` class is toggled on ``, and a `CustomEvent` is dispatched on every change. diff --git a/README.md b/README.md index 42f8a2d..e38d372 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# @vlandoss/theme-toggle +# @variablelab/theme-toggle Light/dark/system color-mode toggle for React apps, with a tiny inline script that sets the theme **before first paint** to avoid the flash of the wrong theme (FOUC). @@ -12,7 +12,7 @@ sets the theme **before first paint** to avoid the flash of the wrong theme (FOU ## Installation ```sh -pnpm add @vlandoss/theme-toggle +pnpm add @variablelab/theme-toggle ``` ## Usage @@ -26,7 +26,7 @@ Load the prebuilt IIFE **before your app renders** — ideally as the first thin ```html - + ``` @@ -38,7 +38,7 @@ exposes its runtime contract on `window.__COLOR_MODE__`. ### 2. React hook (read & control the mode) ```tsx -import { useColorMode } from "@vlandoss/theme-toggle"; +import { useColorMode } from "@variablelab/theme-toggle"; function ThemeSwitch() { const { colorMode, setColorMode } = useColorMode(); @@ -81,17 +81,17 @@ body { ## How it works -| Concern | Value | -| -------------- | ------------------------------------------ | -| DOM class | `dark` on `document.documentElement` | -| Storage key | `@vlandoss/theme-toggle.colorMode` | -| Change event | `@vlandoss/theme-toggle.colorModeChange` | +| Concern | Value | +| ------------ | ------------------------------------------- | +| DOM class | `dark` on `document.documentElement` | +| Storage key | `@variablelab/theme-toggle.colorMode` | +| Change event | `@variablelab/theme-toggle.colorModeChange` | The change event is a `CustomEvent` dispatched on `document`, with the new value in `event.detail.colorMode`: ```ts -document.addEventListener("@vlandoss/theme-toggle.colorModeChange", (event) => { +document.addEventListener("@variablelab/theme-toggle.colorModeChange", (event) => { console.log(event.detail.colorMode); // "light" | "dark" | "system" }); ``` @@ -100,6 +100,19 @@ The inline script also publishes these names on `window.__COLOR_MODE__` (`STORAGE_KEY`, `CUSTOM_EVENT`, `COLOR_MODE`) so other scripts can read them without importing the package. +## Upgrading from `@vlandoss/theme-toggle` 1.x + +The package was renamed to `@variablelab/theme-toggle` in 2.0.0: + +1. Replace the dependency and imports: `pnpm remove @vlandoss/theme-toggle && pnpm add @variablelab/theme-toggle`, + and update the inline script URL to `https://unpkg.com/@variablelab/theme-toggle/dist/initial.js`. +2. Listeners of the change event must use the new name, `@variablelab/theme-toggle.colorModeChange` + (or read it from `window.__COLOR_MODE__.CUSTOM_EVENT`). + +Saved preferences carry over automatically. On init, if the old `@vlandoss/theme-toggle.colorMode` +key exists, its value is copied to the new storage key, unless the new key already holds a value +(that value wins). The old key is then removed either way. + ## Development ```sh diff --git a/mise.toml b/mise.toml index bb9552b..f5d8d74 100644 --- a/mise.toml +++ b/mise.toml @@ -3,4 +3,4 @@ _.path = ["{{config_root}}/node_modules/.bin"] [tools] node = "24.16.0" -pnpm = "10.6.4" +pnpm = "10.30.3" diff --git a/package.json b/package.json index 65a5e37..c0b50c3 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,14 @@ { - "name": "@vlandoss/theme-toggle", + "name": "@variablelab/theme-toggle", "version": "1.0.0", "description": "Light/dark/system color-mode toggle for React with a no-flash inline script", - "homepage": "https://github.com/variableland/theme-toggle#readme", + "homepage": "https://github.com/withvariablelab/theme-toggle#readme", "bugs": { - "url": "https://github.com/variableland/theme-toggle/issues" + "url": "https://github.com/withvariablelab/theme-toggle/issues" }, "repository": { "type": "git", - "url": "git+https://github.com/variableland/theme-toggle.git" + "url": "git+https://github.com/withvariablelab/theme-toggle.git" }, "license": "MIT", "author": "rcrd ", @@ -62,5 +62,5 @@ "react": ">=18", "react-dom": ">=18" }, - "packageManager": "pnpm@10.6.4" + "packageManager": "pnpm@10.30.3" } diff --git a/src/const.ts b/src/const.ts index 1b38b08..4d7a481 100644 --- a/src/const.ts +++ b/src/const.ts @@ -1,6 +1,13 @@ -export const COLOR_MODE_EVENT = "@vlandoss/theme-toggle.colorModeChange"; +export const COLOR_MODE_EVENT = "@variablelab/theme-toggle.colorModeChange"; -export const COLOR_MODE_STORAGE_KEY = "@vlandoss/theme-toggle.colorMode"; +export const COLOR_MODE_STORAGE_KEY = "@variablelab/theme-toggle.colorMode"; + +/** + * Storage key written by `@vlandoss/theme-toggle` 1.x. On init its value is + * moved to {@link COLOR_MODE_STORAGE_KEY} unless that key is already set, and + * the legacy key is removed (see `migrateLegacyColorMode`). + */ +export const LEGACY_COLOR_MODE_STORAGE_KEY = "@vlandoss/theme-toggle.colorMode"; export const COLOR_MODE = { DARK: "dark", diff --git a/src/index.ts b/src/index.ts index a22077b..744087d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ import * as React from "react"; import { COLOR_MODE, COLOR_MODE_EVENT, COLOR_MODE_STORAGE_KEY, type ColorMode } from "./const.ts"; +import { migrateLegacyColorMode } from "./migrate.ts"; export function useColorMode() { const [colorMode, setColorMode] = React.useState(() => { @@ -8,6 +9,8 @@ export function useColorMode() { } try { + migrateLegacyColorMode(); + const raw = localStorage.getItem(COLOR_MODE_STORAGE_KEY); if (!raw) { diff --git a/src/initial.ts b/src/initial.ts index e422cf4..a505044 100644 --- a/src/initial.ts +++ b/src/initial.ts @@ -1,6 +1,9 @@ import { COLOR_MODE, COLOR_MODE_EVENT, COLOR_MODE_STORAGE_KEY } from "./const.ts"; +import { migrateLegacyColorMode } from "./migrate.ts"; function getColorMode() { + migrateLegacyColorMode(); + const persistedColorMode = localStorage.getItem(COLOR_MODE_STORAGE_KEY); return typeof persistedColorMode === "string" ? persistedColorMode : COLOR_MODE.SYSTEM; diff --git a/src/migrate.ts b/src/migrate.ts new file mode 100644 index 0000000..f979763 --- /dev/null +++ b/src/migrate.ts @@ -0,0 +1,23 @@ +import { COLOR_MODE_STORAGE_KEY, LEGACY_COLOR_MODE_STORAGE_KEY } from "./const.ts"; + +/** + * Carries a color mode persisted by `@vlandoss/theme-toggle` 1.x over to the + * current storage key, so upgrading does not reset the user's choice. + * + * When the legacy key exists, its value is copied only if the current key is + * absent (a value already stored under the current key always wins), and the + * legacy key is then removed either way, so no stale 1.x entry is left behind. + */ +export function migrateLegacyColorMode() { + const legacyColorMode = localStorage.getItem(LEGACY_COLOR_MODE_STORAGE_KEY); + + if (legacyColorMode === null) { + return; + } + + if (localStorage.getItem(COLOR_MODE_STORAGE_KEY) === null) { + localStorage.setItem(COLOR_MODE_STORAGE_KEY, legacyColorMode); + } + + localStorage.removeItem(LEGACY_COLOR_MODE_STORAGE_KEY); +} diff --git a/test/functional/index.test.ts b/test/functional/index.test.ts index 7e14f58..4998064 100644 --- a/test/functional/index.test.ts +++ b/test/functional/index.test.ts @@ -1,6 +1,12 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { renderHook } from "vitest-browser-react"; -import { COLOR_MODE, COLOR_MODE_EVENT, COLOR_MODE_STORAGE_KEY, type ColorMode } from "../../src/const.ts"; +import { + COLOR_MODE, + COLOR_MODE_EVENT, + COLOR_MODE_STORAGE_KEY, + type ColorMode, + LEGACY_COLOR_MODE_STORAGE_KEY, +} from "../../src/const.ts"; import { useColorMode } from "../../src/index.ts"; function hasDarkClass() { @@ -93,6 +99,45 @@ describe("useColorMode — initialization from localStorage", () => { }); }); +describe("useColorMode — migrates the @vlandoss/theme-toggle 1.x storage key", () => { + it("adopts the legacy value and removes the legacy key when the current key is absent", async () => { + localStorage.setItem(LEGACY_COLOR_MODE_STORAGE_KEY, COLOR_MODE.DARK); + + const { result } = await renderHook(() => useColorMode()); + + expect(result.current.colorMode).toBe(COLOR_MODE.DARK); + expect(hasDarkClass()).toBe(true); + expect(readStoredMode()).toBe(COLOR_MODE.DARK); + expect(localStorage.getItem(LEGACY_COLOR_MODE_STORAGE_KEY)).toBeNull(); + }); + + it("keeps the current key and removes the legacy key when both are present", async () => { + localStorage.setItem(COLOR_MODE_STORAGE_KEY, COLOR_MODE.LIGHT); + localStorage.setItem(LEGACY_COLOR_MODE_STORAGE_KEY, COLOR_MODE.DARK); + + const { result } = await renderHook(() => useColorMode()); + + expect(result.current.colorMode).toBe(COLOR_MODE.LIGHT); + expect(hasDarkClass()).toBe(false); + expect(readStoredMode()).toBe(COLOR_MODE.LIGHT); + expect(localStorage.getItem(LEGACY_COLOR_MODE_STORAGE_KEY)).toBeNull(); + }); + + it('falls back to "system" and logs when the migrated legacy value is invalid', async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + stubPrefersColorScheme(false); + localStorage.setItem(LEGACY_COLOR_MODE_STORAGE_KEY, "not-a-real-mode"); + + const { result } = await renderHook(() => useColorMode()); + + expect(result.current.colorMode).toBe(COLOR_MODE.SYSTEM); + expect(localStorage.getItem(LEGACY_COLOR_MODE_STORAGE_KEY)).toBeNull(); + expect(consoleError).toHaveBeenCalledWith("Failed to read color mode from localStorage", expect.any(Error)); + + consoleError.mockRestore(); + }); +}); + describe("useColorMode — system mode follows the OS preference", () => { it('applies the dark class for "system" when the OS prefers dark', async () => { stubPrefersColorScheme(true); diff --git a/test/functional/initial.test.ts b/test/functional/initial.test.ts index 085b4e7..612431a 100644 --- a/test/functional/initial.test.ts +++ b/test/functional/initial.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { COLOR_MODE, COLOR_MODE_EVENT, COLOR_MODE_STORAGE_KEY } from "../../src/const.ts"; +import { COLOR_MODE, COLOR_MODE_EVENT, COLOR_MODE_STORAGE_KEY, LEGACY_COLOR_MODE_STORAGE_KEY } from "../../src/const.ts"; // A fresh query string forces the browser to re-evaluate the side-effectful // module; vi.resetModules() does not re-run native modules in Browser Mode. @@ -97,11 +97,54 @@ describe("initial.ts — defaults to system on first visit", () => { }); }); +describe("initial.ts — migrates the @vlandoss/theme-toggle 1.x storage key", () => { + it("adopts the legacy value and removes the legacy key when the current key is absent", async () => { + localStorage.setItem(LEGACY_COLOR_MODE_STORAGE_KEY, COLOR_MODE.DARK); + const matchMedia = stubPrefersDark(false); + + await runInitialScript(); + + expect(hasDarkClass()).toBe(true); + expect(localStorage.getItem(COLOR_MODE_STORAGE_KEY)).toBe(COLOR_MODE.DARK); + expect(localStorage.getItem(LEGACY_COLOR_MODE_STORAGE_KEY)).toBeNull(); + expect(matchMedia).not.toHaveBeenCalled(); + }); + + it('migrates a legacy "system" value and resolves it against the OS preference', async () => { + localStorage.setItem(LEGACY_COLOR_MODE_STORAGE_KEY, COLOR_MODE.SYSTEM); + stubPrefersDark(true); + + await runInitialScript(); + + expect(hasDarkClass()).toBe(true); + expect(localStorage.getItem(COLOR_MODE_STORAGE_KEY)).toBe(COLOR_MODE.SYSTEM); + expect(localStorage.getItem(LEGACY_COLOR_MODE_STORAGE_KEY)).toBeNull(); + }); + + it("keeps the current key and removes the legacy key when both are present", async () => { + localStorage.setItem(COLOR_MODE_STORAGE_KEY, COLOR_MODE.LIGHT); + localStorage.setItem(LEGACY_COLOR_MODE_STORAGE_KEY, COLOR_MODE.DARK); + + await runInitialScript(); + + expect(hasDarkClass()).toBe(false); + expect(localStorage.getItem(COLOR_MODE_STORAGE_KEY)).toBe(COLOR_MODE.LIGHT); + expect(localStorage.getItem(LEGACY_COLOR_MODE_STORAGE_KEY)).toBeNull(); + }); +}); + describe("initial.ts — runtime contract on window", () => { beforeEach(() => { stubPrefersDark(false); }); + it("uses the @variablelab/theme-toggle storage key and event name", async () => { + await runInitialScript(); + + expect(window.__COLOR_MODE__.STORAGE_KEY).toBe("@variablelab/theme-toggle.colorMode"); + expect(window.__COLOR_MODE__.CUSTOM_EVENT).toBe("@variablelab/theme-toggle.colorModeChange"); + }); + it("exposes the storage key, event name, and color-mode map on window", async () => { await runInitialScript();