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 + + +
+
0
+
1
+
2
+
+ +``` + +**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.