@duro-app/ui 5.7.0 → 5.8.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 (127) hide show
  1. package/dist/{Table-Dj7PXqO_.js → Table-CAjPFgTX.js} +397 -394
  2. package/dist/Table-CAjPFgTX.js.map +1 -0
  3. package/dist/{compiler-runtime-B2IcI8_O.js → compiler-runtime-BuugS4D0.js} +67 -59
  4. package/dist/{compiler-runtime-B2IcI8_O.js.map → compiler-runtime-BuugS4D0.js.map} +1 -1
  5. package/dist/components/AppShell/AppShell.d.ts +74 -0
  6. package/dist/components/AppShell/AppShell.d.ts.map +1 -0
  7. package/dist/components/AppShell/AppShell.meta.d.ts +3 -0
  8. package/dist/components/AppShell/AppShell.meta.d.ts.map +1 -0
  9. package/dist/components/AppShell/AppShell.stories.d.ts +186 -0
  10. package/dist/components/AppShell/AppShell.stories.d.ts.map +1 -0
  11. package/dist/components/AppShell/styles.css.d.ts +187 -0
  12. package/dist/components/AppShell/styles.css.d.ts.map +1 -0
  13. package/dist/components/Aside/Aside.d.ts +23 -0
  14. package/dist/components/Aside/Aside.d.ts.map +1 -0
  15. package/dist/components/Aside/Aside.meta.d.ts +3 -0
  16. package/dist/components/Aside/Aside.meta.d.ts.map +1 -0
  17. package/dist/components/Aside/Aside.stories.d.ts +45 -0
  18. package/dist/components/Aside/Aside.stories.d.ts.map +1 -0
  19. package/dist/components/Aside/styles.css.d.ts +44 -0
  20. package/dist/components/Aside/styles.css.d.ts.map +1 -0
  21. package/dist/components/Button/styles.css.d.ts +2 -0
  22. package/dist/components/Button/styles.css.d.ts.map +1 -1
  23. package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts +3 -3
  24. package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts.map +1 -1
  25. package/dist/components/CodeBlock/CodeBlock.d.ts +29 -0
  26. package/dist/components/CodeBlock/CodeBlock.d.ts.map +1 -0
  27. package/dist/components/CodeBlock/CodeBlock.meta.d.ts +3 -0
  28. package/dist/components/CodeBlock/CodeBlock.meta.d.ts.map +1 -0
  29. package/dist/components/CodeBlock/CodeBlock.stories.d.ts +67 -0
  30. package/dist/components/CodeBlock/CodeBlock.stories.d.ts.map +1 -0
  31. package/dist/components/CodeBlock/styles.css.d.ts +32 -0
  32. package/dist/components/CodeBlock/styles.css.d.ts.map +1 -0
  33. package/dist/components/Grid/Grid.d.ts +13 -4
  34. package/dist/components/Grid/Grid.d.ts.map +1 -1
  35. package/dist/components/Grid/Grid.meta.d.ts.map +1 -1
  36. package/dist/components/Grid/Grid.stories.d.ts +28 -0
  37. package/dist/components/Grid/Grid.stories.d.ts.map +1 -1
  38. package/dist/components/Grid/styles.css.d.ts +3 -0
  39. package/dist/components/Grid/styles.css.d.ts.map +1 -1
  40. package/dist/components/Heading/Heading.d.ts +3 -1
  41. package/dist/components/Heading/Heading.d.ts.map +1 -1
  42. package/dist/components/Heading/Heading.stories.d.ts +9 -0
  43. package/dist/components/Heading/Heading.stories.d.ts.map +1 -0
  44. package/dist/components/LinkButton/LinkButton.stories.d.ts +7 -0
  45. package/dist/components/LinkButton/LinkButton.stories.d.ts.map +1 -1
  46. package/dist/components/LinkButton/styles.css.d.ts +2 -0
  47. package/dist/components/LinkButton/styles.css.d.ts.map +1 -1
  48. package/dist/components/LiveRegion/LiveRegion.d.ts +28 -0
  49. package/dist/components/LiveRegion/LiveRegion.d.ts.map +1 -0
  50. package/dist/components/LiveRegion/LiveRegion.meta.d.ts +3 -0
  51. package/dist/components/LiveRegion/LiveRegion.meta.d.ts.map +1 -0
  52. package/dist/components/LiveRegion/LiveRegion.stories.d.ts +66 -0
  53. package/dist/components/LiveRegion/LiveRegion.stories.d.ts.map +1 -0
  54. package/dist/components/Menu/Menu.stories.d.ts +7 -0
  55. package/dist/components/Menu/Menu.stories.d.ts.map +1 -1
  56. package/dist/components/Menu/styles.css.d.ts +2 -0
  57. package/dist/components/Menu/styles.css.d.ts.map +1 -1
  58. package/dist/components/PageNav/styles.css.d.ts +1 -1
  59. package/dist/components/Prose/Prose.d.ts +17 -0
  60. package/dist/components/Prose/Prose.d.ts.map +1 -0
  61. package/dist/components/Prose/Prose.meta.d.ts +3 -0
  62. package/dist/components/Prose/Prose.meta.d.ts.map +1 -0
  63. package/dist/components/Prose/Prose.stories.d.ts +63 -0
  64. package/dist/components/Prose/Prose.stories.d.ts.map +1 -0
  65. package/dist/components/SideNav/SideNav.d.ts +7 -1
  66. package/dist/components/SideNav/SideNav.d.ts.map +1 -1
  67. package/dist/components/SideNav/SideNav.stories.d.ts +3 -0
  68. package/dist/components/SideNav/SideNav.stories.d.ts.map +1 -1
  69. package/dist/components/Time/Time.d.ts +26 -0
  70. package/dist/components/Time/Time.d.ts.map +1 -0
  71. package/dist/components/Time/Time.meta.d.ts +3 -0
  72. package/dist/components/Time/Time.meta.d.ts.map +1 -0
  73. package/dist/components/Time/Time.stories.d.ts +77 -0
  74. package/dist/components/Time/Time.stories.d.ts.map +1 -0
  75. package/dist/components/Time/relative.d.ts +27 -0
  76. package/dist/components/Time/relative.d.ts.map +1 -0
  77. package/dist/form.js +1 -1
  78. package/dist/hooks/useContainerBelow.d.ts +18 -0
  79. package/dist/hooks/useContainerBelow.d.ts.map +1 -0
  80. package/dist/index.css +1 -1
  81. package/dist/index.d.ts +9 -1
  82. package/dist/index.d.ts.map +1 -1
  83. package/dist/index.js +4766 -4088
  84. package/dist/index.js.map +1 -1
  85. package/dist/table.js +2 -2
  86. package/package.json +2 -2
  87. package/src/components/AppShell/AppShell.meta.ts +62 -0
  88. package/src/components/AppShell/AppShell.stories.tsx +770 -0
  89. package/src/components/AppShell/AppShell.tsx +346 -0
  90. package/src/components/AppShell/styles.css.ts +219 -0
  91. package/src/components/Aside/Aside.meta.ts +33 -0
  92. package/src/components/Aside/Aside.stories.tsx +106 -0
  93. package/src/components/Aside/Aside.tsx +42 -0
  94. package/src/components/Aside/styles.css.ts +60 -0
  95. package/src/components/Button/styles.css.ts +7 -2
  96. package/src/components/ButtonGroup/ButtonGroup.stories.tsx +4 -5
  97. package/src/components/CodeBlock/CodeBlock.meta.ts +29 -0
  98. package/src/components/CodeBlock/CodeBlock.stories.tsx +162 -0
  99. package/src/components/CodeBlock/CodeBlock.tsx +99 -0
  100. package/src/components/CodeBlock/styles.css.ts +36 -0
  101. package/src/components/Grid/Grid.meta.ts +2 -1
  102. package/src/components/Grid/Grid.stories.tsx +202 -3
  103. package/src/components/Grid/Grid.tsx +35 -6
  104. package/src/components/Grid/styles.css.ts +11 -0
  105. package/src/components/Heading/Heading.stories.tsx +51 -0
  106. package/src/components/Heading/Heading.tsx +38 -7
  107. package/src/components/LinkButton/LinkButton.stories.tsx +41 -1
  108. package/src/components/LinkButton/styles.css.ts +6 -2
  109. package/src/components/LiveRegion/LiveRegion.meta.ts +29 -0
  110. package/src/components/LiveRegion/LiveRegion.stories.tsx +139 -0
  111. package/src/components/LiveRegion/LiveRegion.tsx +48 -0
  112. package/src/components/Menu/Menu.stories.tsx +62 -0
  113. package/src/components/Menu/styles.css.ts +6 -3
  114. package/src/components/Prose/Prose.meta.ts +32 -0
  115. package/src/components/Prose/Prose.stories.tsx +233 -0
  116. package/src/components/Prose/Prose.tsx +25 -0
  117. package/src/components/Prose/prose.css +177 -0
  118. package/src/components/SideNav/SideNav.stories.tsx +29 -0
  119. package/src/components/SideNav/SideNav.tsx +14 -2
  120. package/src/components/Time/Time.meta.ts +17 -0
  121. package/src/components/Time/Time.stories.tsx +173 -0
  122. package/src/components/Time/Time.tsx +33 -0
  123. package/src/components/Time/relative.ts +89 -0
  124. package/src/hooks/useContainerBelow.ts +39 -0
  125. package/src/index.ts +13 -1
  126. package/src/strict.css +4 -1
  127. package/dist/Table-Dj7PXqO_.js.map +0 -1
@@ -0,0 +1,177 @@
1
+ /*
2
+ * Prose — styles for HTML that Duro does not author: rendered Markdown, CMS
3
+ * output. StyleX cannot write descendant rules, so these ship as plain CSS in
4
+ * dist/index.css. Only var(--duro-*) tokens: the check in
5
+ * packages/ui/test/prose-css.test.ts fails on any raw length or colour.
6
+ *
7
+ * Two things keep it out of everyone's way:
8
+ *
9
+ * - The `duro-prose` layer sits after `reset` (so these margins beat the
10
+ * reset's `margin: 0`) and before StyleX's own layers (strict.css declares
11
+ * the order). A Duro component rendered inside Prose (a TextLink, a
12
+ * CodeBlock) keeps its own styles, and any unlayered app CSS — a CSS
13
+ * Module rule — wins over every rule here.
14
+ * - Every selector is wrapped in :where(), so the rules weigh nothing even
15
+ * against an app's own layered CSS.
16
+ *
17
+ * Scoped to the Prose element: `data-duro-prose` (what the component renders;
18
+ * react-strict-dom elements take no className) or a `duro-prose` class.
19
+ */
20
+
21
+ @layer reset, duro-prose;
22
+
23
+ @layer duro-prose {
24
+ :where(.duro-prose, [data-duro-prose]) {
25
+ color: var(--duro-color-text);
26
+ font-family: var(--duro-typography-font-family);
27
+ font-size: var(--duro-type-scale-font-size4);
28
+ line-height: var(--duro-type-scale-line-height4);
29
+ overflow-wrap: break-word;
30
+ }
31
+
32
+ /* Flow: one gap between blocks, none before the first. */
33
+ :where(.duro-prose, [data-duro-prose]) > :where(* + *) {
34
+ margin-block-start: var(--duro-spacing-md);
35
+ }
36
+
37
+ /* Headings: the Heading presets, with more room above than below. */
38
+ :where(.duro-prose, [data-duro-prose]) :where(h1, h2, h3, h4, h5, h6) {
39
+ font-family: var(--duro-typography-font-family);
40
+ letter-spacing: var(--duro-type-scale-letter-spacing-tight);
41
+ margin-block-start: var(--duro-spacing-xl);
42
+ margin-block-end: var(--duro-spacing-sm);
43
+ /* A table-of-contents jump lands with room above the heading. */
44
+ scroll-margin-top: var(--duro-spacing-lg);
45
+ }
46
+ :where(.duro-prose, [data-duro-prose]) :where(h1) {
47
+ font-size: var(--duro-type-scale-font-size9);
48
+ line-height: var(--duro-type-scale-line-height9);
49
+ font-weight: var(--duro-typography-font-weight-bold);
50
+ }
51
+ /* h2 down follow the picked docs artboard (Reader.dc.html): a reading
52
+ column's sections are a step below the page title, not a second one. */
53
+ :where(.duro-prose, [data-duro-prose]) :where(h2) {
54
+ font-size: var(--duro-type-scale-font-size6);
55
+ line-height: var(--duro-type-scale-line-height6);
56
+ font-weight: var(--duro-typography-font-weight-semibold);
57
+ margin-block-start: var(--duro-spacing-lg);
58
+ }
59
+ :where(.duro-prose, [data-duro-prose]) :where(h3) {
60
+ font-size: var(--duro-type-scale-font-size5);
61
+ line-height: var(--duro-type-scale-line-height5);
62
+ font-weight: var(--duro-typography-font-weight-semibold);
63
+ margin-block-start: var(--duro-spacing-lg);
64
+ }
65
+ :where(.duro-prose, [data-duro-prose]) :where(h4, h5, h6) {
66
+ font-size: var(--duro-type-scale-font-size4);
67
+ line-height: var(--duro-type-scale-line-height4);
68
+ font-weight: var(--duro-typography-font-weight-semibold);
69
+ margin-block-start: var(--duro-spacing-md);
70
+ }
71
+ :where(.duro-prose, [data-duro-prose]) > :where(:first-child) {
72
+ margin-block-start: 0;
73
+ }
74
+
75
+ /* Lists: markers back (the reset removes the indent they hang in). */
76
+ :where(.duro-prose, [data-duro-prose]) :where(ul, ol) {
77
+ padding-inline-start: var(--duro-spacing-lg);
78
+ }
79
+ :where(.duro-prose, [data-duro-prose]) :where(ul) {
80
+ list-style-type: disc;
81
+ }
82
+ :where(.duro-prose, [data-duro-prose]) :where(ol) {
83
+ list-style-type: decimal;
84
+ }
85
+ :where(.duro-prose, [data-duro-prose]) :where(li + li, li > ul, li > ol) {
86
+ margin-block-start: var(--duro-spacing-xs);
87
+ }
88
+
89
+ /* Links: the accent, underlined so they read as links in running text. */
90
+ :where(.duro-prose, [data-duro-prose]) :where(a) {
91
+ color: var(--duro-color-accent);
92
+ text-decoration-line: underline;
93
+ text-underline-offset: var(--duro-micro-spacing-px2);
94
+ }
95
+ :where(.duro-prose, [data-duro-prose]) :where(a:hover) {
96
+ color: var(--duro-color-accent-hover);
97
+ }
98
+ :where(.duro-prose, [data-duro-prose]) :where(a:focus-visible) {
99
+ outline: var(--duro-border-focus-ring) solid var(--duro-color-accent);
100
+ outline-offset: var(--duro-border-focus-offset);
101
+ border-radius: var(--duro-radius-xs);
102
+ }
103
+
104
+ :where(.duro-prose, [data-duro-prose]) :where(strong, b) {
105
+ font-weight: var(--duro-typography-font-weight-semibold);
106
+ }
107
+
108
+ :where(.duro-prose, [data-duro-prose]) :where(blockquote) {
109
+ padding-inline-start: var(--duro-spacing-md);
110
+ border-inline-start: var(--duro-border-accent) solid var(--duro-color-border);
111
+ color: var(--duro-color-text-muted);
112
+ }
113
+
114
+ /* Inline code; a heading keeps its own size around it. */
115
+ :where(.duro-prose, [data-duro-prose]) :where(code) {
116
+ font-family: var(--duro-typography-font-family-mono);
117
+ font-size: var(--duro-type-scale-font-size3);
118
+ padding-inline: var(--duro-spacing-xs);
119
+ background-color: var(--duro-color-bg-card);
120
+ border: var(--duro-border-hairline) solid var(--duro-color-border);
121
+ border-radius: var(--duro-radius-xs);
122
+ }
123
+ :where(.duro-prose, [data-duro-prose]) :where(h1, h2, h3, h4, h5, h6) :where(code) {
124
+ font-size: inherit;
125
+ }
126
+
127
+ /* A code block outside CodeBlock (CodeBlock styles its own). */
128
+ :where(.duro-prose, [data-duro-prose]) :where(pre) {
129
+ font-family: var(--duro-typography-font-family-mono);
130
+ font-size: var(--duro-type-scale-font-size3);
131
+ line-height: var(--duro-type-scale-line-height3);
132
+ padding: var(--duro-spacing-md);
133
+ overflow-x: auto;
134
+ background-color: var(--duro-color-bg-card);
135
+ border: var(--duro-border-hairline) solid var(--duro-color-border);
136
+ border-radius: var(--duro-radius-md);
137
+ }
138
+ :where(.duro-prose, [data-duro-prose]) :where(pre code) {
139
+ padding: 0;
140
+ font-size: inherit;
141
+ background-color: transparent;
142
+ border: 0;
143
+ }
144
+
145
+ /* Tables as the docs artboard draws them: the column's full width,
146
+ horizontal rules only, a muted header. A long cell wraps rather than
147
+ widening the page (a full-width table cannot also scroll on its own). */
148
+ :where(.duro-prose, [data-duro-prose]) :where(table) {
149
+ width: 100%;
150
+ border-collapse: collapse;
151
+ font-size: var(--duro-type-scale-font-size3);
152
+ line-height: var(--duro-type-scale-line-height3);
153
+ }
154
+ :where(.duro-prose, [data-duro-prose]) :where(th, td) {
155
+ padding: var(--duro-spacing-xs) var(--duro-spacing-sm);
156
+ border-block-end: var(--duro-border-hairline) solid var(--duro-color-border);
157
+ text-align: start;
158
+ vertical-align: top;
159
+ overflow-wrap: anywhere;
160
+ }
161
+ :where(.duro-prose, [data-duro-prose]) :where(th) {
162
+ color: var(--duro-color-text-muted);
163
+ font-weight: var(--duro-typography-font-weight-medium);
164
+ }
165
+
166
+ :where(.duro-prose, [data-duro-prose]) :where(img) {
167
+ max-width: 100%;
168
+ height: auto;
169
+ border-radius: var(--duro-radius-sm);
170
+ }
171
+
172
+ :where(.duro-prose, [data-duro-prose]) :where(hr) {
173
+ border: 0;
174
+ border-block-start: var(--duro-border-hairline) solid var(--duro-color-border);
175
+ margin-block: var(--duro-spacing-xl);
176
+ }
177
+ }
@@ -2,6 +2,7 @@ import type {Meta, StoryObj} from '@storybook/react'
2
2
  import {expect, fn} from 'storybook/test'
3
3
  import {SideNav} from './SideNav'
4
4
  import {Icon} from '../Icon/Icon'
5
+ import {onThemeSurface} from '../../docs/themedSurface'
5
6
 
6
7
  const meta: Meta<typeof SideNav.Root> = {
7
8
  title: 'Components/SideNav',
@@ -200,3 +201,31 @@ export const FlatWithIcons: Story = {
200
201
  await expect(identities).toHaveAttribute('aria-current', 'page')
201
202
  },
202
203
  }
204
+
205
+ /** `aria-label` names the landmark, so a page with a breadcrumb or a pager
206
+ * beside it still lists the side navigation by name. */
207
+ export const Named: Story = {
208
+ parameters: {
209
+ a11y: {
210
+ test: 'error',
211
+ // holds-until: https://github.com/fredericrous/duro-design-system/issues/79
212
+ // — the selected item's accent on bgCardHover is 6.76:1, under AAA's
213
+ // 7:1 (AA passes). Re-enable this rule when that pair reaches 7:1.
214
+ options: {rules: {'color-contrast-enhanced': {enabled: false}}},
215
+ },
216
+ },
217
+ decorators: [onThemeSurface],
218
+ render: (args) => (
219
+ <SideNav.Root {...args} aria-label="Settings" defaultValue="profile">
220
+ <SideNav.Section label="Account">
221
+ <SideNav.Item value="profile">Profile</SideNav.Item>
222
+ <SideNav.Item value="security">Security</SideNav.Item>
223
+ </SideNav.Section>
224
+ </SideNav.Root>
225
+ ),
226
+ play: async ({canvas}) => {
227
+ const nav = canvas.getByRole('navigation', {name: 'Settings'})
228
+ await expect(nav).toHaveAttribute('aria-label', 'Settings')
229
+ await expect(canvas.getByRole('button', {name: 'Profile'})).toBeInTheDocument()
230
+ },
231
+ }
@@ -34,12 +34,24 @@ import {SideNavContext, useSideNav} from './SideNavContext'
34
34
 
35
35
  interface RootProps {
36
36
  children: ReactNode
37
+ /**
38
+ * Names the navigation landmark ("Main", "Settings"). Give one whenever
39
+ * the page has another nav (a breadcrumb, a pager): screen readers list
40
+ * landmarks by name.
41
+ */
42
+ 'aria-label'?: string
37
43
  value?: string
38
44
  defaultValue?: string
39
45
  onValueChange?: (value: string) => void
40
46
  }
41
47
 
42
- function Root({children, value: controlledValue, defaultValue, onValueChange}: RootProps) {
48
+ function Root({
49
+ children,
50
+ 'aria-label': ariaLabel,
51
+ value: controlledValue,
52
+ defaultValue,
53
+ onValueChange,
54
+ }: RootProps) {
43
55
  const [activeValue, onSelect] = useControllableValue<string | null>(
44
56
  controlledValue,
45
57
  defaultValue ?? null,
@@ -98,7 +110,7 @@ function Root({children, value: controlledValue, defaultValue, onValueChange}: R
98
110
  orderRef,
99
111
  }}
100
112
  >
101
- <html.nav role="navigation" style={styles.root}>
113
+ <html.nav role="navigation" aria-label={ariaLabel} style={styles.root}>
102
114
  {children}
103
115
  </html.nav>
104
116
  </SideNavContext.Provider>
@@ -0,0 +1,17 @@
1
+ import type {ComponentMeta} from '../component-meta'
2
+
3
+ export const meta: ComponentMeta = {
4
+ description:
5
+ 'A date or time in running text as a <time> element: the visible text comes from you, the exact instant goes in dateTime (an ISO string) and title. relative(date, {now, locale}) gives the text on Intl.RelativeTimeFormat ("3 days ago", "il y a 3 jours"); now is required, so the server and the client render the same string. Web only.',
6
+ whenToUse: [
7
+ 'When something happened: "Updated 3 days ago", a commit date, a last-indexed time',
8
+ 'Any date shown as relative text whose exact value should stay available',
9
+ ],
10
+ whenNotToUse: [
11
+ 'A date the person edits — use an Input',
12
+ 'A live countdown or a clock — Time renders once from the now you pass',
13
+ ],
14
+ example: `<Time dateTime={doc.lastCommitAt} title={formatFull(doc.lastCommitAt, locale)}>
15
+ {relative(doc.lastCommitAt, {now: loaderNow, locale})}
16
+ </Time>`,
17
+ }
@@ -0,0 +1,173 @@
1
+ import type {Meta, StoryObj} from '@storybook/react'
2
+ import {expect, waitFor} from 'storybook/test'
3
+ import {html} from 'react-strict-dom'
4
+ import {renderToString} from 'react-dom/server'
5
+ import {hydrateRoot} from 'react-dom/client'
6
+ import {Time} from './Time'
7
+ import {relative} from './relative'
8
+ import {Stack} from '../Stack/Stack'
9
+ import {Text} from '../Text/Text'
10
+ import {onThemeSurface} from '../../docs/themedSurface'
11
+
12
+ // A fixed instant, as a loader would pass it: never the clock during render.
13
+ const NOW = Date.UTC(2026, 9, 8, 12, 0, 0)
14
+ const COMMIT = '2026-10-05T09:30:00.000Z'
15
+ const DAY_MS = 24 * 60 * 60 * 1000
16
+
17
+ type Locale = 'en' | 'fr' | 'de'
18
+
19
+ interface TimeArgs {
20
+ /** The instant shown, in days from `now` (negative: in the past). */
21
+ offsetDays: number
22
+ /** The locale `relative` and the title use. */
23
+ locale: Locale
24
+ /** `auto` says "yesterday" where the locale has a word; `always` keeps the number. */
25
+ numeric: 'auto' | 'always'
26
+ /** The text before the date. */
27
+ label: string
28
+ }
29
+
30
+ const meta = {
31
+ title: 'Components/Time',
32
+ args: {offsetDays: -3, locale: 'en', numeric: 'auto', label: 'Updated'},
33
+ argTypes: {
34
+ offsetDays: {control: {type: 'range', min: -400, max: 400, step: 1}},
35
+ locale: {
36
+ control: {type: 'select', labels: {en: 'English', fr: 'Français', de: 'Deutsch'}},
37
+ options: ['en', 'fr', 'de'],
38
+ },
39
+ numeric: {control: 'radio', options: ['auto', 'always']},
40
+ label: {control: 'text'},
41
+ },
42
+ parameters: {a11y: {test: 'error'}},
43
+ decorators: [onThemeSurface],
44
+ render: ({offsetDays, locale, numeric, label}) => {
45
+ const iso = new Date(NOW + offsetDays * DAY_MS).toISOString()
46
+ return (
47
+ <Text variant="bodySm" color="muted">
48
+ {label}{' '}
49
+ <Time dateTime={iso} title={full(iso, locale)}>
50
+ {relative(iso, {now: NOW, locale, numeric})}
51
+ </Time>
52
+ </Text>
53
+ )
54
+ },
55
+ } satisfies Meta<TimeArgs>
56
+
57
+ export default meta
58
+ type Story = StoryObj<typeof meta>
59
+
60
+ const TEST_ONLY = ['!dev', '!autodocs']
61
+
62
+ function full(iso: string, locale: string) {
63
+ return new Intl.DateTimeFormat(locale, {
64
+ dateStyle: 'long',
65
+ timeStyle: 'short',
66
+ timeZone: 'UTC',
67
+ }).format(new Date(iso))
68
+ }
69
+
70
+ function Updated({locale, label}: {locale: string; label: string}) {
71
+ return (
72
+ <Text variant="bodySm" color="muted">
73
+ {label}{' '}
74
+ <Time dateTime={COMMIT} title={full(COMMIT, locale)}>
75
+ {relative(COMMIT, {now: NOW, locale})}
76
+ </Time>
77
+ </Text>
78
+ )
79
+ }
80
+
81
+ /**
82
+ * A date relative to a fixed `now` (2026-10-08 12:00 UTC). Move `offsetDays`
83
+ * to walk through the units (days, weeks, months, years), switch `locale`, and
84
+ * `numeric` to always for "1 day ago" instead of "yesterday". Hover the date
85
+ * for the exact instant (title).
86
+ */
87
+ export const Playground: Story = {}
88
+
89
+ /** Playground's checks, kept off the visible story so a Control change cannot fail them. */
90
+ export const PlaygroundChecks: Story = {
91
+ ...Playground,
92
+ tags: TEST_ONLY,
93
+ play: async ({args, canvasElement}) => {
94
+ const time = canvasElement.querySelector('time')!
95
+ const iso = new Date(NOW + args.offsetDays * DAY_MS).toISOString()
96
+ await expect(time).toHaveAttribute('datetime', iso)
97
+ await expect(time).toHaveAttribute('title', full(iso, args.locale))
98
+ },
99
+ }
100
+
101
+ /** The words come from the locale; the exact instant stays in dateTime and title. */
102
+ export const TwoLocales: Story = {
103
+ render: () => (
104
+ <Stack gap="sm">
105
+ <Updated locale="en" label="Updated" />
106
+ <Updated locale="fr" label="Mis à jour" />
107
+ </Stack>
108
+ ),
109
+ }
110
+
111
+ /** TwoLocales' checks, kept off the visible story so a Control change cannot fail them. */
112
+ export const TwoLocalesChecks: Story = {
113
+ ...TwoLocales,
114
+ tags: TEST_ONLY,
115
+ play: async ({canvasElement}) => {
116
+ const times = [...canvasElement.querySelectorAll('time')]
117
+ await expect(times).toHaveLength(2)
118
+ await expect(times[0]).toHaveTextContent('3 days ago')
119
+ await expect(times[1]).toHaveTextContent('il y a 3 jours')
120
+ for (const time of times) {
121
+ await expect(time).toHaveAttribute('datetime', COMMIT)
122
+ }
123
+ await expect(times[0]).toHaveAttribute('title', full(COMMIT, 'en'))
124
+ await expect(times[1]).toHaveAttribute('title', full(COMMIT, 'fr'))
125
+ },
126
+ }
127
+
128
+ /** A Date is written as its ISO string. */
129
+ export const FromDate: Story = {
130
+ tags: TEST_ONLY,
131
+ render: () => (
132
+ <Text>
133
+ <Time dateTime={new Date(NOW)}>{relative(NOW, {now: NOW, locale: 'en'})}</Time>
134
+ </Text>
135
+ ),
136
+ play: async ({canvasElement}) => {
137
+ const time = canvasElement.querySelector('time')!
138
+ await expect(time).toHaveAttribute('datetime', new Date(NOW).toISOString())
139
+ await expect(time).toHaveTextContent('now')
140
+ },
141
+ }
142
+
143
+ /** Server and client render the same text from the same `now`: hydration
144
+ * reports nothing. It replaces the canvas's content, so it runs as a test only. */
145
+ export const ServerRender: Story = {
146
+ tags: TEST_ONLY,
147
+ render: () => <html.div data-ssr-host="" />,
148
+ play: async ({canvasElement}) => {
149
+ const host = canvasElement.querySelector('[data-ssr-host]') as HTMLElement
150
+ const tree = <Updated locale="en" label="Updated" />
151
+ host.innerHTML = renderToString(tree)
152
+ const serverText = host.textContent
153
+ await expect(host.querySelector('time')).toHaveTextContent('3 days ago')
154
+
155
+ const errors: unknown[] = []
156
+ const consoleError = console.error
157
+ console.error = (...args: unknown[]) => {
158
+ errors.push(args)
159
+ consoleError(...args)
160
+ }
161
+ const root = hydrateRoot(host, tree, {onRecoverableError: (error) => errors.push(error)})
162
+ try {
163
+ // let React finish hydrating before reading the result
164
+ await waitFor(() => expect(host.querySelector('time')).toHaveTextContent('3 days ago'))
165
+ await new Promise((resolve) => setTimeout(resolve, 50))
166
+ await expect(host.textContent).toBe(serverText)
167
+ await expect(errors).toEqual([])
168
+ } finally {
169
+ console.error = consoleError
170
+ root.unmount()
171
+ }
172
+ },
173
+ }
@@ -0,0 +1,33 @@
1
+ import type {ReactNode} from 'react'
2
+
3
+ interface TimeProps {
4
+ /**
5
+ * The instant, machine-readable: a `Date` (written as its ISO string) or an
6
+ * ISO 8601 string, which is passed through unchanged.
7
+ */
8
+ dateTime: Date | string
9
+ /**
10
+ * The exact date and time, shown on hover ("8 October 2026, 14:03 UTC"),
11
+ * formatted by you in the reader's locale.
12
+ */
13
+ title?: string
14
+ /** What the reader sees: a relative label from `relative()`, or a date. */
15
+ children: ReactNode
16
+ }
17
+
18
+ /**
19
+ * Time — a date or time in running text as a `<time>` element: the visible
20
+ * text is yours (often `relative(date, {now, locale})`), the exact instant
21
+ * goes in `dateTime` for machines and `title` for people.
22
+ *
23
+ * Web only. A raw `<time>`: react-strict-dom has no `html.time`, and the
24
+ * element carries no style of its own (it inherits the text around it).
25
+ */
26
+ export function Time({dateTime, title, children}: TimeProps) {
27
+ const iso = typeof dateTime === 'string' ? dateTime : dateTime.toISOString()
28
+ return (
29
+ <time dateTime={iso} title={title}>
30
+ {children}
31
+ </time>
32
+ )
33
+ }
@@ -0,0 +1,89 @@
1
+ // Pure date arithmetic behind Time: no React, no react-strict-dom, so the
2
+ // unit project tests it in Node.
3
+
4
+ export interface RelativeOptions {
5
+ /**
6
+ * The instant to measure from. Required: read the clock once where the data
7
+ * is loaded (a route loader) and pass the same value to the server and the
8
+ * client render, so both produce the same text. Never `Date.now()` during a
9
+ * render — the client would hydrate a different string.
10
+ */
11
+ now: Date | number
12
+ /** BCP 47 locale(s) for the words ("3 days ago", "il y a 3 jours"). */
13
+ locale: string | readonly string[]
14
+ /**
15
+ * `auto` (default) says "yesterday" / "tomorrow" / "now" where the locale has
16
+ * a word for it; `always` keeps the number ("1 day ago").
17
+ */
18
+ numeric?: 'auto' | 'always'
19
+ }
20
+
21
+ type Step = readonly [Intl.RelativeTimeFormatUnit, number]
22
+
23
+ // Each unit is used while |difference| stays below the next one. Months and
24
+ // years are average lengths: a relative label is a rounded reading, the
25
+ // exact instant goes in the dateTime attribute.
26
+ const SECOND = 1000
27
+ const MINUTE = 60 * SECOND
28
+ const HOUR = 60 * MINUTE
29
+ const DAY = 24 * HOUR
30
+ const WEEK = 7 * DAY
31
+ const MONTH = 30.436875 * DAY
32
+ const YEAR = 365.2425 * DAY
33
+
34
+ const STEPS: readonly Step[] = [
35
+ ['second', SECOND],
36
+ ['minute', MINUTE],
37
+ ['hour', HOUR],
38
+ ['day', DAY],
39
+ ['week', WEEK],
40
+ ['month', MONTH],
41
+ ['year', YEAR],
42
+ ]
43
+
44
+ const LIMITS: Record<Intl.RelativeTimeFormatUnit, number> = {
45
+ second: MINUTE,
46
+ seconds: MINUTE,
47
+ minute: HOUR,
48
+ minutes: HOUR,
49
+ hour: DAY,
50
+ hours: DAY,
51
+ day: WEEK,
52
+ days: WEEK,
53
+ week: MONTH,
54
+ weeks: MONTH,
55
+ month: YEAR,
56
+ months: YEAR,
57
+ quarter: Infinity,
58
+ quarters: Infinity,
59
+ year: Infinity,
60
+ years: Infinity,
61
+ }
62
+
63
+ function toTime(value: Date | number | string): number {
64
+ return typeof value === 'number' ? value : new Date(value).getTime()
65
+ }
66
+
67
+ /**
68
+ * How far `date` is from `now`, in words, in the caller's locale:
69
+ * `relative(lastCommit, {now, locale: 'en'})` → "3 days ago". Built on
70
+ * `Intl.RelativeTimeFormat`; the largest unit that keeps the count at least 1
71
+ * is used, rounded to the nearest whole unit.
72
+ *
73
+ * An unparseable date throws a `RangeError` (as `Intl` does): check the data
74
+ * before rendering it.
75
+ */
76
+ export function relative(date: Date | number | string, options: RelativeOptions): string {
77
+ const {now, locale, numeric = 'auto'} = options
78
+ const target = toTime(date)
79
+ const from = toTime(now)
80
+ if (!Number.isFinite(target) || !Number.isFinite(from)) {
81
+ throw new RangeError(`relative: invalid date ${String(date)}`)
82
+ }
83
+ const diff = target - from
84
+ const [unit, size] =
85
+ STEPS.find(([unit]) => Math.abs(diff) < LIMITS[unit]) ?? STEPS[STEPS.length - 1]
86
+ // `+ 0` turns a -0 into 0, so "now" is not "0 seconds ago" in `always` mode.
87
+ const value = Math.round(diff / size) + 0
88
+ return new Intl.RelativeTimeFormat(locale as string | string[], {numeric}).format(value, unit)
89
+ }
@@ -0,0 +1,39 @@
1
+ import {useEffect, useState, type RefObject} from 'react'
2
+ import {breakpointsPx, type Breakpoint} from '@duro-app/tokens/tokens/breakpoints.css'
3
+
4
+ /**
5
+ * Whether the element behind `ref` is narrower than a Duro breakpoint — its
6
+ * own width, as a container query sees it, not the window's. For the choices
7
+ * CSS cannot make (a table of contents as a list or a menu, a row of buttons
8
+ * or an overflow `Menu`).
9
+ *
10
+ * `false` on the server and until the first measurement, so the server and
11
+ * the first client render agree on the wide layout; a ResizeObserver then
12
+ * follows the element. A width of 0 (the element is not laid out, e.g.
13
+ * `display: none`) keeps the last answer.
14
+ *
15
+ * Narrower than means `width < breakpoint`: at exactly 768px an element is not
16
+ * below `md`, the same line `Grid`'s named layouts collapse on.
17
+ */
18
+ export function useContainerBelow(
19
+ ref: RefObject<HTMLElement | null>,
20
+ breakpoint: Breakpoint,
21
+ ): boolean {
22
+ const [below, setBelow] = useState(false)
23
+ const limit = breakpointsPx[breakpoint]
24
+
25
+ useEffect(() => {
26
+ const element = ref.current
27
+ if (!element || typeof ResizeObserver === 'undefined') return
28
+ const observer = new ResizeObserver((entries) => {
29
+ const entry = entries[0]
30
+ if (!entry) return
31
+ const width = entry.contentBoxSize?.[0]?.inlineSize ?? entry.contentRect.width
32
+ if (width > 0) setBelow(width < limit)
33
+ })
34
+ observer.observe(element)
35
+ return () => observer.disconnect()
36
+ }, [ref, limit])
37
+
38
+ return below
39
+ }
package/src/index.ts CHANGED
@@ -10,6 +10,12 @@ import './strict.css'
10
10
  export {ActionBar, type ActionBarProps} from './components/ActionBar/ActionBar'
11
11
  export {ActionBarProvider} from './components/ActionBar/ActionBarProvider'
12
12
  export {Alert, type AlertVariant} from './components/Alert/Alert'
13
+ export {
14
+ AppShell,
15
+ type AppShellCollapse,
16
+ type AppShellRailState,
17
+ } from './components/AppShell/AppShell'
18
+ export {Aside} from './components/Aside/Aside'
13
19
  export {Icon, type IconName} from './components/Icon'
14
20
  export {Badge, type BadgeVariant, type BadgeSize} from './components/Badge/Badge'
15
21
  export {Breadcrumb} from './components/Breadcrumb/Breadcrumb'
@@ -19,6 +25,7 @@ export {ButtonGroup, type ButtonGroupProps} from './components/ButtonGroup/Butto
19
25
  export {Callout, type CalloutVariant} from './components/Callout/Callout'
20
26
  export {Card, type CardVariant, type CardSize} from './components/Card/Card'
21
27
  export {Checkbox} from './components/Checkbox/Checkbox'
28
+ export {CodeBlock} from './components/CodeBlock/CodeBlock'
22
29
  export {CheckboxGroup} from './components/CheckboxGroup/CheckboxGroup'
23
30
  export {ColorInput} from './components/ColorInput/ColorInput'
24
31
  export {ConfirmDialog} from './components/ConfirmDialog/ConfirmDialog'
@@ -41,6 +48,7 @@ export {Drawer, type DrawerAnchor, type DrawerSize} from './components/Drawer/Dr
41
48
  export {EmptyState} from './components/EmptyState/EmptyState'
42
49
  export {Heading, type HeadingVariant} from './components/Heading/Heading'
43
50
  export {List, type ListSelectionMode} from './components/List/List'
51
+ export {LiveRegion, type LiveRegionPoliteness} from './components/LiveRegion/LiveRegion'
44
52
  export {Field} from './components/Field/Field'
45
53
  export type {LabelPosition, NecessityIndicator} from './components/Form/FormContext'
46
54
  export {Fieldset, type FieldsetGap} from './components/Fieldset/Fieldset'
@@ -55,6 +63,7 @@ export {Menu} from './components/Menu/Menu'
55
63
  export {Meter, type MeterProps, type MeterTone} from './components/Meter/Meter'
56
64
  export {PageNav} from './components/PageNav/PageNav'
57
65
  export {Panel} from './components/Panel/Panel'
66
+ export {Prose} from './components/Prose/Prose'
58
67
  export {RadioGroup} from './components/RadioGroup/RadioGroup'
59
68
  export {ScrollArea} from './components/ScrollArea/ScrollArea'
60
69
  export {Combobox} from './components/Combobox/Combobox'
@@ -114,6 +123,8 @@ export {
114
123
  type ColorModePreference,
115
124
  } from './components/ColorMode'
116
125
  export {Text, type TextVariant, type TextColor} from './components/Text/Text'
126
+ export {Time} from './components/Time/Time'
127
+ export {relative, type RelativeOptions} from './components/Time/relative'
117
128
  export {Toggle, type ToggleSize} from './components/Toggle/Toggle'
118
129
  export {ToggleGroup} from './components/ToggleGroup/ToggleGroup'
119
130
  export {Tooltip} from './components/Tooltip/Tooltip'
@@ -127,7 +138,7 @@ export {Switch} from './components/Switch/Switch'
127
138
  export {Stack, type SpacingKey} from './components/Stack/Stack'
128
139
  export {Inline} from './components/Inline/Inline'
129
140
  export {Cluster} from './components/Cluster/Cluster'
130
- export {Grid} from './components/Grid/Grid'
141
+ export {Grid, type GridLayout} from './components/Grid/Grid'
131
142
  export type {GridTrack} from './shared/length'
132
143
  export {PageShell, type PageShellMaxWidth, type PageShellPadding} from './components/PageShell'
133
144
 
@@ -136,6 +147,7 @@ export type {LinkClickEvent, OnNavigate} from './shared/navigate'
136
147
 
137
148
  // Hooks
138
149
  export {useContainerQuery, type ContainerSize} from './hooks/useContainerQuery'
150
+ export {useContainerBelow} from './hooks/useContainerBelow'
139
151
 
140
152
  // Tokens — import directly from @duro-app/tokens with deep imports:
141
153
  // import { colors } from '@duro-app/tokens/tokens/colors.css'
package/src/strict.css CHANGED
@@ -1,4 +1,7 @@
1
- @layer reset;
1
+ /* Layer order for the whole page: the reset, then Prose's descendant rules
2
+ * (above the reset's margins, below every component style), then StyleX's
3
+ * priority layers, which @react-strict-dom declares. */
4
+ @layer reset, duro-prose;
2
5
  @react-strict-dom;
3
6
 
4
7
  /* Table: reveal sort indicator on header cell hover */