@archetypeai/ds-cli 0.15.0 → 0.16.1

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.
package/README.md CHANGED
@@ -138,7 +138,7 @@ The configuration is two files: the instruction file (`CLAUDE.md`/`AGENTS.md`
138
138
 
139
139
  ## Fonts
140
140
 
141
- The `--fonts <path>` flag expects a local folder containing all 15 PP Neue Montreal files (9 Sans `.ttf` + 6 Mono `.otf`). The CLI validates the folder and copies the files to `static/fonts/`, where the `@font-face` declarations in `@archetypeai/ds-lib-tokens/fonts.css` expect them. With `--no-fonts`, the theme falls back to system fonts.
141
+ The `--fonts <path>` flag expects a local folder containing all 13 PP Neue Montreal `.woff2` files (8 Sans + 5 Mono). The CLI validates the folder and copies the files to `static/fonts/`, where the `@font-face` declarations in `@archetypeai/ds-lib-tokens/fonts.css` expect them. With `--no-fonts`, the theme falls back to system fonts.
142
142
 
143
143
  ## Registries
144
144
 
@@ -12,7 +12,11 @@ import {
12
12
  createDemoPage,
13
13
  installAgentConfig
14
14
  } from '../lib/scaffold-ds-svelte-project.js';
15
- import { validateFontsPath, installLocalFonts } from '../lib/install-fonts-local.js';
15
+ import {
16
+ validateFontsPath,
17
+ installLocalFonts,
18
+ preloadLinkTags
19
+ } from '../lib/install-fonts-local.js';
16
20
  import { isInteractive, DEFAULTS } from '../lib/is-interactive.js';
17
21
 
18
22
  // parse flags
@@ -250,6 +254,14 @@ export async function create(args) {
250
254
  const fontResult = installLocalFonts(fontsPath, projectPath);
251
255
  if (fontResult.success) {
252
256
  s.stop(`${fontResult.fileCount} font files installed`);
257
+ if (fontResult.preload.patched) {
258
+ p.log.info('Preloading Book, Medium and Mono Book in src/app.html');
259
+ } else {
260
+ p.log.warn(
261
+ `Could not add font preloads (${fontResult.preload.reason}). Add these to your <head>:\n` +
262
+ preloadLinkTags().join('\n')
263
+ );
264
+ }
253
265
  } else {
254
266
  s.stop('Failed to install fonts');
255
267
  p.log.warn(fontResult.error);
package/commands/init.js CHANGED
@@ -11,7 +11,11 @@ import {
11
11
  installLinting,
12
12
  installAgentConfig
13
13
  } from '../lib/scaffold-ds-svelte-project.js';
14
- import { validateFontsPath, installLocalFonts } from '../lib/install-fonts-local.js';
14
+ import {
15
+ validateFontsPath,
16
+ installLocalFonts,
17
+ preloadLinkTags
18
+ } from '../lib/install-fonts-local.js';
15
19
  import { isInteractive, DEFAULTS } from '../lib/is-interactive.js';
16
20
 
17
21
  // parse flags
@@ -210,6 +214,14 @@ export async function init(args) {
210
214
  const fontResult = installLocalFonts(fontsPath, projectPath);
211
215
  if (fontResult.success) {
212
216
  s.stop(`${fontResult.fileCount} font files installed`);
217
+ if (fontResult.preload.patched) {
218
+ p.log.info('Preloading Book, Medium and Mono Book in src/app.html');
219
+ } else {
220
+ p.log.warn(
221
+ `Could not add font preloads (${fontResult.preload.reason}). Add these to your <head>:\n` +
222
+ preloadLinkTags().join('\n')
223
+ );
224
+ }
213
225
  } else {
214
226
  s.stop('Failed to install fonts');
215
227
  p.log.warn(fontResult.error);
package/files/AGENTS.md CHANGED
@@ -81,6 +81,25 @@ use them decoratively.
81
81
 
82
82
  ## Typography
83
83
 
84
+ The theme pairs every `text-*` step with its own line-height and letter-spacing
85
+ (`--text-sm--line-height`, `--text-sm--letter-spacing`, …), so `text-sm` alone
86
+ is on-brand. Never add `leading-*` or `tracking-*` next to a `text-*` utility —
87
+ the ramp already supplies both. `text-2xs` (10px) is the floor for dense
88
+ metadata; never use arbitrary sizes like `text-[11px]`.
89
+
90
+ The theme's base layer styles only authored-content elements: `body` (14px
91
+ sans), `h1`–`h6` (30px → 14px, `h4`–`h6` muted, `h1` title-cased), `p`,
92
+ `small`, `strong`, `blockquote`, plus `cursor-pointer` on `a`. It deliberately
93
+ does **not** style `span`, `label`, `em`, `code` or `pre` — and gives `a` no
94
+ typography either — they inherit from their parent, so set the size once on the
95
+ parent instead of on every inline child (`code`/`pre` get the mono family from
96
+ Tailwind preflight and stay the same size as their text). Bare links are
97
+ visually flat until you style them, but every `a` shows the pointer cursor even
98
+ without an `href`; use the `Label` primitive for labels.
99
+ `h1` is `capitalize` by default (use `normal-case` for ids or sentence-case
100
+ copy); uppercase belongs to `Label` variants and `typography="mono"`, never to
101
+ a heading.
102
+
84
103
  Default to sans for all text. `font-mono` is reserved for exactly two cases:
85
104
 
86
105
  - Primitives that already use it in the design system source of truth (their
@@ -110,8 +129,9 @@ darkMode.toggle(); // switch
110
129
  darkMode.current; // reactive read
111
130
  ```
112
131
 
113
- Gotcha: `CodeBlock` highlights once on mount — wrap it in
114
- `{#key darkMode.current}` so it re-highlights on theme flips.
132
+ `CodeBlock` needs nothing here: it highlights with both themes at once (shiki
133
+ emits the dark colours as `--shiki-dark` custom props) and flips them in CSS, so
134
+ theme switches need no `{#key}` wrapper and no re-highlight.
115
135
 
116
136
  ## Page shell
117
137
 
@@ -173,6 +193,9 @@ Compose project-specific components from package primitives in
173
193
  `sideBarSelection`, `lensTray*`, `backToDashboard`) exist in the package
174
194
  types but are internal to the Archetype AI console product — never use them
175
195
  - Keep the `@source` directives and the CSS import order exactly as above
196
+ - Never add global element rules (`span`, `a`, `label`, `p`, `h1`…) in app CSS,
197
+ and never pair `leading-*`/`tracking-*` with a `text-*` utility — the theme's
198
+ type scale and base layer own both; size a parent, not its inline children
176
199
  - Never style scrollbars in app code — no `::-webkit-scrollbar` rules, no
177
200
  `scrollbar-color`/`scrollbar-width`. The theme sets both, and setting
178
201
  `scrollbar-*` locally also silently disables the webkit pseudo-elements on
package/files/CLAUDE.md CHANGED
@@ -81,6 +81,25 @@ use them decoratively.
81
81
 
82
82
  ## Typography
83
83
 
84
+ The theme pairs every `text-*` step with its own line-height and letter-spacing
85
+ (`--text-sm--line-height`, `--text-sm--letter-spacing`, …), so `text-sm` alone
86
+ is on-brand. Never add `leading-*` or `tracking-*` next to a `text-*` utility —
87
+ the ramp already supplies both. `text-2xs` (10px) is the floor for dense
88
+ metadata; never use arbitrary sizes like `text-[11px]`.
89
+
90
+ The theme's base layer styles only authored-content elements: `body` (14px
91
+ sans), `h1`–`h6` (30px → 14px, `h4`–`h6` muted, `h1` title-cased), `p`,
92
+ `small`, `strong`, `blockquote`, plus `cursor-pointer` on `a`. It deliberately
93
+ does **not** style `span`, `label`, `em`, `code` or `pre` — and gives `a` no
94
+ typography either — they inherit from their parent, so set the size once on the
95
+ parent instead of on every inline child (`code`/`pre` get the mono family from
96
+ Tailwind preflight and stay the same size as their text). Bare links are
97
+ visually flat until you style them, but every `a` shows the pointer cursor even
98
+ without an `href`; use the `Label` primitive for labels.
99
+ `h1` is `capitalize` by default (use `normal-case` for ids or sentence-case
100
+ copy); uppercase belongs to `Label` variants and `typography="mono"`, never to
101
+ a heading.
102
+
84
103
  Default to sans for all text. `font-mono` is reserved for exactly two cases:
85
104
 
86
105
  - Primitives that already use it in the design system source of truth (their
@@ -110,8 +129,9 @@ darkMode.toggle(); // switch
110
129
  darkMode.current; // reactive read
111
130
  ```
112
131
 
113
- Gotcha: `CodeBlock` highlights once on mount — wrap it in
114
- `{#key darkMode.current}` so it re-highlights on theme flips.
132
+ `CodeBlock` needs nothing here: it highlights with both themes at once (shiki
133
+ emits the dark colours as `--shiki-dark` custom props) and flips them in CSS, so
134
+ theme switches need no `{#key}` wrapper and no re-highlight.
115
135
 
116
136
  ## Page shell
117
137
 
@@ -173,6 +193,9 @@ Compose project-specific components from package primitives in
173
193
  `sideBarSelection`, `lensTray*`, `backToDashboard`) exist in the package
174
194
  types but are internal to the Archetype AI console product — never use them
175
195
  - Keep the `@source` directives and the CSS import order exactly as above
196
+ - Never add global element rules (`span`, `a`, `label`, `p`, `h1`…) in app CSS,
197
+ and never pair `leading-*`/`tracking-*` with a `text-*` utility — the theme's
198
+ type scale and base layer own both; size a parent, not its inline children
176
199
  - Never style scrollbars in app code — no `::-webkit-scrollbar` rules, no
177
200
  `scrollbar-color`/`scrollbar-width`. The theme sets both, and setting
178
201
  `scrollbar-*` locally also silently disables the webkit pseudo-elements on
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "archetypeai-design-system",
3
- "version": "0.15.0",
3
+ "version": "0.16.1",
4
4
  "components": [
5
5
  {
6
6
  "name": "alert",
@@ -245,8 +245,8 @@
245
245
  "source": "https://design-system-console.archetypeai.workers.dev/r/codeblock.json",
246
246
  "description": "Syntax-highlighted code display (CodeBlock, shiki; yaml/json/python/shell) and editable code field with JSON-schema validation for YAML (CodeBlockEditor).",
247
247
  "usage": [
248
- "`<CodeBlock code language=\"yaml\" />` for read-only code (border built in). `language` accepts `\"yaml\" | \"json\" | \"python\" | \"shell\"` — use `\"shell\"` for bash/sh/curl snippets. Theme switches are CSS-only — no {#key} wrapper needed. Grows to content by default; to cap the height pass `maxHeight=\"20rem\"` (any CSS length) or the `scroll` shorthand (=600px) — this puts the scrollbar on the internal scroller, whereas a `max-h-*` class on the component clips without scrolling. `showLineNumbers={false}` hides the gutter; `wrap` wraps long lines instead of scrolling horizontally.",
249
- "Config editors: `<CodeBlockEditor bind:value bind:valid bind:validating bind:schemaUnavailable schema={jsonSchema} placeholder showDownload downloadBaseName disabled />` + a status badge row driven by the bindables (validating → 'Checking…' spinner; !valid → 'Invalid'; valid+schemaUnavailable → 'YAML parses'; valid → 'Valid YAML'). Grows to content (floor via `minHeight`, default 10rem); cap+scroll via `maxHeight` (any length) or `scroll` (=600px). Visually identical to CodeBlock. `language` (same union, default `\"yaml\"`) switches highlighting; live validation runs only for `\"yaml\"` — for other languages `schema` is ignored and `valid` stays true. The download button's extension/MIME follows the language (.yaml/.json/.py/.sh)."
248
+ "`<CodeBlock code language=\"yaml\" />` for read-only code (border built in). `language` accepts `\"yaml\" | \"json\" | \"python\" | \"shell\"` — use `\"shell\"` for bash/sh/curl snippets. Theme switches are CSS-only — no {#key} wrapper needed. Grows to content by default and never clips: whenever its height is constrained — by a `max-h-*` class, by a flex/grid parent like `Dialog.Body`, or by the `maxHeight`/`scroll` props — the internal scroller shows a scrollbar instead. To cap it yourself pass `maxHeight=\"20rem\"` (any CSS length) or the `scroll` shorthand (=600px); neither is needed just to avoid clipping. `showLineNumbers={false}` hides the gutter; `wrap` wraps long lines instead of scrolling horizontally.",
249
+ "Config editors: `<CodeBlockEditor bind:value bind:valid bind:validating bind:schemaUnavailable schema={jsonSchema} placeholder showDownload downloadBaseName disabled />` + a status badge row driven by the bindables (validating → 'Checking…' spinner; !valid → 'Invalid'; valid+schemaUnavailable → 'YAML parses'; valid → 'Valid YAML'). Grows to content (floor via `minHeight`, default 10rem) and scrolls rather than clips whenever its height is constrained; cap it yourself via `maxHeight` (any length) or `scroll` (=600px). Visually identical to CodeBlock. `language` (same union, default `\"yaml\"`) switches highlighting; live validation runs only for `\"yaml\"` — for other languages `schema` is ignored and `valid` stays true. The download button's extension/MIME follows the language (.yaml/.json/.py/.sh)."
250
250
  ]
251
251
  },
252
252
  {
@@ -357,7 +357,8 @@
357
357
  "usage": [
358
358
  "Trigger via child snippet spread onto a Button (profile menu: `variant=\"accent\"` avatar + chevron, rounded-full).",
359
359
  "Styled triggers: `DropdownMenu.Trigger variant=\"filter\"` for toolbar/list filter dropdowns; `variant=\"input\"` for form-field-styled dropdowns in pickers.",
360
- "`DropdownMenu.Content align=\"end\" class=\"min-w-64\"`; items `justify=\"between\"` with a trailing icon; `closeOnSelect={false}` for in-menu toggles (dark mode); Separator before Sign Out."
360
+ "`DropdownMenu.Content align=\"end\" class=\"min-w-64\"`; items `justify=\"between\"` with a trailing icon; `closeOnSelect={false}` for in-menu toggles (dark mode); Separator before Sign Out.",
361
+ "Section headings: `DropdownMenu.Label` for a standalone heading, `DropdownMenu.GroupHeading` inside a `Group`. Both render in the console's section-header treatment by default (10px mono, uppercase, muted, more space above than below so the heading binds to the rows it introduces), so a heading recedes rather than competing with its items. Set no type or colour classes on them; if a Label is carrying a row of content rather than a heading, override with `class`."
361
362
  ],
362
363
  "variants": {
363
364
  "dropdownMenuTriggerVariants": {
@@ -907,6 +908,55 @@
907
908
  "Horizontal between stacked sections; `orientation=\"vertical\"` in multi-column layouts."
908
909
  ]
909
910
  },
911
+ {
912
+ "name": "side-panel",
913
+ "import": "@archetypeai/ds-ui-svelte-console/primitives/side-panel",
914
+ "source": "https://design-system-console.archetypeai.workers.dev/r/side-panel.json",
915
+ "description": "Non-modal side panel for peeking at or inspecting one object without leaving the list it came from.",
916
+ "usage": [
917
+ "Bind the panel to an object id, never to a row: `<SidePanel.Root open={selectedId !== null} onOpenChange={(o) => !o && (selectedId = null)}>`. Keep the id in the URL so the state survives refresh, back and sharing; the primitive itself never reads or writes the URL. Do NOT derive the panel's content straight from that id: the panel stays mounted while it closes, so the content would blank out before it leaves. Latch the last opened object instead, `let shown = $state(null); $effect(() => { const next = find(selectedId); if (next) shown = next; })`, and render from `shown`.",
918
+ "Peek vs inspect: content with no page of its own just fills `SidePanel.Body`. Content that has a full page renders the page's body component inside Body and puts the handoff ONCE, in the Header `actions` snippet, as an outline icon button beside the built-in Close: `<SidePanel.Header>{#snippet actions()}<Button variant=\"outline\" size=\"icon\" class=\"size-8\" aria-label=\"Open full page\" href={fullPageUrl}><ArrowUpRightIcon /></Button>{/snippet}<SidePanel.Title>…</SidePanel.Title></SidePanel.Header>`. Header actions are outline icon buttons at the sm height (`class=\"size-8\"`, Button has no small icon size) so they read as one set with Close, pinned to the header's top-right, and match `size=\"sm\"` Footer buttons; never repeat the handoff in the Footer.",
919
+ "It never renders an overlay or scrim and never traps focus or scroll: the list behind stays fully interactive, clicking outside does nothing, Escape closes. If the task needs the page blocked, use Dialog instead.",
920
+ "One panel at a time. Drilling in replaces Body's content and adds an in-panel back; it never opens a second SidePanel.",
921
+ "Read-mostly: actions inside are fine, but destructive confirmations spawn a Dialog over the panel. The panel does not own confirmation.",
922
+ "`size=\"sm\"` (384px) for a record and its fields; `size=\"md\"` (576px) when Body carries a preview, a chart or a code block. `side=\"left\"` only when the list itself lives on the right.",
923
+ "Header layout follows Dialog: a `space-y-2` block, so `SidePanel.Title` and then either `SidePanel.Description` (plain text) or `SidePanel.Meta` (badges, a status line, key/value metadata) stack full width. The `actions` cluster and the close sit absolutely at the top right, so the header reserves right padding and a long Title truncates rather than running under them. Give header actions the same bare icon treatment as the close via `sidePanelCloseButtonVariants`; keep `Description` when the subtitle is text, because it is what wires `aria-describedby` and `Meta` cannot.",
924
+ "Content renders in place (no portal) so Tab reaches it after the list; do not mount it inside a transformed ancestor, where `fixed` misplaces. Focus does not move on open. Header renders an outline icon Close by default (`showCloseButton={false}` to place `SidePanel.Close` yourself). `SidePanel.Footer` is optional: it holds the actions on the data being viewed, never Close or the full-page handoff. One primary action sits right; with `justify=\"between\"` secondary actions (copy an id, compare) go first and land left."
925
+ ],
926
+ "variants": {
927
+ "sidePanelContentVariants": {
928
+ "axes": {
929
+ "side": [
930
+ "right",
931
+ "left"
932
+ ],
933
+ "size": [
934
+ "sm",
935
+ "md"
936
+ ],
937
+ "mode": [
938
+ "overlay"
939
+ ]
940
+ },
941
+ "defaults": {
942
+ "side": "right",
943
+ "size": "sm",
944
+ "mode": "overlay"
945
+ }
946
+ },
947
+ "sidePanelFooterVariants": {
948
+ "axes": {
949
+ "justify": [
950
+ "default",
951
+ "between"
952
+ ]
953
+ },
954
+ "defaults": {
955
+ "justify": "default"
956
+ }
957
+ }
958
+ }
959
+ },
910
960
  {
911
961
  "name": "slider",
912
962
  "import": "@archetypeai/ds-ui-svelte-labs/primitives/slider",
@@ -996,7 +1046,8 @@
996
1046
  "usage": [
997
1047
  "Log/event tables: width-classed Table.Head columns, `Table.Body class=\"font-sans text-xs\"`, level cells colored with status tokens (text-atai-critical for error rows).",
998
1048
  "Mono body (`typography=\"mono\"` on Table.Body) for id-heavy tables; pair with `EmptyState variant=\"list\"` when empty.",
999
- "Header rows never take the data-row hover — `Table.Header` neutralizes it; don't cancel `hover:bg-*` from consumer code."
1049
+ "Header rows never take the data-row hover — `Table.Header` neutralizes it; don't cancel `hover:bg-*` from consumer code.",
1050
+ "Sortable columns: `Table.Head sortable direction={col === sort ? order : null} onSort={() => setSort(col)}`. The primitive owns the affordance, the three-state indicator and `aria-sort`; the consumer owns the sort state, so a Sort menu and the headers can drive the same state and stay in step. Mark only the columns the API can actually order by. Non-sortable heads are unchanged and simply are not clickable."
1000
1051
  ],
1001
1052
  "variants": {
1002
1053
  "tableBodyVariants": {
@@ -1009,6 +1060,44 @@
1009
1060
  "defaults": {
1010
1061
  "typography": "default"
1011
1062
  }
1063
+ },
1064
+ "tableHeadVariants": {
1065
+ "axes": {
1066
+ "sortable": [
1067
+ "true",
1068
+ "false"
1069
+ ]
1070
+ },
1071
+ "defaults": {
1072
+ "sortable": "false"
1073
+ }
1074
+ },
1075
+ "tableHeadSortVariants": {
1076
+ "axes": {
1077
+ "active": [
1078
+ "true",
1079
+ "false"
1080
+ ]
1081
+ },
1082
+ "defaults": {
1083
+ "active": "false"
1084
+ }
1085
+ },
1086
+ "tableHeadSortIconVariants": {
1087
+ "axes": {
1088
+ "active": [
1089
+ "true",
1090
+ "false"
1091
+ ],
1092
+ "flipped": [
1093
+ "true",
1094
+ "false"
1095
+ ]
1096
+ },
1097
+ "defaults": {
1098
+ "active": "false",
1099
+ "flipped": "false"
1100
+ }
1012
1101
  }
1013
1102
  }
1014
1103
  },
@@ -1127,9 +1216,10 @@
1127
1216
  "description": "Hover hint.",
1128
1217
  "usage": [
1129
1218
  "Wrap the app/section in `Tooltip.Provider delayDuration={200}`.",
1130
- "Field help: `Tooltip.Trigger variant=\"info\"` is a filled 20px disc holding a mono `i` — pass it no children, it renders its own glyph. Explanation goes in `Tooltip.Content`.",
1219
+ "Field help: `Tooltip.Trigger variant=\"info\"` is a filled 16px disc holding a mono `i` — pass it no children, it renders its own glyph. Explanation goes in `Tooltip.Content`.",
1131
1220
  "`aria-label` on the info trigger is required and is the caller's: name what is being explained (`aria-label=\"About Detection Log\"`). The glyph is `aria-hidden`, so without a label the button has no accessible name.",
1132
- "The info disc squares against `Counter`'s height on purpose — a list title, its `Counter` and its info disc in one `flex items-center gap-2` row is the intended cluster, the solid pill reading as the count and the muted one as the meaning.",
1221
+ "The info disc squares against `Counter size=\"sm\"`'s height on purpose — a section title, its `Counter` and its info disc in one `flex items-center gap-2` row is the intended cluster, the solid pill reading as the count and the muted one as the meaning. 16px is also exactly the height of a `Label variant=\"sectionHeader\"` box, so the disc sits level with the title rather than towering over it.",
1222
+ "`size=\"lg\"` is the 20px disc, for a cluster built around `Counter size=\"default\"` (`h-5`) rather than a section label. `size` is inert on the other trigger treatments, which have no box.",
1133
1223
  "`variant=\"inlineLink\"` — underlined trigger flowing inside prose (supported file requirements).",
1134
1224
  "The arrow centres itself on the trigger on all four sides and protrudes half its box — `arrowClasses` is for its ink or size, not for centring corrections. If a tip looks off, it is a bug in `tooltipArrowVariants`, not something to counter-shift at the call site."
1135
1225
  ],
@@ -1140,10 +1230,15 @@
1140
1230
  "default",
1141
1231
  "info",
1142
1232
  "inlineLink"
1233
+ ],
1234
+ "size": [
1235
+ "default",
1236
+ "lg"
1143
1237
  ]
1144
1238
  },
1145
1239
  "defaults": {
1146
- "variant": "default"
1240
+ "variant": "default",
1241
+ "size": "default"
1147
1242
  }
1148
1243
  }
1149
1244
  }
@@ -2,7 +2,7 @@ import { execFile } from 'child_process';
2
2
  import { promisify } from 'util';
3
3
  import * as p from '@clack/prompts';
4
4
  import { detectPm, getPm } from './use-package-manager.js';
5
- import { validateFontsPath, installLocalFonts } from './install-fonts-local.js';
5
+ import { validateFontsPath, installLocalFonts, preloadLinkTags } from './install-fonts-local.js';
6
6
  import { isInteractive } from './is-interactive.js';
7
7
  import { prependCss } from './scaffold-ds-svelte-project.js';
8
8
 
@@ -124,6 +124,14 @@ export async function addDsLibTokens(args) {
124
124
  const fontResult = installLocalFonts(fontsPath, projectDir);
125
125
  if (fontResult.success) {
126
126
  fontSpinner.stop(`${fontResult.fileCount} font files installed`);
127
+ if (fontResult.preload.patched) {
128
+ p.log.info('Preloading Book, Medium and Mono Book in src/app.html');
129
+ } else {
130
+ p.log.warn(
131
+ `Could not add font preloads (${fontResult.preload.reason}). Add these to your <head>:\n` +
132
+ preloadLinkTags().join('\n')
133
+ );
134
+ }
127
135
  } else {
128
136
  fontSpinner.stop('Failed to install fonts');
129
137
  p.log.warn(fontResult.error);
@@ -6,7 +6,7 @@ import { fileURLToPath } from 'url';
6
6
  import * as p from '@clack/prompts';
7
7
  import { validateRegistryUrl } from './validate-url.js';
8
8
  import { detectPm, getPm } from './use-package-manager.js';
9
- import { validateFontsPath, installLocalFonts } from './install-fonts-local.js';
9
+ import { validateFontsPath, installLocalFonts, preloadLinkTags } from './install-fonts-local.js';
10
10
  import { isInteractive } from './is-interactive.js';
11
11
  import {
12
12
  prependCss,
@@ -177,6 +177,14 @@ export async function addDsUiSvelte(args) {
177
177
  const fontResult = installLocalFonts(fontsPath, projectDir);
178
178
  if (fontResult.success) {
179
179
  fontSpinner.stop(`${fontResult.fileCount} font files installed`);
180
+ if (fontResult.preload.patched) {
181
+ p.log.info('Preloading Book, Medium and Mono Book in src/app.html');
182
+ } else {
183
+ p.log.warn(
184
+ `Could not add font preloads (${fontResult.preload.reason}). Add these to your <head>:\n` +
185
+ preloadLinkTags().join('\n')
186
+ );
187
+ }
180
188
  } else {
181
189
  fontSpinner.stop('Failed to install fonts');
182
190
  p.log.warn(fontResult.error);
@@ -1,4 +1,4 @@
1
- import { existsSync, readdirSync, copyFileSync, mkdirSync } from 'fs';
1
+ import { existsSync, readdirSync, copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
2
2
  import { join, resolve } from 'path';
3
3
 
4
4
  // strip surrounding quotes that some terminals (VS Code, Cursor) add on drag-and-drop
@@ -13,25 +13,59 @@ function sanitizePath(p) {
13
13
  return trimmed;
14
14
  }
15
15
 
16
- // all 15 required font files
16
+ // all 13 required font files
17
17
  export const REQUIRED_FONTS = [
18
- // PP Neue Montreal Sans (.ttf)
19
- 'PPNeueMontreal-Thin.ttf',
20
- 'PPNeueMontreal-ThinItalic.ttf',
21
- 'PPNeueMontreal-Light.ttf',
22
- 'PPNeueMontreal-Book.ttf',
23
- 'PPNeueMontreal-Italic.ttf',
24
- 'PPNeueMontreal-Medium.ttf',
25
- 'PPNeueMontreal-Bold.ttf',
26
- 'PPNeueMontreal-BoldItalic.ttf',
27
- // PP Neue Montreal Mono (.otf)
28
- 'PPNeueMontrealMono-Thin.otf',
29
- 'PPNeueMontrealMono-Book.otf',
30
- 'PPNeueMontrealMono-RegularItalic.otf',
31
- 'PPNeueMontrealMono-Medium.otf',
32
- 'PPNeueMontrealMono-Bold.otf'
18
+ // PP Neue Montreal Sans
19
+ 'PPNeueMontreal-Thin.woff2',
20
+ 'PPNeueMontreal-ThinItalic.woff2',
21
+ 'PPNeueMontreal-Light.woff2',
22
+ 'PPNeueMontreal-Book.woff2',
23
+ 'PPNeueMontreal-Italic.woff2',
24
+ 'PPNeueMontreal-Medium.woff2',
25
+ 'PPNeueMontreal-Bold.woff2',
26
+ 'PPNeueMontreal-BoldItalic.woff2',
27
+ // PP Neue Montreal Mono
28
+ 'PPNeueMontrealMono-Thin.woff2',
29
+ 'PPNeueMontrealMono-Book.woff2',
30
+ 'PPNeueMontrealMono-RegularItalic.woff2',
31
+ 'PPNeueMontrealMono-Medium.woff2',
32
+ 'PPNeueMontrealMono-Bold.woff2'
33
33
  ];
34
34
 
35
+ // the faces the first paint uses: 400 sans (body), 500 sans (emphasis), 400 mono (labels)
36
+ export const FONT_PRELOADS = [
37
+ 'PPNeueMontreal-Book.woff2',
38
+ 'PPNeueMontreal-Medium.woff2',
39
+ 'PPNeueMontrealMono-Book.woff2'
40
+ ];
41
+
42
+ // `crossorigin` is required on font preloads even same-origin; without it the
43
+ // browser fetches the file twice (once anonymous for the preload, once CORS for @font-face)
44
+ export function preloadLinkTags() {
45
+ return FONT_PRELOADS.map(
46
+ (file) => `<link rel="preload" as="font" type="font/woff2" crossorigin href="/fonts/${file}" />`
47
+ );
48
+ }
49
+
50
+ // insert the preload tags above %sveltekit.head% in src/app.html (idempotent)
51
+ export function patchAppHtmlPreload(projectPath) {
52
+ const htmlPath = join(projectPath, 'src', 'app.html');
53
+ if (!existsSync(htmlPath)) return { patched: false, reason: 'src/app.html not found' };
54
+ const content = readFileSync(htmlPath, 'utf-8');
55
+ if (content.includes(`/fonts/${FONT_PRELOADS[0]}`)) {
56
+ return { patched: false, reason: 'preload tags already present' };
57
+ }
58
+ const marker = /^([ \t]*)%sveltekit\.head%/m;
59
+ const match = content.match(marker);
60
+ if (!match) return { patched: false, reason: '%sveltekit.head% placeholder not found' };
61
+ const indent = match[1];
62
+ const tags = preloadLinkTags()
63
+ .map((tag) => `${indent}${tag}\n`)
64
+ .join('');
65
+ writeFileSync(htmlPath, content.replace(marker, `${tags}${indent}%sveltekit.head%`));
66
+ return { patched: true };
67
+ }
68
+
35
69
  // validate that a directory contains all required font files
36
70
  export function validateFontsPath(fontsPath) {
37
71
  const resolved = resolve(sanitizePath(fontsPath));
@@ -72,7 +106,7 @@ export function copyFontsToStatic(fontsPath, projectPath) {
72
106
  return REQUIRED_FONTS.length;
73
107
  }
74
108
 
75
- // orchestrate: validate and copy font files to static/fonts/
109
+ // orchestrate: validate, copy font files to static/fonts/, and preload the first-paint faces
76
110
  export function installLocalFonts(fontsPath, projectPath) {
77
111
  const validation = validateFontsPath(fontsPath);
78
112
  if (!validation.valid) {
@@ -80,6 +114,7 @@ export function installLocalFonts(fontsPath, projectPath) {
80
114
  }
81
115
 
82
116
  const fileCount = copyFontsToStatic(fontsPath, projectPath);
117
+ const preload = patchAppHtmlPreload(projectPath);
83
118
 
84
- return { success: true, fileCount };
119
+ return { success: true, fileCount, preload };
85
120
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@archetypeai/ds-cli",
3
- "version": "0.15.0",
3
+ "version": "0.16.1",
4
4
  "description": "Archetype AI Design System CLI Tool",
5
5
  "type": "module",
6
6
  "bin": {