Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 19 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Arc>`
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 `<Arc>` 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';

Expand All @@ -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.
Expand Down
28 changes: 19 additions & 9 deletions Source/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,23 +65,33 @@ 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.
`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-*`.

`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 `<subpath>/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.
Expand Down
132 changes: 132 additions & 0 deletions Source/for_theme/when_using_the_error_color_on_dark_surfaces.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// 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',
];

// 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;
}

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 colors on dark surfaces', () => {
let darkRules: Rule[];
let systemDarkRule: Rule;
let lightRules: Map<string, Rule>;

beforeEach(() => {
darkRules = [];
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);
}
for (const selector of rule.selectors) {
if (explicitLightSelectors.includes(selector) && declaration(rule, '--color-error') !== undefined) {
lightRules.set(selector, rule);
}
}
});
});

it('should_find_both_the_explicit_and_the_system_dark_scheme', () => {
expect(darkRules).to.have.lengthOf(2);
});

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;
}
});

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_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');
});
});
11 changes: 11 additions & 0 deletions Source/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -70,6 +72,11 @@
--cratis-shadow-toast: 0 2px 12px rgb(0 0 0 / 10%);
}

/* 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 {
Expand All @@ -82,6 +89,8 @@
--cratis-action-background-active: #bfdbfe;
--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;
Expand Down Expand Up @@ -127,6 +136,8 @@
--cratis-action-background-active: #bfdbfe;
--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;
Expand Down
Loading