@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
@@ -8,16 +8,62 @@ import { jsx as _jsx } from "react/jsx-runtime";
8
8
  //
9
9
  // CalOS → page: { type: "calos:show", site, images, path, selected?, scroll?, labels? }
10
10
  // page → CalOS: { type: "calos:ready" }, { type: "calos:pick", path, block },
11
- // { type: "calos:error", message }
11
+ // { type: "calos:error", message }, { type: "calos:overflow", blocks } (the page's
12
+ // blocks with something past the screen's edge at this width, by index)
13
+ // Built sections (../tree/messages.ts): calos:built-select, -hover and -boxes to CalOS;
14
+ // calos:built-highlight and -state from it.
15
+ import { allPages } from "../content/lists.js";
12
16
  import { Component, useEffect, useRef, useState } from "react";
13
17
  import { lookVars } from "../content/look.js";
14
- import { BUILT_IMAGES, fieldForImage, fieldForText } from "../preview-match.js";
18
+ import { fontFiles, googleFont } from "../fonts-google.js";
19
+ import { BUILT_IMAGES, fieldForImage, fieldForText, isShown, matchBlocks, withoutVariant } from "../preview-match.js";
20
+ import { showWithin, startBehaviors } from "../behaviors/index.js";
21
+ import { swatchSlugs, tailwindInput } from "../built/theme.js";
22
+ import { hasBuilt } from "../built/tree.js";
23
+ import { NODE_ATTR } from "../tree/index.js";
24
+ import { BLOCK_MARK, InPreview } from "./block-mark.js";
15
25
  import { setPrepared } from "./data.js";
26
+ /** `labels`: what CalOS calls each of the page's blocks, shown on its outline. */
27
+ /** The Google fonts this preview has loaded for a draft (by name), once each. */
28
+ const loadedFonts = new Set();
16
29
  /** `look`: the same Look options the site's defineSite has, for the draft's Look to be drawn as it's chosen. */
17
30
  export function SitePreview({ origins, render, look }) {
18
31
  const [shown, setShown] = useState(null);
32
+ // A Google font the draft's Look uses and this build doesn't serve yet: loaded from Fontsource's
33
+ // CDN (the browser's FontFace, which only the font policy governs), so the draft shows in it.
34
+ useEffect(() => {
35
+ const l = shown?.site.look;
36
+ for (const name of [l?.headingFont, l?.bodyFont]) {
37
+ if (typeof name !== "string" || (look?.fonts && name in look.fonts) || loadedFonts.has(name))
38
+ continue;
39
+ const f = googleFont(name);
40
+ if (!f)
41
+ continue;
42
+ loadedFonts.add(name);
43
+ for (const x of fontFiles(f)) {
44
+ const face = new FontFace(f.family, `url(${x.url}) format("woff2")`, { weight: x.weight, style: x.style, unicodeRange: x.range, display: "swap" });
45
+ document.fonts.add(face);
46
+ face.load().catch(() => document.fonts.delete(face));
47
+ }
48
+ }
49
+ // Fonts the business uploaded: from CalOS, where the editor says each file is (with the pictures).
50
+ const uploaded = shown?.site.look?.uploadedFonts;
51
+ for (const f of uploaded ?? [])
52
+ for (const x of f.files) {
53
+ const url = shown?.images[x.src]?.src;
54
+ const key = `${f.family}|${x.src}`;
55
+ if (!url || loadedFonts.has(key) || !/^https?:\/\//.test(url))
56
+ continue;
57
+ loadedFonts.add(key);
58
+ const face = new FontFace(f.family, `url(${JSON.stringify(url)})`, { weight: x.weight, style: x.style, display: "swap" });
59
+ document.fonts.add(face);
60
+ face.load().catch(() => document.fonts.delete(face));
61
+ }
62
+ }, [shown, look]);
19
63
  const [round, setRound] = useState(0);
20
64
  const latest = useRef(null);
65
+ const lookRef = useRef(look);
66
+ lookRef.current = look;
21
67
  // Answers go to the address CalOS is open at; until it's spoken, "ready" goes to each it may be.
22
68
  const replyTo = useRef(null);
23
69
  const post = (msg) => {
@@ -33,12 +79,22 @@ export function SitePreview({ origins, render, look }) {
33
79
  const onMessage = (e) => {
34
80
  if (!origins.includes(e.origin) || e.source !== window.parent)
35
81
  return;
82
+ const said = e.data;
83
+ if (said?.type === "calos:built-highlight" && latest.current) {
84
+ highlight = { block: said.block, nodes: Array.isArray(said.nodes) ? said.nodes.filter((x) => typeof x === "string") : [] };
85
+ return drawHighlight(latest.current, true);
86
+ }
87
+ if (said?.type === "calos:built-state" && latest.current) {
88
+ forced = said.state === "normal" ? null : { block: said.block, node: said.node, state: said.state };
89
+ return applyState(latest.current);
90
+ }
36
91
  const sent = e.data;
37
92
  if (sent?.type !== "calos:show")
38
93
  return;
39
94
  const m = { ...sent, images: asBuilt(sent.images) };
40
95
  replyTo.current = e.origin;
41
- setPrepared({ site: m.site, images: m.images, csp: {} });
96
+ // The draft's Look, its colours read by KitBlocks to know a dark background as it's chosen.
97
+ setPrepared({ site: m.site, images: m.images, csp: {}, ...(lookRef.current ? { look: lookVars(m.site.look, lookRef.current) } : {}) });
42
98
  latest.current = m;
43
99
  setShown(m);
44
100
  setRound((r) => r + 1);
@@ -66,11 +122,29 @@ export function SitePreview({ origins, render, look }) {
66
122
  const target = e.target;
67
123
  if (!m || !target)
68
124
  return;
125
+ // A built section's own controls (a carousel's dots and arrows, which aren't nodes) work as on
126
+ // the site, so a slide can be brought up to edit.
127
+ if (target.closest(".kit-carousel-controls button, button[data-kit-to]"))
128
+ return;
69
129
  e.preventDefault();
70
130
  e.stopPropagation();
131
+ // Only what a person sees is what they clicked: the top element under the pointer that's shown,
132
+ // not a slide faded out over it (a carousel's slides are stacked in one place, the hidden ones
133
+ // still under the pointer), nor the words of one.
134
+ const stack = document.elementsFromPoint(e.clientX, e.clientY);
135
+ const shownEl = stack.find(seen) ?? target;
136
+ // A built section's element names its node: exact, nothing guessed.
137
+ const node = shownEl.closest(`[${NODE_ATTR}]`);
138
+ if (node) {
139
+ const n = blockElements(m)?.findIndex((d) => holds(d, node)) ?? -1;
140
+ // Or in the site's own header or footer (built): -1 or -2, as the editor numbers them.
141
+ const at = n >= 0 ? n : frameOf(node);
142
+ if (at !== null)
143
+ return post({ type: "calos:built-select", block: at, node: node.getAttribute(NODE_ATTR), shift: e.shiftKey });
144
+ }
71
145
  // A link anywhere (a section's, the menu's, the footer's) is edited, never followed: CalOS's page picker moves between pages.
72
146
  // A picture under the pointer counts, even when something over it (a frame, a caption's box) took the click.
73
- const under = document.elementsFromPoint(e.clientX, e.clientY).find((el) => el.tagName === "IMG") ?? target;
147
+ const under = stack.find((el) => el.tagName === "IMG" && seen(el)) ?? shownEl;
74
148
  post({ type: "calos:pick", ...pick(under, m, wordsAt(e.clientX, e.clientY)) });
75
149
  };
76
150
  const onSubmit = (e) => e.preventDefault();
@@ -79,13 +153,30 @@ export function SitePreview({ origins, render, look }) {
79
153
  const m = latest.current;
80
154
  const target = e.target;
81
155
  const blocks = m ? blockElements(m) : null;
82
- const n = blocks && target ? blocks.findIndex((el) => el.contains(target)) : -1;
156
+ const n = blocks && target ? blocks.findIndex((d) => holds(d, target)) : -1;
83
157
  const selected = m ? blockElement(m) : null;
84
158
  hovered = n >= 0 ? blocks[n] : null;
85
- outline("section", hovered && hovered !== selected ? hovered : null, n >= 0 ? m?.labels?.[n] : undefined);
86
- outline("hover", hoverable(target));
159
+ outline("section", hovered && hovered[0] !== selected?.[0] ? hovered : null, n >= 0 ? m?.labels?.[n] : undefined);
160
+ // Over a built section, the element itself (said to CalOS, for its layers), not the words in it.
161
+ const shownEl = document.elementsFromPoint(e.clientX, e.clientY).find(seen) ?? target;
162
+ // In the site's own header or footer (built): -1 or -2, as the editor knows them.
163
+ const framed = n < 0 && shownEl ? shownEl.closest(`[${NODE_ATTR}]`) : null;
164
+ const f = framed ? frameOf(framed) : null;
165
+ const node = n >= 0 ? (shownEl?.closest(`[${NODE_ATTR}]`) ?? null) : f !== null ? framed : null;
166
+ const id = node && (f !== null || holds(blocks[n], node)) ? node.getAttribute(NODE_ATTR) : null;
167
+ const at = f ?? n;
168
+ const said = id ? `${at}:${id}` : null;
169
+ if (said !== hoverSaid) {
170
+ if (id || hoverSaid)
171
+ post({ type: "calos:built-hover", block: id ? at : Number(hoverSaid.split(":")[0]), node: id });
172
+ hoverSaid = said;
173
+ }
174
+ outline("hover", id ? node : hoverable(target));
87
175
  };
88
176
  const leave = () => {
177
+ if (hoverSaid)
178
+ post({ type: "calos:built-hover", block: Number(hoverSaid.split(":")[0]), node: null });
179
+ hoverSaid = null;
89
180
  hovered = null;
90
181
  outline("section", null);
91
182
  outline("hover", null);
@@ -103,6 +194,56 @@ export function SitePreview({ origins, render, look }) {
103
194
  };
104
195
  // eslint-disable-next-line react-hooks/exhaustive-deps
105
196
  }, [key]);
197
+ // The footer's own colour, as designed (the site's CSS may set it, not the Look): said to CalOS so
198
+ // its "as designed" choice shows the colour it is. Only while the draft leaves it as designed.
199
+ const footerSaid = useRef(null);
200
+ useEffect(() => {
201
+ if (!shown)
202
+ return;
203
+ const bg = shown.site.look?.footer?.background;
204
+ if (bg && bg !== "dark")
205
+ return;
206
+ const frame = requestAnimationFrame(() => {
207
+ const footer = document.querySelector("footer.kit-footer");
208
+ const color = footer ? getComputedStyle(footer).backgroundColor : "";
209
+ if (color && color !== footerSaid.current)
210
+ post({ type: "calos:footer", color });
211
+ footerSaid.current = color;
212
+ });
213
+ return () => cancelAnimationFrame(frame);
214
+ // eslint-disable-next-line react-hooks/exhaustive-deps
215
+ }, [shown]);
216
+ // What runs past the edge of the screen, after each drawing and as the width changes: said to
217
+ // CalOS by block, so the editor can point at the section to fix at this size.
218
+ useEffect(() => {
219
+ if (!shown)
220
+ return;
221
+ // Nothing said yet for this drawing: the first measure is always said, so a section fixed since
222
+ // (nothing past the edge any more) is cleared, not left flagged.
223
+ let last = null;
224
+ const measure = () => {
225
+ const { blocks, what } = overflowing(latest.current ?? shown);
226
+ const said = blocks.join(",");
227
+ if (said !== last)
228
+ post({ type: "calos:overflow", blocks, what });
229
+ last = said;
230
+ };
231
+ let frame = 0;
232
+ const soon = () => {
233
+ cancelAnimationFrame(frame);
234
+ frame = requestAnimationFrame(() => requestAnimationFrame(measure));
235
+ };
236
+ soon();
237
+ void document.fonts?.ready.then(soon);
238
+ window.addEventListener("resize", soon);
239
+ window.addEventListener("load", soon, true);
240
+ return () => {
241
+ cancelAnimationFrame(frame);
242
+ window.removeEventListener("resize", soon);
243
+ window.removeEventListener("load", soon, true);
244
+ };
245
+ // eslint-disable-next-line react-hooks/exhaustive-deps
246
+ }, [shown, round]);
106
247
  // The selected block outlined (and brought into view when CalOS asks), after each drawing.
107
248
  useEffect(() => {
108
249
  if (!shown)
@@ -120,32 +261,118 @@ export function SitePreview({ origins, render, look }) {
120
261
  drawnPath = shown.path;
121
262
  // Scrolled here only: scrollIntoView would scroll CalOS's page around the frame too.
122
263
  if (el && shown.scroll)
123
- window.scrollTo({ top: el.getBoundingClientRect().top + window.scrollY - window.innerHeight / 4, behavior: "smooth" });
264
+ window.scrollTo({ top: el[0].getBoundingClientRect().top + window.scrollY - window.innerHeight / 4, behavior: "smooth" });
265
+ // On a resize, once a frame at most: measured on every event, the page was re-laid out mid-change.
266
+ let frame = 0;
124
267
  const keep = () => {
125
- outline("selected", blockElement(latest.current), label(latest.current));
126
- outline("section", null);
127
- outline("hover", null);
268
+ if (frame)
269
+ return;
270
+ frame = requestAnimationFrame(() => {
271
+ frame = 0;
272
+ outline("selected", blockElement(latest.current), label(latest.current));
273
+ outline("section", null);
274
+ outline("hover", null);
275
+ });
276
+ };
277
+ // The outlines are in the page's coordinates, so scrolling moves them with it; the hover ones go.
278
+ let hidden = false;
279
+ const scrolled = () => {
280
+ if (hidden)
281
+ return;
282
+ hidden = true;
283
+ requestAnimationFrame(() => {
284
+ hidden = false;
285
+ outline("section", null);
286
+ outline("hover", null);
287
+ });
128
288
  };
129
- window.addEventListener("scroll", keep, { passive: true });
289
+ window.addEventListener("scroll", scrolled, { passive: true });
130
290
  window.addEventListener("resize", keep);
131
291
  return () => {
132
- window.removeEventListener("scroll", keep);
292
+ cancelAnimationFrame(frame);
293
+ window.removeEventListener("scroll", scrolled);
133
294
  window.removeEventListener("resize", keep);
134
295
  };
135
296
  }, [shown, round]);
297
+ // Built sections: their classes drawn as they're typed (Tailwind's browser build, given the same
298
+ // input as the build's compiler), their behaviors running (nothing moving by itself, so what's
299
+ // being edited stays put), where each element is said to CalOS after every drawing (and as the
300
+ // page scrolls, resizes or the classes' CSS changes), and the editor's selection and state kept.
301
+ useEffect(() => {
302
+ if (!shown)
303
+ return;
304
+ const page = allPages(shown.site).find((p) => p.path === shown.path) ?? shown.site.pages[0];
305
+ if (!page || !hasBuilt(page.blocks))
306
+ return;
307
+ loadTailwind(tailwindInput(swatchSlugs(look ? lookVars(shown.site.look, look) : undefined)));
308
+ if (!behaving) {
309
+ behaving = true;
310
+ startBehaviors(true);
311
+ }
312
+ let frame = 0;
313
+ const soon = () => {
314
+ cancelAnimationFrame(frame);
315
+ frame = requestAnimationFrame(() => {
316
+ const m = latest.current ?? shown;
317
+ applyState(m);
318
+ drawHighlight(m, false);
319
+ for (const [block, boxes] of builtBoxes(m))
320
+ post({ type: "calos:built-boxes", block, boxes });
321
+ });
322
+ };
323
+ soon();
324
+ // The browser build writes the classes' CSS a moment after they're drawn: the boxes are said again then.
325
+ const css = new MutationObserver(soon);
326
+ css.observe(document.head, { childList: true, subtree: true, characterData: true });
327
+ void document.fonts?.ready.then(soon);
328
+ window.addEventListener("resize", soon);
329
+ window.addEventListener("scroll", soon, { passive: true });
330
+ return () => {
331
+ cancelAnimationFrame(frame);
332
+ css.disconnect();
333
+ window.removeEventListener("resize", soon);
334
+ window.removeEventListener("scroll", soon);
335
+ };
336
+ // eslint-disable-next-line react-hooks/exhaustive-deps
337
+ }, [shown, round, look]);
136
338
  // The draft's Look, set on the page (LookStyle drew the published one into the build).
339
+ // One the draft no longer sets (a colour removed, the footer back as designed) is taken off again.
340
+ const setVars = useRef(new Set());
137
341
  useEffect(() => {
138
342
  if (!shown || !look)
139
343
  return;
140
- for (const [k, v] of Object.entries(lookVars(shown.site.look, look)))
141
- document.documentElement.style.setProperty(k, v);
344
+ const vars = lookVars(shown.site.look, look);
345
+ const style = document.documentElement.style;
346
+ // The published Look's values (LookStyle's :root rule) the draft no longer has (a colour it
347
+ // removed): made invalid here, so what reads them falls back as the site will once published.
348
+ const published = new Set();
349
+ for (const sheet of Array.from(document.styleSheets)) {
350
+ let rules;
351
+ try {
352
+ rules = sheet.cssRules;
353
+ }
354
+ catch {
355
+ continue;
356
+ }
357
+ for (const r of Array.from(rules))
358
+ if (r instanceof CSSStyleRule && r.selectorText === ":root")
359
+ for (const name of Array.from(r.style))
360
+ if (name.startsWith("--look-") || name.startsWith("--kit-footer-"))
361
+ published.add(name);
362
+ }
363
+ for (const k of new Set([...setVars.current, ...published]))
364
+ if (!(k in vars))
365
+ style.setProperty(k, "initial");
366
+ for (const [k, v] of Object.entries(vars))
367
+ style.setProperty(k, v);
368
+ setVars.current = new Set(Object.keys(vars));
142
369
  }, [shown, look]);
143
370
  if (!shown)
144
371
  return null;
145
- const page = shown.site.pages.find((p) => p.path === shown.path) ?? shown.site.pages[0];
372
+ const page = allPages(shown.site).find((p) => p.path === shown.path) ?? shown.site.pages[0];
146
373
  if (!page)
147
374
  return null;
148
- return (_jsx(Fallback, { onError: (message) => post({ type: "calos:error", message }), children: render(page, shown.site) }, round));
375
+ return (_jsx(InPreview.Provider, { value: true, children: _jsx(Fallback, { onError: (message) => post({ type: "calos:error", message }), children: render(page, shown.site) }, round) }));
149
376
  }
150
377
  /** The pictures this build made (finish writes the list into this page), by path. */
151
378
  let built = null;
@@ -174,35 +401,113 @@ function asBuilt(images) {
174
401
  const pageIndex = (m) => m.site.pages.findIndex((p) => p.path === m.path);
175
402
  /** What's in `main` without being drawn: a page's structured data (JSON-LD) and the like. */
176
403
  const UNDRAWN = new Set(["SCRIPT", "STYLE", "TEMPLATE", "NOSCRIPT", "LINK", "META"]);
177
- /** The page's blocks as drawn: `main`'s children (those drawn), when there's one for each block. */
404
+ /**
405
+ * What each of the page's blocks drew, one list for each block (a block may draw nothing, or more than
406
+ * one). Exactly, where the site marks where each block starts (BlockMark); otherwise `main`'s
407
+ * children, found by their words when they aren't one for each block.
408
+ */
178
409
  function blockElements(m) {
179
410
  const main = document.querySelector("main");
180
411
  const page = m.site.pages[pageIndex(m)];
181
412
  if (!main || !page)
182
413
  return null;
414
+ const marked = marks(main, page.blocks.length);
415
+ if (marked)
416
+ return marked;
183
417
  const kids = [...main.children].filter((e) => !UNDRAWN.has(e.tagName));
184
- return kids.length === page.blocks.length ? kids : null;
418
+ const words = (el) => [el.innerText ?? el.textContent ?? "", ...[...el.querySelectorAll("img[alt]")].map((i) => i.getAttribute("alt") ?? "")].join(" ");
419
+ const owner = matchBlocks(page.blocks, kids.length === page.blocks.length ? kids.map(() => "") : kids.map(words));
420
+ return page.blocks.map((_, i) => kids.filter((_, j) => owner[j] === i));
421
+ }
422
+ /**
423
+ * The page's blocks with something drawn past the left or right edge of the screen (words running
424
+ * out of a card, a row too wide for a phone), by index. Something inside a box that clips what it
425
+ * holds on purpose (a slideshow's other slides, a scrolling row) doesn't count.
426
+ */
427
+ function overflowing(m) {
428
+ const blocks = blockElements(m);
429
+ if (!blocks)
430
+ return { blocks: [], what: [] };
431
+ const edge = document.documentElement.clientWidth;
432
+ const clips = (el, root) => {
433
+ for (let at = el.parentElement; at && at !== root.parentElement; at = at.parentElement) {
434
+ const c = getComputedStyle(at);
435
+ if (c.overflowX !== "visible" || c.overflow !== "visible")
436
+ return true;
437
+ }
438
+ return false;
439
+ };
440
+ // What it was, said with the block (the element and its words), for whoever's looking into it.
441
+ const what = [];
442
+ const out = (root) => [root, ...root.querySelectorAll("*")].some((el) => {
443
+ const r = el.getBoundingClientRect();
444
+ if (!r.width || !r.height || getComputedStyle(el).visibility === "hidden")
445
+ return false;
446
+ // Across the edge, not wholly off the screen (a field kept out of sight on purpose).
447
+ const over = ((r.right > edge + 1 && r.left < edge) || (r.left < -1 && r.right > 0)) && !clips(el, root);
448
+ if (over)
449
+ what.push(`${el.tagName.toLowerCase()}${typeof el.className === "string" && el.className ? `.${el.className.trim().split(/\s+/).join(".")}` : ""} ${Math.round(r.left)}–${Math.round(r.right)} of ${edge}`);
450
+ return over;
451
+ });
452
+ return { blocks: blocks.flatMap((els, i) => (els.some(out) ? [i] : [])), what };
185
453
  }
454
+ /** Each block's elements by the marks before them: what's drawn after a block's mark, up to the next. Null where there are none. */
455
+ function marks(main, count) {
456
+ const first = main.querySelector(`template[${BLOCK_MARK}]`);
457
+ if (!first?.parentElement)
458
+ return null;
459
+ const out = Array.from({ length: count }, () => []);
460
+ let at = -1;
461
+ for (const el of first.parentElement.children) {
462
+ const mark = el.tagName === "TEMPLATE" ? el.getAttribute(BLOCK_MARK) : null;
463
+ if (mark !== null)
464
+ at = Number(mark);
465
+ else if (at >= 0 && at < count && !UNDRAWN.has(el.tagName))
466
+ out[at].push(el);
467
+ }
468
+ return out;
469
+ }
470
+ const holds = (d, target) => !!d?.some((el) => el.contains(target));
186
471
  function blockElement(m) {
187
472
  if (!m?.selected)
188
473
  return null;
189
474
  const [k, p, b, n] = m.selected;
190
475
  if (k !== "pages" || p !== pageIndex(m) || b !== "blocks" || typeof n !== "number")
191
476
  return null;
192
- return blockElements(m)?.[n] ?? null;
477
+ const d = blockElements(m)?.[n];
478
+ return d?.length ? d : null;
193
479
  }
194
480
  /** The words of the text under the pointer: a name set beside other things in one element (a caption with an email and a phone) is still just the name. */
195
481
  function wordsAt(x, y) {
196
482
  const doc = document;
197
483
  const node = doc.caretPositionFromPoint?.(x, y)?.offsetNode ?? doc.caretRangeFromPoint?.(x, y)?.startContainer;
198
- return node?.nodeType === Node.TEXT_NODE ? (node.textContent?.trim() ?? null) || null : null;
484
+ // The caret snaps to the nearest text, even across empty space: only words actually under the pointer count.
485
+ const under = (t) => {
486
+ const range = document.createRange();
487
+ range.selectNodeContents(t);
488
+ return [...range.getClientRects()].some((r) => x >= r.left && x <= r.right && y >= r.top && y <= r.bottom);
489
+ };
490
+ const words = (t) => (t.textContent?.trim() ?? null) || null;
491
+ // Words a person sees: the caret finds text in whatever's on top, a faded-out slide stacked over
492
+ // the one showing as much as the one showing.
493
+ if (node?.nodeType === Node.TEXT_NODE && node.parentElement && seen(node.parentElement))
494
+ return under(node) ? words(node) : null;
495
+ // Then the words of the shown elements under the pointer, the top one first.
496
+ for (const el of document.elementsFromPoint(x, y)) {
497
+ if (!seen(el))
498
+ continue;
499
+ const t = [...el.childNodes].find((c) => c.nodeType === Node.TEXT_NODE && c.textContent?.trim() && under(c));
500
+ if (t)
501
+ return words(t);
502
+ }
503
+ return null;
199
504
  }
200
505
  /** What a click on `target` meant: the field, and the block it's in. `words`: the text right under the pointer, tried first. */
201
506
  function pick(target, m, words = null) {
202
507
  const pi = pageIndex(m);
203
508
  const page = m.site.pages[pi];
204
509
  const blocks = blockElements(m);
205
- const n = blocks ? blocks.findIndex((el) => el.contains(target)) : -1;
510
+ const n = blocks ? blocks.findIndex((d) => holds(d, target)) : -1;
206
511
  const block = n >= 0 ? ["pages", pi, "blocks", n] : null;
207
512
  const scope = n >= 0 ? page.blocks[n] : null;
208
513
  // What the whole site shares (its founders, its case studies), which a section may show too; the
@@ -216,6 +521,16 @@ function pick(target, m, words = null) {
216
521
  if (found)
217
522
  return { path: found, block: blockOf(found) ?? block };
218
523
  }
524
+ // An icon part has no words to find it by: its name, and which of the section's icons of that name it is.
525
+ const icon = target.closest("svg[data-icon]");
526
+ if (icon && scope && n >= 0) {
527
+ const name = icon.getAttribute("data-icon");
528
+ const found = iconParts(scope, block).filter((x) => x.name === name);
529
+ const drawn = blocks[n].flatMap((el) => [...(el.matches("svg[data-icon]") ? [el] : []), ...el.querySelectorAll("svg[data-icon]")]).filter((el) => el.getAttribute("data-icon") === name);
530
+ const at = found[Math.max(0, drawn.indexOf(icon))] ?? found[0];
531
+ if (at)
532
+ return { path: at.path, block };
533
+ }
219
534
  if (words) {
220
535
  // Outside a section, a menu made from the pages' menu labels is any page's label.
221
536
  const labels = { pages: m.site.pages.map((p) => ({ menu: p.menu })) };
@@ -223,6 +538,16 @@ function pick(target, m, words = null) {
223
538
  if (exact)
224
539
  return { path: exact, block: blockOf(exact) ?? block };
225
540
  }
541
+ // The header (outside every section) away from its words and pictures, its own empty space: its
542
+ // settings in the Look, not whatever words its parents hold (the business's name).
543
+ // The phone's menu button: its own settings (what it shows, its frame), with the header's.
544
+ if (n < 0 && target.closest(".kit-menu-narrow > summary"))
545
+ return { path: ["look", "header", "menuShows"], block: null };
546
+ if (n < 0 && target.closest("header") && !words && !target.closest("a, button, summary, img"))
547
+ return { path: ["look", "header"], block: null };
548
+ // The kit's footer the same: its own settings (its colour), where the site's Look has them.
549
+ if (n < 0 && target.closest("footer.kit-footer") && !words && !target.closest("a, button, summary, img") && m.site.look?.footer)
550
+ return { path: ["look", "footer"], block: null };
226
551
  // The clicked element's words, then its parents' until the block's edge: at each, the section's
227
552
  // own fields first, then the site's shared ones, so a parent holding more of the section isn't
228
553
  // mistaken for what was clicked.
@@ -236,12 +561,42 @@ function pick(target, m, words = null) {
236
561
  if (found)
237
562
  return { path: found, block: blockOf(found) ?? block };
238
563
  }
239
- if (n >= 0 && el === blocks[n])
564
+ if (n >= 0 && blocks[n].includes(el))
240
565
  break;
241
566
  }
242
567
  return { path: block, block };
243
568
  }
569
+ /** A section's icon parts, in the order they're drawn: each one's path and name. */
570
+ function iconParts(value, base) {
571
+ const out = [];
572
+ const visit = (v, path) => {
573
+ if (Array.isArray(v))
574
+ return v.forEach((x, i) => visit(x, [...path, i]));
575
+ if (!v || typeof v !== "object")
576
+ return;
577
+ const o = v;
578
+ if (o.type === "icon" && typeof o.name === "string")
579
+ out.push({ path, name: o.name });
580
+ for (const [k, x] of Object.entries(o))
581
+ if (x && typeof x === "object")
582
+ visit(x, [...path, k]);
583
+ };
584
+ visit(value, base);
585
+ return out;
586
+ }
244
587
  const blockOf = (path) => (path[0] === "pages" && path[2] === "blocks" && typeof path[3] === "number" ? path.slice(0, 4) : null);
588
+ /**
589
+ * Whether a picture is one a person sees: not in something hidden from them (a slideshow's other
590
+ * slides, stacked in the same place) and not faded out to nothing.
591
+ */
592
+ function seen(el) {
593
+ const chain = [];
594
+ for (let at = el; at && at !== document.body; at = at.parentElement) {
595
+ const c = getComputedStyle(at);
596
+ chain.push({ ariaHidden: at.getAttribute("aria-hidden"), inert: at.hasAttribute("inert"), visibility: c.visibility, display: c.display, opacity: c.opacity });
597
+ }
598
+ return isShown(chain);
599
+ }
245
600
  /** The smallest thing under the pointer that's words or a picture. */
246
601
  function hoverable(el) {
247
602
  for (let at = el; at && at !== document.body; at = at.parentElement) {
@@ -252,6 +607,130 @@ function hoverable(el) {
252
607
  }
253
608
  return null;
254
609
  }
610
+ // ---- built sections ----
611
+ /** What the editor has selected in a built section (calos:built-highlight), outlined after every drawing. */
612
+ let highlight = null;
613
+ /** A node drawn as hovered or focused while those classes are edited (calos:built-state). */
614
+ let forced = null;
615
+ /** The built node last said to be under the pointer ("block:node"). */
616
+ let hoverSaid = null;
617
+ let behaving = false;
618
+ let tailwind = null;
619
+ /**
620
+ * Tailwind's browser build, loaded once (a chunk of its own: only this page carries it, never a
621
+ * visitor's), compiling the classes on the page as they change. Its input is set first, so it
622
+ * never compiles its default (which resets the page's own styles): the theme and utilities only,
623
+ * prefixed as built sections draw them, with the draft's Look as the theme.
624
+ */
625
+ function loadTailwind(input) {
626
+ let style = document.getElementById("kit-tailwind");
627
+ if (!style) {
628
+ style = document.createElement("style");
629
+ style.id = "kit-tailwind";
630
+ style.setAttribute("type", "text/tailwindcss");
631
+ document.head.appendChild(style);
632
+ }
633
+ if (style.textContent !== input)
634
+ style.textContent = input;
635
+ tailwind ??= import("@tailwindcss/browser").catch((err) => {
636
+ tailwind = null;
637
+ console.error("site-kit: Tailwind's browser build didn't load", err);
638
+ });
639
+ }
640
+ /** The site's own header (-1) or footer (-2), as the editor numbers them: the one a built element is in, or null. */
641
+ const FRAMES = { "-1": "header", "-2": "footer" };
642
+ function frameOf(el) {
643
+ const f = el.closest("[data-kit-frame]")?.getAttribute("data-kit-frame");
644
+ return f === "header" ? -1 : f === "footer" ? -2 : null;
645
+ }
646
+ function frameEls(block) {
647
+ const which = FRAMES[String(block)];
648
+ const el = which ? document.querySelector(`[data-kit-frame="${which}"]`) : null;
649
+ return el ? [el] : [];
650
+ }
651
+ /** The elements drawn for a node of a block (a repeated node has one for each item). */
652
+ function nodeEls(m, block, node) {
653
+ const els = block < 0 ? frameEls(block) : (blockElements(m)?.[block] ?? []);
654
+ const sel = `[${NODE_ATTR}="${CSS.escape(node)}"]`;
655
+ return els.flatMap((el) => [...(el.matches(sel) ? [el] : []), ...el.querySelectorAll(sel)]);
656
+ }
657
+ /** Where each built element is on screen, by block, for CalOS's overlays: only what's shown (not a slide faded out). */
658
+ function builtBoxes(m) {
659
+ const page = m.site.pages[pageIndex(m)];
660
+ const blocks = blockElements(m);
661
+ if (!page || !blocks)
662
+ return [];
663
+ const out = [];
664
+ page.blocks.forEach((b, i) => {
665
+ if (b.type !== "built" && b.type !== "use")
666
+ return;
667
+ const boxes = (blocks[i] ?? [])
668
+ .flatMap((el) => [...(el.hasAttribute(NODE_ATTR) ? [el] : []), ...el.querySelectorAll(`[${NODE_ATTR}]`)])
669
+ .filter(seen)
670
+ .map((el) => {
671
+ const r = el.getBoundingClientRect();
672
+ return { node: el.getAttribute(NODE_ATTR), x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) };
673
+ });
674
+ out.push([i, boxes]);
675
+ });
676
+ return out;
677
+ }
678
+ /** The selected nodes outlined; brought into view when CalOS has just selected them, and shown where a behavior hides them (another slide, tab or closed item). */
679
+ const highlightBoxes = [];
680
+ function drawHighlight(m, scroll) {
681
+ const els = highlight ? highlight.nodes.flatMap((id) => nodeEls(m, highlight.block, id).slice(0, 1)) : [];
682
+ if (scroll)
683
+ els.forEach((el) => showWithin(el));
684
+ while (highlightBoxes.length < els.length) {
685
+ const box = document.createElement("div");
686
+ Object.assign(box.style, { position: "absolute", pointerEvents: "none", zIndex: "2147483647", ...looks.selected });
687
+ document.body.appendChild(box);
688
+ highlightBoxes.push(box);
689
+ }
690
+ highlightBoxes.forEach((box, i) => {
691
+ const el = els[i];
692
+ if (!el)
693
+ return void (box.style.display = "none");
694
+ const r = el.getBoundingClientRect();
695
+ Object.assign(box.style, { display: "block", top: `${r.top + window.scrollY}px`, left: `${r.left + window.scrollX}px`, width: `${r.width}px`, height: `${r.height}px` });
696
+ });
697
+ const first = els[0];
698
+ if (scroll && first) {
699
+ const r = first.getBoundingClientRect();
700
+ if (r.top < 0 || r.bottom > window.innerHeight)
701
+ window.scrollTo({ top: r.top + window.scrollY - window.innerHeight / 4, behavior: "smooth" });
702
+ }
703
+ }
704
+ /**
705
+ * A node drawn as hovered or focused: each of its classes for that state (hover:, focus:; and
706
+ * group-hover: and the like on what's inside it when it's a group) added without the state, so it
707
+ * shows while its hover classes are edited. Whatever was added before is taken off first.
708
+ */
709
+ const STATE_VARIANTS = { hover: ["hover"], focus: ["focus", "focus-visible", "focus-within"] };
710
+ const GROUP_VARIANTS = { hover: ["group-hover"], focus: ["group-focus", "group-focus-within"] };
711
+ let forcedClasses = [];
712
+ function applyState(m) {
713
+ for (const f of forcedClasses)
714
+ f.el.classList.remove(f.cls);
715
+ forcedClasses = [];
716
+ if (!forced)
717
+ return;
718
+ const force = (el, variants) => {
719
+ for (const c of [...el.classList]) {
720
+ const plain = withoutVariant(c, variants);
721
+ if (plain && !el.classList.contains(plain)) {
722
+ el.classList.add(plain);
723
+ forcedClasses.push({ el, cls: plain });
724
+ }
725
+ }
726
+ };
727
+ for (const el of nodeEls(m, forced.block, forced.node)) {
728
+ force(el, STATE_VARIANTS[forced.state]);
729
+ if ([...el.classList].some((c) => /:group$/.test(c)))
730
+ for (const inner of el.querySelectorAll(`[${NODE_ATTR}]`))
731
+ force(inner, GROUP_VARIANTS[forced.state]);
732
+ }
733
+ }
255
734
  // Outlines drawn over the page, sized through the style object (allowed by the content security
256
735
  // policy, where a style attribute in the markup isn't), never catching the pointer. A section's
257
736
  // outline carries its name.
@@ -269,7 +748,8 @@ function outline(which, el, label) {
269
748
  let made = boxes[which];
270
749
  if (!made) {
271
750
  const box = document.createElement("div");
272
- Object.assign(box.style, { position: "fixed", pointerEvents: "none", zIndex: "2147483647", ...looks[which] });
751
+ // In the page's own coordinates: it scrolls with the page, as the page does, with nothing to redraw.
752
+ Object.assign(box.style, { position: "absolute", pointerEvents: "none", zIndex: "2147483647", ...looks[which] });
273
753
  const tag = document.createElement("span");
274
754
  Object.assign(tag.style, {
275
755
  position: "absolute",
@@ -286,10 +766,14 @@ function outline(which, el, label) {
286
766
  document.body.appendChild(box);
287
767
  made = boxes[which] = { box, tag };
288
768
  }
289
- if (!el)
769
+ const els = el === null ? [] : Array.isArray(el) ? el : [el];
770
+ if (!els.length)
290
771
  return void (made.box.style.display = "none");
291
- const r = el.getBoundingClientRect();
292
- Object.assign(made.box.style, { display: "block", top: `${r.top}px`, left: `${r.left}px`, width: `${r.width}px`, height: `${r.height}px` });
772
+ // Around all of it: a block that drew more than one thing is outlined as one.
773
+ const rs = els.map((e) => e.getBoundingClientRect());
774
+ const top = Math.min(...rs.map((x) => x.top)), left = Math.min(...rs.map((x) => x.left));
775
+ const r = { top, left, width: Math.max(...rs.map((x) => x.right)) - left, height: Math.max(...rs.map((x) => x.bottom)) - top };
776
+ Object.assign(made.box.style, { display: "block", top: `${r.top + window.scrollY}px`, left: `${r.left + window.scrollX}px`, width: `${r.width}px`, height: `${r.height}px` });
293
777
  made.tag.textContent = label ?? "";
294
778
  made.tag.style.display = label ? "block" : "none";
295
779
  }