@charcuterie/ui 0.1.1 → 1.0.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 (115) hide show
  1. package/README.md +9 -0
  2. package/dist/Accordion/Accordion.d.ts +82 -0
  3. package/dist/Accordion/Accordion.d.ts.map +1 -0
  4. package/dist/Accordion/Accordion.js +108 -0
  5. package/dist/Accordion/Accordion.js.map +1 -0
  6. package/dist/Accordion/AccordionSection.d.ts +28 -0
  7. package/dist/Accordion/AccordionSection.d.ts.map +1 -0
  8. package/dist/Accordion/AccordionSection.js +53 -0
  9. package/dist/Accordion/AccordionSection.js.map +1 -0
  10. package/dist/Field/Field.d.ts +104 -0
  11. package/dist/Field/Field.d.ts.map +1 -0
  12. package/dist/Field/Field.js +115 -0
  13. package/dist/Field/Field.js.map +1 -0
  14. package/dist/Field/FieldGroup.d.ts +58 -0
  15. package/dist/Field/FieldGroup.d.ts.map +1 -0
  16. package/dist/Field/FieldGroup.js +65 -0
  17. package/dist/Field/FieldGroup.js.map +1 -0
  18. package/dist/FileDropZone/FileDropZone.d.ts +63 -0
  19. package/dist/FileDropZone/FileDropZone.d.ts.map +1 -0
  20. package/dist/FileDropZone/FileDropZone.js +122 -0
  21. package/dist/FileDropZone/FileDropZone.js.map +1 -0
  22. package/dist/LogViewer/LogViewer.d.ts +90 -0
  23. package/dist/LogViewer/LogViewer.d.ts.map +1 -0
  24. package/dist/LogViewer/LogViewer.js +165 -0
  25. package/dist/LogViewer/LogViewer.js.map +1 -0
  26. package/dist/Menu/Menu.d.ts +81 -0
  27. package/dist/Menu/Menu.d.ts.map +1 -0
  28. package/dist/Menu/Menu.js +199 -0
  29. package/dist/Menu/Menu.js.map +1 -0
  30. package/dist/Menu/MenuAction.d.ts +24 -0
  31. package/dist/Menu/MenuAction.d.ts.map +1 -0
  32. package/dist/Menu/MenuAction.js +37 -0
  33. package/dist/Menu/MenuAction.js.map +1 -0
  34. package/dist/Select/Select.d.ts +87 -0
  35. package/dist/Select/Select.d.ts.map +1 -0
  36. package/dist/Select/Select.js +51 -0
  37. package/dist/Select/Select.js.map +1 -0
  38. package/dist/SortableTableHeader/SortableTableHeader.d.ts +61 -0
  39. package/dist/SortableTableHeader/SortableTableHeader.d.ts.map +1 -0
  40. package/dist/SortableTableHeader/SortableTableHeader.js +56 -0
  41. package/dist/SortableTableHeader/SortableTableHeader.js.map +1 -0
  42. package/dist/Toast/Toast.d.ts +54 -0
  43. package/dist/Toast/Toast.d.ts.map +1 -0
  44. package/dist/Toast/Toast.js +121 -0
  45. package/dist/Toast/Toast.js.map +1 -0
  46. package/dist/Toast/ToastRegion.d.ts +57 -0
  47. package/dist/Toast/ToastRegion.d.ts.map +1 -0
  48. package/dist/Toast/ToastRegion.js +41 -0
  49. package/dist/Toast/ToastRegion.js.map +1 -0
  50. package/dist/Toast/toastLifecycle.d.ts +34 -0
  51. package/dist/Toast/toastLifecycle.d.ts.map +1 -0
  52. package/dist/Toast/toastLifecycle.js +25 -0
  53. package/dist/Toast/toastLifecycle.js.map +1 -0
  54. package/dist/Tooltip/Tooltip.d.ts +82 -0
  55. package/dist/Tooltip/Tooltip.d.ts.map +1 -0
  56. package/dist/Tooltip/Tooltip.js +124 -0
  57. package/dist/Tooltip/Tooltip.js.map +1 -0
  58. package/dist/index.d.ts +26 -0
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +13 -0
  61. package/dist/index.js.map +1 -1
  62. package/dist/mountStory.testHelpers.d.ts +0 -11
  63. package/dist/mountStory.testHelpers.d.ts.map +1 -1
  64. package/dist/mountStory.testHelpers.js +27 -0
  65. package/dist/mountStory.testHelpers.js.map +1 -1
  66. package/dist/slotProps.d.ts +110 -0
  67. package/dist/slotProps.d.ts.map +1 -0
  68. package/dist/slotProps.js +39 -0
  69. package/dist/slotProps.js.map +1 -0
  70. package/package.json +3 -3
  71. package/src/Accordion/Accordion.mdx +83 -0
  72. package/src/Accordion/Accordion.stories.tsx +146 -0
  73. package/src/Accordion/Accordion.test.tsx +163 -0
  74. package/src/Accordion/Accordion.tsx +182 -0
  75. package/src/Accordion/AccordionSection.tsx +163 -0
  76. package/src/Field/Field.mdx +144 -0
  77. package/src/Field/Field.stories.tsx +280 -0
  78. package/src/Field/Field.test.tsx +344 -0
  79. package/src/Field/Field.tsx +212 -0
  80. package/src/Field/FieldGroup.tsx +138 -0
  81. package/src/FileDropZone/FileDropZone.mdx +78 -0
  82. package/src/FileDropZone/FileDropZone.stories.tsx +136 -0
  83. package/src/FileDropZone/FileDropZone.test.tsx +230 -0
  84. package/src/FileDropZone/FileDropZone.tsx +234 -0
  85. package/src/LogViewer/LogViewer.mdx +123 -0
  86. package/src/LogViewer/LogViewer.stories.tsx +169 -0
  87. package/src/LogViewer/LogViewer.test.tsx +236 -0
  88. package/src/LogViewer/LogViewer.tsx +261 -0
  89. package/src/Menu/Menu.mdx +103 -0
  90. package/src/Menu/Menu.stories.tsx +161 -0
  91. package/src/Menu/Menu.test.tsx +202 -0
  92. package/src/Menu/Menu.tsx +300 -0
  93. package/src/Menu/MenuAction.tsx +95 -0
  94. package/src/Select/Select.mdx +74 -0
  95. package/src/Select/Select.stories.tsx +126 -0
  96. package/src/Select/Select.test.tsx +129 -0
  97. package/src/Select/Select.tsx +200 -0
  98. package/src/SortableTableHeader/SortableTableHeader.mdx +67 -0
  99. package/src/SortableTableHeader/SortableTableHeader.stories.tsx +207 -0
  100. package/src/SortableTableHeader/SortableTableHeader.test.tsx +180 -0
  101. package/src/SortableTableHeader/SortableTableHeader.tsx +131 -0
  102. package/src/Toast/Toast.mdx +84 -0
  103. package/src/Toast/Toast.stories.tsx +192 -0
  104. package/src/Toast/Toast.test.tsx +217 -0
  105. package/src/Toast/Toast.tsx +224 -0
  106. package/src/Toast/ToastRegion.tsx +98 -0
  107. package/src/Toast/toastLifecycle.ts +45 -0
  108. package/src/Tooltip/Tooltip.mdx +69 -0
  109. package/src/Tooltip/Tooltip.stories.tsx +104 -0
  110. package/src/Tooltip/Tooltip.test.tsx +138 -0
  111. package/src/Tooltip/Tooltip.tsx +193 -0
  112. package/src/index.ts +47 -0
  113. package/src/mountStory.testHelpers.ts +30 -0
  114. package/src/slotProps.ts +143 -0
  115. package/src/sourceRules.test.ts +221 -30
@@ -0,0 +1,169 @@
1
+ import type { Meta, StoryObj } from "@storybook/react"
2
+
3
+ import { Accordion } from "../Accordion/Accordion.tsx"
4
+ import {
5
+ StoryCell,
6
+ StoryGrid,
7
+ } from "../board.storyHelpers.tsx"
8
+ import type { LogLine } from "./LogViewer.tsx"
9
+ import { LogViewer } from "./LogViewer.tsx"
10
+
11
+ const RIP_LINES: LogLine[] = [
12
+ {
13
+ key: "1",
14
+ text: "14:02:09 Opening /dev/sr0",
15
+ },
16
+ {
17
+ key: "2",
18
+ text: "14:02:11 AACS handshake complete, reading…",
19
+ },
20
+ {
21
+ intent: "warning",
22
+ key: "3",
23
+ text: "14:02:44 Title 4: 2 read errors, retrying",
24
+ },
25
+ {
26
+ key: "4",
27
+ text: "14:03:02 Title 4: recovered",
28
+ },
29
+ {
30
+ intent: "success",
31
+ key: "5",
32
+ text: "14:31:57 9 titles written to /mnt/rips",
33
+ },
34
+ ]
35
+
36
+ /**
37
+ * Long enough to overflow the pane, which is the only way to story
38
+ * the behaviour that matters here.
39
+ */
40
+ const LONG_LINES: LogLine[] = Array.from(
41
+ { length: 60 },
42
+ (_unused, index) => ({
43
+ key: String(index),
44
+ text: `14:${String(index).padStart(2, "0")}:00 Reading sector ${index * 2048}`,
45
+ }),
46
+ )
47
+
48
+ const meta = {
49
+ title: "Components/LogViewer",
50
+ component: LogViewer,
51
+ parameters: { layout: "padded" },
52
+ args: {
53
+ isAnnounced: false,
54
+ label: "Bay 3 rip log",
55
+ lines: RIP_LINES,
56
+ maxLines: 500,
57
+ },
58
+ } satisfies Meta<typeof LogViewer>
59
+
60
+ export default meta
61
+
62
+ type Story = StoryObj<typeof meta>
63
+
64
+ export const Default: Story = {
65
+ args: { label: "Bay 3 rip log", lines: RIP_LINES },
66
+ }
67
+
68
+ export const AllVariants: Story = {
69
+ args: { label: "Bay 3 rip log", lines: RIP_LINES },
70
+ render: () => (
71
+ <StoryGrid columns={2}>
72
+ <StoryCell label="short">
73
+ <LogViewer
74
+ label="Bay 1 rip log"
75
+ lines={RIP_LINES}
76
+ />
77
+ </StoryCell>
78
+
79
+ <StoryCell label="overflowing">
80
+ <LogViewer
81
+ label="Bay 2 rip log"
82
+ lines={LONG_LINES}
83
+ />
84
+ </StoryCell>
85
+
86
+ <StoryCell label="capped at 10 lines">
87
+ <LogViewer
88
+ label="Bay 4 rip log"
89
+ lines={LONG_LINES}
90
+ maxLines={10}
91
+ />
92
+ </StoryCell>
93
+
94
+ <StoryCell label="announced">
95
+ <LogViewer
96
+ isAnnounced
97
+ label="Bay 5 result"
98
+ lines={RIP_LINES}
99
+ />
100
+ </StoryCell>
101
+ </StoryGrid>
102
+ ),
103
+ }
104
+
105
+ /**
106
+ * Empty is a state, not a blank box. mux-magic's pane says
107
+ * "Waiting for log lines…" and rip-deck's says nothing at all,
108
+ * which reads as a broken subscription.
109
+ */
110
+ export const AllStates: Story = {
111
+ args: { label: "Bay 3 rip log", lines: RIP_LINES },
112
+ render: () => (
113
+ <StoryGrid columns={2}>
114
+ <StoryCell label="empty">
115
+ <LogViewer label="Bay 6 rip log" lines={[]} />
116
+ </StoryCell>
117
+
118
+ <StoryCell label="with intents">
119
+ <LogViewer
120
+ label="Bay 7 rip log"
121
+ lines={RIP_LINES}
122
+ />
123
+ </StoryCell>
124
+ </StoryGrid>
125
+ ),
126
+ }
127
+
128
+ /**
129
+ * Scroll the tall pane up and the **Jump to latest** button
130
+ * appears; scroll back to the bottom and it goes away. Following is
131
+ * pinned to the user's own position, which is the difference between
132
+ * a log that follows and one that fights you.
133
+ */
134
+ export const Interactive: Story = {
135
+ args: { label: "Bay 8 rip log", lines: LONG_LINES },
136
+ }
137
+
138
+ /**
139
+ * The pane mounted inside a **collapsed** `Accordion` section, which
140
+ * is how the fleet actually renders a log and is the one arrangement
141
+ * where following used to fail.
142
+ *
143
+ * `AccordionSection` renders its panel with `hidden`, deliberately,
144
+ * so the pane keeps its scroll position and its subscriptions across
145
+ * a collapse. A `hidden` subtree has no layout, so a pane that
146
+ * measures itself on mount measures `scrollHeight 0` — and neither
147
+ * `isFollowing` nor `lines` changes when the section is opened.
148
+ *
149
+ * Expand it: the log opens on its **last** line, not its first.
150
+ */
151
+ export const InsideDisclosure: Story = {
152
+ args: { label: "Bay 9 rip log", lines: LONG_LINES },
153
+ render: () => (
154
+ <Accordion
155
+ items={[
156
+ {
157
+ content: (
158
+ <LogViewer
159
+ label="Bay 9 rip log"
160
+ lines={LONG_LINES}
161
+ />
162
+ ),
163
+ key: "logs",
164
+ label: "Bay 9 output",
165
+ },
166
+ ]}
167
+ />
168
+ ),
169
+ }
@@ -0,0 +1,236 @@
1
+ import { composeStories } from "@storybook/react"
2
+ import { expect, waitFor } from "storybook/test"
3
+ import { test } from "vitest"
4
+
5
+ import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
6
+ import { mountStory } from "../mountStory.testHelpers.ts"
7
+ import { expectAgentDrivable } from "../testing/index.ts"
8
+ import * as stories from "./LogViewer.stories.tsx"
9
+
10
+ const {
11
+ AllStates,
12
+ AllVariants,
13
+ Default,
14
+ InsideDisclosure,
15
+ Interactive,
16
+ } = composeStories(stories)
17
+
18
+ test("the pane is named, and is a log", async () => {
19
+ const { canvas, canvasElement } =
20
+ await mountStory(Default)
21
+
22
+ // Found by its name, not by a `data-log-id`. mux-magic's pane
23
+ // carries one of those — a `data-testid` under a different name,
24
+ // which this package's `sourceRules.test.ts` bans as a practice
25
+ // rather than as a spelling.
26
+ const pane = expectAgentDrivable(canvas, {
27
+ name: "Bay 3 rip log",
28
+ role: "log",
29
+ })
30
+
31
+ await expect(pane).toHaveTextContent(
32
+ "AACS handshake complete",
33
+ )
34
+
35
+ await expectNoAxeViolations(canvasElement)
36
+ })
37
+
38
+ /**
39
+ * The fix for mux-magic's defect, asserted as the behaviour rather
40
+ * than as the precondition. Its auto-scroll runs in an effect with
41
+ * **empty deps** — once, on mount, when the pane is empty and
42
+ * `scrollHeight` is `clientHeight` — so every line after it arrives
43
+ * below the fold.
44
+ */
45
+ test("a full pane is scrolled to the bottom", async () => {
46
+ const { canvas } = await mountStory(Interactive)
47
+
48
+ const pane = expectAgentDrivable(canvas, {
49
+ name: "Bay 8 rip log",
50
+ role: "log",
51
+ })
52
+
53
+ // The precondition — that there *is* something to scroll. On its
54
+ // own this proves nothing, which is the lesson `Tabs` learned the
55
+ // expensive way from `scrollWidth > clientWidth`.
56
+ await expect(pane.scrollHeight).toBeGreaterThan(
57
+ pane.clientHeight,
58
+ )
59
+
60
+ // And the behaviour: it is actually at the end.
61
+ await waitFor(() => {
62
+ expect(
63
+ pane.scrollHeight -
64
+ pane.scrollTop -
65
+ pane.clientHeight,
66
+ ).toBeLessThan(4)
67
+ })
68
+ })
69
+
70
+ /**
71
+ * The same defect, rebuilt out of two components that are each
72
+ * individually right — and the reason `LogViewer` watches its own box
73
+ * rather than trusting one mount measurement.
74
+ *
75
+ * `AccordionSection` renders `hidden` rather than unmounting, because
76
+ * the fleet's panels hold log panes and an unmounted panel loses its
77
+ * scroll position and its subscription. A `hidden` subtree has no
78
+ * layout box, so the mount effect reads `scrollHeight 0` and writes
79
+ * `scrollTop = 0` — and the effect's dependencies, `isFollowing` and
80
+ * the lines, are both unchanged by the reveal. Measured in mux-magic
81
+ * on a 60-line pane:
82
+ *
83
+ * ```
84
+ * while collapsed : scrollTop 0 scrollHeight 0 clientHeight 0
85
+ * after expanding : scrollTop 0 scrollHeight 976 clientHeight 254
86
+ * ```
87
+ *
88
+ * mux-magic worked around it downstream, in `DisclosedLogViewer`, by
89
+ * not mounting the pane until the section had been opened once. This
90
+ * test is that workaround becoming deletable.
91
+ */
92
+ test("a pane revealed by a disclosure follows the tail", async () => {
93
+ const { canvas } = await mountStory(InsideDisclosure)
94
+
95
+ // `hidden: true` because that is the whole precondition: the pane
96
+ // is in the accessibility tree's shadow, and in the layout tree not
97
+ // at all.
98
+ const pane = canvas.getByRole("log", {
99
+ hidden: true,
100
+ name: "Bay 9 rip log",
101
+ })
102
+
103
+ // Not a rhetorical assertion. If a future `AccordionSection`
104
+ // unmounts its panel instead, or stops using `hidden`, this test
105
+ // silently stops testing anything — so it fails here instead.
106
+ await expect(pane.scrollHeight).toBe(0)
107
+
108
+ expectAgentDrivable(canvas, {
109
+ name: "Bay 9 output",
110
+ role: "button",
111
+ }).click()
112
+
113
+ await waitFor(() => {
114
+ expect(pane.scrollHeight).toBeGreaterThan(
115
+ pane.clientHeight,
116
+ )
117
+ })
118
+
119
+ await waitFor(() => {
120
+ expect(
121
+ pane.scrollHeight -
122
+ pane.scrollTop -
123
+ pane.clientHeight,
124
+ ).toBeLessThan(4)
125
+ })
126
+ })
127
+
128
+ /**
129
+ * Following is pinned to the user's own position. Scrolling back to
130
+ * read the line that mentioned an error must not be undone twice a
131
+ * second — which is what the naive fix (scroll to bottom on every
132
+ * render) does.
133
+ */
134
+ test("scrolling away stops the follow and offers a way back", async () => {
135
+ const { canvas } = await mountStory(Interactive)
136
+
137
+ const pane = expectAgentDrivable(canvas, {
138
+ name: "Bay 8 rip log",
139
+ role: "log",
140
+ })
141
+
142
+ await expect(
143
+ canvas.queryAllByRole("button", {
144
+ name: "Jump to latest",
145
+ }),
146
+ ).toHaveLength(0)
147
+
148
+ pane.scrollTop = 0
149
+
150
+ pane.dispatchEvent(new Event("scroll", { bubbles: true }))
151
+
152
+ const jump = await waitFor(() =>
153
+ expectAgentDrivable(canvas, {
154
+ name: "Jump to latest",
155
+ role: "button",
156
+ }),
157
+ )
158
+
159
+ jump.click()
160
+
161
+ await waitFor(() => {
162
+ expect(
163
+ pane.scrollHeight -
164
+ pane.scrollTop -
165
+ pane.clientHeight,
166
+ ).toBeLessThan(4)
167
+ })
168
+ })
169
+
170
+ /**
171
+ * A live log emitting a line every 300 ms is a screen reader that
172
+ * cannot be interrupted. `role="log"` is correct; `aria-live` is off
173
+ * unless the lines *are* the outcome.
174
+ */
175
+ test("a log does not announce itself unless asked", async () => {
176
+ const { canvas } = await mountStory(AllVariants)
177
+
178
+ await expect(
179
+ expectAgentDrivable(canvas, {
180
+ name: "Bay 1 rip log",
181
+ role: "log",
182
+ }),
183
+ ).toHaveAttribute("aria-live", "off")
184
+
185
+ await expect(
186
+ expectAgentDrivable(canvas, {
187
+ name: "Bay 5 result",
188
+ role: "log",
189
+ }),
190
+ ).toHaveAttribute("aria-live", "polite")
191
+ })
192
+
193
+ test("maxLines drops the oldest lines from the DOM", async () => {
194
+ const { canvas } = await mountStory(AllVariants)
195
+
196
+ const capped = expectAgentDrivable(canvas, {
197
+ name: "Bay 4 rip log",
198
+ role: "log",
199
+ })
200
+
201
+ // A rip emits a line every few hundred milliseconds for an hour,
202
+ // and the browser keeps every node.
203
+ await expect(capped.querySelectorAll("p")).toHaveLength(
204
+ 10,
205
+ )
206
+
207
+ // The *last* ten, not the first — a log you have to scroll up
208
+ // through to reach the newest line is backwards.
209
+ await expect(capped).toHaveTextContent(
210
+ "Reading sector 120832",
211
+ )
212
+ })
213
+
214
+ test("a scrollable pane is reachable by keyboard", async () => {
215
+ const { canvas, canvasElement } =
216
+ await mountStory(AllStates)
217
+
218
+ // Chrome made scrollers focusable automatically in 2024; Firefox
219
+ // and Safari have not. Without it a keyboard user can see the pane
220
+ // and cannot scroll it.
221
+ await expect(
222
+ expectAgentDrivable(canvas, {
223
+ name: "Bay 7 rip log",
224
+ role: "log",
225
+ }).tabIndex,
226
+ ).toBe(0)
227
+
228
+ await expect(
229
+ expectAgentDrivable(canvas, {
230
+ name: "Bay 6 rip log",
231
+ role: "log",
232
+ }),
233
+ ).toHaveTextContent("Waiting for output…")
234
+
235
+ await expectNoAxeViolations(canvasElement)
236
+ })
@@ -0,0 +1,261 @@
1
+ import type { IntentName } from "@charcuterie/tokens"
2
+ import type { ReactNode } from "react"
3
+ import { useEffect, useRef, useState } from "react"
4
+
5
+ import { Button } from "../Button/Button.tsx"
6
+ import { INTENT_CONTENT_CLASS } from "../intentStyles.ts"
7
+ import { toClassName } from "../toClassName.ts"
8
+
9
+ export type LogLine = {
10
+ intent?: IntentName
11
+ key: string
12
+ text: string
13
+ }
14
+
15
+ export type LogViewerProps = {
16
+ className?: string
17
+ /**
18
+ * Announce new lines to a screen reader. **Off by default**, and
19
+ * that is a considered choice rather than an oversight — see
20
+ * below.
21
+ */
22
+ isAnnounced?: boolean
23
+ /** The pane's accessible name. Required. */
24
+ label: string
25
+ lines: LogLine[]
26
+ /**
27
+ * Oldest lines past this are dropped from the DOM. A rip in
28
+ * progress emits a line every few hundred milliseconds for an
29
+ * hour, and the browser keeps every node.
30
+ */
31
+ maxLines?: number
32
+ }
33
+
34
+ /**
35
+ * Three repos render a scrolling log pane —
36
+ * mux-magic's `JobLogsDisclosure` and `StepLogs`, rip-deck's
37
+ * `LogModal` — and between them they have four defects that this
38
+ * component exists to not have. A fifth, below the effects, is not
39
+ * theirs: it is defect 1 coming back through the front door, in this
40
+ * component, found by the first app to consume it.
41
+ *
42
+ * ### 1. The one that follows nothing
43
+ *
44
+ * ```tsx
45
+ * useEffect(() => {
46
+ * const pane = paneRef.current
47
+ * if (pane) pane.scrollTop = pane.scrollHeight
48
+ * }, []) // ← empty deps
49
+ * ```
50
+ *
51
+ * That is mux-magic's auto-scroll, and it runs **once**, on mount,
52
+ * when the pane is empty and `scrollHeight` is `clientHeight`. Every
53
+ * line after it arrives below the fold. It reads as correct, it is
54
+ * the shape everyone writes, and the log simply never follows.
55
+ *
56
+ * ### 2. Following that cannot be escaped
57
+ *
58
+ * The naive fix — scroll to the bottom on every render — is worse:
59
+ * a user scrolling back to read the line that mentioned an error is
60
+ * yanked to the end twice a second. Following has to be *pinned to
61
+ * the user's own position*, which is what `isFollowing` below is: it
62
+ * turns off the moment the user scrolls away and back on when they
63
+ * return to the bottom, and there is a button for when they do not
64
+ * want to scroll all that way.
65
+ *
66
+ * ### 3. `data-log-id`
67
+ *
68
+ * mux-magic's pane carries one. It is a `data-testid` under a
69
+ * different name — a handle only the test suite can see — and this
70
+ * package's `sourceRules.test.ts` bans the practice rather than the
71
+ * spelling. The pane here is found by its `label`, the same query
72
+ * Playwright makes.
73
+ *
74
+ * ### 4. Announcing an hour of build output
75
+ *
76
+ * `role="log"` is the correct role and `aria-live` is off unless
77
+ * asked for. A live log pane emitting a line every 300 ms is a
78
+ * screen reader that cannot be interrupted and a user who cannot
79
+ * read anything else on the page — the accessible answer for a
80
+ * long-running job is a `LiveStatusIndicator` announcing *state*
81
+ * and a log that is there to be read when wanted. `isAnnounced`
82
+ * exists for the short case: a five-line result panel where the
83
+ * lines *are* the outcome.
84
+ *
85
+ * ### Not every boolean is a state kind
86
+ *
87
+ * `isFollowing` is `useState`, not `useVisibility`, and this is the
88
+ * first component in the library to say so out loud. The kinds in
89
+ * `@charcuterie/logic` earn their keep when state is **registered**
90
+ * (members join a group), **composed** (focus and selection
91
+ * disagreeing), or **shared** (a trigger and a panel at opposite
92
+ * ends of a tree). None of that is true here: one component reads
93
+ * one boolean it wrote itself. Reaching for `useVisibility` because
94
+ * it is also a boolean would make `isVisible` mean "is following",
95
+ * which is how a state layer stops meaning anything.
96
+ */
97
+ export const LogViewer = ({
98
+ className,
99
+ isAnnounced = false,
100
+ label,
101
+ lines,
102
+ maxLines = 500,
103
+ }: LogViewerProps): ReactNode => {
104
+ const paneRef = useRef<HTMLDivElement>(null)
105
+
106
+ const [isFollowing, setIsFollowing] = useState(true)
107
+
108
+ const shownLines =
109
+ lines.length > maxLines
110
+ ? lines.slice(lines.length - maxLines)
111
+ : lines
112
+
113
+ // biome-ignore lint/correctness/useExhaustiveDependencies: `shownLines` is read through the DOM, not through the closure — `pane.scrollHeight` is a *consequence* of the lines having rendered, so the linter sees a dependency it cannot trace and calls it unnecessary. Removing it is not a style change: the effect then runs only when `isFollowing` flips, which is mux-magic's `}, [])` bug rebuilt exactly. `yarn lint` runs `--write --unsafe`, so this was applied silently once already and the log stopped following.
114
+ useEffect(() => {
115
+ const pane = paneRef.current
116
+
117
+ if (!pane || !isFollowing) {
118
+ return
119
+ }
120
+
121
+ pane.scrollTop = pane.scrollHeight
122
+ // `shownLines` rather than an empty array — this is the whole fix
123
+ // for defect 1, and the dependency *is* the fix.
124
+ }, [isFollowing, shownLines])
125
+
126
+ /**
127
+ * Defect 1 again, and the effect above cannot see it: **a pane can
128
+ * be measured before it has a box.**
129
+ *
130
+ * `AccordionSection` renders its panel with `hidden` rather than
131
+ * unmounting it, deliberately — "a panel that unmounts loses a
132
+ * scroll position, a partially typed form, and any subscription its
133
+ * content opened — and the fleet's log panes are exactly that." A
134
+ * `hidden` subtree has no layout, so the effect above runs on mount
135
+ * with `scrollHeight 0` and writes `scrollTop = 0`, and then never
136
+ * runs again: neither `isFollowing` nor `shownLines` changes when
137
+ * the section is opened. Measured in mux-magic on a 60-line pane:
138
+ *
139
+ * ```
140
+ * while collapsed : scrollTop 0 scrollHeight 0 clientHeight 0
141
+ * after expanding : scrollTop 0 scrollHeight 976 clientHeight 254
142
+ * ```
143
+ *
144
+ * That is this component's own `}, [])` bug, rebuilt out of two
145
+ * components whose individual decisions are both right, and
146
+ * invisible to both of their test suites — `LogViewer`'s mounted
147
+ * visible, `Accordion`'s holding content that never measures
148
+ * itself. mux-magic hit it in its first week as a consumer and
149
+ * worked around it downstream, in a `DisclosedLogViewer` that
150
+ * withheld the pane until the section had been opened once. This is
151
+ * that workaround coming into the library so it can be deleted.
152
+ *
153
+ * ### Why the box and not the viewport
154
+ *
155
+ * An `IntersectionObserver` answers "is it on screen", which is a
156
+ * different question with two wrong answers here: a pane below the
157
+ * fold on a long page is *not* intersecting and has perfectly good
158
+ * layout, and scrolling the page later would re-fire for no reason.
159
+ * `ResizeObserver` answers "does it have a box, and is that box
160
+ * still the same size" — which is the precondition the measurement
161
+ * above actually needs. Per spec it does not fire at `observe()`
162
+ * time for an element that is not being rendered, so gaining a box
163
+ * *is* the first callback, and that is the reveal.
164
+ *
165
+ * It pays for itself twice: a window resize re-wraps the lines and
166
+ * changes what is at the bottom, and a following pane should be at
167
+ * the new bottom rather than a few lines above it.
168
+ *
169
+ * Only while following. A user who scrolled up to read an error is
170
+ * not dragged back by resizing their window.
171
+ */
172
+ useEffect(() => {
173
+ const pane = paneRef.current
174
+
175
+ if (!pane || !isFollowing) {
176
+ return
177
+ }
178
+
179
+ const observer = new ResizeObserver(() => {
180
+ pane.scrollTop = pane.scrollHeight
181
+ })
182
+
183
+ observer.observe(pane)
184
+
185
+ return () => {
186
+ observer.disconnect()
187
+ }
188
+ }, [isFollowing])
189
+
190
+ return (
191
+ <div
192
+ className={toClassName(
193
+ "relative flex flex-col",
194
+ className,
195
+ )}
196
+ >
197
+ <div
198
+ aria-label={label}
199
+ // `off` unless asked. See the note above; this is the
200
+ // difference between an accessible log and an unusable page.
201
+ aria-live={isAnnounced ? "polite" : "off"}
202
+ className="max-h-64 overflow-y-auto rounded-md border border-border-subtle bg-surface-sunken p-2 font-mono text-content-secondary text-xs"
203
+ onScroll={(scrollEvent) => {
204
+ const pane = scrollEvent.currentTarget
205
+
206
+ // A few pixels of slack, because a fractional device pixel
207
+ // ratio means `scrollTop + clientHeight` never lands
208
+ // exactly on `scrollHeight` — an equality test here
209
+ // un-pins the pane on a 125% display and on nothing else,
210
+ // which is the kind of bug that gets reported as "it works
211
+ // on my machine".
212
+ const isPinnedToBottom =
213
+ pane.scrollHeight -
214
+ pane.scrollTop -
215
+ pane.clientHeight <
216
+ 4
217
+
218
+ setIsFollowing(isPinnedToBottom)
219
+ }}
220
+ ref={paneRef}
221
+ role="log"
222
+ // biome-ignore lint/a11y/noNoninteractiveTabindex: a scroll container must be focusable or a keyboard user can see the pane and cannot scroll it — axe's `scrollable-region-focusable`, which fails this component without the line. Chrome started doing it automatically in 2024; Firefox and Safari have not. The rule is right about `<div>`s in general and wrong about scrollers.
223
+ tabIndex={0}
224
+ >
225
+ {shownLines.length === 0 ? (
226
+ <p className="text-content-muted">
227
+ Waiting for output…
228
+ </p>
229
+ ) : (
230
+ shownLines.map((line) => (
231
+ <p
232
+ className={toClassName(
233
+ "whitespace-pre-wrap break-all",
234
+ line.intent === undefined
235
+ ? null
236
+ : INTENT_CONTENT_CLASS[line.intent],
237
+ )}
238
+ key={line.key}
239
+ >
240
+ {line.text}
241
+ </p>
242
+ ))
243
+ )}
244
+ </div>
245
+
246
+ {isFollowing ? null : (
247
+ <Button
248
+ appearance="soft"
249
+ className="absolute inset-x-0 bottom-2 mx-auto w-fit"
250
+ intent="neutral"
251
+ onClick={() => {
252
+ setIsFollowing(true)
253
+ }}
254
+ size="sm"
255
+ >
256
+ Jump to latest
257
+ </Button>
258
+ )}
259
+ </div>
260
+ )
261
+ }