@codefusion-cc/theme

中文文档
0.1.5发布于 2026/11/09MITTypeScriptES Module零依赖近 30 天下载 2211

Light, dark or the system's theme and the look picked, kept per browser (the look also per account) and shown before first paint: the page never flashes the wrong one, follows the system while open, every open tab follows a change, and a switch can grow t

原始使用文档
文档目录8 节
  1. The config
  2. The look
  3. The look on the account
  4. Before first paint
  5. In the page
  6. In React
  7. The animated switch
  8. In an app's tests

@codefusion-cc/theme

Light, dark or the system's theme, kept per browser and shown before first paint. The page never flashes the wrong theme, follows the system while it is open, and every open tab follows a change made in another. The look a person picks (data-look, for @codefusion-cc/ui) and other choices (a palette, an accent) are kept and shown next to the mode, and the look can follow a signed-in person to every browser.

npm install @codefusion-cc/theme

The config

One plain object, shared by the page and the Vite config, so the pre-paint script and the page can never disagree:

// src/themeConfig.ts
import { defineTheme } from '@codefusion-cc/theme'

export const themeConfig = defineTheme({
  storageKey: 'myapp-theme',
  // Tailwind's dark class…
  html: { class: 'dark' },
  // …or daisyUI's data-theme, a value for each:
  // html: { attribute: 'data-theme', light: 'myapp-light', dark: 'myapp-dark' },
  themeColor: '--color-base-100',
})
  • storageKey: where the mode is kept in localStorage. A stored light or dark counts; anything else (nothing, the system mode, a value from an older version, junk) is the system mode. The system mode and every default are kept as nothing, so a later default reaches everyone who never picked.

  • html: how <html> shows the theme. color-scheme is always set too, so the browser's own controls and scrollbars match.

  • themeColor: a CSS custom property holding the page's background. Each <meta name="theme-color"> takes its value once the page has loaded and after every change, so an installed app's title bar and a phone's browser bar match the theme picked. Its media attribute is dropped: a meta for the system's other mode would win over the pick. A colour that computes to another space (a registered colour property gives oklch(…)) is written as sRGB hex, which every browser's chrome takes.

  • choices: other looks, each under its own key, with the values it can be and a default. A choice can show as its own data attribute (attribute: 'data-accent', absent while null), and html's values can name one as {name}:

    html: { attribute: 'data-theme', light: '{palette}', dark: '{palette}-dark' },
    choices: {
      palette: { storageKey: 'myapp-palette', values: ['graphite', 'ember'], default: 'graphite' },
      accent: { storageKey: 'myapp-accent', values: ['lime', 'sky'], default: null, attribute: 'data-accent' },
    },
    

defineTheme throws a TypeError for a mistake in the config (no key, two things under one key, a default that is not one of the values, a {name} that is no choice, a look name @codefusion-cc/ui refuses).

A stored value a choice cannot be (one the app has since removed, one from a newer version of the app open in another tab, junk) reads as the choice's default, before first paint, in the page and in every tab. It stays stored, so if the app has the value again (a rolled-back release), the person's pick comes back.

The look

The look is the set of values @codefusion-cc/ui compiles from the app's look modules: colours, fonts, radii. Its stylesheet selects one by data-look on <html>, beside light and dark (color-scheme). lookChoice makes it a choice shown as that attribute:

import { defineTheme, lookChoice } from '@codefusion-cc/theme'

export const themeConfig = defineTheme({
  storageKey: 'myapp-theme',
  html: { class: 'dark' },
  choices: { look: lookChoice({ storageKey: 'myapp-look', values: ['kuznia', 'noc'], default: 'kuznia' }) },
})
  • It is a choice like any other: shown before first paint, kept across reloads, followed by every open tab, kept for the page only where storage is blocked. theme.setChoice('look', 'noc') picks one, null the default; theme.get().choices.look is the one shown, and revealThemeChange can animate a change of look too.
  • default is the app's look: what everyone sees who never picked one, and anyone whose stored look the app no longer has (see above). It is never stored, so a new default reaches everyone who never picked.
  • The names are those of the look modules (defineLook({ name }) in @codefusion-cc/ui): lowercase letters, digits and hyphens, starting with a letter. defineTheme refuses others, and a look without a default.
  • html can name it too, for an app that still has per-look daisyUI themes: light: '{look}', dark: '{look}-dark'.

The look on the account

useLookSync (./react) keeps the look of a signed-in person on their account, so it follows them to every browser; before sign-in it is kept per browser as above. Once, in the app's root:

import { useLookSync } from '@codefusion-cc/theme/react'

function App() {
  const { user } = useSession()
  const look = useLookSync(theme, {
    // The look on the account: a name, null when it has none, undefined while signed out or loading.
    account: user ? user.look : undefined,
    save: (look) => api.put('/me/look', { look }),
  })
  // look.status: 'idle' | 'saving' | 'failed'; look.error; look.retry()
}

The rules, in order:

  • Signing in to an account with a look shows it here (and keeps it in this browser for the next visit). An account with none takes the look picked in this browser before, if one was; the app's default is not saved.
  • A pick made while signed in is saved. A pick made in another tab is saved by that tab only, and replaces a pick here still waiting to be saved or whose save failed.
  • Saves go one at a time, in the order picked: picks made during a save wait, and only the last of them is saved.
  • A failed save (save threw or rejected) keeps the look in this browser; status is failed, error says why, and retry() saves the look shown now. A newer pick replaces the failed one.
  • The account's look changing (picked in another browser) shows here, except during a save from this page: the last pick made here wins.
  • A look the app does not have on the account is neither shown nor overwritten: this browser's look shows, which falls back to the app's default.
  • Signing out keeps the look in this browser and saves nothing more; a save still under way finishes for the account it began for. Switching accounts passes undefined in between (signed out, or loading the next one).

createLookSync (./browser) is the same without React: setAccount(look) each time the account's look is known, get and subscribe for the status, stop(). Both take choice to keep another choice than look. On the server the hook renders idle and saves nothing.

Before first paint

prePaintTheme from ./vite puts a small classic script first in each page's <head>. It reads the stored theme and shows it on <html> before anything paints, and it never throws, whatever the storage does:

// vite.config.ts
import { prePaintTheme } from '@codefusion-cc/theme/vite'
import { themeConfig } from './src/themeConfig.ts'

export default defineConfig({ plugins: [react(), prePaintTheme(themeConfig)] })

It is served as /theme.js (fileName changes it), which a CSP with script-src 'self' allows. inline: true writes it into the page instead (a CSP then needs its hash). Without Vite, themeScript(config) from . is its source.

In the page

Once, at startup, in a module the rest of the app imports:

// src/theme.ts
import { createTheme } from '@codefusion-cc/theme/browser'
import { themeConfig } from './themeConfig.ts'

export const theme = createTheme(themeConfig)

It shows the same stored theme as the script, and from then on:

  • follows the system while the mode is system, as it changes (also on Safari before 14);
  • follows a pick made in another tab of the app;
  • keeps a pick in localStorage, or for this page only when storage is blocked, missing or full;
  • switches every colour at once: transitions are off for the frame the theme changes in (through the CSSOM, which a CSP's style-src doesn't block), so buttons don't fade through the old theme's colours;
  • sets the theme-color meta.

theme.get() is what the page shows: { mode, resolved, systemDark, choices }. theme.subscribe(listener) calls the listener after each change with what made it: pick (this page), system or elsewhere (another tab or program). Picks: theme.setMode('dark'), theme.toggle() (a single light/dark button: it picks the other theme, or system when that is what the system shows, so one click doesn't hold the page to one mode for good), theme.setChoice('accent', 'sky') (null for the default). An unknown mode or choice value throws a TypeError: a programming error.

In React

import { useTheme } from '@codefusion-cc/theme/react'
import { theme } from './theme.ts'

function ThemeButton() {
  const { resolved } = useTheme(theme)
  return <button onClick={theme.toggle}>{resolved === 'dark' ? 'Light' : 'Dark'}</button>
}

Every component renders again on each change, from any source. No provider is needed.

The animated switch

revealThemeChange (./browser) runs a change inside a View Transition: the new theme grows as a circle from the button over the old one. Browsers without View Transitions, visitors who asked for reduced motion, and a browser that refuses to start a transition get the change at once.

Once, in the app's CSS (or import '@codefusion-cc/theme/reveal.css' in a module):

@import '@codefusion-cc/theme/reveal.css';

In React, revealThemeChangeFrom (./react) does it from the control:

import { revealThemeChangeFrom } from '@codefusion-cc/theme/react'
import { theme } from './theme.ts'

<button onClick={(event) => revealThemeChangeFrom(event, theme.toggle)} />
// A radio group's own handler, which knows the option's button but has no click (arrow keys):
<Choice onChange={(mode, button) => revealThemeChangeFrom(button, () => theme.setMode(mode))} />
  • The circle starts at the click, or at the control's centre when the keyboard pressed it (a keyboard click has no pointer position) or when the control itself is given; with null, the middle of the viewport.
  • The pick runs inside flushSync, so the components that read useTheme (the button's icon and label) are in the new snapshot.
  • A tap takes 400 ms and a mouse click 650 ms, whichever pointer the device has first (a touchscreen laptop, an iPad with a trackpad). duration and easing are options, as below.

Without React, or with an origin of your own, revealThemeChange (./browser):

import { revealThemeChange } from '@codefusion-cc/theme/browser'

revealThemeChange(() => theme.toggle(), { origin: button })
  • The change must be on the page when the callback returns: the browser snapshots the new page right after it. theme.toggle() and theme.setMode() paint <html> at once; in React, wrap a change that renders components in flushSync (revealThemeChangeFrom does).
  • origin is a point or an element (the circle starts from its centre); without one, the middle of the viewport. duration and easing are options. The circle takes 650 ms (REVEAL_MS), and 400 ms on a touch screen (pointer: coarse, TOUCH_REVEAL_MS), where the page takes no taps while it grows.
  • The promise resolves when the new theme is on the page for good and rejects only with an error the callback threw.
  • Several clicks in a row are fine: the browser ends the running transition and starts the next one.

While the circle grows, the page is one snapshot: elements the app names for its own view transitions (view-transition-name in a stylesheet or inline, React's <ViewTransition>) are flattened into it, so a fixed header or a shared photo does not fade or jump on its own. The reveal finds them by their computed style and takes their names off inline (a few milliseconds for thousands of elements), then puts each back as it was. A rule matching every element would cost far more: WebKit restyles the whole page when such a rule starts and again when it stops applying, each time as long as the theme change itself. The class cf-theme-reveal (REVEAL_CLASS) is on <html> for the duration; reveal.css keys its rules (on the transition's pseudo-elements only) on it. Names are taken off twice, before the old snapshot and right after the callback; a name that appears later (in an effect, a frame later) is not flattened.

In an app's tests

@codefusion-cc/theme/testing is a browser for tests in Node, without a DOM: tabs of one site sharing its storage, a system that switches between light and dark, and storage that can be blocked, missing or full. Each tab's <html> keeps what was shown on it:

import { createTheme } from '@codefusion-cc/theme/browser'
import { testBrowser } from '@codefusion-cc/theme/testing'

const browser = testBrowser({ systemDark: true, stored: { 'myapp-theme': 'light' } })
const [first, second] = [browser.openTab(), browser.openTab()]
const mine = createTheme(themeConfig, first.env)
const theirs = createTheme(themeConfig, second.env)
mine.setMode('system')
expect(second.html.classes).toEqual(['dark'])

tab.runScript(themeScript(config)) runs the pre-paint script in a tab, tab.frame() runs its animation frames, and browser.writeElsewhere(key, value) is a change made by another program.

版本与发布记录

@codefusion-cc/theme 共发布 9 个版本,首次发布于 2026/10/02;过去 12 个月发布了 9 个版本。

版本发布日期距今
0.1.5latest2026/11/091 天前
0.1.42026/10/2219 天前
0.1.32026/10/1426 天前
0.1.22026/10/1427 天前
0.1.12026/10/041 个月前
0.1.02026/10/021 个月前

关于 @codefusion-cc/theme 的常见问题

@codefusion-cc/theme 是什么?

Light, dark or the system's theme and the look picked, kept per browser (the look also per account) and shown before first paint: the page never flashes the wrong one, follows the system while open, every open tab follows a change, and a switch can grow t

如何安装 @codefusion-cc/theme?

在项目目录运行 npm install @codefusion-cc/theme,或使用 pnpm add / yarn add / bun add。

@codefusion-cc/theme 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@codefusion-cc/theme 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@codefusion-cc/theme 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=20。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@codefusion-cc/theme 可以商用吗?

@codefusion-cc/theme 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@codefusion-cc/theme 的下载量有多少?

根据 npm 官方下载统计,@codefusion-cc/theme 在 2026/10/10 – 2026/11/08 这 30 天内被下载 2,211 次,日均约 74 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@codefusion-cc/theme 还在维护吗?

最新版本 0.1.5 发布于 2026/11/09(1 天前);至今共发布 9 个版本,其中过去 12 个月发布了 9 个;首次发布于 2026/10/02。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。