diff --git a/docs/STYLING.md b/docs/STYLING.md index 5f85b0df..d1ef21f0 100644 --- a/docs/STYLING.md +++ b/docs/STYLING.md @@ -13,8 +13,10 @@ element. Neither step injects styles at runtime. - `styles/theme-root.css.ts`: base typography and text colour scoped to `.luke-ui-theme`. - `recipes/`: component recipes exported from `@luke-ui/react/recipes`. - `styles/`: public layout utilities exported from `@luke-ui/react/styles`. -- `theme/contract.ts`: the semantic token tree and its `--luke-*` variable naming. -- `theme/contract.css.ts`: the typed `vars` contract built with `createGlobalThemeContract`. +- `theme/contract.ts`: the semantic token tree, its `--luke-*` variable naming, and the source-owned + `fontSizeSteps` typography step keys. +- `theme/contract.css.ts`: the typed `vars` contract, built by walking the semantic token tree + directly so it stays source-owned and free of styling-engine types. - `theme/foundation.ts`: the typed theme-foundation input and curated defaults. - `theme/color.ts`: OKLCH colour math, sRGB gamut mapping, and WCAG contrast. - `theme/build-theme.ts`: `buildTheme(foundation)`, `themeClassName`, and contrast validation. diff --git a/packages/@luke-ui/react/src/recipes/text.browser.test.ts b/packages/@luke-ui/react/src/recipes/text.browser.test.ts index 0df484ce..b6b0a050 100644 --- a/packages/@luke-ui/react/src/recipes/text.browser.test.ts +++ b/packages/@luke-ui/react/src/recipes/text.browser.test.ts @@ -1,5 +1,6 @@ import '@luke-ui/react/themes/tactile.css'; import { afterEach, expect, test } from 'vite-plus/test'; +import { fontSizeSteps } from '../theme/contract.js'; import { tactileFoundation } from '../theme/foundations.js'; import { buildTheme, themeClassName, themeRootClassName } from '../theme/index.js'; import { tactileThemeClassName } from '../themes/index.js'; @@ -54,6 +55,28 @@ test('trim is rendered by default and can be disabled', () => { expect(getComputedStyle(untrimmed, '::after').content).toBe('none'); }); +test('boolean lineClamp truncates to a single line without the multi-line box', () => { + const style = getComputedStyle(mountText({ lineClamp: true })); + + expect(style.display).toBe('block'); + expect(style.textOverflow).toBe('ellipsis'); + expect(style.whiteSpace).toBe('nowrap'); + expect(style.webkitLineClamp).toBe('none'); +}); + +test('numeric lineClamp switches on the multi-line -webkit box and clamp count', () => { + const style = getComputedStyle(mountText({ lineClamp: 3 })); + + // The browser folds the standard `line-clamp` declaration into this used `display`, so a + // `flow-root` box (rather than the authored `-webkit-box`) is itself proof the clamp applied. + // The full standard `line-clamp` declaration is verified against the built stylesheet in + // `../styles/stylesheet-contract.test.ts`. + expect(style.display).toBe('flow-root'); + expect(style.webkitBoxOrient).toBe('vertical'); + expect(style.webkitLineClamp).toBe('3'); + expect(style.overflow).toBe('hidden'); +}); + test('font inheritance also preserves surrounding currentColor', () => { const root = mountRoot(); root.style.color = 'rgb(1, 2, 3)'; @@ -92,7 +115,6 @@ test('an explicit numeric variant survives font inheritance', () => { test('all size steps use the generated Capsize trims for every curated font', () => { const families = ['inter', 'apple-system', 'dm-sans'] as const; - const sizes = ['100', '200', '300', '400', '500', '600', '700', '800', '900'] as const; const expectedTypography = { '100': { fontSize: '12px', lineHeight: '16px' }, '200': { fontSize: '14px', lineHeight: '20px' }, @@ -115,7 +137,7 @@ test('all size steps use the generated Capsize trims for every curated font', () ); expect(authoredFontFamily).toContain(curatedFamilyIdentity[fontFamily]); - for (const size of sizes) { + for (const size of fontSizeSteps) { const element = root.appendChild(document.createElement('span')); element.className = text({ size }); element.textContent = `${fontFamily} ${size}`; diff --git a/packages/@luke-ui/react/src/recipes/text.css.ts b/packages/@luke-ui/react/src/recipes/text.css.ts index 820b6c0d..c9e56c67 100644 --- a/packages/@luke-ui/react/src/recipes/text.css.ts +++ b/packages/@luke-ui/react/src/recipes/text.css.ts @@ -2,10 +2,11 @@ import type { ComplexStyleRule } from '@vanilla-extract/css'; import type { RecipeVariants } from '@vanilla-extract/recipes'; import { styleInLayer, recipeInLayer } from '../styles/layered-style.css.js'; import { vars } from '../theme/contract.css.js'; +import { fontSizeSteps } from '../theme/contract.js'; +import type { FontSizeStep } from '../theme/contract.js'; -const sizes = ['100', '200', '300', '400', '500', '600', '700', '800', '900'] as const; /** Typography size steps. */ -export type TextSize = (typeof sizes)[number]; +export type TextSize = FontSizeStep; /** Semantic text colours. */ export type TextColor = @@ -98,7 +99,7 @@ const weightVariants = { } as const; const sizeVariants = Object.fromEntries( - sizes.map((size) => [ + fontSizeSteps.map((size) => [ size, { fontSize: vars.font[size].fontSize, @@ -108,7 +109,7 @@ const sizeVariants = Object.fromEntries( ]), ) as Record; -const sizeStepCompoundVariants = sizes.map((size) => { +const sizeStepCompoundVariants = fontSizeSteps.map((size) => { const { baselineTrim, capHeightTrim, fontSize, lineHeight } = vars.font[size]; return { style: createLayeredTextStyle({ baselineTrim, capHeightTrim, fontSize, lineHeight }), diff --git a/packages/@luke-ui/react/src/styles/stylesheet-contract.test.ts b/packages/@luke-ui/react/src/styles/stylesheet-contract.test.ts index da519b98..e1aab068 100644 --- a/packages/@luke-ui/react/src/styles/stylesheet-contract.test.ts +++ b/packages/@luke-ui/react/src/styles/stylesheet-contract.test.ts @@ -3,12 +3,18 @@ import { parse } from 'postcss'; import type { AtRule, Root, Rule } from 'postcss'; import selectorParser from 'postcss-selector-parser'; import { expect, test } from 'vite-plus/test'; +import { fontSizeSteps } from '../theme/contract.js'; +import type { FontSizeStep } from '../theme/contract.js'; const retainedLayerNames = ['reset', 'theme', 'recipes', 'utilities'] as const; const retainedLayerNameSet = new Set(retainedLayerNames); -const textSizes = ['100', '200', '300', '400', '500', '600', '700', '800', '900'] as const; -type TextSize = (typeof textSizes)[number]; -type TextClassesBySize = Record>; +type TextClassesBySize = Record>; +const numericLineClampVariants = [2, 3, 4, 5] as const; +type NumericLineClampVariant = (typeof numericLineClampVariants)[number]; +type LineClampClasses = { + singleLine: Array; + numeric: Record>; +}; test('builds the public stylesheet with the retained layer contract', async () => { const stylesheet = await readFile(new URL('../../dist/stylesheet.css', import.meta.url), 'utf8'); @@ -16,12 +22,26 @@ test('builds the public stylesheet with the retained layer contract', async () = const styles = await import('@luke-ui/react/styles'); const recipeClasses = [...recipes.icon().split(' '), recipes.loadingSkeleton]; const textClassesBySize = Object.fromEntries( - textSizes.map((size) => [size, recipes.text({ size }).split(' ')]), + fontSizeSteps.map((size) => [size, recipes.text({ size }).split(' ')]), ) as TextClassesBySize; const utilityClasses = styles.createSprinkles({ display: 'grid' }).className?.split(' ') ?? []; + const lineClampClasses: LineClampClasses = { + numeric: Object.fromEntries( + numericLineClampVariants.map((lineClamp) => [ + lineClamp, + recipes.text({ lineClamp }).split(' '), + ]), + ) as Record>, + singleLine: recipes.text({ lineClamp: true }).split(' '), + }; expect(() => - assertStylesheetContract(stylesheet, { recipeClasses, textClassesBySize, utilityClasses }), + assertStylesheetContract(stylesheet, { + lineClampClasses, + recipeClasses, + textClassesBySize, + utilityClasses, + }), ).not.toThrow(); }); @@ -87,10 +107,12 @@ test('recognises escaped class identifiers', () => { function assertStylesheetContract( stylesheet: string, { + lineClampClasses, recipeClasses, textClassesBySize, utilityClasses, }: { + lineClampClasses?: LineClampClasses; recipeClasses: Array; textClassesBySize?: TextClassesBySize; utilityClasses: Array; @@ -109,6 +131,7 @@ function assertStylesheetContract( for (const className of recipeClasses) assertClassOwnership(root, className, 'recipes'); for (const className of utilityClasses) assertClassOwnership(root, className, 'utilities'); if (textClassesBySize) assertTextTrimOwnership(root, textClassesBySize); + if (lineClampClasses) assertLineClampOwnership(root, lineClampClasses); } function getInitialLayerOrder(root: Root): Array { @@ -194,7 +217,7 @@ function assertClassOwnership(root: Root, className: string, layerName: string): } function assertTextTrimOwnership(root: Root, textClassesBySize: TextClassesBySize): void { - for (const size of textSizes) { + for (const size of fontSizeSteps) { const rules = textClassesBySize[size].flatMap((className) => getRulesForClass(root, className)); assertPseudoDeclaration( rules, @@ -211,6 +234,31 @@ function assertTextTrimOwnership(root: Root, textClassesBySize: TextClassesBySiz } } +function assertLineClampOwnership(root: Root, { numeric, singleLine }: LineClampClasses): void { + const singleLineRules = singleLine.flatMap((className) => getRulesForClass(root, className)); + assertDeclaration(singleLineRules, 'display', 'block'); + assertDeclaration(singleLineRules, 'text-overflow', 'ellipsis'); + assertDeclaration(singleLineRules, 'white-space', 'nowrap'); + + for (const lineClamp of numericLineClampVariants) { + const rules = numeric[lineClamp].flatMap((className) => getRulesForClass(root, className)); + assertDeclaration(rules, 'display', '-webkit-box'); + assertDeclaration(rules, '-webkit-box-orient', 'vertical'); + assertDeclaration(rules, '-webkit-line-clamp', String(lineClamp)); + assertDeclaration(rules, 'line-clamp', String(lineClamp)); + } +} + +function assertDeclaration(rules: Array, property: string, value: string): void { + const matchingRules = rules.filter((rule) => + rule.nodes.some( + (node) => node.type === 'decl' && node.prop === property && node.value === value, + ), + ); + expect(matchingRules.length).toBeGreaterThan(0); + for (const rule of matchingRules) expect(getOwningLayer(rule)).toBe('recipes'); +} + function assertPseudoDeclaration( rules: Array, pseudo: string, diff --git a/packages/@luke-ui/react/src/theme/build-theme.ts b/packages/@luke-ui/react/src/theme/build-theme.ts index 9e34b623..44eb8fa6 100644 --- a/packages/@luke-ui/react/src/theme/build-theme.ts +++ b/packages/@luke-ui/react/src/theme/build-theme.ts @@ -4,7 +4,7 @@ import interMetrics from '@capsizecss/metrics/inter'; import { precomputeValues } from '@capsizecss/vanilla-extract'; import type { Oklch } from './color.js'; import { contrastRatio, formatOklch, gamutMapOklch, parseColor } from './color.js'; -import { flattenThemeContract } from './contract.js'; +import { flattenThemeContract, fontSizeSteps } from './contract.js'; import type { ThemeFoundation, ThemeModeFoundation, ThemeSourceColors } from './foundation.js'; import { defaultFontFamily, @@ -721,7 +721,7 @@ function buildIdentityValues(foundation: ThemeFoundation): Record { const values: Record = {}; - for (const step of [100, 200, 300, 400, 500, 600, 700, 800, 900] as const) { + for (const step of fontSizeSteps) { const fontSize = Number.parseFloat(FONT_VALUES[`font.${step}.fontSize`]); const leading = Number.parseFloat(FONT_VALUES[`font.${step}.lineHeight`]); const { baselineTrim, capHeightTrim } = precomputeValues({ diff --git a/packages/@luke-ui/react/src/theme/contract.css.ts b/packages/@luke-ui/react/src/theme/contract.css.ts index 32fdbef0..fd489c78 100644 --- a/packages/@luke-ui/react/src/theme/contract.css.ts +++ b/packages/@luke-ui/react/src/theme/contract.css.ts @@ -1,5 +1,32 @@ -import { createGlobalThemeContract } from '@vanilla-extract/css'; -import { kebabCaseSegment, themeContractTree } from './contract.js'; +import { themeContractTree, themeVarName } from './contract.js'; + +/** + * Recursively walks the contract tree and builds a same-shape object where each leaf holds its + * stable `var(--luke-*)` reference. This keeps the public `vars` shape fully source-owned and free + * of styling-engine types: no `@vanilla-extract/css` import contributes to its declarations. + */ +function buildVars( + node: Record, + segments: Array = [], +): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(node)) { + const path = [...segments, key]; + result[key] = + value === null + ? `var(${themeVarName(path)})` + : buildVars(value as Record, path); + } + return result; +} + +type ContractVars = { + [K in keyof T]: T[K] extends null + ? string + : T[K] extends Record + ? ContractVars + : never; +}; /** * Typed access to the semantic theme custom properties. Each path resolves to a stable global @@ -7,7 +34,4 @@ import { kebabCaseSegment, themeContractTree } from './contract.js'; * `var(--luke-color-intent-danger-surface-solid-hover)`. Emits no CSS; values are supplied by a * theme stylesheet built with `buildTheme`. */ -export const vars = createGlobalThemeContract( - themeContractTree, - (_value, path) => `luke-${path.map(kebabCaseSegment).join('-')}`, -); +export const vars = buildVars(themeContractTree) as ContractVars; diff --git a/packages/@luke-ui/react/src/theme/contract.test.ts b/packages/@luke-ui/react/src/theme/contract.test.ts index 358743ff..7ef9cbf4 100644 --- a/packages/@luke-ui/react/src/theme/contract.test.ts +++ b/packages/@luke-ui/react/src/theme/contract.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vite-plus/test'; import { vars } from './contract.css.js'; -import { flattenThemeContract } from './contract.js'; +import { flattenThemeContract, fontSizeSteps, themeContractTree } from './contract.js'; function countLeaves(node: unknown): number { if (typeof node === 'string') return 1; @@ -52,4 +52,11 @@ describe('theme contract', () => { xsmall: 'var(--luke-icon-size-xsmall)', }); }); + + it('keeps fontSizeSteps as the single source of truth for the font contract keys', () => { + const fontStepKeys = Object.keys(themeContractTree.font).filter((key) => { + return key !== 'family' && key !== 'weight'; + }); + expect(fontSizeSteps).toEqual(fontStepKeys); + }); }); diff --git a/packages/@luke-ui/react/src/theme/contract.ts b/packages/@luke-ui/react/src/theme/contract.ts index 3354d366..f1244973 100644 --- a/packages/@luke-ui/react/src/theme/contract.ts +++ b/packages/@luke-ui/react/src/theme/contract.ts @@ -6,10 +6,26 @@ const fontStep = { lineHeight: null, }; +/** Source-owned typography size step keys, in display order. */ +export const fontSizeSteps = [ + '100', + '200', + '300', + '400', + '500', + '600', + '700', + '800', + '900', +] as const; + +/** A typography size step key. */ +export type FontSizeStep = (typeof fontSizeSteps)[number]; + /** - * The semantic token tree shared by the vanilla-extract contract and `buildTheme`, so typed paths - * and emitted CSS variable names can never diverge. Leaves are `null`; every path maps to one - * stable `--luke-*` custom property. + * The semantic token tree shared by the public `vars` contract and `buildTheme`, so typed paths and + * emitted CSS variable names can never diverge. Leaves are `null`; every path maps to one stable + * `--luke-*` custom property. */ export const themeContractTree = { /** Semantic colours for surfaces, content, borders, loading, and six named intents. */ @@ -227,7 +243,7 @@ export function flattenThemeContract(): Array<[path: string, varName: string]> { * Kebab-cases one camelCase path segment, for example `solidHover` becomes `solid-hover`. Joining * kebab-cased segments with `-` under the `luke-` prefix yields the CSS variable name. */ -export function kebabCaseSegment(segment: string): string { +function kebabCaseSegment(segment: string): string { return segment.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase(); } @@ -253,6 +269,11 @@ function isContractNode(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } -function themeVarName(segments: Array): string { +/** + * Builds the stable `--luke-*` CSS variable name for a contract path's segments, for example + * `['color', 'intent', 'danger', 'surface', 'solidHover']` becomes + * `--luke-color-intent-danger-surface-solid-hover`. + */ +export function themeVarName(segments: Array): string { return `--luke-${segments.map(kebabCaseSegment).join('-')}`; }