@repobit/dex-system-design 0.25.12 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (336) hide show
  1. package/CHANGELOG.md +187 -0
  2. package/README.md +131 -8
  3. package/package.json +95 -13
  4. package/schemas/component-manifest.json +3294 -405
  5. package/schemas/manifest-omissions.json +173 -0
  6. package/src/blog/src/breakpoints.js +6 -6
  7. package/src/blog/src/content/blog-atoms-showcase.js +1 -1
  8. package/src/blog/src/content/blog-page-layout.js +2 -2
  9. package/src/blog/src/lib/article-toc.js +7 -7
  10. package/src/blog/src/shell/blog-shell.js +1 -1
  11. package/src/blog/src/tokens/blog.js +2 -2
  12. package/src/blog/src/tokens/core.js +34 -35
  13. package/src/blog/src/tokens/legacy.js +0 -9
  14. package/src/blog/tokens/blog.js +2 -2
  15. package/src/blog/tokens/core.js +34 -35
  16. package/src/blog/tokens/legacy.js +0 -9
  17. package/src/blog/tokens.css +36 -46
  18. package/src/components/accordion/accordion-bg.css.js +83 -59
  19. package/src/components/accordion/accordion-bg.js +29 -10
  20. package/src/components/accordion/accordion-bg.stories.js +67 -67
  21. package/src/components/accordion/accordion-keyboard.js +80 -0
  22. package/src/components/accordion/accordion.css.js +142 -80
  23. package/src/components/accordion/accordion.js +37 -10
  24. package/src/components/accordion/accordion.stories.js +7 -7
  25. package/src/components/agent-endpoints/agent-endpoints.css.js +36 -39
  26. package/src/components/agent-endpoints/agent-endpoints.js +30 -20
  27. package/src/components/agent-endpoints/agent-endpoints.stories.js +1 -1
  28. package/src/components/ai-guardian-demo.stories.js +91 -91
  29. package/src/components/alert/alert.css.js +198 -0
  30. package/src/components/alert/alert.js +103 -0
  31. package/src/components/alert/alert.stories.js +206 -0
  32. package/src/components/anchor/anchor-nav.css.js +213 -101
  33. package/src/components/anchor/anchor-nav.js +26 -40
  34. package/src/components/anchor/anchor.stories.js +22 -12
  35. package/src/components/annotation/annotation.css.js +58 -0
  36. package/src/components/annotation/annotation.js +67 -0
  37. package/src/components/annotation/annotation.stories.js +63 -0
  38. package/src/components/architecture.stories.js +285 -0
  39. package/src/components/atoms.stories.js +342 -0
  40. package/src/components/avatar/avatar.css.js +67 -0
  41. package/src/components/avatar/avatar.js +42 -0
  42. package/src/components/avatar/avatar.stories.js +95 -0
  43. package/src/components/awards/awards-flex.css.js +209 -23
  44. package/src/components/awards/awards-flex.js +66 -7
  45. package/src/components/awards/awards-flex.stories.js +1 -1
  46. package/src/components/awards/{awards-light.stories.js → awards-summary.stories.js} +54 -64
  47. package/src/components/awards/awards.css.js +116 -58
  48. package/src/components/awards/awards.js +102 -28
  49. package/src/components/awards/awards.stories.js +1 -1
  50. package/src/components/back/back.css.js +51 -22
  51. package/src/components/back/back.js +28 -7
  52. package/src/components/back/back.stories.js +9 -3
  53. package/src/components/badge/badge.css.js +158 -46
  54. package/src/components/badge/badge.js +62 -74
  55. package/src/components/badge/badge.stories.js +176 -248
  56. package/src/components/bento-grid/bento-grid.css.js +21 -16
  57. package/src/components/bento-grid/bento-grid.js +16 -4
  58. package/src/components/bento-grid/bento-grid.stories.js +19 -18
  59. package/src/components/bento-grid/bento-item.css.js +52 -28
  60. package/src/components/bento-grid/bento-item.js +2 -2
  61. package/src/components/blockquote/blockquote.css.js +47 -0
  62. package/src/components/blockquote/blockquote.js +29 -0
  63. package/src/components/blockquote/blockquote.stories.js +62 -0
  64. package/src/components/breadcrumb/breadcrumb.css.js +92 -22
  65. package/src/components/breadcrumb/breadcrumb.js +56 -11
  66. package/src/components/button/button.css.js +531 -161
  67. package/src/components/button/button.js +206 -214
  68. package/src/components/button/button.stories.js +322 -174
  69. package/src/components/caption/caption.css.js +21 -0
  70. package/src/components/caption/caption.js +31 -0
  71. package/src/components/caption/caption.stories.js +62 -0
  72. package/src/components/card-features/card-features.css.js +14 -22
  73. package/src/components/card-features/card-features.js +68 -9
  74. package/src/components/card-features/card-features.stories.js +11 -11
  75. package/src/components/cards/card.css.js +60 -81
  76. package/src/components/cards/card.js +56 -25
  77. package/src/components/cards/card.stories.js +39 -39
  78. package/src/components/carousel/carousel.css.js +93 -99
  79. package/src/components/carousel/carousel.js +32 -4
  80. package/src/components/checkbox/checkbox.css.js +111 -93
  81. package/src/components/checkbox/checkbox.js +64 -90
  82. package/src/components/checkbox/checkbox.stories.js +96 -29
  83. package/src/components/code/code.css.js +70 -0
  84. package/src/components/code/code.js +40 -0
  85. package/src/components/code/code.stories.js +73 -0
  86. package/src/components/compare/compare.css.js +179 -43
  87. package/src/components/compare/compare.js +52 -25
  88. package/src/components/compare/compare.stories.js +61 -4
  89. package/src/components/comparison-panel/comparison-panel.css.js +38 -51
  90. package/src/components/comparison-panel/comparison-panel.js +5 -4
  91. package/src/components/comparison-panel/comparison-panel.stories.js +1 -1
  92. package/src/components/compatibility-table/compatibility-table.css.js +180 -80
  93. package/src/components/compatibility-table/compatibility-table.js +37 -9
  94. package/src/components/control-alignment.stories.js +393 -0
  95. package/src/components/cta-banner/cta-banner.css.js +41 -13
  96. package/src/components/cta-banner/cta-banner.js +6 -5
  97. package/src/components/cta-banner/cta-banner.stories.js +43 -98
  98. package/src/components/display/display.css.js +25 -1
  99. package/src/components/display/display.js +46 -13
  100. package/src/components/display/display.stories.js +270 -40
  101. package/src/components/divider/divider-horizontal.js +21 -5
  102. package/src/components/divider/divider-vertical.js +15 -7
  103. package/src/components/divider/divider.css.js +2 -2
  104. package/src/components/divider/divider.stories.js +38 -34
  105. package/src/components/dropdown/dropdown.css.js +93 -126
  106. package/src/components/dropdown/dropdown.js +114 -34
  107. package/src/components/dropdown/dropdown.stories.js +56 -24
  108. package/src/components/features/features.css.js +24 -11
  109. package/src/components/features/features.js +7 -3
  110. package/src/components/features/features.stories.js +37 -37
  111. package/src/components/features-tab/features-tab.css.js +19 -8
  112. package/src/components/features-tab/features-tab.js +6 -5
  113. package/src/components/features-tab/features-tab.stories.js +23 -23
  114. package/src/components/fieldset/fieldset.css.js +30 -0
  115. package/src/components/fieldset/fieldset.js +28 -0
  116. package/src/components/fieldset/fieldset.stories.js +84 -0
  117. package/src/components/footer/footer-links-group.css.js +10 -14
  118. package/src/components/footer/footer-links-group.js +4 -3
  119. package/src/components/footer/footer-lp.css.js +59 -30
  120. package/src/components/footer/footer-lp.js +17 -12
  121. package/src/components/footer/footer-lp.stories.js +63 -75
  122. package/src/components/footer/footer-nav-menu.css.js +26 -4
  123. package/src/components/footer/footer-nav-menu.js +3 -2
  124. package/src/components/footer/footer.css.js +146 -264
  125. package/src/components/footer/footer.js +176 -106
  126. package/src/components/footer/footer.stories.js +9 -9
  127. package/src/components/footer/localeMap.js +1 -1
  128. package/src/components/grid/grid.css.js +1 -1
  129. package/src/components/grid/grid.js +49 -7
  130. package/src/components/grid/grid.stories.js +111 -0
  131. package/src/components/header/header-code.css.js +61 -51
  132. package/src/components/header/header-code.js +28 -14
  133. package/src/components/header/header-code.stories.js +5 -5
  134. package/src/components/header/header.css.js +655 -176
  135. package/src/components/header/header.js +269 -36
  136. package/src/components/header/header.stories.js +841 -18
  137. package/src/components/heading/heading.css.js +14 -17
  138. package/src/components/heading/heading.js +48 -33
  139. package/src/components/heading/heading.stories.js +27 -25
  140. package/src/components/help-text/help-text.css.js +21 -0
  141. package/src/components/help-text/help-text.js +25 -0
  142. package/src/components/help-text/help-text.stories.js +66 -0
  143. package/src/components/highlight/highlight.css.js +86 -64
  144. package/src/components/highlight/highlight.js +18 -27
  145. package/src/components/highlight/highlight.stories.js +14 -3
  146. package/src/components/icons/analysis-icon.js +6 -16
  147. package/src/components/icons/arrow-down-icon.js +6 -19
  148. package/src/components/icons/arrow-icon.js +44 -0
  149. package/src/components/icons/arrow-right-icon.js +30 -23
  150. package/src/components/icons/arrow-up-icon.js +19 -18
  151. package/src/components/icons/bd-ai-icon.js +1 -1
  152. package/src/components/icons/brand-marks.stories.js +279 -0
  153. package/src/components/icons/brand-signature-icon.js +42 -0
  154. package/src/components/icons/check-icon.js +27 -0
  155. package/src/components/icons/circle-info-icon.js +33 -0
  156. package/src/components/icons/close-icon.js +6 -18
  157. package/src/components/icons/family-icon.js +8 -20
  158. package/src/components/icons/globe-icon.js +6 -18
  159. package/src/components/icons/guide-icon.js +6 -18
  160. package/src/components/icons/icon-element.js +99 -0
  161. package/src/components/icons/icons.js +36 -0
  162. package/src/components/icons/icons.stories.js +184 -52
  163. package/src/components/icons/individual-icon.js +6 -18
  164. package/src/components/icons/laurel-icon.js +9 -19
  165. package/src/components/icons/machine-learning-icon.js +6 -17
  166. package/src/components/icons/marks.js +41 -0
  167. package/src/components/icons/minus-icon.js +7 -21
  168. package/src/components/icons/missing-icon.js +52 -0
  169. package/src/components/icons/plus-icon.js +7 -21
  170. package/src/components/icons/quote-icon.js +19 -19
  171. package/src/components/image/image.css.js +72 -25
  172. package/src/components/image/image.js +11 -6
  173. package/src/components/image/image.stories.js +2 -2
  174. package/src/components/input/custom-form.stories.js +214 -62
  175. package/src/components/input/input-clipboard.css.js +51 -144
  176. package/src/components/input/input-clipboard.js +31 -15
  177. package/src/components/input/input.css.js +8 -146
  178. package/src/components/input/input.js +93 -38
  179. package/src/components/install-options/install-options.css.js +84 -77
  180. package/src/components/install-options/install-options.js +33 -30
  181. package/src/components/install-options/install-options.stories.js +3 -3
  182. package/src/components/label/label.css.js +35 -0
  183. package/src/components/label/label.js +38 -0
  184. package/src/components/label/label.stories.js +82 -0
  185. package/src/components/light-carousel/light-carousel.css.js +111 -63
  186. package/src/components/light-carousel/light-carousel.js +85 -11
  187. package/src/components/light-carousel/light-carousel.stories.js +126 -82
  188. package/src/components/link/link.css.js +150 -99
  189. package/src/components/link/link.js +86 -11
  190. package/src/components/link/link.stories.js +124 -30
  191. package/src/components/list/list-item/list-item.css.js +74 -22
  192. package/src/components/list/list-item/list-item.js +32 -10
  193. package/src/components/list/list-item/list-item.stories.js +23 -23
  194. package/src/components/list/list.css.js +8 -2
  195. package/src/components/list/list.js +3 -2
  196. package/src/components/list/list.stories.js +222 -0
  197. package/src/components/modal/modal.css.js +114 -24
  198. package/src/components/modal/modal.js +83 -7
  199. package/src/components/modal/modal.stories.js +9 -9
  200. package/src/components/overview/overview.css.js +19 -14
  201. package/src/components/overview/overview.js +6 -5
  202. package/src/components/overview/overview.stories.js +10 -10
  203. package/src/components/page-switcher/page-switcher.css.js +97 -40
  204. package/src/components/page-switcher/page-switcher.js +28 -5
  205. package/src/components/page-switcher/page-switcher.stories.js +95 -0
  206. package/src/components/paragraph/paragraph.css.js +15 -10
  207. package/src/components/paragraph/paragraph.js +41 -44
  208. package/src/components/paragraph/paragraph.stories.js +56 -21
  209. package/src/components/picture/picture.css.js +103 -44
  210. package/src/components/picture/picture.js +100 -30
  211. package/src/components/picture/picture.stories.js +1 -1
  212. package/src/components/pricing-cards/new-pricing-card.js +3 -3
  213. package/src/components/pricing-cards/new-pricing.css.js +24 -15
  214. package/src/components/pricing-cards/pricing-card-actions.css.js +1 -1
  215. package/src/components/pricing-cards/pricing-card-actions.js +3 -4
  216. package/src/components/pricing-cards/pricing-card-container.css.js +3 -3
  217. package/src/components/pricing-cards/pricing-card-container.js +3 -2
  218. package/src/components/pricing-cards/pricing-card-header.css.js +10 -8
  219. package/src/components/pricing-cards/pricing-card-header.js +6 -7
  220. package/src/components/pricing-cards/pricing-card-pricing.css.js +16 -9
  221. package/src/components/pricing-cards/pricing-card-pricing.js +8 -6
  222. package/src/components/pricing-cards/pricing-card-show-more.css.js +11 -3
  223. package/src/components/pricing-cards/pricing-card-show-more.js +8 -3
  224. package/src/components/pricing-cards/pricing-card.css.js +244 -190
  225. package/src/components/pricing-cards/pricing-card.js +54 -70
  226. package/src/components/pricing-cards/pricing-feature-item.css.js +25 -8
  227. package/src/components/pricing-cards/pricing-feature-item.js +8 -3
  228. package/src/components/process-steps/process-steps.css.js +26 -14
  229. package/src/components/process-steps/process-steps.js +23 -6
  230. package/src/components/process-steps/process-steps.stories.js +1 -1
  231. package/src/components/progress/progress.css.js +80 -0
  232. package/src/components/progress/progress.js +42 -0
  233. package/src/components/progress/progress.stories.js +79 -0
  234. package/src/components/quick-answer/quick-answer.css.js +75 -13
  235. package/src/components/quick-answer/quick-answer.js +8 -3
  236. package/src/components/radio/radio.css.js +92 -128
  237. package/src/components/radio/radio.js +106 -127
  238. package/src/components/radio/radio.stories.js +54 -19
  239. package/src/components/review/review-grid.css.js +5 -6
  240. package/src/components/review/review-grid.js +14 -11
  241. package/src/components/review/review.css.js +11 -6
  242. package/src/components/review/review.js +3 -8
  243. package/src/components/shared/ensemble.css.js +93 -0
  244. package/src/components/shared/form-field.css.js +206 -0
  245. package/src/components/shared/prop-vocabulary.js +48 -0
  246. package/src/components/shared/token-scales.js +306 -0
  247. package/src/components/skeleton/skeleton.css.js +34 -0
  248. package/src/components/skeleton/skeleton.js +32 -0
  249. package/src/components/skeleton/skeleton.stories.js +70 -0
  250. package/src/components/spinner/spinner.css.js +62 -0
  251. package/src/components/spinner/spinner.js +38 -0
  252. package/src/components/spinner/spinner.stories.js +92 -0
  253. package/src/components/spotlight-section/spotlight-section.css.js +14 -13
  254. package/src/components/spotlight-section/spotlight-section.js +42 -39
  255. package/src/components/switch/switch.css.js +103 -0
  256. package/src/components/switch/switch.js +44 -0
  257. package/src/components/switch/switch.stories.js +86 -0
  258. package/src/components/tab-list/content-switcher.stories.js +189 -0
  259. package/src/components/tab-list/tab-list.css.js +250 -0
  260. package/src/components/tab-list/tab-list.js +184 -0
  261. package/src/components/tab-list/tab-list.stories.js +221 -0
  262. package/src/components/tabs/tabs.css.js +14 -123
  263. package/src/components/tabs/tabs.js +16 -19
  264. package/src/components/tabs/tabs.stories.js +95 -95
  265. package/src/components/tag/tag.css.js +54 -0
  266. package/src/components/tag/tag.js +32 -0
  267. package/src/components/tag/tag.stories.js +126 -0
  268. package/src/components/terminal/terminal-message.css.js +164 -60
  269. package/src/components/terminal/terminal-message.js +5 -5
  270. package/src/components/terminal/terminal-message.stories.js +70 -1
  271. package/src/components/termsOfUse/terms.css.js +25 -20
  272. package/src/components/termsOfUse/terms.js +13 -9
  273. package/src/components/termsOfUse/terms.stories.js +1 -1
  274. package/src/components/textarea/textarea.css.js +51 -0
  275. package/src/components/textarea/textarea.js +136 -0
  276. package/src/components/textarea/textarea.stories.js +191 -0
  277. package/src/components/tooltip/tooltip.css.js +75 -0
  278. package/src/components/tooltip/tooltip.js +156 -0
  279. package/src/components/tooltip/tooltip.stories.js +161 -0
  280. package/src/components/trust-panel/trust-panel.css.js +85 -33
  281. package/src/components/trust-panel/trust-panel.js +18 -10
  282. package/src/components/trust-panel/trust-panel.stories.js +1 -1
  283. package/src/components/ultimate-security-demo.stories.js +431 -0
  284. package/src/components/video/video.css.js +17 -0
  285. package/src/components/video/video.js +40 -0
  286. package/src/components/video/video.stories.js +106 -0
  287. package/src/components/video-player/video-player.css.js +67 -11
  288. package/src/components/video-player/video-player.js +35 -4
  289. package/src/components/video-player/video-player.stories.js +1 -1
  290. package/src/components/visual-alignment.stories.js +411 -0
  291. package/src/components/visually-hidden/visually-hidden.css.js +24 -0
  292. package/src/components/visually-hidden/visually-hidden.js +16 -0
  293. package/src/components/visually-hidden/visually-hidden.stories.js +56 -0
  294. package/src/stories/Button.stories.js +17 -17
  295. package/src/stories/Header.stories.js +10 -10
  296. package/src/stories/Page.js +1 -1
  297. package/src/stories/Page.stories.js +6 -6
  298. package/src/tokens/borders.stories.js +339 -0
  299. package/src/tokens/color-responsibilities.stories.js +227 -0
  300. package/src/tokens/dark-derivation.js +309 -0
  301. package/src/tokens/dark-findings.js +197 -0
  302. package/src/tokens/dark-mode.stories.js +207 -0
  303. package/src/tokens/dimension.stories.js +251 -0
  304. package/src/tokens/elevation.stories.js +143 -0
  305. package/src/tokens/focus.stories.js +276 -0
  306. package/src/tokens/fonts.css +20 -20
  307. package/src/tokens/fonts.stories.js +354 -374
  308. package/src/tokens/layout.css +25 -11
  309. package/src/tokens/motion.stories.js +231 -0
  310. package/src/tokens/shape.stories.js +272 -0
  311. package/src/tokens/spacing-rhythm.stories.js +359 -0
  312. package/src/tokens/spacing.stories.js +208 -703
  313. package/src/tokens/story-chrome.js +503 -0
  314. package/src/tokens/surfaces.stories.js +224 -0
  315. package/src/tokens/text-on-surface.stories.js +164 -0
  316. package/src/tokens/token-map.js +220 -0
  317. package/src/tokens/token-map.stories.js +257 -0
  318. package/src/tokens/token-purposes.js +266 -0
  319. package/src/tokens/tokens-grid.stories.js +388 -900
  320. package/src/tokens/tokens.css +1633 -1045
  321. package/src/tokens/tokens.js +1130 -492
  322. package/src/tokens/tokens.stories.js +137 -47
  323. package/src/tokens/type-ceiling-words.js +299 -0
  324. package/src/tokens/typography.stories.js +1003 -965
  325. package/src/tokens/vertical-rhythm.stories.js +224 -0
  326. package/src/components/awards/awards-icon.js +0 -56
  327. package/src/components/awards/awards-light.js +0 -240
  328. package/src/components/banner/banner.js +0 -60
  329. package/src/components/banner/banner.stories.js +0 -51
  330. package/src/components/button/icons.js +0 -24
  331. package/src/components/input/index.js +0 -0
  332. package/src/components/light-carousel/light-carousel-simple.css.js +0 -183
  333. package/src/components/light-carousel/light-carousel-simple.js +0 -74
  334. package/src/tokens/colors.js +0 -121
  335. package/src/tokens/typography.css.js +0 -207
  336. package/src/tokens/typography.js +0 -0
@@ -1,1011 +1,1049 @@
1
1
  import { html } from 'lit';
2
+ import { until } from 'lit/directives/until.js';
3
+ import { ceilingWords, sampledLanguages, unsampledLanguages } from './type-ceiling-words.js';
4
+ import {
5
+ copyToken,
6
+ discoverConsumers,
7
+ discoverTokens,
8
+ docsPage,
9
+ readAllCssText,
10
+ refSection,
11
+ referenceNote,
12
+ resolve,
13
+ toPx,
14
+ usageCell
15
+ } from './story-chrome.js';
2
16
 
3
17
  export default {
4
- title: 'Design Tokens/Typography',
5
- tags : ['autodocs'],
6
-
7
- argTypes: {
8
- sampleText: {
9
- control : 'text',
10
- description: 'Sample text to display typography examples',
11
- table : {
12
- type : { summary: 'string' },
13
- defaultValue: { summary: 'The quick brown fox jumps over the lazy dog.' }
14
- }
15
- },
16
- showLineHeight: {
17
- control : 'boolean',
18
- description: 'Show line height visualization',
19
- table : {
20
- type : { summary: 'boolean' },
21
- defaultValue: { summary: 'true' }
22
- }
23
- },
24
- showLetterSpacing: {
25
- control : 'boolean',
26
- description: 'Show letter spacing visualization',
27
- table : {
28
- type : { summary: 'boolean' },
29
- defaultValue: { summary: 'false' }
30
- }
31
- }
32
- },
33
-
34
- parameters: {
35
- docs: {
36
- description: {
37
- component: `
38
- # Typography System
39
-
40
- Complete typography system with font families, sizes, weights, and spacing tokens for consistent text styling across the application.
41
-
42
- ## Overview
43
-
44
- The typography system uses CSS custom properties to ensure consistency in font styling. It includes comprehensive scales for font sizes, weights, line heights, and letter spacing.
45
-
46
- ## Design Tokens
47
-
48
- ### Font Families
49
- | Token | Value | Use Case |
50
- |-------|-------|----------|
51
- | \`--font-family-sans\` | 'IBM Plex Sans', Arial, sans-serif | Primary UI font |
52
- | \`--font-family-mono\` | 'IBM Plex Mono', monospace | Code, technical content |
53
-
54
- ### Font Sizes (Modular Scale)
55
- | Token | Value (rem) | Value (px) | Use Case |
56
- |-------|-------------|------------|----------|
57
- | \`--font-size-xs\` | 0.75rem | 12px | Labels, captions |
58
- | \`--font-size-sm\` | 0.875rem | 14px | Small body text |
59
- | \`--font-size-md\` | 1rem | 16px | Base body text |
60
- | \`--font-size-lg\` | 1.25rem | 20px | Subheadings |
61
- | \`--font-size-xl\` | 1.5rem | 24px | Headings |
62
- | \`--font-size-2xl\` | 2rem | 32px | Large headings |
63
- | \`--font-size-3xl\` | 2.5rem | 40px | Page titles |
64
- | \`--font-size-4xl\` | 3rem | 48px | Hero text |
65
-
66
- ### Font Weights
67
- | Token | Value | Use Case |
68
- |-------|-------|----------|
69
- | \`--font-weight-light\` | 300 | Light text, subtle emphasis |
70
- | \`--font-weight-regular\` | 400 | Normal body text |
71
- | \`--font-weight-medium\` | 500 | Medium emphasis |
72
- | \`--font-weight-semibold\` | 600 | Strong emphasis, subheadings |
73
- | \`--font-weight-bold\` | 700 | Bold text, headings |
74
- | \`--font-weight-extrabold\` | 800 | Heavy emphasis, display text |
75
-
76
- ### Line Heights
77
- | Token | Value | Use Case |
78
- |-------|-------|----------|
79
- | \`--line-height-tight\` | 1.2 | Headings, single lines |
80
- | \`--line-height-normal\` | 1.5 | Body text, paragraphs |
81
- | \`--line-height-relaxed\` | 1.75 | Long-form content |
82
- | \`--line-height-loose\` | 2 | Poetry, special layouts |
83
-
84
- ### Letter Spacing
85
- | Token | Value | Use Case |
86
- |-------|-------|----------|
87
- | \`--letter-spacing-tighter\` | -0.05em | Condensed text |
88
- | \`--letter-spacing-tight\` | -0.025em | Slightly condensed |
89
- | \`--letter-spacing-normal\` | 0 | Normal text |
90
- | \`--letter-spacing-wide\` | 0.025em | Slightly expanded |
91
- | \`--letter-spacing-wider\` | 0.05em | Expanded text |
92
- | \`--letter-spacing-widest\` | 0.1em | Display text, headers |
93
-
94
- ## Usage
95
-
96
- ### Basic Typography
97
- \`\`\`css
98
- /* Using CSS custom properties */
99
- body {
100
- font-family: var(--font-family-sans);
101
- font-size: var(--font-size-md);
102
- line-height: var(--line-height-normal);
103
- font-weight: var(--font-weight-regular);
104
- }
105
-
106
- h1 {
107
- font-size: var(--font-size-3xl);
108
- font-weight: var(--font-weight-bold);
109
- line-height: var(--line-height-tight);
110
- }
111
-
112
- code {
113
- font-family: var(--font-family-mono);
114
- font-size: var(--font-size-sm);
115
- }
116
- \`\`\`
117
-
118
- ### Semantic CSS Classes
119
- \`\`\`html
120
- <p class="text-sm">Small text</p>
121
- <p class="text-lg font-semibold">Large semi-bold text</p>
122
- <p class="text-xl font-bold leading-tight">Extra large bold text with tight leading</p>
123
- <div class="font-mono text-sm">Monospace code text</div>
124
- \`\`\`
125
-
126
- ### Responsive Typography
127
- \`\`\`css
128
- .heading {
129
- font-size: var(--font-size-lg);
130
-
131
- @media (min-width: 768px) {
132
- font-size: var(--font-size-xl);
133
- }
134
-
135
- @media (min-width: 1024px) {
136
- font-size: var(--font-size-2xl);
137
- }
138
- }
139
- \`\`\`
140
-
141
- ## Complete CSS Custom Properties
142
-
143
- \`\`\`css
144
- :root {
145
- /* Font Families */
146
- --font-family-sans: 'IBM Plex Sans', Arial, sans-serif;
147
- --font-family-mono: 'IBM Plex Mono', monospace;
148
-
149
- /* Font Sizes */
150
- --font-size-xs: 0.75rem;
151
- --font-size-sm: 0.875rem;
152
- --font-size-md: 1rem;
153
- --font-size-lg: 1.25rem;
154
- --font-size-xl: 1.5rem;
155
- --font-size-2xl: 2rem;
156
- --font-size-3xl: 2.5rem;
157
- --font-size-4xl: 3rem;
158
-
159
- /* Font Weights */
160
- --font-weight-light: 300;
161
- --font-weight-regular: 400;
162
- --font-weight-medium: 500;
163
- --font-weight-semibold: 600;
164
- --font-weight-bold: 700;
165
- --font-weight-extrabold: 800;
166
-
167
- /* Line Heights */
168
- --line-height-tight: 1.2;
169
- --line-height-normal: 1.5;
170
- --line-height-relaxed: 1.75;
171
- --line-height-loose: 2;
172
-
173
- /* Letter Spacing */
174
- --letter-spacing-tighter: -0.05em;
175
- --letter-spacing-tight: -0.025em;
176
- --letter-spacing-normal: 0;
177
- --letter-spacing-wide: 0.025em;
178
- --letter-spacing-wider: 0.05em;
179
- --letter-spacing-widest: 0.1em;
180
-
181
- /* Text Colors */
182
- --text-color-primary: #212529;
183
- --text-color-secondary: #6c757d;
184
- --text-color-muted: #adb5bd;
185
- --text-color-on-dark: #ffffff;
186
- }
187
- \`\`\`
188
-
189
- ## Best Practices
190
-
191
- ### 1. Use Relative Units
192
- \`\`\`css
193
- /* Good ✅ - Uses rem for accessibility */
194
- font-size: var(--font-size-md);
195
-
196
- /* Bad ❌ - Fixed pixels ignore user preferences */
197
- font-size: 16px;
198
- \`\`\`
199
-
200
- ### 2. Maintain Hierarchy
201
- \`\`\`css
202
- h1 { font-size: var(--font-size-3xl); }
203
- h2 { font-size: var(--font-size-2xl); }
204
- h3 { font-size: var(--font-size-xl); }
205
- h4 { font-size: var(--font-size-lg); }
206
- h5 { font-size: var(--font-size-md); }
207
- h6 { font-size: var(--font-size-sm); }
208
- \`\`\`
209
-
210
- ### 3. Ensure Readability
211
- - Minimum 16px (1rem) for body text
212
- - Line length between 45-75 characters
213
- - Contrast ratio of at least 4.5:1 for normal text
214
- - Adequate line spacing (1.5× for body text)
215
-
216
- ### 4. Responsive Considerations
217
- - Increase font sizes on larger screens
218
- - Adjust line heights for different viewports
219
- - Consider touch targets for interactive text
220
-
221
- ## Accessibility
222
-
223
- ### Text Scaling
224
- All typography tokens use \`rem\` units, which respect the user's browser font size settings.
225
-
226
- ### Color Contrast
227
- Ensure text meets WCAG 2.1 contrast requirements:
228
- - Normal text: 4.5:1 minimum
229
- - Large text (18px+): 3:1 minimum
230
-
231
- ### Screen Readers
232
- - Use semantic HTML headings (h1-h6)
233
- - Provide alternative text for images
234
- - Ensure proper reading order
235
- - Test with screen readers
236
-
237
- ## Browser Support
238
-
239
- All typography features are supported in modern browsers:
240
- - Chrome 60+
241
- - Firefox 55+
242
- - Safari 11+
243
- - Edge 79+
244
-
245
- For variable fonts support (if using IBM Plex Variable):
246
- - Chrome 60+
247
- - Firefox 62+
248
- - Safari 11+
249
- - Edge 17+
250
-
251
- ## Performance
252
-
253
- - Use \`font-display: swap\` for web fonts
254
- - Subset fonts when possible
255
- - Consider system fonts as fallbacks
256
- - Implement font loading strategies
257
-
258
- ## Testing
259
-
260
- Test typography with:
261
- - Different screen sizes and resolutions
262
- - High contrast modes
263
- - Screen magnifiers
264
- - Various browser zoom levels
265
- - Different operating systems
266
- `
267
- }
268
- }
269
- }
270
- };
271
-
272
- const typographyTokens = {
273
- families: {
274
- 'Sans': 'var(--font-family-sans, "IBM Plex Sans", Arial, sans-serif)',
275
- 'Mono': 'var(--font-family-mono, "IBM Plex Mono", monospace)'
276
- },
277
- sizes: {
278
- 'xs' : 'var(--font-size-xs, 0.75rem)',
279
- 'sm' : 'var(--font-size-sm, 0.875rem)',
280
- 'md' : 'var(--font-size-md, 1rem)',
281
- 'lg' : 'var(--font-size-lg, 1.25rem)',
282
- 'xl' : 'var(--font-size-xl, 1.5rem)',
283
- '2xl': 'var(--font-size-2xl, 2rem)',
284
- '3xl': 'var(--font-size-3xl, 2.5rem)',
285
- '4xl': 'var(--font-size-4xl, 3rem)'
286
- },
287
- weights: {
288
- 'light' : 'var(--font-weight-light, 300)',
289
- 'regular' : 'var(--font-weight-regular, 400)',
290
- 'medium' : 'var(--font-weight-medium, 500)',
291
- 'semibold' : 'var(--font-weight-semibold, 600)',
292
- 'bold' : 'var(--font-weight-bold, 700)',
293
- 'extrabold': 'var(--font-weight-extrabold, 800)'
294
- },
295
- lineHeights: {
296
- 'tight' : 'var(--line-height-tight, 1.2)',
297
- 'normal' : 'var(--line-height-normal, 1.5)',
298
- 'relaxed': 'var(--line-height-relaxed, 1.75)',
299
- 'loose' : 'var(--line-height-loose, 2)'
300
- },
301
- letterSpacing: {
302
- 'tighter': 'var(--letter-spacing-tighter, -0.05em)',
303
- 'tight' : 'var(--letter-spacing-tight, -0.025em)',
304
- 'normal' : 'var(--letter-spacing-normal, 0)',
305
- 'wide' : 'var(--letter-spacing-wide, 0.025em)',
306
- 'wider' : 'var(--letter-spacing-wider, 0.05em)',
307
- 'widest' : 'var(--letter-spacing-widest, 0.1em)'
308
- }
18
+ title: 'Foundations/Typography',
19
+ tags : ['autodocs']
309
20
  };
310
21
 
311
- const Template = (args) => html`
22
+ const typeStyles = html`
312
23
  <style>
313
- .typography-container {
314
- max-width: 800px;
315
- margin: 0 auto;
316
- padding: var(--space-lg);
317
- background: white;
318
- border-radius: 8px;
319
- box-shadow: 0 2px 8px rgba(0,0,0,0.1);
24
+ .specimen-row {
25
+ padding: var(--spacing-16) 0;
26
+ border-bottom: var(--border-width-1) solid var(--color-neutral-100);
320
27
  }
321
-
322
- .section {
323
- margin-bottom: var(--space-2xl);
324
- padding-bottom: var(--space-lg);
325
- border-bottom: 2px solid #f0f0f0;
28
+
29
+ .specimen-row:last-child {
30
+ border-bottom: 0;
326
31
  }
327
-
328
- .section-title {
329
- font-family: var(--font-family-sans);
330
- font-size: var(--font-size-xl);
331
- font-weight: var(--font-weight-bold);
332
- margin: 0 0 var(--space-lg) 0;
333
- color: #212529;
32
+
33
+ .specimen-meta {
334
34
  display: flex;
335
- align-items: center;
336
- gap: var(--space-sm);
337
- }
338
-
339
- .section-title::before {
340
- content: '';
341
- width: 4px;
342
- height: 24px;
343
- background: #007bff;
344
- border-radius: 2px;
345
- }
346
-
347
- .token-grid {
348
- display: grid;
349
- grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
350
- gap: var(--space-md);
351
- }
352
-
353
- .token-card {
354
- padding: var(--space-md);
355
- background: #f8f9fa;
356
- border-radius: 6px;
357
- border: 1px solid #e9ecef;
358
- transition: all 0.2s ease;
35
+ flex-wrap: wrap;
36
+ gap: var(--spacing-4) var(--spacing-12);
37
+ margin-bottom: var(--spacing-8);
38
+ font-family: var(--typography-fontFamily-mono);
39
+ font-size: var(--typography-fontSize-xxs);
40
+ color: var(--color-neutral-500);
359
41
  }
360
-
361
- .token-card:hover {
362
- transform: translateY(-2px);
363
- box-shadow: 0 4px 12px rgba(0,0,0,0.1);
364
- border-color: #007bff;
42
+
43
+ .specimen-meta .name {
44
+ color: var(--color-neutral-900);
45
+ font-weight: var(--typography-fontWeight-semibold);
365
46
  }
366
-
367
- .token-header {
368
- display: flex;
369
- justify-content: space-between;
370
- align-items: flex-start;
371
- margin-bottom: var(--space-sm);
47
+
48
+ /* Sample text is deliberately unstyled apart from the tokens under test. */
49
+ .specimen-text {
50
+ margin: 0;
51
+ color: var(--color-neutral-900);
52
+ /* long specimens should not force the page wide */
53
+ overflow-wrap: anywhere;
372
54
  }
373
-
374
- .token-name {
375
- font-family: var(--font-family-mono);
376
- font-size: var(--font-size-sm);
377
- color: #007bff;
378
- font-weight: var(--font-weight-semibold);
379
- background: white;
380
- padding: 2px 8px;
381
- border-radius: 4px;
382
- border: 1px solid #dee2e6;
55
+
56
+ .sample-cell {
57
+ color: var(--color-neutral-900);
58
+ white-space: nowrap;
383
59
  }
384
-
385
- .token-value {
386
- font-family: var(--font-family-mono);
387
- font-size: var(--font-size-xs);
388
- color: #6c757d;
389
- background: white;
390
- padding: 2px 6px;
391
- border-radius: 3px;
392
- border: 1px solid #dee2e6;
60
+
61
+ /* Leading cannot be judged on one line, so this sample is deliberately
62
+ narrow enough to wrap. */
63
+ .leading-sample {
64
+ margin: 0;
65
+ width: 30ch;
66
+ min-width: 22ch;
67
+ font-size: var(--typography-fontSize-sm);
68
+ color: var(--color-neutral-900);
69
+ background: var(--color-blue-50);
393
70
  }
394
-
395
- .token-preview {
396
- font-family: inherit;
397
- font-size: inherit;
398
- font-weight: inherit;
399
- line-height: inherit;
400
- letter-spacing: inherit;
401
- margin: var(--space-sm) 0;
402
- padding: var(--space-sm);
403
- background: white;
404
- border-radius: 4px;
405
- border: 1px solid #dee2e6;
406
- min-height: 60px;
71
+
72
+ .ratio-demo {
407
73
  display: flex;
408
- align-items: center;
409
- }
410
-
411
- .line-height-visual {
412
- height: 40px;
413
- background: linear-gradient(to bottom,
414
- transparent 0%,
415
- transparent calc(50% - 0.6px),
416
- #007bff calc(50% - 0.6px),
417
- #007bff calc(50% + 0.6px),
418
- transparent calc(50% + 0.6px),
419
- transparent 100%
420
- );
421
- margin-top: var(--space-xs);
422
- position: relative;
74
+ flex-wrap: wrap;
75
+ gap: var(--spacing-24);
423
76
  }
424
-
425
- .line-height-label {
426
- position: absolute;
427
- right: 0;
428
- top: 50%;
429
- transform: translateY(-50%);
430
- font-family: var(--font-family-mono);
431
- font-size: var(--font-size-xs);
432
- color: #6c757d;
433
- background: white;
434
- padding: 2px 6px;
435
- border-radius: 3px;
436
- border: 1px solid #dee2e6;
77
+
78
+ .ratio-demo figure {
79
+ margin: 0;
80
+ flex: 1 1 200px;
81
+ min-width: 180px;
437
82
  }
438
-
439
- .letter-spacing-visual {
440
- display: flex;
441
- align-items: center;
442
- gap: 2px;
443
- margin-top: var(--space-xs);
444
- padding: var(--space-xs);
445
- background: white;
446
- border-radius: 4px;
447
- border: 1px solid #dee2e6;
83
+
84
+ .ratio-demo figcaption {
85
+ font-family: var(--typography-fontFamily-mono);
86
+ font-size: var(--typography-fontSize-xxs);
87
+ color: var(--color-neutral-600);
88
+ margin-bottom: var(--spacing-6);
448
89
  }
449
-
450
- .letter-box {
451
- width: 12px;
452
- height: 20px;
453
- background: #007bff;
454
- border-radius: 2px;
455
- opacity: 0.7;
90
+
91
+ .ratio-demo p {
92
+ margin: 0;
93
+ background: var(--color-blue-50);
94
+ color: var(--color-neutral-900);
456
95
  }
457
-
458
- .info-panel {
459
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
460
- color: white;
461
- padding: var(--space-lg);
462
- border-radius: 8px;
463
- margin-bottom: var(--space-xl);
96
+
97
+ .rule-list {
98
+ margin: 0;
99
+ padding-left: var(--spacing-20);
100
+ font-size: var(--typography-fontSize-xs);
101
+ line-height: var(--typography-lineHeight-normal);
102
+ color: var(--color-neutral-700);
464
103
  }
465
-
466
- .info-title {
467
- font-family: var(--font-family-sans);
468
- font-size: var(--font-size-lg);
469
- font-weight: var(--font-weight-bold);
470
- margin: 0 0 var(--space-sm) 0;
104
+
105
+ .rule-list li + li {
106
+ margin-top: var(--spacing-6);
471
107
  }
472
-
473
- .info-text {
474
- font-family: var(--font-family-sans);
475
- font-size: var(--font-size-sm);
476
- opacity: 0.9;
477
- margin: 0;
108
+
109
+ .rule-list code {
110
+ font-family: var(--typography-fontFamily-mono);
478
111
  }
479
112
  </style>
113
+ `;
114
+
115
+ /* ---------- primitive scales ---------- */
480
116
 
481
- <div class="typography-container">
482
- <div class="info-panel">
483
- <h3 class="info-title">Typography System</h3>
484
- <p class="info-text">${args.sampleText}</p>
117
+ function primitiveTable({ caption, tokens, consumers, sample, leading }) {
118
+ return html`
119
+ <div class="table-scroll">
120
+ <table>
121
+ <caption>${caption}</caption>
122
+ <thead>
123
+ <tr>
124
+ <th scope="col">Token</th>
125
+ <th scope="col">Value</th>
126
+ ${leading ? html`<th scope="col">At 16px</th>` : ''}
127
+ ${sample ? html`<th scope="col">Sample</th>` : ''}
128
+ ${leading ? html`<th scope="col">Leading</th>` : ''}
129
+ <th scope="col">Referenced by</th>
130
+ </tr>
131
+ </thead>
132
+ <tbody>
133
+ ${tokens.map(token => {
134
+ const value = resolve(token);
135
+ // a rem step is hard to place without the pixel value beside it
136
+ const px = toPx(value);
137
+ return html`
138
+ <tr>
139
+ <td>${copyToken(token)}</td>
140
+ <td class="num">
141
+ ${value}${px ? html`<span class="px-hint">${px}</span>` : ''}
142
+ </td>
143
+ ${leading
144
+ ? html`<td class="num muted">${(parseFloat(value) * 16).toFixed(parseFloat(value) * 16 % 1 ? 2 : 0)}px</td>`
145
+ : ''}
146
+ ${sample
147
+ ? html`<td class="sample-cell" style=${sample(token)}>Aa Bb Cc 123</td>`
148
+ : ''}
149
+ ${leading
150
+ ? html`<td>
151
+ <p class="leading-sample" style="line-height: var(${token})">
152
+ Leading only becomes visible across more than one line, so this
153
+ sample is deliberately narrow enough to wrap onto three.
154
+ </p>
155
+ </td>`
156
+ : ''}
157
+ <td>${usageCell(consumers.get(token))}</td>
158
+ </tr>
159
+ `;
160
+ })}
161
+ </tbody>
162
+ </table>
485
163
  </div>
164
+ `;
165
+ }
166
+
167
+ function tokensMatching(cssText, pattern) {
168
+ return discoverTokens(cssText, pattern).map(match => match[1]);
169
+ }
170
+
171
+ /* ---------------------------------------------------------------------------
172
+ Nine roles are fluid. This page renders them live at the current viewport
173
+ AND states what they resolve to at each of the seven column widths, so the
174
+ numbers can be checked against what the browser is actually doing rather
175
+ than against a table somebody typed.
176
+
177
+ The column widths are derived from the grid tokens, not measured: a
178
+ Storybook story sits inside 16px of harness body padding either side, and
179
+ measuring one is how a 288px column once got recorded as 256 and moved a
180
+ conclusion by a whole step of the type scale. */
181
+ const FLUID = [
182
+ ['heading-h1', 48, 30, 'Bold'],
183
+ ['heading-h2', 36, 27, 'Bold'],
184
+ ['heading-h3', 32, 24, 'SemiBold'],
185
+ ['heading-h4', 24, 21, 'SemiBold'],
186
+ ['heading-h5', 20, 18, 'Medium'],
187
+ ['heading-h6', 18, 16, 'Medium'],
188
+ ['display-md', 48, 30, 'Bold'],
189
+ ['display-sm', 36, 30, 'Bold'],
190
+ ['body-lead', 20, 18, 'Regular']
191
+ ];
192
+ /* viewport -> column, from --grid-container-max-* minus 2x --grid-padding-* */
193
+ const COLUMNS = [[320, 288], [375, 343], [576, 492], [768, 656], [992, 880], [1200, 1060], [1400, 1240]];
194
+ const WINDOW = [320, 992];
195
+
196
+ /** What the clamp resolves to at a given viewport width, in px. */
197
+ const fluidAt = (vw, min, max) => {
198
+ const [v1, v2] = WINDOW;
199
+ if (vw <= v1) return min;
200
+ if (vw >= v2) return max;
201
+ return min + ((max - min) * (vw - v1)) / (v2 - v1);
202
+ };
203
+
204
+ /* ---------------------------------------------------------------------------
205
+ The evidence the mobile ladder was chosen from, rendered LIVE rather than
206
+ transcribed from the Figma study.
207
+
208
+ Live matters here: it measures the real IBM Plex Sans in the reader's own
209
+ browser at the reader's own viewport, so it cannot drift from the file the
210
+ way a pasted table can, and it re-measures when you resize. The Figma page
211
+ "Type on mobile" holds the same study as a fixed artifact for designers;
212
+ this is the developer's copy, and if the two ever disagree the browser wins.
213
+
214
+ The words are representative compounds written for the study, NOT strings
215
+ from the real translation memory -- English is roughly twice as permissive
216
+ as German and Finnish, so English alone would flatter every number here. */
217
+ /* From src/tokens/type-ceiling-words.js, which carries the provenance of every
218
+ entry -- `tm` for a real translation-memory string, `representative` for a
219
+ plausible compound written for the study, `outlier` for a deliberately
220
+ extreme one that is shown but never sets a ceiling. Entries marked `retired`
221
+ are excluded from the worst case: Finnish does not ship, and the three fi
222
+ words are kept only because the shipped ladder was calibrated against the
223
+ widest of them. */
224
+ const CEILING_WORDS = ceilingWords.words
225
+ .filter(w => !w.retired)
226
+ .map(w => [w.lang, w.word, w.source, w.observedAt || 0]);
227
+ const RETIRED_WORDS = ceilingWords.words
228
+ .filter(w => w.retired)
229
+ .map(w => [w.lang, w.word, w.source, 0]);
486
230
 
487
- <!-- Font Families -->
488
- <div class="section">
489
- <h3 class="section-title">Font Families</h3>
490
- <div class="token-grid">
491
- ${Object.entries(typographyTokens.families).map(([name, family]) => html`
492
- <div class="token-card">
493
- <div class="token-header">
494
- <span class="token-name">--font-family-${name.toLowerCase()}</span>
495
- <span class="token-value">${family}</span>
231
+ /* The face every measurement on this page depends on. Spelled once, because
232
+ it is passed to fonts.load(), asserted with fonts.check(), and named in the
233
+ banner the story shows when the assertion fails. */
234
+ const PLEX_BOLD = '700 100px "IBM Plex Sans"';
235
+
236
+ /* Set by widthPer100 whenever it is asked to measure before the face is
237
+ available. It must stay false; the story renders a red banner if it does not,
238
+ because the alternative is a page of numbers that are quietly ~3% wrong. */
239
+ let measuredWithoutPlex = false;
240
+
241
+ /** Width of `text` at 100px in `weight`, measured in this browser. */
242
+ function widthPer100(text, weight) {
243
+ /* THE BUG THIS GUARDS, measured 2026-09-17. Before the await was added below,
244
+ this page rendered its tables at whatever font was available when the story
245
+ function ran -- which on a cold load is system-ui, not Plex. The same word
246
+ measured 9.045 px per px of size in Plex Bold and 8.778 in system-ui Bold,
247
+ so every number on the page came out about 3% low, INTERMITTENTLY, and the
248
+ ranking itself could change: `worst` is chosen by width, so a fallback
249
+ render can hand the title to a different word.
250
+ That 3% is the whole of the "Figma renders 3.5% wider than the browser"
251
+ discrepancy this study recorded and then reasoned from. There is no
252
+ renderer difference. There was a font race.
253
+ scripts/verify-runtime.mjs has carried the same lesson since P1.5 -- and
254
+ its own header says why it recurred: the await had been written
255
+ per-investigation. This was another investigation. */
256
+ if (!document.fonts.check(PLEX_BOLD)) measuredWithoutPlex = true;
257
+ const el = document.createElement('span');
258
+ el.style.cssText = 'position:absolute;visibility:hidden;white-space:nowrap;'
259
+ + 'font-family:var(--typography-fontFamily-sans);letter-spacing:0;font-size:100px;'
260
+ + `font-weight:${weight}`;
261
+ el.textContent = text;
262
+ document.body.appendChild(el);
263
+ const w = el.getBoundingClientRect().width / 100;
264
+ el.remove();
265
+ return w;
266
+ }
267
+
268
+ const whyTheseSizes = async () => {
269
+ /* Nothing below may run before the face lands -- see widthPer100. load()
270
+ rather than ready(), because ready() resolves when nothing more is
271
+ PENDING, and a face nobody has asked for yet is not pending. */
272
+ await document.fonts.load(PLEX_BOLD);
273
+ await document.fonts.ready;
274
+ /* measured on demand, so a font swap or a metric change shows up here */
275
+ const measure = ([lang, word, source, observedAt]) => {
276
+ const per = widthPer100(word, 700);
277
+ return {
278
+ lang, word, source, observedAt, per,
279
+ tier : observedAt >= 40 ? 'headline' : 'feature',
280
+ ceilings: COLUMNS.map(([, col]) => Math.floor(col / per))
281
+ };
282
+ };
283
+ const rows = CEILING_WORDS.map(measure);
284
+ const retired = RETIRED_WORDS.map(measure);
285
+ /* An `outlier` is shown and never sets a ceiling -- that is what the source
286
+ vocabulary is for, rather than excluding a word by name. */
287
+ const worst = rows.filter(r => r.source !== 'outlier')
288
+ .reduce((a, r) => (r.per > a.per ? r : a));
289
+ const worstRetired = retired.length
290
+ ? retired.reduce((a, r) => (r.per > a.per ? r : a))
291
+ : null;
292
+ /* The tier split is the whole point of the 2026-09-17 harvest: the widest
293
+ tokens are real, and they are FEATURE copy. `worst` is the widest token in
294
+ any tier -- the conservative ceiling. `headlineWorst` is the widest token
295
+ actually observed playing the part heading-h1 plays, which is the one that
296
+ decides whether h1's mobile size is wrong or merely tight. */
297
+ const headline = rows.filter(r => r.tier === 'headline' && r.source !== 'outlier');
298
+ const headlineWorst = headline.length
299
+ ? headline.reduce((a, r) => (r.per > a.per ? r : a))
300
+ : null;
301
+ /* Per role, against both worsts, on the two phone columns. */
302
+ const exposure = FLUID.map(([name, , min]) => ({
303
+ name,
304
+ min,
305
+ all : [288, 343].map(col => Math.round(worst.per * min) <= col),
306
+ headline: headlineWorst
307
+ ? [288, 343].map(col => Math.round(headlineWorst.per * min) <= col)
308
+ : [true, true],
309
+ needAll : Math.round(worst.per * min),
310
+ needHeadline: headlineWorst ? Math.round(headlineWorst.per * min) : 0
311
+ }));
312
+ const overAnywhere = exposure.filter(e => !e.all[0] || !e.all[1]);
313
+
314
+ return html`
315
+ ${typeStyles}
316
+ ${docsPage({
317
+ title: 'Why these sizes',
318
+ lede : html`The measurement the mobile ladder was derived from, run live in your browser.
319
+ For large type the binding constraint is not how many lines a sentence takes — it is
320
+ whether the longest <b>unbreakable word</b> fits the column. A word wider than its column
321
+ overflows or hyphenates, and neither is a size anyone chose.`,
322
+ note: html`<b>These are real words now.</b> Every entry was harvested on 2026-09-17 from
323
+ live localized <code>bitdefender.com</code> copy rendering at 24px or more — nine
324
+ languages, two product pages each — and carries the page it came from and the size that
325
+ page sets it at. The invented compounds this study started with are gone, and so is the
326
+ Finnish word the shipped ladder was actually calibrated against: Finnish does not sell.
327
+ The widest English token in the sample is 31% narrower than the widest Swedish one —
328
+ a 41px ceiling against 28 on the same column — so an English-only check does not merely
329
+ pass one size that overflows elsewhere, it passes every size on the ladder.
330
+ ${referenceNote}`,
331
+ children: html`
332
+ ${measuredWithoutPlex
333
+ ? html`<p style="border: 2px solid var(--color-text-error); padding: var(--spacing-16);
334
+ color: var(--color-text-error)">
335
+ <b>These numbers are wrong — do not read them.</b> At least one measurement ran before
336
+ <code>${PLEX_BOLD}</code> had a loaded face, so it measured a fallback font, which is
337
+ about 3% narrower. Reload. This banner exists because the page used to do this silently.
338
+ </p>`
339
+ : ''}
340
+ <p>
341
+ Word width scales linearly with font size, so one measurement per word gives the
342
+ ceiling at every size: <b>ceiling = column ÷ (width per px of size)</b>. The widest
343
+ real token in the sample is <b>${worst.word}</b> (${worst.lang},
344
+ ${(worst.per * 100).toFixed(0)}px per 100px), which puts the ceiling on a 288px column
345
+ at <b>${Math.floor(288 / worst.per)}px</b> — below <code>heading-h1</code>'s mobile
346
+ 30. ${headlineWorst
347
+ ? html`But it is <b>feature copy</b>, set at ${worst.observedAt}px on its own page. The
348
+ widest token observed doing <code>heading-h1</code>'s job is
349
+ <b>${headlineWorst.word}</b> (${headlineWorst.lang},
350
+ ${(headlineWorst.per * 100).toFixed(0)}px per 100px), whose ceiling is
351
+ <b>${Math.floor(288 / headlineWorst.per)}px</b> — above 30, by one.`
352
+ : ''}
353
+ </p>
354
+ <p>
355
+ <b>That gap is the finding, and it is why the ladder did not move.</b> Both numbers are
356
+ real. Which one binds depends on whether a twenty-character compound from a feature
357
+ list can ever be promoted into a headline — a copy question, not a token question.
358
+ </p>
359
+ ${refSection({
360
+ name : 'Language coverage — complete, for every language this model applies to',
361
+ meta : `${sampledLanguages.length} of ${ceilingWords.shippingLanguages.languages.length} shipping languages sampled from production copy · the other ${Object.keys(ceilingWords.wrappingCaveats).length} wrap per character`,
362
+ children: html`
363
+ <p>
364
+ <b>Sampled, from shipped copy:</b>
365
+ ${sampledLanguages.map(l => html`<code>${l}</code> `)} — all nine of the
366
+ eleven shipping languages that wrap on spaces, the other two being
367
+ ${Object.keys(ceilingWords.wrappingCaveats).map(l => html`<code>${l}</code> `)}
368
+ below —
369
+ ${rows.length} tokens, harvested ${ceilingWords.harvest.date} from
370
+ <code>/consumer/total-security</code> and
371
+ <code>/consumer/ultimate-security</code> in each market.
372
+ </p>
373
+ <p>
374
+ <b>The ladder that shipped was calibrated against a language this product does not
375
+ sell in.</b> Its worst case was Finnish <i>Henkilöllisyyssuoja</i>${worstRetired
376
+ ? html` (${(worstRetired.per * 100).toFixed(0)}px per 100px)`
377
+ : ''}, and there is no <code>fi-FI</code> locale in <code>localeMap.js</code>.
378
+ The three Finnish words stay in the sample file marked <code>retired</code> —
379
+ deleting them would erase the record of what the shipped ladder was sized against —
380
+ and they set no number on this page. <b>The production harvest has since found
381
+ three real tokens wider than that Finnish one</b>, in Swedish, Dutch and German.
382
+ </p>
383
+ <p>
384
+ <b>Not sampled: ${unsampledLanguages.length
385
+ ? html`${unsampledLanguages.map(l => html`<code>${l}</code> `)}`
386
+ : html`nothing`}.</b> Coverage is complete for every shipping language this
387
+ model applies to, and the last gap closed by turning out not to be one.
388
+ Hungarian was listed here as an untested compounding language because no
389
+ Hungarian copy could be found. There is none: <b>Hungary is an English
390
+ market</b>, served at <code>/en-hu/</code>.
391
+ </p>
392
+ <p>
393
+ Finding that took a defect with it. The footer had the locale as
394
+ <code>hu-HU</code>, and <code>footer.js</code> navigates to
395
+ <code>/{locale}/</code> on country click — so picking Hungary sent the user
396
+ to <code>/hu-hu/</code>, which is rewritten to <code>/en-us/hu-hu/</code>
397
+ and returns 404. It was the only one of the 46 roots that did not resolve.
398
+ <code>localeMap.js</code> says <code>en-HU</code> — <i>Hungary -
399
+ English</i> — now, and all 46 resolve. Hungarian is no longer claimed as a
400
+ language this product sells in, which is why it is no longer counted as
401
+ unsampled.
402
+ </p>
403
+ <p>
404
+ <b>Not applicable:</b>
405
+ ${Object.keys(ceilingWords.wrappingCaveats).map(l => html`<code>${l}</code> `)} —
406
+ they wrap between characters, so the longest-word constraint barely applies. The
407
+ relevant question there is characters per line at roughly one em per glyph, which
408
+ this study still does not model.
409
+ </p>
410
+ <p>
411
+ <b>What changed, and what did not.</b> The margin is measured now instead of
412
+ unknown. The numbers did not move the ladder: they moved the worst case
413
+ <i>up</i> in the feature tier and confirmed it in the headline tier, and those two
414
+ results argue in opposite directions — see the next section.
415
+ </p>
416
+ `
417
+ })}
418
+ ${refSection({
419
+ name: 'Which role is actually exposed',
420
+ meta: overAnywhere.length
421
+ ? `${overAnywhere.length} of ${exposure.length} roles overflow against the widest real token — all three at the same 30px floor`
422
+ : 'no role overflows against the widest real token',
423
+ children: html`
424
+ <p>
425
+ The ladder is nine fluid roles, and only the mobile end of each can overflow. Each
426
+ row asks the same question twice: does the role's <b>320px size</b> fit the column
427
+ when set in the widest real token of any tier, and when set in the widest token
428
+ observed doing headline work?
429
+ </p>
430
+ <div style="overflow-x: auto">
431
+ <table class="tokens-table">
432
+ <caption>
433
+ Fits at the role's 320px size · any-tier worst is
434
+ <b>${worst.word}</b> (${worst.lang})${headlineWorst
435
+ ? html` · headline-tier worst is <b>${headlineWorst.word}</b> (${headlineWorst.lang})`
436
+ : ''}
437
+ </caption>
438
+ <thead>
439
+ <tr>
440
+ <th>role</th><th>size at 320</th>
441
+ <th>any tier<br /><small>288px col</small></th>
442
+ <th>any tier<br /><small>343px col</small></th>
443
+ <th>headline tier<br /><small>288px col</small></th>
444
+ <th>headline tier<br /><small>343px col</small></th>
445
+ </tr>
446
+ </thead>
447
+ <tbody>
448
+ ${exposure.map(e => html`
449
+ <tr>
450
+ <td><code>${e.name}</code></td>
451
+ <td>${e.min}px</td>
452
+ <td style="color: ${e.all[0] ? 'var(--color-text-muted)' : 'var(--color-text-error)'}">
453
+ ${e.needAll}px — ${e.all[0] ? 'fits' : 'OVER'}
454
+ </td>
455
+ <td style="color: ${e.all[1] ? 'var(--color-text-muted)' : 'var(--color-text-error)'}">
456
+ ${e.needAll}px — ${e.all[1] ? 'fits' : 'OVER'}
457
+ </td>
458
+ <td style="color: ${e.headline[0] ? 'var(--color-text-muted)' : 'var(--color-text-error)'}">
459
+ ${e.needHeadline}px — ${e.headline[0] ? 'fits' : 'OVER'}
460
+ </td>
461
+ <td style="color: ${e.headline[1] ? 'var(--color-text-muted)' : 'var(--color-text-error)'}">
462
+ ${e.needHeadline}px — ${e.headline[1] ? 'fits' : 'OVER'}
463
+ </td>
464
+ </tr>
465
+ `)}
466
+ </tbody>
467
+ </table>
496
468
  </div>
497
- <div class="token-preview" style="font-family: ${family};">
498
- ${args.sampleText}
469
+ <p style="margin-top: var(--spacing-16)">
470
+ <b>The exposure is one size, on one tier, at one viewport.</b> Everything at
471
+ 27px and below fits every real token in the sample at both phone columns. The
472
+ 30px floor is shared by three roles — <code>heading-h1</code>,
473
+ <code>display-md</code> and <code>display-sm</code> — and all three clear the
474
+ headline-tier worst and fail the feature-tier worst, on the 288px column only,
475
+ because 343px absorbs even the widest token in the set.
476
+ </p>
477
+ <p>
478
+ <b>So the ladder stays at 30, and now for a reason rather than an artefact.</b>
479
+ Dropping the floor to ${Math.floor(288 / worst.per)} would buy protection against a
480
+ headline nobody has written — no twenty-character compound appears above 24px in
481
+ any of the nine markets — and would spend a real step of hierarchy to do it, on the
482
+ one role whose whole job is to outrank the rest. What the measurement buys instead
483
+ is a copy constraint that can be stated: <b>a headline token wider than
484
+ ${(288 / 30 * 100).toFixed(0)}px per 100px of size overflows a 320px phone</b>, and
485
+ five real tokens in <code>sv</code>, <code>nl</code> and <code>de</code> exceed it.
486
+ None of them is a headline today.
487
+ </p>
488
+ `
489
+ })}
490
+ ${refSection({
491
+ name : 'The ceiling, per word and per column',
492
+ meta : `${rows.length} words · Bold · measured in this browser`,
493
+ children: html`
494
+ <div style="overflow-x: auto">
495
+ <table class="tokens-table">
496
+ <caption>Largest font size at which the word still fits the column</caption>
497
+ <thead>
498
+ <tr>
499
+ <th>lang</th><th>word</th><th>source</th><th>seen at</th><th>w / 100px</th>
500
+ ${COLUMNS.map(([vw, col]) => html`<th>${vw}<br /><small>${col}px</small></th>`)}
501
+ </tr>
502
+ </thead>
503
+ <tbody>
504
+ ${rows.map(r => html`
505
+ <tr>
506
+ <td><code>${r.lang}</code></td>
507
+ <td>${r.word}</td>
508
+ <td><code>${r.source}</code></td>
509
+ <td>${r.observedAt ? html`${r.observedAt}px · ${r.tier}` : '—'}</td>
510
+ <td>${(r.per * 100).toFixed(0)}</td>
511
+ ${r.ceilings.map(c => html`<td>${c}</td>`)}
512
+ </tr>
513
+ `)}
514
+ </tbody>
515
+ </table>
499
516
  </div>
500
- </div>
501
- `)}
502
- </div>
503
- </div>
504
-
505
- <!-- Font Sizes -->
506
- <div class="section">
507
- <h3 class="section-title">Font Sizes</h3>
508
- <div class="token-grid">
509
- ${Object.entries(typographyTokens.sizes).map(([name, size]) => html`
510
- <div class="token-card">
511
- <div class="token-header">
512
- <span class="token-name">--font-size-${name}</span>
513
- <span class="token-value">${size}</span>
517
+ `
518
+ })}
519
+ ${refSection({
520
+ name : 'The same thing, visible',
521
+ meta : 'the dashed edge is the column — red type crosses it',
522
+ children: html`
523
+ <p style="margin-bottom: var(--spacing-16)">
524
+ <b>The mobile ladder's own steps</b> — 16 / 18 / 21 / 24 / 27 / 30 — plus the two
525
+ desktop sizes above them, set in <b>${worst.word}</b> inside a 288px column, which
526
+ is what type gets on a 320px phone. Read down: the first size that crosses the
527
+ dashed edge is where this word stops fitting, and it lands exactly on the ladder's
528
+ top step.
529
+ </p>
530
+ <p style="margin-bottom: var(--spacing-16)">
531
+ <b>Correction, 2026-09-17: Figma and this browser agree to within 0.05%, and the
532
+ 3.5% margin this study thought it had was a font race in this very page.</b> The
533
+ Figma study on page <i>Type on mobile</i> records the Finnish compound at
534
+ <b>907px per 100px</b>. Measured here in Plex Bold it is
535
+ ${worstRetired ? html`<b>${(worstRetired.per * 100).toFixed(1)}</b>` : ''} — the
536
+ same number, and it is not one lucky word: <b>seven were then measured in both
537
+ tools</b> — 1017/1016.2, 1015/1014.8, 1011/1010.5, 905/904.5, 792/791.4,
538
+ 907/906.6, 844/843.5 — agreeing to within 0.1% every time. What reproduces the 875
539
+ that was written down as "the browser" is <b>the same word in system-ui Bold:
540
+ 874.1</b>, because this page measured before the Plex face had loaded and silently
541
+ read the fallback. Same cause, same 3%, and
542
+ it could also change <i>which</i> word won the worst-case title, since the ranking
543
+ is by width. The measurement now waits for the face and says so if it ever cannot.
544
+ The rule that came out of the discrepancy survives, because it never depended on
545
+ the number: where the two disagree about whether something <i>renders</i>, the
546
+ browser is right; where they disagree about what to <i>ship</i>, the conservative
547
+ number is. But there was no renderer margin, and the ladder must not be credited
548
+ with one. The word on screen here is <b>${worst.word}</b> at
549
+ ${(worst.per * 100).toFixed(0)}px per 100px, production copy the Figma study never
550
+ saw.
551
+ </p>
552
+ <div style="width: 288px; max-width: 100%; border: 2px dashed var(--color-text-error);
553
+ padding-block: var(--spacing-16); display: grid; gap: var(--spacing-16)">
554
+ ${[16, 18, 21, 24, 27, 30, 36, 48].map(size => {
555
+ const need = Math.round(worst.per * size);
556
+ const fits = need <= 288;
557
+ return html`
558
+ <div>
559
+ <div style="font-family: var(--typography-fontFamily-mono);
560
+ font-size: var(--typography-fontSize-xxs);
561
+ color: ${fits ? 'var(--color-text-muted)' : 'var(--color-text-error)'}">
562
+ ${size}px · needs ${need}px · ${fits ? `fits by ${288 - need}` : `OVER by ${need - 288}`}
563
+ </div>
564
+ <div style="font-size: ${size}px; font-weight: var(--typography-fontWeight-bold);
565
+ line-height: var(--typography-lineHeight-tight);
566
+ white-space: nowrap;
567
+ color: ${fits ? 'var(--color-text-primary)' : 'var(--color-text-error)'}">
568
+ ${worst.word}
569
+ </div>
570
+ </div>
571
+ `;
572
+ })}
514
573
  </div>
515
- <div class="token-preview" style="font-size: ${size};">
516
- ${args.sampleText}
574
+ `
575
+ })}
576
+ ${refSection({
577
+ name : 'And what the roles do about it',
578
+ meta : 'the same word, at each fluid role, live at your viewport',
579
+ children: html`
580
+ <p style="margin-bottom: var(--spacing-16)">
581
+ Resize the window: every line below changes size, and none of them crosses its
582
+ column at any width. Compare with the block above, where the sizes are fixed.
583
+ </p>
584
+ <div style="display: grid; gap: var(--spacing-16)">
585
+ ${FLUID.map(([name, max, min]) => html`
586
+ <div>
587
+ <div style="font-family: var(--typography-fontFamily-mono);
588
+ font-size: var(--typography-fontSize-xxs);
589
+ color: var(--color-text-muted)">
590
+ ${name} · ${min}px at 320 → ${max}px at 992
591
+ </div>
592
+ <div style="font-size: var(--typography-${name}-fontSize);
593
+ font-weight: var(--typography-${name}-fontWeight);
594
+ line-height: var(--typography-${name}-lineHeight);
595
+ color: var(--color-text-primary)">${worst.word}</div>
596
+ </div>
597
+ `)}
517
598
  </div>
518
- ${args.showLineHeight
519
- ? html`
520
- <div class="line-height-visual" style="height: calc(${size} * var(--line-height-normal));">
521
- <span class="line-height-label">Line height: ${size}</span>
522
- </div>
523
- `
524
- : ''}
525
- </div>
526
- `)}
527
- </div>
528
- </div>
599
+ `
600
+ })}
601
+ `
602
+ })}
603
+ `;
604
+ };
529
605
 
530
- <!-- Font Weights -->
531
- <div class="section">
532
- <h3 class="section-title">Font Weights</h3>
533
- <div class="token-grid">
534
- ${Object.entries(typographyTokens.weights).map(([name, weight]) => html`
535
- <div class="token-card">
536
- <div class="token-header">
537
- <span class="token-name">--font-weight-${name}</span>
538
- <span class="token-value">${weight}</span>
606
+ /* The measurement is async now, so the story resolves a promise rather than
607
+ returning a template. `until` renders the placeholder for the one frame the
608
+ font takes on a warm cache, and for however long it takes on a cold one --
609
+ which is the point: a visible "measuring" beats an invisible wrong number. */
610
+ export const WhyTheseSizes = () => html`${until(
611
+ whyTheseSizes(),
612
+ html`<p style="font-family: var(--typography-fontFamily-mono);
613
+ font-size: var(--typography-fontSize-xs);
614
+ color: var(--color-text-muted)">
615
+ Loading IBM Plex Sans Bold before measuring anything…
616
+ </p>`
617
+ )}`;
618
+
619
+ export const FluidType = () => {
620
+ const cssText = readAllCssText();
621
+ const consumers = discoverConsumers(cssText);
622
+ return html`
623
+ ${typeStyles}
624
+ ${docsPage({
625
+ title: 'Fluid type',
626
+ lede : html`Nine roles change size with the viewport. The minimum is reached at
627
+ <b>320px</b> and the maximum — the size this system already shipped — at
628
+ <b>992px</b>, the <code>lg</code> breakpoint. <b>Above lg nothing changed</b>, so every
629
+ desktop layout that has been reviewed renders exactly as reviewed.`,
630
+ note: html`<b>Why the role and not <code>bd-h</code>.</b> More than one thing reads these
631
+ roles — the hero's final price reads <code>--typography-heading-h2-*</code> directly, and
632
+ so does <code>blog-heading</code>. A media query inside <code>heading.css.js</code> would
633
+ have made one role render 36px in some places and 32px in others, and would have failed at
634
+ the job it was for: the headline would have dropped while the price stayed, flattening the
635
+ hierarchy it was meant to protect. ${referenceNote}`,
636
+ children: html`
637
+ <p>
638
+ <b>The constraint is a long word, not a long sentence.</b> What decides whether large
639
+ type works on a phone is whether the longest <b>unbreakable word</b> fits the column —
640
+ and the column is the viewport minus twice <code>--grid-padding-*</code>, so a 320px
641
+ phone gives type 288px. Against that column the widest token in shipped copy
642
+ (Swedish <i>kalenderinbjudningar</i>, harvested 2026-09-17) tops out at <b>28px</b>,
643
+ and the widest token observed doing a headline's job (German
644
+ <i>Gründungsmitglied</i>) at <b>31px</b>. <code>heading-h1</code> is 48, so it
645
+ overflowed at 375 as well as 320 — in German, Dutch and Swedish. See
646
+ <i>Why these sizes</i> for the per-role exposure and the tier split.
647
+ </p>
648
+ <p>
649
+ <b>A breakpoint step was tried first and abandoned.</b> Stepping h1 to 36 below
650
+ <code>sm</code> still overflows at 320 and changes nothing at 576–767, where the ceiling
651
+ is already 54. The constraint moves continuously with the column, because the column is
652
+ a function of viewport width — a step function approximates a line with a staircase.
653
+ </p>
654
+ <p>
655
+ <b>The middle term carries a <code>rem</code>, and that is not optional.</b>
656
+ <code>clamp(2rem, 4vw, 3rem)</code> is the spelling everyone reaches for and it breaks
657
+ text resize: a size depending only on viewport width ignores the reader's own font-size
658
+ setting, which is WCAG 1.4.4. Same principle as unitless <code>line-height</code> and
659
+ rem <code>--spacing-*</code>.
660
+ </p>
661
+ <p>
662
+ <b>The mobile ladder is the design decision</b> — 30 / 27 / 24 / 21 / 18 / 16 for h1–h6,
663
+ held in six <code>--typography-*-fontSizeMin</code> tokens. Its ratios are even
664
+ (1.11–1.17) against the desktop ladder's irregular 1.33 / 1.13 / 1.33 / 1.20 / 1.11,
665
+ because a phone has less range to spend: 16 to the 31px ceiling is a factor of 1.94
666
+ where desktop has 18 to 48, a factor of 2.67.
667
+ </p>
668
+ <p>
669
+ <b><code>display-lg</code> and above are desktop-only.</b> Five roles from 60 to 128px
670
+ cannot squeeze between 30px and a 31px ceiling, so the family has no mobile identity
671
+ above <code>display-md</code>. A component that needs one on a phone should take a
672
+ heading role.
673
+ </p>
674
+ <p>
675
+ <b>Do not judge a phone's measure against 45–75 characters per line.</b> That band is a
676
+ print and desktop yardstick. Reaching 45 CPL on a 288px column needs 14.2px body text
677
+ and 50 CPL needs 12.8px, so applying it to a phone trades the 16px floor for a soft
678
+ target. <code>body-regular</code> runs 40 characters there and stays 16px.
679
+ <code>body-lead</code> is the one prose role that moves, 20 → 18.
680
+ </p>
681
+ ${refSection({
682
+ name : 'What each role resolves to',
683
+ meta : `${FLUID.length} fluid roles · clamp(min, rem + vw, max) · window 320–992`,
684
+ children: html`
685
+ <div style="overflow-x: auto">
686
+ <table class="tokens-table">
687
+ <caption>Resolved size in px at each viewport. Column = viewport − 2 × gutter.</caption>
688
+ <thead>
689
+ <tr>
690
+ <th>role</th><th>weight</th>
691
+ ${COLUMNS.map(([vw, col]) => html`<th>${vw}<br /><small>${col}px col</small></th>`)}
692
+ </tr>
693
+ </thead>
694
+ <tbody>
695
+ ${FLUID.map(([name, max, min, weight]) => html`
696
+ <tr>
697
+ <td><code>--typography-${name}-fontSize</code></td>
698
+ <td>${weight}</td>
699
+ ${COLUMNS.map(([vw]) => html`<td>${fluidAt(vw, min, max).toFixed(1)}</td>`)}
700
+ </tr>
701
+ `)}
702
+ </tbody>
703
+ </table>
539
704
  </div>
540
- <div class="token-preview" style="font-weight: ${weight};">
541
- ${args.sampleText}
705
+ `
706
+ })}
707
+ ${refSection({
708
+ name : 'Live, at this viewport',
709
+ meta : 'resize the window — every line below moves',
710
+ children: html`
711
+ <div style="display: grid; gap: var(--spacing-16)">
712
+ ${FLUID.map(([name]) => html`
713
+ <div>
714
+ <div style="font-family: var(--typography-fontFamily-mono);
715
+ font-size: var(--typography-fontSize-xs);
716
+ color: var(--color-text-muted)">${name}</div>
717
+ <div style="font-size: var(--typography-${name}-fontSize);
718
+ font-weight: var(--typography-${name}-fontWeight);
719
+ line-height: var(--typography-${name}-lineHeight);
720
+ letter-spacing: var(--typography-${name}-letterSpacing);
721
+ font-family: var(--typography-${name}-fontFamily);
722
+ color: var(--color-text-primary)">Henkilöllisyyssuoja</div>
723
+ </div>
724
+ `)}
542
725
  </div>
543
- </div>
544
- `)}
545
- </div>
546
- </div>
726
+ `
727
+ })}
728
+ ${refSection({
729
+ name : 'The minimums',
730
+ meta : '9 tokens · --typography-*-fontSizeMin · the mobile ladder',
731
+ children: primitiveTable({
732
+ caption: 'Mobile endpoint tokens',
733
+ tokens : FLUID.map(([name]) => `--typography-${name}-fontSizeMin`),
734
+ consumers,
735
+ sample : token => `font-size: var(${token})`
736
+ })
737
+ })}
738
+ `
739
+ })}
740
+ `;
741
+ };
547
742
 
548
- <!-- Line Heights -->
549
- ${args.showLineHeight
550
- ? html`
551
- <div class="section">
552
- <h3 class="section-title">Line Heights</h3>
553
- <div class="token-grid">
554
- ${Object.entries(typographyTokens.lineHeights).map(([name, lineHeight]) => html`
555
- <div class="token-card">
556
- <div class="token-header">
557
- <span class="token-name">--line-height-${name}</span>
558
- <span class="token-value">${lineHeight}</span>
559
- </div>
560
- <div class="token-preview" style="line-height: ${lineHeight};">
561
- ${args.sampleText}<br>
562
- ${args.sampleText}
563
- </div>
564
- <div class="line-height-visual" style="height: calc(1rem * ${lineHeight});">
565
- <span class="line-height-label">${lineHeight}</span>
743
+ export const TypographyScale = () => {
744
+ const cssText = readAllCssText();
745
+ const consumers = discoverConsumers(cssText);
746
+
747
+ const families = tokensMatching(cssText, /(--typography-fontFamily-[a-zA-Z0-9-]+)\s*:/g);
748
+ const sizes = tokensMatching(cssText, /(--typography-fontSize-[a-zA-Z0-9]+)\s*:/g);
749
+ const weights = tokensMatching(cssText, /(--typography-fontWeight-[a-zA-Z0-9]+)\s*:/g);
750
+ const lineHeights = tokensMatching(cssText, /(--typography-lineHeight-[a-zA-Z0-9]+)\s*:/g);
751
+ const tracking = tokensMatching(cssText, /(--typography-letterSpacing-[a-zA-Z0-9]+)\s*:/g);
752
+
753
+ return html`
754
+ ${typeStyles}
755
+ ${docsPage({
756
+ title: 'Type scale',
757
+ lede : html`The primitive typography tokens, read from the loaded token CSS at render time
758
+ so this page always matches <code>src/tokens/tokens.css</code>. Click a token name to copy
759
+ its <code>var()</code> reference.`,
760
+ note: html`These are the raw scales. For a style you can apply directly to a heading, a
761
+ paragraph or a label, use the composed tokens on the <b>Typography</b> page — those are
762
+ what components consume. ${referenceNote}`,
763
+ children: html`
764
+ ${refSection({
765
+ name : 'Font family',
766
+ meta : `${families.length} tokens · --typography-fontFamily-*`,
767
+ children: primitiveTable({
768
+ caption: 'Font family tokens',
769
+ tokens : families,
770
+ consumers,
771
+ sample : token => `font-family: var(${token})`
772
+ })
773
+ })}
774
+ ${refSection({
775
+ name : 'Font size',
776
+ meta : `${sizes.length} steps · --typography-fontSize-*`,
777
+ children: primitiveTable({
778
+ caption: 'Font size scale',
779
+ tokens : sizes,
780
+ consumers,
781
+ sample : token => `font-size: var(${token}); line-height: 1.2`
782
+ })
783
+ })}
784
+ ${refSection({
785
+ name : 'Font weight',
786
+ meta : `${weights.length} steps · --typography-fontWeight-*`,
787
+ children: primitiveTable({
788
+ caption: 'Font weight scale',
789
+ tokens : weights,
790
+ consumers,
791
+ sample : token => `font-weight: var(${token}); font-size: var(--typography-fontSize-lg)`
792
+ })
793
+ })}
794
+ ${refSection({
795
+ name : 'Line height',
796
+ meta : `${lineHeights.length} steps · --typography-lineHeight-*`,
797
+ children: primitiveTable({
798
+ caption: 'Line height scale',
799
+ tokens : lineHeights,
800
+ consumers,
801
+ leading: true
802
+ })
803
+ })}
804
+ ${refSection({
805
+ name : 'A ratio is not a fixed leading',
806
+ meta : '--typography-lineHeight-normal at three sizes',
807
+ children: html`
808
+ <div class="specimen">
809
+ <p class="docs-lede" style="margin-top: 0">
810
+ One unitless value produces different leading at every size, because each element
811
+ resolves it against its own font-size. That is the whole reason these tokens are
812
+ ratios and not lengths.
813
+ </p>
814
+ <div class="ratio-demo">
815
+ ${['--typography-fontSize-xs', '--typography-fontSize-base', '--typography-fontSize-2xl'].map(
816
+ sizeToken => {
817
+ const px = parseFloat(resolve(sizeToken)) * 16;
818
+ const ratio = parseFloat(resolve('--typography-lineHeight-normal'));
819
+ return html`
820
+ <figure>
821
+ <figcaption>
822
+ ${sizeToken.replace('--typography-', '')} · ${px}px × ${ratio} =
823
+ ${(px * ratio).toFixed(0)}px
824
+ </figcaption>
825
+ <p
826
+ style="font-size: var(${sizeToken}); line-height: var(--typography-lineHeight-normal)"
827
+ >
828
+ The line box grows with the text, so the rhythm holds at any size.
829
+ </p>
830
+ </figure>
831
+ `;
832
+ }
833
+ )}
566
834
  </div>
567
835
  </div>
568
- `)}
569
- </div>
570
- </div>
571
- `
572
- : ''}
573
-
574
- <!-- Letter Spacing -->
575
- ${args.showLetterSpacing
576
- ? html`
577
- <div class="section">
578
- <h3 class="section-title">Letter Spacing</h3>
579
- <div class="token-grid">
580
- ${Object.entries(typographyTokens.letterSpacing).map(([name, spacing]) => html`
581
- <div class="token-card">
582
- <div class="token-header">
583
- <span class="token-name">--letter-spacing-${name}</span>
584
- <span class="token-value">${spacing}</span>
585
- </div>
586
- <div class="token-preview" style="letter-spacing: ${spacing};">
587
- ${args.sampleText}
588
- </div>
589
- <div class="letter-spacing-visual">
590
- ${'abcdef'.split('').map(letter => html`
591
- <div class="letter-box" style="margin-right: ${spacing};"></div>
592
- `)}
593
- </div>
836
+ `
837
+ })}
838
+
839
+ ${refSection({
840
+ name : 'Rules',
841
+ children: html`
842
+ <div class="docs-note">
843
+ <ol class="rule-list">
844
+ <li>
845
+ <b>Always unitless.</b> Never <code>px</code>, <code>rem</code> or
846
+ <code>em</code>. A unitless value inherits as a number and each child
847
+ recomputes it against its own font-size; a length inherits as a fixed line box,
848
+ so nested text of a different size gets the wrong leading and stops growing when
849
+ a reader raises their default font size.
850
+ </li>
851
+ <li>
852
+ <b>Always from this scale.</b> Use
853
+ <code>--typography-lineHeight-*</code>, or the composed style token for the role
854
+ you are styling — <code>--typography-heading-h2-lineHeight</code>,
855
+ <code>--typography-body-regular-lineHeight</code>. Do not invent a ratio.
856
+ </li>
857
+ <li>
858
+ <b>Never a spacing or dimension token.</b>
859
+ <code>line-height: var(--spacing-18)</code> is both an absolute value and a
860
+ category error.
861
+ </li>
862
+ <li>
863
+ <b>Anything that can wrap needs at least
864
+ <code>tight</code></b> (${resolve('--typography-lineHeight-tight')}). A ratio at
865
+ or below 1 makes the line box shorter than the text, which clips descenders and
866
+ collides wrapped lines.
867
+ </li>
868
+ <li>
869
+ <b>Tighter as text gets bigger.</b> Display and the two largest headings sit on
870
+ <code>tight</code>, h3 and h4 on <code>130</code>, the smaller headings and most
871
+ body copy on <code>140</code> and <code>145</code>, and running prose at 16px on
872
+ <code>normal</code>. The ramp tightens as the size grows rather than holding one
873
+ value per family. The composed tokens already encode this, which is the simplest
874
+ reason to use them rather than picking a primitive by hand.
875
+ </li>
876
+ <li>
877
+ <b>No fallback.</b> A fallback of the form
878
+ <code>var(--typography-lineHeight-normal, 1.5)</code> restates the token's
879
+ current value and silently lies the moment it changes.
880
+ </li>
881
+ </ol>
594
882
  </div>
595
- `)}
596
- </div>
597
- </div>
598
- `
599
- : ''}
600
- </div>
601
- `;
883
+ `
884
+ })}
602
885
 
603
- export const Typography = Template.bind({});
604
- Typography.args = {
605
- sampleText : 'The quick brown fox jumps over the lazy dog.',
606
- showLineHeight : true,
607
- showLetterSpacing: false
608
- };
609
- Typography.parameters = {
610
- docs: {
611
- description: {
612
- story: 'Complete typography token system showing font families, sizes, weights, line heights, and letter spacing with visual examples.'
613
- }
614
- }
886
+ ${refSection({
887
+ name : 'Letter spacing',
888
+ meta : `${tracking.length} steps · --typography-letterSpacing-*`,
889
+ children: primitiveTable({
890
+ caption: 'Letter spacing scale',
891
+ tokens : tracking,
892
+ consumers,
893
+ sample : token =>
894
+ `letter-spacing: var(${token}); font-size: var(--typography-fontSize-base)`
895
+ })
896
+ })}
897
+ `
898
+ })}
899
+ `;
615
900
  };
616
901
 
617
- export const TypographyScale = () => html`
618
- <style>
619
- .scale-container {
620
- max-width: 800px;
621
- margin: 0 auto;
622
- padding: var(--space-xl);
623
- background: white;
624
- border-radius: 8px;
625
- box-shadow: 0 2px 8px rgba(0,0,0,0.1);
626
- }
627
-
628
- .scale-title {
629
- font-family: var(--font-family-sans);
630
- font-size: var(--font-size-2xl);
631
- font-weight: var(--font-weight-bold);
632
- margin: 0 0 var(--space-xl) 0;
633
- color: #212529;
634
- text-align: center;
635
- }
636
-
637
- .scale-item {
638
- display: flex;
639
- align-items: center;
640
- margin-bottom: var(--space-md);
641
- padding: var(--space-sm);
642
- border-radius: 6px;
643
- background: #f8f9fa;
644
- border-left: 4px solid #007bff;
645
- }
646
-
647
- .scale-size {
648
- font-family: var(--font-family-mono);
649
- font-size: var(--font-size-sm);
650
- color: #007bff;
651
- font-weight: var(--font-weight-semibold);
652
- min-width: 120px;
653
- padding-right: var(--space-md);
654
- }
655
-
656
- .scale-preview {
657
- flex: 1;
658
- font-family: var(--font-family-sans);
659
- line-height: var(--line-height-normal);
660
- }
661
-
662
- .scale-label {
663
- font-family: var(--font-family-mono);
664
- font-size: var(--font-size-xs);
665
- color: #6c757d;
666
- background: white;
667
- padding: 2px 6px;
668
- border-radius: 3px;
669
- border: 1px solid #dee2e6;
670
- margin-left: var(--space-sm);
671
- }
672
-
673
- .heading-hierarchy {
674
- margin-top: var(--space-2xl);
675
- padding-top: var(--space-xl);
676
- border-top: 2px solid #f0f0f0;
677
- }
678
-
679
- .hierarchy-title {
680
- font-family: var(--font-family-sans);
681
- font-size: var(--font-size-xl);
682
- font-weight: var(--font-weight-bold);
683
- margin: 0 0 var(--space-lg) 0;
684
- color: #212529;
685
- }
686
-
687
- .heading-sample {
688
- margin-bottom: var(--space-lg);
689
- padding: var(--space-md);
690
- background: white;
691
- border-radius: 6px;
692
- border: 1px solid #e9ecef;
693
- }
694
-
695
- .heading-info {
696
- display: flex;
697
- align-items: center;
698
- gap: var(--space-md);
699
- margin-top: var(--space-sm);
700
- }
701
-
702
- .heading-token {
703
- font-family: var(--font-family-mono);
704
- font-size: var(--font-size-xs);
705
- color: #6c757d;
706
- }
707
- </style>
902
+ /* ---------- composed styles ---------- */
708
903
 
709
- <div class="scale-container">
710
- <h2 class="scale-title">Typography Scale</h2>
711
-
712
- ${Object.entries(typographyTokens.sizes).map(([name, size]) => html`
713
- <div class="scale-item">
714
- <div class="scale-size">--font-size-${name}</div>
715
- <div class="scale-preview" style="font-size: ${size};">
716
- The quick brown fox jumps over the lazy dog.
717
- </div>
718
- <span class="scale-label">${size}</span>
719
- </div>
720
- `)}
721
-
722
- <div class="heading-hierarchy">
723
- <h3 class="hierarchy-title">Heading Hierarchy</h3>
724
-
725
- <div class="heading-sample">
726
- <h1 style="font-size: var(--font-size-4xl); font-weight: var(--font-weight-bold); margin: 0 0 var(--space-sm) 0;">
727
- Heading 1 (h1)
728
- </h1>
729
- <div class="heading-info">
730
- <span class="heading-token">--font-size-4xl</span>
731
- <span class="heading-token">--font-weight-bold</span>
732
- <span class="heading-token">--line-height-tight</span>
733
- </div>
734
- </div>
735
-
736
- <div class="heading-sample">
737
- <h2 style="font-size: var(--font-size-3xl); font-weight: var(--font-weight-bold); margin: 0 0 var(--space-sm) 0;">
738
- Heading 2 (h2)
739
- </h2>
740
- <div class="heading-info">
741
- <span class="heading-token">--font-size-3xl</span>
742
- <span class="heading-token">--font-weight-bold</span>
743
- <span class="heading-token">--line-height-tight</span>
744
- </div>
745
- </div>
746
-
747
- <div class="heading-sample">
748
- <h3 style="font-size: var(--font-size-2xl); font-weight: var(--font-weight-semibold); margin: 0 0 var(--space-sm) 0;">
749
- Heading 3 (h3)
750
- </h3>
751
- <div class="heading-info">
752
- <span class="heading-token">--font-size-2xl</span>
753
- <span class="heading-token">--font-weight-semibold</span>
754
- <span class="heading-token">--line-height-tight</span>
755
- </div>
756
- </div>
757
-
758
- <div class="heading-sample">
759
- <h4 style="font-size: var(--font-size-xl); font-weight: var(--font-weight-semibold); margin: 0 0 var(--space-sm) 0;">
760
- Heading 4 (h4)
761
- </h4>
762
- <div class="heading-info">
763
- <span class="heading-token">--font-size-xl</span>
764
- <span class="heading-token">--font-weight-semibold</span>
765
- <span class="heading-token">--line-height-normal</span>
766
- </div>
767
- </div>
768
-
769
- <div class="heading-sample">
770
- <h5 style="font-size: var(--font-size-lg); font-weight: var(--font-weight-medium); margin: 0 0 var(--space-sm) 0;">
771
- Heading 5 (h5)
772
- </h5>
773
- <div class="heading-info">
774
- <span class="heading-token">--font-size-lg</span>
775
- <span class="heading-token">--font-weight-medium</span>
776
- <span class="heading-token">--line-height-normal</span>
777
- </div>
778
- </div>
779
-
780
- <div class="heading-sample">
781
- <h6 style="font-size: var(--font-size-md); font-weight: var(--font-weight-medium); margin: 0 0 var(--space-sm) 0;">
782
- Heading 6 (h6)
783
- </h6>
784
- <div class="heading-info">
785
- <span class="heading-token">--font-size-md</span>
786
- <span class="heading-token">--font-weight-medium</span>
787
- <span class="heading-token">--line-height-normal</span>
788
- </div>
789
- </div>
790
- </div>
791
- </div>
792
- `;
793
- TypographyScale.storyName = 'Typography Scale';
794
- TypographyScale.parameters = {
795
- docs: {
796
- description: {
797
- story: 'Complete typography scale showing all font sizes with visual hierarchy and proper heading structure examples.'
798
- }
904
+ const PROPERTIES = [
905
+ ['fontFamily', 'font-family'],
906
+ ['fontSize', 'font-size'],
907
+ ['fontWeight', 'font-weight'],
908
+ ['lineHeight', 'line-height'],
909
+ ['letterSpacing', 'letter-spacing']
910
+ ];
911
+
912
+ const SAMPLE = 'Security that keeps up with you';
913
+
914
+ /**
915
+ * Finds every composed style — any --typography-<group>-<variant>-fontSize —
916
+ * and groups them, so a style added to the tokens appears here automatically.
917
+ */
918
+ function discoverComposedStyles(cssText) {
919
+ const groups = new Map();
920
+ const pattern = /--typography-([a-z]+)-([a-z0-9-]+?)-fontSize\s*:/g;
921
+ for (const [, group, variant] of cssText.matchAll(pattern)) {
922
+ if (!groups.has(group)) groups.set(group, new Set());
923
+ groups.get(group).add(variant);
924
+ }
925
+ return [...groups].map(([group, variants]) => ({ group, variants: [...variants] }));
926
+ }
927
+
928
+ function styleFor(group, variant) {
929
+ const declarations = [];
930
+ const used = [];
931
+ for (const [suffix, property] of PROPERTIES) {
932
+ const token = `--typography-${group}-${variant}-${suffix}`;
933
+ if (!resolve(token)) continue;
934
+ declarations.push(`${property}: var(${token})`);
935
+ // a full fallback stack is too long for the meta line; the stack itself is
936
+ // documented on the Fonts page
937
+ const raw = resolve(token);
938
+ const value = property === 'font-family' ? raw.split(',')[0].replace(/['"]/g, '').trim() : raw;
939
+ used.push({ token, property, value });
799
940
  }
941
+ return { css: declarations.join('; '), used };
942
+ }
943
+
944
+ export const Typography = () => {
945
+ const cssText = readAllCssText();
946
+ const groups = discoverComposedStyles(cssText);
947
+
948
+ return html`
949
+ ${typeStyles}
950
+ ${docsPage({
951
+ title: 'Typography',
952
+ lede : html`The composed type styles components actually use. Each specimen is rendered by
953
+ applying that style's own tokens, so what you see is what the token produces — nothing is
954
+ hardcoded.`,
955
+ note: html`Every style is discovered from the loaded CSS by looking for a
956
+ <code>-fontSize</code> token, so adding a style to
957
+ <code>src/tokens/tokens.css</code> makes it appear here without touching this page.`,
958
+ children: groups.map(({ group, variants }) =>
959
+ refSection({
960
+ name : group,
961
+ meta : `${variants.length} styles · --typography-${group}-*`,
962
+ children: html`
963
+ <div class="specimen">
964
+ ${variants.map(variant => {
965
+ const { css, used } = styleFor(group, variant);
966
+ if (!used.length) return html``;
967
+ return html`
968
+ <div class="specimen-row">
969
+ <div class="specimen-meta">
970
+ <span class="name">${group}-${variant}</span>
971
+ ${used.map(
972
+ ({ property, value }) => html`<span>${property}: ${value}</span>`
973
+ )}
974
+ </div>
975
+ <p class="specimen-text" style=${css}>${SAMPLE}</p>
976
+ </div>
977
+ `;
978
+ })}
979
+ </div>
980
+ `
981
+ })
982
+ )
983
+ })}
984
+ `;
800
985
  };
801
986
 
802
- export const TypographyCombinations = () => html`
803
- <style>
804
- .combinations-container {
805
- max-width: 800px;
806
- margin: 0 auto;
807
- padding: var(--space-xl);
808
- background: white;
809
- border-radius: 8px;
810
- box-shadow: 0 2px 8px rgba(0,0,0,0.1);
811
- }
812
-
813
- .combinations-grid {
814
- display: grid;
815
- grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
816
- gap: var(--space-lg);
817
- }
818
-
819
- .combination-card {
820
- padding: var(--space-lg);
821
- border-radius: 8px;
822
- border: 1px solid #e9ecef;
823
- background: #f8f9fa;
824
- }
825
-
826
- .combination-title {
827
- font-family: var(--font-family-sans);
828
- font-size: var(--font-size-lg);
829
- font-weight: var(--font-weight-semibold);
830
- margin: 0 0 var(--space-md) 0;
831
- color: #212529;
832
- }
833
-
834
- .combination-preview {
835
- font-family: inherit;
836
- font-size: inherit;
837
- font-weight: inherit;
838
- line-height: inherit;
839
- letter-spacing: inherit;
840
- margin: var(--space-md) 0;
841
- padding: var(--space-md);
842
- background: white;
843
- border-radius: 6px;
844
- border: 1px solid #dee2e6;
845
- }
846
-
847
- .combination-tokens {
848
- display: flex;
849
- flex-wrap: wrap;
850
- gap: var(--space-xs);
851
- margin-top: var(--space-sm);
852
- }
853
-
854
- .token-tag {
855
- font-family: var(--font-family-mono);
856
- font-size: var(--font-size-xs);
857
- color: #6c757d;
858
- background: white;
859
- padding: 2px 6px;
860
- border-radius: 3px;
861
- border: 1px solid #dee2e6;
987
+ export const TypographyCombinations = () => {
988
+ const pairings = [
989
+ {
990
+ name : 'Page header',
991
+ layers: [
992
+ ['display', 'md', 'Complete digital security'],
993
+ ['body', 'lead', 'One plan for every device you own, with privacy and identity cover built in.']
994
+ ]
995
+ },
996
+ {
997
+ name : 'Section',
998
+ layers: [
999
+ ['heading', 'h2', 'Why it matters'],
1000
+ ['body', 'regular', 'Threats move faster than annual reviews. Protection has to keep pace, quietly, without asking you to manage it.']
1001
+ ]
1002
+ },
1003
+ {
1004
+ name : 'Card',
1005
+ layers: [
1006
+ ['heading', 'h5', 'Password Manager'],
1007
+ ['body', 'small', 'Generate, store and autofill strong credentials across your devices.'],
1008
+ ['caption', 'default', 'Included in every plan']
1009
+ ]
1010
+ },
1011
+ {
1012
+ name : 'Form field',
1013
+ layers: [
1014
+ ['label', 'default', 'Email address'],
1015
+ ['caption', 'small', 'We only use this for your licence key.']
1016
+ ]
862
1017
  }
863
- </style>
1018
+ ];
864
1019
 
865
- <div class="combinations-container">
866
- <h2 style="font-family: var(--font-family-sans); font-size: var(--font-size-2xl); font-weight: var(--font-weight-bold); margin: 0 0 var(--space-xl) 0; text-align: center;">
867
- Common Typography Combinations
868
- </h2>
869
-
870
- <div class="combinations-grid">
871
- <!-- Body Text -->
872
- <div class="combination-card">
873
- <h3 class="combination-title">Body Text</h3>
874
- <div class="combination-preview" style="
875
- font-family: var(--font-family-sans);
876
- font-size: var(--font-size-md);
877
- font-weight: var(--font-weight-regular);
878
- line-height: var(--line-height-normal);
879
- letter-spacing: var(--letter-spacing-normal);
880
- ">
881
- The quick brown fox jumps over the lazy dog. This is standard body text used for paragraphs and general content.
882
- </div>
883
- <div class="combination-tokens">
884
- <span class="token-tag">--font-family-sans</span>
885
- <span class="token-tag">--font-size-md</span>
886
- <span class="token-tag">--font-weight-regular</span>
887
- <span class="token-tag">--line-height-normal</span>
888
- </div>
889
- </div>
890
-
891
- <!-- Heading -->
892
- <div class="combination-card">
893
- <h3 class="combination-title">Page Heading</h3>
894
- <div class="combination-preview" style="
895
- font-family: var(--font-family-sans);
896
- font-size: var(--font-size-3xl);
897
- font-weight: var(--font-weight-bold);
898
- line-height: var(--line-height-tight);
899
- letter-spacing: var(--letter-spacing-tight);
900
- ">
901
- Main Page Title
902
- </div>
903
- <div class="combination-tokens">
904
- <span class="token-tag">--font-family-sans</span>
905
- <span class="token-tag">--font-size-3xl</span>
906
- <span class="token-tag">--font-weight-bold</span>
907
- <span class="token-tag">--line-height-tight</span>
908
- </div>
909
- </div>
910
-
911
- <!-- Code Block -->
912
- <div class="combination-card">
913
- <h3 class="combination-title">Code Snippet</h3>
914
- <div class="combination-preview" style="
915
- font-family: var(--font-family-mono);
916
- font-size: var(--font-size-sm);
917
- font-weight: var(--font-weight-regular);
918
- line-height: var(--line-height-normal);
919
- letter-spacing: var(--letter-spacing-normal);
920
- background: #1a1a1a;
921
- color: #f8f8f2;
922
- ">
923
- const greeting = "Hello, World!";<br>
924
- console.log(greeting);
925
- </div>
926
- <div class="combination-tokens">
927
- <span class="token-tag">--font-family-mono</span>
928
- <span class="token-tag">--font-size-sm</span>
929
- <span class="token-tag">--font-weight-regular</span>
930
- </div>
931
- </div>
932
-
933
- <!-- Label -->
934
- <div class="combination-card">
935
- <h3 class="combination-title">Form Label</h3>
936
- <div class="combination-preview" style="
937
- font-family: var(--font-family-sans);
938
- font-size: var(--font-size-sm);
939
- font-weight: var(--font-weight-semibold);
940
- line-height: var(--line-height-tight);
941
- letter-spacing: var(--letter-spacing-wide);
942
- text-transform: uppercase;
943
- ">
944
- Email Address
945
- </div>
946
- <div class="combination-tokens">
947
- <span class="token-tag">--font-family-sans</span>
948
- <span class="token-tag">--font-size-sm</span>
949
- <span class="token-tag">--font-weight-semibold</span>
950
- <span class="token-tag">--letter-spacing-wide</span>
951
- </div>
952
- </div>
953
-
954
- <!-- Quote -->
955
- <div class="combination-card">
956
- <h3 class="combination-title">Block Quote</h3>
957
- <div class="combination-preview" style="
958
- font-family: var(--font-family-sans);
959
- font-size: var(--font-size-lg);
960
- font-weight: var(--font-weight-light);
961
- line-height: var(--line-height-relaxed);
962
- letter-spacing: var(--letter-spacing-normal);
963
- font-style: italic;
964
- border-left: 4px solid #007bff;
965
- padding-left: var(--space-md);
966
- ">
967
- The only way to do great work is to love what you do.
968
- </div>
969
- <div class="combination-tokens">
970
- <span class="token-tag">--font-family-sans</span>
971
- <span class="token-tag">--font-size-lg</span>
972
- <span class="token-tag">--font-weight-light</span>
973
- <span class="token-tag">--line-height-relaxed</span>
974
- </div>
975
- </div>
976
-
977
- <!-- Button Text -->
978
- <div class="combination-card">
979
- <h3 class="combination-title">Button Text</h3>
980
- <div class="combination-preview" style="
981
- font-family: var(--font-family-sans);
982
- font-size: var(--font-size-md);
983
- font-weight: var(--font-weight-semibold);
984
- line-height: var(--line-height-tight);
985
- letter-spacing: var(--letter-spacing-wide);
986
- text-align: center;
987
- background: #007bff;
988
- color: white;
989
- padding: var(--space-sm) var(--space-lg);
990
- border-radius: 4px;
991
- ">
992
- Submit Form
993
- </div>
994
- <div class="combination-tokens">
995
- <span class="token-tag">--font-family-sans</span>
996
- <span class="token-tag">--font-size-md</span>
997
- <span class="token-tag">--font-weight-semibold</span>
998
- <span class="token-tag">--letter-spacing-wide</span>
999
- </div>
1000
- </div>
1001
- </div>
1002
- </div>
1003
- `;
1004
- TypographyCombinations.storyName = 'Common Combinations';
1005
- TypographyCombinations.parameters = {
1006
- docs: {
1007
- description: {
1008
- story: 'Practical typography combinations for common UI elements like headings, body text, code blocks, labels, quotes, and buttons.'
1009
- }
1010
- }
1011
- };
1020
+ return html`
1021
+ ${typeStyles}
1022
+ ${docsPage({
1023
+ title : 'Type pairings',
1024
+ lede : 'Composed styles combined the way pages actually use them, so the relative scale and weight can be judged in context rather than one specimen at a time.',
1025
+ children: pairings.map(({ name, layers }) =>
1026
+ refSection({
1027
+ name,
1028
+ children: html`
1029
+ <div class="specimen">
1030
+ ${layers.map(([group, variant, text]) => {
1031
+ const { css, used } = styleFor(group, variant);
1032
+ if (!used.length) return html``;
1033
+ return html`
1034
+ <p
1035
+ class="specimen-text"
1036
+ style="${css}; margin-bottom: var(--spacing-8)"
1037
+ title="--typography-${group}-${variant}-*"
1038
+ >
1039
+ ${text}
1040
+ </p>
1041
+ `;
1042
+ })}
1043
+ </div>
1044
+ `
1045
+ })
1046
+ )
1047
+ })}
1048
+ `;
1049
+ };