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
-
-
-
-
-```
-
-**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 },
+ },
+ },
+});