@drawnagency/primitives 0.1.92 → 0.1.94

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 (59) hide show
  1. package/dist/{chunk-Z66TBL4Q.js → chunk-3HJDMFTB.js} +1 -1
  2. package/dist/{chunk-LQQY2RW6.js → chunk-DJAJRBPD.js} +1 -0
  3. package/dist/{chunk-7QEJDYTG.js → chunk-GDLH3LVH.js} +6 -6
  4. package/dist/{chunk-RUKO2N3F.js → chunk-RHWWQV3J.js} +30 -5
  5. package/dist/{chunk-YI2HUFE3.js → chunk-RQPHPAT2.js} +1 -1
  6. package/dist/{chunk-WOYGQK2D.js → chunk-UFOWXOG6.js} +49 -1
  7. package/dist/chunk-VAOW4WXQ.js +15 -0
  8. package/dist/{chunk-IZLXF3CR.js → chunk-XYFZ57AS.js} +1 -1
  9. package/dist/{chunk-A2V7BZPD.js → chunk-ZKGMLLUQ.js} +2 -2
  10. package/dist/components/editor/index.js +2 -2
  11. package/dist/components/primitives/ResolvedMedia.d.ts.map +1 -1
  12. package/dist/components/primitives/index.js +3 -3
  13. package/dist/components/sections/Video/VideoBlock.d.ts.map +1 -1
  14. package/dist/components/sections/brand-guide.js +3 -3
  15. package/dist/components/sections/register-schemas.js +6 -6
  16. package/dist/components/sections/register.js +6 -6
  17. package/dist/components/shared/SubTabs.d.ts +22 -0
  18. package/dist/components/shared/SubTabs.d.ts.map +1 -0
  19. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  20. package/dist/components/shell/SiteSettingsChatbot.d.ts.map +1 -1
  21. package/dist/components/shell/SiteSettingsDisplay.d.ts.map +1 -1
  22. package/dist/components/shell/ViewerLoginForm.d.ts.map +1 -1
  23. package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
  24. package/dist/index.js +22 -15
  25. package/dist/lib/contrast.d.ts +19 -0
  26. package/dist/lib/contrast.d.ts.map +1 -1
  27. package/dist/lib/index.d.ts +2 -1
  28. package/dist/lib/index.d.ts.map +1 -1
  29. package/dist/lib/index.js +16 -4
  30. package/dist/lib/theme-vars.d.ts +16 -0
  31. package/dist/lib/theme-vars.d.ts.map +1 -0
  32. package/dist/media/index.js +1 -1
  33. package/dist/schemas/index.js +2 -2
  34. package/dist/schemas/site-config.d.ts +2 -0
  35. package/dist/schemas/site-config.d.ts.map +1 -1
  36. package/dist/storage/index.d.ts +1 -1
  37. package/dist/storage/index.d.ts.map +1 -1
  38. package/dist/storage/index.js +9 -0
  39. package/dist/storage/types.d.ts +14 -0
  40. package/dist/storage/types.d.ts.map +1 -1
  41. package/dist/types/database.d.ts +12 -0
  42. package/dist/types/database.d.ts.map +1 -1
  43. package/package.json +5 -1
  44. package/src/components/primitives/ResolvedMedia.tsx +6 -0
  45. package/src/components/sections/Video/VideoBlock.tsx +9 -1
  46. package/src/components/shared/SubTabs.tsx +70 -0
  47. package/src/components/shared/Tabs.tsx +1 -1
  48. package/src/components/shell/EditorShell.tsx +7 -4
  49. package/src/components/shell/SiteSettingsChatbot.tsx +38 -18
  50. package/src/components/shell/SiteSettingsDisplay.tsx +202 -31
  51. package/src/components/shell/ViewerLoginForm.tsx +12 -19
  52. package/src/hooks/useMediaPipeline.ts +4 -0
  53. package/src/lib/contrast.ts +52 -0
  54. package/src/lib/index.ts +10 -1
  55. package/src/lib/theme-vars.ts +31 -0
  56. package/src/schemas/site-config.ts +37 -5
  57. package/src/storage/index.ts +1 -1
  58. package/src/storage/types.ts +15 -0
  59. 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,6 +1,7 @@
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 {
@@ -14,6 +15,11 @@ 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
+
17
23
  // An open (passwordless) audience needs no password field — enter with one click.
18
24
  // Default to requiring a password until an audience is chosen so the field
19
25
  // doesn't flash for the "Select audience..." state.
@@ -56,25 +62,12 @@ export function ViewerLoginForm({ audiences, next }: Props) {
56
62
  return (
57
63
  <div className="mx-auto max-w-sm">
58
64
  <form onSubmit={handleSubmit} className="space-y-4">
59
- <div>
60
- <label htmlFor="audience" className="mb-1.5 block text-sm font-medium text-base-contrast">
61
- Audience
62
- </label>
63
- <select
64
- id="audience"
65
- value={audience}
66
- onChange={(e) => setAudience(e.target.value)}
67
- required
68
- className="w-full rounded border border-base-200 px-3 py-2 text-sm"
69
- >
70
- {audiences.length > 1 && <option value="">Select audience...</option>}
71
- {audiences.map((a) => (
72
- <option key={a.name} value={a.name}>
73
- {a.displayName}
74
- </option>
75
- ))}
76
- </select>
77
- </div>
65
+ <Select
66
+ label="Audience"
67
+ value={audience}
68
+ onChange={setAudience}
69
+ options={audienceOptions}
70
+ />
78
71
 
79
72
  {needsPassword && (
80
73
  <PasswordInput
@@ -13,6 +13,7 @@ import {
13
13
  removePendingMediaDeletion,
14
14
  } from "../lib/dexie";
15
15
  import { generateVideoPoster } from "../media/videoPoster";
16
+ import { ASSET_CACHE_CONTROL } from "../storage/types";
16
17
  import { tallyImageIds, collectImageIds, clearImageIds, applyLogoDeletion } from "../lib/image-refs";
17
18
  import {
18
19
  createFolderId, uniqueFolderName, effectiveParentId, canMoveFolder,
@@ -285,6 +286,9 @@ export function useMediaPipeline({
285
286
  const xhr = new XMLHttpRequest();
286
287
  xhr.open("PUT", uploadUrl);
287
288
  xhr.setRequestHeader("Content-Type", file.type);
289
+ // Without this the object is stored as `no-cache` and every later
290
+ // play is billed origin egress — see ASSET_CACHE_CONTROL.
291
+ xhr.setRequestHeader("Cache-Control", ASSET_CACHE_CONTROL);
288
292
  xhr.upload.onprogress = (e) => {
289
293
  if (e.lengthComputable) setUploadProgress((prev) => ({ ...prev, [hash as string]: e.loaded / e.total }));
290
294
  };
@@ -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
+ }
@@ -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.
@@ -1,2 +1,2 @@
1
1
  export type { StorageProvider, FileWrite, AssetStore } from "./types";
2
- export { StorageConflictError } from "./types";
2
+ export { StorageConflictError, ASSET_CACHE_CONTROL } from "./types";
@@ -39,6 +39,21 @@ export type FileWrite = {
39
39
  encoding?: "utf-8" | "base64";
40
40
  };
41
41
 
42
+ /**
43
+ * `Cache-Control` every bucket upload must set. Object paths are content-hashed
44
+ * ("media/{hash}/original.mp4"), so a given URL's bytes never change — replacing
45
+ * a video mints a new hash and therefore a new URL. That makes `immutable` safe
46
+ * and a one-year max-age correct.
47
+ *
48
+ * This is NOT cosmetic. A bucket upload sent with no `cache-control` request
49
+ * header is stored as `no-cache`, which means neither the browser nor a CDN will
50
+ * ever reuse a response — every play of every video becomes origin egress,
51
+ * including a plain page reload. That default is what exhausted the Supabase
52
+ * project's egress quota while the bucket sat at 46% of its size limit. Any new
53
+ * upload path MUST send this header.
54
+ */
55
+ export const ASSET_CACHE_CONTROL = "max-age=31536000, immutable";
56
+
42
57
  /**
43
58
  * Optional large-asset store (Supabase Storage bucket). Holds video originals
44
59
  * too big for the git path; deliberately generic so future large kinds (zips)
@@ -737,6 +737,16 @@ export type Database = {
737
737
  Returns: undefined
738
738
  }
739
739
  collab_site_member: { Args: { p_site_id: string }; Returns: boolean }
740
+ get_site_users_with_status: {
741
+ Args: { p_site_id: string }
742
+ Returns: {
743
+ created_at: string | null
744
+ email: string
745
+ last_sign_in_at: string | null
746
+ role: string
747
+ user_id: string
748
+ }[]
749
+ }
740
750
  get_user_id_by_email: { Args: { lookup_email: string }; Returns: string }
741
751
  heartbeat_section_locks: {
742
752
  Args: { p_section_ids: string[]; p_site_id: string }