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

    1. Scan the heading to see what the section covers.
    2. Read the paragraph for context.
    3. 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 ( - -
      - Looking up between tall canyon walls at a strip of blue sky -
      -
      - ); -}; 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 pointExports
      + @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. - - - - - - - - - - - - - - - - - - - - - - - - - - -
      TokenValueCSS 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)}Second cell
      +
      , + ); + const root = query(locator.element(), 'div'); + const table = query(root, 'table'); + + expect(table.getBoundingClientRect().right).toBeLessThanOrEqual( + root.getBoundingClientRect().right + 1, + ); + expect(table.scrollWidth).toBeGreaterThan(table.clientWidth); + + locator.getByRole('button', { name: 'Before table' }).element().focus(); + await user.tab(); + expect(table).toHaveFocus(); + await user.keyboard('{ArrowRight}'); + await expect.poll(() => table.scrollLeft).toBeGreaterThan(0); +}); + test('preserves whitespace in multiline pre code', () => { const { locator } = render( @@ -362,7 +392,7 @@ test('styles table headers and body cells', () => { const rowHeader = query(root, 'tbody th'); const cell = query(root, 'tbody td'); - expect(query(root, 'table').getBoundingClientRect().width).toBeCloseTo(160, 0); + expect(query(root, 'table').getBoundingClientRect().width).toBeLessThan(160); expect(getComputedStyle(columnHeader).verticalAlign).toBe('bottom'); expect(getComputedStyle(columnHeader).textAlign).toBe('start'); expect(getComputedStyle(rowHeader).verticalAlign).not.toBe('bottom'); @@ -389,19 +419,221 @@ test('keeps pre chrome outside a not-prose boundary', () => { expect(plain.paddingInlineStart).toBe('0px'); }); +const headingLevels = [1, 2, 3, 4, 5, 6] as const; + +function typeMetrics(element: Element) { + const style = getComputedStyle(element); + return { + blockSize: element.getBoundingClientRect().height, + fontFamily: style.fontFamily, + fontSize: style.fontSize, + fontWeight: style.fontWeight, + letterSpacing: style.letterSpacing, + lineHeight: style.lineHeight, + }; +} + +// Plain markup from a CMS must look like the component. Height checks the trimmed block size. +// Visual coverage protects trim placement. +for (const level of headingLevels) { + test(`gives a plain h${level} the type style of Heading level ${level}`, () => { + const Plain = `h${level}` as const; + const { locator } = render( + + Plain heading + Plain heading + , + ); + const root = locator.element(); + + expect(typeMetrics(query(root, `${Plain}:not([class])`))).toEqual( + typeMetrics(query(root, `${Plain}[class]`)), + ); + }); +} + +test('gives a plain p the type style of Text', () => { + const { locator } = render( + +

      Plain paragraph

      + Plain paragraph +
      , + ); + const root = locator.element(); + + expect(typeMetrics(query(root, 'p:not([class])'))).toEqual(typeMetrics(query(root, 'p[class]'))); +}); + +// A component keeps its own props inside Prose. The block size proves the trims match too. +const overrides = [ + { + name: 'Heading with shouldDisableTrim', + node: ( + + Override + + ), + }, + { + name: 'Heading with typography display', + node: ( + + Override + + ), + }, + { + name: 'Text with typography lead', + node: ( + + Override + + ), + }, + { + name: 'Text with lineClamp', + node: ( + + Override + + ), + }, +]; + +for (const { name, node } of overrides) { + test(`keeps the props of a ${name} inside Prose`, () => { + const { locator } = render( +
      + {node} + {node} +
      , + ); + const [inside, outside] = locator.element().querySelectorAll('h2, p'); + + expect(typeMetrics(inside!)).toEqual(typeMetrics(outside!)); + expect(getComputedStyle(inside!, '::before').content).toBe( + getComputedStyle(outside!, '::before').content, + ); + }); +} + +test('leaves a plain heading inside a not-prose boundary unstyled', () => { + const { locator } = render( +
      + +
      +

      Heading

      +
      +
      +

      Heading

      +
      , + ); + 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( +
      + {node} + Heading + Paragraph +
      , + ); + const root = locator.element(); + + expect(typeMetrics(query(root, 'h2:not([class])'))).toEqual( + typeMetrics(query(root, 'h2[class]')), + ); + expect(typeMetrics(query(root, 'p:not([class])'))).toEqual( + typeMetrics(query(root, 'p[class]')), + ); + }); +} + const document = ( - Choosing a spacing scale - +

      Choosing a spacing scale

      +

      A spacing scale trades range for consistency. Nine steps cover a component library without offering two values a designer cannot tell apart. - - - Every step below is a token. Reach for the nearest one rather than a raw pixel value. - +

      +

      Every step below is a token. Reach for the nearest one rather than a raw pixel value.

      - Picking a step - Work outwards from the tightest gap the layout needs. +

      Picking a step

      +

      Work outwards from the tightest gap the layout needs.

      1. Measure the smallest gap in the design.
      2. @@ -414,14 +646,14 @@ const document = (
      3. Use that step everywhere the same relationship appears.
      - Rounding in practice +

      Rounding in practice

      • - A loose item holds more than one paragraph. - The second takes a tighter gap than a document paragraph. +

        A loose item holds more than one paragraph.

        +

        The second takes a tighter gap than a document paragraph.

      • - Each item keeps the list rhythm. +

        Each item keeps the list rhythm.

        @@ -445,7 +677,7 @@ const document = ( near it in obstinacy. - Reading a token +

        Reading a token

        {/* oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- scrollable pre */}
         			{'padding-inline: var(--luke-space-sp16);\nmargin-block: 0;'}
        @@ -460,9 +692,11 @@ const document = (
         			
         				
         					
        -						Step
        -						
        - name +

        + Step +
        + name +

        Value @@ -470,11 +704,15 @@ const document = ( - sp8 -
        - Small gap +

        + sp8 +
        + Small gap +

        + + +

        8px

        - 8px @@ -493,7 +731,7 @@ const document = (
        - Terms +
        Terms
        @@ -513,6 +751,7 @@ const document = (
        +
        Scale specimen
        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