import { readdirSync, readFileSync, statSync } from 'node:fs' import { dirname, join, relative, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' // SDK control refresh (decisions/2026-09-25-sdk-control-refresh) style rules. // Each failure message lists every offending `file:line`, so it doubles as the // migration work-list. const here = dirname(fileURLToPath(import.meta.url)) const pkgRoot = resolve(here, '../../..') const srcRoot = join(pkgRoot, 'src') const stylesDir = join(srcRoot, 'styles') const componentsDir = join(stylesDir, 'components') const IGNORED_DIRS = new Set(['__tests__', 'generated-ui', 'node_modules']) function walk(dir: string, exts: string[], out: string[] = []): string[] { for (const entry of readdirSync(dir)) { const full = join(dir, entry) if (statSync(full).isDirectory()) { if (!IGNORED_DIRS.has(entry)) walk(full, exts, out) } else if (exts.some((ext) => entry.endsWith(ext))) { out.push(full) } } return out } /** Block comments blanked out, newlines kept so line numbers stay true. */ function stripComments(text: string): string { return text.replace(/\/\*[\s\S]*?\*\//g, (c) => c.replace(/[^\n]/g, ' ')) } function offenders(files: string[], test: (line: string) => boolean): string[] { const hits: string[] = [] for (const file of files) { const lines = stripComments(readFileSync(file, 'utf-8')).split('\n') lines.forEach((line, i) => { if (test(line)) hits.push(`${relative(pkgRoot, file)}:${i + 1}: ${line.trim()}`) }) } return hits } function expectNone(hits: string[]): void { expect(hits, `${hits.length} offenders\n${hits.join('\n')}`).toEqual([]) } const componentCss = walk(componentsDir, ['.css']) const allStyleCss = walk(stylesDir, ['.css']) const allSrcCssAndVue = walk(srcRoot, ['.css', '.vue']) const component = (name: string) => join(componentsDir, name) // 8d: UI text >= 11px (0.6875rem). In-graphic well labels are exempt. const MIN_FONT_PX = 11 const FONT_SIZE_WHITELIST = new Set(['well-plate.css']) function hasTooSmallFont(line: string): boolean { // Several declarations can share a line (`font: inherit; font-size: …`). for (const decl of line.matchAll(/\bfont(?:-size)?\s*:\s*([^;]*)/g)) { // The first length in `font-size:` or the `font:` shorthand is the size. const size = decl[1].match(/(\d*\.?\d+)(px|rem)\b/) if (!size) continue const px = size[2] === 'rem' ? Number(size[1]) * 16 : Number(size[1]) if (px < MIN_FONT_PX) return true } return false } describe('SDK control refresh style rules', () => { it('should find no uppercase text-transform when scanning styles (2a)', () => { expectNone(offenders(allStyleCss, (l) => /text-transform\s*:\s*uppercase/i.test(l))) }) it('should find no font size below 11px when scanning component styles (8d)', () => { const files = componentCss.filter((f) => !FONT_SIZE_WHITELIST.has(relative(componentsDir, f))) expectNone(offenders(files, hasTooSmallFont)) }) it('should find no var(--token, #hex) fallback when scanning styles (4f)', () => { expectNone(offenders(allStyleCss, (l) => /var\(--[\w-]+,\s*#/.test(l))) }) it('should find no legacy --mint-border/bg-/text- reference when scanning styles (4f)', () => { // Usages only: variables.css still declares the aliases, and // --mint-border-focus is a real token, not part of the legacy chain. expectNone(offenders(allStyleCss, (l) => /var\(--mint-(?:border(?![\w-])|bg-|text-)/.test(l))) }) it('should find no pre-rebrand blue literal when scanning source CSS and Vue files (6d)', () => { // The --mint-info-* token declarations are the genuine info blue (the // replacement target for info intent), not a legacy use. expectNone( offenders( allSrcCssAndVue, (l) => /rgba\(\s*59\s*,\s*130\s*,\s*246/.test(l) && !/^\s*--mint-info-[\w-]+\s*:/.test(l), ), ) }) it('should find no linear-gradient when scanning flat-surface components (1d, 2f, 2g, 3e)', () => { const files = ['icon-button.css', 'empty-state.css', 'jobs-status-tray.css', 'file-picker.css', 'skeleton.css'].map(component) expectNone(offenders(files, (l) => /linear-gradient/.test(l))) }) it('should find no hex color literal when scanning token-only graphics (1g, 2b, 3b, 7a)', () => { const files = ['well-plate.css', 'tooltip.css', 'well-edit-popup.css', 'plate-editor.css'].map(component) expectNone(offenders(files, (l) => /#[0-9a-fA-F]{3,8}\b/.test(l))) }) it('should ring the focused editor root when scanning plate-editor.css (7a)', () => { const css = stripComments(readFileSync(component('plate-editor.css'), 'utf-8')) expect(/\.mint-plate-editor:focus-visible\s*\{[^}]*box-shadow:\s*var\(--focus-ring\)/.test(css)).toBe(true) }) it('should fill progress with the primary color only when scanning progress-bar.css (6b)', () => { // --mint-info is reserved for the "running" status dot. expectNone(offenders([component('progress-bar.css')], (l) => /--mint-info/.test(l))) }) it('should keep sequence progress text off --mint-info when scanning sequence-progress-bar.css (6b)', () => { expectNone(offenders([component('sequence-progress-bar.css')], (l) => /--mint-info/.test(l))) }) it('should fill primary and info progress with --color-primary when scanning progress-bar.css (6b)', () => { // The rail bar, for primary and info. const fills = stripComments(readFileSync(component('progress-bar.css'), 'utf-8')) .split('\n') .filter((l) => /\.mint-progress__[\w.-]*--(primary|info)\s*\{/.test(l)) .map((l) => /background-color:\s*([^;]+);/.exec(l)?.[1]) expect(fills).toEqual(Array(2).fill('var(--color-primary)')) }) it('should compose SDK fields instead of private field styles when scanning protocol-step-editor.css and dose-calculator.css (6c)', () => { const files = [component('protocol-step-editor.css'), component('dose-calculator.css')] expectNone(offenders(files, (l) => /__(input|select|textarea|unit-suffix|template-|mode-badge|btn|apply-btn)/.test(l))) }) it('should wrap FormField rows so a row error sits on its own line (6c)', () => { expect(readFileSync(component('form-field.css'), 'utf-8')).toMatch(/\.mint-form-field--row\s*\{[^}]*flex-wrap:\s*wrap;/) }) it('should set FormField row labels in 13px text-secondary-strong (3b, 6c)', () => { expect(readFileSync(component('form-field.css'), 'utf-8')).toMatch(/\.mint-form-field--row \.mint-form-field__label\s*\{[^}]*font-size:\s*0\.8125rem;[^}]*color:\s*var\(--text-secondary-strong\);/) }) it('should draw no checkbox glyph as a data URI when scanning sample-selector.css (7c)', () => { expectNone(offenders([component('sample-selector.css')], (l) => /data:image/.test(l))) }) it('should generate no label text in CSS when scanning scientific inputs (4b)', () => { // "MW" and "≈" live in templates so they can be translated, selected and read aloud. const files = ['formula-input.css', 'concentration-input.css', 'unit-input.css'].map(component) expectNone(offenders(files, (l) => /(^|[^-\w])content\s*:/.test(l))) }) it('should find no italic conversion hint when scanning scientific inputs (4b)', () => { const files = ['concentration-input.css', 'unit-input.css'].map(component) expectNone(offenders(files, (l) => /font-style\s*:\s*italic/.test(l))) }) it('should set the unit at the number size when scanning scientific-number.css (4b)', () => { const css = stripComments(readFileSync(component('scientific-number.css'), 'utf-8')) const rule = /\.mint-sci-number__unit\s*\{([^}]*)\}/.exec(css)?.[1] ?? '' expect(rule).not.toMatch(/font-size/) }) it('should give numeric DataFrame cells tabular figures when scanning dataframe.css (2a)', () => { const css = stripComments(readFileSync(component('dataframe.css'), 'utf-8')) const rule = /\.mint-dataframe__td--number\s*\{([^}]*)\}/.exec(css)?.[1] ?? '' expect(rule).toMatch(/font-variant-numeric:\s*tabular-nums/) }) it('should center the select-all checkbox over the row checkboxes when .mint-dataframe__th forces left (2a)', () => { const css = stripComments(readFileSync(component('dataframe.css'), 'utf-8')) const rule = /\.mint-dataframe__th--checkbox\s*\{([^}]*)\}/.exec(css)?.[1] ?? '' expect(rule).toMatch(/text-align:\s*center\s*!important/) }) it('should wrap a row FormField so its hint drops below the control when scanning form-field.css (3b)', () => { const css = stripComments(readFileSync(component('form-field.css'), 'utf-8')) const rule = /\.mint-form-field--row\s*\{([^}]*)\}/.exec(css)?.[1] ?? '' expect(rule).toMatch(/flex-wrap:\s*wrap/) }) it('should pad resource picker rows to the roomier two-column layout when scanning resource-picker-list.css', () => { const css = stripComments(readFileSync(component('resource-picker-list.css'), 'utf-8')) const rule = /\.mint-resource-picker__row\s*\{([^}]*)\}/.exec(css)?.[1] ?? '' expect(rule).toMatch(/padding:\s*10px 12px/) }) // Group bars and the list frame were inset by the modal body padding; the list now runs edge to edge. it('should drop the modal body padding around the resource picker when scanning resource-picker-list.css', () => { const css = stripComments(readFileSync(component('resource-picker-list.css'), 'utf-8')) const rule = /\.mint-modal__body:has\(> \.mint-resource-picker\)\s*\{([^}]*)\}/.exec(css)?.[1] ?? '' expect(rule).toMatch(/padding:\s*0/) }) it('should pad resource picker group bars to the modal gutter when scanning resource-picker-list.css', () => { const css = stripComments(readFileSync(component('resource-picker-list.css'), 'utf-8')) const rule = /\.mint-resource-picker__group\s*\{([^}]*)\}/.exec(css)?.[1] ?? '' expect(rule).toMatch(/padding:\s*0 1\.25rem/) }) // The list sat in a bordered card inside the modal card. it('should draw no frame around the resource picker list when scanning resource-picker-list.css', () => { const css = stripComments(readFileSync(component('resource-picker-list.css'), 'utf-8')) const rule = /\.mint-resource-picker__frame\s*\{([^}]*)\}/.exec(css)?.[1] ?? '' expect(rule).not.toMatch(/(^|;)\s*border:/) }) it('should paint the clear-selection link with primary ink when scanning experiment-selector-modal.css (8b)', () => { const css = stripComments(readFileSync(component('experiment-selector-modal.css'), 'utf-8')) const rule = /\.mint-experiment-selector__clear-btn\s*\{([^}]*)\}/.exec(css)?.[1] ?? '' expect(rule).toMatch(/color:\s*var\(--_ink-primary\)/) }) })