# Gotchas — bugs that took multiple iterations to find

Read this when something renders wrong despite following the rules. These are real bugs that appeared during the 7-iteration build and are easy to repeat.

## Layout traps

### Sticky tab bar covers top callouts

**Symptom:** Top-side callout labels are missing from the rendered output, or appear *under* the tab strip.

**Cause:** Tab bar has `sticky top-3 z-20`. Top callouts are positioned at `bottom: stage_height + margin + label_height` from the bottom of the parent — which puts them in the y-range of the sticky tab bar. Tab bar's z-index wins because `z-20 > z-5` on Callouts.

**Fix:** Drop `sticky` from the tab bar. Make it `flex flex-wrap items-center gap-x-4 gap-y-2` in normal flow. Then ensure the stage wrapper has `mt-32` (128 px) to give top callouts breathing room below the tab bar's natural height (~106 px when wrapped to 2 rows).

### Side-callout labels clipped at viewport edge

**Symptom:** Left/right callout labels have their first/last few characters cut off.

**Cause:** The label box is 230 px wide and sits 60 px outside the stage edge — total footprint 290 px. If the gutter is < 290 px, the label overflows the page container. With a body that has `overflow: hidden` (or even default behavior on narrow viewports), the overflow is clipped.

**Fix:** Use 320 px gutters minimum. Update `marginLeft: 320, marginRight: 320, width: "calc(100% - 640px)"` on the stage container. On a 1920 viewport this gives a 1280-px-wide stage at scale 0.667, with 320 px of clear gutter space on each side.

### Stage scaled too small on wide monitors

**Symptom:** On a 4K screen, the wireframe sits in the middle at ~60% of available width.

**Cause:** Outer container has `max-w-[1680px] mx-auto` and stage has `maxWidth: 1280`. Both cap the size.

**Fix:** Outer container `style={{maxWidth: 2400}}`, stage `maxWidth: 1920`. Now on a wide monitor the stage hits 1:1 scale instead of being capped at 67%.

## Callout traps

### Two callouts pointing at the same `id`

**Symptom:** Two leader lines terminate at the same dot.

**Cause:** The `_CALLOUTS` array has two entries with the same `id`. The Callouts overlay queries by id and renders both leaders pointing to the same anchor.

**Fix:** Each callout needs a unique `id`. If you want to label the same element from two angles (rare and usually wrong), give each angle its own anchor with a distinct id.

### Zero-length leader (label flush against stage edge)

**Symptom:** A side label has effectively no visible leader; the dot is right at the wireframe boundary.

**Cause:** The element is at the wireframe edge AND `anchor` is on the same edge. E.g. WhySheet is flush-right; setting `anchor: "right"` puts the dot on the right edge of the sheet, which IS the wireframe right edge — leader has zero horizontal travel.

**Fix:** Flip `anchor` to the opposite edge so the leader has visible travel. `WhySheet` callouts use `anchor: "left"` so the dot lands inside the sheet near its left boundary; the leader travels right across the sheet to the gutter.

### Leader cuts across the wrong row

**Symptom:** A multi-column row (e.g. announcements + schedule + recent) gets a callout described as "this row of supporting components" but the leader points at one specific column instead.

**Cause:** Anchor span is inside ONE cell of the grid (e.g. the announcements column). Its bounding rect is the cell's right edge, which is in the middle of the row.

**Fix:** Move the span out of the inner cell. Add `relative` to the OUTER row wrapper and host the span there at `right-0 top-1/2`. Now the dot lands at the row's outer right edge, which (when the centre extends to the wireframe edge) is at the wireframe's right border. Reads as "this whole row".

### Leader goes to the FAR gutter

**Symptom:** A banner at the top of the wireframe has its label in the bottom gutter; a button in the right rail has its label in the bottom gutter.

**Cause:** Wrong `side` chosen. The rule is "closest gutter wins" — but it's tempting to default to `bottom` for action buttons or `top` for global elements without thinking about which gutter is actually closest.

**Fix:** Pick `side` by physical proximity to the anchor.
- Top-of-stage banner → `side: "top"`
- Right-rail button → `side: "right"`
- Bottom-of-stage footer → `side: "bottom"`
- Sidebar item → `side: "left"`

The leader should never cross the stage to reach a far gutter.

## Demo state traps

### Per-step callouts disappear immediately on demo mount

**Symptom:** The demo's step 0 has no callouts visible. Console is clean. The anchor IDs are present in the DOM. The CALLOUTS array has entries.

**Cause:** App component has a useEffect that resets dynamic callouts on `[active]`. Parent useEffects run AFTER child useEffects, so:
1. Demo mounts, useEffect fires, pushes step-0 callouts
2. App's reset useEffect fires, sets dynamicCallouts back to null
3. Re-render with no callouts

**Fix:** Don't reset in App. Move the reset to the demo's useEffect cleanup function:

```jsx
React.useEffect(() => {
  if (setCallouts) setCallouts(D1_STEP_CALLOUTS[step] || []);
  return () => { if (setCallouts) setCallouts(null); };  // cleanup on unmount
}, [step, setCallouts]);
```

When the demo unmounts (view change), cleanup runs and nulls the callouts. New view's mount push (or static view's nothing) takes over cleanly.

### Inbox highlighted case is wrong channel after Accept

**Symptom:** D1 inbound demo accepts a phone call, but the inbox's highlighted row is an EMAIL case for the same customer.

**Cause:** The demo passed `activeCaseId="CS-4860"` — but CS-4860 is the existing email row in the shared `LeftCaseList`. The demo treated it as the new call case but the inbox doesn't have a phone row for that customer.

**Fix:** Use the `injectActive` prop on `LeftCaseList`:

```jsx
<LeftCaseList
  activeCaseId="CS-4861"
  injectActive={{id: "CS-4861", name: "Chan · live call", channel: "phone"}}
/>
```

This prepends a fresh phone row at the top of the Open section, bumps the count by 1, and highlights it. The existing email row stays below — modeling that the customer might have multiple cases.

### Two demos share the wrap-up modal but their content diverges

**Symptom:** The demo's wrap-up has fewer fields than S7's. Tags / Case status / merge nudge missing.

**Cause:** A `DemoWrapUpModal` was created as a stripped-down clone of S7's `WrapUpModal`. They drift over time — one gets a new field, the other doesn't.

**Fix:** Don't fork. Refactor S7's `WrapUpModal` to accept `onSave`, `onHome`, `headline`, `subline`, `saveAnchorId` props (all optional, defaults match S7's existing behavior). Demos use the same component with their props. Single source of truth.

## Component / styling traps

### `bg-amber-50/50` matches the same selector as callout label boxes

**Symptom:** Counting callout labels via `[class*="bg-amber-50"][class*="border-amber-200"][class*="rounded"]` returns more labels than expected.

**Cause:** The `AnnouncementsBlock` "System" announcement row has `bg-amber-50/50` + `border-amber-200` + `rounded-md`. Selector matches.

**Fix:** Use a more specific selector that includes `text-center` (which only callout labels have):

```js
document.querySelectorAll('.pointer-events-none [class*="text-center"][class*="bg-amber-50"]')
```

### Browser caches the artifact across iterations

**Symptom:** Edits to the file aren't reflected in the browser even after reload.

**Fix:** Add a cache buster to the URL: `http://localhost:8767/index.html?v=2#s1`. Bump `v` each iteration.

### `file://` protocol blocked in Playwright

**Symptom:** Trying to navigate to `file:///path/to/index.html` in Playwright produces "Access to file: protocol is blocked".

**Fix:** Run a local server: `npx http-server -p 8767 -c-1 .` from the wireframe directory. Then navigate to `http://localhost:8767/index.html`.

## Verification recipe (Playwright)

When you've made callout changes, this query batch-checks every state for clipping and tab-overlap issues:

```js
async () => {
  const views = ['s1','s2','s3','s4','s5','s6','s7','sup1','sup2','sup3','sup4','sup5'];
  const out = {};
  for (const v of views) {
    window.location.hash = '#' + v;
    await new Promise(r => setTimeout(r, 400));
    const labels = Array.from(document.querySelectorAll('.pointer-events-none [class*="text-center"][class*="bg-amber-50"]'));
    const tabbar = document.querySelector('[class*="flex flex-wrap"][class*="border-border"][class*="rounded-md"]');
    const tabBottom = tabbar ? tabbar.getBoundingClientRect().bottom : 0;
    const issues = [];
    labels.forEach(l => {
      const r = l.getBoundingClientRect();
      if (r.left < 0) issues.push('L'+Math.round(-r.left));
      if (r.right > window.innerWidth) issues.push('R'+Math.round(r.right - window.innerWidth));
      if (r.top < tabBottom + 4) issues.push('T');
    });
    out[v] = {n: labels.length, issues};
  }
  return out;
}
```

Goal: every state has `issues: []`. If any view shows L/R/T entries, look up the matching gotcha above.

## Iteration history (one-line each)

These are the iterations the wireframe artifact went through. Each one closed a real bug.

- **i1** — Initial 14-state build.
- **i2** — Removed duplicate-anchor entries, larger flow thumbs, 3 interactive demo tabs.
- **i3** — Wider stage, tab-bar wraps, callouts 30→48 (rule 4 flipped from "less" to "comprehensive").
- **i4** — Playwright-verified everything; tab-bar non-sticky; gutter 220→320; rule 7 v1 added.
- **i5a–b** — Sidebar/Inbox leaders moved to left edge; KPI footer to bottom; announcements to outer row wrapper.
- **i5c** — Per-step demo callouts wired up; race condition fixed via cleanup-on-unmount.
- **i5d** — Rule 7 generalized: closest path wins for `side` AND `anchor`.
- **i5e** — Wrap-up modal de-duplicated; one canonical component for S7 + demos.
- **i5f** — D3 email composer became a real editor (toolbar + To/Subject + body) with templates rail.
- **i5g** — Inbound demo highlights fresh phone case via `injectActive`, not stale email row.

Don't try to skip directly to "the final state" — the rules above only make sense if you understand what they're protecting against. When stuck, find the matching iteration and read its log entry.
