@jimmy_harika/websitekit 0.3.1

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 (45) hide show
  1. package/README.md +86 -0
  2. package/package.json +54 -0
  3. package/src/blocks/author-bio.tsx +52 -0
  4. package/src/blocks/author-hero.tsx +99 -0
  5. package/src/blocks/book-grid.tsx +97 -0
  6. package/src/blocks/book-spotlight.tsx +109 -0
  7. package/src/blocks/contact.tsx +64 -0
  8. package/src/blocks/faq.tsx +65 -0
  9. package/src/blocks/form.tsx +234 -0
  10. package/src/blocks/gallery.tsx +230 -0
  11. package/src/blocks/index.ts +32 -0
  12. package/src/blocks/newsletter.tsx +100 -0
  13. package/src/blocks/pricing.tsx +120 -0
  14. package/src/blocks/primitives.tsx +109 -0
  15. package/src/blocks/testimonials.tsx +89 -0
  16. package/src/catalogs/author/catalog.tsx +496 -0
  17. package/src/catalogs/author/index.ts +9 -0
  18. package/src/catalogs/author/templates.ts +133 -0
  19. package/src/catalogs/publisher/catalog.tsx +308 -0
  20. package/src/catalogs/publisher/index.ts +10 -0
  21. package/src/catalogs/publisher/templates.ts +158 -0
  22. package/src/editable.tsx +76 -0
  23. package/src/editor/BlockLibrary.tsx +110 -0
  24. package/src/editor/Canvas.tsx +128 -0
  25. package/src/editor/ErrorBoundary.tsx +54 -0
  26. package/src/editor/InsertPoint.tsx +31 -0
  27. package/src/editor/Inspector.tsx +341 -0
  28. package/src/editor/PageBuilder.tsx +217 -0
  29. package/src/editor/SectionFrame.tsx +127 -0
  30. package/src/editor/SectionsPanel.tsx +149 -0
  31. package/src/editor/TemplateGallery.tsx +98 -0
  32. package/src/editor/ThemePanel.tsx +131 -0
  33. package/src/editor/Toolbar.tsx +162 -0
  34. package/src/editor/dnd.tsx +61 -0
  35. package/src/editor/edit-primitives.tsx +164 -0
  36. package/src/editor/index.tsx +8 -0
  37. package/src/editor/shortcuts.ts +106 -0
  38. package/src/editor/ui-context.tsx +32 -0
  39. package/src/index.ts +11 -0
  40. package/src/lib/cn.ts +8 -0
  41. package/src/model.ts +116 -0
  42. package/src/registry.ts +138 -0
  43. package/src/renderer/index.tsx +55 -0
  44. package/src/store.ts +216 -0
  45. package/src/theme.ts +85 -0
package/src/model.ts ADDED
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Page-builder document model — the single source of truth the engine edits and
3
+ * renders. A `PageDocument` is one page: an ordered list of sections plus the
4
+ * (site-wide) theme. Persistence adapters map this to/from app storage (Convex
5
+ * `storefrontPages` + `storefrontSections` for pixbox; anything else elsewhere).
6
+ *
7
+ * Block-agnostic by design: a `Section` is just `{ type, variant?, props }`.
8
+ * What each `type` means lives in the app's catalog, never here.
9
+ */
10
+
11
+ export type ThemeRadius = "none" | "sm" | "md" | "lg";
12
+ export type ThemeSpacing = "tight" | "normal" | "roomy";
13
+
14
+ /** Responsive breakpoints a section can be hidden on. mobile <640, tablet
15
+ * 640–1024, desktop ≥1024 (Tailwind sm / lg boundaries). */
16
+ export type Breakpoint = "mobile" | "tablet" | "desktop";
17
+
18
+ export interface ThemePalette {
19
+ bg: string;
20
+ text: string;
21
+ primary: string;
22
+ accent: string;
23
+ muted: string;
24
+ }
25
+
26
+ export interface ThemeTokens {
27
+ /** Google font family name for headings, e.g. "Cormorant Garamond". */
28
+ fontHeading: string;
29
+ /** Google font family name for body text, e.g. "Jost". */
30
+ fontBody: string;
31
+ colors: ThemePalette;
32
+ radius: ThemeRadius;
33
+ spacing: ThemeSpacing;
34
+ }
35
+
36
+ export interface Section {
37
+ /** Stable per-section id (survives reorder). */
38
+ id: string;
39
+ /** Registry key — resolved against the app catalog at render time. */
40
+ type: string;
41
+ /** Optional layout variant within the type (e.g. "split-right", "3-up"). */
42
+ variant?: string;
43
+ /** Validated against the type's zod schema by the engine. */
44
+ props: Record<string, unknown>;
45
+ /** Responsive visibility — hide the section on these breakpoints in production. */
46
+ hideOn?: Breakpoint[];
47
+ }
48
+
49
+ /**
50
+ * Tailwind visibility classes for a `hideOn` set. Emits a base + sm: + lg: token
51
+ * so every combination (including ranges like "hide on tablet only") resolves.
52
+ * Returns "" when nothing is hidden so sections render with no extra wrapper.
53
+ */
54
+ export function hideOnClasses(hideOn?: Breakpoint[]): string {
55
+ if (!hideOn || hideOn.length === 0) return "";
56
+ const m = hideOn.includes("mobile");
57
+ const t = hideOn.includes("tablet");
58
+ const d = hideOn.includes("desktop");
59
+ if (!m && !t && !d) return "";
60
+ return `${m ? "hidden" : "block"} sm:${t ? "hidden" : "block"} lg:${d ? "hidden" : "block"}`;
61
+ }
62
+
63
+ export interface PageDocument {
64
+ sections: Section[];
65
+ theme: ThemeTokens;
66
+ }
67
+
68
+ /**
69
+ * Metadata for a starter template shown in the editor's TemplateGallery.
70
+ * Lives in the model (not the editor) so catalog data modules can import it
71
+ * without dragging the client editor bundle into an RSC graph.
72
+ */
73
+ export interface TemplateMeta {
74
+ id: string;
75
+ name: string;
76
+ category: string;
77
+ description?: string;
78
+ thumb?: string;
79
+ build: () => PageDocument;
80
+ }
81
+
82
+ export const DEFAULT_THEME: ThemeTokens = {
83
+ fontHeading: "Cormorant Garamond",
84
+ fontBody: "Jost",
85
+ colors: {
86
+ bg: "#ffffff",
87
+ text: "#1a1a1a",
88
+ primary: "#1a1a1a",
89
+ accent: "#9a7b4f",
90
+ muted: "#6b6b6b",
91
+ },
92
+ radius: "sm",
93
+ spacing: "normal",
94
+ };
95
+
96
+ let _seq = 0;
97
+ /**
98
+ * Compact unique id for new sections. Not cryptographic — only needs to be
99
+ * unique within a document and stable across a session. Avoids any timestamp so
100
+ * it is safe to call in any runtime.
101
+ */
102
+ export function createSectionId(prefix = "sec"): string {
103
+ const rnd = Math.random().toString(36).slice(2, 9);
104
+ return `${prefix}_${rnd}${(_seq++).toString(36)}`;
105
+ }
106
+
107
+ /** Deep-ish clone of a section with a fresh id (for duplicate). */
108
+ export function cloneSection(section: Section): Section {
109
+ return {
110
+ id: createSectionId(),
111
+ type: section.type,
112
+ variant: section.variant,
113
+ props: structuredClone(section.props),
114
+ ...(section.hideOn ? { hideOn: [...section.hideOn] } : {}),
115
+ };
116
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Section registry — the extension point that makes the engine block-agnostic.
3
+ *
4
+ * An app builds a `Catalog` from `defineSection(...)` definitions. The SAME
5
+ * `Component` is used by the renderer (production) and the editor canvas (so
6
+ * what you edit is exactly what ships). `schema` (zod) gives typed props, an
7
+ * auto-generated inspector form for non-inline fields, and template validation.
8
+ */
9
+ import type { ComponentType } from "react";
10
+ import type { ZodType } from "zod";
11
+
12
+ /** How a prop is edited directly on the canvas (vs. in the right-rail inspector). */
13
+ export type InlineFieldKind = "text" | "richtext" | "image";
14
+
15
+ export interface SectionVariant {
16
+ id: string;
17
+ label: string;
18
+ /** Props merged over `defaults` when this variant is chosen. */
19
+ props?: Record<string, unknown>;
20
+ }
21
+
22
+ /**
23
+ * Inspector field descriptor — drives the right-rail form. Explicit (not zod
24
+ * introspection) so labels/widgets are reliable; zod stays purely for
25
+ * validation. Props marked `inline` are edited on the canvas instead.
26
+ */
27
+ export type FieldDescriptor =
28
+ | {
29
+ key: string;
30
+ label: string;
31
+ widget: "text" | "textarea" | "url";
32
+ placeholder?: string;
33
+ help?: string;
34
+ }
35
+ | { key: string; label: string; widget: "number"; min?: number; max?: number; help?: string }
36
+ | {
37
+ key: string;
38
+ label: string;
39
+ widget: "select";
40
+ options: { value: string; label: string }[];
41
+ help?: string;
42
+ }
43
+ | {
44
+ key: string;
45
+ label: string;
46
+ widget: "list";
47
+ /** Format hint shown under the textarea, e.g. "Title | Description". */
48
+ help?: string;
49
+ }
50
+ | {
51
+ key: string;
52
+ label: string;
53
+ widget: "image";
54
+ /** Sibling prop key holding the alt text (e.g. key:"portrait" → altKey:"portraitAlt"). */
55
+ altKey?: string;
56
+ placeholder?: string;
57
+ help?: string;
58
+ };
59
+
60
+ export interface SectionDefinition<
61
+ P extends Record<string, unknown> = Record<string, unknown>,
62
+ > {
63
+ /** Registry key, e.g. "hero", "gallery", "newsletter". */
64
+ type: string;
65
+ /** Human label shown in the block library. */
66
+ label: string;
67
+ /** Library grouping, e.g. "Banners", "Images", "Newsletter". */
68
+ category: string;
69
+ description?: string;
70
+ /** Zod schema for `props` — typed props + inspector form + template validation. */
71
+ schema: ZodType;
72
+ /** Props for a freshly inserted section (a variant's `props` merge over this). */
73
+ defaults: P;
74
+ /** Layout variants surfaced in the block library + "Change Layout". */
75
+ variants?: SectionVariant[];
76
+ /** Props inline-editable on the canvas, and how. Others go to the inspector. */
77
+ inline?: Partial<Record<keyof P & string, InlineFieldKind>>;
78
+ /** Right-rail inspector fields (for props not edited inline on the canvas). */
79
+ fields?: FieldDescriptor[];
80
+ /** Renderer used in BOTH editor canvas and production. Must be pure/SSR-safe. */
81
+ Component: ComponentType<P>;
82
+ }
83
+
84
+ export interface Catalog {
85
+ sections: Record<string, SectionDefinition>;
86
+ /** Category display order for the block library. */
87
+ categories: string[];
88
+ }
89
+
90
+ /** Identity helper that preserves the prop generic for inference at call sites. */
91
+ export function defineSection<P extends Record<string, unknown>>(
92
+ def: SectionDefinition<P>,
93
+ ): SectionDefinition<P> {
94
+ return def;
95
+ }
96
+
97
+ export function buildCatalog(defs: SectionDefinition[]): Catalog {
98
+ const sections: Record<string, SectionDefinition> = {};
99
+ const categories: string[] = [];
100
+ for (const d of defs) {
101
+ sections[d.type] = d;
102
+ if (!categories.includes(d.category)) categories.push(d.category);
103
+ }
104
+ return { sections, categories };
105
+ }
106
+
107
+ /** Look up a definition; undefined for unknown types (renderer shows a stub). */
108
+ export function getDefinition(
109
+ catalog: Catalog,
110
+ type: string,
111
+ ): SectionDefinition | undefined {
112
+ return catalog.sections[type];
113
+ }
114
+
115
+ /**
116
+ * Validate + coerce a section's props against its schema. On failure (e.g. an
117
+ * old document missing a new required field) returns the definition defaults so
118
+ * the canvas never crashes on bad data — the editor surfaces the mismatch.
119
+ */
120
+ export function resolveProps(
121
+ def: SectionDefinition,
122
+ props: unknown,
123
+ ): Record<string, unknown> {
124
+ const res = def.schema.safeParse(props ?? {});
125
+ if (res.success) return res.data as Record<string, unknown>;
126
+ return { ...def.defaults };
127
+ }
128
+
129
+ /** Default props for a new section of `type`, with an optional variant applied. */
130
+ export function defaultPropsFor(
131
+ def: SectionDefinition,
132
+ variantId?: string,
133
+ ): Record<string, unknown> {
134
+ const base = { ...def.defaults };
135
+ if (!variantId) return base;
136
+ const v = def.variants?.find((x) => x.id === variantId);
137
+ return { ...base, ...(v?.props ?? {}) };
138
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Production renderer — RSC-safe. No client imports, no hooks. The published
3
+ * site uses this: server-renders a `PageDocument` to static HTML, theme tokens
4
+ * on the container, each section via its catalog `Component` (no `edit` prop →
5
+ * pure/static). The editor canvas renders the same components with `edit`
6
+ * injected (see editor/Canvas).
7
+ */
8
+ import { createElement, type ReactNode } from "react";
9
+ import type { PageDocument, Section } from "../model";
10
+ import { hideOnClasses } from "../model";
11
+ import { type Catalog, getDefinition, resolveProps } from "../registry";
12
+ import { googleFontsHref, themeVars } from "../theme";
13
+
14
+ /** One section, statically. Unknown types render an inert placeholder. */
15
+ export function renderSection(catalog: Catalog, section: Section): ReactNode {
16
+ const def = getDefinition(catalog, section.type);
17
+ const visibility = hideOnClasses(section.hideOn);
18
+ if (!def) {
19
+ return createElement(
20
+ "div",
21
+ {
22
+ key: section.id,
23
+ className: `px-6 py-8 text-center text-xs opacity-40 ${visibility}`,
24
+ },
25
+ `Unknown section: ${section.type}`,
26
+ );
27
+ }
28
+ const props = resolveProps(def, section.props);
29
+ // Wrap only when the section is hidden on a breakpoint — keeps the DOM flat
30
+ // for normally-visible sections (no wrapper to disturb full-bleed layouts).
31
+ if (!visibility) return createElement(def.Component, { key: section.id, ...props });
32
+ const el = createElement(def.Component, props);
33
+ return createElement("div", { key: section.id, className: visibility }, el);
34
+ }
35
+
36
+ export function PageRenderer({
37
+ doc,
38
+ catalog,
39
+ className,
40
+ }: {
41
+ doc: PageDocument;
42
+ catalog: Catalog;
43
+ className?: string;
44
+ }) {
45
+ return (
46
+ <div style={themeVars(doc.theme)} className={className}>
47
+ {doc.sections.map((section) => renderSection(catalog, section))}
48
+ </div>
49
+ );
50
+ }
51
+
52
+ /** <link> tag for the document's Google fonts — drop in <head> or above body. */
53
+ export function PageFonts({ doc }: { doc: PageDocument }) {
54
+ return <link rel="stylesheet" href={googleFontsHref(doc.theme)} />;
55
+ }
package/src/store.ts ADDED
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Editor state store — zustand (vanilla, per-instance) + zundo for undo/redo.
3
+ *
4
+ * Pure data operations only: actions take fully-formed sections, so the store
5
+ * has NO dependency on the catalog. The editor computes default/variant props
6
+ * from the catalog and hands the store a ready `Section`. History tracks only
7
+ * `doc` (selection changes don't create undo steps), keyed by `doc` reference —
8
+ * so every real edit is one clean undo step.
9
+ */
10
+ "use client";
11
+
12
+ import { createContext, createElement, useContext, useRef, type ReactNode } from "react";
13
+ import { createStore, type StoreApi } from "zustand/vanilla";
14
+ import { useStore } from "zustand";
15
+ import { temporal, type TemporalState } from "zundo";
16
+ import {
17
+ cloneSection,
18
+ type PageDocument,
19
+ type Section,
20
+ type ThemeTokens,
21
+ } from "./model";
22
+
23
+ export interface BuilderState {
24
+ doc: PageDocument;
25
+ selectedId: string | null;
26
+
27
+ select(id: string | null): void;
28
+ /** Insert a ready-built section at `index` (default: append). Selects it. */
29
+ insertSection(section: Section, index?: number): void;
30
+ /** Shallow-merge a patch into a section's props. */
31
+ updateProps(id: string, patch: Record<string, unknown>): void;
32
+ /** Replace a section's props wholesale (used by "Change Layout"). */
33
+ setProps(id: string, props: Record<string, unknown>): void;
34
+ /** Set a section's layout variant, optionally replacing props. */
35
+ setVariant(id: string, variant: string, props?: Record<string, unknown>): void;
36
+ /** Set a section's responsive visibility (hide on mobile/tablet/desktop). */
37
+ setHideOn(id: string, hideOn: import("./model").Breakpoint[]): void;
38
+ /** Move a section from one index to another. */
39
+ moveSection(from: number, to: number): void;
40
+ removeSection(id: string): void;
41
+ duplicateSection(id: string): void;
42
+ setTheme(patch: Partial<ThemeTokens>): void;
43
+ /** Replace the whole document (load / apply template). Clears selection. */
44
+ replaceDocument(doc: PageDocument): void;
45
+ }
46
+
47
+ type PartializedHistory = { doc: PageDocument };
48
+
49
+ export type BuilderStoreApi = StoreApi<BuilderState> & {
50
+ temporal: StoreApi<TemporalState<PartializedHistory>>;
51
+ };
52
+
53
+ function indexOf(doc: PageDocument, id: string): number {
54
+ return doc.sections.findIndex((s) => s.id === id);
55
+ }
56
+
57
+ export function createBuilderStore(initial: PageDocument): BuilderStoreApi {
58
+ const store = createStore<BuilderState>()(
59
+ temporal(
60
+ (set, get) => ({
61
+ doc: initial,
62
+ selectedId: null,
63
+
64
+ select: (id) => set({ selectedId: id }),
65
+
66
+ insertSection: (section, index) =>
67
+ set((s) => {
68
+ const sections = [...s.doc.sections];
69
+ const at = index ?? sections.length;
70
+ sections.splice(Math.max(0, Math.min(at, sections.length)), 0, section);
71
+ return { doc: { ...s.doc, sections }, selectedId: section.id };
72
+ }),
73
+
74
+ updateProps: (id, patch) =>
75
+ set((s) => ({
76
+ doc: {
77
+ ...s.doc,
78
+ sections: s.doc.sections.map((sec) =>
79
+ sec.id === id ? { ...sec, props: { ...sec.props, ...patch } } : sec,
80
+ ),
81
+ },
82
+ })),
83
+
84
+ setProps: (id, props) =>
85
+ set((s) => ({
86
+ doc: {
87
+ ...s.doc,
88
+ sections: s.doc.sections.map((sec) =>
89
+ sec.id === id ? { ...sec, props } : sec,
90
+ ),
91
+ },
92
+ })),
93
+
94
+ setVariant: (id, variant, props) =>
95
+ set((s) => ({
96
+ doc: {
97
+ ...s.doc,
98
+ sections: s.doc.sections.map((sec) =>
99
+ sec.id === id
100
+ ? { ...sec, variant, props: props ?? sec.props }
101
+ : sec,
102
+ ),
103
+ },
104
+ })),
105
+
106
+ setHideOn: (id, hideOn) =>
107
+ set((s) => ({
108
+ doc: {
109
+ ...s.doc,
110
+ sections: s.doc.sections.map((sec) =>
111
+ sec.id === id
112
+ ? hideOn.length === 0
113
+ ? (() => {
114
+ const { hideOn: _omit, ...rest } = sec;
115
+ return rest;
116
+ })()
117
+ : { ...sec, hideOn }
118
+ : sec,
119
+ ),
120
+ },
121
+ })),
122
+
123
+ moveSection: (from, to) =>
124
+ set((s) => {
125
+ const sections = [...s.doc.sections];
126
+ if (from < 0 || from >= sections.length) return s;
127
+ const [moved] = sections.splice(from, 1);
128
+ sections.splice(Math.max(0, Math.min(to, sections.length)), 0, moved);
129
+ return { doc: { ...s.doc, sections } };
130
+ }),
131
+
132
+ removeSection: (id) =>
133
+ set((s) => ({
134
+ doc: { ...s.doc, sections: s.doc.sections.filter((sec) => sec.id !== id) },
135
+ selectedId: s.selectedId === id ? null : s.selectedId,
136
+ })),
137
+
138
+ duplicateSection: (id) =>
139
+ set((s) => {
140
+ const i = indexOf(s.doc, id);
141
+ if (i < 0) return s;
142
+ const copy = cloneSection(s.doc.sections[i]);
143
+ const sections = [...s.doc.sections];
144
+ sections.splice(i + 1, 0, copy);
145
+ return { doc: { ...s.doc, sections }, selectedId: copy.id };
146
+ }),
147
+
148
+ setTheme: (patch) =>
149
+ set((s) => ({
150
+ doc: {
151
+ ...s.doc,
152
+ theme: {
153
+ ...s.doc.theme,
154
+ ...patch,
155
+ colors: { ...s.doc.theme.colors, ...(patch.colors ?? {}) },
156
+ },
157
+ },
158
+ })),
159
+
160
+ replaceDocument: (doc) => set({ doc, selectedId: null }),
161
+ }),
162
+ {
163
+ // Track only the document; key history by doc reference so selection
164
+ // changes never produce phantom undo steps.
165
+ partialize: (s) => ({ doc: s.doc }),
166
+ equality: (a, b) => a.doc === b.doc,
167
+ limit: 100,
168
+ },
169
+ ),
170
+ );
171
+ return store as BuilderStoreApi;
172
+ }
173
+
174
+ /* ── React bindings ─────────────────────────────────────────────────────── */
175
+
176
+ const BuilderStoreContext = createContext<BuilderStoreApi | null>(null);
177
+
178
+ export function BuilderProvider({
179
+ store,
180
+ children,
181
+ }: {
182
+ store: BuilderStoreApi;
183
+ children: ReactNode;
184
+ }) {
185
+ return createElement(BuilderStoreContext.Provider, { value: store }, children);
186
+ }
187
+
188
+ /**
189
+ * Lazily create-and-hold a store for the lifetime of the component.
190
+ *
191
+ * NOTE: ignores updates to `initial` after first render. If you need to swap
192
+ * documents (e.g. navigating to a different page without unmounting), pass a
193
+ * `key` prop to the parent so React remounts this hook with fresh state.
194
+ */
195
+ export function useCreateBuilderStore(initial: PageDocument): BuilderStoreApi {
196
+ const ref = useRef<BuilderStoreApi | null>(null);
197
+ if (ref.current === null) ref.current = createBuilderStore(initial);
198
+ return ref.current;
199
+ }
200
+
201
+ export function useBuilderStoreApi(): BuilderStoreApi {
202
+ const store = useContext(BuilderStoreContext);
203
+ if (!store) throw new Error("useBuilderStoreApi must be used within <BuilderProvider>");
204
+ return store;
205
+ }
206
+
207
+ export function useBuilder<T>(selector: (s: BuilderState) => T): T {
208
+ return useStore(useBuilderStoreApi(), selector);
209
+ }
210
+
211
+ /** Subscribe to undo/redo state (pastStates/futureStates/undo/redo/clear). */
212
+ export function useTemporal<T>(
213
+ selector: (s: TemporalState<PartializedHistory>) => T,
214
+ ): T {
215
+ return useStore(useBuilderStoreApi().temporal, selector);
216
+ }
package/src/theme.ts ADDED
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Theme → CSS custom properties. The engine emits BOTH the legacy storefront
3
+ * tokens (`--sf-*`, so pixbox's existing block renderers work unchanged) and
4
+ * generic `--pb-*` tokens for new/cross-app blocks. Blocks read tokens via
5
+ * `var(--pb-color-accent, ...)` so one component skins across every theme — the
6
+ * same "UI as skinnable primitives" convention used by event-card skins.
7
+ */
8
+ import type { CSSProperties } from "react";
9
+ import type { ThemeTokens } from "./model";
10
+
11
+ function fontStack(family: string, fallback: "serif" | "sans-serif"): string {
12
+ const tail =
13
+ fallback === "serif"
14
+ ? "ui-serif, Georgia, 'Times New Roman', serif"
15
+ : "ui-sans-serif, system-ui, sans-serif";
16
+ return `"${family}", ${tail}`;
17
+ }
18
+
19
+ function radiusPx(r: ThemeTokens["radius"]): string {
20
+ return r === "none" ? "0px" : r === "sm" ? "4px" : r === "md" ? "10px" : "20px";
21
+ }
22
+
23
+ function spacingScale(s: ThemeTokens["spacing"]): string {
24
+ return s === "tight" ? "0.85" : s === "roomy" ? "1.25" : "1";
25
+ }
26
+
27
+ /** Inline style object setting every theme token on a container element. */
28
+ export function themeVars(theme: ThemeTokens): CSSProperties {
29
+ const heading = fontStack(theme.fontHeading, "serif");
30
+ const body = fontStack(theme.fontBody, "sans-serif");
31
+ const vars: Record<string, string> = {
32
+ // legacy storefront tokens (existing renderers depend on these)
33
+ "--sf-font-heading": heading,
34
+ "--sf-font-body": body,
35
+ "--sf-bg": theme.colors.bg,
36
+ "--sf-text": theme.colors.text,
37
+ "--sf-accent": theme.colors.accent,
38
+ "--sf-primary": theme.colors.primary,
39
+ // generic page-builder tokens
40
+ "--pb-font-heading": heading,
41
+ "--pb-font-body": body,
42
+ "--pb-color-bg": theme.colors.bg,
43
+ "--pb-color-text": theme.colors.text,
44
+ "--pb-color-primary": theme.colors.primary,
45
+ "--pb-color-accent": theme.colors.accent,
46
+ "--pb-color-muted": theme.colors.muted,
47
+ "--pb-radius": radiusPx(theme.radius),
48
+ "--pb-spacing": spacingScale(theme.spacing),
49
+ };
50
+ return {
51
+ ...(vars as CSSProperties),
52
+ backgroundColor: theme.colors.bg,
53
+ color: theme.colors.text,
54
+ };
55
+ }
56
+
57
+ const WEIGHTS = "wght@300;400;500;600;700";
58
+
59
+ /** Google Fonts stylesheet href for the theme's two families. */
60
+ export function googleFontsHref(theme: ThemeTokens): string {
61
+ const fam = (f: string) => `family=${encodeURIComponent(f)}:${WEIGHTS}`;
62
+ const families = [theme.fontHeading, theme.fontBody]
63
+ .filter((f, i, a) => a.indexOf(f) === i)
64
+ .map(fam)
65
+ .join("&");
66
+ return `https://fonts.googleapis.com/css2?${families}&display=swap`;
67
+ }
68
+
69
+ /** Curated heading/body font pairings offered in the theme panel. */
70
+ export const FONT_PAIRS: { id: string; label: string; heading: string; body: string }[] = [
71
+ { id: "editorial", label: "Editorial", heading: "Cormorant Garamond", body: "Jost" },
72
+ { id: "classic", label: "Classic", heading: "Playfair Display", body: "Inter" },
73
+ { id: "modern", label: "Modern", heading: "Fraunces", body: "Manrope" },
74
+ { id: "grotesk", label: "Grotesk", heading: "Space Grotesk", body: "Inter" },
75
+ { id: "warm", label: "Warm", heading: "DM Serif Display", body: "DM Sans" },
76
+ ];
77
+
78
+ /** Curated palettes offered in the theme panel. */
79
+ export const PALETTES: { id: string; label: string; colors: ThemeTokens["colors"] }[] = [
80
+ { id: "ivory", label: "Ivory", colors: { bg: "#ffffff", text: "#1a1a1a", primary: "#1a1a1a", accent: "#9a7b4f", muted: "#6b6b6b" } },
81
+ { id: "noir", label: "Noir", colors: { bg: "#0e0e0e", text: "#f5f5f5", primary: "#f5f5f5", accent: "#c9a36a", muted: "#9a9a9a" } },
82
+ { id: "blush", label: "Blush", colors: { bg: "#fbf6f3", text: "#2b2422", primary: "#2b2422", accent: "#c98b7e", muted: "#8a7d78" } },
83
+ { id: "sage", label: "Sage", colors: { bg: "#f4f6f2", text: "#22281f", primary: "#22281f", accent: "#7c8a6a", muted: "#7a8073" } },
84
+ { id: "cobalt", label: "Cobalt", colors: { bg: "#ffffff", text: "#0f172a", primary: "#0f172a", accent: "#2f5cff", muted: "#64748b" } },
85
+ ];