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
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* Copyright 2026 Collate.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { Alert, AlertVariant } from './alert';

describe('Alert theme semantics', () => {
it.each<[AlertVariant, string, string]>([
['success', 'tw:bg-success-primary', 'tw:border-success-subtle'],
['warning', 'tw:bg-warning-primary', 'tw:border-warning-subtle'],
['error', 'tw:bg-error-primary', 'tw:border-error-subtle'],
])(
'uses semantic surface and border roles for the %s variant',
(variant, backgroundClass, borderClass) => {
render(<Alert title={`${variant} alert`} variant={variant} />);

expect(screen.getByRole('alert')).toHaveClass(
backgroundClass,
borderClass
);
}
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -34,18 +34,20 @@ const variantStyles: Record<
defaultIcon: FC<{ className?: string }>;
}
> = {
// Severity roles preserve the intended contrast on each theme surface;
// palette steps invert independently and are reserved for data-bound color.
success: {
root: 'tw:border-utility-success-300 tw:bg-success-primary',
root: 'tw:border-success-subtle tw:bg-success-primary',
iconColor: 'success',
defaultIcon: CheckCircle,
},
warning: {
root: 'tw:border-utility-warning-300 tw:bg-warning-primary',
root: 'tw:border-warning-subtle tw:bg-warning-primary',
iconColor: 'warning',
defaultIcon: AlertTriangle,
},
error: {
root: 'tw:border-utility-error-300 tw:bg-error-primary',
root: 'tw:border-error-subtle tw:bg-error-primary',
iconColor: 'error',
defaultIcon: AlertCircle,
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
* Copyright 2026 Collate.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { TextAreaBase } from './textarea';

describe('TextAreaBase theme semantics', () => {
it('colors the resize handle with the active semantic border token', () => {
render(<TextAreaBase aria-label="Description" />);

const textArea = screen.getByRole('textbox', { name: 'Description' });

expect(textArea).toHaveClass(
'tw:[&::-webkit-resizer]:bg-border-primary',
'tw:[&::-webkit-resizer]:mask-(image:--resize-handle-mask)'
);
expect(textArea.style.getPropertyValue('--resize-handle-mask')).toContain(
'data:image/svg+xml;base64,'
);
expect(textArea.className).not.toContain('tw:dark:');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ import { Label } from '@/components/base/input/label';
import { cx } from '@/utils/cx';
import { fontSizeClass } from '@/utils';

// Creates a data URL for an SVG resize handle with a given color.
const getResizeHandleBg = (color: string) => {
// Masking keeps the resize glyph's shape fixed while its semantic color follows the active theme.
const getResizeHandleMask = () => {
return `url(data:image/svg+xml;base64,${btoa(
`<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M10 2L2 10" stroke="${color}" stroke-linecap="round"/><path d="M11 7L7 11" stroke="${color}" stroke-linecap="round"/></svg>`
'<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M10 2L2 10" stroke="currentColor" stroke-linecap="round"/><path d="M11 7L7 11" stroke="currentColor" stroke-linecap="round"/></svg>'
)})`;
};

Expand All @@ -40,8 +40,7 @@ export const TextAreaBase = ({
// gone — the outline IS the focus indicator here, as in input.tsx.
'tw:w-full tw:scroll-py-3 tw:rounded-lg tw:bg-primary tw:px-3.5 tw:py-3 tw:text-primary tw:shadow-xs tw:outline-1 tw:-outline-offset-1 tw:outline-primary tw:transition tw:duration-100 tw:ease-linear tw:placeholder:text-placeholder tw:autofill:rounded-lg tw:autofill:text-primary',

// Resize handle
'tw:[&::-webkit-resizer]:bg-(image:--resize-handle-bg) tw:[&::-webkit-resizer]:bg-contain tw:dark:[&::-webkit-resizer]:bg-(image:--resize-handle-bg-dark)',
'tw:[&::-webkit-resizer]:bg-border-primary tw:[&::-webkit-resizer]:mask-(image:--resize-handle-mask) tw:[&::-webkit-resizer]:mask-contain tw:[&::-webkit-resizer]:mask-no-repeat',

state.isFocused &&
!state.isDisabled &&
Expand All @@ -60,8 +59,7 @@ export const TextAreaBase = ({
}
style={
{
'--resize-handle-bg': getResizeHandleBg('#D5D7DA'),
'--resize-handle-bg-dark': getResizeHandleBg('#373A41'),
'--resize-handle-mask': getResizeHandleMask(),
} as React.CSSProperties
}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,19 +67,67 @@ function resolve(name, defs, seen = new Set()) {
}

const GROUPS = [
{ key: /^--om-space-/, title: 'Spacing', note: 'padding / margin / gap. See foundations/spacing.md.' },
{ key: /^--om-page-/, title: 'Layout', note: 'Shared viewport and shell-derived dimensions.' },
{ key: /^--om-radius-/, title: 'Radius', note: 'border-radius. See foundations/radius.md.' },
{ key: /^--om-font-size-/, title: 'Font size', note: 'font-size. See foundations/typography.md.' },
{
key: /^--om-space-/,
title: 'Spacing',
note: 'padding / margin / gap. See foundations/spacing.md.',
},
{
key: /^--om-page-/,
title: 'Layout',
note: 'Shared viewport and shell-derived dimensions.',
},
{
key: /^--om-radius-/,
title: 'Radius',
note: 'border-radius. See foundations/radius.md.',
},
{
key: /^--om-font-size-/,
title: 'Font size',
note: 'font-size. See foundations/typography.md.',
},
{ key: /^--om-font-weight-/, title: 'Font weight', note: 'font-weight.' },
{ key: /^--om-font-|^--om-line-height-/, title: 'Font family & line height', note: '' },
{ key: /^--om-shadow-/, title: 'Elevation', note: 'box-shadow. See foundations/elevation.md.' },
{ key: /^--om-z-/, title: 'z-index', note: 'stacking. Prefer the semantic ladder for new work.' },
{ key: /^--om-duration-|^--om-ease-/, title: 'Motion', note: 'transition / animation. See foundations/motion.md.' },
{ key: /^--om-color-(text|bg|border|fg|link|interactive|focus)/, title: 'Semantic colors', note: 'Prefer these — they adapt to dark mode.' },
{ key: /^--om-color-(white|black|transparent)/, title: 'Absolute colors', note: '' },
{ key: /^--om-color-/, title: 'Palette colors', note: 'Fixed swatches; do NOT adapt to dark mode. Prefer semantic tokens.' },
{ key: /^--om-legacy-color-/, title: 'Legacy colors', note: 'Exact migrated one-offs (migration debt). Do not use in new code; re-express with a semantic token.' },
{
key: /^--om-font-|^--om-line-height-|^--om-letter-spacing-/,
title: 'Font family, line height & letter spacing',
note: '',
},
{
key: /^--om-shadow-/,
title: 'Elevation',
note: 'box-shadow. See foundations/elevation.md.',
},
{
key: /^--om-z-/,
title: 'z-index',
note: 'stacking. Prefer the semantic ladder for new work.',
},
{
key: /^--om-duration-|^--om-ease-/,
title: 'Motion',
note: 'transition / animation. See foundations/motion.md.',
},
{
key: /^--om-color-(text|bg|border|fg|link|interactive|focus)/,
title: 'Semantic colors',
note: 'Prefer these — they adapt to dark mode.',
},
{
key: /^--om-color-(white|black|transparent)/,
title: 'Absolute colors',
note: '',
},
{
key: /^--om-color-/,
title: 'Palette colors',
note: 'Fixed swatches; do NOT adapt to dark mode. Prefer semantic tokens.',
},
{
key: /^--om-legacy-color-/,
title: 'Legacy colors',
note: 'Exact migrated one-offs (migration debt). Do not use in new code; re-express with a semantic token.',
},
];

function classify(name) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,13 @@ const map = require('./token-map');
const STYLES_ROOT = path.resolve(__dirname, '../../src');
const TOKENS_CSS = path.resolve(STYLES_ROOT, 'styles/tokens.css');
const SIDECAR = path.resolve(__dirname, 'legacy-colors.json');
const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', 'target', 'output']);
const SKIP_DIRS = new Set([
'node_modules',
'dist',
'build',
'target',
'output',
]);
const BEGIN = '/* @tokens:generated-begin';
const END = '/* @tokens:generated-end */';
const OM_USAGE_RE = /var\(\s*(--om-[\w-]+)/g;
Expand Down Expand Up @@ -147,17 +153,21 @@ function main() {
const r = map.registry;
process.stdout.write(
`tokens.css regenerated:\n` +
` palette (full upstream): ${Object.keys(map.constants.UPSTREAM).length}\n` +
` palette passthrough: ${
Object.keys(map.constants.UPSTREAM).length -
map.constants.MANUAL_ABSOLUTE_COLOR_TOKENS.size
}\n` +
` legacy colors: ${r.legacyColors.size}\n` +
` extended spacing: ${
[...r.spacing.keys()].filter((px) => !map.constants.CORE_SPACING.has(px))
.length
[...r.spacing.keys()].filter(
(px) => !map.constants.CORE_SPACING.has(px)
).length
}\n` +
` extended radius: ${
[...r.radius.keys()].filter((k) => /^\d+$/.test(k)).length
[...r.radius.keys()].filter(map.isNumericSlug).length
}\n` +
` extended font-size: ${
[...r.fontSize.keys()].filter((k) => /^\d+$/.test(k)).length
[...r.fontSize.keys()].filter(map.isNumericSlug).length
}\n` +
` z-index tokens: ${r.zIndex.size}\n` +
` duration tokens: ${r.duration.size}\n`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
/* Focused round-trip checks for the token scanner. Run: node scanner.test.js */
const assert = require('assert');
const { processText } = require('./scanner');
const tokenMap = require('./token-map');

let pass = 0;
function check(name, input, expected) {
Expand All @@ -22,7 +23,13 @@ function check(name, input, expected) {
assert.strictEqual(newText, expected);
pass++;
} catch (e) {
process.stderr.write(`\nFAIL: ${name}\n in: ${JSON.stringify(input)}\n got: ${JSON.stringify(newText)}\n exp: ${JSON.stringify(expected)}\n`);
process.stderr.write(
`\nFAIL: ${name}\n in: ${JSON.stringify(
input
)}\n got: ${JSON.stringify(newText)}\n exp: ${JSON.stringify(
expected
)}\n`
);
process.exitCode = 1;
}
}
Expand All @@ -34,7 +41,11 @@ check(
'.a { color: var(--om-color-brand-500); }'
);
// color: 3-digit expands and matches white
check('short hex white', '.a { color: #FFF; }', '.a { color: var(--om-color-white); }');
check(
'short hex white',
'.a { color: #FFF; }',
'.a { color: var(--om-color-white); }'
);
// color: no upstream home -> legacy token, exact value preserved
check(
'legacy color',
Expand Down Expand Up @@ -80,9 +91,17 @@ check(
'.a { width: 100px; /* padding: 8px */ color: var(--om-color-white); }'
);
// width/top are out of scope -> untouched
check('width out of scope', '.a { width: 20px; top: 8px; }', '.a { width: 20px; top: 8px; }');
check(
'width out of scope',
'.a { width: 20px; top: 8px; }',
'.a { width: 20px; top: 8px; }'
);
// border-radius -> radius token
check('radius', '.a { border-radius: 4px; }', '.a { border-radius: var(--om-radius-sm); }');
check(
'radius',
'.a { border-radius: 4px; }',
'.a { border-radius: var(--om-radius-sm); }'
);
// font-size + font-weight
check(
'font-size and weight',
Expand All @@ -104,7 +123,11 @@ check(
'.a { box-shadow: 0px 2px 10px var(--om-legacy-color-0-0-0-0-12); }'
);
// LESS @variable usage untouched (not a literal)
check('less var untouched', '.a { color: @grey-15; }', '.a { color: @grey-15; }');
check(
'less var untouched',
'.a { color: @grey-15; }',
'.a { color: @grey-15; }'
);
// LESS color function: hex inside darken() must stay raw (LESS needs a literal)
check(
'less color fn untouched',
Expand Down Expand Up @@ -167,4 +190,27 @@ const once = processText(rich, 't').newText;
const twice = processText(once, 't').newText;
check('idempotent (all categories)', twice, once);

process.stdout.write(`\n${pass} checks passed${process.exitCode ? ' (with failures above)' : ''}\n`);
tokenMap.resetRegistry();
for (const radius of [10, 16, 24, 1, 3.2]) {
tokenMap.resolveRadius(radius, 'px');
}
const generatedTokens = tokenMap.emitGeneratedBlocks();
const generatedRadiusLines = generatedTokens
.split('\n')
.filter((line) => line.includes('--om-radius-'));
assert.deepStrictEqual(generatedRadiusLines, [
' --om-radius-1: 1px;',
' --om-radius-3_2: 3.2px;',
' --om-radius-10: 10px;',
]);
assert.deepStrictEqual(
generatedTokens
.split('\n')
.filter((line) => /--om-color-(?:black|white):/.test(line)),
[]
);
pass++;

process.stdout.write(
`\n${pass} checks passed${process.exitCode ? ' (with failures above)' : ''}\n`
);
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ test('exposes the approved roles through the legacy token bridge', () => {
'--om-color-bg-surface': 'var(--color-bg-surface, #ffffff)',
'--om-color-bg-raised': 'var(--color-bg-raised, #ffffff)',
'--om-color-bg-overlay-surface': 'var(--color-bg-overlay-surface, #ffffff)',
'--om-color-bg-secondary-hover': 'var(--color-bg-secondary_hover, #f5f5f5)',
'--om-color-border-subtle': 'var(--color-border-subtle, rgb(0 0 0 / 0.08))',
'--om-color-border-hover': 'var(--color-border-hover, #a4a7ae)',
'--om-color-border-brand-subtle':
Expand Down
Loading
Loading