@drawnagency/primitives 0.1.91 → 0.1.93

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 (53) hide show
  1. package/dist/{chunk-MK7YB2A4.js → chunk-3HJDMFTB.js} +1 -1
  2. package/dist/{chunk-NV4R6VHN.js → chunk-CI624HRS.js} +1 -1
  3. package/dist/{chunk-XUIJHYKI.js → chunk-HLKAQLTV.js} +3 -3
  4. package/dist/{chunk-XKRQMTE4.js → chunk-LQQY2RW6.js} +2 -2
  5. package/dist/{chunk-ZLG7S54V.js → chunk-RBKBNQGB.js} +6 -6
  6. package/dist/{chunk-KMQZYORS.js → chunk-RHWWQV3J.js} +35 -6
  7. package/dist/{chunk-CZ3YXGK5.js → chunk-SOILPZ7U.js} +27 -3
  8. package/dist/{chunk-AGWE2LJG.js → chunk-UFOWXOG6.js} +49 -1
  9. package/dist/{chunk-SIYQ37QY.js → chunk-XYFZ57AS.js} +2 -2
  10. package/dist/{chunk-KBQNJZUX.js → chunk-YI2HUFE3.js} +1 -1
  11. package/dist/components/editor/index.js +4 -4
  12. package/dist/components/primitives/index.js +5 -5
  13. package/dist/components/sections/brand-guide.js +5 -5
  14. package/dist/components/sections/register-schemas.js +8 -8
  15. package/dist/components/sections/register.js +8 -8
  16. package/dist/components/shared/SubTabs.d.ts +22 -0
  17. package/dist/components/shared/SubTabs.d.ts.map +1 -0
  18. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  19. package/dist/components/shell/SiteSettingsChatbot.d.ts.map +1 -1
  20. package/dist/components/shell/SiteSettingsDisplay.d.ts.map +1 -1
  21. package/dist/components/shell/ViewerLoginForm.d.ts +1 -0
  22. package/dist/components/shell/ViewerLoginForm.d.ts.map +1 -1
  23. package/dist/index.js +18 -6
  24. package/dist/lib/contrast.d.ts +19 -0
  25. package/dist/lib/contrast.d.ts.map +1 -1
  26. package/dist/lib/index.d.ts +2 -1
  27. package/dist/lib/index.d.ts.map +1 -1
  28. package/dist/lib/index.js +16 -4
  29. package/dist/lib/theme-vars.d.ts +16 -0
  30. package/dist/lib/theme-vars.d.ts.map +1 -0
  31. package/dist/media/compose-preview.d.ts +9 -0
  32. package/dist/media/compose-preview.d.ts.map +1 -1
  33. package/dist/media/index.js +2 -2
  34. package/dist/media/preview-inputs.d.ts.map +1 -1
  35. package/dist/schemas/index.js +2 -2
  36. package/dist/schemas/site-config.d.ts +3 -0
  37. package/dist/schemas/site-config.d.ts.map +1 -1
  38. package/dist/types/database.d.ts +12 -0
  39. package/dist/types/database.d.ts.map +1 -1
  40. package/package.json +1 -1
  41. package/src/components/shared/SubTabs.tsx +70 -0
  42. package/src/components/shared/Tabs.tsx +1 -1
  43. package/src/components/shell/EditorShell.tsx +24 -10
  44. package/src/components/shell/SiteSettingsChatbot.tsx +38 -18
  45. package/src/components/shell/SiteSettingsDisplay.tsx +202 -31
  46. package/src/components/shell/ViewerLoginForm.tsx +31 -27
  47. package/src/lib/contrast.ts +52 -0
  48. package/src/lib/index.ts +10 -1
  49. package/src/lib/theme-vars.ts +31 -0
  50. package/src/media/compose-preview.ts +56 -4
  51. package/src/media/preview-inputs.ts +3 -1
  52. package/src/schemas/site-config.ts +41 -5
  53. package/src/types/database.ts +10 -0
@@ -5,9 +5,11 @@ import { ColorPicker } from "../shared/ColorPicker";
5
5
  import { FontPicker } from "../shared/FontPicker";
6
6
  import { Input } from "../shared/Input";
7
7
  import { Select } from "../shared/Select";
8
+ import { SubTabs } from "../shared/SubTabs";
8
9
  import { FormLabel } from "../shared/FormLabel";
9
10
  import { useMediaLibrary } from "./MediaLibraryContext";
10
- import { deriveContrast } from "../../lib/contrast";
11
+ import { contrastRatio, wcagLevel, suggestAccessibleOn } from "../../lib/contrast";
12
+ import { cn } from "../../lib/cn";
11
13
  import { buildGoogleFontsUrl } from "../../lib/google-fonts";
12
14
  import type { SiteConfig } from "../../schemas/site-config";
13
15
  import type { MediaItem } from "../../media/types";
@@ -43,6 +45,39 @@ const cornerRadiusOptions = [
43
45
  const FAVICON_TYPES = ["image/svg+xml", "image/png"];
44
46
  const FAVICON_MAX_BYTES = 100 * 1024;
45
47
 
48
+ // Page-background tokens the brand colors are shown against, so contrast is
49
+ // measured against the real surface (base.css: --color-base is hsl(0 0% 92%)
50
+ // in light, hsl(0 0% 12%) in dark).
51
+ const LIGHT_BASE = "#ebebeb";
52
+ const DARK_BASE = "#1f1f1f";
53
+
54
+ interface ContrastEntry {
55
+ mode: string;
56
+ ratio: number;
57
+ }
58
+
59
+ /** Compact WCAG readout: contrast ratio + AA / large-only / fail badge per mode. */
60
+ function ContrastRow({ label, entries }: { label: string; entries: ContrastEntry[] }) {
61
+ return (
62
+ <div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs">
63
+ <span className="text-base-contrast-light">{label}</span>
64
+ {entries.map((e) => {
65
+ const level = wcagLevel(e.ratio);
66
+ const tone = level.aa ? "text-green-600" : level.aaLarge ? "text-amber-600" : "text-red-600";
67
+ const mark = level.aa ? "AA" : level.aaLarge ? "large only" : "fail";
68
+ return (
69
+ <span key={e.mode} className="inline-flex items-center gap-1">
70
+ {entries.length > 1 && <span className="text-base-contrast-light">{e.mode}</span>}
71
+ <span className={cn("font-medium tabular-nums", tone)}>
72
+ {e.ratio.toFixed(2)}:1 · {mark}
73
+ </span>
74
+ </span>
75
+ );
76
+ })}
77
+ </div>
78
+ );
79
+ }
80
+
46
81
  export function SiteSettingsDisplay({ siteConfig, onChange }: Props) {
47
82
  const [faviconError, setFaviconError] = useState<string | null>(null);
48
83
  const mediaLibrary = useMediaLibrary();
@@ -82,17 +117,34 @@ export function SiteSettingsDisplay({ siteConfig, onChange }: Props) {
82
117
  update({ primaryContrast: color });
83
118
  }
84
119
 
85
- function resetContrastToAuto() {
86
- update({ primaryContrast: deriveContrast(siteConfig.primaryColor) });
120
+ function suggestAccent() {
121
+ update({ primaryContrast: suggestAccessibleOn(siteConfig.primaryColor) });
122
+ }
123
+
124
+ function handleDarkColorChange(color: string) {
125
+ update({ primaryColorDark: color });
126
+ }
127
+
128
+ function handleDarkContrastChange(color: string) {
129
+ update({ primaryContrastDark: color });
130
+ }
131
+
132
+ function suggestAccentDark() {
133
+ update({ primaryContrastDark: suggestAccessibleOn(siteConfig.primaryColorDark ?? siteConfig.primaryColor) });
87
134
  }
88
135
 
136
+ const customizeDarkPrimary = () => update({ primaryColorDark: siteConfig.primaryColor });
137
+ const matchLightPrimary = () => update({ primaryColorDark: null });
138
+ const customizeDarkAccent = () => update({ primaryContrastDark: siteConfig.primaryContrast });
139
+ const matchLightAccent = () => update({ primaryContrastDark: null });
140
+
89
141
  function handleFontChange(field: "headingFont" | "bodyFont", family: string) {
90
142
  const next = { ...siteConfig, [field]: family };
91
143
  next.googleFontsUrl = buildGoogleFontsUrl(next.headingFont, next.bodyFont);
92
144
  onChange(next);
93
145
  }
94
146
 
95
- return (
147
+ const detailsContent = (
96
148
  <div className="space-y-5">
97
149
  <Input
98
150
  label="Site name"
@@ -102,34 +154,25 @@ export function SiteSettingsDisplay({ siteConfig, onChange }: Props) {
102
154
  />
103
155
 
104
156
  <div>
105
- <FormLabel>Primary color</FormLabel>
106
- <div className="flex items-center gap-3">
107
- <ColorPicker
108
- value={siteConfig.primaryColor}
109
- onChange={handleColorChange}
110
- label="Primary color"
111
- />
112
- <span className="text-sm text-base-contrast-light">{siteConfig.primaryColor}</span>
113
- </div>
114
- </div>
115
-
116
- <div>
117
- <FormLabel>Accent (button &amp; highlight text)</FormLabel>
118
- <div className="flex items-center gap-3">
119
- <ColorPicker
120
- value={siteConfig.primaryContrast}
121
- onChange={handleContrastChange}
122
- label="Accent color"
123
- />
124
- <span className="text-sm text-base-contrast-light">{siteConfig.primaryContrast}</span>
125
- <Button type="button" variant="ghost" size="sm" onClick={resetContrastToAuto}>
126
- Reset to auto
127
- </Button>
157
+ <FormLabel htmlFor="site-description">Description</FormLabel>
158
+ <textarea
159
+ id="site-description"
160
+ aria-label="Site description"
161
+ rows={2}
162
+ maxLength={300}
163
+ value={siteConfig.description ?? ""}
164
+ onChange={(e) => update({ description: e.target.value || null })}
165
+ placeholder="Short summary for search results and social shares."
166
+ className="w-full rounded border border-base-200 bg-base px-3 py-2 text-sm text-base-contrast"
167
+ />
168
+ <div className="mt-1 flex items-start justify-between gap-2">
169
+ <p className="text-xs text-base-contrast/70">
170
+ Used for the page's meta description and the social / OG preview.
171
+ </p>
172
+ <span className="shrink-0 text-xs text-base-contrast-light tabular-nums">
173
+ {(siteConfig.description ?? "").length}/300
174
+ </span>
128
175
  </div>
129
- <p className="mt-1 text-xs text-base-contrast/70">
130
- Shown as text on primary-colored buttons and as the text-selection color. "Reset to auto" derives a
131
- legible light or dark value from your primary brand color.
132
- </p>
133
176
  </div>
134
177
 
135
178
  <div>
@@ -259,6 +302,58 @@ export function SiteSettingsDisplay({ siteConfig, onChange }: Props) {
259
302
  </div>
260
303
  );
261
304
  })()}
305
+ </div>
306
+ );
307
+
308
+ // Which modes to score depends on darkMode: light-only shows Light, dark-only
309
+ // shows Dark, "optional" shows both (and reveals the per-mode variant inputs).
310
+ const showLight = siteConfig.darkMode !== "dark";
311
+ const showDark = siteConfig.darkMode !== "light";
312
+ const effPrimaryDark = siteConfig.primaryColorDark ?? siteConfig.primaryColor;
313
+ const effAccentDark = siteConfig.primaryContrastDark ?? siteConfig.primaryContrast;
314
+ const primaryTextEntries: ContrastEntry[] = [
315
+ ...(showLight ? [{ mode: "Light", ratio: contrastRatio(siteConfig.primaryColor, LIGHT_BASE) }] : []),
316
+ ...(showDark ? [{ mode: "Dark", ratio: contrastRatio(effPrimaryDark, DARK_BASE) }] : []),
317
+ ];
318
+ const accentEntries: ContrastEntry[] = [
319
+ ...(showLight ? [{ mode: "Light", ratio: contrastRatio(siteConfig.primaryContrast, siteConfig.primaryColor) }] : []),
320
+ ...(showDark ? [{ mode: "Dark", ratio: contrastRatio(effAccentDark, effPrimaryDark) }] : []),
321
+ ];
322
+
323
+ const stylesContent = (
324
+ <div className="space-y-5">
325
+ <div>
326
+ <FormLabel>Primary color</FormLabel>
327
+ <div className="flex items-center gap-3">
328
+ <ColorPicker
329
+ value={siteConfig.primaryColor}
330
+ onChange={handleColorChange}
331
+ label="Primary color"
332
+ />
333
+ <span className="text-sm text-base-contrast-light">{siteConfig.primaryColor}</span>
334
+ </div>
335
+ <ContrastRow label="As heading / link text" entries={primaryTextEntries} />
336
+ </div>
337
+
338
+ <div>
339
+ <FormLabel>Accent (button &amp; highlight text)</FormLabel>
340
+ <div className="flex items-center gap-3">
341
+ <ColorPicker
342
+ value={siteConfig.primaryContrast}
343
+ onChange={handleContrastChange}
344
+ label="Accent color"
345
+ />
346
+ <span className="text-sm text-base-contrast-light">{siteConfig.primaryContrast}</span>
347
+ <Button type="button" variant="ghost" size="sm" onClick={suggestAccent}>
348
+ Suggest
349
+ </Button>
350
+ </div>
351
+ <ContrastRow label="As button label on primary" entries={accentEntries} />
352
+ <p className="mt-1 text-xs text-base-contrast/70">
353
+ Shown as text on primary-colored buttons and as the text-selection color. "Suggest" picks the black
354
+ or white with the strongest contrast on your primary color.
355
+ </p>
356
+ </div>
262
357
 
263
358
  <Select
264
359
  label="Dark mode"
@@ -267,6 +362,68 @@ export function SiteSettingsDisplay({ siteConfig, onChange }: Props) {
267
362
  options={darkModeOptions}
268
363
  />
269
364
 
365
+ {siteConfig.darkMode === "optional" && (
366
+ <div className="space-y-4 border-l-2 border-base-200 pl-3">
367
+ <div>
368
+ <p className="text-sm font-medium text-base-contrast">Dark mode variants</p>
369
+ <p className="mt-0.5 text-xs text-base-contrast-light">
370
+ Optional overrides used only in dark mode, so light and dark can each hit their own contrast target.
371
+ </p>
372
+ </div>
373
+
374
+ <div>
375
+ <FormLabel>Primary (dark)</FormLabel>
376
+ {!siteConfig.primaryColorDark ? (
377
+ <div className="flex items-center gap-3">
378
+ <span className="text-sm text-base-contrast-light">Matches light ({siteConfig.primaryColor})</span>
379
+ <Button type="button" variant="secondary" size="sm" onClick={customizeDarkPrimary}>
380
+ Customize
381
+ </Button>
382
+ </div>
383
+ ) : (
384
+ <div className="flex items-center gap-3">
385
+ <ColorPicker
386
+ value={siteConfig.primaryColorDark}
387
+ onChange={handleDarkColorChange}
388
+ label="Primary color (dark)"
389
+ />
390
+ <span className="text-sm text-base-contrast-light">{siteConfig.primaryColorDark}</span>
391
+ <Button type="button" variant="ghost" size="sm" onClick={matchLightPrimary}>
392
+ Match light
393
+ </Button>
394
+ </div>
395
+ )}
396
+ </div>
397
+
398
+ <div>
399
+ <FormLabel>Accent (dark)</FormLabel>
400
+ {!siteConfig.primaryContrastDark ? (
401
+ <div className="flex items-center gap-3">
402
+ <span className="text-sm text-base-contrast-light">Matches light ({siteConfig.primaryContrast})</span>
403
+ <Button type="button" variant="secondary" size="sm" onClick={customizeDarkAccent}>
404
+ Customize
405
+ </Button>
406
+ </div>
407
+ ) : (
408
+ <div className="flex items-center gap-3">
409
+ <ColorPicker
410
+ value={siteConfig.primaryContrastDark}
411
+ onChange={handleDarkContrastChange}
412
+ label="Accent color (dark)"
413
+ />
414
+ <span className="text-sm text-base-contrast-light">{siteConfig.primaryContrastDark}</span>
415
+ <Button type="button" variant="ghost" size="sm" onClick={suggestAccentDark}>
416
+ Suggest
417
+ </Button>
418
+ <Button type="button" variant="ghost" size="sm" onClick={matchLightAccent}>
419
+ Match light
420
+ </Button>
421
+ </div>
422
+ )}
423
+ </div>
424
+ </div>
425
+ )}
426
+
270
427
  <div>
271
428
  <Select
272
429
  label="Corner radius"
@@ -278,7 +435,11 @@ export function SiteSettingsDisplay({ siteConfig, onChange }: Props) {
278
435
  Rounding of cards, media frames, buttons, and panels across the site.
279
436
  </p>
280
437
  </div>
438
+ </div>
439
+ );
281
440
 
441
+ const typographyContent = (
442
+ <div className="space-y-5">
282
443
  <FontPicker
283
444
  label="Heading font"
284
445
  value={siteConfig.headingFont}
@@ -310,4 +471,14 @@ export function SiteSettingsDisplay({ siteConfig, onChange }: Props) {
310
471
  />
311
472
  </div>
312
473
  );
474
+
475
+ return (
476
+ <SubTabs
477
+ tabs={[
478
+ { id: "details", label: "Details", content: detailsContent },
479
+ { id: "styles", label: "Styles", content: stylesContent },
480
+ { id: "typography", label: "Typography", content: typographyContent },
481
+ ]}
482
+ />
483
+ );
313
484
  }
@@ -1,10 +1,11 @@
1
1
  import { useState, type FormEvent } from "react";
2
2
  import { PasswordInput } from "../shared/PasswordInput";
3
3
  import { Button } from "../shared/Button";
4
+ import { Select } from "../shared/Select";
4
5
  import { safeRedirect } from "../../lib/safeRedirect";
5
6
 
6
7
  interface Props {
7
- audiences: Array<{ name: string; displayName: string }>;
8
+ audiences: Array<{ name: string; displayName: string; hasPassword?: boolean }>;
8
9
  next?: string | null;
9
10
  }
10
11
 
@@ -14,12 +15,26 @@ export function ViewerLoginForm({ audiences, next }: Props) {
14
15
  const [error, setError] = useState<string | null>(null);
15
16
  const [loading, setLoading] = useState(false);
16
17
 
18
+ const audienceOptions = [
19
+ ...(audiences.length > 1 ? [{ value: "", label: "Select audience..." }] : []),
20
+ ...audiences.map((a) => ({ value: a.name, label: a.displayName })),
21
+ ];
22
+
23
+ // An open (passwordless) audience needs no password field — enter with one click.
24
+ // Default to requiring a password until an audience is chosen so the field
25
+ // doesn't flash for the "Select audience..." state.
26
+ const selected = audiences.find((a) => a.name === audience);
27
+ const needsPassword = selected ? selected.hasPassword !== false : true;
28
+
17
29
  async function handleSubmit(e: FormEvent) {
18
30
  e.preventDefault();
19
31
  setLoading(true);
20
32
  setError(null);
21
33
 
22
- const body: { audience: string; password: string; next?: string } = { audience, password };
34
+ const body: { audience: string; password: string; next?: string } = {
35
+ audience,
36
+ password: needsPassword ? password : "",
37
+ };
23
38
  if (next) body.next = next;
24
39
 
25
40
  try {
@@ -47,33 +62,22 @@ export function ViewerLoginForm({ audiences, next }: Props) {
47
62
  return (
48
63
  <div className="mx-auto max-w-sm">
49
64
  <form onSubmit={handleSubmit} className="space-y-4">
50
- <div>
51
- <label htmlFor="audience" className="mb-1.5 block text-sm font-medium text-base-contrast">
52
- Audience
53
- </label>
54
- <select
55
- id="audience"
56
- value={audience}
57
- onChange={(e) => setAudience(e.target.value)}
58
- required
59
- className="w-full rounded border border-base-200 px-3 py-2 text-sm"
60
- >
61
- {audiences.length > 1 && <option value="">Select audience...</option>}
62
- {audiences.map((a) => (
63
- <option key={a.name} value={a.name}>
64
- {a.displayName}
65
- </option>
66
- ))}
67
- </select>
68
- </div>
69
-
70
- <PasswordInput
71
- label="Password"
72
- value={password}
73
- onChange={setPassword}
74
- autoComplete="current-password"
65
+ <Select
66
+ label="Audience"
67
+ value={audience}
68
+ onChange={setAudience}
69
+ options={audienceOptions}
75
70
  />
76
71
 
72
+ {needsPassword && (
73
+ <PasswordInput
74
+ label="Password"
75
+ value={password}
76
+ onChange={setPassword}
77
+ autoComplete="current-password"
78
+ />
79
+ )}
80
+
77
81
  {error && (
78
82
  <p className="text-sm text-red-600">{error}</p>
79
83
  )}
@@ -21,3 +21,55 @@ export function contrastColor(hex: string): "#1a1a1a" | "#ffffff" {
21
21
  const b = parseInt(h.slice(4, 6), 16);
22
22
  return (r * 299 + g * 587 + b * 114) / 1000 > 150 ? "#1a1a1a" : "#ffffff";
23
23
  }
24
+
25
+ // --- WCAG 2.x contrast (relative-luminance based) -------------------------
26
+
27
+ function toRgb(hex: string): [number, number, number] {
28
+ const h = hex.replace("#", "");
29
+ const x = h.length === 3 ? h[0] + h[0] + h[1] + h[1] + h[2] + h[2] : h;
30
+ return [
31
+ parseInt(x.slice(0, 2), 16),
32
+ parseInt(x.slice(2, 4), 16),
33
+ parseInt(x.slice(4, 6), 16),
34
+ ];
35
+ }
36
+
37
+ /** WCAG relative luminance of an sRGB hex color (0 = black, 1 = white). */
38
+ export function relativeLuminance(hex: string): number {
39
+ const [r, g, b] = toRgb(hex).map((c) => {
40
+ const s = c / 255;
41
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
42
+ });
43
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
44
+ }
45
+
46
+ /** WCAG contrast ratio between two hex colors, 1 (identical) → 21 (#000/#fff). */
47
+ export function contrastRatio(a: string, b: string): number {
48
+ const la = relativeLuminance(a);
49
+ const lb = relativeLuminance(b);
50
+ const hi = Math.max(la, lb);
51
+ const lo = Math.min(la, lb);
52
+ return (hi + 0.05) / (lo + 0.05);
53
+ }
54
+
55
+ export interface WcagLevel {
56
+ /** ≥ 4.5:1 — AA for normal-size text. */
57
+ aa: boolean;
58
+ /** ≥ 3:1 — AA for large text and UI components. */
59
+ aaLarge: boolean;
60
+ /** ≥ 7:1 — AAA for normal-size text. */
61
+ aaa: boolean;
62
+ }
63
+
64
+ export function wcagLevel(ratio: number): WcagLevel {
65
+ return { aa: ratio >= 4.5, aaLarge: ratio >= 3, aaa: ratio >= 7 };
66
+ }
67
+
68
+ /**
69
+ * Pick the pure black/white that maximizes contrast against `bg` — used to
70
+ * suggest an accessible accent (text on a primary-colored button). Pure b/w
71
+ * gives the highest achievable ratio for a foreground on a fixed background.
72
+ */
73
+ export function suggestAccessibleOn(bg: string): "#000000" | "#ffffff" {
74
+ return contrastRatio("#000000", bg) >= contrastRatio("#ffffff", bg) ? "#000000" : "#ffffff";
75
+ }
package/src/lib/index.ts CHANGED
@@ -3,7 +3,16 @@ export { cn } from "./cn";
3
3
  export { audiencePasses } from "./pages";
4
4
  export { resolveChatbotName, resolveThinkingPhrases, DEFAULT_CHATBOT_NAME } from "./chatbot";
5
5
  export { generateNavLinks, toSectionId, type NavItem } from "./nav";
6
- export { deriveContrast, contrastColor } from "./contrast";
6
+ export {
7
+ deriveContrast,
8
+ contrastColor,
9
+ relativeLuminance,
10
+ contrastRatio,
11
+ wcagLevel,
12
+ suggestAccessibleOn,
13
+ type WcagLevel,
14
+ } from "./contrast";
15
+ export { primaryVarValue, accentVarValue } from "./theme-vars";
7
16
  export { sanitizeHtml, ensureSanitizer } from "./sanitize";
8
17
  export { gridColsClass } from "./grid";
9
18
  export { getIcon, curatedIcons, type IconEntry } from "./icons";
@@ -0,0 +1,31 @@
1
+ import type { SiteConfig } from "../schemas/site-config";
2
+
3
+ /**
4
+ * Computes the CSS value for the brand `--color-primary` / `--color-primary-contrast`
5
+ * custom properties. When darkMode is "optional" AND a dark variant is set, the
6
+ * value is a CSS `light-dark(<light>, <dark>)` expression — the browser resolves
7
+ * it per the element's `color-scheme` (the `.dark` class sets `color-scheme: dark`;
8
+ * `:root` sets `color-scheme: light`). Otherwise it's the plain light hex.
9
+ *
10
+ * Single source of truth for every injection site: Layout.astro / LoginLayout.astro
11
+ * (SSR inline `<html style>`) and EditorShell (live setProperty + localStorage).
12
+ */
13
+ type ThemeColorConfig = Pick<
14
+ SiteConfig,
15
+ "primaryColor" | "primaryContrast" | "primaryColorDark" | "primaryContrastDark" | "darkMode"
16
+ >;
17
+
18
+ const DEFAULT_PRIMARY = "#009ca6";
19
+ const DEFAULT_CONTRAST = "#f0f0f0";
20
+
21
+ function lightDark(light: string, dark: string | null | undefined, optional: boolean): string {
22
+ return optional && dark ? `light-dark(${light}, ${dark})` : light;
23
+ }
24
+
25
+ export function primaryVarValue(c?: Partial<ThemeColorConfig> | null): string {
26
+ return lightDark(c?.primaryColor ?? DEFAULT_PRIMARY, c?.primaryColorDark, c?.darkMode === "optional");
27
+ }
28
+
29
+ export function accentVarValue(c?: Partial<ThemeColorConfig> | null): string {
30
+ return lightDark(c?.primaryContrast ?? DEFAULT_CONTRAST, c?.primaryContrastDark, c?.darkMode === "optional");
31
+ }
@@ -10,6 +10,9 @@ export interface PreviewImageInputs {
10
10
  invertLogo: boolean;
11
11
  siteName: string;
12
12
  headingFont: string;
13
+ /** The site's Google Fonts CSS URL (or null for system-ui), used to load the
14
+ * heading font before drawing so text mode never falls back to system-ui. */
15
+ googleFontsUrl: string | null;
13
16
  /** Computed contrasting text color (text mode). */
14
17
  textColor: string;
15
18
  }
@@ -17,6 +20,53 @@ export interface PreviewImageInputs {
17
20
  const WIDTH = 1200;
18
21
  const HEIGHT = 630;
19
22
 
23
+ /**
24
+ * Bump when the composition logic changes so already-composed rasters (stamped
25
+ * with the old version, or 0 for pre-versioning ones) regenerate on next editor
26
+ * open. v2 = load the Google heading font before drawing + draw at weight 700.
27
+ */
28
+ export const PREVIEW_COMPOSE_VERSION = 2;
29
+
30
+ // The raster draws the site name at this weight; the loader ensures this exact
31
+ // weight is available so canvas doesn't silently fall back to a nearest weight.
32
+ const HEADING_WEIGHT = 700;
33
+
34
+ /**
35
+ * Ensure the heading font is actually loaded before canvas draws with it.
36
+ * `document.fonts.load` only fetches a family that already has a matching
37
+ * @font-face rule registered; in the editor that rule comes from a <link> that
38
+ * may not be parsed yet when composition runs, so an ambient load() can resolve
39
+ * empty and the draw silently falls back to system-ui. If the ambient load
40
+ * yields nothing, fetch the Google Fonts CSS and register the weight-700
41
+ * face(s) explicitly via the CSS Font Loading API.
42
+ */
43
+ async function loadHeadingFont(family: string, googleFontsUrl: string | null): Promise<void> {
44
+ if (!family || family === "system-ui" || !googleFontsUrl || typeof FontFace === "undefined") return;
45
+ const spec = `${HEADING_WEIGHT} 84px "${family}"`;
46
+ try {
47
+ const loaded = await document.fonts.load(spec);
48
+ if (loaded.length > 0) return;
49
+ } catch {
50
+ // fall through to explicit registration
51
+ }
52
+ try {
53
+ const css = await (await fetch(googleFontsUrl)).text();
54
+ const blocks = css.match(/@font-face\s*\{[^}]*\}/g) ?? [];
55
+ const wanted = blocks.filter((b) => new RegExp(`font-weight:\\s*${HEADING_WEIGHT}\\b`).test(b));
56
+ await Promise.all(
57
+ (wanted.length ? wanted : blocks).map(async (block) => {
58
+ const url = block.match(/url\(([^)]+)\)/)?.[1];
59
+ if (!url) return;
60
+ const face = new FontFace(family, `url(${url})`, { weight: String(HEADING_WEIGHT) });
61
+ await face.load();
62
+ document.fonts.add(face);
63
+ }),
64
+ );
65
+ } catch {
66
+ // give up — the draw below falls back to system-ui
67
+ }
68
+ }
69
+
20
70
  /**
21
71
  * Composes the 1200×630 social-preview raster in the browser (Canvas API only,
22
72
  * no dependency). Returns a WebP blob. Must run client-side (uses document/Image
@@ -60,13 +110,15 @@ export async function composePreviewImage(inputs: PreviewImageInputs): Promise<B
60
110
 
61
111
  if (!drewLogo) {
62
112
  if (document.fonts?.ready) await document.fonts.ready;
63
- // fillText doesn't itself trigger a font load — explicitly load the exact
64
- // weight/family used below so it's available before we draw.
65
- await document.fonts.load(`600 84px ${inputs.headingFont}`).catch(() => {});
113
+ // Load the heading font (exact weight) before drawing — fillText doesn't
114
+ // itself trigger a webfont load, so without this canvas falls back silently.
115
+ await loadHeadingFont(inputs.headingFont, inputs.googleFontsUrl);
66
116
  ctx.fillStyle = inputs.textColor;
67
117
  ctx.textAlign = "center";
68
118
  ctx.textBaseline = "middle";
69
- ctx.font = `600 84px ${inputs.headingFont}, system-ui, sans-serif`;
119
+ // Quote the family: multi-word names ("Playfair Display") break the CSS font
120
+ // shorthand parse when unquoted, which would drop the whole declaration.
121
+ ctx.font = `${HEADING_WEIGHT} 84px "${inputs.headingFont}", system-ui, sans-serif`;
70
122
  ctx.fillText(inputs.siteName, WIDTH / 2, HEIGHT / 2, WIDTH * 0.86);
71
123
  }
72
124
 
@@ -19,7 +19,7 @@ export function derivePreviewInputs(
19
19
  siteConfig: SiteConfig,
20
20
  resolveLogoUrl: (imageId: string) => string | null,
21
21
  ): { inputs: PreviewImageInputs; signature: string } {
22
- const { logo, darkMode, primaryColor, siteName, headingFont } = siteConfig;
22
+ const { logo, darkMode, primaryColor, siteName, headingFont, googleFontsUrl } = siteConfig;
23
23
  const dark = darkMode === "dark";
24
24
 
25
25
  let mode: "logo" | "text" = "text";
@@ -43,6 +43,7 @@ export function derivePreviewInputs(
43
43
  invertLogo,
44
44
  siteName,
45
45
  headingFont,
46
+ googleFontsUrl,
46
47
  textColor: contrastColor(primaryColor),
47
48
  };
48
49
 
@@ -53,6 +54,7 @@ export function derivePreviewInputs(
53
54
  invertLogo: inputs.invertLogo,
54
55
  siteName: inputs.siteName,
55
56
  headingFont: inputs.headingFont,
57
+ googleFontsUrl: inputs.googleFontsUrl,
56
58
  textColor: inputs.textColor,
57
59
  });
58
60
 
@@ -127,15 +127,41 @@ const CanonicalIndexSchema = z
127
127
  seenSlugs.add(p.slug);
128
128
  }
129
129
 
130
+ // Page section orders must partition sections{} exactly: every section id
131
+ // appears in exactly one page order, and every page-order id has a
132
+ // sections{} entry. On failure, name the offending ids by category so an
133
+ // agent (or human) can act without diffing the whole index by hand.
134
+ const fmtIds = (ids: string[]) => ids.map((id) => `"${id}"`).sort().join(", ");
130
135
  const fromPages: string[] = [];
131
136
  for (const p of data.pages) fromPages.push(...p.order);
132
137
  const fromPagesSet = new Set(fromPages);
133
- if (fromPages.length !== fromPagesSet.size) {
134
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: "A section id appears in more than one page" });
135
- }
136
138
  const sectionKeys = Object.keys(data.sections);
137
- if (fromPagesSet.size !== sectionKeys.length || !sectionKeys.every((k) => fromPagesSet.has(k))) {
138
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Page section orders must partition sections{} exactly" });
139
+ const sectionSet = new Set(sectionKeys);
140
+
141
+ const counts = new Map<string, number>();
142
+ for (const id of fromPages) counts.set(id, (counts.get(id) ?? 0) + 1);
143
+ const duplicated = [...counts].filter(([, n]) => n > 1).map(([id]) => id);
144
+ if (duplicated.length > 0) {
145
+ ctx.addIssue({
146
+ code: z.ZodIssueCode.custom,
147
+ message: `Section id(s) appear in more than one page order: ${fmtIds(duplicated)}`,
148
+ });
149
+ }
150
+
151
+ const orderedNotInSections = [...fromPagesSet].filter((id) => !sectionSet.has(id));
152
+ if (orderedNotInSections.length > 0) {
153
+ ctx.addIssue({
154
+ code: z.ZodIssueCode.custom,
155
+ message: `Section id(s) in a page order but missing from sections{}: ${fmtIds(orderedNotInSections)}`,
156
+ });
157
+ }
158
+
159
+ const sectionsNotOrdered = sectionKeys.filter((id) => !fromPagesSet.has(id));
160
+ if (sectionsNotOrdered.length > 0) {
161
+ ctx.addIssue({
162
+ code: z.ZodIssueCode.custom,
163
+ message: `Section id(s) in sections{} but not in any page order: ${fmtIds(sectionsNotOrdered)}`,
164
+ });
139
165
  }
140
166
  });
141
167
 
@@ -201,6 +227,12 @@ export const SiteConfigSchema = z.object({
201
227
  siteName: z.string().default("Brand Portal"),
202
228
  primaryColor: HexColorSchema.default("#009ca6"),
203
229
  primaryContrast: HexColorSchema.default("#f0f0f0"),
230
+ // Dark-mode overrides for the brand primary / accent. Only applied when
231
+ // darkMode is "optional" (both modes are reachable, so a single value can't
232
+ // satisfy both). null = reuse the light value in dark mode. Rendered via the
233
+ // CSS light-dark() function (see lib/theme-vars.ts).
234
+ primaryColorDark: HexColorSchema.nullable().default(null),
235
+ primaryContrastDark: HexColorSchema.nullable().default(null),
204
236
  darkMode: z.enum(["light", "dark", "optional"]).default("light"),
205
237
  // Maps to the --radius-outer design token (see lib/corner-radius.ts);
206
238
  // defaults so pre-existing site-config.json files keep parsing.
@@ -245,6 +277,10 @@ export const SiteConfigSchema = z.object({
245
277
  imageId: z.string(),
246
278
  width: z.number().int().positive(),
247
279
  height: z.number().int().positive(),
280
+ // Compose-logic version that produced this raster. Pre-existing rasters
281
+ // (and manifests written before this field) default to 0, which is below
282
+ // any current PREVIEW_COMPOSE_VERSION, so the editor regenerates them.
283
+ version: z.number().int().nonnegative().default(0),
248
284
  })
249
285
  .nullable()
250
286
  .default(null),