From 6c8d9cbc16ebeebe1d9aa244fa31d11a449e622f Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 1 Oct 2026 09:35:13 +0200 Subject: [PATCH 1/4] Map the dark error color to a lighter red that meets WCAG AA --- ..._using_the_error_color_on_dark_surfaces.ts | 94 +++++++++++++++++++ Source/theme.css | 10 ++ 2 files changed, 104 insertions(+) create mode 100644 Source/for_theme/when_using_the_error_color_on_dark_surfaces.ts diff --git a/Source/for_theme/when_using_the_error_color_on_dark_surfaces.ts b/Source/for_theme/when_using_the_error_color_on_dark_surfaces.ts new file mode 100644 index 00000000..4a1987af --- /dev/null +++ b/Source/for_theme/when_using_the_error_color_on_dark_surfaces.ts @@ -0,0 +1,94 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { readFileSync } from 'node:fs'; +import { expect } from 'chai'; +import postcss, { type AtRule, type Rule } from 'postcss'; +import { beforeEach, describe, it } from 'vitest'; + +const theme = readFileSync(new URL('../theme.css', import.meta.url), 'utf8'); + +// WCAG 2.x AA minimum for normal-size text. +const minimumContrast = 4.5; + +// Surfaces a field's validation message is drawn on. +const textSurfaces = [ + '--cratis-surface-ground', + '--cratis-surface-section', + '--cratis-surface-card', + '--cratis-surface-overlay', + '--cratis-control-background', +]; + +function declaration(rule: Rule, property: string): string | undefined { + return rule.nodes.findLast(node => node.type === 'decl' && node.prop === property)?.value; +} + +function channel(hex: string, start: number): number { + const value = parseInt(hex.slice(start, start + 2), 16) / 255; + return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; +} + +function luminance(color: string): number { + const hex = color.trim().replace('#', ''); + const full = hex.length === 3 ? [...hex].map(character => character + character).join('') : hex; + return 0.2126 * channel(full, 0) + 0.7152 * channel(full, 2) + 0.0722 * channel(full, 4); +} + +function contrast(foreground: string, background: string): number { + const [lighter, darker] = [luminance(foreground), luminance(background)].sort((a, b) => b - a); + return (lighter + 0.05) / (darker + 0.05); +} + +function resolve(rule: Rule, value: string): string { + const reference = /^var\((--[\w-]+)\)$/.exec(value); + if (!reference) return value; + const resolved = declaration(rule, reference[1]); + if (resolved === undefined) throw new Error(`${reference[1]} is not declared next to ${value}`); + return resolve(rule, resolved); +} + +describe('when using the error color on dark surfaces', () => { + let darkRules: Rule[]; + let lightSubtreeRules: Rule[]; + + beforeEach(() => { + darkRules = []; + lightSubtreeRules = []; + postcss.parse(theme).walkRules(rule => { + // Forced colors replace the palette with system colors, so contrast is the user's own. + if (rule.parent?.type === 'atrule' && (rule.parent as AtRule).params === '(forced-colors: active)') return; + if (rule.selectors.includes(':root.cratis-dark') || rule.selectors.includes(':root:not(.cratis-light)')) { + if (declaration(rule, '--cratis-surface-card') !== undefined) darkRules.push(rule); + } else if (rule.selectors.includes('.cratis-dark .cratis-theme.cratis-light')) { + lightSubtreeRules.push(rule); + } + }); + }); + + it('should_find_both_the_explicit_and_the_system_dark_scheme', () => { + expect(darkRules).to.have.lengthOf(2); + }); + + it('should_map_the_error_color_in_every_dark_scheme', () => { + for (const rule of darkRules) { + expect(declaration(rule, '--color-error'), rule.selectors.join(', ')).not.to.be.undefined; + } + }); + + it('should_reach_the_minimum_contrast_on_every_dark_text_surface', () => { + for (const rule of darkRules) { + const error = resolve(rule, declaration(rule, '--color-error')!); + for (const surface of textSurfaces) { + const background = resolve(rule, declaration(rule, surface)!); + expect(contrast(error, background), `${rule.selectors[0]} on ${surface}`).to.be.at.least(minimumContrast); + } + } + }); + + it('should_give_a_light_subtree_inside_a_dark_root_its_own_error_color', () => { + const restored = lightSubtreeRules.find(rule => declaration(rule, '--color-error') !== undefined); + expect(restored).not.to.be.undefined; + expect(contrast(resolve(restored!, declaration(restored!, '--color-error')!), '#ffffff')).to.be.at.least(minimumContrast); + }); +}); diff --git a/Source/theme.css b/Source/theme.css index deed603a..60da8570 100644 --- a/Source/theme.css +++ b/Source/theme.css @@ -70,6 +70,14 @@ --cratis-shadow-toast: 0 2px 12px rgb(0 0 0 / 10%); } +/* Arc draws a field's validation message in var(--color-error, #c00), which is 2.5:1 on the dark + surfaces. The dark scheme maps it to the lighter red the field outline already uses (5.3:1 on the + card surface). An explicitly light subtree inside a dark root gets the Arc fallback back. */ +:root.cratis-dark.cratis-light, +.cratis-dark .cratis-theme.cratis-light { + --color-error: #c00; +} + :root.cratis-dark, .cratis-dark .cratis-theme, .cratis-theme.cratis-dark { @@ -82,6 +90,7 @@ --cratis-action-background-active: #bfdbfe; --cratis-action-text: #030712; --cratis-red-500: #f87171; + --color-error: var(--cratis-red-500); --cratis-info-background: #38bdf8; --cratis-info-text: #082f49; --cratis-success-background: #4ade80; @@ -127,6 +136,7 @@ --cratis-action-background-active: #bfdbfe; --cratis-action-text: #030712; --cratis-red-500: #f87171; + --color-error: var(--cratis-red-500); --cratis-info-background: #38bdf8; --cratis-info-text: #082f49; --cratis-success-background: #4ade80; From b9f721e48f983def4e6ac2b575f4ecf405495c8c Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 1 Oct 2026 09:35:13 +0200 Subject: [PATCH 2/4] Set up the stylesheets from CSS so the setup type-checks on TypeScript 6 --- README.md | 25 +++++++++++++++++++------ Source/README.md | 26 ++++++++++++++++++-------- 2 files changed, 37 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 88cff02a..583146ba 100644 --- a/README.md +++ b/README.md @@ -63,15 +63,21 @@ installs, and `pixi.js` `^8.20.0` is an optional peer needed only by `Canvas` an The package does not depend on PrimeReact; the optional MUI and PrimeReact renderer adapters are separate packages. -Import `reflect-metadata`, the semantic tokens, and the component structure once at the application -entry point. The baseline theme is optional. Mount Components' provider inside Arc's `` -provider, which supplies the command and query runtime the generated proxies use: +Import `reflect-metadata` once at the application entry point, and the semantic tokens and the +component structure from your CSS entry file. The baseline theme is optional. Mount Components' +provider inside Arc's `` provider, which supplies the command and query runtime the generated +proxies use: + +```css +/* index.css */ +@import '@cratis/components/tokens'; +@import '@cratis/components/styles'; +@import '@cratis/components/theme'; /* optional baseline appearance */ +``` ```tsx import 'reflect-metadata'; -import '@cratis/components/tokens'; -import '@cratis/components/styles'; -import '@cratis/components/theme'; // optional baseline appearance +import './index.css'; import { Arc } from '@cratis/arc.react'; import { CratisComponentsProvider } from '@cratis/components'; @@ -84,6 +90,13 @@ export const App = () => ( ); ``` +Import the stylesheets from CSS, not from TypeScript. The stylesheet subpaths ship no type +declarations, so on TypeScript 6, which checks side-effect imports by default, +`import '@cratis/components/tokens'` in a `.tsx` file fails with TS2882. The `./index.css` import +type-checks in a Vite project, whose `vite/client` types declare `*.css`; otherwise add +`declare module '*.css';` to an ambient `.d.ts` file. The [package README](./Source/README.md#styles) +shows the declarations for importing the stylesheet subpaths from TypeScript instead. + The package root is setup-only: import the provider and configuration helpers there, then import every component from its explicit subpath. The built-in renderer needs no additional package or configuration. diff --git a/Source/README.md b/Source/README.md index 364a471f..ab7a8b20 100644 --- a/Source/README.md +++ b/Source/README.md @@ -65,10 +65,20 @@ Keep exactly one compatible Pixi resolution across the application and Component ## Styles +```css +/* index.css */ +@import '@cratis/components/tokens'; +@import '@cratis/components/styles'; +@import '@cratis/components/theme'; /* optional baseline appearance */ +``` + +Import this file once at the application entry point, for example `import './index.css';`. Import the stylesheets from CSS rather than from TypeScript: the subpaths ship no type declarations, so on TypeScript 6, which checks side-effect imports by default, `import '@cratis/components/tokens'` in a `.tsx` file fails with TS2882. A Vite project type-checks `import './index.css'` through its `vite/client` types. A project that still imports the stylesheet subpaths from TypeScript declares them in an ambient `.d.ts` file: + ```ts -import '@cratis/components/tokens'; -import '@cratis/components/styles'; -import '@cratis/components/theme'; // optional baseline appearance +declare module '@cratis/components/tokens'; +declare module '@cratis/components/styles'; +declare module '@cratis/components/styles/base'; +declare module '@cratis/components/theme'; ``` `tokens` supplies conservative light defaults. `styles` contains structural rules and internal utilities in low-priority Cratis cascade layers, with no Tailwind Preflight/reset or token duplication. `theme` adds automatic/explicit dark mode, forced colors, and themed subtrees. @@ -77,11 +87,11 @@ A custom product design omits `theme`, imports product CSS after `tokens` and `s `styles` is every component's CSS in one file. To pay only for the surfaces the application mounts, import the shared base plus one entry point per subpath instead: -```ts -import '@cratis/components/tokens'; -import '@cratis/components/styles/base'; -import '@cratis/components/Dialogs/styles'; -import '@cratis/components/DataTables/styles'; +```css +@import '@cratis/components/tokens'; +@import '@cratis/components/styles/base'; +@import '@cratis/components/Dialogs/styles'; +@import '@cratis/components/DataTables/styles'; ``` Every JavaScript subpath publishes a matching `/styles`, self-contained for that subpath. `styles/base` carries the internal utilities and the cascade-layer order, and is required exactly once; the aggregate already contains it. From 8bd2ddc29e08452e9c760644fe61eea3e28866fa Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 1 Oct 2026 11:17:57 +0200 Subject: [PATCH 3/4] Map the error background in the dark theme and restore the error colors for every light subtree --- ..._using_the_error_color_on_dark_surfaces.ts | 58 +++++++++++++++---- Source/theme.css | 17 +++--- 2 files changed, 57 insertions(+), 18 deletions(-) diff --git a/Source/for_theme/when_using_the_error_color_on_dark_surfaces.ts b/Source/for_theme/when_using_the_error_color_on_dark_surfaces.ts index 4a1987af..75a94482 100644 --- a/Source/for_theme/when_using_the_error_color_on_dark_surfaces.ts +++ b/Source/for_theme/when_using_the_error_color_on_dark_surfaces.ts @@ -20,6 +20,15 @@ const textSurfaces = [ '--cratis-control-background', ]; +// Every selector that makes a root or subtree explicitly light. +const explicitLightSelectors = [ + ':root.cratis-light', + ':root.cratis-dark.cratis-light', + ':root.cratis-light .cratis-theme:not(.cratis-dark)', + '.cratis-theme.cratis-light', + '.cratis-dark .cratis-theme.cratis-light', +]; + function declaration(rule: Rule, property: string): string | undefined { return rule.nodes.findLast(node => node.type === 'decl' && node.prop === property)?.value; } @@ -48,20 +57,25 @@ function resolve(rule: Rule, value: string): string { return resolve(rule, resolved); } -describe('when using the error color on dark surfaces', () => { +describe('when using the error colors on dark surfaces', () => { let darkRules: Rule[]; - let lightSubtreeRules: Rule[]; + let systemDarkRule: Rule; + let lightRules: Map; beforeEach(() => { darkRules = []; - lightSubtreeRules = []; + lightRules = new Map(); postcss.parse(theme).walkRules(rule => { // Forced colors replace the palette with system colors, so contrast is the user's own. if (rule.parent?.type === 'atrule' && (rule.parent as AtRule).params === '(forced-colors: active)') return; + if (rule.selectors.includes(':root:not(.cratis-light)')) systemDarkRule = rule; if (rule.selectors.includes(':root.cratis-dark') || rule.selectors.includes(':root:not(.cratis-light)')) { if (declaration(rule, '--cratis-surface-card') !== undefined) darkRules.push(rule); - } else if (rule.selectors.includes('.cratis-dark .cratis-theme.cratis-light')) { - lightSubtreeRules.push(rule); + } + for (const selector of rule.selectors) { + if (explicitLightSelectors.includes(selector) && declaration(rule, '--color-error') !== undefined) { + lightRules.set(selector, rule); + } } }); }); @@ -70,9 +84,10 @@ describe('when using the error color on dark surfaces', () => { expect(darkRules).to.have.lengthOf(2); }); - it('should_map_the_error_color_in_every_dark_scheme', () => { + it('should_map_the_error_color_pair_in_every_dark_scheme', () => { for (const rule of darkRules) { expect(declaration(rule, '--color-error'), rule.selectors.join(', ')).not.to.be.undefined; + expect(declaration(rule, '--color-error-bg'), rule.selectors.join(', ')).not.to.be.undefined; } }); @@ -86,9 +101,32 @@ describe('when using the error color on dark surfaces', () => { } }); - it('should_give_a_light_subtree_inside_a_dark_root_its_own_error_color', () => { - const restored = lightSubtreeRules.find(rule => declaration(rule, '--color-error') !== undefined); - expect(restored).not.to.be.undefined; - expect(contrast(resolve(restored!, declaration(restored!, '--color-error')!), '#ffffff')).to.be.at.least(minimumContrast); + it('should_reach_the_minimum_contrast_for_the_error_text_on_the_error_background_in_every_dark_scheme', () => { + for (const rule of darkRules) { + const error = resolve(rule, declaration(rule, '--color-error')!); + const background = resolve(rule, declaration(rule, '--color-error-bg')!); + expect(contrast(error, background), rule.selectors[0]).to.be.at.least(minimumContrast); + } + }); + + it('should_restore_the_error_color_pair_for_every_explicit_light_selector', () => { + for (const selector of explicitLightSelectors) { + const rule = lightRules.get(selector); + expect(rule, selector).not.to.be.undefined; + expect(declaration(rule!, '--color-error-bg'), selector).not.to.be.undefined; + const error = resolve(rule!, declaration(rule!, '--color-error')!); + const errorBackground = resolve(rule!, declaration(rule!, '--color-error-bg')!); + expect(contrast(error, '#ffffff'), `${selector} on white`).to.be.at.least(minimumContrast); + expect(contrast(error, errorBackground), `${selector} on the error background`).to.be.at.least(minimumContrast); + } + }); + + it('should_give_a_light_subtree_under_a_system_dark_root_the_light_error_colors', () => { + // The system dark rule must not match the explicit light subtree, otherwise its dark values win. + expect(systemDarkRule.selectors).to.include('.cratis-theme:not(.cratis-light)'); + expect(systemDarkRule.selectors).not.to.include('.cratis-theme'); + const restored = lightRules.get('.cratis-theme.cratis-light')!; + expect(declaration(restored, '--color-error')).to.equal('#c00'); + expect(declaration(restored, '--color-error-bg')).to.equal('#fee'); }); }); diff --git a/Source/theme.css b/Source/theme.css index 60da8570..b6c2a0f9 100644 --- a/Source/theme.css +++ b/Source/theme.css @@ -35,6 +35,8 @@ --cratis-green-500: #16a34a; --cratis-orange-500: #d97706; --cratis-red-500: #dc2626; + --color-error: #c00; + --color-error-bg: #fee; --cratis-info-background: #0369a1; --cratis-info-text: #ffffff; --cratis-success-background: #15803d; @@ -70,14 +72,11 @@ --cratis-shadow-toast: 0 2px 12px rgb(0 0 0 / 10%); } -/* Arc draws a field's validation message in var(--color-error, #c00), which is 2.5:1 on the dark - surfaces. The dark scheme maps it to the lighter red the field outline already uses (5.3:1 on the - card surface). An explicitly light subtree inside a dark root gets the Arc fallback back. */ -:root.cratis-dark.cratis-light, -.cratis-dark .cratis-theme.cratis-light { - --color-error: #c00; -} - +/* Arc draws an error in var(--color-error, #c00) on var(--color-error-bg, #fee): a field's validation + message and the command exception panel. Those fallbacks are 2.5:1 / 2.47:1 on the dark surfaces, so + the dark schemes below map the pair to a lighter red on a deep red. Every explicit-light selector in + the shared light block above restores the Arc fallbacks, including a light subtree under a root that + is dark only because of the system setting. */ :root.cratis-dark, .cratis-dark .cratis-theme, .cratis-theme.cratis-dark { @@ -91,6 +90,7 @@ --cratis-action-text: #030712; --cratis-red-500: #f87171; --color-error: var(--cratis-red-500); + --color-error-bg: #450a0a; --cratis-info-background: #38bdf8; --cratis-info-text: #082f49; --cratis-success-background: #4ade80; @@ -137,6 +137,7 @@ --cratis-action-text: #030712; --cratis-red-500: #f87171; --color-error: var(--cratis-red-500); + --color-error-bg: #450a0a; --cratis-info-background: #38bdf8; --cratis-info-text: #082f49; --cratis-success-background: #4ade80; From 1a13143c48b2c28022ef43b53b87b277b544f62e Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 1 Oct 2026 11:35:50 +0200 Subject: [PATCH 4/4] Note that the theme maps Arc's error tokens --- Source/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/README.md b/Source/README.md index ab7a8b20..858084b5 100644 --- a/Source/README.md +++ b/Source/README.md @@ -81,7 +81,7 @@ declare module '@cratis/components/styles/base'; declare module '@cratis/components/theme'; ``` -`tokens` supplies conservative light defaults. `styles` contains structural rules and internal utilities in low-priority Cratis cascade layers, with no Tailwind Preflight/reset or token duplication. `theme` adds automatic/explicit dark mode, forced colors, and themed subtrees. +`tokens` supplies conservative light defaults. `styles` contains structural rules and internal utilities in low-priority Cratis cascade layers, with no Tailwind Preflight/reset or token duplication. `theme` adds automatic/explicit dark mode, forced colors, and themed subtrees. It also maps Arc's `--color-error` and `--color-error-bg` for each light and dark scheme, so an app that customizes those Arc tokens sets them after importing `theme`. A custom product design omits `theme`, imports product CSS after `tokens` and `styles`, and maps its canonical values directly to `--cratis-*`.