@open-mercato/ui 0.7.1-develop.7151.1.00d0391847 → 0.7.1-develop.7153.1.7145d295e6

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 (30) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/backend/AppShell.js +9 -1
  3. package/dist/backend/AppShell.js.map +2 -2
  4. package/dist/backend/CrudForm.js +3 -0
  5. package/dist/backend/CrudForm.js.map +2 -2
  6. package/dist/backend/DataTable.js +3 -1
  7. package/dist/backend/DataTable.js.map +2 -2
  8. package/dist/backend/dev/DevRuntimeDiagnosticsBanner.js +332 -0
  9. package/dist/backend/dev/DevRuntimeDiagnosticsBanner.js.map +7 -0
  10. package/dist/backend/dev/DevRuntimeReporter.js +36 -0
  11. package/dist/backend/dev/DevRuntimeReporter.js.map +7 -0
  12. package/dist/backend/forms/FormHeader.js +3 -1
  13. package/dist/backend/forms/FormHeader.js.map +2 -2
  14. package/dist/backend/messages/MessageComposer.js +1 -0
  15. package/dist/backend/messages/MessageComposer.js.map +2 -2
  16. package/package.json +3 -3
  17. package/src/backend/AppShell.tsx +7 -1
  18. package/src/backend/CrudForm.tsx +5 -0
  19. package/src/backend/DataTable.tsx +10 -1
  20. package/src/backend/__tests__/AppShell.test.tsx +14 -0
  21. package/src/backend/__tests__/CrudForm.defaults.test.tsx +26 -0
  22. package/src/backend/__tests__/DataTable.render.test.tsx +30 -0
  23. package/src/backend/__tests__/DevRuntimeDiagnosticsBanner.test.tsx +495 -0
  24. package/src/backend/__tests__/DevRuntimeReporter.test.tsx +145 -0
  25. package/src/backend/__tests__/FormHeader.test.tsx +9 -2
  26. package/src/backend/dev/DevRuntimeDiagnosticsBanner.tsx +412 -0
  27. package/src/backend/dev/DevRuntimeReporter.tsx +46 -0
  28. package/src/backend/forms/FormHeader.tsx +5 -1
  29. package/src/backend/messages/MessageComposer.tsx +1 -0
  30. package/src/backend/messages/__tests__/MessageComposer.test.tsx +10 -0
@@ -0,0 +1,145 @@
1
+ import * as React from 'react'
2
+ import { cleanup, render, waitFor } from '@testing-library/react'
3
+
4
+ import { DevRuntimeReporter } from '../dev/DevRuntimeReporter'
5
+ import {
6
+ reportDevRuntimeError,
7
+ resetDevRuntimeReporterForTests,
8
+ } from '@open-mercato/shared/lib/dev-runtime/report'
9
+ import { DEV_RUNTIME_TOKEN_HEADER, DEV_RUNTIME_TOKEN_META_NAME } from '@open-mercato/shared/lib/dev-runtime/types'
10
+
11
+ const TOKEN = 'reporter-token-fixture'
12
+
13
+ function enableCollector(): void {
14
+ const element = document.createElement('meta')
15
+ element.setAttribute('name', DEV_RUNTIME_TOKEN_META_NAME)
16
+ element.setAttribute('content', TOKEN)
17
+ document.head.appendChild(element)
18
+ }
19
+
20
+ function sentReports(fetchMock: jest.Mock): Array<Record<string, unknown>> {
21
+ return fetchMock.mock.calls.map((call) => JSON.parse(String(call[1].body)) as Record<string, unknown>)
22
+ }
23
+
24
+ let fetchMock: jest.Mock
25
+
26
+ beforeEach(() => {
27
+ document.head.innerHTML = ''
28
+ resetDevRuntimeReporterForTests()
29
+ fetchMock = jest.fn().mockResolvedValue(new Response(null, { status: 202 }))
30
+ global.fetch = fetchMock as unknown as typeof fetch
31
+ })
32
+
33
+ afterEach(cleanup)
34
+
35
+ describe('reportDevRuntimeError', () => {
36
+ it('stays silent without a collector token', () => {
37
+ reportDevRuntimeError({ kind: 'global-error', error: new Error('boom') })
38
+ expect(fetchMock).not.toHaveBeenCalled()
39
+ })
40
+
41
+ it('posts a bounded report with the per-run token', () => {
42
+ enableCollector()
43
+ reportDevRuntimeError({ kind: 'global-error', error: Object.assign(new TypeError('boom'), { digest: 'abc123' }) })
44
+
45
+ expect(fetchMock).toHaveBeenCalledTimes(1)
46
+ const [url, init] = fetchMock.mock.calls[0]
47
+ expect(url).toBe('/api/dev-runtime/diagnostics')
48
+ expect(init.method).toBe('POST')
49
+ expect(init.headers[DEV_RUNTIME_TOKEN_HEADER]).toBe(TOKEN)
50
+ expect(sentReports(fetchMock)[0]).toMatchObject({
51
+ kind: 'global-error',
52
+ message: 'TypeError: boom',
53
+ digest: 'abc123',
54
+ path: '/',
55
+ })
56
+ })
57
+
58
+ it('bounds an over-long stack', () => {
59
+ enableCollector()
60
+ const error = new Error('boom')
61
+ error.stack = 'y'.repeat(5000)
62
+ reportDevRuntimeError({ kind: 'global-error', error })
63
+
64
+ expect(String(sentReports(fetchMock)[0].stack).length).toBeLessThanOrEqual(2000)
65
+ })
66
+
67
+ it('reports the same failure only once per page', () => {
68
+ enableCollector()
69
+ const error = new Error('boom')
70
+ reportDevRuntimeError({ kind: 'global-error', error })
71
+ reportDevRuntimeError({ kind: 'global-error', error })
72
+ reportDevRuntimeError({ kind: 'global-error', error })
73
+
74
+ expect(fetchMock).toHaveBeenCalledTimes(1)
75
+ })
76
+
77
+ it('caps the number of distinct reports per page', () => {
78
+ enableCollector()
79
+ for (let index = 0; index < 30; index += 1) {
80
+ reportDevRuntimeError({ kind: 'window-error', message: `boom ${index}` })
81
+ }
82
+ expect(fetchMock).toHaveBeenCalledTimes(20)
83
+ })
84
+
85
+ it('never throws when the collector rejects the request', () => {
86
+ enableCollector()
87
+ fetchMock.mockImplementation(() => { throw new Error('network down') })
88
+ expect(() => reportDevRuntimeError({ kind: 'global-error', message: 'boom' })).not.toThrow()
89
+ })
90
+
91
+ it('ignores a report without a usable message', () => {
92
+ enableCollector()
93
+ reportDevRuntimeError({ kind: 'window-error', message: ' ' })
94
+ expect(fetchMock).not.toHaveBeenCalled()
95
+ })
96
+ })
97
+
98
+ describe('DevRuntimeReporter', () => {
99
+ it('forwards an uncaught window error', async () => {
100
+ enableCollector()
101
+ render(<DevRuntimeReporter />)
102
+
103
+ window.dispatchEvent(new ErrorEvent('error', { message: 'TypeError: boom', error: new TypeError('boom') }))
104
+
105
+ await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1))
106
+ expect(sentReports(fetchMock)[0]).toMatchObject({ kind: 'window-error' })
107
+ })
108
+
109
+ it('classifies a chunk load failure separately', async () => {
110
+ enableCollector()
111
+ render(<DevRuntimeReporter />)
112
+
113
+ window.dispatchEvent(new ErrorEvent('error', { message: 'ChunkLoadError: Loading chunk 42 failed' }))
114
+
115
+ await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1))
116
+ expect(sentReports(fetchMock)[0]).toMatchObject({ kind: 'chunk-load-error' })
117
+ })
118
+
119
+ it('forwards an unhandled promise rejection', async () => {
120
+ enableCollector()
121
+ render(<DevRuntimeReporter />)
122
+
123
+ const event = new Event('unhandledrejection') as Event & { reason?: unknown }
124
+ event.reason = new Error('rejected')
125
+ window.dispatchEvent(event)
126
+
127
+ await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1))
128
+ expect(sentReports(fetchMock)[0]).toMatchObject({ kind: 'unhandled-rejection', message: 'Error: rejected' })
129
+ })
130
+
131
+ it('stops listening after unmount', async () => {
132
+ enableCollector()
133
+ const { unmount } = render(<DevRuntimeReporter />)
134
+ unmount()
135
+
136
+ window.dispatchEvent(new ErrorEvent('error', { message: 'TypeError: boom' }))
137
+ await waitFor(() => expect(fetchMock).not.toHaveBeenCalled())
138
+ })
139
+
140
+ it('renders nothing', () => {
141
+ enableCollector()
142
+ const { container } = render(<DevRuntimeReporter />)
143
+ expect(container.innerHTML).toBe('')
144
+ })
145
+ })
@@ -59,13 +59,13 @@ describe('FormHeader', () => {
59
59
  )
60
60
  })
61
61
 
62
- it('renders edit header injection spot with routing context', () => {
62
+ it('renders edit titles as section headings by default with an explicit page-heading option', () => {
63
63
  renderWithProviders(
64
64
  <FormHeader title="Edit Person" />,
65
65
  { dict: {} },
66
66
  )
67
67
 
68
- expect(screen.getByText('Edit Person')).toBeInTheDocument()
68
+ expect(screen.getByRole('heading', { level: 2, name: 'Edit Person' })).toBeInTheDocument()
69
69
  expect(screen.getByTestId('injection-spot:form-header:edit')).toBeInTheDocument()
70
70
  expect(mockInjectionSpot).toHaveBeenCalledWith(
71
71
  expect.objectContaining({
@@ -76,5 +76,12 @@ describe('FormHeader', () => {
76
76
  },
77
77
  }),
78
78
  )
79
+
80
+ renderWithProviders(
81
+ <FormHeader title="Edit Company" titleHeadingLevel={1} />,
82
+ { dict: {} },
83
+ )
84
+
85
+ expect(screen.getByRole('heading', { level: 1, name: 'Edit Company' })).toBeInTheDocument()
79
86
  })
80
87
  })
@@ -0,0 +1,412 @@
1
+ "use client"
2
+ import * as React from 'react'
3
+ import { ChevronDown, ChevronUp, Database, RefreshCw, RotateCcw, ScrollText, Wrench, X } from 'lucide-react'
4
+ import { useOptionalT } from '@open-mercato/shared/lib/i18n/context'
5
+ import {
6
+ isDevRuntimeBannerEnabled,
7
+ readDevRuntimeLogsUrl,
8
+ readDevRuntimeToken,
9
+ } from '@open-mercato/shared/lib/dev-runtime/report'
10
+ import {
11
+ DEV_RUNTIME_ACTIONS_PATH,
12
+ DEV_RUNTIME_LOGS_PATH,
13
+ DEV_RUNTIME_STATUS_PATH,
14
+ DEV_RUNTIME_TOKEN_HEADER,
15
+ type RuntimeHealth,
16
+ type RuntimeIssue,
17
+ type DevRuntimeLogSnapshot,
18
+ type RuntimeRecoveryAction,
19
+ type RuntimeStatus,
20
+ } from '@open-mercato/shared/lib/dev-runtime/types'
21
+ import { Button } from '../../primitives/button'
22
+ import { IconButton } from '../../primitives/icon-button'
23
+ import { useConfirmDialog } from '../confirm-dialog'
24
+ import { apiCall } from '../utils/apiCall'
25
+
26
+ const ACTION_ICONS: Record<RuntimeRecoveryAction, typeof RefreshCw> = {
27
+ generate: Wrench,
28
+ migrate: Database,
29
+ restart: RotateCcw,
30
+ }
31
+
32
+ // `restart` is always safe to offer; `generate` and `migrate` appear only when
33
+ // the classifier justified them for this incident. `migrate` additionally
34
+ // requires a confirmation surface — the shared dialog needs the i18n provider,
35
+ // so a provider-less tree gets no irreversible action rather than an
36
+ // unconfirmed one.
37
+ function resolveOfferedActions(
38
+ issue: RuntimeIssue | null,
39
+ { canConfirm }: { canConfirm: boolean },
40
+ ): RuntimeRecoveryAction[] {
41
+ const actions: RuntimeRecoveryAction[] = []
42
+ if (issue?.recovery === 'generate') actions.push('generate')
43
+ if (issue?.recovery === 'migrate' && canConfirm) actions.push('migrate')
44
+ actions.push('restart')
45
+ return actions
46
+ }
47
+
48
+ const POLL_INTERVAL_MS = 2000
49
+
50
+ const VISIBLE_HEALTH: RuntimeHealth[] = ['starting', 'degraded', 'recovering', 'unavailable']
51
+
52
+ type BannerTone = 'info' | 'warning' | 'error'
53
+
54
+ const HEALTH_TONE: Record<RuntimeHealth, BannerTone> = {
55
+ starting: 'info',
56
+ ready: 'info',
57
+ degraded: 'warning',
58
+ recovering: 'info',
59
+ unavailable: 'error',
60
+ }
61
+
62
+ const TONE_CLASSES: Record<BannerTone, string> = {
63
+ info: 'border-status-info-border bg-status-info-bg text-status-info-text',
64
+ warning: 'border-status-warning-border bg-status-warning-bg text-status-warning-text',
65
+ error: 'border-status-error-border bg-status-error-bg text-status-error-text',
66
+ }
67
+
68
+ const TONE_ACTION_CLASSES: Record<BannerTone, string> = {
69
+ info: 'border-status-info-border bg-status-info-bg text-status-info-text hover:bg-status-info-border hover:text-status-info-text',
70
+ warning: 'border-status-warning-border bg-status-warning-bg text-status-warning-text hover:bg-status-warning-border hover:text-status-warning-text',
71
+ error: 'border-status-error-border bg-status-error-bg text-status-error-text hover:bg-status-error-border hover:text-status-error-text',
72
+ }
73
+
74
+ function dismissalKey(status: RuntimeStatus, issue: RuntimeIssue | null): string {
75
+ return `${status.generation}:${issue?.fingerprint ?? status.health}`
76
+ }
77
+
78
+ // The dev bridge answers 403 whenever the per-run token is stale — routine after
79
+ // a `yarn dev` restart leaves an already-open tab holding the previous run's
80
+ // token — and 404 once diagnostics are off. Neither is a staff-auth event, so
81
+ // both of `apiFetch`'s redirect hooks are switched off. Without this it throws
82
+ // `ForbiddenError` instead of returning the response, the poll below swallows
83
+ // the throw, and the banner freezes on the dead runtime's incident rather than
84
+ // clearing itself.
85
+ function devRuntimeRequestHeaders(token: string): Record<string, string> {
86
+ return {
87
+ [DEV_RUNTIME_TOKEN_HEADER]: token,
88
+ 'x-om-unauthorized-redirect': '0',
89
+ 'x-om-forbidden-redirect': '0',
90
+ }
91
+ }
92
+
93
+ async function fetchRuntimeStatus(token: string, signal: AbortSignal): Promise<RuntimeStatus | null> {
94
+ const response = await apiCall<RuntimeStatus>(DEV_RUNTIME_STATUS_PATH, {
95
+ headers: devRuntimeRequestHeaders(token),
96
+ cache: 'no-store',
97
+ signal,
98
+ })
99
+ if (!response.ok) return null
100
+ return response.result
101
+ }
102
+
103
+ function useRuntimeStatus(token: string | null): RuntimeStatus | null {
104
+ const [status, setStatus] = React.useState<RuntimeStatus | null>(null)
105
+
106
+ React.useEffect(() => {
107
+ if (!token) return undefined
108
+ let cancelled = false
109
+ const controller = new AbortController()
110
+
111
+ const poll = async () => {
112
+ try {
113
+ const next = await fetchRuntimeStatus(token, controller.signal)
114
+ if (!cancelled) setStatus(next)
115
+ } catch {
116
+ // A momentarily unreachable bridge must never break the page: keep the
117
+ // last known status and try again on the next tick.
118
+ }
119
+ }
120
+
121
+ void poll()
122
+ const timer = setInterval(() => { void poll() }, POLL_INTERVAL_MS)
123
+ return () => {
124
+ cancelled = true
125
+ controller.abort()
126
+ clearInterval(timer)
127
+ }
128
+ }, [token])
129
+
130
+ return status
131
+ }
132
+
133
+ function reloadPage(): void {
134
+ if (typeof window === 'undefined') return
135
+ try {
136
+ window.location.reload()
137
+ } catch {
138
+ // Reload is a convenience affordance; ignore hosts that block it.
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Dev-only, in-app counterpart to the standalone startup splash. It reports the
144
+ * supervisor's runtime state on an already-open page so a post-ready failure is
145
+ * visible without switching to the terminal. It never renders in production and
146
+ * never renders while the runtime is healthy.
147
+ */
148
+ export function DevRuntimeDiagnosticsBanner() {
149
+ // The banner must render even when a broken tree left the app without its
150
+ // i18n provider, so the translator is optional with inline English fallbacks.
151
+ const translate = useOptionalT()
152
+ const t = React.useCallback(
153
+ (key: string, fallback: string) => (translate ? translate(key, fallback) : fallback),
154
+ [translate],
155
+ )
156
+ const [token, setToken] = React.useState<string | null>(null)
157
+ const [logsUrl, setLogsUrl] = React.useState<string | null>(null)
158
+ const [expanded, setExpanded] = React.useState(false)
159
+ const [dismissed, setDismissed] = React.useState<string | null>(null)
160
+ const [pendingAction, setPendingAction] = React.useState<RuntimeRecoveryAction | null>(null)
161
+ const [logs, setLogs] = React.useState<DevRuntimeLogSnapshot | null>(null)
162
+ const [logsOpen, setLogsOpen] = React.useState(false)
163
+ const [actionError, setActionError] = React.useState<string | null>(null)
164
+ const { confirm, ConfirmDialogElement } = useConfirmDialog()
165
+ // ConfirmDialog itself calls `useT`, so it can only be mounted where the
166
+ // provider exists.
167
+ const canConfirm = translate !== undefined
168
+
169
+ React.useEffect(() => {
170
+ if (!isDevRuntimeBannerEnabled()) return
171
+ setToken(readDevRuntimeToken())
172
+ setLogsUrl(readDevRuntimeLogsUrl())
173
+ }, [])
174
+
175
+ const status = useRuntimeStatus(token)
176
+ const issue = status?.issueSummary ?? null
177
+ const currentKey = status ? dismissalKey(status, issue) : null
178
+
179
+ // Dismissal is view-local and scoped to one generation:fingerprint, so a new
180
+ // incident — or the same one in a new generation — reappears.
181
+ React.useEffect(() => {
182
+ if (currentKey && dismissed && dismissed !== currentKey) setDismissed(null)
183
+ }, [currentKey, dismissed])
184
+
185
+ React.useEffect(() => {
186
+ if (status?.health === 'ready') setExpanded(false)
187
+ }, [status?.health])
188
+
189
+ // Logs are fetched on demand from the app itself, so opening them never
190
+ // navigates away from the page being debugged.
191
+ const toggleLogs = React.useCallback(async () => {
192
+ if (logsOpen) {
193
+ setLogsOpen(false)
194
+ return
195
+ }
196
+ setLogsOpen(true)
197
+ if (!token) return
198
+ try {
199
+ const response = await apiCall<DevRuntimeLogSnapshot>(`${DEV_RUNTIME_LOGS_PATH}?cursor=0`, {
200
+ headers: devRuntimeRequestHeaders(token),
201
+ cache: 'no-store',
202
+ })
203
+ setLogs(response.ok ? response.result : null)
204
+ } catch {
205
+ setLogs(null)
206
+ }
207
+ }, [logsOpen, token])
208
+
209
+ const runRecoveryAction = React.useCallback(async (action: RuntimeRecoveryAction) => {
210
+ if (!token || pendingAction) return
211
+ // `migrate` writes to the database and cannot be undone automatically, so it
212
+ // always goes through the shared confirmation dialog.
213
+ if (action === 'migrate') {
214
+ const confirmed = await confirm({
215
+ title: t('ui.devRuntime.confirm.migrate.title', 'Apply database migrations?'),
216
+ text: t(
217
+ 'ui.devRuntime.confirm.migrate.text',
218
+ 'This applies pending migrations to your development database. It is not automatically reversible — rolling back is a separate manual task.',
219
+ ),
220
+ confirmText: t('ui.devRuntime.actions.migrate', 'Run migrations'),
221
+ variant: 'destructive',
222
+ })
223
+ if (!confirmed) return
224
+ }
225
+
226
+ setPendingAction(action)
227
+ setActionError(null)
228
+ try {
229
+ const response = await apiCall<{ error?: { message?: string } }>(`${DEV_RUNTIME_ACTIONS_PATH}/${action}`, {
230
+ method: 'POST',
231
+ headers: devRuntimeRequestHeaders(token),
232
+ cache: 'no-store',
233
+ })
234
+ if (!response.ok) {
235
+ setActionError(response.result?.error?.message ?? t('ui.devRuntime.actions.failed', 'The recovery action could not be started.'))
236
+ }
237
+ } catch {
238
+ setActionError(t('ui.devRuntime.actions.failed', 'The recovery action could not be started.'))
239
+ } finally {
240
+ setPendingAction(null)
241
+ }
242
+ }, [token, pendingAction, confirm, t])
243
+
244
+ if (!status || !VISIBLE_HEALTH.includes(status.health)) return null
245
+ if (currentKey && dismissed === currentKey) return null
246
+
247
+ const tone = HEALTH_TONE[status.health]
248
+ const isBusy = status.recovery?.busy === true
249
+ const headline = t(`ui.devRuntime.health.${status.health}`, DEFAULT_HEALTH_COPY[status.health])
250
+ const title = issue?.title ?? t('ui.devRuntime.noIncident', 'No incident details available')
251
+
252
+ return (
253
+ <div
254
+ data-testid="dev-runtime-diagnostics-banner"
255
+ data-health={status.health}
256
+ role={status.health === 'unavailable' ? 'alert' : 'status'}
257
+ aria-live={status.health === 'unavailable' ? 'assertive' : 'polite'}
258
+ // Floating bottom-right dev overlay, lifted clear of the support-chat
259
+ // launcher that sits in that corner. Third-party launchers ship their own
260
+ // very high z-index, so the banner stacks ABOVE the bubble rather than
261
+ // trying to outrank it. `max-w-4xl` keeps the action row on one line on
262
+ // desktop; it still wraps (never scrolls) once the viewport is narrow.
263
+ className={`fixed inset-x-3 bottom-20 z-banner flex flex-col gap-2 rounded-lg border px-4 py-3 text-sm shadow-lg sm:inset-x-auto sm:right-4 sm:max-w-4xl ${TONE_CLASSES[tone]}`}
264
+ >
265
+ <div className="flex items-start justify-between gap-2">
266
+ <div className="min-w-0 flex-1">
267
+ <p className="font-medium">
268
+ {headline}
269
+ <span aria-hidden="true"> · </span>
270
+ {title}
271
+ </p>
272
+ {issue?.detail ? <p className="mt-0.5 break-words">{issue.detail}</p> : null}
273
+ </div>
274
+ {/* Dismiss stays pinned to the corner instead of joining the wrapping
275
+ action row, where it used to orphan onto a line of its own. */}
276
+ <IconButton
277
+ type="button"
278
+ variant="ghost"
279
+ size="sm"
280
+ aria-label={t('ui.devRuntime.actions.dismiss', 'Dismiss')}
281
+ onClick={() => setDismissed(currentKey)}
282
+ >
283
+ <X className="size-4" aria-hidden="true" />
284
+ </IconButton>
285
+ </div>
286
+
287
+ <div className="flex flex-wrap items-center gap-1">
288
+ {issue ? (
289
+ <Button
290
+ type="button"
291
+ variant="outline"
292
+ size="sm"
293
+ aria-expanded={expanded}
294
+ onClick={() => setExpanded((value) => !value)}
295
+ className={`whitespace-nowrap ${TONE_ACTION_CLASSES[tone]}`}
296
+ >
297
+ {expanded
298
+ ? <ChevronUp className="mr-1 size-4" aria-hidden="true" />
299
+ : <ChevronDown className="mr-1 size-4" aria-hidden="true" />}
300
+ {expanded
301
+ ? t('ui.devRuntime.actions.hideDetails', 'Hide details')
302
+ : t('ui.devRuntime.actions.showDetails', 'Show details')}
303
+ </Button>
304
+ ) : null}
305
+ {!isBusy ? (
306
+ <Button
307
+ type="button"
308
+ variant="outline"
309
+ size="sm"
310
+ onClick={reloadPage}
311
+ className={`whitespace-nowrap ${TONE_ACTION_CLASSES[tone]}`}
312
+ >
313
+ <RefreshCw className="mr-1 size-4" aria-hidden="true" />
314
+ {t('ui.devRuntime.actions.retry', 'Retry')}
315
+ </Button>
316
+ ) : null}
317
+ {!isBusy && token
318
+ ? resolveOfferedActions(issue, { canConfirm }).map((action) => {
319
+ const ActionIcon = ACTION_ICONS[action]
320
+ return (
321
+ <Button
322
+ key={action}
323
+ type="button"
324
+ variant="outline"
325
+ size="sm"
326
+ disabled={pendingAction !== null}
327
+ onClick={() => { void runRecoveryAction(action) }}
328
+ className={`whitespace-nowrap ${TONE_ACTION_CLASSES[tone]}`}
329
+ >
330
+ <ActionIcon className="mr-1 size-4" aria-hidden="true" />
331
+ {t(`ui.devRuntime.actions.${action}`, DEFAULT_ACTION_COPY[action])}
332
+ </Button>
333
+ )
334
+ })
335
+ : null}
336
+ {token ? (
337
+ <Button
338
+ type="button"
339
+ variant="outline"
340
+ size="sm"
341
+ aria-expanded={logsOpen}
342
+ onClick={() => { void toggleLogs() }}
343
+ className={`whitespace-nowrap ${TONE_ACTION_CLASSES[tone]}`}
344
+ >
345
+ <ScrollText className="mr-1 size-4" aria-hidden="true" />
346
+ {logsOpen
347
+ ? t('ui.devRuntime.actions.hideLogs', 'Hide logs')
348
+ : t('ui.devRuntime.actions.viewLogs', 'View logs')}
349
+ </Button>
350
+ ) : null}
351
+ </div>
352
+
353
+ {actionError ? <p className="text-xs font-medium">{actionError}</p> : null}
354
+
355
+ {expanded && issue ? (
356
+ <dl className="grid grid-cols-1 gap-x-6 gap-y-1 text-xs sm:grid-cols-2">
357
+ <DetailRow label={t('ui.devRuntime.details.code', 'Error code')} value={issue.code} />
358
+ <DetailRow label={t('ui.devRuntime.details.source', 'Source')} value={issue.source} />
359
+ <DetailRow label={t('ui.devRuntime.details.occurrences', 'Occurrences')} value={String(issue.occurrences)} />
360
+ <DetailRow label={t('ui.devRuntime.details.generation', 'Runtime generation')} value={String(issue.generation)} />
361
+ <DetailRow label={t('ui.devRuntime.details.firstSeen', 'First seen')} value={issue.firstSeenAt} />
362
+ <DetailRow label={t('ui.devRuntime.details.lastSeen', 'Last seen')} value={issue.lastSeenAt} />
363
+ {issue.path ? <DetailRow label={t('ui.devRuntime.details.path', 'Path')} value={issue.path} /> : null}
364
+ </dl>
365
+ ) : null}
366
+ {logsOpen ? (
367
+ <div className="rounded-md border border-current/20 bg-black/20">
368
+ {logs && logs.lines.length > 0 ? (
369
+ <pre className="max-h-48 overflow-auto whitespace-pre-wrap break-words p-2 font-mono text-xs leading-relaxed">
370
+ {logs.lines.map((line) => `${line.at.slice(11, 19)} ${line.text}`).join('\n')}
371
+ </pre>
372
+ ) : (
373
+ <p className="p-2 text-xs">{t('ui.devRuntime.logs.empty', 'No diagnostic lines yet.')}</p>
374
+ )}
375
+ {logsUrl ? (
376
+ <p className="border-t border-current/20 px-2 py-1 text-xs opacity-80">
377
+ {t('ui.devRuntime.logs.splashHint', 'Full startup stream:')}{' '}
378
+ <a className="underline" href={logsUrl} target="_blank" rel="noreferrer">{logsUrl}</a>
379
+ </p>
380
+ ) : null}
381
+ </div>
382
+ ) : null}
383
+
384
+ {canConfirm ? ConfirmDialogElement : null}
385
+ </div>
386
+ )
387
+ }
388
+
389
+ const DEFAULT_ACTION_COPY: Record<RuntimeRecoveryAction, string> = {
390
+ generate: 'Run generators',
391
+ migrate: 'Run migrations',
392
+ restart: 'Restart runtime',
393
+ }
394
+
395
+ const DEFAULT_HEALTH_COPY: Record<RuntimeHealth, string> = {
396
+ starting: 'Runtime starting',
397
+ ready: 'Runtime ready',
398
+ degraded: 'Runtime degraded',
399
+ recovering: 'Runtime recovering',
400
+ unavailable: 'Runtime unavailable',
401
+ }
402
+
403
+ function DetailRow({ label, value }: { label: string; value: string }) {
404
+ return (
405
+ <div className="flex gap-2">
406
+ <dt className="shrink-0 opacity-80">{label}</dt>
407
+ <dd className="min-w-0 break-words font-mono">{value}</dd>
408
+ </div>
409
+ )
410
+ }
411
+
412
+ export default DevRuntimeDiagnosticsBanner
@@ -0,0 +1,46 @@
1
+ "use client"
2
+ import * as React from 'react'
3
+ import { reportDevRuntimeError } from '@open-mercato/shared/lib/dev-runtime/report'
4
+
5
+ function isChunkLoadFailure(message: string): boolean {
6
+ const haystack = message.toLowerCase()
7
+ return haystack.includes('chunkloaderror')
8
+ || haystack.includes('loading chunk')
9
+ || haystack.includes('loading css chunk')
10
+ }
11
+
12
+ /**
13
+ * Dev-only client island that forwards uncaught browser failures to the local
14
+ * supervisor. It registers bounded listeners only, adds no context provider,
15
+ * and stays silent when the collector token is absent (production, CI, or
16
+ * diagnostics disabled).
17
+ */
18
+ export function DevRuntimeReporter() {
19
+ React.useEffect(() => {
20
+ if (typeof window === 'undefined') return undefined
21
+
22
+ const handleError = (event: ErrorEvent) => {
23
+ const message = event.message ?? ''
24
+ reportDevRuntimeError({
25
+ kind: isChunkLoadFailure(message) ? 'chunk-load-error' : 'window-error',
26
+ error: event.error,
27
+ message: message || undefined,
28
+ })
29
+ }
30
+
31
+ const handleRejection = (event: PromiseRejectionEvent) => {
32
+ reportDevRuntimeError({ kind: 'unhandled-rejection', error: event.reason })
33
+ }
34
+
35
+ window.addEventListener('error', handleError)
36
+ window.addEventListener('unhandledrejection', handleRejection)
37
+ return () => {
38
+ window.removeEventListener('error', handleError)
39
+ window.removeEventListener('unhandledrejection', handleRejection)
40
+ }
41
+ }, [])
42
+
43
+ return null
44
+ }
45
+
46
+ export default DevRuntimeReporter
@@ -23,6 +23,8 @@ export type FormHeaderEditProps = FormHeaderBaseProps & {
23
23
  mode?: 'edit'
24
24
  /** Small title next to the back link */
25
25
  title?: string
26
+ /** Semantic level for the title. Defaults to 2 for section-level compatibility. */
27
+ titleHeadingLevel?: 1 | 2
26
28
  /** Structured action buttons (Delete/Cancel/Save) */
27
29
  actions?: FormActionButtonsProps
28
30
  /** Custom right-side content (overrides `actions`) */
@@ -96,9 +98,11 @@ function EditHeader({
96
98
  backHref,
97
99
  resolvedBackLabel,
98
100
  title,
101
+ titleHeadingLevel = 2,
99
102
  actions,
100
103
  actionsContent,
101
104
  }: FormHeaderEditProps & { resolvedBackLabel: string }) {
105
+ const TitleHeading = titleHeadingLevel === 1 ? 'h1' : 'h2'
102
106
  return (
103
107
  <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
104
108
  <div className="flex items-center gap-3">
@@ -107,7 +111,7 @@ function EditHeader({
107
111
  &larr; {resolvedBackLabel}
108
112
  </Link>
109
113
  ) : null}
110
- {title ? <div className="text-base font-medium">{title}</div> : null}
114
+ {title ? <TitleHeading className="text-base font-medium">{title}</TitleHeading> : null}
111
115
  </div>
112
116
  {actionsContent ?? (actions ? <FormActionButtons {...actions} /> : null)}
113
117
  </div>
@@ -141,6 +141,7 @@ export function MessageComposer(props: MessageComposerProps) {
141
141
  <CrudForm<Record<string, unknown>>
142
142
  backHref={backHref}
143
143
  title={composeWithContextPreview.composerTitle}
144
+ titleHeadingLevel={2}
144
145
  fields={createMessageComposeFormGroups(composeWithContextPreview)}
145
146
  initialValues={{}}
146
147
  submitLabel={composeWithContextPreview.submitLabel}