# `SetupWidget` ← `dashboard-setup` integration — gap analysis

What's missing for the production **Focus Widget** steps
(`wix-private/dashboard-setup` → `packages/dashboard-setup-app/src/components/dashboard-widget/steps`)
to render through cairo's `@wix/patterns` **`SetupWidget`** instead of its own
components.

> Scope: the **steps area** only (step list + per-step rendering + status flow).
> Surrounding shells (full Setup page, sidebar progress, minimize/hero,
> personalized banner) are out of scope.
>
> See also [`dashboard-setup-migration-plan.md`](./dashboard-setup-migration-plan.md)
> — how the integration is actually wired and sequenced. This doc tracks *what's
> missing*; the plan tracks *how to adopt it*.

---

## 1. How production works today (reference)

**Status is three orthogonal server-owned booleans**, not a single enum
(`types.ts` `Step` extends `@wix/dashboard-setup-common`'s `_Step`):

| field | meaning |
|---|---|
| `isCompleted` | step done (green check, struck title, no CTA) |
| `inProgress` | partially done — shows the "In progress" badge |
| `isPermitted` | user may act — `false` ⇒ muted bullet + disabled CTA + permission tooltip |

"Focused / next" is **derived**, not stored: `getNextStep` = first
`!isCompleted && isPermitted` (with a `connect-domain` priority experiment).
There is no `active`/`upcoming` status in production.

**Status changes through exactly two paths** (`dashboard-widget.context.tsx`):

1. **Optimistic inline completion.** An inline ("extension") step mounts a
   consumer widget via the platform (`getWidget`) and passes it
   `onActionCompleted` (`custom-step/extension-step/extension-step.container.tsx`).
   The widget calls it → fires `STEP_COMPLETED {stepId}` → `setStepCompleted`
   sets `isCompleted: true` **and resets `focusedStepId`**.
2. **Server refetch.** A duplexer real-time subscription
   (`subscribeToEventsServerless`) calls `onDashboardWidgetDataChange` →
   `fetchFocusWidgetData()` → replaces **all** steps + resets focus. Navigate
   steps (completed elsewhere) reflect their new status only this way.
   (`clearWidgetDataCache()` on a navigate CTA click just busts the cache so the
   next mount refetches.)

**Clicking never changes status** (`step/step.container.tsx`): locked → no-op;
completed → `completedCta` action; inline → expand; navigate →
`handleAction` (open URL / BM navigation / modal) and walk away.

---

## 2. Current `SetupWidget` API surface

```ts
// data (consumer resolves via fetchData)
SetupWidgetData          = { title?: string; steps: SetupWidgetStep[] }
SetupWidgetStep    = { id; title; description?; ctaLabel?;
                       status: 'upcoming'|'in-progress'|'completed'|'disabled';
                       expansion?: 'inline'|'navigate' }

// hook → state (like useCollection → CollectionState)
useSetupWidget({ fetchData }) → SetupWidgetState
SetupWidgetState: steps, title, getStep(id), isLoading, isError,
                  completedCount, totalCount, progressPercentage, isAllComplete,
                  primaryStepId, expandedStepId,
                  setStepStatus(id, status), toggleStep(id), retry()

// component
<SetupWidget state renderStepContent?(step) onStepClick?(step) onAllCompleteClick? dataHook? />
```

---

## 3. Gap table

**Status tracking.** This file is the single source of truth. Each gap carries a
`Status`; update it in the same PR that closes the gap and link the PR/commit in
the **Resolution** column. Legend:

- 🔴 **Open** — not started
- 🟡 **In progress** — being implemented (PR open)
- 🟢 **Done** — merged; behavior + test in place
- ⚪ **Won't do** — decided out of scope (note why)

**Definition of Done.** A gap may only be marked 🟢 once it ships with coverage
matched to the *essence* of the change (see §5 for the per-gap mix):

- **Unit test** — `packages/core` state spec for logic, and/or a `packages/cairo`
  component spec (driver) for behavior. _Always required_ when there's logic or
  behavior.
- **Story** — a named export in
  `packages/cairo/visual-e2e/SetupWidget/SetupWidget.story.tsx` exercising it.
  _Required when it changes rendered output or interaction._
- **SLED3 visual test** — a spec in `packages/cairo-visual-e2e` that navigates to
  that story and snapshots it. _Required when it changes the rendered look._

Pure docs/decisions (G8) need none. Link the test/story/SLED files in **Resolution**.

> **Progress: 12 / 15 closed** _(last updated: 2026-06-03)_ — keep this counter in sync.
> ("closed" = 🟢 Done or ⚪ Won't do — i.e. not 🔴 Open. Currently: 11 Done + 1 Won't do.)

| # | Status | Production capability | `SetupWidget` today | Gap | Proposed | Resolution |
|---|---|---|---|---|---|---|
| **G1** | 🟢 Done | **Server refetch** drives most status changes (duplexer push, cache-bust on navigate) | only initial `fetchData` + `retry()` (error-only) | **No public on-demand refresh.** Navigate-step completions / external changes can't be reflected by refetch — consumer can only `setStepStatus` by hand | add `state.refresh()` (re-run `fetchData`, reseed steps, reset focus), distinct from error `retry()` | `SetupWidgetState.refresh()` (background swap, no skeleton; resets focus; failure keeps current data). Unit: core `SetupWidgetState.spec` (reseed+focus-reset, failure-keeps-data) + cairo `SetupWidget.spec` (swap, no flash). Story: "Refresh from server" in `SetupWidget.story.tsx`. |
| **G2** | 🟢 Done | `completedCta` — completed steps stay actionable (`completed-cta.container.tsx`) | completed steps render **no** CTA, not clickable | **No completed-step action** | add `completedCtaLabel?` to `SetupWidgetStep`; render a `TextButton` on completed rows → `onStepClick(step)` | `completedCtaLabel` on `SetupWidgetStep`; desktop + mobile render a quiet `TextButton` on completed rows (gated on label + `!locked`), `activateStep` routes completed → `onStepClick`. Unit: cairo `SetupWidget.spec` (renders completed CTA, click fires `onStepClick` with the completed step; no-label completed shows none). Story: `site-type` "View". SLED3: `cairo-visual-e2e/sled3/SetupWidget.visual.spec.ts` (in-progress + all-complete); baseline generated on next SLED3 run (`yarn test:sled3:snapshot`). |
| **G3** | 🟢 Done | **Aggregated groups** — `stepGroup` rolls children into one row with a `x/y` counter Tag, chevron, expandable sub-steps (`steps.component.tsx getAggregatedSteps`, `aggregated-step.component.tsx`) | not modeled | **No aggregated/sub-steps** (Stores, Bookings) | add `steps?: SetupWidgetStep[]` (+ derived rollup) and an aggregated row renderer; or keep flat and document as unsupported | `steps?` on `SetupWidgetStep` + `SetupStepGroup` (icon/title/in-progress derived from children, `x/y` counter Tag, chevron, single-open expand → indented child rows). Progress counts **leaves** (`state._leafSteps`). Children are leaf actions (no nested inline). `setStepStatus`/`getStep` recurse into `steps` (nested), so completing a child updates the group's counter + leaf progress (and flips the group's check when all children are done). Unit: core (`leafSteps`/progress over leaves) + cairo (group renders, expands, child fires `onStepClick`). Story: "Set up your store" group. SLED3: `aggregated group expanded`. |
| **G4** | ⚪ Won't do | **Orthogonal status** — a step can be `inProgress` **and** `!isPermitted`, or completed-after-in-progress | single mutually-exclusive enum | **Cannot represent combinations**; `active` is our invention (prod derives focus) | model status as `{ completed; inProgress; permitted }` (or document the lossy mapping in §4) | **Decided: keep the enum.** The enum matches what's actually *rendered* (completed wins; in-progress vs upcoming; locked), and the lossy combos (in-progress + locked; completed-after-in-progress) don't change the visual. `active` was later **dropped from the enum** (doc-review comment C3: focus is presentation state) — the widget always derives the focused step (`_primaryStepId`). The adapter maps production's booleans → enum per §4 (precedence: completed → disabled → in-progress → upcoming). Revisit only if a real consumer needs a true combo. |
| **G5** | 🟢 Done | **Permission tooltip** on locked steps ("no permission" `permission-tooltip.tsx`) | locked = disabled CTA, **no explanation** | missing affordance | add optional `disabledTooltip`/render the disabled-reason on disabled rows | disabled step's CTA wrapped in a `Tooltip` (desktop + mobile); content = per-step `disabledTooltip` or default `cairo.setupWidget.step.lockedTooltip` (TC key id unchanged pending rename). Disabled rows stay non-clickable / CTA disabled (no `onStepClick`). Unit: cairo (tooltip wraps CTA, click no-ops). Story: `design` locked w/ custom tooltip. SLED3: `locked step permission tooltip` (hover). |
| **G6** | 🟢 Done | **Telemetry** — `sendStepView` (VisibilityDetector), `sendStepClick`, `sendFocusWidgetOpenStep`, `sendMicroactionStepOpen`, dealer view/click offer events | none | **No analytics hooks** | expose `onStepView?(step)` / keep `onStepClick`; let the consumer fire BI/dealer (widget stays telemetry-agnostic) | `onStepView?(step)` fires once when a step/group scrolls into view (`useIntersectionObserver`; only mounts the observer when provided). Click telemetry hooks into existing `onStepClick`/`onCompletedStepClick`. Widget stays telemetry-agnostic — consumer fires BI/dealer. Unit: cairo (IntersectionObserverMock → `triggerIntersection` → `onStepView` called with the step). Note: fires once per mount (not re-fired on re-scroll like prod's `triggerOnce:false`); children-of-group views not separately tracked. |
| **G7** | 🔴 Open | **Structured CTA** — `cta`/`completedCta` are `{ type: externalUrl\|bizMgrNavigation\|modal, value }`, resolved by `handleAction` | `ctaLabel: string` + consumer `onStepClick` | navigation is consumer-owned (OK) but there's **no completedCta** (see G2) and no action metadata | keep label + `onStepClick` (consumer owns `handleAction`); only add `completedCtaLabel` | — |
| **G8** | 🔴 Open | `title` is a **translation key** (`t(title)`); `inProgress`/aggregated counters use ICU params | `title`/`ctaLabel` are literal strings | **Widget doesn't translate** | document: consumer pre-translates before `fetchData` resolves (matches "consumer owns the data") | — |
| **G9** | 🟢 Done | Inline step identified by `microActionData.id` → `MICROACTION_ID_TO_COMPONENT_ID` → platform `getWidget` | `expansionMode:'inline'` + `renderStepContent(step)` render prop | parity (consumer maps id→widget) | none — render prop already externalizes this | render prop shipped in initial `SetupWidget` |
| **G10** | 🟢 Done | **Loading (initial + refresh).** Initial load runs in the BM host `ResolveFn` **before mount** (`dashboard-widget.module.ts → fetchInitialData`); the host shows loading, the widget renders **no skeleton**. Refresh (`onDashboardWidgetDataChange`) is a **silent background swap** — no loader. | **Baseline covered**: `TaskState`→`CardContainer` shows a `SkeletonCard` on initial load (right for the adapter — no host `resolve`); `refresh()` swaps silently (matches prod). | **No customization / no in-flight signal** — can't supply a custom loader or opt out when a host already shows one, and there's no `isRefreshing` flag for an optional background-refresh indicator | optional `renderLoading` / `loadingState: 'skeleton' \| 'none'` + an `isRefreshing` computed; keep the owned skeleton as the default | Built-in `SetupWidgetSkeleton` shaped like the widget (header + step-row placeholders; card-wrapped desktop / bare mobile) replaces the generic `SkeletonCard` on initial load — no layout jump. `state.isRefreshing` is true while `refresh()` is in flight (refresh stays a silent background swap — consumer can show an optional indicator). Unit: core (`isRefreshing` toggles around refresh) + cairo (loading shows the skeleton). Story: `Loading`. SLED3: `loading skeleton`. |
| **G12** | 🟢 Done | **Distinct completed-step action.** The completed action is a separate descriptor + handler — `completedCta` (≠ active `cta`) via `onCompletedCtaClick → handleAction(step, step.completedCta)` (`step.container.tsx`) | G2 shipped the completed CTA, but it **reuses `onStepClick(step)`** — the completed action is disambiguated only by `step.status === 'completed'` | **No dedicated completed-action callback** — the completed action is implicit (status-keyed), not first-class; consumer must branch on status (supersedes G7's "only add completedCtaLabel" stance) | add `onCompletedStepClick?(step)` fired only by the completed CTA (mirrors `onCompletedCtaClick`); keep `onStepClick` for active/navigate | `onCompletedStepClick?(step)` prop; `activateStep` routes completed → `onCompletedStepClick` (CTA + row), active/navigate still → `onStepClick`. Unit: cairo `SetupWidget.spec` (completed CTA fires `onCompletedStepClick`, `onStepClick` not called). Story wires both handlers. |
| **G14** | 🟢 Done | **Completed CTA reveals on hover (desktop).** `completedCta` is `opacity: 0` and fades in on row hover (`step.component.scss`: `.completedCta`/`.step:hover .completedCta`) | completed CTA was shown **always** | minor visual divergence — completed CTA always visible vs hover-revealed | gate the completed CTA on row hover/focus via Stylable (desktop only; mobile has no hover) | `.completedCta` class (`opacity:0` + `.row:hover/:focus-within .completedCta{opacity:1}`) applied to the desktop completed CTA; mobile keeps it visible. Still occupies space (stable row height) + stays clickable/focusable. SLED3: `completed step CTA reveals on hover`. (Behavior unchanged → existing G2/G12 unit tests still cover render+click.) |
| **G15** | 🟢 Done | **CTA end spacing.** (a) **All** CTAs clear the edge via the row's **end padding** `11px` (`--margin`+4 in `step.component.scss`). (b) **Only the completed CTA** adds `marginRight: 13px` (`completed-cta.container.tsx`) → ~24px from the edge. The **active/regular** CTA has **no** margin (`.prominent` = `max-width:none` only) | row uses uniform `--wds-space-100` (6px); no CTA margins | active CTA: 6px vs 11px. completed CTA: 6px vs ~24px | give an end margin on the **completed** CTA only (mirroring prod); active CTA gets none (same logic) | **Completed CTA** wrapped with `marginRight="SP2"` (12px ≈ prod 13px) on the desktop row, mirroring `completed-cta.container.tsx`. **Active CTA** gets no margin — same as prod; its end spacing remains the row's `--wds-space-100` padding (6px, ⚪ accepted tokenization vs prod's 11px end; asymmetric row padding was reverted). Covered by the G14 hover SLED3 snapshot. |
| **G13** | 🔴 Open | **Copy/label overrides.** Chrome copy is i18n-key-driven; the only per-instance label override is the widget title (`widgetTitle`) | Per-instance overrides exist only for `title` (`SetupWidgetData`) and step `ctaLabel`/`completedCtaLabel`. Fixed chrome copy — error (`cairo.setupWidget.error.*`), all-complete (`…allComplete.*`), "In progress", progress ratio — is **translation-key-only**; `renderError`/`renderStepContent` are full-UI escape hatches | **No per-instance text override** for fixed chrome copy without editing the Translation Center (e.g. can't change the error title or all-complete copy per consumer). Matches production (also key-driven), so by-default-deliberate | optional `labels?: Partial<{ error.title; error.subtitle; error.cta; allComplete.* ; … }>` map (or per-state text props) overriding the default keys; keep TC keys as defaults. (P2 — `renderError` already covers the error case) | — |
| **G11** | 🟢 Done | **Error state.** Initial error → `<SetupError>` (`Card` + `Card.Header(title)` + `EmptyState` "Couldn't load your setup", **no retry**, panorama report — `dashboard-widget.root.tsx`, `setup-error.component.tsx`). Refresh error → `catch { return }` (keeps current data). | **Baseline covered**: initial error → `CardContainer` `ErrorCard` (**generic** `cairo.entityPage.widgetError.*` copy **+ a retry button**), state reports via `internalMonitor`; refresh error swallowed + reported, keeps data (matches prod). | **Copy/visual diverge & not customizable** — generic error text vs "Couldn't load your setup", ours adds retry + drops the header title; no `renderError`/copy override | add `renderError` / Setup-specific error keys; decide whether to mirror `SetupError` exactly or keep cairo's standard `ErrorCard` (retry is arguably better than prod) | `renderError?({ retry, error })` prop fully replaces the error UI (e.g. consumer's `SetupError`); default error now uses Setup-specific copy `cairo.setupWidget.error.*` ("Couldn't load your setup") + a "Try again" retry. Refresh error already silent/keeps data (G1). Unit: cairo `SetupWidget.spec` (renderError replaces default + retry re-runs load; default error+retry). Story: `ErrorState`. SLED3: `error state` test. |

**Already covered (no gap):**

- Inline completion → `state.setStepStatus(id,'completed')` mirrors
  `onActionCompleted`/`setStepCompleted`, including the **focus reset** when the
  expanded step completes.
- Progress ratio / `isAllComplete` ↔ `allStepsCompleted`; the all-complete hero.
- Single-open inline expansion (`toggleStep`/`expandedStepId`) ↔
  `focusedStepId` + `isExtensionStep`.
- Loading / error+retry (`TaskState` ↔ initial fetch).

---

## 4. Data-model mapping (production `Step` → `SetupWidgetStep`)

| production | `SetupWidgetStep` | notes |
|---|---|---|
| `id` | `id` | ✓ |
| `t(title)` | `title` | consumer must translate (G8) |
| `cta.value.key` (→ `t`) | `ctaLabel` | label only; consumer wires the action via `onStepClick` (G7) |
| `completedCta` | — | **missing** (G2) |
| `isCompleted` | `status:'completed'` | ✓ |
| `inProgress` | `status:'in-progress'` | lossy — can't combine with disabled (G4) |
| `isPermitted:false` | `status:'disabled'` | drops the tooltip reason (G5) |
| `isExtensionStep` (`microActionData.id`) | `expansionMode:'inline'` | + `renderStepContent` (G9) |
| navigate (no microaction) | `expansion:'navigate'` | ✓ |
| derived `getNextStep` | derived `_primaryStepId` | parity — both derive client-side (`'active'` dropped per doc-review C3) |
| `order` | array order | consumer pre-sorts |
| `stepGroup` / `aggregated` | — | **missing** (G3) |
| `dealer` (Offer) | — | telemetry only (G6) |

---

## 5. Definition of Done — required coverage per gap

Matched to the essence of each change (✅ required · ➖ n/a). "Story" =
the visual-e2e export; "SLED3" = a `cairo-visual-e2e` spec snapshotting that story.

| # | Essence | Unit | Story | SLED3 | What to assert |
|---|---|---|---|---|---|
| **G1** refresh | state/logic | ✅ | ✅ | ➖ | core: `refresh()` re-runs `fetchData`, reseeds steps, resets focus; cairo: stale data → `refresh()` shows new steps. Story: a "Refresh" affordance |
| **G2** completedCta | render + behavior | ✅ | ✅ | ✅ | cairo: completed step renders the completed CTA → `onStepClick` fires; visually distinct from active rows |
| **G3** aggregated steps | logic + visual | ✅ | ✅ | ✅ | core: rollup (`x/y`, `inProgress`/`permitted`/`completed` derive from children); cairo: expand → sub-steps. Story: collapsed + expanded group |
| **G4** status model | — | ⚪ | ⚪ | ⚪ | ⚪ Won't do — no code; documented decision (keep enum + adapter mapping in §4) |
| **G5** permission tooltip | behavior + visual | ✅ | ✅ | ✅ | cairo: locked step disabled + tooltip content present. Story/SLED: locked-row look |
| **G6** telemetry hooks ✅ | behavior | ✅ done | ➖ | ➖ | cairo: `onStepView` fires with the step (IntersectionObserver mock); `onStepClick`/`onCompletedStepClick` cover click telemetry; no rendered change |
| **G7** (→ folds into G2) | — | — | — | — | covered by G2 |
| **G8** translation | docs/decision | ➖ | ➖ | ➖ | documentation only |
| **G9** inline render prop ✅ | behavior + visual | ✅ done | ✅ done | ➖ | already shipped: inline expand renders `renderStepContent`, no-collapse-on-interact; `InProgress` story |
| **G10** loading | behavior + visual | ✅ | ✅ | ✅ | core: `isRefreshing` true during refresh, false after; cairo: initial load shows the built-in `SetupWidgetSkeleton`, refresh shows no skeleton. Story/SLED: `Loading` / `loading skeleton` |
| **G11** error | behavior + visual | ✅ | ✅ | ✅ | cairo: initial error renders error state + retry → refetch; `renderError`/custom copy honored; refresh failure keeps data (no error UI). Story/SLED: error variant |
| **G12** completed action | behavior | ✅ | ➖ | ➖ | cairo: completed CTA fires `onCompletedStepClick` (not `onStepClick`); active/navigate still fire `onStepClick`. No rendered change |
| **G13** copy overrides | behavior | ✅ | ➖ | ➖ | cairo: a provided `labels` override replaces the default key copy (e.g. error title); omitted → translated defaults. Text-only, no new visual |
| **G15** CTA end margin | visual | ➖ | ✅ | ✅ | CSS-only; existing row/CTA snapshots cover it. SLED3: CTA distance from the container edge matches prod |
| **G14** completed CTA hover ✅ | visual | ➖ | ✅ done | ✅ | CSS-only reveal (opacity); behavior unchanged so existing render/click unit tests apply. SLED3: hover reveals the completed CTA |

## 6. Prioritized recommendations

- **P0 — required for the real flow**
  - **G1 `refresh()`** — without it, navigate-step completions and any
    server/real-time status change can't propagate. (Option C's own "cons"
    flagged *"must anticipate events, push refresh"*.)
  - **G2 `completedCtaLabel`** — completed steps are actionable in production.
- **P1 — common production features**
  - **G3 aggregated/sub-steps** (Stores, Bookings).
  - **G4 status model** — either go orthogonal or explicitly accept the lossy
    enum mapping above.
  - **G5 permission tooltip** on locked steps.
- **P2 — telemetry & polish**
  - **G6** view/click analytics callbacks (keep BI/dealer in the consumer).
  - **G7/G8** documented as consumer responsibilities.

---

## 7. Decisions to confirm (out of current scope)

- **Real-time wiring** stays the consumer's job: the widget exposes `refresh()`
  (G1); the consumer owns the duplexer subscription. (Matches Phase-1 dev notes:
  ship just the widget.)
- **Minimize / milestone hero / sidebar / personalized banner** — not part of
  the steps area; not planned here.
- **Translation** of step copy happens before `fetchData` resolves (G8).
- **Status stays a single enum** (G4 — decided ⚪): the adapter maps production's
  `isCompleted`/`inProgress`/`isPermitted` → `status` with precedence
  completed → disabled → in-progress → upcoming. The lossy
  combos don't affect the rendered output. Revisit only if a consumer needs a
  true combination.

---

## 8. Sizing & spacing parity (vs dashboard-setup)

Every size/spacing value vs production (`step.component.scss`,
`steps.component.scss`, `extension-step.container.tsx`).
✅ match · ⚪ accepted divergence (deliberate — px→token rounding) · 🔴 open.
`Box` props use `SP` tokens; `.st.css` uses `--wds-space-*` / `--wds-border-radius-*` / `--wsr-color-*`.

| Element | Production | SetupWidget | Status |
|---|---|---|---|
| Row padding (top/bottom/left) | `7px` (`--margin`) | `--wds-space-100` (6px) | ⚪ tokenized (~1px) |
| Row padding (**end/right**) | `11px` (`--margin` + 4px) — the **active** CTA's only end spacing | `--wds-space-100` (6px) | ⚪ accepted (tokenized; active CTA ~6px vs 11px — no CTA margin, same as prod) |
| Row-to-row gap | `8px` (`.group` margin-top) | `SP1` (6px) | ⚪ tokenized (was 8px) |
| Row border-radius | `4px` | `--wds-border-radius-200` (4px) | ✅ |
| Hover / focus / next bg + border | `B60` / `B30` / `B40` | same `--wsr-color-*` | ✅ |
| Status icon size / top nudge | `18px` / `3px` | `18px` / `3px` | ✅ |
| Title side padding / title-badge gap | `12px` / `6px` | `SP2` / `SP1` (12/6) | ✅ |
| "In progress" badge top nudge | `2px` | `2px` | ✅ |
| Completed CTA end margin | `marginRight: 13px` (**completed only**, on top of the 11px row padding → ~24px) | `marginRight="SP2"` (12px) | ✅ G15 (≈prod 13px) |
| Inline (extension) content | `gap:12px`, `paddingTop:3px`, `paddingBottom:8px`, icon-column layout + `Divider` | `paddingLeft:SP5(30)` indent, `Divider` with `marginTop/Bottom:SP2(12)`, `paddingRight/Bottom:SP1(6)` | 🔴 layout differs — we indent 30px under the bullet vs prod's icon column; content itself is consumer-supplied (`renderStepContent`) |
| Mobile card | n/a — different repo (owners-app) | shadowed card, `SP4` padding, `--wds-border-radius-400` (8px) | — our design (no prod desktop equivalent) |

> ⚪ rows are the deliberate px→token rounding (the "closest 6px-multiple / `--wds-space`" calls). 🔴 rows are genuine open parity items: **end spacing** (G15 + the 11px container end padding) and the **inline-content indent/layout**. Sub-token optical nudges (`2px`/`3px`), negative margins, and `minHeight`/icon **sizing** intentionally stay literal px (no token equivalent).
