@caelo-cms/shared 0.10.60 → 0.10.62

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 (50) hide show
  1. package/dist/auto-update.d.ts +15 -0
  2. package/dist/auto-update.d.ts.map +1 -1
  3. package/dist/auto-update.js +27 -0
  4. package/dist/auto-update.js.map +1 -1
  5. package/dist/cloud-shell-install.d.ts +42 -10
  6. package/dist/cloud-shell-install.d.ts.map +1 -1
  7. package/dist/cloud-shell-install.js +59 -0
  8. package/dist/cloud-shell-install.js.map +1 -1
  9. package/dist/css-scope-scan.d.ts +29 -0
  10. package/dist/css-scope-scan.d.ts.map +1 -0
  11. package/dist/css-scope-scan.js +251 -0
  12. package/dist/css-scope-scan.js.map +1 -0
  13. package/dist/font-assets.d.ts +1 -1
  14. package/dist/index.d.ts +2 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +2 -0
  17. package/dist/index.js.map +1 -1
  18. package/dist/interactions.d.ts +65 -13
  19. package/dist/interactions.d.ts.map +1 -1
  20. package/dist/interactions.js +132 -33
  21. package/dist/interactions.js.map +1 -1
  22. package/dist/preview-compose.d.ts.map +1 -1
  23. package/dist/preview-compose.js +15 -19
  24. package/dist/preview-compose.js.map +1 -1
  25. package/dist/release-manifest.d.ts +3 -0
  26. package/dist/release-manifest.d.ts.map +1 -1
  27. package/dist/release-manifest.js +11 -0
  28. package/dist/release-manifest.js.map +1 -1
  29. package/dist/static-security-headers.d.ts +50 -0
  30. package/dist/static-security-headers.d.ts.map +1 -0
  31. package/dist/static-security-headers.js +58 -0
  32. package/dist/static-security-headers.js.map +1 -0
  33. package/dist/version.d.ts +2 -2
  34. package/dist/version.js +1 -1
  35. package/package.json +1 -1
  36. package/src/auto-update.test.ts +25 -0
  37. package/src/auto-update.ts +36 -0
  38. package/src/cloud-shell-install.test.ts +36 -1
  39. package/src/cloud-shell-install.ts +64 -0
  40. package/src/css-scope-scan.test.ts +117 -0
  41. package/src/css-scope-scan.ts +263 -0
  42. package/src/index.ts +2 -0
  43. package/src/interactions.test.ts +61 -0
  44. package/src/interactions.ts +181 -26
  45. package/src/preview-compose.test.ts +87 -1
  46. package/src/preview-compose.ts +16 -21
  47. package/src/release-manifest.ts +12 -0
  48. package/src/static-security-headers.test.ts +60 -0
  49. package/src/static-security-headers.ts +64 -0
  50. package/src/version.ts +1 -1
@@ -0,0 +1,263 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * issue #158 — module CSS scoping check (epic #149).
5
+ *
6
+ * Every module's CSS lands in ONE page-wide stylesheet, so a selector
7
+ * that is not tied to the module bleeds into every other module on the
8
+ * page: an `h1 {}` in the hero restyles the pricing table's heading, a
9
+ * `.card {}` in one module restyles another module's cards. The
10
+ * convention (edit_module description, design-quality skill) is to nest
11
+ * module CSS under the module's own root class; this scanner is the
12
+ * mechanical write-time check that rides the authoring tool's result,
13
+ * so the AI fixes the selector in the same turn. A warning, not a gate:
14
+ * the write already succeeded, and a module may legitimately style
15
+ * something global on purpose.
16
+ *
17
+ * Pure string analysis, no CSS parser dependency: comments and strings
18
+ * are skipped, rule preludes are read at the top level and inside
19
+ * grouping at-rules (`@media`, `@supports`, `@layer`, `@container`),
20
+ * and blocks whose contents are not selectors (`@keyframes`,
21
+ * `@font-face`, …) are skipped whole.
22
+ */
23
+
24
+ /** Grouping at-rules whose blocks contain style rules. */
25
+ const GROUPING_AT_RULES = new Set(["media", "supports", "layer", "container", "document"]);
26
+
27
+ /** Max offending selectors named per finding. */
28
+ const MAX_SELECTORS_SHOWN = 5;
29
+
30
+ /** Index just past the string literal starting at `i` (a quote char). */
31
+ function skipString(css: string, i: number): number {
32
+ const quote = css[i];
33
+ let j = i + 1;
34
+ while (j < css.length && css[j] !== quote) {
35
+ if (css[j] === "\\") j++;
36
+ j++;
37
+ }
38
+ return j + 1;
39
+ }
40
+
41
+ /** Index of the `}` matching the `{` at `open`, or css.length when unbalanced. */
42
+ function matchingBrace(css: string, open: number): number {
43
+ let depth = 0;
44
+ for (let i = open; i < css.length; i++) {
45
+ const c = css[i];
46
+ if (c === '"' || c === "'") {
47
+ i = skipString(css, i) - 1;
48
+ } else if (c === "{") {
49
+ depth++;
50
+ } else if (c === "}") {
51
+ depth--;
52
+ if (depth === 0) return i;
53
+ }
54
+ }
55
+ return css.length;
56
+ }
57
+
58
+ /** Selector preludes of every style rule in `css[start, end)`, recursing into grouping at-rules. */
59
+ function collectPreludes(css: string, start: number, end: number, out: string[]): void {
60
+ let i = start;
61
+ while (i < end) {
62
+ // Read one prelude: up to `{` (a block) or `;` (a statement at-rule).
63
+ let j = i;
64
+ while (j < end && css[j] !== "{" && css[j] !== ";" && css[j] !== "}") {
65
+ if (css[j] === '"' || css[j] === "'") j = skipString(css, j);
66
+ else j++;
67
+ }
68
+ const prelude = css.slice(i, j).trim();
69
+ if (j >= end) return;
70
+ if (css[j] !== "{") {
71
+ i = j + 1;
72
+ continue;
73
+ }
74
+ const close = matchingBrace(css, j);
75
+ if (prelude.startsWith("@")) {
76
+ const name = /^@([\w-]+)/.exec(prelude)?.[1]?.toLowerCase() ?? "";
77
+ if (GROUPING_AT_RULES.has(name)) collectPreludes(css, j + 1, close, out);
78
+ } else if (prelude.length > 0) {
79
+ out.push(prelude);
80
+ }
81
+ i = close + 1;
82
+ }
83
+ }
84
+
85
+ /** Split a selector list on top-level commas (not inside `()` / `[]`). */
86
+ function splitSelectorList(prelude: string): string[] {
87
+ const out: string[] = [];
88
+ let depth = 0;
89
+ let from = 0;
90
+ for (let i = 0; i < prelude.length; i++) {
91
+ const c = prelude[i];
92
+ if (c === "(" || c === "[") depth++;
93
+ else if (c === ")" || c === "]") depth--;
94
+ else if (c === "," && depth === 0) {
95
+ out.push(prelude.slice(from, i));
96
+ from = i + 1;
97
+ }
98
+ }
99
+ out.push(prelude.slice(from));
100
+ return out.map((s) => s.trim().replace(/\s+/g, " ")).filter((s) => s.length > 0);
101
+ }
102
+
103
+ /** The selector with every `(…)` argument removed (`:not(.x)` does not scope `h1:not(.x)`). */
104
+ function withoutArguments(selector: string): string {
105
+ let out = "";
106
+ let depth = 0;
107
+ for (const c of selector) {
108
+ if (c === "(") depth++;
109
+ else if (c === ")") depth--;
110
+ else if (depth === 0) out += c;
111
+ }
112
+ return out;
113
+ }
114
+
115
+ /**
116
+ * Index of the first character after leading whitespace and `<!-- -->`
117
+ * comments, or -1 when a comment is never closed. A scan, not a regex:
118
+ * `(?:<!--[\s\S]*?-->\s*)*` backtracks exponentially on `<!----><!--…`
119
+ * input (CodeQL js/redos), and module html is operator/AI supplied.
120
+ */
121
+ function skipLeadingHtmlComments(html: string): number {
122
+ let i = 0;
123
+ for (;;) {
124
+ while (i < html.length && /\s/.test(html.charAt(i))) i++;
125
+ if (!html.startsWith("<!--", i)) return i;
126
+ const end = html.indexOf("-->", i + 4);
127
+ if (end === -1) return -1;
128
+ i = end + 3;
129
+ }
130
+ }
131
+
132
+ /**
133
+ * The attribute text of the start tag at `at` (`""` for `<div>`), or null
134
+ * when no element starts there. Parsed by hand: the regex form
135
+ * `<name(\s[^>]*)?>` is quadratic on `<A\t<A\t…` input without a `>`
136
+ * (CodeQL js/polynomial-redos).
137
+ */
138
+ function openTagAttributes(html: string, at: number): string | null {
139
+ if (html.charAt(at) !== "<" || !/[a-zA-Z]/.test(html.charAt(at + 1))) return null;
140
+ let nameEnd = at + 2;
141
+ while (nameEnd < html.length && /[\w-]/.test(html.charAt(nameEnd))) nameEnd++;
142
+ const close = html.indexOf(">", nameEnd);
143
+ if (close === -1) return null;
144
+ const attrs = html.slice(nameEnd, close);
145
+ return attrs === "" || /^\s/.test(attrs) ? attrs : null;
146
+ }
147
+
148
+ /** Class and id tokens of the module's first element, or null when the html has no element. */
149
+ export function moduleRootTokens(html: string): { classes: string[]; id: string | null } | null {
150
+ const start = skipLeadingHtmlComments(html);
151
+ if (start === -1) return null;
152
+ const attrs = openTagAttributes(html, start);
153
+ if (attrs === null) return null;
154
+ const classAttr = /\sclass\s*=\s*(?:"([^"]*)"|'([^']*)')/.exec(attrs);
155
+ const classes = (classAttr?.[1] ?? classAttr?.[2] ?? "")
156
+ .split(/\s+/)
157
+ .filter((c) => c.length > 0 && !c.includes("{{"));
158
+ const idAttr = /\sid\s*=\s*(?:"([^"]*)"|'([^']*)')/.exec(attrs);
159
+ const id = idAttr?.[1] ?? idAttr?.[2] ?? null;
160
+ return { classes, id: id !== null && !id.includes("{{") ? id : null };
161
+ }
162
+
163
+ /**
164
+ * The CSS without its comments. An unterminated `/*` runs to the end of
165
+ * the input, as in the CSS syntax spec. A scan rather than a lazy regex,
166
+ * which is quadratic on `/*a/*a/*…` input (CodeQL js/polynomial-redos).
167
+ */
168
+ function stripCssComments(css: string): string {
169
+ let out = "";
170
+ let from = 0;
171
+ for (;;) {
172
+ const open = css.indexOf("/*", from);
173
+ if (open === -1) return out + css.slice(from);
174
+ out += css.slice(from, open);
175
+ const close = css.indexOf("*/", open + 2);
176
+ if (close === -1) return out;
177
+ from = close + 2;
178
+ }
179
+ }
180
+
181
+ /** What the scope check found in one module's CSS. */
182
+ export interface CssScopeFindings {
183
+ /** Selectors with no class, id or attribute at all (`h1`, `body p`, `*`). */
184
+ readonly global: string[];
185
+ /** Selectors that are not tied to the module's root class (needs the html). */
186
+ readonly unanchored: string[];
187
+ /** The module's root classes the CSS was checked against. */
188
+ readonly rootClasses: string[];
189
+ /** The module has CSS but its root element carries no class to scope under. */
190
+ readonly rootHasNoClass: boolean;
191
+ }
192
+
193
+ /**
194
+ * Check a module's CSS for selectors that bleed into other modules.
195
+ * Pass the module's `html` when the write carried it: the selectors are
196
+ * then also checked against the root element's classes. Prefixed class
197
+ * names count as scoped (`.hero__title`, `.hero-cta` under root `.hero`).
198
+ */
199
+ export function scanModuleCssScope(input: { css: string; html?: string }): CssScopeFindings {
200
+ const css = stripCssComments(input.css);
201
+ const preludes: string[] = [];
202
+ collectPreludes(css, 0, css.length, preludes);
203
+ const selectors = preludes.flatMap(splitSelectorList);
204
+
205
+ const root = input.html !== undefined ? moduleRootTokens(input.html) : null;
206
+ const rootClasses = root?.classes ?? [];
207
+ const anchorable = rootClasses.length > 0 || (root?.id ?? null) !== null;
208
+
209
+ const global: string[] = [];
210
+ const unanchored: string[] = [];
211
+ for (const selector of selectors) {
212
+ const bare = withoutArguments(selector);
213
+ if (!/[.#[]/.test(bare)) {
214
+ global.push(selector);
215
+ continue;
216
+ }
217
+ if (!anchorable) continue;
218
+ const classes = [...bare.matchAll(/\.(-?[_a-zA-Z][\w-]*)/g)].map((m) => m[1] ?? "");
219
+ const anchored =
220
+ classes.some((c) =>
221
+ rootClasses.some((r) => c === r || c.startsWith(`${r}-`) || c.startsWith(`${r}_`)),
222
+ ) ||
223
+ (root?.id != null && bare.includes(`#${root.id}`));
224
+ if (!anchored) unanchored.push(selector);
225
+ }
226
+ return {
227
+ global,
228
+ unanchored,
229
+ rootClasses,
230
+ rootHasNoClass: root !== null && !anchorable && selectors.length > 0,
231
+ };
232
+ }
233
+
234
+ function listSelectors(selectors: readonly string[]): string {
235
+ const shown = selectors.slice(0, MAX_SELECTORS_SHOWN).map((s) => `\`${s}\``);
236
+ const more = selectors.length - shown.length;
237
+ return more > 0 ? `${shown.join(", ")} (+${more} more)` : shown.join(", ");
238
+ }
239
+
240
+ /** The findings as one tool-result sentence group, or null when the CSS is scoped. */
241
+ export function formatCssScopeFindings(f: CssScopeFindings): string | null {
242
+ const scopeHint =
243
+ f.rootClasses.length > 0
244
+ ? `nest them under the module root \`.${f.rootClasses[0]}\` (e.g. \`.${f.rootClasses[0]} h2\`)`
245
+ : "nest them under the module's root class";
246
+ const parts: string[] = [];
247
+ if (f.global.length > 0) {
248
+ parts.push(
249
+ `module CSS selectors ${listSelectors(f.global)} style EVERY matching element on the page, including other modules' — ${scopeHint}`,
250
+ );
251
+ }
252
+ if (f.unanchored.length > 0) {
253
+ parts.push(
254
+ `module CSS selectors ${listSelectors(f.unanchored)} are not scoped to this module and can restyle other modules using the same class — ${scopeHint} or prefix the class with it`,
255
+ );
256
+ }
257
+ if (f.rootHasNoClass) {
258
+ parts.push(
259
+ 'the module\'s root element has no class to scope its CSS under — give it one (e.g. `<section class="pricing">`) and nest the module CSS under it',
260
+ );
261
+ }
262
+ return parts.length > 0 ? parts.join(". ") : null;
263
+ }
package/src/index.ts CHANGED
@@ -13,6 +13,7 @@ export * from "./cloud-shell-install.js";
13
13
  export * from "./content.js";
14
14
  export * from "./context.js";
15
15
  export * from "./css-gradient-scan.js";
16
+ export * from "./css-scope-scan.js";
16
17
  export * from "./css-var-scan.js";
17
18
  export * from "./database-url.js";
18
19
  export * from "./design-draft-shell.js";
@@ -68,6 +69,7 @@ export * from "./skills.js";
68
69
  export * from "./snapshots.js";
69
70
  export * from "./staging-gate.js";
70
71
  export * from "./static-cache-policy.js";
72
+ export * from "./static-security-headers.js";
71
73
  export * from "./strip-cdata.js";
72
74
  export * from "./structured-sets.js";
73
75
  export * from "./subagents.js";
@@ -0,0 +1,61 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ import { describe, expect, it } from "bun:test";
4
+ import {
5
+ findInteractionIds,
6
+ INTERACTION_MARKUP_CONTRACT,
7
+ INTERACTIONS,
8
+ interactionAssets,
9
+ } from "./interactions.js";
10
+
11
+ describe("findInteractionIds (issue #160)", () => {
12
+ it("finds kit ids in either quote style and reports unknown ones", () => {
13
+ const html =
14
+ "<div data-caelo-ui=\"tabs\"></div><section data-caelo-ui='carousel'></section>" +
15
+ '<div data-caelo-ui="tabs"></div><div data-caelo-ui="lightbox"></div>';
16
+ expect(findInteractionIds(html)).toEqual({
17
+ known: ["tabs", "carousel"],
18
+ unknown: ["lightbox"],
19
+ });
20
+ });
21
+
22
+ it("ignores the attribute text when it appears as escaped page content", () => {
23
+ expect(findInteractionIds("<p>use data-caelo-ui=&quot;tabs&quot;</p>").known).toEqual([]);
24
+ });
25
+
26
+ it("does not treat inherited object keys as kit ids", () => {
27
+ expect(findInteractionIds('<div data-caelo-ui="toString"></div>')).toEqual({
28
+ known: [],
29
+ unknown: ["toString"],
30
+ });
31
+ });
32
+ });
33
+
34
+ describe("interactionAssets (issue #160)", () => {
35
+ it("emits in registry order regardless of the order asked", () => {
36
+ const out = interactionAssets(new Set(["carousel", "accordion"] as const));
37
+ expect(out.js).toEqual([INTERACTIONS.accordion.js, INTERACTIONS.carousel.js]);
38
+ expect(out.css).toEqual([INTERACTIONS.accordion.css, INTERACTIONS.carousel.css]);
39
+ });
40
+ });
41
+
42
+ describe("the kit", () => {
43
+ it("documents every snippet's markup contract for the AI", () => {
44
+ for (const id of Object.keys(INTERACTIONS)) {
45
+ expect(INTERACTION_MARKUP_CONTRACT).toContain(`\`${id}\``);
46
+ }
47
+ });
48
+
49
+ it("carries no aesthetics: no color literals in any snippet's CSS", () => {
50
+ for (const { css } of Object.values(INTERACTIONS)) {
51
+ expect(css).not.toMatch(/#[0-9a-f]{3,8}\b|rgba?\(|hsla?\(|oklch\(/i);
52
+ }
53
+ });
54
+
55
+ it("binds by delegation so modules inserted after load still work", () => {
56
+ for (const { js } of Object.values(INTERACTIONS)) {
57
+ expect(js).toContain("document.addEventListener(");
58
+ expect(js).not.toContain("querySelectorAll('.caelo-nav-menu')");
59
+ }
60
+ });
61
+ });
@@ -5,19 +5,35 @@
5
5
  *
6
6
  * A header without a working mobile menu reads as broken, not minimal
7
7
  * — and hand-rolled per-module JS gets focus/aria handling wrong more
8
- * often than right. This module carries the FUNCTIONAL (non-aesthetic)
9
- * CSS + JS for the built-in nav renderer: hamburger toggle under the
10
- * mobile breakpoint, `aria-expanded` bookkeeping, escape-to-close.
11
- * All colors/spacing stay the module/theme's business — these rules
12
- * only govern show/hide behaviour, so they don't violate the
13
- * no-global-look re-scope (#151).
8
+ * often than right. This module carries a small kit of dependency-free
9
+ * interactions (mobile nav, accordion, tabs, carousel) the AI composes
10
+ * from by MARKUP alone: an element carrying `data-caelo-ui="<id>"` pulls
11
+ * that interaction's CSS + JS into the page. No module column, no
12
+ * declaration step — the attribute in the HTML is the declaration, so
13
+ * the AI cannot forget half of it.
14
14
  *
15
- * The composer includes each asset ONCE per page when at least one
16
- * nav-menu module rendered (natural dedup, same rule as #158).
15
+ * Every snippet is FUNCTIONAL only: show/hide, scroll-snap, focus and
16
+ * aria bookkeeping. Colors, spacing and typography stay the module /
17
+ * theme's business (`currentColor`, `inherit`), so the kit does not
18
+ * reintroduce a global look (#151 re-scope).
19
+ *
20
+ * Every snippet's JS uses event delegation on `document` instead of
21
+ * binding to elements at load: a module withheld behind a consent gate
22
+ * (`wrapDeferredModule`) enters the DOM only after its plugin clones it
23
+ * out of its `<template>`, long after page load, and must still work.
24
+ *
25
+ * The composer includes each used snippet ONCE per page, in registry
26
+ * order (natural dedup, same rule as #158).
17
27
  */
18
28
 
19
- /** Functional-only rules: collapse/expand mechanics, zero aesthetics. */
20
- export const NAV_FUNCTIONAL_CSS =
29
+ /** One vetted interaction: its functional CSS and its behaviour JS. */
30
+ export interface InteractionSnippet {
31
+ readonly css: string;
32
+ readonly js: string;
33
+ }
34
+
35
+ /** Functional-only rules for the built-in nav renderer: collapse/expand, zero aesthetics. */
36
+ const MOBILE_NAV_CSS =
21
37
  ".caelo-nav-menu .caelo-nav-toggle{display:none;background:none;border:0;cursor:pointer;padding:0.5rem;color:inherit;font:inherit}" +
22
38
  ".caelo-nav-menu .caelo-nav-toggle .caelo-nav-bar{display:block;width:1.25em;height:2px;background:currentColor;margin:0.25em 0}" +
23
39
  "@media (max-width: 720px){" +
@@ -26,23 +42,162 @@ export const NAV_FUNCTIONAL_CSS =
26
42
  '.caelo-nav-menu[data-nav-open="true"] > ul{display:block}' +
27
43
  "}";
28
44
 
45
+ /** Toggle on click; Escape closes and returns focus to the toggle. */
46
+ const MOBILE_NAV_JS =
47
+ "(function(){" +
48
+ "var S='[data-caelo-ui=\"mobile-nav\"]';" +
49
+ "function set(nav,open){nav.setAttribute('data-nav-open',String(open));" +
50
+ "var b=nav.querySelector('.caelo-nav-toggle');if(b)b.setAttribute('aria-expanded',String(open));return b;}" +
51
+ "document.addEventListener('click',function(e){" +
52
+ "var t=e.target;if(!(t instanceof Element))return;" +
53
+ "var b=t.closest('.caelo-nav-toggle');if(!b)return;var nav=b.closest(S);if(!nav)return;" +
54
+ "set(nav,nav.getAttribute('data-nav-open')!=='true');});" +
55
+ "document.addEventListener('keydown',function(e){" +
56
+ "if(e.key!=='Escape')return;var t=e.target;if(!(t instanceof Element))return;" +
57
+ "var nav=t.closest(S+'[data-nav-open=\"true\"]');if(!nav)return;" +
58
+ "var b=set(nav,false);if(b)b.focus();});" +
59
+ "})();";
60
+
29
61
  /**
30
- * Toggle behaviour. Runs deferred; idempotent per element via the
31
- * data attribute; closes on Escape and returns focus to the toggle.
62
+ * Accordion = native `<details>`/`<summary>` (keyboard + screen-reader
63
+ * support for free). The snippet adds what the platform lacks: a
64
+ * container with `data-single` keeps one panel open, and a URL hash
65
+ * pointing into a panel opens it (deep links to an FAQ answer).
32
66
  */
33
- export const NAV_TOGGLE_JS =
67
+ const ACCORDION_CSS = ':where([data-caelo-ui="accordion"] summary){cursor:pointer}';
68
+
69
+ const ACCORDION_JS =
34
70
  "(function(){" +
35
- "document.querySelectorAll('.caelo-nav-menu').forEach(function(nav){" +
36
- "var btn=nav.querySelector('.caelo-nav-toggle');if(!btn)return;" +
37
- "btn.addEventListener('click',function(){" +
38
- "var open=nav.getAttribute('data-nav-open')==='true';" +
39
- "nav.setAttribute('data-nav-open',String(!open));" +
40
- "btn.setAttribute('aria-expanded',String(!open));" +
41
- "});" +
42
- "nav.addEventListener('keydown',function(e){" +
43
- "if(e.key==='Escape'&&nav.getAttribute('data-nav-open')==='true'){" +
44
- "nav.setAttribute('data-nav-open','false');" +
45
- "btn.setAttribute('aria-expanded','false');btn.focus();" +
46
- "}});" +
47
- "});" +
71
+ "var S='[data-caelo-ui=\"accordion\"]';" +
72
+ "document.addEventListener('toggle',function(e){" +
73
+ "var d=e.target;if(!(d instanceof HTMLDetailsElement)||!d.open)return;" +
74
+ "var acc=d.closest(S);if(!acc||!acc.hasAttribute('data-single'))return;" +
75
+ "acc.querySelectorAll('details[open]').forEach(function(o){if(o!==d&&o.closest(S)===acc)o.open=false;});" +
76
+ "},true);" +
77
+ "function openHash(){var id;try{id=decodeURIComponent(location.hash.slice(1));}catch(_){return;}if(!id)return;" +
78
+ "var el=document.getElementById(id);if(!el)return;var d=el.closest(S+' details');" +
79
+ "if(d&&!d.open){d.open=true;el.scrollIntoView();}}" +
80
+ "window.addEventListener('hashchange',openHash);" +
81
+ "if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',openHash);else openHash();" +
48
82
  "})();";
83
+
84
+ /**
85
+ * Tabs per the WAI-ARIA tabs pattern: `role="tablist"` of
86
+ * `role="tab"` buttons, each `aria-controls` its `role="tabpanel"`.
87
+ * Click or arrow keys / Home / End select (automatic activation) with a
88
+ * roving tabindex. The CSS keeps `hidden` panels hidden even when module
89
+ * CSS gives panels a `display`.
90
+ */
91
+ const TABS_CSS = '[data-caelo-ui="tabs"] [role="tabpanel"][hidden]{display:none}';
92
+
93
+ const TABS_JS =
94
+ "(function(){" +
95
+ "function tabsOf(list){return Array.prototype.filter.call(list.querySelectorAll('[role=\"tab\"]'),function(t){return t.closest('[role=\"tablist\"]')===list;});}" +
96
+ "function select(tab,focus){var list=tab.closest('[role=\"tablist\"]');if(!list)return;" +
97
+ "tabsOf(list).forEach(function(t){var on=t===tab;" +
98
+ "t.setAttribute('aria-selected',String(on));t.setAttribute('tabindex',on?'0':'-1');" +
99
+ "var p=document.getElementById(t.getAttribute('aria-controls')||'');if(p){if(on)p.removeAttribute('hidden');else p.setAttribute('hidden','');}});" +
100
+ "if(focus)tab.focus();}" +
101
+ "function tabAt(e){var t=e.target;if(!(t instanceof Element))return null;" +
102
+ "var tab=t.closest('[role=\"tab\"]');return tab&&tab.closest('[data-caelo-ui=\"tabs\"]')?tab:null;}" +
103
+ "document.addEventListener('click',function(e){var tab=tabAt(e);if(tab)select(tab,false);});" +
104
+ "document.addEventListener('keydown',function(e){var tab=tabAt(e);if(!tab)return;" +
105
+ "var list=tab.closest('[role=\"tablist\"]');if(!list)return;var all=tabsOf(list);var i=all.indexOf(tab);var n=-1;" +
106
+ "if(e.key==='ArrowRight'||e.key==='ArrowDown')n=(i+1)%all.length;" +
107
+ "else if(e.key==='ArrowLeft'||e.key==='ArrowUp')n=(i-1+all.length)%all.length;" +
108
+ "else if(e.key==='Home')n=0;else if(e.key==='End')n=all.length-1;" +
109
+ "if(n<0)return;e.preventDefault();select(all[n],true);});" +
110
+ "})();";
111
+
112
+ /**
113
+ * Carousel = a CSS scroll-snap track (swipe, trackpad and keyboard
114
+ * scrolling work without JS); the snippet only wires the optional
115
+ * previous / next buttons, honouring `prefers-reduced-motion`. The
116
+ * layout defaults sit in `:where()` (zero specificity): the snippet CSS
117
+ * is emitted after module CSS, and a module must still be able to set
118
+ * its own slide width or track display.
119
+ */
120
+ const CAROUSEL_CSS =
121
+ ':where([data-caelo-ui="carousel"] [data-caelo-carousel-track]){display:flex;overflow-x:auto;scroll-snap-type:x mandatory;scroll-behavior:smooth}' +
122
+ ':where([data-caelo-ui="carousel"] [data-caelo-carousel-track]>*){flex:0 0 100%;scroll-snap-align:start}' +
123
+ '@media (prefers-reduced-motion: reduce){[data-caelo-ui="carousel"] [data-caelo-carousel-track]{scroll-behavior:auto}}';
124
+
125
+ const CAROUSEL_JS =
126
+ "(function(){" +
127
+ "document.addEventListener('click',function(e){var t=e.target;if(!(t instanceof Element))return;" +
128
+ "var b=t.closest('[data-caelo-carousel-prev],[data-caelo-carousel-next]');if(!b)return;" +
129
+ "var c=b.closest('[data-caelo-ui=\"carousel\"]');if(!c)return;" +
130
+ "var track=c.querySelector('[data-caelo-carousel-track]');if(!track)return;" +
131
+ "var dir=b.hasAttribute('data-caelo-carousel-next')?1:-1;" +
132
+ "var reduce=window.matchMedia('(prefers-reduced-motion: reduce)').matches;" +
133
+ "track.scrollBy({left:dir*track.clientWidth,behavior:reduce?'auto':'smooth'});});" +
134
+ "})();";
135
+
136
+ /**
137
+ * The vetted kit, in emission order. Keys are the values of
138
+ * `data-caelo-ui`. Adding a snippet: add it here, document its markup
139
+ * contract in `INTERACTION_MARKUP_CONTRACT`, and add a composer test.
140
+ */
141
+ export const INTERACTIONS = {
142
+ "mobile-nav": { css: MOBILE_NAV_CSS, js: MOBILE_NAV_JS },
143
+ accordion: { css: ACCORDION_CSS, js: ACCORDION_JS },
144
+ tabs: { css: TABS_CSS, js: TABS_JS },
145
+ carousel: { css: CAROUSEL_CSS, js: CAROUSEL_JS },
146
+ } as const satisfies Record<string, InteractionSnippet>;
147
+
148
+ /** A `data-caelo-ui` value the kit implements. */
149
+ export type InteractionId = keyof typeof INTERACTIONS;
150
+
151
+ const INTERACTION_IDS = Object.keys(INTERACTIONS) as InteractionId[];
152
+
153
+ function isInteractionId(id: string): id is InteractionId {
154
+ return Object.hasOwn(INTERACTIONS, id);
155
+ }
156
+
157
+ // Matches the attribute only inside a tag: text content carrying the
158
+ // same characters is entity-escaped (`&quot;`) by the template engine,
159
+ // so it cannot produce a match.
160
+ const UI_ATTR_RE = /\sdata-caelo-ui\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
161
+
162
+ /** Every `data-caelo-ui` value in `html`, split into kit ids and unknown ones. */
163
+ export function findInteractionIds(html: string): {
164
+ known: InteractionId[];
165
+ unknown: string[];
166
+ } {
167
+ const known = new Set<InteractionId>();
168
+ const unknown = new Set<string>();
169
+ for (const m of html.matchAll(UI_ATTR_RE)) {
170
+ const id = (m[1] ?? m[2] ?? "").trim();
171
+ if (isInteractionId(id)) known.add(id);
172
+ else unknown.add(id);
173
+ }
174
+ return { known: [...known], unknown: [...unknown] };
175
+ }
176
+
177
+ /**
178
+ * The CSS and JS for the used interactions, each once, in registry order
179
+ * (stable output regardless of which module asked first).
180
+ */
181
+ export function interactionAssets(used: ReadonlySet<InteractionId>): {
182
+ css: string[];
183
+ js: string[];
184
+ } {
185
+ const ids = INTERACTION_IDS.filter((id) => used.has(id));
186
+ return {
187
+ css: ids.map((id) => INTERACTIONS[id].css),
188
+ js: ids.map((id) => INTERACTIONS[id].js),
189
+ };
190
+ }
191
+
192
+ /**
193
+ * The markup contract for each kit interaction, as the AI reads it in
194
+ * tool descriptions and the design-quality skill. One source so the
195
+ * guidance cannot drift from the snippets above.
196
+ */
197
+ export const INTERACTION_MARKUP_CONTRACT =
198
+ '**Vetted interactions - use these instead of writing JS for them.** Add `data-caelo-ui="<id>"` to the markup and Caelo ships accessible, dependency-free behaviour once per page; style it freely in the module CSS. ' +
199
+ '`accordion` (FAQ, collapsible sections): `<div data-caelo-ui="accordion" data-single>` containing `<details id="..."><summary>Question</summary><div>Answer</div></details>` items - `data-single` keeps one open; a link to `#<details id>` opens it. ' +
200
+ '`tabs`: `<div data-caelo-ui="tabs"><div role="tablist" aria-label="..."><button type="button" role="tab" id="t1" aria-controls="p1" aria-selected="true">...</button><button type="button" role="tab" id="t2" aria-controls="p2" aria-selected="false" tabindex="-1">...</button></div><div role="tabpanel" id="p1" aria-labelledby="t1">...</div><div role="tabpanel" id="p2" aria-labelledby="t2" hidden>...</div></div>` - ids must be unique on the page. ' +
201
+ '`carousel` (slider, testimonials): `<section data-caelo-ui="carousel" aria-roledescription="carousel" aria-label="..."><div data-caelo-carousel-track tabindex="0"><div role="group" aria-roledescription="slide" aria-label="1 of 3">...</div>...</div><button type="button" data-caelo-carousel-prev aria-label="Previous slide"><span aria-hidden="true">&lsaquo;</span></button><button type="button" data-caelo-carousel-next aria-label="Next slide"><span aria-hidden="true">&rsaquo;</span></button></section>` (an icon button keeps its visible content aria-hidden; a text button needs no aria-label) - one slide per view by default; set the slides\' `flex-basis` in module CSS to show more. ' +
202
+ "`mobile-nav` is applied by the built-in nav-menu renderer; never add it by hand. " +
203
+ "Write module JS only for behaviour the kit does not cover.";