A private way to rate and share kinks with others
kinklist docs spec.md
36 kB
Markdown
at main

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.

  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) #

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, 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 (<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.hash matches ^#[a-zA-Z0-9]{3,10}$, fetch https://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 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 (#<imgurId>) 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.

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-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").
  • 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> 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://<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.
  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 #<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; 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: "<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:

  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 <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) #

  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) #


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)