import assert from 'node:assert/strict'; import test from 'node:test'; import { buildElement, dialogWidth, toastDuration, trapIndex, validationMessage, UI_CSS, WIDTHS } from '../src/ui.ts'; import { contrastRatio, createThemeApi, normalizeResolved, readableOn, themeFromColors, themeVars, themeVarsCss, toHex, type ResolvedTheme, type ThemeSource, } from '../src/themeApi.ts'; import { createCorePluginApi, isTypingTarget, type PluginApiSite } from '../src/pluginApi.ts'; test('dialog widths: presets, clamped numbers, md by default', () => { assert.equal(dialogWidth(undefined), WIDTHS.md); assert.equal(dialogWidth('sm'), 380); assert.equal(dialogWidth('lg'), 640); assert.equal(dialogWidth(100), 260); assert.equal(dialogWidth(5000), 960); assert.equal(dialogWidth(520.4), 520); assert.equal(dialogWidth(Number.NaN), WIDTHS.md); }); test('toast duration: defaults, action default, sticky and clamping', () => { assert.equal(toastDuration({}), 3200); assert.equal(toastDuration({ action: { label: 'Undo', onClick() {} } }), 6000); assert.equal(toastDuration({ duration: 0 }), 0, '0 keeps it until closed'); assert.equal(toastDuration({ duration: Infinity }), 0); assert.equal(toastDuration({ duration: 10 }), 800); assert.equal(toastDuration({ duration: 9000 }), 9000); assert.equal(toastDuration({ duration: 10 ** 9 }), 60_000); }); test('focus trap wraps at both ends and lets the browser move focus in between', () => { assert.equal(trapIndex(0, -1, false), -1, 'nothing to focus'); assert.equal(trapIndex(3, -1, false), 0, 'focus outside the list enters at the start'); assert.equal(trapIndex(3, -1, true), 2, 'Shift+Tab from outside enters at the end'); assert.equal(trapIndex(3, 2, false), 0, 'Tab on the last wraps to the first'); assert.equal(trapIndex(3, 0, true), 2, 'Shift+Tab on the first wraps to the last'); assert.equal(trapIndex(3, 1, false), -1); assert.equal(trapIndex(3, 1, true), -1); assert.equal(trapIndex(1, 0, false), 0, 'a single element keeps focus'); }); test('validation answers become error messages', () => { assert.equal(validationMessage('Too long'), 'Too long'); assert.equal(validationMessage(' '), null); assert.equal(validationMessage(false), 'Please check this value.'); for (const ok of [true, null, undefined, 0, '']) assert.equal(validationMessage(ok), null); }); test('the kit stylesheet is scoped and honours reduced motion', () => { assert.match(UI_CSS, /prefers-reduced-motion: reduce/); assert.match(UI_CSS, /:host \{ all: initial; \}/); // Every rule targets the kit's own classes (no bare element selectors that could leak by accident). for (const selector of UI_CSS.replace(/\/\*[\s\S]*?\*\//g, '').replace(/@keyframes[^{]+\{(?:[^{}]*\{[^}]*\})*\s*\}/g, '').match(/(^|\})\s*([^{}@]+)\{/g) ?? []) { const text = selector.replace(/^\}?\s*/, '').replace(/\{$/, '').trim(); if (!text || text.startsWith('from') || text.startsWith('to')) continue; for (const part of text.split(',')) assert.match(part.trim(), /^(:host|\.wb-)/, part); } }); type FakeNode = { tag: string; attrs: Record; props: Record; children: unknown[]; listeners: Record; className: string; textContent: string; style: Record; dataset: Record; setAttribute(k: string, v: string): void; addEventListener(t: string, f: unknown): void; appendChild(c: unknown): unknown; }; function fakeDoc() { return { createElement(tag: string) { const node: FakeNode = { tag, attrs: {}, props: {}, children: [], listeners: {}, className: '', textContent: '', style: {}, dataset: {}, setAttribute(k, v) { node.attrs[k] = v; }, addEventListener(t, f) { node.listeners[t] = f; }, appendChild(c) { node.children.push(c); return c; }, }; return node; }, createTextNode(text: string) { return { text }; }, } as unknown as Document; } test('ui.h builds from text only: no innerHTML, string handlers or javascript: URLs', () => { const doc = fakeDoc(); const click = () => {}; const el = buildElement(doc, 'a', { class: 'wb-check', href: 'javascript:alert(1)', onclick: 'alert(1)', onClick: click, innerHTML: '', title: 'hi', hidden: true, skip: false, checked: false, dataset: { id: '7' }, }, 'not html', 3, null, ['a', false]) as unknown as FakeNode; assert.equal(el.className, 'wb-check'); assert.equal(el.attrs.href, undefined, 'javascript: URL dropped'); assert.equal(el.attrs.onclick, undefined, 'string handler dropped'); assert.equal(el.listeners.click, click, 'function handler becomes a listener'); assert.equal((el as unknown as Record).innerHTML, undefined); assert.equal(el.attrs.innerHTML, undefined); assert.equal(el.attrs.title, 'hi'); assert.equal(el.attrs.hidden, ''); assert.equal(el.attrs.skip, undefined, 'false attributes are left out'); assert.equal((el as unknown as Record).checked, false, 'false properties are still set'); assert.equal(el.dataset.id, '7'); assert.deepEqual(el.children, [{ text: 'not html' }, { text: '3' }, { text: 'a' }]); assert.throws(() => buildElement(doc, 'script'), /unsupported tag/); assert.throws(() => buildElement(doc, 'img onerror=x'), /unsupported tag/); }); test('colors: hex parsing, contrast, readable label color', () => { assert.equal(toHex('rgb(0, 0, 0)'), '#000000'); assert.equal(toHex('rgba(21, 32, 43, 1)'), '#15202b'); assert.equal(toHex('rgba(0, 0, 0, 0)'), null, 'transparent is no color'); assert.equal(toHex('#FFF'), '#ffffff'); assert.equal(toHex('red'), null); assert.ok(Math.abs(contrastRatio('#000000', '#ffffff') - 21) < 0.01); assert.equal(readableOn('#0b5ed7'), '#ffffff', 'white when it reaches AA'); assert.equal(readableOn('#1d9bf0'), '#000000', "X's blue reads better with black (7:1 vs 3:1)"); assert.equal(readableOn('#ffd400'), '#000000'); }); test('a resolved theme fills missing roles, clamps geometry and becomes --wb-* variables', () => { const partial = { id: 'demo', name: 'Demo', mode: 'dark', siteDefault: false, radius: 99, buttonRadius: 9999, font: '', colors: { bg: '#000000', text: '#E7E9EA', accent: '#1d9bf0' } } as unknown as ResolvedTheme; const theme = normalizeResolved(partial); assert.equal(theme.colors.text, '#e7e9ea'); assert.ok(theme.colors.surface && theme.colors.border && theme.colors.muted, 'derived roles'); assert.equal(theme.colors.onAccent, readableOn('#1d9bf0')); assert.equal(theme.radius, 28, 'radius clamped'); assert.ok(theme.font.includes('sans-serif'), 'font falls back to a system stack'); assert.ok(Object.isFrozen(theme) && Object.isFrozen(theme.colors)); const vars = themeVars(theme); assert.equal(vars['--wb-bg'], '#000000'); assert.equal(vars['--wb-on-inverse'], theme.colors.onInverse); assert.equal(vars['--wb-radius'], '28px'); assert.equal(vars['--wb-mode'], 'dark'); const css = themeVarsCss({ ...theme, font: 'Evil;}