@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.
- package/dist/{chunk-MK7YB2A4.js → chunk-3HJDMFTB.js} +1 -1
- package/dist/{chunk-NV4R6VHN.js → chunk-CI624HRS.js} +1 -1
- package/dist/{chunk-XUIJHYKI.js → chunk-HLKAQLTV.js} +3 -3
- package/dist/{chunk-XKRQMTE4.js → chunk-LQQY2RW6.js} +2 -2
- package/dist/{chunk-ZLG7S54V.js → chunk-RBKBNQGB.js} +6 -6
- package/dist/{chunk-KMQZYORS.js → chunk-RHWWQV3J.js} +35 -6
- package/dist/{chunk-CZ3YXGK5.js → chunk-SOILPZ7U.js} +27 -3
- package/dist/{chunk-AGWE2LJG.js → chunk-UFOWXOG6.js} +49 -1
- package/dist/{chunk-SIYQ37QY.js → chunk-XYFZ57AS.js} +2 -2
- package/dist/{chunk-KBQNJZUX.js → chunk-YI2HUFE3.js} +1 -1
- package/dist/components/editor/index.js +4 -4
- package/dist/components/primitives/index.js +5 -5
- package/dist/components/sections/brand-guide.js +5 -5
- package/dist/components/sections/register-schemas.js +8 -8
- package/dist/components/sections/register.js +8 -8
- 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 +1 -0
- package/dist/components/shell/ViewerLoginForm.d.ts.map +1 -1
- package/dist/index.js +18 -6
- 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/compose-preview.d.ts +9 -0
- package/dist/media/compose-preview.d.ts.map +1 -1
- package/dist/media/index.js +2 -2
- package/dist/media/preview-inputs.d.ts.map +1 -1
- package/dist/schemas/index.js +2 -2
- package/dist/schemas/site-config.d.ts +3 -0
- package/dist/schemas/site-config.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 +1 -1
- package/src/components/shared/SubTabs.tsx +70 -0
- package/src/components/shared/Tabs.tsx +1 -1
- package/src/components/shell/EditorShell.tsx +24 -10
- package/src/components/shell/SiteSettingsChatbot.tsx +38 -18
- package/src/components/shell/SiteSettingsDisplay.tsx +202 -31
- package/src/components/shell/ViewerLoginForm.tsx +31 -27
- package/src/lib/contrast.ts +52 -0
- package/src/lib/index.ts +10 -1
- package/src/lib/theme-vars.ts +31 -0
- package/src/media/compose-preview.ts +56 -4
- package/src/media/preview-inputs.ts +3 -1
- package/src/schemas/site-config.ts +41 -5
- 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,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 } = {
|
|
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
|
-
<
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
)}
|
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
|
+
}
|
|
@@ -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
|
-
//
|
|
64
|
-
//
|
|
65
|
-
await
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
@@ -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),
|