@caltho/site-kit 0.2.10 → 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 (140) hide show
  1. package/README.md +146 -16
  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/cli/check.js +31 -1
  23. package/dist/consent.d.ts +33 -0
  24. package/dist/consent.js +36 -0
  25. package/dist/consent.test.d.ts +1 -0
  26. package/dist/consent.test.js +61 -0
  27. package/dist/content/index.d.ts +450 -33
  28. package/dist/content/index.js +297 -15
  29. package/dist/content/lists.d.ts +69 -0
  30. package/dist/content/lists.js +77 -0
  31. package/dist/content/lists.test.d.ts +1 -0
  32. package/dist/content/lists.test.js +257 -0
  33. package/dist/content/look.d.ts +260 -9
  34. package/dist/content/look.js +386 -22
  35. package/dist/content-migrate.d.ts +51 -0
  36. package/dist/content-migrate.js +69 -0
  37. package/dist/finish.d.ts +1 -1
  38. package/dist/finish.js +27 -6
  39. package/dist/fonts/families/dm-sans/LICENSE +93 -0
  40. package/dist/fonts/families/dm-sans/dm-sans-latin-ext-wght-italic.woff2 +0 -0
  41. package/dist/fonts/families/dm-sans/dm-sans-latin-ext-wght-normal.woff2 +0 -0
  42. package/dist/fonts/families/dm-sans/dm-sans-latin-wght-italic.woff2 +0 -0
  43. package/dist/fonts/families/dm-sans/dm-sans-latin-wght-normal.woff2 +0 -0
  44. package/dist/fonts/families/fraunces/LICENSE +93 -0
  45. package/dist/fonts/families/fraunces/fraunces-latin-ext-wght-italic.woff2 +0 -0
  46. package/dist/fonts/families/fraunces/fraunces-latin-ext-wght-normal.woff2 +0 -0
  47. package/dist/fonts/families/fraunces/fraunces-latin-wght-italic.woff2 +0 -0
  48. package/dist/fonts/families/fraunces/fraunces-latin-wght-normal.woff2 +0 -0
  49. package/dist/fonts/families/inter/LICENSE +93 -0
  50. package/dist/fonts/families/inter/inter-latin-ext-wght-italic.woff2 +0 -0
  51. package/dist/fonts/families/inter/inter-latin-ext-wght-normal.woff2 +0 -0
  52. package/dist/fonts/families/inter/inter-latin-wght-italic.woff2 +0 -0
  53. package/dist/fonts/families/inter/inter-latin-wght-normal.woff2 +0 -0
  54. package/dist/fonts/families/lora/LICENSE +93 -0
  55. package/dist/fonts/families/lora/lora-latin-ext-wght-italic.woff2 +0 -0
  56. package/dist/fonts/families/lora/lora-latin-ext-wght-normal.woff2 +0 -0
  57. package/dist/fonts/families/lora/lora-latin-wght-italic.woff2 +0 -0
  58. package/dist/fonts/families/lora/lora-latin-wght-normal.woff2 +0 -0
  59. package/dist/fonts/families/manrope/LICENSE +93 -0
  60. package/dist/fonts/families/manrope/manrope-latin-ext-wght-normal.woff2 +0 -0
  61. package/dist/fonts/families/manrope/manrope-latin-wght-normal.woff2 +0 -0
  62. package/dist/fonts/families/newsreader/LICENSE +93 -0
  63. package/dist/fonts/families/newsreader/newsreader-latin-ext-wght-italic.woff2 +0 -0
  64. package/dist/fonts/families/newsreader/newsreader-latin-ext-wght-normal.woff2 +0 -0
  65. package/dist/fonts/families/newsreader/newsreader-latin-wght-italic.woff2 +0 -0
  66. package/dist/fonts/families/newsreader/newsreader-latin-wght-normal.woff2 +0 -0
  67. package/dist/fonts/families/outfit/LICENSE +93 -0
  68. package/dist/fonts/families/outfit/outfit-latin-ext-wght-normal.woff2 +0 -0
  69. package/dist/fonts/families/outfit/outfit-latin-wght-normal.woff2 +0 -0
  70. package/dist/fonts/families/playfair-display/LICENSE +93 -0
  71. package/dist/fonts/families/playfair-display/playfair-display-latin-ext-wght-italic.woff2 +0 -0
  72. package/dist/fonts/families/playfair-display/playfair-display-latin-ext-wght-normal.woff2 +0 -0
  73. package/dist/fonts/families/playfair-display/playfair-display-latin-wght-italic.woff2 +0 -0
  74. package/dist/fonts/families/playfair-display/playfair-display-latin-wght-normal.woff2 +0 -0
  75. package/dist/fonts/families/source-serif-4/LICENSE +93 -0
  76. package/dist/fonts/families/source-serif-4/source-serif-4-latin-ext-wght-italic.woff2 +0 -0
  77. package/dist/fonts/families/source-serif-4/source-serif-4-latin-ext-wght-normal.woff2 +0 -0
  78. package/dist/fonts/families/source-serif-4/source-serif-4-latin-wght-italic.woff2 +0 -0
  79. package/dist/fonts/families/source-serif-4/source-serif-4-latin-wght-normal.woff2 +0 -0
  80. package/dist/fonts/families/work-sans/LICENSE +93 -0
  81. package/dist/fonts/families/work-sans/work-sans-latin-ext-wght-italic.woff2 +0 -0
  82. package/dist/fonts/families/work-sans/work-sans-latin-ext-wght-normal.woff2 +0 -0
  83. package/dist/fonts/families/work-sans/work-sans-latin-wght-italic.woff2 +0 -0
  84. package/dist/fonts/families/work-sans/work-sans-latin-wght-normal.woff2 +0 -0
  85. package/dist/fonts/google.json +1 -0
  86. package/dist/fonts-google.d.ts +35 -0
  87. package/dist/fonts-google.js +70 -0
  88. package/dist/fonts.css +365 -0
  89. package/dist/frame.test.d.ts +1 -0
  90. package/dist/frame.test.js +58 -0
  91. package/dist/migrate.d.ts +45 -0
  92. package/dist/migrate.js +284 -0
  93. package/dist/next/block-mark.d.ts +7 -0
  94. package/dist/next/block-mark.js +16 -0
  95. package/dist/next/blocks.d.ts +11 -0
  96. package/dist/next/blocks.js +35 -0
  97. package/dist/next/built.d.ts +40 -0
  98. package/dist/next/built.js +318 -0
  99. package/dist/next/consent.d.ts +4 -0
  100. package/dist/next/consent.js +13 -0
  101. package/dist/next/data.d.ts +6 -2
  102. package/dist/next/data.js +10 -4
  103. package/dist/next/frame.d.ts +42 -0
  104. package/dist/next/frame.js +73 -0
  105. package/dist/next/index.d.ts +8 -2
  106. package/dist/next/index.js +9 -3
  107. package/dist/next/menu-group.d.ts +11 -0
  108. package/dist/next/menu-group.js +33 -0
  109. package/dist/next/metadata.js +3 -2
  110. package/dist/next/parts/Img.d.ts +6 -1
  111. package/dist/next/parts/Img.js +11 -3
  112. package/dist/next/parts/LookStyle.js +3 -1
  113. package/dist/next/preview.js +511 -27
  114. package/dist/next/sections.d.ts +19 -10
  115. package/dist/next/sections.js +134 -17
  116. package/dist/policies.js +22 -4
  117. package/dist/prepare.d.ts +15 -1
  118. package/dist/prepare.js +125 -3
  119. package/dist/preview-match.d.ts +29 -0
  120. package/dist/preview-match.js +58 -1
  121. package/dist/schema.d.ts +10 -0
  122. package/dist/schema.js +29 -1
  123. package/dist/sections/icons.d.ts +49 -0
  124. package/dist/sections/icons.js +55 -0
  125. package/dist/sections/index.d.ts +188 -12
  126. package/dist/sections/index.js +136 -10
  127. package/dist/sections/presets.d.ts +13 -0
  128. package/dist/sections/presets.js +300 -0
  129. package/dist/sections.css +1584 -41
  130. package/dist/sections.test.js +276 -14
  131. package/dist/seo.js +2 -1
  132. package/dist/tree/behaviors.d.ts +50 -0
  133. package/dist/tree/behaviors.js +67 -0
  134. package/dist/tree/classes.d.ts +24 -0
  135. package/dist/tree/classes.js +94 -0
  136. package/dist/tree/index.d.ts +145 -0
  137. package/dist/tree/index.js +196 -0
  138. package/dist/tree/messages.d.ts +45 -0
  139. package/dist/tree/messages.js +7 -0
  140. package/package.json +22 -3
package/README.md CHANGED
@@ -45,7 +45,11 @@ export default function CalosPreview() { return <Preview origins={previewOrigins
45
45
 
46
46
  Components read content only through the kit (`site()`, `image()`), so the same components draw the
47
47
  built pages (from `.site/prepared.json`) and the draft (from CalOS). A click on the preview is matched
48
- back to the field it meant with nothing added to the components. Only the business's own CalOS
48
+ back to the field it meant with nothing added to the components. Which block a click landed in is
49
+ exact where the page puts `<BlockMark at={i} />` (from `@caltho/site-kit/next`) before each block it
50
+ draws: a block may then draw nothing (a list with nothing in it yet) or several elements. It draws
51
+ nothing on the live site. Without marks, blocks are found among `main`'s children, by their words
52
+ when there isn't one for each. Only the business's own CalOS
49
53
  addresses, which the build is given with the content, may frame the page or send it anything.
50
54
 
51
55
  Forms are separate and optional. A block can post to any address (a CalOS form, another service, or none);
@@ -62,7 +66,7 @@ the kit only checks it's a URL.
62
66
  | **Search** | Titles that fit, canonical addresses, the business as schema.org data (hours, areas, address), FAQ and breadcrumb data, robots.txt, the sitemap. |
63
67
  | **Policies** | Privacy, accessibility and terms, written from the business's facts and what the site does. Optional: a site calls `policy()` where it wants them. |
64
68
  | **Helpers for components** (`@caltho/site-kit/lib`) | The pieces that touch no files: `md` (Markdown that can't run HTML), `plain`, `policy`, the search data, `imagesIn`, `clock`, `dayRange`, `telHref`. |
65
- | **Next.js helpers** (`@caltho/site-kit/next`) | The prepared content, a page by its path, page metadata, `Img` (the prepared widths), `Md` (Markdown that can't run HTML), `JsonLd`, `previewOrigins`. The content is a store: on the server it reads `.site/prepared.json`; in the preview, what CalOS sends. |
69
+ | **Next.js helpers** (`@caltho/site-kit/next`) | The prepared content, a page by its path, page metadata, `Img` (the prepared widths), `Md` (Markdown that can't run HTML), `JsonLd`, `BlockMark` (where each block starts, for the preview), `previewOrigins`. The content is a store: on the server it reads `.site/prepared.json`; in the preview, what CalOS sends. |
66
70
  | **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
71
  | **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
72
 
@@ -70,14 +74,32 @@ the kit only checks it's a URL.
70
74
 
71
75
  Sections any site can use, built once here (accessible, sized for each screen, inside the content
72
76
  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
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.
87
+
88
+ **Your own section** (`own`) is built in CalOS rather than in code: a heading and intro over one to
89
+ four columns (or a wide one beside a narrow one), each a list of parts (heading, text, photo, button,
90
+ list, quote, facts in a row, price, photos in a grid, slideshow, space, table, icon, logos, video),
91
+ left or centred, plain or in the brand colour. A column can be drawn as a card (`card: true`). Its
92
+ parts wear the site's own classes, so what's built looks like the site.
93
+
94
+ - **table**: 2 to 5 columns (the first over the rows' labels, its heading may be ""), 1 to 20 rows,
95
+ a column picked out (`highlight`, counting from 1 after the labels). A cell "yes" or "no" is a tick
96
+ or a dash, read out as the word. A real table from 640px up; below, each row a small card.
97
+ - **icon**: one of the kit's line icons by name (`ICON_NAMES`: check, calendar, clock, users, …),
98
+ drawn inline in the brand colour, small, medium or large.
99
+ - **logos**: 2 to 12, in a row in grey, with a line above if it has one.
100
+ - **video**: a YouTube or Vimeo address and what the video is (its title, for a screen reader), shown
101
+ in YouTube's no-cookie player or Vimeo's with tracking off. `finish` lets the player in
102
+ (`frame-src`) only on a page that shows one, and in the preview. One that's wanted again is saved by
81
103
  name in the content's `saved` list, and CalOS offers it beside the site's own kinds.
82
104
 
83
105
  `Slideshow` (from `@caltho/site-kit/next`) is also a building block a site's own sections use: SCV's
@@ -92,17 +114,125 @@ A site takes them in once, three lines:
92
114
  import { sections } from "@caltho/site-kit/sections";
93
115
  export const blocks = { ...sections, hero, about /* … */ };
94
116
 
95
- // the site's block renderer: whatever it hasn't a component for
96
- default:
97
- return <KitSection s={block} />;
117
+ // the site's page: every block after its mark, framed when it has a background, the kit drawing
118
+ // whatever kind the site's own `render` leaves (returns null for)
119
+ <KitBlocks blocks={page.blocks} render={(block, i) => <Block s={block} />} level={(i) => (i === 0 ? 1 : 2)} />
98
120
 
99
121
  // app/layout.tsx
100
122
  import "@caltho/site-kit/sections.css";
101
123
  ```
102
124
 
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: "…" }} />`.
125
+ A section of the business's own has a background (`tone`: plain, soft, brand or dark, from the
126
+ Look's `--look-*`, with `--look-page` and `--look-ink` from the Look's `background` and `ink`), space
127
+ above and below (`spacing`: tight, normal, roomy) and can have a photo behind it (`background`, under
128
+ a scrim). On soft, brand or dark it's a card inset within the content's width in the theme's corners
129
+ (`shape: "card"`, the default) or a band across the page (`shape: "band"`). On brand, dark or a photo the kit keeps its words and buttons readable over the site's own
130
+ colour classes. `presets` (from `@caltho/site-kit/sections`) are ready-made ones (an opening, words
131
+ over a photo, three reasons, a client's words, prices, a call to get in touch, contact, common
132
+ questions, a photo and words, facts in a row, a comparison, features with icons, a logo strip, a
133
+ video), each an `own` block to add and edit like any other.
134
+
135
+ From then on each section the kit adds reaches the site with `cal sites kit update`. They're drawn
136
+ from the site's Look (`--look-*`: its type scale, colours, buttons, width and spacing) and need no
137
+ classes of the site's; a site may still give its own for any part, in place of the kit's:
138
+ `<KitBlocks … classes={{ headingLarge: "font-display text-3xl" }} />`.
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
+
214
+ ## The theme
215
+
216
+ The Look is the site's theme. Beside its fonts, brand and accent colours, corners and buttons, it has
217
+ the page's colours by role (`page`, `ink`, `muted`, `line`, `soft`), colours of the site's own by name
218
+ (`palette`), a type scale (`type`: display, h1, h2, h3, body and small, each a size in px, a weight,
219
+ tracking in em and a line height), the content's `width` and the `space` between sections. A site's
220
+ `look` options are its design as it is (the defaults CalOS shows); its CSS reads the variables
221
+ (`--look-page`, `--look-swatch-sand`, `--look-type-h2-size`, `--look-width`, `--look-section-pad`, the
222
+ full list in src/content/look.ts) so a change in CalOS reaches every page. `--look-gutter` is the
223
+ space each side of the content (the site's `gutter` option in px on a wide screen, 40 unless said,
224
+ half on a phone): a site's own wrap reads it so its sections and the kit's line up.
225
+
226
+ The menu edited in CalOS can have links under a link (`children`, up to 8, one level): `menuOf`
227
+ returns them, and the site's header draws them (the starter: a dropdown on a wide screen, indented
228
+ in the phone's menu). `blockLabel(block, kindTitle)` (`@caltho/site-kit/schema`) is what a block is
229
+ called in a list: its heading, an `own` section's first heading part, else its kind.
230
+
231
+ Every section's Layout (`placement`, on unless a site sets `placement: false`) has a **Background**: a
232
+ colour (a role, or one of the site's own by its slug) and a photo under a shade. KitBlocks draws it as
233
+ a frame around the section (`frameClass`), its words light when the colour or photo is dark
234
+ (`backgroundIsDark`, from the Look's values at build and in the preview). The schema a site writes
235
+ carries the ready-made sections its `own` kind takes, as `x-presets` at its root.
106
236
 
107
237
  ## Share cards
108
238
 
@@ -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;