diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d71634f --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# build / tooling +node_modules +.yarn/* +!.yarn/releases +dist +coverage +test-results +playwright-report + +# local-only docs +docs/ +logs_llm/ + +*.md +!README.md diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..074cac7 --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +@ilia:registry=https://git.allmy.work/api/packages/ilia/npm/ +//git.allmy.work/api/packages/ilia/npm/:_authToken=${NODE_AUTH_TOKEN} diff --git a/.size-limit.json b/.size-limit.json new file mode 100644 index 0000000..fb6a3a5 --- /dev/null +++ b/.size-limit.json @@ -0,0 +1,5 @@ +[ + { "name": "core", "path": "dist/index.js", "limit": "1 KB" }, + { "name": "dots", "path": "dist/dots.js", "limit": "0.6 KB" }, + { "name": "autoplay", "path": "dist/autoplay.js", "limit": "0.6 KB" } +] diff --git a/.yarnrc.yml b/.yarnrc.yml new file mode 100644 index 0000000..3186f3f --- /dev/null +++ b/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..248473e --- /dev/null +++ b/biome.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.13/schema.json", + "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, + "files": { "includes": ["src/**/*", "tests/**/*", "e2e/**/*", "*.ts", "*.json"] }, + "formatter": { "enabled": true, "indentStyle": "space", "indentWidth": 2, "lineWidth": 120 }, + "linter": { "enabled": true, "rules": { "recommended": true } }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "semicolons": "always", + "trailingCommas": "all" + } + } +} diff --git a/docs/plans/2026-06-30-carousel-design.md b/docs/plans/2026-06-30-carousel-design.md deleted file mode 100644 index ed45a49..0000000 --- a/docs/plans/2026-06-30-carousel-design.md +++ /dev/null @@ -1,147 +0,0 @@ -# Carousel — design - -> **Status:** approved design (2026-06-30). Package name provisional (`carousel`) — rename before publish. - -A tiny, framework-agnostic carousel. Native scroll does the heavy lifting; CSS -does the motion; JavaScript is a thin, opt-in fallback. Size is the priority. - -## Goals - -- Smallest realistic footprint. Native platform features before code. -- Responsive and touch-first via **native scroll** — no hand-rolled drag/momentum. -- Modern CSS (scroll-snap, scroll-driven animations, native scroll markers/buttons). -- Controls as **opt-in modules** the developer wires to their own markup. -- Evergreen browsers with graceful degradation. No legacy polyfills. - -## Non-goals (deferred — add when asked) - -- Infinite/looping mode (needs slide cloning; fights scroll-snap). -- Fade/swap (transform-driven) variant. -- Modules generating their own DOM or shipping CSS. - -## Browser target - -Evergreen + graceful degrade. Baseline as of mid-2026: - -| Feature | Chrome | Safari | Firefox | Use | -|---|---|---|---|---| -| `scroll-snap`, scrollbar hiding | ✅ years | ✅ years | ✅ years | **Core path** — universal | -| `scroll-snap-stop: always` | ✅ | ✅ | ✅ | One-swipe-one-slide | -| CSS scroll-driven animations (`animation-timeline: view()`) | ✅ | ⚠️ partial | ✅ | Opt-in per-slide effects; degrades to plain snap | -| `::scroll-marker` / `::scroll-button` | ✅ 135+ | ✅ 18.2+ | ⚠️ partial | **CSS-first controls**; JS `dots()` is the FF/legacy fallback | -| `scrollsnapchange` event | ✅ | ✅ | ❌ (mid-2026) | Deferred index-tracking upgrade; **IntersectionObserver** used today | -| `scroll-state(snapped:)` container queries | ✅ | ❌ | ❌ | Skipped — not Baseline | - -## Architecture - -Three independently tree-shakeable layers: - -1. **Core** — `createCarousel(track, opts?)` wraps one scroll container, returns an - instance. Zero deps. -2. **Sugar modules** (opt-in imports) — `dots()`, `autoplay()`. Wire the developer's - **existing** markup to the instance. Create no DOM, ship no CSS. -3. **CSS** — a stylesheet the developer imports/copies: snap track + optional - scroll-driven effect keyframes. The motion lives here, not in JS. - -**CSS-first controls.** Native `::scroll-marker` (dots) and `::scroll-button` (arrows) -are the default in Chrome 135+ / Safari 18.2+ — zero JS. The `dots()` JS module is a -**progressive fallback** that engages only where native markers are unsupported -(Firefox, older browsers). Arrows need no module at all — the developer calls -`c.next()` / `c.prev()` from their own click handlers. - -## Core instance API - -```ts -type Carousel = { - next(): void; - prev(): void; - scrollToIndex(i: number): void; - readonly index: number; // current snapped slide (leftmost) - readonly count: number; // slide count - on(evt: 'change', cb: (index: number) => void): () => void; // returns unsubscribe - destroy(): void; -}; -``` - -- **Index tracking** = `IntersectionObserver` on slides. Accurate, fires on swipe too — - no scroll-position math. Single code path across all browsers. - - *Deferred upgrade:* swap to `scrollsnapchange` (snapped element handed to you - directly) once Firefox ships it; drop the observer then. -- `next` / `prev` / `scrollToIndex` = `el.scrollTo()` (or `scrollIntoView`) to the - target child's offset. Smooth scroll + snap finish the job. -- `destroy()` disconnects the observer and removes listeners. - -## Responsive & touch - -- **Touch / drag / momentum:** 100% native scroll. Zero JS. -- **Items-per-view:** pure CSS — the developer sizes slides - (`flex: 0 0 80%` → `33%` at a breakpoint). Core is count-agnostic; `index` is the - leftmost snapped slide. -- **Scrollbar hidden** (`scrollbar-width: none` + `::-webkit-scrollbar`). Markers / - arrows are the affordance; on touch the gesture is self-evident. - -## Effects (the "tactile" feel) - -Pure CSS `animation-timeline: view()` on slides — scale / opacity react to scroll -position live as a slide nears center. Ships as an **optional** CSS snippet; the -developer opts in by adding a class. Degrades to plain snap where unsupported -(Safari partial today). No JS. - -## CSS sketch - -```css -.track { - display: flex; - overflow-x: auto; - scroll-snap-type: x mandatory; - scrollbar-width: none; /* Firefox */ -} -.track::-webkit-scrollbar { display: none; } /* Chrome/Safari */ - -.slide { - flex: 0 0 80%; - scroll-snap-align: center; - scroll-snap-stop: always; /* one swipe = one slide */ -} - -/* opt-in: tactile effect, degrades to plain snap */ -.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; } -} - -/* CSS-first controls (Chrome 135+ / Safari 18.2+); JS dots() fills FF */ -.track { scroll-marker-group: after; } -.slide::scroll-marker { /* dot styling */ } -``` - -## Package shape - -``` -src/index.ts // createCarousel — core -src/dots.ts // dots(c, container) — FF/legacy fallback -src/autoplay.ts // autoplay(c, opts) -src/carousel.css // snap track + optional effects + native markers -``` - -Separate entry points → import only what you use. No runtime dependencies. Build with -`tsc` + a small bundler (tsup or equivalent). - -## Testing - -- **Core logic** (index math, `change` emit, `destroy` cleanup) — unit tests, jsdom - with a fake `IntersectionObserver` shim. -- **Snap / scroll behavior** — one Playwright smoke test in a real browser. jsdom - can't scroll. - -## Sources - -- [Carousels with CSS — Chrome for Developers](https://developer.chrome.com/blog/carousels-with-css) -- [`::scroll-button()` — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/::scroll-button) -- [Creating CSS carousels — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Overflow/Carousels) -- [Using scroll snap events — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Scroll_snap/Using_scroll_snap_events) -- [CSS `scroll-state()` container queries — Chrome for Developers](https://developer.chrome.com/blog/css-scroll-state-queries) diff --git a/docs/plans/2026-06-30-carousel-implementation.md b/docs/plans/2026-06-30-carousel-implementation.md deleted file mode 100644 index 137de74..0000000 --- a/docs/plans/2026-06-30-carousel-implementation.md +++ /dev/null @@ -1,707 +0,0 @@ -# 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. diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000..f92742c --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,11 @@ +pre-commit: + parallel: true + commands: + biome-check: + glob: "*.{ts,json,css}" + run: yarn biome check --write {staged_files} + stage_fixed: true + typecheck: + run: yarn check + tests: + run: yarn test diff --git a/package.json b/package.json new file mode 100644 index 0000000..934fb62 --- /dev/null +++ b/package.json @@ -0,0 +1,39 @@ +{ + "name": "@ilia/carousel", + "version": "0.0.0", + "type": "module", + "sideEffects": ["*.css"], + "packageManager": "yarn@4.11.0", + "publishConfig": { + "registry": "https://git.allmy.work/api/packages/ilia/npm/" + }, + "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", + "check": "tsc --noEmit", + "lint": "biome check .", + "format": "biome format --write .", + "test": "vitest run", + "test:coverage": "vitest run --coverage", + "test:e2e": "playwright test", + "size": "size-limit" + }, + "devDependencies": { + "@biomejs/biome": "^2", + "@playwright/test": "^1", + "@size-limit/preset-small-lib": "^11", + "@vitest/coverage-v8": "^2", + "jsdom": "^25", + "lefthook": "^1", + "size-limit": "^11", + "tsup": "^8", + "typescript": "^5", + "vitest": "^2" + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/src/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d38c4e0 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "verbatimModuleSyntax": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "noEmit": true, + "skipLibCheck": true + }, + "include": ["src", "tests"] +} diff --git a/tsup.config.ts b/tsup.config.ts new file mode 100644 index 0000000..9c84cf6 --- /dev/null +++ b/tsup.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts', 'src/dots.ts', 'src/autoplay.ts'], + format: 'esm', + dts: true, + clean: true, + // unminified on purpose — consumer bundlers minify. size-limit measures the + // minified+gzipped size for budgeting. +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..9b61fbc --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'jsdom', + include: ['tests/**/*.test.ts'], + coverage: { + provider: 'v8', + include: ['src/**/*.ts'], + thresholds: { lines: 90, branches: 90, functions: 90, statements: 90 }, + }, + }, +});