@port60/template-kit 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -1
- package/package.json +1 -1
- package/src/commands/create.mjs +11 -0
- package/src/lib/agentsMd.mjs +176 -0
- package/src/vendor/contract/v2/dialect.json +2 -1
- package/src/vendor/contract/v2/islands.json +3 -2
- package/src/vendor/contract/v2/manifest.schema.json +73 -2
- package/src/vendor/contract/v2/presentation.json +107 -0
- package/src/vendor/contract/v2/sections.json +120 -4
- package/src/vendor/contract/v2.lock.json +562 -9
- package/src/vendor/engine/budgets.mjs +0 -6
- package/src/vendor/engine/colour-roles.mjs +68 -0
- package/src/vendor/engine/content-footprint.mjs +0 -11
- package/src/vendor/engine/design-settings.mjs +34 -0
- package/src/vendor/engine/dialect.mjs +3 -10
- package/src/vendor/engine/locale.mjs +0 -5
- package/src/vendor/engine/majors.mjs +0 -5
- package/src/vendor/engine/presentation-capabilities.mjs +0 -2
- package/src/vendor/engine/section-fields.mjs +64 -0
- package/src/vendor/engine/section-heading-alignment.mjs +0 -2
- package/src/vendor/engine/section-presentation.mjs +127 -0
- package/src/vendor/validator/behaviors-runtime.js +1 -1
- package/src/vendor/validator/collection-link-visibility.mjs +0 -1
- package/src/vendor/validator/colour-treatment.mjs +154 -0
- package/src/vendor/validator/fixture-art.mjs +0 -15
- package/src/vendor/validator/focus.mjs +0 -5
- package/src/vendor/validator/fonts.mjs +0 -6
- package/src/vendor/validator/heading-alignment.mjs +0 -3
- package/src/vendor/validator/icons.mjs +0 -4
- package/src/vendor/validator/image-overlay.mjs +141 -0
- package/src/vendor/validator/intro-photo-framing.mjs +101 -0
- package/src/vendor/validator/model-reference-v2.mjs +0 -4
- package/src/vendor/validator/model-reference.mjs +0 -4
- package/src/vendor/validator/navigation-highlights.mjs +0 -3
- package/src/vendor/validator/platform-base.css +23 -0
- package/src/vendor/validator/presentation-proof.mjs +0 -2
- package/src/vendor/validator/preview-v1.mjs +3 -103
- package/src/vendor/validator/preview-v2.mjs +12 -105
- package/src/vendor/validator/section-fields.mjs +41 -0
- package/src/vendor/validator/section-layout.mjs +156 -0
- package/src/vendor/validator/section-presentation.mjs +225 -0
- package/src/vendor/validator/site-context-v2.mjs +17 -2
- package/src/vendor/validator/site-context.mjs +0 -14
- package/src/vendor/validator/validate-v1.mjs +4 -98
- package/src/vendor/validator/validate-v2.mjs +44 -103
- package/src/vendor/validator/validate.mjs +0 -2
- package/starter/assets/theme.css +55 -0
- package/starter/manifest.json +20 -0
- package/starter/sections/cta.liquid +2 -2
- package/starter/sections/hero.liquid +5 -3
- package/starter/sections/homeHero.liquid +6 -6
- package/starter/sections/values.liquid +2 -2
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { SECTION_PRESENTATION_CONTROLS, SECTION_ROOT_TAGS } from '../engine/section-presentation.mjs';
|
|
2
|
+
import { parsePresentationMarkup, presentationSignature, presentationVisible, presentationText } from './presentation-proof.mjs';
|
|
3
|
+
|
|
4
|
+
const ATTRIBUTE = SECTION_PRESENTATION_CONTROLS.colourTreatment.attribute;
|
|
5
|
+
const ROLE = 'data-p60-colour-role';
|
|
6
|
+
const ROLES = ['text', 'muted', 'accent', 'surface', 'border', 'link', 'button', 'secondary-button'];
|
|
7
|
+
const ACTIONS = new Set(['link', 'button', 'secondary-button']);
|
|
8
|
+
const PROPERTIES = new Set(['color', 'background-color', 'border-color', 'outline-color', 'text-decoration-color']);
|
|
9
|
+
const ROOT = '(?:[a-z][\\w-]*)?(?:\\.[\\w-]+)*';
|
|
10
|
+
const TOKEN = '\\[data-p60-colour-treatment\\s*=\\s*(?:"(standard|soft|contrast)"|\'(standard|soft|contrast)\'|(standard|soft|contrast))\\s*\\]';
|
|
11
|
+
const OWNED = '\\[data-p60-colour-role\\s*=\\s*(?:"([\\w-]+)"|\'([\\w-]+)\'|([\\w-]+))\\s*\\]';
|
|
12
|
+
const SELECTOR = new RegExp(`^${ROOT}${TOKEN}(?:\\s+${OWNED}(?::(hover|focus-visible|active))?)?$`);
|
|
13
|
+
const cssIdentifier = value => value.replace(/\\([\da-f]{1,6})\s?|\\([^\r\n])/gi, (_, hex, character) =>
|
|
14
|
+
hex ? String.fromCodePoint(Math.min(Number.parseInt(hex, 16), 0x10ffff)) : character);
|
|
15
|
+
const mentionsTreatment = value => cssIdentifier(value).toLowerCase().includes(ATTRIBUTE);
|
|
16
|
+
|
|
17
|
+
function hasNestedTreatmentRule(css) {
|
|
18
|
+
const scopes = [];
|
|
19
|
+
let start = 0, quote = null;
|
|
20
|
+
for (let index = 0; index < css.length; index++) {
|
|
21
|
+
const character = css[index];
|
|
22
|
+
if (character === '\\') { index++; continue; }
|
|
23
|
+
if (quote) { if (character === quote) quote = null; continue; }
|
|
24
|
+
if (character === '"' || character === "'") { quote = character; continue; }
|
|
25
|
+
if (character === '{') {
|
|
26
|
+
if (scopes.some(Boolean)) return true;
|
|
27
|
+
scopes.push(mentionsTreatment(css.slice(start, index)));
|
|
28
|
+
start = index + 1;
|
|
29
|
+
} else if (character === '}') {
|
|
30
|
+
scopes.pop();
|
|
31
|
+
start = index + 1;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Deliberately small paint-only grammar. Authored palette values never become stored CSS. */
|
|
38
|
+
export function colourTreatmentRules(css) {
|
|
39
|
+
const rules = [], errors = [];
|
|
40
|
+
const clean = css.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
41
|
+
if (hasNestedTreatmentRule(clean)) errors.push('colourTreatment rules must use explicit flat scoped selectors; nested rules and nested at-rules are not supported');
|
|
42
|
+
for (const [, selectors, body] of clean.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
|
|
43
|
+
// HTML attribute names match case-insensitively. Decode only for discovery so alternative
|
|
44
|
+
// casing/escapes cannot hide unsafe declarations; the canonical author grammar still rejects them.
|
|
45
|
+
if (!mentionsTreatment(selectors)) continue;
|
|
46
|
+
const declarations = new Map();
|
|
47
|
+
for (const part of body.split(';').map(value => value.trim()).filter(Boolean)) {
|
|
48
|
+
const match = /^([a-z-]+)\s*:\s*(.+)$/i.exec(part);
|
|
49
|
+
if (match && cssIdentifier(match[2]).includes('!')) {
|
|
50
|
+
errors.push('colourTreatment paint must not use !important or other priority overrides; retain the authored cascade');
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (!match || !PROPERTIES.has(match[1].toLowerCase())
|
|
54
|
+
|| /(?:url|image-set|attr|expression)\s*\(|[{}@]/i.test(match[2])) {
|
|
55
|
+
errors.push('colourTreatment CSS may change only colour, background-colour, border-colour, outline-colour and text-decoration-colour; no palette rebinding, images, layout, opacity or filters');
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
declarations.set(match[1].toLowerCase(), match[2].trim());
|
|
59
|
+
}
|
|
60
|
+
if (!declarations.size) errors.push('colourTreatment needs effective paint declarations, not only a changing marker');
|
|
61
|
+
for (const selector of selectors.split(',')) {
|
|
62
|
+
const matched = SELECTOR.exec(selector.trim());
|
|
63
|
+
const token = matched?.[1] ?? matched?.[2] ?? matched?.[3];
|
|
64
|
+
const role = matched?.[4] ?? matched?.[5] ?? matched?.[6] ?? null;
|
|
65
|
+
if (!matched || role !== null && !ROLES.includes(role)) {
|
|
66
|
+
errors.push('colourTreatment CSS must target its exact natural root token or a documented marked colour role inside that root, with optional hover/focus-visible/active');
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
rules.push({ token, role, state: matched[7] ?? null, root: selector.trim().split('[')[0], declarations });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { rules, errors };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function coverage(rules, token, role, state = null) {
|
|
76
|
+
return new Map(rules.filter(rule => rule.token === token && rule.role === role && rule.state === state)
|
|
77
|
+
.flatMap(rule => [...rule.declarations]));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function paintErrors(rules, declaration) {
|
|
81
|
+
const errors = [];
|
|
82
|
+
const options = declaration?.options ?? [];
|
|
83
|
+
const signatures = new Set();
|
|
84
|
+
for (const rule of rules) {
|
|
85
|
+
if (rule.token === declaration?.default) errors.push('colourTreatment default must keep inherited CSS exactly; do not author a token override for the default');
|
|
86
|
+
}
|
|
87
|
+
for (const token of options.filter(option => option !== declaration.default)) {
|
|
88
|
+
const paint = coverage(rules, token, null);
|
|
89
|
+
if (!paint.has('background-color') || !paint.has('color') || /^(?:transparent|inherit|initial|unset)$/i.test(paint.get('background-color') ?? '')) {
|
|
90
|
+
errors.push(`colourTreatment '${token}' must coordinate an authored root background-color and color`);
|
|
91
|
+
}
|
|
92
|
+
const signature = JSON.stringify(rules.filter(rule => rule.token === token).map(rule =>
|
|
93
|
+
[rule.role, rule.state, [...rule.declarations].sort(([a], [b]) => a.localeCompare(b))]).sort());
|
|
94
|
+
if (signatures.has(signature)) errors.push('colourTreatment options must not repeat identical paint rules');
|
|
95
|
+
signatures.add(signature);
|
|
96
|
+
}
|
|
97
|
+
return [...new Set(errors)];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function colourTreatmentCssErrors(css, declaration) {
|
|
101
|
+
const { rules, errors } = colourTreatmentRules(css);
|
|
102
|
+
if (declaration?.options?.length && !rules.length) errors.push('colourTreatment needs authored coordinated paint rules');
|
|
103
|
+
// A stylesheet covers several sections with independent choices/defaults. The render proof
|
|
104
|
+
// below scopes semantic coverage to the actual root instead of borrowing another section's CSS.
|
|
105
|
+
return [...new Set(errors)];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function matchesRoot(node, selector) {
|
|
109
|
+
const tag = /^[a-z][\w-]*/i.exec(selector)?.[0];
|
|
110
|
+
const classes = [...selector.matchAll(/\.([\w-]+)/g)].map(match => match[1]);
|
|
111
|
+
return (!tag || node.tag === tag.toLowerCase()) && classes.every(name => (node.attrs.class ?? '').split(/\s+/).includes(name));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Marked roles identify authored content, not arbitrary selectors into a platform island. */
|
|
115
|
+
export function inspectColourTreatment(html, expected, required, css, declaration) {
|
|
116
|
+
const parsed = parsePresentationMarkup(html);
|
|
117
|
+
const roots = parsed.root.children.filter(node => typeof node !== 'string');
|
|
118
|
+
const hooks = parsed.nodes.filter(node => Object.hasOwn(node.attrs, ATTRIBUTE));
|
|
119
|
+
const roles = parsed.nodes.filter(node => Object.hasOwn(node.attrs, ROLE));
|
|
120
|
+
const errors = [];
|
|
121
|
+
const entirelyEmpty = !roots.length && !presentationText(parsed.root);
|
|
122
|
+
if (required && !entirelyEmpty && hooks.length !== 1) errors.push('one permanent data-p60-colour-treatment hook is required on the natural outer section root');
|
|
123
|
+
for (const hook of hooks) {
|
|
124
|
+
if (roots.length !== 1 || hook !== roots[0] || !SECTION_ROOT_TAGS.includes(hook.tag) || !presentationVisible(hook)
|
|
125
|
+
|| parsed.root.children.some(node => typeof node === 'string' && node.trim())) errors.push('colourTreatment must target the single visible natural outer section root');
|
|
126
|
+
if (hook.attrs[ATTRIBUTE] !== (expected ?? '')) errors.push('colourTreatment must render its exact accepted token or an empty inherited hook');
|
|
127
|
+
}
|
|
128
|
+
if (required && (parsed.nodes.some(node => node.tag === 'astro-island' || Object.hasOwn(node.attrs, 'data-p60-island'))
|
|
129
|
+
|| /\u0000P60_ISLAND:/.test(html))) errors.push('colourTreatment does not support sections containing platform islands; keep their palette and state untouched');
|
|
130
|
+
const rules = colourTreatmentRules(css).rules.filter(rule => hooks.length === 1 && matchesRoot(hooks[0], rule.root));
|
|
131
|
+
if (required && !entirelyEmpty) errors.push(...paintErrors(rules, declaration));
|
|
132
|
+
for (const node of roles) {
|
|
133
|
+
const role = node.attrs[ROLE];
|
|
134
|
+
if (!ROLES.includes(role) || hooks.length !== 1 || node === hooks[0] || ['img', 'picture', 'source', 'video', 'canvas', 'astro-island'].includes(node.tag)) {
|
|
135
|
+
errors.push('data-p60-colour-role must mark an owned content descendant with a documented role, never an image or island');
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (ACTIONS.has(role) && !['a', 'button', 'summary'].includes(node.tag)) errors.push(`colourTreatment '${role}' must mark an actual link or button`);
|
|
139
|
+
if (!expected || expected === declaration?.default || !required) continue;
|
|
140
|
+
const paint = coverage(rules, expected, role);
|
|
141
|
+
const properties = role === 'surface' || role === 'button' || role === 'secondary-button'
|
|
142
|
+
? ['background-color', 'color'] : role === 'border' ? ['border-color'] : ['color'];
|
|
143
|
+
if (properties.some(property => !paint.has(property))) errors.push(`colourTreatment '${expected}' must coordinate the rendered '${role}' role (${properties.join(', ')})`);
|
|
144
|
+
if (ACTIONS.has(role)) for (const state of ['hover', 'focus-visible']) {
|
|
145
|
+
if (!coverage(rules, expected, role, state).size) errors.push(`colourTreatment '${expected}' needs an authored ${state} treatment for '${role}'`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (required && presentationText(parsed.root) && !roles.some(node =>
|
|
149
|
+
['text', 'muted', 'accent', 'link', 'button', 'secondary-button'].includes(node.attrs[ROLE])
|
|
150
|
+
&& presentationVisible(node) && presentationText(node))) {
|
|
151
|
+
errors.push('colourTreatment needs a marked visible copy role, not a background-only control');
|
|
152
|
+
}
|
|
153
|
+
return { errors: [...new Set(errors)], signature: presentationSignature(parsed.root, () => [ATTRIBUTE]) };
|
|
154
|
+
}
|
|
@@ -1,14 +1,3 @@
|
|
|
1
|
-
// FIXTURE ART, the studio's sealed imagery. Fixtures reference imagery as `p60fixture:<kind>/<seed>`
|
|
2
|
-
// and this module resolves every reference before a render: by default to a deterministic inline-SVG
|
|
3
|
-
// scene (a data: URI, so the network-dead studio CSP renders it), or, when the caller passes an
|
|
4
|
-
// `imageBase`, to `<imageBase>/<kind>-<seed>.jpg`, the dev-richer half of the dev-richer /
|
|
5
|
-
// studio-sealed split (the same shape as the behaviour runtime: the kit's dev server may opt into
|
|
6
|
-
// the one platform CDN host; the studio document never fetches anything).
|
|
7
|
-
//
|
|
8
|
-
// The art is deliberately scenery, not labels: layered gradients, a horizon, soft shapes, enough
|
|
9
|
-
// composition that cards, carousels and banners read like a real site rather than a wireframe.
|
|
10
|
-
// Deterministic by seed so goldens stay byte-identical run to run.
|
|
11
|
-
|
|
12
1
|
const PALETTES = {
|
|
13
2
|
hero: [['#27413a', '#0f2b22', '#c9a961'], ['#1f3550', '#0c1a2c', '#d08c4a'], ['#3c2f42', '#191423', '#c96f6f']],
|
|
14
3
|
event: [['#2c2440', '#141024', '#e0a458'], ['#402438', '#1f0f1c', '#d97f6a'], ['#1f3345', '#0d1a26', '#7fb3d9']],
|
|
@@ -23,8 +12,6 @@ const PALETTES = {
|
|
|
23
12
|
|
|
24
13
|
const SIZES = { wide: [160, 90], photo: [150, 100], portrait: [96, 120] };
|
|
25
14
|
|
|
26
|
-
// A tiny deterministic PRNG seeded from the reference string, good enough for composition,
|
|
27
|
-
// stable across runs and platforms.
|
|
28
15
|
function rng(seedText) {
|
|
29
16
|
let h = 2166136261;
|
|
30
17
|
for (let i = 0; i < seedText.length; i += 1) {
|
|
@@ -43,7 +30,6 @@ function scene(kind, seed, aspect) {
|
|
|
43
30
|
const set = PALETTES[kind] ?? PALETTES.hero;
|
|
44
31
|
const [top, bottom, accent] = set[Math.floor(random() * set.length)];
|
|
45
32
|
|
|
46
|
-
// Horizon line + two hill paths, varied by seed.
|
|
47
33
|
const horizon = hgt * (0.52 + random() * 0.2);
|
|
48
34
|
const hill = (base, amp, fill, opacity) => {
|
|
49
35
|
const y1 = base - amp * random();
|
|
@@ -52,7 +38,6 @@ function scene(kind, seed, aspect) {
|
|
|
52
38
|
return `<path d='M0 ${base.toFixed(1)} C ${w * 0.2} ${y1.toFixed(1)}, ${w * 0.4} ${y2.toFixed(1)}, ${w * 0.6} ${y3.toFixed(1)} S ${w} ${y1.toFixed(1)}, ${w} ${base.toFixed(1)} L ${w} ${hgt} L 0 ${hgt} Z' fill='${fill}' opacity='${opacity}'/>`;
|
|
53
39
|
};
|
|
54
40
|
|
|
55
|
-
// A sun/moon disc and a small cluster of soft foreground shapes.
|
|
56
41
|
const discX = w * (0.15 + random() * 0.7);
|
|
57
42
|
const discY = horizon * (0.35 + random() * 0.4);
|
|
58
43
|
const discR = 6 + random() * (w / 14);
|
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
// The site focus for the PREVIEW: a JavaScript twin of charity-site's lib/focus.ts, kept in step by
|
|
2
|
-
// hand (the validator is plain JS and is vendored into the kit). Same rules: the leading action is
|
|
3
|
-
// the widget in the hero; the other action is the one button in the header and beside the hero; the
|
|
4
|
-
// leading action never appears twice. The preview assumes volunteering is OPEN, so the pairing shows.
|
|
5
1
|
export const FOCUS_CHOICES = ['donate', 'volunteer', 'none'];
|
|
6
2
|
|
|
7
3
|
const ACTION = {
|
|
@@ -20,7 +16,6 @@ export function previewActions(focus) {
|
|
|
20
16
|
return { primary: ACTION.volunteer, secondary: ACTION.donate, widget: 'volunteer', header: ACTION.donate, hero: ACTION.donate };
|
|
21
17
|
}
|
|
22
18
|
if (focus === 'none') {
|
|
23
|
-
// Buttons only (a Custom setting with no widget): the hero button leads with giving.
|
|
24
19
|
return { primary: ACTION.donate, secondary: ACTION.volunteer, widget: null, header: ACTION.donate, hero: ACTION.donate };
|
|
25
20
|
}
|
|
26
21
|
return { primary: ACTION.donate, secondary: ACTION.volunteer, widget: 'donate', header: ACTION.volunteer, hero: ACTION.volunteer };
|
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
// Font-slot resolution for the DEV preview, the same rules as the engine's fonts.ts: the template
|
|
2
|
-
// declares font knobs (slot, default family, the weights its type system uses); a chosen family
|
|
3
|
-
// becomes a --p60s-<key> variable with an honest fallback stack and ONE stylesheet URL for every
|
|
4
|
-
// chosen family at the slot's weights, clamped to the family's real weights. Served from the
|
|
5
|
-
// catalogue's provider (Bunny Fonts, the privacy-first mirror of the Google catalogue).
|
|
6
1
|
import catalogue from '../contract/v1/fonts.json' with { type: 'json' };
|
|
7
2
|
|
|
8
3
|
const byName = new Map(catalogue.families.map((f) => [f.name, f]));
|
|
@@ -44,7 +39,6 @@ export function fontCssHref(slots) {
|
|
|
44
39
|
const weightsByFamily = new Map();
|
|
45
40
|
for (const slot of slots) {
|
|
46
41
|
const family = byName.get(slot.family);
|
|
47
|
-
// A platform-hosted family is declared in global.css (served from /fonts/), never fetched.
|
|
48
42
|
if (!family || family.hosted) continue;
|
|
49
43
|
const set = weightsByFamily.get(family.name) ?? new Set();
|
|
50
44
|
const asked = slot.weights?.length > 0 ? slot.weights : [400, 700];
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
// This is a structural render proof, not a CSS audit. The platform's desktop-only rule
|
|
2
|
-
// aligns the marked heading; authors still check every Look at desktop/mobile and in RTL.
|
|
3
1
|
import { HEADING_ALIGNMENTS } from '../engine/section-heading-alignment.mjs';
|
|
4
2
|
|
|
5
3
|
const ATTRIBUTE = 'data-p60-heading-align';
|
|
@@ -27,7 +25,6 @@ function markup(html) {
|
|
|
27
25
|
const attrs = {};
|
|
28
26
|
for (const attribute of token.slice(open[0].length, -1).matchAll(/([^\s=/>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g)) {
|
|
29
27
|
const name = attribute[1].toLowerCase();
|
|
30
|
-
// HTML keeps the first duplicate attribute; do not prove a later value the browser ignores.
|
|
31
28
|
if (!Object.hasOwn(attrs, name)) attrs[name] = decodeEntities(attribute[2] ?? attribute[3] ?? attribute[4] ?? '');
|
|
32
29
|
}
|
|
33
30
|
const node = { tag: open[1].toLowerCase(), attrs, children: [], parent: stack.at(-1) };
|
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
// Icon resolution for the preview, the same rule as the engine's icons.ts: content carries a
|
|
2
|
-
// Lucide icon NAME ("heart-handshake"), the platform supplies `iconSvg` on items[] entries, and
|
|
3
|
-
// the template renders it through the dialect's `raw` filter. Unknown names resolve to '' so a
|
|
4
|
-
// template's `{% if item.iconSvg %}` branch falls back to the plain name, exactly as it does live.
|
|
5
1
|
import * as lucide from 'lucide-static';
|
|
6
2
|
|
|
7
3
|
const table = lucide;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { SECTION_PRESENTATION_CONTROLS, SECTION_ROOT_TAGS } from '../engine/section-presentation.mjs';
|
|
2
|
+
import { parsePresentationMarkup, presentationSignature, presentationText } from './presentation-proof.mjs';
|
|
3
|
+
|
|
4
|
+
const ATTRIBUTE = SECTION_PRESENTATION_CONTROLS.imageOverlay.attribute;
|
|
5
|
+
const MEDIA = 'data-p60-image-overlay-media';
|
|
6
|
+
const LAYER = 'data-p60-image-overlay-layer';
|
|
7
|
+
const ISLAND = '\u0000P60_ISLAND:hero_carousel\u0000';
|
|
8
|
+
const descendants = node => [node, ...node.children.flatMap(child => typeof child === 'string' ? [] : descendants(child))];
|
|
9
|
+
const contains = (parent, node) => descendants(parent).includes(node);
|
|
10
|
+
const overlayRules = css => [...css.replace(/\/\*[\s\S]*?\*\//g, '').matchAll(/([^{}]+)\{([^{}]*)\}/g)]
|
|
11
|
+
.filter(([, selectors]) => /\[data-p60-image-overlay(?:\s*=|\])/.test(selectors));
|
|
12
|
+
|
|
13
|
+
function matchesCompound(node, selector) {
|
|
14
|
+
const parts = selector.match(/\[[^\]]+\]|[.#][\w-]+|^[\w-]+/g) ?? [];
|
|
15
|
+
if (!parts.length || parts.join('') !== selector) return false;
|
|
16
|
+
return parts.every(part => {
|
|
17
|
+
if (part[0] === '.') return (node.attrs.class ?? '').split(/\s+/).includes(part.slice(1));
|
|
18
|
+
if (part[0] === '#') return node.attrs.id === part.slice(1);
|
|
19
|
+
if (part[0] !== '[') return node.tag === part.toLowerCase();
|
|
20
|
+
const match = /^\[([\w-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\]\s]+)))?\]$/.exec(part);
|
|
21
|
+
if (!match || !Object.hasOwn(node.attrs, match[1])) return false;
|
|
22
|
+
const expected = match[2] ?? match[3] ?? match[4];
|
|
23
|
+
return expected === undefined || node.attrs[match[1]] === expected;
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Small deliberate descendant/child grammar matching the bounded author selector contract. */
|
|
28
|
+
function matchesSelector(node, selector) {
|
|
29
|
+
const parts = selector.match(/(?:\[[^\]]+\]|[^\s>])+|>/g) ?? [];
|
|
30
|
+
const match = (current, index) => {
|
|
31
|
+
if (!current || !matchesCompound(current, parts[index])) return false;
|
|
32
|
+
if (index === 0) return true;
|
|
33
|
+
if (parts[index - 1] === '>') return match(current.parent, index - 2);
|
|
34
|
+
for (let parent = current.parent; parent; parent = parent.parent) if (match(parent, index - 1)) return true;
|
|
35
|
+
return false;
|
|
36
|
+
};
|
|
37
|
+
return parts.length > 0 && match(node, parts.length - 1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function mediaHasRule(owner, rules) {
|
|
41
|
+
const descendantsInMedia = descendants(owner);
|
|
42
|
+
const candidates = descendantsInMedia.filter(node => Object.hasOwn(node.attrs, LAYER));
|
|
43
|
+
// The real platform island supplies this documented empty class after the fragment renders.
|
|
44
|
+
if (presentationText(owner).includes(ISLAND)) candidates.push({ tag: 'span', attrs: { class: 'hero-slide-scrim' }, parent: owner, children: [] });
|
|
45
|
+
return rules.some(([, selectors]) => selectors.split(',').some(selector => {
|
|
46
|
+
const match = /::(?:before|after)\s*$/.exec(selector);
|
|
47
|
+
if (match) return matchesSelector(owner, selector.slice(0, match.index).trim());
|
|
48
|
+
return candidates.some(node => matchesSelector(node, selector.trim()));
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The token belongs to the existing hero; treatment markers belong only to its photographs. */
|
|
53
|
+
export function inspectImageOverlay(html, expected, required, hasImages, css = '', declaration) {
|
|
54
|
+
const parsed = parsePresentationMarkup(html);
|
|
55
|
+
const roots = parsed.root.children.filter(child => typeof child !== 'string');
|
|
56
|
+
const hooks = parsed.nodes.filter(node => Object.hasOwn(node.attrs, ATTRIBUTE));
|
|
57
|
+
const media = parsed.nodes.filter(node => Object.hasOwn(node.attrs, MEDIA));
|
|
58
|
+
const errors = [];
|
|
59
|
+
if (required && hooks.length !== 1) errors.push('one permanent data-p60-image-overlay hook is required on the existing first outer hero root');
|
|
60
|
+
for (const hook of hooks) {
|
|
61
|
+
if (hook !== roots[0] || !SECTION_ROOT_TAGS.includes(hook.tag) || hook.parent !== parsed.root) errors.push('imageOverlay must target the existing first outer hero root, not an image, heading or island');
|
|
62
|
+
if (hook.attrs[ATTRIBUTE] !== (expected ?? '')) errors.push('imageOverlay must render its exact accepted token, or an empty inherited hook');
|
|
63
|
+
}
|
|
64
|
+
if (required && hasImages && !media.length) errors.push('imageOverlay needs an existing photo-only data-p60-image-overlay-media wrapper in every image branch');
|
|
65
|
+
for (const owner of media) {
|
|
66
|
+
const nodes = descendants(owner);
|
|
67
|
+
const text = presentationText(owner);
|
|
68
|
+
const hasIsland = text.includes(ISLAND);
|
|
69
|
+
if (!['div', 'figure', 'span'].includes(owner.tag) || hooks.length !== 1 || !contains(hooks[0], owner)
|
|
70
|
+
|| !nodes.some(node => node.tag === 'img') && !hasIsland
|
|
71
|
+
|| text.replaceAll(ISLAND, '').trim()
|
|
72
|
+
|| nodes.some(node => !['div', 'figure', 'picture', 'source', 'img', 'span'].includes(node.tag))) {
|
|
73
|
+
errors.push('imageOverlay media markers must contain photographs and decorative empty layers only, never copy, links or transactional islands');
|
|
74
|
+
}
|
|
75
|
+
if (required && expected && expected !== declaration?.default && declaration && !mediaHasRule(owner, overlayRules(css))) {
|
|
76
|
+
errors.push(`imageOverlay '${expected}' needs matching background CSS for every rendered photo treatment, including static and carousel branches`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
for (const layer of parsed.nodes.filter(node => Object.hasOwn(node.attrs, LAYER))) {
|
|
80
|
+
if (!['span', 'div'].includes(layer.tag) || layer.children.some(child => typeof child !== 'string' || child.trim())
|
|
81
|
+
|| !media.some(owner => owner !== layer && contains(owner, layer))) {
|
|
82
|
+
errors.push('data-p60-image-overlay-layer must mark an empty decorative layer inside its photo wrapper, never the image or its parent');
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return { errors: [...new Set(errors)], signature: presentationSignature(parsed.root, () => [ATTRIBUTE]) };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Deliberately narrow CSS grammar: no image opacity, filters, geometry or unrelated targets. */
|
|
89
|
+
export function imageOverlayCssErrors(css, declaration) {
|
|
90
|
+
const rules = overlayRules(css);
|
|
91
|
+
const errors = [], covered = new Set();
|
|
92
|
+
const rootToken = /\[data-p60-image-overlay\s*=\s*(?:"(subtle|standard|strong)"|'(subtle|standard|strong)'|(subtle|standard|strong))\s*\]/;
|
|
93
|
+
const target = /(?:\[data-p60-image-overlay-layer\]|\[data-p60-image-overlay-media\]::(?:before|after)|\[data-p60-image-overlay-media\]\s+\.hero-slide-scrim)\s*$/;
|
|
94
|
+
for (const [, selectors, body] of rules) {
|
|
95
|
+
const declarations = body.split(';').map(part => part.trim()).filter(Boolean);
|
|
96
|
+
if (!declarations.length || declarations.some(part => !/^background(?:-image|-color)?\s*:\s*[^;]+$/i.test(part)
|
|
97
|
+
|| !/var\(\s*--[\w-]+/.test(part) || /(?:url|image-set|attr|expression)\s*\(/i.test(part))) {
|
|
98
|
+
errors.push('imageOverlay token CSS may change only palette-derived background treatments, never opacity, filters, images, geometry, typography or custom properties');
|
|
99
|
+
}
|
|
100
|
+
for (const selector of selectors.split(',')) {
|
|
101
|
+
const token = rootToken.exec(selector);
|
|
102
|
+
const remainder = token ? selector.slice(token.index + token[0].length) : '';
|
|
103
|
+
if (!token || !/^\s+/.test(remainder) || !target.test(remainder)
|
|
104
|
+
|| /[+~]|:not\(|:is\(|:where\(|\*/.test(selector)
|
|
105
|
+
|| (selector.match(/data-p60-image-overlay(?=[\s=\]])/g) ?? []).length !== 1) {
|
|
106
|
+
errors.push('imageOverlay CSS must target a marked decorative layer, marked photo-wrapper pseudo-element or its documented hero-slide-scrim beneath the exact root token');
|
|
107
|
+
} else covered.add(token[1] ?? token[2] ?? token[3]);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
for (const option of declaration?.options ?? []) {
|
|
111
|
+
if (option !== declaration.default && !covered.has(option)) errors.push(`imageOverlay '${option}' needs an authored marked-layer background rule; the inherited default alone may omit an override`);
|
|
112
|
+
}
|
|
113
|
+
return [...new Set(errors)];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Exercise each distinct photo branch with the real manifest-aware render function. */
|
|
117
|
+
export async function proveImageOverlay(render, fixture, declaration, css, limit = 6) {
|
|
118
|
+
const required = Array.isArray(declaration?.options) && declaration.options.length > 0;
|
|
119
|
+
const errors = imageOverlayCssErrors(css, declaration);
|
|
120
|
+
const samples = fixture.content.images?.length ? fixture.content.images : [{ imageUrl: 'p60fixture:hero/dusk', alt: 'A photograph' }];
|
|
121
|
+
const counts = required ? [...new Set([0, 1, Math.min(2, limit), limit]), 'legacy'] : [1];
|
|
122
|
+
for (const count of counts) for (const framing of ['fill', 'whole']) {
|
|
123
|
+
const content = { ...fixture.content, imageUrl: count === 'legacy' ? samples[0].imageUrl : '', photoFraming: framing,
|
|
124
|
+
images: Array.from({ length: count === 'legacy' ? 0 : count }, (_, index) => ({ ...samples[index % samples.length], alt: `Overlay photograph ${index + 1}` })) };
|
|
125
|
+
let baseline;
|
|
126
|
+
for (const value of [undefined, ...SECTION_PRESENTATION_CONTROLS.imageOverlay.options, 'unknown', undefined]) {
|
|
127
|
+
try {
|
|
128
|
+
const html = await render({ ...content, ...(value === undefined ? {} : { presentation: { imageOverlay: value } }) });
|
|
129
|
+
const expected = declaration?.options?.includes(value) ? value : undefined;
|
|
130
|
+
const result = inspectImageOverlay(html, expected, required, count === 'legacy' || count > 0, css, declaration);
|
|
131
|
+
errors.push(...result.errors.map(error => `${count} photos ${framing} ${value ?? 'inherited'}: ${error}`));
|
|
132
|
+
baseline ??= result.signature;
|
|
133
|
+
if (baseline !== result.signature) errors.push(`${count} photos ${framing} ${value ?? 'inherited'}: imageOverlay must not change content, photographs, islands or markup outside its root token`);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
errors.push(`failed rendering imageOverlay fixture, ${error.message}`);
|
|
136
|
+
return { errors, failed: true };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return { errors: [...new Set(errors)], failed: false };
|
|
141
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { INTRO_PHOTO_FRAMING_ATTRIBUTE as ATTRIBUTE } from '../engine/section-fields.mjs';
|
|
2
|
+
import { parsePresentationMarkup, presentationSignature, presentationVisible } from './presentation-proof.mjs';
|
|
3
|
+
|
|
4
|
+
const ROOT = '(?:[a-z][\\w-]*)?(?:\\.[\\w-]+)*';
|
|
5
|
+
const SELECTOR = new RegExp(`^(?:(${ROOT})\\s+)?(img(?:\\.[\\w-]+)*)\\[${ATTRIBUTE}\\s*=\\s*(?:"(fill|whole)"|'(fill|whole)'|(fill|whole))\\s*\\]$`);
|
|
6
|
+
const RATIOS = new Set(['1/1', '4/3', '3/2', '16/9', '2/1']);
|
|
7
|
+
const decodeCss = value => value.replace(/\\([\da-f]{1,6})\s?|\\([^\r\n])/gi, (_, hex, character) =>
|
|
8
|
+
hex ? String.fromCodePoint(Math.min(Number.parseInt(hex, 16), 0x10ffff)) : character);
|
|
9
|
+
|
|
10
|
+
/** Framing changes only the actual image's crop and bounded ratio, never arbitrary geometry. */
|
|
11
|
+
export function introPhotoFramingRules(css) {
|
|
12
|
+
const rules = [], errors = [];
|
|
13
|
+
const clean = css.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
14
|
+
const scopes = [];
|
|
15
|
+
let start = 0, quote = null;
|
|
16
|
+
for (let index = 0; index < clean.length; index++) {
|
|
17
|
+
if (clean[index] === '\\') { index++; continue; }
|
|
18
|
+
if (quote) { if (clean[index] === quote) quote = null; continue; }
|
|
19
|
+
if (clean[index] === '"' || clean[index] === "'") { quote = clean[index]; continue; }
|
|
20
|
+
if (clean[index] === '{') {
|
|
21
|
+
const header = clean.slice(start, index).trim();
|
|
22
|
+
if (decodeCss(header).toLowerCase().includes(ATTRIBUTE) && scopes.some(scope => !/^@(media|container)\b/.test(scope))) {
|
|
23
|
+
errors.push('photoFraming selectors must be flat; only media/container conditions may surround them');
|
|
24
|
+
}
|
|
25
|
+
if (scopes.some(scope => decodeCss(scope).toLowerCase().includes(ATTRIBUTE))) errors.push('photoFraming rules must not contain nested rules');
|
|
26
|
+
scopes.push(header); start = index + 1;
|
|
27
|
+
} else if (clean[index] === '}') { scopes.pop(); start = index + 1; }
|
|
28
|
+
}
|
|
29
|
+
for (const [, selectors, body] of clean.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
|
|
30
|
+
if (!decodeCss(selectors).toLowerCase().includes(ATTRIBUTE)) continue;
|
|
31
|
+
for (const selector of selectors.split(',')) {
|
|
32
|
+
const match = SELECTOR.exec(selector.trim());
|
|
33
|
+
if (!match) { errors.push('photoFraming CSS must target only the actual img token, optionally prefixed by its natural root tag/classes'); continue; }
|
|
34
|
+
const token = match[3] ?? match[4] ?? match[5];
|
|
35
|
+
const declarations = new Map();
|
|
36
|
+
for (const part of body.split(';').map(value => value.trim()).filter(Boolean)) {
|
|
37
|
+
const declaration = /^([a-z-]+)\s*:\s*(.+)$/.exec(part);
|
|
38
|
+
const property = declaration?.[1], value = declaration?.[2].trim();
|
|
39
|
+
if (!((property === 'object-fit' && value === (token === 'fill' ? 'cover' : 'contain'))
|
|
40
|
+
|| (property === 'aspect-ratio' && (token === 'fill' ? RATIOS.has(value.replace(/\s/g, '')) : value === 'auto')))) {
|
|
41
|
+
errors.push('photoFraming CSS allows only object-fit cover/contain and bounded fill aspect-ratio (1/1, 4/3, 3/2, 16/9, 2/1), or auto for whole');
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
declarations.set(property, value);
|
|
45
|
+
}
|
|
46
|
+
rules.push({ root: match[1], image: match[2], token, declarations });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return { rules, errors: [...new Set(errors)] };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function matches(node, selector) {
|
|
53
|
+
const tag = /^[a-z][\w-]*/i.exec(selector)?.[0];
|
|
54
|
+
return (!tag || node.tag === tag) && [...selector.matchAll(/\.([\w-]+)/g)].every(([, name]) => (node.attrs.class ?? '').split(/\s+/).includes(name));
|
|
55
|
+
}
|
|
56
|
+
function matchingRule(rule, image) {
|
|
57
|
+
if (!matches(image, rule.image)) return false;
|
|
58
|
+
if (!rule.root) return true;
|
|
59
|
+
for (let node = image.parent; node; node = node.parent) if (matches(node, rule.root)) return true;
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
function inIsland(node) {
|
|
63
|
+
for (let parent = node; parent; parent = parent.parent) if (parent.tag === 'astro-island' || Object.hasOwn(parent.attrs, 'data-p60-island')) return true;
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function proveIntroPhotoFraming(render, entry, css) {
|
|
68
|
+
const { rules, errors } = introPhotoFramingRules(css);
|
|
69
|
+
const photo = { ...structuredClone(entry.sample), imageUrl: 'https://example.invalid/framing-photo.jpg', imageAlt: 'Framing fixture' };
|
|
70
|
+
let baseline;
|
|
71
|
+
for (const value of [undefined, 'fill', 'whole', null, 'unknown', undefined]) {
|
|
72
|
+
try {
|
|
73
|
+
const content = { ...photo, ...(value === undefined ? {} : { photoFraming: value }) };
|
|
74
|
+
const parsed = parsePresentationMarkup(await render(content));
|
|
75
|
+
const hooks = parsed.nodes.filter(node => Object.hasOwn(node.attrs, ATTRIBUTE));
|
|
76
|
+
const image = hooks[0];
|
|
77
|
+
if (hooks.length !== 1 || image.tag !== 'img' || !presentationVisible(image) || inIsland(image)
|
|
78
|
+
|| image.attrs['data-p60-field'] !== 'imageUrl' || image.attrs.src !== photo.imageUrl
|
|
79
|
+
|| image.attrs[ATTRIBUTE] !== (['fill', 'whole'].includes(value) ? value : '')) {
|
|
80
|
+
errors.push('photoFraming requires one permanent exact token hook on the actual marked photograph, empty when inherited or unsupported');
|
|
81
|
+
} else {
|
|
82
|
+
for (const token of ['fill', 'whole']) {
|
|
83
|
+
const matching = rules.filter(rule => rule.token === token && matchingRule(rule, image));
|
|
84
|
+
for (const property of ['object-fit', 'aspect-ratio']) if (!matching.some(rule => rule.declarations.has(property))) {
|
|
85
|
+
errors.push(`photoFraming '${token}' needs matching image-only ${property} rules`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const signature = presentationSignature(parsed.root, node => node === image ? [ATTRIBUTE] : []);
|
|
90
|
+
baseline ??= signature;
|
|
91
|
+
if (signature !== baseline) errors.push('photoFraming must preserve image source, alt text, content, order and markup; only its image hook may change');
|
|
92
|
+
} catch (error) { errors.push(`photoFraming failed rendering fixture: ${error.message}`); return [...new Set(errors)]; }
|
|
93
|
+
}
|
|
94
|
+
for (const value of [undefined, 'fill', 'whole']) {
|
|
95
|
+
try {
|
|
96
|
+
const parsed = parsePresentationMarkup(await render({ ...photo, imageUrl: '', photoFraming: value }));
|
|
97
|
+
if (parsed.nodes.some(node => Object.hasOwn(node.attrs, ATTRIBUTE))) errors.push('photoFraming must not invent an image or hook when no photograph exists');
|
|
98
|
+
} catch (error) { errors.push(`photoFraming failed rendering no-photo fixture: ${error.message}`); return [...new Set(errors)]; }
|
|
99
|
+
}
|
|
100
|
+
return [...new Set(errors)];
|
|
101
|
+
}
|
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
// THE MODEL, VISIBLE (content model v2): a self-contained reference page the kit's dev server
|
|
2
|
-
// serves at /model, the registry's shape beside the LIVE example data the preview is rendering
|
|
3
|
-
// right now (preview-content.json overlay included), so "what can I read?" is answered where the
|
|
4
|
-
// developer already lives. Network-dead like every preview document.
|
|
5
1
|
import { contentModelV2 as contentModel } from '../engine/content-footprint.mjs';
|
|
6
2
|
|
|
7
3
|
const escapeHtml = (s) =>
|
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
// THE MODEL, VISIBLE (content model v1): a self-contained reference page the kit's dev server
|
|
2
|
-
// serves at /model, the registry's shape beside the LIVE example data the preview is rendering
|
|
3
|
-
// right now (preview-content.json overlay included), so "what can I read?" is answered where the
|
|
4
|
-
// developer already lives. Network-dead like every preview document.
|
|
5
1
|
import { contentModel } from '../engine/content-footprint.mjs';
|
|
6
2
|
|
|
7
3
|
const escapeHtml = (s) =>
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
// Structural render proof, not a CSS or accessibility audit. Menus may start closed and their
|
|
2
|
-
// responsive styling belongs to the author. Browser checks still establish visual usability.
|
|
3
1
|
const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
|
|
4
2
|
const NON_CONTENT = new Set(['script', 'style', 'template', 'noscript']);
|
|
5
3
|
const CARDS = new Set(['aside', 'article', 'section', 'div', 'a']);
|
|
@@ -36,7 +34,6 @@ function parseMarkup(html) {
|
|
|
36
34
|
|
|
37
35
|
function isHidden(node) {
|
|
38
36
|
if (NON_CONTENT.has(node.tag)) return true;
|
|
39
|
-
// A closed navigation panel is expected. Hiding the card or its fields explicitly is not.
|
|
40
37
|
if ('data-p60-nav-menu' in node.attrs || 'data-p60-nav' in node.attrs) return false;
|
|
41
38
|
return 'hidden' in node.attrs || node.attrs['aria-hidden'] === 'true' ||
|
|
42
39
|
/(?:display\s*:\s*none|visibility\s*:\s*hidden|opacity\s*:\s*0(?:[;\s]|$))/i.test(node.attrs.style ?? '');
|
|
@@ -8,6 +8,29 @@
|
|
|
8
8
|
template ships a themeCss injected at body-end. Fonts load from the layout <head> (preconnect +
|
|
9
9
|
stylesheet link) instead of a CSS @import, an @import chains render-blocking requests. */
|
|
10
10
|
|
|
11
|
+
/* Photo-only hero rotation. Templates style these hooks, not the campaign carousel. */
|
|
12
|
+
.hero-carousel .hero-playback {
|
|
13
|
+
position: absolute; z-index: 5; inset-block-start: 12px; inset-inline-end: 12px;
|
|
14
|
+
display: grid; place-items: center; width: 44px; height: 44px; padding: 8px;
|
|
15
|
+
border: 1px solid #ffffffb3; border-radius: 50%; background: #14241ce6; color: #fff;
|
|
16
|
+
cursor: pointer; touch-action: manipulation;
|
|
17
|
+
}
|
|
18
|
+
.hero-carousel .hero-playback svg { width: 20px; height: 20px; fill: currentColor; }
|
|
19
|
+
.hero-carousel .hero-playback:disabled { cursor: default; opacity: .65; }
|
|
20
|
+
.hero-carousel:focus-visible, .hero-carousel .hero-playback:focus-visible {
|
|
21
|
+
outline: 2px solid var(--ink); outline-offset: 4px;
|
|
22
|
+
}
|
|
23
|
+
.hero-carousel .hero-playback[hidden], .hero-carousel .hero-dots[hidden] { display: none !important; }
|
|
24
|
+
.hero-carousel-instructions {
|
|
25
|
+
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
|
|
26
|
+
overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
|
|
27
|
+
}
|
|
28
|
+
@media (prefers-reduced-motion: reduce) {
|
|
29
|
+
.hero-carousel .hero-slide, .hero-carousel .hero-slide-img {
|
|
30
|
+
animation: none !important; transition: none !important;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
11
34
|
:root {
|
|
12
35
|
--bg: #fbf6ef;
|
|
13
36
|
--bg-tint: #f4ece1;
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
// Small structural parser for capability proofs. CSS geometry and accessibility still need browser QA.
|
|
2
1
|
const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
|
|
3
2
|
export function parsePresentationMarkup(html) {
|
|
4
3
|
const root = { tag: 'root', attrs: {}, children: [], parent: null };
|
|
@@ -39,7 +38,6 @@ export function presentationText(node) {
|
|
|
39
38
|
export function presentationVisible(node, { navigation = false } = {}) {
|
|
40
39
|
for (let current = node; current; current = current.parent) {
|
|
41
40
|
if (['template', 'script', 'style', 'noscript', 'textarea', 'select'].includes(current.tag)) return false;
|
|
42
|
-
// Dropdown panels may start closed. Hidden links and their own fields may not.
|
|
43
41
|
if (navigation && current !== node && 'data-p60-nav-menu' in current.attrs) continue;
|
|
44
42
|
if ('hidden' in current.attrs || current.attrs['aria-hidden'] === 'true' || /(?:display\s*:\s*none|visibility\s*:\s*hidden|opacity\s*:\s*0(?:[;\s]|$))/i.test(current.attrs.style ?? '')) return false;
|
|
45
43
|
}
|