@caltho/site-kit 0.2.10 → 0.2.11

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 (92) hide show
  1. package/README.md +61 -12
  2. package/dist/cli/check.js +31 -1
  3. package/dist/content/index.d.ts +310 -23
  4. package/dist/content/index.js +142 -15
  5. package/dist/content/look.d.ts +260 -9
  6. package/dist/content/look.js +386 -22
  7. package/dist/finish.js +9 -1
  8. package/dist/fonts/families/dm-sans/LICENSE +93 -0
  9. package/dist/fonts/families/dm-sans/dm-sans-latin-ext-wght-italic.woff2 +0 -0
  10. package/dist/fonts/families/dm-sans/dm-sans-latin-ext-wght-normal.woff2 +0 -0
  11. package/dist/fonts/families/dm-sans/dm-sans-latin-wght-italic.woff2 +0 -0
  12. package/dist/fonts/families/dm-sans/dm-sans-latin-wght-normal.woff2 +0 -0
  13. package/dist/fonts/families/fraunces/LICENSE +93 -0
  14. package/dist/fonts/families/fraunces/fraunces-latin-ext-wght-italic.woff2 +0 -0
  15. package/dist/fonts/families/fraunces/fraunces-latin-ext-wght-normal.woff2 +0 -0
  16. package/dist/fonts/families/fraunces/fraunces-latin-wght-italic.woff2 +0 -0
  17. package/dist/fonts/families/fraunces/fraunces-latin-wght-normal.woff2 +0 -0
  18. package/dist/fonts/families/inter/LICENSE +93 -0
  19. package/dist/fonts/families/inter/inter-latin-ext-wght-italic.woff2 +0 -0
  20. package/dist/fonts/families/inter/inter-latin-ext-wght-normal.woff2 +0 -0
  21. package/dist/fonts/families/inter/inter-latin-wght-italic.woff2 +0 -0
  22. package/dist/fonts/families/inter/inter-latin-wght-normal.woff2 +0 -0
  23. package/dist/fonts/families/lora/LICENSE +93 -0
  24. package/dist/fonts/families/lora/lora-latin-ext-wght-italic.woff2 +0 -0
  25. package/dist/fonts/families/lora/lora-latin-ext-wght-normal.woff2 +0 -0
  26. package/dist/fonts/families/lora/lora-latin-wght-italic.woff2 +0 -0
  27. package/dist/fonts/families/lora/lora-latin-wght-normal.woff2 +0 -0
  28. package/dist/fonts/families/manrope/LICENSE +93 -0
  29. package/dist/fonts/families/manrope/manrope-latin-ext-wght-normal.woff2 +0 -0
  30. package/dist/fonts/families/manrope/manrope-latin-wght-normal.woff2 +0 -0
  31. package/dist/fonts/families/newsreader/LICENSE +93 -0
  32. package/dist/fonts/families/newsreader/newsreader-latin-ext-wght-italic.woff2 +0 -0
  33. package/dist/fonts/families/newsreader/newsreader-latin-ext-wght-normal.woff2 +0 -0
  34. package/dist/fonts/families/newsreader/newsreader-latin-wght-italic.woff2 +0 -0
  35. package/dist/fonts/families/newsreader/newsreader-latin-wght-normal.woff2 +0 -0
  36. package/dist/fonts/families/outfit/LICENSE +93 -0
  37. package/dist/fonts/families/outfit/outfit-latin-ext-wght-normal.woff2 +0 -0
  38. package/dist/fonts/families/outfit/outfit-latin-wght-normal.woff2 +0 -0
  39. package/dist/fonts/families/playfair-display/LICENSE +93 -0
  40. package/dist/fonts/families/playfair-display/playfair-display-latin-ext-wght-italic.woff2 +0 -0
  41. package/dist/fonts/families/playfair-display/playfair-display-latin-ext-wght-normal.woff2 +0 -0
  42. package/dist/fonts/families/playfair-display/playfair-display-latin-wght-italic.woff2 +0 -0
  43. package/dist/fonts/families/playfair-display/playfair-display-latin-wght-normal.woff2 +0 -0
  44. package/dist/fonts/families/source-serif-4/LICENSE +93 -0
  45. package/dist/fonts/families/source-serif-4/source-serif-4-latin-ext-wght-italic.woff2 +0 -0
  46. package/dist/fonts/families/source-serif-4/source-serif-4-latin-ext-wght-normal.woff2 +0 -0
  47. package/dist/fonts/families/source-serif-4/source-serif-4-latin-wght-italic.woff2 +0 -0
  48. package/dist/fonts/families/source-serif-4/source-serif-4-latin-wght-normal.woff2 +0 -0
  49. package/dist/fonts/families/work-sans/LICENSE +93 -0
  50. package/dist/fonts/families/work-sans/work-sans-latin-ext-wght-italic.woff2 +0 -0
  51. package/dist/fonts/families/work-sans/work-sans-latin-ext-wght-normal.woff2 +0 -0
  52. package/dist/fonts/families/work-sans/work-sans-latin-wght-italic.woff2 +0 -0
  53. package/dist/fonts/families/work-sans/work-sans-latin-wght-normal.woff2 +0 -0
  54. package/dist/fonts/google.json +1 -0
  55. package/dist/fonts-google.d.ts +35 -0
  56. package/dist/fonts-google.js +70 -0
  57. package/dist/fonts.css +365 -0
  58. package/dist/frame.test.d.ts +1 -0
  59. package/dist/frame.test.js +33 -0
  60. package/dist/migrate.d.ts +45 -0
  61. package/dist/migrate.js +284 -0
  62. package/dist/next/block-mark.d.ts +7 -0
  63. package/dist/next/block-mark.js +16 -0
  64. package/dist/next/blocks.d.ts +11 -0
  65. package/dist/next/blocks.js +35 -0
  66. package/dist/next/data.d.ts +4 -0
  67. package/dist/next/data.js +4 -0
  68. package/dist/next/frame.d.ts +34 -0
  69. package/dist/next/frame.js +70 -0
  70. package/dist/next/index.d.ts +6 -2
  71. package/dist/next/index.js +7 -3
  72. package/dist/next/menu-group.d.ts +11 -0
  73. package/dist/next/menu-group.js +33 -0
  74. package/dist/next/parts/LookStyle.js +3 -1
  75. package/dist/next/preview.js +282 -25
  76. package/dist/next/sections.d.ts +19 -10
  77. package/dist/next/sections.js +129 -17
  78. package/dist/prepare.d.ts +5 -1
  79. package/dist/prepare.js +59 -1
  80. package/dist/preview-match.d.ts +8 -0
  81. package/dist/preview-match.js +33 -1
  82. package/dist/schema.d.ts +10 -0
  83. package/dist/schema.js +26 -0
  84. package/dist/sections/icons.d.ts +47 -0
  85. package/dist/sections/icons.js +51 -0
  86. package/dist/sections/index.d.ts +165 -11
  87. package/dist/sections/index.js +129 -8
  88. package/dist/sections/presets.d.ts +13 -0
  89. package/dist/sections/presets.js +300 -0
  90. package/dist/sections.css +1260 -41
  91. package/dist/sections.test.js +276 -14
  92. package/package.json +8 -3
@@ -8,16 +8,55 @@ 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)
12
13
  import { Component, useEffect, useRef, useState } from "react";
13
14
  import { lookVars } from "../content/look.js";
14
- import { BUILT_IMAGES, fieldForImage, fieldForText } from "../preview-match.js";
15
+ import { fontFiles, googleFont } from "../fonts-google.js";
16
+ import { BUILT_IMAGES, fieldForImage, fieldForText, matchBlocks } from "../preview-match.js";
17
+ import { BLOCK_MARK, InPreview } from "./block-mark.js";
15
18
  import { setPrepared } from "./data.js";
19
+ /** `labels`: what CalOS calls each of the page's blocks, shown on its outline. */
20
+ /** The Google fonts this preview has loaded for a draft (by name), once each. */
21
+ const loadedFonts = new Set();
16
22
  /** `look`: the same Look options the site's defineSite has, for the draft's Look to be drawn as it's chosen. */
17
23
  export function SitePreview({ origins, render, look }) {
18
24
  const [shown, setShown] = useState(null);
25
+ // A Google font the draft's Look uses and this build doesn't serve yet: loaded from Fontsource's
26
+ // CDN (the browser's FontFace, which only the font policy governs), so the draft shows in it.
27
+ useEffect(() => {
28
+ const l = shown?.site.look;
29
+ for (const name of [l?.headingFont, l?.bodyFont]) {
30
+ if (typeof name !== "string" || (look?.fonts && name in look.fonts) || loadedFonts.has(name))
31
+ continue;
32
+ const f = googleFont(name);
33
+ if (!f)
34
+ continue;
35
+ loadedFonts.add(name);
36
+ for (const x of fontFiles(f)) {
37
+ const face = new FontFace(f.family, `url(${x.url}) format("woff2")`, { weight: x.weight, style: x.style, unicodeRange: x.range, display: "swap" });
38
+ document.fonts.add(face);
39
+ face.load().catch(() => document.fonts.delete(face));
40
+ }
41
+ }
42
+ // Fonts the business uploaded: from CalOS, where the editor says each file is (with the pictures).
43
+ const uploaded = shown?.site.look?.uploadedFonts;
44
+ for (const f of uploaded ?? [])
45
+ for (const x of f.files) {
46
+ const url = shown?.images[x.src]?.src;
47
+ const key = `${f.family}|${x.src}`;
48
+ if (!url || loadedFonts.has(key) || !/^https?:\/\//.test(url))
49
+ continue;
50
+ loadedFonts.add(key);
51
+ const face = new FontFace(f.family, `url(${JSON.stringify(url)})`, { weight: x.weight, style: x.style, display: "swap" });
52
+ document.fonts.add(face);
53
+ face.load().catch(() => document.fonts.delete(face));
54
+ }
55
+ }, [shown, look]);
19
56
  const [round, setRound] = useState(0);
20
57
  const latest = useRef(null);
58
+ const lookRef = useRef(look);
59
+ lookRef.current = look;
21
60
  // Answers go to the address CalOS is open at; until it's spoken, "ready" goes to each it may be.
22
61
  const replyTo = useRef(null);
23
62
  const post = (msg) => {
@@ -38,7 +77,8 @@ export function SitePreview({ origins, render, look }) {
38
77
  return;
39
78
  const m = { ...sent, images: asBuilt(sent.images) };
40
79
  replyTo.current = e.origin;
41
- setPrepared({ site: m.site, images: m.images, csp: {} });
80
+ // The draft's Look, its colours read by KitBlocks to know a dark background as it's chosen.
81
+ setPrepared({ site: m.site, images: m.images, csp: {}, ...(lookRef.current ? { look: lookVars(m.site.look, lookRef.current) } : {}) });
42
82
  latest.current = m;
43
83
  setShown(m);
44
84
  setRound((r) => r + 1);
@@ -70,7 +110,7 @@ export function SitePreview({ origins, render, look }) {
70
110
  e.stopPropagation();
71
111
  // A link anywhere (a section's, the menu's, the footer's) is edited, never followed: CalOS's page picker moves between pages.
72
112
  // 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;
113
+ const under = document.elementsFromPoint(e.clientX, e.clientY).find((el) => el.tagName === "IMG" && seen(el)) ?? target;
74
114
  post({ type: "calos:pick", ...pick(under, m, wordsAt(e.clientX, e.clientY)) });
75
115
  };
76
116
  const onSubmit = (e) => e.preventDefault();
@@ -79,10 +119,10 @@ export function SitePreview({ origins, render, look }) {
79
119
  const m = latest.current;
80
120
  const target = e.target;
81
121
  const blocks = m ? blockElements(m) : null;
82
- const n = blocks && target ? blocks.findIndex((el) => el.contains(target)) : -1;
122
+ const n = blocks && target ? blocks.findIndex((d) => holds(d, target)) : -1;
83
123
  const selected = m ? blockElement(m) : null;
84
124
  hovered = n >= 0 ? blocks[n] : null;
85
- outline("section", hovered && hovered !== selected ? hovered : null, n >= 0 ? m?.labels?.[n] : undefined);
125
+ outline("section", hovered && hovered[0] !== selected?.[0] ? hovered : null, n >= 0 ? m?.labels?.[n] : undefined);
86
126
  outline("hover", hoverable(target));
87
127
  };
88
128
  const leave = () => {
@@ -103,6 +143,56 @@ export function SitePreview({ origins, render, look }) {
103
143
  };
104
144
  // eslint-disable-next-line react-hooks/exhaustive-deps
105
145
  }, [key]);
146
+ // The footer's own colour, as designed (the site's CSS may set it, not the Look): said to CalOS so
147
+ // its "as designed" choice shows the colour it is. Only while the draft leaves it as designed.
148
+ const footerSaid = useRef(null);
149
+ useEffect(() => {
150
+ if (!shown)
151
+ return;
152
+ const bg = shown.site.look?.footer?.background;
153
+ if (bg && bg !== "dark")
154
+ return;
155
+ const frame = requestAnimationFrame(() => {
156
+ const footer = document.querySelector("footer.kit-footer");
157
+ const color = footer ? getComputedStyle(footer).backgroundColor : "";
158
+ if (color && color !== footerSaid.current)
159
+ post({ type: "calos:footer", color });
160
+ footerSaid.current = color;
161
+ });
162
+ return () => cancelAnimationFrame(frame);
163
+ // eslint-disable-next-line react-hooks/exhaustive-deps
164
+ }, [shown]);
165
+ // What runs past the edge of the screen, after each drawing and as the width changes: said to
166
+ // CalOS by block, so the editor can point at the section to fix at this size.
167
+ useEffect(() => {
168
+ if (!shown)
169
+ return;
170
+ // Nothing said yet for this drawing: the first measure is always said, so a section fixed since
171
+ // (nothing past the edge any more) is cleared, not left flagged.
172
+ let last = null;
173
+ const measure = () => {
174
+ const { blocks, what } = overflowing(latest.current ?? shown);
175
+ const said = blocks.join(",");
176
+ if (said !== last)
177
+ post({ type: "calos:overflow", blocks, what });
178
+ last = said;
179
+ };
180
+ let frame = 0;
181
+ const soon = () => {
182
+ cancelAnimationFrame(frame);
183
+ frame = requestAnimationFrame(() => requestAnimationFrame(measure));
184
+ };
185
+ soon();
186
+ void document.fonts?.ready.then(soon);
187
+ window.addEventListener("resize", soon);
188
+ window.addEventListener("load", soon, true);
189
+ return () => {
190
+ cancelAnimationFrame(frame);
191
+ window.removeEventListener("resize", soon);
192
+ window.removeEventListener("load", soon, true);
193
+ };
194
+ // eslint-disable-next-line react-hooks/exhaustive-deps
195
+ }, [shown, round]);
106
196
  // The selected block outlined (and brought into view when CalOS asks), after each drawing.
107
197
  useEffect(() => {
108
198
  if (!shown)
@@ -120,32 +210,77 @@ export function SitePreview({ origins, render, look }) {
120
210
  drawnPath = shown.path;
121
211
  // Scrolled here only: scrollIntoView would scroll CalOS's page around the frame too.
122
212
  if (el && shown.scroll)
123
- window.scrollTo({ top: el.getBoundingClientRect().top + window.scrollY - window.innerHeight / 4, behavior: "smooth" });
213
+ window.scrollTo({ top: el[0].getBoundingClientRect().top + window.scrollY - window.innerHeight / 4, behavior: "smooth" });
214
+ // On a resize, once a frame at most: measured on every event, the page was re-laid out mid-change.
215
+ let frame = 0;
124
216
  const keep = () => {
125
- outline("selected", blockElement(latest.current), label(latest.current));
126
- outline("section", null);
127
- outline("hover", null);
217
+ if (frame)
218
+ return;
219
+ frame = requestAnimationFrame(() => {
220
+ frame = 0;
221
+ outline("selected", blockElement(latest.current), label(latest.current));
222
+ outline("section", null);
223
+ outline("hover", null);
224
+ });
128
225
  };
129
- window.addEventListener("scroll", keep, { passive: true });
226
+ // The outlines are in the page's coordinates, so scrolling moves them with it; the hover ones go.
227
+ let hidden = false;
228
+ const scrolled = () => {
229
+ if (hidden)
230
+ return;
231
+ hidden = true;
232
+ requestAnimationFrame(() => {
233
+ hidden = false;
234
+ outline("section", null);
235
+ outline("hover", null);
236
+ });
237
+ };
238
+ window.addEventListener("scroll", scrolled, { passive: true });
130
239
  window.addEventListener("resize", keep);
131
240
  return () => {
132
- window.removeEventListener("scroll", keep);
241
+ cancelAnimationFrame(frame);
242
+ window.removeEventListener("scroll", scrolled);
133
243
  window.removeEventListener("resize", keep);
134
244
  };
135
245
  }, [shown, round]);
136
246
  // The draft's Look, set on the page (LookStyle drew the published one into the build).
247
+ // One the draft no longer sets (a colour removed, the footer back as designed) is taken off again.
248
+ const setVars = useRef(new Set());
137
249
  useEffect(() => {
138
250
  if (!shown || !look)
139
251
  return;
140
- for (const [k, v] of Object.entries(lookVars(shown.site.look, look)))
141
- document.documentElement.style.setProperty(k, v);
252
+ const vars = lookVars(shown.site.look, look);
253
+ const style = document.documentElement.style;
254
+ // The published Look's values (LookStyle's :root rule) the draft no longer has (a colour it
255
+ // removed): made invalid here, so what reads them falls back as the site will once published.
256
+ const published = new Set();
257
+ for (const sheet of Array.from(document.styleSheets)) {
258
+ let rules;
259
+ try {
260
+ rules = sheet.cssRules;
261
+ }
262
+ catch {
263
+ continue;
264
+ }
265
+ for (const r of Array.from(rules))
266
+ if (r instanceof CSSStyleRule && r.selectorText === ":root")
267
+ for (const name of Array.from(r.style))
268
+ if (name.startsWith("--look-") || name.startsWith("--kit-footer-"))
269
+ published.add(name);
270
+ }
271
+ for (const k of new Set([...setVars.current, ...published]))
272
+ if (!(k in vars))
273
+ style.setProperty(k, "initial");
274
+ for (const [k, v] of Object.entries(vars))
275
+ style.setProperty(k, v);
276
+ setVars.current = new Set(Object.keys(vars));
142
277
  }, [shown, look]);
143
278
  if (!shown)
144
279
  return null;
145
280
  const page = shown.site.pages.find((p) => p.path === shown.path) ?? shown.site.pages[0];
146
281
  if (!page)
147
282
  return null;
148
- return (_jsx(Fallback, { onError: (message) => post({ type: "calos:error", message }), children: render(page, shown.site) }, round));
283
+ return (_jsx(InPreview.Provider, { value: true, children: _jsx(Fallback, { onError: (message) => post({ type: "calos:error", message }), children: render(page, shown.site) }, round) }));
149
284
  }
150
285
  /** The pictures this build made (finish writes the list into this page), by path. */
151
286
  let built = null;
@@ -174,35 +309,100 @@ function asBuilt(images) {
174
309
  const pageIndex = (m) => m.site.pages.findIndex((p) => p.path === m.path);
175
310
  /** What's in `main` without being drawn: a page's structured data (JSON-LD) and the like. */
176
311
  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. */
312
+ /**
313
+ * What each of the page's blocks drew, one list for each block (a block may draw nothing, or more than
314
+ * one). Exactly, where the site marks where each block starts (BlockMark); otherwise `main`'s
315
+ * children, found by their words when they aren't one for each block.
316
+ */
178
317
  function blockElements(m) {
179
318
  const main = document.querySelector("main");
180
319
  const page = m.site.pages[pageIndex(m)];
181
320
  if (!main || !page)
182
321
  return null;
322
+ const marked = marks(main, page.blocks.length);
323
+ if (marked)
324
+ return marked;
183
325
  const kids = [...main.children].filter((e) => !UNDRAWN.has(e.tagName));
184
- return kids.length === page.blocks.length ? kids : null;
326
+ const words = (el) => [el.innerText ?? el.textContent ?? "", ...[...el.querySelectorAll("img[alt]")].map((i) => i.getAttribute("alt") ?? "")].join(" ");
327
+ const owner = matchBlocks(page.blocks, kids.length === page.blocks.length ? kids.map(() => "") : kids.map(words));
328
+ return page.blocks.map((_, i) => kids.filter((_, j) => owner[j] === i));
329
+ }
330
+ /**
331
+ * The page's blocks with something drawn past the left or right edge of the screen (words running
332
+ * out of a card, a row too wide for a phone), by index. Something inside a box that clips what it
333
+ * holds on purpose (a slideshow's other slides, a scrolling row) doesn't count.
334
+ */
335
+ function overflowing(m) {
336
+ const blocks = blockElements(m);
337
+ if (!blocks)
338
+ return { blocks: [], what: [] };
339
+ const edge = document.documentElement.clientWidth;
340
+ const clips = (el, root) => {
341
+ for (let at = el.parentElement; at && at !== root.parentElement; at = at.parentElement) {
342
+ const c = getComputedStyle(at);
343
+ if (c.overflowX !== "visible" || c.overflow !== "visible")
344
+ return true;
345
+ }
346
+ return false;
347
+ };
348
+ // What it was, said with the block (the element and its words), for whoever's looking into it.
349
+ const what = [];
350
+ const out = (root) => [root, ...root.querySelectorAll("*")].some((el) => {
351
+ const r = el.getBoundingClientRect();
352
+ if (!r.width || !r.height || getComputedStyle(el).visibility === "hidden")
353
+ return false;
354
+ // Across the edge, not wholly off the screen (a field kept out of sight on purpose).
355
+ const over = ((r.right > edge + 1 && r.left < edge) || (r.left < -1 && r.right > 0)) && !clips(el, root);
356
+ if (over)
357
+ 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}`);
358
+ return over;
359
+ });
360
+ return { blocks: blocks.flatMap((els, i) => (els.some(out) ? [i] : [])), what };
361
+ }
362
+ /** 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. */
363
+ function marks(main, count) {
364
+ const first = main.querySelector(`template[${BLOCK_MARK}]`);
365
+ if (!first?.parentElement)
366
+ return null;
367
+ const out = Array.from({ length: count }, () => []);
368
+ let at = -1;
369
+ for (const el of first.parentElement.children) {
370
+ const mark = el.tagName === "TEMPLATE" ? el.getAttribute(BLOCK_MARK) : null;
371
+ if (mark !== null)
372
+ at = Number(mark);
373
+ else if (at >= 0 && at < count && !UNDRAWN.has(el.tagName))
374
+ out[at].push(el);
375
+ }
376
+ return out;
185
377
  }
378
+ const holds = (d, target) => !!d?.some((el) => el.contains(target));
186
379
  function blockElement(m) {
187
380
  if (!m?.selected)
188
381
  return null;
189
382
  const [k, p, b, n] = m.selected;
190
383
  if (k !== "pages" || p !== pageIndex(m) || b !== "blocks" || typeof n !== "number")
191
384
  return null;
192
- return blockElements(m)?.[n] ?? null;
385
+ const d = blockElements(m)?.[n];
386
+ return d?.length ? d : null;
193
387
  }
194
388
  /** 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
389
  function wordsAt(x, y) {
196
390
  const doc = document;
197
391
  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;
392
+ if (node?.nodeType !== Node.TEXT_NODE)
393
+ return null;
394
+ // The caret snaps to the nearest text, even across empty space: only words actually under the pointer count.
395
+ const range = document.createRange();
396
+ range.selectNodeContents(node);
397
+ const under = [...range.getClientRects()].some((r) => x >= r.left && x <= r.right && y >= r.top && y <= r.bottom);
398
+ return under ? (node.textContent?.trim() ?? null) || null : null;
199
399
  }
200
400
  /** What a click on `target` meant: the field, and the block it's in. `words`: the text right under the pointer, tried first. */
201
401
  function pick(target, m, words = null) {
202
402
  const pi = pageIndex(m);
203
403
  const page = m.site.pages[pi];
204
404
  const blocks = blockElements(m);
205
- const n = blocks ? blocks.findIndex((el) => el.contains(target)) : -1;
405
+ const n = blocks ? blocks.findIndex((d) => holds(d, target)) : -1;
206
406
  const block = n >= 0 ? ["pages", pi, "blocks", n] : null;
207
407
  const scope = n >= 0 ? page.blocks[n] : null;
208
408
  // What the whole site shares (its founders, its case studies), which a section may show too; the
@@ -216,6 +416,16 @@ function pick(target, m, words = null) {
216
416
  if (found)
217
417
  return { path: found, block: blockOf(found) ?? block };
218
418
  }
419
+ // An icon part has no words to find it by: its name, and which of the section's icons of that name it is.
420
+ const icon = target.closest("svg[data-icon]");
421
+ if (icon && scope && n >= 0) {
422
+ const name = icon.getAttribute("data-icon");
423
+ const found = iconParts(scope, block).filter((x) => x.name === name);
424
+ const drawn = blocks[n].flatMap((el) => [...(el.matches("svg[data-icon]") ? [el] : []), ...el.querySelectorAll("svg[data-icon]")]).filter((el) => el.getAttribute("data-icon") === name);
425
+ const at = found[Math.max(0, drawn.indexOf(icon))] ?? found[0];
426
+ if (at)
427
+ return { path: at.path, block };
428
+ }
219
429
  if (words) {
220
430
  // Outside a section, a menu made from the pages' menu labels is any page's label.
221
431
  const labels = { pages: m.site.pages.map((p) => ({ menu: p.menu })) };
@@ -223,6 +433,16 @@ function pick(target, m, words = null) {
223
433
  if (exact)
224
434
  return { path: exact, block: blockOf(exact) ?? block };
225
435
  }
436
+ // The header (outside every section) away from its words and pictures, its own empty space: its
437
+ // settings in the Look, not whatever words its parents hold (the business's name).
438
+ // The phone's menu button: its own settings (what it shows, its frame), with the header's.
439
+ if (n < 0 && target.closest(".kit-menu-narrow > summary"))
440
+ return { path: ["look", "header", "menuShows"], block: null };
441
+ if (n < 0 && target.closest("header") && !words && !target.closest("a, button, summary, img"))
442
+ return { path: ["look", "header"], block: null };
443
+ // The kit's footer the same: its own settings (its colour), where the site's Look has them.
444
+ if (n < 0 && target.closest("footer.kit-footer") && !words && !target.closest("a, button, summary, img") && m.site.look?.footer)
445
+ return { path: ["look", "footer"], block: null };
226
446
  // The clicked element's words, then its parents' until the block's edge: at each, the section's
227
447
  // own fields first, then the site's shared ones, so a parent holding more of the section isn't
228
448
  // mistaken for what was clicked.
@@ -236,12 +456,44 @@ function pick(target, m, words = null) {
236
456
  if (found)
237
457
  return { path: found, block: blockOf(found) ?? block };
238
458
  }
239
- if (n >= 0 && el === blocks[n])
459
+ if (n >= 0 && blocks[n].includes(el))
240
460
  break;
241
461
  }
242
462
  return { path: block, block };
243
463
  }
464
+ /** A section's icon parts, in the order they're drawn: each one's path and name. */
465
+ function iconParts(value, base) {
466
+ const out = [];
467
+ const visit = (v, path) => {
468
+ if (Array.isArray(v))
469
+ return v.forEach((x, i) => visit(x, [...path, i]));
470
+ if (!v || typeof v !== "object")
471
+ return;
472
+ const o = v;
473
+ if (o.type === "icon" && typeof o.name === "string")
474
+ out.push({ path, name: o.name });
475
+ for (const [k, x] of Object.entries(o))
476
+ if (x && typeof x === "object")
477
+ visit(x, [...path, k]);
478
+ };
479
+ visit(value, base);
480
+ return out;
481
+ }
244
482
  const blockOf = (path) => (path[0] === "pages" && path[2] === "blocks" && typeof path[3] === "number" ? path.slice(0, 4) : null);
483
+ /**
484
+ * Whether a picture is one a person sees: not in something hidden from them (a slideshow's other
485
+ * slides, stacked in the same place) and not faded out to nothing.
486
+ */
487
+ function seen(el) {
488
+ for (let at = el; at && at !== document.body; at = at.parentElement) {
489
+ if (at.getAttribute("aria-hidden") === "true")
490
+ return false;
491
+ const c = getComputedStyle(at);
492
+ if (c.visibility === "hidden" || c.display === "none" || Number(c.opacity) < 0.05)
493
+ return false;
494
+ }
495
+ return true;
496
+ }
245
497
  /** The smallest thing under the pointer that's words or a picture. */
246
498
  function hoverable(el) {
247
499
  for (let at = el; at && at !== document.body; at = at.parentElement) {
@@ -269,7 +521,8 @@ function outline(which, el, label) {
269
521
  let made = boxes[which];
270
522
  if (!made) {
271
523
  const box = document.createElement("div");
272
- Object.assign(box.style, { position: "fixed", pointerEvents: "none", zIndex: "2147483647", ...looks[which] });
524
+ // In the page's own coordinates: it scrolls with the page, as the page does, with nothing to redraw.
525
+ Object.assign(box.style, { position: "absolute", pointerEvents: "none", zIndex: "2147483647", ...looks[which] });
273
526
  const tag = document.createElement("span");
274
527
  Object.assign(tag.style, {
275
528
  position: "absolute",
@@ -286,10 +539,14 @@ function outline(which, el, label) {
286
539
  document.body.appendChild(box);
287
540
  made = boxes[which] = { box, tag };
288
541
  }
289
- if (!el)
542
+ const els = el === null ? [] : Array.isArray(el) ? el : [el];
543
+ if (!els.length)
290
544
  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` });
545
+ // Around all of it: a block that drew more than one thing is outlined as one.
546
+ const rs = els.map((e) => e.getBoundingClientRect());
547
+ const top = Math.min(...rs.map((x) => x.top)), left = Math.min(...rs.map((x) => x.left));
548
+ const r = { top, left, width: Math.max(...rs.map((x) => x.right)) - left, height: Math.max(...rs.map((x) => x.bottom)) - top };
549
+ 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
550
  made.tag.textContent = label ?? "";
294
551
  made.tag.style.display = label ? "block" : "none";
295
552
  }
@@ -4,20 +4,29 @@ export type KitBlock = {
4
4
  placement?: Record<string, unknown>;
5
5
  } & Record<string, unknown>;
6
6
  /**
7
- * The site's own classes for a kit section's parts, each in place of the kit's: its band, its
8
- * container, its heading, its words, and its buttons (the main one and a quieter one).
7
+ * The styles for each thing a kit section draws. A kit section follows the site's Look (its theme:
8
+ * type scale, colours, buttons, width and spacing, as --look-* variables) with the kit's own classes
9
+ * (sections.css), so it looks right on any site with none of these given. A site may still name its
10
+ * own for any of them, in place of the kit's: `headingLarge` a section's own heading, `headingMedium`
11
+ * a heading under it, `headingSmall` the smallest; `text` body text, `muted` quieter text beside it (a
12
+ * price's "per month", a fact's label, who a quote is from).
9
13
  */
10
14
  export type KitClasses = {
11
- section?: string;
12
- wrap?: string;
13
- heading?: string;
14
- text?: string;
15
- button?: string;
16
- buttonQuiet?: string;
15
+ section: string;
16
+ wrap: string;
17
+ headingLarge: string;
18
+ headingMedium: string;
19
+ headingSmall: string;
20
+ text: string;
21
+ muted: string;
22
+ button: string;
23
+ buttonQuiet: string;
17
24
  };
25
+ /** The kit's own classes, drawn from the Look by sections.css. */
26
+ export declare const KIT_CLASSES: KitClasses;
18
27
  /** A block of one of the kit's types, drawn; null for any other (the site's own to draw). */
19
- export declare function KitSection({ s, level, classes }: {
28
+ export declare function KitSection({ s, level, classes: given }: {
20
29
  s: KitBlock;
21
30
  level?: number;
22
- classes?: KitClasses;
31
+ classes?: Partial<KitClasses>;
23
32
  }): import("react").JSX.Element | null;