# 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."). - `