@drawnagency/primitives 0.1.92 → 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-Z66TBL4Q.js → chunk-3HJDMFTB.js} +1 -1
- package/dist/{chunk-A2V7BZPD.js → chunk-HLKAQLTV.js} +1 -1
- package/dist/{chunk-7QEJDYTG.js → chunk-RBKBNQGB.js} +3 -3
- package/dist/{chunk-RUKO2N3F.js → chunk-RHWWQV3J.js} +30 -5
- package/dist/{chunk-WOYGQK2D.js → chunk-UFOWXOG6.js} +49 -1
- package/dist/{chunk-IZLXF3CR.js → chunk-XYFZ57AS.js} +1 -1
- package/dist/components/editor/index.js +2 -2
- package/dist/components/primitives/index.js +1 -1
- package/dist/components/sections/brand-guide.js +2 -2
- package/dist/components/sections/register-schemas.js +4 -4
- package/dist/components/sections/register.js +4 -4
- 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/index.js +17 -5
- 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/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 +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/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/types/database.ts +10 -0
|
@@ -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
|
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/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 }
|