From 75c6ef474bfd3b363f5c7c72b13258efee4d7e15 Mon Sep 17 00:00:00 2001 From: Hiroki Uemura Date: Wed, 30 Sep 2026 09:28:55 +0900 Subject: [PATCH 01/12] feat(theme): add typography role tokens Add ten typography roles (heading-lg/md/sm, body-md/sm, label-md/sm, code-sm, body-md-relaxed, body-sm-relaxed). Each role is four CSS variables in themes/default.css, bridged to Tailwind text-* utilities in theme.bridge.css. Register the role names in core's cn() so a role is kept next to a text color class. Co-Authored-By: Claude Opus 5.5 --- packages/core/src/assets/theme-bridge.test.ts | 84 +++++++++++++++ packages/core/src/assets/theme.bridge.css | 50 +++++++++ packages/core/src/assets/theme.css | 4 +- packages/core/src/assets/themes/_template.css | 3 +- packages/core/src/assets/themes/default.css | 47 ++++++++ packages/core/src/lib/utils.test.ts | 41 +++++++ packages/core/src/lib/utils.ts | 25 ++++- .../core/tests/typography-compile.test.ts | 100 ++++++++++++++++++ 8 files changed, 351 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/lib/utils.test.ts create mode 100644 packages/core/tests/typography-compile.test.ts diff --git a/packages/core/src/assets/theme-bridge.test.ts b/packages/core/src/assets/theme-bridge.test.ts index a4a47ff30..6cb5944d2 100644 --- a/packages/core/src/assets/theme-bridge.test.ts +++ b/packages/core/src/assets/theme-bridge.test.ts @@ -56,4 +56,88 @@ describe("theme bridge", () => { // both files — fails loudly. expect(bridgedTokens("alert").size).toBe(20); }); + + describe("typography roles", () => { + const slots = ["size", "line-height", "weight", "letter-spacing"] as const; + /** `--app-shell-type--` variables in the default palette, grouped by role. */ + const defined = new Map>(); + for (const m of defaultTheme.matchAll( + /^\s*--app-shell-type-([a-z-]+)-(size|line-height|weight|letter-spacing):\s*([^;]+);/gm, + )) { + const role = m[1]; + defined.set(role, (defined.get(role) ?? new Map()).set(m[2], m[3].trim())); + } + /** Bridge entries `--text-[--]: var(--app-shell-type-...)`, keyed by property name. */ + const bridgedText = new Map( + [...bridge.matchAll(/^\s*(--text-[a-z-]+):\s*var\((--app-shell-type-[a-z-]+)\);/gm)].map( + (m) => [m[1], m[2]], + ), + ); + + it("pins the role count at 10", () => { + // Dropping a whole role from both files would still pass the symmetry checks. + expect(defined.size).toBe(10); + }); + + it("defines exactly four variables per role", () => { + for (const [role, values] of defined) { + expect([...values.keys()].toSorted(), role).toEqual([...slots].toSorted()); + } + }); + + it("bridges every role slot to its own variable", () => { + const suffix = { + size: "", + "line-height": "--line-height", + weight: "--font-weight", + "letter-spacing": "--letter-spacing", + } as const; + const expected = new Map(); + for (const role of defined.keys()) { + for (const slot of slots) { + expected.set(`--text-${role}${suffix[slot]}`, `--app-shell-type-${role}-${slot}`); + } + } + expect(bridgedText).toEqual(expected); + }); + + it("does not collide with a --color-* token that Tailwind resolves first", () => { + // `text-` resolves against --color- before --text-. + const colors = [...bridge.matchAll(/^\s*--color-([a-z0-9-]+):/gm)].map((m) => m[1]); + for (const role of defined.keys()) { + expect(colors, role).not.toContain(role); + } + for (const name of colors) { + expect(name).not.toMatch(/^(heading|body|label|code)(-|$)/); + } + }); + + it("keeps the line boxes on the spec values", () => { + // size (px) x line-height ratio = line box (px). Values from decisions/typography-roles.md. + const spec: Record = { + "heading-lg": [24, 32, 700], + "heading-md": [18, 28, 600], + "heading-sm": [14, 20, 600], + "body-md": [14, 20, 400], + "body-sm": [12, 16, 400], + "label-md": [14, 20, 500], + "label-sm": [12, 16, 500], + "code-sm": [12, 18, 400], + "body-md-relaxed": [14, 24, 400], + "body-sm-relaxed": [12, 20, 400], + }; + expect([...defined.keys()].toSorted()).toEqual(Object.keys(spec).toSorted()); + for (const [role, [size, lineBox, weight]] of Object.entries(spec)) { + const values = defined.get(role)!; + const rem = /^([0-9.]+)rem$/.exec(values.get("size") ?? ""); + expect(rem, `${role} size`).not.toBeNull(); + expect(Number(rem![1]) * 16, `${role} size`).toBe(size); + const ratio = /^calc\((\d+) \/ (\d+)\)$/.exec(values.get("line-height") ?? ""); + expect(ratio, `${role} line-height`).not.toBeNull(); + expect(Number(ratio![2]), `${role} ratio base`).toBe(size); + expect(Number(ratio![1]), `${role} line box`).toBe(lineBox); + expect(Number(values.get("weight")), `${role} weight`).toBe(weight); + } + }); + }); }); diff --git a/packages/core/src/assets/theme.bridge.css b/packages/core/src/assets/theme.bridge.css index c0a2ebf65..f254d3390 100644 --- a/packages/core/src/assets/theme.bridge.css +++ b/packages/core/src/assets/theme.bridge.css @@ -36,6 +36,56 @@ sans-serif ); + /* + * Typography roles. `text-heading-lg` sets font-size, line-height, font-weight and + * letter-spacing together. The values live in themes/default.css and are referenced + * with `var()` here, like the colors: `@theme inline` writes literals into the + * utility, so a value placed here could not be overridden on `:root`. + * + * The `--text-*` namespace cannot set font-family. `text-code-sm` is used with + * `font-mono`. + */ + --text-heading-lg: var(--app-shell-type-heading-lg-size); + --text-heading-lg--line-height: var(--app-shell-type-heading-lg-line-height); + --text-heading-lg--font-weight: var(--app-shell-type-heading-lg-weight); + --text-heading-lg--letter-spacing: var(--app-shell-type-heading-lg-letter-spacing); + --text-heading-md: var(--app-shell-type-heading-md-size); + --text-heading-md--line-height: var(--app-shell-type-heading-md-line-height); + --text-heading-md--font-weight: var(--app-shell-type-heading-md-weight); + --text-heading-md--letter-spacing: var(--app-shell-type-heading-md-letter-spacing); + --text-heading-sm: var(--app-shell-type-heading-sm-size); + --text-heading-sm--line-height: var(--app-shell-type-heading-sm-line-height); + --text-heading-sm--font-weight: var(--app-shell-type-heading-sm-weight); + --text-heading-sm--letter-spacing: var(--app-shell-type-heading-sm-letter-spacing); + --text-body-md: var(--app-shell-type-body-md-size); + --text-body-md--line-height: var(--app-shell-type-body-md-line-height); + --text-body-md--font-weight: var(--app-shell-type-body-md-weight); + --text-body-md--letter-spacing: var(--app-shell-type-body-md-letter-spacing); + --text-body-sm: var(--app-shell-type-body-sm-size); + --text-body-sm--line-height: var(--app-shell-type-body-sm-line-height); + --text-body-sm--font-weight: var(--app-shell-type-body-sm-weight); + --text-body-sm--letter-spacing: var(--app-shell-type-body-sm-letter-spacing); + --text-label-md: var(--app-shell-type-label-md-size); + --text-label-md--line-height: var(--app-shell-type-label-md-line-height); + --text-label-md--font-weight: var(--app-shell-type-label-md-weight); + --text-label-md--letter-spacing: var(--app-shell-type-label-md-letter-spacing); + --text-label-sm: var(--app-shell-type-label-sm-size); + --text-label-sm--line-height: var(--app-shell-type-label-sm-line-height); + --text-label-sm--font-weight: var(--app-shell-type-label-sm-weight); + --text-label-sm--letter-spacing: var(--app-shell-type-label-sm-letter-spacing); + --text-code-sm: var(--app-shell-type-code-sm-size); + --text-code-sm--line-height: var(--app-shell-type-code-sm-line-height); + --text-code-sm--font-weight: var(--app-shell-type-code-sm-weight); + --text-code-sm--letter-spacing: var(--app-shell-type-code-sm-letter-spacing); + --text-body-md-relaxed: var(--app-shell-type-body-md-relaxed-size); + --text-body-md-relaxed--line-height: var(--app-shell-type-body-md-relaxed-line-height); + --text-body-md-relaxed--font-weight: var(--app-shell-type-body-md-relaxed-weight); + --text-body-md-relaxed--letter-spacing: var(--app-shell-type-body-md-relaxed-letter-spacing); + --text-body-sm-relaxed: var(--app-shell-type-body-sm-relaxed-size); + --text-body-sm-relaxed--line-height: var(--app-shell-type-body-sm-relaxed-line-height); + --text-body-sm-relaxed--font-weight: var(--app-shell-type-body-sm-relaxed-weight); + --text-body-sm-relaxed--letter-spacing: var(--app-shell-type-body-sm-relaxed-letter-spacing); + --color-background: var(--background); --color-foreground: var(--foreground); --color-card: var(--card); diff --git a/packages/core/src/assets/theme.css b/packages/core/src/assets/theme.css index e9c3b37c2..03e100b43 100644 --- a/packages/core/src/assets/theme.css +++ b/packages/core/src/assets/theme.css @@ -16,7 +16,9 @@ * 4. PALETTE — optional: radius, charts, shadows, destructive. * * Inherited from default.css — do not duplicate in new palettes: - * --ring, --sidebar-*, --status-*, --alert-*. + * --ring, --sidebar-*, --status-*, --alert-*, --app-shell-type-*. + * Typography roles (--app-shell-type-*) are not part of a palette. A palette may + * override them on `:root` when it needs different typography. * * Light/dark mode is controlled separately via `.dark` on . * Palette selection is by CSS import; there is no runtime palette prop. diff --git a/packages/core/src/assets/themes/_template.css b/packages/core/src/assets/themes/_template.css index 4ea979ef0..af6ecfd2d 100644 --- a/packages/core/src/assets/themes/_template.css +++ b/packages/core/src/assets/themes/_template.css @@ -17,7 +17,8 @@ * `@tailor-platform/app-shell/styles` in the consuming app. * * DO NOT SET (inherited from `:root` in default.css): - * --ring, --sidebar-*, --status-*, --alert-* + * --ring, --sidebar-*, --status-*, --alert-*, --app-shell-type-* + * (typography roles; override `--app-shell-type-*` only if the brand needs different type) * * PREVIEW: `/showcase/colors` in the Vite example app. * ═══════════════════════════════════════════════════════════════════════════ diff --git a/packages/core/src/assets/themes/default.css b/packages/core/src/assets/themes/default.css index 96929f051..b50d05a1e 100644 --- a/packages/core/src/assets/themes/default.css +++ b/packages/core/src/assets/themes/default.css @@ -92,6 +92,53 @@ --alert-info-foreground-muted: color-mix(in srgb, var(--alert-info-foreground) 80%, transparent); --alert-info-background: color-mix(in srgb, #3b82f6 10%, transparent); --alert-info-border: color-mix(in srgb, #3b82f6 20%, transparent); + + /* ── 7. TYPOGRAPHY — composite roles; light only, no dark variant ────────── + * Four variables per role: size, line-height (unitless ratio), weight and + * letter-spacing. theme.bridge.css maps them to `text-` utilities. + * Palettes inherit these; override `--app-shell-type-*` on `:root` to change a role. + * See decisions/typography-roles.md. + */ + --app-shell-type-heading-lg-size: 1.5rem; + --app-shell-type-heading-lg-line-height: calc(32 / 24); + --app-shell-type-heading-lg-weight: 700; + --app-shell-type-heading-lg-letter-spacing: -0.025em; + --app-shell-type-heading-md-size: 1.125rem; + --app-shell-type-heading-md-line-height: calc(28 / 18); + --app-shell-type-heading-md-weight: 600; + --app-shell-type-heading-md-letter-spacing: 0em; + --app-shell-type-heading-sm-size: 0.875rem; + --app-shell-type-heading-sm-line-height: calc(20 / 14); + --app-shell-type-heading-sm-weight: 600; + --app-shell-type-heading-sm-letter-spacing: 0em; + --app-shell-type-body-md-size: 0.875rem; + --app-shell-type-body-md-line-height: calc(20 / 14); + --app-shell-type-body-md-weight: 400; + --app-shell-type-body-md-letter-spacing: 0em; + --app-shell-type-body-sm-size: 0.75rem; + --app-shell-type-body-sm-line-height: calc(16 / 12); + --app-shell-type-body-sm-weight: 400; + --app-shell-type-body-sm-letter-spacing: 0em; + --app-shell-type-label-md-size: 0.875rem; + --app-shell-type-label-md-line-height: calc(20 / 14); + --app-shell-type-label-md-weight: 500; + --app-shell-type-label-md-letter-spacing: 0em; + --app-shell-type-label-sm-size: 0.75rem; + --app-shell-type-label-sm-line-height: calc(16 / 12); + --app-shell-type-label-sm-weight: 500; + --app-shell-type-label-sm-letter-spacing: 0em; + --app-shell-type-code-sm-size: 0.75rem; + --app-shell-type-code-sm-line-height: calc(18 / 12); + --app-shell-type-code-sm-weight: 400; + --app-shell-type-code-sm-letter-spacing: 0em; + --app-shell-type-body-md-relaxed-size: 0.875rem; + --app-shell-type-body-md-relaxed-line-height: calc(24 / 14); + --app-shell-type-body-md-relaxed-weight: 400; + --app-shell-type-body-md-relaxed-letter-spacing: 0em; + --app-shell-type-body-sm-relaxed-size: 0.75rem; + --app-shell-type-body-sm-relaxed-line-height: calc(20 / 12); + --app-shell-type-body-sm-relaxed-weight: 400; + --app-shell-type-body-sm-relaxed-letter-spacing: 0em; } /* diff --git a/packages/core/src/lib/utils.test.ts b/packages/core/src/lib/utils.test.ts new file mode 100644 index 000000000..3ae4bb10f --- /dev/null +++ b/packages/core/src/lib/utils.test.ts @@ -0,0 +1,41 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { cn } from "./utils"; + +const defaultTheme = readFileSync( + join(dirname(fileURLToPath(import.meta.url)), "../assets/themes/default.css"), + "utf8", +); +const roles = [ + ...new Set([...defaultTheme.matchAll(/^\s*--app-shell-type-([a-z-]+)-size:/gm)].map((m) => m[1])), +]; + +describe("cn", () => { + it("keeps a typography role next to a text color", () => { + expect(roles.length).toBeGreaterThan(0); + for (const role of roles) { + expect(cn(`text-${role}`, "text-muted-foreground"), role).toBe( + `text-${role} text-muted-foreground`, + ); + } + }); + + it("keeps a typography role next to font-mono", () => { + expect(cn("text-code-sm font-mono", "text-muted-foreground")).toBe( + "text-code-sm font-mono text-muted-foreground", + ); + }); + + it("lets a later role or stock font size replace an earlier one", () => { + expect(cn("text-body-md", "text-heading-lg")).toBe("text-heading-lg"); + expect(cn("text-label-md", "text-sm")).toBe("text-sm"); + expect(cn("text-sm", "text-label-md")).toBe("text-label-md"); + }); + + it("still merges stock utilities", () => { + expect(cn("p-2", "p-4")).toBe("p-4"); + expect(cn("text-red-500", "text-muted-foreground")).toBe("text-muted-foreground"); + }); +}); diff --git a/packages/core/src/lib/utils.ts b/packages/core/src/lib/utils.ts index a5ef19350..11b742c73 100644 --- a/packages/core/src/lib/utils.ts +++ b/packages/core/src/lib/utils.ts @@ -1,5 +1,28 @@ import { clsx, type ClassValue } from "clsx"; -import { twMerge } from "tailwind-merge"; +import { extendTailwindMerge } from "tailwind-merge"; + +/** + * Typography role names from `theme.bridge.css` (`text-heading-lg`, ...). + * tailwind-merge treats an unknown `text-*` value as a text color, so without + * this list `cn("text-label-md", "text-muted-foreground")` keeps only the color. + * `src/lib/utils.test.ts` checks that this list matches the roles in `default.css`. + */ +const TYPOGRAPHY_ROLES = [ + "heading-lg", + "heading-md", + "heading-sm", + "body-md", + "body-sm", + "label-md", + "label-sm", + "code-sm", + "body-md-relaxed", + "body-sm-relaxed", +]; + +const twMerge = extendTailwindMerge({ + extend: { classGroups: { "font-size": [{ text: TYPOGRAPHY_ROLES }] } }, +}); export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); diff --git a/packages/core/tests/typography-compile.test.ts b/packages/core/tests/typography-compile.test.ts new file mode 100644 index 000000000..9979dc435 --- /dev/null +++ b/packages/core/tests/typography-compile.test.ts @@ -0,0 +1,100 @@ +// @vitest-environment node +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import tailwind from "@tailwindcss/postcss"; +import postcss from "postcss"; +import { describe, expect, it } from "vitest"; + +/** + * Compiles theme.bridge.css with Tailwind and checks the emitted utility rules. + * The string-level checks in src/assets/theme-bridge.test.ts confirm that the + * bridge names the right variables. This test confirms that Tailwind turns those + * entries into the four declarations a role needs, both for AppShell's own + * prefixed build (`astw:`) and for a consumer's unprefixed build. + */ +const assetsDir = join(dirname(fileURLToPath(import.meta.url)), "../src/assets"); +const defaultTheme = readFileSync(join(assetsDir, "themes/default.css"), "utf8"); + +async function compile(header: string, candidates: string[]): Promise { + const css = `${header} +@source inline("${candidates.join(" ")}"); +@import "./theme.bridge.css"; +`; + // `from` must be a real path inside packages/core so that `tailwindcss` and + // `./theme.bridge.css` resolve. + const result = await postcss([tailwind()]).process(css, { from: join(assetsDir, "entry.css") }); + return result.css; +} + +/** The declarations of the rule with the given selector, as a property to value map. */ +function declarationsOf(css: string, selector: string): Map { + const root = postcss.parse(css); + const declarations = new Map(); + root.walkRules((rule) => { + if (rule.selector !== selector) return; + rule.walkDecls((decl) => { + declarations.set(decl.prop, decl.value.replace(/\s+/g, " ").trim()); + }); + }); + return declarations; +} + +describe("typography role utilities", () => { + it.each([ + [ + "prefixed (AppShell build)", + '@import "tailwindcss" prefix(astw) source(none);', + "astw:", + "astw\\:", + ], + ["unprefixed (consumer build)", '@import "tailwindcss" source(none);', "", ""], + ])( + "%s emits all four declarations from the role variables", + async (_name, header, prefix, escaped) => { + const css = await compile(header, [`${prefix}text-label-md`, `${prefix}text-heading-lg`]); + + const label = declarationsOf(css, `.${escaped}text-label-md`); + expect(label.get("font-size")).toBe("var(--app-shell-type-label-md-size)"); + expect(label.get("line-height")).toBe( + "var(--tw-leading, var(--app-shell-type-label-md-line-height))", + ); + expect(label.get("font-weight")).toBe( + "var(--tw-font-weight, var(--app-shell-type-label-md-weight))", + ); + expect(label.get("letter-spacing")).toBe( + "var(--tw-tracking, var(--app-shell-type-label-md-letter-spacing))", + ); + + const heading = declarationsOf(css, `.${escaped}text-heading-lg`); + expect(heading.get("font-size")).toBe("var(--app-shell-type-heading-lg-size)"); + }, + ); + + it("lets stock leading-* utilities override the role line-height", async () => { + // `leading-none` sets --tw-leading, which the role reads before its own variable. + const css = await compile('@import "tailwindcss" source(none);', [ + "text-label-md", + "leading-none", + ]); + expect(declarationsOf(css, ".leading-none").get("--tw-leading")).toBe("1"); + expect(declarationsOf(css, ".text-label-md").get("line-height")).toMatch(/^var\(--tw-leading,/); + }); + + it("emits every role defined in the default palette", async () => { + const roles = [ + ...new Set( + [...defaultTheme.matchAll(/^\s*--app-shell-type-([a-z-]+)-size:/gm)].map((m) => m[1]), + ), + ]; + const css = await compile( + '@import "tailwindcss" source(none);', + roles.map((role) => `text-${role}`), + ); + for (const role of roles) { + const decls = declarationsOf(css, `.text-${role}`); + expect(decls.get("font-size"), role).toBe(`var(--app-shell-type-${role}-size)`); + expect(decls.size, role).toBe(4); + } + }); +}); From c47500b47b1cb6ed34b8c75aa3a3725c02af3f00 Mon Sep 17 00:00:00 2001 From: Hiroki Uemura Date: Wed, 30 Sep 2026 09:28:58 +0900 Subject: [PATCH 02/12] fix(docs-browser): point @source at the docs-src example files The path resolved outside the repository and matched no file, so utility classes used only in examples were not generated. Co-Authored-By: Claude Opus 5.5 --- docs-browser/src/index.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs-browser/src/index.css b/docs-browser/src/index.css index e1e5a597b..21b7f6a16 100644 --- a/docs-browser/src/index.css +++ b/docs-browser/src/index.css @@ -2,10 +2,10 @@ @import "@tailor-platform/app-shell/styles"; @import "@tailor-platform/app-shell/themes/bloom"; -/* The live examples live at the repo root (docs/), outside this app's own tree, +/* The live examples live at the repo root (docs-src/), outside this app's own tree, so Tailwind won't discover their utility classes by default. Point it at them explicitly so classes like `flex gap-2` used in examples get generated. */ -@source "../../../docs/**/*.examples.tsx"; +@source "../../docs-src/**/*.docs.examples.tsx"; html, body { From 9d4a714af278de76775888510eb840e441a67409 Mon Sep 17 00:00:00 2001 From: Hiroki Uemura Date: Wed, 30 Sep 2026 09:28:59 +0900 Subject: [PATCH 03/12] docs: document typography roles and add the decision record Rewrite the Typography section of the styling guide, add a live example, add decisions/typography-roles.md, and add a changeset. Co-Authored-By: Claude Opus 5.5 --- .changeset/quiet-roles-align.md | 12 ++ decisions/typography-roles.md | 81 ++++++++++ .../styling-theming.docs.examples.tsx | 79 ++++++++++ .../concepts/styling-theming.docs.outline.md | 125 ++++++++++++---- docs/concepts/styling-theming.md | 141 +++++++++++++++--- 5 files changed, 389 insertions(+), 49 deletions(-) create mode 100644 .changeset/quiet-roles-align.md create mode 100644 decisions/typography-roles.md diff --git a/.changeset/quiet-roles-align.md b/.changeset/quiet-roles-align.md new file mode 100644 index 000000000..fe419ab90 --- /dev/null +++ b/.changeset/quiet-roles-align.md @@ -0,0 +1,12 @@ +--- +"@tailor-platform/app-shell": minor +--- + +Add typography role tokens. A role sets font size, line height, font weight, and letter spacing together. Ten `text-*` utilities are available after you import `@tailor-platform/app-shell/styles`: `text-heading-lg`, `text-heading-md`, `text-heading-sm`, `text-body-md`, `text-body-sm`, `text-label-md`, `text-label-sm`, `text-code-sm`, `text-body-md-relaxed`, and `text-body-sm-relaxed`. + +```tsx +

Purchase orders

+

Orders sent to suppliers this month.

+``` + +Each role reads four CSS variables named `--app-shell-type--`, which you can override on `:root`. AppShell's `cn()` now keeps a role together with a text color class. If your app has its own `tailwind-merge` setup, see the Typography section of the styling guide. diff --git a/decisions/typography-roles.md b/decisions/typography-roles.md new file mode 100644 index 000000000..6f76c8404 --- /dev/null +++ b/decisions/typography-roles.md @@ -0,0 +1,81 @@ +# Decision: typography role tokens + +> Status: **Decided for the first change — add 10 typography role tokens as CSS variables with Tailwind `text-*` utilities. No component uses them yet.** +> Scope: role names and values, how they reach Tailwind, and the rules for combining them. This does not cover applying roles to AppShell components, a `Text` component, or sizes below 12px. + +## Context + +Tracking issue: tailor-inc/platform-planning#1580 ([analysis comment](https://github.com/tailor-inc/platform-planning/issues/1580#issuecomment-5855498547)). + +Until now, AppShell defined only the font family (`--font-sans`). `docs/concepts/styling-theming.md` told consumers to compose text from stock Tailwind utilities and gave a table of eight pairings. A sample of four sources on 2026-09-27 measured how often real code matched that table, counting whole combinations of size, weight, and line height: + +| Source | Combinations that match the table | +| -------------------------------------------- | -------------------------------------- | +| AppShell components (238 usages) | 17% | +| UI catalogue pages and patterns (321 usages) | 15% | +| erp-kit templates | 40% (combinations that include a size) | + +The most common unlisted combination was `text-sm font-medium` (a label). It appears in all four sources. Code also used arbitrary sizes such as `text-[10px]`, and set line height explicitly in about 8 to 11% of places. + +The styling guide says "the tokens are the rails". Typography had no tokens, so nothing gave a reader or an AI agent a name to pick. + +## Decision + +### Tokens are the source of truth + +Each role is a named set of four values: size, line height, weight, and letter spacing. A `Text` component can be added later as a thin layer on top. The tokens come first, because consumers, AI agents, and future components all need the same names. + +### Two layers, as for colors + +- `themes/default.css` holds the values as `--app-shell-type--` on `:root`. A palette or an app can override them. +- `theme.bridge.css` maps them to Tailwind with `--text-` and its `--line-height`, `--font-weight`, and `--letter-spacing` suffixes, inside the existing `@theme inline`. + +`@theme inline` writes literal values into the utility. If the values sat in the bridge, they could not be overridden on `:root`. So the bridge holds only `var()` references. + +The `--text-*` namespace cannot set `font-family`. `code-sm` therefore requires `font-mono` on the element. + +### Ten roles + +| Role | Size | Line height | Weight | Letter spacing | +| ----------------- | ---- | ----------- | ------ | -------------- | +| `heading-lg` | 24px | 32px | 700 | -0.025em | +| `heading-md` | 18px | 28px | 600 | 0 | +| `heading-sm` | 14px | 20px | 600 | 0 | +| `body-md` | 14px | 20px | 400 | 0 | +| `body-sm` | 12px | 16px | 400 | 0 | +| `label-md` | 14px | 20px | 500 | 0 | +| `label-sm` | 12px | 16px | 500 | 0 | +| `code-sm` | 12px | 18px | 400 | 0 | +| `body-md-relaxed` | 14px | 24px | 400 | 0 | +| `body-sm-relaxed` | 12px | 20px | 400 | 0 | + +- Line height is stored as a unitless ratio (`calc(32 / 24)`), so the line box scales with the size. The line boxes are on a 4px grid. `code-sm` (18px) is the one step of 2px, chosen because the current `font-mono text-xs` gives 16px, which is tight for monospace text. +- Every role sets letter spacing, including `0em`, so that no role inherits letter spacing from a parent. +- Labels keep weight 500, the value used today. +- The relaxed variants exist for body text only. They are for long text such as chat messages and documents. +- `overline` is not included. It is planned as a scoped role in a later change. + +### Modifiers + +Four stock Tailwind utilities may be combined with a role: `leading-none` (compact, single-line text only), `tabular-nums`, `font-medium` or `font-semibold` for emphasis on inline text, and `uppercase`. This is a documented rule and has no code. Other `leading-*`, `tracking-*`, and `text-[Npx]` values are not combined with a role. + +A stock `leading-*`, `font-*`, or `tracking-*` on the same element still overrides the role value, because the emitted rule reads `--tw-leading`, `--tw-font-weight`, and `--tw-tracking` first. + +### Other rules + +- 12px is the minimum size. Existing 10px and 11px text moves to a 12px role in a separate change. +- A role does not choose the HTML element. The element comes from the document structure. +- A role carries no color. +- The card title direction is `heading-md` with `leading-none`. It is documented in the pairing table only. No component changes in this PR. + +### `cn()` knows the role names + +`tailwind-merge` reads an unknown `text-*` value as a text color. Without a change, `cn("text-label-md", "text-muted-foreground")` keeps only the color. AppShell components pass consumer `className` through `cn()` today, so this would drop a role as soon as a consumer follows the documented pairings. Core's `cn()` now registers the ten role names in the `font-size` group. The documentation shows the same setting for consumers who have their own `cn()`. + +## Consequences and open questions + +- **No visual change.** No component uses the roles yet. The default, cream, and bloom palettes render as before. +- **cream and bloom `h1` to `h6` rule.** Both palettes set `line-height: 1.2` and `letter-spacing: -0.03em` on `html :where(h1, h2, h3, h4, h5, h6)` outside any `@layer`. That rule wins over a role utility on those elements, so a heading role on an `h1` to `h6` gets the palette values for line height and letter spacing. This PR keeps the rule and documents the effect. Open question for the maintainers: what does this rule protect, and should it stay once heading roles exist? +- **Keyboard hint.** The earlier pairing table listed "keyboard hint" with `font-mono text-xs`. The only keyboard hint in the code (the sidebar ⌘K hint) uses `text-xs font-normal`. The new table has no keyboard hint row. +- **Follow-up changes** (not in this PR): apply the roles to core components; `overline`; migrate 10px and 11px text to 12px; a `Text` component; numeric DataTable columns with `tabular-nums` and right alignment as the default; a MetricCard value role and a display role above 24px. +- **Docs example.** The live example in the docs uses `div` and `p` elements. The docs app runs the bloom palette, and its `h1` to `h6` rule would otherwise change the heading rows. diff --git a/docs-src/concepts/styling-theming.docs.examples.tsx b/docs-src/concepts/styling-theming.docs.examples.tsx index 7e4dfdec3..4b3fad9e7 100644 --- a/docs-src/concepts/styling-theming.docs.examples.tsx +++ b/docs-src/concepts/styling-theming.docs.examples.tsx @@ -12,3 +12,82 @@ export function ThemeToggle() { export function AppearanceToggle() { return ; } + +const TYPOGRAPHY_SAMPLES = [ + { + role: "heading-lg", + className: "text-heading-lg", + en: "Purchase orders", + ja: "発注管理", + }, + { + role: "heading-md", + className: "text-heading-md", + en: "Purchase orders by supplier", + ja: "仕入先別の発注一覧", + }, + { + role: "heading-sm", + className: "text-heading-sm", + en: "Delivery address", + ja: "納品先", + }, + { + role: "body-md", + className: "text-body-md", + en: "The order was sent to the supplier on 12 March.", + ja: "発注書は3月12日に仕入先へ送付されました。", + }, + { + role: "body-sm", + className: "text-body-sm text-muted-foreground", + en: "Updated 2 hours ago", + ja: "2時間前に更新", + }, + { + role: "label-md", + className: "text-label-md", + en: "Order quantity", + ja: "発注数量", + }, + { + role: "label-sm", + className: "text-label-sm", + en: "Draft", + ja: "下書き", + }, + { + role: "code-sm", + className: "font-mono text-code-sm", + en: "PO-2026-000184", + ja: "PO-2026-000184", + }, + { + role: "body-md-relaxed", + className: "text-body-md-relaxed", + en: "Please confirm the delivery date with the supplier before you approve this order.", + ja: "この発注を承認する前に、納期を仕入先に確認してください。", + }, + { + role: "body-sm-relaxed", + className: "text-body-sm-relaxed text-muted-foreground", + en: "Changes to a confirmed order are recorded in the order history.", + ja: "確定済みの発注への変更は、発注履歴に記録されます。", + }, +]; + +export function TypographyRoles() { + return ( +
+ {TYPOGRAPHY_SAMPLES.map(({ role, className, en, ja }) => ( +
+ {role} +
+

{en}

+

{ja}

+
+
+ ))} +
+ ); +} diff --git a/docs-src/concepts/styling-theming.docs.outline.md b/docs-src/concepts/styling-theming.docs.outline.md index ffe7b0f85..5429e71d0 100644 --- a/docs-src/concepts/styling-theming.docs.outline.md +++ b/docs-src/concepts/styling-theming.docs.outline.md @@ -226,29 +226,104 @@ Hand-typing `padding: 13px` is a smell. Round to the nearest scale step; if noth ### Typography -**AppShell defines no typography scale tokens.** There is no `text-h1`, `text-body`, or `text-caption`. The only typography token is `--font-sans` (Inter Variable for Latin, Noto Sans JP Variable for Japanese) → `font-sans`, which the base layer already applies to `body`. Both are variable fonts, so every weight token is real in both scripts. Override the whole stack by setting `--app-shell-font-sans` on `:root`. - -Compose roles from stock Tailwind utilities. These pairings are what AppShell's own components use — match them so your screens sit consistently alongside the primitives: - -| Role | Utilities | -| --------------------------------- | ----------------------------------------- | -| Page title (`Layout.Header`) | `text-2xl font-bold tracking-tight` | -| Section heading | `text-lg font-semibold` | -| Card title | `text-lg font-semibold leading-none` | -| Body copy | `text-sm` | -| Secondary copy, descriptions | `text-sm text-muted-foreground` | -| Caption, timestamp, metadata | `text-xs text-muted-foreground` | -| Numeric value in a table or field | `text-sm font-medium tabular-nums` | -| ID, code, keyboard hint | `font-mono text-xs text-muted-foreground` | +AppShell defines **composite typography role tokens**. A role sets font size, line height, font weight, and letter spacing together, so one utility such as `text-heading-md` replaces a set like `text-lg font-semibold`. The font family is a separate token, `--font-sans` (Inter Variable for Latin, Noto Sans JP Variable for Japanese) → `font-sans`, which the base layer already applies to `body`. Both are variable fonts, so every weight token is real in both scripts. Override the whole stack by setting `--app-shell-font-sans` on `:root`. + +There are 8 core roles and 2 relaxed body variants: + +| Role | Utility | Size / line height | Weight | Use | +| ----------------- | ---------------------- | ------------------ | ------ | ----------------------------------------------- | +| `heading-lg` | `text-heading-lg` | 24px / 32px | 700 | Page title (`Layout.Header`) | +| `heading-md` | `text-heading-md` | 18px / 28px | 600 | Section heading, card title | +| `heading-sm` | `text-heading-sm` | 14px / 20px | 600 | Sub-section heading | +| `body-md` | `text-body-md` | 14px / 20px | 400 | Body copy, descriptions | +| `body-sm` | `text-body-sm` | 12px / 16px | 400 | Caption, timestamp, metadata | +| `label-md` | `text-label-md` | 14px / 20px | 500 | Field label, short UI text, figures in a table | +| `label-sm` | `text-label-sm` | 12px / 16px | 500 | Small label, badge text | +| `code-sm` | `text-code-sm` | 12px / 18px | 400 | ID and code. Use with `font-mono` | +| `body-md-relaxed` | `text-body-md-relaxed` | 14px / 24px | 400 | Long text, such as a chat message or a document | +| `body-sm-relaxed` | `text-body-sm-relaxed` | 12px / 20px | 400 | Long text at the small size | + +`heading-lg` also sets letter spacing to `-0.025em`. All other roles set `0em`. + +Pair a role with a color token from the Color section above. These pairings are the recommended combinations: + +| Use | Utilities | +| --------------------------------- | ---------------------------------------------- | +| Page title (`Layout.Header`) | `text-heading-lg` | +| Section heading | `text-heading-md` | +| Card title | `text-heading-md leading-none` | +| Body copy | `text-body-md` | +| Secondary copy, descriptions | `text-body-md text-muted-foreground` | +| Caption, timestamp, metadata | `text-body-sm text-muted-foreground` | +| Numeric value in a table or field | `text-label-md tabular-nums` | +| ID, code | `font-mono text-code-sm text-muted-foreground` | + + ```tsx -

Section

-

Description copy

-Updated 2h ago +

Section

+

Description copy

+Updated 2h ago ``` +AppShell's own components still use the stock utilities (`text-sm`, `font-medium`, and so on) today. They will move to the roles in later changes, so the two sets can appear side by side for now. + +**Rules** + +- **A role carries no color.** Set the color with a token such as `text-muted-foreground`. +- **A role does not choose the HTML element.** Pick `h2`, `p`, or `span` from the document structure, not from the size. +- **Four modifiers may be combined with a role.** All are stock Tailwind utilities: + - Compact line height: `leading-none`. Use it only for single-line text, such as a card title. + - Tabular figures: `tabular-nums`. + - Emphasis on inline text: `font-medium` or `font-semibold`. + - Uppercase: `uppercase`. +- **Do not combine a role with other `leading-*`, `tracking-*`, or `text-[Npx]` values.** If none of the roles fits, use the nearest role. +- **12px is the minimum size.** Do not use smaller text. +- **`code-sm` needs `font-mono`.** The role sets size, line height, weight, and letter spacing, but a role cannot set the font family. +- **Use the relaxed variants for long text only.** For labels, table cells, and other short text, use the regular roles. + Always use `tabular-nums` for numbers that stack in a column — without it, digits jitter between rows. +**Change a role.** Each role is four CSS variables named `--app-shell-type--`. Override them on `:root` to change a role for the whole app. Typography has no dark variant, so one override applies to light and dark mode. + +```css +:root { + --app-shell-type-heading-lg-size: 1.75rem; + --app-shell-type-heading-lg-line-height: calc(36 / 28); +} +``` + +The `cream` and `bloom` palettes set `line-height` and `letter-spacing` on `h1` to `h6`. These rules are outside any `@layer`, so they win over a role utility on those elements. Under these palettes, an `h1` to `h6` element takes its line height and letter spacing from the palette, not from the role. Font size and weight still come from the role. The `default` palette has no such rule. + +**`tailwind-merge`.** AppShell's `cn()` already knows the role names. If your app has its own `cn()` built on `tailwind-merge`, register them. Without this, `twMerge("text-label-md text-muted-foreground")` removes the role and keeps only the color. + +```ts +import { extendTailwindMerge } from "tailwind-merge"; + +const twMerge = extendTailwindMerge({ + extend: { + classGroups: { + "font-size": [ + { + text: [ + "heading-lg", + "heading-md", + "heading-sm", + "body-md", + "body-sm", + "body-md-relaxed", + "body-sm-relaxed", + "label-md", + "label-sm", + "code-sm", + ], + }, + ], + }, + }, +}); +``` + ### Radius `--radius` (`0.625rem`) is the base; the bridge derives four steps from it. Pick by component role — a card is always `md`, regardless of its size on screen. @@ -315,12 +390,12 @@ Never invent a z value — `z-index: 9999` is always wrong. Popups and overlays **AppShell exports no `Icon` component and defines no `--icon-*` tokens.** Icons come from [`lucide-react`](https://lucide.dev), which AppShell already depends on. Size them with Tailwind, pairing icon size to the adjacent text: -| Text size | Icon class | -| ------------------ | ---------- | -| `text-xs` | `size-3` | -| `text-sm` | `size-4` | -| `text-lg` | `size-5` | -| `text-2xl` (title) | `size-6` | +| Text size | Icon class | +| ------------------------------------------------------------------- | ---------- | +| 12px: `text-xs`, `text-body-sm`, `text-label-sm`, `text-code-sm` | `size-3` | +| 14px: `text-sm`, `text-body-md`, `text-label-md`, `text-heading-sm` | `size-4` | +| 18px: `text-lg`, `text-heading-md` | `size-5` | +| 24px (title): `text-2xl`, `text-heading-lg` | `size-6` | ```tsx import { Check } from "lucide-react"; @@ -567,5 +642,5 @@ These are visual-composition rules every screen must follow, regardless of patte | Hover / focus transition | `duration-150 ease-out` | | State change (toggle, select) | `duration-200 ease-in-out` | | Two-column detail at <1024 | right column collapses below main — do not override | -| Inline ID, code, table number | `font-mono text-xs` (identifiers) / `tabular-nums` (figures) | -| Timestamp, label, subtle metadata | `text-xs text-muted-foreground` | +| Inline ID, code, table number | `font-mono text-code-sm` (identifiers) / `text-label-md tabular-nums` (figures) | +| Timestamp, label, subtle metadata | `text-body-sm text-muted-foreground` | diff --git a/docs/concepts/styling-theming.md b/docs/concepts/styling-theming.md index 8cf735cf9..161271d70 100644 --- a/docs/concepts/styling-theming.md +++ b/docs/concepts/styling-theming.md @@ -243,29 +243,122 @@ Hand-typing `padding: 13px` is a smell. Round to the nearest scale step; if noth ### Typography -**AppShell defines no typography scale tokens.** There is no `text-h1`, `text-body`, or `text-caption`. The only typography token is `--font-sans` (Inter Variable for Latin, Noto Sans JP Variable for Japanese) → `font-sans`, which the base layer already applies to `body`. Both are variable fonts, so every weight token is real in both scripts. Override the whole stack by setting `--app-shell-font-sans` on `:root`. +AppShell defines **composite typography role tokens**. A role sets font size, line height, font weight, and letter spacing together, so one utility such as `text-heading-md` replaces a set like `text-lg font-semibold`. The font family is a separate token, `--font-sans` (Inter Variable for Latin, Noto Sans JP Variable for Japanese) → `font-sans`, which the base layer already applies to `body`. Both are variable fonts, so every weight token is real in both scripts. Override the whole stack by setting `--app-shell-font-sans` on `:root`. + +There are 8 core roles and 2 relaxed body variants: + +| Role | Utility | Size / line height | Weight | Use | +| ----------------- | ---------------------- | ------------------ | ------ | ----------------------------------------------- | +| `heading-lg` | `text-heading-lg` | 24px / 32px | 700 | Page title (`Layout.Header`) | +| `heading-md` | `text-heading-md` | 18px / 28px | 600 | Section heading, card title | +| `heading-sm` | `text-heading-sm` | 14px / 20px | 600 | Sub-section heading | +| `body-md` | `text-body-md` | 14px / 20px | 400 | Body copy, descriptions | +| `body-sm` | `text-body-sm` | 12px / 16px | 400 | Caption, timestamp, metadata | +| `label-md` | `text-label-md` | 14px / 20px | 500 | Field label, short UI text, figures in a table | +| `label-sm` | `text-label-sm` | 12px / 16px | 500 | Small label, badge text | +| `code-sm` | `text-code-sm` | 12px / 18px | 400 | ID and code. Use with `font-mono` | +| `body-md-relaxed` | `text-body-md-relaxed` | 14px / 24px | 400 | Long text, such as a chat message or a document | +| `body-sm-relaxed` | `text-body-sm-relaxed` | 12px / 20px | 400 | Long text at the small size | + +`heading-lg` also sets letter spacing to `-0.025em`. All other roles set `0em`. + +Pair a role with a color token from the Color section above. These pairings are the recommended combinations: + +| Use | Utilities | +| --------------------------------- | ---------------------------------------------- | +| Page title (`Layout.Header`) | `text-heading-lg` | +| Section heading | `text-heading-md` | +| Card title | `text-heading-md leading-none` | +| Body copy | `text-body-md` | +| Secondary copy, descriptions | `text-body-md text-muted-foreground` | +| Caption, timestamp, metadata | `text-body-sm text-muted-foreground` | +| Numeric value in a table or field | `text-label-md tabular-nums` | +| ID, code | `font-mono text-code-sm text-muted-foreground` | + + -Compose roles from stock Tailwind utilities. These pairings are what AppShell's own components use — match them so your screens sit consistently alongside the primitives: - -| Role | Utilities | -| --------------------------------- | ----------------------------------------- | -| Page title (`Layout.Header`) | `text-2xl font-bold tracking-tight` | -| Section heading | `text-lg font-semibold` | -| Card title | `text-lg font-semibold leading-none` | -| Body copy | `text-sm` | -| Secondary copy, descriptions | `text-sm text-muted-foreground` | -| Caption, timestamp, metadata | `text-xs text-muted-foreground` | -| Numeric value in a table or field | `text-sm font-medium tabular-nums` | -| ID, code, keyboard hint | `font-mono text-xs text-muted-foreground` | +```tsx +function TypographyRoles() { + return ( +
+ {TYPOGRAPHY_SAMPLES.map(({ role, className, en, ja }) => ( +
+ {role} +
+

{en}

+

{ja}

+
+
+ ))} +
+ ); +} +``` ```tsx -

Section

-

Description copy

-Updated 2h ago +

Section

+

Description copy

+Updated 2h ago ``` +AppShell's own components still use the stock utilities (`text-sm`, `font-medium`, and so on) today. They will move to the roles in later changes, so the two sets can appear side by side for now. + +**Rules** + +- **A role carries no color.** Set the color with a token such as `text-muted-foreground`. +- **A role does not choose the HTML element.** Pick `h2`, `p`, or `span` from the document structure, not from the size. +- **Four modifiers may be combined with a role.** All are stock Tailwind utilities: + - Compact line height: `leading-none`. Use it only for single-line text, such as a card title. + - Tabular figures: `tabular-nums`. + - Emphasis on inline text: `font-medium` or `font-semibold`. + - Uppercase: `uppercase`. +- **Do not combine a role with other `leading-*`, `tracking-*`, or `text-[Npx]` values.** If none of the roles fits, use the nearest role. +- **12px is the minimum size.** Do not use smaller text. +- **`code-sm` needs `font-mono`.** The role sets size, line height, weight, and letter spacing, but a role cannot set the font family. +- **Use the relaxed variants for long text only.** For labels, table cells, and other short text, use the regular roles. + Always use `tabular-nums` for numbers that stack in a column — without it, digits jitter between rows. +**Change a role.** Each role is four CSS variables named `--app-shell-type--`. Override them on `:root` to change a role for the whole app. Typography has no dark variant, so one override applies to light and dark mode. + +```css +:root { + --app-shell-type-heading-lg-size: 1.75rem; + --app-shell-type-heading-lg-line-height: calc(36 / 28); +} +``` + +The `cream` and `bloom` palettes set `line-height` and `letter-spacing` on `h1` to `h6`. These rules are outside any `@layer`, so they win over a role utility on those elements. Under these palettes, an `h1` to `h6` element takes its line height and letter spacing from the palette, not from the role. Font size and weight still come from the role. The `default` palette has no such rule. + +**`tailwind-merge`.** AppShell's `cn()` already knows the role names. If your app has its own `cn()` built on `tailwind-merge`, register them. Without this, `twMerge("text-label-md text-muted-foreground")` removes the role and keeps only the color. + +```ts +import { extendTailwindMerge } from "tailwind-merge"; + +const twMerge = extendTailwindMerge({ + extend: { + classGroups: { + "font-size": [ + { + text: [ + "heading-lg", + "heading-md", + "heading-sm", + "body-md", + "body-sm", + "body-md-relaxed", + "body-sm-relaxed", + "label-md", + "label-sm", + "code-sm", + ], + }, + ], + }, + }, +}); +``` + ### Radius `--radius` (`0.625rem`) is the base; the bridge derives four steps from it. Pick by component role — a card is always `md`, regardless of its size on screen. @@ -332,12 +425,12 @@ Never invent a z value — `z-index: 9999` is always wrong. Popups and overlays **AppShell exports no `Icon` component and defines no `--icon-*` tokens.** Icons come from [`lucide-react`](https://lucide.dev), which AppShell already depends on. Size them with Tailwind, pairing icon size to the adjacent text: -| Text size | Icon class | -| ------------------ | ---------- | -| `text-xs` | `size-3` | -| `text-sm` | `size-4` | -| `text-lg` | `size-5` | -| `text-2xl` (title) | `size-6` | +| Text size | Icon class | +| ------------------------------------------------------------------- | ---------- | +| 12px: `text-xs`, `text-body-sm`, `text-label-sm`, `text-code-sm` | `size-3` | +| 14px: `text-sm`, `text-body-md`, `text-label-md`, `text-heading-sm` | `size-4` | +| 18px: `text-lg`, `text-heading-md` | `size-5` | +| 24px (title): `text-2xl`, `text-heading-lg` | `size-6` | ```tsx import { Check } from "lucide-react"; @@ -584,5 +677,5 @@ These are visual-composition rules every screen must follow, regardless of patte | Hover / focus transition | `duration-150 ease-out` | | State change (toggle, select) | `duration-200 ease-in-out` | | Two-column detail at <1024 | right column collapses below main — do not override | -| Inline ID, code, table number | `font-mono text-xs` (identifiers) / `tabular-nums` (figures) | -| Timestamp, label, subtle metadata | `text-xs text-muted-foreground` | +| Inline ID, code, table number | `font-mono text-code-sm` (identifiers) / `text-label-md tabular-nums` (figures) | +| Timestamp, label, subtle metadata | `text-body-sm text-muted-foreground` | From 5906cc191d0f427ab8529d99c0d33fbdeb5a080a Mon Sep 17 00:00:00 2001 From: Hiroki Uemura Date: Wed, 30 Sep 2026 09:41:19 +0900 Subject: [PATCH 04/12] fix(theme): pin role letter spacing in tests and reword palette comments Add letter spacing to the role spec test, add a leading-none order test for cn(), and reword the default palette and template comments so they do not contradict themselves. Co-Authored-By: Claude Sonnet 5.5 --- packages/core/src/assets/theme-bridge.test.ts | 30 +++++++++++-------- packages/core/src/assets/theme.css | 6 ++-- packages/core/src/assets/themes/_template.css | 2 +- packages/core/src/lib/utils.test.ts | 6 ++++ 4 files changed, 27 insertions(+), 17 deletions(-) diff --git a/packages/core/src/assets/theme-bridge.test.ts b/packages/core/src/assets/theme-bridge.test.ts index 6cb5944d2..a8282bb44 100644 --- a/packages/core/src/assets/theme-bridge.test.ts +++ b/packages/core/src/assets/theme-bridge.test.ts @@ -113,21 +113,24 @@ describe("theme bridge", () => { }); it("keeps the line boxes on the spec values", () => { - // size (px) x line-height ratio = line box (px). Values from decisions/typography-roles.md. - const spec: Record = { - "heading-lg": [24, 32, 700], - "heading-md": [18, 28, 600], - "heading-sm": [14, 20, 600], - "body-md": [14, 20, 400], - "body-sm": [12, 16, 400], - "label-md": [14, 20, 500], - "label-sm": [12, 16, 500], - "code-sm": [12, 18, 400], - "body-md-relaxed": [14, 24, 400], - "body-sm-relaxed": [12, 20, 400], + // size (px) x line-height ratio = line box (px); weight and letter spacing are pinned too. Values from decisions/typography-roles.md. + const spec: Record< + string, + [size: number, lineBox: number, weight: number, letterSpacing: string] + > = { + "heading-lg": [24, 32, 700, "-0.025em"], + "heading-md": [18, 28, 600, "0em"], + "heading-sm": [14, 20, 600, "0em"], + "body-md": [14, 20, 400, "0em"], + "body-sm": [12, 16, 400, "0em"], + "label-md": [14, 20, 500, "0em"], + "label-sm": [12, 16, 500, "0em"], + "code-sm": [12, 18, 400, "0em"], + "body-md-relaxed": [14, 24, 400, "0em"], + "body-sm-relaxed": [12, 20, 400, "0em"], }; expect([...defined.keys()].toSorted()).toEqual(Object.keys(spec).toSorted()); - for (const [role, [size, lineBox, weight]] of Object.entries(spec)) { + for (const [role, [size, lineBox, weight, letterSpacing]] of Object.entries(spec)) { const values = defined.get(role)!; const rem = /^([0-9.]+)rem$/.exec(values.get("size") ?? ""); expect(rem, `${role} size`).not.toBeNull(); @@ -137,6 +140,7 @@ describe("theme bridge", () => { expect(Number(ratio![2]), `${role} ratio base`).toBe(size); expect(Number(ratio![1]), `${role} line box`).toBe(lineBox); expect(Number(values.get("weight")), `${role} weight`).toBe(weight); + expect(values.get("letter-spacing"), `${role} letter-spacing`).toBe(letterSpacing); } }); }); diff --git a/packages/core/src/assets/theme.css b/packages/core/src/assets/theme.css index 03e100b43..06a0f117e 100644 --- a/packages/core/src/assets/theme.css +++ b/packages/core/src/assets/theme.css @@ -16,9 +16,9 @@ * 4. PALETTE — optional: radius, charts, shadows, destructive. * * Inherited from default.css — do not duplicate in new palettes: - * --ring, --sidebar-*, --status-*, --alert-*, --app-shell-type-*. - * Typography roles (--app-shell-type-*) are not part of a palette. A palette may - * override them on `:root` when it needs different typography. + * --ring, --sidebar-*, --status-*, --alert-*. + * Typography roles (--app-shell-type-*) are also inherited from default.css. Do not + * copy the default values into a palette. Override only the variables whose values differ. * * Light/dark mode is controlled separately via `.dark` on . * Palette selection is by CSS import; there is no runtime palette prop. diff --git a/packages/core/src/assets/themes/_template.css b/packages/core/src/assets/themes/_template.css index af6ecfd2d..39e0d85f1 100644 --- a/packages/core/src/assets/themes/_template.css +++ b/packages/core/src/assets/themes/_template.css @@ -18,7 +18,7 @@ * * DO NOT SET (inherited from `:root` in default.css): * --ring, --sidebar-*, --status-*, --alert-*, --app-shell-type-* - * (typography roles; override `--app-shell-type-*` only if the brand needs different type) + * (typography roles: do not copy the defaults; override only the variables whose values differ) * * PREVIEW: `/showcase/colors` in the Vite example app. * ═══════════════════════════════════════════════════════════════════════════ diff --git a/packages/core/src/lib/utils.test.ts b/packages/core/src/lib/utils.test.ts index 3ae4bb10f..6b5c8db76 100644 --- a/packages/core/src/lib/utils.test.ts +++ b/packages/core/src/lib/utils.test.ts @@ -28,6 +28,12 @@ describe("cn", () => { ); }); + it("keeps leading-none only when it comes after the role", () => { + // Same behavior as a stock size such as text-lg: a size class drops an earlier leading class. + expect(cn("text-heading-md", "leading-none")).toBe("text-heading-md leading-none"); + expect(cn("leading-none", "text-heading-md")).toBe("text-heading-md"); + }); + it("lets a later role or stock font size replace an earlier one", () => { expect(cn("text-body-md", "text-heading-lg")).toBe("text-heading-lg"); expect(cn("text-label-md", "text-sm")).toBe("text-sm"); From 7d5099d7e82efd167f6551477bb43af8d074becf Mon Sep 17 00:00:00 2001 From: Hiroki Uemura Date: Wed, 30 Sep 2026 09:41:22 +0900 Subject: [PATCH 05/12] docs(theme): state cream and bloom h1-h6 effect, cn() limits, and ADR data Document that cream and bloom ignore leading-* and tracking-* on h1-h6, the astw: prefix limit of cn(), and that a role sets its own weight and letter spacing. Fix the ADR source table (four sources, usage counts) and record the cn() registration as a deviation from the first plan. Make the docs example self-contained. Co-Authored-By: Claude Sonnet 5.5 --- .changeset/quiet-roles-align.md | 2 +- decisions/typography-roles.md | 25 ++-- .../styling-theming.docs.examples.tsx | 128 +++++++++--------- .../concepts/styling-theming.docs.outline.md | 11 +- docs/concepts/styling-theming.md | 76 ++++++++++- 5 files changed, 157 insertions(+), 85 deletions(-) diff --git a/.changeset/quiet-roles-align.md b/.changeset/quiet-roles-align.md index fe419ab90..d96d8707b 100644 --- a/.changeset/quiet-roles-align.md +++ b/.changeset/quiet-roles-align.md @@ -9,4 +9,4 @@ Add typography role tokens. A role sets font size, line height, font weight, and

Orders sent to suppliers this month.

``` -Each role reads four CSS variables named `--app-shell-type--`, which you can override on `:root`. AppShell's `cn()` now keeps a role together with a text color class. If your app has its own `tailwind-merge` setup, see the Typography section of the styling guide. +Each role reads four CSS variables named `--app-shell-type--`, which you can override on `:root`. AppShell's `cn()` now keeps a role together with a text color class, for example in `className="text-label-md text-muted-foreground"` passed to an AppShell component. If your app has its own `tailwind-merge` setup, see the Typography section of the styling guide. diff --git a/decisions/typography-roles.md b/decisions/typography-roles.md index 6f76c8404..602550479 100644 --- a/decisions/typography-roles.md +++ b/decisions/typography-roles.md @@ -9,11 +9,14 @@ Tracking issue: tailor-inc/platform-planning#1580 ([analysis comment](https://gi Until now, AppShell defined only the font family (`--font-sans`). `docs/concepts/styling-theming.md` told consumers to compose text from stock Tailwind utilities and gave a table of eight pairings. A sample of four sources on 2026-09-27 measured how often real code matched that table, counting whole combinations of size, weight, and line height: -| Source | Combinations that match the table | -| -------------------------------------------- | -------------------------------------- | -| AppShell components (238 usages) | 17% | -| UI catalogue pages and patterns (321 usages) | 15% | -| erp-kit templates | 40% (combinations that include a size) | +| Source | Usages | Combinations that match the table | +| ------------------------------- | ------ | --------------------------------- | +| AppShell components | 238 | 17% | +| UI catalogue pages and patterns | 321 | 15% | +| erp-kit templates | 528 | 35% (\*) | +| Catalogue (small sample) | 24 | 50%, from 6 combinations | + +(\*) 40% if the denominator is only the combinations that include a size. The catalogue sample is too small to compare with the others. The most common unlisted combination was `text-sm font-medium` (a label). It appears in all four sources. Code also used arbitrary sizes such as `text-[10px]`, and set line height explicitly in about 8 to 11% of places. @@ -49,7 +52,7 @@ The `--text-*` namespace cannot set `font-family`. `code-sm` therefore requires | `body-md-relaxed` | 14px | 24px | 400 | 0 | | `body-sm-relaxed` | 12px | 20px | 400 | 0 | -- Line height is stored as a unitless ratio (`calc(32 / 24)`), so the line box scales with the size. The line boxes are on a 4px grid. `code-sm` (18px) is the one step of 2px, chosen because the current `font-mono text-xs` gives 16px, which is tight for monospace text. +- Line height is stored as a unitless ratio (`calc(32 / 24)`), so the line box scales with the size. The line boxes are on a 4px grid. `code-sm` uses an 18px line box (ratio 1.5). The current `font-mono text-xs` gives 16px (ratio 1.33). The 2px step is a design choice for monospace text, not a measured result. - Every role sets letter spacing, including `0em`, so that no role inherits letter spacing from a parent. - Labels keep weight 500, the value used today. - The relaxed variants exist for body text only. They are for long text such as chat messages and documents. @@ -57,7 +60,7 @@ The `--text-*` namespace cannot set `font-family`. `code-sm` therefore requires ### Modifiers -Four stock Tailwind utilities may be combined with a role: `leading-none` (compact, single-line text only), `tabular-nums`, `font-medium` or `font-semibold` for emphasis on inline text, and `uppercase`. This is a documented rule and has no code. Other `leading-*`, `tracking-*`, and `text-[Npx]` values are not combined with a role. +Four stock Tailwind utilities may be combined with a role: `leading-none` (compact, single-line text only), `tabular-nums`, `font-medium` or `font-semibold` for emphasis on inline text, and `uppercase`. This is a documented rule and has no code. Other `leading-*`, `tracking-*`, and `text-[Npx]` values are not combined with a role. When a role and `leading-none` are merged with `cn()`, write `leading-none` after the role; a size class removes an earlier `leading-*` class in `tailwind-merge`, the same as for a stock size such as `text-lg`. A stock `leading-*`, `font-*`, or `tracking-*` on the same element still overrides the role value, because the emitted rule reads `--tw-leading`, `--tw-font-weight`, and `--tw-tracking` first. @@ -70,12 +73,16 @@ A stock `leading-*`, `font-*`, or `tracking-*` on the same element still overrid ### `cn()` knows the role names -`tailwind-merge` reads an unknown `text-*` value as a text color. Without a change, `cn("text-label-md", "text-muted-foreground")` keeps only the color. AppShell components pass consumer `className` through `cn()` today, so this would drop a role as soon as a consumer follows the documented pairings. Core's `cn()` now registers the ten role names in the `font-size` group. The documentation shows the same setting for consumers who have their own `cn()`. +`tailwind-merge` reads an unknown `text-*` value as a text color. Without a change, `cn("text-label-md", "text-muted-foreground")` keeps only the color. AppShell components pass a consumer `className` through `cn()` today, so `` would lose the role even though nothing in core uses roles yet (checked with `tailwind-merge` on `"astw:flex astw:text-sm"` merged with `"text-label-md text-muted-foreground"`: the role is removed). Core's `cn()` therefore registers the ten role names in the `font-size` group. The documentation shows the same setting for consumers who have their own `cn()`. + +This registration is a deviation from the first plan, which left `cn()` for the change that applies roles to components. It is kept in this PR because of the case above. The change is limited to `packages/core/src/lib/utils.ts` and its test, and can be reverted on its own. + +Limit: this does not let a role replace a size that core sets on its own element. Core classes carry the `astw:` prefix, and `tailwind-merge` reads that prefix as a variant, so `cn("astw:text-sm", "text-label-md")` keeps both classes. The same limit applies today to any unprefixed `text-*` class. Which of the two wins then depends on CSS order. Overriding an `astw:text-*` size from a consumer `className` is not supported; it needs the follow-up change that applies roles in core components. The role list in `utils.ts` and the roles in `default.css` must stay in sync; `utils.test.ts` checks this. ## Consequences and open questions - **No visual change.** No component uses the roles yet. The default, cream, and bloom palettes render as before. -- **cream and bloom `h1` to `h6` rule.** Both palettes set `line-height: 1.2` and `letter-spacing: -0.03em` on `html :where(h1, h2, h3, h4, h5, h6)` outside any `@layer`. That rule wins over a role utility on those elements, so a heading role on an `h1` to `h6` gets the palette values for line height and letter spacing. This PR keeps the rule and documents the effect. Open question for the maintainers: what does this rule protect, and should it stay once heading roles exist? +- **cream and bloom `h1` to `h6` rule.** Both palettes set `line-height: 1.2` and `letter-spacing: -0.03em` on `html :where(h1, h2, h3, h4, h5, h6)` outside any `@layer`. That rule wins over a role utility on those elements, so a heading role on an `h1` to `h6` gets the palette values for line height and letter spacing. It also wins over any `leading-*` or `tracking-*` class on those elements, including the `leading-none` in the card title pairing: an `h3` with `text-heading-md leading-none` has a line height of 1.2 under cream and bloom, and 1.0 under the default palette. The same classes on a `div` or `span` render as documented. This PR keeps the rule and documents the effect. Open question for the maintainers: what does this rule protect, and should it stay once heading roles exist? - **Keyboard hint.** The earlier pairing table listed "keyboard hint" with `font-mono text-xs`. The only keyboard hint in the code (the sidebar ⌘K hint) uses `text-xs font-normal`. The new table has no keyboard hint row. - **Follow-up changes** (not in this PR): apply the roles to core components; `overline`; migrate 10px and 11px text to 12px; a `Text` component; numeric DataTable columns with `tabular-nums` and right alignment as the default; a MetricCard value role and a display role above 24px. - **Docs example.** The live example in the docs uses `div` and `p` elements. The docs app runs the bloom palette, and its `h1` to `h6` rule would otherwise change the heading rows. diff --git a/docs-src/concepts/styling-theming.docs.examples.tsx b/docs-src/concepts/styling-theming.docs.examples.tsx index 4b3fad9e7..04b1c1536 100644 --- a/docs-src/concepts/styling-theming.docs.examples.tsx +++ b/docs-src/concepts/styling-theming.docs.examples.tsx @@ -13,73 +13,73 @@ export function AppearanceToggle() { return ; } -const TYPOGRAPHY_SAMPLES = [ - { - role: "heading-lg", - className: "text-heading-lg", - en: "Purchase orders", - ja: "発注管理", - }, - { - role: "heading-md", - className: "text-heading-md", - en: "Purchase orders by supplier", - ja: "仕入先別の発注一覧", - }, - { - role: "heading-sm", - className: "text-heading-sm", - en: "Delivery address", - ja: "納品先", - }, - { - role: "body-md", - className: "text-body-md", - en: "The order was sent to the supplier on 12 March.", - ja: "発注書は3月12日に仕入先へ送付されました。", - }, - { - role: "body-sm", - className: "text-body-sm text-muted-foreground", - en: "Updated 2 hours ago", - ja: "2時間前に更新", - }, - { - role: "label-md", - className: "text-label-md", - en: "Order quantity", - ja: "発注数量", - }, - { - role: "label-sm", - className: "text-label-sm", - en: "Draft", - ja: "下書き", - }, - { - role: "code-sm", - className: "font-mono text-code-sm", - en: "PO-2026-000184", - ja: "PO-2026-000184", - }, - { - role: "body-md-relaxed", - className: "text-body-md-relaxed", - en: "Please confirm the delivery date with the supplier before you approve this order.", - ja: "この発注を承認する前に、納期を仕入先に確認してください。", - }, - { - role: "body-sm-relaxed", - className: "text-body-sm-relaxed text-muted-foreground", - en: "Changes to a confirmed order are recorded in the order history.", - ja: "確定済みの発注への変更は、発注履歴に記録されます。", - }, -]; - export function TypographyRoles() { + const rows = [ + { + role: "heading-lg", + className: "text-heading-lg", + en: "Purchase orders", + ja: "発注管理", + }, + { + role: "heading-md", + className: "text-heading-md", + en: "Purchase orders by supplier", + ja: "仕入先別の発注一覧", + }, + { + role: "heading-sm", + className: "text-heading-sm", + en: "Delivery address", + ja: "納品先", + }, + { + role: "body-md", + className: "text-body-md", + en: "The order was sent to the supplier on 12 March.", + ja: "発注書は3月12日に仕入先へ送付されました。", + }, + { + role: "body-sm", + className: "text-body-sm text-muted-foreground", + en: "Updated 2 hours ago", + ja: "2時間前に更新", + }, + { + role: "label-md", + className: "text-label-md", + en: "Order quantity", + ja: "発注数量", + }, + { + role: "label-sm", + className: "text-label-sm", + en: "Draft", + ja: "下書き", + }, + { + role: "code-sm", + className: "font-mono text-code-sm", + en: "PO-2026-000184", + ja: "PO-2026-000184", + }, + { + role: "body-md-relaxed", + className: "text-body-md-relaxed", + en: "Please confirm the delivery date with the supplier before you approve this order.", + ja: "この発注を承認する前に、納期を仕入先に確認してください。", + }, + { + role: "body-sm-relaxed", + className: "text-body-sm-relaxed text-muted-foreground", + en: "Changes to a confirmed order are recorded in the order history.", + ja: "確定済みの発注への変更は、発注履歴に記録されます。", + }, + ]; + return (
- {TYPOGRAPHY_SAMPLES.map(({ role, className, en, ja }) => ( + {rows.map(({ role, className, en, ja }) => (
{role}
diff --git a/docs-src/concepts/styling-theming.docs.outline.md b/docs-src/concepts/styling-theming.docs.outline.md index 5429e71d0..46ba0b52a 100644 --- a/docs-src/concepts/styling-theming.docs.outline.md +++ b/docs-src/concepts/styling-theming.docs.outline.md @@ -251,7 +251,7 @@ Pair a role with a color token from the Color section above. These pairings are | --------------------------------- | ---------------------------------------------- | | Page title (`Layout.Header`) | `text-heading-lg` | | Section heading | `text-heading-md` | -| Card title | `text-heading-md leading-none` | +| Card title (`div` or `span`) | `text-heading-md leading-none` | | Body copy | `text-body-md` | | Secondary copy, descriptions | `text-body-md text-muted-foreground` | | Caption, timestamp, metadata | `text-body-sm text-muted-foreground` | @@ -266,14 +266,15 @@ Pair a role with a color token from the Color section above. These pairings are Updated 2h ago ``` -AppShell's own components still use the stock utilities (`text-sm`, `font-medium`, and so on) today. They will move to the roles in later changes, so the two sets can appear side by side for now. +On an `h1` to `h6` element, the `cream` and `bloom` palettes ignore `leading-none`; see "Change a role" below. AppShell's own components still use the stock utilities (`text-sm`, `font-medium`, and so on) today. They will move to the roles in later changes, so the two sets can appear side by side for now. **Rules** - **A role carries no color.** Set the color with a token such as `text-muted-foreground`. - **A role does not choose the HTML element.** Pick `h2`, `p`, or `span` from the document structure, not from the size. +- **A role sets weight and letter spacing on its own element.** It does not inherit them from a parent. Put an emphasis or tracking class on the element that has the role, not on its parent. - **Four modifiers may be combined with a role.** All are stock Tailwind utilities: - - Compact line height: `leading-none`. Use it only for single-line text, such as a card title. + - Compact line height: `leading-none`. Use it only for single-line text, such as a card title. Write it after the role when you merge with `cn()`. - Tabular figures: `tabular-nums`. - Emphasis on inline text: `font-medium` or `font-semibold`. - Uppercase: `uppercase`. @@ -293,9 +294,9 @@ Always use `tabular-nums` for numbers that stack in a column — without it, dig } ``` -The `cream` and `bloom` palettes set `line-height` and `letter-spacing` on `h1` to `h6`. These rules are outside any `@layer`, so they win over a role utility on those elements. Under these palettes, an `h1` to `h6` element takes its line height and letter spacing from the palette, not from the role. Font size and weight still come from the role. The `default` palette has no such rule. +The `cream` and `bloom` palettes set `line-height` and `letter-spacing` on `h1` to `h6`. These rules are outside any `@layer`, so they win over a role utility, and over any `leading-*` or `tracking-*` class, on those elements. Under these palettes, an `h1` to `h6` element takes its line height (1.2) and letter spacing (`-0.03em`) from the palette. This includes `leading-none`: `

` has a line height of 1.2, while a `div` with the same classes has 1.0. Font size and weight still come from the role. The `default` palette has no such rule. -**`tailwind-merge`.** AppShell's `cn()` already knows the role names. If your app has its own `cn()` built on `tailwind-merge`, register them. Without this, `twMerge("text-label-md text-muted-foreground")` removes the role and keeps only the color. +**`tailwind-merge`.** AppShell's `cn()` keeps a role together with a text color class, for example when you pass `className="text-label-md text-muted-foreground"` to an AppShell component. It cannot replace a size that a component sets itself, because component classes carry the internal `astw:` prefix (see "Why a plain utility can't override an AppShell default" below). If your app has its own `cn()` built on `tailwind-merge`, register the role names. Without this, `twMerge("text-label-md text-muted-foreground")` removes the role and keeps only the color. ```ts import { extendTailwindMerge } from "tailwind-merge"; diff --git a/docs/concepts/styling-theming.md b/docs/concepts/styling-theming.md index 161271d70..fdbf3176b 100644 --- a/docs/concepts/styling-theming.md +++ b/docs/concepts/styling-theming.md @@ -268,7 +268,7 @@ Pair a role with a color token from the Color section above. These pairings are | --------------------------------- | ---------------------------------------------- | | Page title (`Layout.Header`) | `text-heading-lg` | | Section heading | `text-heading-md` | -| Card title | `text-heading-md leading-none` | +| Card title (`div` or `span`) | `text-heading-md leading-none` | | Body copy | `text-body-md` | | Secondary copy, descriptions | `text-body-md text-muted-foreground` | | Caption, timestamp, metadata | `text-body-sm text-muted-foreground` | @@ -279,9 +279,72 @@ Pair a role with a color token from the Color section above. These pairings are ```tsx function TypographyRoles() { + const rows = [ + { + role: "heading-lg", + className: "text-heading-lg", + en: "Purchase orders", + ja: "発注管理", + }, + { + role: "heading-md", + className: "text-heading-md", + en: "Purchase orders by supplier", + ja: "仕入先別の発注一覧", + }, + { + role: "heading-sm", + className: "text-heading-sm", + en: "Delivery address", + ja: "納品先", + }, + { + role: "body-md", + className: "text-body-md", + en: "The order was sent to the supplier on 12 March.", + ja: "発注書は3月12日に仕入先へ送付されました。", + }, + { + role: "body-sm", + className: "text-body-sm text-muted-foreground", + en: "Updated 2 hours ago", + ja: "2時間前に更新", + }, + { + role: "label-md", + className: "text-label-md", + en: "Order quantity", + ja: "発注数量", + }, + { + role: "label-sm", + className: "text-label-sm", + en: "Draft", + ja: "下書き", + }, + { + role: "code-sm", + className: "font-mono text-code-sm", + en: "PO-2026-000184", + ja: "PO-2026-000184", + }, + { + role: "body-md-relaxed", + className: "text-body-md-relaxed", + en: "Please confirm the delivery date with the supplier before you approve this order.", + ja: "この発注を承認する前に、納期を仕入先に確認してください。", + }, + { + role: "body-sm-relaxed", + className: "text-body-sm-relaxed text-muted-foreground", + en: "Changes to a confirmed order are recorded in the order history.", + ja: "確定済みの発注への変更は、発注履歴に記録されます。", + }, + ]; + return (
- {TYPOGRAPHY_SAMPLES.map(({ role, className, en, ja }) => ( + {rows.map(({ role, className, en, ja }) => (
{role}
@@ -301,14 +364,15 @@ function TypographyRoles() { Updated 2h ago ``` -AppShell's own components still use the stock utilities (`text-sm`, `font-medium`, and so on) today. They will move to the roles in later changes, so the two sets can appear side by side for now. +On an `h1` to `h6` element, the `cream` and `bloom` palettes ignore `leading-none`; see "Change a role" below. AppShell's own components still use the stock utilities (`text-sm`, `font-medium`, and so on) today. They will move to the roles in later changes, so the two sets can appear side by side for now. **Rules** - **A role carries no color.** Set the color with a token such as `text-muted-foreground`. - **A role does not choose the HTML element.** Pick `h2`, `p`, or `span` from the document structure, not from the size. +- **A role sets weight and letter spacing on its own element.** It does not inherit them from a parent. Put an emphasis or tracking class on the element that has the role, not on its parent. - **Four modifiers may be combined with a role.** All are stock Tailwind utilities: - - Compact line height: `leading-none`. Use it only for single-line text, such as a card title. + - Compact line height: `leading-none`. Use it only for single-line text, such as a card title. Write it after the role when you merge with `cn()`. - Tabular figures: `tabular-nums`. - Emphasis on inline text: `font-medium` or `font-semibold`. - Uppercase: `uppercase`. @@ -328,9 +392,9 @@ Always use `tabular-nums` for numbers that stack in a column — without it, dig } ``` -The `cream` and `bloom` palettes set `line-height` and `letter-spacing` on `h1` to `h6`. These rules are outside any `@layer`, so they win over a role utility on those elements. Under these palettes, an `h1` to `h6` element takes its line height and letter spacing from the palette, not from the role. Font size and weight still come from the role. The `default` palette has no such rule. +The `cream` and `bloom` palettes set `line-height` and `letter-spacing` on `h1` to `h6`. These rules are outside any `@layer`, so they win over a role utility, and over any `leading-*` or `tracking-*` class, on those elements. Under these palettes, an `h1` to `h6` element takes its line height (1.2) and letter spacing (`-0.03em`) from the palette. This includes `leading-none`: `

` has a line height of 1.2, while a `div` with the same classes has 1.0. Font size and weight still come from the role. The `default` palette has no such rule. -**`tailwind-merge`.** AppShell's `cn()` already knows the role names. If your app has its own `cn()` built on `tailwind-merge`, register them. Without this, `twMerge("text-label-md text-muted-foreground")` removes the role and keeps only the color. +**`tailwind-merge`.** AppShell's `cn()` keeps a role together with a text color class, for example when you pass `className="text-label-md text-muted-foreground"` to an AppShell component. It cannot replace a size that a component sets itself, because component classes carry the internal `astw:` prefix (see "Why a plain utility can't override an AppShell default" below). If your app has its own `cn()` built on `tailwind-merge`, register the role names. Without this, `twMerge("text-label-md text-muted-foreground")` removes the role and keeps only the color. ```ts import { extendTailwindMerge } from "tailwind-merge"; From 16291319b6db7728dbdcc888e2c1b896e86a9b10 Mon Sep 17 00:00:00 2001 From: Hiroki Uemura Date: Wed, 30 Sep 2026 10:18:55 +0900 Subject: [PATCH 06/12] test(theme): assert all four declarations for every role The compile test only checked font-size for each role. A literal added to the bridge after the role entries could change line-height, weight or letter-spacing without failing the test. Assert all four values against the var() references. Co-Authored-By: Claude Sonnet 5.5 --- packages/core/tests/typography-compile.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/core/tests/typography-compile.test.ts b/packages/core/tests/typography-compile.test.ts index 9979dc435..75fc503f3 100644 --- a/packages/core/tests/typography-compile.test.ts +++ b/packages/core/tests/typography-compile.test.ts @@ -94,6 +94,15 @@ describe("typography role utilities", () => { for (const role of roles) { const decls = declarationsOf(css, `.text-${role}`); expect(decls.get("font-size"), role).toBe(`var(--app-shell-type-${role}-size)`); + expect(decls.get("line-height"), role).toBe( + `var(--tw-leading, var(--app-shell-type-${role}-line-height))`, + ); + expect(decls.get("letter-spacing"), role).toBe( + `var(--tw-tracking, var(--app-shell-type-${role}-letter-spacing))`, + ); + expect(decls.get("font-weight"), role).toBe( + `var(--tw-font-weight, var(--app-shell-type-${role}-weight))`, + ); expect(decls.size, role).toBe(4); } }); From d0916cb3c5d26a6f4fea4b0104390cccd8e7967c Mon Sep 17 00:00:00 2001 From: Hiroki Uemura Date: Wed, 30 Sep 2026 10:18:56 +0900 Subject: [PATCH 07/12] docs(theme): say h1-h6 palette rules replace role line height and letter spacing The sentence after the typography example only named leading-none. Align it with the Change a role paragraph. Co-Authored-By: Claude Sonnet 5.5 --- docs-src/concepts/styling-theming.docs.outline.md | 2 +- docs/concepts/styling-theming.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs-src/concepts/styling-theming.docs.outline.md b/docs-src/concepts/styling-theming.docs.outline.md index 46ba0b52a..37de3871b 100644 --- a/docs-src/concepts/styling-theming.docs.outline.md +++ b/docs-src/concepts/styling-theming.docs.outline.md @@ -266,7 +266,7 @@ Pair a role with a color token from the Color section above. These pairings are Updated 2h ago ``` -On an `h1` to `h6` element, the `cream` and `bloom` palettes ignore `leading-none`; see "Change a role" below. AppShell's own components still use the stock utilities (`text-sm`, `font-medium`, and so on) today. They will move to the roles in later changes, so the two sets can appear side by side for now. +On an `h1` to `h6` element, the `cream` and `bloom` palettes replace the role's line height and letter spacing, including `leading-none`; see "Change a role" below. AppShell's own components still use the stock utilities (`text-sm`, `font-medium`, and so on) today. They will move to the roles in later changes, so the two sets can appear side by side for now. **Rules** diff --git a/docs/concepts/styling-theming.md b/docs/concepts/styling-theming.md index fdbf3176b..0b77d2612 100644 --- a/docs/concepts/styling-theming.md +++ b/docs/concepts/styling-theming.md @@ -364,7 +364,7 @@ function TypographyRoles() { Updated 2h ago ``` -On an `h1` to `h6` element, the `cream` and `bloom` palettes ignore `leading-none`; see "Change a role" below. AppShell's own components still use the stock utilities (`text-sm`, `font-medium`, and so on) today. They will move to the roles in later changes, so the two sets can appear side by side for now. +On an `h1` to `h6` element, the `cream` and `bloom` palettes replace the role's line height and letter spacing, including `leading-none`; see "Change a role" below. AppShell's own components still use the stock utilities (`text-sm`, `font-medium`, and so on) today. They will move to the roles in later changes, so the two sets can appear side by side for now. **Rules** From cf45875b28f200c5bb71c9ec2c7906f8aa379e27 Mon Sep 17 00:00:00 2001 From: Hiroki Uemura Date: Wed, 30 Sep 2026 10:20:42 +0900 Subject: [PATCH 08/12] docs(decisions): align typography measurement with the #1580 comment Co-Authored-By: Claude Opus 5.5 --- decisions/typography-roles.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/decisions/typography-roles.md b/decisions/typography-roles.md index 602550479..2993867ce 100644 --- a/decisions/typography-roles.md +++ b/decisions/typography-roles.md @@ -7,16 +7,16 @@ Tracking issue: tailor-inc/platform-planning#1580 ([analysis comment](https://github.com/tailor-inc/platform-planning/issues/1580#issuecomment-5855498547)). -Until now, AppShell defined only the font family (`--font-sans`). `docs/concepts/styling-theming.md` told consumers to compose text from stock Tailwind utilities and gave a table of eight pairings. A sample of four sources on 2026-09-27 measured how often real code matched that table, counting whole combinations of size, weight, and line height: +Until now, AppShell defined only the font family (`--font-sans`). `docs/concepts/styling-theming.md` told consumers to compose text from stock Tailwind utilities and gave a table of eight pairings. A measurement of four sources on 2026-09-27 counted how often real code matched that table. The match rate counts only className strings that set a size and at least one more property (weight, line height, or letter spacing): -| Source | Usages | Combinations that match the table | -| ------------------------------- | ------ | --------------------------------- | -| AppShell components | 238 | 17% | -| UI catalogue pages and patterns | 321 | 15% | -| erp-kit templates | 528 | 35% (\*) | -| Catalogue (small sample) | 24 | 50%, from 6 combinations | +| Source | Usages | Combinations that match the table | +| ----------------------------------------------------------------- | ------ | --------------------------------- | +| AppShell components (`packages/core/src/components`, `ebd92cf`) | 238 | 17% | +| UI Catalogue pages and patterns (`tailor-inc/app-web`, `a5872bb`) | 321 | 15% | +| erp-kit scaffold (`tailor-platform/erp-kit`, `a736aeb`) | 528 | 40% | +| AppShell catalogue folder (AI reference code, `ebd92cf`) | 24 | 50%, from 6 combinations | -(\*) 40% if the denominator is only the combinations that include a size. The catalogue sample is too small to compare with the others. +The catalogue folder is the AI reference code that has since moved to `docs-src/`. Its sample is too small to compare with the others. The same numbers are in the analysis comment on #1580. The most common unlisted combination was `text-sm font-medium` (a label). It appears in all four sources. Code also used arbitrary sizes such as `text-[10px]`, and set line height explicitly in about 8 to 11% of places. From 13886390459cca0b4ef86395910f47d573c8697c Mon Sep 17 00:00:00 2001 From: Hiroki Uemura Date: Mon, 5 Oct 2026 08:48:42 +0900 Subject: [PATCH 09/12] chore(docs): regenerate docs manifest after rebase Co-Authored-By: Claude Opus 5.5 --- docs-manifest.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs-manifest.json b/docs-manifest.json index 63477342d..bdd7fa11c 100644 --- a/docs-manifest.json +++ b/docs-manifest.json @@ -605,7 +605,7 @@ "hashes": { "typeSurface": "b1614970f950ceb6", "outline": "bda40376d6e3e008", - "snapshot": "d4fa33f2da19d5ee", + "snapshot": "b845d4c54453b0f0", "outputMd": "ec6a0ac098d1d87a", "examples": "934c52d4e8761404" } @@ -1013,7 +1013,7 @@ "hashes": { "typeSurface": "943616def28cafb0", "outline": "8f06df28129676c8", - "snapshot": "02dfd02003105939", + "snapshot": "6233cd9de511210f", "outputMd": "ceb4a05822538d20", "examples": "cff0508bb5520ae6" } @@ -1166,7 +1166,7 @@ "hashes": { "typeSurface": "429710973dfa912b", "outline": "721e8995f53f0e8f", - "snapshot": "f6fcbd73e127f2f4", + "snapshot": "0ceac9786fb4ce3c", "outputMd": "ae287e2bb5abc1c6", "examples": "1ce89cec30045466" } @@ -1183,7 +1183,7 @@ "hashes": { "typeSurface": "e760949efcbe4c38", "outline": "464d91acc48f7c9d", - "snapshot": "12c20fc6f44d1b4c", + "snapshot": "40d15c3f1003fdb0", "outputMd": "1f81ebd562c50371", "examples": "b0eebb8c5e2219dc" } @@ -1284,10 +1284,10 @@ "symbols": [], "hashes": { "typeSurface": null, - "outline": "96d4bfc1c2681154", + "outline": "6b09dbdf8bb18a52", "snapshot": null, - "outputMd": "0133fdae405f467c", - "examples": "d3cca347b055a503" + "outputMd": "37d5ea82d91d9376", + "examples": "ec6e631787bd6e49" } }, "table": { @@ -1656,7 +1656,7 @@ "packages/core/skills/app-shell-patterns/references/concepts/modules-and-resources.md": "bf15b5460f22d165", "packages/core/skills/app-shell-patterns/references/concepts/routing-navigation.md": "2f3ff3f095c2b1cc", "packages/core/skills/app-shell-patterns/references/concepts/sidebar-navigation.md": "dc6b3afc049b4148", - "packages/core/skills/app-shell-patterns/references/concepts/styling-theming.md": "0133fdae405f467c", + "packages/core/skills/app-shell-patterns/references/concepts/styling-theming.md": "37d5ea82d91d9376", "packages/core/skills/app-shell-patterns/references/components/action-panel.md": "71794d4b561e4d4b", "packages/core/skills/app-shell-patterns/references/components/activity-card.md": "4f386e49380fb0b0", "packages/core/skills/app-shell-patterns/references/components/ai-chat.md": "824d1aa36bb7f194", From f075f8cc8efb67ae39ba9510bce004bd2f45bae1 Mon Sep 17 00:00:00 2001 From: Hiroki Uemura Date: Mon, 5 Oct 2026 08:59:52 +0900 Subject: [PATCH 10/12] docs(theme): use multi-line text in the relaxed role examples The relaxed roles change only line height, so a one-line sample does not show the difference. Co-Authored-By: Claude Opus 5.5 --- docs-manifest.json | 6 +++--- docs-src/concepts/styling-theming.docs.examples.tsx | 8 ++++---- docs/concepts/styling-theming.md | 8 ++++---- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/docs-manifest.json b/docs-manifest.json index bdd7fa11c..01b2beb8d 100644 --- a/docs-manifest.json +++ b/docs-manifest.json @@ -1286,8 +1286,8 @@ "typeSurface": null, "outline": "6b09dbdf8bb18a52", "snapshot": null, - "outputMd": "37d5ea82d91d9376", - "examples": "ec6e631787bd6e49" + "outputMd": "c51d8d22301825dd", + "examples": "882f4dd95de6d150" } }, "table": { @@ -1656,7 +1656,7 @@ "packages/core/skills/app-shell-patterns/references/concepts/modules-and-resources.md": "bf15b5460f22d165", "packages/core/skills/app-shell-patterns/references/concepts/routing-navigation.md": "2f3ff3f095c2b1cc", "packages/core/skills/app-shell-patterns/references/concepts/sidebar-navigation.md": "dc6b3afc049b4148", - "packages/core/skills/app-shell-patterns/references/concepts/styling-theming.md": "37d5ea82d91d9376", + "packages/core/skills/app-shell-patterns/references/concepts/styling-theming.md": "c51d8d22301825dd", "packages/core/skills/app-shell-patterns/references/components/action-panel.md": "71794d4b561e4d4b", "packages/core/skills/app-shell-patterns/references/components/activity-card.md": "4f386e49380fb0b0", "packages/core/skills/app-shell-patterns/references/components/ai-chat.md": "824d1aa36bb7f194", diff --git a/docs-src/concepts/styling-theming.docs.examples.tsx b/docs-src/concepts/styling-theming.docs.examples.tsx index 04b1c1536..38db909d6 100644 --- a/docs-src/concepts/styling-theming.docs.examples.tsx +++ b/docs-src/concepts/styling-theming.docs.examples.tsx @@ -66,14 +66,14 @@ export function TypographyRoles() { { role: "body-md-relaxed", className: "text-body-md-relaxed", - en: "Please confirm the delivery date with the supplier before you approve this order.", - ja: "この発注を承認する前に、納期を仕入先に確認してください。", + en: "Please confirm the delivery date with the supplier before you approve this order. If the supplier cannot deliver by the requested date, change the date or choose another supplier. Approved orders are sent to the supplier by email.", + ja: "この発注を承認する前に、納期を仕入先に確認してください。希望日までに納品できない場合は、納期を変更するか、別の仕入先を選んでください。承認した発注は、メールで仕入先に送られます。", }, { role: "body-sm-relaxed", className: "text-body-sm-relaxed text-muted-foreground", - en: "Changes to a confirmed order are recorded in the order history.", - ja: "確定済みの発注への変更は、発注履歴に記録されます。", + en: "Changes to a confirmed order are recorded in the order history. Each record shows who made the change, when it was made, and the values before and after the change.", + ja: "確定済みの発注への変更は、発注履歴に記録されます。記録には、変更した人、変更した日時、変更前と変更後の値が含まれます。", }, ]; diff --git a/docs/concepts/styling-theming.md b/docs/concepts/styling-theming.md index 0b77d2612..049e5749e 100644 --- a/docs/concepts/styling-theming.md +++ b/docs/concepts/styling-theming.md @@ -331,14 +331,14 @@ function TypographyRoles() { { role: "body-md-relaxed", className: "text-body-md-relaxed", - en: "Please confirm the delivery date with the supplier before you approve this order.", - ja: "この発注を承認する前に、納期を仕入先に確認してください。", + en: "Please confirm the delivery date with the supplier before you approve this order. If the supplier cannot deliver by the requested date, change the date or choose another supplier. Approved orders are sent to the supplier by email.", + ja: "この発注を承認する前に、納期を仕入先に確認してください。希望日までに納品できない場合は、納期を変更するか、別の仕入先を選んでください。承認した発注は、メールで仕入先に送られます。", }, { role: "body-sm-relaxed", className: "text-body-sm-relaxed text-muted-foreground", - en: "Changes to a confirmed order are recorded in the order history.", - ja: "確定済みの発注への変更は、発注履歴に記録されます。", + en: "Changes to a confirmed order are recorded in the order history. Each record shows who made the change, when it was made, and the values before and after the change.", + ja: "確定済みの発注への変更は、発注履歴に記録されます。記録には、変更した人、変更した日時、変更前と変更後の値が含まれます。", }, ]; From 63e8646b57b0abf3983b6f42c43a64507ae5d44b Mon Sep 17 00:00:00 2001 From: Hiroki Uemura Date: Mon, 5 Oct 2026 09:04:33 +0900 Subject: [PATCH 11/12] docs(theme): compare each relaxed role with its base role Show the same paragraph in body-md and body-md-relaxed (and body-sm and body-sm-relaxed) so the line height difference is visible. Co-Authored-By: Claude Opus 5.5 --- docs-manifest.json | 6 ++--- .../styling-theming.docs.examples.tsx | 27 +++++++++++++++---- docs/concepts/styling-theming.md | 27 +++++++++++++++---- 3 files changed, 47 insertions(+), 13 deletions(-) diff --git a/docs-manifest.json b/docs-manifest.json index 01b2beb8d..23263dcac 100644 --- a/docs-manifest.json +++ b/docs-manifest.json @@ -1286,8 +1286,8 @@ "typeSurface": null, "outline": "6b09dbdf8bb18a52", "snapshot": null, - "outputMd": "c51d8d22301825dd", - "examples": "882f4dd95de6d150" + "outputMd": "d19bcdb533ff50ef", + "examples": "4d19f4f688e0f0ac" } }, "table": { @@ -1656,7 +1656,7 @@ "packages/core/skills/app-shell-patterns/references/concepts/modules-and-resources.md": "bf15b5460f22d165", "packages/core/skills/app-shell-patterns/references/concepts/routing-navigation.md": "2f3ff3f095c2b1cc", "packages/core/skills/app-shell-patterns/references/concepts/sidebar-navigation.md": "dc6b3afc049b4148", - "packages/core/skills/app-shell-patterns/references/concepts/styling-theming.md": "c51d8d22301825dd", + "packages/core/skills/app-shell-patterns/references/concepts/styling-theming.md": "d19bcdb533ff50ef", "packages/core/skills/app-shell-patterns/references/components/action-panel.md": "71794d4b561e4d4b", "packages/core/skills/app-shell-patterns/references/components/activity-card.md": "4f386e49380fb0b0", "packages/core/skills/app-shell-patterns/references/components/ai-chat.md": "824d1aa36bb7f194", diff --git a/docs-src/concepts/styling-theming.docs.examples.tsx b/docs-src/concepts/styling-theming.docs.examples.tsx index 38db909d6..8a1444819 100644 --- a/docs-src/concepts/styling-theming.docs.examples.tsx +++ b/docs-src/concepts/styling-theming.docs.examples.tsx @@ -66,12 +66,14 @@ export function TypographyRoles() { { role: "body-md-relaxed", className: "text-body-md-relaxed", + compareWith: { role: "body-md", className: "text-body-md" }, en: "Please confirm the delivery date with the supplier before you approve this order. If the supplier cannot deliver by the requested date, change the date or choose another supplier. Approved orders are sent to the supplier by email.", ja: "この発注を承認する前に、納期を仕入先に確認してください。希望日までに納品できない場合は、納期を変更するか、別の仕入先を選んでください。承認した発注は、メールで仕入先に送られます。", }, { role: "body-sm-relaxed", className: "text-body-sm-relaxed text-muted-foreground", + compareWith: { role: "body-sm", className: "text-body-sm text-muted-foreground" }, en: "Changes to a confirmed order are recorded in the order history. Each record shows who made the change, when it was made, and the values before and after the change.", ja: "確定済みの発注への変更は、発注履歴に記録されます。記録には、変更した人、変更した日時、変更前と変更後の値が含まれます。", }, @@ -79,13 +81,28 @@ export function TypographyRoles() { return (
- {rows.map(({ role, className, en, ja }) => ( + {rows.map(({ role, className, compareWith, en, ja }) => (
{role} -
-

{en}

-

{ja}

-
+ {compareWith ? ( + // A relaxed role changes only line height, so show the same text in the base role first. +
+ {[compareWith, { role, className }].map((variant) => ( +
+ + {variant.role} + +

{en}

+

{ja}

+
+ ))} +
+ ) : ( +
+

{en}

+

{ja}

+
+ )}
))}
diff --git a/docs/concepts/styling-theming.md b/docs/concepts/styling-theming.md index 049e5749e..ea1d71af1 100644 --- a/docs/concepts/styling-theming.md +++ b/docs/concepts/styling-theming.md @@ -331,12 +331,14 @@ function TypographyRoles() { { role: "body-md-relaxed", className: "text-body-md-relaxed", + compareWith: { role: "body-md", className: "text-body-md" }, en: "Please confirm the delivery date with the supplier before you approve this order. If the supplier cannot deliver by the requested date, change the date or choose another supplier. Approved orders are sent to the supplier by email.", ja: "この発注を承認する前に、納期を仕入先に確認してください。希望日までに納品できない場合は、納期を変更するか、別の仕入先を選んでください。承認した発注は、メールで仕入先に送られます。", }, { role: "body-sm-relaxed", className: "text-body-sm-relaxed text-muted-foreground", + compareWith: { role: "body-sm", className: "text-body-sm text-muted-foreground" }, en: "Changes to a confirmed order are recorded in the order history. Each record shows who made the change, when it was made, and the values before and after the change.", ja: "確定済みの発注への変更は、発注履歴に記録されます。記録には、変更した人、変更した日時、変更前と変更後の値が含まれます。", }, @@ -344,13 +346,28 @@ function TypographyRoles() { return (
- {rows.map(({ role, className, en, ja }) => ( + {rows.map(({ role, className, compareWith, en, ja }) => (
{role} -
-

{en}

-

{ja}

-
+ {compareWith ? ( + // A relaxed role changes only line height, so show the same text in the base role first. +
+ {[compareWith, { role, className }].map((variant) => ( +
+ + {variant.role} + +

{en}

+

{ja}

+
+ ))} +
+ ) : ( +
+

{en}

+

{ja}

+
+ )}
))}
From 412a866544f181d00a76f5965a1d84f22b90900b Mon Sep 17 00:00:00 2001 From: Hiroki Uemura Date: Mon, 5 Oct 2026 09:17:42 +0900 Subject: [PATCH 12/12] docs(decisions): explain the --app-shell- prefix on typography variables Co-Authored-By: Claude Opus 5.5 --- decisions/typography-roles.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/decisions/typography-roles.md b/decisions/typography-roles.md index 2993867ce..400702edd 100644 --- a/decisions/typography-roles.md +++ b/decisions/typography-roles.md @@ -30,7 +30,7 @@ Each role is a named set of four values: size, line height, weight, and letter s ### Two layers, as for colors -- `themes/default.css` holds the values as `--app-shell-type--` on `:root`. A palette or an app can override them. +- `themes/default.css` holds the values as `--app-shell-type--` on `:root`. A palette or an app can override them. The `--app-shell-` prefix follows the existing `--app-shell-font-sans` override variable and keeps these `:root` variables apart from variables defined by the app or other libraries. - `theme.bridge.css` maps them to Tailwind with `--text-` and its `--line-height`, `--font-weight`, and `--letter-spacing` suffixes, inside the existing `@theme inline`. `@theme inline` writes literal values into the utility. If the values sat in the bridge, they could not be overridden on `:root`. So the bridge holds only `var()` references.