From d58e2fd4602220ee5f689d4f24644cc852c6aec3 Mon Sep 17 00:00:00 2001 From: "Ricardo Q. Bazan" Date: Mon, 12 Oct 2026 18:22:00 -0500 Subject: [PATCH 1/8] feat!: rename to @variablelab/theme-toggle and migrate the 1.x storage key - package name, repository/homepage/bugs URLs and changelog-github repo now point at @variablelab/theme-toggle and withvariablelab/theme-toggle - the localStorage key and the change event move to the @variablelab/theme-toggle.* namespace - on init, the inline script and useColorMode copy a value stored under the old @vlandoss/theme-toggle.colorMode key to the new key (only when the new key is absent) and remove the old key, so upgrading keeps the user's choice - README (including the unpkg URL and an upgrade section) and CHANGELOG heading renamed; major changeset describing the breaking change Co-Authored-By: Claude Opus 5.5 --- .changeset/config.json | 2 +- .changeset/renamed-to-variablelab.md | 15 +++++++++ CHANGELOG.md | 4 +-- README.md | 33 +++++++++++++------ package.json | 8 ++--- src/const.ts | 10 ++++-- src/index.ts | 3 ++ src/initial.ts | 3 ++ src/migrate.ts | 20 ++++++++++++ test/functional/index.test.ts | 47 +++++++++++++++++++++++++++- test/functional/initial.test.ts | 45 +++++++++++++++++++++++++- 11 files changed, 169 insertions(+), 21 deletions(-) create mode 100644 .changeset/renamed-to-variablelab.md create mode 100644 src/migrate.ts 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..93a795a --- /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 and the old key is removed. A value already stored under the new key always wins. 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..bae73a5 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 new storage key is empty and the old +`@vlandoss/theme-toggle.colorMode` key exists, its value is copied to the new key and the old key +is removed. + ## Development ```sh diff --git a/package.json b/package.json index 65a5e37..f525bb2 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 ", diff --git a/src/const.ts b/src/const.ts index 1b38b08..f0c1314 100644 --- a/src/const.ts +++ b/src/const.ts @@ -1,6 +1,12 @@ -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} (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..ee01c26 --- /dev/null +++ b/src/migrate.ts @@ -0,0 +1,20 @@ +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. + * + * Only acts when the current key is absent and the legacy key exists: the + * legacy value is copied and the legacy key removed. A value already stored + * under the current key always wins. + */ +export function migrateLegacyColorMode() { + const legacyColorMode = localStorage.getItem(LEGACY_COLOR_MODE_STORAGE_KEY); + + if (legacyColorMode === null || localStorage.getItem(COLOR_MODE_STORAGE_KEY) !== null) { + return; + } + + 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..e23a774 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 leaves the legacy key untouched 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)).toBe(COLOR_MODE.DARK); + }); + + 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..1bf62bb 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 leaves the legacy key untouched 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)).toBe(COLOR_MODE.DARK); + }); +}); + 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(); From e7607d906db6149aae693644e270ee0a13955ed2 Mon Sep 17 00:00:00 2001 From: "Ricardo Q. Bazan" Date: Mon, 12 Oct 2026 18:22:08 -0500 Subject: [PATCH 2/8] chore: bump pnpm to 10.30.3 and refuse dependency versions younger than 24 h minimumReleaseAge needs pnpm >= 10.16. The setting lives in .npmrc rather than pnpm-workspace.yaml because @rrlab/cli (via @vlandoss/clibuddy) treats any pnpm-workspace.yaml as a monorepo marker, which breaks `rr tsc` in this single-package repo. Our own scopes (@variablelab/*, @rrlab/*) are exempt. The lockfile is unchanged: `pnpm install --frozen-lockfile` passes on 10.30.3. Co-Authored-By: Claude Opus 5.5 --- .npmrc | 13 +++++++++++++ mise.toml | 2 +- package.json | 2 +- 3 files changed, 15 insertions(+), 2 deletions(-) create mode 100644 .npmrc diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..c79d06d --- /dev/null +++ b/.npmrc @@ -0,0 +1,13 @@ +# Supply-chain guard: never resolve a dependency version published less than +# 24 h ago (most malicious releases are pulled within the first hour). Our own +# scopes are exempt so fresh releases can be adopted right away. Requires +# pnpm >= 10.16. +# +# This lives here instead of pnpm-workspace.yaml on purpose: @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. Move it once clibuddy only +# treats the file as a workspace when it declares `packages`. +minimum-release-age=1440 +minimum-release-age-exclude[]=@variablelab/* +minimum-release-age-exclude[]=@rrlab/* 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 f525bb2..c0b50c3 100644 --- a/package.json +++ b/package.json @@ -62,5 +62,5 @@ "react": ">=18", "react-dom": ">=18" }, - "packageManager": "pnpm@10.6.4" + "packageManager": "pnpm@10.30.3" } From 8cbf992d6180a7750f623b559fb49df6aa9fcf81 Mon Sep 17 00:00:00 2001 From: "Ricardo Q. Bazan" Date: Mon, 12 Oct 2026 18:24:41 -0500 Subject: [PATCH 3/8] ci: move to withvariablelab, npm OIDC, vland-bot v3 tokens and two-job previews - every workflow declares its permissions (the org default token is read-only); checkouts use persist-credentials: false - release: drop NPM_TOKEN, publish through npm trusted publishing (OIDC); mint the vland-bot token with create-github-app-token v3 (client id, scoped to contents and pull-requests write) and pass it to changesets, so the bot opens the version PR and CI runs on it; derive the git identity from the app slug instead of hardcoding the bot user id - previews in two jobs: `preview` runs the PR code and publishes via OIDC (mode: publish, publish_mode: oidc-only), exposing only the package list; `preview-comment` calls the gh-actions reusable workflow, which never checks out PR code and is the only place the bot key is used - actions from withvariablelab/gh-actions (tracked at @main), every other action pinned by full SHA - CI audits the workflows with zizmor 1.30.1 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 29 +++++++++-- .github/workflows/release.yml | 91 ++++++++++++++++++++++++----------- 2 files changed, 89 insertions(+), 31 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 363f3c8..b1c89b4 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 (MIGRATION.md, Regla 4). + 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..2fd1081 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,100 @@ 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 (MIGRATION.md, Regla 4). + 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 (MIGRATION.md, Regla 4). + 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 (MIGRATION.md, Regla 4). + uses: withvariablelab/gh-actions/actions/preview-release@main # zizmor: ignore[unpinned-uses] with: - npm_token: ${{ secrets.NPM_TOKEN }} + mode: publish + 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 (MIGRATION.md, Regla 4). + uses: withvariablelab/gh-actions/.github/workflows/preview-comment.yml@main # zizmor: ignore[unpinned-uses] + with: + packages: ${{ needs.preview.outputs.packages }} + secrets: + VLAND_BOT_PRIVATE_KEY: ${{ secrets.VLAND_BOT_PRIVATE_KEY }} From cb3393c347ce2ded8bf3600287403782204473a7 Mon Sep 17 00:00:00 2001 From: "Ricardo Q. Bazan" Date: Mon, 12 Oct 2026 18:27:24 -0500 Subject: [PATCH 4/8] ci: pass the action name to the preview-comment reusable workflow preview-comment.yml (withvariablelab/gh-actions, chore/two-job-previews) requires an `action` input naming the action that renders the comment; this repo publishes with preview-release. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2fd1081..da0b8fe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -114,6 +114,7 @@ jobs: # First-party reusable workflow, deliberately tracked at @main (MIGRATION.md, Regla 4). 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 }} From 4724d0c0bef36b72252eccde666d5a4c7cfc520a Mon Sep 17 00:00:00 2001 From: "Ricardo Q. Bazan" Date: Mon, 12 Oct 2026 19:17:42 -0500 Subject: [PATCH 5/8] chore: drop the minimum-release-age exclude list from .npmrc When pnpm is launched through npm (`npx pnpm`, `npm exec`, `npm run`), npm re-exports `minimum-release-age-exclude[]` as one `npm_config_minimum_release_age_exclude="@variablelab/*\n\n@rrlab/*"` string. pnpm 10.30.3 then exempts every package, so the 24 h cooldown has no effect. A single unrelated entry (`@foo/*`) does the same. When pnpm is run directly (mise, pnpm/action-setup, nested `pnpm run`), it parses the array correctly. Keep only `minimum-release-age=1440`, so own scopes wait 24 h too. Checked on pnpm 10.30.3 with the age raised to 2 years: `pnpm add typescript@5.9.2 --lockfile-only` fails with ERR_PNPM_NO_MATURE_MATCHING_VERSION both directly and via `npx pnpm`, and `--config.minimum-release-age=0` overrides it. `pnpm install --frozen-lockfile` still passes. The excludes come back once the setting can move to pnpm-workspace.yaml (withvariablelab/dx#249). Co-Authored-By: Claude Opus 5.5 --- .npmrc | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/.npmrc b/.npmrc index c79d06d..36f3e84 100644 --- a/.npmrc +++ b/.npmrc @@ -1,13 +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). Our own -# scopes are exempt so fresh releases can be adopted right away. Requires +# 24 h ago (most malicious releases are pulled within the first hour). Requires # pnpm >= 10.16. # -# This lives here instead of pnpm-workspace.yaml on purpose: @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. Move it once clibuddy only -# treats the file as a workspace when it declares `packages`. +# 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 -minimum-release-age-exclude[]=@variablelab/* -minimum-release-age-exclude[]=@rrlab/* From 45512fe5897d40c44a8c298102848d01b6ea7de2 Mon Sep 17 00:00:00 2001 From: "Ricardo Q. Bazan" Date: Mon, 12 Oct 2026 19:18:04 -0500 Subject: [PATCH 6/8] ci: document why the preview job pins publish_mode to oidc-only preview-release core.ts treats any project .npmrc as token-only when publish_mode is auto, and this repo has no NPM_TOKEN. The explicit `oidc-only` must stay until .npmrc can be deleted (withvariablelab/dx#249). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index da0b8fe..f31961f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -103,6 +103,8 @@ jobs: uses: withvariablelab/gh-actions/actions/preview-release@main # zizmor: ignore[unpinned-uses] with: 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 From e986ef73ca923376029a22e1d978fb5b4e058815 Mon Sep 17 00:00:00 2001 From: "Ricardo Q. Bazan" Date: Mon, 12 Oct 2026 19:18:53 -0500 Subject: [PATCH 7/8] fix: remove the 1.x storage key even when the new key is already set migrateLegacyColorMode() used to return early when both keys existed, so `@vlandoss/theme-toggle.colorMode` stayed in localStorage forever. D2 says the old key is copied and then deleted. Now, whenever the legacy key exists, its value is copied only if the new key is absent (a stored new value still wins), and the legacy key is then removed either way. The two "both present" tests now expect the legacy key to be gone. With the previous implementation both fail, and all 25 pass with this one. The changeset, README and JSDoc describe the new behavior. dist/initial.js stays at 756 B. Co-Authored-By: Claude Opus 5.5 --- .changeset/renamed-to-variablelab.md | 2 +- README.md | 6 +++--- src/const.ts | 3 ++- src/migrate.ts | 13 ++++++++----- test/functional/index.test.ts | 4 ++-- test/functional/initial.test.ts | 4 ++-- 6 files changed, 18 insertions(+), 14 deletions(-) diff --git a/.changeset/renamed-to-variablelab.md b/.changeset/renamed-to-variablelab.md index 93a795a..d92e67d 100644 --- a/.changeset/renamed-to-variablelab.md +++ b/.changeset/renamed-to-variablelab.md @@ -12,4 +12,4 @@ Renamed from `@vlandoss/theme-toggle`. The package now lives at [withvariablelab **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 and the old key is removed. A value already stored under the new key always wins. +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/README.md b/README.md index bae73a5..e38d372 100644 --- a/README.md +++ b/README.md @@ -109,9 +109,9 @@ The package was renamed to `@variablelab/theme-toggle` in 2.0.0: 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 new storage key is empty and the old -`@vlandoss/theme-toggle.colorMode` key exists, its value is copied to the new key and the old key -is removed. +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 diff --git a/src/const.ts b/src/const.ts index f0c1314..4d7a481 100644 --- a/src/const.ts +++ b/src/const.ts @@ -4,7 +4,8 @@ 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} (see `migrateLegacyColorMode`). + * 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"; diff --git a/src/migrate.ts b/src/migrate.ts index ee01c26..f979763 100644 --- a/src/migrate.ts +++ b/src/migrate.ts @@ -4,17 +4,20 @@ import { COLOR_MODE_STORAGE_KEY, LEGACY_COLOR_MODE_STORAGE_KEY } from "./const.t * 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. * - * Only acts when the current key is absent and the legacy key exists: the - * legacy value is copied and the legacy key removed. A value already stored - * under the current key always wins. + * 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 || localStorage.getItem(COLOR_MODE_STORAGE_KEY) !== null) { + if (legacyColorMode === null) { return; } - localStorage.setItem(COLOR_MODE_STORAGE_KEY, legacyColorMode); + 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 e23a774..4998064 100644 --- a/test/functional/index.test.ts +++ b/test/functional/index.test.ts @@ -111,7 +111,7 @@ describe("useColorMode — migrates the @vlandoss/theme-toggle 1.x storage key", expect(localStorage.getItem(LEGACY_COLOR_MODE_STORAGE_KEY)).toBeNull(); }); - it("keeps the current key and leaves the legacy key untouched when both are present", async () => { + 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); @@ -120,7 +120,7 @@ describe("useColorMode — migrates the @vlandoss/theme-toggle 1.x storage key", 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)).toBe(COLOR_MODE.DARK); + expect(localStorage.getItem(LEGACY_COLOR_MODE_STORAGE_KEY)).toBeNull(); }); it('falls back to "system" and logs when the migrated legacy value is invalid', async () => { diff --git a/test/functional/initial.test.ts b/test/functional/initial.test.ts index 1bf62bb..612431a 100644 --- a/test/functional/initial.test.ts +++ b/test/functional/initial.test.ts @@ -121,7 +121,7 @@ describe("initial.ts — migrates the @vlandoss/theme-toggle 1.x storage key", ( expect(localStorage.getItem(LEGACY_COLOR_MODE_STORAGE_KEY)).toBeNull(); }); - it("keeps the current key and leaves the legacy key untouched when both are present", async () => { + 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); @@ -129,7 +129,7 @@ describe("initial.ts — migrates the @vlandoss/theme-toggle 1.x storage key", ( expect(hasDarkClass()).toBe(false); expect(localStorage.getItem(COLOR_MODE_STORAGE_KEY)).toBe(COLOR_MODE.LIGHT); - expect(localStorage.getItem(LEGACY_COLOR_MODE_STORAGE_KEY)).toBe(COLOR_MODE.DARK); + expect(localStorage.getItem(LEGACY_COLOR_MODE_STORAGE_KEY)).toBeNull(); }); }); From a9eb3a2fa099b2ff2a703afac49ef301b9f4a01b Mon Sep 17 00:00:00 2001 From: "Ricardo Q. Bazan" Date: Mon, 12 Oct 2026 19:38:23 -0500 Subject: [PATCH 8/8] chore: drop references to the internal migration plan Comments and docs now explain each policy on their own; the decision history lives in git. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MrWogBto4w6eFAaVEkczQf --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1c89b4..46c91c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: node-version-file: .node-version - name: ⌛ Setup pnpm - # First-party action, deliberately tracked at @main (MIGRATION.md, Regla 4). + # First-party action, deliberately tracked at @main. uses: withvariablelab/gh-actions/actions/setup-pnpm@main # zizmor: ignore[unpinned-uses] - name: 💅 Check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f31961f..ed68c36 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,7 +39,7 @@ jobs: node-version-file: .node-version - name: ⌛ Setup pnpm - # First-party action, deliberately tracked at @main (MIGRATION.md, Regla 4). + # First-party action, deliberately tracked at @main. uses: withvariablelab/gh-actions/actions/setup-pnpm@main # zizmor: ignore[unpinned-uses] - name: 🔑 Mint vland-bot token @@ -94,12 +94,12 @@ jobs: node-version-file: .node-version - name: ⌛ Setup CI - # First-party action, deliberately tracked at @main (MIGRATION.md, Regla 4). + # First-party action, deliberately tracked at @main. uses: withvariablelab/gh-actions/actions/setup-pnpm@main # zizmor: ignore[unpinned-uses] - name: 🚀 Publish preview id: preview - # First-party action, deliberately tracked at @main (MIGRATION.md, Regla 4). + # First-party action, deliberately tracked at @main. uses: withvariablelab/gh-actions/actions/preview-release@main # zizmor: ignore[unpinned-uses] with: mode: publish @@ -113,7 +113,7 @@ jobs: needs: preview if: ${{ needs.preview.result == 'success' }} permissions: {} - # First-party reusable workflow, deliberately tracked at @main (MIGRATION.md, Regla 4). + # 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