@caelo-cms/shared 0.10.61 → 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.
- package/dist/auto-update.d.ts +15 -0
- package/dist/auto-update.d.ts.map +1 -1
- package/dist/auto-update.js +27 -0
- package/dist/auto-update.js.map +1 -1
- package/dist/cloud-shell-install.d.ts +42 -10
- package/dist/cloud-shell-install.d.ts.map +1 -1
- package/dist/cloud-shell-install.js +59 -0
- package/dist/cloud-shell-install.js.map +1 -1
- package/dist/css-scope-scan.d.ts +29 -0
- package/dist/css-scope-scan.d.ts.map +1 -0
- package/dist/css-scope-scan.js +251 -0
- package/dist/css-scope-scan.js.map +1 -0
- package/dist/font-assets.d.ts +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/interactions.d.ts +65 -13
- package/dist/interactions.d.ts.map +1 -1
- package/dist/interactions.js +132 -33
- package/dist/interactions.js.map +1 -1
- package/dist/preview-compose.d.ts.map +1 -1
- package/dist/preview-compose.js +15 -19
- package/dist/preview-compose.js.map +1 -1
- package/dist/release-manifest.d.ts +3 -0
- package/dist/release-manifest.d.ts.map +1 -1
- package/dist/release-manifest.js +11 -0
- package/dist/release-manifest.js.map +1 -1
- package/dist/static-security-headers.d.ts +50 -0
- package/dist/static-security-headers.d.ts.map +1 -0
- package/dist/static-security-headers.js +58 -0
- package/dist/static-security-headers.js.map +1 -0
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/auto-update.test.ts +25 -0
- package/src/auto-update.ts +36 -0
- package/src/cloud-shell-install.test.ts +36 -1
- package/src/cloud-shell-install.ts +64 -0
- package/src/css-scope-scan.test.ts +117 -0
- package/src/css-scope-scan.ts +263 -0
- package/src/index.ts +2 -0
- package/src/interactions.test.ts +61 -0
- package/src/interactions.ts +181 -26
- package/src/preview-compose.test.ts +87 -1
- package/src/preview-compose.ts +16 -21
- package/src/release-manifest.ts +12 -0
- package/src/static-security-headers.test.ts +60 -0
- package/src/static-security-headers.ts +64 -0
- 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="tabs"</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
|
+
});
|
package/src/interactions.ts
CHANGED
|
@@ -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
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
*
|
|
16
|
-
*
|
|
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
|
-
/**
|
|
20
|
-
export
|
|
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
|
-
*
|
|
31
|
-
*
|
|
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
|
-
|
|
67
|
+
const ACCORDION_CSS = ':where([data-caelo-ui="accordion"] summary){cursor:pointer}';
|
|
68
|
+
|
|
69
|
+
const ACCORDION_JS =
|
|
34
70
|
"(function(){" +
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
"var
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"});" +
|
|
42
|
-
"
|
|
43
|
-
"if(
|
|
44
|
-
"
|
|
45
|
-
"
|
|
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 (`"`) 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">‹</span></button><button type="button" data-caelo-carousel-next aria-label="Next slide"><span aria-hidden="true">›</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.";
|