@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.
Files changed (43) hide show
  1. package/dist/{chunk-Z66TBL4Q.js → chunk-3HJDMFTB.js} +1 -1
  2. package/dist/{chunk-A2V7BZPD.js → chunk-HLKAQLTV.js} +1 -1
  3. package/dist/{chunk-7QEJDYTG.js → chunk-RBKBNQGB.js} +3 -3
  4. package/dist/{chunk-RUKO2N3F.js → chunk-RHWWQV3J.js} +30 -5
  5. package/dist/{chunk-WOYGQK2D.js → chunk-UFOWXOG6.js} +49 -1
  6. package/dist/{chunk-IZLXF3CR.js → chunk-XYFZ57AS.js} +1 -1
  7. package/dist/components/editor/index.js +2 -2
  8. package/dist/components/primitives/index.js +1 -1
  9. package/dist/components/sections/brand-guide.js +2 -2
  10. package/dist/components/sections/register-schemas.js +4 -4
  11. package/dist/components/sections/register.js +4 -4
  12. package/dist/components/shared/SubTabs.d.ts +22 -0
  13. package/dist/components/shared/SubTabs.d.ts.map +1 -0
  14. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  15. package/dist/components/shell/SiteSettingsChatbot.d.ts.map +1 -1
  16. package/dist/components/shell/SiteSettingsDisplay.d.ts.map +1 -1
  17. package/dist/components/shell/ViewerLoginForm.d.ts.map +1 -1
  18. package/dist/index.js +17 -5
  19. package/dist/lib/contrast.d.ts +19 -0
  20. package/dist/lib/contrast.d.ts.map +1 -1
  21. package/dist/lib/index.d.ts +2 -1
  22. package/dist/lib/index.d.ts.map +1 -1
  23. package/dist/lib/index.js +16 -4
  24. package/dist/lib/theme-vars.d.ts +16 -0
  25. package/dist/lib/theme-vars.d.ts.map +1 -0
  26. package/dist/media/index.js +1 -1
  27. package/dist/schemas/index.js +2 -2
  28. package/dist/schemas/site-config.d.ts +2 -0
  29. package/dist/schemas/site-config.d.ts.map +1 -1
  30. package/dist/types/database.d.ts +12 -0
  31. package/dist/types/database.d.ts.map +1 -1
  32. package/package.json +1 -1
  33. package/src/components/shared/SubTabs.tsx +70 -0
  34. package/src/components/shared/Tabs.tsx +1 -1
  35. package/src/components/shell/EditorShell.tsx +7 -4
  36. package/src/components/shell/SiteSettingsChatbot.tsx +38 -18
  37. package/src/components/shell/SiteSettingsDisplay.tsx +202 -31
  38. package/src/components/shell/ViewerLoginForm.tsx +12 -19
  39. package/src/lib/contrast.ts +52 -0
  40. package/src/lib/index.ts +10 -1
  41. package/src/lib/theme-vars.ts +31 -0
  42. package/src/schemas/site-config.ts +37 -5
  43. 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
- <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
@@ -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.
@@ -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 }