@caltho/site-kit 0.2.8 → 0.2.10

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/README.md CHANGED
@@ -66,6 +66,44 @@ the kit only checks it's a URL.
66
66
  | **Preview** (`@caltho/site-kit/next/preview`) | `SitePreview`: CalOS's draft drawn with the site's own components, a click matched to its field, outlines with each section's name. |
67
67
  | **Schema** (`@caltho/site-kit/schema`) | What CalOS needs of a declaration with no site code: validate content against the JSON Schema with messages a person can act on, make a blank block or entry, list the kinds of block, and the checks across pages. |
68
68
 
69
+ ## Sections
70
+
71
+ Sections any site can use, built once here (accessible, sized for each screen, inside the content
72
+ security policy) so a site gets them with a kit update rather than code of its own. CalOS offers
73
+ them in its editor like the site's own, and Cal can add one. Today: **gallery** (photos in a grid,
74
+ each with an optional caption, opening its largest copy; 2 to 4 across, square, landscape, portrait
75
+ or as taken) and **slideshow** (photos one at a time, fading from one to the next, with captions).
76
+
77
+ **Your own section** (`own`) is built in CalOS rather than in code: one to four columns (or a wide one
78
+ beside a narrow one), each a list of parts (heading, text, photo, button, list, quote, facts in a row,
79
+ price, photos in a grid, slideshow, space), left or centred, plain or in the brand colour. Its parts
80
+ wear the site's own classes, so what's built looks like the site. One that's wanted again is saved by
81
+ name in the content's `saved` list, and CalOS offers it beside the site's own kinds.
82
+
83
+ `Slideshow` (from `@caltho/site-kit/next`) is also a building block a site's own sections use: SCV's
84
+ hero moves through its photos behind its words with `<Slideshow fill label="…">{photos}</Slideshow>`.
85
+ It waits while it's off screen, pointed at or focused, never moves for someone who's asked for less
86
+ motion, and has a button to stop it and one for each photo.
87
+
88
+ A site takes them in once, three lines:
89
+
90
+ ```ts
91
+ // content/schema.ts: the kit's sections beside the site's own (one of its own by the same name is kept)
92
+ import { sections } from "@caltho/site-kit/sections";
93
+ export const blocks = { ...sections, hero, about /* … */ };
94
+
95
+ // the site's block renderer: whatever it hasn't a component for
96
+ default:
97
+ return <KitSection s={block} />;
98
+
99
+ // app/layout.tsx
100
+ import "@caltho/site-kit/sections.css";
101
+ ```
102
+
103
+ From then on each section the kit adds reaches the site with `cal sites kit update`. They're themed by
104
+ the site's Look (`--look-*`); a site with a design of its own gives its own classes so they sit in
105
+ it like its own: `<KitSection s={block} classes={{ section: "", wrap: "mx-auto max-w-5xl px-4 py-12", heading: "font-display text-3xl", text: "…", button: "…", buttonQuiet: "…" }} />`.
106
+
69
107
  ## Share cards
70
108
 
71
109
  Each page is shared (Open Graph) with a 1200×630 card made at build from its words: its title and
@@ -242,6 +242,10 @@ export declare function defineSite<const B extends Record<string, z.ZodObject>>(
242
242
  shareText: z.ZodOptional<z.ZodString>;
243
243
  noindex: z.ZodDefault<z.ZodBoolean>;
244
244
  }, z.core.$strip>>;
245
+ saved: z.ZodDefault<z.ZodArray<z.ZodObject<{
246
+ name: z.ZodString;
247
+ block: z.ZodDiscriminatedUnion<[z.ZodObject<z.core.$ZodLooseShape, z.core.$strip>, ...z.ZodObject<z.core.$ZodLooseShape, z.core.$strip>[]], "type">;
248
+ }, z.core.$strip>>>;
245
249
  look?: z.ZodOptional<z.ZodObject<{
246
250
  brand: z.ZodDefault<z.ZodString>;
247
251
  accent: z.ZodDefault<z.ZodString>;
@@ -359,6 +363,10 @@ export declare function defineSite<const B extends Record<string, z.ZodObject>>(
359
363
  shareText: z.ZodOptional<z.ZodString>;
360
364
  noindex: z.ZodDefault<z.ZodBoolean>;
361
365
  }, z.core.$strip>>;
366
+ saved: z.ZodDefault<z.ZodArray<z.ZodObject<{
367
+ name: z.ZodString;
368
+ block: z.ZodDiscriminatedUnion<[z.ZodObject<z.core.$ZodLooseShape, z.core.$strip>, ...z.ZodObject<z.core.$ZodLooseShape, z.core.$strip>[]], "type">;
369
+ }, z.core.$strip>>>;
362
370
  look?: z.ZodOptional<z.ZodObject<{
363
371
  brand: z.ZodDefault<z.ZodString>;
364
372
  accent: z.ZodDefault<z.ZodString>;
@@ -172,6 +172,12 @@ export function defineSite(blocks, o = {}) {
172
172
  ...(o.look ? { look: lookSchema(o.look) } : {}),
173
173
  ...o.extend,
174
174
  pages: z.array(page).min(1),
175
+ /** Sections the business put together and named, to add again anywhere ("Package card"): CalOS offers them beside the site's own kinds. */
176
+ saved: z
177
+ .array(z.object({ name: text.meta({ title: "Name" }), block: union }))
178
+ .max(30)
179
+ .default([])
180
+ .meta({ title: "Saved sections" }),
175
181
  })
176
182
  .superRefine((site, ctx) => {
177
183
  const s = site;
@@ -4,3 +4,5 @@ export { pageMetadata, robots, sitemap, staticParams } from "./metadata.ts";
4
4
  export { Img, type Ratio } from "./parts/Img.tsx";
5
5
  export { LookStyle } from "./parts/LookStyle.tsx";
6
6
  export { Md } from "./parts/Md.tsx";
7
+ export { KitSection, type KitBlock, type KitClasses } from "./sections.tsx";
8
+ export { Slideshow } from "./slideshow.tsx";
@@ -7,3 +7,5 @@ export { pageMetadata, robots, sitemap, staticParams } from "./metadata.js";
7
7
  export { Img } from "./parts/Img.js";
8
8
  export { LookStyle } from "./parts/LookStyle.js";
9
9
  export { Md } from "./parts/Md.js";
10
+ export { KitSection } from "./sections.js";
11
+ export { Slideshow } from "./slideshow.js";
@@ -0,0 +1,23 @@
1
+ export type KitBlock = {
2
+ type: string;
3
+ id?: string;
4
+ placement?: Record<string, unknown>;
5
+ } & Record<string, unknown>;
6
+ /**
7
+ * The site's own classes for a kit section's parts, each in place of the kit's: its band, its
8
+ * container, its heading, its words, and its buttons (the main one and a quieter one).
9
+ */
10
+ export type KitClasses = {
11
+ section?: string;
12
+ wrap?: string;
13
+ heading?: string;
14
+ text?: string;
15
+ button?: string;
16
+ buttonQuiet?: string;
17
+ };
18
+ /** A block of one of the kit's types, drawn; null for any other (the site's own to draw). */
19
+ export declare function KitSection({ s, level, classes }: {
20
+ s: KitBlock;
21
+ level?: number;
22
+ classes?: KitClasses;
23
+ }): import("react").JSX.Element | null;
@@ -0,0 +1,87 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { placementClass } from "../content/index.js";
3
+ import { image } from "./data.js";
4
+ import { Img } from "./parts/Img.js";
5
+ import { Md } from "./parts/Md.js";
6
+ import { Slideshow } from "./slideshow.js";
7
+ /** A block of one of the kit's types, drawn; null for any other (the site's own to draw). */
8
+ export function KitSection({ s, level = 2, classes = {} }) {
9
+ switch (s.type) {
10
+ case "gallery":
11
+ return _jsx(Gallery, { s: s, level: level, classes: classes });
12
+ case "slideshow":
13
+ return _jsx(Slides, { s: s, level: level, classes: classes });
14
+ case "own":
15
+ return _jsx(Own, { s: s, level: level, classes: classes });
16
+ default:
17
+ return null;
18
+ }
19
+ }
20
+ const RATIO = { square: "1/1", landscape: "4/3", portrait: "4/5", "as-taken": undefined };
21
+ function Heading({ level, id, className, children }) {
22
+ const H = `h${Math.min(Math.max(level, 1), 6)}`;
23
+ return (_jsx(H, { id: id, className: className, children: children }));
24
+ }
25
+ /** Photos in a grid, each opening its largest copy; its heading and intro above when it has them. */
26
+ function Gallery({ s, level, classes }) {
27
+ const columns = Number(s.columns ?? "3");
28
+ const headingId = s.heading ? `${s.id ?? "gallery"}-heading` : undefined;
29
+ return (_jsx("section", { className: [classes.section ?? "kit-section", "kit-gallery", placementClass(s)].filter(Boolean).join(" "), id: s.id, "aria-labelledby": headingId, children: _jsxs("div", { className: classes.wrap ?? "kit-wrap", children: [s.heading && (_jsx(Heading, { level: level, id: headingId, className: classes.heading ?? "kit-heading", children: s.heading })), s.intro && _jsx(Md, { text: s.intro, top: level + 1, className: "kit-intro" }), _jsx("ul", { className: `kit-gallery-grid kit-cols-${columns}`, children: s.images.map((img) => {
30
+ const copies = image(img.src).srcset;
31
+ const largest = copies.at(-1)?.url ?? image(img.src).src;
32
+ return (_jsx("li", { children: _jsxs("figure", { children: [_jsx("a", { href: largest, className: "kit-gallery-open", children: _jsx(Img, { src: img.src, alt: img.alt, sizes: `(min-width: 1024px) ${Math.round(100 / columns)}vw, 50vw`, ratio: RATIO[s.shape ?? "square"] }) }), img.caption && _jsx("figcaption", { children: img.caption })] }) }, img.src));
33
+ }) })] }) }));
34
+ }
35
+ const SLIDE_RATIO = { wide: "kit-shape-wide", landscape: "kit-shape-landscape", square: "kit-shape-square" };
36
+ /** Photos one at a time (Slideshow), each with its caption under it; its heading above when it has one. */
37
+ function Slides({ s, level, classes }) {
38
+ const headingId = s.heading ? `${s.id ?? "slideshow"}-heading` : undefined;
39
+ return (_jsx("section", { className: [classes.section ?? "kit-section", "kit-slideshow", placementClass(s)].filter(Boolean).join(" "), id: s.id, "aria-labelledby": headingId, children: _jsxs("div", { className: classes.wrap ?? "kit-wrap", children: [s.heading && (_jsx(Heading, { level: level, id: headingId, className: classes.heading ?? "kit-heading", children: s.heading })), _jsx(Slideshow, { label: s.heading ?? "Photos", seconds: s.seconds, className: SLIDE_RATIO[s.shape ?? "wide"], children: s.images.map((img, i) => (_jsxs("figure", { children: [_jsx(Img, { src: img.src, alt: img.alt, sizes: "(min-width: 1200px) 1200px, 100vw", eager: i === 0 }), img.caption && _jsx("figcaption", { children: img.caption })] }, img.src))) })] }) }));
40
+ }
41
+ const SHAPE = { "as-taken": undefined, landscape: "4/3", square: "1/1", portrait: "4/5" };
42
+ const COLUMNS = { one: 1, two: 2, three: 3, four: 4, "wide-left": 2, "wide-right": 2 };
43
+ /**
44
+ * A section of the business's own: its columns of parts, laid out as chosen, each part in the site's
45
+ * own classes where it gave them and the kit's otherwise. A heading is the page's next level down.
46
+ */
47
+ function Own({ s, level, classes }) {
48
+ const n = COLUMNS[s.layout ?? "one"] ?? 1;
49
+ const columns = s.columns.slice(0, n);
50
+ return (_jsx("section", { className: [classes.section ?? "kit-section", "kit-own", `kit-own-${s.tone ?? "plain"}`, placementClass(s)].filter(Boolean).join(" "), id: s.id, children: _jsx("div", { className: classes.wrap ?? "kit-wrap", children: _jsx("div", { className: `kit-own-grid kit-own-${s.layout ?? "one"} kit-own-align-${s.align ?? "left"}`, children: columns.map((c, i) => (_jsx("div", { className: "kit-own-column", children: c.parts.map((p, j) => (_jsx(OwnPart, { p: p, level: level, classes: classes, columns: n }, j))) }, i))) }) }) }));
51
+ }
52
+ function OwnPart({ p, level, classes, columns }) {
53
+ const width = `(min-width: 1024px) ${Math.round(100 / columns)}vw, 100vw`;
54
+ switch (p.type) {
55
+ case "heading": {
56
+ // Large is the section's own heading level; medium and small the levels under it.
57
+ const at = level + (p.size === "large" ? 0 : p.size === "small" ? 2 : 1);
58
+ return (_jsx(Heading, { level: at, className: [classes.heading ?? "kit-heading", `kit-own-h-${p.size ?? "medium"}`].join(" "), children: p.text }));
59
+ }
60
+ case "text":
61
+ return _jsx(Md, { text: p.text, top: level + 1, className: ["kit-own-text", classes.text].filter(Boolean).join(" ") });
62
+ case "photo":
63
+ return (_jsxs("figure", { className: "kit-own-photo", children: [_jsx(Img, { src: p.image.src, alt: p.image.alt, sizes: width, ratio: SHAPE[p.shape ?? "as-taken"] }), p.image.caption && _jsx("figcaption", { children: p.image.caption })] }));
64
+ case "button": {
65
+ const quiet = p.style === "quiet";
66
+ return (_jsx("p", { className: "kit-own-action", children: _jsx("a", { href: p.href, className: (quiet ? classes.buttonQuiet : classes.button) ?? (quiet ? "kit-button kit-button-quiet" : "kit-button"), children: p.label }) }));
67
+ }
68
+ case "list": {
69
+ const L = p.style === "numbers" ? "ol" : "ul";
70
+ return (_jsx(L, { className: ["kit-own-list", `kit-own-list-${p.style ?? "bullets"}`, classes.text].filter(Boolean).join(" "), children: p.items.map((item, i) => (_jsx("li", { children: item }, i))) }));
71
+ }
72
+ case "quote":
73
+ return (_jsxs("figure", { className: "kit-own-quote", children: [_jsx("blockquote", { className: classes.text, children: p.text }), p.who && _jsx("figcaption", { children: p.who })] }));
74
+ case "stats":
75
+ return (_jsx("dl", { className: "kit-own-stats", children: p.items.map((it, i) => (_jsxs("div", { children: [_jsx("dt", { children: it.label }), _jsx("dd", { children: it.value })] }, i))) }));
76
+ case "price":
77
+ return (_jsxs("p", { className: "kit-own-price", children: [_jsx("span", { className: "kit-own-amount", children: p.amount }), p.per && _jsxs("span", { className: "kit-own-per", children: [" ", p.per] }), p.note && _jsx("span", { className: "kit-own-note", children: p.note })] }));
78
+ case "gallery": {
79
+ const n = Number(p.columns ?? "3");
80
+ return (_jsx("ul", { className: `kit-gallery-grid kit-cols-${n}`, children: p.images.map((img) => (_jsx("li", { children: _jsxs("figure", { children: [_jsx(Img, { src: img.src, alt: img.alt, sizes: `(min-width: 1024px) ${Math.round(100 / (n * columns))}vw, 50vw`, ratio: "1/1" }), img.caption && _jsx("figcaption", { children: img.caption })] }) }, img.src))) }));
81
+ }
82
+ case "slideshow":
83
+ return (_jsx(Slideshow, { label: "Photos", seconds: p.seconds, className: "kit-shape-landscape", children: p.images.map((img, i) => (_jsxs("figure", { children: [_jsx(Img, { src: img.src, alt: img.alt, sizes: width, eager: i === 0 }), img.caption && _jsx("figcaption", { children: img.caption })] }, img.src))) }));
84
+ case "space":
85
+ return _jsx("div", { "aria-hidden": "true", className: `kit-own-space kit-own-space-${p.size ?? "medium"}` });
86
+ }
87
+ }
@@ -0,0 +1,8 @@
1
+ import { type ReactNode } from "react";
2
+ export declare function Slideshow({ children, label, seconds, className, fill }: {
3
+ children: ReactNode;
4
+ label: string;
5
+ seconds?: number;
6
+ className?: string;
7
+ fill?: boolean;
8
+ }): import("react").JSX.Element;
@@ -0,0 +1,44 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // Photos shown one at a time, fading from one to the next: on its own (the slideshow section) or
4
+ // behind a site's own hero, whose words stay over it. The slides are drawn by the page (pictures at
5
+ // the sizes the build made); this moves between them. Every few seconds while it's on screen and
6
+ // not pointed at or focused, never for someone who's asked for less motion, with a button to stop it
7
+ // (moving content must be stoppable) and one to show each photo. Classes only, so the content
8
+ // security policy stands: data-active shows a slide, sections.css fades it.
9
+ import { Children, useEffect, useRef, useState } from "react";
10
+ export function Slideshow({ children, label, seconds = 5, className, fill = false }) {
11
+ const slides = Children.toArray(children);
12
+ const [at, setAt] = useState(0);
13
+ const [stopped, setStopped] = useState(false);
14
+ const [held, setHeld] = useState(false);
15
+ const [still, setStill] = useState(false);
16
+ const root = useRef(null);
17
+ const [seen, setSeen] = useState(true);
18
+ // Someone who's asked for less motion: it waits for them.
19
+ useEffect(() => {
20
+ const m = window.matchMedia("(prefers-reduced-motion: reduce)");
21
+ const set = () => setStill(m.matches);
22
+ set();
23
+ m.addEventListener("change", set);
24
+ return () => m.removeEventListener("change", set);
25
+ }, []);
26
+ // Off screen, it waits.
27
+ useEffect(() => {
28
+ if (!root.current || typeof IntersectionObserver === "undefined")
29
+ return;
30
+ const o = new IntersectionObserver(([e]) => setSeen(!!e?.isIntersecting));
31
+ o.observe(root.current);
32
+ return () => o.disconnect();
33
+ }, []);
34
+ const moving = slides.length > 1 && !stopped && !held && !still && seen;
35
+ useEffect(() => {
36
+ if (!moving)
37
+ return;
38
+ const t = setTimeout(() => setAt((i) => (i + 1) % slides.length), Math.max(2, seconds) * 1000);
39
+ return () => clearTimeout(t);
40
+ }, [moving, at, seconds, slides.length]);
41
+ if (slides.length < 2)
42
+ return _jsx("div", { className: [fill ? "kit-slides kit-slides-fill" : "kit-slides", className].filter(Boolean).join(" "), children: slides });
43
+ return (_jsxs("div", { ref: root, role: "region", "aria-roledescription": "slideshow", "aria-label": label, className: [fill ? "kit-slides kit-slides-fill" : "kit-slides", className].filter(Boolean).join(" "), onPointerEnter: () => setHeld(true), onPointerLeave: () => setHeld(false), onFocus: () => setHeld(true), onBlur: (e) => !e.currentTarget.contains(e.relatedTarget) && setHeld(false), children: [slides.map((slide, i) => (_jsx("div", { className: "kit-slide", "data-active": i === at ? "" : undefined, "aria-hidden": i === at ? undefined : true, role: "group", "aria-roledescription": "slide", "aria-label": `${i + 1} of ${slides.length}`, children: slide }, i))), _jsxs("div", { className: "kit-slides-controls", children: [_jsx("button", { type: "button", className: "kit-slides-stop", onClick: () => setStopped((s) => !s), "aria-label": stopped || still ? "Play the slideshow" : "Pause the slideshow", "aria-pressed": stopped, children: _jsx("span", { "aria-hidden": "true", children: stopped || still ? "▶" : "❚❚" }) }), slides.map((_, i) => (_jsx("button", { type: "button", className: "kit-slides-dot", "aria-label": `Show photo ${i + 1} of ${slides.length}`, "aria-current": i === at ? "true" : undefined, onClick: () => setAt(i) }, i)))] })] }));
44
+ }
@@ -0,0 +1,204 @@
1
+ import { z } from "zod";
2
+ /** Photos in a grid, each with a caption if it has one: their work, the place, the team at it. */
3
+ export declare const gallery: z.ZodObject<{
4
+ heading: z.ZodOptional<z.ZodString>;
5
+ intro: z.ZodOptional<z.ZodString>;
6
+ images: z.ZodArray<z.ZodObject<{
7
+ src: z.ZodString;
8
+ alt: z.ZodString;
9
+ caption: z.ZodOptional<z.ZodString>;
10
+ }, z.core.$strip>>;
11
+ columns: z.ZodDefault<z.ZodEnum<{
12
+ 2: "2";
13
+ 3: "3";
14
+ 4: "4";
15
+ }>>;
16
+ shape: z.ZodDefault<z.ZodEnum<{
17
+ square: "square";
18
+ landscape: "landscape";
19
+ portrait: "portrait";
20
+ "as-taken": "as-taken";
21
+ }>>;
22
+ }, z.core.$strip>;
23
+ /** Photos one at a time, fading from one to the next, each with a caption if it has one. */
24
+ export declare const slideshow: z.ZodObject<{
25
+ heading: z.ZodOptional<z.ZodString>;
26
+ images: z.ZodArray<z.ZodObject<{
27
+ src: z.ZodString;
28
+ alt: z.ZodString;
29
+ caption: z.ZodOptional<z.ZodString>;
30
+ }, z.core.$strip>>;
31
+ shape: z.ZodDefault<z.ZodEnum<{
32
+ square: "square";
33
+ wide: "wide";
34
+ landscape: "landscape";
35
+ }>>;
36
+ seconds: z.ZodDefault<z.ZodNumber>;
37
+ }, z.core.$strip>;
38
+ export declare const parts: {
39
+ readonly heading: z.ZodObject<{
40
+ text: z.ZodString;
41
+ size: z.ZodDefault<z.ZodEnum<{
42
+ small: "small";
43
+ large: "large";
44
+ medium: "medium";
45
+ }>>;
46
+ }, z.core.$strip>;
47
+ readonly text: z.ZodObject<{
48
+ text: z.ZodString;
49
+ }, z.core.$strip>;
50
+ readonly photo: z.ZodObject<{
51
+ image: z.ZodObject<{
52
+ src: z.ZodString;
53
+ alt: z.ZodString;
54
+ caption: z.ZodOptional<z.ZodString>;
55
+ }, z.core.$strip>;
56
+ shape: z.ZodDefault<z.ZodEnum<{
57
+ square: "square";
58
+ landscape: "landscape";
59
+ portrait: "portrait";
60
+ "as-taken": "as-taken";
61
+ }>>;
62
+ }, z.core.$strip>;
63
+ readonly button: z.ZodObject<{
64
+ label: z.ZodString;
65
+ href: z.ZodString;
66
+ style: z.ZodDefault<z.ZodEnum<{
67
+ main: "main";
68
+ quiet: "quiet";
69
+ }>>;
70
+ }, z.core.$strip>;
71
+ readonly list: z.ZodObject<{
72
+ items: z.ZodArray<z.ZodString>;
73
+ style: z.ZodDefault<z.ZodEnum<{
74
+ checks: "checks";
75
+ bullets: "bullets";
76
+ numbers: "numbers";
77
+ }>>;
78
+ }, z.core.$strip>;
79
+ readonly quote: z.ZodObject<{
80
+ text: z.ZodString;
81
+ who: z.ZodOptional<z.ZodString>;
82
+ }, z.core.$strip>;
83
+ readonly stats: z.ZodObject<{
84
+ items: z.ZodArray<z.ZodObject<{
85
+ value: z.ZodString;
86
+ label: z.ZodString;
87
+ }, z.core.$strip>>;
88
+ }, z.core.$strip>;
89
+ readonly price: z.ZodObject<{
90
+ amount: z.ZodString;
91
+ per: z.ZodOptional<z.ZodString>;
92
+ note: z.ZodOptional<z.ZodString>;
93
+ }, z.core.$strip>;
94
+ readonly gallery: z.ZodObject<{
95
+ images: z.ZodArray<z.ZodObject<{
96
+ src: z.ZodString;
97
+ alt: z.ZodString;
98
+ caption: z.ZodOptional<z.ZodString>;
99
+ }, z.core.$strip>>;
100
+ columns: z.ZodDefault<z.ZodEnum<{
101
+ 2: "2";
102
+ 3: "3";
103
+ 4: "4";
104
+ }>>;
105
+ }, z.core.$strip>;
106
+ readonly slideshow: z.ZodObject<{
107
+ images: z.ZodArray<z.ZodObject<{
108
+ src: z.ZodString;
109
+ alt: z.ZodString;
110
+ caption: z.ZodOptional<z.ZodString>;
111
+ }, z.core.$strip>>;
112
+ seconds: z.ZodDefault<z.ZodNumber>;
113
+ }, z.core.$strip>;
114
+ readonly space: z.ZodObject<{
115
+ size: z.ZodDefault<z.ZodEnum<{
116
+ small: "small";
117
+ large: "large";
118
+ medium: "medium";
119
+ }>>;
120
+ }, z.core.$strip>;
121
+ };
122
+ export type PartType = keyof typeof parts;
123
+ /** A section of the business's own: columns of parts, laid out as chosen, in the site's design. */
124
+ export declare const own: z.ZodObject<{
125
+ layout: z.ZodDefault<z.ZodEnum<{
126
+ one: "one";
127
+ two: "two";
128
+ three: "three";
129
+ four: "four";
130
+ "wide-left": "wide-left";
131
+ "wide-right": "wide-right";
132
+ }>>;
133
+ align: z.ZodDefault<z.ZodEnum<{
134
+ left: "left";
135
+ center: "center";
136
+ }>>;
137
+ tone: z.ZodDefault<z.ZodEnum<{
138
+ brand: "brand";
139
+ plain: "plain";
140
+ }>>;
141
+ columns: z.ZodArray<z.ZodObject<{
142
+ parts: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<z.core.$ZodLooseShape, z.core.$strip>, ...z.ZodObject<z.core.$ZodLooseShape, z.core.$strip>[]], "type">>;
143
+ }, z.core.$strip>>;
144
+ }, z.core.$strip>;
145
+ /** Every section the kit draws, by the type a block names. */
146
+ export declare const sections: {
147
+ readonly gallery: z.ZodObject<{
148
+ heading: z.ZodOptional<z.ZodString>;
149
+ intro: z.ZodOptional<z.ZodString>;
150
+ images: z.ZodArray<z.ZodObject<{
151
+ src: z.ZodString;
152
+ alt: z.ZodString;
153
+ caption: z.ZodOptional<z.ZodString>;
154
+ }, z.core.$strip>>;
155
+ columns: z.ZodDefault<z.ZodEnum<{
156
+ 2: "2";
157
+ 3: "3";
158
+ 4: "4";
159
+ }>>;
160
+ shape: z.ZodDefault<z.ZodEnum<{
161
+ square: "square";
162
+ landscape: "landscape";
163
+ portrait: "portrait";
164
+ "as-taken": "as-taken";
165
+ }>>;
166
+ }, z.core.$strip>;
167
+ readonly slideshow: z.ZodObject<{
168
+ heading: z.ZodOptional<z.ZodString>;
169
+ images: z.ZodArray<z.ZodObject<{
170
+ src: z.ZodString;
171
+ alt: z.ZodString;
172
+ caption: z.ZodOptional<z.ZodString>;
173
+ }, z.core.$strip>>;
174
+ shape: z.ZodDefault<z.ZodEnum<{
175
+ square: "square";
176
+ wide: "wide";
177
+ landscape: "landscape";
178
+ }>>;
179
+ seconds: z.ZodDefault<z.ZodNumber>;
180
+ }, z.core.$strip>;
181
+ readonly own: z.ZodObject<{
182
+ layout: z.ZodDefault<z.ZodEnum<{
183
+ one: "one";
184
+ two: "two";
185
+ three: "three";
186
+ four: "four";
187
+ "wide-left": "wide-left";
188
+ "wide-right": "wide-right";
189
+ }>>;
190
+ align: z.ZodDefault<z.ZodEnum<{
191
+ left: "left";
192
+ center: "center";
193
+ }>>;
194
+ tone: z.ZodDefault<z.ZodEnum<{
195
+ brand: "brand";
196
+ plain: "plain";
197
+ }>>;
198
+ columns: z.ZodArray<z.ZodObject<{
199
+ parts: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<z.core.$ZodLooseShape, z.core.$strip>, ...z.ZodObject<z.core.$ZodLooseShape, z.core.$strip>[]], "type">>;
200
+ }, z.core.$strip>>;
201
+ }, z.core.$strip>;
202
+ };
203
+ export type KitSectionType = keyof typeof sections;
204
+ export declare const isKitSection: (type: string) => type is KitSectionType;
@@ -0,0 +1,69 @@
1
+ // Sections any site can use, built once here so a site gets them with a kit update rather than code of
2
+ // its own (README, "Sections"): each one's fields here, drawn by KitSection (../next/sections.tsx) and
3
+ // styled by sections.css, themed by the site's Look. A site takes them all into its blocks
4
+ // (`defineSite({ ...blocks, ...sections })`); one of its own by the same name is kept over the kit's.
5
+ import { z } from "zod";
6
+ import { href, image, md, text } from "../content/index.js";
7
+ /** Photos in a grid, each with a caption if it has one: their work, the place, the team at it. */
8
+ export const gallery = z
9
+ .object({
10
+ heading: text.optional().meta({ title: "Heading" }),
11
+ intro: md.optional().meta({ title: "Intro" }),
12
+ images: z
13
+ .array(image.extend({ caption: text.optional().meta({ title: "Caption" }) }))
14
+ .min(1)
15
+ .max(60)
16
+ .meta({ title: "Photos" }),
17
+ columns: z.enum(["2", "3", "4"]).default("3").meta({ title: "Photos across" }),
18
+ shape: z.enum(["square", "landscape", "portrait", "as-taken"]).default("square").meta({ title: "Shape" }),
19
+ })
20
+ .meta({ title: "Gallery", description: "Photos in a grid, each with an optional caption" });
21
+ /** Photos one at a time, fading from one to the next, each with a caption if it has one. */
22
+ export const slideshow = z
23
+ .object({
24
+ heading: text.optional().meta({ title: "Heading" }),
25
+ images: z
26
+ .array(image.extend({ caption: text.optional().meta({ title: "Caption" }) }))
27
+ .min(2)
28
+ .max(20)
29
+ .meta({ title: "Photos" }),
30
+ shape: z.enum(["wide", "landscape", "square"]).default("wide").meta({ title: "Shape" }),
31
+ seconds: z.number().int().min(3).max(15).default(5).meta({ title: "Seconds on each photo" }),
32
+ })
33
+ .meta({ title: "Slideshow", description: "Photos one at a time, fading from one to the next" });
34
+ /**
35
+ * The parts a section of the business's own is put together from in CalOS: each drawn in the site's
36
+ * own type, buttons and widths (the classes it gives KitSection), so what's built looks like the
37
+ * site, not a template. A fixed set, no colours or fonts of their own.
38
+ */
39
+ const photo = image.extend({ caption: text.optional().meta({ title: "Caption" }) });
40
+ export const parts = {
41
+ heading: z.object({ text, size: z.enum(["large", "medium", "small"]).default("medium").meta({ title: "Size" }) }).meta({ title: "Heading" }),
42
+ text: z.object({ text: md }).meta({ title: "Text" }),
43
+ photo: z.object({ image: photo, shape: z.enum(["as-taken", "landscape", "square", "portrait"]).default("as-taken").meta({ title: "Shape" }) }).meta({ title: "Photo" }),
44
+ button: z.object({ label: text.meta({ title: "Label" }), href, style: z.enum(["main", "quiet"]).default("main").meta({ title: "Style" }) }).meta({ title: "Button" }),
45
+ list: z.object({ items: z.array(text).min(1).max(20).meta({ title: "Items" }), style: z.enum(["bullets", "checks", "numbers"]).default("bullets").meta({ title: "Style" }) }).meta({ title: "List" }),
46
+ quote: z.object({ text: text.meta({ title: "Quote" }), who: text.optional().meta({ title: "Who said it" }) }).meta({ title: "Quote" }),
47
+ stats: z.object({ items: z.array(z.object({ value: text.meta({ title: "Number" }), label: text.meta({ title: "What it is" }) })).min(1).max(6).meta({ title: "Facts" }) }).meta({ title: "Facts in a row", description: "8 guests · 4 cabins · 2 baths" }),
48
+ price: z.object({ amount: text.meta({ title: "Price" }), per: text.optional().meta({ title: "Per" }), note: text.optional().meta({ title: "Note" }) }).meta({ title: "Price" }),
49
+ gallery: z.object({ images: z.array(photo).min(1).max(30).meta({ title: "Photos" }), columns: z.enum(["2", "3", "4"]).default("3").meta({ title: "Photos across" }) }).meta({ title: "Photos in a grid" }),
50
+ slideshow: z.object({ images: z.array(photo).min(2).max(20).meta({ title: "Photos" }), seconds: z.number().int().min(3).max(15).default(5).meta({ title: "Seconds on each photo" }) }).meta({ title: "Slideshow" }),
51
+ space: z.object({ size: z.enum(["small", "medium", "large"]).default("medium").meta({ title: "Size" }) }).meta({ title: "Space" }),
52
+ };
53
+ const part = z.discriminatedUnion("type", Object.keys(parts).map((type) => z.object({ type: z.literal(type) }).extend(parts[type].shape).meta(parts[type].meta())));
54
+ /** A section of the business's own: columns of parts, laid out as chosen, in the site's design. */
55
+ export const own = z
56
+ .object({
57
+ layout: z.enum(["one", "two", "three", "four", "wide-left", "wide-right"]).default("one").meta({ title: "Columns" }),
58
+ align: z.enum(["left", "center"]).default("left").meta({ title: "Align" }),
59
+ tone: z.enum(["plain", "brand"]).default("plain").meta({ title: "Background" }),
60
+ columns: z
61
+ .array(z.object({ parts: z.array(part).max(12).meta({ title: "Parts" }) }).meta({ title: "Column" }))
62
+ .min(1)
63
+ .max(4)
64
+ .meta({ title: "Columns' parts" }),
65
+ })
66
+ .meta({ title: "Your own section", description: "Put together from headings, text, photos, buttons and more, in the site's design" });
67
+ /** Every section the kit draws, by the type a block names. */
68
+ export const sections = { gallery, slideshow, own };
69
+ export const isKitSection = (type) => Object.prototype.hasOwnProperty.call(sections, type);
@@ -0,0 +1,358 @@
1
+ /*
2
+ * The kit's sections (@caltho/site-kit/sections), styled to sit in any site: its width, spacing and
3
+ * type, themed by the site's Look (--look-*) where it has one and its own fonts and colours otherwise.
4
+ * Import once in the site's layout: import "@caltho/site-kit/sections.css".
5
+ */
6
+ .kit-section {
7
+ padding-block: clamp(48px, 8vw, 96px);
8
+ }
9
+ .kit-wrap {
10
+ width: min(1200px, 100% - 40px);
11
+ margin-inline: auto;
12
+ }
13
+ .kit-heading {
14
+ margin: 0 0 12px;
15
+ font-family: var(--look-heading-font, inherit);
16
+ font-size: clamp(1.6rem, 3vw, 2.25rem);
17
+ line-height: 1.15;
18
+ }
19
+ .kit-intro {
20
+ max-width: 65ch;
21
+ margin-top: 12px;
22
+ margin-bottom: 28px;
23
+ }
24
+ /* Room after a heading, whoever's class it wears (a site's own may have no margin). */
25
+ :is(h1, h2, h3, h4, h5, h6) + .kit-gallery-grid,
26
+ :is(h1, h2, h3, h4, h5, h6) + .kit-slides {
27
+ margin-top: 24px;
28
+ }
29
+
30
+ /* Gallery: two across on a phone, as many as chosen from a tablet up. */
31
+ .kit-gallery-grid {
32
+ display: grid;
33
+ grid-template-columns: repeat(2, minmax(0, 1fr));
34
+ gap: clamp(8px, 1.5vw, 16px);
35
+ margin: 0;
36
+ padding: 0;
37
+ list-style: none;
38
+ }
39
+ @media (min-width: 720px) {
40
+ .kit-cols-3,
41
+ .kit-cols-4 {
42
+ grid-template-columns: repeat(3, minmax(0, 1fr));
43
+ }
44
+ }
45
+ @media (min-width: 1024px) {
46
+ .kit-cols-4 {
47
+ grid-template-columns: repeat(4, minmax(0, 1fr));
48
+ }
49
+ }
50
+ .kit-gallery figure {
51
+ margin: 0;
52
+ }
53
+ .kit-gallery-open {
54
+ display: block;
55
+ border-radius: var(--look-radius, 8px);
56
+ }
57
+ .kit-gallery-open:focus-visible {
58
+ outline: 3px solid var(--look-accent, currentColor);
59
+ outline-offset: 3px;
60
+ }
61
+ .kit-gallery img {
62
+ display: block;
63
+ width: 100%;
64
+ height: auto;
65
+ border-radius: var(--look-radius, 8px);
66
+ object-fit: cover;
67
+ }
68
+ .kit-gallery img.ratio-1-1 {
69
+ aspect-ratio: 1 / 1;
70
+ }
71
+ .kit-gallery img.ratio-4-3 {
72
+ aspect-ratio: 4 / 3;
73
+ }
74
+ .kit-gallery img.ratio-4-5 {
75
+ aspect-ratio: 4 / 5;
76
+ }
77
+ .kit-gallery figcaption {
78
+ margin-top: 8px;
79
+ font-size: 0.9rem;
80
+ opacity: 0.75;
81
+ }
82
+
83
+ /*
84
+ * Slideshow: the slides stacked, the one showing faded in. In a box of its own shape (the section),
85
+ * or filling what it's in (`fill`: behind a hero's words). Controls bottom right, over the photos.
86
+ */
87
+ .kit-slides {
88
+ position: relative;
89
+ overflow: hidden;
90
+ border-radius: var(--look-radius, 8px);
91
+ }
92
+ .kit-slides-fill {
93
+ position: absolute;
94
+ inset: 0;
95
+ border-radius: 0;
96
+ }
97
+ .kit-shape-wide {
98
+ aspect-ratio: 16 / 9;
99
+ }
100
+ .kit-shape-landscape {
101
+ aspect-ratio: 4 / 3;
102
+ }
103
+ .kit-shape-square {
104
+ aspect-ratio: 1 / 1;
105
+ }
106
+ .kit-slide {
107
+ position: absolute;
108
+ inset: 0;
109
+ opacity: 0;
110
+ visibility: hidden;
111
+ transition:
112
+ opacity 0.8s ease,
113
+ visibility 0.8s;
114
+ }
115
+ .kit-slide[data-active] {
116
+ opacity: 1;
117
+ visibility: visible;
118
+ }
119
+ .kit-slide figure {
120
+ margin: 0;
121
+ height: 100%;
122
+ }
123
+ .kit-slide img {
124
+ display: block;
125
+ width: 100%;
126
+ height: 100%;
127
+ object-fit: cover;
128
+ }
129
+ .kit-slide figcaption {
130
+ position: absolute;
131
+ inset-inline: 0;
132
+ bottom: 0;
133
+ padding: 28px 16px 12px;
134
+ background: linear-gradient(transparent, rgb(0 0 0 / 0.55));
135
+ color: #fff;
136
+ font-size: 0.9rem;
137
+ }
138
+ .kit-slides-controls {
139
+ position: absolute;
140
+ right: 12px;
141
+ bottom: 12px;
142
+ z-index: 2;
143
+ display: flex;
144
+ align-items: center;
145
+ gap: 8px;
146
+ padding: 6px 10px;
147
+ border-radius: 999px;
148
+ background: rgb(0 0 0 / 0.35);
149
+ }
150
+ .kit-slides-stop {
151
+ display: grid;
152
+ place-items: center;
153
+ width: 24px;
154
+ height: 24px;
155
+ border: 0;
156
+ padding: 0;
157
+ background: none;
158
+ color: #fff;
159
+ font-size: 10px;
160
+ cursor: pointer;
161
+ }
162
+ .kit-slides-dot {
163
+ width: 8px;
164
+ height: 8px;
165
+ border: 0;
166
+ padding: 0;
167
+ border-radius: 999px;
168
+ background: rgb(255 255 255 / 0.5);
169
+ cursor: pointer;
170
+ }
171
+ .kit-slides-dot[aria-current] {
172
+ background: #fff;
173
+ }
174
+ .kit-slides-stop:focus-visible,
175
+ .kit-slides-dot:focus-visible {
176
+ outline: 2px solid #fff;
177
+ outline-offset: 2px;
178
+ }
179
+ /* Bigger targets for a finger, the dot itself as it was. */
180
+ @media (pointer: coarse) {
181
+ .kit-slides-dot {
182
+ width: 10px;
183
+ height: 10px;
184
+ }
185
+ .kit-slides-controls {
186
+ gap: 12px;
187
+ }
188
+ }
189
+ @media (prefers-reduced-motion: reduce) {
190
+ .kit-slide {
191
+ transition: none;
192
+ }
193
+ }
194
+
195
+ /*
196
+ * A section of the business's own: its columns side by side from a tablet up (stacked on a phone),
197
+ * its parts spaced evenly, in the site's type. Brand tone: the site's brand colour behind, its
198
+ * readable colour over it.
199
+ */
200
+ .kit-own-brand {
201
+ background: var(--look-brand, #111);
202
+ color: var(--look-on-brand, #fff);
203
+ }
204
+ .kit-own-grid {
205
+ display: grid;
206
+ gap: clamp(24px, 4vw, 48px);
207
+ }
208
+ @media (min-width: 720px) {
209
+ .kit-own-two,
210
+ .kit-own-four {
211
+ grid-template-columns: repeat(2, minmax(0, 1fr));
212
+ }
213
+ .kit-own-three {
214
+ grid-template-columns: repeat(3, minmax(0, 1fr));
215
+ }
216
+ .kit-own-wide-left {
217
+ grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
218
+ }
219
+ .kit-own-wide-right {
220
+ grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
221
+ }
222
+ }
223
+ @media (min-width: 1024px) {
224
+ .kit-own-four {
225
+ grid-template-columns: repeat(4, minmax(0, 1fr));
226
+ }
227
+ }
228
+ .kit-own-align-center {
229
+ text-align: center;
230
+ }
231
+ .kit-own-align-center .kit-own-text,
232
+ .kit-own-align-center .kit-own-list {
233
+ margin-inline: auto;
234
+ }
235
+ .kit-own-column {
236
+ display: flex;
237
+ flex-direction: column;
238
+ gap: 16px;
239
+ min-width: 0;
240
+ }
241
+ .kit-own-column > * {
242
+ margin: 0;
243
+ }
244
+ .kit-own-h-large {
245
+ font-size: clamp(1.75rem, 3.5vw, 2.5rem);
246
+ }
247
+ .kit-own-h-small {
248
+ font-size: 1.15rem;
249
+ }
250
+ .kit-own-text {
251
+ max-width: 65ch;
252
+ }
253
+ .kit-own-photo {
254
+ margin: 0;
255
+ }
256
+ .kit-own-photo img {
257
+ display: block;
258
+ width: 100%;
259
+ height: auto;
260
+ border-radius: var(--look-radius, 8px);
261
+ object-fit: cover;
262
+ }
263
+ .kit-own-photo img.ratio-1-1 {
264
+ aspect-ratio: 1 / 1;
265
+ }
266
+ .kit-own-photo img.ratio-4-3 {
267
+ aspect-ratio: 4 / 3;
268
+ }
269
+ .kit-own-photo img.ratio-4-5 {
270
+ aspect-ratio: 4 / 5;
271
+ }
272
+ .kit-own-photo figcaption,
273
+ .kit-own-quote figcaption {
274
+ margin-top: 8px;
275
+ font-size: 0.9rem;
276
+ opacity: 0.75;
277
+ }
278
+ .kit-button {
279
+ display: inline-block;
280
+ padding: 10px 18px;
281
+ border: 1px solid var(--look-button-border, currentColor);
282
+ border-radius: var(--look-button-radius, 8px);
283
+ background: var(--look-button-bg, #111);
284
+ color: var(--look-button-fg, #fff);
285
+ font-weight: 500;
286
+ text-decoration: none;
287
+ }
288
+ .kit-button-quiet {
289
+ background: transparent;
290
+ color: inherit;
291
+ }
292
+ .kit-own-list {
293
+ max-width: 65ch;
294
+ padding-left: 1.25em;
295
+ }
296
+ .kit-own-list li + li {
297
+ margin-top: 6px;
298
+ }
299
+ .kit-own-list-checks {
300
+ list-style: none;
301
+ padding-left: 0;
302
+ }
303
+ .kit-own-list-checks li::before {
304
+ content: "✓";
305
+ margin-right: 8px;
306
+ color: var(--look-accent, currentColor);
307
+ }
308
+ .kit-own-quote {
309
+ margin: 0;
310
+ }
311
+ .kit-own-quote blockquote {
312
+ margin: 0;
313
+ font-size: 1.15rem;
314
+ line-height: 1.5;
315
+ }
316
+ .kit-own-stats {
317
+ display: flex;
318
+ flex-wrap: wrap;
319
+ gap: 16px 32px;
320
+ }
321
+ .kit-own-align-center .kit-own-stats {
322
+ justify-content: center;
323
+ }
324
+ .kit-own-stats div {
325
+ display: flex;
326
+ flex-direction: column-reverse;
327
+ }
328
+ .kit-own-stats dd {
329
+ margin: 0;
330
+ font-size: 1.6rem;
331
+ font-weight: 600;
332
+ line-height: 1.1;
333
+ }
334
+ .kit-own-stats dt {
335
+ font-size: 0.85rem;
336
+ opacity: 0.75;
337
+ }
338
+ .kit-own-amount {
339
+ font-size: 2rem;
340
+ font-weight: 600;
341
+ }
342
+ .kit-own-per {
343
+ opacity: 0.75;
344
+ }
345
+ .kit-own-note {
346
+ display: block;
347
+ font-size: 0.85rem;
348
+ opacity: 0.75;
349
+ }
350
+ .kit-own-space-small {
351
+ height: 8px;
352
+ }
353
+ .kit-own-space-medium {
354
+ height: 24px;
355
+ }
356
+ .kit-own-space-large {
357
+ height: 56px;
358
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,134 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { describe, expect, it } from "bun:test";
3
+ import { renderToStaticMarkup } from "react-dom/server";
4
+ import { defineSite, z } from "./content/index.js";
5
+ import { setPrepared } from "./next/data.js";
6
+ import { KitSection } from "./next/sections.js";
7
+ import { Slideshow } from "./next/slideshow.js";
8
+ import { gallery, isKitSection, sections, slideshow } from "./sections/index.js";
9
+ // Sections any site can use: their fields, as a site takes them into its own, and drawn.
10
+ const own = { hero: z.object({ heading: z.string() }) };
11
+ describe("the kit's sections", () => {
12
+ it("join a site's own blocks, and one of the site's by the same name is kept over the kit's", () => {
13
+ const site = defineSite({ ...sections, ...own });
14
+ const page = (blocks) => ({ url: "https://acme.example", business: { name: "Acme" }, pages: [{ path: "/", title: "Acme", description: "Acme does things.", blocks }] });
15
+ const ok = site.parse(page([{ type: "gallery", images: [{ src: "a.jpg", alt: "A deck" }] }]));
16
+ expect(ok.problems).toBeUndefined();
17
+ // Its defaults: three across, square.
18
+ expect(ok.site.pages[0].blocks[0]).toMatchObject({ type: "gallery", columns: "3", shape: "square" });
19
+ expect(site.parse(page([{ type: "gallery", images: [] }])).problems?.join(" ")).toMatch(/images/);
20
+ const theirs = defineSite({ ...sections, gallery: z.object({ photos: z.array(z.string()) }) });
21
+ expect(theirs.parse(page([{ type: "gallery", photos: ["a.jpg"] }])).problems).toBeUndefined();
22
+ expect(isKitSection("gallery")).toBe(true);
23
+ expect(isKitSection("hero")).toBe(false);
24
+ });
25
+ it("are named for CalOS's editor", () => {
26
+ expect(gallery.meta()?.title).toBe("Gallery");
27
+ });
28
+ });
29
+ describe("a gallery, drawn", () => {
30
+ setPrepared({
31
+ site: { url: "https://acme.example", lang: "en", business: { name: "Acme" }, redirects: [], pages: [] },
32
+ images: {
33
+ "a.jpg": { width: 1600, height: 1200, src: "/_img/a.1600.jpg", srcset: [{ url: "/_img/a.640.webp", w: 640 }, { url: "/_img/a.1600.webp", w: 1600 }] },
34
+ "b.jpg": { width: 800, height: 1000, src: "/_img/b.800.jpg", srcset: [] },
35
+ },
36
+ csp: {},
37
+ });
38
+ it("is a grid of its photos, each opening its largest copy, with its heading, intro and captions", () => {
39
+ const html = renderToStaticMarkup(_jsx(KitSection, { s: { type: "gallery", id: "work", heading: "Our decks", intro: "Built this **summer**.", columns: "4", shape: "landscape", images: [{ src: "a.jpg", alt: "A cedar deck", caption: "Cedar, 2026" }, { src: "b.jpg", alt: "A composite deck" }] }, level: 2 }));
40
+ expect(html).toContain('<section class="kit-section kit-gallery" id="work" aria-labelledby="work-heading">');
41
+ expect(html).toContain('<h2 id="work-heading" class="kit-heading">Our decks</h2>');
42
+ expect(html).toContain("<strong>summer</strong>");
43
+ expect(html).toContain('class="kit-gallery-grid kit-cols-4"');
44
+ expect(html).toContain('<a href="/_img/a.1600.webp" class="kit-gallery-open">');
45
+ expect(html).toContain('<a href="/_img/b.800.jpg" class="kit-gallery-open">');
46
+ expect(html).toContain('alt="A cedar deck"');
47
+ expect(html).toContain("ratio-4-3");
48
+ expect(html).toContain("<figcaption>Cedar, 2026</figcaption>");
49
+ expect(html.match(/<figcaption>/g)).toHaveLength(1);
50
+ // No style attribute: the content security policy would block it.
51
+ expect(html).not.toMatch(/\sstyle=/);
52
+ });
53
+ it("sits in a site with a design of its own: its section, container and heading classes in place of the kit's", () => {
54
+ const html = renderToStaticMarkup(_jsx(KitSection, { s: { type: "gallery", heading: "Inside", images: [{ src: "a.jpg", alt: "A cabin" }] }, classes: { section: "", wrap: "mx-auto max-w-5xl px-4", heading: "font-display text-3xl" } }));
55
+ expect(html).toContain('<section class="kit-gallery"');
56
+ expect(html).toContain('<div class="mx-auto max-w-5xl px-4">');
57
+ expect(html).toContain('class="font-display text-3xl">Inside</h2>');
58
+ expect(html).not.toContain("kit-wrap");
59
+ });
60
+ it("draws nothing for a type that isn't the kit's: the site's own to draw", () => {
61
+ expect(renderToStaticMarkup(_jsx(KitSection, { s: { type: "hero", heading: "Hi" } }))).toBe("");
62
+ });
63
+ });
64
+ describe("a slideshow", () => {
65
+ it("shows the first photo, the rest hidden from a screen reader till they're shown, with a button to stop it and one for each photo", () => {
66
+ const html = renderToStaticMarkup(_jsxs(Slideshow, { label: "The boat", seconds: 6, children: [_jsx("img", { alt: "One", src: "/1.jpg" }), _jsx("img", { alt: "Two", src: "/2.jpg" }), _jsx("img", { alt: "Three", src: "/3.jpg" })] }));
67
+ expect(html).toContain('role="region" aria-roledescription="slideshow" aria-label="The boat"');
68
+ expect(html.match(/class="kit-slide"/g)).toHaveLength(3);
69
+ expect(html).toContain('class="kit-slide" data-active="" role="group" aria-roledescription="slide" aria-label="1 of 3"');
70
+ expect(html.match(/aria-hidden="true" role="group"/g)).toHaveLength(2);
71
+ expect(html).toContain('aria-label="Pause the slideshow"');
72
+ expect(html).toContain('aria-label="Show photo 3 of 3"');
73
+ expect(html.match(/aria-current="true"/g)).toHaveLength(1);
74
+ expect(html).not.toMatch(/\sstyle=/);
75
+ });
76
+ it("is a single photo, with nothing to move between, when it has one", () => {
77
+ const html = renderToStaticMarkup(_jsx(Slideshow, { label: "The boat", fill: true, children: _jsx("img", { alt: "One", src: "/1.jpg" }) }));
78
+ expect(html).toContain('<div class="kit-slides kit-slides-fill"><img alt="One" src="/1.jpg"/></div>');
79
+ expect(html).not.toContain("kit-slides-controls");
80
+ });
81
+ it("as a section: two photos at least, five seconds each unless it says, wide unless it says", () => {
82
+ const site = defineSite({ ...sections });
83
+ const page = (blocks) => ({ url: "https://acme.example", business: { name: "Acme" }, pages: [{ path: "/", title: "Acme", description: "Acme does things.", blocks }] });
84
+ const ok = site.parse(page([{ type: "slideshow", images: [{ src: "a.jpg", alt: "A" }, { src: "b.jpg", alt: "B" }] }]));
85
+ expect(ok.problems).toBeUndefined();
86
+ expect(ok.site.pages[0].blocks[0]).toMatchObject({ seconds: 5, shape: "wide" });
87
+ expect(site.parse(page([{ type: "slideshow", images: [{ src: "a.jpg", alt: "A" }] }])).problems?.join(" ")).toMatch(/images/);
88
+ expect(slideshow.meta()?.title).toBe("Slideshow");
89
+ const html = renderToStaticMarkup(_jsx(KitSection, { s: { type: "slideshow", heading: "On board", shape: "landscape", seconds: 4, images: [{ src: "a.jpg", alt: "A cabin", caption: "Cabin" }, { src: "b.jpg", alt: "The deck" }] } }));
90
+ expect(html).toContain('class="kit-slides kit-shape-landscape"');
91
+ expect(html).toContain("<figcaption>Cabin</figcaption>");
92
+ expect(html).toContain('aria-label="On board"');
93
+ });
94
+ });
95
+ describe("a section of the business's own", () => {
96
+ const page = (blocks, saved = []) => ({ url: "https://acme.example", business: { name: "Acme" }, saved, pages: [{ path: "/", title: "Acme", description: "Acme does things.", blocks }] });
97
+ const block = {
98
+ type: "own",
99
+ layout: "two",
100
+ columns: [
101
+ { parts: [{ type: "heading", text: "Our packages", size: "large" }, { type: "text", text: "Pick **the one** that suits." }, { type: "list", items: ["Captain", "Food"], style: "checks" }, { type: "button", label: "Book", href: "/" }] },
102
+ { parts: [{ type: "price", amount: "$120", per: "a night", note: "per person" }, { type: "stats", items: [{ value: "8", label: "guests" }, { value: "4", label: "cabins" }] }, { type: "quote", text: "Best week ever.", who: "Ana" }] },
103
+ ],
104
+ };
105
+ it("is put together from parts, checked like any section, and can be saved by name to add again", () => {
106
+ const site = defineSite({ ...sections });
107
+ const ok = site.parse(page([block], [{ name: "Package card", block }]));
108
+ expect(ok.problems).toBeUndefined();
109
+ expect(ok.site.pages[0].blocks[0]).toMatchObject({ align: "left", tone: "plain" });
110
+ expect(ok.site.saved[0].name).toBe("Package card");
111
+ // A part it doesn't know, or a list with nothing in it, is said.
112
+ expect(site.parse(page([{ ...block, columns: [{ parts: [{ type: "marquee" }] }] }])).problems?.join(" ")).toMatch(/type/);
113
+ expect(site.parse(page([{ ...block, columns: [{ parts: [{ type: "list", items: [] }] }] }])).problems?.join(" ")).toMatch(/items/);
114
+ // Saved sections are optional: a site without any is as it was.
115
+ expect(site.parse(page([block])).site.saved).toEqual([]);
116
+ });
117
+ it("is drawn in the site's own classes: its heading, words and buttons", () => {
118
+ const html = renderToStaticMarkup(_jsx(KitSection, { s: block, level: 2, classes: { section: "", wrap: "site-wrap", heading: "site-h", text: "site-text", button: "site-button" } }));
119
+ expect(html).toContain('class="kit-own-grid kit-own-two kit-own-align-left"');
120
+ expect(html).toContain('<h2 class="site-h kit-own-h-large">Our packages</h2>');
121
+ expect(html).toContain("<strong>the one</strong>");
122
+ expect(html).toContain('class="kit-own-list kit-own-list-checks site-text"');
123
+ expect(html).toContain('<a href="/" class="site-button">Book</a>');
124
+ expect(html).toContain('<span class="kit-own-amount">$120</span>');
125
+ expect(html).toContain("<dd>8</dd>");
126
+ expect(html).toContain("<blockquote class=\"site-text\">Best week ever.</blockquote>");
127
+ expect(html).not.toMatch(/\sstyle=/);
128
+ });
129
+ it("keeps to its layout's columns, and draws the kit's own button where the site gave none", () => {
130
+ const html = renderToStaticMarkup(_jsx(KitSection, { s: { ...block, layout: "one" } }));
131
+ expect(html.match(/kit-own-column/g)).toHaveLength(1);
132
+ expect(html).toContain('class="kit-button"');
133
+ });
134
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@caltho/site-kit",
3
- "version": "0.2.8",
3
+ "version": "0.2.10",
4
4
  "description": "What every Caltho site is built with: the contract with CalOS (content, and how a site declares what is editable), images, search and security, and the checks",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -44,13 +44,18 @@
44
44
  "./next/preview": {
45
45
  "types": "./dist/next/preview.d.ts",
46
46
  "default": "./dist/next/preview.js"
47
- }
47
+ },
48
+ "./sections": {
49
+ "types": "./dist/sections/index.d.ts",
50
+ "default": "./dist/sections/index.js"
51
+ },
52
+ "./sections.css": "./dist/sections.css"
48
53
  },
49
54
  "engines": {
50
55
  "node": ">=22.18"
51
56
  },
52
57
  "scripts": {
53
- "build": "rm -rf dist && tsc -p tsconfig.build.json && cp -R src/fonts dist/fonts && chmod +x dist/cli/index.js",
58
+ "build": "rm -rf dist && tsc -p tsconfig.build.json && cp -R src/fonts dist/fonts && cp src/sections.css dist/sections.css && chmod +x dist/cli/index.js",
54
59
  "typecheck": "tsc -p tsconfig.json --noEmit",
55
60
  "test": "bun test src",
56
61
  "prepack": "bun run build"
@@ -86,10 +91,12 @@
86
91
  "@types/bun": "1.4.2",
87
92
  "@types/node": "22.20.5",
88
93
  "@types/react": "19.3.0",
94
+ "@types/react-dom": "19.2.3",
89
95
  "axe-core": "4.14.0",
90
96
  "next": "16.4.1",
91
97
  "playwright": "1.63.0",
92
98
  "react": "19.3.0",
99
+ "react-dom": "19.3.0",
93
100
  "typescript": "6.0.3"
94
101
  },
95
102
  "bugs": {