@caltho/site-kit 0.2.8 → 0.2.9
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 +32 -0
- package/dist/next/index.d.ts +2 -0
- package/dist/next/index.js +2 -0
- package/dist/next/sections.d.ts +17 -0
- package/dist/next/sections.js +38 -0
- package/dist/next/slideshow.d.ts +8 -0
- package/dist/next/slideshow.js +44 -0
- package/dist/sections/index.d.ts +76 -0
- package/dist/sections/index.js +36 -0
- package/dist/sections.css +193 -0
- package/dist/sections.test.d.ts +1 -0
- package/dist/sections.test.js +94 -0
- package/package.json +10 -3
package/README.md
CHANGED
|
@@ -66,6 +66,38 @@ 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
|
+
`Slideshow` (from `@caltho/site-kit/next`) is also a building block a site's own sections use: SCV's
|
|
78
|
+
hero moves through its photos behind its words with `<Slideshow fill label="…">{photos}</Slideshow>`.
|
|
79
|
+
It waits while it's off screen, pointed at or focused, never moves for someone who's asked for less
|
|
80
|
+
motion, and has a button to stop it and one for each photo.
|
|
81
|
+
|
|
82
|
+
A site takes them in once, three lines:
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
// content/schema.ts: the kit's sections beside the site's own (one of its own by the same name is kept)
|
|
86
|
+
import { sections } from "@caltho/site-kit/sections";
|
|
87
|
+
export const blocks = { ...sections, hero, about /* … */ };
|
|
88
|
+
|
|
89
|
+
// the site's block renderer: whatever it hasn't a component for
|
|
90
|
+
default:
|
|
91
|
+
return <KitSection s={block} />;
|
|
92
|
+
|
|
93
|
+
// app/layout.tsx
|
|
94
|
+
import "@caltho/site-kit/sections.css";
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
From then on each section the kit adds reaches the site with `cal sites kit update`. They're themed by
|
|
98
|
+
the site's Look (`--look-*`); a site with a design of its own gives its own classes so they sit in
|
|
99
|
+
it like its own: `<KitSection s={block} classes={{ section: "", wrap: "mx-auto max-w-5xl px-4 py-12", heading: "font-display text-3xl" }} />`.
|
|
100
|
+
|
|
69
101
|
## Share cards
|
|
70
102
|
|
|
71
103
|
Each page is shared (Open Graph) with a 1200×630 card made at build from its words: its title and
|
package/dist/next/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/next/index.js
CHANGED
|
@@ -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,17 @@
|
|
|
1
|
+
export type KitBlock = {
|
|
2
|
+
type: string;
|
|
3
|
+
id?: string;
|
|
4
|
+
placement?: Record<string, unknown>;
|
|
5
|
+
} & Record<string, unknown>;
|
|
6
|
+
/** The site's own classes for a kit section's parts, each in place of the kit's: its band, its container, its heading. */
|
|
7
|
+
export type KitClasses = {
|
|
8
|
+
section?: string;
|
|
9
|
+
wrap?: string;
|
|
10
|
+
heading?: string;
|
|
11
|
+
};
|
|
12
|
+
/** A block of one of the kit's types, drawn; null for any other (the site's own to draw). */
|
|
13
|
+
export declare function KitSection({ s, level, classes }: {
|
|
14
|
+
s: KitBlock;
|
|
15
|
+
level?: number;
|
|
16
|
+
classes?: KitClasses;
|
|
17
|
+
}): import("react").JSX.Element | null;
|
|
@@ -0,0 +1,38 @@
|
|
|
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
|
+
default:
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const RATIO = { square: "1/1", landscape: "4/3", portrait: "4/5", "as-taken": undefined };
|
|
19
|
+
function Heading({ level, id, className, children }) {
|
|
20
|
+
const H = `h${Math.min(Math.max(level, 1), 6)}`;
|
|
21
|
+
return (_jsx(H, { id: id, className: className, children: children }));
|
|
22
|
+
}
|
|
23
|
+
/** Photos in a grid, each opening its largest copy; its heading and intro above when it has them. */
|
|
24
|
+
function Gallery({ s, level, classes }) {
|
|
25
|
+
const columns = Number(s.columns ?? "3");
|
|
26
|
+
const headingId = s.heading ? `${s.id ?? "gallery"}-heading` : undefined;
|
|
27
|
+
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) => {
|
|
28
|
+
const copies = image(img.src).srcset;
|
|
29
|
+
const largest = copies.at(-1)?.url ?? image(img.src).src;
|
|
30
|
+
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));
|
|
31
|
+
}) })] }) }));
|
|
32
|
+
}
|
|
33
|
+
const SLIDE_RATIO = { wide: "kit-shape-wide", landscape: "kit-shape-landscape", square: "kit-shape-square" };
|
|
34
|
+
/** Photos one at a time (Slideshow), each with its caption under it; its heading above when it has one. */
|
|
35
|
+
function Slides({ s, level, classes }) {
|
|
36
|
+
const headingId = s.heading ? `${s.id ?? "slideshow"}-heading` : undefined;
|
|
37
|
+
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))) })] }) }));
|
|
38
|
+
}
|
|
@@ -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,76 @@
|
|
|
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
|
+
/** Every section the kit draws, by the type a block names. */
|
|
39
|
+
export declare const sections: {
|
|
40
|
+
readonly gallery: z.ZodObject<{
|
|
41
|
+
heading: z.ZodOptional<z.ZodString>;
|
|
42
|
+
intro: z.ZodOptional<z.ZodString>;
|
|
43
|
+
images: z.ZodArray<z.ZodObject<{
|
|
44
|
+
src: z.ZodString;
|
|
45
|
+
alt: z.ZodString;
|
|
46
|
+
caption: z.ZodOptional<z.ZodString>;
|
|
47
|
+
}, z.core.$strip>>;
|
|
48
|
+
columns: z.ZodDefault<z.ZodEnum<{
|
|
49
|
+
2: "2";
|
|
50
|
+
3: "3";
|
|
51
|
+
4: "4";
|
|
52
|
+
}>>;
|
|
53
|
+
shape: z.ZodDefault<z.ZodEnum<{
|
|
54
|
+
square: "square";
|
|
55
|
+
landscape: "landscape";
|
|
56
|
+
portrait: "portrait";
|
|
57
|
+
"as-taken": "as-taken";
|
|
58
|
+
}>>;
|
|
59
|
+
}, z.core.$strip>;
|
|
60
|
+
readonly slideshow: z.ZodObject<{
|
|
61
|
+
heading: z.ZodOptional<z.ZodString>;
|
|
62
|
+
images: z.ZodArray<z.ZodObject<{
|
|
63
|
+
src: z.ZodString;
|
|
64
|
+
alt: z.ZodString;
|
|
65
|
+
caption: z.ZodOptional<z.ZodString>;
|
|
66
|
+
}, z.core.$strip>>;
|
|
67
|
+
shape: z.ZodDefault<z.ZodEnum<{
|
|
68
|
+
square: "square";
|
|
69
|
+
wide: "wide";
|
|
70
|
+
landscape: "landscape";
|
|
71
|
+
}>>;
|
|
72
|
+
seconds: z.ZodDefault<z.ZodNumber>;
|
|
73
|
+
}, z.core.$strip>;
|
|
74
|
+
};
|
|
75
|
+
export type KitSectionType = keyof typeof sections;
|
|
76
|
+
export declare const isKitSection: (type: string) => type is KitSectionType;
|
|
@@ -0,0 +1,36 @@
|
|
|
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 { 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
|
+
/** Every section the kit draws, by the type a block names. */
|
|
35
|
+
export const sections = { gallery, slideshow };
|
|
36
|
+
export const isKitSection = (type) => Object.prototype.hasOwnProperty.call(sections, type);
|
|
@@ -0,0 +1,193 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,94 @@
|
|
|
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
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@caltho/site-kit",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.9",
|
|
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": {
|