Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions AGENT_HANDOFF.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,54 @@
# AGENT HANDOFF — theme-forge

## Batch 11 — Nit-pick cleanup (Batch 10 inspector findings)

**Date:** 2026-04-16
**Status:** Complete. Closes 2 P2 + 4 P3 + 2 cavekit clarifications surfaced by Batch 10 inspect.

### Scope

Eight tasks T-140..T-147 across 2 tiers, all from the Batch 10 inspector report:

| Origin | Task | Resolution |
|---|---|---|
| F-001 (P2) | T-140, T-147 | Removed dead `e.stopPropagation()` and misleading comment from variant click handler. Test reworded to credit the structural sibling separation rather than propagation suppression. |
| F-002 (P2) | T-143 | Replaced theatrical render-only assertion with a real CSS-consumption test that scans `WidgetPreview.module.css` and proves every theme color slot is referenced via `var(--token)` somewhere — catches token-drop regressions. |
| F-003 (P3) | T-144 | Replaced loose `outerHTML !== outerHTML` comparison with specific `data-variant="metric"` attribute assertion. |
| F-004 (P3) | T-145 | Split `toCSSVarsVariant` output on `:root[data-theme="dark"] {` and assert each new var appears in BOTH halves. Catches future regression where dark block omits a token. |
| F-005 (P3) | T-141 | Variant `aria-label` simplified from `"kpi-tile <v> variant"` to `"<V> variant"` (Tile variant / Metric variant) — cleaner SR announcement. |
| F-006 (P3) | T-146 | Cavekit-widgets R4 codified that variant toggle is operable regardless of selection; new test asserts toggle renders + responds when kpi-tile selection is false. |
| Cavekit-schema R8 (revised) | T-142 | New backslash-exemption acceptance criterion + 3 tests proving CSS hex escapes (`\3b` etc.) load successfully and don't break declaration boundaries (CSS Syntax Level 3 decodes inside value token after declaration parsing). |

### Cavekit revisions

- **cavekit-schema R8** — added explicit acceptance criterion: backslash is intentionally NOT in the blocklist; CSS hex escapes are decoded after declaration parsing per CSS Syntax Level 3 and cannot break the declaration boundary. Test mandate added.
- **cavekit-widgets R4** — added acceptance criterion: variant toggle renders + remains interactive regardless of kpi-tile selection state (codifies shipped behavior).

### Validation

| Gate | Result |
|---|---|
| Lint | 0 errors |
| Typecheck | pass |
| Test | 267/267 pass (261 prior + 6 new across 3 files) |
| Build | pass (vite ~99ms; 18.09kB CSS / 293kB JS) |
| CI | (pending — see PR) |

### Browser verification

- Variant toggle now reads "Tile" / "Metric" with simplified `aria-label="Tile variant"` / `"Metric variant"`
- Clicking Metric flips `aria-pressed`, switches preview `data-variant` to `metric`
- Old `aria-label="kpi-tile tile variant"` no longer present (regression check passes)

### Next recommended batch

The repo is in a notably clean state: 3 boundary-locked cavekits, 11-widget catalog, 9-color/4-shadow/5-radius token surface, full back-compat on persistence, hardened shadow-injection guard. Two non-overlapping next directions:

1. **Variant-pair authoring UI.** Schema + persistence already support `ThemeVariantPair` with shared shadows/radii. Add light/dark editor toggle and a variant-aware export selector. Closes the dark-mode export story.
2. **Accessibility audit pass.** With 9 color pickers, 4 shadow textareas, 5 radius inputs, 11 widget cards, and the new variant toggle, the editor surface has grown enough to warrant formal WCAG AA review (color contrast, keyboard nav, SR announcements). Would need a small cavekit addition to scope it — boundary R1 doesn't include accessibility explicitly.

---

## Batch 10 — Follow-up: Shadow Safety + Coverage GAPs + KPI Variant UX

**Date:** 2026-04-16
Expand Down
2 changes: 2 additions & 0 deletions context/kits/cavekit-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ Defines the canonical data model for a theme: token groups (color, typography, s
- [ ] Validation rejects shadow groups missing any required slot
- [ ] Validation rejects shadow groups containing slots not in the required set
- [ ] DEFAULT_THEME and all built-in presets pass the tightened validator
- [ ] Backslash (`\`) is intentionally NOT in the blocklist. CSS hexadecimal escapes (e.g., `\3b` for `;`) are decoded inside the value token AFTER declaration parsing per CSS Syntax Level 3, so they cannot break the `:root { --shadow-x: VALUE; }` declaration boundary. A test must demonstrate that a shadow value containing `\3b` validates as ok and the resulting CSS export is well-formed (one declaration, not two).
**Dependencies:** none

### R9: Radius Token Group
Expand Down Expand Up @@ -132,4 +133,5 @@ Defines the canonical data model for a theme: token groups (color, typography, s
- 2026-04-16: Initial draft.
- 2026-04-16 (Batch 9): Color group expanded to 9 slots (added muted, hairline, inkSoft, surfaceInvert, onInvert). Added shadow token group (R8) and radius token group (R9). Theme configuration and variant pair validation updated to compose them.
- 2026-04-16 (Batch 10): R8 tightened — shadow values now reject CSS-injection vectors (`;` `}` `{` `/*` `*/` `\n` `\r` `<` `>`) so untrusted imported themes cannot break out of the `:root { ... }` declaration in CSS / SCSS / Tailwind exports.
- 2026-04-16 (Batch 11): R8 documents the intentional backslash exemption — CSS hex escapes (`\3b` etc.) are decoded inside the value token after declaration parsing, so they cannot break the declaration boundary.
- 2026-04-16: Boundary revision (Batch 9). Color group extended to 9 slots (added muted, hairline, inkSoft, surfaceInvert, onInvert). Added shadow token group (R8) and radius token group (R9). Theme configuration and variant pair validation updated to compose them.
2 changes: 2 additions & 0 deletions context/kits/cavekit-widgets.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ The widget manifest layer: a fixed catalog of UI widgets that the user toggles f
- [ ] Only the kpi-tile preview exposes a variant toggle; no other widget gains per-widget configuration UI
- [ ] The toggle has accessible labels for both states (Tile / Metric) and reflects the active variant via aria-pressed or aria-checked
- [ ] The variant choice is per-session (not persisted to localStorage) and does not appear in any export format
- [ ] The variant toggle is rendered alongside the kpi-tile card whenever the card is rendered, regardless of whether kpi-tile is included in the current selection (the toggle controls preview rendering, not export inclusion)
- [ ] Previews never trigger network, storage, or DOM-mutation side effects
**Dependencies:** R1, cavekit-product-boundary R4

Expand Down Expand Up @@ -97,3 +98,4 @@ The widget manifest layer: a fixed catalog of UI widgets that the user toggles f
## Changelog
- 2026-04-16: Initial cavekit. Backfills shipped 8-widget surface; extends catalog to 11 (adds badge, pricing-card, testimonial). kpi-tile gains a "metric" visual variant absorbing the proposed metric-stat widget.
- 2026-04-16 (Batch 10): R4 clarified — kpi-tile metric variant is user-selectable via an in-card toggle (transient per-session, not persisted, not in export). Codifies the boundary that no other widget exposes per-widget configuration UI.
- 2026-04-16 (Batch 11): R4 codifies that the variant toggle is operable regardless of kpi-tile selection state (matches shipped behavior).
74 changes: 74 additions & 0 deletions context/plans/build-site-batch11.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
---
created: "2026-04-16"
last_edited: "2026-04-16"
---

# Build Site — Batch 11 Nit-Pick Cleanup

Tiny follow-up to Batch 10 — addresses the 2 P2 + 4 P3 nits from the Batch 10 inspector report and the 2 cavekit clarifications it proposed.

**8 tasks across 2 tiers.**

---

## Tier 0 — Cavekit-driven changes (start here)

| Task | Title | Cavekit | Requirement | Effort |
|------|-------|---------|-------------|--------|
| T-140 | F-001: remove dead `e.stopPropagation()` and misleading comment from variant toggle handler | (code quality) | — | S |
| T-141 | F-005: simplify variant aria-label from `"kpi-tile <v> variant"` to `"<V> variant"` | cavekit-widgets.md | R4 | S |
| T-142 | R8 backslash exemption test: `0 1px 2px red\3b color: red` validates as ok and round-trips through CSS export as a single declaration | cavekit-schema.md | R8 (revised) | S |

---

## Tier 1 — Test sharpening

| Task | Title | Cavekit | Requirement | blockedBy | Effort |
|------|-------|---------|-------------|-----------|--------|
| T-143 | F-002: replace theatrical wrapper-only assertion with a static check that scans WidgetPreview.module.css and proves every theme color/shadow/radius slot is referenced via `var(--token)` somewhere | cavekit-widgets.md | R4 | T-141 | M |
| T-144 | F-003: replace loose `outerHTML !== outerHTML` comparison with specific `data-variant="metric"` attribute assertion | cavekit-widgets.md | R4 | T-141 | S |
| T-145 | F-004: split `toCSSVarsVariant` output on `:root[data-theme="dark"] {` and assert each new var appears in BOTH halves | cavekit-export.md | R2 | — | S |
| T-146 | F-006: add test asserting variant toggle renders + is interactive when kpi-tile selection is false | cavekit-widgets.md | R4 (revised) | T-141 | S |
| T-147 | Update existing F-001 regression test to remove the `stopPropagation`-specific intent (rename / reword) since the structural separation is what guarantees isolation, not propagation suppression | (test quality) | — | T-140 | S |

---

## Summary

| Tier | Tasks | Effort |
|------|-------|--------|
| 0 | 3 | 3S |
| 1 | 5 | 1M, 4S |

**Total: 8 tasks, 1M + 7S.**

---

## Coverage Matrix

| Origin | Item | Task(s) | Status |
|--------|------|---------|--------|
| Inspector F-001 (P2) | Dead stopPropagation removed | T-140, T-147 | COVERED |
| Inspector F-002 (P2) | Real WidgetPreview CSS-consumption assertion | T-143 | COVERED |
| Inspector F-003 (P3) | Specific data-variant assertion | T-144 | COVERED |
| Inspector F-004 (P3) | Dark-block-specific assertion in toCSSVarsVariant test | T-145 | COVERED |
| Inspector F-005 (P3) | aria-label phrasing simplified | T-141 | COVERED |
| Inspector F-006 (P3) | Toggle availability behavior codified + tested | T-146 + cavekit-widgets R4 revision | COVERED |
| Cavekit-schema R8 (revised) | Backslash exemption documented + tested | T-142 + cavekit-schema R8 revision | COVERED |
| Cavekit-widgets R4 (revised) | Toggle-availability acceptance criterion added | T-146 | COVERED |

**Coverage: 8/8 batch-11 items (100%). 0 GAP.**

---

## Dependency Graph

```mermaid
graph LR
T-140 --> T-147
T-141 --> T-143
T-141 --> T-144
T-141 --> T-146
T-142
T-145
```
8 changes: 2 additions & 6 deletions src/components/WidgetSelector/WidgetSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,14 +105,10 @@ export default function WidgetSelector({ selection, onChange }: Props) {
key={v}
type="button"
aria-pressed={active}
aria-label={`kpi-tile ${v} variant`}
aria-label={`${v === 'tile' ? 'Tile' : 'Metric'} variant`}
data-variant-option={v}
className={`${styles.variantBtn} ${active ? styles.variantBtnActive : ''}`}
onClick={(e) => {
// Stop propagation so this doesn't toggle the parent switch
e.stopPropagation()
setKpiVariant(v)
}}
onClick={() => setKpiVariant(v)}
>
{v === 'tile' ? 'Tile' : 'Metric'}
</button>
Expand Down
45 changes: 26 additions & 19 deletions tests/components/WidgetSelector.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,39 +105,32 @@ describe('WidgetSelector — preview-card surface', () => {
describe('WidgetSelector — kpi-tile variant toggle (T-134/T-135)', () => {
it('renders the kpi-tile variant toggle with two pressable options', () => {
render(<Harness />)
const tileBtn = screen.getByRole('button', { name: /kpi-tile tile variant/i })
const metricBtn = screen.getByRole('button', { name: /kpi-tile metric variant/i })
expect(tileBtn).toBeInTheDocument()
expect(metricBtn).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Tile variant' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Metric variant' })).toBeInTheDocument()
})

it('defaults to tile variant on mount (Tile pressed, Metric not)', () => {
render(<Harness />)
const tileBtn = screen.getByRole('button', { name: /kpi-tile tile variant/i })
const metricBtn = screen.getByRole('button', { name: /kpi-tile metric variant/i })
expect(tileBtn).toHaveAttribute('aria-pressed', 'true')
expect(metricBtn).toHaveAttribute('aria-pressed', 'false')
expect(screen.getByRole('button', { name: 'Tile variant' })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByRole('button', { name: 'Metric variant' })).toHaveAttribute('aria-pressed', 'false')
})

it('clicking Metric flips aria-pressed and changes the kpi-tile preview', async () => {
it('clicking Metric flips aria-pressed and switches preview to data-variant="metric"', async () => {
const user = userEvent.setup()
const { container } = render(<Harness />)
const before = container.querySelector('[data-widget-preview="kpi-tile"]')!.outerHTML
await user.click(screen.getByRole('button', { name: /kpi-tile metric variant/i }))
const tileBtn = screen.getByRole('button', { name: /kpi-tile tile variant/i })
const metricBtn = screen.getByRole('button', { name: /kpi-tile metric variant/i })
expect(tileBtn).toHaveAttribute('aria-pressed', 'false')
expect(metricBtn).toHaveAttribute('aria-pressed', 'true')
const after = container.querySelector('[data-widget-preview="kpi-tile"]')!.outerHTML
expect(after).not.toBe(before)
expect(container.querySelector('[data-widget-preview="kpi-tile"]')).toHaveAttribute('data-variant', 'tile')
await user.click(screen.getByRole('button', { name: 'Metric variant' }))
expect(screen.getByRole('button', { name: 'Tile variant' })).toHaveAttribute('aria-pressed', 'false')
expect(screen.getByRole('button', { name: 'Metric variant' })).toHaveAttribute('aria-pressed', 'true')
expect(container.querySelector('[data-widget-preview="kpi-tile"]')).toHaveAttribute('data-variant', 'metric')
})

it('clicking the variant toggle does NOT toggle the kpi-tile selection switch', async () => {
it('clicking the variant toggle does NOT change the kpi-tile selection (sibling structure isolates events)', async () => {
const user = userEvent.setup()
let latest: WidgetSelection = DEFAULT_WIDGET_SELECTION
render(<Harness onSelectionChange={s => { latest = s }} />)
const before = latest['kpi-tile']
await user.click(screen.getByRole('button', { name: /kpi-tile metric variant/i }))
await user.click(screen.getByRole('button', { name: 'Metric variant' }))
expect(latest['kpi-tile']).toBe(before)
})

Expand All @@ -147,6 +140,20 @@ describe('WidgetSelector — kpi-tile variant toggle (T-134/T-135)', () => {
const toggles = container.querySelectorAll('[data-testid="kpi-tile-variant-toggle"]')
expect(toggles).toHaveLength(1)
})

// T-146 / cavekit-widgets R4 (revised) — toggle is operable regardless of selection state
it('renders + responds to clicks even when kpi-tile is excluded from selection', async () => {
const user = userEvent.setup()
const allOff: WidgetSelection = { ...DEFAULT_WIDGET_SELECTION }
const { container } = render(<Harness initial={allOff} />)
// kpi-tile is unselected
expect(screen.getByRole('switch', { name: 'KPI tile' })).toHaveAttribute('aria-checked', 'false')
// variant toggle still rendered
expect(container.querySelectorAll('[data-testid="kpi-tile-variant-toggle"]')).toHaveLength(1)
// and still operable
await user.click(screen.getByRole('button', { name: 'Metric variant' }))
expect(container.querySelector('[data-widget-preview="kpi-tile"]')).toHaveAttribute('data-variant', 'metric')
})
})

describe('WidgetSelector → export linkage', () => {
Expand Down
17 changes: 14 additions & 3 deletions tests/export/newTokens.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,24 @@ describe('toCSSVars — new token emission', () => {
})

describe('toCSSVarsVariant — new token emission', () => {
it('emits new color/shadow/radius vars in both :root and :root[data-theme="dark"] blocks', () => {
it('emits the dark selector and a light :root selector', () => {
const out = toCSSVarsVariant(variantPair)
expect(out).toContain(':root {')
expect(out).toContain(':root[data-theme="dark"] {')
})

// T-145 / F-004 — both blocks must individually carry every new var
it('emits every new color/shadow/radius var in BOTH the light AND dark blocks (split-and-check)', () => {
const out = toCSSVarsVariant(variantPair)
const darkSelector = ':root[data-theme="dark"] {'
const splitIdx = out.indexOf(darkSelector)
expect(splitIdx).toBeGreaterThan(-1)
const lightHalf = out.slice(0, splitIdx)
const darkHalf = out.slice(splitIdx)

for (const name of [...NEW_COLOR_VAR_NAMES, ...SHADOW_VAR_NAMES, ...RADIUS_VAR_NAMES]) {
// appears at least once in light or dark block — both blocks cite the same vars
expect(out).toContain(name)
expect(lightHalf, `light block must contain ${name}`).toContain(name)
expect(darkHalf, `dark block must contain ${name}`).toContain(name)
}
})
})
Expand Down
Loading