diff --git a/changelog.d/149.security.md b/changelog.d/149.security.md
new file mode 100644
index 0000000..bc61d72
--- /dev/null
+++ b/changelog.d/149.security.md
@@ -0,0 +1 @@
+Hardened the `titleSlam` template so title words are rendered through DOM text and `data-text` assignments instead of raw span HTML.
diff --git a/src/system/templates/_shared.ts b/src/system/templates/_shared.ts
index ea2ee98..8e69a46 100644
--- a/src/system/templates/_shared.ts
+++ b/src/system/templates/_shared.ts
@@ -19,6 +19,7 @@ import { addAdvanceGate } from '../helpers/timing';
// ---------- ctx narrowing (shared with the scene authoring contract) ----
export interface TemplateDomElement {
+ className?: string;
setAttribute(name: string, value: string): void;
appendChild?(node: unknown): unknown;
textContent?: string;
diff --git a/src/system/templates/title-slam.ts b/src/system/templates/title-slam.ts
index d41dc22..677af49 100644
--- a/src/system/templates/title-slam.ts
+++ b/src/system/templates/title-slam.ts
@@ -6,7 +6,6 @@
// Optional subtitle.
import type { SceneModule } from '../../runtime/scene';
-import { markedTextHtml } from '../helpers';
import { buildTemplateScene, buildTemplateTimeline, mountTemplateRoot } from './_shared';
export interface TitleSlamContent {
@@ -28,14 +27,14 @@ export const titleSlam = (id: string, content: TitleSlamContent): SceneModule =>
templateKind: 'title-slam',
buildChildren: (root, ownerDoc) => {
const h1 = ownerDoc.createElement('h1');
- h1.setAttribute('class', 'pulsar-title__h');
- const html = words
- .map((w) => {
- const cls = content.glitch === true ? 'word pulsar-glitch' : 'word';
- return `${markedTextHtml(w)}`;
- })
- .join('');
- h1.innerHTML = html;
+ h1.className = 'pulsar-title__h';
+ for (const w of words) {
+ const span = ownerDoc.createElement('span');
+ span.className = content.glitch === true ? 'word pulsar-glitch' : 'word';
+ span.textContent = w;
+ span.dataset.text = w;
+ h1.appendChild?.(span);
+ }
root.appendChild?.(h1);
if (content.subtitle !== undefined) {
const sub = ownerDoc.createElement('p');
diff --git a/tests/system/templates-options.test.ts b/tests/system/templates-options.test.ts
index b0cd326..6a07cc1 100644
--- a/tests/system/templates-options.test.ts
+++ b/tests/system/templates-options.test.ts
@@ -8,7 +8,7 @@
import { gsap } from 'gsap';
import { describe, expect, it } from 'vitest';
-import { assertSceneModule } from '../../src/runtime/scene';
+import { type SceneModule, assertSceneModule } from '../../src/runtime/scene';
import {
actHeader,
activityFeedPayoff,
@@ -207,6 +207,53 @@ const assertLifecycle = (scene: ReturnType, fakeCtx: unknown):
expect(tl).not.toBeNull();
};
+const hasClass = (node: FakeNode, className: string): boolean =>
+ node.className.split(/\s+/).includes(className);
+
+const findAll = (root: FakeNode, predicate: (node: FakeNode) => boolean): FakeNode[] => {
+ const out: FakeNode[] = [];
+ const search = (node: FakeNode): void => {
+ if (predicate(node)) out.push(node);
+ for (const child of node.childNodes) search(child);
+ };
+ search(root);
+ return out;
+};
+
+const findByClass = (root: FakeNode, className: string): FakeNode | null =>
+ findAll(root, (node) => hasClass(node, className))[0] ?? null;
+
+const findAllByClass = (root: FakeNode, className: string): FakeNode[] =>
+ findAll(root, (node) => hasClass(node, className));
+
+const findByAttr = (root: FakeNode, attr: string, value?: string): FakeNode | null =>
+ findAll(root, (node) => {
+ const actual = node.getAttribute(attr);
+ return actual !== null && (value === undefined || actual === value);
+ })[0] ?? null;
+
+const requireNode = (node: FakeNode | null | undefined, message: string): FakeNode => {
+ expect(node).toBeDefined();
+ expect(node).not.toBeNull();
+ if (node === null || node === undefined) throw new Error(message);
+ return node;
+};
+
+const renderRoot = (scene: SceneModule, rootValue: string): FakeNode => {
+ const stage = makeStage();
+ scene.create(ctx(stage));
+ return requireNode(
+ stage.querySelector(`[data-pulsar-template="${rootValue}"]`),
+ `expected ${rootValue} root`,
+ );
+};
+
+const expectClassText = (root: FakeNode, className: string, text: string): FakeNode => {
+ const node = requireNode(findByClass(root, className), `expected .${className}`);
+ expect(node.textContent).toBe(text);
+ return node;
+};
+
describe('L2 templates — optional-branch coverage', () => {
it('titleSlam with subtitle + glitch + 5-word title (>4 word stagger)', () => {
const scene = titleSlam('opt-title', {
@@ -215,9 +262,47 @@ describe('L2 templates — optional-branch coverage', () => {
glitch: true,
});
expect(() => assertSceneModule(scene)).not.toThrow();
+ const root = renderRoot(scene, 'opt-title');
+ expect(findAllByClass(root, 'word')).toHaveLength(5);
+ expect(findAllByClass(root, 'pulsar-glitch')).toHaveLength(5);
+ expectClassText(root, 'pulsar-title__subtitle', 'a subtitle');
assertLifecycle(scene, ctx(makeStage()));
});
+ it('titleSlam assigns glitch data-text without parsing word content as HTML', () => {
+ const token = 'bad"onclick=';
+ const stage = makeStage();
+ const scene = titleSlam('opt-title-escape', {
+ title: token,
+ glitch: true,
+ });
+ scene.create(ctx(stage));
+
+ const root = stage.querySelector('[data-pulsar-template="opt-title-escape"]');
+ expect(root).not.toBeNull();
+ if (root === null) throw new Error('expected titleSlam root');
+
+ const titleEl = root.childNodes[0];
+ expect(titleEl).toBeDefined();
+ if (titleEl === undefined) throw new Error('expected title element');
+
+ expect(titleEl.childNodes).toHaveLength(1);
+ const word = titleEl.childNodes[0];
+ expect(word).toBeDefined();
+ if (word === undefined) throw new Error('expected word span');
+
+ expect(word.className).toBe('word pulsar-glitch');
+ expect(word.textContent).toBe(token);
+ expect(word.getAttribute('data-text')).toBe(token);
+ expect(word.getAttribute('onclick')).toBeNull();
+ expect(word.attrs.has('b')).toBe(false);
+ const injectedAttrs = [...word.attrs.keys()].filter(
+ (name) => name !== 'class' && name !== 'data-text',
+ );
+ expect(injectedAttrs).toEqual([]);
+ expect(word.childNodes).toHaveLength(0);
+ });
+
it('outro with qrSrc + subtitle', () => {
const scene = outro('opt-outro', {
title: 'thanks',
@@ -226,79 +311,103 @@ describe('L2 templates — optional-branch coverage', () => {
qrAlt: 'qr',
});
expect(scene.assets).toEqual(['/qr.png']);
+ const root = renderRoot(scene, 'opt-outro');
+ expectClassText(root, 'outro__subtitle', 'sub');
+ const qr = requireNode(findByClass(root, 'outro__qr'), 'expected outro QR image');
+ expect(qr.getAttribute('src')).toBe('/qr.png');
+ expect(qr.getAttribute('alt')).toBe('qr');
assertLifecycle(scene, ctx(makeStage()));
});
it('statRow / statPairGrid / definitionTable / quoteStack / bulletList with eyebrow', () => {
- assertLifecycle(
- statRow('opt-stat-row', { eyebrow: 'eb', title: 't', rows: [['a', 'b']] }),
- ctx(makeStage()),
- );
- assertLifecycle(
- statPairGrid('opt-pair', { eyebrow: 'eb', title: 't', pairs: [['1', 'one']] }),
- ctx(makeStage()),
- );
- assertLifecycle(
- definitionTable('opt-defs', {
- eyebrow: 'eb',
- title: 't',
- rows: [
- { cat: 'A', rule: 'r', mod: 'red' },
- { cat: 'B', rule: 'r', mod: 'amber' },
- { cat: 'C', rule: 'r', mod: 'green' },
- { cat: 'D', rule: 'r', mod: 'clear' },
- ],
- }),
- ctx(makeStage()),
- );
- assertLifecycle(
- quoteStack('opt-qstack', {
- eyebrow: 'eb',
- title: 't',
- quotes: [{ text: 'a' }, { text: 'b', attribution: 'x' }],
- }),
- ctx(makeStage()),
- );
- assertLifecycle(
- bulletList('opt-bullets', {
- eyebrow: 'eb',
- title: 't',
- bullets: ['x', 'y'],
- staggerMs: 200,
- }),
- ctx(makeStage()),
- );
+ const row = statRow('opt-stat-row', { eyebrow: 'eb', title: 't', rows: [['a', 'b']] });
+ expectClassText(renderRoot(row, 'opt-stat-row'), 'eyebrow', 'eb');
+ assertLifecycle(row, ctx(makeStage()));
+
+ const pair = statPairGrid('opt-pair', {
+ eyebrow: 'eb',
+ title: 't',
+ pairs: [['1', 'one']],
+ });
+ expectClassText(renderRoot(pair, 'opt-pair'), 'eyebrow', 'eb');
+ assertLifecycle(pair, ctx(makeStage()));
+
+ const defs = definitionTable('opt-defs', {
+ eyebrow: 'eb',
+ title: 't',
+ rows: [
+ { cat: 'A', rule: 'r', mod: 'red' },
+ { cat: 'B', rule: 'r', mod: 'amber' },
+ { cat: 'C', rule: 'r', mod: 'green' },
+ { cat: 'D', rule: 'r', mod: 'clear' },
+ ],
+ });
+ const defsRoot = renderRoot(defs, 'opt-defs');
+ expectClassText(defsRoot, 'eyebrow', 'eb');
+ expect(findByClass(defsRoot, 'mod-red')).not.toBeNull();
+ expect(findByClass(defsRoot, 'mod-amber')).not.toBeNull();
+ expect(findByClass(defsRoot, 'mod-green')).not.toBeNull();
+ expect(findByClass(defsRoot, 'mod-clear')).not.toBeNull();
+ assertLifecycle(defs, ctx(makeStage()));
+
+ const stack = quoteStack('opt-qstack', {
+ eyebrow: 'eb',
+ title: 't',
+ quotes: [{ text: 'a' }, { text: 'b', attribution: 'x' }],
+ });
+ const stackRoot = renderRoot(stack, 'opt-qstack');
+ expectClassText(stackRoot, 'eyebrow', 'eb');
+ expect(findAll(stackRoot, (node) => node.textContent === 'x')).toHaveLength(1);
+ assertLifecycle(stack, ctx(makeStage()));
+
+ const bullets = bulletList('opt-bullets', {
+ eyebrow: 'eb',
+ title: 't',
+ bullets: ['x', 'y'],
+ staggerMs: 200,
+ });
+ const bulletsRoot = renderRoot(bullets, 'opt-bullets');
+ expectClassText(bulletsRoot, 'eyebrow', 'eb');
+ expect(
+ findAll(bulletsRoot, (node) => node.getAttribute('style') === '--bullet-delay: 700ms'),
+ ).toHaveLength(1);
+ assertLifecycle(bullets, ctx(makeStage()));
});
it('introGrid roles with logos and primary flag', () => {
- assertLifecycle(
- introGrid('opt-intro', {
- title: 't',
- roles: [{ logoSrc: '/a.png', logoAlt: 'a', role: 'lead', primary: true }, { role: 'co' }],
- }),
- ctx(makeStage()),
- );
+ const scene = introGrid('opt-intro', {
+ title: 't',
+ roles: [{ logoSrc: '/a.png', logoAlt: 'a', role: 'lead', primary: true }, { role: 'co' }],
+ });
+ expect(scene.assets).toEqual(['/a.png']);
+ const root = renderRoot(scene, 'opt-intro');
+ expect(findAllByClass(root, 'primary')).toHaveLength(1);
+ const logo = requireNode(findByAttr(root, 'src', '/a.png'), 'expected intro logo');
+ expect(logo.getAttribute('alt')).toBe('a');
+ assertLifecycle(scene, ctx(makeStage()));
});
it('compare with headline', () => {
- assertLifecycle(
- compare('opt-compare', { headline: 'vs', left: 'a', right: 'b' }),
- ctx(makeStage()),
- );
+ const scene = compare('opt-compare', { headline: 'vs', left: 'a', right: 'b' });
+ expectClassText(renderRoot(scene, 'opt-compare'), 'headline', 'vs');
+ assertLifecycle(scene, ctx(makeStage()));
});
it('screenshotCallouts with multiple callouts', () => {
- assertLifecycle(
- screenshotCallouts('opt-shot', {
- imageSrc: '/img.png',
- imageAlt: 'alt',
- callouts: [
- { x: 10, y: 20, text: 'a' },
- { x: 30, y: 40, text: 'b' },
- ],
- }),
- ctx(makeStage()),
- );
+ const scene = screenshotCallouts('opt-shot', {
+ imageSrc: '/img.png',
+ imageAlt: 'alt',
+ callouts: [
+ { x: 10, y: 20, text: 'a' },
+ { x: 30, y: 40, text: 'b' },
+ ],
+ });
+ expect(scene.assets).toEqual(['/img.png']);
+ const root = renderRoot(scene, 'opt-shot');
+ const image = requireNode(findByAttr(root, 'src', '/img.png'), 'expected screenshot image');
+ expect(image.getAttribute('alt')).toBe('alt');
+ expect(findAllByClass(root, 'callout').map((node) => node.textContent)).toEqual(['a', 'b']);
+ assertLifecycle(scene, ctx(makeStage()));
});
it('metricTicker create + cleanup tears down the interval', () => {
@@ -313,208 +422,283 @@ describe('L2 templates — optional-branch coverage', () => {
});
const stage = makeStage();
expect(() => scene.create(ctx(stage))).not.toThrow();
+ const root = requireNode(
+ stage.querySelector('[data-pulsar-template="opt-ticker"]'),
+ 'ticker root',
+ );
+ expectClassText(root, 'eyebrow', 'eb');
+ expect(
+ findAll(root, (node) => node.getAttribute('data-metric') !== null).map(
+ (node) => node.textContent,
+ ),
+ ).toEqual(['1', '$100k']);
expect(() => scene.cleanup(ctx(stage))).not.toThrow(); // should stop the interval
});
it('terminal exercises every script step kind', () => {
- assertLifecycle(
- terminal('opt-term', {
- script: [
- { t: 'user', text: 'ls' },
- { t: 'agent', text: 'ok' },
- { t: 'tool', text: 'curl' },
- { t: 'output', text: 'two\nlines' },
- { t: 'wait', ms: 50 },
- { t: 'popout', text: 'BOOM' },
- ],
- typeBaseMs: 1,
- }),
- ctx(makeStage()),
- );
+ const scene = terminal('opt-term', {
+ script: [
+ { t: 'user', text: 'ls' },
+ { t: 'agent', text: 'ok' },
+ { t: 'tool', text: 'curl' },
+ { t: 'output', text: 'two\nlines' },
+ { t: 'wait', ms: 50 },
+ { t: 'popout', text: 'BOOM' },
+ ],
+ typeBaseMs: 1,
+ });
+ expect(renderRoot(scene, 'opt-term').querySelector('.term')).not.toBeNull();
+ expect(scene.captions.map((caption) => caption.text)).toEqual([
+ 'ls',
+ 'ok',
+ 'curl',
+ 'two\nlines',
+ ]);
+ assertLifecycle(scene, ctx(makeStage()));
});
it('placard with no subtitle + quote with no attribution + centerpiece with no attr', () => {
- assertLifecycle(placard('opt-pl', { line1: 'solo' }), ctx(makeStage()));
- assertLifecycle(quote('opt-q', { text: 'lone' }), ctx(makeStage()));
- assertLifecycle(centerpiece('opt-cp', { quote: 'lonely' }), ctx(makeStage()));
+ const pl = placard('opt-pl', { line1: 'solo' });
+ const plRoot = renderRoot(pl, 'opt-pl');
+ expectClassText(plRoot, 'placard__title', 'solo');
+ expect(findByClass(plRoot, 'placard__sub')).toBeNull();
+ assertLifecycle(pl, ctx(makeStage()));
+
+ const q = quote('opt-q', { text: 'lone' });
+ const qRoot = renderRoot(q, 'opt-q');
+ expectClassText(qRoot, 'quote__text', 'lone');
+ expect(findByClass(qRoot, 'quote__attr')).toBeNull();
+ assertLifecycle(q, ctx(makeStage()));
+
+ const cp = centerpiece('opt-cp', { quote: 'lonely' });
+ const cpRoot = renderRoot(cp, 'opt-cp');
+ expectClassText(cpRoot, 'centerpiece__quote', 'lonely');
+ expect(findByClass(cpRoot, 'centerpiece__attr')).toBeNull();
+ assertLifecycle(cp, ctx(makeStage()));
});
it('actHeader + outlineTitle with custom prefix', () => {
- assertLifecycle(actHeader('opt-ah', { act: 'X', section: 'Late' }), ctx(makeStage()));
- assertLifecycle(
- outlineTitle('opt-ot', { index: 99, title: 'Beyond', prefix: 'Chapter' }),
- ctx(makeStage()),
- );
- assertLifecycle(outlineTitle('opt-ot2', { index: 5, title: 'Mid' }), ctx(makeStage()));
+ const ah = actHeader('opt-ah', { act: 'X', section: 'Late' });
+ expectClassText(renderRoot(ah, 'opt-ah'), 'act__numeral', 'Act X');
+ assertLifecycle(ah, ctx(makeStage()));
+
+ const custom = outlineTitle('opt-ot', { index: 99, title: 'Beyond', prefix: 'Chapter' });
+ expectClassText(renderRoot(custom, 'opt-ot'), 'outline__index', 'Chapter 99');
+ assertLifecycle(custom, ctx(makeStage()));
+
+ const roman = outlineTitle('opt-ot2', { index: 5, title: 'Mid' });
+ expectClassText(renderRoot(roman, 'opt-ot2'), 'outline__index', 'Section V');
+ assertLifecycle(roman, ctx(makeStage()));
});
it('incidentPlate with bg + sub', () => {
- assertLifecycle(
- incidentPlate('opt-ip', {
- time: '03:14 UTC',
- headline: 'BRIDGE COLLAPSE',
- sub: 'no casualties yet',
- bgSrc: '/bg.png',
- bgAlt: 'bridge',
- }),
- ctx(makeStage()),
- );
+ const scene = incidentPlate('opt-ip', {
+ time: '03:14 UTC',
+ headline: 'BRIDGE COLLAPSE',
+ sub: 'no casualties yet',
+ bgSrc: '/bg.png',
+ bgAlt: 'bridge',
+ });
+ expect(scene.assets).toEqual(['/bg.png']);
+ const root = renderRoot(scene, 'opt-ip');
+ const bg = requireNode(findByClass(root, 'incident-plate__bg'), 'expected incident bg');
+ expect(bg.getAttribute('style')).toBe('background-image: url(/bg.png)');
+ expect(bg.getAttribute('aria-label')).toBe('bridge');
+ expectClassText(root, 'incident-plate__sub', 'no casualties yet');
+ assertLifecycle(scene, ctx(makeStage()));
});
it('operatorDossier renders all rows', () => {
- assertLifecycle(
- operatorDossier('opt-od', {
- handle: '@drift',
- rows: [
- { k: 'origin', v: 'unknown' },
- { k: 'first seen', v: '2025-04' },
- { k: 'reach', v: 'global' },
- ],
- }),
- ctx(makeStage()),
- );
+ const scene = operatorDossier('opt-od', {
+ handle: '@drift',
+ rows: [
+ { k: 'origin', v: 'unknown' },
+ { k: 'first seen', v: '2025-04' },
+ { k: 'reach', v: 'global' },
+ ],
+ });
+ const root = renderRoot(scene, 'opt-od');
+ expectClassText(root, 'dossier__handle', '@drift');
+ expect(findAllByClass(root, 'dossier__row')).toHaveLength(3);
+ assertLifecycle(scene, ctx(makeStage()));
});
it('splitPaneTerminalDoc with eyebrow + headline + canted doc + caption', () => {
- assertLifecycle(
- splitPaneTerminalDoc('opt-sptd', {
- eyebrow: 'EB',
- headline: 'CHATBOT',
- leftScript: [
- { role: 'user', text: 'q' },
- { role: 'agent', text: 'a' },
- { role: 'tool', text: 'curl', base: 5 },
- { role: 'output', text: 'ok', afterMs: 5 },
- ],
- rightDoc: { imgSrc: '/d.png', imgAlt: 'd', caption: 'cap', cantDegrees: 4 },
- typeBaseMs: 1,
- }),
- ctx(makeStage()),
- );
+ const scene = splitPaneTerminalDoc('opt-sptd', {
+ eyebrow: 'EB',
+ headline: 'CHATBOT',
+ leftScript: [
+ { role: 'user', text: 'q' },
+ { role: 'agent', text: 'a' },
+ { role: 'tool', text: 'curl', base: 5 },
+ { role: 'output', text: 'ok', afterMs: 5 },
+ ],
+ rightDoc: { imgSrc: '/d.png', imgAlt: 'd', caption: 'cap', cantDegrees: 4 },
+ typeBaseMs: 1,
+ });
+ expect(scene.assets).toEqual(['/d.png']);
+ const root = renderRoot(scene, 'opt-sptd');
+ expectClassText(root, 'splitpane__eyebrow', 'EB');
+ expectClassText(root, 'splitpane__headline', 'CHATBOT');
+ expectClassText(root, 'splitpane__doc', '');
+ const doc = requireNode(findByClass(root, 'splitpane__doc'), 'expected splitpane doc');
+ expect(doc.getAttribute('style')).toBe('transform: rotate(4deg)');
+ const img = requireNode(findByAttr(root, 'src', '/d.png'), 'expected splitpane image');
+ expect(img.getAttribute('alt')).toBe('d');
+ expect(findAll(root, (node) => node.textContent === 'cap')).toHaveLength(1);
+ assertLifecycle(scene, ctx(makeStage()));
});
it('activityFeedPayoff with eyebrow + headline + all entry types + payoff title', () => {
- assertLifecycle(
- activityFeedPayoff('opt-afp', {
- eyebrow: 'EB',
- headline: 'OSINT',
- feed: [
- { type: 'search', text: 'who is X' },
- { type: 'read', text: 'wiki', afterMs: 5 },
- { type: 'think', text: 'reasoning' },
+ const scene = activityFeedPayoff('opt-afp', {
+ eyebrow: 'EB',
+ headline: 'OSINT',
+ feed: [
+ { type: 'search', text: 'who is X' },
+ { type: 'read', text: 'wiki', afterMs: 5 },
+ { type: 'think', text: 'reasoning' },
+ ],
+ payoff: {
+ title: 'Dossier',
+ rows: [
+ { label: 'name', value: 'X' },
+ { label: 'role', value: '[[CEO]]' },
],
- payoff: {
- title: 'Dossier',
- rows: [
- { label: 'name', value: 'X' },
- { label: 'role', value: '[[CEO]]' },
- ],
- },
- typeBaseMs: 1,
- }),
- ctx(makeStage()),
- );
+ },
+ typeBaseMs: 1,
+ });
+ const root = renderRoot(scene, 'opt-afp');
+ expectClassText(root, 'afp__eyebrow', 'EB');
+ expectClassText(root, 'afp__headline', 'OSINT');
+ expectClassText(root, 'afp__payoff-title', 'Dossier');
+ expect(findByAttr(root, 'data-afp-feed')).not.toBeNull();
+ expect(findByAttr(root, 'data-afp-payoff')).not.toBeNull();
+ assertLifecycle(scene, ctx(makeStage()));
});
it('haulCitations with eyebrow + headline + hot row mod + citation title', () => {
- assertLifecycle(
- haulCitations('opt-hc', {
- eyebrow: 'EB',
- headline: 'HAUL',
- haul: [
- { count: '12,847', label: 'records', mod: 'hot' },
- { count: '100%', label: 'coverage' },
+ const scene = haulCitations('opt-hc', {
+ eyebrow: 'EB',
+ headline: 'HAUL',
+ haul: [
+ { count: '12,847', label: 'records', mod: 'hot' },
+ { count: '100%', label: 'coverage' },
+ ],
+ citations: {
+ title: 'Sources',
+ rows: [
+ { source: 'foo.com', quote: 'breach' },
+ { source: 'bar.com', quote: 'leaked' },
],
- citations: {
- title: 'Sources',
- rows: [
- { source: 'foo.com', quote: 'breach' },
- { source: 'bar.com', quote: 'leaked' },
- ],
- },
- staggerMs: 100,
- }),
- ctx(makeStage()),
- );
+ },
+ staggerMs: 100,
+ });
+ const root = renderRoot(scene, 'opt-hc');
+ expectClassText(root, 'hc__eyebrow', 'EB');
+ expectClassText(root, 'hc__headline', 'HAUL');
+ expectClassText(root, 'hc__citations-title', 'Sources');
+ const hot = requireNode(findByClass(root, 'hc__row--hot'), 'expected hot haul row');
+ expect(hot.getAttribute('style')).toBe('--row-delay: 0ms');
+ assertLifecycle(scene, ctx(makeStage()));
});
it('chatTranscript with title + alert flag + doc', () => {
- assertLifecycle(
- chatTranscript('opt-ct', {
- title: 'Signal export',
- messages: [
- { handle: '@a', text: 'safe' },
- { handle: '@b', text: 'unsafe', alert: true, afterMs: 5 },
- ],
- perBeatMs: 50,
- doc: { imgSrc: '/d.png', imgAlt: 'd', caption: 'doc', cantDegrees: -3 },
- }),
- ctx(makeStage()),
- );
+ const scene = chatTranscript('opt-ct', {
+ title: 'Signal export',
+ messages: [
+ { handle: '@a', text: 'safe' },
+ { handle: '@b', text: 'unsafe', alert: true, afterMs: 5 },
+ ],
+ perBeatMs: 50,
+ doc: { imgSrc: '/d.png', imgAlt: 'd', caption: 'doc', cantDegrees: -3 },
+ });
+ expect(scene.assets).toEqual(['/d.png']);
+ const root = renderRoot(scene, 'opt-ct');
+ expectClassText(root, 'ct__title', 'Signal export');
+ expect(findByClass(root, 'ct__msg--alert')).not.toBeNull();
+ const doc = requireNode(findByClass(root, 'ct__doc'), 'expected transcript doc');
+ expect(doc.getAttribute('style')).toBe('transform: rotate(-3deg)');
+ const img = requireNode(findByAttr(root, 'src', '/d.png'), 'expected transcript image');
+ expect(img.getAttribute('alt')).toBe('d');
+ expect(findAll(root, (node) => node.textContent === 'doc')).toHaveLength(1);
+ assertLifecycle(scene, ctx(makeStage()));
});
it('cardCarousel with eyebrow + sub + src + per-card dwell', () => {
- assertLifecycle(
- cardCarousel('opt-cc', {
- eyebrow: 'EB',
- dwellMs: 50,
- cards: [
- { headline: 'A', sub: 'subA', src: 'src.com' },
- { headline: 'B', dwellMs: 25 },
- ],
- }),
- ctx(makeStage()),
- );
+ const scene = cardCarousel('opt-cc', {
+ eyebrow: 'EB',
+ dwellMs: 50,
+ cards: [
+ { headline: 'A', sub: 'subA', src: 'src.com' },
+ { headline: 'B', dwellMs: 25 },
+ ],
+ });
+ expectClassText(renderRoot(scene, 'opt-cc'), 'cc__eyebrow', 'EB');
+ expect(scene.captions.map((caption) => caption.text)).toEqual(['A', 'B']);
+ assertLifecycle(scene, ctx(makeStage()));
});
it('splitDialogueEmail with eyebrow + headline + signoff + footer + base override', () => {
- assertLifecycle(
- splitDialogueEmail('opt-sde', {
- eyebrow: 'EB',
- headline: 'PHISH',
- dialogue: [
- { handle: '@a', text: 'plan it' },
- { handle: '@b', text: 'go', base: 5, afterMs: 5 },
- ],
- email: {
- from: 'a@x',
- to: 'b@x',
- subject: 'urgent',
- bodyParagraphs: ['line one', 'line [[two]]'],
- signoff: '— [[alex]]',
- footer: 'sent 09:12 MDT',
- },
- typeBaseMs: 1,
- emailRevealAfterMs: 10,
- }),
- ctx(makeStage()),
- );
+ const scene = splitDialogueEmail('opt-sde', {
+ eyebrow: 'EB',
+ headline: 'PHISH',
+ dialogue: [
+ { handle: '@a', text: 'plan it' },
+ { handle: '@b', text: 'go', base: 5, afterMs: 5 },
+ ],
+ email: {
+ from: 'a@x',
+ to: 'b@x',
+ subject: 'urgent',
+ bodyParagraphs: ['line one', 'line [[two]]'],
+ signoff: '— [[alex]]',
+ footer: 'sent 09:12 MDT',
+ },
+ typeBaseMs: 1,
+ emailRevealAfterMs: 10,
+ });
+ const root = renderRoot(scene, 'opt-sde');
+ expectClassText(root, 'sde__eyebrow', 'EB');
+ expectClassText(root, 'sde__headline', 'PHISH');
+ expectClassText(root, 'sde__email-footer', 'sent 09:12 MDT');
+ expectClassText(root, 'sde__email-signoff', '');
+ expect(findByAttr(root, 'data-sde-dialogue')).not.toBeNull();
+ expect(findByAttr(root, 'data-sde-email')).not.toBeNull();
+ assertLifecycle(scene, ctx(makeStage()));
});
it('dropList with eyebrow + headline + sub + hot mod', () => {
- assertLifecycle(
- dropList('opt-dl', {
- eyebrow: 'EB',
- headline: 'UPGRADES',
- staggerMs: 50,
- items: [{ label: 'fast', sub: 'really fast', mod: 'hot' }, { label: 'cheap' }],
- }),
- ctx(makeStage()),
- );
+ const scene = dropList('opt-dl', {
+ eyebrow: 'EB',
+ headline: 'UPGRADES',
+ staggerMs: 50,
+ items: [{ label: 'fast', sub: 'really fast', mod: 'hot' }, { label: 'cheap' }],
+ });
+ const root = renderRoot(scene, 'opt-dl');
+ expectClassText(root, 'dl__eyebrow', 'EB');
+ expectClassText(root, 'dl__headline', 'UPGRADES');
+ expectClassText(root, 'dl__sub', 'really fast');
+ const hot = requireNode(findByClass(root, 'dl__item--hot'), 'expected hot drop item');
+ expect(hot.getAttribute('style')).toBe('--drop-delay: 0ms');
+ assertLifecycle(scene, ctx(makeStage()));
});
it('chatPickList with promptHandle + sub + pickCaption', () => {
- assertLifecycle(
- chatPickList('opt-cpl', {
- promptHandle: '@orch',
- promptMessage: 'pick one',
- items: [{ label: 'A', sub: 'option A' }, { label: 'B' }, { label: 'C' }],
- pickIndex: 2,
- staggerMs: 25,
- pickAfterMs: 25,
- pickCaption: 'committed',
- }),
- ctx(makeStage()),
- );
+ const scene = chatPickList('opt-cpl', {
+ promptHandle: '@orch',
+ promptMessage: 'pick one',
+ items: [{ label: 'A', sub: 'option A' }, { label: 'B' }, { label: 'C' }],
+ pickIndex: 2,
+ staggerMs: 25,
+ pickAfterMs: 25,
+ pickCaption: 'committed',
+ });
+ const root = renderRoot(scene, 'opt-cpl');
+ expectClassText(root, 'cpl__prompt-handle', '@orch');
+ expectClassText(root, 'cpl__sub', 'option A');
+ expectClassText(root, 'cpl__pick-caption', 'committed');
+ expect(findAll(root, (node) => node.getAttribute('data-cpl-index') !== null)).toHaveLength(3);
+ expect(findByAttr(root, 'data-cpl-caption')).not.toBeNull();
+ assertLifecycle(scene, ctx(makeStage()));
});
});