Source: ddx-documentation/debug-overlay.md
The debug overlay is a dev-only inspector in ddx-web. It lists the tagged components of the page, highlights one, shows the live SSE tail and the browser-log tail, and copies a "Copy for Claude" dossier to the clipboard. It ships in no production bundle. ## 1 Flag | Key | Values | Read by | |---|---|---| | `NEXT_PUBLIC_UI_DEBUG` | `0` (default) · `1` | `ddx-web/src/env.ts` (client schema), `lib/debug/types.ts` `isDebugEnabled()`, `components/debug/DdxDebugProvider.tsx`, `lib/logger/browser.ts` (debug-level browser logs) | - Set it in `ddx-web/.env.local` only, then `./ddx-manage.sh restart ddx-web`. Next inlines `NEXT_PUBLIC_*` values at build time; a running dev server does not see a change. - The old generic debug flag is removed. There is no alias: `NEXT_PUBLIC_UI_DEBUG` is the one key for the overlay and for browser debug-level logs. Server log levels stay on `LOG_LEVEL` / `LOG_SCOPES` ([observability.md](/en/docs/observability)). - Never commit `NEXT_PUBLIC_UI_DEBUG=1` to a build config, a Dockerfile or `.env.example` (harness-alignment invariant 8). Open and close the panel with the floating bug button (bottom start) or **Alt+Shift+D**. ## 2 Id format `toDdxId(entryId, instance?)` (`ddx-web/src/lib/debug/ids.ts`) is the one id builder: | Call | Id | |---|---| | `toDdxId('PortalPageList')` | `portal-page-list` | | `toDdxId('DataTable', 'members')` | `data-table:members` | | `toDdxId('EntityForm', 'Project Edit')` | `entity-form:project-edit` | - The base is the kebab form of the inventory entry id (`src/inventory/entries/*.ts`). - `:<instance>` tells two instances of one entry apart on one page. It is kebab-cased too. - The id lands in the `data-ddx-id` attribute. A second attribute, `data-debug-id`, holds a unique per-instance value (`useId`); the registry rebuilds the tree from it. ## 3 How to instrument Spread the attributes on the root element of the component. Never write the attribute by hand in TSX: the HMS repo leaked hand-written ids from 213 files into production. The task gate checks that no `.tsx` file under `ddx-web/src` contains the literal attribute assignment. Client component: ```tsx import { useDebugAttrs } from '@/lib/debug/use-debug-attrs'; export function DataTable(props: DataTableProps): React.JSX.Element { const debugAttrs = useDebugAttrs('DataTable', props.instantiation.id); return <div {...debugAttrs}>…</div>; } ``` Server component (no registry, id only): ```tsx import { debugIdAttrs } from '@/lib/debug/ids'; export function PortalPage(props: PortalPageProps): React.JSX.Element { return <section {...debugIdAttrs('PortalPage')}>…</section>; } ``` - `useDebugAttrs` returns `{}` when the flag is off, so the component renders the same markup as before. With the flag on it registers the instance in `ddxDebugRegistry` on mount and removes it on unmount. - `debugIdAttrs` only sets `data-ddx-id`. A server-only element is visible in the DOM but not in the panel tree; tag its nearest client island with `useDebugAttrs` when you need it there. - Use the inventory entry id as `entryId`. Hooks and helpers are never entries (N-ENTRY-ID). - `debugIdAttrs` is the server form of `useDebugAttrs` (same id, same flag). Pick by the file: `'use client'` first line → `useDebugAttrs`; no directive → `debugIdAttrs` (the file may render inside a Server Component, where a hook crashes). Never add `'use client'` only to instrument. - The outer entry wins. When an entry renders another entry (EmptyState → Surface, PortalPageList → PortalPage), the outer spreads its attributes on the inner one, and the inner spreads its own attributes BEFORE `{...props}` / `{...callerDebugAttrs}`, so the caller's id lands in the DOM. `PortalPage`, `StateFrame` (portal template states) and `SegmentedChoice` take `DebugAttrs` in their props for this. - Not tagged (no DOM root of their own): providers (`SseBusProvider`), `PdfDocument` (`@react-pdf/renderer` tree), fragment roots (`PortalCommandPalette`). ## 4 Panel `DdxDebugProvider` is mounted once in `ddx-web/src/app/[locale]/layout.tsx`, inside `NextIntlClientProvider`. It is the Platform inventory entry `PlatformDebugProvider` (`src/inventory/entries/debug.ts`). It loads two lazy bodies: | Body | Mounted by | Role | |---|---|---| | `DdxDebugToggle` | `DdxDebugToggleSlot` in the portal header | Bug button; flips `ddxDebugRegistry` active state | | `DdxDebugPanel` | `DdxDebugProvider` (every route) | Alt+Shift+D; panel; page layers below | While the overlay is active the panel also mounts the page layers. All of them live in `ddx-web/src/components/debug/` and load only inside the panel chunk: | File | Role | |---|---| | `DdxDebugHoverLayer.tsx` | dashed outline on every instrumented element; box + tooltip on the one under the pointer | | `DdxDebugContextMenu.tsx` | right-click dialog for the component under the pointer | | `DdxDebugTree.tsx` | Components tab tree | | `DdxDebugInspector.tsx` | Inspector tab | | `DdxDebugStylesTab.tsx` | Styles tab | | `DdxDebugSurface.tsx` · `DdxDebugSurfaceBar.tsx` | frame of the panel and of the component dialog: float · dock · side sheet (§4.2) | | `DdxDebugFacts.tsx` · `DdxDebugBoxModel.tsx` · `DdxDebugCopyStatus.tsx` | shared views | Tabs: Components · Inspector · Styles · SSE · Logs. The tab is store state (`DebugUiState.panelTab`), so the right-click dialog can switch it. A strip above the tabs shows the current page path with a copy button. The panel reads three module-level stores in `ddx-web/src/lib/debug/`: `ddxDebugRegistry` (`registry.ts`), `sseInspector` (`sse-inspector.ts`) and `ddxLogBuffer` (`log-buffer.ts`). Components read the registry only through `use-debug-state.ts` (`useSyncExternalStore` snapshots). A value read off the singleton during render is cached by the React Compiler. The SSE and log tabs show the newest 50 rows (override `tailSize`). All texts are in `ddx-web/messages/<locale>/debug.json`. ### 4.1 How to use Turn the overlay on: the Bug button in the portal header, or Alt+Shift+D on any route. The panel and the component dialog can float, dock or open as a side sheet (§4.2). | You do | You get | |---|---| | Move the pointer over the page | the nearest instrumented element gets a box and a tooltip: entry name, debug id, display, size, first 6 classes | | Right-click an instrumented element | the component dialog (non-modal; the next right-click retargets it): kind (client / server), debug id, ancestor path, context (route, element, text), layout, box model, props, classes | | Dialog: Inspect | the panel opens on the Inspector tab with the component selected | | Dialog: Select in tree | the Components tab selects it and opens the path to it | | Dialog: Scroll to | the page scrolls the element into the middle of the screen | | Dialog: Copy for Claude · Copy JSON · Copy id | dossier JSON · the dialog data as JSON · the debug id | | Shift+right-click, or right-click with the overlay off | the normal browser menu | | Tree: chevron · click · double-click | open / close a branch · select (keeps the highlight) · inspect | | Inspector | breadcrumb (click an ancestor to select it), identity, layout, box model, props, classes; Copy for Claude, Copy id, Scroll to, Measure again | | Styles | resolved `--token-color-*` values with swatches; Copy all | The target of a right-click or hover is the nearest element with `data-debug-id` (client instance in the registry) or only `data-ddx-id` (server component). A server-only target still opens the dialog; Inspect and Select in tree then use the nearest registered ancestor, and the dossier is built from the DOM. Elements inside the overlay carry `data-ddx-debug-ui` (`DEBUG_UI_ATTR` in `lib/debug/target.ts`) and are never a target. A page click is never intercepted: links and buttons keep working while the overlay is on. Every DOM measurement happens at the event, on selection, or on a button — never in render. ### 4.2 Layout modes (panel and component dialog) Both surfaces use one frame, `DdxDebugSurface`. Each has its own mode, side, position and size. The title bar buttons switch the mode: Dock left · Dock right · Dock bottom (panel only) · Float · Open in a side sheet · Close. | Mode | What it is | How you get there | |---|---|---| | Float | a box over the page | Float button; or drag a docked surface by its title bar | | Dock | a full-height side bar (or a bottom bar for the panel) that pushes the page aside | Dock button; or drop a dragged surface within 24 px of a screen edge (a band shows the target) | | Side sheet | the kit `Sheet`, non-modal: the page still takes right-clicks | Open in a side sheet button | - Move: drag the title bar, or focus the grip and press the arrow keys (16 px, Shift = 64 px). The box stays inside the viewport and moves off the header Bug button (`data-ddx-debug-toggle`). - Resize: the corner grip in float mode, the inner edge in dock mode. Both also take the arrow keys when focused. A dock stays between the minimum size and 80 % of the screen. - Dock reserves space: a docked surface sets `--ddx-debug-<surface>-<side>` on `<html>`. `DdxDebugLayoutStyle` pads `<html>` by the sum per side and shortens `.h-dvh` columns by the bottom inset. Undock, Close and switching the overlay off remove the variables. Two surfaces docked on the same side sit next to each other. - The component dialog never blocks the page in any mode. Right-click another component and it follows the new target. - The debug side sheet renders without a backdrop (`SheetContent showOverlay={false}`), so the page stays visible and clickable. App sheets keep their backdrop (default `true`). - The layout persists: `ddxDebugRegistry.getLayout()`, saved to `localStorage` `ddx-debug:layout` (dev-only UI state, not a user setting; every read is validated in `lib/debug/layout.ts`). A reload keeps the layout. - The overlay on/off state persists too: `localStorage` `ddx-debug:active` (`1` / `0`), written by `setActive`, read when the registry loads. It survives reloads and browser restarts; clear the key (or toggle off) to start closed. - Motion: the dock push uses `--token-duration-base`; there is no transition under `prefers-reduced-motion`. Scroll to is instant there too. Not built: category filter chips (the registry has `toggleCategory`, but `useDebugAttrs` sets no category yet) and i18n keys per node (the registry records none). ## 5 Export format All exports go to the clipboard. Nothing is stored in `localStorage` or IndexedDB: SSE payloads and log lines can hold data. | Button | Source | Shape | |---|---|---| | Copy for Claude | `formatForClaude(id, registry)` | `ClaudeComponentExport` JSON (`lib/debug/types.ts`): component, ddxId, textHint, route, props, tailwind, computed layout, box model, ancestors, parent layout, children, siblings | | Copy page snapshot | `snapshotPage(registry)` | `{ url, viewport, role, components, tree[] }` with display and size per node | | Copy tree | `formatHierarchy(registry)` | ASCII tree: `Name #ddx-id (F, 320px × 48px) [category]` | | Copy events | `formatShortLog(...)` | header + one line per event: `#seq HH:MM:SS.mmm type {payload-signature}` | | Copy logs | panel | one line per entry: `ISO-time level scope message` | Copy for Claude needs a selected row (panel) or a right-clicked component (dialog). The dossier is built at click time, so the box model and computed layout match the screen at that moment. ## 6 Production guarantee 1. `DdxDebugProvider.tsx` reads `process.env.NEXT_PUBLIC_UI_DEBUG === '1'` at module scope. In a build without the flag the bundler folds it to `false`; both `next/dynamic` imports are dead code, so the toggle and panel chunks are never emitted. The provider renders `null`. 2. `useDebugAttrs` and `debugIdAttrs` return a frozen empty object: no `data-ddx-id`, no `data-debug-id`, no registry entry. 3. Toggle and panel are not inventory entries. The gallery and docs import the registry, and an entry would pull them into a production gallery chunk. 4. The stores (`registry.ts`, `sse-inspector.ts`) check `isDebugEnabled()` and drop every `register` / `push` when the flag is off. 5. Browser `debug`-level log calls are dropped unless the flag is on. Proof (lead, at `/unify`): a production build with the flag unset, then `rg -l "debug\.panel|debug\.toggle" ddx-web/.next/static/chunks` prints nothing (those namespace strings exist only in the lazy bodies). The `data-debug-id` name itself may appear: it is a constant in `ids.ts`, and the gate on it is the empty return value, not its absence from the bundle.
The debug overlay is a dev-only inspector in ddx-web. It lists the tagged components of the page, highlights one, shows the live SSE tail and the browser-log tail, and copies a "Copy for Claude" dossier to the clipboard. It ships in no production bundle.
ddx-web/.env.local only, then ./ddx-manage.sh restart ddx-web. Next inlines
NEXT_PUBLIC_* values at build time; a running dev server does not see a change.NEXT_PUBLIC_UI_DEBUG is the one key for
the overlay and for browser debug-level logs. Server log levels stay on LOG_LEVEL /
LOG_SCOPES ().NEXT_PUBLIC_UI_DEBUG=1 to a build config, a Dockerfile or .env.example
(harness-alignment invariant 8).Open and close the panel with the floating bug button (bottom start) or Alt+Shift+D.
toDdxId(entryId, instance?) (ddx-web/src/lib/debug/ids.ts) is the one id builder:
src/inventory/entries/*.ts).:<instance> tells two instances of one entry apart on one page. It is kebab-cased too.data-ddx-id attribute. A second attribute, data-debug-id, holds a
unique per-instance value (useId); the registry rebuilds the tree from it.Spread the attributes on the root element of the component. Never write the attribute by hand
in TSX: the HMS repo leaked hand-written ids from 213 files into production. The task gate
checks that no .tsx file under ddx-web/src contains the literal attribute assignment.
Client component:
Server component (no registry, id only):
useDebugAttrs returns {} when the flag is off, so the component renders the same markup
as before. With the flag on it registers the instance in ddxDebugRegistry on mount and
removes it on unmount.debugIdAttrs only sets data-ddx-id. A server-only element is visible in the DOM but not in
the panel tree; tag its nearest client island with useDebugAttrs when you need it there.entryId. Hooks and helpers are never entries (N-ENTRY-ID).debugIdAttrs is the server form of useDebugAttrs (same id, same flag). Pick by the file:
'use client' first line → useDebugAttrs; no directive → debugIdAttrs (the file may render
inside a Server Component, where a hook crashes). Never add 'use client' only to instrument.{...props} / {...callerDebugAttrs}, so the caller's id lands
in the DOM. PortalPage, StateFrame (portal template states) and SegmentedChoice take
DebugAttrs in their props for this.SseBusProvider), PdfDocument
(@react-pdf/renderer tree), fragment roots (PortalCommandPalette).DdxDebugProvider is mounted once in ddx-web/src/app/[locale]/layout.tsx, inside
NextIntlClientProvider. It is the Platform inventory entry PlatformDebugProvider
(src/inventory/entries/debug.ts). It loads two lazy bodies:
While the overlay is active the panel also mounts the page layers. All of them live in
ddx-web/src/components/debug/ and load only inside the panel chunk:
Tabs: Components · Inspector · Styles · SSE · Logs. The tab is store state
(DebugUiState.panelTab), so the right-click dialog can switch it. A strip above the tabs
shows the current page path with a copy button.
The panel reads three module-level stores in ddx-web/src/lib/debug/: ddxDebugRegistry
(registry.ts), sseInspector (sse-inspector.ts) and ddxLogBuffer (log-buffer.ts).
Components read the registry only through use-debug-state.ts (useSyncExternalStore
snapshots). A value read off the singleton during render is cached by the React Compiler.
The SSE and log tabs show the newest 50 rows (override tailSize). All texts are in
ddx-web/messages/<locale>/debug.json.
Turn the overlay on: the Bug button in the portal header, or Alt+Shift+D on any route. The panel and the component dialog can float, dock or open as a side sheet (§4.2).
The target of a right-click or hover is the nearest element with data-debug-id (client
instance in the registry) or only data-ddx-id (server component). A server-only target
still opens the dialog; Inspect and Select in tree then use the nearest registered
ancestor, and the dossier is built from the DOM. Elements inside the overlay carry
data-ddx-debug-ui (DEBUG_UI_ATTR in lib/debug/target.ts) and are never a target.
A page click is never intercepted: links and buttons keep working while the overlay is on.
Every DOM measurement happens at the event, on selection, or on a button — never in render.
Both surfaces use one frame, DdxDebugSurface. Each has its own mode, side, position and
size. The title bar buttons switch the mode: Dock left · Dock right · Dock bottom (panel
only) · Float · Open in a side sheet · Close.
data-ddx-debug-toggle).--ddx-debug-<surface>-<side> on <html>.
DdxDebugLayoutStyle pads <html> by the sum per side and shortens .h-dvh columns by
the bottom inset. Undock, Close and switching the overlay off remove the variables. Two
surfaces docked on the same side sit next to each other.SheetContent showOverlay={false}), so
the page stays visible and clickable. App sheets keep their backdrop (default true).ddxDebugRegistry.getLayout(), saved to localStorage
ddx-debug:layout (dev-only UI state, not a user setting; every read is validated in
lib/debug/layout.ts). A reload keeps the layout.localStorage ddx-debug:active (1 / 0), written by
setActive, read when the registry loads. It survives reloads and browser restarts; clear the
key (or toggle off) to start closed.--token-duration-base; there is no transition under
prefers-reduced-motion. Scroll to is instant there too.Not built: category filter chips (the registry has toggleCategory, but useDebugAttrs
sets no category yet) and i18n keys per node (the registry records none).
All exports go to the clipboard. Nothing is stored in localStorage or IndexedDB: SSE
payloads and log lines can hold data.
Copy for Claude needs a selected row (panel) or a right-clicked component (dialog). The dossier is built at click time, so the box model and computed layout match the screen at that moment.
DdxDebugProvider.tsx reads process.env.NEXT_PUBLIC_UI_DEBUG === '1' at module scope. In a
build without the flag the bundler folds it to false; both next/dynamic imports are dead
code, so the toggle and panel chunks are never emitted. The provider renders null.useDebugAttrs and debugIdAttrs return a frozen empty object: no data-ddx-id, no
data-debug-id, no registry entry.registry.ts, sse-inspector.ts) check isDebugEnabled() and drop every
register / push when the flag is off.debug-level log calls are dropped unless the flag is on.Proof (lead, at /unify): a production build with the flag unset, then
rg -l "debug\.panel|debug\.toggle" ddx-web/.next/static/chunks prints nothing (those
namespace strings exist only in the lazy bodies). The data-debug-id name itself may
appear: it is a constant in ids.ts, and the gate on it is the empty return value, not its
absence from the bundle.
adr/
deploy/
research/
import { useDebugAttrs } from '@/lib/debug/use-debug-attrs';
export function DataTable(props: DataTableProps): React.JSX.Element { const debugAttrs = useDebugAttrs('DataTable', props.instantiation.id); return <div {...debugAttrs}>…</div>;}import { debugIdAttrs } from '@/lib/debug/ids';
export function PortalPage(props: PortalPageProps): React.JSX.Element { return <section {...debugIdAttrs('PortalPage')}>…</section>;}