@astryxdesign/cli 0.4.1 → 0.4.2-canary.464a445

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +1 -0
  3. package/api/init/init.doc.mjs +2 -2
  4. package/api/init/init.test.mjs +19 -2
  5. package/api/init/init.type.d.mts +9 -1
  6. package/api/init/init.type.mjs +3 -1
  7. package/api/init/run/run.mjs +36 -6
  8. package/api/theme/add/add.mjs +2 -13
  9. package/api/theme/build/build.font-warning.test.mjs +161 -0
  10. package/api/theme/build/build.mjs +19 -12
  11. package/api/theme/build/build.test.mjs +53 -0
  12. package/api/theme/build/font-warning.d.mts +26 -0
  13. package/api/theme/build/font-warning.mjs +214 -0
  14. package/api/theme/build/font-warning.test.mjs +242 -0
  15. package/api/theme/template/template.d.mts +21 -0
  16. package/api/theme/template/template.mjs +69 -0
  17. package/api/theme/template/template.test.mjs +82 -0
  18. package/api/theme/theme.d.mts +1 -0
  19. package/api/theme/theme.mjs +1 -0
  20. package/api/theme/theme.type.d.mts +13 -0
  21. package/api/theme/theme.type.mjs +11 -1
  22. package/api/theme/themeTemplate.doc.d.mts +11 -0
  23. package/api/theme/themeTemplate.doc.mjs +64 -0
  24. package/assets/docs/getting-started.doc.mjs +11 -1
  25. package/assets/docs/theme.doc.dense.mjs +2 -2
  26. package/assets/docs/theme.doc.mjs +20 -9
  27. package/assets/docs/theme.doc.zh.mjs +1 -1
  28. package/assets/docs/typography.doc.mjs +38 -0
  29. package/assets/templates/blocks/components/ChatMessageBubble/ChatMessageBubbleCustomContent.doc.mjs +13 -0
  30. package/assets/templates/blocks/components/ChatMessageBubble/ChatMessageBubbleCustomContent.tsx +55 -0
  31. package/assets/templates/pages/ai-chat/page.tsx +18 -13
  32. package/assets/templates/themes/butter/butterTheme.ts +4 -1
  33. package/assets/templates/themes/chocolate/chocolateTheme.ts +4 -1
  34. package/assets/templates/themes/gothic/gothicTheme.ts +4 -1
  35. package/assets/templates/themes/stone/stoneTheme.ts +4 -1
  36. package/assets/theme.template.ts +322 -0
  37. package/authoring/doctypes/base/type.ts +9 -1
  38. package/authoring/doctypes/component/component.doc.mjs +1 -1
  39. package/clients/cli/commands/build-theme.font-warning.test.mjs +138 -0
  40. package/clients/cli/commands/build-theme.mjs +50 -0
  41. package/clients/cli/commands/init.behavior.test.mjs +13 -2
  42. package/clients/cli/commands/theme-template.behavior.test.mjs +85 -0
  43. package/clients/cli/commands/theme-template.doc.mjs +42 -0
  44. package/clients/cli/commands/theme.doc.mjs +2 -2
  45. package/clients/cli/index.mjs +1 -0
  46. package/clients/cli/lib/manifest.mjs +2 -0
  47. package/foundation/agent-docs/agent-docs.mjs +1 -1
  48. package/foundation/response/response-types.doc.mjs +5 -0
  49. package/foundation/text/copyright-header.d.mts +11 -0
  50. package/foundation/text/copyright-header.mjs +35 -0
  51. package/foundation/text/copyright-header.test.mjs +58 -0
  52. package/package.json +9 -9
@@ -0,0 +1,322 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * Astryx theme template — every `defineTheme` field, with a note on what it
5
+ * does and when to reach for it.
6
+ *
7
+ * Only `name` is required. Delete what you do not need: the values here are
8
+ * placeholders that reproduce the built-in defaults, so a field you leave
9
+ * untouched changes nothing.
10
+ *
11
+ * Each section names the CLI command that prints the authoritative reference
12
+ * for it. These comments are a map; the CLI is the territory — it reads the
13
+ * same source the components do. `astryx docs` lists every topic.
14
+ *
15
+ * ═══════════════════════════════════════════════════════════════════════
16
+ * HOW A THEME IS CONSUMED
17
+ * ═══════════════════════════════════════════════════════════════════════
18
+ *
19
+ * 1. WRAP THE APP. A theme does nothing until it is provided.
20
+ *
21
+ * import {Theme} from '@astryxdesign/core';
22
+ * import {myTheme} from './theme';
23
+ *
24
+ * <Theme theme={myTheme} mode="system"> // 'system' | 'light' | 'dark'
25
+ * <App />
26
+ * </Theme>
27
+ *
28
+ * `system` follows the OS. Nest a second `<Theme>` to give one region its
29
+ * own theme or mode — a dark sidebar inside a light page.
30
+ *
31
+ * 2. SHIP THE FONTS YOU NAME. Astryx sets the `--font-family-*` tokens; it
32
+ * never loads a font file, so use whatever typeface the design needs and
33
+ * load it yourself. Either add it to your app's <head>:
34
+ *
35
+ * <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
36
+ * <link rel="stylesheet"
37
+ * href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" />
38
+ *
39
+ * or self-host it in your global CSS:
40
+ *
41
+ * @font-face {
42
+ * font-family: 'Inter';
43
+ * src: url('/fonts/Inter.woff2') format('woff2');
44
+ * font-weight: 100 900;
45
+ * font-display: swap;
46
+ * }
47
+ *
48
+ * Always give `fallbacks` as well. A named family with no file loads
49
+ * nothing and warns about nothing — the fallback silently becomes your
50
+ * theme.
51
+ *
52
+ * 3. BUILD IT FOR PRODUCTION.
53
+ *
54
+ * astryx theme build src/theme.ts
55
+ *
56
+ * writes `<name>.css` (tokens, component overrides and prose styles in
57
+ * `@scope` rules), `<name>.js` (the theme with `__built: true` and
58
+ * pre-resolved values), `<name>.d.ts`, and `<name>.variants.d.ts` when the
59
+ * theme adds custom prop values. Import the module and the CSS together:
60
+ *
61
+ * import {myThemeTheme} from './theme/my-theme';
62
+ * import './theme/my-theme.css';
63
+ *
64
+ * Unbuilt, `<Theme>` injects a <style> tag at hydration instead — fine for
65
+ * dev and client-only apps, but under SSR the component overrides flash on
66
+ * hydration. Build for Next.js and Remix. Run the build after every edit:
67
+ * it also validates the theme and regenerates the types for custom
68
+ * variants.
69
+ *
70
+ * 4. LOOK AT IT IN BOTH COLOUR MODES before you ship, including the states you
71
+ * did not write. Full guide: `astryx docs theme`.
72
+ *
73
+ * SYNC: this template mirrors the theme system. When you change one of these,
74
+ * update it here — scripts/check-theme-template.test.mjs fails when they drift:
75
+ * - /packages/core/src/theme/defineTheme.ts (the fields)
76
+ * - /packages/core/src/theme/tokens.stylex.ts (the token families)
77
+ * - /packages/core/src/theme/expandColorScale.ts
78
+ * - /packages/core/src/theme/expandTypeScale.ts
79
+ * - /packages/core/src/theme/expandRadiusScale.ts
80
+ * - /packages/core/src/theme/expandMotionScale.ts
81
+ */
82
+
83
+ import {defineTheme} from '@astryxdesign/core/theme';
84
+ // import {dracula} from '@astryxdesign/core/theme/syntax';
85
+ // import {neutralTheme} from '@astryxdesign/theme-neutral';
86
+
87
+ export const myTheme = defineTheme({
88
+ /** Required. Becomes the `data-astryx-theme` attribute and the registry key. */
89
+ name: 'my-theme',
90
+
91
+ /**
92
+ * Start from another theme instead of the defaults. Tokens are copied then
93
+ * overridden, `components` deep-merge, `icons` shallow-merge — but the scale
94
+ * configs below REPLACE the base's rather than merging, because they are
95
+ * inputs to a generator, not values.
96
+ *
97
+ * Reference: `astryx theme list` (themes you can install and extend).
98
+ */
99
+ // extends: neutralTheme,
100
+
101
+ // ───────────────────────────────────────────────────────────────────────
102
+ // Scale configs — a few parameters generate a whole family of tokens.
103
+ // Reach for these first. They keep a theme internally consistent and cover
104
+ // far more ground than you would hand-write.
105
+ // ───────────────────────────────────────────────────────────────────────
106
+
107
+ /**
108
+ * Generates the neutral ramp and the accent tokens from one seed colour
109
+ * using the HCT perceptual model: surfaces, text, icons, borders, muted
110
+ * fills, hover and pressed overlays — light and dark both.
111
+ *
112
+ * accent seed hex; omit to keep the default accent and re-tone only
113
+ * the neutrals
114
+ * neutralStyle 'warm' | 'cool' | 'neutral' — the temperature of the greys
115
+ * contrast 'standard' | 'high' — 'high' widens the text/surface tone
116
+ * gap, for dense data UI or bright and clinical screens
117
+ *
118
+ * Whatever seed you pass, generated text holds >= 4.5:1 against its surface
119
+ * and --color-border-emphasized >= 3:1. Two limits on that guarantee:
120
+ *
121
+ * - It covers what this config generates. Write one side of a pair by hand
122
+ * in `tokens` below — an accent without its --color-on-accent, a surface
123
+ * without its text — and you own the contrast for that pair.
124
+ * - Status colours (success, warning, error) and the categorical data hues
125
+ * are not derived from the accent. They keep their defaults, which are
126
+ * tuned for the default surfaces; check them against yours.
127
+ *
128
+ * Either way the check is the same: resolve the pair in BOTH modes and
129
+ * measure it. `useTheme()` and `resolveThemeTokens()` return the resolved
130
+ * values, and the rendered DOM is the final word.
131
+ *
132
+ * Reference: `astryx docs color` for what each semantic role means,
133
+ * `astryx docs tokens` for every colour token and its light/dark default.
134
+ */
135
+ color: {accent: '#0064E0', neutralStyle: 'cool', contrast: 'standard'},
136
+
137
+ /**
138
+ * Type scale and the three font roles.
139
+ * scale.base body size in px; scale.ratio step between sizes
140
+ * (1.125 tight/dense · 1.2 default · 1.333 dramatic)
141
+ * body / heading / code: {family, fallbacks, weight, weights}
142
+ * heading inherits family and fallbacks from body when omitted; `weights`
143
+ * sets per-level weight for headings 1–6.
144
+ *
145
+ * See §2 above — naming a family here does not load it.
146
+ *
147
+ * Reference: `astryx docs typography`.
148
+ */
149
+ typography: {
150
+ scale: {base: 16, ratio: 1.2},
151
+ body: {family: 'Inter', fallbacks: '-apple-system, system-ui, sans-serif'},
152
+ heading: {weight: 'semibold', weights: {1: 'bold', 2: 'bold'}},
153
+ code: {
154
+ family: 'Geist Mono',
155
+ fallbacks: '"SF Mono", ui-monospace, monospace',
156
+ },
157
+ },
158
+
159
+ /**
160
+ * Radius scale. `base` is the unit in px; `multiplier` scales every step
161
+ * (0 = square and brutalist, 2 = very soft). --radius-none and --radius-full
162
+ * are fixed and never scaled.
163
+ *
164
+ * Reference: `astryx docs shape`.
165
+ */
166
+ radius: {base: 4, multiplier: 1},
167
+
168
+ /**
169
+ * Duration scale in ms. Each of fast/medium/slow also gets a -min and -max
170
+ * sibling computed with `ratio` (min = base × ratio, max = base ÷ ratio), so
171
+ * a component can pick a longer or shorter variant of the same tempo.
172
+ * Snappy {fast: 100, medium: 250} · Default {fast: 175, medium: 410, slow: 975}
173
+ * Cinematic {fast: 200, medium: 500, slow: 1200}
174
+ * `easing` overrides --ease-standard.
175
+ *
176
+ * Reference: `astryx docs motion`.
177
+ */
178
+ motion: {fast: 175, medium: 410, slow: 975, ratio: 0.75},
179
+
180
+ // ───────────────────────────────────────────────────────────────────────
181
+ // tokens — explicit overrides, which beat anything the scales generated.
182
+ // A string applies to both colour modes; a [light, dark] tuple compiles to
183
+ // CSS light-dark(). List only what you want to change.
184
+ //
185
+ // `astryx docs tokens` PRINTS THE WHOLE TABLE — every token with its light
186
+ // and dark default. Read it before hand-writing colours: most of what you
187
+ // want already has a semantic name, and the defaults tell you what you are
188
+ // moving away from.
189
+ //
190
+ // The families, so you know what exists:
191
+ // --color-* accent, on-accent · background-{body,surface,card,popover,
192
+ // muted,inverted} · text-* · icon-* · border,
193
+ // border-emphasized · overlay{,-hover,-pressed} ·
194
+ // success/warning/error (+ -muted, on-*) · skeleton, track,
195
+ // shadow, tint-hover · categorical {blue,cyan,gray,green,
196
+ // orange,pink,purple,red,teal,yellow} ×
197
+ // {background,border,icon,text}
198
+ // --spacing-* 0 · 0-5 · 1 … 12
199
+ // --size-element-* sm · md · lg (control heights)
200
+ // --focus-outline-* width · style · color · offset
201
+ // --border-width hairline thickness shared by bordered surfaces
202
+ // --radius-* none · inner · element · container · page · chat · full
203
+ // --shadow-* low/med/high + inset-{hover,selected,success,warning,error}
204
+ // --duration-* {fast,medium,slow} × {-min, base, -max}
205
+ // --ease-standard
206
+ // --font-family-* body · heading · code
207
+ // --font-size-* 4xs … 5xl
208
+ // --font-weight-* normal · medium · semibold · bold
209
+ // --text-* {heading-1…6, body, large, label, code, supporting,
210
+ // display-1…3} × {-size, -weight, -leading}
211
+ // --color-syntax-* code highlighting · --color-data-* charts
212
+ // ───────────────────────────────────────────────────────────────────────
213
+ tokens: {
214
+ '--color-accent': ['#0064E0', '#2694FE'],
215
+ // Changing an accent means owning its on-colour: this is the label that
216
+ // sits on top of the fill above, in each mode.
217
+ '--color-on-accent': ['#FFFFFF', '#FFFFFF'],
218
+ '--color-background-body': ['#F1F4F7', '#111112'],
219
+ '--color-background-surface': ['#FFFFFF', '#1F1F22'],
220
+ '--focus-outline-color': 'var(--color-accent)',
221
+ '--shadow-low': '0 1px 2px light-dark(#0000001A, #00000066)',
222
+ },
223
+
224
+ // ───────────────────────────────────────────────────────────────────────
225
+ // components — per-component CSS, emitted inside
226
+ // `@scope ([data-astryx-theme="my-theme"])`, so it only touches your theme.
227
+ //
228
+ // Keys are the component's stable class minus the `astryx-` prefix
229
+ // (`astryx-button` → `button`, `astryx-side-nav-item` → `side-nav-item`),
230
+ // including inner parts like `progressbar-fill`.
231
+ //
232
+ // `astryx component <Name>` IS THE REFERENCE HERE. It prints that
233
+ // component's theming targets, the visual props and states you can key on,
234
+ // its public CSS variables with defaults, and which standard CSS properties
235
+ // it expands. `astryx component --list` names every component, and
236
+ // `astryx docs styling` covers the escape hatches outside a theme.
237
+ //
238
+ // Style-key forms:
239
+ // 'base' every instance
240
+ // 'variant:ghost' one prop value (any visual prop, not just variant)
241
+ // 'variant:ghost+size:sm' intersection of two
242
+ // 'checked' a state the component reflects
243
+ // ':hover' / ':focus-visible' / ':disabled' — nested inside any of the above
244
+ //
245
+ // Write ordinary CSS properties: `borderRadius` and `padding` expand into
246
+ // the component's internal vars for you, concentric radius math included.
247
+ // Set a public CSS var directly when no standard property reaches it — take
248
+ // the name from `astryx component <Name>`, since a var no component defines
249
+ // compiles to CSS that never applies. Never set a private `--_`-prefixed
250
+ // var; the build rejects it.
251
+ // ───────────────────────────────────────────────────────────────────────
252
+ components: {
253
+ button: {
254
+ base: {
255
+ borderRadius: 'var(--radius-full)',
256
+ fontWeight: 'var(--font-weight-semibold)',
257
+ // A public var from `astryx component Button` — no CSS-property
258
+ // equivalent exists for it.
259
+ '--button-focus-offset': '2px',
260
+ // Interaction states live inside the same block.
261
+ ':hover': {transform: 'translateY(-1px)'},
262
+ ':active': {transform: 'translateY(0)'},
263
+ },
264
+ 'variant:ghost': {borderWidth: '1px', borderStyle: 'solid'},
265
+ 'variant:destructive+size:sm': {letterSpacing: '0.02em'},
266
+ // A value that is not built in becomes a NEW variant: `astryx theme
267
+ // build` writes the module augmentation, so <Button variant="quiet" />
268
+ // typechecks wherever this theme is active. Add one only where the
269
+ // product will render it — an unused variant is invisible work.
270
+ 'variant:quiet': {
271
+ backgroundColor: 'var(--color-background-muted)',
272
+ color: 'var(--color-text-secondary)',
273
+ },
274
+ },
275
+ // Container components take `padding` too; it expands to their layout tokens.
276
+ card: {
277
+ base: {
278
+ borderRadius: 'var(--radius-container)',
279
+ padding: 'var(--spacing-6)',
280
+ },
281
+ },
282
+ // The same mechanism adds custom Text types: <Text type="hero" />.
283
+ text: {'type:hero': {fontSize: 'var(--font-size-4xl)', lineHeight: '1.05'}},
284
+ },
285
+
286
+ // ───────────────────────────────────────────────────────────────────────
287
+ // Everything below takes React values, so it needs a .tsx file or an
288
+ // imported module. Commented out for that reason.
289
+ // ───────────────────────────────────────────────────────────────────────
290
+
291
+ /**
292
+ * Swap the artwork behind semantic icon names — every component that asks
293
+ * for `check`, `close`, `chevron-down` gets yours.
294
+ * Reference: `astryx docs icons` lists the names.
295
+ */
296
+ // icons: {check: <MyCheck />, close: <MyClose />},
297
+
298
+ /**
299
+ * Replace the small components that DRAW control state — the checkbox box,
300
+ * the radio dot, the mark on a chosen option — by indicator name rather than
301
+ * per call site. Replacing `check` re-skins every single-selection mark in
302
+ * the app at once.
303
+ * Reference: `astryx component Indicator`.
304
+ */
305
+ // indicators: {check: RadioIndicator},
306
+
307
+ /**
308
+ * Code highlighting: sets the --color-syntax-* tokens. Presets live in
309
+ * `@astryxdesign/core/theme/syntax`.
310
+ */
311
+ // syntax: dracula,
312
+
313
+ /**
314
+ * Content sitting on an inverted surface — a dark toast in light mode, a
315
+ * light popover in dark mode. Same shape as the theme itself (tokens and
316
+ * components). Sensible values are generated if you say nothing, so override
317
+ * only where your palette needs it.
318
+ * Reference: `astryx component MediaTheme`, which reads these.
319
+ */
320
+ // onDark: {tokens: {'--color-accent': '#90CAF9'}, components: {button: {'variant:ghost': {borderWidth: '1px'}}}},
321
+ // onLight: {tokens: {'--color-accent': '#0B5FCC'}},
322
+ });
@@ -117,6 +117,14 @@ export interface ComponentPlaygroundConfig {
117
117
  * the component is visible on load and knobs stay usable, whereas a real
118
118
  * top-layer modal makes the rest of the page inert (#3657). */
119
119
  overlay?: boolean;
120
+ /** The component reads AppShell mobile context and renders nothing
121
+ * without it (e.g. `MobileNavToggle` returns null unless the context
122
+ * reports an enabled mobile viewport — the default value outside
123
+ * AppShell never does). The interactive preview provides a simulated
124
+ * mobile AppShell context so the stage is not an empty box, keeps the
125
+ * drawer open state interactive, and notes the simulation under the
126
+ * rendered component (#4983). */
127
+ appShellMobile?: boolean;
120
128
  /** Required parent wrapper for sub-components that depend on a parent
121
129
  * context provider (e.g. `Tab` calls `useTabListContext()` and throws
122
130
  * standalone). The preview wraps the component in this parent before
@@ -339,7 +347,7 @@ export interface ComponentThemingTarget {
339
347
  * ```
340
348
  */
341
349
  export interface ComponentThemingVar {
342
- /** CSS custom property name, e.g. '--_card-radius' or '--button-press-scale' */
350
+ /** CSS custom property name, e.g. '--_card-radius' or '--button-focus-offset' */
343
351
  name: string;
344
352
  /** What this var controls */
345
353
  description: string;
@@ -146,7 +146,7 @@ export const doc = {
146
146
  name: 'playground',
147
147
  type: 'ComponentPlaygroundConfig',
148
148
  description:
149
- 'Interactive-preview config: initial prop `defaults`, `overlay` for modal-only components, and a `wrapper` for context-dependent sub-components.',
149
+ 'Interactive-preview config: initial prop `defaults`, `overlay` for modal-only components, `appShellMobile` for components gated on AppShell mobile context, and a `wrapper` for context-dependent sub-components.',
150
150
  },
151
151
  {
152
152
  name: 'props',
@@ -0,0 +1,138 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file End-to-end test for the `astryx theme build` font-loading warning
5
+ * (#5015). A theme that names webfont families gets, AFTER the install
6
+ * instructions, a stdout notice naming the fonts plus the copy-pasteable
7
+ * fix — the Google Fonts <link> pair and a self-hosted @font-face with
8
+ * font-display: swap — and still exits 0 (it is a warning, not an error).
9
+ * Themes that only name generics or known system stacks get none of it.
10
+ */
11
+
12
+ import {describe, it, expect, beforeAll, beforeEach, afterEach} from 'vitest';
13
+ import * as fs from 'node:fs';
14
+ import * as path from 'node:path';
15
+ import * as os from 'node:os';
16
+ import {ensureCoreBuilt} from './ensure-core-built.mjs';
17
+ import {runCli} from '../../../test-utils/run-cli.mjs';
18
+
19
+ function writeTheme(dir, name, source) {
20
+ fs.mkdirSync(dir, {recursive: true});
21
+ const file = path.join(dir, `${name}.mjs`);
22
+ fs.writeFileSync(file, source);
23
+ return file;
24
+ }
25
+
26
+ // `astryx theme build` imports the compiled @astryxdesign/core/theme entry.
27
+ // Build core once if it isn't already present so the suite works in any CI
28
+ // job, regardless of job ordering.
29
+ beforeAll(() => {
30
+ ensureCoreBuilt();
31
+ }, 200_000);
32
+
33
+ let tmpDir;
34
+ beforeEach(() => {
35
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-build-theme-fonts-'));
36
+ });
37
+ afterEach(() => {
38
+ fs.rmSync(tmpDir, {recursive: true, force: true});
39
+ });
40
+
41
+ describe('theme build font-loading warning', () => {
42
+ it('prints the unloaded fonts and the <link>/@font-face fix after the install instructions', async () => {
43
+ const project = path.join(tmpDir, 'project');
44
+ const themeFile = writeTheme(
45
+ project,
46
+ 'fonty',
47
+ `export default {
48
+ name: 'fonty',
49
+ typography: {
50
+ body: {family: 'Space Grotesk', fallbacks: 'Arial, sans-serif'},
51
+ code: {family: 'JetBrains Mono'},
52
+ },
53
+ };\n`,
54
+ );
55
+
56
+ const result = await runCli(
57
+ ['theme', 'build', path.relative(project, themeFile)],
58
+ project,
59
+ );
60
+
61
+ expect(result.code).toBe(0);
62
+ // The install instructions still come out intact…
63
+ expect(result.stdout).toContain("from './fonty'");
64
+ // …followed by the warning naming the theme and every unloaded family…
65
+ expect(result.stdout).toContain('names fonts it does not load');
66
+ expect(result.stdout).toContain('"Space Grotesk"');
67
+ expect(result.stdout).toContain('"JetBrains Mono"');
68
+ // …and the copy-pasteable fix, both flavors.
69
+ expect(result.stdout).toContain(
70
+ '<link rel="preconnect" href="https://fonts.googleapis.com"',
71
+ );
72
+ expect(result.stdout).toContain('family=Space+Grotesk');
73
+ expect(result.stdout).toContain('family=JetBrains+Mono');
74
+ expect(result.stdout).toContain('display=swap');
75
+ expect(result.stdout).toContain('@font-face');
76
+ expect(result.stdout).toContain('font-display: swap');
77
+ expect(result.stdout).toContain('astryx docs typography');
78
+ // The one-line summaries follow the CLI's stream contract: warnings on
79
+ // stderr, like the override-validation warnings in the same build.
80
+ expect(result.stderr).toContain('Font "Space Grotesk"');
81
+ expect(result.stderr).toContain('Font "JetBrains Mono"');
82
+ });
83
+
84
+ it('keeps --json stdout one valid envelope: warnings inside, snippet suppressed', async () => {
85
+ const project = path.join(tmpDir, 'project');
86
+ const themeFile = writeTheme(
87
+ project,
88
+ 'fonty',
89
+ `export default { name: 'fonty', tokens: { '--font-family-body': '"Space Grotesk", sans-serif' } };\n`,
90
+ );
91
+
92
+ const result = await runCli(
93
+ ['--json', 'theme', 'build', path.relative(project, themeFile)],
94
+ project,
95
+ );
96
+
97
+ expect(result.code).toBe(0);
98
+ // The whole stdout must parse — any human snippet leaking into --json
99
+ // mode corrupts the envelope, so this asserts the "JSON is always JSON"
100
+ // contract, not just substring presence.
101
+ const envelope = JSON.parse(result.stdout);
102
+ expect(envelope.type).toBe('theme.build');
103
+ expect(envelope.data.warnings).toEqual(
104
+ expect.arrayContaining([expect.stringContaining('Font "Space Grotesk"')]),
105
+ );
106
+ expect(result.stdout).not.toContain('fonts.googleapis.com');
107
+ // Human one-liners are silenced too — machine mode stays quiet.
108
+ expect(result.stderr).not.toContain('Font "');
109
+ });
110
+
111
+ it('prints nothing font-related for a theme of generics and system stacks', async () => {
112
+ const project = path.join(tmpDir, 'project');
113
+ const themeFile = writeTheme(
114
+ project,
115
+ 'sys',
116
+ `export default {
117
+ name: 'sys',
118
+ tokens: {
119
+ '--color-bg': '#fff',
120
+ '--font-family-body': 'Helvetica, Arial, sans-serif',
121
+ '--font-family-code': 'ui-monospace',
122
+ },
123
+ };\n`,
124
+ );
125
+
126
+ const result = await runCli(
127
+ ['theme', 'build', path.relative(project, themeFile)],
128
+ project,
129
+ );
130
+
131
+ expect(result.code).toBe(0);
132
+ expect(result.stdout).toContain("from './sys'");
133
+ expect(result.stdout).not.toContain('names fonts it does not load');
134
+ expect(result.stdout).not.toContain('fonts.googleapis.com');
135
+ expect(result.stdout).not.toContain('@font-face');
136
+ expect(result.stderr).not.toContain('Font "');
137
+ });
138
+ });
@@ -30,6 +30,7 @@ import {logger} from '../../../api/logger.mjs';
30
30
  import {cliError} from '../lib/cli-error.mjs';
31
31
  import {ERROR_CODES} from '../../../foundation/response/error-codes.mjs';
32
32
  import {themeAdd} from '../../../api/theme/add/add.mjs';
33
+ import {themeTemplate} from '../../../api/theme/template/template.mjs';
33
34
  import {themeList} from '../../../api/theme/list/list.mjs';
34
35
  import {themeBuild, importSpecifier} from '../../../api/theme/build/build.mjs';
35
36
  import {defineCommand} from '../lib/define-command.mjs';
@@ -37,9 +38,11 @@ import {doc as themeGroup} from './theme.doc.mjs';
37
38
  import {doc as themeBuildCommand} from './theme-build.doc.mjs';
38
39
  import {doc as themeListCommand} from './theme-list.doc.mjs';
39
40
  import {doc as themeAddCommand} from './theme-add.doc.mjs';
41
+ import {doc as themeTemplateCommand} from './theme-template.doc.mjs';
40
42
  import {doc as themeBuildFn} from '../../../api/theme/themeBuild.doc.mjs';
41
43
  import {doc as themeListFn} from '../../../api/theme/themeList.doc.mjs';
42
44
  import {doc as themeAddFn} from '../../../api/theme/themeAdd.doc.mjs';
45
+ import {doc as themeTemplateFn} from '../../../api/theme/themeTemplate.doc.mjs';
43
46
 
44
47
  /**
45
48
  * Path to this CLI's real entry (clients/cli/bin/astryx.mjs), resolved from
@@ -363,4 +366,51 @@ export function registerTheme(program) {
363
366
  );
364
367
  },
365
368
  });
369
+
370
+ defineCommand(theme, themeTemplateCommand, {
371
+ fn: themeTemplateFn,
372
+ action: (
373
+ /** @type {string | undefined} */ targetPath,
374
+ /** @type {{overwrite?: boolean}} */ options,
375
+ ) => {
376
+ const json = program.opts().json || false;
377
+
378
+ /** @type {import('../../../api/theme/theme.type.mjs').ThemeTemplateResponse} */
379
+ let result;
380
+ try {
381
+ result = themeTemplate({
382
+ targetPath,
383
+ overwrite: options.overwrite,
384
+ cwd: process.cwd(),
385
+ });
386
+ } catch (e) {
387
+ const err =
388
+ /** @type {import('../../../api/error.mjs').AstryxError} */ (e);
389
+ cliError(err.message, {
390
+ suggestions: err.suggestions || [],
391
+ code: err.code,
392
+ });
393
+ return;
394
+ }
395
+
396
+ if (json) return jsonOut(result);
397
+
398
+ const invocation = getCliInvocation(process.cwd());
399
+ if (!result.data.written) {
400
+ emit(
401
+ text(`[skip] ${result.data.path} already exists — left as is.`),
402
+ text(`Pass --overwrite to replace it with a fresh copy.`),
403
+ );
404
+ return;
405
+ }
406
+ emit(
407
+ text(`[ok] Wrote ${result.data.path}`),
408
+ text(
409
+ 'It documents every defineTheme field, the token families, and the component override syntax. ' +
410
+ 'Copy what you need into your own theme file, then delete it.',
411
+ ),
412
+ code(`${invocation} theme build ${result.data.path}`),
413
+ );
414
+ },
415
+ });
366
416
  }
@@ -71,13 +71,24 @@ describe('astryx init --features', () => {
71
71
  expect(exists('AGENTS.md')).toBe(true);
72
72
  });
73
73
 
74
- it('--features theme writes no files and points at the theme workflow', async () => {
74
+ it('--features theme writes the annotated theme template', async () => {
75
75
  const {status, stdout} = await runCli(['init', '--features', 'theme'], {cwd: tmpDir});
76
76
  expect(status).toBe(0);
77
- expect(fs.readdirSync(tmpDir)).toEqual([]);
77
+ expect(exists('theme.template.ts')).toBe(true);
78
+ expect(read('theme.template.ts')).toMatch(/defineTheme/);
79
+ expect(read('theme.template.ts')).not.toMatch(/Copyright \(c\) Meta Platforms/);
78
80
  expect(stdout).toMatch(/theme/i);
79
81
  });
80
82
 
83
+ it('--features theme never clobbers an existing theme.template.ts', async () => {
84
+ // Someone's edited copy outranks ours; re-running init must be safe.
85
+ fs.writeFileSync(path.join(tmpDir, 'theme.template.ts'), '// mine\n');
86
+ const {status, stdout} = await runCli(['init', '--features', 'theme'], {cwd: tmpDir});
87
+ expect(status).toBe(0);
88
+ expect(read('theme.template.ts')).toBe('// mine\n');
89
+ expect(stdout).toMatch(/already exists/);
90
+ });
91
+
81
92
  it('rejects an unknown feature with exit 1 and a helpful message', async () => {
82
93
  const {status, stderr} = await runCli(['init', '--features', 'bogus'], {cwd: tmpDir});
83
94
  expect(status).toBe(1);