@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.
- package/dist/{chunk-Z66TBL4Q.js → chunk-3HJDMFTB.js} +1 -1
- package/dist/{chunk-LQQY2RW6.js → chunk-DJAJRBPD.js} +1 -0
- package/dist/{chunk-7QEJDYTG.js → chunk-GDLH3LVH.js} +6 -6
- package/dist/{chunk-RUKO2N3F.js → chunk-RHWWQV3J.js} +30 -5
- package/dist/{chunk-YI2HUFE3.js → chunk-RQPHPAT2.js} +1 -1
- package/dist/{chunk-WOYGQK2D.js → chunk-UFOWXOG6.js} +49 -1
- package/dist/chunk-VAOW4WXQ.js +15 -0
- package/dist/{chunk-IZLXF3CR.js → chunk-XYFZ57AS.js} +1 -1
- package/dist/{chunk-A2V7BZPD.js → chunk-ZKGMLLUQ.js} +2 -2
- package/dist/components/editor/index.js +2 -2
- package/dist/components/primitives/ResolvedMedia.d.ts.map +1 -1
- package/dist/components/primitives/index.js +3 -3
- package/dist/components/sections/Video/VideoBlock.d.ts.map +1 -1
- package/dist/components/sections/brand-guide.js +3 -3
- package/dist/components/sections/register-schemas.js +6 -6
- package/dist/components/sections/register.js +6 -6
- package/dist/components/shared/SubTabs.d.ts +22 -0
- package/dist/components/shared/SubTabs.d.ts.map +1 -0
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/components/shell/SiteSettingsChatbot.d.ts.map +1 -1
- package/dist/components/shell/SiteSettingsDisplay.d.ts.map +1 -1
- package/dist/components/shell/ViewerLoginForm.d.ts.map +1 -1
- package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
- package/dist/index.js +22 -15
- package/dist/lib/contrast.d.ts +19 -0
- package/dist/lib/contrast.d.ts.map +1 -1
- package/dist/lib/index.d.ts +2 -1
- package/dist/lib/index.d.ts.map +1 -1
- package/dist/lib/index.js +16 -4
- package/dist/lib/theme-vars.d.ts +16 -0
- package/dist/lib/theme-vars.d.ts.map +1 -0
- package/dist/media/index.js +1 -1
- package/dist/schemas/index.js +2 -2
- package/dist/schemas/site-config.d.ts +2 -0
- package/dist/schemas/site-config.d.ts.map +1 -1
- package/dist/storage/index.d.ts +1 -1
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/index.js +9 -0
- package/dist/storage/types.d.ts +14 -0
- package/dist/storage/types.d.ts.map +1 -1
- package/dist/types/database.d.ts +12 -0
- package/dist/types/database.d.ts.map +1 -1
- package/package.json +5 -1
- package/src/components/primitives/ResolvedMedia.tsx +6 -0
- package/src/components/sections/Video/VideoBlock.tsx +9 -1
- package/src/components/shared/SubTabs.tsx +70 -0
- package/src/components/shared/Tabs.tsx +1 -1
- package/src/components/shell/EditorShell.tsx +7 -4
- package/src/components/shell/SiteSettingsChatbot.tsx +38 -18
- package/src/components/shell/SiteSettingsDisplay.tsx +202 -31
- package/src/components/shell/ViewerLoginForm.tsx +12 -19
- package/src/hooks/useMediaPipeline.ts +4 -0
- package/src/lib/contrast.ts +52 -0
- package/src/lib/index.ts +10 -1
- package/src/lib/theme-vars.ts +31 -0
- package/src/schemas/site-config.ts +37 -5
- package/src/storage/index.ts +1 -1
- package/src/storage/types.ts +15 -0
- 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 {
|
|
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
|
|
86
|
-
update({ primaryContrast:
|
|
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
|
-
|
|
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>
|
|
106
|
-
<
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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 & 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
|
-
<
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
};
|
package/src/lib/contrast.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
138
|
-
|
|
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.
|
package/src/storage/index.ts
CHANGED
|
@@ -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";
|
package/src/storage/types.ts
CHANGED
|
@@ -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)
|
package/src/types/database.ts
CHANGED
|
@@ -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 }
|