velloo 0.0.0 → 0.2.0

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 (267) hide show
  1. package/BUN-LICENSE.md +91 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +9 -0
  4. package/README.md +288 -2
  5. package/THIRD-PARTY-NOTICES.md +467 -0
  6. package/assets/elsewhere-alps-9fqhv029.jpg +0 -0
  7. package/assets/elsewhere-bali-vp60g115.jpg +0 -0
  8. package/assets/elsewhere-forest-xgn23zrj.jpg +0 -0
  9. package/assets/elsewhere-japan-qs7qkbr2.jpg +0 -0
  10. package/assets/elsewhere-kyoto-map-n2bvkrya.svg +1 -0
  11. package/assets/elsewhere-kyoto-qemtn6nk.jpg +0 -0
  12. package/assets/elsewhere-room-g1gaw7cs.jpg +0 -0
  13. package/canvas/apple-touch-icon.png +0 -0
  14. package/canvas/assets/index-BCvDZiBo.css +2 -0
  15. package/canvas/assets/index-ClaBqcaS.js +28 -0
  16. package/canvas/assets/lucide-all-iIF-pmzN.js +1 -0
  17. package/canvas/assets/radix-C3HaOB-d.js +41 -0
  18. package/canvas/assets/react-D0Ec-185.js +9 -0
  19. package/canvas/assets/rolldown-runtime-hePW80VL.js +1 -0
  20. package/canvas/assets/vendor-CKmQ-Zst.js +53 -0
  21. package/canvas/favicon.ico +0 -0
  22. package/canvas/favicon.svg +12 -0
  23. package/canvas/fonts/OFL-1.1.txt +93 -0
  24. package/canvas/fonts/poppins-700.woff2 +0 -0
  25. package/canvas/icon-192.png +0 -0
  26. package/canvas/icon-512.png +0 -0
  27. package/canvas/index.html +22 -0
  28. package/canvas/site.webmanifest +11 -0
  29. package/chunk-08sv3k41.js +4 -0
  30. package/chunk-0fe83yzt.js +2 -0
  31. package/chunk-0t68szgr.js +3 -0
  32. package/chunk-1h55s5he.js +2 -0
  33. package/chunk-2c7jbx46.js +2 -0
  34. package/chunk-2smsg2ct.js +7 -0
  35. package/chunk-308ynsxd.js +2 -0
  36. package/chunk-3e8vez4d.js +1229 -0
  37. package/chunk-40xgcth8.js +63 -0
  38. package/chunk-4dn75m6t.js +3 -0
  39. package/chunk-4fj5tahv.js +3 -0
  40. package/chunk-6nqghjda.js +3 -0
  41. package/chunk-6tx68d90.js +3 -0
  42. package/chunk-6w6ch4ce.js +2 -0
  43. package/chunk-735ezz23.js +86 -0
  44. package/chunk-7eq7nx37.js +7 -0
  45. package/chunk-7eyb8yrf.js +6 -0
  46. package/chunk-80fjfyha.js +86 -0
  47. package/chunk-80r93t2e.js +4 -0
  48. package/chunk-94vx1yb0.js +3 -0
  49. package/chunk-9vg3yafz.js +3 -0
  50. package/chunk-a84n72m2.js +4 -0
  51. package/chunk-ampmdzz4.js +10 -0
  52. package/chunk-avns4dbf.js +2 -0
  53. package/chunk-ayrb3qnm.js +3 -0
  54. package/chunk-bwnvcncx.js +3 -0
  55. package/chunk-ccszg02q.js +5 -0
  56. package/chunk-cvn689d5.js +3 -0
  57. package/chunk-cxe6jjwj.js +3 -0
  58. package/chunk-dg6z4vdd.js +3 -0
  59. package/chunk-dgwjpbp9.js +3 -0
  60. package/chunk-dy41n60j.js +52 -0
  61. package/chunk-ek71z2fv.js +226 -0
  62. package/chunk-er1fxb7d.js +2 -0
  63. package/chunk-eyr8hrzf.js +2 -0
  64. package/chunk-fjbzk4sw.js +3 -0
  65. package/chunk-fsbsksga.js +3 -0
  66. package/chunk-h6gchare.js +3 -0
  67. package/chunk-hcb68zt7.js +85 -0
  68. package/chunk-hggap8fq.js +3 -0
  69. package/chunk-j11fyeqh.js +2 -0
  70. package/chunk-jb21jkp1.js +8 -0
  71. package/chunk-jfx9xjmr.js +18 -0
  72. package/chunk-jr0ss0mm.js +303 -0
  73. package/chunk-jrsknzjt.js +3 -0
  74. package/chunk-jv0xggh0.js +2 -0
  75. package/chunk-k17gfwq3.js +2 -0
  76. package/chunk-mh2pr7cq.js +5 -0
  77. package/chunk-mnapdp1y.js +2 -0
  78. package/chunk-mzhdrawd.js +3 -0
  79. package/chunk-n9gs6yx9.js +2 -0
  80. package/chunk-ngnymg2q.js +3 -0
  81. package/chunk-nwx5700b.js +3 -0
  82. package/chunk-pjtg3wnm.js +4 -0
  83. package/chunk-pp86v6m6.js +3 -0
  84. package/chunk-r22epxz2.js +3 -0
  85. package/chunk-rgasy862.js +5 -0
  86. package/chunk-rpwpeph5.js +3 -0
  87. package/chunk-s23gtejf.js +3 -0
  88. package/chunk-sjgqnykd.js +6 -0
  89. package/chunk-sn3x2m0m.js +6 -0
  90. package/chunk-t448rrfr.js +2 -0
  91. package/chunk-tdsk5wxa.js +451 -0
  92. package/chunk-v40fn08f.js +442 -0
  93. package/chunk-vbk1qhdz.js +3 -0
  94. package/chunk-vdkrbzh1.js +2 -0
  95. package/chunk-vgmr98cv.js +40 -0
  96. package/chunk-vsv8z729.js +6 -0
  97. package/chunk-x4f4k9pp.js +26 -0
  98. package/chunk-xt5ayg7g.js +4 -0
  99. package/chunk-ydhwv29b.js +244 -0
  100. package/chunk-zb6hc1y0.js +2 -0
  101. package/cli.js +3 -0
  102. package/launcher.cjs +97 -0
  103. package/package.json +58 -5
  104. package/pkgs/helpers/src/box.tsx +21 -0
  105. package/pkgs/helpers/src/cn.ts +32 -0
  106. package/pkgs/helpers/src/descriptors.ts +270 -0
  107. package/pkgs/helpers/src/divider.tsx +123 -0
  108. package/pkgs/helpers/src/gradient.tsx +90 -0
  109. package/pkgs/helpers/src/heading.tsx +20 -0
  110. package/pkgs/helpers/src/icon-data.ts +8009 -0
  111. package/pkgs/helpers/src/icon.tsx +53 -0
  112. package/pkgs/helpers/src/image.tsx +111 -0
  113. package/pkgs/helpers/src/index.ts +29 -0
  114. package/pkgs/helpers/src/layer.tsx +61 -0
  115. package/pkgs/helpers/src/lowering.ts +53 -0
  116. package/pkgs/helpers/src/paths.ts +47 -0
  117. package/pkgs/helpers/src/placeholder.tsx +88 -0
  118. package/pkgs/helpers/src/prose.tsx +42 -0
  119. package/pkgs/helpers/src/registry.ts +41 -0
  120. package/pkgs/helpers/src/svg.tsx +62 -0
  121. package/pkgs/helpers/src/text.tsx +16 -0
  122. package/pkgs/provider-antd/src/index.ts +78 -0
  123. package/pkgs/provider-antd/src/intro.ts +14 -0
  124. package/pkgs/provider-antd/src/manifest.ts +439 -0
  125. package/pkgs/provider-antd/src/overlays.ts +176 -0
  126. package/pkgs/provider-antd/src/registry.ts +104 -0
  127. package/pkgs/provider-antd/src/render-pass.ts +43 -0
  128. package/pkgs/provider-antd/src/tailwind-entry.css +21 -0
  129. package/pkgs/provider-antd/src/theme.ts +132 -0
  130. package/pkgs/provider-antd/src/version.ts +8 -0
  131. package/pkgs/provider-chakra/src/index.ts +80 -0
  132. package/pkgs/provider-chakra/src/intro.ts +14 -0
  133. package/pkgs/provider-chakra/src/manifest.ts +421 -0
  134. package/pkgs/provider-chakra/src/overlays.ts +246 -0
  135. package/pkgs/provider-chakra/src/registry.ts +227 -0
  136. package/pkgs/provider-chakra/src/render-pass.ts +55 -0
  137. package/pkgs/provider-chakra/src/tailwind-entry.css +21 -0
  138. package/pkgs/provider-chakra/src/theme.ts +180 -0
  139. package/pkgs/provider-chakra/src/version.ts +8 -0
  140. package/pkgs/provider-mui/src/index.ts +117 -0
  141. package/pkgs/provider-mui/src/intro.ts +12 -0
  142. package/pkgs/provider-mui/src/manifest.ts +263 -0
  143. package/pkgs/provider-mui/src/overlays.ts +124 -0
  144. package/pkgs/provider-mui/src/registry.ts +120 -0
  145. package/pkgs/provider-mui/src/render-pass.ts +36 -0
  146. package/pkgs/provider-mui/src/tailwind-entry.css +21 -0
  147. package/pkgs/provider-mui/src/theme.ts +129 -0
  148. package/pkgs/provider-mui/src/version.ts +8 -0
  149. package/pkgs/provider-none/src/components-inline.tsx +204 -0
  150. package/pkgs/provider-none/src/components.tsx +155 -0
  151. package/pkgs/provider-none/src/index.ts +57 -0
  152. package/pkgs/provider-none/src/intro.ts +18 -0
  153. package/pkgs/provider-none/src/manifest.ts +163 -0
  154. package/pkgs/provider-none/src/registry-inline.ts +36 -0
  155. package/pkgs/provider-none/src/registry.ts +39 -0
  156. package/pkgs/provider-none/src/tailwind-entry.css +57 -0
  157. package/pkgs/provider-none/src/version.ts +10 -0
  158. package/pkgs/schema/src/annotation.ts +120 -0
  159. package/pkgs/schema/src/asset.ts +66 -0
  160. package/pkgs/schema/src/board.ts +71 -0
  161. package/pkgs/schema/src/comment.ts +107 -0
  162. package/pkgs/schema/src/config.ts +185 -0
  163. package/pkgs/schema/src/css-sanitize.ts +56 -0
  164. package/pkgs/schema/src/extension.ts +105 -0
  165. package/pkgs/schema/src/fonts.ts +690 -0
  166. package/pkgs/schema/src/frame.ts +40 -0
  167. package/pkgs/schema/src/icon-name.ts +21 -0
  168. package/pkgs/schema/src/ids.ts +14 -0
  169. package/pkgs/schema/src/index.ts +169 -0
  170. package/pkgs/schema/src/migrate.ts +297 -0
  171. package/pkgs/schema/src/node.ts +229 -0
  172. package/pkgs/schema/src/paths.ts +28 -0
  173. package/pkgs/schema/src/repo.ts +134 -0
  174. package/pkgs/schema/src/screen.ts +30 -0
  175. package/pkgs/schema/src/snippet-resolve.ts +193 -0
  176. package/pkgs/schema/src/snippet.ts +104 -0
  177. package/pkgs/schema/src/svg-sanitize.ts +487 -0
  178. package/pkgs/schema/src/theme.ts +218 -0
  179. package/pkgs/schema/src/typeset.ts +693 -0
  180. package/pkgs/schema/src/validate-ids.ts +46 -0
  181. package/pkgs/schema/src/viewport.ts +8 -0
  182. package/pkgs/shadcn-snapshot/dist/manifest.json +8109 -0
  183. package/pkgs/shadcn-snapshot/src/components/canvas-portal.tsx +55 -0
  184. package/pkgs/shadcn-snapshot/src/components/ui/accordion.tsx +122 -0
  185. package/pkgs/shadcn-snapshot/src/components/ui/alert-dialog.tsx +164 -0
  186. package/pkgs/shadcn-snapshot/src/components/ui/alert.tsx +74 -0
  187. package/pkgs/shadcn-snapshot/src/components/ui/aspect-ratio.tsx +13 -0
  188. package/pkgs/shadcn-snapshot/src/components/ui/attachment.tsx +197 -0
  189. package/pkgs/shadcn-snapshot/src/components/ui/avatar.tsx +100 -0
  190. package/pkgs/shadcn-snapshot/src/components/ui/badge.tsx +50 -0
  191. package/pkgs/shadcn-snapshot/src/components/ui/breadcrumb.tsx +109 -0
  192. package/pkgs/shadcn-snapshot/src/components/ui/bubble.tsx +130 -0
  193. package/pkgs/shadcn-snapshot/src/components/ui/button-group.tsx +82 -0
  194. package/pkgs/shadcn-snapshot/src/components/ui/button.tsx +72 -0
  195. package/pkgs/shadcn-snapshot/src/components/ui/calendar.tsx +229 -0
  196. package/pkgs/shadcn-snapshot/src/components/ui/card.tsx +92 -0
  197. package/pkgs/shadcn-snapshot/src/components/ui/carousel.tsx +235 -0
  198. package/pkgs/shadcn-snapshot/src/components/ui/chart-option.ts +250 -0
  199. package/pkgs/shadcn-snapshot/src/components/ui/chart.tsx +177 -0
  200. package/pkgs/shadcn-snapshot/src/components/ui/checkbox.tsx +41 -0
  201. package/pkgs/shadcn-snapshot/src/components/ui/collapsible.tsx +32 -0
  202. package/pkgs/shadcn-snapshot/src/components/ui/combobox.tsx +196 -0
  203. package/pkgs/shadcn-snapshot/src/components/ui/context-menu.tsx +227 -0
  204. package/pkgs/shadcn-snapshot/src/components/ui/dialog.tsx +131 -0
  205. package/pkgs/shadcn-snapshot/src/components/ui/direction.tsx +24 -0
  206. package/pkgs/shadcn-snapshot/src/components/ui/drawer.tsx +112 -0
  207. package/pkgs/shadcn-snapshot/src/components/ui/dropdown-menu.tsx +205 -0
  208. package/pkgs/shadcn-snapshot/src/components/ui/empty.tsx +98 -0
  209. package/pkgs/shadcn-snapshot/src/components/ui/field.tsx +228 -0
  210. package/pkgs/shadcn-snapshot/src/components/ui/hover-card.tsx +51 -0
  211. package/pkgs/shadcn-snapshot/src/components/ui/input-group.tsx +148 -0
  212. package/pkgs/shadcn-snapshot/src/components/ui/input.tsx +43 -0
  213. package/pkgs/shadcn-snapshot/src/components/ui/item.tsx +187 -0
  214. package/pkgs/shadcn-snapshot/src/components/ui/kbd.tsx +30 -0
  215. package/pkgs/shadcn-snapshot/src/components/ui/label.tsx +25 -0
  216. package/pkgs/shadcn-snapshot/src/components/ui/marker.tsx +71 -0
  217. package/pkgs/shadcn-snapshot/src/components/ui/menubar.tsx +243 -0
  218. package/pkgs/shadcn-snapshot/src/components/ui/message.tsx +89 -0
  219. package/pkgs/shadcn-snapshot/src/components/ui/native-select.tsx +60 -0
  220. package/pkgs/shadcn-snapshot/src/components/ui/navigation-menu.tsx +165 -0
  221. package/pkgs/shadcn-snapshot/src/components/ui/pagination.tsx +117 -0
  222. package/pkgs/shadcn-snapshot/src/components/ui/popover.tsx +66 -0
  223. package/pkgs/shadcn-snapshot/src/components/ui/progress.tsx +35 -0
  224. package/pkgs/shadcn-snapshot/src/components/ui/radio-group.tsx +48 -0
  225. package/pkgs/shadcn-snapshot/src/components/ui/scroll-area.tsx +59 -0
  226. package/pkgs/shadcn-snapshot/src/components/ui/select.tsx +180 -0
  227. package/pkgs/shadcn-snapshot/src/components/ui/separator.tsx +32 -0
  228. package/pkgs/shadcn-snapshot/src/components/ui/sheet.tsx +107 -0
  229. package/pkgs/shadcn-snapshot/src/components/ui/skeleton.tsx +17 -0
  230. package/pkgs/shadcn-snapshot/src/components/ui/slider.tsx +68 -0
  231. package/pkgs/shadcn-snapshot/src/components/ui/sonner.tsx +91 -0
  232. package/pkgs/shadcn-snapshot/src/components/ui/spinner.tsx +21 -0
  233. package/pkgs/shadcn-snapshot/src/components/ui/switch.tsx +41 -0
  234. package/pkgs/shadcn-snapshot/src/components/ui/table.tsx +93 -0
  235. package/pkgs/shadcn-snapshot/src/components/ui/tabs.tsx +84 -0
  236. package/pkgs/shadcn-snapshot/src/components/ui/textarea.tsx +24 -0
  237. package/pkgs/shadcn-snapshot/src/components/ui/toggle-group.tsx +94 -0
  238. package/pkgs/shadcn-snapshot/src/components/ui/toggle.tsx +50 -0
  239. package/pkgs/shadcn-snapshot/src/components/ui/tooltip.tsx +47 -0
  240. package/pkgs/shadcn-snapshot/src/examples.ts +36 -0
  241. package/pkgs/shadcn-snapshot/src/groups.ts +87 -0
  242. package/pkgs/shadcn-snapshot/src/index.ts +43 -0
  243. package/pkgs/shadcn-snapshot/src/lib/utils.ts +4 -0
  244. package/pkgs/shadcn-snapshot/src/manifest.ts +12 -0
  245. package/pkgs/shadcn-snapshot/src/notes.ts +97 -0
  246. package/pkgs/shadcn-snapshot/src/paths.ts +47 -0
  247. package/pkgs/shadcn-snapshot/src/registry-files.ts +293 -0
  248. package/pkgs/shadcn-snapshot/src/registry.ts +631 -0
  249. package/pkgs/shadcn-snapshot/src/shadcn-tailwind.css +632 -0
  250. package/pkgs/shadcn-snapshot/src/tailwind-entry.css +159 -0
  251. package/pkgs/shadcn-snapshot/src/version.ts +8 -0
  252. package/plugins/claude/velloo/.claude-plugin/plugin.json +6 -0
  253. package/plugins/claude/velloo/agents/velloo-design-reviewer.md +33 -0
  254. package/plugins/claude/velloo/agents/velloo-designer.md +39 -0
  255. package/plugins/claude/velloo/commands/design.md +11 -0
  256. package/plugins/claude/velloo/commands/implement.md +10 -0
  257. package/plugins/claude/velloo/commands/review.md +8 -0
  258. package/plugins/gemini/GEMINI.md +31 -0
  259. package/plugins/gemini/commands/velloo/design.toml +10 -0
  260. package/plugins/gemini/commands/velloo/implement.toml +9 -0
  261. package/plugins/gemini/gemini-extension.json +9 -0
  262. package/skills/velloo-brand/SKILL.md +124 -0
  263. package/skills/velloo-design/SKILL.md +144 -0
  264. package/skills/velloo-design-system/SKILL.md +90 -0
  265. package/skills/velloo-implement/SKILL.md +101 -0
  266. package/skills/velloo-logo/SKILL.md +93 -0
  267. package/skills/velloo-setup/SKILL.md +156 -0
@@ -0,0 +1,693 @@
1
+ /**
2
+ * The typeset derivation — velloo's one home for typographic proportion.
3
+ *
4
+ * A typeset is three rhythm controls (size / leading / flow) plus font roles.
5
+ * Everything else — the h1..h6 ladder, body/lead/small copy, the space under a
6
+ * heading, list indents — derives from them through `TYPESET_RATIOS`. An agent
7
+ * reasons about three knobs far more reliably than a twelve-entry scale, and one
8
+ * ratio table means the canvas, the emitted CSS, the native framework themes,
9
+ * and the Heading/Text components cannot drift apart.
10
+ *
11
+ * Deliberately zod-free and exported as `@velloo/schema/typeset` so the canvas,
12
+ * the helpers, and the providers can import the tables without pulling zod into
13
+ * a browser bundle. The Zod shape lives in `theme.ts` and re-uses these types.
14
+ *
15
+ * Two output modes over the same ratios:
16
+ * - `typesetCss` / `typesetVars` — CSS custom properties, for every channel
17
+ * that renders through a stylesheet (Tailwind utilities, inline `style`, the
18
+ * `:where()` element rules). Values stay symbolic, so a rhythm change
19
+ * re-flows every frame without re-rendering.
20
+ * - `typesetScale` — the same ratios resolved to concrete numbers, for the
21
+ * native framework themes (MUI/antd/chakra), whose theme objects get
22
+ * serialized into codegen artifacts where no CSS variables exist.
23
+ */
24
+
25
+ import { isCssIdent, sanitizeCssTokenValue } from "./css-sanitize.ts";
26
+
27
+ /** The three rhythm controls plus font roles. Every field is optional; `TYPESET_DEFAULT` fills the gaps. */
28
+ export interface Typeset {
29
+ /** Base text size. `1em` follows the surrounding layout (the container-relative default). */
30
+ size?: string | number | undefined;
31
+ /** Body line-height, unitless. Heading leading derives from it. */
32
+ leading?: number | undefined;
33
+ /** Vertical space between blocks. Heading margins and rule spacing derive from it. */
34
+ flow?: string | number | undefined;
35
+ /** Font *role* name (a key of `typography.fontFamily`) for body copy. */
36
+ fontBody?: string | undefined;
37
+ /** Font role name for headings. */
38
+ fontHeading?: string | undefined;
39
+ /** Font role name for code. */
40
+ fontMono?: string | undefined;
41
+ }
42
+
43
+ /**
44
+ * Baseline rhythm — shadcn/typeset's defaults, which read well on long-form
45
+ * content.
46
+ *
47
+ * Spelled out rather than `Required<Pick<Typeset, …>>`: `Required` strips the
48
+ * `?` but not an explicit `| undefined`, which `Typeset`'s fields carry so
49
+ * they can hold zod's output. The defaults are concrete, so say so.
50
+ */
51
+ export const TYPESET_DEFAULT: { size: string | number; leading: number; flow: string | number } = {
52
+ size: "1em",
53
+ leading: 1.75,
54
+ flow: "1.25em",
55
+ };
56
+
57
+ /** The name of the typeset every folder has: the baseline projected onto `:root`. */
58
+ export const DEFAULT_TYPESET_NAME = "default";
59
+
60
+ /**
61
+ * Typeset names are emitted as `.typeset-<name>` class selectors, so they must be
62
+ * safe idents. `default` is reserved for the folder baseline (it lands on
63
+ * `:root` and on `.typeset`, not on a preset class).
64
+ */
65
+ export function isTypesetName(name: string): boolean {
66
+ return isCssIdent(name);
67
+ }
68
+
69
+ /**
70
+ * Roles in the derived scale. Fixed vocabulary — the *values* vary per typeset,
71
+ * the names never do, which is what lets the class literals live in a shared
72
+ * module and be safelisted rather than scanned.
73
+ *
74
+ * Names are chosen to stay clear of the theme's other Tailwind namespaces: a
75
+ * size token becomes a `text-<role>` utility, which would collide with the
76
+ * `text-<color>` utility that `--color-<name>` generates. Hence `caption` rather
77
+ * than `muted` — `muted` is a semantic color slot in every velloo theme.
78
+ */
79
+ export type TypesetRole =
80
+ | "h1"
81
+ | "h2"
82
+ | "h3"
83
+ | "h4"
84
+ | "h5"
85
+ | "h6"
86
+ | "body"
87
+ | "lead"
88
+ | "small"
89
+ | "caption";
90
+
91
+ export interface TypesetRatio {
92
+ /** Multiplier on the typeset's base size. */
93
+ size: number;
94
+ /** Multiplier on the typeset's leading — so a leading change moves the whole ladder proportionally. */
95
+ leading: number;
96
+ /** Letter-spacing, in `em` of the role's own size. Optical, so it does not scale with rhythm. */
97
+ tracking: string;
98
+ /** Font weight. Constant per role: weight is deliberately not one of the three controls. */
99
+ weight: 400 | 500 | 600 | 700;
100
+ /** Which font role paints this — headings take the heading face, copy takes the body face. */
101
+ face: "heading" | "body";
102
+ }
103
+
104
+ /**
105
+ * The ratio table. **The only place typographic proportion is written down.**
106
+ * `typesetVars`, `typesetCss`, and `typesetScale` all read it, which is what
107
+ * replaced four hand-synced class tables (helpers' Tailwind ladder, its codegen
108
+ * mirror, provider-none's inline ladder, and that ladder's codegen mirror).
109
+ */
110
+ export const TYPESET_RATIOS: Record<TypesetRole, TypesetRatio> = {
111
+ h1: { size: 2.5, leading: 0.63, tracking: "-0.025em", weight: 700, face: "heading" },
112
+ h2: { size: 2, leading: 0.66, tracking: "-0.025em", weight: 700, face: "heading" },
113
+ h3: { size: 1.5, leading: 0.71, tracking: "-0.02em", weight: 600, face: "heading" },
114
+ h4: { size: 1.25, leading: 0.74, tracking: "-0.02em", weight: 600, face: "heading" },
115
+ h5: { size: 1.125, leading: 0.8, tracking: "-0.015em", weight: 600, face: "heading" },
116
+ h6: { size: 1, leading: 0.8, tracking: "-0.01em", weight: 600, face: "heading" },
117
+ body: { size: 1, leading: 1, tracking: "0em", weight: 400, face: "body" },
118
+ lead: { size: 1.25, leading: 0.91, tracking: "0em", weight: 400, face: "body" },
119
+ small: { size: 0.875, leading: 0.57, tracking: "0em", weight: 500, face: "body" },
120
+ caption: { size: 0.875, leading: 0.86, tracking: "0em", weight: 400, face: "body" },
121
+ };
122
+
123
+ /** Every role name, in ladder order. Drives the `@theme` tokens, the Tailwind safelist, and the twMerge config. */
124
+ export const TYPESET_SCALE_NAMES: readonly TypesetRole[] = Object.keys(
125
+ TYPESET_RATIOS,
126
+ ) as TypesetRole[];
127
+
128
+ /** Heading level → scale role. */
129
+ export const HEADING_ROLE_BY_LEVEL: Record<number, TypesetRole> = {
130
+ 1: "h1",
131
+ 2: "h2",
132
+ 3: "h3",
133
+ 4: "h4",
134
+ 5: "h5",
135
+ 6: "h6",
136
+ };
137
+
138
+ /** The `Text` helper's visual variants. */
139
+ export type TextVariant = "default" | "muted" | "small" | "lead";
140
+
141
+ /** Text variant → scale role. */
142
+ export const TEXT_ROLE_BY_VARIANT: Record<TextVariant, TypesetRole> = {
143
+ default: "body",
144
+ muted: "caption",
145
+ small: "small",
146
+ lead: "lead",
147
+ };
148
+
149
+ /**
150
+ * The semantic color slot each text variant paints with, or absent to inherit.
151
+ * Colour is not part of the typeset — it lives here only so the four channels
152
+ * that lower `Text` (Tailwind runtime + codegen, inline runtime + codegen) read
153
+ * one table instead of four.
154
+ */
155
+ export const TEXT_TONE_BY_VARIANT: Partial<Record<TextVariant, string>> = {
156
+ default: "foreground",
157
+ muted: "muted-foreground",
158
+ lead: "muted-foreground",
159
+ };
160
+
161
+ /** Stock Tailwind weight utility per weight value. */
162
+ const WEIGHT_CLASS: Record<TypesetRatio["weight"], string> = {
163
+ 400: "font-normal",
164
+ 500: "font-medium",
165
+ 600: "font-semibold",
166
+ 700: "font-bold",
167
+ };
168
+
169
+ /**
170
+ * The Tailwind utilities each role resolves to. Fixed literal class names whose
171
+ * *values* come from the theme's generated `@theme` tokens — which is what lets
172
+ * these strings live in a plain `.ts` module: the JIT learns them from the
173
+ * generated `@source inline(...)` safelist rather than from scanning a `.tsx`.
174
+ *
175
+ * Weight uses the stock utility rather than a themed token: weight is constant
176
+ * per role, and a `--font-weight-<role>` token would generate a `font-<role>`
177
+ * utility that collides with the `font-<role>` a font *family* declares.
178
+ */
179
+ export const TYPESET_CLASSES: Record<TypesetRole, string> = Object.fromEntries(
180
+ TYPESET_SCALE_NAMES.map((role) => {
181
+ const ratio = TYPESET_RATIOS[role];
182
+ return [
183
+ role,
184
+ `text-${role} leading-${role} tracking-${role} ${WEIGHT_CLASS[ratio.weight]}`,
185
+ ] as const;
186
+ }),
187
+ ) as Record<TypesetRole, string>;
188
+
189
+ /**
190
+ * The inline `style` object each role resolves to, for the `none`-CSS channel.
191
+ * Sizes are `var()` references into the same derived tokens the utilities read,
192
+ * so a `none/none` folder gets themed type and re-rhythms live like every other
193
+ * channel; weight is a literal because it never varies with rhythm.
194
+ */
195
+ export const TYPESET_INLINE_STYLE: Record<
196
+ TypesetRole,
197
+ { fontSize: string; lineHeight: string; letterSpacing: string; fontWeight: number }
198
+ > = Object.fromEntries(
199
+ TYPESET_SCALE_NAMES.map((role) => [
200
+ role,
201
+ {
202
+ fontSize: `var(--text-${role})`,
203
+ lineHeight: `var(--leading-${role})`,
204
+ letterSpacing: `var(--tracking-${role})`,
205
+ fontWeight: TYPESET_RATIOS[role].weight,
206
+ },
207
+ ]),
208
+ ) as Record<
209
+ TypesetRole,
210
+ { fontSize: string; lineHeight: string; letterSpacing: string; fontWeight: number }
211
+ >;
212
+
213
+ /** Clamp an authored heading level to 1–6, so the tag and the ladder rung always agree. */
214
+ export function resolveHeadingLevel(level: unknown): number {
215
+ const n = Number(level ?? 1);
216
+ return Number.isFinite(n) && n >= 1 && n <= 6 ? Math.trunc(n) : 1;
217
+ }
218
+
219
+ function textVariant(variant: unknown): TextVariant {
220
+ const v = String(variant ?? "default");
221
+ return v in TEXT_ROLE_BY_VARIANT ? (v as TextVariant) : "default";
222
+ }
223
+
224
+ /**
225
+ * The Tailwind classes a `Heading` of this level renders with. THE definition —
226
+ * the runtime component and the codegen lowering both call this, which is what
227
+ * retired the hand-synced `lowering.ts` mirror.
228
+ */
229
+ export function headingClasses(level: unknown): string {
230
+ const role = HEADING_ROLE_BY_LEVEL[resolveHeadingLevel(level)] as TypesetRole;
231
+ return TYPESET_CLASSES[role];
232
+ }
233
+
234
+ /** The Tailwind classes a `Text` of this variant renders with, tone included. */
235
+ export function textClasses(variant: unknown): string {
236
+ const v = textVariant(variant);
237
+ const tone = TEXT_TONE_BY_VARIANT[v];
238
+ return tone
239
+ ? `${TYPESET_CLASSES[TEXT_ROLE_BY_VARIANT[v]]} text-${tone}`
240
+ : TYPESET_CLASSES[TEXT_ROLE_BY_VARIANT[v]];
241
+ }
242
+
243
+ /** The inline `style` a `Heading` of this level renders with, on the `none`-CSS channel. */
244
+ export function headingInlineStyle(level: unknown): Record<string, string | number> {
245
+ const role = HEADING_ROLE_BY_LEVEL[resolveHeadingLevel(level)] as TypesetRole;
246
+ return { ...TYPESET_INLINE_STYLE[role] };
247
+ }
248
+
249
+ /** The inline `style` a `Text` of this variant renders with, on the `none`-CSS channel. */
250
+ export function textInlineStyle(variant: unknown): Record<string, string | number> {
251
+ const v = textVariant(variant);
252
+ const tone = TEXT_TONE_BY_VARIANT[v];
253
+ return {
254
+ ...TYPESET_INLINE_STYLE[TEXT_ROLE_BY_VARIANT[v]],
255
+ ...(tone ? { color: `var(--color-${tone})` } : {}),
256
+ };
257
+ }
258
+
259
+ /**
260
+ * `@source inline(...)` arguments covering every utility the scale can produce.
261
+ * Emitted with the generated `@theme` block so the utilities compile whether or
262
+ * not a class literal happens to be scanned — the mechanism that let the ladder
263
+ * move out of `heading.tsx` into a shared module.
264
+ */
265
+ export function typesetSafelist(): string[] {
266
+ const roles = TYPESET_SCALE_NAMES.join(",");
267
+ const weights = Object.values(WEIGHT_CLASS)
268
+ .map((c) => c.replace(/^font-/, ""))
269
+ .join(",");
270
+ // The tone classes ride along: `textClasses` composes them from the same
271
+ // unscanned module, so they need the same treatment as the scale itself.
272
+ const tones = [...new Set(Object.values(TEXT_TONE_BY_VARIANT))].join(",");
273
+ return [`{text,leading,tracking}-{${roles}}`, `font-{${weights}}`, `text-{${tones}}`];
274
+ }
275
+
276
+ /**
277
+ * Every utility class the scale can produce, deduped. Tailwind v3 has no
278
+ * `@source inline(...)`, so its emitted preset safelists this list instead.
279
+ */
280
+ export function typesetUtilityClasses(): string[] {
281
+ const seen = new Set<string>();
282
+ for (const classes of Object.values(TYPESET_CLASSES)) {
283
+ for (const cls of classes.split(" ")) seen.add(cls);
284
+ }
285
+ for (const tone of Object.values(TEXT_TONE_BY_VARIANT)) seen.add(`text-${tone}`);
286
+ return [...seen];
287
+ }
288
+
289
+ /**
290
+ * The scale as a Tailwind v3 `theme.extend.fontSize` map. Each entry carries its
291
+ * paired leading and tracking, and every value is a `var()` into the typeset
292
+ * sheet — so a v3 app re-derives inside a `.typeset-<preset>` region exactly
293
+ * like a v4 one.
294
+ */
295
+ export function typesetV3FontSize(): Record<
296
+ string,
297
+ [string, { lineHeight: string; letterSpacing: string }]
298
+ > {
299
+ return Object.fromEntries(
300
+ TYPESET_SCALE_NAMES.map((role) => [
301
+ role,
302
+ [
303
+ `var(--text-${role})`,
304
+ { lineHeight: `var(--leading-${role})`, letterSpacing: `var(--tracking-${role})` },
305
+ ],
306
+ ]),
307
+ );
308
+ }
309
+
310
+ /**
311
+ * Placeholder `@theme` tokens so `text-<role>` / `leading-<role>` /
312
+ * `tracking-<role>` exist as utilities. Values here are irrelevant — the JIT
313
+ * only needs the token to be declared, and `typesetCss` overrides every one of
314
+ * them at render time (same contract as the `--font-<role>` tokens).
315
+ */
316
+ export function typesetThemeTokens(): string[] {
317
+ const lines: string[] = [];
318
+ for (const role of TYPESET_SCALE_NAMES) {
319
+ const ratio = TYPESET_RATIOS[role];
320
+ lines.push(` --text-${role}: ${ratio.size}rem;`);
321
+ lines.push(` --leading-${role}: ${round(ratio.leading * TYPESET_DEFAULT.leading)};`);
322
+ lines.push(` --tracking-${role}: ${ratio.tracking};`);
323
+ }
324
+ return lines;
325
+ }
326
+
327
+ function round(n: number): number {
328
+ return Math.round(n * 1000) / 1000;
329
+ }
330
+
331
+ /** A length authored as a bare number means px, matching the rest of the theme's token handling. */
332
+ function len(value: string | number): string {
333
+ return typeof value === "number" ? `${value}px` : sanitizeCssTokenValue(value);
334
+ }
335
+
336
+ /** Resolve a font *role* name to the `var(--font-<role>)` reference, or a fallback when unset/unsafe. */
337
+ function faceVar(role: string | undefined, fallback: string): string {
338
+ if (!role || !isCssIdent(role)) return fallback;
339
+ return `var(--font-${role})`;
340
+ }
341
+
342
+ /** Fill a partial typeset from the baseline. */
343
+ export function resolveTypeset(typeset: Typeset | undefined): Typeset & {
344
+ size: string | number;
345
+ leading: number;
346
+ flow: string | number;
347
+ } {
348
+ return {
349
+ ...typeset,
350
+ size: typeset?.size ?? TYPESET_DEFAULT.size,
351
+ leading: typeset?.leading ?? TYPESET_DEFAULT.leading,
352
+ flow: typeset?.flow ?? TYPESET_DEFAULT.flow,
353
+ };
354
+ }
355
+
356
+ /**
357
+ * The authored rhythm controls, as CSS declarations. Presets override exactly
358
+ * these — and *only* these.
359
+ *
360
+ * `partial` is the difference between the baseline and a preset. The baseline
361
+ * (`:root` / `.typeset`) fills every control from `TYPESET_DEFAULT` so the vars
362
+ * always resolve. A preset emits only what it authored, because it inherits the
363
+ * rest: a `.typeset-compact` that restated `--typeset-font-heading` would reset
364
+ * the folder's display face to the body font just by changing the size.
365
+ */
366
+ export function typesetBaseVars(
367
+ typeset: Typeset | undefined,
368
+ indent = " ",
369
+ partial = false,
370
+ ): string[] {
371
+ const t = partial ? (typeset ?? {}) : resolveTypeset(typeset);
372
+ const lines: string[] = [];
373
+ const push = (name: string, value: string) => lines.push(`${indent}--typeset-${name}: ${value};`);
374
+ if (t.size !== undefined) push("size", len(t.size));
375
+ if (t.leading !== undefined) push("leading", String(round(t.leading)));
376
+ if (t.flow !== undefined) push("flow", len(t.flow));
377
+ if (!partial || t.fontBody !== undefined) {
378
+ push("font-body", faceVar(t.fontBody, "inherit"));
379
+ }
380
+ if (!partial || t.fontHeading !== undefined) {
381
+ push("font-heading", faceVar(t.fontHeading, "var(--typeset-font-body)"));
382
+ }
383
+ if (!partial || t.fontMono !== undefined) {
384
+ push("font-mono", faceVar(t.fontMono, "var(--font-mono, ui-monospace, monospace)"));
385
+ }
386
+ return lines;
387
+ }
388
+
389
+ /**
390
+ * The derived scale, as CSS declarations referencing the authored controls.
391
+ *
392
+ * These MUST be emitted in the same rule as the authored controls. Custom
393
+ * property substitution resolves `var()` against the referencing element's own
394
+ * cascaded value, so co-locating them means a preset that overrides
395
+ * `--typeset-size` on the element re-derives the whole ladder for that subtree —
396
+ * and the `text-h1` utility and the `:where(.typeset h1)` rule then compute the
397
+ * *same* value, with no specificity fight to arbitrate.
398
+ */
399
+ export function typesetVars(indent = " "): string[] {
400
+ const lines: string[] = [`${indent}--typeset-rhythm: var(--typeset-size);`];
401
+ for (const role of TYPESET_SCALE_NAMES) {
402
+ const ratio = TYPESET_RATIOS[role];
403
+ const size =
404
+ ratio.size === 1 ? "var(--typeset-rhythm)" : `calc(var(--typeset-rhythm) * ${ratio.size})`;
405
+ const leading =
406
+ ratio.leading === 1
407
+ ? "var(--typeset-leading)"
408
+ : `calc(var(--typeset-leading) * ${ratio.leading})`;
409
+ lines.push(`${indent}--text-${role}: ${size};`);
410
+ lines.push(`${indent}--leading-${role}: ${leading};`);
411
+ lines.push(`${indent}--tracking-${role}: ${ratio.tracking};`);
412
+ }
413
+ return lines;
414
+ }
415
+
416
+ /** Width under which the base size gets a small bump, so phone-width frames read comfortably. */
417
+ const NARROW_BREAKPOINT = "48rem";
418
+ const NARROW_BUMP = 1.125;
419
+
420
+ /**
421
+ * The narrow-width bump rides on the *derived* `--typeset-rhythm` rather than on
422
+ * the authored `--typeset-size`, so it applies to every preset automatically
423
+ * without each one restating it.
424
+ */
425
+ function rhythmBump(selector: string): string[] {
426
+ return [
427
+ `@media (width < ${NARROW_BREAKPOINT}) {`,
428
+ ` ${selector} {`,
429
+ ` --typeset-rhythm: calc(var(--typeset-size) * ${NARROW_BUMP});`,
430
+ " }",
431
+ "}",
432
+ ];
433
+ }
434
+
435
+ /**
436
+ * Element rules for a typeset region. Every selector is fully `:where()`-wrapped
437
+ * so the whole block has zero specificity and any authored utility or inline
438
+ * style wins. Spacing is `margin-block-start` only, with no `:last-child` /
439
+ * `:has()` / `:empty` — matching upstream's streaming guarantee that appending a
440
+ * block never restyles the ones before it.
441
+ */
442
+ function typesetElementRules(): string[] {
443
+ const flow = "var(--typeset-flow)";
444
+ const rules: string[] = [
445
+ ":where(.typeset) {",
446
+ " font-family: var(--typeset-font-body);",
447
+ " font-size: var(--typeset-rhythm);",
448
+ " line-height: var(--typeset-leading);",
449
+ "}",
450
+ ];
451
+
452
+ for (const level of [1, 2, 3, 4, 5, 6]) {
453
+ const role = HEADING_ROLE_BY_LEVEL[level] as TypesetRole;
454
+ // Bigger headings earn more air above them; the multiplier rides on flow so
455
+ // the whole vertical rhythm still moves with one control.
456
+ const space = round(1.9 - (level - 1) * 0.15);
457
+ rules.push(
458
+ `:where(.typeset h${level}) {`,
459
+ " font-family: var(--typeset-font-heading);",
460
+ ` font-size: var(--text-${role});`,
461
+ ` line-height: var(--leading-${role});`,
462
+ ` letter-spacing: var(--tracking-${role});`,
463
+ ` font-weight: ${TYPESET_RATIOS[role].weight};`,
464
+ ` margin-block-start: calc(${flow} * ${space});`,
465
+ " margin-block-end: 0;",
466
+ " text-wrap: balance;",
467
+ "}",
468
+ );
469
+ }
470
+
471
+ rules.push(
472
+ // One selector per line: the flow list is long enough that a single line
473
+ // runs off the side of an editor, and this sheet is meant to be read.
474
+ ":where(",
475
+ " .typeset p,",
476
+ " .typeset ul,",
477
+ " .typeset ol,",
478
+ " .typeset blockquote,",
479
+ " .typeset pre,",
480
+ " .typeset table,",
481
+ " .typeset figure",
482
+ ") {",
483
+ ` margin-block-start: ${flow};`,
484
+ " margin-block-end: 0;",
485
+ "}",
486
+ ":where(.typeset > :first-child) {",
487
+ " margin-block-start: 0;",
488
+ "}",
489
+ ":where(.typeset ul, .typeset ol) {",
490
+ ` padding-inline-start: calc(${flow} * 1.25);`,
491
+ "}",
492
+ ":where(.typeset ul) { list-style: disc; }",
493
+ ":where(.typeset ol) { list-style: decimal; }",
494
+ ":where(.typeset li) {",
495
+ ` margin-block-start: calc(${flow} * 0.4);`,
496
+ "}",
497
+ ":where(.typeset li)::marker {",
498
+ " color: var(--color-muted-foreground, currentColor);",
499
+ "}",
500
+ ":where(.typeset blockquote) {",
501
+ ` padding-inline-start: ${flow};`,
502
+ " border-inline-start: 2px solid var(--color-border, currentColor);",
503
+ " color: var(--color-muted-foreground, inherit);",
504
+ " font-style: italic;",
505
+ "}",
506
+ ":where(.typeset code) {",
507
+ " font-family: var(--typeset-font-mono);",
508
+ " font-size: 0.875em;",
509
+ " border-radius: calc(var(--radius, 0.5rem) * 0.5);",
510
+ " background: var(--color-muted, transparent);",
511
+ " padding: 0.15em 0.35em;",
512
+ "}",
513
+ ":where(.typeset pre) {",
514
+ " font-family: var(--typeset-font-mono);",
515
+ ` padding: ${flow};`,
516
+ " border-radius: var(--radius, 0.5rem);",
517
+ " background: var(--color-muted, transparent);",
518
+ " overflow-x: auto;",
519
+ "}",
520
+ ":where(.typeset pre code) {",
521
+ " background: none;",
522
+ " padding: 0;",
523
+ " font-size: 0.875em;",
524
+ "}",
525
+ ":where(.typeset a) {",
526
+ " color: inherit;",
527
+ " text-decoration: underline;",
528
+ " text-underline-offset: 0.2em;",
529
+ "}",
530
+ ":where(.typeset strong) { font-weight: 600; }",
531
+ ":where(.typeset small) {",
532
+ " font-size: var(--text-small);",
533
+ " line-height: var(--leading-small);",
534
+ "}",
535
+ ":where(.typeset hr) {",
536
+ ` margin-block-start: calc(${flow} * 1.6);`,
537
+ " margin-block-end: 0;",
538
+ " border: 0;",
539
+ " border-block-start: 1px solid var(--color-border, currentColor);",
540
+ "}",
541
+ ":where(.typeset table) {",
542
+ " width: 100%;",
543
+ " border-collapse: collapse;",
544
+ " font-size: var(--text-caption);",
545
+ " line-height: var(--leading-caption);",
546
+ "}",
547
+ ":where(.typeset th, .typeset td) {",
548
+ ` padding: calc(${flow} * 0.4) calc(${flow} * 0.6);`,
549
+ " border-block-end: 1px solid var(--color-border, currentColor);",
550
+ " text-align: start;",
551
+ "}",
552
+ ":where(.typeset th) { font-weight: 600; }",
553
+ ":where(.typeset img, .typeset video) {",
554
+ " max-width: 100%;",
555
+ " height: auto;",
556
+ " border-radius: var(--radius, 0.5rem);",
557
+ "}",
558
+ );
559
+
560
+ return rules;
561
+ }
562
+
563
+ export interface TypesetCssOptions {
564
+ /**
565
+ * Emit the `:root` baseline (the folder default projected globally, so
566
+ * `text-h1` works outside a typeset region). The `.typeset` region rules are
567
+ * always emitted. Defaults to true.
568
+ */
569
+ root?: boolean;
570
+ }
571
+
572
+ /**
573
+ * The complete typeset stylesheet for a folder's typesets. One generator, used
574
+ * verbatim by the renderer (canvas + screenshots) and by `emit_theme` (the
575
+ * `typeset.css` artifact) so the design and the generated app cannot diverge.
576
+ */
577
+ export function typesetCss(
578
+ typesets: Record<string, Typeset> | undefined,
579
+ options: TypesetCssOptions = {},
580
+ ): string {
581
+ const all = typesets ?? {};
582
+ const base = all[DEFAULT_TYPESET_NAME];
583
+ const blocks: string[] = [];
584
+
585
+ if (options.root !== false) {
586
+ blocks.push([":root {", ...typesetBaseVars(base), ...typesetVars(), "}"].join("\n"));
587
+ blocks.push(rhythmBump(":root").join("\n"));
588
+ }
589
+
590
+ // The region baseline. Authored controls and the derived scale share this rule
591
+ // so a preset class on the same element re-derives the ladder.
592
+ blocks.push([".typeset {", ...typesetBaseVars(base), ...typesetVars(), "}"].join("\n"));
593
+ blocks.push(rhythmBump(".typeset").join("\n"));
594
+
595
+ for (const [name, typeset] of Object.entries(all)) {
596
+ if (name === DEFAULT_TYPESET_NAME) continue;
597
+ if (!isTypesetName(name)) continue;
598
+ // Presets override the authored controls only — the derived scale declared
599
+ // on `.typeset` re-substitutes against these on the same element. And only
600
+ // the controls the preset actually authored, so tuning one does not silently
601
+ // reset the others to the baseline.
602
+ const declarations = typesetBaseVars(typeset, " ", true);
603
+ if (declarations.length === 0) continue;
604
+ blocks.push([`.typeset-${name} {`, ...declarations, "}"].join("\n"));
605
+ }
606
+
607
+ blocks.push(typesetElementRules().join("\n"));
608
+
609
+ return blocks.join("\n\n");
610
+ }
611
+
612
+ /** A role's proportions resolved to concrete values, for native framework themes. */
613
+ export interface ResolvedTypesetRole {
614
+ /** Font size in px. */
615
+ fontSize: number;
616
+ /** Unitless line-height. */
617
+ lineHeight: number;
618
+ /** Letter-spacing, as an `em` string. */
619
+ letterSpacing: string;
620
+ fontWeight: number;
621
+ /** The resolved font stack for this role's face, when the typeset names one. */
622
+ fontFamily?: string;
623
+ }
624
+
625
+ export type TypesetScale = Record<TypesetRole, ResolvedTypesetRole>;
626
+
627
+ export interface TypesetScaleOptions {
628
+ /**
629
+ * Pixel value for a relative base size. The default `1em` is
630
+ * container-relative, which a serialized framework theme cannot express, so
631
+ * native adapters resolve it against the document root.
632
+ */
633
+ rootPx?: number;
634
+ /** `typography.fontFamily`, for resolving `fontBody` / `fontHeading` roles to real stacks. */
635
+ fontFamily?: Record<string, string>;
636
+ }
637
+
638
+ const DEFAULT_ROOT_PX = 16;
639
+
640
+ /**
641
+ * Resolve an authored length to px. Relative units scale `rootPx` by their
642
+ * factor. Exported because every surface that turns an authored control back
643
+ * into a number — the native scale below, the canvas's rhythm sliders — has to
644
+ * agree on what `"1em"` and a bare `15` mean.
645
+ */
646
+ export function typesetSizePx(size: string | number, rootPx = DEFAULT_ROOT_PX): number {
647
+ if (typeof size === "number") return size;
648
+ const match = /^(-?[\d.]+)\s*(px|rem|em|%)?$/.exec(size.trim());
649
+ if (!match) return rootPx;
650
+ const n = Number.parseFloat(match[1] as string);
651
+ if (!Number.isFinite(n)) return rootPx;
652
+ switch (match[2]) {
653
+ case "px":
654
+ case undefined:
655
+ return n;
656
+ case "%":
657
+ return (n / 100) * rootPx;
658
+ default:
659
+ return n * rootPx;
660
+ }
661
+ }
662
+
663
+ /**
664
+ * The same ratios as `typesetVars`, resolved to concrete values. Native adapters
665
+ * (MUI/antd/chakra) project this onto their own typography scales, and the very
666
+ * same call feeds both the canvas mount and the emitted theme artifact — so a
667
+ * preview and generated code cannot disagree.
668
+ */
669
+ export function typesetScale(
670
+ typeset: Typeset | undefined,
671
+ options: TypesetScaleOptions = {},
672
+ ): TypesetScale {
673
+ const t = resolveTypeset(typeset);
674
+ const rootPx = options.rootPx ?? DEFAULT_ROOT_PX;
675
+ const basePx = typesetSizePx(t.size, rootPx);
676
+ const families = options.fontFamily ?? {};
677
+ const headingStack = t.fontHeading ? families[t.fontHeading] : undefined;
678
+ const bodyStack = t.fontBody ? families[t.fontBody] : undefined;
679
+
680
+ const out = {} as TypesetScale;
681
+ for (const role of TYPESET_SCALE_NAMES) {
682
+ const ratio = TYPESET_RATIOS[role];
683
+ const stack = ratio.face === "heading" ? (headingStack ?? bodyStack) : bodyStack;
684
+ out[role] = {
685
+ fontSize: round(basePx * ratio.size),
686
+ lineHeight: round(t.leading * ratio.leading),
687
+ letterSpacing: ratio.tracking,
688
+ fontWeight: ratio.weight,
689
+ ...(stack ? { fontFamily: stack } : {}),
690
+ };
691
+ }
692
+ return out;
693
+ }