@eagami/ui 4.5.1 → 4.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eagami/ui",
3
- "version": "4.5.1",
3
+ "version": "4.7.0",
4
4
  "description": "Lightweight, accessible, themeable Angular UI component library and icon set built on CSS custom properties",
5
5
  "author": "Michal Wiraszka <michal@eagami.com>",
6
6
  "license": "MIT",
@@ -55,6 +55,9 @@
55
55
  "files": [
56
56
  "fesm2022",
57
57
  "types",
58
+ "src/styles",
59
+ "schematics",
58
60
  "README.md"
59
- ]
61
+ ],
62
+ "schematics": "./schematics/collection.json"
60
63
  }
@@ -0,0 +1,9 @@
1
+ {
2
+ "schematics": {
3
+ "ng-add": {
4
+ "description": "Set up Eagami UI: register the global stylesheet and fonts in an Angular workspace.",
5
+ "factory": "./ng-add/index#ngAdd",
6
+ "schema": "./ng-add/schema.json"
7
+ }
8
+ }
9
+ }
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ngAdd = ngAdd;
4
+ const schematics_1 = require("@angular-devkit/schematics");
5
+ const workspace_1 = require("@schematics/angular/utility/workspace");
6
+ const utils_1 = require("./utils");
7
+ function ngAdd(options) {
8
+ return (0, schematics_1.chain)([registerStylesheet(options), registerFonts(options), logNextSteps()]);
9
+ }
10
+ function registerStylesheet(options) {
11
+ return (0, workspace_1.updateWorkspace)(workspace => {
12
+ const { project } = findProject(workspace, options.project);
13
+ const build = project.targets.get('build');
14
+ if (!build) {
15
+ throw new schematics_1.SchematicsException('No "build" target found; cannot register the Eagami UI stylesheet.');
16
+ }
17
+ build.options ??= {};
18
+ const existing = build.options['styles'];
19
+ const styles = Array.isArray(existing) ? existing : [];
20
+ build.options['styles'] = (0, utils_1.withEagamiStyle)(styles);
21
+ });
22
+ }
23
+ function registerFonts(options) {
24
+ return async (tree, context) => {
25
+ const workspace = await (0, workspace_1.getWorkspace)(tree);
26
+ const { name, project } = findProject(workspace, options.project);
27
+ const indexPath = (0, utils_1.resolveIndexPath)(project.targets.get('build')?.options?.['index']);
28
+ if (!indexPath) {
29
+ context.logger.warn(`No index.html found for project "${name}"; add the Eagami UI fonts manually.`);
30
+ return;
31
+ }
32
+ const current = tree.read(indexPath);
33
+ if (!current) {
34
+ context.logger.warn(`Could not read "${indexPath}"; add the Eagami UI fonts manually.`);
35
+ return;
36
+ }
37
+ const html = current.toString('utf-8');
38
+ const updated = (0, utils_1.withEagamiFonts)(html);
39
+ if (updated !== html) {
40
+ tree.overwrite(indexPath, updated);
41
+ }
42
+ };
43
+ }
44
+ function logNextSteps() {
45
+ return (_tree, context) => {
46
+ context.logger.info('Eagami UI is set up: the global stylesheet and fonts are registered.');
47
+ context.logger.info('Optional: call provideEagamiUi() in app.config.ts for a custom brand palette or extra locales.');
48
+ };
49
+ }
50
+ function findProject(workspace, name) {
51
+ if (name) {
52
+ const project = workspace.projects.get(name);
53
+ if (!project) {
54
+ throw new schematics_1.SchematicsException(`Project "${name}" not found in the workspace.`);
55
+ }
56
+ return { name, project };
57
+ }
58
+ for (const [projectName, project] of workspace.projects) {
59
+ if (project.extensions['projectType'] === 'application') {
60
+ return { name: projectName, project };
61
+ }
62
+ }
63
+ throw new schematics_1.SchematicsException('No application project found to set up Eagami UI.');
64
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,16 @@
1
+ {
2
+ "$schema": "http://json-schema.org/schema",
3
+ "$id": "eagami-ui-ng-add",
4
+ "title": "Eagami UI ng-add schematic",
5
+ "type": "object",
6
+ "properties": {
7
+ "project": {
8
+ "type": "string",
9
+ "description": "The project to set up Eagami UI in.",
10
+ "$default": {
11
+ "$source": "projectName"
12
+ }
13
+ }
14
+ },
15
+ "additionalProperties": false
16
+ }
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+ // Pure, dependency-free transforms for the ng-add schematic. Kept separate from
3
+ // index.ts (which pulls in @angular-devkit) so the unit tests compile under the
4
+ // Angular test build without dragging Node-only schematics APIs into the bundle.
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.EAGAMI_FONT_LINKS = exports.EAGAMI_STYLE_PATH = void 0;
7
+ exports.styleEntryMatches = styleEntryMatches;
8
+ exports.withEagamiStyle = withEagamiStyle;
9
+ exports.hasEagamiFonts = hasEagamiFonts;
10
+ exports.withEagamiFonts = withEagamiFonts;
11
+ exports.resolveIndexPath = resolveIndexPath;
12
+ exports.EAGAMI_STYLE_PATH = 'node_modules/@eagami/ui/src/styles/eagami-ui.scss';
13
+ exports.EAGAMI_FONT_LINKS = [
14
+ '<link rel="preconnect" href="https://fonts.googleapis.com" />',
15
+ '<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />',
16
+ '<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;1,9..40,400&family=Syne:wght@400;500;600;700&display=swap" />',
17
+ ];
18
+ // Unique to our Google Fonts request, so it doubles as the idempotency marker
19
+ const FONT_MARKER = 'family=DM+Sans';
20
+ function styleEntryMatches(entry, path) {
21
+ return typeof entry === 'string' ? entry === path : entry.input === path;
22
+ }
23
+ function withEagamiStyle(styles) {
24
+ if (styles.some(style => styleEntryMatches(style, exports.EAGAMI_STYLE_PATH))) {
25
+ return styles;
26
+ }
27
+ return [exports.EAGAMI_STYLE_PATH, ...styles];
28
+ }
29
+ function hasEagamiFonts(html) {
30
+ return html.includes(FONT_MARKER);
31
+ }
32
+ function withEagamiFonts(html) {
33
+ if (hasEagamiFonts(html)) {
34
+ return html;
35
+ }
36
+ const block = exports.EAGAMI_FONT_LINKS.map(link => ` ${link}`).join('\n');
37
+ const headClose = /([ \t]*)<\/head>/i;
38
+ if (headClose.test(html)) {
39
+ return html.replace(headClose, `${block}\n$1</head>`);
40
+ }
41
+ return `${block}\n${html}`;
42
+ }
43
+ // The build target's `index` option is either a path string or a { input, output } object
44
+ function resolveIndexPath(index) {
45
+ if (typeof index === 'string') {
46
+ return index;
47
+ }
48
+ if (index !== null && typeof index === 'object' && 'input' in index) {
49
+ const input = index.input;
50
+ return typeof input === 'string' ? input : undefined;
51
+ }
52
+ return undefined;
53
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "commonjs"
3
+ }
@@ -0,0 +1,68 @@
1
+ // Shared SCSS mixins for the library. Consume from a component stylesheet with:
2
+ // @use '../../styles/mixins' as ea;
3
+ // .ea-foo__close { @include ea.icon-button; }
4
+
5
+ // Standard focus indicator. The ring is a soft box-shadow halo, but box-shadow
6
+ // is dropped in forced-colors (Windows High Contrast) mode, which would leave
7
+ // keyboard users with no visible focus. Pair the halo with a real outline that
8
+ // only renders in forced-colors so the ring survives there. Pass the full
9
+ // box-shadow value to compose the ring with an existing shadow.
10
+ @mixin focus-ring($ring: var(--shadow-focus-ring)) {
11
+ box-shadow: $ring;
12
+
13
+ @media (forced-colors: active) {
14
+ outline: 2px solid Highlight;
15
+ outline-offset: 2px;
16
+ }
17
+ }
18
+
19
+ // Floating surfaces (menus, dialogs, popovers, toasts) lean on elevation shadow
20
+ // for their boundary, but shadows are dropped in forced-colors mode. Add a
21
+ // hairline border there so the surface stays separated from the content behind.
22
+ @mixin elevated-surface-border {
23
+ @media (forced-colors: active) {
24
+ border: 1px solid CanvasText;
25
+ }
26
+ }
27
+
28
+ // Standard icon button: clear, close/dismiss, password-toggle, nav, etc. The box
29
+ // is `--ea-icon-button-size` (em) so it scales with the host component's size and
30
+ // is consistent across components at a given size tier. The glyph is enlarged so
31
+ // it reads clearly inside the box regardless of how much padding the icon's own
32
+ // viewBox carries (the feather `x`, for instance, only fills its middle half).
33
+ @mixin icon-button {
34
+ display: inline-flex;
35
+ align-items: center;
36
+ justify-content: center;
37
+ flex-shrink: 0;
38
+ width: var(--ea-icon-button-size, 1.75em);
39
+ height: var(--ea-icon-button-size, 1.75em);
40
+ padding: 0;
41
+ border: none;
42
+ border-radius: var(--radius-sm);
43
+ background: none;
44
+ color: var(--color-text-secondary);
45
+ cursor: pointer;
46
+ transition: var(--transition-colors);
47
+
48
+ // Glyph sized via font-size (not width) so it overrides the icon's inline 1em
49
+ // host size and still scales with the box.
50
+ > * {
51
+ font-size: 1.25em;
52
+ }
53
+
54
+ &:hover {
55
+ background-color: var(--color-state-hover);
56
+ color: var(--color-text-primary);
57
+ }
58
+
59
+ &:focus-visible {
60
+ outline: none;
61
+ @include focus-ring;
62
+ }
63
+
64
+ &:disabled {
65
+ cursor: not-allowed;
66
+ opacity: 0.5;
67
+ }
68
+ }
@@ -0,0 +1,62 @@
1
+ *,
2
+ *::before,
3
+ *::after {
4
+ box-sizing: border-box;
5
+ margin: 0;
6
+ padding: 0;
7
+ }
8
+
9
+ html {
10
+ font-size: 16px;
11
+ -webkit-text-size-adjust: 100%;
12
+ tab-size: 4;
13
+ }
14
+
15
+ body {
16
+ font-family: var(--font-family-sans);
17
+ font-size: var(--font-size-md);
18
+ line-height: var(--line-height-normal);
19
+ color: var(--color-text-primary);
20
+ background-color: var(--color-bg-canvas);
21
+ -webkit-font-smoothing: antialiased;
22
+ -moz-osx-font-smoothing: grayscale;
23
+ }
24
+
25
+ img,
26
+ svg,
27
+ video {
28
+ display: block;
29
+ max-width: 100%;
30
+ }
31
+
32
+ button,
33
+ input,
34
+ optgroup,
35
+ select,
36
+ textarea {
37
+ font-family: inherit;
38
+ font-size: 100%;
39
+ font-weight: inherit;
40
+ line-height: inherit;
41
+ color: inherit;
42
+ }
43
+
44
+ button {
45
+ cursor: pointer;
46
+ background: transparent;
47
+ border: none;
48
+ }
49
+
50
+ a {
51
+ color: var(--color-text-link);
52
+ text-decoration: underline;
53
+
54
+ &:hover {
55
+ color: var(--color-text-link-hover);
56
+ }
57
+ }
58
+
59
+ :focus-visible {
60
+ outline: var(--border-width-medium) solid var(--color-border-focus);
61
+ outline-offset: 2px;
62
+ }
@@ -0,0 +1,38 @@
1
+ // Sign multiplier for horizontal translate animations: +1 in LTR, -1 in RTL, so
2
+ // a single `translateX(calc(<x> * var(--ea-rtl-sign)))` mirrors automatically.
3
+ :where(:root) {
4
+ --ea-rtl-sign: 1;
5
+ }
6
+
7
+ [dir='rtl'] {
8
+ --ea-rtl-sign: -1;
9
+ }
10
+
11
+ // Horizontally mirrors directional glyphs (prev/next chevrons, breadcrumb
12
+ // separators, transfer arrows) when an ancestor sets dir="rtl". Opt in by
13
+ // adding the `ea-rtl-flip` class to the icon element.
14
+ [dir='rtl'] .ea-rtl-flip {
15
+ transform: scaleX(-1);
16
+ }
17
+
18
+ // background-position has no logical keyword, so flip the paginator select's
19
+ // native chevron from the inline-end to the inline-start edge under RTL.
20
+ [dir='rtl'] .ea-paginator__select {
21
+ background-position: left 6px center;
22
+ }
23
+
24
+ // The tree disclosure chevron points toward the inline-start (left) when
25
+ // collapsed in RTL; the open state still rotates down. A 180deg turn keeps it in
26
+ // pure-rotation space so the expand transition stays smooth. Must live here, not
27
+ // the component, because the dir ancestor is outside its encapsulation scope.
28
+ [dir='rtl'] .ea-tree-node__chevron:not(.ea-tree-node__chevron--open) {
29
+ transform: rotate(180deg);
30
+ }
31
+
32
+ // Native fields use dir="auto" so typed LTR text stays LTR, but that resolves
33
+ // an empty field to LTR and parks its placeholder on the left. Pin them to the
34
+ // inline-end edge so every RTL field reads from the right like the others.
35
+ [dir='rtl'] input[dir='auto'],
36
+ [dir='rtl'] textarea[dir='auto'] {
37
+ text-align: right;
38
+ }
@@ -0,0 +1,39 @@
1
+ @use 'mixins' as ea;
2
+
3
+ // Guard against "sticky hover" on touch devices, which fire `mouseenter` on tap
4
+ // but never the matching `mouseleave`. The directive gates pointer listeners on
5
+ // `(hover: hover)`, but if a tooltip ever slips through keep it invisible on touch.
6
+ .ea-tooltip {
7
+ z-index: var(--z-index-tooltip);
8
+ position: absolute;
9
+ padding: var(--space-1-5) var(--space-2-5);
10
+ font-family: var(--font-family-sans);
11
+ font-size: var(--font-size-xs);
12
+ font-weight: var(--font-weight-medium);
13
+ line-height: var(--line-height-normal);
14
+ white-space: nowrap;
15
+ // When a max-width switches the bubble to multi-line, break long unbreakable
16
+ // strings (URLs, tokens) instead of letting them overflow the bubble.
17
+ overflow-wrap: anywhere;
18
+ border: var(--border-width-thin) solid var(--color-tooltip-border);
19
+ border-radius: var(--radius-md);
20
+ background-color: var(--color-tooltip-surface);
21
+ color: var(--color-neutral-0);
22
+ pointer-events: none;
23
+ animation: ea-tooltip-fade-in var(--duration-fast) var(--ease-out);
24
+ @include ea.elevated-surface-border;
25
+
26
+ @media (hover: none) {
27
+ display: none;
28
+ }
29
+ }
30
+
31
+ @keyframes ea-tooltip-fade-in {
32
+ from {
33
+ opacity: 0;
34
+ }
35
+
36
+ to {
37
+ opacity: 1;
38
+ }
39
+ }
@@ -0,0 +1,7 @@
1
+ // Global stylesheet: loads all design token custom properties and the base reset.
2
+ // Consumers must also load the DM Sans and Syne fonts via <link> tags in the HTML head.
3
+
4
+ @use 'tokens/index';
5
+ @use 'reset';
6
+ @use 'tooltip';
7
+ @use 'rtl';
@@ -0,0 +1,265 @@
1
+ // Every token-declaring selector is wrapped in :where() so it contributes zero
2
+ // specificity. A consumer's own root-level token override (specificity 0,1,0)
3
+ // then wins in light mode, OS-dark, and forced dark alike, independent of
4
+ // stylesheet load order. The library's own light-to-dark switching still
5
+ // resolves correctly by source order, since the dark blocks come after the
6
+ // light ones at equal (zero) specificity.
7
+ :where(:root) {
8
+ --color-primary-50: #ecf3f9;
9
+ --color-primary-100: #d1e3f0;
10
+ --color-primary-200: #abcbe3;
11
+ --color-primary-300: #7dafd4;
12
+ --color-primary-400: #4b91c3;
13
+ --color-primary-500: #3674a1;
14
+ --color-primary-600: #2a5b7e;
15
+ --color-primary-700: #204560;
16
+ --color-primary-800: #162f41;
17
+ --color-primary-900: #0d1c26;
18
+
19
+ --color-secondary-50: #f2eff5;
20
+ --color-secondary-100: #dfd9e8;
21
+ --color-secondary-200: #c4b9d5;
22
+ --color-secondary-300: #a493be;
23
+ --color-secondary-400: #8169a5;
24
+ --color-secondary-500: #665086;
25
+ --color-secondary-600: #503f69;
26
+ --color-secondary-700: #3d3050;
27
+ --color-secondary-800: #292136;
28
+ --color-secondary-900: #181320;
29
+
30
+ --color-neutral-0: #ffffff;
31
+ --color-neutral-50: #f9fafb;
32
+ --color-neutral-100: #f3f4f6;
33
+ --color-neutral-200: #e5e7eb;
34
+ --color-neutral-300: #d1d5db;
35
+ --color-neutral-400: #9ca3af;
36
+ --color-neutral-500: #6b7280;
37
+ --color-neutral-600: #4b5563;
38
+ --color-neutral-700: #374151;
39
+ --color-neutral-800: #1f2937;
40
+ --color-neutral-900: #111827;
41
+ --color-neutral-950: #030712;
42
+
43
+ --color-success-50: #f0fdf4;
44
+ --color-success-100: #dcfce7;
45
+ --color-success-200: #bbf7d0;
46
+ --color-success-500: #22c55e;
47
+ --color-success-600: #16a34a;
48
+ --color-success-700: #15803d;
49
+
50
+ --color-warning-50: #fffbeb;
51
+ --color-warning-100: #fef3c7;
52
+ --color-warning-200: #fde68a;
53
+ --color-warning-500: #f59e0b;
54
+ --color-warning-600: #d97706;
55
+ --color-warning-700: #b45309;
56
+
57
+ --color-error-50: #fef2f2;
58
+ --color-error-100: #fee2e2;
59
+ --color-error-200: #fecaca;
60
+ --color-error-500: #ef4444;
61
+ --color-error-600: #dc2626;
62
+ --color-error-700: #b91c1c;
63
+
64
+ --color-info-50: #ecfeff;
65
+ --color-info-100: #cffafe;
66
+ --color-info-200: #a5f3fc;
67
+ --color-info-500: #06b6d4;
68
+ --color-info-600: #0891b2;
69
+ --color-info-700: #0e7490;
70
+
71
+ --color-text-primary: var(--color-neutral-900);
72
+ --color-text-secondary: var(--color-neutral-600);
73
+ --color-text-tertiary: var(--color-neutral-400);
74
+ --color-text-disabled: var(--color-neutral-400);
75
+ --color-text-inverse: var(--color-neutral-0);
76
+ --color-text-link: var(--color-primary-600);
77
+ --color-text-link-hover: var(--color-primary-800);
78
+
79
+ // Two-tier surfaces: bg-canvas is the page, bg-base is component surfaces on
80
+ // it (inputs, cards, accordion, popovers). In dark mode bg-base lifts above
81
+ // bg-canvas so surfaces don't vanish into the page.
82
+ --color-bg-canvas: var(--color-neutral-0);
83
+ --color-bg-base: var(--color-neutral-0);
84
+ --color-bg-subtle: var(--color-neutral-50);
85
+ --color-bg-stripe: var(--color-neutral-50);
86
+ // Zebra-stripe fill for table rows: a mid-tone between the base row and the
87
+ // header tint so striped rows read as lighter than the header, not identical to
88
+ // it. Dark mode overrides the ratio to stay equally subtle.
89
+ --color-bg-stripe-subtle: color-mix(
90
+ in srgb,
91
+ var(--color-bg-base) 30%,
92
+ var(--color-bg-stripe)
93
+ );
94
+ --color-bg-muted: var(--color-neutral-100);
95
+ // Soft neutral fill for placeholder surfaces (e.g. avatar initials) when no
96
+ // image is set; light enough to sit gently on a white page
97
+ --color-bg-emphasis: var(--color-neutral-100);
98
+ --color-bg-elevated: var(--color-neutral-0);
99
+ --color-bg-overlay: rgba(0, 0, 0, 0.5);
100
+
101
+ // Tooltips float over arbitrary content, so their surface is a unique tone that
102
+ // never matches a page or palette colour in either theme; the translucent
103
+ // hairline border separates it from same-tone backgrounds.
104
+ --color-tooltip-surface: #1a1b21;
105
+ --color-tooltip-border: rgba(255, 255, 255, 0.15);
106
+
107
+ // Interactive lift layers for hover and active/selected fills. Light surfaces
108
+ // are all near-white and never collapse onto these shades, so solid muted
109
+ // tones read cleanly. Dark mode swaps them for translucent washes (see the
110
+ // dark overrides) because its surfaces all collapse onto the same neutrals,
111
+ // where a solid fill would vanish into whatever it sits on.
112
+ --color-state-hover: var(--color-neutral-100);
113
+ --color-state-active: var(--color-neutral-200);
114
+
115
+ --color-border-subtle: var(--color-neutral-200);
116
+ --color-border-default: var(--color-neutral-200);
117
+ --color-border-strong: var(--color-neutral-400);
118
+ --color-divider: var(--color-border-subtle);
119
+ --color-border-focus: var(--color-primary-500);
120
+
121
+ // brand-default/-hover/-active are the brand colour as a surface (solid bg
122
+ // under white text); needs 4.5:1 vs white. brand-text is the brand colour as
123
+ // a foreground on a non-brand surface; needs 4.5:1 vs bg-base and flips
124
+ // lighter in dark mode.
125
+ --color-brand-default: var(--color-primary-600);
126
+ --color-brand-hover: var(--color-primary-700);
127
+ --color-brand-active: var(--color-primary-800);
128
+ --color-brand-text: var(--color-primary-700);
129
+ --color-brand-subtle: var(--color-primary-50);
130
+ --color-brand-muted: var(--color-primary-100);
131
+
132
+ --color-brand-secondary-default: var(--color-secondary-500);
133
+ --color-brand-secondary-hover: var(--color-secondary-600);
134
+ --color-brand-secondary-active: var(--color-secondary-700);
135
+ --color-brand-secondary-subtle: var(--color-secondary-50);
136
+ --color-brand-secondary-muted: var(--color-secondary-100);
137
+
138
+ // *-text is the status hue as a foreground on its own *-subtle/-muted wash
139
+ // (badge, tag, toast). It needs 4.5:1 vs that wash and flips lighter in dark
140
+ // mode, mirroring --color-brand-text.
141
+ --color-success-default: var(--color-success-600);
142
+ --color-success-subtle: var(--color-success-50);
143
+ --color-success-muted: var(--color-success-100);
144
+ --color-success-text: var(--color-success-700);
145
+
146
+ --color-warning-default: var(--color-warning-600);
147
+ --color-warning-subtle: var(--color-warning-50);
148
+ --color-warning-muted: var(--color-warning-100);
149
+ --color-warning-text: var(--color-warning-700);
150
+
151
+ --color-error-default: var(--color-error-600);
152
+ --color-error-subtle: var(--color-error-50);
153
+ --color-error-muted: var(--color-error-100);
154
+ --color-error-text: var(--color-error-700);
155
+
156
+ --color-info-default: var(--color-info-600);
157
+ --color-info-subtle: var(--color-info-50);
158
+ --color-info-muted: var(--color-info-100);
159
+ --color-info-text: var(--color-info-700);
160
+
161
+ // Pure RGB primaries for the picker's hue wheel and saturation/value
162
+ // gradient. Intrinsic to the picker, not themeable; kept here so component
163
+ // SCSS stays literal-free.
164
+ --color-picker-hue-red: #ff0000;
165
+ --color-picker-hue-yellow: #ffff00;
166
+ --color-picker-hue-green: #00ff00;
167
+ --color-picker-hue-cyan: #00ffff;
168
+ --color-picker-hue-blue: #0000ff;
169
+ --color-picker-hue-magenta: #ff00ff;
170
+ --color-picker-sv-white: #ffffff;
171
+ --color-picker-sv-black: #000000;
172
+ --color-picker-thumb-halo: rgba(0, 0, 0, 0.25);
173
+ }
174
+
175
+ // Dark mode: applied when the OS prefers dark, unless forced light via
176
+ // `<html data-theme="light">`. `<html data-theme="dark">` forces dark.
177
+ @mixin dark-color-tokens {
178
+ --color-text-primary: var(--color-neutral-50);
179
+ --color-text-secondary: var(--color-neutral-300);
180
+ --color-text-tertiary: var(--color-neutral-500);
181
+ --color-text-disabled: var(--color-neutral-500);
182
+ --color-text-inverse: var(--color-neutral-900);
183
+ --color-text-link: var(--color-primary-300);
184
+ --color-text-link-hover: var(--color-primary-100);
185
+
186
+ // Canvas is the deepest page tone; bg-base and up lift component surfaces,
187
+ // striped rows, floating surfaces, and hover states above it.
188
+ --color-bg-canvas: var(--color-neutral-950);
189
+ --color-bg-base: var(--color-neutral-800);
190
+ --color-bg-subtle: var(--color-neutral-700);
191
+ --color-bg-stripe: var(--color-neutral-900);
192
+ // The dark base-to-stripe gap reads stronger than the light one, so weight the
193
+ // stripe mix toward the base row to keep zebra striping subtle.
194
+ --color-bg-stripe-subtle: color-mix(
195
+ in srgb,
196
+ var(--color-bg-base) 62.5%,
197
+ var(--color-bg-stripe)
198
+ );
199
+ --color-bg-elevated: var(--color-neutral-700);
200
+ // Opaque muted surface for static fills (disabled fields, slider/progress
201
+ // tracks, skeletons). Hover/active fills route through the translucent
202
+ // --color-state-* tokens instead, so they never collide with this shade.
203
+ --color-bg-muted: var(--color-neutral-700);
204
+ --color-bg-emphasis: var(--color-neutral-600);
205
+
206
+ // White wash so an interactive lift reads on any dark surface, including the
207
+ // neutral-700 tier where bg-muted/-subtle/-elevated all coincide. This is the
208
+ // fix that lets dropdown/menu/dialog hovers lift off their elevated surfaces.
209
+ --color-state-hover: rgba(255, 255, 255, 0.08);
210
+ --color-state-active: rgba(255, 255, 255, 0.14);
211
+
212
+ // Borders stay clear of every bg-* shade so they stay visible. subtle mixes
213
+ // neutral-700/-800 to sit between card and cell backgrounds; default can't go
214
+ // darker without colliding with bg-subtle/-elevated (both neutral-700).
215
+ --color-border-subtle: color-mix(
216
+ in srgb,
217
+ var(--color-neutral-700),
218
+ var(--color-neutral-800)
219
+ );
220
+ --color-border-default: var(--color-neutral-400);
221
+ --color-border-strong: var(--color-neutral-300);
222
+ // Dividers read as barely-there against dark surfaces at border-subtle, so
223
+ // they step one neutral lighter for a visible-but-quiet rule.
224
+ --color-divider: var(--color-neutral-600);
225
+
226
+ // Surface roles step one shade lighter than light mode so the button clears
227
+ // WCAG 1.4.11 (3:1) on the near-black canvas while keeping a white label
228
+ // above 4.5:1.
229
+ --color-brand-default: var(--color-primary-500);
230
+ --color-brand-hover: var(--color-primary-600);
231
+ --color-brand-active: var(--color-primary-700);
232
+ --color-brand-text: var(--color-primary-300);
233
+ --color-brand-subtle: rgba(75, 145, 195, 0.1);
234
+ --color-brand-muted: rgba(75, 145, 195, 0.2);
235
+
236
+ // Light pastels become unreadable behind light text in dark mode. Re-tint as
237
+ // a low-alpha wash of the saturated *-500 hue so the surface darkens enough
238
+ // for white text.
239
+ --color-success-subtle: rgba(34, 197, 94, 0.15);
240
+ --color-success-muted: rgba(34, 197, 94, 0.25);
241
+ --color-warning-subtle: rgba(245, 158, 11, 0.15);
242
+ --color-warning-muted: rgba(245, 158, 11, 0.25);
243
+ --color-error-subtle: rgba(239, 68, 68, 0.15);
244
+ --color-error-muted: rgba(239, 68, 68, 0.25);
245
+ --color-info-subtle: rgba(6, 182, 212, 0.15);
246
+ --color-info-muted: rgba(6, 182, 212, 0.25);
247
+
248
+ // The *-700 shades are unreadable on the dark translucent washes above; flip
249
+ // to the light *-200 pastels so status text keeps 4.5:1.
250
+ --color-success-text: var(--color-success-200);
251
+ --color-warning-text: var(--color-warning-200);
252
+ --color-error-text: var(--color-error-200);
253
+ --color-info-text: var(--color-info-200);
254
+ }
255
+
256
+ @media (prefers-color-scheme: dark) {
257
+ :where(:root:not([data-theme='light'])) {
258
+ @include dark-color-tokens;
259
+ }
260
+ }
261
+
262
+ :where(:root[data-theme='dark']) {
263
+ @include dark-color-tokens;
264
+ color-scheme: dark;
265
+ }