@ideare/kit 0.0.0-stage → 0.1.0-next.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 (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +148 -2
  3. package/THIRD_PARTY.md +52 -0
  4. package/dist/brand/schema.d.ts +265 -0
  5. package/dist/brand/schema.js +271 -0
  6. package/dist/check/copy.d.ts +27 -0
  7. package/dist/check/copy.js +139 -0
  8. package/dist/check/index.d.ts +54 -0
  9. package/dist/check/index.js +492 -0
  10. package/dist/color/contrast.d.ts +41 -0
  11. package/dist/color/contrast.js +84 -0
  12. package/dist/color/palette.d.ts +27 -0
  13. package/dist/color/palette.js +114 -0
  14. package/dist/color/wcag.d.ts +22 -0
  15. package/dist/color/wcag.js +50 -0
  16. package/dist/draft/draft.d.ts +61 -0
  17. package/dist/draft/draft.js +383 -0
  18. package/dist/editor/history.d.ts +78 -0
  19. package/dist/editor/history.js +221 -0
  20. package/dist/editor/operations.d.ts +112 -0
  21. package/dist/editor/operations.js +621 -0
  22. package/dist/files/docs.d.ts +49 -0
  23. package/dist/files/docs.js +392 -0
  24. package/dist/files/generate.d.ts +47 -0
  25. package/dist/files/generate.js +254 -0
  26. package/dist/files/png.d.ts +40 -0
  27. package/dist/files/png.js +168 -0
  28. package/dist/files/raster.d.ts +14 -0
  29. package/dist/files/raster.js +0 -0
  30. package/dist/files/templates.d.ts +162 -0
  31. package/dist/files/templates.js +390 -0
  32. package/dist/files/tokens.d.ts +22 -0
  33. package/dist/files/tokens.js +204 -0
  34. package/dist/fonts/catalog.d.ts +3 -0
  35. package/dist/fonts/catalog.js +345 -0
  36. package/dist/fonts/ofl.d.ts +5 -0
  37. package/dist/fonts/ofl.js +5 -0
  38. package/dist/fonts/pick.d.ts +33 -0
  39. package/dist/fonts/pick.js +73 -0
  40. package/dist/imports/folder.d.ts +80 -0
  41. package/dist/imports/folder.js +124 -0
  42. package/dist/imports/unzip.d.ts +32 -0
  43. package/dist/imports/unzip.js +128 -0
  44. package/dist/index.d.ts +59 -0
  45. package/dist/index.js +34 -0
  46. package/dist/marks/draw.d.ts +48 -0
  47. package/dist/marks/draw.js +149 -0
  48. package/dist/marks/icons.data.d.ts +5 -0
  49. package/dist/marks/icons.data.js +600 -0
  50. package/dist/marks/svg.d.ts +46 -0
  51. package/dist/marks/svg.js +341 -0
  52. package/dist/node/index.d.ts +13 -0
  53. package/dist/node/index.js +41 -0
  54. package/dist/posts/pix.d.ts +19 -0
  55. package/dist/posts/pix.js +49 -0
  56. package/dist/posts/templates.d.ts +45 -0
  57. package/dist/posts/templates.js +868 -0
  58. package/dist/resvg/index.d.ts +2 -0
  59. package/dist/resvg/index.js +22 -0
  60. package/dist/scene/brand-art.d.ts +49 -0
  61. package/dist/scene/brand-art.js +154 -0
  62. package/dist/scene/frame.d.ts +89 -0
  63. package/dist/scene/frame.js +213 -0
  64. package/dist/scene/grain.d.ts +7 -0
  65. package/dist/scene/grain.js +7 -0
  66. package/dist/scene/qr.d.ts +10 -0
  67. package/dist/scene/qr.js +359 -0
  68. package/dist/scene/render.d.ts +76 -0
  69. package/dist/scene/render.js +557 -0
  70. package/dist/scene/schema.d.ts +271 -0
  71. package/dist/scene/schema.js +257 -0
  72. package/dist/sign.d.ts +47 -0
  73. package/dist/sign.js +88 -0
  74. package/dist/text/engine.d.ts +128 -0
  75. package/dist/text/engine.js +245 -0
  76. package/dist/text/fonts.d.ts +56 -0
  77. package/dist/text/fonts.js +65 -0
  78. package/dist/version.d.ts +2 -0
  79. package/dist/version.js +2 -0
  80. package/dist/wasm/index.d.ts +15 -0
  81. package/dist/wasm/index.js +46 -0
  82. package/package.json +78 -5
  83. package/schema/brand.v1.json +442 -0
@@ -0,0 +1,390 @@
1
+ import { copyFor } from "./docs.js";
2
+ /**
3
+ * The scenes behind every derived file. Each one is plain data, so the studio can show the same
4
+ * scene in the kit grid and the editor can open any of them as a starting design.
5
+ */
6
+ export const TRANSPARENT = { hex: "#000000", alpha: 0 };
7
+ const MM = 300 / 25.4; // px per mm at 300 dpi
8
+ function scene(w, h, theme, layers, background, extra = {}) {
9
+ return {
10
+ v: 1,
11
+ size: { w: Math.round(w), h: Math.round(h) },
12
+ theme,
13
+ pages: [{ id: "p", background, layers, ...extra }],
14
+ };
15
+ }
16
+ /** Two soft color fields, drawn as radial gradients (never blur: render.md rule 9). */
17
+ function blobs(w, h, first = "primary", second = "accent") {
18
+ const r1 = Math.max(w, h) * 0.55;
19
+ const r2 = Math.max(w, h) * 0.45;
20
+ const blob = (id, cx, cy, r, token, alpha) => ({
21
+ id,
22
+ type: "shape",
23
+ shape: "ellipse",
24
+ frame: { x: cx - r, y: cy - r, w: 2 * r, h: 2 * r },
25
+ fill: {
26
+ radial: {
27
+ cx: 0.5,
28
+ cy: 0.5,
29
+ r: 0.5,
30
+ stops: [
31
+ { at: 0, paint: { token, alpha } },
32
+ { at: 1, paint: { token, alpha: 0 } },
33
+ ],
34
+ },
35
+ },
36
+ });
37
+ return [
38
+ blob("glow-1", w * 0.92, h * 0.1, r1, first, 0.16),
39
+ blob("glow-2", w * 0.78, h * 1.02, r2, second, 0.18),
40
+ ];
41
+ }
42
+ /** The mark on the brand's tile, for favicons and app icons. */
43
+ export function tileScene(brand, spec) {
44
+ const { size, padding } = spec;
45
+ const tile = brand.mark.tile;
46
+ const fill = { token: tile.fill };
47
+ const inset = size * padding;
48
+ const logo = {
49
+ id: "mark",
50
+ type: "logo",
51
+ variant: spec.variant,
52
+ tone: "color",
53
+ frame: { x: inset, y: inset, w: size - 2 * inset, h: size - 2 * inset },
54
+ };
55
+ if (spec.opaque)
56
+ return scene(size, size, spec.theme ?? "light", [
57
+ {
58
+ id: "tile",
59
+ type: "shape",
60
+ shape: "rect",
61
+ frame: { x: 0, y: 0, w: size, h: size },
62
+ fill,
63
+ },
64
+ logo,
65
+ ], fill);
66
+ const shape = tile.shape === "circle"
67
+ ? {
68
+ id: "tile",
69
+ type: "shape",
70
+ shape: "ellipse",
71
+ frame: { x: 0, y: 0, w: size, h: size },
72
+ fill,
73
+ }
74
+ : {
75
+ id: "tile",
76
+ type: "shape",
77
+ shape: "rect",
78
+ radius: tile.shape === "rounded" ? tile.radius * size : 0,
79
+ frame: { x: 0, y: 0, w: size, h: size },
80
+ fill,
81
+ };
82
+ return scene(size, size, spec.theme ?? "light", [shape, logo], TRANSPARENT);
83
+ }
84
+ /** A profile picture: the mark inside the circle every network crops to. */
85
+ export function avatarScene(brand, size) {
86
+ // Inset 0.2 keeps the mark's box inside the inscribed circle (its diagonal is 0.85 of it).
87
+ return tileScene(brand, {
88
+ size,
89
+ padding: 0.2,
90
+ opaque: true,
91
+ variant: size < 64 ? "small" : "mark",
92
+ });
93
+ }
94
+ /** A logo version on its own: transparent, or on the brand color. */
95
+ export function logoScene(variant, tone, w, h, theme = "light") {
96
+ if (tone === "brand") {
97
+ const pad = Math.round(h * 0.2);
98
+ return scene(w + 2 * pad, h + 2 * pad, theme, [{ id: "logo", type: "logo", variant, tone: "color", frame: { x: pad, y: pad, w, h } }], { token: "primary" });
99
+ }
100
+ // A see-through ground still tells the logo what it sits on, so it picks its dark version.
101
+ return scene(w, h, theme, [{ id: "logo", type: "logo", variant, tone, frame: { x: 0, y: 0, w, h } }], { token: "ground", alpha: 0 });
102
+ }
103
+ /** The 1200 by 630 share card, in one language. */
104
+ export function ogScene(brand, locale, copy) {
105
+ const card = copy ?? copyFor(brand, locale).card;
106
+ const layers = [
107
+ ...blobs(1200, 630),
108
+ {
109
+ id: "logo",
110
+ type: "logo",
111
+ variant: "horizontal",
112
+ tone: "color",
113
+ align: "start",
114
+ frame: { x: 80, y: 72, w: 520, h: 72 },
115
+ },
116
+ {
117
+ id: "title",
118
+ type: "text",
119
+ slot: "title",
120
+ text: card.title,
121
+ frame: { x: 80, y: 196, w: 900, h: 250 },
122
+ style: {
123
+ font: { role: "display" },
124
+ size: 78,
125
+ lineHeight: 1.08,
126
+ valign: "bottom",
127
+ fill: { token: "ink" },
128
+ },
129
+ fit: { mode: "shrink", min: 44, maxLines: 3 },
130
+ },
131
+ ];
132
+ if (card.subtitle) {
133
+ layers.push({
134
+ id: "subtitle",
135
+ type: "text",
136
+ slot: "subtitle",
137
+ text: card.subtitle,
138
+ frame: { x: 80, y: 470, w: 960, h: 44 },
139
+ style: { font: { role: "text" }, size: 30, lineHeight: 1.25, fill: { token: "muted" } },
140
+ fit: { mode: "shrink", min: 22, maxLines: 1 },
141
+ });
142
+ }
143
+ if (brand.domain) {
144
+ layers.push({
145
+ id: "domain",
146
+ type: "text",
147
+ text: brand.domain,
148
+ frame: { x: 80, y: 548, w: 1040, h: 34 },
149
+ style: {
150
+ font: { role: "text", weight: 600 },
151
+ size: 24,
152
+ lineHeight: 1.2,
153
+ fill: { token: "primary" },
154
+ },
155
+ fit: { mode: "shrink", min: 16, maxLines: 1 },
156
+ });
157
+ }
158
+ // No grain on files: noise does not compress, and it took the card from 60 KB to 530 KB.
159
+ return { ...scene(1200, 630, "light", layers, { token: "ground" }), locale };
160
+ }
161
+ /** A cover or banner: the logo and the tagline centered inside the network's safe area. */
162
+ export function bannerScene(brand, locale, w, h, safe) {
163
+ const copy = copyFor(brand, locale);
164
+ const logoHeight = Math.min(safe.h * 0.3, safe.w * 0.12);
165
+ const tagSize = Math.max(14, Math.min(safe.h * 0.13, 64));
166
+ // Centered for a one-line tagline; a longer one takes a second line below it, which every
167
+ // network's safe area has room for (a 60-character tagline, the copy limit, needs it on the
168
+ // Google and Facebook covers).
169
+ const block = logoHeight + tagSize * 1.9;
170
+ const top = safe.y + (safe.h - block) / 2;
171
+ const layers = [
172
+ ...blobs(w, h),
173
+ {
174
+ id: "logo",
175
+ type: "logo",
176
+ variant: "horizontal",
177
+ tone: "color",
178
+ frame: { x: safe.x, y: top, w: safe.w, h: logoHeight },
179
+ },
180
+ {
181
+ id: "tagline",
182
+ type: "text",
183
+ slot: "tagline",
184
+ text: copy.tagline,
185
+ frame: { x: safe.x, y: top + logoHeight + tagSize * 0.6, w: safe.w, h: tagSize * 2.5 },
186
+ style: {
187
+ font: { role: "display" },
188
+ size: tagSize,
189
+ lineHeight: 1.2,
190
+ align: "center",
191
+ valign: "top",
192
+ fill: { token: "ink" },
193
+ },
194
+ fit: { mode: "shrink", min: Math.max(12, tagSize * 0.6), maxLines: 2 },
195
+ },
196
+ ];
197
+ return { ...scene(w, h, "light", layers, { token: "surface" }), locale };
198
+ }
199
+ /** A soft branded background for posts, stories and status, with the mark in a corner. */
200
+ export function backgroundScene(w, h, bottomSafe) {
201
+ const size = Math.round(w * 0.09);
202
+ const layers = [
203
+ ...blobs(w, h),
204
+ {
205
+ id: "mark",
206
+ type: "logo",
207
+ variant: "mark",
208
+ tone: "color",
209
+ frame: { x: w - size - w * 0.07, y: h - bottomSafe - size, w: size, h: size },
210
+ },
211
+ ];
212
+ return scene(w, h, "light", layers, { token: "surface" });
213
+ }
214
+ /** An Instagram highlight cover: one icon in the circle Instagram shows. */
215
+ export function highlightScene(icon) {
216
+ const size = 360;
217
+ return scene(1080, 1920, "light", [
218
+ {
219
+ id: "icon",
220
+ type: "icon",
221
+ icon,
222
+ stroke: { token: "onPrimary" },
223
+ strokeWidth: 1.6,
224
+ frame: { x: (1080 - size) / 2, y: (1920 - size) / 2, w: size, h: size },
225
+ },
226
+ ], { token: "primary" });
227
+ }
228
+ /** A square catalog image: the stacked logo and the tagline. */
229
+ export function catalogScene(brand, locale) {
230
+ const copy = copyFor(brand, locale);
231
+ return {
232
+ ...scene(1080, 1080, "light", [
233
+ ...blobs(1080, 1080),
234
+ {
235
+ id: "logo",
236
+ type: "logo",
237
+ variant: "stacked",
238
+ tone: "color",
239
+ frame: { x: 240, y: 300, w: 600, h: 340 },
240
+ },
241
+ {
242
+ id: "tagline",
243
+ type: "text",
244
+ slot: "tagline",
245
+ text: copy.tagline,
246
+ frame: { x: 120, y: 700, w: 840, h: 120 },
247
+ style: {
248
+ font: { role: "display" },
249
+ size: 54,
250
+ lineHeight: 1.15,
251
+ align: "center",
252
+ fill: { token: "ink" },
253
+ },
254
+ fit: { mode: "shrink", min: 32, maxLines: 2 },
255
+ },
256
+ ], { token: "surface" }),
257
+ locale,
258
+ };
259
+ }
260
+ /** The email logo: an opaque bar at 2x, so Gmail's dark mode cannot invert a transparent logo. */
261
+ export function emailLogoScene() {
262
+ return scene(600, 160, "light", [
263
+ {
264
+ id: "logo",
265
+ type: "logo",
266
+ variant: "horizontal",
267
+ tone: "color",
268
+ align: "start",
269
+ frame: { x: 24, y: 32, w: 552, h: 96 },
270
+ },
271
+ ], { token: "ground" });
272
+ }
273
+ export const CARD_SIZES = {
274
+ br: { w: 90, h: 50, label: "Brazil, 90 by 50 mm" },
275
+ us: { w: 88.9, h: 50.8, label: "US, 3.5 by 2 in" },
276
+ eu: { w: 85, h: 55, label: "Europe, 85 by 55 mm" },
277
+ };
278
+ export const BLEED_MM = 3;
279
+ /** The canvas of a business card at 300 dpi, bleed included, and its safe box. */
280
+ export function cardCanvas(size) {
281
+ const trim = CARD_SIZES[size];
282
+ const w = Math.round((trim.w + 2 * BLEED_MM) * MM);
283
+ const h = Math.round((trim.h + 2 * BLEED_MM) * MM);
284
+ const inset = 2 * BLEED_MM * MM; // bleed plus 3 mm of safe margin inside the trim
285
+ return { w, h, safe: { x: inset, y: inset, w: w - 2 * inset, h: h - 2 * inset } };
286
+ }
287
+ /** The front: the stacked logo on the brand color, to the bleed. */
288
+ export function cardFrontScene(size) {
289
+ const { w, h, safe } = cardCanvas(size);
290
+ const logoH = safe.h * 0.62;
291
+ return scene(w, h, "light", [
292
+ {
293
+ id: "logo",
294
+ type: "logo",
295
+ variant: "stacked",
296
+ tone: "color",
297
+ frame: { x: safe.x, y: safe.y + (safe.h - logoH) / 2, w: safe.w, h: logoH },
298
+ },
299
+ ], { token: "primary" });
300
+ }
301
+ /** The back: the name, the tagline and the public contacts. */
302
+ export function cardBackScene(brand, size, locale) {
303
+ const { w, h, safe } = cardCanvas(size);
304
+ const copy = copyFor(brand, locale);
305
+ const contacts = ["site", "email", "phone", "whatsapp", "instagram"]
306
+ .map((kind) => brand.links[kind])
307
+ .filter((link) => link?.public)
308
+ .map((link) => (link?.value ?? "").replace(/^https?:\/\//, "").replace(/\/$/, ""));
309
+ if (contacts.length === 0 && brand.domain)
310
+ contacts.push(brand.domain);
311
+ const layers = [
312
+ {
313
+ id: "mark",
314
+ type: "logo",
315
+ variant: "mark",
316
+ tone: "color",
317
+ frame: { x: safe.x, y: safe.y, w: safe.h * 0.28, h: safe.h * 0.28 },
318
+ },
319
+ {
320
+ id: "name",
321
+ type: "text",
322
+ text: brand.name,
323
+ frame: { x: safe.x, y: safe.y + safe.h * 0.34, w: safe.w, h: safe.h * 0.2 },
324
+ style: {
325
+ font: { role: "display" },
326
+ size: safe.h * 0.16,
327
+ lineHeight: 1.1,
328
+ fill: { token: "ink" },
329
+ },
330
+ fit: { mode: "shrink", min: 28, maxLines: 1 },
331
+ },
332
+ {
333
+ id: "tagline",
334
+ type: "text",
335
+ text: copy.tagline,
336
+ frame: { x: safe.x, y: safe.y + safe.h * 0.55, w: safe.w, h: safe.h * 0.12 },
337
+ style: {
338
+ font: { role: "text" },
339
+ size: safe.h * 0.085,
340
+ lineHeight: 1.2,
341
+ fill: { token: "muted" },
342
+ },
343
+ fit: { mode: "shrink", min: 22, maxLines: 1 },
344
+ },
345
+ {
346
+ id: "contacts",
347
+ type: "text",
348
+ text: contacts.slice(0, 3).join("\n"),
349
+ frame: { x: safe.x, y: safe.y + safe.h * 0.72, w: safe.w, h: safe.h * 0.28 },
350
+ style: {
351
+ font: { role: "text" },
352
+ size: safe.h * 0.07,
353
+ lineHeight: 1.3,
354
+ valign: "bottom",
355
+ fill: { token: "ink" },
356
+ },
357
+ fit: { mode: "shrink", min: 20, maxLines: 3 },
358
+ },
359
+ ];
360
+ return { ...scene(w, h, "light", layers, { token: "ground" }), locale };
361
+ }
362
+ /** Safe areas per network, in px of each canvas (research: brandgen, section 5). */
363
+ export const BANNERS = {
364
+ "x/header-1500x500.png": { w: 1500, h: 500, safe: { x: 420, y: 70, w: 1020, h: 360 } },
365
+ "facebook/cover-851x315.png": { w: 851, h: 315, safe: { x: 146, y: 40, w: 560, h: 235 } },
366
+ "linkedin/cover-1512x256.png": { w: 1512, h: 256, safe: { x: 360, y: 40, w: 1092, h: 176 } },
367
+ "linkedin/personal-1584x396.png": { w: 1584, h: 396, safe: { x: 420, y: 60, w: 1104, h: 276 } },
368
+ "youtube/banner-2560x1440.png": { w: 2560, h: 1440, safe: { x: 507, y: 509, w: 1546, h: 423 } },
369
+ "google/cover-1024x576.png": { w: 1024, h: 576, safe: { x: 102, y: 58, w: 820, h: 460 } },
370
+ };
371
+ export const AVATARS = {
372
+ "avatar-1080.png": 1080,
373
+ "instagram/avatar-320.png": 320,
374
+ "whatsapp/avatar-640.png": 640,
375
+ "google/logo-720.png": 720,
376
+ "x/avatar-400.png": 400,
377
+ "facebook/profile-320.png": 320,
378
+ "linkedin/logo-400.png": 400,
379
+ "youtube/avatar-800.png": 800,
380
+ "tiktok/avatar-200.png": 200,
381
+ };
382
+ /** Instagram highlight covers: file name, icon. */
383
+ export const HIGHLIGHTS = {
384
+ about: "info",
385
+ hours: "clock",
386
+ location: "map-pin",
387
+ offers: "tag",
388
+ contact: "message-circle",
389
+ reviews: "star",
390
+ };
@@ -0,0 +1,22 @@
1
+ import type { Brand } from "../brand/schema.ts";
2
+ /** A value as a quoted CSS string. The schema already keeps quotes and braces out of a family
3
+ * name; this is the second lock, in the writers themselves. */
4
+ export declare function cssString(value: string, quote?: '"' | "'"): string;
5
+ /** A font stack with a fallback that matches the family's shape. */
6
+ export declare function fontStack(brand: Brand, role: "display" | "text"): string;
7
+ /**
8
+ * tokens.css. `dark`: "auto" adds the dark block for `data-theme="dark"` and mirrors it for the
9
+ * system setting when no theme is chosen; "attribute" adds only the first; "none" ships light only
10
+ * (ideare's own UI, plan D19).
11
+ */
12
+ export declare function tokensCss(brand: Brand, options?: {
13
+ dark?: "auto" | "attribute" | "none";
14
+ }): string;
15
+ /** tailwind.css: `@import` it after `tailwindcss`. Dark rebinds the same names under `.dark`. */
16
+ export declare function tailwindCss(brand: Brand): string;
17
+ /** brand.tokens.json in the DTCG 2025.10 format (application/design-tokens+json). */
18
+ export declare function dtcgTokens(brand: Brand): Record<string, unknown>;
19
+ /** One shadcn `registry:theme` item, so `npx shadcn add <url>` dresses an app in the brand. */
20
+ export declare function registryTheme(brand: Brand): Record<string, unknown>;
21
+ /** fonts.css for the font files `generate()` writes next to it (`fonts/<id>-<weight>.woff2`). */
22
+ export declare function fontsCss(brand: Brand): string;
@@ -0,0 +1,204 @@
1
+ import { COLOR_ROLES } from "../brand/schema.js";
2
+ import { onColor } from "../color/contrast.js";
3
+ import { over, parseHex } from "../color/wcag.js";
4
+ import { catalogFont } from "../fonts/pick.js";
5
+ import { slugOf } from "./docs.js";
6
+ /**
7
+ * The brand as code: plain CSS variables (enarro's format), a Tailwind 4 theme with
8
+ * @gusnips/tokens' role names, DTCG 2025.10 tokens and a shadcn registry theme. Every file says
9
+ * it is made, so nobody edits it by hand.
10
+ */
11
+ const MADE = "Made by @ideare/kit from brand.json. Do not edit: change the brand and make the files again.";
12
+ function kebab(role) {
13
+ return role.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
14
+ }
15
+ /** A value as a quoted CSS string. The schema already keeps quotes and braces out of a family
16
+ * name; this is the second lock, in the writers themselves. */
17
+ export function cssString(value, quote = '"') {
18
+ const body = value
19
+ .replaceAll("\\", "\\\\")
20
+ .replaceAll(quote, `\\${quote}`)
21
+ .replaceAll("\n", "\\a ")
22
+ .replaceAll("\r", "\\d ")
23
+ .replaceAll("\f", "\\c ");
24
+ return `${quote}${body}${quote}`;
25
+ }
26
+ /** A font stack with a fallback that matches the family's shape. */
27
+ export function fontStack(brand, role) {
28
+ const font = brand.fonts[role];
29
+ // The catalog knows the shape; for other families the name usually says it.
30
+ const category = catalogFont(font.id)?.category ??
31
+ (/\bserif\b/i.test(font.family) && !/\bsans\b/i.test(font.family) ? "serif" : undefined);
32
+ const fallback = category === "serif"
33
+ ? "ui-serif, Georgia, serif"
34
+ : category === "handwriting"
35
+ ? "cursive"
36
+ : "ui-sans-serif, system-ui, sans-serif";
37
+ return `${cssString(font.family)}, ${fallback}`;
38
+ }
39
+ function block(selector, lines, indent = "") {
40
+ return `${indent}${selector} {\n${lines.map((line) => `${indent} ${line}`).join("\n")}\n${indent}}`;
41
+ }
42
+ function colorLines(palette) {
43
+ return COLOR_ROLES.map((role) => `--${kebab(role)}: ${palette[role]};`);
44
+ }
45
+ /**
46
+ * tokens.css. `dark`: "auto" adds the dark block for `data-theme="dark"` and mirrors it for the
47
+ * system setting when no theme is chosen; "attribute" adds only the first; "none" ships light only
48
+ * (ideare's own UI, plan D19).
49
+ */
50
+ export function tokensCss(brand, options = {}) {
51
+ const dark = options.dark ?? "auto";
52
+ const root = [
53
+ ...colorLines(brand.colors.light),
54
+ ...Object.entries(brand.paints).map(([name, hex]) => `--paint-${name}: ${hex};`),
55
+ `--font-display: ${fontStack(brand, "display")};`,
56
+ `--font-text: ${fontStack(brand, "text")};`,
57
+ `--radius-control: ${brand.radii.control}px;`,
58
+ `--radius-card: ${brand.radii.card}px;`,
59
+ ];
60
+ const parts = [`/* ${MADE} */`, block(":root", root)];
61
+ if (dark !== "none") {
62
+ parts.push(`@media screen {\n${block(':root[data-theme="dark"]', colorLines(brand.colors.dark), " ")}\n}`);
63
+ }
64
+ if (dark === "auto") {
65
+ parts.push(`@media screen and (prefers-color-scheme: dark) {\n${block(":root:not([data-theme])", colorLines(brand.colors.dark), " ")}\n}`);
66
+ }
67
+ return `${parts.join("\n\n")}\n`;
68
+ }
69
+ /** @gusnips/tokens' 20 names (their `accent` is the neutral highlight), plus the brand's own. */
70
+ function shadcnColors(palette) {
71
+ return {
72
+ background: palette.ground,
73
+ foreground: palette.ink,
74
+ card: palette.surface,
75
+ "card-foreground": palette.ink,
76
+ popover: palette.ground,
77
+ "popover-foreground": palette.ink,
78
+ primary: palette.primary,
79
+ "primary-foreground": palette.onPrimary,
80
+ secondary: palette.surface,
81
+ "secondary-foreground": palette.ink,
82
+ muted: palette.surface,
83
+ "muted-foreground": palette.muted,
84
+ accent: palette.surface,
85
+ "accent-foreground": palette.ink,
86
+ destructive: palette.danger,
87
+ "destructive-foreground": onColor(palette.danger, palette.ink, "#ffffff").hex,
88
+ // A divider is softer than a field border; the field border keeps 3:1 (input).
89
+ border: over(palette.line, 0.4, palette.ground),
90
+ input: palette.line,
91
+ ring: palette.focus,
92
+ "brand-accent": palette.accent,
93
+ "brand-accent-foreground": palette.onAccent,
94
+ success: palette.success,
95
+ warning: palette.warning,
96
+ };
97
+ }
98
+ /** tailwind.css: `@import` it after `tailwindcss`. Dark rebinds the same names under `.dark`. */
99
+ export function tailwindCss(brand) {
100
+ const light = Object.entries(shadcnColors(brand.colors.light)).map(([name, value]) => `--color-${name}: ${value};`);
101
+ const dark = Object.entries(shadcnColors(brand.colors.dark)).map(([name, value]) => `--color-${name}: ${value};`);
102
+ const theme = [
103
+ ...light,
104
+ ...Object.entries(brand.paints).map(([name, hex]) => `--color-paint-${name}: ${hex};`),
105
+ `--font-display: ${fontStack(brand, "display")};`,
106
+ `--font-sans: ${fontStack(brand, "text")};`,
107
+ `--radius-control: ${brand.radii.control}px;`,
108
+ `--radius-card: ${brand.radii.card}px;`,
109
+ ];
110
+ return `/* ${MADE} */\n/* Never wrap this in @theme inline: it would stop .dark from rebinding the colors. */\n${block("@theme", theme)}\n\n${block(".dark", dark)}\n`;
111
+ }
112
+ function dtcgColor(hex) {
113
+ const { r, g, b } = parseHex(hex);
114
+ const unit = (value) => Math.round((value / 255) * 10000) / 10000;
115
+ return { colorSpace: "srgb", components: [unit(r), unit(g), unit(b)], hex };
116
+ }
117
+ /** brand.tokens.json in the DTCG 2025.10 format (application/design-tokens+json). */
118
+ export function dtcgTokens(brand) {
119
+ const theme = (palette) => Object.fromEntries(COLOR_ROLES.map((role) => [role, { $value: dtcgColor(palette[role]) }]));
120
+ return {
121
+ $description: `${brand.name}. ${MADE}`,
122
+ color: {
123
+ $type: "color",
124
+ light: theme(brand.colors.light),
125
+ dark: theme(brand.colors.dark),
126
+ paint: Object.fromEntries(Object.entries(brand.paints).map(([name, hex]) => [
127
+ name,
128
+ { $value: dtcgColor(hex) },
129
+ ])),
130
+ },
131
+ font: {
132
+ $type: "fontFamily",
133
+ display: {
134
+ $value: fontStack(brand, "display")
135
+ .split(", ")
136
+ .map((name) => name.replaceAll('"', "")),
137
+ },
138
+ text: {
139
+ $value: fontStack(brand, "text")
140
+ .split(", ")
141
+ .map((name) => name.replaceAll('"', "")),
142
+ },
143
+ },
144
+ radius: {
145
+ $type: "dimension",
146
+ control: { $value: { value: brand.radii.control, unit: "px" } },
147
+ card: { $value: { value: brand.radii.card, unit: "px" } },
148
+ },
149
+ };
150
+ }
151
+ /** One shadcn `registry:theme` item, so `npx shadcn add <url>` dresses an app in the brand. */
152
+ export function registryTheme(brand) {
153
+ const slug = slugOf(brand);
154
+ const vars = (palette) => {
155
+ const colors = shadcnColors(palette);
156
+ return {
157
+ ...Object.fromEntries(Object.entries(colors).filter(([name]) => !name.startsWith("brand-") && name !== "success" && name !== "warning")),
158
+ "chart-1": palette.primary,
159
+ "chart-2": palette.accent,
160
+ "chart-3": palette.success,
161
+ "chart-4": palette.warning,
162
+ "chart-5": palette.focus,
163
+ };
164
+ };
165
+ return {
166
+ $schema: "https://ui.shadcn.com/schema/registry-item.json",
167
+ name: `${slug}-theme`,
168
+ type: "registry:theme",
169
+ title: `${brand.name} theme`,
170
+ description: `The colors, fonts and radius of ${brand.name}, from ideare.`,
171
+ cssVars: {
172
+ theme: {
173
+ "font-sans": fontStack(brand, "text"),
174
+ "font-heading": fontStack(brand, "display"),
175
+ radius: `${brand.radii.control / 16}rem`,
176
+ },
177
+ light: vars(brand.colors.light),
178
+ dark: vars(brand.colors.dark),
179
+ },
180
+ };
181
+ }
182
+ /** fonts.css for the font files `generate()` writes next to it (`fonts/<id>-<weight>.woff2`). */
183
+ export function fontsCss(brand) {
184
+ const faces = [];
185
+ const seen = new Set();
186
+ for (const role of ["display", "text"]) {
187
+ const font = brand.fonts[role];
188
+ for (const weight of font.weights) {
189
+ const key = `${font.id}-${weight}`;
190
+ if (seen.has(key))
191
+ continue;
192
+ seen.add(key);
193
+ faces.push(block("@font-face", [
194
+ `font-family: ${cssString(font.family)};`,
195
+ "font-style: normal;",
196
+ `font-weight: ${weight};`,
197
+ "font-display: swap;",
198
+ `src: url("./fonts/${key}.woff2") format("woff2");`,
199
+ "unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;",
200
+ ]));
201
+ }
202
+ }
203
+ return `/* ${MADE} */\n/* The fonts are under the SIL Open Font License 1.1: keep OFL.txt next to them. */\n${faces.join("\n\n")}\n`;
204
+ }
@@ -0,0 +1,3 @@
1
+ import type { CatalogFont } from "./pick.ts";
2
+ /** 37 OFL families from Google Fonts, each with a latin subset for pt-BR and es. */
3
+ export declare const FONT_CATALOG: readonly CatalogFont[];