# Mode 2, App-shell QA: pitfalls + fix recipes

Script: `node scripts/dev/audit-app-shells.mjs` (repo-local). Walks every
`apps/<name>/…/<page>.html` shell headlessly and checks console errors,
custom-element registration, collapsed heights, icon-ui presence, demo-root
flex, and network 4xx/5xx. Flags: `--only=NAME` · `--fail-fast` ·
`--compare-prod` (diff registered tags against the prod deploy) ·
`--playgrounds` (gh#3197, also sweeps `playgrounds/<name>/app/<name>.html`,
same shell shape; opt-in because that root carries other pre-existing,
unaudited findings, combine with `--only=NAME` to scope to one playground).

Prerequisites: `npm run dev` running (vite `:5173`); `npm run proxy` only when
probing chat / gen-ui pages. If vite is mid dep-reoptimization the first sweep
may stall, wait 30s, re-run.

## Pitfall → finding map (fix recipes apply unattended)

| # | Pitfall | Finding tag | Fix |
|---|---|---|---|
| 1 | Co-located custom element not imported (e.g. `tab-ui` registers in `tabs/tab.js`, not a `tab/` dir) | `[unregistered-tag]` | import the co-located sibling: `import "/packages/web-components/components/tabs/tab.js"` |
| 2 | Demo-root flex chain broken | `[demo-root-flex]` | `#demo-root { flex: 1; display: flex; flex-direction: column; min-height: 0; }` |
| 3 | Composite renders internal `*-ui` tags the HTML doesn't show | `[unregistered-tag]` on a tag absent from markup | import every primitive the composite renders (table below) |
| 4 | Top-level `await` without async setup wrap | `[setup-failed]` console error | `export default async function setup(host) { … }` |
| 5 | Vite import-analysis 500 on dynamic import | `[network-4xx] 500` for `./<name>.contents.js` | add `/* @vite-ignore */` to the dynamic import |
| 6 | icon-ui not imported despite `<icon-ui>` / `[icon=…]` / icon-rendering composites | `[icon-ui-missing]` | `import "/packages/web-components/components/icon/icon.js"` |
| 7 | `<admin-page-body>` emitted without its `<admin-page>` ancestor (gh#981) | *(no audit-app-shells.mjs tag, apps/-only script, doesn't sweep this surface)* | wrap in `<admin-page>`, `admin-page > admin-page-body { flex:1; … }` (`admin-shell.bespoke.css:144`) is a direct-child selector; without that literal parent, `admin-page-body` falls back to UA `display:inline`, **[deprecated 2026-09-01, ADR-0098]** `admin-page`/`admin-page-body` are retired deprecate-then-delete; author new surfaces with `page-ui[band]` instead |

Secondary signals: `[collapsed-element]` (registered but 0px tall, `audit-app-shells.mjs`'s own threshold is <4px, so a shallower-but-still-broken
collapse won't trip it), `[network-4xx]` (typoed stylesheet href, missing
contents.html, stale `import.meta.url`). Row 7's specific case (gh#981, in
`site/site.js`'s router, not swept by this script at all): the 150px isn't
`admin-page-body`'s own height, it's a *replaced child* (an `<iframe>`)
whose `height:100%` can't resolve, so it falls back to the browser's
intrinsic default (300×150). The observable is a body rendering ~150px tall
with no console error, regardless of real content height.

## Composite → internal primitives (undiscoverable from the markup)

| Composite | Internally renders |
|---|---|
| `chat-input-ui` | `textarea-ui`, `select-ui` |
| `search-ui` | `input-ui` |
| `empty-state-ui` | `icon-ui`, `text-ui` |
| `button-ui` (with `icon=`) | `icon-ui` |
| `badge-ui` (with `icon=`) | `icon-ui` |
| `menu-item-ui` | `icon-ui`, `text-ui` |
| `admin-roster-ui` | `upload-ui`, `menu-item-ui` |
| `table-toolbar-ui` | `search-ui`, `select-ui`, `menu-ui`, `menu-item-ui` |

## Registration diagnosis rules

- Bespoke shell children (`admin-sidebar`, `chat-thread`, `editor-sidebar`, …)
  register only when their sibling JS loads, importing `admin-shell.js` alone
  registers ONLY the host. Use the cluster barrel
  `/packages/web-modules/<cluster>/index.js`.
- Some tags register in their **parent component's** `.js`, not a directory of
  their own name, when deciding whether a tag is real, grep
  `customElements.define`, never `ls components/`.
- `customElements.whenDefined(name)` never rejects; an un-imported name leaves
  the promise pending forever. A `Promise.all([...]).then(bootstrap)` gate then
  hangs silently: shell chrome renders (tag-keyed CSS) but the page is empty
  with **no console error**. Suspect this when a page is blank yet clean.

## False positives (already filtered by the script)

- CSS-only components (`aside-ui`, `header-ui`, `section-ui`, `footer-ui`), some components ship `.yaml` + `.a2ui.json` with no `.js` by design; they are
  real, not stubs.
- Inherently thin elements (`divider-ui`, `separator-ui`); empty containers
  with no children.

## Verification + escalation

1. Apply diffs; re-run the audit, target 0 findings on real issues.
2. Spot-check the worst-affected page in a real browser.
3. Escalate when: a finding matches no pitfall above, >10 shells are affected,
   or the fix would alter shared `catalog/` or `packages/` files.
