@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,492 @@
1
+ import { ensureContrast, oklabDistance } from "../color/contrast.js";
2
+ import { CONTRAST_PAIRS } from "../color/palette.js";
3
+ import { contrast, formatRatio } from "../color/wcag.js";
4
+ import { BANNERS, avatarScene, bannerScene, cardBackScene, catalogScene, ogScene, tileScene, } from "../files/templates.js";
5
+ import { parseMark } from "../marks/svg.js";
6
+ import { engineFor, renderScene } from "../scene/render.js";
7
+ import { fontFor } from "../scene/brand-art.js";
8
+ import { checkCopy } from "./copy.js";
9
+ const GROUND_WORDS = {
10
+ ground: "the page",
11
+ surface: "cards",
12
+ ink: "text",
13
+ muted: "quiet text",
14
+ line: "borders",
15
+ primary: "the brand color",
16
+ onPrimary: "the brand color's text",
17
+ accent: "the accent",
18
+ onAccent: "the accent's text",
19
+ focus: "the focus ring",
20
+ danger: "errors",
21
+ success: "success",
22
+ warning: "warnings",
23
+ };
24
+ function capitalize(text) {
25
+ return text.charAt(0).toUpperCase() + text.slice(1);
26
+ }
27
+ function contrastChecks(brand) {
28
+ const results = [];
29
+ for (const theme of ["light", "dark"]) {
30
+ const palette = brand.colors[theme];
31
+ for (const pair of CONTRAST_PAIRS) {
32
+ const fg = palette[pair.fg];
33
+ const bg = palette[pair.bg];
34
+ const ratio = contrast(fg, bg);
35
+ const id = `contrast.${theme}.${pair.fg}.${pair.bg}`;
36
+ const details = {
37
+ color: fg,
38
+ background: bg,
39
+ ratio: formatRatio(ratio),
40
+ needed: `${pair.min}:1`,
41
+ theme,
42
+ };
43
+ if (ratio >= pair.min) {
44
+ results.push({
45
+ id,
46
+ area: "colors",
47
+ level: "pass",
48
+ title: `${capitalize(pair.what)}: easy to ${pair.min === 3 ? "see" : "read"}`,
49
+ details,
50
+ });
51
+ continue;
52
+ }
53
+ const fix = ensureContrast(fg, bg, pair.min);
54
+ results.push({
55
+ id,
56
+ area: "colors",
57
+ level: "error",
58
+ // "Quiet text on cards" already names its ground; "Error text" gets "on the page".
59
+ title: `${capitalize(pair.what)}: hard to ${pair.min === 3 ? "see" : "read"}${pair.what.includes(" on ") ? "" : ` on ${GROUND_WORDS[pair.bg]}`}${theme === "dark" ? " in dark mode" : ""}. Fix it`,
60
+ message: `It needs ${pair.min}:1 and has ${formatRatio(ratio)}.`,
61
+ fix: fix.ok
62
+ ? {
63
+ kind: "set",
64
+ label: `Use ${fix.hex}`,
65
+ changes: [{ path: `colors.${theme}.${pair.fg}`, from: fg, to: fix.hex }],
66
+ }
67
+ : undefined,
68
+ details,
69
+ });
70
+ }
71
+ if (theme === "dark" && palette.primary === brand.colors.light.primary) {
72
+ results.push({
73
+ id: "contrast.retune",
74
+ area: "colors",
75
+ level: "warning",
76
+ title: "The brand color is the same in light and dark. Give dark mode its own",
77
+ message: "A color picked for white lands too close to the line on a dark page.",
78
+ details: { color: palette.primary },
79
+ });
80
+ }
81
+ }
82
+ return results;
83
+ }
84
+ function markChecks(brand) {
85
+ const results = [];
86
+ for (const [variant, svg] of [
87
+ ["svg", brand.mark.svg],
88
+ ["small", brand.mark.small],
89
+ ["dark", brand.mark.dark],
90
+ ["horizontal", brand.mark.lockups?.horizontal],
91
+ ["stacked", brand.mark.lockups?.stacked],
92
+ ["wordmark", brand.mark.lockups?.wordmark],
93
+ ]) {
94
+ if (!svg)
95
+ continue;
96
+ try {
97
+ const parsed = parseMark(svg);
98
+ if (parsed.unsupported.length) {
99
+ results.push({
100
+ id: `logo.parse.${variant}`,
101
+ area: "logo",
102
+ level: "error",
103
+ title: "Parts of the logo file can't be drawn. Simplify it",
104
+ message: `ideare draws shapes with solid colors. Leave out ${parsed.unsupported.join(", ")}.`,
105
+ details: { variant, unsupported: parsed.unsupported.join("; ") },
106
+ });
107
+ }
108
+ }
109
+ catch (error) {
110
+ results.push({
111
+ id: `logo.parse.${variant}`,
112
+ area: "logo",
113
+ level: "error",
114
+ title: "The logo file can't be read. Upload it again",
115
+ message: error instanceof Error ? error.message : String(error),
116
+ details: { variant },
117
+ });
118
+ }
119
+ }
120
+ if (results.some((result) => result.level === "error"))
121
+ return results;
122
+ // The mark on its tile (non-text, 3:1): the color that stands out most must reach it.
123
+ for (const theme of ["light", "dark"]) {
124
+ const tile = brand.colors[theme][brand.mark.tile.fill];
125
+ const mark = parseMark(theme === "dark" ? (brand.mark.dark ?? brand.mark.svg) : brand.mark.svg);
126
+ const colors = mark.colors.length ? mark.colors : [brand.colors[theme].ink];
127
+ const best = Math.max(...colors.map((color) => contrast(color, tile)));
128
+ const id = `logo.tile.${theme}`;
129
+ if (best >= 3) {
130
+ results.push({
131
+ id,
132
+ area: "logo",
133
+ level: "pass",
134
+ title: `The logo stands out on its icon${theme === "dark" ? " in dark mode" : ""}`,
135
+ details: { tile, ratio: formatRatio(best) },
136
+ });
137
+ }
138
+ else {
139
+ const options = ["ground", "surface", "primary", "ink"];
140
+ const better = options
141
+ .map((role) => ({
142
+ role,
143
+ ratio: Math.max(...colors.map((color) => contrast(color, brand.colors[theme][role]))),
144
+ }))
145
+ .sort((a, b) => b.ratio - a.ratio)[0];
146
+ results.push({
147
+ id,
148
+ area: "logo",
149
+ level: "error",
150
+ title: `The logo is hard to see on its icon background${theme === "dark" ? " in dark mode" : ""}. Fix it`,
151
+ message: `It needs 3:1 and has ${formatRatio(best)}.`,
152
+ fix: better && better.ratio >= 3
153
+ ? {
154
+ kind: "set",
155
+ label: `Put the logo on ${GROUND_WORDS[better.role]}`,
156
+ changes: [
157
+ {
158
+ path: "mark.tile.fill",
159
+ from: brand.mark.tile.fill,
160
+ to: better.role,
161
+ },
162
+ ],
163
+ }
164
+ : undefined,
165
+ details: { tile, ratio: formatRatio(best), needed: "3:1" },
166
+ });
167
+ }
168
+ }
169
+ // Thin lines vanish at 16 px: the thinnest stroke must stay at 1 px or more.
170
+ const small = parseMark(brand.mark.small ?? brand.mark.svg);
171
+ const drawn = 16 * (1 - Math.min(brand.mark.tile.padding, 0.12));
172
+ const thinnest = (small.thinnestStroke * drawn) / Math.max(small.viewBox.w, small.viewBox.h);
173
+ if (Number.isFinite(thinnest)) {
174
+ results.push(thinnest >= 1
175
+ ? {
176
+ id: "logo.legibility.16",
177
+ area: "logo",
178
+ level: "pass",
179
+ title: "The logo reads at 16 px",
180
+ details: { thinnestLinePx: Math.round(thinnest * 100) / 100 },
181
+ }
182
+ : {
183
+ id: "logo.legibility.16",
184
+ area: "logo",
185
+ level: "warning",
186
+ title: "Thin lines in the logo fade out at 16 px. Add a simpler small version",
187
+ message: "Browser tabs show the logo at 16 px.",
188
+ details: { thinnestLinePx: Math.round(thinnest * 100) / 100, needed: 1 },
189
+ });
190
+ }
191
+ // Colors in the mark that are not in either palette.
192
+ const palette = new Set([
193
+ ...Object.values(brand.colors.light),
194
+ ...Object.values(brand.colors.dark),
195
+ ]);
196
+ const near = (hex) => [...palette].some((color) => oklabDistance(hex, color) < 0.03);
197
+ const outside = parseMark(brand.mark.svg).colors.filter((color) => color !== "#ffffff" && color !== "#000000" && !near(color));
198
+ results.push(outside.length
199
+ ? {
200
+ id: "logo.offPalette",
201
+ area: "logo",
202
+ level: "warning",
203
+ title: "The logo uses colors that are not in the palette",
204
+ message: "That's fine for a many-color logo. Otherwise, use the brand's colors so the files match.",
205
+ details: { colors: outside.join(" ") },
206
+ }
207
+ : {
208
+ id: "logo.offPalette",
209
+ area: "logo",
210
+ level: "pass",
211
+ title: "The logo uses the brand's colors",
212
+ });
213
+ return results;
214
+ }
215
+ function copyChecks(brand) {
216
+ const results = [];
217
+ const names = [brand.name, brand.shortName, brand.wordmark.text];
218
+ for (const locale of brand.locales) {
219
+ const copy = brand.copy[locale];
220
+ if (!copy)
221
+ continue;
222
+ const fields = [
223
+ ["tagline", copy.tagline, "tagline"],
224
+ ["description", copy.description, "description"],
225
+ ["card.title", copy.card.title, "title"],
226
+ ["card.subtitle", copy.card.subtitle, "subtitle"],
227
+ ];
228
+ for (const [field, text, kind] of fields) {
229
+ if (!text)
230
+ continue;
231
+ const issues = checkCopy(text, {
232
+ locale,
233
+ field: kind,
234
+ neverSay: brand.voice.neverSay,
235
+ names,
236
+ });
237
+ const id = `copy.${locale}.${field}`;
238
+ const path = `copy.${locale}.${field}`;
239
+ if (issues.length === 0) {
240
+ results.push({
241
+ id,
242
+ area: "copy",
243
+ level: "pass",
244
+ title: "The words read well",
245
+ details: { locale, field },
246
+ });
247
+ continue;
248
+ }
249
+ const level = issues.some((issue) => issue.level === "error") ? "error" : "warning";
250
+ results.push({
251
+ id,
252
+ area: "copy",
253
+ level,
254
+ title: issues[0]?.message ?? "Rewrite this",
255
+ message: issues
256
+ .slice(1)
257
+ .map((issue) => issue.message)
258
+ .join(" ") || undefined,
259
+ fix: { kind: "rewrite", label: "Write it again", path },
260
+ details: {
261
+ locale,
262
+ field,
263
+ rules: issues.map((issue) => issue.rule).join(", "),
264
+ text,
265
+ },
266
+ });
267
+ }
268
+ }
269
+ return results;
270
+ }
271
+ const SLOT_PATHS = {
272
+ title: (locale) => `copy.${locale}.card.title`,
273
+ subtitle: (locale) => `copy.${locale}.card.subtitle`,
274
+ tagline: (locale) => `copy.${locale}.tagline`,
275
+ };
276
+ async function textChecks(brand, ctx) {
277
+ if (!ctx.fonts) {
278
+ return [
279
+ {
280
+ id: "text.skipped",
281
+ area: "text",
282
+ level: "warning",
283
+ title: "Text fit was not checked",
284
+ message: "Pass the fonts to check() to measure it.",
285
+ },
286
+ ];
287
+ }
288
+ const results = [];
289
+ const engine = await engineFor(ctx.fonts);
290
+ // Every character of every piece of copy must exist in the font that draws it.
291
+ for (const role of ["display", "text"]) {
292
+ const font = await engine.font(fontFor(brand, role));
293
+ const texts = [
294
+ brand.name,
295
+ ...brand.locales.flatMap((locale) => {
296
+ const copy = brand.copy[locale];
297
+ return copy
298
+ ? [copy.tagline, copy.description, copy.card.title, copy.card.subtitle ?? ""]
299
+ : [];
300
+ }),
301
+ ];
302
+ if (brand.wordmark.font === role)
303
+ texts.push(brand.wordmark.text);
304
+ const missing = [...new Set(texts.join("").split(""))].filter((char) => char.trim() && !font.coverage.has(char.codePointAt(0) ?? 0));
305
+ results.push(missing.length
306
+ ? {
307
+ id: `fonts.glyphs.${role}`,
308
+ area: "fonts",
309
+ level: "error",
310
+ title: `${brand.fonts[role].family} has no ${missing.join(" ")}. Pick another font or change the words`,
311
+ details: { font: brand.fonts[role].id, missing: missing.join(" ") },
312
+ }
313
+ : {
314
+ id: `fonts.glyphs.${role}`,
315
+ area: "fonts",
316
+ level: "pass",
317
+ title: `${brand.fonts[role].family} has every letter the brand writes`,
318
+ details: { locales: brand.locales.join(", ") },
319
+ });
320
+ }
321
+ // Text fit, on the files that carry the brand's words.
322
+ const scenes = [];
323
+ for (const locale of brand.locales) {
324
+ scenes.push({ name: `og.${locale}`, locale, scene: ogScene(brand, locale) });
325
+ scenes.push({ name: `catalog.${locale}`, locale, scene: catalogScene(brand, locale) });
326
+ for (const [path, banner] of Object.entries(BANNERS))
327
+ scenes.push({
328
+ name: `${path}.${locale}`,
329
+ locale,
330
+ scene: bannerScene(brand, locale, banner.w, banner.h, banner.safe),
331
+ });
332
+ scenes.push({ name: `card.${locale}`, locale, scene: cardBackScene(brand, "br", locale) });
333
+ for (const page of ctx.pages ?? []) {
334
+ const copy = page.copy[locale];
335
+ if (copy)
336
+ scenes.push({
337
+ name: `og.${page.slug}.${locale}`,
338
+ locale,
339
+ scene: ogScene(brand, locale, copy),
340
+ });
341
+ }
342
+ }
343
+ let fitErrors = 0;
344
+ for (const { name, locale, scene } of scenes) {
345
+ const result = await renderScene(scene, brand, { fonts: ctx.fonts });
346
+ for (const sceneCheck of result.checks) {
347
+ if (sceneCheck.rule !== "text.fit")
348
+ continue;
349
+ fitErrors++;
350
+ const layer = scene.pages[0]?.layers.find((item) => item.id === sceneCheck.layer);
351
+ const slot = layer?.type === "text" ? layer.slot : undefined;
352
+ const path = slot
353
+ ? SLOT_PATHS[slot]?.(locale)
354
+ : sceneCheck.layer === "name"
355
+ ? "name"
356
+ : undefined;
357
+ results.push({
358
+ id: `text.fit.${name}.${sceneCheck.layer ?? "text"}`,
359
+ area: "text",
360
+ level: "error",
361
+ title: `Too long for ${name.split(".")[0] === "og" ? "the share card" : name.split(".")[0]}: ${sceneCheck.message}`,
362
+ fix: path ? { kind: "rewrite", label: "Write it again", path } : undefined,
363
+ details: { file: name, ...sceneCheck.details },
364
+ });
365
+ }
366
+ }
367
+ if (fitErrors === 0)
368
+ results.push({
369
+ id: "text.fit",
370
+ area: "text",
371
+ level: "pass",
372
+ title: "Every text fits its space, in every language",
373
+ details: { files: scenes.length },
374
+ });
375
+ // Safe zones: the banners place everything inside each network's safe area.
376
+ for (const [path, banner] of Object.entries(BANNERS)) {
377
+ const result = await renderScene(bannerScene(brand, brand.defaultLocale, banner.w, banner.h, banner.safe), brand, { fonts: ctx.fonts });
378
+ const outside = Object.entries(result.boxes)
379
+ .filter(([id]) => !id.startsWith("glow"))
380
+ .filter(([, box]) => box.x < banner.safe.x - 0.5 ||
381
+ box.y < banner.safe.y - 0.5 ||
382
+ box.x + box.w > banner.safe.x + banner.safe.w + 0.5 ||
383
+ box.y + box.h > banner.safe.y + banner.safe.h + 0.5);
384
+ results.push({
385
+ id: `files.safe.${path}`,
386
+ area: "files",
387
+ level: outside.length ? "error" : "pass",
388
+ title: outside.length
389
+ ? `Part of ${path} could be cropped by the network`
390
+ : `${path} keeps everything where the network shows it`,
391
+ details: {
392
+ safe: `${banner.safe.w}x${banner.safe.h} at ${banner.safe.x},${banner.safe.y}`,
393
+ },
394
+ });
395
+ }
396
+ // The maskable icon's mark sits inside the 40% safe circle; the avatar's inside its circle.
397
+ const maskPadding = Math.max(brand.mark.tile.padding, 0.22);
398
+ const reach = (0.5 - maskPadding) * Math.SQRT2;
399
+ results.push({
400
+ id: "files.maskable",
401
+ area: "files",
402
+ level: reach <= 0.4 ? "pass" : "error",
403
+ title: reach <= 0.4
404
+ ? "The Android icon keeps the logo inside the safe circle"
405
+ : "Android could cut the logo's corners",
406
+ details: { cornerReach: Math.round(reach * 1000) / 1000, safeRadius: 0.4 },
407
+ });
408
+ const avatar = avatarScene(brand, 1080).pages[0]?.layers.find((layer) => layer.id === "mark");
409
+ if (avatar) {
410
+ const corner = Math.hypot(avatar.frame.w / 2, avatar.frame.h / 2);
411
+ results.push({
412
+ id: "files.avatar.circle",
413
+ area: "files",
414
+ level: corner <= 540 ? "pass" : "error",
415
+ title: corner <= 540
416
+ ? "Round profile pictures keep the whole logo"
417
+ : "Round profile pictures cut the logo",
418
+ details: { cornerPx: Math.round(corner), circlePx: 540 },
419
+ });
420
+ }
421
+ // The apple icon is opaque by construction; with pixels, it is measured.
422
+ if (ctx.rasterize) {
423
+ const svg = (await renderScene(tileScene(brand, { size: 180, padding: 0.1, opaque: true, variant: "mark" }), brand, { fonts: ctx.fonts })).svg;
424
+ const raster = await ctx.rasterize(svg, { width: 180 });
425
+ let transparent = 0;
426
+ for (let i = 3; i < raster.pixels.length; i += 4)
427
+ if ((raster.pixels[i] ?? 0) < 255)
428
+ transparent++;
429
+ results.push({
430
+ id: "files.apple.opaque",
431
+ area: "files",
432
+ level: transparent === 0 ? "pass" : "error",
433
+ title: transparent === 0
434
+ ? "The iPhone icon has no see-through corners"
435
+ : "The iPhone icon has see-through pixels, which iOS turns black",
436
+ details: { transparentPixels: transparent },
437
+ });
438
+ }
439
+ return results;
440
+ }
441
+ /** Upload limits per network, in bytes. */
442
+ const SIZE_LIMITS = [
443
+ { match: /^og\//, max: 5 * 1024 * 1024, where: "X share cards (5 MB)" },
444
+ { match: /^social\/x\/avatar/, max: 2 * 1024 * 1024, where: "X profile pictures (2 MB)" },
445
+ { match: /^social\/youtube\/banner/, max: 6 * 1024 * 1024, where: "YouTube banners (6 MB)" },
446
+ { match: /^social\/linkedin\/cover/, max: 3 * 1024 * 1024, where: "LinkedIn covers (3 MB)" },
447
+ { match: /^social\/whatsapp\//, max: 5 * 1024 * 1024, where: "WhatsApp (5 MB)" },
448
+ { match: /^web\/web-app-manifest-512/, max: 1024 * 1024, where: "Google Play icons (1 MB)" },
449
+ ];
450
+ function sizeChecks(files) {
451
+ const results = [];
452
+ for (const [path, file] of files) {
453
+ const limit = SIZE_LIMITS.find((rule) => rule.match.test(path));
454
+ if (!limit || file.bytes.length <= limit.max)
455
+ continue;
456
+ results.push({
457
+ id: `files.size.${path}`,
458
+ area: "files",
459
+ level: "error",
460
+ title: `${path} is too big for ${limit.where}`,
461
+ details: { bytes: file.bytes.length, max: limit.max },
462
+ });
463
+ }
464
+ if (results.length === 0)
465
+ results.push({
466
+ id: "files.size",
467
+ area: "files",
468
+ level: "pass",
469
+ title: "Every file is under each network's upload limit",
470
+ });
471
+ return results;
472
+ }
473
+ const ORDER = { error: 0, warning: 1, pass: 2 };
474
+ /** Run every check. Errors come first, then warnings, then what passed. */
475
+ export async function check(brand, ctx = {}) {
476
+ const results = [
477
+ ...contrastChecks(brand),
478
+ ...markChecks(brand),
479
+ ...copyChecks(brand),
480
+ ...(await textChecks(brand, ctx)),
481
+ ...(ctx.files ? sizeChecks(ctx.files) : []),
482
+ ];
483
+ return results.sort((a, b) => ORDER[a.level] - ORDER[b.level]);
484
+ }
485
+ /** "2 to fix" and friends, for the folded summary. */
486
+ export function summarize(results) {
487
+ return {
488
+ errors: results.filter((result) => result.level === "error").length,
489
+ warnings: results.filter((result) => result.level === "warning").length,
490
+ passed: results.filter((result) => result.level === "pass").length,
491
+ };
492
+ }
@@ -0,0 +1,41 @@
1
+ export type Oklch = {
2
+ l: number;
3
+ c: number;
4
+ h: number;
5
+ };
6
+ /** A hex color's OKLCH coordinates. A gray has no hue; it reads as 0. */
7
+ export declare function toOklch(hex: string): Oklch;
8
+ /** Distance in OKLab between two hex colors: under 0.02 looks the same, 0.1 is clearly different. */
9
+ export declare function oklabDistance(a: string, b: string): number;
10
+ /**
11
+ * The hex for an OKLCH color, brought into sRGB by lowering chroma at the same lightness and hue
12
+ * (culori's clampChroma). Lightness is clamped to 0..1.
13
+ */
14
+ export declare function fromOklch({ l, c, h }: Oklch): string;
15
+ export type ContrastFix = {
16
+ /** The color to use: the original when it already passed. */
17
+ hex: string;
18
+ /** Its WCAG 2 ratio against the background, measured on the final hex. */
19
+ ratio: number;
20
+ /** False when no lightness reaches the minimum (only possible above about 4.58:1). */
21
+ ok: boolean;
22
+ changed: boolean;
23
+ };
24
+ /**
25
+ * The closest color to `fg`, at the same OKLCH hue and chroma where sRGB allows, that reaches
26
+ * `min` contrast against `bg`. It searches lightness both ways (darker and lighter), keeps the
27
+ * smaller change that passes, and measures WCAG 2 on the final hex, so rounding to 8 bits can
28
+ * never push it under the line.
29
+ */
30
+ export declare function ensureContrast(fg: string, bg: string, min: number): ContrastFix;
31
+ /**
32
+ * The better of the candidates (by default the given ink and white) to put on `bg`, measured.
33
+ * smartgenius's onColor, generalized: a brand fill keeps its own color, and the text on it picks
34
+ * the side that reads.
35
+ */
36
+ export declare function onColor(bg: string, ink?: string, light?: string): {
37
+ hex: string;
38
+ ratio: number;
39
+ };
40
+ /** True when `bg` is light enough that dark text is the natural pick. */
41
+ export declare function isLight(hex: string): boolean;
@@ -0,0 +1,84 @@
1
+ import { clampChroma, formatHex, modeOklch, modeRgb, useMode } from "culori/fn";
2
+ import { contrast, luminance, normalizeHex } from "./wcag.js";
3
+ // culori's tree-shakable entry: register only the two spaces the kit converts between.
4
+ const toOklchColor = useMode(modeOklch);
5
+ useMode(modeRgb);
6
+ /** A hex color's OKLCH coordinates. A gray has no hue; it reads as 0. */
7
+ export function toOklch(hex) {
8
+ const color = toOklchColor(normalizeHex(hex));
9
+ return { l: color?.l ?? 0, c: color?.c ?? 0, h: color?.h ?? 0 };
10
+ }
11
+ /** Distance in OKLab between two hex colors: under 0.02 looks the same, 0.1 is clearly different. */
12
+ export function oklabDistance(a, b) {
13
+ const lab = (hex) => {
14
+ const { l, c, h } = toOklch(hex);
15
+ const radians = (h * Math.PI) / 180;
16
+ return [l, c * Math.cos(radians), c * Math.sin(radians)];
17
+ };
18
+ const [l1, a1, b1] = lab(a);
19
+ const [l2, a2, b2] = lab(b);
20
+ return Math.hypot(l1 - l2, a1 - a2, b1 - b2);
21
+ }
22
+ /**
23
+ * The hex for an OKLCH color, brought into sRGB by lowering chroma at the same lightness and hue
24
+ * (culori's clampChroma). Lightness is clamped to 0..1.
25
+ */
26
+ export function fromOklch({ l, c, h }) {
27
+ const color = clampChroma({ mode: "oklch", l: Math.min(1, Math.max(0, l)), c: Math.max(0, c), h }, "oklch", "rgb");
28
+ return formatHex(color);
29
+ }
30
+ /**
31
+ * The closest color to `fg`, at the same OKLCH hue and chroma where sRGB allows, that reaches
32
+ * `min` contrast against `bg`. It searches lightness both ways (darker and lighter), keeps the
33
+ * smaller change that passes, and measures WCAG 2 on the final hex, so rounding to 8 bits can
34
+ * never push it under the line.
35
+ */
36
+ export function ensureContrast(fg, bg, min) {
37
+ const start = normalizeHex(fg);
38
+ const startRatio = contrast(start, bg);
39
+ if (startRatio >= min)
40
+ return { hex: start, ratio: startRatio, ok: true, changed: false };
41
+ const base = toOklch(start);
42
+ const candidates = [0, 1]
43
+ .map((extreme) => searchLightness(base, extreme, bg, min))
44
+ .filter((found) => !!found)
45
+ .sort((a, b) => a.distance - b.distance);
46
+ const best = candidates[0];
47
+ if (best)
48
+ return { hex: best.hex, ratio: best.ratio, ok: true, changed: best.hex !== start };
49
+ // Nothing reaches the minimum: give back whichever of black and white comes closest.
50
+ const fallback = contrast("#000000", bg) >= contrast("#ffffff", bg) ? "#000000" : "#ffffff";
51
+ return { hex: fallback, ratio: contrast(fallback, bg), ok: false, changed: true };
52
+ }
53
+ function searchLightness(base, extreme, bg, min) {
54
+ const at = (l) => fromOklch({ ...base, l });
55
+ const far = at(extreme);
56
+ if (contrast(far, bg) < min)
57
+ return undefined;
58
+ // `pass` always holds a lightness whose measured hex passes; `fail` one that does not.
59
+ let fail = base.l;
60
+ let pass = extreme;
61
+ for (let step = 0; step < 24; step++) {
62
+ const mid = (fail + pass) / 2;
63
+ if (contrast(at(mid), bg) >= min)
64
+ pass = mid;
65
+ else
66
+ fail = mid;
67
+ }
68
+ const hex = at(pass);
69
+ return { hex, ratio: contrast(hex, bg), distance: Math.abs(pass - base.l) };
70
+ }
71
+ /**
72
+ * The better of the candidates (by default the given ink and white) to put on `bg`, measured.
73
+ * smartgenius's onColor, generalized: a brand fill keeps its own color, and the text on it picks
74
+ * the side that reads.
75
+ */
76
+ export function onColor(bg, ink = "#141414", light = "#ffffff") {
77
+ const dark = { hex: normalizeHex(ink), ratio: contrast(ink, bg) };
78
+ const bright = { hex: normalizeHex(light), ratio: contrast(light, bg) };
79
+ return dark.ratio >= bright.ratio ? dark : bright;
80
+ }
81
+ /** True when `bg` is light enough that dark text is the natural pick. */
82
+ export function isLight(hex) {
83
+ return luminance(hex) > 0.179;
84
+ }
@@ -0,0 +1,27 @@
1
+ import type { ColorRole, Palette } from "../brand/schema.ts";
2
+ /**
3
+ * Every pair of roles a brand promises to keep readable, with its WCAG 2 floor: 4.5 for text,
4
+ * 3 for lines, fills and focus (non-text). check() measures these on every brand, and
5
+ * generatePalette() is built to pass them.
6
+ */
7
+ export declare const CONTRAST_PAIRS: readonly {
8
+ fg: ColorRole;
9
+ bg: ColorRole;
10
+ min: number;
11
+ what: string;
12
+ }[];
13
+ export declare const MOODS: readonly ["calm", "bold", "warm", "fresh", "classic", "playful"];
14
+ export type Mood = (typeof MOODS)[number];
15
+ export type PaletteSeed = {
16
+ hue: number;
17
+ mood: Mood;
18
+ };
19
+ /**
20
+ * A light and a dark role set from one hue and a mood. Lightness and chroma come from the mood;
21
+ * every role is then pushed (in OKLCH lightness only) until each of CONTRAST_PAIRS passes, so the
22
+ * result is readable by construction and keeps the hue the owner picked.
23
+ */
24
+ export declare function generatePalette({ hue, mood }: PaletteSeed): {
25
+ light: Palette;
26
+ dark: Palette;
27
+ };