feat(KAN-17): add Emerald design system and resilient themes

This commit is contained in:
2026-09-11 12:11:50 +00:00
parent 71d9d1b63e
commit 8657d75682
19 changed files with 757 additions and 254 deletions
+219
View File
@@ -0,0 +1,219 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import vm from 'node:vm';
import vue from '@vitejs/plugin-vue';
import { createServer } from 'vite';
import { createSSRApp, h } from 'vue';
import { renderToString } from 'vue/server-renderer';
const server = await createServer({
configFile: false,
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
},
},
server: { middlewareMode: true, hmr: false, watch: null },
appType: 'custom',
});
function browserState(cookie, stored, dark, storageDenied = false) {
const root = { dataset: { appearance: cookie }, style: {}, dark: false };
root.classList = {
toggle: (_, value) => {
root.dark = value;
},
};
const listeners = new Map();
const storage = new Map(stored === null ? [] : [['appearance', stored]]);
const media = {
matches: dark,
addEventListener: (_, listener) => listeners.set('media', listener),
removeEventListener: () => listeners.delete('media'),
};
return {
document: { documentElement: root, cookie: '' },
window: {
matchMedia: () => media,
localStorage: {
getItem: (key) => {
if (storageDenied) {
throw new Error('Storage denied');
}
return storage.get(key) ?? null;
},
setItem: (key, value) => {
if (storageDenied) {
throw new Error('Storage denied');
}
storage.set(key, value);
},
},
addEventListener: (_, listener) =>
listeners.set('storage', listener),
removeEventListener: () => listeners.delete('storage'),
},
root,
media,
listeners,
storage,
};
}
try {
const { createAppearance, normalizeAppearance } =
await server.ssrLoadModule(
'/resources/js/composables/useAppearance.ts',
);
const { createLocalization } = await server.ssrLoadModule(
'/resources/js/lib/localization.ts',
);
const { default: AppearanceTabs } = await server.ssrLoadModule(
'/resources/js/components/AppearanceTabs.vue',
);
const renderTheme = (value) =>
renderToString(
createSSRApp({ render: () => h(AppearanceTabs) })
.use(createLocalization())
.use(createAppearance(value)),
);
const [light, dark] = await Promise.all([
renderTheme('light'),
renderTheme('dark'),
]);
const selectedButton = (html) =>
html.match(
/<button[^>]*aria-pressed="true"[^>]*>([\s\S]*?)<\/button>/,
)[1];
assert.match(selectedButton(light), />Light</);
assert.match(selectedButton(dark), />Dark</);
assert.match(light, /role="group" aria-label="Color theme"/);
assert.equal((light.match(/type="button"/g) ?? []).length, 3);
assert.equal((dark.match(/aria-pressed="true"/g) ?? []).length, 1);
assert.equal(normalizeAppearance('unexpected'), 'system');
const blade = await readFile('resources/views/app.blade.php', 'utf8');
const bootstrap = blade.match(/<script>([\s\S]*?)<\/script>/)[1];
for (const [cookie, stored, osDark, denied, expected, preference] of [
['system', null, false, false, false, 'system'],
['system', null, true, false, true, 'system'],
['dark', null, false, false, true, 'dark'],
['light', null, true, false, false, 'light'],
['light', 'dark', false, false, true, 'dark'],
['dark', null, false, true, true, 'dark'],
['light', 'invalid', true, false, true, 'system'],
]) {
const state = browserState(cookie, stored, osDark, denied);
vm.runInNewContext(bootstrap, state);
assert.equal(state.root.dark, expected);
assert.equal(state.root.dataset.appearance, preference);
assert.equal(state.root.style.colorScheme, expected ? 'dark' : 'light');
}
for (const denied of [false, true]) {
const state = browserState('system', null, false, denied);
globalThis.window = state.window;
globalThis.document = state.document;
const theme = createAppearance();
let cleanup;
theme.install({
provide() {},
onUnmount(callback) {
cleanup = callback;
},
});
assert.equal(theme.resolvedAppearance.value, 'light');
state.listeners.get('media')({ matches: true });
assert.equal(theme.resolvedAppearance.value, 'dark');
assert.equal(state.root.dark, true);
theme.updateAppearance('light');
assert.equal(state.root.dark, false);
assert.match(state.document.cookie, /appearance=light;.*SameSite=Lax/);
state.listeners.get('media')({ matches: false });
state.listeners.get('media')({ matches: true });
assert.equal(theme.resolvedAppearance.value, 'light');
theme.updateAppearance('system');
assert.equal(theme.resolvedAppearance.value, 'dark');
state.listeners.get('storage')({
key: 'appearance',
newValue: 'light',
});
assert.equal(theme.appearance.value, 'light');
state.listeners.get('storage')({ key: null, newValue: null });
assert.equal(theme.appearance.value, 'system');
if (!denied) {
assert.equal(state.storage.get('appearance'), 'system');
}
cleanup();
assert.equal(state.listeners.size, 0);
delete globalThis.window;
delete globalThis.document;
}
const css = await readFile('resources/css/theme.css', 'utf8');
function luminance(hex) {
const values = hex
.match(/[a-f\d]{2}/gi)
.map((part) => parseInt(part, 16) / 255)
.map((value) =>
value <= 0.04045
? value / 12.92
: ((value + 0.055) / 1.055) ** 2.4,
);
return values[0] * 0.2126 + values[1] * 0.7152 + values[2] * 0.0722;
}
function contrast(first, second) {
const values = [luminance(first), luminance(second)].sort(
(a, b) => b - a,
);
return (values[0] + 0.05) / (values[1] + 0.05);
}
for (const selector of [':root', '.dark']) {
const declarations = css
.slice(css.indexOf(selector))
.match(/\{([^}]+)\}/)[1];
const tokens = Object.fromEntries(
[...declarations.matchAll(/--([\w-]+):\s*(#[a-f\d]{6})/gi)].map(
(match) => [match[1], match[2]],
),
);
for (const [background, foreground] of [
['background', 'foreground'],
['card', 'card-foreground'],
['popover', 'popover-foreground'],
['primary', 'primary-foreground'],
['secondary', 'secondary-foreground'],
['muted', 'muted-foreground'],
['accent', 'accent-foreground'],
['destructive', 'destructive-foreground'],
['card', 'destructive'],
['brand', 'brand-foreground'],
['success', 'success-foreground'],
['warning', 'warning-foreground'],
['info', 'info-foreground'],
['sidebar-background', 'sidebar-foreground'],
['sidebar-accent', 'sidebar-accent-foreground'],
['sidebar-primary', 'sidebar-primary-foreground'],
]) {
assert.ok(
contrast(tokens[background], tokens[foreground]) >= 4.5,
selector + ': ' + background + '/' + foreground,
);
}
assert.ok(contrast(tokens.background, tokens.ring) >= 3);
assert.ok(contrast(tokens.card, tokens.input) >= 3);
}
const appCss = await readFile('resources/css/app.css', 'utf8');
assert.match(appCss, /@media \(prefers-reduced-motion: reduce\)/);
assert.match(appCss, /animation-duration: 0.01ms !important/);
assert.match(appCss, /transition-duration: 0.01ms !important/);
assert.match(appCss, /scroll-behavior: auto !important/);
console.log('Appearance behavior checks passed.');
} finally {
delete globalThis.window;
delete globalThis.document;
await server.close();
}