@drawnagency/primitives 0.1.66 → 0.1.68
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-4YEOTFVU.js +14 -0
- package/dist/{chunk-VU3XR362.js → chunk-6UDYJQL6.js} +1 -1
- package/dist/{chunk-7IMQVDDJ.js → chunk-EWKCEPMW.js} +1 -1
- package/dist/{chunk-XNGG5AGI.js → chunk-KWBM27UR.js} +1 -1
- package/dist/{chunk-TEYTLQVH.js → chunk-QKOIEWZ4.js} +33 -1
- package/dist/chunk-TYYCKVAW.js +968 -0
- package/dist/{chunk-VEIDANEU.js → chunk-VVK6ZRCN.js} +128 -41
- package/dist/{chunk-ZYXLNJ5M.js → chunk-WNPMBFD6.js} +4 -4
- package/dist/{chunk-DQTKWKSK.js → chunk-WXVKX6PY.js} +12 -3
- package/dist/components/chat/ChatPanel.d.ts +31 -0
- package/dist/components/chat/ChatPanel.d.ts.map +1 -0
- package/dist/components/chat/ChatWidget.d.ts +11 -0
- package/dist/components/chat/ChatWidget.d.ts.map +1 -0
- package/dist/components/primitives/defineHeadingSection.d.ts +2 -0
- package/dist/components/primitives/defineHeadingSection.d.ts.map +1 -1
- package/dist/components/primitives/index.js +5 -5
- package/dist/components/sections/Button/index.d.ts.map +1 -1
- package/dist/components/sections/Colors/index.d.ts.map +1 -1
- package/dist/components/sections/Colors/schema.d.ts.map +1 -1
- package/dist/components/sections/Container/index.d.ts.map +1 -1
- package/dist/components/sections/DoDontMedia/index.d.ts.map +1 -1
- package/dist/components/sections/IconList/IconList.d.ts +2 -1
- package/dist/components/sections/IconList/IconList.d.ts.map +1 -1
- package/dist/components/sections/IconList/IconListSettings.d.ts +2 -1
- package/dist/components/sections/IconList/IconListSettings.d.ts.map +1 -1
- package/dist/components/sections/IconList/index.d.ts +1 -0
- package/dist/components/sections/IconList/index.d.ts.map +1 -1
- package/dist/components/sections/LinkHeading/index.d.ts.map +1 -1
- package/dist/components/sections/Media/index.d.ts.map +1 -1
- package/dist/components/sections/Prose/index.d.ts.map +1 -1
- package/dist/components/sections/Spacer/index.d.ts.map +1 -1
- package/dist/components/sections/SubHeading/index.d.ts.map +1 -1
- package/dist/components/sections/SubSubHeading/index.d.ts.map +1 -1
- package/dist/components/sections/all-sections.d.ts +1 -0
- package/dist/components/sections/all-sections.d.ts.map +1 -1
- package/dist/components/sections/brand-guide.d.ts +1 -0
- package/dist/components/sections/brand-guide.d.ts.map +1 -1
- package/dist/components/sections/brand-guide.js +5 -5
- package/dist/components/sections/register-schemas.js +13 -943
- package/dist/components/sections/register.js +36 -0
- package/dist/components/shell/ChatbotConfigModal.d.ts +13 -0
- package/dist/components/shell/ChatbotConfigModal.d.ts.map +1 -0
- package/dist/components/shell/EditorShell.d.ts +2 -1
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/index.js +31 -24
- package/dist/lib/index.d.ts +1 -0
- package/dist/lib/index.d.ts.map +1 -1
- package/dist/lib/index.js +7 -4
- package/dist/lib/insert-menu.d.ts +19 -0
- package/dist/lib/insert-menu.d.ts.map +1 -0
- package/dist/lib/platform-broker.d.ts +7 -0
- package/dist/lib/platform-broker.d.ts.map +1 -1
- package/dist/lib/platform-broker.js +27 -1
- package/dist/lib/registry.d.ts +23 -0
- package/dist/lib/registry.d.ts.map +1 -1
- package/dist/media/index.js +3 -3
- package/dist/schemas/index.js +6 -2
- package/dist/schemas/site-config.d.ts +30 -0
- package/dist/schemas/site-config.d.ts.map +1 -1
- package/dist/types/database.d.ts +296 -34
- package/dist/types/database.d.ts.map +1 -1
- package/package.json +5 -1
- package/src/components/chat/ChatPanel.tsx +152 -0
- package/src/components/chat/ChatWidget.tsx +38 -0
- package/src/components/primitives/defineHeadingSection.tsx +18 -2
- package/src/components/sections/Button/index.tsx +10 -3
- package/src/components/sections/Colors/index.tsx +15 -7
- package/src/components/sections/Colors/schema.ts +5 -2
- package/src/components/sections/Container/index.tsx +19 -3
- package/src/components/sections/DoDontMedia/index.tsx +18 -7
- package/src/components/sections/IconList/IconList.tsx +43 -19
- package/src/components/sections/IconList/IconListSettings.tsx +58 -11
- package/src/components/sections/IconList/index.tsx +51 -7
- package/src/components/sections/LinkHeading/index.tsx +2 -0
- package/src/components/sections/Media/index.tsx +15 -6
- package/src/components/sections/Prose/index.tsx +9 -1
- package/src/components/sections/Spacer/index.tsx +2 -0
- package/src/components/sections/SubHeading/index.tsx +2 -0
- package/src/components/sections/SubSubHeading/index.tsx +2 -0
- package/src/components/shell/ChatbotConfigModal.tsx +152 -0
- package/src/components/shell/EditorShell.tsx +41 -20
- package/src/lib/index.ts +1 -0
- package/src/lib/insert-menu.ts +42 -0
- package/src/lib/platform-broker.ts +36 -0
- package/src/lib/registry.ts +24 -0
- package/src/schemas/site-config.ts +36 -0
- package/src/types/database.ts +284 -36
- package/dist/chunk-ZGFAYZWB.js +0 -8
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { useRef, useState } from "react";
|
|
2
|
+
import { cn } from "../../lib/cn";
|
|
3
|
+
|
|
4
|
+
export interface ChatMessage { role: "user" | "assistant"; content: string }
|
|
5
|
+
|
|
6
|
+
const SESSION_KEY = "bp-chat-session";
|
|
7
|
+
|
|
8
|
+
function sessionId(): string {
|
|
9
|
+
let id = window.localStorage.getItem(SESSION_KEY);
|
|
10
|
+
if (!id) {
|
|
11
|
+
id = crypto.randomUUID();
|
|
12
|
+
window.localStorage.setItem(SESSION_KEY, id);
|
|
13
|
+
}
|
|
14
|
+
return id;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const ERROR_COPY: Record<string, string> = {
|
|
18
|
+
rate_limited: "You've hit the message limit — try again in a minute.",
|
|
19
|
+
disabled: "Chat isn't available right now.",
|
|
20
|
+
not_authorized: "You don't have access to chat on this site.",
|
|
21
|
+
upstream_error: "Something went wrong — please try again.",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Parse our /api/chat SSE frames out of a fetch body. Exported for tests. */
|
|
25
|
+
export async function streamChat(
|
|
26
|
+
body: { messages: ChatMessage[]; sessionId: string; taskId?: string; draftChatbot?: unknown },
|
|
27
|
+
onText: (text: string) => void,
|
|
28
|
+
): Promise<{ ok: true; truncated: boolean } | { ok: false; error: string; retryAfterMs?: number }> {
|
|
29
|
+
try {
|
|
30
|
+
const res = await fetch("/api/chat", {
|
|
31
|
+
method: "POST",
|
|
32
|
+
headers: { "Content-Type": "application/json" },
|
|
33
|
+
body: JSON.stringify(body),
|
|
34
|
+
});
|
|
35
|
+
if (!res.ok || !res.body) {
|
|
36
|
+
const err = await res.json().catch(() => ({ error: "upstream_error" }));
|
|
37
|
+
return { ok: false, error: err.error ?? "upstream_error", retryAfterMs: err.retryAfterMs };
|
|
38
|
+
}
|
|
39
|
+
const reader = res.body.getReader();
|
|
40
|
+
const decoder = new TextDecoder();
|
|
41
|
+
let buffer = "";
|
|
42
|
+
let truncated = false;
|
|
43
|
+
let errored: string | null = null;
|
|
44
|
+
for (;;) {
|
|
45
|
+
const { done, value } = await reader.read();
|
|
46
|
+
if (done) break;
|
|
47
|
+
buffer += decoder.decode(value, { stream: true });
|
|
48
|
+
let sep: number;
|
|
49
|
+
while ((sep = buffer.indexOf("\n\n")) !== -1) {
|
|
50
|
+
const frame = buffer.slice(0, sep);
|
|
51
|
+
buffer = buffer.slice(sep + 2);
|
|
52
|
+
const event = /^event: (.+)$/m.exec(frame)?.[1];
|
|
53
|
+
const dataRaw = /^data: (.+)$/m.exec(frame)?.[1];
|
|
54
|
+
if (!event || !dataRaw) continue;
|
|
55
|
+
try {
|
|
56
|
+
const data = JSON.parse(dataRaw);
|
|
57
|
+
if (event === "text") onText(data.text);
|
|
58
|
+
else if (event === "done") truncated = data.truncated === true;
|
|
59
|
+
else if (event === "error") errored = data.error ?? "upstream_error";
|
|
60
|
+
} catch { /* malformed frame — skip */ }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return errored ? { ok: false, error: errored } : { ok: true, truncated };
|
|
64
|
+
} catch {
|
|
65
|
+
// Network failure or a mid-stream reader rejection (upstream abort) — never
|
|
66
|
+
// let this propagate out of send(), or `busy` sticks true forever.
|
|
67
|
+
return { ok: false, error: "upstream_error" };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export default function ChatPanel({ open, onClose, siteName, tasks, isEditor, draftChatbot }: {
|
|
72
|
+
open: boolean;
|
|
73
|
+
onClose: () => void;
|
|
74
|
+
siteName: string;
|
|
75
|
+
tasks: { id: string; label: string }[];
|
|
76
|
+
isEditor?: boolean;
|
|
77
|
+
/** Set only by the configurator's test panel. */
|
|
78
|
+
draftChatbot?: unknown;
|
|
79
|
+
}) {
|
|
80
|
+
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
81
|
+
const [taskId, setTaskId] = useState<string>("");
|
|
82
|
+
const [input, setInput] = useState("");
|
|
83
|
+
const [busy, setBusy] = useState(false);
|
|
84
|
+
const [notice, setNotice] = useState<string | null>(null);
|
|
85
|
+
const streamingRef = useRef("");
|
|
86
|
+
|
|
87
|
+
async function send() {
|
|
88
|
+
const text = input.trim();
|
|
89
|
+
if (!text || busy) return;
|
|
90
|
+
setNotice(null);
|
|
91
|
+
setInput("");
|
|
92
|
+
setBusy(true);
|
|
93
|
+
const history: ChatMessage[] = [...messages, { role: "user", content: text }];
|
|
94
|
+
setMessages([...history, { role: "assistant", content: "" }]);
|
|
95
|
+
streamingRef.current = "";
|
|
96
|
+
const result = await streamChat(
|
|
97
|
+
{ messages: history, sessionId: sessionId(), ...(taskId ? { taskId } : {}), ...(draftChatbot !== undefined ? { draftChatbot } : {}) },
|
|
98
|
+
(delta) => {
|
|
99
|
+
streamingRef.current += delta;
|
|
100
|
+
const streamed = streamingRef.current;
|
|
101
|
+
setMessages([...history, { role: "assistant", content: streamed }]);
|
|
102
|
+
},
|
|
103
|
+
);
|
|
104
|
+
setBusy(false);
|
|
105
|
+
if (!result.ok) {
|
|
106
|
+
setMessages(history); // drop the empty assistant stub
|
|
107
|
+
setNotice(ERROR_COPY[result.error] ?? ERROR_COPY.upstream_error);
|
|
108
|
+
} else if (result.truncated) {
|
|
109
|
+
setNotice("Some brand content didn't fit — answers may be incomplete.");
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!open) return null;
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<div role="dialog" aria-label={`Chat with ${siteName}`}
|
|
117
|
+
className="fixed bottom-20 right-4 z-40 flex h-[28rem] w-80 flex-col rounded-lg border border-base-200 bg-base shadow-xl">
|
|
118
|
+
<div className="flex items-center justify-between gap-2 border-b border-base-200 px-3 py-2">
|
|
119
|
+
<span className="text-sm font-medium text-base-contrast">{siteName} assistant</span>
|
|
120
|
+
{tasks.length > 0 && (
|
|
121
|
+
<select aria-label="Task" value={taskId} onChange={(e) => setTaskId(e.target.value)}
|
|
122
|
+
className="max-w-36 rounded border border-base-200 bg-base-accent px-1 py-0.5 text-xs">
|
|
123
|
+
<option value="">General</option>
|
|
124
|
+
{tasks.map((t) => <option key={t.id} value={t.id}>{t.label}</option>)}
|
|
125
|
+
</select>
|
|
126
|
+
)}
|
|
127
|
+
<button type="button" aria-label="Close chat" onClick={onClose} className="text-base-contrast-light hover:text-base-contrast">×</button>
|
|
128
|
+
</div>
|
|
129
|
+
<div className="flex-1 space-y-2 overflow-y-auto p-3">
|
|
130
|
+
{messages.map((m, i) => (
|
|
131
|
+
<div key={i} className={cn("max-w-[85%] whitespace-pre-wrap rounded-lg px-3 py-2 text-sm",
|
|
132
|
+
m.role === "user" ? "ml-auto bg-primary text-primary-contrast" : "bg-base-accent text-base-contrast")}>
|
|
133
|
+
{m.content || (busy && i === messages.length - 1 ? "…" : m.content)}
|
|
134
|
+
</div>
|
|
135
|
+
))}
|
|
136
|
+
{notice && <p className="text-xs text-base-contrast-light">{notice}</p>}
|
|
137
|
+
</div>
|
|
138
|
+
<form className="flex gap-2 border-t border-base-200 p-2" onSubmit={(e) => { e.preventDefault(); void send(); }}>
|
|
139
|
+
<input
|
|
140
|
+
value={input}
|
|
141
|
+
onChange={(e) => setInput(e.target.value)}
|
|
142
|
+
placeholder={`Ask about ${siteName}…`}
|
|
143
|
+
maxLength={4000}
|
|
144
|
+
className="min-w-0 flex-1 rounded border border-base-200 bg-base px-2 py-1 text-sm text-base-contrast"
|
|
145
|
+
/>
|
|
146
|
+
<button type="submit" aria-label="Send" disabled={busy}
|
|
147
|
+
className="rounded bg-primary px-3 py-1 text-sm text-primary-contrast disabled:opacity-50">→</button>
|
|
148
|
+
</form>
|
|
149
|
+
{isEditor && <p className="border-t border-base-200 px-3 py-1 text-[10px] text-base-contrast-light">Editor view — answers ground on published content.</p>}
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Viewer-facing chat launcher. Renders ONLY the floating button until first
|
|
2
|
+
// open; the panel (and its code) loads lazily so viewers who never click pay
|
|
3
|
+
// almost nothing. Browser-safe: no node imports, no process.env.
|
|
4
|
+
import { lazy, Suspense, useState } from "react";
|
|
5
|
+
|
|
6
|
+
const ChatPanel = lazy(() => import("./ChatPanel"));
|
|
7
|
+
|
|
8
|
+
export interface ChatWidgetProps {
|
|
9
|
+
siteName: string;
|
|
10
|
+
/** Already audience-filtered server-side — never pass unfiltered config tasks. */
|
|
11
|
+
tasks: { id: string; label: string }[];
|
|
12
|
+
isEditor?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default function ChatWidget({ siteName, tasks, isEditor }: ChatWidgetProps) {
|
|
16
|
+
const [open, setOpen] = useState(false);
|
|
17
|
+
const [everOpened, setEverOpened] = useState(false);
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<>
|
|
21
|
+
<button
|
|
22
|
+
type="button"
|
|
23
|
+
aria-label={`Chat with ${siteName}`}
|
|
24
|
+
onClick={() => { setOpen((o) => !o); setEverOpened(true); }}
|
|
25
|
+
className="fixed bottom-4 right-4 z-40 flex h-12 w-12 items-center justify-center rounded-full bg-primary text-primary-contrast shadow-lg hover:opacity-90 transition-opacity"
|
|
26
|
+
>
|
|
27
|
+
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
28
|
+
<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z" />
|
|
29
|
+
</svg>
|
|
30
|
+
</button>
|
|
31
|
+
{everOpened && (
|
|
32
|
+
<Suspense fallback={null}>
|
|
33
|
+
<ChatPanel open={open} onClose={() => setOpen(false)} siteName={siteName} tasks={tasks} isEditor={isEditor} />
|
|
34
|
+
</Suspense>
|
|
35
|
+
)}
|
|
36
|
+
</>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -15,6 +15,8 @@ interface HeadingSectionConfig<T extends string> {
|
|
|
15
15
|
defaultHeading: string;
|
|
16
16
|
/** When true, adds the `excludeFromNav` content field plus its settings toggle. */
|
|
17
17
|
navExclusion?: boolean;
|
|
18
|
+
/** Per-type authoring note surfaced by get_section_schema. */
|
|
19
|
+
authoringNote: string;
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
/**
|
|
@@ -24,9 +26,22 @@ interface HeadingSectionConfig<T extends string> {
|
|
|
24
26
|
* shared HeadingSection primitive through the identical edit/view plumbing.
|
|
25
27
|
*/
|
|
26
28
|
export function defineHeadingSection<T extends string>(config: HeadingSectionConfig<T>) {
|
|
29
|
+
const heading = z
|
|
30
|
+
.string()
|
|
31
|
+
.describe(
|
|
32
|
+
"The heading text. Plain text, one line; keep concise — it appears verbatim in the on-page heading and (unless excluded) the navigation.",
|
|
33
|
+
);
|
|
27
34
|
const content = config.navExclusion
|
|
28
|
-
? z.object({
|
|
29
|
-
|
|
35
|
+
? z.object({
|
|
36
|
+
heading,
|
|
37
|
+
excludeFromNav: z
|
|
38
|
+
.boolean()
|
|
39
|
+
.describe(
|
|
40
|
+
"When true, this heading is omitted from the generated navigation tree while still rendering on the page.",
|
|
41
|
+
)
|
|
42
|
+
.optional(),
|
|
43
|
+
})
|
|
44
|
+
: z.object({ heading });
|
|
30
45
|
const schema = z.object({ type: z.literal(config.type), content });
|
|
31
46
|
|
|
32
47
|
return defineSection({
|
|
@@ -35,6 +50,7 @@ export function defineHeadingSection<T extends string>(config: HeadingSectionCon
|
|
|
35
50
|
icon: config.icon,
|
|
36
51
|
schema,
|
|
37
52
|
navRole: config.navRole,
|
|
53
|
+
authoringNote: config.authoringNote,
|
|
38
54
|
component: ({ content, onChange }) => (
|
|
39
55
|
<HeadingSection
|
|
40
56
|
heading={content.content.heading}
|
|
@@ -7,9 +7,14 @@ import CTAButton from "./CTAButton";
|
|
|
7
7
|
const schema = z.object({
|
|
8
8
|
type: z.literal("button"),
|
|
9
9
|
content: z.object({
|
|
10
|
-
text: z.string(),
|
|
11
|
-
link: LinkValueSchema.
|
|
12
|
-
|
|
10
|
+
text: z.string().describe("The button label. Plain text, short (a few words)."),
|
|
11
|
+
link: LinkValueSchema.describe(
|
|
12
|
+
"Where the button points — an internal page or a safe external URL.",
|
|
13
|
+
).optional(),
|
|
14
|
+
download: z
|
|
15
|
+
.boolean()
|
|
16
|
+
.describe("When true, the link is treated as a file download rather than navigation.")
|
|
17
|
+
.optional(),
|
|
13
18
|
}),
|
|
14
19
|
});
|
|
15
20
|
|
|
@@ -18,6 +23,8 @@ export default defineSection({
|
|
|
18
23
|
label: "Button",
|
|
19
24
|
icon: <RectangleHorizontal size={18} />,
|
|
20
25
|
schema,
|
|
26
|
+
authoringNote:
|
|
27
|
+
"A call-to-action button linking to an internal page or external URL, optionally as a file download. Use for a single prominent action.",
|
|
21
28
|
component: ({ content, onChange }) => (
|
|
22
29
|
<CTAButton
|
|
23
30
|
text={content.content.text}
|
|
@@ -7,13 +7,19 @@ import { pluralize } from "../../../lib/text";
|
|
|
7
7
|
|
|
8
8
|
const schema = z.object({
|
|
9
9
|
type: z.literal("colors"),
|
|
10
|
-
content: z.object({
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
10
|
+
content: z.object({
|
|
11
|
+
colors: z
|
|
12
|
+
.array(ColorItemSchema)
|
|
13
|
+
.describe("The list of brand colors, each with one or more color-space values (hex/rgb/cmyk/pantone)."),
|
|
14
|
+
}),
|
|
15
|
+
options: z
|
|
16
|
+
.object({
|
|
17
|
+
label: z.string().describe("Optional heading shown above the palette.").optional(),
|
|
18
|
+
columns: z.number().int().min(2).max(4).describe("Number of swatch columns (2–4).").optional(),
|
|
19
|
+
collapsing: z.boolean().describe("When true, use the compact collapsing swatch layout.").optional(),
|
|
20
|
+
showLabel: z.boolean().describe("When true, show the palette label.").optional(),
|
|
21
|
+
})
|
|
22
|
+
.optional(),
|
|
17
23
|
});
|
|
18
24
|
|
|
19
25
|
export default defineSection({
|
|
@@ -22,6 +28,8 @@ export default defineSection({
|
|
|
22
28
|
label: "Colors",
|
|
23
29
|
icon: <Palette size={18} />,
|
|
24
30
|
schema,
|
|
31
|
+
authoringNote:
|
|
32
|
+
"A brand color palette. Each color may carry hex/rgb/cmyk/pantone values. Use to document brand colors with swatches.",
|
|
25
33
|
component: ({ content, options, onChange, openModal }) => (
|
|
26
34
|
<Colors
|
|
27
35
|
colors={content.content.colors}
|
|
@@ -17,8 +17,11 @@ export const ColorSpaceSchema = z.object({
|
|
|
17
17
|
export type ColorSpace = z.infer<typeof ColorSpaceSchema>;
|
|
18
18
|
|
|
19
19
|
export const ColorItemSchema = z.object({
|
|
20
|
-
name: z.string().optional(),
|
|
21
|
-
spaces: z
|
|
20
|
+
name: z.string().describe("Optional display name for this color (e.g. 'Brand Primary').").optional(),
|
|
21
|
+
spaces: z
|
|
22
|
+
.array(ColorSpaceSchema)
|
|
23
|
+
.describe("One or more color-space definitions (hex/rgb/cmyk/pantone) for this color.")
|
|
24
|
+
.min(1),
|
|
22
25
|
});
|
|
23
26
|
|
|
24
27
|
export type ColorItem = z.infer<typeof ColorItemSchema>;
|
|
@@ -11,9 +11,23 @@ const schema = z.object({
|
|
|
11
11
|
type: z.literal("container"),
|
|
12
12
|
content: z
|
|
13
13
|
.object({
|
|
14
|
-
columns: z
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
columns: z
|
|
15
|
+
.number()
|
|
16
|
+
.int()
|
|
17
|
+
.min(1)
|
|
18
|
+
.max(MAX_CONTAINER_COLUMNS)
|
|
19
|
+
.describe("Number of columns in the grid. Children flow across this many columns.")
|
|
20
|
+
.default(1),
|
|
21
|
+
flow: z
|
|
22
|
+
.enum(["row", "column"])
|
|
23
|
+
.describe(
|
|
24
|
+
"Layout direction: 'row' (children flow left-to-right across columns) or 'column' (stacked).",
|
|
25
|
+
)
|
|
26
|
+
.default("row"),
|
|
27
|
+
childDefaults: z
|
|
28
|
+
.record(z.string(), z.unknown())
|
|
29
|
+
.describe("Optional default option values applied to child sections (inheritable settings only).")
|
|
30
|
+
.optional(),
|
|
17
31
|
// Recursive: children are full blocks. z.lazy defers evaluation to parse
|
|
18
32
|
// time, by which point every section schema (incl. container) is registered.
|
|
19
33
|
children: z.array(z.lazy(() => getSectionSchema())).default([]),
|
|
@@ -37,6 +51,8 @@ export default defineSection({
|
|
|
37
51
|
label: "Container",
|
|
38
52
|
icon: <LayoutTemplate size={18} />,
|
|
39
53
|
schema,
|
|
54
|
+
authoringNote:
|
|
55
|
+
"A layout wrapper that arranges child sections into columns/rows. Use to place blocks side by side or build multi-column layouts; set columns and add children.",
|
|
40
56
|
component: ({ content, onChange, isEditMode, openModal }) => (
|
|
41
57
|
<Container content={content as never} isEditMode={!!isEditMode} onChange={onChange as never} openModal={openModal} />
|
|
42
58
|
),
|
|
@@ -8,16 +8,25 @@ import { DoDontMediaBlock } from "./DoDontMediaBlock";
|
|
|
8
8
|
const schema = z.object({
|
|
9
9
|
type: z.literal("dodont_media"),
|
|
10
10
|
content: z.object({
|
|
11
|
-
ref: SingleMediaReferenceSchema
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
ref: SingleMediaReferenceSchema.describe(
|
|
12
|
+
"The image or video asset to display (a media reference by id). Required.",
|
|
13
|
+
),
|
|
14
|
+
link: LinkValueSchema.describe(
|
|
15
|
+
"Optional link wrapping the media — an internal page or a safe external URL.",
|
|
16
|
+
).optional(),
|
|
17
|
+
dodont: z
|
|
18
|
+
.enum(["do", "dont"])
|
|
19
|
+
.describe("Whether this is a 'do' (correct) or 'dont' (incorrect) example."),
|
|
14
20
|
}),
|
|
15
21
|
options: z
|
|
16
22
|
.object({
|
|
17
|
-
square: z.boolean().optional(),
|
|
18
|
-
showCaption: z.boolean().optional(),
|
|
19
|
-
border: z.boolean().optional(),
|
|
20
|
-
objectFit: z
|
|
23
|
+
square: z.boolean().describe("When true, crop the media to a 1:1 square aspect ratio.").optional(),
|
|
24
|
+
showCaption: z.boolean().describe("When true, render the asset's caption beneath it.").optional(),
|
|
25
|
+
border: z.boolean().describe("When true, draw a border around the media.").optional(),
|
|
26
|
+
objectFit: z
|
|
27
|
+
.enum(["cover", "contain"])
|
|
28
|
+
.describe("How the image fills its box: 'contain' (fit whole image) or 'cover' (fill and crop).")
|
|
29
|
+
.optional(),
|
|
21
30
|
})
|
|
22
31
|
.default({}),
|
|
23
32
|
});
|
|
@@ -28,6 +37,8 @@ export default defineSection({
|
|
|
28
37
|
category: "brand-guide",
|
|
29
38
|
icon: <ImageIcon size={18} />,
|
|
30
39
|
schema,
|
|
40
|
+
authoringNote:
|
|
41
|
+
"A single image or video tagged as a 'do' or 'don't' example. Use inside a Container to build side-by-side do/don't comparisons of visual usage.",
|
|
31
42
|
component: ({ content, options, onChange }) => (
|
|
32
43
|
<DoDontMediaBlock
|
|
33
44
|
refValue={content.content.ref}
|
|
@@ -16,6 +16,7 @@ interface Props {
|
|
|
16
16
|
icon?: string | null;
|
|
17
17
|
showLabel?: boolean;
|
|
18
18
|
stackText?: boolean;
|
|
19
|
+
dodont?: "do" | "dont";
|
|
19
20
|
labelClassName?: string;
|
|
20
21
|
textClassName?: string;
|
|
21
22
|
iconClassName?: string;
|
|
@@ -28,6 +29,7 @@ export default function IconList({
|
|
|
28
29
|
icon = null,
|
|
29
30
|
showLabel = true,
|
|
30
31
|
stackText = false,
|
|
32
|
+
dodont,
|
|
31
33
|
labelClassName = "font-bold text-base-contrast",
|
|
32
34
|
textClassName = "text-base-contrast-light",
|
|
33
35
|
iconClassName = "text-primary",
|
|
@@ -41,11 +43,11 @@ export default function IconList({
|
|
|
41
43
|
onChange?.({
|
|
42
44
|
type: "icon_list",
|
|
43
45
|
content: { items: newItems },
|
|
44
|
-
options: { icon, showLabel, stackText },
|
|
46
|
+
options: { icon, showLabel, stackText, dodont },
|
|
45
47
|
});
|
|
46
48
|
onItemsChange?.(newItems);
|
|
47
49
|
},
|
|
48
|
-
[onChange, onItemsChange, icon, showLabel, stackText],
|
|
50
|
+
[onChange, onItemsChange, icon, showLabel, stackText, dodont],
|
|
49
51
|
);
|
|
50
52
|
|
|
51
53
|
if (!isEditMode) {
|
|
@@ -55,6 +57,7 @@ export default function IconList({
|
|
|
55
57
|
defaultIcon={icon}
|
|
56
58
|
showLabel={showLabel}
|
|
57
59
|
stackText={stackText}
|
|
60
|
+
dodont={dodont}
|
|
58
61
|
labelClassName={labelClassName}
|
|
59
62
|
textClassName={textClassName}
|
|
60
63
|
iconClassName={iconClassName}
|
|
@@ -68,6 +71,7 @@ export default function IconList({
|
|
|
68
71
|
defaultIcon={icon}
|
|
69
72
|
showLabel={showLabel}
|
|
70
73
|
stackText={stackText}
|
|
74
|
+
dodont={dodont}
|
|
71
75
|
labelClassName={labelClassName}
|
|
72
76
|
textClassName={textClassName}
|
|
73
77
|
iconClassName={iconClassName}
|
|
@@ -80,18 +84,32 @@ export default function IconList({
|
|
|
80
84
|
// Resolve the effective icon for an item: per-item override > section default
|
|
81
85
|
// ---------------------------------------------------------------------------
|
|
82
86
|
|
|
83
|
-
function resolveIcon(
|
|
84
|
-
|
|
87
|
+
function resolveIcon(
|
|
88
|
+
item: IconListItem,
|
|
89
|
+
defaultIcon: string | null | undefined,
|
|
90
|
+
sectionDodont: "do" | "dont" | undefined,
|
|
91
|
+
) {
|
|
92
|
+
// Precedence: per-item tag > per-item icon > whole-list default > section default icon.
|
|
85
93
|
if (item.dodont === "do") return getIcon("check") ?? null;
|
|
86
94
|
if (item.dodont === "dont") return getIcon("x") ?? null;
|
|
87
|
-
|
|
88
|
-
|
|
95
|
+
if (item.icon) return getIcon(item.icon) ?? null;
|
|
96
|
+
if (sectionDodont === "do") return getIcon("check") ?? null;
|
|
97
|
+
if (sectionDodont === "dont") return getIcon("x") ?? null;
|
|
98
|
+
return defaultIcon ? getIcon(defaultIcon) : null;
|
|
89
99
|
}
|
|
90
100
|
|
|
91
|
-
// Per-item icon color: green for "do", red for "dont"
|
|
92
|
-
|
|
101
|
+
// Per-item icon color: green for "do", red for "dont" (item tag then whole-list
|
|
102
|
+
// default), else the section-wide class. Mirrors resolveIcon's precedence.
|
|
103
|
+
function resolveIconColor(
|
|
104
|
+
item: IconListItem,
|
|
105
|
+
iconClassName: string,
|
|
106
|
+
sectionDodont: "do" | "dont" | undefined,
|
|
107
|
+
) {
|
|
93
108
|
if (item.dodont === "do") return "text-green-600";
|
|
94
109
|
if (item.dodont === "dont") return "text-red-600";
|
|
110
|
+
if (item.icon) return iconClassName;
|
|
111
|
+
if (sectionDodont === "do") return "text-green-600";
|
|
112
|
+
if (sectionDodont === "dont") return "text-red-600";
|
|
95
113
|
return iconClassName;
|
|
96
114
|
}
|
|
97
115
|
|
|
@@ -104,6 +122,7 @@ function ViewIconList({
|
|
|
104
122
|
defaultIcon,
|
|
105
123
|
showLabel,
|
|
106
124
|
stackText,
|
|
125
|
+
dodont,
|
|
107
126
|
labelClassName,
|
|
108
127
|
textClassName,
|
|
109
128
|
iconClassName,
|
|
@@ -112,17 +131,18 @@ function ViewIconList({
|
|
|
112
131
|
defaultIcon: string | null | undefined;
|
|
113
132
|
showLabel: boolean;
|
|
114
133
|
stackText: boolean;
|
|
134
|
+
dodont: "do" | "dont" | undefined;
|
|
115
135
|
labelClassName: string;
|
|
116
136
|
textClassName: string;
|
|
117
137
|
iconClassName: string;
|
|
118
138
|
}) {
|
|
119
|
-
const hasAnyIcon = items.some((item) => resolveIcon(item, defaultIcon));
|
|
139
|
+
const hasAnyIcon = items.some((item) => resolveIcon(item, defaultIcon, dodont));
|
|
120
140
|
|
|
121
141
|
return (
|
|
122
142
|
<div className="grid gap-4 pb-4">
|
|
123
143
|
{items.map((item, i) => {
|
|
124
|
-
const iconEntry = resolveIcon(item, defaultIcon);
|
|
125
|
-
const iconColor = resolveIconColor(item, iconClassName);
|
|
144
|
+
const iconEntry = resolveIcon(item, defaultIcon, dodont);
|
|
145
|
+
const iconColor = resolveIconColor(item, iconClassName, dodont);
|
|
126
146
|
return (
|
|
127
147
|
<div
|
|
128
148
|
key={i}
|
|
@@ -164,6 +184,7 @@ function EditIconList({
|
|
|
164
184
|
defaultIcon,
|
|
165
185
|
showLabel,
|
|
166
186
|
stackText,
|
|
187
|
+
dodont,
|
|
167
188
|
labelClassName,
|
|
168
189
|
textClassName,
|
|
169
190
|
iconClassName,
|
|
@@ -173,6 +194,7 @@ function EditIconList({
|
|
|
173
194
|
defaultIcon: string | null | undefined;
|
|
174
195
|
showLabel: boolean;
|
|
175
196
|
stackText: boolean;
|
|
197
|
+
dodont: "do" | "dont" | undefined;
|
|
176
198
|
labelClassName: string;
|
|
177
199
|
textClassName: string;
|
|
178
200
|
iconClassName: string;
|
|
@@ -228,6 +250,7 @@ function EditIconList({
|
|
|
228
250
|
hasAnyIcon={hasAnyIcon}
|
|
229
251
|
showLabel={showLabel}
|
|
230
252
|
stackText={stackText}
|
|
253
|
+
dodont={dodont}
|
|
231
254
|
labelClassName={labelClassName}
|
|
232
255
|
textClassName={textClassName}
|
|
233
256
|
iconClassName={iconClassName}
|
|
@@ -268,6 +291,7 @@ function EditableRow({
|
|
|
268
291
|
hasAnyIcon,
|
|
269
292
|
showLabel,
|
|
270
293
|
stackText,
|
|
294
|
+
dodont,
|
|
271
295
|
labelClassName,
|
|
272
296
|
textClassName,
|
|
273
297
|
iconClassName,
|
|
@@ -285,6 +309,7 @@ function EditableRow({
|
|
|
285
309
|
hasAnyIcon: boolean;
|
|
286
310
|
showLabel: boolean;
|
|
287
311
|
stackText: boolean;
|
|
312
|
+
dodont: "do" | "dont" | undefined;
|
|
288
313
|
labelClassName: string;
|
|
289
314
|
textClassName: string;
|
|
290
315
|
iconClassName: string;
|
|
@@ -302,9 +327,9 @@ function EditableRow({
|
|
|
302
327
|
const [dodontOpen, setDodontOpen] = useState(false);
|
|
303
328
|
const [editingSuppressed, setEditingSuppressed] = useState(false);
|
|
304
329
|
|
|
305
|
-
const iconEntry = resolveIcon(item, defaultIcon);
|
|
330
|
+
const iconEntry = resolveIcon(item, defaultIcon, dodont);
|
|
306
331
|
const effectiveIconId = item.icon ?? defaultIcon ?? null;
|
|
307
|
-
const iconColor = resolveIconColor(item, iconClassName);
|
|
332
|
+
const iconColor = resolveIconColor(item, iconClassName, dodont);
|
|
308
333
|
|
|
309
334
|
useEffect(() => {
|
|
310
335
|
const row = rowRef.current;
|
|
@@ -373,12 +398,11 @@ function EditableRow({
|
|
|
373
398
|
onMouseMove={() => setEditingSuppressed(false)}
|
|
374
399
|
>
|
|
375
400
|
|
|
376
|
-
{/* Icon — matches ViewIconList layout: 24px column, flex items-start
|
|
377
|
-
A
|
|
378
|
-
(resolveIcon returns the forced check/x for tagged
|
|
379
|
-
the IconPicker
|
|
380
|
-
the
|
|
381
|
-
or not, so tags can be edited or cleared via the picker. */}
|
|
401
|
+
{/* Icon cell — matches ViewIconList layout: 24px column, flex items-start
|
|
402
|
+
pt-0.5. A single always-present trigger shows the current effective glyph
|
|
403
|
+
(resolveIcon returns the forced check/x for tagged rows or a whole-list
|
|
404
|
+
default) and opens the IconPicker (a pure icon grid). Per-item do/dont
|
|
405
|
+
tagging lives in the separate DoDontToggle in the row's hover toolbar. */}
|
|
382
406
|
{hasAnyIcon && (
|
|
383
407
|
<div
|
|
384
408
|
data-testid="icon-list-icon"
|