Kinklist v3 — Specification #
A ground-up rewrite of 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.
- Tooling comes only from Nix. Every tool (node, pnpm, linters, formatters, browsers for e2e) is provided by
flake.nix's dev shell, entered automatically viadirenv(.envrccontainsuse flake). Do not rely on globally installed tools. Do not usenpxto fetch tools that aren't inpackage.json. - 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. - 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).
- Commit messages are a single line in Conventional Commits format. No body, no trailers (no
Co-Authored-By, noSigned-off-by), no blank lines. Enforced by acommit-msghook (§8.3). Examples:feat(ratings): add keyboard shortcuts for rating selectionfix(export): preserve non-ascii characters in embedded datatest(share): cover malformed share payloadsbuild(nix): add playwright browsers to dev shell
- Small, incremental commits. One logical change per commit. Follow the milestone order in §11; each milestone is several commits.
- The repo is the documentation. Keep
README.md(how to develop, how gates work) anddocs/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) #
interface Rating { name: string; color: string } // identity = name (!)
interface Kink { name: string; ratings: Record<string /*subcategory*/, string /*rating name*/>; 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
<canvas>(6 fixed columns × 250px, legend top-right, titleKinklist (v2.0)+ username) and downloadskinklist.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 (
<site>#<imgurId>) 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.hashmatches^#[a-zA-Z0-9]{3,10}$, fetchhttps://i.imgur.com/<id>.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 whenxreaches canvas width. Canvas height was extended byceil(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. addCategoryseeds the example kink withratings: { 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 Ratingand 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.
LegendDialogwatches a non-existentvalueprop.- 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 #
- Feature parity with v2 (§1.2), minus mandatory Imgur upload.
- Modern, calm, legible UI; excellent dark mode (system / light / dark, persisted).
- Mobile-first responsive layout with comfortable tap targets.
- Serverless sharing: state round-trips through a URL and through the exported PNG.
- Backward compatibility: open v2 Imgur links (
#<imgurId>) and v2 PNG files. - Accessibility: WCAG 2.2 AA; color is never the only carrier of meaning.
- Local autosave; nothing leaves the device unless the user explicitly shares.
- 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.
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<Id /*role id*/, Id /*rating id*/>; 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:
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-checkover 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), andrender/layout.tsis pure and runs in Node. Canvas drawing and DOM glue are thin and covered by e2e. - Codecs operate on bytes/
Uint8ClampedArrayRGBA 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 fromops.tsand pushes onto an undo stack (limit 100). Autosave tolocalStoragedebounced 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-schemelive), Light, Dark; toggle in the header; persisted. Setdata-themebefore first paint (inline script inindex.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
--borderso 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
radiogroupof swatches, eachradiowith 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 keys1…nselect 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
<dialog>withshowModal(): 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. GlobalCtrl/Cmd+Z/Shift+Ctrl/Cmd+Zundo/redo when focus isn't in a text field.
6.7 Share sheet #
One dialog with:
- Copy link —
https://<site>/#/s/<payload>(§4.1). Shows character length; warns (not blocks) above 8 000 chars that some chat apps truncate long links, suggesting the PNG instead. - 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).
- Download data —
kinklist.json(v3 doc). - 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
#<imgurId>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/<id>.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.").
<noscript>message; sensible<title>, meta description, favicon, theme-color meta per theme;robotsallowed.- 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:
"<title>'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:
- An
iTXtchunk, keywordkinklist, compressed, containing the v3 wire JSON (png-chunks.ts: parse/insert chunks with correct CRC32; pure, unit-tested on byte fixtures). - 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; envPLAYWRIGHT_BROWSERS_PATH=${pkgs.playwright-driver.browsers}andPLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1;shellHookinstalls git hooks (from git-hooks.nix) and runspnpm install --frozen-lockfileifnode_modulesis stale.- Gotcha: the
@playwright/testversion inpackage.jsonMUST exactly match nixpkgs'playwright-driver.version. Add a unit test (or a check script) that asserts this and fails with a clear message.
- Gotcha: the
packages.default: the static site built withstdenv.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 (viawriteShellApplication, exposed onPATHin 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, andvitest related --runfor staged sources.pre-push: fullcheck.commit-msg: custom hook rejecting anything that isn't exactly one non-empty line matching:
Comment lines (^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-z0-9][a-z0-9-]*\))?!?: \S.{0,99}$#…) 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; onsrc/**overall ≥ 85% lines. Thresholds enforced invitest.config.ts. - ESLint: zero warnings (
--max-warnings 0);@typescript-eslint/strict-type-checked+stylistic-type-checked; noany, 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: …thenfix: …, or both in thefixcommit). - 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
<meta http-equiv>: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) #
- 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),
checkscript, CI, README. A trivial smoke test at each layer. - Model — types, zod schema, defaults (Appendix A), pure ops with invariants, stats/progress. Full unit + property tests.
- 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.
- State — store with undo/redo, settings, localStorage persistence + recovery.
- Design system — tokens, light/dark/system theme with no-flash script, base components (Button, IconButton, Dialog, Menu, Toast, Input, Tooltip), axe-clean.
- Fill mode UI — header, legend bar, category grid, kink rows, RatingPicker, comments, progress, search/filters, collapse, responsive/mobile.
- Edit mode — CRUD, inline rename, roles dialog, ratings editor, reorder (pointer + keyboard), undo toasts.
- Export — layout engine + snapshots, canvas renderer, themes, footnotes, embedding, download.
- Share & import — share sheet (link, image, JSON, text), import dialog, drag-drop, URL routes, legacy Imgur route.
- Polish — About, first-run hint, print CSS, CSP, bundle budget, visual baselines, README screenshots, Pages deploy.
12. Acceptance criteria (definition of done) #
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").
[
{"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 <dialog>) |
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) |