feat(KAN-17): add Emerald design system and resilient themes
This commit is contained in:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user