tailwind2-10 Exercise 1: Tokens, a cva Button, an Input, and a Contrast Matrix With a Control =========================================================================================== Setup (folder "kit"): npm install react react-dom vite @vitejs/plugin-react tailwindcss @tailwindcss/vite class-variance-authority clsx tailwind-merge @radix-ui/react-tabs @radix-ui/react-dialog typescript @types/react @types/react-dom Add .gitignore (node_modules, dist), vite.config.ts, tsconfig.json, index.html with the no-flash script, and the files below. The measuring scripts live OUTSIDE the project folder (../tests) and use my own DevTools driver (cdp.mjs); in DevTools you can read the same computed colours by hand. --- vite.config.ts --- import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import tailwindcss from '@tailwindcss/vite'; export default defineConfig({ plugins: [react(), tailwindcss()] }); --- index.html --- Kit docs
--- src/ui/tokens.css --- @import 'tailwindcss'; /* The kit's own files, wherever the app that imports this stylesheet lives */ @source '../'; /* Dark mode follows the data-mode attribute, not the OS setting */ @custom-variant dark (&:where([data-mode="dark"], [data-mode="dark"] *)); /* Semantic tokens are plain variables, switched by data-brand and data-mode */ :root { color-scheme: light; --surface: oklch(99% 0.003 250); --surface-raised: oklch(100% 0 0); --ink: oklch(22% 0.03 255); --ink-muted: oklch(45% 0.03 255); --line: oklch(86% 0.01 255); --primary: oklch(50% 0.2 255); --primary-ink: oklch(99% 0 0); --danger: oklch(50% 0.2 27); --danger-ink: oklch(99% 0 0); --ring: oklch(55% 0.2 255); } [data-mode='dark'] { color-scheme: dark; --surface: oklch(18% 0.02 255); --surface-raised: oklch(23% 0.025 255); --ink: oklch(95% 0.01 255); --ink-muted: oklch(75% 0.02 255); --line: oklch(36% 0.025 255); --primary: oklch(72% 0.14 255); --primary-ink: oklch(18% 0.03 255); --danger: oklch(72% 0.15 27); --danger-ink: oklch(18% 0.03 27); --ring: oklch(75% 0.14 255); } [data-brand='sunset'] { --primary: oklch(52% 0.18 40); --ring: oklch(58% 0.18 40); } [data-brand='sunset'][data-mode='dark'] { --primary: oklch(75% 0.15 55); --primary-ink: oklch(20% 0.03 55); --ring: oklch(78% 0.15 55); } @theme inline { --color-surface: var(--surface); --color-raised: var(--surface-raised); --color-ink: var(--ink); --color-muted: var(--ink-muted); --color-line: var(--line); --color-primary: var(--primary); --color-primary-ink: var(--primary-ink); --color-danger: var(--danger); --color-danger-ink: var(--danger-ink); --color-ring: var(--ring); --radius-control: 0.5rem; --radius-panel: 0.75rem; } @layer base { body { background: var(--surface); color: var(--ink); } } --- src/ui/cn.ts --- import { clsx, type ClassValue } from 'clsx'; import { twMerge } from 'tailwind-merge'; export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs)); --- src/ui/button.tsx --- import { cva, type VariantProps } from 'class-variance-authority'; import type { ButtonHTMLAttributes } from 'react'; import { cn } from './cn'; export const buttonStyles = cva( 'inline-flex items-center justify-center gap-2 rounded-control font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none', { variants: { intent: { primary: 'bg-primary text-primary-ink hover:bg-primary/90', secondary: 'border border-line bg-raised text-ink hover:bg-line/40', ghost: 'text-ink hover:bg-line/40', danger: 'bg-danger text-danger-ink hover:bg-danger/90', }, size: { sm: 'h-8 px-3 text-sm', md: 'h-10 px-4 text-sm', lg: 'h-12 px-6 text-base' }, }, defaultVariants: { intent: 'primary', size: 'md' }, }, ); type Props = ButtonHTMLAttributes & VariantProps; export function Button({ intent, size, className, type = 'button', ...rest }: Props) { return ))} {MODES.map((m) => ( ))}
setEmail(e.target.value)} error={bad ? 'Enter a valid email address.' : undefined} />
Card title

Body text in the muted colour, on the raised surface.

One Two Three First panel Second panel Third panel
); } --- src/docs/main.tsx --- import { createRoot } from 'react-dom/client'; import { App } from './App'; createRoot(document.getElementById('root')!).render(); --- tests/test-contrast.mjs (serves dist/, clicks each theme, measures ratios) --- import { withPage } from '../../c4/cdp.mjs'; import http from 'node:http'; import fs from 'node:fs'; import path from 'node:path'; const root = path.resolve('dist'); const srv = http.createServer((q, r) => { const f = path.join(root, q.url === '/' ? 'index.html' : q.url.split('?')[0]); fs.readFile(f, (e, d) => { if (e) { r.statusCode = 404; r.end(); return; } r.setHeader('content-type', f.endsWith('.js') ? 'text/javascript' : f.endsWith('.css') ? 'text/css' : 'text/html'); r.end(d); }); }).listen(5410); const PAIRS = [ ['body text', 'body', 'color', 'body', 'background-color'], ['muted text on card', '#cards p', 'color', '#cards > div', 'background-color'], ['primary button', '#b-primary', 'color', '#b-primary', 'background-color'], ['danger button', '#b-danger', 'color', '#b-danger', 'background-color'], ['secondary button', '#b-secondary', 'color', '#b-secondary', 'background-color'], ['input label', '#inputs label', 'color', 'body', 'background-color'], ['placeholder', '#name', '::placeholder', '#name', 'background-color'], ['inactive tab', '#tab-two', 'color', 'body', 'background-color'], ['error text', '#email-error', 'color', 'body', 'background-color'], ['CONTROL ink on primary', 'body', 'color', '#b-primary', 'background-color'], ['focus ring vs page', '#b-primary', 'outline-color:ring', 'body', 'background-color'], ]; const out = []; await withPage('http://localhost:5410/', async (p) => { await p.sleep(500); await p.click('#email'); await p.send('Input.insertText', { text: 'x' }); await p.sleep(200); for (const brand of ['ocean', 'sunset']) for (const mode of ['light', 'dark']) { await p.click('#brand-' + brand); await p.click('#mode-' + mode); await p.sleep(300); const attrs = await p.ev(`document.documentElement.dataset.brand + '/' + document.documentElement.dataset.mode`); const res = await p.ev(`(() => { const cv = document.createElement('canvas'); cv.width = cv.height = 1; const cx = cv.getContext('2d', { willReadFrequently: true }); const rgb = (c) => { cx.clearRect(0,0,1,1); cx.fillStyle = '#000'; cx.fillStyle = c; cx.fillRect(0,0,1,1); const d = cx.getImageData(0,0,1,1).data; return [d[0],d[1],d[2]]; }; const lum = ([r,g,b]) => { const f = v => { v/=255; return v<=0.03928? v/12.92 : Math.pow((v+0.055)/1.055,2.4); }; return 0.2126*f(r)+0.7152*f(g)+0.0722*f(b); }; const pairs = ${JSON.stringify(PAIRS)}; return pairs.map(([name, s1, p1, s2, p2]) => { const e1 = document.querySelector(s1), e2 = document.querySelector(s2); if (!e1 || !e2) return [name, 'missing element']; let c1; if (p1 === '::placeholder') c1 = getComputedStyle(e1, '::placeholder').color; else if (p1 === 'outline-color:ring') c1 = getComputedStyle(document.documentElement).getPropertyValue('--ring'); else c1 = getComputedStyle(e1)[p1.replace(/-(\\w)/g, (m, c) => c.toUpperCase())]; const c2 = getComputedStyle(e2).backgroundColor; const L1 = lum(rgb(c1)), L2 = lum(rgb(c2)); const r = (Math.max(L1,L2)+0.05)/(Math.min(L1,L2)+0.05); return [name, r.toFixed(2)]; }); })()`); out.push([attrs, res]); } }); srv.close(); for (const [a, res] of out) { console.log('== ' + a); for (const [n, r] of res) { const v = Number(r); const need = n.startsWith('focus') ? 3 : 4.5; console.log(' ' + n.padEnd(22), String(r).padStart(6), isNaN(v) ? '' : (v >= need ? 'ok' : 'FAIL (needs ' + need + ')')); } } process.exit(0); Run: npx vite build then node ../tests/test-contrast.mjs (from the kit folder) Results (WCAG ratio; text pairs need 4.5, the focus ring needs 3): pair ocean light ocean dark sunset light sunset dark body text 16.86 16.26 16.86 16.26 muted text on card 7.46 7.58 7.46 7.58 primary button 5.87 7.58 5.83 7.81 danger button 6.47 7.18 6.47 7.18 secondary button 17.34 14.54 17.34 14.54 input label 16.86 16.26 16.86 16.26 placeholder 7.46 7.58 7.46 7.58 inactive tab 7.26 8.48 7.26 8.48 error text 6.45 7.13 6.45 7.13 CONTROL ink on primary 2.88 FAIL 2.15 FAIL 2.90 FAIL 2.01 FAIL focus ring vs page 4.76 8.41 4.51 9.00 WHY THIS WORKS AS AN ANSWER --------------------------- All real pairs pass in all four themes, and the control (ink text on the primary colour, which should be unreadable) fails in all four, which shows the method can detect a failure; the values also differ from each other, so it is not returning a constant. The tokens are plain variables replaced per attribute, and the components name only semantic colours (bg-primary, text-muted), so adding a third brand means one new attribute block and one more row in this matrix, not a change to any component. Not measured: hover colours (bg-primary/90), the disabled state, and any state that needs interaction beyond a typed error. Note: I chose the colours to hit these targets and they passed first time; do not expect that, and keep the test.