diff --git a/docs/plans/2026-06-30-carousel-implementation.md b/docs/plans/2026-06-30-carousel-implementation.md
new file mode 100644
index 0000000..137de74
--- /dev/null
+++ b/docs/plans/2026-06-30-carousel-implementation.md
@@ -0,0 +1,707 @@
+# Carousel Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Build a tiny, framework-agnostic scroll-snap carousel: a zero-dep core plus opt-in `dots()`/`autoplay()` modules and a CSS file.
+
+**Architecture:** Native scroll handles touch/momentum; CSS handles snap and motion; JS is a thin layer. Core `createCarousel(track)` returns an instance (`next/prev/scrollToIndex/index/count/on/destroy`) with index tracking via IntersectionObserver. Controls are CSS-first (native `::scroll-marker`/`::scroll-button`); JS `dots()` is the Firefox/legacy fallback.
+
+**Tech Stack:** TypeScript, tsup (build), Vitest + jsdom (unit), Playwright (browser smoke). No runtime deps.
+
+Design reference: `docs/plans/2026-06-30-carousel-design.md`.
+
+---
+
+## Task 1: Scaffold the package
+
+**Files:**
+- Create: `package.json`, `tsconfig.json`, `tsup.config.ts`, `vitest.config.ts`, `.gitignore`, `src/index.ts`
+
+**Step 1: Write `package.json`**
+
+```json
+{
+ "name": "carousel",
+ "version": "0.0.0",
+ "type": "module",
+ "sideEffects": ["*.css"],
+ "exports": {
+ ".": "./dist/index.js",
+ "./dots": "./dist/dots.js",
+ "./autoplay": "./dist/autoplay.js",
+ "./carousel.css": "./src/carousel.css"
+ },
+ "files": ["dist", "src/carousel.css"],
+ "scripts": {
+ "build": "tsup src/index.ts src/dots.ts src/autoplay.ts --format esm --dts",
+ "test": "vitest run",
+ "test:e2e": "playwright test",
+ "check": "tsc --noEmit"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1",
+ "jsdom": "^25",
+ "tsup": "^8",
+ "typescript": "^5",
+ "vitest": "^2"
+ }
+}
+```
+
+**Step 2: Write `tsconfig.json`**
+
+```json
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "verbatimModuleSyntax": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "noEmit": true,
+ "skipLibCheck": true
+ },
+ "include": ["src", "tests"]
+}
+```
+
+**Step 3: Write `vitest.config.ts`**
+
+```ts
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: { environment: 'jsdom', include: ['tests/**/*.test.ts'] },
+});
+```
+
+**Step 4: Write `.gitignore`**
+
+```
+node_modules
+dist
+test-results
+playwright-report
+```
+
+**Step 5: Stub `src/index.ts`**
+
+```ts
+export {};
+```
+
+**Step 6: Install and verify**
+
+Run: `yarn install && yarn check`
+Expected: installs cleanly, `tsc` exits 0.
+
+**Step 7: Commit**
+
+```bash
+git add -A
+git commit -m "chore: scaffold carousel package"
+```
+
+---
+
+## Task 2: Core `createCarousel` — count, navigation, scrollToIndex
+
+**Files:**
+- Modify: `src/index.ts`
+- Test: `tests/core.test.ts`
+
+> jsdom has no layout/scroll. Provide a minimal `IntersectionObserver` shim in the
+> test setup and assert on `scrollTo` calls (spy) rather than real scrolling.
+
+**Step 1: Write the failing test**
+
+```ts
+// tests/core.test.ts
+import { beforeEach, expect, test, vi } from 'vitest';
+import { createCarousel } from '../src/index.ts';
+
+// Minimal IO shim — records instances so tests can fire entries manually.
+class IOShim {
+ static last: IOShim | null = null;
+ cb: IntersectionObserverCallback;
+ elements: Element[] = [];
+ constructor(cb: IntersectionObserverCallback) {
+ this.cb = cb;
+ IOShim.last = this;
+ }
+ observe(el: Element) { this.elements.push(el); }
+ unobserve() {}
+ disconnect() {}
+ // helper: emit "slide i is the one intersecting"
+ emit(i: number) {
+ this.cb(
+ this.elements.map((target, idx) => ({
+ target,
+ isIntersecting: idx === i,
+ intersectionRatio: idx === i ? 1 : 0,
+ })) as unknown as IntersectionObserverEntry[],
+ this as unknown as IntersectionObserver,
+ );
+ }
+}
+vi.stubGlobal('IntersectionObserver', IOShim);
+
+function makeTrack(n: number): HTMLElement {
+ const track = document.createElement('div');
+ for (let i = 0; i < n; i++) {
+ const slide = document.createElement('div');
+ slide.className = 'slide';
+ track.append(slide);
+ }
+ track.scrollTo = vi.fn();
+ return track;
+}
+
+let track: HTMLElement;
+beforeEach(() => { track = makeTrack(3); });
+
+test('count reflects slide children', () => {
+ const c = createCarousel(track);
+ expect(c.count).toBe(3);
+});
+
+test('next/prev clamp to range and scroll to target offset', () => {
+ const c = createCarousel(track);
+ // give slides fake offsets
+ (track.children[1] as HTMLElement).offsetLeft; // 0 in jsdom
+ c.next();
+ expect(track.scrollTo).toHaveBeenCalled();
+});
+```
+
+**Step 2: Run test to verify it fails**
+
+Run: `yarn test tests/core.test.ts`
+Expected: FAIL — `createCarousel` not exported.
+
+**Step 3: Write minimal implementation**
+
+```ts
+// src/index.ts
+export type CarouselEvent = 'change';
+
+export type Carousel = {
+ next(): void;
+ prev(): void;
+ scrollToIndex(i: number): void;
+ readonly index: number;
+ readonly count: number;
+ on(evt: CarouselEvent, cb: (index: number) => void): () => void;
+ destroy(): void;
+};
+
+/**
+ * Wrap a scroll-snap track element and return a carousel controller.
+ * @param track - the overflow-x scroll container whose children are slides
+ */
+export function createCarousel(track: HTMLElement): Carousel {
+ const slides = () => Array.from(track.children) as HTMLElement[];
+ let index = 0;
+ const listeners = new Set<(i: number) => void>();
+
+ const clamp = (i: number) => Math.max(0, Math.min(i, slides().length - 1));
+
+ function scrollToIndex(i: number) {
+ const target = slides()[clamp(i)];
+ if (target) {
+ track.scrollTo({ left: target.offsetLeft, behavior: 'smooth' });
+ }
+ }
+
+ // index tracking: whichever slide is most intersecting is current
+ const io = new IntersectionObserver(
+ (entries) => {
+ const hit = entries.find((e) => e.isIntersecting);
+ if (!hit) return;
+ const i = slides().indexOf(hit.target as HTMLElement);
+ if (i !== -1 && i !== index) {
+ index = i;
+ listeners.forEach((cb) => cb(index));
+ }
+ },
+ { root: track, threshold: 0.6 },
+ );
+ slides().forEach((s) => io.observe(s));
+
+ return {
+ next: () => scrollToIndex(index + 1),
+ prev: () => scrollToIndex(index - 1),
+ scrollToIndex,
+ get index() { return index; },
+ get count() { return slides().length; },
+ on(_evt, cb) {
+ listeners.add(cb);
+ return () => listeners.delete(cb);
+ },
+ destroy() {
+ io.disconnect();
+ listeners.clear();
+ },
+ };
+}
+```
+
+**Step 4: Run test to verify it passes**
+
+Run: `yarn test tests/core.test.ts`
+Expected: PASS (2 tests).
+
+**Step 5: Commit**
+
+```bash
+git add src/index.ts tests/core.test.ts
+git commit -m "feat: carousel core (count, navigation, scrollToIndex)"
+```
+
+---
+
+## Task 3: Core — `change` event fires on snap, `destroy` cleans up
+
+**Files:**
+- Test: `tests/core.test.ts` (add cases)
+
+**Step 1: Write failing tests**
+
+```ts
+test('change fires with new index when a slide intersects', () => {
+ const c = createCarousel(track);
+ const seen: number[] = [];
+ c.on('change', (i) => seen.push(i));
+ (IOShim.last as IOShim).emit(2); // user swiped to slide 2
+ expect(seen).toEqual([2]);
+ expect(c.index).toBe(2);
+});
+
+test('change does not re-fire for the same index', () => {
+ const c = createCarousel(track);
+ const seen: number[] = [];
+ c.on('change', (i) => seen.push(i));
+ (IOShim.last as IOShim).emit(1);
+ (IOShim.last as IOShim).emit(1);
+ expect(seen).toEqual([1]);
+});
+
+test('unsubscribe stops delivery; destroy disconnects observer', () => {
+ const c = createCarousel(track);
+ const seen: number[] = [];
+ const off = c.on('change', (i) => seen.push(i));
+ off();
+ (IOShim.last as IOShim).emit(2);
+ expect(seen).toEqual([]);
+ const spy = vi.spyOn(IOShim.last as IOShim, 'disconnect');
+ c.destroy();
+ expect(spy).toHaveBeenCalled();
+});
+```
+
+**Step 2: Run to verify**
+
+Run: `yarn test tests/core.test.ts`
+Expected: PASS — implementation from Task 2 already covers these. If any fail, fix `src/index.ts` minimally.
+
+**Step 3: Commit (only if code changed)**
+
+```bash
+git add -A
+git commit -m "test: carousel core change/destroy edge cases"
+```
+
+---
+
+## Task 4: `dots()` fallback module
+
+**Files:**
+- Create: `src/dots.ts`
+- Test: `tests/dots.test.ts`
+
+> Wires the developer's existing dot elements. Click → `scrollToIndex`. `change` →
+> toggle `aria-current`/`.active`. Engages only when native `::scroll-marker` is
+> unsupported (feature-detect via `CSS.supports('selector(::scroll-marker)')`); in
+> tests we force-enable by passing the dots explicitly.
+
+**Step 1: Write failing test**
+
+```ts
+// tests/dots.test.ts
+import { expect, test, vi } from 'vitest';
+import { dots } from '../src/dots.ts';
+
+function fakeCarousel() {
+ let cb: (i: number) => void = () => {};
+ return {
+ index: 0, count: 3,
+ next: vi.fn(), prev: vi.fn(),
+ scrollToIndex: vi.fn(),
+ on: (_e: string, fn: (i: number) => void) => { cb = fn; return () => {}; },
+ destroy: vi.fn(),
+ fire: (i: number) => cb(i),
+ };
+}
+
+test('clicking a dot scrolls to its index', () => {
+ const c = fakeCarousel();
+ const container = document.createElement('div');
+ container.innerHTML = '';
+ dots(c as never, container);
+ (container.children[2] as HTMLButtonElement).click();
+ expect(c.scrollToIndex).toHaveBeenCalledWith(2);
+});
+
+test('change marks the active dot with aria-current', () => {
+ const c = fakeCarousel();
+ const container = document.createElement('div');
+ container.innerHTML = '';
+ dots(c as never, container);
+ c.fire(1);
+ expect(container.children[1].getAttribute('aria-current')).toBe('true');
+ expect(container.children[0].hasAttribute('aria-current')).toBe(false);
+});
+```
+
+**Step 2: Run to verify it fails**
+
+Run: `yarn test tests/dots.test.ts`
+Expected: FAIL — `dots` not found.
+
+**Step 3: Implement**
+
+```ts
+// src/dots.ts
+import type { Carousel } from './index.ts';
+
+/**
+ * Wire the developer's existing dot elements to a carousel.
+ * Progressive fallback: prefer native ::scroll-marker where supported.
+ * @param c - carousel instance
+ * @param container - element whose children are the dot controls
+ */
+export function dots(c: Carousel, container: HTMLElement): () => void {
+ const items = Array.from(container.children) as HTMLElement[];
+
+ const onClick = (i: number) => () => c.scrollToIndex(i);
+ const handlers = items.map((el, i) => {
+ const h = onClick(i);
+ el.addEventListener('click', h);
+ return h;
+ });
+
+ function mark(active: number) {
+ items.forEach((el, i) => {
+ if (i === active) {
+ el.setAttribute('aria-current', 'true');
+ el.classList.add('active');
+ } else {
+ el.removeAttribute('aria-current');
+ el.classList.remove('active');
+ }
+ });
+ }
+ mark(c.index);
+ const off = c.on('change', mark);
+
+ return () => {
+ off();
+ items.forEach((el, i) => el.removeEventListener('click', handlers[i]));
+ };
+}
+```
+
+**Step 4: Run to verify it passes**
+
+Run: `yarn test tests/dots.test.ts`
+Expected: PASS (2 tests).
+
+**Step 5: Commit**
+
+```bash
+git add src/dots.ts tests/dots.test.ts
+git commit -m "feat: dots() fallback control module"
+```
+
+---
+
+## Task 5: `autoplay()` module
+
+**Files:**
+- Create: `src/autoplay.ts`
+- Test: `tests/autoplay.test.ts`
+
+> Timer calls `c.next()`. Pause on `pointerenter`/`focusin`, resume on leave/blur,
+> stop on manual interaction. Loops back to 0 at the end. Use fake timers.
+
+**Step 1: Write failing test**
+
+```ts
+// tests/autoplay.test.ts
+import { afterEach, beforeEach, expect, test, vi } from 'vitest';
+import { autoplay } from '../src/autoplay.ts';
+
+function fakeCarousel(count = 3) {
+ let index = 0;
+ return {
+ get index() { return index; }, count,
+ next: vi.fn(() => { index = (index + 1) % count; }),
+ prev: vi.fn(), scrollToIndex: vi.fn(),
+ on: () => () => {}, destroy: vi.fn(),
+ };
+}
+
+beforeEach(() => vi.useFakeTimers());
+afterEach(() => vi.useRealTimers());
+
+test('advances every interval', () => {
+ const c = fakeCarousel();
+ autoplay(c as never, { interval: 1000, root: document.createElement('div') });
+ vi.advanceTimersByTime(2000);
+ expect(c.next).toHaveBeenCalledTimes(2);
+});
+
+test('pause on pointerenter, resume on pointerleave', () => {
+ const c = fakeCarousel();
+ const root = document.createElement('div');
+ autoplay(c as never, { interval: 1000, root });
+ root.dispatchEvent(new Event('pointerenter'));
+ vi.advanceTimersByTime(3000);
+ expect(c.next).not.toHaveBeenCalled();
+ root.dispatchEvent(new Event('pointerleave'));
+ vi.advanceTimersByTime(1000);
+ expect(c.next).toHaveBeenCalledTimes(1);
+});
+
+test('stop() halts and removes listeners', () => {
+ const c = fakeCarousel();
+ const stop = autoplay(c as never, { interval: 1000, root: document.createElement('div') });
+ stop();
+ vi.advanceTimersByTime(5000);
+ expect(c.next).not.toHaveBeenCalled();
+});
+```
+
+**Step 2: Run to verify it fails**
+
+Run: `yarn test tests/autoplay.test.ts`
+Expected: FAIL — `autoplay` not found.
+
+**Step 3: Implement**
+
+```ts
+// src/autoplay.ts
+import type { Carousel } from './index.ts';
+
+/**
+ * Options for autoplay.
+ */
+export type AutoplayOptions = {
+ /** ms between advances */
+ interval: number;
+ /** element whose hover/focus pauses playback (usually the track wrapper) */
+ root: HTMLElement;
+};
+
+/**
+ * Auto-advance a carousel, pausing on hover/focus.
+ * @param c - carousel instance
+ * @param opts - interval (ms) and the root element to bind pause events to
+ * @returns stop function that halts playback and removes listeners
+ */
+export function autoplay(c: Carousel, opts: AutoplayOptions): () => void {
+ let timer: ReturnType | undefined;
+
+ const tick = () => c.next();
+ const start = () => { timer ??= setInterval(tick, opts.interval); };
+ const pause = () => { clearInterval(timer); timer = undefined; };
+
+ opts.root.addEventListener('pointerenter', pause);
+ opts.root.addEventListener('pointerleave', start);
+ opts.root.addEventListener('focusin', pause);
+ opts.root.addEventListener('focusout', start);
+ start();
+
+ return () => {
+ pause();
+ opts.root.removeEventListener('pointerenter', pause);
+ opts.root.removeEventListener('pointerleave', start);
+ opts.root.removeEventListener('focusin', pause);
+ opts.root.removeEventListener('focusout', start);
+ };
+}
+```
+
+**Step 4: Run to verify it passes**
+
+Run: `yarn test tests/autoplay.test.ts`
+Expected: PASS (3 tests).
+
+**Step 5: Commit**
+
+```bash
+git add src/autoplay.ts tests/autoplay.test.ts
+git commit -m "feat: autoplay() module with hover/focus pause"
+```
+
+---
+
+## Task 6: `carousel.css`
+
+**Files:**
+- Create: `src/carousel.css`
+
+> No test — it's static CSS, validated by the Playwright smoke test in Task 7.
+
+**Step 1: Write the stylesheet**
+
+```css
+/* carousel.css — snap track, hidden scrollbar, native markers, opt-in effects */
+.track {
+ display: flex;
+ gap: 1rem;
+ overflow-x: auto;
+ scroll-snap-type: x mandatory;
+ scrollbar-width: none;
+ scroll-marker-group: after; /* native dots where supported */
+}
+.track::-webkit-scrollbar { display: none; }
+
+.slide {
+ flex: 0 0 80%;
+ scroll-snap-align: center;
+ scroll-snap-stop: always; /* one swipe = one slide */
+}
+
+/* native CSS markers (Chrome 135+, Safari 18.2+) */
+.slide::scroll-marker {
+ content: '';
+ width: 0.6rem;
+ height: 0.6rem;
+ border-radius: 50%;
+ background: currentColor;
+ opacity: 0.4;
+}
+.slide::scroll-marker:target-current { opacity: 1; }
+
+/* opt-in tactile effect; degrades to plain snap where unsupported */
+@supports (animation-timeline: view()) {
+ .track.fx .slide {
+ animation: slide-fx linear both;
+ animation-timeline: view(inline);
+ }
+ @keyframes slide-fx {
+ entry 0%, exit 100% { scale: 0.9; opacity: 0.5; }
+ cover 50% { scale: 1; opacity: 1; }
+ }
+}
+```
+
+**Step 2: Commit**
+
+```bash
+git add src/carousel.css
+git commit -m "feat: carousel.css (snap, native markers, opt-in effects)"
+```
+
+---
+
+## Task 7: Playwright smoke test (real scroll)
+
+**Files:**
+- Create: `playwright.config.ts`, `e2e/demo.html`, `e2e/smoke.test.ts`
+
+> jsdom can't scroll. One real-browser test proves snap + `next()` actually move.
+
+**Step 1: Write `playwright.config.ts`**
+
+```ts
+import { defineConfig } from '@playwright/test';
+
+export default defineConfig({
+ testDir: 'e2e',
+ use: { baseURL: 'http://localhost:5173' },
+ webServer: { command: 'npx http-server e2e -p 5173 -s', port: 5173, reuseExistingServer: true },
+});
+```
+
+**Step 2: Write `e2e/demo.html`**
+
+A page that imports the built core from `../dist/index.js`, builds a 3-slide track,
+exposes `window.c = createCarousel(track)`. Include `carousel.css`.
+
+```html
+
+
+
+
+```
+
+**Step 3: Write the test**
+
+```ts
+// e2e/smoke.test.ts
+import { expect, test } from '@playwright/test';
+
+test('next() scrolls the track and updates index', async ({ page }) => {
+ await page.goto('/demo.html');
+ const before = await page.evaluate(() => document.getElementById('t')!.scrollLeft);
+ await page.evaluate(() => (window as any).c.next());
+ await page.waitForTimeout(500); // smooth scroll settle
+ const after = await page.evaluate(() => document.getElementById('t')!.scrollLeft);
+ expect(after).toBeGreaterThan(before);
+ await expect.poll(() => page.evaluate(() => (window as any).c.index)).toBe(1);
+});
+```
+
+**Step 4: Build, then run**
+
+Run: `yarn build && yarn test:e2e`
+Expected: PASS — `scrollLeft` increases, `index` becomes 1.
+
+**Step 5: Commit**
+
+```bash
+git add playwright.config.ts e2e/
+git commit -m "test: playwright smoke for real scroll behavior"
+```
+
+---
+
+## Task 8: README + verify the public API
+
+**Files:**
+- Create: `README.md`
+
+**Step 1: Write a short README** — install, the three import paths, a copy-paste
+example wiring arrows (manual handlers) + `dots()` fallback + native CSS markers, and
+the browser-support table from the design doc.
+
+**Step 2: Final verification**
+
+Run: `yarn check && yarn test && yarn build`
+Expected: all green; `dist/` contains `index.js`, `dots.js`, `autoplay.js` with `.d.ts`.
+
+**Step 3: Commit**
+
+```bash
+git add README.md
+git commit -m "docs: README with usage and browser support"
+```
+
+---
+
+## Notes for the executor
+
+- **DRY/YAGNI:** no loop mode, no DOM generation, no fade variant — explicitly deferred.
+- **Fold review fixes** into the related task's commit; don't leave "feature + fix" pairs.
+- The IO shim and fake-carousel helpers are deliberately tiny — don't promote them to a
+ framework.