feat: add @bchen/ui v0.1.0 — dark-mode token palette and 16px base rule

- dist/tokens.css: canonical light + @media dark semantic-token palette
  extracted from inventory (cross-checked against authd and buchinese).
  Covers --fg, --fg-muted, --bg, --bg-elevated, --surface, --accent*,
  --danger*, --border*, --input-*, --warning, primitive --gray-* scale.
- dist/base.css: `input, textarea, select { font-size: 16px; }` prevents
  iOS Safari auto-zoom on focus.
- tests/tokens.test.ts: vitest — token presence in both light and dark
  blocks; WCAG AA contrast (>=4.5:1 body, >=3.0:1 UI) via inline
  hex-to-luminance math. All 10 tests green.
- tests/base.test.ts: vitest — asserts 16px rule covers all three selectors.
- .gitea/workflows/release.yml: tag-triggered CI — npm ci + npm test,
  CHANGELOG version gate, dist/scripts security grep, Gitea release
  artifact upload.
- README.md: four-step consumer integration guide with copy-pasteable
  snippets (package.json dep, Dockerfile cp, HTML link tags, app CSS).
- CHANGELOG.md: v0.1.0 entry.

npm audit --omit=dev: 0 vulnerabilities

Closes #1
This commit is contained in:
2026-05-13 17:56:08 -07:00
parent da38554c17
commit 6b06d00115
11 changed files with 1932 additions and 2 deletions

16
tests/base.test.ts Normal file
View File

@@ -0,0 +1,16 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'fs';
const css = readFileSync(new URL('../dist/base.css', import.meta.url), 'utf-8');
describe('base.css', () => {
it('sets font-size: 16px on input, textarea, select', () => {
expect(css).toMatch(/input\s*,\s*textarea\s*,\s*select\s*\{[^}]*font-size\s*:\s*16px/);
});
it('covers all three form control selectors in one rule', () => {
expect(css).toMatch(/input/);
expect(css).toMatch(/textarea/);
expect(css).toMatch(/select/);
});
});

133
tests/tokens.test.ts Normal file
View File

@@ -0,0 +1,133 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'fs';
const css = readFileSync(new URL('../dist/tokens.css', import.meta.url), 'utf-8');
// ---- CSS parser helpers ----
function extractRootBlock(src: string): string {
const m = src.match(/:root\s*\{([^}]+)\}/);
return m?.[1] ?? '';
}
function extractDarkBlock(src: string): string {
const afterMedia = src.split('@media (prefers-color-scheme: dark)')[1] ?? '';
const m = afterMedia.match(/:root\s*\{([^}]+)\}/);
return m?.[1] ?? '';
}
function parseProps(block: string): Map<string, string> {
const map = new Map<string, string>();
const re = /--([\w-]+)\s*:\s*([^;]+);/g;
let m: RegExpExecArray | null;
while ((m = re.exec(block)) !== null) {
map.set(`--${m[1]}`, m[2].trim());
}
return map;
}
function resolveToken(value: string, props: Map<string, string>): string {
const varRe = /var\(--([\w-]+)\)/;
let v = value;
for (let i = 0; i < 10 && varRe.test(v); i++) {
v = v.replace(varRe, (_, name) => props.get(`--${name}`) ?? 'unresolved');
}
return v.trim();
}
// ---- WCAG contrast helpers ----
function expandHex(hex: string): string {
const h = hex.replace('#', '');
return h.length === 3
? `#${h[0]}${h[0]}${h[1]}${h[1]}${h[2]}${h[2]}`
: `#${h}`;
}
function luminance(hex: string): number {
const h = expandHex(hex).replace('#', '');
const r = parseInt(h.slice(0, 2), 16) / 255;
const g = parseInt(h.slice(2, 4), 16) / 255;
const b = parseInt(h.slice(4, 6), 16) / 255;
const lin = (c: number) =>
c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
}
function contrast(hex1: string, hex2: string): number {
const l1 = luminance(hex1);
const l2 = luminance(hex2);
const [lighter, darker] = l1 > l2 ? [l1, l2] : [l2, l1];
return (lighter + 0.05) / (darker + 0.05);
}
// ---- Fixtures ----
const rootProps = parseProps(extractRootBlock(css));
const darkProps = parseProps(extractDarkBlock(css));
// Merged: dark overrides on top of light primitives so var() refs resolve correctly.
const mergedDark = new Map([...rootProps, ...darkProps]);
// Tokens that must be explicitly defined in both light and dark blocks.
const DARK_MODE_TOKENS = [
'--bg', '--bg-elevated', '--surface',
'--fg', '--fg-muted',
'--border-color', '--border-strong',
'--accent', '--accent-fg', '--accent-hover',
'--danger', '--danger-fg', '--danger-bg',
'--input-bg', '--input-fg', '--input-border', '--input-bg-focus',
];
// ---- Tests ----
describe('tokens.css — token presence', () => {
it('defines all dark-mode tokens in :root (light)', () => {
for (const token of DARK_MODE_TOKENS) {
expect(rootProps.has(token), `${token} missing from :root`).toBe(true);
}
});
it('defines all dark-mode tokens in @media (prefers-color-scheme: dark)', () => {
for (const token of DARK_MODE_TOKENS) {
expect(darkProps.has(token), `${token} missing from @media dark`).toBe(true);
}
});
});
describe('tokens.css — WCAG AA contrast (light)', () => {
const fg = resolveToken(rootProps.get('--fg')!, rootProps);
const fgMuted = resolveToken(rootProps.get('--fg-muted')!, rootProps);
const bg = resolveToken(rootProps.get('--bg')!, rootProps);
const borderStrong = resolveToken(rootProps.get('--border-strong')!, rootProps);
it('--fg on --bg >= 4.5:1', () => {
expect(contrast(fg, bg)).toBeGreaterThanOrEqual(4.5);
});
it('--fg-muted on --bg >= 4.5:1', () => {
expect(contrast(fgMuted, bg)).toBeGreaterThanOrEqual(4.5);
});
it('--border-strong on --bg >= 3.0:1 (UI contrast)', () => {
expect(contrast(borderStrong, bg)).toBeGreaterThanOrEqual(3.0);
});
});
describe('tokens.css — WCAG AA contrast (dark)', () => {
const fg = resolveToken(mergedDark.get('--fg')!, mergedDark);
const fgMuted = resolveToken(mergedDark.get('--fg-muted')!, mergedDark);
const bg = resolveToken(mergedDark.get('--bg')!, mergedDark);
const borderStrong = resolveToken(mergedDark.get('--border-strong')!, mergedDark);
it('--fg on --bg >= 4.5:1', () => {
expect(contrast(fg, bg)).toBeGreaterThanOrEqual(4.5);
});
it('--fg-muted on --bg >= 4.5:1', () => {
expect(contrast(fgMuted, bg)).toBeGreaterThanOrEqual(4.5);
});
it('--border-strong on --bg >= 3.0:1 (UI contrast)', () => {
expect(contrast(borderStrong, bg)).toBeGreaterThanOrEqual(3.0);
});
});