@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,139 @@
1
+ import {useState} from 'react'
2
+ import type {Meta, StoryObj} from '@storybook/react'
3
+ import {expect, fn} from 'storybook/test'
4
+ import {LiveRegion, type LiveRegionPoliteness} from './LiveRegion'
5
+ import {Button} from '../Button/Button'
6
+ import {Inline} from '../Inline/Inline'
7
+ import {Stack} from '../Stack/Stack'
8
+ import {onThemeSurface} from '../../docs/themedSurface'
9
+
10
+ interface LiveRegionArgs {
11
+ /** `polite` (role="status") waits for a pause; `assertive` (role="alert") interrupts. */
12
+ politeness: LiveRegionPoliteness
13
+ /** Off screen, still in the accessibility tree. */
14
+ visuallyHidden: boolean
15
+ /** What the region announces when the trigger button is pressed. */
16
+ message: string
17
+ /** The trigger button's label. */
18
+ triggerLabel: string
19
+ /** The button that empties the region. */
20
+ clearLabel: string
21
+ /** Called with the message the region now holds. */
22
+ onAnnounce: (message: string) => void
23
+ }
24
+
25
+ /**
26
+ * The region is mounted empty from the start; a button puts the message in
27
+ * it, which is what assistive tech announces.
28
+ */
29
+ function Announcer({
30
+ politeness,
31
+ visuallyHidden,
32
+ message,
33
+ triggerLabel,
34
+ clearLabel,
35
+ onAnnounce,
36
+ }: LiveRegionArgs) {
37
+ const [content, setContent] = useState('')
38
+ return (
39
+ <Stack gap="sm">
40
+ <Inline gap="sm">
41
+ <Button
42
+ variant="secondary"
43
+ onClick={() => {
44
+ setContent(message)
45
+ onAnnounce(message)
46
+ }}
47
+ >
48
+ {triggerLabel}
49
+ </Button>
50
+ <Button variant="secondary" onClick={() => setContent('')}>
51
+ {clearLabel}
52
+ </Button>
53
+ </Inline>
54
+ <LiveRegion politeness={politeness} visuallyHidden={visuallyHidden}>
55
+ {content}
56
+ </LiveRegion>
57
+ </Stack>
58
+ )
59
+ }
60
+
61
+ const meta = {
62
+ title: 'Components/LiveRegion',
63
+ args: {
64
+ politeness: 'polite',
65
+ visuallyHidden: false,
66
+ message: 'Link copied',
67
+ triggerLabel: 'Copy link',
68
+ clearLabel: 'Clear',
69
+ onAnnounce: fn(),
70
+ },
71
+ argTypes: {
72
+ politeness: {control: 'radio', options: ['polite', 'assertive']},
73
+ visuallyHidden: {control: 'boolean'},
74
+ message: {control: 'text'},
75
+ triggerLabel: {control: 'text'},
76
+ clearLabel: {control: 'text'},
77
+ },
78
+ parameters: {a11y: {test: 'error'}},
79
+ decorators: [onThemeSurface],
80
+ render: (args) => <Announcer {...args} />,
81
+ } satisfies Meta<LiveRegionArgs>
82
+
83
+ export default meta
84
+ type Story = StoryObj<typeof meta>
85
+
86
+ const TEST_ONLY = ['!dev', '!autodocs']
87
+
88
+ /**
89
+ * Press the trigger: the message appears in the region (unless
90
+ * `visuallyHidden`) and the Actions panel logs `onAnnounce`. Switch
91
+ * `politeness` to assertive for role="alert".
92
+ */
93
+ export const Playground: Story = {}
94
+
95
+ /** Mounted empty from the start; a change of content is what gets announced. */
96
+ export const Polite: Story = {
97
+ tags: TEST_ONLY,
98
+ play: async ({args, canvas, userEvent}) => {
99
+ const region = canvas.getByRole('status')
100
+ await expect(region).toHaveAttribute('aria-live', 'polite')
101
+ await expect(region).toHaveAttribute('aria-atomic', 'true')
102
+ await expect(region).toBeEmptyDOMElement()
103
+
104
+ await userEvent.click(canvas.getByRole('button', {name: args.triggerLabel}))
105
+ // the same element, now holding the message: a change, not a new region
106
+ await expect(canvas.getByRole('status')).toBe(region)
107
+ await expect(region).toHaveTextContent(args.message)
108
+ await expect(args.onAnnounce).toHaveBeenCalledWith(args.message)
109
+
110
+ await userEvent.click(canvas.getByRole('button', {name: args.clearLabel}))
111
+ await expect(region).toBeInTheDocument()
112
+ await expect(canvas.getByRole('status')).toBe(region)
113
+ await expect(region).toBeEmptyDOMElement()
114
+ },
115
+ }
116
+
117
+ export const Assertive: Story = {
118
+ tags: TEST_ONLY,
119
+ render: () => <LiveRegion politeness="assertive">Connection lost</LiveRegion>,
120
+ play: async ({canvas}) => {
121
+ const region = canvas.getByRole('alert')
122
+ await expect(region).toHaveAttribute('aria-live', 'assertive')
123
+ await expect(region).toHaveTextContent('Connection lost')
124
+ },
125
+ }
126
+
127
+ /** Off screen, still in the accessibility tree. */
128
+ export const VisuallyHidden: Story = {
129
+ tags: TEST_ONLY,
130
+ args: {visuallyHidden: true},
131
+ play: async ({args, canvas, userEvent}) => {
132
+ await userEvent.click(canvas.getByRole('button', {name: args.triggerLabel}))
133
+ const region = canvas.getByRole('status')
134
+ await expect(region).toHaveTextContent(args.message)
135
+ const box = region.getBoundingClientRect()
136
+ await expect(box.width).toBeLessThanOrEqual(1)
137
+ await expect(box.height).toBeLessThanOrEqual(1)
138
+ },
139
+ }
@@ -0,0 +1,48 @@
1
+ import type {ReactNode} from 'react'
2
+ import {html} from 'react-strict-dom'
3
+ import {visuallyHidden as hidden} from '../../styles/visually-hidden.css'
4
+
5
+ export type LiveRegionPoliteness = 'polite' | 'assertive'
6
+
7
+ interface LiveRegionProps {
8
+ /**
9
+ * What to announce. Change it to announce; leave it empty when there is
10
+ * nothing to say — never unmount the region to clear it.
11
+ */
12
+ children?: ReactNode
13
+ /**
14
+ * `polite` (default) waits for the screen reader to finish (`role="status"`):
15
+ * "Copied", "3 results". `assertive` interrupts (`role="alert"`): only for
16
+ * what the person must hear now, such as an error that blocks them.
17
+ */
18
+ politeness?: LiveRegionPoliteness
19
+ /** Keeps the region off screen and in the accessibility tree. Default false. */
20
+ visuallyHidden?: boolean
21
+ id?: string
22
+ }
23
+
24
+ /**
25
+ * LiveRegion — tells assistive tech about a change that has no focus move to
26
+ * go with it ("Link copied", "Answer ready"). A screen reader only announces
27
+ * a change to a region that was already in the page, so render it from the
28
+ * start (empty) and change its content; a region mounted together with its
29
+ * message is often not read at all.
30
+ */
31
+ export function LiveRegion({
32
+ children,
33
+ politeness = 'polite',
34
+ visuallyHidden = false,
35
+ id,
36
+ }: LiveRegionProps) {
37
+ return (
38
+ <html.div
39
+ id={id}
40
+ role={politeness === 'assertive' ? 'alert' : 'status'}
41
+ aria-live={politeness}
42
+ aria-atomic={true}
43
+ style={visuallyHidden ? [hidden.base, hidden.text] : null}
44
+ >
45
+ {children}
46
+ </html.div>
47
+ )
48
+ }
@@ -8,6 +8,9 @@ import {Menu} from './Menu'
8
8
  import {Button} from '../Button/Button'
9
9
  import {Dialog} from '../Dialog/Dialog'
10
10
  import {Icon} from '../Icon/Icon'
11
+ import {onThemeSurface} from '../../docs/themedSurface'
12
+ import {SIZES_PX} from '@duro-app/tokens/keys'
13
+ import {withCoarsePointer, withFinePointer} from '../../docs/coarsePointer'
11
14
 
12
15
  const meta: Meta = {
13
16
  title: 'Components/Menu',
@@ -477,3 +480,62 @@ async function realKeyboard(): Promise<((keys: string) => Promise<void>) | null>
477
480
  return null
478
481
  }
479
482
  }
483
+
484
+ /**
485
+ * A touch screen gets 44px triggers (coarse pointer), the default and the
486
+ * ghost variant alike; a mouse keeps the compact control.
487
+ * It drives Chrome's touch emulation through Vitest's CDP session, so it runs
488
+ * as a test and stays out of the dev sidebar (there is no Vitest there).
489
+ */
490
+ export const TouchTargets: Story = {
491
+ tags: ['!dev', '!autodocs'],
492
+ parameters: {a11y: {test: 'error'}},
493
+ decorators: [onThemeSurface],
494
+ render: () => (
495
+ <html.div style={touchStyles.row}>
496
+ <Menu.Root>
497
+ <Menu.Trigger>Actions</Menu.Trigger>
498
+ <Menu.Popup>
499
+ <Menu.Item>Rename</Menu.Item>
500
+ </Menu.Popup>
501
+ </Menu.Root>
502
+ <Menu.Root>
503
+ <Menu.Trigger variant="ghost" aria-label="Page actions">
504
+ <Icon name="menu" size="sm" />
505
+ </Menu.Trigger>
506
+ <Menu.Popup align="end">
507
+ <Menu.Item>Copy link</Menu.Item>
508
+ </Menu.Popup>
509
+ </Menu.Root>
510
+ <Menu.Root>
511
+ <Menu.Trigger size="small">Sort</Menu.Trigger>
512
+ <Menu.Popup>
513
+ <Menu.Item>Name</Menu.Item>
514
+ </Menu.Popup>
515
+ </Menu.Root>
516
+ </html.div>
517
+ ),
518
+ play: async ({canvas}) => {
519
+ const triggers = () => [
520
+ canvas.getByRole('button', {name: 'Actions'}),
521
+ canvas.getByRole('button', {name: 'Page actions'}),
522
+ canvas.getByRole('button', {name: 'Sort'}),
523
+ ]
524
+ await withFinePointer()
525
+ const ghost = canvas.getByRole('button', {name: 'Page actions'}).getBoundingClientRect()
526
+ await expect(ghost.height).toBeLessThan(SIZES_PX.touchTarget)
527
+ await withCoarsePointer(async () => {
528
+ for (const trigger of triggers()) {
529
+ await waitFor(() => {
530
+ const box = trigger.getBoundingClientRect()
531
+ expect(box.height).toBeGreaterThanOrEqual(SIZES_PX.touchTarget)
532
+ expect(box.width).toBeGreaterThanOrEqual(SIZES_PX.touchTarget)
533
+ })
534
+ }
535
+ })
536
+ },
537
+ }
538
+
539
+ const touchStyles = css.create({
540
+ row: {display: 'flex', gap: spacing.md, alignItems: 'center'},
541
+ })
@@ -32,6 +32,9 @@ export const styles = css.create({
32
32
  borderStyle: 'solid',
33
33
  borderColor: colors.border,
34
34
  borderRadius: radii.sm,
35
+ // A touch screen gets the 44px target; a mouse keeps the compact control.
36
+ minWidth: {default: null, '@media (pointer: coarse)': sizes.touchTarget},
37
+ minHeight: {default: null, '@media (pointer: coarse)': sizes.touchTarget},
35
38
  cursor: 'pointer',
36
39
  transitionProperty: 'background-color, border-color',
37
40
  transitionDuration: duration.fast,
@@ -39,8 +42,8 @@ export const styles = css.create({
39
42
  // Toolbar trigger: no border or fill until hover, at least icon-button size.
40
43
  triggerGhost: {
41
44
  justifyContent: 'center',
42
- minWidth: sizes.iconButton,
43
- minHeight: sizes.iconButton,
45
+ minWidth: {default: sizes.iconButton, '@media (pointer: coarse)': sizes.touchTarget},
46
+ minHeight: {default: sizes.iconButton, '@media (pointer: coarse)': sizes.touchTarget},
44
47
  paddingTop: spacing.xs,
45
48
  paddingBottom: spacing.xs,
46
49
  paddingLeft: spacing.xs,
@@ -62,7 +65,7 @@ export const styles = css.create({
62
65
  // A small ghost trigger keeps the ghost's xs padding at the small icon-button size.
63
66
  triggerGhostSmall: {
64
67
  fontSize: typography.fontSizeXs,
65
- minWidth: sizes.iconButtonSm,
68
+ minWidth: {default: sizes.iconButtonSm, '@media (pointer: coarse)': sizes.touchTarget},
66
69
  minHeight: {default: sizes.iconButtonSm, '@media (pointer: coarse)': sizes.touchTarget},
67
70
  },
68
71
  popup: {
@@ -0,0 +1,32 @@
1
+ import type {ComponentMeta} from '../component-meta'
2
+
3
+ export const meta: ComponentMeta = {
4
+ description:
5
+ "The container for HTML Duro does not author — rendered Markdown, CMS output: headings, paragraphs, lists, tables, blockquotes, inline code, links and images are styled from tokens by descendant rules scoped to it (plain CSS in dist/index.css, :where() and its own cascade layer). Duro components inside it keep their styles, and an app's own CSS wins over every Prose rule. Web only.",
6
+ whenToUse: [
7
+ 'The body of a docs page rendered from Markdown',
8
+ 'Any HTML produced by a renderer you do not write as JSX',
9
+ ],
10
+ whenNotToUse: [
11
+ 'Text you write as components — use Heading, Text and Stack',
12
+ 'A short label or paragraph — use Text',
13
+ ],
14
+ relatedTo: [
15
+ {
16
+ component: 'CodeBlock',
17
+ kind: 'composition',
18
+ relationship: "Pass CodeBlock as the Markdown renderer's pre, for a copy button",
19
+ },
20
+ {
21
+ component: 'TextLink',
22
+ kind: 'composition',
23
+ relationship:
24
+ "Pass TextLink as the renderer's a, for onNavigate; it keeps its own styles inside Prose",
25
+ },
26
+ ],
27
+ example: `<Prose>
28
+ <ReactMarkdown components={{pre: ({children}) => <CodeBlock copyLabel="Copy" copiedLabel="Copied">{children}</CodeBlock>}}>
29
+ {body}
30
+ </ReactMarkdown>
31
+ </Prose>`,
32
+ }
@@ -0,0 +1,233 @@
1
+ import type {Meta, StoryObj} from '@storybook/react'
2
+ import {expect, waitFor} from 'storybook/test'
3
+ import {Prose} from './Prose'
4
+ import {Heading} from '../Heading/Heading'
5
+ import {Text} from '../Text/Text'
6
+ import {CodeBlock} from '../CodeBlock/CodeBlock'
7
+ import {onThemeSurface} from '../../docs/themedSurface'
8
+
9
+ // What a Markdown renderer hands over: HTML that is never authored as JSX.
10
+ const RENDERED_MARKDOWN = `
11
+ <p>Flux reconciles every cluster from <code>kubernetes/</code>. Read the
12
+ <a href="#bootstrap">bootstrap notes</a> first.</p>
13
+ <h2 id="bootstrap" class="docs_title__a1b2">Bootstrap</h2>
14
+ <p>Each cluster starts from the same three steps:</p>
15
+ <ol>
16
+ <li>Install the operator.</li>
17
+ <li>Seed the secrets:
18
+ <ul>
19
+ <li>the Vault token</li>
20
+ <li>the registry pull secret</li>
21
+ </ul>
22
+ </li>
23
+ <li>Point Flux at the repository.</li>
24
+ </ol>
25
+ <blockquote><p>Never commit a decrypted secret.</p></blockquote>
26
+ <h3>Clusters</h3>
27
+ <table>
28
+ <thead><tr><th>Cluster</th><th>Nodes</th><th>Role</th></tr></thead>
29
+ <tbody>
30
+ <tr><td>homelab</td><td>5</td><td>Applications</td></tr>
31
+ <tr><td>nas</td><td>1</td><td>Storage</td></tr>
32
+ </tbody>
33
+ </table>
34
+ <pre><code>flux bootstrap git --url=ssh://git@forge/homelab</code></pre>
35
+ <hr>
36
+ <p><img alt="" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'/%3E"></p>
37
+ `
38
+
39
+ const RELEASE_NOTES = `
40
+ <h2>5.6.0</h2>
41
+ <p>Small controls line up: every <code>size="small"</code> control is at least
42
+ <code>controlSm</code> tall, whatever it holds.</p>
43
+ <h3>Fixed</h3>
44
+ <ul>
45
+ <li>A small <code>Menu.Trigger</code> in a toolbar no longer shrinks.</li>
46
+ <li>Toggle and Button share one small padding.</li>
47
+ </ul>
48
+ <blockquote><p>Upgrade with <code>pnpm add @duro-app/ui@5.6.0</code>.</p></blockquote>
49
+ `
50
+
51
+ const REFERENCE = `
52
+ <h2>relative(date, options)</h2>
53
+ <p>Formats <code>date</code> against <code>options.now</code> with
54
+ <a href="https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Intl/RelativeTimeFormat">Intl.RelativeTimeFormat</a>.</p>
55
+ <table>
56
+ <thead><tr><th>Option</th><th>Type</th><th>Default</th></tr></thead>
57
+ <tbody>
58
+ <tr><td><code>now</code></td><td>Date | number</td><td>required</td></tr>
59
+ <tr><td><code>locale</code></td><td>string</td><td>required</td></tr>
60
+ <tr><td><code>numeric</code></td><td>'auto' | 'always'</td><td>'auto'</td></tr>
61
+ </tbody>
62
+ </table>
63
+ <pre><code>relative(commit.date, {now, locale: 'fr'})</code></pre>
64
+ `
65
+
66
+ const DOCUMENTS = {
67
+ runbook: RENDERED_MARKDOWN,
68
+ 'release-notes': RELEASE_NOTES,
69
+ reference: REFERENCE,
70
+ } as const
71
+
72
+ type DocumentName = keyof typeof DOCUMENTS
73
+
74
+ interface ProseArgs {
75
+ /** A sample of what a Markdown renderer hands over. */
76
+ document: DocumentName
77
+ /** Your own HTML, in place of the sample; empty to show the sample. */
78
+ html: string
79
+ }
80
+
81
+ /** Sets the HTML on the element, as a Markdown renderer's output arrives. */
82
+ function Markdown({html = RENDERED_MARKDOWN}: {html?: string}) {
83
+ return (
84
+ <Prose
85
+ ref={(el) => {
86
+ if (el) el.innerHTML = html
87
+ }}
88
+ >
89
+ {null}
90
+ </Prose>
91
+ )
92
+ }
93
+
94
+ const meta = {
95
+ title: 'Components/Prose',
96
+ args: {document: 'runbook', html: ''},
97
+ argTypes: {
98
+ document: {
99
+ control: {
100
+ type: 'select',
101
+ labels: {runbook: 'runbook', 'release-notes': 'release notes', reference: 'API reference'},
102
+ },
103
+ options: Object.keys(DOCUMENTS),
104
+ },
105
+ html: {control: 'text'},
106
+ },
107
+ parameters: {a11y: {test: 'error'}},
108
+ decorators: [onThemeSurface],
109
+ render: (args) => <Markdown html={args.html || DOCUMENTS[args.document]} />,
110
+ } satisfies Meta<ProseArgs>
111
+
112
+ export default meta
113
+ type Story = StoryObj<typeof meta>
114
+
115
+ const TEST_ONLY = ['!dev', '!autodocs']
116
+
117
+ /** The computed value of a token, read the way the page resolves it. */
118
+ function resolved(property: 'color' | 'fontSize', token: string, host: HTMLElement) {
119
+ const probe = document.createElement('span')
120
+ probe.style[property] = `var(${token})`
121
+ host.appendChild(probe)
122
+ const value = getComputedStyle(probe)[property]
123
+ probe.remove()
124
+ return value
125
+ }
126
+
127
+ const style = (el: Element) => getComputedStyle(el)
128
+
129
+ /**
130
+ * Rendered Markdown styled by Prose. Pick another sample in `document`, or
131
+ * paste your own HTML in `html`.
132
+ */
133
+ export const Playground: Story = {}
134
+
135
+ /** The runbook sample, measured: flow, headings, lists, tables, code, links. */
136
+ export const RenderedMarkdown: Story = {
137
+ tags: TEST_ONLY,
138
+ play: async ({canvasElement}) => {
139
+ const prose = canvasElement.querySelector('[data-duro-prose]') as HTMLElement
140
+ await waitFor(() => expect(prose.querySelector('h2')).not.toBeNull())
141
+ const q = (selector: string) => prose.querySelector(selector) as HTMLElement
142
+
143
+ // flow: no gap before the first block, the scale between the rest
144
+ await expect(style(q('p')).marginTop).toBe('0px')
145
+ await expect(style(q('ol')).marginTop).toBe('16px')
146
+ // headings as the docs artboard draws them (Reader.dc.html): a section is
147
+ // a step below the page title, with more room above than below
148
+ await expect(style(q('h2')).fontSize).toBe('20px')
149
+ await expect(style(q('h2')).fontWeight).toBe('600')
150
+ await expect(style(q('h2')).marginTop).toBe('24px')
151
+ await expect(style(q('h2')).marginBottom).toBe('8px')
152
+ await expect(style(q('h3')).fontSize).toBe('18px')
153
+ // lists keep their markers and indent
154
+ await expect(style(q('ol')).listStyleType).toBe('decimal')
155
+ await expect(style(q('ol ul')).listStyleType).toBe('disc')
156
+ await expect(style(q('ol')).paddingLeft).toBe('24px')
157
+ await expect(style(q('li + li')).marginTop).toBe('4px')
158
+ // tables: the column's full width, horizontal rules only, a muted header
159
+ const table = q('table')
160
+ await expect(table.getBoundingClientRect().width).toBe(prose.getBoundingClientRect().width)
161
+ await expect(style(q('td')).borderBottomWidth).toBe('1px')
162
+ await expect(style(q('td')).borderLeftWidth).toBe('0px')
163
+ await expect(style(q('th')).borderTopWidth).toBe('0px')
164
+ await expect(style(q('th')).fontWeight).toBe('500')
165
+ await expect(style(q('th')).color).toBe(resolved('color', '--duro-color-text-muted', prose))
166
+ await expect(style(q('td')).paddingLeft).toBe('8px')
167
+ // code: monospace inline and in a block
168
+ await expect(style(q('p code')).fontFamily).toMatch(/mono|Menlo|Consolas|monospace/i)
169
+ await expect(style(q('pre')).overflowX).toBe('auto')
170
+ await expect(style(q('pre code')).borderTopWidth).toBe('0px')
171
+ // links read as links
172
+ await expect(style(q('a')).color).toBe(resolved('color', '--duro-color-accent', prose))
173
+ await expect(style(q('a')).textDecorationLine).toBe('underline')
174
+ await expect(style(q('blockquote')).borderLeftWidth).toBe('3px')
175
+ await expect(style(q('img')).maxWidth).toBe('100%')
176
+ },
177
+ }
178
+
179
+ /**
180
+ * An app's own CSS wins over every Prose rule: a CSS Module class and even a
181
+ * bare element selector, loaded after Duro's stylesheet. Prose sits in its
182
+ * own cascade layer and weighs nothing (:where()).
183
+ */
184
+ export const AppCssWins: Story = {
185
+ tags: TEST_ONLY,
186
+ play: async ({canvasElement}) => {
187
+ const prose = canvasElement.querySelector('[data-duro-prose]') as HTMLElement
188
+ await waitFor(() => expect(prose.querySelector('h2')).not.toBeNull())
189
+ const h2 = prose.querySelector('h2') as HTMLElement
190
+ const h3 = prose.querySelector('h3') as HTMLElement
191
+ await expect(style(h2).fontSize).toBe('20px')
192
+ await expect(style(h3).fontSize).toBe('18px')
193
+
194
+ const sheet = document.createElement('style')
195
+ // what kb-vision's docs.module.css compiles to, and the weakest rule an app can write
196
+ sheet.textContent = `.docs_title__a1b2 { font-size: 13px; margin-top: 0px; } h3 { font-size: 15px; }`
197
+ document.head.appendChild(sheet)
198
+ try {
199
+ await expect(style(h2).fontSize).toBe('13px')
200
+ await expect(style(h2).marginTop).toBe('0px')
201
+ await expect(style(h3).fontSize).toBe('15px')
202
+ } finally {
203
+ sheet.remove()
204
+ }
205
+ },
206
+ }
207
+
208
+ /** A Duro component inside Prose keeps its own styles: Prose is below them. */
209
+ export const DuroComponentsInside: Story = {
210
+ render: () => (
211
+ <Prose>
212
+ <Heading level={2} variant="headingLg">
213
+ Kept at headingLg
214
+ </Heading>
215
+ <Text>A paragraph from Text.</Text>
216
+ <CodeBlock copyLabel="Copy" copiedLabel="Copied">
217
+ pnpm add @duro-app/ui
218
+ </CodeBlock>
219
+ </Prose>
220
+ ),
221
+ }
222
+
223
+ /** DuroComponentsInside's checks, kept off the visible story so a Control change cannot fail them. */
224
+ export const DuroComponentsInsideChecks: Story = {
225
+ ...DuroComponentsInside,
226
+ tags: TEST_ONLY,
227
+ play: async ({canvas}) => {
228
+ const heading = canvas.getByRole('heading', {level: 2, name: 'Kept at headingLg'})
229
+ // headingLg is 30px; a Prose h2 would be 20px
230
+ await expect(style(heading).fontSize).toBe('30px')
231
+ await expect(canvas.getByRole('button', {name: 'Copy'})).toBeVisible()
232
+ },
233
+ }
@@ -0,0 +1,25 @@
1
+ import type {ReactNode, Ref} from 'react'
2
+ import {html} from 'react-strict-dom'
3
+ import './prose.css'
4
+
5
+ interface ProseProps {
6
+ /** HTML Duro does not author: the output of a Markdown renderer. */
7
+ children: ReactNode
8
+ /** The container element. */
9
+ ref?: Ref<HTMLDivElement>
10
+ }
11
+
12
+ /**
13
+ * Prose — the container for HTML Duro does not author (rendered Markdown, CMS
14
+ * output): headings, paragraphs, lists, tables, blockquotes, inline code,
15
+ * links and images are styled from tokens by descendant rules scoped to it.
16
+ * Duro components inside it (a TextLink, a CodeBlock) keep their own styles,
17
+ * and an app's own CSS wins over every Prose rule. Web only.
18
+ */
19
+ export function Prose({children, ref}: ProseProps) {
20
+ return (
21
+ <html.div ref={ref} data-duro-prose="">
22
+ {children}
23
+ </html.div>
24
+ )
25
+ }