From e8e612ebef632e3691865fed8a36e526dc053318 Mon Sep 17 00:00:00 2001
From: Luke Bennett
Date: Mon, 12 Oct 2026 11:11:07 +1100
Subject: [PATCH] Style native HTML in Prose and scroll wide tables without a
wrapper (#779)
---
.../docs/components/typography/prose.mdx | 62 ++--
.../mdx-elements.browser.test.tsx | 10 +-
.../docs-article/mdx-elements.css.ts | 4 +
apps/docs/src/examples/prose/basic.tsx | 10 +-
apps/docs/src/examples/prose/code.tsx | 19 --
.../src/examples/prose/heading-and-text.tsx | 14 +
apps/docs/src/examples/prose/media.tsx | 16 -
apps/docs/src/examples/prose/wide-content.tsx | 50 ++++
apps/docs/src/examples/prose/wide-table.tsx | 52 ----
.../src/core/heading/heading-typography.ts | 20 ++
.../react/src/core/heading/heading.tsx | 21 +-
.../src/core/prose/prose.browser.test.tsx | 283 ++++++++++++++++--
.../@luke-ui/react/src/core/prose/prose.tsx | 5 +-
.../react/src/core/prose/recipe.css.ts | 92 +++++-
.../src/core/styles/stylesheet-size.test.ts | 7 +-
.../react/src/core/text/recipe.css.ts | 23 +-
.../src/core/text/type-style-declarations.ts | 28 ++
17 files changed, 530 insertions(+), 186 deletions(-)
delete mode 100644 apps/docs/src/examples/prose/code.tsx
create mode 100644 apps/docs/src/examples/prose/heading-and-text.tsx
delete mode 100644 apps/docs/src/examples/prose/media.tsx
create mode 100644 apps/docs/src/examples/prose/wide-content.tsx
delete mode 100644 apps/docs/src/examples/prose/wide-table.tsx
create mode 100644 packages/@luke-ui/react/src/core/heading/heading-typography.ts
create mode 100644 packages/@luke-ui/react/src/core/text/type-style-declarations.ts
diff --git a/apps/docs/content/docs/components/typography/prose.mdx b/apps/docs/content/docs/components/typography/prose.mdx
index dc42d0a5..aedfaf1b 100644
--- a/apps/docs/content/docs/components/typography/prose.mdx
+++ b/apps/docs/content/docs/components/typography/prose.mdx
@@ -1,54 +1,46 @@
---
title: Prose
-description: Vertical rhythm and list styling for long-form content.
+description: Type, spacing, and element styles for long-form HTML content.
source: packages/@luke-ui/react/src/exports/prose.ts
---
-`Prose` adds vertical rhythm and list styling to long-form content. It does not constrain content
-width. Pair it with [Heading](/components/typography/heading) and
-[Text](/components/typography/text) for visual hierarchy, or map rendered Markdown and CMS elements
-to those components.
+`Prose` styles long-form content written as plain HTML, such as rendered Markdown or CMS output.
+Write headings, paragraphs, lists, code blocks, and tables as native elements. Headings and
+paragraphs get the same type styles as [Heading](/components/typography/heading) and
+[Text](/components/typography/text). `Prose` spaces the blocks and does not set a content width.
-## Lists
-
-Lists inside `Prose` include markers and indentation, including nested lists. Untyped ordered lists
-use decimal markers, and typed lists keep their HTML `type` markers. Under a reset that removes list
-markers, such as Tailwind Preflight, Chromium shows no letter or roman markers. Decimal markers stay
-in every browser.
-
-
+## Heading and Text
-## Media
+Use `Heading` or `Text` for an element that needs a prop, such as a different type style or colour.
+They keep their own props inside `Prose`, and `Prose` still spaces them.
-Images and videos shrink to fit the content width without changing their proportions.
+
-
-
-## Code blocks
+## Lists
-Use native `
` for code blocks and [Code](/components/typography/code) for inline
-fragments. Long code lines scroll inside `pre`. Add `tabIndex={0}` to an overflowing `pre` so it can
-receive keyboard focus.
+Lists get markers and indentation at every nesting level. Untyped ordered lists use decimal markers.
+A typed list, such as ``, keeps its HTML markers, except in Chromium under a reset that
+removes list markers, such as Tailwind Preflight.
-
+
-## Tables
+## Wide content
-Tables fill the available width but can overflow when their content is too wide. Wrap wide tables in
-[ScrollFade](/components/layout/scroll-fade) with an accessible name.
+Images and videos shrink to fit the content width. A code block or table wider than the content
+scrolls sideways inside its own box. A table that fits takes the width of its content.
-The scroll wrapper needs its own spacing. After a paragraph, set `marginBlockStart="sp40"` on the
-wrapper to match the usual `Prose` gap.
+When you control the markup, set `tabIndex={0}` on a `pre` or `table` so someone using a keyboard
+can scroll it in every browser.
-
+
## Opting out
-Add the `not-prose` class to an element to keep `Prose` spacing, list styles, and table padding off
-it and everything inside it. Use it for an interactive widget or a component that sets its own
-layout. A `Prose` inside that element applies again.
+Add the `not-prose` class to an element to keep `Prose` styles off it and everything inside it. Use
+it for an interactive widget or a component that sets its own layout. A `Prose` nested inside that
+element applies again.
```tsx
@@ -59,10 +51,12 @@ layout. A `Prose` inside that element applies again.
```
-A `not-prose` element that follows a heading or rule keeps the gap `Prose` puts after it.
+The element still keeps the gap that `Prose` puts after a heading or rule.
+
+## Browser support
-`Prose` styles use CSS `@scope`, which needs Chrome 118, Safari 17.4, or Firefox 146. Older browsers
-show the content without `Prose` spacing.
+`Prose` uses CSS `@scope`, which needs Chrome 118, Safari 17.4, or Firefox 146. Older browsers show
+the content without `Prose` styles.
## API
diff --git a/apps/docs/src/components/docs-article/mdx-elements.browser.test.tsx b/apps/docs/src/components/docs-article/mdx-elements.browser.test.tsx
index 429840bb..0b29f085 100644
--- a/apps/docs/src/components/docs-article/mdx-elements.browser.test.tsx
+++ b/apps/docs/src/components/docs-article/mdx-elements.browser.test.tsx
@@ -1,5 +1,6 @@
import '../../styles/app.css';
import '@luke-ui/theme-tactile/stylesheet.css';
+import { Prose } from '@luke-ui/react/prose';
import { Provider } from '@luke-ui/react/provider';
import spriteSheetHref from '@luke-ui/react/spritesheet.svg?url&no-inline';
import {
@@ -258,7 +259,7 @@ test('renders a heading without an id as plain text', async () => {
test('names a wide table as a focusable region once it overflows', async () => {
await page.viewport(400, 800);
await renderMdx(
-
+
@@ -266,12 +267,17 @@ test('names a wide table as a focusable region once it overflows', async () => {
-
,
+ ,
);
const region = page.getByRole('region', { name: 'Scrollable table' });
await expect.element(region).toBeVisible();
expect(region.element().tabIndex).toBe(0);
+ // Prose makes a plain table scroll itself. The docs table leaves that to its ScrollFade.
+ const scroller = region.element();
+ const table = scroller.querySelector('table')!;
+ expect(scroller.scrollWidth).toBeGreaterThan(scroller.clientWidth);
+ expect(table.scrollWidth).toBe(table.clientWidth);
});
test('leaves fenced code as plain code and styles inline code', async () => {
diff --git a/apps/docs/src/components/docs-article/mdx-elements.css.ts b/apps/docs/src/components/docs-article/mdx-elements.css.ts
index ac3bea1a..eecba92b 100644
--- a/apps/docs/src/components/docs-article/mdx-elements.css.ts
+++ b/apps/docs/src/components/docs-article/mdx-elements.css.ts
@@ -117,7 +117,11 @@ export const tableScroll = style({
export const table = style({
'@layer': {
components: {
+ // Prose makes plain tables scroll themselves. This one scrolls inside its ScrollFade.
+ display: 'table',
inlineSize: '100%',
+ maxInlineSize: 'none',
+ overflowX: 'visible',
},
},
});
diff --git a/apps/docs/src/examples/prose/basic.tsx b/apps/docs/src/examples/prose/basic.tsx
index 6694f072..9f942014 100644
--- a/apps/docs/src/examples/prose/basic.tsx
+++ b/apps/docs/src/examples/prose/basic.tsx
@@ -1,16 +1,14 @@
-import { Heading } from '@luke-ui/react/heading';
import { Prose } from '@luke-ui/react/prose';
-import { Text } from '@luke-ui/react/text';
export default () => {
return (
- Why structure matters
-
+
Why structure matters
+
Headings break a page into sections a reader can scan, paragraphs group related sentences,
and lists set out steps or options one at a time.
-
- Reading a list
+
+
Reading a list
Scan the heading to see what the section covers.
Read the paragraph for context.
diff --git a/apps/docs/src/examples/prose/code.tsx b/apps/docs/src/examples/prose/code.tsx
deleted file mode 100644
index e093e552..00000000
--- a/apps/docs/src/examples/prose/code.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import { Code } from '@luke-ui/react/code';
-import { Prose } from '@luke-ui/react/prose';
-import { Text } from '@luke-ui/react/text';
-
-export default () => {
- return (
-
-
- Use Code for a short inline fragment.
-
-
- {`
- A heading
- A paragraph in the document.
-`}
-
-
- );
-};
diff --git a/apps/docs/src/examples/prose/heading-and-text.tsx b/apps/docs/src/examples/prose/heading-and-text.tsx
new file mode 100644
index 00000000..72d11a41
--- /dev/null
+++ b/apps/docs/src/examples/prose/heading-and-text.tsx
@@ -0,0 +1,14 @@
+import { Prose } from '@luke-ui/react/prose';
+import { Text } from '@luke-ui/react/text';
+
+export default () => {
+ return (
+
+
Choosing a type style
+
+ A lead paragraph uses Text to step up from the body style.
+
+
The paragraphs after it stay plain and use the body style.
+
+ );
+};
diff --git a/apps/docs/src/examples/prose/media.tsx b/apps/docs/src/examples/prose/media.tsx
deleted file mode 100644
index caa0cc30..00000000
--- a/apps/docs/src/examples/prose/media.tsx
+++ /dev/null
@@ -1,16 +0,0 @@
-import { Prose } from '@luke-ui/react/prose';
-
-export default () => {
- return (
-
-
-
-
-
- );
-};
diff --git a/apps/docs/src/examples/prose/wide-content.tsx b/apps/docs/src/examples/prose/wide-content.tsx
new file mode 100644
index 00000000..043e232f
--- /dev/null
+++ b/apps/docs/src/examples/prose/wide-content.tsx
@@ -0,0 +1,50 @@
+import { Box } from '@luke-ui/react/box';
+import { Code } from '@luke-ui/react/code';
+import { Prose } from '@luke-ui/react/prose';
+import { Text } from '@luke-ui/react/text';
+
+export default () => {
+ return (
+
+
+ Import each component from its own entry point.
+
+ {`import { Prose } from '@luke-ui/react/prose';`}
+
+
+
+
+
Entry point
+
Exports
+
+
+
+
+
+ @luke-ui/react/heading
+
+
+ Heading, HeadingProps
+
+
+
+
+ @luke-ui/react/prose
+
+
+ Prose, ProseProps
+
+
+
+
+
+
+ );
+};
diff --git a/apps/docs/src/examples/prose/wide-table.tsx b/apps/docs/src/examples/prose/wide-table.tsx
deleted file mode 100644
index b1cd64d9..00000000
--- a/apps/docs/src/examples/prose/wide-table.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import { Code } from '@luke-ui/react/code';
-import { Prose } from '@luke-ui/react/prose';
-import { ScrollFade } from '@luke-ui/react/scroll-fade';
-import { Text } from '@luke-ui/react/text';
-
-export default () => {
- return (
-
- Scroll horizontally to see every column.
-
-
-
-
-
Token
-
Value
-
CSS custom property
-
-
-
-
-
- sp8
-
-
8px
-
- var(--luke-space-sp8)
-
-
-
-
- sp16
-
-
16px
-
- var(--luke-space-sp16)
-
-
-
-
- sp24
-
-
24px
-
- var(--luke-space-sp24)
-
-
-
-
-
-
- );
-};
diff --git a/packages/@luke-ui/react/src/core/heading/heading-typography.ts b/packages/@luke-ui/react/src/core/heading/heading-typography.ts
new file mode 100644
index 00000000..f2963faf
--- /dev/null
+++ b/packages/@luke-ui/react/src/core/heading/heading-typography.ts
@@ -0,0 +1,20 @@
+import type { FontWeightRole, TypeStyle } from '../../theme/type-styles.js';
+import type { HeadingLevel } from './heading-context.js';
+
+/**
+ * The type style each heading level resolves to. `Heading` uses it for its default, and `Prose`
+ * uses it for plain `h1`–`h6`, so the two cannot drift. A plain module so a `.css.ts` file can
+ * import it.
+ */
+export const headingTypographyByLevel = {
+ 1: 'heading1',
+ 2: 'heading2',
+ 3: 'heading3',
+ 4: 'heading4',
+ // h5/h6 stay in the outline without inventing heading5/heading6 type styles.
+ 5: 'lead',
+ 6: 'body',
+} as const satisfies Record;
+
+/** The weight role a `Heading` uses unless `fontWeight` says otherwise. */
+export const headingDefaultFontWeight = 'heading' as const satisfies FontWeightRole;
diff --git a/packages/@luke-ui/react/src/core/heading/heading.tsx b/packages/@luke-ui/react/src/core/heading/heading.tsx
index 0c38e6e0..9c1f1ffd 100644
--- a/packages/@luke-ui/react/src/core/heading/heading.tsx
+++ b/packages/@luke-ui/react/src/core/heading/heading.tsx
@@ -3,6 +3,7 @@ import { Text } from '../text/text.js';
import type { Prettify } from '../types/prettify.js';
import type { HeadingLevel } from './heading-context.js';
import { HeadingPresenceProvider, useHeadingLevel } from './heading-context.js';
+import { headingDefaultFontWeight, headingTypographyByLevel } from './heading-typography.js';
export type { HeadingLevel } from './heading-context.js';
export { HeadingLevels, useHeadingLevel } from './heading-context.js';
@@ -15,19 +16,15 @@ interface _HeadingProps extends TextProps {
/** Props for `Heading`. */
export type HeadingProps = Prettify<_HeadingProps>;
-const typographyByLevel = {
- 1: 'heading1',
- 2: 'heading2',
- 3: 'heading3',
- 4: 'heading4',
- // h5/h6 stay in the outline without inventing heading5/heading6 type styles.
- 5: 'lead',
- 6: 'body',
-} as const satisfies Record>;
-
/** Semantic heading with automatic level composition and level-based typography. */
export function Heading(props: HeadingProps) {
- const { elementType, fontWeight = 'heading', level, typography, ...textProps } = props;
+ const {
+ elementType,
+ fontWeight = headingDefaultFontWeight,
+ level,
+ typography,
+ ...textProps
+ } = props;
const { level: contextLevel } = useHeadingLevel();
const resolvedLevel = level ?? contextLevel;
@@ -36,7 +33,7 @@ export function Heading(props: HeadingProps) {
diff --git a/packages/@luke-ui/react/src/core/prose/prose.browser.test.tsx b/packages/@luke-ui/react/src/core/prose/prose.browser.test.tsx
index de620271..8ffd9bf8 100644
--- a/packages/@luke-ui/react/src/core/prose/prose.browser.test.tsx
+++ b/packages/@luke-ui/react/src/core/prose/prose.browser.test.tsx
@@ -328,6 +328,36 @@ test('scrolls long preformatted lines without widening a narrow grid column', as
await expect.poll(() => pre.scrollLeft).toBeGreaterThan(0);
});
+test('scrolls a wide table inside a narrow container', async () => {
+ const { locator, user } = render(
+
+
+ {/* oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- scrollable table */}
+
+
+
+
{'A long cell that needs more room than the column. '.repeat(3)}
,
+ );
+ const [inside, outside] = locator.element().querySelectorAll('h2');
+
+ expect(typeMetrics(inside!)).toEqual(typeMetrics(outside!));
+});
+
+// A list marker and a cell baseline align to the first box, which the trim box would take.
+test('gives a loose list item the block size of a tight one', () => {
+ const { locator } = render(
+
+
+
+
Item
+
+
Item
+
+ ,
+ );
+ const [loose, tight] = locator.element().querySelectorAll('li');
+
+ expect(loose!.getBoundingClientRect().height).toBe(tight!.getBoundingClientRect().height);
+});
+
+test('aligns the text of a paragraph cell with a plain cell', () => {
+ const { locator } = render(
+
+
+
+
+
+
Cell
+
+
Cell
+
+
+
+ ,
+ );
+ const [paragraphCell, plainCell] = locator.element().querySelectorAll('td');
+ const textTop = (cell: Element) => {
+ const range = new Range();
+ range.selectNodeContents(cell.querySelector('p') ?? cell);
+ return range.getBoundingClientRect().top;
+ };
+
+ expect(textTop(paragraphCell!)).toBe(textTop(plainCell!));
+});
+
+// A Prose below a not-prose element and a Prose inside a Prose both start a scope.
+const proseBoundaries = [
+ {
+ name: 'a Prose inside a not-prose element',
+ node: (
+
+
+
+
Heading
+
Paragraph
+
+
+
+ ),
+ },
+ {
+ name: 'a Prose inside a Prose',
+ node: (
+
+
+
Heading
+
Paragraph
+
+
+ ),
+ },
+];
+
+for (const { name, node } of proseBoundaries) {
+ test(`styles plain elements in ${name} like Heading and Text`, () => {
+ const { locator } = render(
+
diff --git a/packages/@luke-ui/react/src/core/prose/prose.tsx b/packages/@luke-ui/react/src/core/prose/prose.tsx
index de945128..ee6a6655 100644
--- a/packages/@luke-ui/react/src/core/prose/prose.tsx
+++ b/packages/@luke-ui/react/src/core/prose/prose.tsx
@@ -5,8 +5,9 @@ import { proseRecipe } from './recipe.css.js';
export interface ProseProps extends ComponentProps<'div'> {}
/**
- * Adds vertical rhythm and list styling to long-form content such as rendered Markdown, MDX, or
- * CMS content. Pair it with Luke UI typography components for visual hierarchy.
+ * Styles long-form content such as rendered Markdown, MDX, or CMS content. Plain headings and
+ * paragraphs get the same type styles as `Heading` and `Text`, and Prose spaces the blocks and
+ * styles lists, code blocks, tables, rules, and media.
*
* Add the `not-prose` class to an element to keep Prose styling off it and everything inside it. A
* `Prose` nested inside that element applies again.
diff --git a/packages/@luke-ui/react/src/core/prose/recipe.css.ts b/packages/@luke-ui/react/src/core/prose/recipe.css.ts
index e1e5e9a2..fef92b2b 100644
--- a/packages/@luke-ui/react/src/core/prose/recipe.css.ts
+++ b/packages/@luke-ui/react/src/core/prose/recipe.css.ts
@@ -1,6 +1,15 @@
+import { createVar } from '@vanilla-extract/css';
import { vars } from '../../theme/contract.css.js';
+import type { FontWeightRole, TypeStyle } from '../../theme/type-styles.js';
+import { typeStyleWeightRole } from '../../theme/type-styles.js';
+import {
+ headingDefaultFontWeight,
+ headingTypographyByLevel,
+} from '../heading/heading-typography.js';
import { globalStyleInLayer, style } from '../styles/layered-style.css.js';
import { recipe } from '../styles/recipe.js';
+import { textElementClassName } from '../text/recipe.css.js';
+import { typeStyleFontDeclarations, typeStyleTrimVars } from '../text/type-style-declarations.js';
const proseScopeClassName = style({}, 'prose');
@@ -16,8 +25,13 @@ export const proseRecipe = recipe({ base: proseScopeClassName });
// specificity, so recipe and utility classes win on specificity as well as on layer order.
const proseScope = `(.${proseScopeClassName})`;
-function proseStyle(selector: string, rule: Parameters[2]) {
- globalStyleInLayer('recipes', `:where(${selector})`, {
+function proseStyle(
+ selector: string,
+ rule: Parameters[2],
+ // A pseudo-element cannot sit inside `:where()`, so it follows it.
+ pseudoElement = '',
+) {
+ globalStyleInLayer('recipes', `:where(${selector})${pseudoElement}`, {
'@scope': { [`${proseScope} to (.not-prose)`]: rule },
});
}
@@ -94,10 +108,16 @@ proseStyle('hr', {
border: 'none',
borderBlockStart: `1px solid ${vars.color.border.decorative}`,
});
+// A table scrolls inside its own box only as a block, so a narrow table takes its content width
+// instead of filling the line. `fit-content` rather than `max-content`, so a table of wrapping text
+// does not widen an auto-sized grid or flex track. CMS markup can't wrap a table in a scroller.
proseStyle('table', {
borderCollapse: 'collapse',
borderSpacing: 0,
- inlineSize: '100%',
+ display: 'block',
+ inlineSize: 'fit-content',
+ maxInlineSize: '100%',
+ overflowX: 'auto',
});
proseStyle('th, td', { paddingBlock: vars.space.sp8, paddingInline: vars.space.sp12 });
proseStyle('caption, th', { textAlign: 'start' });
@@ -110,6 +130,72 @@ proseStyle('tbody tr + tr, tfoot', {
borderBlockStart: `1px solid ${vars.color.border.decorative}`,
});
+// Plain headings and paragraphs get the type style `Heading` and `Text` give them, trim included.
+// `Heading` and `Text` carry `textElementClassName` and are excluded, so they keep their own props.
+// The level map, the weight roles, and the font and trim declarations belong to `Heading` and
+// `Text`, so nothing here names a type style or a weight.
+interface PlainElement {
+ element: string;
+ typography: TypeStyle;
+ weightRole: FontWeightRole;
+}
+const plainElements: ReadonlyArray = [
+ ...(Object.entries(headingTypographyByLevel) as Array<[string, TypeStyle]>).map(
+ ([level, typography]) => {
+ return { element: `h${level}`, typography, weightRole: headingDefaultFontWeight };
+ },
+ ),
+ { element: 'p', typography: 'body', weightRole: typeStyleWeightRole.body },
+];
+
+// `:is()` shares the exclusion across a list, and `:where()` keeps the whole match at zero
+// specificity.
+function plain(elements: ReadonlyArray) {
+ if (elements.length === 1) return `${elements[0]}:not(.${textElementClassName})`;
+
+ return `:is(${elements.join(',')}):not(.${textElementClassName})`;
+}
+
+function groupElementsBy(key: (entry: PlainElement) => Key) {
+ const groups = new Map>();
+ for (const entry of plainElements) {
+ const group = groups.get(key(entry));
+ if (group) group.push(entry.element);
+ else groups.set(key(entry), [entry.element]);
+ }
+
+ return groups;
+}
+
+// Each font rule hands its Capsize trims to the single pair of pseudo-element rules below.
+const capHeightTrim = createVar();
+const baselineTrim = createVar();
+
+for (const [typography, elements] of groupElementsBy((entry) => entry.typography)) {
+ const trims = typeStyleTrimVars(typography);
+ proseStyle(plain(elements), {
+ ...typeStyleFontDeclarations(typography),
+ vars: { [baselineTrim]: trims.baselineTrim, [capHeightTrim]: trims.capHeightTrim },
+ });
+}
+for (const [weightRole, elements] of groupElementsBy((entry) => entry.weightRole)) {
+ proseStyle(plain(elements), { fontWeight: vars.font.weight[weightRole] });
+}
+
+// A list marker and a cell baseline align to the first box, and the trim box comes first, so a
+// paragraph directly in `li`, `td`, or `th` skips the trim.
+const trimmedElements = `${plain(plainElements.map(({ element }) => element))}:not(:is(li, td, th) > *)`;
+proseStyle(
+ trimmedElements,
+ { content: "''", display: 'table', marginBlockEnd: capHeightTrim },
+ '::before',
+);
+proseStyle(
+ trimmedElements,
+ { content: "''", display: 'table', marginBlockStart: baselineTrim },
+ '::after',
+);
+
proseBoundaryGapStyle(['h1'], vars.space.sp40);
proseBoundaryGapStyle(['h2'], vars.space.sp32);
proseBoundaryGapStyle(['h3'], vars.space.sp24);
diff --git a/packages/@luke-ui/react/src/core/styles/stylesheet-size.test.ts b/packages/@luke-ui/react/src/core/styles/stylesheet-size.test.ts
index f4e91426..05168376 100644
--- a/packages/@luke-ui/react/src/core/styles/stylesheet-size.test.ts
+++ b/packages/@luke-ui/react/src/core/styles/stylesheet-size.test.ts
@@ -2,9 +2,10 @@ import { gzipSync } from 'node:zlib';
import { readFile } from 'node:fs/promises';
import { expect, test } from 'vite-plus/test';
-// Measured at 12680 gzip bytes (level 9), per the measurement procedure in issue #550. Ceiling leaves headroom for the remaining component
-// migrations. If this fails, investigate what grew; don't raise the ceiling without a reason.
-const maximumGzipBytes = 13_000;
+// Measured at 13129 gzip bytes (level 9). The ceiling keeps about 200 bytes of headroom for the
+// remaining component migrations. If this fails, investigate what grew; don't raise the ceiling
+// without a reason.
+const maximumGzipBytes = 13_330;
test('keeps the public stylesheet within its size budget', async () => {
const stylesheet = await readFile(new URL('../../../dist/stylesheet.css', import.meta.url));
diff --git a/packages/@luke-ui/react/src/core/text/recipe.css.ts b/packages/@luke-ui/react/src/core/text/recipe.css.ts
index 272115b2..51232df3 100644
--- a/packages/@luke-ui/react/src/core/text/recipe.css.ts
+++ b/packages/@luke-ui/react/src/core/text/recipe.css.ts
@@ -1,15 +1,15 @@
import type { ComplexStyleRule } from '@vanilla-extract/css';
import { createVar } from '@vanilla-extract/css';
-import { capsizeTrimVarName } from '../../theme/capsize-trim-vars.js';
import { vars } from '../../theme/contract.css.js';
import type { FontWeightRole, TypeStyle } from '../../theme/type-styles.js';
import { fontWeightRoles, typeStyles } from '../../theme/type-styles.js';
import { globalStyleInLayer, style } from '../styles/layered-style.css.js';
import type { RecipeSelection } from '../styles/recipe-types.js';
import { recipe } from '../styles/recipe.js';
+import { typeStyleFontDeclarations, typeStyleTrimVars } from './type-style-declarations.js';
/** Marks every element `textRecipe` styles, so the margin rule below can match it. */
-const textElementClassName = style({}, 'text');
+export const textElementClassName = style({}, 'text');
// `Text` renders headings, paragraphs, and blockquotes, which have user agent margins. Spacing
// between them belongs to layout, so `Text` removes them. `:where()` keeps this at zero
@@ -61,16 +61,10 @@ const weightVariants = Object.fromEntries(
) as Record;
const typographyVariants = Object.fromEntries(
- typeStyles.map((typography) => [
- typography,
- {
- fontFamily: vars.font[typography].fontFamily,
- fontSize: vars.font[typography].fontSize,
- letterSpacing: vars.font[typography].letterSpacing,
- lineHeight: vars.font[typography].lineHeight,
- vars: { [textLineHeight]: vars.font[typography].lineHeight },
- },
- ]),
+ typeStyles.map((typography) => {
+ const declarations = typeStyleFontDeclarations(typography);
+ return [typography, { ...declarations, vars: { [textLineHeight]: declarations.lineHeight } }];
+ }),
) as Record<
TypeStyle,
{
@@ -83,11 +77,10 @@ const typographyVariants = Object.fromEntries(
>;
const typographyCompoundVariants = typeStyles.map((typography) => {
- const { fontSize, lineHeight } = vars.font[typography];
+ const { fontSize, lineHeight } = typeStyleFontDeclarations(typography);
// The trims depend on the theme's font, so the theme stylesheet supplies them as private
// variables rather than public tokens.
- const baselineTrim = `var(${capsizeTrimVarName(typography, 'baselineTrim')})`;
- const capHeightTrim = `var(${capsizeTrimVarName(typography, 'capHeightTrim')})`;
+ const { baselineTrim, capHeightTrim } = typeStyleTrimVars(typography);
return {
style: createLayeredTextStyle({ baselineTrim, capHeightTrim, fontSize, lineHeight }),
// `shouldInheritFont: true` asks the browser to resolve font size and line height from
diff --git a/packages/@luke-ui/react/src/core/text/type-style-declarations.ts b/packages/@luke-ui/react/src/core/text/type-style-declarations.ts
new file mode 100644
index 00000000..8aa09906
--- /dev/null
+++ b/packages/@luke-ui/react/src/core/text/type-style-declarations.ts
@@ -0,0 +1,28 @@
+import { capsizeTrimVarName } from '../../theme/capsize-trim-vars.js';
+import { vars } from '../../theme/contract.css.js';
+import type { TypeStyle } from '../../theme/type-styles.js';
+
+/**
+ * The font declarations a type style sets. `Text` and `Prose` both build their rules from this, so
+ * a style means the same thing in each. A plain module so `.css.ts` files can import it.
+ */
+export function typeStyleFontDeclarations(typography: TypeStyle) {
+ const font = vars.font[typography];
+ return {
+ fontFamily: font.fontFamily,
+ fontSize: font.fontSize,
+ letterSpacing: font.letterSpacing,
+ lineHeight: font.lineHeight,
+ };
+}
+
+/**
+ * References to a type style's private Capsize trims. The theme stylesheet writes them, because
+ * they depend on the theme's font.
+ */
+export function typeStyleTrimVars(typography: TypeStyle) {
+ return {
+ baselineTrim: `var(${capsizeTrimVarName(typography, 'baselineTrim')})`,
+ capHeightTrim: `var(${capsizeTrimVarName(typography, 'capHeightTrim')})`,
+ };
+}
--
2.54.0