@caltho/site-kit 0.2.11 → 0.3.0

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 (73) hide show
  1. package/README.md +84 -3
  2. package/dist/behaviors/accordion.d.ts +4 -0
  3. package/dist/behaviors/accordion.js +56 -0
  4. package/dist/behaviors/carousel.d.ts +4 -0
  5. package/dist/behaviors/carousel.js +127 -0
  6. package/dist/behaviors/filter.d.ts +1 -0
  7. package/dist/behaviors/filter.js +25 -0
  8. package/dist/behaviors/index.d.ts +14 -0
  9. package/dist/behaviors/index.js +78 -0
  10. package/dist/behaviors/motion.d.ts +13 -0
  11. package/dist/behaviors/motion.js +88 -0
  12. package/dist/behaviors/tabs.d.ts +3 -0
  13. package/dist/behaviors/tabs.js +55 -0
  14. package/dist/built/css.d.ts +2 -0
  15. package/dist/built/css.js +26 -0
  16. package/dist/built/theme.d.ts +19 -0
  17. package/dist/built/theme.js +86 -0
  18. package/dist/built/tree.d.ts +24 -0
  19. package/dist/built/tree.js +96 -0
  20. package/dist/built.test.d.ts +1 -0
  21. package/dist/built.test.js +257 -0
  22. package/dist/consent.d.ts +33 -0
  23. package/dist/consent.js +36 -0
  24. package/dist/consent.test.d.ts +1 -0
  25. package/dist/consent.test.js +61 -0
  26. package/dist/content/index.d.ts +170 -40
  27. package/dist/content/index.js +155 -0
  28. package/dist/content/lists.d.ts +69 -0
  29. package/dist/content/lists.js +77 -0
  30. package/dist/content/lists.test.d.ts +1 -0
  31. package/dist/content/lists.test.js +257 -0
  32. package/dist/content/look.d.ts +13 -13
  33. package/dist/content-migrate.d.ts +51 -0
  34. package/dist/content-migrate.js +69 -0
  35. package/dist/finish.d.ts +1 -1
  36. package/dist/finish.js +18 -5
  37. package/dist/frame.test.js +25 -0
  38. package/dist/next/built.d.ts +40 -0
  39. package/dist/next/built.js +318 -0
  40. package/dist/next/consent.d.ts +4 -0
  41. package/dist/next/consent.js +13 -0
  42. package/dist/next/data.d.ts +2 -2
  43. package/dist/next/data.js +6 -4
  44. package/dist/next/frame.d.ts +9 -1
  45. package/dist/next/frame.js +5 -2
  46. package/dist/next/index.d.ts +2 -0
  47. package/dist/next/index.js +2 -0
  48. package/dist/next/metadata.js +3 -2
  49. package/dist/next/parts/Img.d.ts +6 -1
  50. package/dist/next/parts/Img.js +11 -3
  51. package/dist/next/preview.js +242 -15
  52. package/dist/next/sections.js +5 -0
  53. package/dist/policies.js +22 -4
  54. package/dist/prepare.d.ts +10 -0
  55. package/dist/prepare.js +67 -3
  56. package/dist/preview-match.d.ts +21 -0
  57. package/dist/preview-match.js +25 -0
  58. package/dist/schema.js +3 -1
  59. package/dist/sections/icons.d.ts +2 -0
  60. package/dist/sections/icons.js +4 -0
  61. package/dist/sections/index.d.ts +38 -16
  62. package/dist/sections/index.js +7 -2
  63. package/dist/sections.css +324 -0
  64. package/dist/seo.js +2 -1
  65. package/dist/tree/behaviors.d.ts +50 -0
  66. package/dist/tree/behaviors.js +67 -0
  67. package/dist/tree/classes.d.ts +24 -0
  68. package/dist/tree/classes.js +94 -0
  69. package/dist/tree/index.d.ts +145 -0
  70. package/dist/tree/index.js +196 -0
  71. package/dist/tree/messages.d.ts +45 -0
  72. package/dist/tree/messages.js +7 -0
  73. package/package.json +15 -1
package/README.md CHANGED
@@ -74,9 +74,16 @@ the kit only checks it's a URL.
74
74
 
75
75
  Sections any site can use, built once here (accessible, sized for each screen, inside the content
76
76
  security policy) so a site gets them with a kit update rather than code of its own. CalOS offers
77
- them in its editor like the site's own, and Cal can add one. Today: **gallery** (photos in a grid,
78
- each with an optional caption, opening its largest copy; 2 to 4 across, square, landscape, portrait
79
- or as taken) and **slideshow** (photos one at a time, fading from one to the next, with captions).
77
+ them in its editor like the site's own, and Cal can add one. Today:
78
+
79
+ - **gallery**: photos in a grid, each with an optional caption, opening its largest copy; 2 to 4
80
+ across, square, landscape, portrait or as taken.
81
+ - **slideshow**: photos one at a time, fading from one to the next, with captions.
82
+ - **own**: your own section, columns of parts (below).
83
+ - **built**: a section built in CalOS (or by Cal) as a tree of elements styled with Tailwind
84
+ classes: anything a designer builds with HTML and Tailwind (below, "Built sections").
85
+ - **use**: one of the site's components (a tree kept once in the content's `components`) with its
86
+ slots' words, links and pictures filled in; changing the component changes every use.
80
87
 
81
88
  **Your own section** (`own`) is built in CalOS rather than in code: a heading and intro over one to
82
89
  four columns (or a wide one beside a narrow one), each a list of parts (heading, text, photo, button,
@@ -130,6 +137,80 @@ from the site's Look (`--look-*`: its type scale, colours, buttons, width and sp
130
137
  classes of the site's; a site may still give its own for any part, in place of the kit's:
131
138
  `<KitBlocks … classes={{ headingLarge: "font-display text-3xl" }} />`.
132
139
 
140
+ ## Built sections
141
+
142
+ A **built** section is a tree of elements (`@caltho/site-kit/tree`: the `Node` type, its zod schema
143
+ `built`, `component` and `use`, the class checker, behaviors' settings, and the messages between
144
+ CalOS's editor and the preview). The kit draws each node as its tag with its Tailwind classes:
145
+
146
+ - **Checked as it's drawn**, whatever the content says: a tag outside the list is left out, a class
147
+ the checker refuses is dropped (`safeClasses`), a link that isn't a page, `#part`, http(s), mailto:
148
+ or tel: goes nowhere (an outside one marked `external` opens in a new tab with
149
+ `rel="noopener noreferrer"`), a picture is one of the site's own through the kit's images, a video
150
+ is the kit's own YouTube (no-cookie) or Vimeo (no tracking) player built from a provider and an
151
+ id, words are light Markdown whose HTML is shown as text, `label` is its `aria-label`, `hidden`
152
+ nodes aren't drawn. A tree's depth, size and ids are checked with the content.
153
+ - **Shared values**: `bind` shows a value from the site's content by its dotted path
154
+ (`company.email`); `repeat` draws a node once for each item of a list (`caseStudies`), its binds
155
+ reading `item.…`.
156
+ - **Classes are prefixed** as drawn (`p-4` is `kit:p-4`), so nothing of the site's own is restyled
157
+ by them, nor they by its classes of the same names. `prepare` compiles exactly the classes the
158
+ content's trees use with Tailwind's own compiler (`tailwindcss` 4.3.3), with **no reset** and in no
159
+ cascade layer, into `public/_kit/built.<hash>.css`; a page with a built section links it (React
160
+ puts the link in the head), a page without doesn't.
161
+ - **The Look is the theme**: colours by role (`bg-brand`, `text-on-brand`, `text-ink`, `bg-page`,
162
+ `text-muted`, `border-line`, `bg-soft`, `text-accent`, `bg-light`, `bg-dark`) and the site's own by
163
+ their slug (`bg-dark-sand`), fonts (`font-display`/`font-heading`, `font-body`/`font-text`), the
164
+ type scale (`text-display`, `text-h1` … `text-small`, each with its line height, tracking and
165
+ weight), corners (`rounded-card`, `rounded-button`), width (`max-w-content`) and spacing
166
+ (`px-gutter`, `py-section`), each reading the Look's `--look-*` variable, so a change of Look needs
167
+ no build of the CSS. Tailwind's own scale and colours are there too. `@caltho/site-kit/theme` has
168
+ the tokens and the input both compilers are given (`tailwindInput`).
169
+ - **Behaviors**, chosen not written: `carousel` (dots, arrows, a stop button, the arrow keys, a
170
+ swipe; its own controls where the design marks nodes with the slots prev, next and dots), `tabs`
171
+ (the first child holds the tabs, the children after it are the panels), `accordion` (each child an
172
+ item, its first child the heading that opens it), `marquee`, `reveal`, `count-up`, and `sticky`
173
+ (CSS alone). Each is a small script of its own, written by `prepare` to `public/_kit` and loaded
174
+ only on a page that uses it. Without scripting a carousel shows its first slide, tabs show every
175
+ panel, every accordion item is open, a marquee's row is still and scrollable; nothing moves for
176
+ someone who asked for less motion.
177
+ - **In CalOS's preview** each element carries `data-node` (never on the published page), so a click
178
+ names its node (`calos:built-select`, with shift), the pointer's node is said (`calos:built-hover`)
179
+ and where each element is (`calos:built-boxes`); the editor outlines nodes (`calos:built-highlight`,
180
+ bringing up the slide, tab or item that holds one) and draws one as hovered or focused
181
+ (`calos:built-state`). Classes take effect as they're typed with Tailwind's browser build
182
+ (`@tailwindcss/browser` 4.3.3, the same input as the build's), loaded by the preview page only;
183
+ visitors never load it. The preview page's policy allows its inline styles for that.
184
+
185
+ **What a site needs to do**: nothing beyond what it has, on 0.3.0: its schema takes the kit's
186
+ `sections` (built and use come with them), it draws blocks with `KitBlocks`, imports
187
+ `sections.css`, and runs `prepare` and `finish`. `public/_kit` is written by every build (add it to
188
+ `.gitignore` with `public/_img`).
189
+
190
+ ## Cookie banner
191
+
192
+ Off unless a site turns it on (`consent: { on: true }` in its content, edited in CalOS): visits are
193
+ then counted only once a visitor accepts (finish loads the counter after the choice, never before),
194
+ and `KitFrame` draws a banner in the site's words (`text`, `accept`, `decline`, a `policy` link),
195
+ along the bottom or top or in a corner (`place`), in the kit's design or one of the site's components
196
+ (`design`: a tree with the slots text, accept, decline and policy). The kit's script shows it to a
197
+ visitor who hasn't chosen, keeps the choice in their browser, and brings it back from anything
198
+ marked `data-kit-consent="change"`. The privacy policy says it. Off, visits are counted without
199
+ cookies as before, and no banner is needed.
200
+
201
+ ## Content formats
202
+
203
+ A site is the starter plus its configuration, so the configuration's format is the kit's. The
204
+ content says its format (`format`; none is 0, before built sections; this kit's is
205
+ `CONTENT_FORMAT`, 1). When a kit changes a shape, a migration in `@caltho/site-kit/content-migrate`
206
+ moves content to it, as data: `migrateContent(content)` runs those it hasn't had, in order, once
207
+ each, and returns `{ ok: true, content, from, format, applied }`, or `{ ok: false, error: { id,
208
+ message, path } }` with the content as it was when one can't move something (for Caltho to fix in
209
+ the migration, never a site by hand). CalOS runs it on a site's draft and published content when
210
+ the site moves to a newer kit; `prepare` runs it too, so content not moved yet still builds. A new
211
+ kind of block or optional field needs no migration, only the number. Code migrations (a site's own
212
+ files) are `@caltho/site-kit/migrate`.
213
+
133
214
  ## The theme
134
215
 
135
216
  The Look is the site's theme. Beside its fonts, brand and accent colours, corners and buttons, it has
@@ -0,0 +1,4 @@
1
+ export declare function accordion(el: HTMLElement, s: {
2
+ single?: boolean;
3
+ open?: number;
4
+ }): void;
@@ -0,0 +1,56 @@
1
+ // An accordion: each child of the node is an item, its first child the heading that opens and closes
2
+ // it, the rest what it opens. The heading works as a button (a click, Enter or Space) and says
3
+ // whether it's open. Without scripting every item is open, so all of it can be read.
4
+ //
5
+ // Self-contained: sent to visitors as its own script (./index.ts). The kit marks the items
6
+ // (data-kit-item), headings (data-kit-head) and what they open (data-kit-body) (../next/built.tsx).
7
+ export function accordion(el, s) {
8
+ const items = Array.from(el.querySelectorAll(":scope > [data-kit-item]"));
9
+ const key = Math.random().toString(36).slice(2, 8);
10
+ const parts = items.map((item, i) => {
11
+ const head = item.querySelector(":scope > [data-kit-head]");
12
+ const body = Array.from(item.querySelectorAll(":scope > [data-kit-body]"));
13
+ body.forEach((b, j) => (b.id ||= `kit-acc-${key}-${i}-${j}`));
14
+ return { item, head, body };
15
+ });
16
+ const set = (i, open) => {
17
+ const p = parts[i];
18
+ p.item.toggleAttribute("data-open", open);
19
+ p.head?.setAttribute("aria-expanded", String(open));
20
+ p.body.forEach((b) => (b.hidden = !open));
21
+ };
22
+ parts.forEach((p, i) => {
23
+ const head = p.head;
24
+ if (!head)
25
+ return;
26
+ if (head.tagName !== "BUTTON") {
27
+ head.setAttribute("role", "button");
28
+ head.tabIndex = 0;
29
+ }
30
+ head.setAttribute("aria-controls", p.body.map((b) => b.id).join(" "));
31
+ const toggle = () => {
32
+ const open = head.getAttribute("aria-expanded") !== "true";
33
+ if (open && s.single)
34
+ parts.forEach((_, j) => j !== i && set(j, false));
35
+ set(i, open);
36
+ };
37
+ head.addEventListener("click", (e) => {
38
+ e.preventDefault();
39
+ toggle();
40
+ });
41
+ head.addEventListener("keydown", (e) => {
42
+ if (head.tagName !== "BUTTON" && (e.key === "Enter" || e.key === " ")) {
43
+ e.preventDefault();
44
+ toggle();
45
+ }
46
+ });
47
+ set(i, i === s.open);
48
+ });
49
+ // CalOS's preview asks for the item holding what's selected to be opened.
50
+ el.addEventListener("kit:show", (e) => {
51
+ const i = parts.findIndex((p) => p.item.contains(e.target));
52
+ if (i >= 0)
53
+ set(i, true);
54
+ });
55
+ el.setAttribute("data-kit-ready", "");
56
+ }
@@ -0,0 +1,4 @@
1
+ export declare function carousel(el: HTMLElement, s: {
2
+ seconds?: number;
3
+ pauseOnHover?: boolean;
4
+ }, still: boolean): void;
@@ -0,0 +1,127 @@
1
+ // A carousel: its slides one at a time, by its dots and arrows (buttons, each saying what it shows),
2
+ // the arrow keys while it has focus, or a swipe; and every few seconds, where it's set to, while it's
3
+ // on screen and not pointed at or focused, with a button to stop it, and never for someone who asked
4
+ // for less motion. Without scripting the first slide shows (sections.css), and the controls don't.
5
+ //
6
+ // Self-contained (nothing from outside the function): it's sent to visitors as its own script
7
+ // (./index.ts). The kit draws what it works on (../next/built.tsx): the slides in a track
8
+ // (data-kit-track, data-kit-slide, the first data-active), the dots (data-kit-to), arrows
9
+ // (data-kit-prev, data-kit-next) and the stop button (data-kit-pause).
10
+ export function carousel(el, s, still) {
11
+ const track = el.querySelector(":scope > [data-kit-track]");
12
+ if (!track)
13
+ return;
14
+ const slides = Array.from(track.children).filter((c) => c.hasAttribute("data-kit-slide"));
15
+ const n = slides.length;
16
+ const mine = (sel) => Array.from(el.querySelectorAll(sel)).filter((x) => x.closest('[data-kit-behavior="carousel"]') === el);
17
+ const dots = mine("[data-kit-to]");
18
+ const pause = mine("[data-kit-pause]")[0];
19
+ const slideWise = track.getAttribute("data-kit-transition") === "slide";
20
+ const reduce = still || window.matchMedia("(prefers-reduced-motion: reduce)").matches;
21
+ let at = Math.max(0, slides.findIndex((x) => x.hasAttribute("data-active")));
22
+ let timer = 0;
23
+ let held = false;
24
+ let stopped = false;
25
+ let seen = true;
26
+ const show = (i) => {
27
+ at = ((i % n) + n) % n;
28
+ slides.forEach((x, j) => {
29
+ const on = j === at;
30
+ x.toggleAttribute("data-active", on);
31
+ if (on)
32
+ x.removeAttribute("aria-hidden");
33
+ else
34
+ x.setAttribute("aria-hidden", "true");
35
+ x.inert = !on;
36
+ // Sideways: each slide moved by the place shown (a style set from script, which the page's policy allows).
37
+ if (slideWise)
38
+ x.style.transform = `translateX(${-100 * at}%)`;
39
+ });
40
+ dots.forEach((d, j) => d.setAttribute("aria-current", j === at ? "true" : "false"));
41
+ wait();
42
+ };
43
+ const moving = () => !!s.seconds && !reduce && !held && !stopped && seen && !document.hidden && n > 1;
44
+ function wait() {
45
+ clearTimeout(timer);
46
+ if (moving())
47
+ timer = window.setTimeout(() => (el.isConnected ? show(at + 1) : undefined), Math.max(2, s.seconds ?? 5) * 1000);
48
+ }
49
+ // A control that isn't a button of its own (a designed arrow drawn as a div) still works from the keyboard.
50
+ const press = (b, go) => {
51
+ if (b.tagName !== "BUTTON") {
52
+ b.setAttribute("role", "button");
53
+ b.tabIndex = 0;
54
+ b.addEventListener("keydown", (e) => {
55
+ if (e.key === "Enter" || e.key === " ") {
56
+ e.preventDefault();
57
+ go();
58
+ }
59
+ });
60
+ }
61
+ b.addEventListener("click", (e) => {
62
+ e.preventDefault();
63
+ go();
64
+ });
65
+ };
66
+ if (n < 2)
67
+ return void el.setAttribute("data-kit-ready", "");
68
+ dots.forEach((d) => press(d, () => show(Number(d.getAttribute("data-kit-to")))));
69
+ mine("[data-kit-prev]").forEach((b) => press(b, () => show(at - 1)));
70
+ mine("[data-kit-next]").forEach((b) => press(b, () => show(at + 1)));
71
+ if (pause) {
72
+ // Nothing turns by itself (no seconds, less motion asked for, CalOS's still preview): no pause to press.
73
+ // Set as a style too, since the element's own display class (inline-flex) would outrank [hidden].
74
+ if (!s.seconds || reduce) {
75
+ pause.hidden = true;
76
+ pause.style.display = "none";
77
+ }
78
+ press(pause, () => {
79
+ stopped = !stopped;
80
+ pause.setAttribute("aria-pressed", String(stopped));
81
+ pause.setAttribute("aria-label", stopped ? "Play the slides" : "Pause the slides");
82
+ pause.toggleAttribute("data-stopped", stopped);
83
+ wait();
84
+ });
85
+ }
86
+ el.addEventListener("keydown", (e) => {
87
+ const t = e.target;
88
+ if (t.closest("input, textarea, select, [contenteditable]"))
89
+ return;
90
+ if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
91
+ e.preventDefault();
92
+ show(at + (e.key === "ArrowRight" ? 1 : -1));
93
+ }
94
+ });
95
+ if (s.pauseOnHover !== false) {
96
+ el.addEventListener("pointerenter", () => ((held = true), wait()));
97
+ el.addEventListener("pointerleave", () => ((held = false), wait()));
98
+ }
99
+ el.addEventListener("focusin", () => ((held = true), wait()));
100
+ el.addEventListener("focusout", (e) => {
101
+ if (!el.contains(e.relatedTarget))
102
+ (held = false), wait();
103
+ });
104
+ // A swipe on a touch screen.
105
+ let from = null;
106
+ track.addEventListener("pointerdown", (e) => (from = e.clientX));
107
+ track.addEventListener("pointerup", (e) => {
108
+ if (from !== null && Math.abs(e.clientX - from) > 40)
109
+ show(at + (e.clientX < from ? 1 : -1));
110
+ from = null;
111
+ });
112
+ // Off screen or in another tab, it waits.
113
+ if (typeof IntersectionObserver !== "undefined")
114
+ new IntersectionObserver(([e]) => {
115
+ seen = !!e?.isIntersecting;
116
+ wait();
117
+ }).observe(el);
118
+ document.addEventListener("visibilitychange", wait);
119
+ // CalOS's preview asks for the slide holding what's selected to be shown.
120
+ el.addEventListener("kit:show", (e) => {
121
+ const i = slides.findIndex((x) => x.contains(e.target));
122
+ if (i >= 0)
123
+ show(i);
124
+ });
125
+ el.setAttribute("data-kit-ready", "");
126
+ show(at);
127
+ }
@@ -0,0 +1 @@
1
+ export declare function filter(el: HTMLElement): void;
@@ -0,0 +1,25 @@
1
+ // A filter: the buttons inside it choose which of the items repeated inside it show, by a field of the
2
+ // list (its settings' field): a button matches the items whose value is its words (lowercase, dashes
3
+ // for spaces: "Real estate" is real-estate), "All" (or no words) shows every one. The chosen button says
4
+ // so (aria-pressed), for its design's aria-pressed: classes. Without scripting every item shows.
5
+ //
6
+ // Self-contained: sent to visitors as its own script (./index.ts). The kit marks each item with its value
7
+ // (data-kit-filter-value, ../next/built.tsx).
8
+ export function filter(el) {
9
+ const norm = (v) => v.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
10
+ const items = () => Array.from(el.querySelectorAll("[data-kit-filter-value]"));
11
+ const buttons = Array.from(el.querySelectorAll("button")).filter((b) => !b.closest("[data-kit-filter-value]"));
12
+ const valueOf = (b) => {
13
+ const v = norm(b.textContent ?? "");
14
+ return v === "all" ? "" : v;
15
+ };
16
+ const choose = (chosen) => {
17
+ const v = chosen ? valueOf(chosen) : "";
18
+ items().forEach((i) => (i.hidden = !!v && i.getAttribute("data-kit-filter-value") !== v));
19
+ buttons.forEach((b) => b.setAttribute("aria-pressed", String(b === chosen)));
20
+ };
21
+ buttons.forEach((b) => b.addEventListener("click", (e) => (e.preventDefault(), choose(b))));
22
+ // Every item to start, its "All" button pressed where it has one.
23
+ choose(buttons.find((b) => valueOf(b) === "") ?? null);
24
+ el.setAttribute("data-kit-ready", "");
25
+ }
@@ -0,0 +1,14 @@
1
+ import type { BehaviorName } from "../tree/index.ts";
2
+ type Run = (el: HTMLElement, settings: Record<string, unknown>, still: boolean) => void;
3
+ /** Each behavior that has a script. */
4
+ export declare const BEHAVIOR_RUNS: Partial<Record<BehaviorName, Run>>;
5
+ /** The attribute naming an element's behavior, and the one holding its settings. */
6
+ export declare const BEHAVIOR_ATTR = "data-kit-behavior";
7
+ export declare const SETTINGS_ATTR = "data-kit-settings";
8
+ /** The script a published page carries for one behavior: its own code and the starter, nothing else. */
9
+ export declare function behaviorScript(name: BehaviorName): string | null;
10
+ /** Starts every behavior on the page (CalOS's preview, still: nothing moves by itself). */
11
+ export declare function startBehaviors(still: boolean): void;
12
+ /** Asks whatever behavior holds this element to show it (a carousel's slide, a tab's panel, an accordion's item): the preview, when it's selected. */
13
+ export declare function showWithin(el: Element): void;
14
+ export {};
@@ -0,0 +1,78 @@
1
+ import { accordion } from "./accordion.js";
2
+ import { carousel } from "./carousel.js";
3
+ import { countUp, marquee, reveal } from "./motion.js";
4
+ import { tabs } from "./tabs.js";
5
+ import { filter } from "./filter.js";
6
+ /** Each behavior that has a script. */
7
+ export const BEHAVIOR_RUNS = {
8
+ carousel: carousel,
9
+ tabs: tabs,
10
+ accordion: accordion,
11
+ marquee: marquee,
12
+ reveal: reveal,
13
+ "count-up": countUp,
14
+ filter: filter,
15
+ };
16
+ /** The attribute naming an element's behavior, and the one holding its settings. */
17
+ export const BEHAVIOR_ATTR = "data-kit-behavior";
18
+ export const SETTINGS_ATTR = "data-kit-settings";
19
+ /**
20
+ * Starts every element of this behavior not started yet, now and as more are drawn. `still`: nothing
21
+ * moves by itself (CalOS's preview, so what's being edited stays put). Written to be sent as its own
22
+ * script as well as called here, so it uses nothing from outside itself but `run`.
23
+ */
24
+ function start(name, run, still) {
25
+ const go = () => {
26
+ for (const el of Array.from(document.querySelectorAll(`[data-kit-behavior="${name}"]:not([data-kit-on])`))) {
27
+ el.setAttribute("data-kit-on", "");
28
+ let settings = {};
29
+ try {
30
+ settings = JSON.parse(el.getAttribute("data-kit-settings") || "{}");
31
+ }
32
+ catch {
33
+ // Settings the kit wrote can't be broken; the behavior's own defaults if they somehow are.
34
+ }
35
+ try {
36
+ run(el, settings, still);
37
+ }
38
+ catch (err) {
39
+ console.error(`site-kit: the ${name} behavior stopped`, err);
40
+ }
41
+ }
42
+ };
43
+ let queued = false;
44
+ const soon = () => {
45
+ if (queued)
46
+ return;
47
+ queued = true;
48
+ requestAnimationFrame(() => {
49
+ queued = false;
50
+ go();
51
+ });
52
+ };
53
+ const begin = () => {
54
+ go();
55
+ new MutationObserver(soon).observe(document.body, { childList: true, subtree: true });
56
+ };
57
+ // Loaded before the page is all there (an async script): it waits for the whole page, so a carousel isn't started with half its slides.
58
+ if (document.readyState === "loading")
59
+ document.addEventListener("DOMContentLoaded", begin, { once: true });
60
+ else
61
+ begin();
62
+ }
63
+ /** The script a published page carries for one behavior: its own code and the starter, nothing else. */
64
+ export function behaviorScript(name) {
65
+ const run = BEHAVIOR_RUNS[name];
66
+ if (!run)
67
+ return null;
68
+ return `(()=>{const run=${run.toString()};(${start.toString()})(${JSON.stringify(name)},run,false);})();\n`;
69
+ }
70
+ /** Starts every behavior on the page (CalOS's preview, still: nothing moves by itself). */
71
+ export function startBehaviors(still) {
72
+ for (const [name, run] of Object.entries(BEHAVIOR_RUNS))
73
+ start(name, run, still);
74
+ }
75
+ /** Asks whatever behavior holds this element to show it (a carousel's slide, a tab's panel, an accordion's item): the preview, when it's selected. */
76
+ export function showWithin(el) {
77
+ el.dispatchEvent(new CustomEvent("kit:show", { bubbles: true }));
78
+ }
@@ -0,0 +1,13 @@
1
+ /** The kit draws the row twice in a track (data-kit-track, its copy data-kit-copy, hidden from screen readers); this moves it by one copy's width. */
2
+ export declare function marquee(el: HTMLElement, s: {
3
+ seconds?: number;
4
+ reverse?: boolean;
5
+ }, still: boolean): void;
6
+ /** Hidden until it's scrolled to (sections.css, by its effect), then in; its children one after another where it staggers them. */
7
+ export declare function reveal(el: HTMLElement, s: {
8
+ stagger?: number;
9
+ }, still: boolean): void;
10
+ /** The first number in its words ("$1,200+", "98%"), counted up from nothing, then left exactly as written. */
11
+ export declare function countUp(el: HTMLElement, s: {
12
+ seconds?: number;
13
+ }, still: boolean): void;
@@ -0,0 +1,88 @@
1
+ // The behaviors that only move things: a marquee (a row scrolling past without end), reveal (in as
2
+ // it's scrolled to) and count-up (a number counting up to itself as it's scrolled to). Each does
3
+ // nothing for someone who asked for less motion, and without scripting what they hold is simply
4
+ // there: the row still (scrollable), everything shown, the number as written.
5
+ //
6
+ // Each self-contained: sent to visitors as its own script (./index.ts).
7
+ /** The kit draws the row twice in a track (data-kit-track, its copy data-kit-copy, hidden from screen readers); this moves it by one copy's width. */
8
+ export function marquee(el, s, still) {
9
+ const track = el.querySelector(":scope > [data-kit-track]");
10
+ const copy = track?.querySelector(":scope > [data-kit-copy]");
11
+ const first = track?.firstElementChild;
12
+ if (!track || !copy || !first || still || window.matchMedia("(prefers-reduced-motion: reduce)").matches)
13
+ return;
14
+ // Styles set from script (the page's policy allows them): how far one round goes and how long it takes.
15
+ const measure = () => el.style.setProperty("--kit-marquee-shift", `${copy.offsetLeft - first.offsetLeft}px`);
16
+ measure();
17
+ el.style.setProperty("--kit-marquee-seconds", `${s.seconds ?? 30}s`);
18
+ if (s.reverse)
19
+ el.setAttribute("data-kit-reverse", "");
20
+ if (typeof ResizeObserver !== "undefined")
21
+ new ResizeObserver(measure).observe(track);
22
+ el.setAttribute("data-kit-ready", "");
23
+ }
24
+ /** Hidden until it's scrolled to (sections.css, by its effect), then in; its children one after another where it staggers them. */
25
+ export function reveal(el, s, still) {
26
+ if (still || window.matchMedia("(prefers-reduced-motion: reduce)").matches || typeof IntersectionObserver === "undefined")
27
+ return;
28
+ if (s.stagger) {
29
+ el.setAttribute("data-kit-stagger", "");
30
+ Array.from(el.children).forEach((c, i) => (c.style.transitionDelay = `${i * s.stagger}ms`));
31
+ }
32
+ const o = new IntersectionObserver((entries) => {
33
+ if (!entries.some((e) => e.isIntersecting))
34
+ return;
35
+ el.setAttribute("data-kit-shown", "");
36
+ o.disconnect();
37
+ }, { rootMargin: "0px 0px -10% 0px" });
38
+ // Ready first (hidden), then watched: what's already on screen comes in at once.
39
+ el.setAttribute("data-kit-ready", "");
40
+ o.observe(el);
41
+ }
42
+ /** The first number in its words ("$1,200+", "98%"), counted up from nothing, then left exactly as written. */
43
+ export function countUp(el, s, still) {
44
+ if (still || window.matchMedia("(prefers-reduced-motion: reduce)").matches || typeof IntersectionObserver === "undefined")
45
+ return;
46
+ const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
47
+ let text = null;
48
+ for (let t = walker.nextNode(); t; t = walker.nextNode()) {
49
+ if (/\d/.test(t.textContent ?? "")) {
50
+ text = t;
51
+ break;
52
+ }
53
+ }
54
+ if (!text)
55
+ return;
56
+ const written = text.textContent;
57
+ const m = /(\d[\d,]*)(\.\d+)?/.exec(written);
58
+ if (!m)
59
+ return;
60
+ const target = Number((m[1] + (m[2] ?? "")).replace(/,/g, ""));
61
+ const decimals = m[2] ? m[2].length - 1 : 0;
62
+ const commas = m[1].includes(",");
63
+ const format = (v) => {
64
+ const fixed = v.toFixed(decimals);
65
+ return commas ? fixed.replace(/\B(?=(\d{3})+(?!\d))/g, ",") : fixed;
66
+ };
67
+ const node = text;
68
+ // Read out as written, not as it counts.
69
+ if (!el.hasAttribute("aria-label"))
70
+ el.setAttribute("aria-label", el.textContent?.trim() ?? written);
71
+ node.textContent = written.slice(0, m.index) + format(0) + written.slice(m.index + m[0].length);
72
+ const o = new IntersectionObserver((entries) => {
73
+ if (!entries.some((e) => e.isIntersecting))
74
+ return;
75
+ o.disconnect();
76
+ const start = performance.now();
77
+ const ms = (s.seconds ?? 1.2) * 1000;
78
+ const step = (now) => {
79
+ const t = Math.min(1, (now - start) / ms);
80
+ const eased = 1 - Math.pow(1 - t, 3);
81
+ node.textContent = t < 1 ? written.slice(0, m.index) + format(target * eased) + written.slice(m.index + m[0].length) : written;
82
+ if (t < 1)
83
+ requestAnimationFrame(step);
84
+ };
85
+ requestAnimationFrame(step);
86
+ });
87
+ o.observe(el);
88
+ }
@@ -0,0 +1,3 @@
1
+ export declare function tabs(el: HTMLElement, s: {
2
+ first?: number;
3
+ }): void;
@@ -0,0 +1,55 @@
1
+ // Tabs: the node's first child holds the tabs (one child for each panel), the children after it are
2
+ // the panels, in the same order. A tab list, tabs and panels as a screen reader expects them (roles,
3
+ // selected, controls), the arrow keys, Home and End moving between tabs. Without scripting every
4
+ // panel shows, one after another, and the tabs don't (sections.css).
5
+ //
6
+ // Self-contained: sent to visitors as its own script (./index.ts). The kit marks the list
7
+ // (data-kit-tablist) and the panels (data-kit-panel) (../next/built.tsx).
8
+ export function tabs(el, s) {
9
+ const list = el.querySelector(":scope > [data-kit-tablist]");
10
+ const panels = Array.from(el.querySelectorAll(":scope > [data-kit-panel]"));
11
+ if (!list || !panels.length)
12
+ return;
13
+ const tabs = Array.from(list.children).slice(0, panels.length);
14
+ const key = Math.random().toString(36).slice(2, 8);
15
+ list.setAttribute("role", "tablist");
16
+ tabs.forEach((t, i) => {
17
+ t.id ||= `kit-tab-${key}-${i}`;
18
+ panels[i].id ||= `kit-panel-${key}-${i}`;
19
+ t.setAttribute("role", "tab");
20
+ t.setAttribute("aria-controls", panels[i].id);
21
+ panels[i].setAttribute("role", "tabpanel");
22
+ panels[i].setAttribute("aria-labelledby", t.id);
23
+ panels[i].tabIndex = 0;
24
+ t.addEventListener("click", (e) => {
25
+ e.preventDefault();
26
+ pick(i, false);
27
+ });
28
+ t.addEventListener("keydown", (e) => {
29
+ const to = e.key === "ArrowRight" || e.key === "ArrowDown" ? i + 1 : e.key === "ArrowLeft" || e.key === "ArrowUp" ? i - 1 : e.key === "Home" ? 0 : e.key === "End" ? tabs.length - 1 : e.key === "Enter" || e.key === " " ? i : null;
30
+ if (to === null)
31
+ return;
32
+ e.preventDefault();
33
+ pick((to + tabs.length) % tabs.length, true);
34
+ });
35
+ });
36
+ function pick(i, focus) {
37
+ tabs.forEach((t, j) => {
38
+ const on = j === i;
39
+ t.setAttribute("aria-selected", String(on));
40
+ t.tabIndex = on ? 0 : -1;
41
+ t.toggleAttribute("data-active", on);
42
+ panels[j].hidden = !on;
43
+ });
44
+ if (focus)
45
+ tabs[i].focus();
46
+ }
47
+ // CalOS's preview asks for the panel holding what's selected to be shown.
48
+ el.addEventListener("kit:show", (e) => {
49
+ const i = panels.findIndex((p) => p.contains(e.target));
50
+ if (i >= 0)
51
+ pick(i, false);
52
+ });
53
+ pick(Math.min(s.first ?? 0, tabs.length - 1), false);
54
+ el.setAttribute("data-kit-ready", "");
55
+ }
@@ -0,0 +1,2 @@
1
+ /** The CSS for these classes (prefixed, as drawn), with the site's own colours (their slugs) as Tailwind colours. Empty for none. */
2
+ export declare function builtCss(classes: Iterable<string>, swatches?: string[]): Promise<string>;
@@ -0,0 +1,26 @@
1
+ // Built sections' CSS at build: Tailwind v4's own compiler, given exactly the classes the prepared
2
+ // content's trees use (./tree.ts), so the CSS holds those and nothing else; the theme from the Look
3
+ // and no reset (./theme.ts). Node only: prepare runs it.
4
+ import { readFile } from "node:fs/promises";
5
+ import { createRequire } from "node:module";
6
+ import { dirname } from "node:path";
7
+ import { compile } from "tailwindcss";
8
+ import { tailwindInput } from "./theme.js";
9
+ const require = createRequire(import.meta.url);
10
+ /** Tailwind's own stylesheets (`tailwindcss/theme.css`, `tailwindcss/utilities.css`), from its package. */
11
+ async function loadStylesheet(id, base) {
12
+ const name = id.startsWith("tailwindcss") ? id : `tailwindcss/${id.replace(/^\.\//, "")}`;
13
+ if (!/^tailwindcss\/(theme|utilities)(\.css)?$/.test(name))
14
+ throw new Error(`Built sections' CSS imports only Tailwind's theme and utilities, not ${id}`);
15
+ const path = require.resolve(name.endsWith(".css") ? name : `${name}.css`);
16
+ return { path, base: dirname(path) || base, content: await readFile(path, "utf8") };
17
+ }
18
+ /** The CSS for these classes (prefixed, as drawn), with the site's own colours (their slugs) as Tailwind colours. Empty for none. */
19
+ export async function builtCss(classes, swatches = []) {
20
+ const list = [...classes];
21
+ if (!list.length)
22
+ return "";
23
+ const compiler = await compile(tailwindInput(swatches), { base: process.cwd(), loadStylesheet });
24
+ // Tailwind's banner comment names its version; the file's name carries its hash, so it's dropped.
25
+ return compiler.build(list).replace(/^\/\*![^*]*\*\/\n?/, "");
26
+ }
@@ -0,0 +1,19 @@
1
+ /** What every built section's class carries on the page (the tree keeps it without). */
2
+ export declare const PREFIX = "kit";
3
+ /** A class of a tree as it's drawn: `md:p-4` → `kit:md:p-4`. */
4
+ export declare const prefixed: (token: string) => string;
5
+ /** The Look's colours by role, as Tailwind colours (`bg-brand`, `text-on-brand`, `border-line`), with what they fall back to on a site without a Look. */
6
+ export declare const THEME_COLOURS: Record<string, string>;
7
+ /** The Look's fonts: `font-display` and `font-heading` its headings', `font-body` and `font-text` its text's. */
8
+ export declare const THEME_FONTS: Record<string, string>;
9
+ /** The Look's type scale as text sizes (`text-h1`, `text-body`), each with its line height, tracking and weight. */
10
+ export declare const THEME_TYPE: readonly ["display", "h1", "h2", "h3", "body", "small"];
11
+ /** Its corners, width and spacing: `rounded-card`, `rounded-button`, `max-w-content`, `px-gutter`, `py-section`. */
12
+ export declare const THEME_OTHER: Record<string, string>;
13
+ /**
14
+ * The site's own colours' slugs, from the Look's values (lookVars: `--look-swatch-dark-sand`), each
15
+ * one a Tailwind colour by its slug (`bg-dark-sand`).
16
+ */
17
+ export declare function swatchSlugs(look: Record<string, string> | undefined): string[];
18
+ /** The Tailwind input for built sections: the theme and utilities only, prefixed, and the site's Look as tokens. */
19
+ export declare function tailwindInput(swatches?: string[]): string;