Skip to content

Commit ccc97d8

Browse files
OneHunnidclaude
andauthored
feat(tokens): add editorial typography family (#1940)
* feat(tokens): add editorial typography family Adds a Sora-based editorial typography token family for brand-themed surfaces (empty states, hero moments, announcements) that need the marketing-site type system rather than the utility scale. - New `fontFamily.sora` with Inter fallback - New `fontSize.editorial.{14..80}` rungs (pixel-named to match size primitives; avoids style-dictionary CSS font-shorthand emitter mis-tokenizing 4-digit rung names) - New `fontWeight.editorial.{medium,semibold,bold}` namespace - New unitless `lineHeight.editorial.{tight,snug,close,default,relaxed}` - New top-level `letterSpacing` group with `editorial.*` sub-namespace (consumers apply alongside the typography shorthand since CSS font shorthand does not include letter-spacing) - 7 new `text.editorial.*` compositions: display, h1, h1-alt, h2, h2-alt, h2-medium, h3 — Sora-only; Söhne mixins deferred pending licensing - Adds `size.30` and `size.50` primitives required by the new scale Source of truth: `_typography.scss` and `Text/index.tsx` DEFAULT_WEIGHT map in launchdarkly-nextjs/ai-refresh. Phase 2 (gonfalon Sora global preload + fairytale rollout) lands in a separate PR — see Editorial Typography Tokens proposal. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(tokens): split typography story into Utility and Editorial sections Replaces the single Typography story with two stories so each section renders with its own heading and description above its own canvas. Custom docs page (Title + Description + Stories with includePrimary and no section title) skips the default Primary canvas and removes the "Stories" separator heading. Editorial label rendering: getDisplayText now handles editorial keys by expanding h1/h2/h3 to "Heading 1/2/3" with alt and medium kept as variant suffixes. Component description mentions the two type sets and links to the Utility & Editorial Type System proposal. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(tokens): load Sora in Storybook + fix editorial heading tags Storybook now demonstrates the actual Sora editorial type instead of silently falling back to Inter: the Editorial specimens render with font-family var(--lp-font-family-sora) ("Sora, Inter, ..."), but no Sora webfont was loaded in the preview, so they rendered in Inter. - preview-head.html: load Sora from Google Fonts (OFL) for Storybook specimens only. The in-product @font-face/preload ships in Phase 2 (gonfalon), so this stays scoped to the docs surface. - typography.stories.tsx: map editorial-h1/h2/h3 to real h1/h2/h3 tags in getSemanticElement (previously fell through to a generic div); drop a stray per-token console.log. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(box): flatten fontSize/fontWeight/lineHeight token groups; add changeset The editorial typography tokens nest an `editorial.*` sub-namespace under fontSize, fontWeight, and lineHeight. Box's rainbow-sprinkles passed these groups straight into defineProperties, which expects a flat Record<string, string>, so the nested objects broke its overload typing — cascading into BoxProps and every <Box> consumer (preview, stories, specs) failing Type Check. Flatten those three groups via `flat` (the same treatment the `font` typography group already gets), so editorial.* tokens become flat keys and defineProperties typing holds. Also adds the missing changeset for the @launchpad-ui/tokens + @launchpad-ui/box changes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 941f77c commit ccc97d8

7 files changed

Lines changed: 289 additions & 9 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@launchpad-ui/tokens': minor
3+
'@launchpad-ui/box': patch
4+
---
5+
6+
Add a Sora-based editorial typography token family for brand-themed surfaces (empty states, hero moments, announcements). Adds `fontFamily.sora` (with an Inter fallback), pixel-named `fontSize.editorial.*`, `fontWeight.editorial.*`, unitless `lineHeight.editorial.*`, a new top-level `letterSpacing.editorial.*` group, seven `text.editorial.*` compositions, and `size.30`/`size.50` primitives. All values mirror the `ai-refresh` `_typography.scss` source of truth. Existing utility `text.*` compositions are unchanged.
7+
8+
Box's `rainbow-sprinkles` now flattens the `fontSize`, `fontWeight`, and `lineHeight` token groups (matching how the `font` typography group is already handled) so the nested `editorial.*` sub-namespaces don't break `defineProperties`' flat-record typing.

.storybook/preview-head.html

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
1+
<link rel="preconnect" href="https://fonts.googleapis.com" />
12
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
23
<link rel="preload" href="./static/assets/Audimat3000-Regulier.var-subset.woff2" as="font" crossorigin />
34
<link rel="preload" href="./static/assets/Inter.var-subset.woff2" as="font" crossorigin />
45

6+
<!-- Sora (editorial type family) loaded from Google Fonts for Storybook specimens only.
7+
The in-product @font-face/preload ships separately in Phase 2 (gonfalon). -->
8+
<link
9+
href="https://fonts.googleapis.com/css2?family=Sora:wght@400;500;600;700&display=swap"
10+
rel="stylesheet"
11+
/>
12+
513
<link href="./styles.css" rel="stylesheet" />
614
<link href="./static/fonts.css" rel="stylesheet" />

packages/box/src/styles/rainbow-sprinkles.css.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { createRainbowSprinkles, defineProperties } from 'rainbow-sprinkles';
55

66
// biome-ignore lint/correctness/noUnusedVariables: ignore
77
const { bg, border, fill, shadow, text, ...global } = vars.color;
8-
const { text: typography, gradient } = vars;
8+
const { text: typography, gradient, fontSize, fontWeight, lineHeight } = vars;
99

1010
type FlattenObjectKeys<T extends Record<string, unknown>, Key = keyof T> = Key extends string
1111
? T[Key] extends Record<string, unknown>
@@ -19,13 +19,19 @@ type BorderKeys = FlattenObjectKeys<typeof border>;
1919
type FillKeys = FlattenObjectKeys<typeof fill>;
2020
type TextKeys = FlattenObjectKeys<typeof text>;
2121
type TypographyKeys = FlattenObjectKeys<typeof typography>;
22+
type FontSizeKeys = FlattenObjectKeys<typeof fontSize>;
23+
type FontWeightKeys = FlattenObjectKeys<typeof fontWeight>;
24+
type LineHeightKeys = FlattenObjectKeys<typeof lineHeight>;
2225

2326
const colors = flatten<typeof global, Record<GlobalKeys, string>>(global);
2427
const backgrounds = flatten<typeof bg, Record<BackgroundKeys, string>>(bg);
2528
const borders = flatten<typeof border, Record<BorderKeys, string>>(border);
2629
const fills = flatten<typeof fill, Record<FillKeys, string>>(fill);
2730
const texts = flatten<typeof text, Record<TextKeys, string>>(text);
2831
const typographies = flatten<typeof typography, Record<TypographyKeys, string>>(typography);
32+
const fontSizes = flatten<typeof fontSize, Record<FontSizeKeys, string>>(fontSize);
33+
const fontWeights = flatten<typeof fontWeight, Record<FontWeightKeys, string>>(fontWeight);
34+
const lineHeights = flatten<typeof lineHeight, Record<LineHeightKeys, string>>(lineHeight);
2935

3036
const responsiveProperties = defineProperties({
3137
conditions: {
@@ -70,9 +76,9 @@ const responsiveProperties = defineProperties({
7076
borderWidth: vars.borderWidth,
7177
font: typographies,
7278
fontFamily: vars.fontFamily,
73-
fontSize: vars.fontSize,
74-
fontWeight: vars.fontWeight,
75-
lineHeight: vars.lineHeight,
79+
fontSize: fontSizes,
80+
fontWeight: fontWeights,
81+
lineHeight: lineHeights,
7682
width: vars.size,
7783
height: vars.size,
7884
maxHeight: vars.size,

packages/tokens/stories/typography.stories.tsx

Lines changed: 81 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { vars } from '@launchpad-ui/vars';
2+
import { Description, Stories, Title } from '@storybook/addon-docs/blocks';
23

34
import { Button } from '../../components/src/Button';
45
import { ToastRegion, toastQueue } from '../../components/src/Toast';
@@ -11,8 +12,15 @@ export default {
1112
docs: {
1213
description: {
1314
component:
14-
'Typography tokens for the LaunchPad design system. For components using these tokens, see [Text](/docs/components-content-text--docs), [Heading](/docs/components-content-heading--docs), [Label](/docs/components-content-label--docs), and [Code](/docs/components-content-code--docs).',
15+
'Typography tokens for the LaunchPad design system. Our typography is split between two type sets: Utility and Editorial. For components using these tokens, see [Text](/docs/components-content-text--docs), [Heading](/docs/components-content-heading--docs), [Label](/docs/components-content-label--docs), and [Code](/docs/components-content-code--docs). For the full framework, see the [Utility & Editorial Type System for LaunchPad](https://launchdarkly.atlassian.net/wiki/spaces/~712020490f77e4363240f1888e975e52e895be/pages/4939022523/) proposal.',
1516
},
17+
page: () => (
18+
<>
19+
<Title />
20+
<Description />
21+
<Stories title="" includePrimary />
22+
</>
23+
),
1624
},
1725
},
1826
};
@@ -33,15 +41,33 @@ const flatten = (obj: Record<string, unknown>, prefix = ''): Record<string, stri
3341
return result;
3442
};
3543

44+
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
45+
3646
const getDisplayText = (key: string) => {
3747
const parts = key.split('-');
3848

49+
if (parts[0] === 'editorial') {
50+
const variant = parts[1]; // display, h1, h2, h3
51+
const modifiers = parts.slice(2); // alt, medium, etc. all treated as variant suffixes
52+
53+
let base: string;
54+
if (variant === 'display') {
55+
base = 'Display';
56+
} else if (/^h\d+$/.test(variant)) {
57+
base = `Heading ${variant.slice(1)}`;
58+
} else {
59+
base = capitalize(variant);
60+
}
61+
62+
return modifiers.length ? `${base} ${modifiers.map(capitalize).join(' ')}` : base;
63+
}
64+
3965
const category = parts[0]; // heading, body, etc.
4066
const size = parts[1]; // 1, 2, etc.
4167
const weight = parts[2]; // medium, semibold, etc.
4268

4369
if (weight) {
44-
return `${category.charAt(0).toUpperCase() + category.slice(1)} ${size} - ${weight.charAt(0).toUpperCase() + weight.slice(1)}`;
70+
return `${capitalize(category)} ${size} - ${capitalize(weight)}`;
4571
}
4672
if (category === 'display') {
4773
return `Display ${size}`;
@@ -55,7 +81,24 @@ const getSemanticElement = (token: string, font: string) => {
5581
const category = parts[0]; // heading, body, etc.
5682
const size = parts[1]; // 1, 2, etc.
5783

58-
console.log({ category, token });
84+
// Editorial tokens are keyed as editorial-<variant>[-modifier] (e.g. editorial-h1-alt),
85+
// so the heading level lives in parts[1]. Map it to the matching tag instead of falling
86+
// through to the generic div below.
87+
if (category === 'editorial') {
88+
const variant = parts[1]; // display, h1, h2, h3
89+
const match = /^h(\d+)$/.exec(variant);
90+
if (match) {
91+
const Tag = `h${match[1]}` as 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
92+
return {
93+
element: Tag,
94+
render: (text: string) => <Tag style={{ font }}>{text}</Tag>,
95+
};
96+
}
97+
return {
98+
element: 'div',
99+
render: (text: string) => <div style={{ font }}>{text}</div>,
100+
};
101+
}
59102

60103
switch (category) {
61104
case 'display':
@@ -167,6 +210,39 @@ const TokenTable = ({ tokens }: { tokens: Record<string, string> }) => {
167210
);
168211
};
169212

170-
export const Typography = {
171-
render: () => <TokenTable tokens={flatten(vars.text)} />,
213+
const UTILITY_DESCRIPTION = `The foundation of the LaunchDarkly product. Inter for text, SF Mono for numeric content where vertical alignment matters. Designed to feel invisible: when it's working correctly, users don't notice it. Used across navigation, forms, tables, modals, settings, and any task-driven surface.`;
214+
215+
const EDITORIAL_DESCRIPTION =
216+
'Carries the LaunchDarkly brand voice into the product by using Sora for display headings. Used on first-time onboarding empty states, in-app banners, feature announcements, and educational moments where the user has paused. Should feel intentional and slightly different from the rest of the UI.';
217+
218+
const utilityTokens = () =>
219+
Object.fromEntries(
220+
Object.entries(flatten(vars.text)).filter(([key]) => !key.startsWith('editorial-')),
221+
);
222+
223+
const editorialTokens = () =>
224+
Object.fromEntries(
225+
Object.entries(flatten(vars.text)).filter(([key]) => key.startsWith('editorial-')),
226+
);
227+
228+
export const Utility = {
229+
parameters: {
230+
docs: {
231+
description: {
232+
story: UTILITY_DESCRIPTION,
233+
},
234+
},
235+
},
236+
render: () => <TokenTable tokens={utilityTokens()} />,
237+
};
238+
239+
export const Editorial = {
240+
parameters: {
241+
docs: {
242+
description: {
243+
story: EDITORIAL_DESCRIPTION,
244+
},
245+
},
246+
},
247+
render: () => <TokenTable tokens={editorialTokens()} />,
172248
};

packages/tokens/tokens/font.json

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
},
1717
"display": {
1818
"$value": "Audimat\\ 3000 Regulier"
19+
},
20+
"sora": {
21+
"$value": "Sora, Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, Helvetica, Arial, sans-serif"
1922
}
2023
},
2124
"fontSize": {
@@ -50,6 +53,47 @@
5053
},
5154
"800": {
5255
"$value": 60
56+
},
57+
"editorial": {
58+
"14": {
59+
"$value": "{size.14}"
60+
},
61+
"16": {
62+
"$value": "{size.16}"
63+
},
64+
"18": {
65+
"$value": "{size.18}"
66+
},
67+
"20": {
68+
"$value": "{size.20}"
69+
},
70+
"24": {
71+
"$value": "{size.24}"
72+
},
73+
"30": {
74+
"$value": "{size.30}"
75+
},
76+
"32": {
77+
"$value": "{size.32}"
78+
},
79+
"40": {
80+
"$value": "{size.40}"
81+
},
82+
"48": {
83+
"$value": "{size.48}"
84+
},
85+
"50": {
86+
"$value": "{size.50}"
87+
},
88+
"56": {
89+
"$value": "{size.56}"
90+
},
91+
"64": {
92+
"$value": "{size.64}"
93+
},
94+
"80": {
95+
"$value": "{size.80}"
96+
}
5397
}
5498
},
5599
"fontWeight": {
@@ -78,6 +122,17 @@
78122
},
79123
"extrabold": {
80124
"$value": 800
125+
},
126+
"editorial": {
127+
"medium": {
128+
"$value": 500
129+
},
130+
"semibold": {
131+
"$value": 600
132+
},
133+
"bold": {
134+
"$value": 700
135+
}
81136
}
82137
},
83138
"lineHeight": {
@@ -103,6 +158,62 @@
103158
},
104159
"500": {
105160
"$value": "{size.72}"
161+
},
162+
"editorial": {
163+
"tight": {
164+
"$value": 1
165+
},
166+
"snug": {
167+
"$value": 1.05
168+
},
169+
"close": {
170+
"$value": 1.1
171+
},
172+
"default": {
173+
"$value": 1.15
174+
},
175+
"relaxed": {
176+
"$value": 1.5
177+
}
178+
}
179+
},
180+
"letterSpacing": {
181+
"$type": "dimension",
182+
"$extensions": {
183+
"com.figma": {
184+
"hiddenFromPublishing": false,
185+
"scopes": ["LETTER_SPACING"],
186+
"codeSyntax": {}
187+
}
188+
},
189+
"editorial": {
190+
"tight-2px": {
191+
"$value": "-2px"
192+
},
193+
"tight-1px": {
194+
"$value": "-1px"
195+
},
196+
"tight-em-05": {
197+
"$value": "-0.05em"
198+
},
199+
"tight-em-03": {
200+
"$value": "-0.03em"
201+
},
202+
"tight-px-065": {
203+
"$value": "-0.65px"
204+
},
205+
"tight-px-050": {
206+
"$value": "-0.5px"
207+
},
208+
"tight-px-025": {
209+
"$value": "-0.25px"
210+
},
211+
"wide-em-06": {
212+
"$value": "0.06em"
213+
},
214+
"normal": {
215+
"$value": 0
216+
}
106217
}
107218
}
108219
}

packages/tokens/tokens/size.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@
6262
"28": {
6363
"$value": 28
6464
},
65+
"30": {
66+
"$value": 30
67+
},
6568
"32": {
6669
"$value": 32
6770
},
@@ -77,6 +80,9 @@
7780
"48": {
7881
"$value": 48
7982
},
83+
"50": {
84+
"$value": 50
85+
},
8086
"56": {
8187
"$value": 56
8288
},

0 commit comments

Comments
 (0)