Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
8fbc8d8
feat(Spinner): make size diameters and rail width themeable
freddymeta Aug 19, 2026
573d04f
feat(Spinner): make the ring's geometry and colors themeable through …
freddymeta Aug 20, 2026
20cf9f8
fix(Spinner): type the theme-CSS test helper from defineTheme's own i…
freddymeta Aug 20, 2026
2d77ba0
fix(Spinner): stop a flex host shrinking the box out from under the ring
freddymeta Aug 20, 2026
bc2f7a6
fix(Spinner): let a theme's geometry actually reach the ring
freddymeta Aug 24, 2026
bb7c02d
Merge remote-tracking branch 'origin/main' into feat/spinner-themeabl…
freddymeta Aug 24, 2026
1a41110
Merge origin/main into feat/spinner-themeable-diameter-rail
freddymeta Aug 24, 2026
df31802
chore(Spinner): separate the two non-theming changes out of this PR
freddymeta Aug 25, 2026
2fa0a3a
docs(Spinner): say what a themed rail of 0 does, and stop the stories…
freddymeta Aug 25, 2026
1c5fa11
Merge origin/main into feat/spinner-themeable-diameter-rail
freddymeta Aug 25, 2026
d18ef06
refactor(Spinner): declare the public vars, now that #5410 layers the…
freddymeta Aug 25, 2026
42ae22f
perf(Spinner): register the geometry vars at import, not at first mount
freddymeta Aug 26, 2026
95c6de4
Merge origin/main into feat/spinner-themeable-diameter-rail
freddymeta Aug 27, 2026
d673c7d
fix(Spinner): rename the stroke var, keep the box's sizing precedence…
freddymeta Aug 27, 2026
47f7bb8
Merge branch 'main' into feat/spinner-themeable-diameter-rail
cixzhang Aug 27, 2026
db0c1c0
Merge branch 'main' into feat/spinner-themeable-diameter-rail
cixzhang Aug 27, 2026
cb234d9
Merge branch 'main' into feat/spinner-themeable-diameter-rail
cixzhang Aug 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/spinner-themeable-diameter-rail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@astryxdesign/core': patch
---

[feat] Spinner: the ring's geometry and its two colors are now themeable. The `size` and `shade` props keep their fixed enums; what each named value _resolves to_ is now a theme's to set, through four public custom properties on the `spinner` target — `--spinner-diameter` and `--spinner-stroke-width` under a size variant, `--spinner-color` and `--spinner-track-color` under a shade variant (or on the base target for all of them at once):

```ts
spinner: {
'size:xl': {'--spinner-diameter': '2.5rem', '--spinner-stroke-width': '0.375rem'},
'shade:subtle': {'--spinner-track-color': 'transparent'},
}
```

Any length and any color notation works — `rem`, `em` and `calc()` are resolved by the cascade into the radius and stroke the ring is drawn with, and colors accept `var()`, `color-mix()` and `currentColor`. A stroke width of `0` is honoured as a zero-width stroke — it paints nothing, rather than being read as "unset" and silently drawing the default. The drawn ring and the box around it come from the same values, so they stay in step, including when a media query or a root font-size change moves them after mount.

The two private vars the ring resolves into are registered as `<length>` when the module is imported, not when a spinner first mounts. Registering an inherited property with an `initial-value` invalidates style for the whole document, and a spinner is the loading indicator — it arrives on a page that has already rendered, so paying that there is paying it on the full tree: 29 ms against 12 ms for the same mount on an 11k-element page. A build that never imports `Spinner` drops the module and the registration with it.

Output is unchanged for every size and shade unless a theme overrides something, and so is every precedence around the box: it is still sized by an inline `width`/`height` written after the caller's `style`, as it has always been, with the composed value in place of the number.

@freddymeta
129 changes: 128 additions & 1 deletion apps/storybook/stories/Spinner.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {Meta, StoryObj} from '@storybook/react';
import {Spinner} from '@astryxdesign/core/Spinner';
import {Text} from '@astryxdesign/core/Text';
import {HStack, VStack} from '@astryxdesign/core/Layout';
import {Theme, defineTheme} from '@astryxdesign/core/theme';

const meta: Meta<typeof Spinner> = {
title: 'Core/Spinner',
Expand All @@ -17,7 +18,7 @@ const meta: Meta<typeof Spinner> = {
},
shade: {
control: 'select',
options: ['default', 'onMedia'],
options: ['default', 'onMedia', 'subtle', 'inherit'],
description: 'Color shade',
},
},
Expand Down Expand Up @@ -81,3 +82,129 @@ export const WithLabel: Story = {
</HStack>
),
};

// The ring is an SVG circle whose radius and stroke come off the cascade, so a
// theme reaches its geometry and its two colors through public custom
// properties rather than CSS box properties — `width` would name a box the
// ring is not. These stories are how that surface is checked: the a11y and RTL
// audits only see what a story renders, and a themed ring cannot be verified
// in jsdom (no layout, no registered properties), so this is where a browser
// can look at it.
//
// Geometry is deliberately themed in `rem` rather than `px`: the resolved vars
// are registered as `<length>`, and a relative unit surviving into the drawn
// radius is what distinguishes a resolved value from substituted text.
const themedGeometry = defineTheme({
name: 'spinner-themed-geometry',
components: {
spinner: {
'size:sm': {
'--spinner-diameter': '1rem',
'--spinner-stroke-width': '0.125rem',
},
'size:md': {
'--spinner-diameter': '1.5rem',
'--spinner-stroke-width': '0.25rem',
},
'size:lg': {
'--spinner-diameter': '2rem',
'--spinner-stroke-width': '0.3125rem',
},
'size:xl': {
'--spinner-diameter': 'calc(2rem + 8px)',
'--spinner-stroke-width': '0.375rem',
},
},
},
});

// A `Theme` with no parent Theme syncs its name onto the document root so its
// @scope'd component rules also reach portals — which means they reach every
// spinner on the page, including ones rendered outside the provider. A
// "default vs themed" pair inside one story therefore shows two themed rows,
// measured in Chromium; the unthemed reference is the `Sizes` story above.
export const ThemedGeometry: Story = {
name: 'Themed Geometry (per size)',
render: () => (
<VStack gap={2}>
<Text type="supporting" color="secondary">
Themed — rem and calc() diameters; the box tracks the ring
</Text>
<Theme theme={themedGeometry} mode="light">
<HStack gap={4} vAlign="center">
<Spinner size="sm" />
<Spinner size="md" />
<Spinner size="lg" />
<Spinner size="xl" />
</HStack>
</Theme>
</VStack>
),
};

// A hairline stroke: geometry themed down to 1px while the diameter stays put.
// Not `0` — one `stroke-width` drives both circles, so a stroke width of `0` is a
// zero-width stroke on each and paints nothing. An arc with no track behind it
// is `--spinner-track-color: transparent`, which is what the subtle shade in
// `ThemedColor` shows.
const themedHairline = defineTheme({
name: 'spinner-themed-hairline',
components: {
spinner: {
'size:xl': {'--spinner-stroke-width': '1px'},
base: {'--spinner-track-color': 'transparent'},
},
},
});

// Colors default to each shade's token, so a theme can retune one shade
// without touching the others. `--color-text-blue` over a muted wash reads as
// themed in the monochrome neutral theme, where an accent-muted pair would
// land within a shade of the default.
const themedColor = defineTheme({
name: 'spinner-themed-color',
components: {
spinner: {
base: {
'--spinner-color': 'var(--color-text-blue)',
'--spinner-track-color': 'var(--color-background-blue)',
},
'shade:subtle': {'--spinner-track-color': 'transparent'},
},
},
});

export const ThemedColor: Story = {
name: 'Themed Color (per shade)',
render: () => (
<VStack gap={2}>
<Text type="supporting" color="secondary">
Themed — blue arc and wash; the subtle shade drops its track (the
`Shades` story above is the untinted reference — see the note on
`ThemedGeometry` for why it cannot sit in this story)
</Text>
<Theme theme={themedColor} mode="light">
<HStack gap={4} vAlign="center">
<Spinner size="xl" />
<Spinner size="xl" shade="subtle" />
</HStack>
</Theme>
</VStack>
),
};

// One Theme per story, for the same reason: two providers in one story would
// each claim the document root and the last one mounted would paint both rows.
export const ThemedHairlineStroke: Story = {
name: 'Themed Hairline Stroke',
render: () => (
<VStack gap={2}>
<Text type="supporting" color="secondary">
Themed — a 1px hairline stroke over a transparent track
</Text>
<Theme theme={themedHairline} mode="light">
<Spinner size="xl" />
</Theme>
</VStack>
),
};
151 changes: 151 additions & 0 deletions packages/cli/api/theme/build/build.public-component-vars.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.

/**
* A component's *documented* theming vars must survive `astryx theme build`.
*
* `validatePrivateVars` rejects a theme that sets a `--_*` var, on the rule
* that private vars are reached through the derived-var pipeline rather than
* written directly. That makes "which prefix a themeable var carries" a
* build-time contract rather than a naming preference — and nothing checked
* the two against each other, so a component could document a var, and ship a
* changeset telling theme authors to set it, that the build then complains
* about (#5214).
*
* The theme this builds is generated FROM each component's own
* `theming.vars[]`, not from a snippet copied into this file. A hand-copied
* snippet only ever proves the builder accepts the string it was handed; a
* doc-driven one fails the moment a component documents a var a theme author
* cannot actually set. It covers every component with public vars, so the
* next one is covered without touching this file.
*
* Asserted on the receipt's `warnings` rather than on a rejection: a private
* var is reported (logged `✗`, collected into the receipt) and the build then
* emits its CSS and resolves anyway. Asserting a throw would pass for the
* wrong reason — it never throws, which is why a throwaway build read as a
* pass on the first version of #5214.
*
* `themeBuild` compiles via @astryxdesign/core's generator, so it needs a built
* core — the `node` project's globalSetup builds it once before workers fork.
*/

import {
describe,
it,
expect,
beforeAll,
beforeEach,
afterEach,
vi,
} from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import {fileURLToPath} from 'node:url';
import {themeBuild} from './build.mjs';
import {loadComponentDoc} from '../../../foundation/discovery/component-loader.mjs';

vi.setConfig({testTimeout: 60000});

/** Every documented public var, as `{component, key, vars: [{name, value}]}`. */
const documented = [];

beforeAll(async () => {
// Core and the CLI ship as siblings, the same resolution build.mjs uses.
const here = path.dirname(fileURLToPath(import.meta.url));
const coreSrc = path.resolve(here, '../../../../core/src');
const docs = [];
(function scan(dir) {
for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name !== 'node_modules' && entry.name !== '__tests__')
scan(full);
} else if (entry.name.endsWith('.doc.mjs')) {
docs.push(full);
}
}
})(coreSrc);

for (const docPath of docs) {
let doc;
try {
doc = await loadComponentDoc(docPath);
} catch {
continue;
}
const theming = doc?.theming;
// The `defineTheme` key is the first target's class minus the namespace —
// the same derivation `theme targets` and the builder's own validation use.
const key = theming?.targets?.[0]?.className?.replace(/^astryx-/, '');
// Every var the docs PRESENT as settable — `private: true` is what hides
// one from `astryx component <Name>`, so anything without it is something
// a theme author is being told they may write. Deliberately not filtered
// by the `--_` prefix: a var carrying the private prefix while missing the
// private flag is advertised by the CLI and rejected by the builder, and
// that disagreement is the whole thing this test exists to catch.
const publicVars = (theming?.vars || []).filter(
v => typeof v?.name === 'string' && !v.private && !v.derived,
);
if (!key || publicVars.length === 0) continue;
documented.push({
component: path.basename(docPath, '.doc.mjs'),
key,
// A length or a color would each need a plausible value; `unset` is
// valid for any custom property and is not what is under test — that a
// theme may NAME the var at all is.
vars: publicVars.map(v => v.name),
});
}
});

let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-public-vars-'));
});
afterEach(() => {
fs.rmSync(tmpDir, {recursive: true, force: true});
});

async function buildTheme(name, components) {
const themeFile = path.join(tmpDir, `${name}.mjs`);
fs.writeFileSync(
themeFile,
`export default ${JSON.stringify({name, tokens: {}, components}, null, 2)};\n`,
);
return themeBuild(`${name}.mjs`, {}, {cwd: tmpDir});
}

const privateVarWarnings = result =>
(result?.data.warnings ?? []).filter(w => /private var/i.test(w));

describe('documented component vars build cleanly', () => {
it('finds components with public theming vars to check', () => {
// A rename that broke the doc scan would otherwise silently empty this
// file out, the way a var-count bail once did in derivedVarRegistry.test.
expect(documented.length).toBeGreaterThan(0);
});

it('accepts every var the component docs tell a theme author to set', async () => {
const components = Object.fromEntries(
documented.map(({key, vars}) => [
key,
{base: Object.fromEntries(vars.map(name => [name, 'unset']))},
]),
);

const result = await buildTheme('documentedvars', components);

expect(result).not.toBeNull();
expect(privateVarWarnings(result)).toEqual([]);
});

it('still reports a private var, so the rule this relies on is real', async () => {
// The negative control: if the builder stopped reporting `--_*`, the test
// above would pass for the wrong reason.
const result = await buildTheme('privatevar', {
spinner: {'size:xl': {'--_spinner-diameter': '40px'}},
});

expect(privateVarWarnings(result)).toHaveLength(1);
});
});
22 changes: 17 additions & 5 deletions packages/core/src/Spinner/Spinner.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ export const docs = {
props: [
{
name: 'size',
type: "'sm' | 'md' | 'lg'",
description: 'Spinner size (10px, 14px, 18px).',
type: "'sm' | 'md' | 'lg' | 'xl'",
description: 'Spinner size — ring diameter (10px, 14px, 18px, 28px).',
default: "'md'",
},
{
Expand Down Expand Up @@ -43,6 +43,12 @@ export const docs = {
targets: [
{className: 'astryx-spinner', visualProps: ['size', 'shade']},
],
vars: [
{name: '--spinner-diameter', description: "Diameter of the drawn ring. Set it on a size-variant target to retheme what each named size resolves to, e.g. spinner: { 'size:xl': { '--spinner-diameter': '2.5rem' } }. The rendered box is this plus the stroke width on each side, and follows automatically. Any length works — rem, em and calc() are resolved before the ring is drawn.", default: '10px (sm), 14px (md), 18px (lg), 28px (xl)'},
{name: '--spinner-stroke-width', description: 'Stroke width of both circles the ring is drawn from — the moving arc and the track behind it. Set it per size alongside the diameter. One stroke width drives both, so 0 is honoured as a zero-width stroke and paints nothing at all rather than falling back to the default — for an arc with no track behind it, set --spinner-track-color to transparent instead.', default: '2px (sm), 3px (md), 3px (lg), 4px (xl)'},
{name: '--spinner-color', description: "Color of the moving arc. Defaults to the shade's token, so set it on a shade-variant target to retheme one shade — spinner: { 'shade:subtle': { '--spinner-color': 'var(--color-text-tertiary)' } } — or on the base target to retheme all four. Accepts any color notation, including var(), color-mix() and currentColor.", default: 'var(--color-accent) (default), var(--color-text-secondary) (subtle), var(--color-on-dark) (onMedia), currentColor (inherit)'},
{name: '--spinner-track-color', description: 'Color of the track the arc travels on. Set it to `transparent` for an arc with no track. The onMedia and inherit shades draw the track at reduced alpha (30%) so it reads against an arbitrary backdrop; that fade applies to a themed color too.', default: 'var(--color-track) (default, subtle), var(--color-on-dark) (onMedia), currentColor (inherit)'},
],
},
usage: {
description:
Expand All @@ -63,8 +69,8 @@ export const docsZh = {
props: [
{
name: 'size',
type: "'sm' | 'md' | 'lg'",
description: '旋转器尺寸(10px、14px、18px)。',
type: "'sm' | 'md' | 'lg' | 'xl'",
description: '旋转器尺寸——环直径(10px、14px、18px、28px)。',
default: "'md'",
},
{
Expand Down Expand Up @@ -95,6 +101,12 @@ export const docsZh = {
targets: [
{className: 'astryx-spinner', visualProps: ['size', 'shade']},
],
vars: [
{name: '--spinner-diameter', description: "绘制环的直径。在尺寸变体目标上设置,以重新定义每个命名尺寸的解析值,例如 spinner: { 'size:xl': { '--spinner-diameter': '2.5rem' } }。渲染盒子的尺寸为该值加上两侧的描边宽度,并自动跟随。支持任意长度单位——rem、em 与 calc() 会在绘制前解析。", default: '10px (sm), 14px (md), 18px (lg), 28px (xl)'},
{name: '--spinner-stroke-width', description: '绘制环的两个圆——移动圆弧与其后的轨道——的描边宽度。与直径一起按尺寸设置。同一个描边宽度同时驱动两者,因此 0 会被采纳为零宽描边——什么都不绘制,而不会回退到默认值;若想要没有轨道的圆弧,请改将 --spinner-track-color 设为 transparent。', default: '2px (sm), 3px (md), 3px (lg), 4px (xl)'},
{name: '--spinner-color', description: "运动圆弧的颜色。默认取所在 shade 的令牌,因此可在 shade 变体目标上设置以重新定义单个 shade——spinner: { 'shade:subtle': { '--spinner-color': 'var(--color-text-tertiary)' } }——或在 base 目标上设置以覆盖全部四种。接受任意颜色写法,包括 var()、color-mix() 与 currentColor。", default: 'var(--color-accent)(default)、var(--color-text-secondary)(subtle)、var(--color-on-dark)(onMedia)、currentColor(inherit)'},
{name: '--spinner-track-color', description: '圆弧所在轨道的颜色。设为 `transparent` 可得到无轨道的圆弧。onMedia 与 inherit 两种 shade 会以降低的透明度(30%)绘制轨道,以便在任意背景上可辨;该淡化同样作用于主题化的颜色。', default: 'var(--color-track)(default、subtle)、var(--color-on-dark)(onMedia)、currentColor(inherit)'},
],
},
usage: {
description:
Expand All @@ -121,7 +133,7 @@ export const docsDense = {
],
},
propDescriptions: {
size: 'Spinner size (10px, 14px, 18px).',
size: 'Spinner size — ring diameter (10px, 14px, 18px, 28px).',
shade: 'Color shade for light or dark backgrounds.',
label: 'Visible content below spinner. String auto-sets aria-label.',
'aria-label': 'A11y name for screen readers. Defaults to label or "Loading".',
Expand Down
Loading
Loading