# Kinklist v3 — Specification
A ground-up rewrite of [Goctionni/kinklist-v2](https://github.com/Goctionni/kinklist-v2) (live: https://goctionni.github.io/kinklist-v2/) with a modern UI, first-class dark mode, mobile support, no third-party upload dependency, and a strictly gated, fully tested codebase.
This document is the single source of truth for the implementing agent. Where it says **MUST**, it is a requirement; **SHOULD** is a strong default that can be deviated from with a written reason in `docs/decisions.md`.
---
## 0. Ground rules for the implementing agent (read first)
These apply to every single change, from the very first commit.
1. **Tooling comes only from Nix.** Every tool (node, pnpm, linters, formatters, browsers for e2e) is provided by `flake.nix`'s dev shell, entered automatically via `direnv` (`.envrc` contains `use flake`). Do not rely on globally installed tools. Do not use `npx` to fetch tools that aren't in `package.json`.
2. **Every commit passes all quality gates.** Before each commit run `check` (defined in §8). A commit that fails any gate MUST NOT be made. Never use `--no-verify`.
3. **All functionality requires tests.** No feature, bug fix or behavior change lands without a test that fails without it. Pure logic → unit tests; components → component tests; user flows → e2e tests (see §9).
4. **Commit messages are a single line in Conventional Commits format.** No body, no trailers (no `Co-Authored-By`, no `Signed-off-by`), no blank lines. Enforced by a `commit-msg` hook (§8.3). Examples:
- `feat(ratings): add keyboard shortcuts for rating selection`
- `fix(export): preserve non-ascii characters in embedded data`
- `test(share): cover malformed share payloads`
- `build(nix): add playwright browsers to dev shell`
5. **Small, incremental commits.** One logical change per commit. Follow the milestone order in §11; each milestone is several commits.
6. **The repo is the documentation.** Keep `README.md` (how to develop, how gates work) and `docs/decisions.md` (short ADR-style log of non-obvious choices) current.
---
## 1. What the original does (reference behavior)
Studied from the source (Vue 2 + class components, ~1.5k LOC) and the live site.
### 1.1 Domain model (v2)
```ts
interface Rating { name: string; color: string } // identity = name (!)
interface Kink { name: string; ratings: Record ; comment?: string }
interface KinkCategory { name: string; subcategories: string[]; kinks: Kink[] }
```
- A **category** (e.g. "Pain") has one or more **subcategories** — really *roles/perspectives* (e.g. "Giving"/"Receiving", "Self"/"Partner", or just "General").
- Each **kink** in a category gets one rating **per subcategory**.
- **Ratings** form a user-editable legend. Defaults (chosen upstream to be colorblind-distinguishable):
| Name | Color |
|-------------|-----------|
| Not Entered | `#FFFFFF` |
| Favorite | `#6DB5FE` |
| Like | `#23FD22` |
| Okay | `#FDFD6B` |
| Maybe | `#DB6C00` |
| No | `#920000` |
The first rating is the implicit "unset" value.
- Default content: 16 categories, 116 kinks (Appendix A, verbatim).
### 1.2 Features
- Username field (shown in exported image title).
- Rate every kink × subcategory via a row of colored radio dots.
- Per-kink free-text comment (dialog).
- Edit mode is always on: add/rename/delete categories, kinks, subcategories (category dialog), add/rename/recolor/delete ratings (with colorblindness warning).
- **Download**: renders a PNG on a `` (6 fixed columns × 250px, legend top-right, title `Kinklist (v2.0)` + username) and downloads `kinklist.png`.
- **Export**: same PNG uploaded anonymously to **Imgur** with a client ID hard-coded in source; shows a dialog with the Imgur URL, a "view" URL (`#`) and a BBCode snippet.
- **Data embedding ("Encode data")**: the full JSON `{username, categories, ratings}` is steganographically written into extra pixel rows at the bottom of the PNG so the view URL can reconstruct the list, including comments (comments are *not* drawn in the image, only a comment icon).
- **Import**: on load, if `location.hash` matches `^#[a-zA-Z0-9]{3,10}$`, fetch `https://i.imgur.com/.png`, decode embedded data, populate state.
- Options dropdown: Dark mode checkbox (hidden away), Encode data checkbox, About, Imgur Client ID.
### 1.3 Legacy pixel encoding (MUST be decodable by v3)
Writing (v2):
- `data = JSON.stringify({ username, categories, ratings })` (categories without ids).
- Each **UTF-16 code unit** `n = data.charCodeAt(i)` becomes one pixel:
`r = 254 - (n & 0b111)`, `g = 254 - ((n >> 3) & 0b111)`, `b = 254 - ((n >> 6) & 0b11)`.
(Only 8 bits are kept → any char > U+00FF is corrupted. This is a v2 bug.)
- Pixels are written starting at `(x=0, y=height-1)`, left→right, then moving **up** a row when `x` reaches canvas width. Canvas height was extended by `ceil(len / width)` rows; the remainder is white.
Reading (v2): scan from the bottom row upward, left→right; stop at the first pure white pixel `(255,255,255)`; per pixel `byte = (254-r) + ((254-g) << 3) + ((254-b) << 6)`; `String.fromCharCode(byte)`; `JSON.parse` the result.
### 1.4 Problems to fix (observed live or in code)
UI/UX
- Dated look: hard grey background, bordered square icon buttons on every card, dense radio dots with no labels, Arial everywhere, no visual hierarchy.
- Dark mode exists but is buried in an options dropdown, is pure black, desaturates the rating colors, is **not persisted** and ignores `prefers-color-scheme`.
- Mobile: header stacks into a tall column, legend overflows horizontally, rating dots are tiny tap targets (~16px).
- No progress indication, no search/filter, no way to collapse categories, no undo for destructive actions (delete category is one click, no confirm).
- Edit controls (+ ✎ ✕ on every category, edit/delete on every kink) are always visible, cluttering what is mostly a *fill-in* task.
- No persistence: reload loses everything.
Accessibility
- Dialogs: no focus trap, **Escape does not close them** (verified), no `aria-modal`.
- Rating radios have no accessible names (only color communicates meaning) — at odds with the upstream's own colorblindness concern.
- Tooltips implemented as visually hidden spans; icon-only buttons.
Bugs
- Exported title concatenates without a separator: `Kinklist (v2.0)` + `alice` → `Kinklist (v2.0)alice`.
- `addCategory` seeds the example kink with `ratings: { General: … }` regardless of the chosen subcategories.
- Ratings are identified by **name**: renaming a rating silently resets every selection of it to the first rating.
- Deleting all ratings (or the first) breaks the app; image generation does `ratings.find(...) as Rating` and crashes on unknown names.
- Categories are keyed by **name** in the list render; duplicate names break rendering.
- Renaming a kink to an empty string silently deletes it.
- Non-Latin-1 characters (emoji, accented-beyond-Latin-1, CJK) in comments/names are corrupted by the pixel encoding.
- `LegendDialog` watches a non-existent `value` prop.
- Upload depends on a shared hard-coded Imgur client ID (rate-limited, can be revoked, third-party content policy risk for adult content).
### 1.5 Original `todo.md`
`about page`, `reorder categories`, `reorder kinks`, `darkmode` — all in scope for v3.
---
## 2. Goals and non-goals
### Goals
1. Feature parity with v2 (§1.2), minus mandatory Imgur upload.
2. Modern, calm, legible UI; excellent **dark mode** (system / light / dark, persisted).
3. Mobile-first responsive layout with comfortable tap targets.
4. Serverless sharing: state round-trips through a URL and through the exported PNG.
5. **Backward compatibility**: open v2 Imgur links (`#`) and v2 PNG files.
6. Accessibility: WCAG 2.2 AA; color is never the only carrier of meaning.
7. Local autosave; nothing leaves the device unless the user explicitly shares.
8. Reproducible, gated, test-first engineering (§0, §8, §9).
### Non-goals (v3.0)
- Accounts, backend, database, analytics, telemetry of any kind.
- Uploading to Imgur or any other host (users download the PNG and post it themselves).
- Comparing two people's lists (good v3.x candidate; keep the data model friendly to it).
- i18n of the UI (keep strings centralized in one module so it's easy later).
---
## 3. Tech stack
| Concern | Choice |
|---|---|
| Environment | **Nix flake** (`flake-parts`), **direnv** (`use flake`), **git** |
| Language | TypeScript, `strict: true`, `noUncheckedIndexedAccess: true` |
| UI framework | **Svelte 5** (runes) + **Vite** — static SPA, no SSR, no SvelteKit |
| Styling | **Tailwind CSS v4** with CSS-variable design tokens (§6) |
| Package manager | **pnpm** (from nixpkgs), lockfile committed |
| Schema/validation | **zod** (all external input: URL, file, localStorage, legacy decode) |
| Compression | **fflate** (sync, works in Node tests and browser) |
| Drag & drop reorder | `svelte-dnd-action` or a small hand-rolled pointer+keyboard implementation (must be keyboard-accessible either way) |
| Unit/component tests | **Vitest** + `@testing-library/svelte` + `jsdom`, coverage via `@vitest/coverage-v8` |
| E2E | **Playwright** (`@playwright/test`), Chromium + Firefox + WebKit, browsers from nixpkgs |
| Lint | ESLint (flat config) + `typescript-eslint` (type-checked) + `eslint-plugin-svelte`; `svelte-check`; `statix` + `deadnix` for Nix |
| Format | Prettier (+ `prettier-plugin-svelte`, `prettier-plugin-tailwindcss`); `nixfmt` (RFC style) for Nix |
| Git hooks | `git-hooks.nix` (cachix) wired into the flake; installed by the dev shell |
| CI/CD | GitHub Actions running the same Nix commands; deploy `dist/` to GitHub Pages |
Use the latest stable releases at implementation time; pin everything via `flake.lock` and `pnpm-lock.yaml`. Record any deviation from this table in `docs/decisions.md`.
---
## 4. Data model (v3)
Located in `src/lib/model/`. Plain serializable objects; all mutations go through pure functions (§5) so they're unit-testable.
```ts
type Id = string; // nanoid-style, generated by an injectable id generator (tests use a deterministic one)
interface Rating { id: Id; name: string; color: string /* #RRGGBB */ }
interface Kink { id: Id; name: string; ratings: Record ; comment?: string }
interface Role { id: Id; name: string } // v2 "subcategory"
interface Category { id: Id; name: string; roles: Role[]; kinks: Kink[]; collapsed?: boolean }
interface KinklistDoc {
v: 3; // schema version
title: string; // v2 "username"
ratings: Rating[]; // ratings[0] is the "unset" rating; invariant: length >= 2
categories: Category[];
updatedAt: string; // ISO timestamp
}
```
Invariants (enforced by operations and by the zod schema; each needs tests):
- Every kink has exactly one rating entry per role of its category, and every rating id exists.
- Removing a rating reassigns its selections to `ratings[0]`; `ratings[0]` cannot be deleted while it is the only unset rating (UI disables it); minimum 2 ratings.
- Renaming a rating or role **never** changes selections (identity is by id).
- Category/role/kink names: trimmed, 1–80 chars; comments ≤ 2000 chars; title ≤ 60 chars. Duplicate names are allowed (identity is id).
- A category has 1–4 roles.
Separate from the document, `Settings` (theme, embed-data toggle, view filters) persisted independently; settings are never part of shared payloads.
### 4.1 Wire format (shared/embedded)
Ids are stripped and replaced with indices for compactness:
```ts
interface WireV3 {
v: 3;
t: string; // title
r: [name: string, color: string][]; // ratings
c: { n: string; s: string[]; k: [name: string, sel: number[] /* rating index per role */, comment?: string][] }[];
}
```
- Encode: `doc → WireV3 → JSON → UTF-8 → deflate-raw (fflate) → base64url`.
- Decode: reverse, then validate with zod, then hydrate with fresh ids. Reject payloads > 64 KiB decompressed.
- Unit tests MUST cover: round-trip equality (property-based with `fast-check` over generated docs, including emoji/CJK/RTL text), corrupted base64, corrupted deflate, valid JSON failing schema, oversized payload, out-of-range rating indices.
### 4.2 Migration from v2
`fromV2(json: unknown): KinklistDoc` maps `{username, categories[{name, subcategories, kinks[{name, ratings{sub→ratingName}, comment}]}], ratings[{name,color}]}` to v3:
- Ratings get ids; colors normalized to `#RRGGBB` (`#FFF` → `#FFFFFF`).
- Unknown rating names in selections → `ratings[0]`; missing role entries → `ratings[0]`.
- Fixtures: `tests/fixtures/v2/*.json` (default list, list with comments, list with custom ratings, list with a missing rating name).
---
## 5. Architecture
```
src/
main.ts
App.svelte
lib/
model/ types.ts, schema.ts (zod), defaults.ts, ops.ts (pure mutations), stats.ts (progress)
io/ wire.ts (v3 encode/decode), legacyV2.ts (fromV2), share-url.ts,
png-chunks.ts (read/write iTXt), pixel-codec.ts (legacy + v3 pixel strip),
import.ts (orchestrates: url | file | imgur-legacy), json-file.ts
render/ layout.ts (pure: doc → list of draw commands), canvas.ts (draw commands → canvas),
export.ts (orchestrates layout + canvas + embedding → Blob)
state/ store.svelte.ts (doc store + undo/redo history), settings.svelte.ts, persistence.ts
ui/ components (Button, IconButton, Dialog, Menu, Toast, RatingPicker, …)
features/ header/, category/, kink/, legend/, share/, import/, about/
strings.ts every user-facing string
styles/app.css Tailwind entry + design tokens
tests/
unit/ component/ e2e/ fixtures/
```
Principles:
- **Pure core, thin shell.** Everything in `model/`, `io/` (except fetch/File glue), and `render/layout.ts` is pure and runs in Node. Canvas drawing and DOM glue are thin and covered by e2e.
- **Codecs operate on bytes/`Uint8ClampedArray` RGBA buffers**, not on canvases, so they're unit-testable without a browser.
- State: a single Svelte 5 runes store holding `KinklistDoc`; every mutation calls a pure op from `ops.ts` and pushes onto an undo stack (limit 100). Autosave to `localStorage` debounced 300 ms; load validates with zod and falls back to defaults on failure (and tells the user).
- No global singletons except the store; dependencies (id generator, clock, `fetch`, storage) injected for testability.
---
## 6. UI / UX specification
### 6.1 Visual direction
Calm, modern, content-first. Think a well-made form app, not a dashboard.
- Typography: system UI stack or a self-hosted variable font (e.g. Inter) — **no external font CDN** (privacy). Clear scale: page title, category title, kink name, role label, meta.
- Surfaces: soft neutral background, cards with subtle border + small radius (~12px), no heavy shadows. Generous spacing on a 4px grid.
- Icons: a single consistent line icon set (e.g. Lucide via `lucide-svelte`), always paired with an accessible label or tooltip.
- Motion: short (120–200 ms) transitions; respect `prefers-reduced-motion`.
### 6.2 Theming
- Tokens as CSS variables on `:root` (`--bg`, `--surface`, `--surface-2`, `--border`, `--text`, `--text-muted`, `--accent`, `--focus-ring`, `--danger`), overridden under `[data-theme="dark"]`.
- Theme setting: **System** (default, follows `prefers-color-scheme` live), **Light**, **Dark**; toggle in the header; persisted. Set `data-theme` before first paint (inline script in `index.html`) to avoid a flash.
- Dark theme is a dark grey (not pure black), with rating colors shown **at full fidelity** — rating swatches get a 1px ring using `--border` so white/yellow/dark-red swatches stay visible on both themes.
- All text/UI contrast ≥ WCAG AA in both themes (tested, §9).
### 6.3 Layout
- **Header (sticky, compact):** app name/logo · title input ("Your name", inline-editable) · progress pill (`37 / 160 rated`) · search · theme toggle · **Share** (primary button, opens share sheet) · overflow menu (Import, Edit list, Reset, About).
- **Legend bar (sticky under header):** rating chips (swatch + name). In Edit mode, a "Customize ratings" action appears.
- **Content:** masonry of category cards via CSS multi-column (1 col < 640px, 2 ≥ 640, 3 ≥ 1024, 4 ≥ 1440); cards don't break across columns. Card order in the grid is stable reading order.
- **Category card:** title, role names as a muted subtitle, collapse chevron, per-card progress (`4/6`). Body: kink rows.
- **Kink row:** name on the left; one **RatingPicker** per role on the right (role label above or left of it when >1 role); comment button (filled icon when a comment exists, comment preview on hover/focus).
- **RatingPicker:** an ARIA `radiogroup` of swatches, each `radio` with an accessible name (`"Favorite — Giving"`), tooltip with the rating name, 28px visual size, ≥ 44×44 px hit area on touch (`@media (pointer: coarse)`). Selected state shown by **shape and color**: enlarged swatch + check mark/ring — never color alone. Arrow keys move within the group (roving tabindex); number keys `1…n` select the nth rating while focused.
- **Mobile (< 640px):** header collapses to name + Share + menu; search and progress move into a toolbar row; kink rows stack (name above pickers); legend bar scrolls horizontally with snap and fade edges.
### 6.4 Modes
- **Fill mode (default):** only rating, comments, title, search/filter. No add/delete clutter.
- **Edit mode** (toggle in menu; visual banner "Editing list — Done"): reveals add category, add kink, rename (inline, Enter/blur commits, Escape cancels), delete (with Undo toast, no confirm dialog), drag handles to reorder categories and kinks (also reorderable via keyboard: focus handle, Space to lift, arrows to move, Space to drop, Escape to cancel, with live-region announcements), edit roles in a category dialog, customize ratings dialog (name + native color picker + hex field, add/remove/reorder, colorblindness note kept from v2, "Reset to defaults").
### 6.5 Filters & search
- Search box filters kinks by name (case/diacritic-insensitive), hides empty categories, highlights matches.
- Quick filters: **All**, **Unrated** (any role still at `ratings[0]`), **Has comment**.
- Expand all / collapse all.
### 6.6 Dialogs, toasts
- Built on native `` with `showModal()`: focus trapped, **Escape closes**, focus returns to the trigger, backdrop click closes non-destructive dialogs.
- Toasts in an `aria-live="polite"` region; destructive actions show "Deleted X — Undo" for 6 s. Global `Ctrl/Cmd+Z` / `Shift+Ctrl/Cmd+Z` undo/redo when focus isn't in a text field.
### 6.7 Share sheet
One dialog with:
1. **Copy link** — `https:///#/s/` (§4.1). Shows character length; warns (not blocks) above 8 000 chars that some chat apps truncate long links, suggesting the PNG instead.
2. **Download image** — PNG (§7). Options: image theme (Light/Dark, defaults to current theme), include comments as footnotes (on/off), embed data (on by default; explains that the image can be re-imported).
3. **Download data** — `kinklist.json` (v3 doc).
4. **Copy as text** — Markdown summary grouped by rating (useful for chats).
### 6.8 Import
- Entry points: menu → Import (file picker), **drag-and-drop a PNG or JSON anywhere** on the page, paste a share link into the import dialog, open a share URL, open a legacy `#` URL.
- Accepted: v3 PNG (iTXt chunk, else v3/v2 pixel strip), v2 PNG (pixel strip), v3 JSON, v2 JSON.
- If the current list has any ratings/comments, ask: **Replace current list** or **Cancel** (and offer "Download current first").
- After loading from a URL, clear the hash (`history.replaceState`) so a reload doesn't re-import over edits; autosave takes over.
- Legacy Imgur: `GET https://i.imgur.com/.png` → decode pixel strip. Loading screen with a skeleton; clear error state with "Start fresh" if the image is gone or has no data. This is the **only** network request the app ever makes, and only when such a URL is opened.
### 6.9 Other
- About dialog: credits the original by Goctionni with links (MIT), version, link to this repo, privacy note ("Your list never leaves your device unless you share it").
- Reset: confirm dialog (this is the one destructive action without undo history surviving reload) → defaults.
- First-run: no modal tour; a single dismissible hint card above the grid ("Tap a color to rate… Your progress saves automatically.").
- `` message; sensible ``, meta description, favicon, theme-color meta per theme; `robots` allowed.
- Print stylesheet: hides chrome, prints the grid legibly.
---
## 7. PNG export
### 7.1 Layout (`render/layout.ts`, pure)
Produce a list of draw commands (`text`, `circle`, `rect`, `icon`) with absolute coordinates for a given doc + options. Requirements:
- Width adapts: 4–6 columns of ~260 px chosen to minimize total height for the content; greedy column balancing like v2 (category goes to next column when `colHeight + catHeight/2 > total/cols`), tested against fixtures.
- Header: `"'s Kinklist"` (or `"Kinklist"` if title empty), legend on the right (wraps to a second line if needed), small footer with the site URL and date.
- Category: name (bold), roles (italic, muted) when > 1 role, rows with one swatch per role + kink name; comment marker `[n]` when footnotes are on.
- Footnotes section (optional): `[n] Category / Kink: comment`, word-wrapped to column width.
- Themes: light and dark palettes; rating swatches always drawn with a contrasting 1px stroke.
- Device pixel ratio 2 for crisp output.
Layout is snapshot-tested as JSON (positions rounded) for: default list, long names (ellipsis/wrap), 1-role-only list, 4-role category, comments with footnotes, RTL/emoji text.
### 7.2 Rendering (`render/canvas.ts`)
Executes draw commands on an `OffscreenCanvas`/`HTMLCanvasElement`, using the app's font (ensure `document.fonts.ready` before drawing). Covered by e2e (non-blank output, expected dimensions) and a Playwright visual snapshot per theme.
### 7.3 Embedding
When "embed data" is on, write **both**:
1. An `iTXt` chunk, keyword `kinklist`, compressed, containing the v3 wire JSON (`png-chunks.ts`: parse/insert chunks with correct CRC32; pure, unit-tested on byte fixtures).
2. A **pixel strip** using the v2 3-3-2 scheme (§1.3) but over the **UTF-8 bytes** of the v3 wire JSON, appended as extra rows at the bottom, terminated by a white pixel. (Survives hosts that strip metadata but re-encode losslessly.)
Decoding order: iTXt chunk → pixel strip. The pixel decoder reads bytes, decodes as UTF-8 (falling back to Latin-1 for v2 images), parses JSON, then dispatches on `v` (`3` → `fromWire`, absent → `fromV2`).
Tests MUST include: a **real v2 image fixture** produced by the original algorithm (write a test helper that ports v2's `getDataPixels` verbatim to generate RGBA buffers, plus at least one actual PNG exported from the live v2 site committed under `tests/fixtures/v2/`), v3 round-trip with emoji/CJK comments, missing terminator, truncated strip, chunk with bad CRC (ignored, falls back to pixels).
---
## 8. Repository, Nix, and quality gates
### 8.1 Layout
```
flake.nix flake.lock .envrc .gitignore .editorconfig
package.json pnpm-lock.yaml tsconfig.json vite.config.ts vitest.config.ts playwright.config.ts
eslint.config.js .prettierrc .prettierignore
index.html public/ src/ tests/ docs/decisions.md README.md LICENSE SPEC.md
.github/workflows/ci.yml
```
`LICENSE`: MIT, including the original copyright notice (`Copyright (c) 2022 Goctionni`) alongside the new author's, since default content and the legacy codec are derived from it.
### 8.2 `flake.nix`
Use `flake-parts`. Systems: `x86_64-linux`, `aarch64-linux`, `x86_64-darwin`, `aarch64-darwin`.
- `devShells.default`: `nodejs` (current LTS), `pnpm`, `nixfmt-rfc-style`, `statix`, `deadnix`, `playwright-driver.browsers`; env `PLAYWRIGHT_BROWSERS_PATH=${pkgs.playwright-driver.browsers}` and `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1`; `shellHook` installs git hooks (from git-hooks.nix) and runs `pnpm install --frozen-lockfile` if `node_modules` is stale.
- **Gotcha:** the `@playwright/test` version in `package.json` MUST exactly match nixpkgs' `playwright-driver.version`. Add a unit test (or a check script) that asserts this and fails with a clear message.
- `packages.default`: the static site built with `stdenv.mkDerivation` + nixpkgs' pnpm deps fetcher (`pnpm.fetchDeps`/`fetchPnpmDeps` + config hook, whichever the pinned nixpkgs provides), output = `dist/`.
- `checks`: `format`, `lint`, `typecheck`, `unit` (vitest with coverage thresholds), `build` (= `packages.default`), `nix-lint` (statix + deadnix + nixfmt --check), `pre-commit` (git-hooks.nix). E2E runs outside the Nix sandbox (needs a display-less browser run with network for the dev server; keep it as a dev-shell script and a CI step).
- `apps` / dev-shell scripts (via `writeShellApplication`, exposed on `PATH` in the shell):
- `check` — runs **everything**: format check, ESLint, svelte-check, Nix lints, Vitest with coverage, build, Playwright e2e. Exit non-zero on any failure. This is what the agent runs before every commit and what CI runs.
- `fmt` — format all files (Prettier + nixfmt).
- `dev` — Vite dev server.
### 8.3 Git hooks (git-hooks.nix)
- `pre-commit`: prettier (check, on staged files), eslint, svelte-check, nixfmt, statix, deadnix, and `vitest related --run` for staged sources.
- `pre-push`: full `check`.
- `commit-msg`: custom hook rejecting anything that isn't exactly one non-empty line matching:
```
^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-z0-9][a-z0-9-]*\))?!?: \S.{0,99}$
```
Comment lines (`#…`) from git's template are ignored; any other second line (including trailers) fails. The hook script itself has tests (bats or a Vitest test that spawns it) covering valid/invalid messages.
### 8.4 Thresholds
- Vitest coverage (v8) on `src/lib/**`: **≥ 95% lines/statements/functions, ≥ 90% branches**; on `src/**` overall ≥ 85% lines. Thresholds enforced in `vitest.config.ts`.
- ESLint: zero warnings (`--max-warnings 0`); `@typescript-eslint/strict-type-checked` + `stylistic-type-checked`; no `any`, no non-null assertions, no floating promises.
- TypeScript: zero errors in `svelte-check --fail-on-warnings`.
- Bundle budget: initial JS ≤ 80 kB gzip (checked by a script in `check`).
- Lighthouse-ish budget not required in CI, but e2e includes an axe scan (§9).
### 8.5 CI (`.github/workflows/ci.yml`)
On push and PR: install Nix (`cachix/install-nix-action` or `DeterminateSystems/nix-installer-action`), enable Magic Nix Cache or Cachix, `nix flake check`, then `nix develop -c check` (for e2e). Also validate every commit message in a PR range with the same commit-msg script. On `main`: `nix build`, deploy `result/` to GitHub Pages (`actions/deploy-pages`). Vite `base` configurable via env for the Pages subpath.
---
## 9. Testing requirements
"All functionality requires tests." Concretely:
| Layer | Tooling | What |
|---|---|---|
| Unit | Vitest (node env) | Every exported function in `model/`, `io/`, `render/layout.ts`, `state/` (store ops, undo/redo, persistence with a fake storage), `strings.ts` completeness. Property-based (`fast-check`) for codecs and ops invariants. |
| Component | Vitest + Testing Library (jsdom) | RatingPicker (keyboard nav, number keys, aria names, selected state), Dialog (Escape, focus return), KinkRow, CategoryCard (collapse, progress), Legend editor, Share sheet, Import dialog, Toast/Undo, theme toggle. Query by role/label, not by class. |
| E2E | Playwright (Chromium, Firefox, WebKit; desktop + Pixel 7 + iPhone 14 viewports) | Rate → reload → persisted. Theme switch persisted + no flash. Edit mode CRUD + reorder (mouse and keyboard). Search/filters. Share link round-trip in a fresh context. PNG download → re-import via drag-drop round-trip. **v2 PNG fixture import.** Legacy `#imgurId` route with `page.route` mocking `i.imgur.com` (no real network in tests). Undo/redo. Escape closes dialogs. |
| Accessibility | `@axe-core/playwright` | Zero violations on: main view (light + dark), edit mode, each dialog. |
| Visual | Playwright screenshots | Main view light/dark/mobile, exported PNG light/dark — Chromium only, committed baselines, updated deliberately. |
Rules:
- Bug fixes start with a failing test reproducing the bug (commit `test: …` then `fix: …`, or both in the `fix` commit).
- Tests are deterministic: injected id generator, fixed clock, no real network, no sleeps (use Playwright auto-waiting).
- Each v2 bug listed in §1.4 that is applicable to v3 has a regression test named after it.
---
## 10. Privacy & security
- No analytics, no third-party scripts/fonts/CDNs. Strict CSP via ` `: `default-src 'self'; img-src 'self' blob: data: https://i.imgur.com; connect-src 'self' https://i.imgur.com; style-src 'self' 'unsafe-inline'` (tighten if Tailwind build allows), `object-src 'none'`, `base-uri 'none'`.
- All imported data validated by zod; rendered as text only (never `{@html}` with user content).
- Share payloads live in the URL **fragment** (never sent to servers). Mention this in the About dialog and Share sheet.
- Size limits on imported files (PNG ≤ 20 MB, JSON ≤ 1 MB) and decompressed payloads (64 KiB).
---
## 11. Milestones (implement in order; each ends green on `check`)
1. **Scaffold** — flake + direnv + dev shell, pnpm/Vite/Svelte/TS/Tailwind, ESLint/Prettier/svelte-check, Vitest + Playwright wired, git hooks incl. commit-msg (with its tests), `check` script, CI, README. A trivial smoke test at each layer.
2. **Model** — types, zod schema, defaults (Appendix A), pure ops with invariants, stats/progress. Full unit + property tests.
3. **IO core** — wire v3 codec, v2 migration, pixel codec (legacy + v3), PNG iTXt chunks, share-URL parse/build, JSON file import/export. Fixtures incl. a real v2 PNG.
4. **State** — store with undo/redo, settings, localStorage persistence + recovery.
5. **Design system** — tokens, light/dark/system theme with no-flash script, base components (Button, IconButton, Dialog, Menu, Toast, Input, Tooltip), axe-clean.
6. **Fill mode UI** — header, legend bar, category grid, kink rows, RatingPicker, comments, progress, search/filters, collapse, responsive/mobile.
7. **Edit mode** — CRUD, inline rename, roles dialog, ratings editor, reorder (pointer + keyboard), undo toasts.
8. **Export** — layout engine + snapshots, canvas renderer, themes, footnotes, embedding, download.
9. **Share & import** — share sheet (link, image, JSON, text), import dialog, drag-drop, URL routes, legacy Imgur route.
10. **Polish** — About, first-run hint, print CSS, CSP, bundle budget, visual baselines, README screenshots, Pages deploy.
---
## 12. Acceptance criteria (definition of done)
- [ ] `direnv allow` in a fresh clone yields a working shell; `check` passes; `nix flake check` passes; `nix build` produces the site.
- [ ] Every commit on `main` is a single-line Conventional Commit and passed the hooks.
- [ ] Coverage thresholds met; axe reports zero violations; all e2e green on 3 engines.
- [ ] Opening a v2 Imgur link (mocked in tests) and dropping a v2 PNG both restore the list correctly.
- [ ] A list with emoji/CJK comments survives: share link round-trip, PNG round-trip (both chunk and pixel-strip paths).
- [ ] Theme follows system by default, toggles to light/dark, persists, no flash on load.
- [ ] Usable one-handed on a 390 px wide phone: no horizontal page scroll, ≥ 44 px targets.
- [ ] All applicable §1.4 bugs fixed with regression tests.
- [ ] The app makes zero network requests in normal use (verified in an e2e test via request interception), except the legacy Imgur route.
---
## Appendix A — Default content (verbatim from v2)
Each entry: category name, roles (v2 "subcategories"), kinks in order. All selections start at `ratings[0]` ("Not Entered").
```json
[
{"name": "Bodies", "subcategories": ["Self", "Partner"], "kinks": ["Skinny", "Chubby", "Small breasts", "Large breasts", "Small cocks", "Large cocks"]},
{"name": "Clothing", "subcategories": ["Self", "Partner"], "kinks": ["Clothed sex", "Lingerie", "Stockings", "Heels", "Leather", "Latex", "Uniform / costume", "Cross-dressing"]},
{"name": "Groupings", "subcategories": ["General"], "kinks": ["You and 1 male", "You and 1 female", "You and trans female", "You and trans male", "You and 1 male, 1 female", "You and 2 males", "You and 2 females", "Orgy"]},
{"name": "General", "subcategories": ["Giving", "Receiving"], "kinks": ["Romance / Affection", "Handjob / fingering", "Blowjob", "Deep throat", "Swallowing", "Facials", "Cunnilingus", "Face-sitting", "Edging", "Teasing", "JOI, SI"]},
{"name": "Ass play", "subcategories": ["Giving", "Receiving"], "kinks": ["Anal toys", "Anal sex, pegging", "Rimming", "Double penetration", "Anal fisting"]},
{"name": "Restrictive", "subcategories": ["Self", "Partner"], "kinks": ["Gag", "Collar", "Leash", "Chastity", "Bondage (Light)", "Bondage (Heavy)", "Encasement"]},
{"name": "Toys", "subcategories": ["Self", "Partner"], "kinks": ["Dildos", "Plugs", "Vibrators", "Sounding"]},
{"name": "Domination", "subcategories": ["Dominant", "Submissive"], "kinks": ["Dominant / Submissive", "Domestic servitude", "Slavery", "Pet play", "DD/lg, MD/lb", "Discipline", "Begging", "Forced orgasm", "Orgasm control", "Orgasm denial", "Power exchange"]},
{"name": "No consent", "subcategories": ["Aggressor", "Target"], "kinks": ["Non-con / rape", "Blackmail / coercion", "Kidnapping", "Drugs", "Alcohol", "Sleep play"]},
{"name": "Taboo", "subcategories": ["General"], "kinks": ["Incest", "Age gap", "Raceplay", "Bestiality", "Necrophilia", "Cheating", "Exhibitionism", "Voyeurism"]},
{"name": "Surrealism", "subcategories": ["Self", "Partner"], "kinks": ["Futanari", "Furry", "Vore", "Transformation", "Tentacles", "Monster or Alien"]},
{"name": "Fluids", "subcategories": ["General"], "kinks": ["Blood", "Watersports", "Scat", "Lactation", "Diapers", "Cum play"]},
{"name": "Degradation", "subcategories": ["Giving", "Receiving"], "kinks": ["Glory hole", "Name calling", "Humiliation"]},
{"name": "Touch & Stimulation", "subcategories": ["Actor", "Subject"], "kinks": ["Cock/Pussy worship", "Ass worship", "Foot play", "Tickling", "Sensation play", "Electro stimulation"]},
{"name": "Misc. Fetish", "subcategories": ["Giving", "Receiving"], "kinks": ["Fisting", "Gangbang", "Breath play", "Impregnation", "Pregnancy", "Feminization", "Cuckold / Cuckquean"]},
{"name": "Pain", "subcategories": ["Giving", "Receiving"], "kinks": ["Light pain", "Heavy pain", "Nipple clamps", "Clothes pins", "Caning", "Flogging", "Beating", "Spanking", "Cock/Pussy slapping", "Cock/Pussy torture", "Hot Wax", "Scratching", "Biting", "Cutting"]}
]
```
## Appendix B — v2 source map (for reference)
| v2 file | Responsibility | v3 home |
|---|---|---|
| `src/App.vue` | state, column layout, export/import orchestration, CRUD | `state/`, `features/*` |
| `src/components/Kink.vue`, `Category.vue` | rows/cards, radio dots | `features/kink`, `features/category`, `ui/RatingPicker` |
| `src/components/Legend.vue`, `Dialogs/LegendDialog.vue` | legend + editor | `features/legend` |
| `src/components/Dialogs/*` | imperative `showDialog()` mounting | `ui/Dialog` (declarative, native ``) |
| `src/util/generateImage.ts` | canvas layout+draw+pixel encode in one | `render/layout.ts`, `render/canvas.ts`, `io/pixel-codec.ts` |
| `src/util/importFromImgur.ts` | fetch + pixel decode | `io/import.ts`, `io/pixel-codec.ts` |
| `src/util/uploadToImgur.ts`, `src/constants.ts` | Imgur upload, client ID | **dropped** |
| `src/data/default.ts` | default ratings/content | `model/defaults.ts` (Appendix A) |