Perplexed7675 commited on
Commit
f5aa134
·
verified ·
1 Parent(s): dfbc006

Sync from kink_cli (Docker Space)

Browse files
README.md CHANGED
@@ -147,13 +147,16 @@ python scripts/audit_starter_deck.py --store data/store_slim.db --limit 24
147
 
148
  ## Live deployment
149
 
150
- - **Hub:** [huggingface.co/spaces/ronheichman/kink-discovery](https://huggingface.co/spaces/ronheichman/kink-discovery)
151
- - **App URL:** [ronheichman-kink-discovery.hf.space](https://ronheichman-kink-discovery.hf.space) (same-origin API + static UI)
 
 
 
152
 
153
  Re-verify anytime:
154
 
155
  ```bash
156
- HF_VERIFY_BASE_URL=https://ronheichman-kink-discovery.hf.space python scripts/verify_hf_stack.py
157
  ```
158
 
159
  After **frontend** changes, bump the `?v=` query on `frontend/index.html` styles + `app.js` so browsers load the new bundle, then redeploy (command below).
@@ -166,7 +169,7 @@ After **frontend** changes, bump the `?v=` query on `frontend/index.html` styles
166
  ```bash
167
  # Either export credentials, or rely on parser secrets.toml + [huggingface].username → <username>/kink-discovery
168
  export HF_TOKEN=hf_...
169
- export HF_SPACE_REPO=yourname/your-space-name # e.g. ronheichman/kink-discovery
170
  python scripts/publish_hf_space.py --verify
171
  ```
172
 
 
147
 
148
  ## Live deployment
149
 
150
+ - **Hub:** [huggingface.co/spaces/Perplexed7675/kink-discovery](https://huggingface.co/spaces/Perplexed7675/kink-discovery)
151
+ - **App URL:** [perplexed7675-kink-discovery.hf.space](https://perplexed7675-kink-discovery.hf.space) (same-origin API + static UI)
152
+ - **Account persistence:** pick **one** option so user profiles survive restarts:
153
+ - **Paid:** enable HF Persistent Storage on the Space and set `KINK_STORE_PATH=/data/store_slim.db` + `KINK_FAIL_ON_EPHEMERAL_STORE=1`. Zero engineering, perfect durability.
154
+ - **Free (Hub-snapshot):** create a private HF Hub dataset (e.g. `Perplexed7675/kink-userstate`), then set Space variables `KINK_USER_SNAPSHOT_REPO=Perplexed7675/kink-userstate` and `HF_TOKEN=<write-token>`. The container then pulls the latest user-state snapshot on cold boot and pushes back every 60s when the user tables change (catalog stays on `/tmp`). Tunables: `KINK_USER_SNAPSHOT_FILENAME` (default `user_state.db`), `KINK_USER_SNAPSHOT_INTERVAL_S` (default 60). Trade-off: up to ~60s of writes can be lost on a hard crash; the Space must stay single-replica (cpu-basic is).
155
 
156
  Re-verify anytime:
157
 
158
  ```bash
159
+ HF_VERIFY_BASE_URL=https://perplexed7675-kink-discovery.hf.space python scripts/verify_hf_stack.py
160
  ```
161
 
162
  After **frontend** changes, bump the `?v=` query on `frontend/index.html` styles + `app.js` so browsers load the new bundle, then redeploy (command below).
 
169
  ```bash
170
  # Either export credentials, or rely on parser secrets.toml + [huggingface].username → <username>/kink-discovery
171
  export HF_TOKEN=hf_...
172
+ export HF_SPACE_REPO=yourname/your-space-name # e.g. Perplexed7675/kink-discovery
173
  python scripts/publish_hf_space.py --verify
174
  ```
175
 
api.py CHANGED
@@ -44,12 +44,17 @@ def _frontend_static_cache_control(target: Path) -> str:
44
 
45
 
46
  def _require_frontend_build() -> None:
47
- if FRONTEND_PATH.is_file():
48
- return
49
- raise HTTPException(
50
- status_code=503,
51
- detail="Frontend build missing. Run `npm ci && npm run build` before serving the app.",
52
- )
 
 
 
 
 
53
 
54
 
55
  def _hf_space_published_image() -> bool:
@@ -144,6 +149,7 @@ def _get_backend() -> Backend:
144
  ensure_media_cache(MEDIA_ROOT)
145
  path = ensure_store_db(_default_store)
146
  _warn_or_fail_ephemeral_store(path)
 
147
  b = Backend(path)
148
  # Catalog must be ready before the first recommendations request. Build it once here:
149
  # starting a warm thread and then blocking can double-build on slow cpu-basic Spaces.
@@ -170,12 +176,106 @@ class _BackendProxy:
170
  backend = _BackendProxy()
171
 
172
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  @asynccontextmanager
174
  async def lifespan(_app: FastAPI):
175
  """Begin Hub download + DB open in a worker thread so the server binds to PORT immediately (HF Spaces timeout)."""
176
  loop = asyncio.get_running_loop()
177
  loop.run_in_executor(None, _get_backend)
178
- yield
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
 
180
 
181
  app = FastAPI(title="Kink Discovery API", version="0.1.0", lifespan=lifespan)
 
44
 
45
 
46
  def _require_frontend_build() -> None:
47
+ if not FRONTEND_PATH.is_file():
48
+ raise HTTPException(
49
+ status_code=503,
50
+ detail="Frontend build missing. Run `npm ci && npm run build` before serving the app.",
51
+ )
52
+ assets_dir = FRONTEND_DIST_DIR / "assets"
53
+ if not assets_dir.is_dir() or not any(assets_dir.iterdir()):
54
+ raise HTTPException(
55
+ status_code=503,
56
+ detail="Frontend build incomplete (no bundled assets). Run `npm ci && npm run build` to refresh.",
57
+ )
58
 
59
 
60
  def _hf_space_published_image() -> bool:
 
149
  ensure_media_cache(MEDIA_ROOT)
150
  path = ensure_store_db(_default_store)
151
  _warn_or_fail_ephemeral_store(path)
152
+ _restore_user_snapshot_on_boot(path)
153
  b = Backend(path)
154
  # Catalog must be ready before the first recommendations request. Build it once here:
155
  # starting a warm thread and then blocking can double-build on slow cpu-basic Spaces.
 
176
  backend = _BackendProxy()
177
 
178
 
179
+ def _restore_user_snapshot_on_boot(store_path: Path) -> None:
180
+ """Pull the latest user-state snapshot from the configured Hub dataset when local user tables are empty.
181
+
182
+ No-op when KINK_USER_SNAPSHOT_REPO is unset. Failures are logged and swallowed so a Hub outage
183
+ cannot prevent the Space from booting (catalog still serves; periodic push will retry).
184
+ """
185
+ try:
186
+ from backend import user_snapshot, user_snapshot_hub
187
+ except ImportError:
188
+ return
189
+ if not user_snapshot_hub.snapshot_enabled():
190
+ return
191
+ try:
192
+ if not user_snapshot.user_state_is_empty(store_path):
193
+ return
194
+ tmp = store_path.parent / ".user_state_snapshot.db"
195
+ if not user_snapshot_hub.pull_user_snapshot(tmp):
196
+ return
197
+ counts = user_snapshot.restore_user_state(store_path, tmp)
198
+ try:
199
+ tmp.unlink()
200
+ except OSError:
201
+ pass
202
+ applied = sum(counts.values())
203
+ print(
204
+ f"[kink_cli] restored {applied} user-state rows from snapshot "
205
+ f"({user_snapshot_hub.snapshot_repo()}/{user_snapshot_hub.snapshot_filename()})"
206
+ )
207
+ except Exception as exc: # noqa: BLE001 — degrade gracefully on any restore failure
208
+ print(f"[kink_cli] user-state snapshot restore failed: {exc}")
209
+
210
+
211
+ async def _user_snapshot_flusher(store_path: Path, interval_s: float) -> None:
212
+ """Periodic push: when fingerprint changes, dump user tables and upload to Hub. Tolerant of transient failures."""
213
+ from backend import user_snapshot, user_snapshot_hub
214
+
215
+ last_fingerprint = user_snapshot.compute_user_state_fingerprint(store_path)
216
+ while True:
217
+ try:
218
+ await asyncio.sleep(interval_s)
219
+ fp = await asyncio.to_thread(user_snapshot.compute_user_state_fingerprint, store_path)
220
+ if fp == last_fingerprint:
221
+ continue
222
+ tmp = store_path.parent / ".user_state_snapshot_out.db"
223
+ await asyncio.to_thread(user_snapshot.dump_user_state, store_path, tmp)
224
+ sent = await asyncio.to_thread(user_snapshot_hub.push_user_snapshot, tmp)
225
+ try:
226
+ tmp.unlink()
227
+ except OSError:
228
+ pass
229
+ if sent:
230
+ last_fingerprint = fp
231
+ except asyncio.CancelledError:
232
+ raise
233
+ except Exception as exc: # noqa: BLE001 — never let the flusher die
234
+ print(f"[kink_cli] user-state snapshot flush failed: {exc}")
235
+
236
+
237
  @asynccontextmanager
238
  async def lifespan(_app: FastAPI):
239
  """Begin Hub download + DB open in a worker thread so the server binds to PORT immediately (HF Spaces timeout)."""
240
  loop = asyncio.get_running_loop()
241
  loop.run_in_executor(None, _get_backend)
242
+
243
+ flusher_task: asyncio.Task | None = None
244
+ try:
245
+ from backend import user_snapshot_hub
246
+ except ImportError:
247
+ user_snapshot_hub = None # type: ignore[assignment]
248
+ if user_snapshot_hub is not None and user_snapshot_hub.snapshot_enabled():
249
+ interval_s = float(os.environ.get("KINK_USER_SNAPSHOT_INTERVAL_S", "60") or "60")
250
+
251
+ async def _start_when_ready() -> None:
252
+ while _backend_impl is None:
253
+ await asyncio.sleep(1.0)
254
+ await _user_snapshot_flusher(_backend_impl.path, interval_s)
255
+
256
+ flusher_task = asyncio.create_task(_start_when_ready())
257
+
258
+ try:
259
+ yield
260
+ finally:
261
+ if flusher_task is not None:
262
+ flusher_task.cancel()
263
+ try:
264
+ await flusher_task
265
+ except (asyncio.CancelledError, Exception): # noqa: BLE001
266
+ pass
267
+ if _backend_impl is not None:
268
+ try:
269
+ from backend import user_snapshot, user_snapshot_hub as _hub
270
+ tmp = _backend_impl.path.parent / ".user_state_snapshot_shutdown.db"
271
+ user_snapshot.dump_user_state(_backend_impl.path, tmp)
272
+ _hub.push_user_snapshot(tmp, commit_message="user-state snapshot (shutdown)")
273
+ try:
274
+ tmp.unlink()
275
+ except OSError:
276
+ pass
277
+ except Exception as exc: # noqa: BLE001
278
+ print(f"[kink_cli] final user-state snapshot push failed: {exc}")
279
 
280
 
281
  app = FastAPI(title="Kink Discovery API", version="0.1.0", lifespan=lifespan)
backend/user_snapshot.py ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Snapshot of user-state tables only — the slim, durable subset of the SQLite store.
2
+
3
+ Used to keep accounts alive across HF Space restarts without paid persistent storage:
4
+ the live container reads/writes the catalog DB at ``KINK_STORE_PATH`` (ephemeral on
5
+ ``/tmp``), and a periodic snapshotter pushes only the user-state tables to a private
6
+ HF Hub dataset (or any pull/push pair) so a fresh container can restore them on boot.
7
+
8
+ Authoritative user-table list mirrors ``scripts/dev_reset_local.py`` so a wipe and a
9
+ snapshot/restore stay in sync. Catalog tables (``kink``, ``play_fts*``, ``fetlife*``,
10
+ ``similarityedge``, ``kinkscenarioparent``) are NOT included — they bootstrap from the
11
+ catalog seed/Hub dataset/B2 separately.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import sqlite3
16
+ from pathlib import Path
17
+
18
+ USER_TABLES: tuple[str, ...] = (
19
+ "user",
20
+ "userpreference",
21
+ "playpreference",
22
+ "rolepreference",
23
+ "scenariopreference",
24
+ "promptdismissal",
25
+ "partnerlink",
26
+ "partnerlinkrequest",
27
+ "partnergroup",
28
+ "partnergroupmember",
29
+ )
30
+
31
+
32
+ def _table_exists(conn: sqlite3.Connection, name: str) -> bool:
33
+ row = conn.execute(
34
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1",
35
+ (name,),
36
+ ).fetchone()
37
+ return row is not None
38
+
39
+
40
+ def _present_tables(conn: sqlite3.Connection) -> list[str]:
41
+ return [name for name in USER_TABLES if _table_exists(conn, name)]
42
+
43
+
44
+ def _table_create_sql(conn: sqlite3.Connection, name: str) -> str:
45
+ row = conn.execute(
46
+ "SELECT sql FROM sqlite_master WHERE type='table' AND name=? LIMIT 1",
47
+ (name,),
48
+ ).fetchone()
49
+ if not row or not row[0]:
50
+ raise RuntimeError(f"missing CREATE TABLE for {name!r} in source store")
51
+ return row[0]
52
+
53
+
54
+ def dump_user_state(store_path: Path, out_path: Path) -> dict[str, int]:
55
+ """Write a slim SQLite at ``out_path`` containing only USER_TABLES rows from ``store_path``.
56
+
57
+ Returns a mapping of table -> row count. Does not modify ``store_path``.
58
+ """
59
+ store_path = Path(store_path)
60
+ out_path = Path(out_path)
61
+ if not store_path.is_file():
62
+ raise FileNotFoundError(f"store does not exist: {store_path}")
63
+ if out_path.exists():
64
+ out_path.unlink()
65
+ out_path.parent.mkdir(parents=True, exist_ok=True)
66
+
67
+ src = sqlite3.connect(f"file:{store_path}?mode=ro", uri=True)
68
+ try:
69
+ present = _present_tables(src)
70
+ creates = {name: _table_create_sql(src, name) for name in present}
71
+
72
+ dst = sqlite3.connect(out_path)
73
+ try:
74
+ counts: dict[str, int] = {}
75
+ with dst:
76
+ dst.execute("PRAGMA journal_mode=OFF")
77
+ dst.execute("PRAGMA synchronous=OFF")
78
+ for name in present:
79
+ dst.execute(creates[name])
80
+ for name in present:
81
+ rows = list(src.execute(f'SELECT * FROM "{name}"'))
82
+ if not rows:
83
+ counts[name] = 0
84
+ continue
85
+ placeholders = ",".join(["?"] * len(rows[0]))
86
+ dst.executemany(
87
+ f'INSERT INTO "{name}" VALUES ({placeholders})',
88
+ rows,
89
+ )
90
+ counts[name] = len(rows)
91
+ return counts
92
+ finally:
93
+ dst.close()
94
+ finally:
95
+ src.close()
96
+
97
+
98
+ def restore_user_state(store_path: Path, in_path: Path) -> dict[str, int]:
99
+ """Upsert all USER_TABLES rows from ``in_path`` into ``store_path``.
100
+
101
+ Existing rows with conflicting primary keys are replaced (the snapshot wins).
102
+ Catalog tables in ``store_path`` are untouched. Returns table -> rows-applied.
103
+ """
104
+ store_path = Path(store_path)
105
+ in_path = Path(in_path)
106
+ if not store_path.is_file():
107
+ raise FileNotFoundError(f"store does not exist: {store_path}")
108
+ if not in_path.is_file():
109
+ raise FileNotFoundError(f"snapshot does not exist: {in_path}")
110
+
111
+ counts: dict[str, int] = {}
112
+ dst = sqlite3.connect(store_path)
113
+ try:
114
+ dst.execute("PRAGMA foreign_keys=OFF")
115
+ dst.execute(f"ATTACH DATABASE '{in_path.as_posix()}' AS snap")
116
+ try:
117
+ snap_tables = {
118
+ row[0]
119
+ for row in dst.execute("SELECT name FROM snap.sqlite_master WHERE type='table'")
120
+ }
121
+ with dst:
122
+ for name in USER_TABLES:
123
+ if name not in snap_tables:
124
+ continue
125
+ if not _table_exists(dst, name):
126
+ continue
127
+ cur = dst.execute(
128
+ f'INSERT OR REPLACE INTO main."{name}" SELECT * FROM snap."{name}"'
129
+ )
130
+ counts[name] = cur.rowcount or 0
131
+ finally:
132
+ dst.execute("DETACH DATABASE snap")
133
+ dst.execute("PRAGMA foreign_keys=ON")
134
+ finally:
135
+ dst.close()
136
+ return counts
137
+
138
+
139
+ def compute_user_state_fingerprint(store_path: Path) -> str:
140
+ """Cheap fingerprint that changes whenever user-state rows change.
141
+
142
+ Combines per-table row count with max(updated_at) when the column exists. A bare
143
+ count would miss updates to existing rows; reading every column would be wasteful.
144
+ """
145
+ store_path = Path(store_path)
146
+ if not store_path.is_file():
147
+ return "missing"
148
+ conn = sqlite3.connect(f"file:{store_path}?mode=ro", uri=True)
149
+ try:
150
+ parts: list[str] = []
151
+ for name in USER_TABLES:
152
+ if not _table_exists(conn, name):
153
+ parts.append(f"{name}:-")
154
+ continue
155
+ count = conn.execute(f'SELECT COUNT(*) FROM "{name}"').fetchone()[0]
156
+ cols = {row[1] for row in conn.execute(f'PRAGMA table_info("{name}")')}
157
+ ts = ""
158
+ if "updated_at" in cols:
159
+ ts_row = conn.execute(f'SELECT MAX(updated_at) FROM "{name}"').fetchone()
160
+ ts = "" if ts_row is None or ts_row[0] is None else str(ts_row[0])
161
+ parts.append(f"{name}:{count}:{ts}")
162
+ return "|".join(parts)
163
+ finally:
164
+ conn.close()
165
+
166
+
167
+ def user_state_is_empty(store_path: Path) -> bool:
168
+ """True when no rows exist in any USER_TABLES — used to gate boot-time pull."""
169
+ store_path = Path(store_path)
170
+ if not store_path.is_file():
171
+ return True
172
+ conn = sqlite3.connect(f"file:{store_path}?mode=ro", uri=True)
173
+ try:
174
+ for name in USER_TABLES:
175
+ if not _table_exists(conn, name):
176
+ continue
177
+ count = conn.execute(f'SELECT COUNT(*) FROM "{name}"').fetchone()[0]
178
+ if count > 0:
179
+ return False
180
+ return True
181
+ finally:
182
+ conn.close()
backend/user_snapshot_hub.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hugging Face Hub dataset I/O for the user-state snapshot.
2
+
3
+ Auth + repo come from environment so the snapshotter is a no-op when not configured:
4
+ KINK_USER_SNAPSHOT_REPO ``owner/dataset`` of a private Hub dataset (required to enable)
5
+ KINK_USER_SNAPSHOT_FILENAME default ``user_state.db``
6
+ HF_TOKEN write token; reuses the same secret as the catalog/Space deploy
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import os
12
+ from pathlib import Path
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ DEFAULT_SNAPSHOT_FILENAME = "user_state.db"
17
+
18
+
19
+ def snapshot_repo() -> str:
20
+ return (os.environ.get("KINK_USER_SNAPSHOT_REPO") or "").strip()
21
+
22
+
23
+ def snapshot_filename() -> str:
24
+ return (os.environ.get("KINK_USER_SNAPSHOT_FILENAME") or DEFAULT_SNAPSHOT_FILENAME).strip() or DEFAULT_SNAPSHOT_FILENAME
25
+
26
+
27
+ def snapshot_enabled() -> bool:
28
+ return bool(snapshot_repo())
29
+
30
+
31
+ def _hf_token() -> str | None:
32
+ return (os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN") or "").strip() or None
33
+
34
+
35
+ def pull_user_snapshot(target_path: Path) -> bool:
36
+ """Download the latest snapshot from the configured Hub dataset to ``target_path``.
37
+
38
+ Returns True on success, False when snapshot is disabled or the file is not yet present
39
+ on the Hub. All other Hub errors propagate.
40
+ """
41
+ if not snapshot_enabled():
42
+ return False
43
+ target_path = Path(target_path)
44
+ target_path.parent.mkdir(parents=True, exist_ok=True)
45
+ try:
46
+ from huggingface_hub import hf_hub_download
47
+ from huggingface_hub.errors import EntryNotFoundError, RepositoryNotFoundError
48
+ except ImportError:
49
+ logger.warning("huggingface_hub not installed; skipping snapshot pull")
50
+ return False
51
+ try:
52
+ downloaded = hf_hub_download(
53
+ repo_id=snapshot_repo(),
54
+ repo_type="dataset",
55
+ filename=snapshot_filename(),
56
+ token=_hf_token(),
57
+ )
58
+ except (EntryNotFoundError, RepositoryNotFoundError):
59
+ logger.info("snapshot %s/%s not present on Hub yet (cold start)", snapshot_repo(), snapshot_filename())
60
+ return False
61
+ Path(downloaded).replace(target_path)
62
+ return True
63
+
64
+
65
+ def push_user_snapshot(source_path: Path, *, commit_message: str | None = None) -> bool:
66
+ """Upload ``source_path`` as the latest snapshot. Returns True when sent."""
67
+ if not snapshot_enabled():
68
+ return False
69
+ source_path = Path(source_path)
70
+ if not source_path.is_file():
71
+ logger.warning("snapshot source %s missing; skipping push", source_path)
72
+ return False
73
+ try:
74
+ from huggingface_hub import HfApi
75
+ except ImportError:
76
+ logger.warning("huggingface_hub not installed; skipping snapshot push")
77
+ return False
78
+ token = _hf_token()
79
+ if not token:
80
+ logger.warning("HF_TOKEN missing; cannot push snapshot")
81
+ return False
82
+ api = HfApi(token=token)
83
+ api.create_repo(repo_id=snapshot_repo(), repo_type="dataset", private=True, exist_ok=True)
84
+ api.upload_file(
85
+ path_or_fileobj=str(source_path),
86
+ path_in_repo=snapshot_filename(),
87
+ repo_id=snapshot_repo(),
88
+ repo_type="dataset",
89
+ commit_message=commit_message or "user-state snapshot",
90
+ )
91
+ return True
deploy/hf/README.md CHANGED
@@ -10,6 +10,21 @@
10
 
11
  SQLite stores users in the **same file** as the catalog. If that file is on **persistent storage**, new users and plays are written into that file and remain after image updates or container restarts. If the DB only existed on ephemeral disk, it would reset when the Space restarts.
12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  ## First boot (empty volume)
14
 
15
  Pick **one** way to supply the full `store_slim.db` (multi‑GB):
 
10
 
11
  SQLite stores users in the **same file** as the catalog. If that file is on **persistent storage**, new users and plays are written into that file and remain after image updates or container restarts. If the DB only existed on ephemeral disk, it would reset when the Space restarts.
12
 
13
+ ### Free alternative: snapshot user-state to a private Hub dataset
14
+
15
+ If you do not want to pay for HF Persistent Storage, set these Space variables instead:
16
+
17
+ - `KINK_USER_SNAPSHOT_REPO=<owner>/<dataset>` — a private dataset repo for the snapshot.
18
+ - `HF_TOKEN=<write-token>` — same secret used by the catalog/Space deploy.
19
+ - Optional: `KINK_USER_SNAPSHOT_FILENAME` (default `user_state.db`), `KINK_USER_SNAPSHOT_INTERVAL_S` (default 60).
20
+
21
+ Behaviour:
22
+
23
+ - On cold boot, after the catalog opens, `_restore_user_snapshot_on_boot` pulls the latest snapshot from the dataset and upserts it into the live SQLite — but only when the local user tables are empty. Failures are logged and ignored so a Hub outage cannot block boot.
24
+ - A background task in `lifespan` recomputes the user-state fingerprint every `KINK_USER_SNAPSHOT_INTERVAL_S` seconds; when it changes, it dumps `USER_TABLES` to a slim SQLite and uploads via `huggingface_hub.upload_file`. A final push runs on lifespan shutdown.
25
+ - Tables shipped: `user`, `userpreference`, `playpreference`, `rolepreference`, `scenariopreference`, `promptdismissal`, `partnerlink`, `partnerlinkrequest`, `partnergroup`, `partnergroupmember`. Catalog tables (`kink`, `play_fts*`, `similarityedge`, `fetlife*`, `kinkscenarioparent`) stay on the ephemeral image.
26
+ - Trade-off: up to ~`interval` seconds of writes can be lost on a hard crash. Multi-replica Spaces would race on push — keep cpu-basic (single replica) or add an external mutex before scaling.
27
+
28
  ## First boot (empty volume)
29
 
30
  Pick **one** way to supply the full `store_slim.db` (multi‑GB):
frontend/app.js CHANGED
@@ -20,6 +20,7 @@ import {
20
  useLoginMutation,
21
  useCreateMutation,
22
  useSavePlayMutation,
 
23
  useSaveRoleMutation,
24
  usePrefMutation,
25
  useScenarioPreferenceMutation,
@@ -298,6 +299,11 @@ function AppInner() {
298
  onSaveStart: () => setPendingPlaySaveCount((count) => count + 1),
299
  onSaveSettled: () => setPendingPlaySaveCount((count) => Math.max(0, count - 1)),
300
  });
 
 
 
 
 
301
  const saveRoleMutation = useSaveRoleMutation({ auth, setStatus });
302
  const prefMutation = usePrefMutation({ auth, setStatus });
303
  const scenarioPreferenceMutation = useScenarioPreferenceMutation({ auth, activeGroupId, setStatus });
@@ -595,6 +601,7 @@ function AppInner() {
595
  onDirectionColumnChange=${handleDirectionColumnChange}
596
  onNavigate=${handleNavigate}
597
  onOpenScenarios=${handleOpenScenarios}
 
598
  focusKinkId=${playsFocusKinkId}
599
  onFocusKinkConsumed=${() => setPlaysFocusKinkId("")}
600
  />
@@ -608,6 +615,8 @@ function AppInner() {
608
  user=${user}
609
  currentPlays=${currentPlays}
610
  onRate=${handleRate}
 
 
611
  onNavigate=${handleNavigate}
612
  onOpenScenarios=${handleOpenScenarios}
613
  setStatus=${setStatus}
@@ -652,6 +661,37 @@ function AppInner() {
652
  `;
653
  }
654
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
655
  function Onboarding({
656
  step,
657
  setStep,
@@ -675,12 +715,7 @@ function Onboarding({
675
  <h1>Set up your profile</h1>
676
  <p className="sub">Keep these credentials. You need them to reopen the profile later.</p>
677
  <div className="card stack">
678
- <div className="creds">
679
- <div><strong>Profile ID</strong></div>
680
- <code data-testid="onboarding-created-user-id">${createResult.id}</code>
681
- <div style=${{ marginTop: 8 }}><strong>Private pass</strong></div>
682
- <code data-testid="onboarding-created-private-token">${createResult.private_token}</code>
683
- </div>
684
 
685
  ${pendingPartnerId ? html`<div className="tiny">Invite saved for <code>${pendingPartnerId}</code>. The connection request will go out after this setup.</div>` : null}
686
 
 
20
  useLoginMutation,
21
  useCreateMutation,
22
  useSavePlayMutation,
23
+ useDeletePlayMutation,
24
  useSaveRoleMutation,
25
  usePrefMutation,
26
  useScenarioPreferenceMutation,
 
299
  onSaveStart: () => setPendingPlaySaveCount((count) => count + 1),
300
  onSaveSettled: () => setPendingPlaySaveCount((count) => Math.max(0, count - 1)),
301
  });
302
+ const deletePlayMutation = useDeletePlayMutation({ auth, setStatus });
303
+ const handleDeletePlay = useCallback((kinkId) => {
304
+ if (!kinkId) return;
305
+ deletePlayMutation.mutate(kinkId);
306
+ }, [deletePlayMutation]);
307
  const saveRoleMutation = useSaveRoleMutation({ auth, setStatus });
308
  const prefMutation = usePrefMutation({ auth, setStatus });
309
  const scenarioPreferenceMutation = useScenarioPreferenceMutation({ auth, activeGroupId, setStatus });
 
601
  onDirectionColumnChange=${handleDirectionColumnChange}
602
  onNavigate=${handleNavigate}
603
  onOpenScenarios=${handleOpenScenarios}
604
+ onDeletePlay=${handleDeletePlay}
605
  focusKinkId=${playsFocusKinkId}
606
  onFocusKinkConsumed=${() => setPlaysFocusKinkId("")}
607
  />
 
615
  user=${user}
616
  currentPlays=${currentPlays}
617
  onRate=${handleRate}
618
+ onDirectionToggle=${handleDirectionToggle}
619
+ onDeletePlay=${handleDeletePlay}
620
  onNavigate=${handleNavigate}
621
  onOpenScenarios=${handleOpenScenarios}
622
  setStatus=${setStatus}
 
661
  `;
662
  }
663
 
664
+ function NewProfileCredentials({ createResult }) {
665
+ const [revealed, setRevealed] = useState(false);
666
+ const token = createResult.private_token || "";
667
+ const masked = `••••••••${token.slice(-4)}`;
668
+ return html`
669
+ <div className="creds">
670
+ <div><strong>Profile ID</strong></div>
671
+ <code data-testid="onboarding-created-user-id">${createResult.id}</code>
672
+ <div style=${{ marginTop: 8 }}><strong>Private pass</strong></div>
673
+ <div className="onboarding-token-row" style=${{ display: "flex", gap: 8, flexWrap: "wrap", alignItems: "center" }}>
674
+ ${revealed
675
+ ? html`<code data-testid="onboarding-created-private-token">${token}</code>`
676
+ : html`<code data-testid="onboarding-created-private-token-mask">${masked}</code>`}
677
+ <button
678
+ type="button"
679
+ className="ghost sm"
680
+ data-testid="onboarding-reveal-token"
681
+ onClick=${() => setRevealed((v) => !v)}
682
+ >${revealed ? "Hide" : "Reveal"}</button>
683
+ <button
684
+ type="button"
685
+ className="ghost sm"
686
+ data-testid="onboarding-copy-token"
687
+ onClick=${() => { try { navigator.clipboard?.writeText(token); } catch {} }}
688
+ >Copy</button>
689
+ </div>
690
+ <div className="tiny muted">Save the private pass somewhere safe. You will need it to reopen this profile.</div>
691
+ </div>
692
+ `;
693
+ }
694
+
695
  function Onboarding({
696
  step,
697
  setStep,
 
715
  <h1>Set up your profile</h1>
716
  <p className="sub">Keep these credentials. You need them to reopen the profile later.</p>
717
  <div className="card stack">
718
+ <${NewProfileCredentials} createResult=${createResult} />
 
 
 
 
 
719
 
720
  ${pendingPartnerId ? html`<div className="tiny">Invite saved for <code>${pendingPartnerId}</code>. The connection request will go out after this setup.</div>` : null}
721
 
frontend/discover-state.js CHANGED
@@ -5,6 +5,7 @@ export function visibleDiscoveryItems(rawItems, currentPlays) {
5
  for (const row of rawItems || []) {
6
  const id = row?.kink?.id;
7
  if (id == null || id === "") continue;
 
8
  const sid = String(id);
9
  if (played[sid] || seen.has(sid)) continue;
10
  seen.add(sid);
 
5
  for (const row of rawItems || []) {
6
  const id = row?.kink?.id;
7
  if (id == null || id === "") continue;
8
+ if (row.kink?.is_scenario) continue;
9
  const sid = String(id);
10
  if (played[sid] || seen.has(sid)) continue;
11
  seen.add(sid);
frontend/discover-state.test.mjs CHANGED
@@ -27,6 +27,15 @@ test("visibleDiscoveryItems drops already-rated raw recommendation rows", () =>
27
  assert.deepEqual(visibleDiscoveryItems(rawItems, currentPlays).map((row) => row.kink.id), ["b"]);
28
  });
29
 
 
 
 
 
 
 
 
 
 
30
  test("visibleDiscoveryItems skips malformed rows, blank ids, and duplicate ids", () => {
31
  const rawItems = [
32
  null,
 
27
  assert.deepEqual(visibleDiscoveryItems(rawItems, currentPlays).map((row) => row.kink.id), ["b"]);
28
  });
29
 
30
+ test("visibleDiscoveryItems drops scenario kinks even when not yet rated", () => {
31
+ const rawItems = [
32
+ { kink: { id: "a", name: "regular" } },
33
+ { kink: { id: "b", name: "scenario", is_scenario: true } },
34
+ { kink: { id: "c", name: "another" } },
35
+ ];
36
+ assert.deepEqual(visibleDiscoveryItems(rawItems, {}).map((row) => row.kink.id), ["a", "c"]);
37
+ });
38
+
39
  test("visibleDiscoveryItems skips malformed rows, blank ids, and duplicate ids", () => {
40
  const rawItems = [
41
  null,
frontend/dist/assets/index-BRxg-Ey6.js ADDED
The diff for this file is too large to render. See raw diff
 
frontend/dist/assets/index-HvMqkWIl.css ADDED
@@ -0,0 +1 @@
 
 
1
+ :root{--bg: #f5efe7;--bg-2: #efe5d7;--surface: rgba(255, 251, 246, .92);--surface-strong: #fffaf3;--ink: #1d1815;--muted: #6d6157;--line: #dbcdbd;--accent: #b33636;--accent-2: #264653;--accent-soft: #f0ddd0;--ok: #236b45;--radius: 18px;--radius-sm: 12px;--radius-pill: 999px;--tab-height: 56px}*{box-sizing:border-box}body{margin:0;color:var(--ink);font-family:IBM Plex Sans,Inter,system-ui,sans-serif;background:radial-gradient(circle at top left,rgba(179,54,54,.14),transparent 24%),radial-gradient(circle at bottom right,rgba(38,70,83,.12),transparent 22%),linear-gradient(180deg,var(--bg-2),var(--bg));min-height:100vh;padding-bottom:calc(var(--tab-height) + 12px)}button,input,textarea{font:inherit}button{border:0;border-radius:var(--radius-pill);background:var(--accent);color:#fff;padding:10px 16px;cursor:pointer;transition:transform .12s ease,opacity .12s ease;font-size:.88rem}button:hover{transform:translateY(-1px)}button:active{transform:scale(.97)}button.secondary{background:var(--accent-2)}button.ghost{background:transparent;color:var(--ink);border:1px solid var(--line)}button.sm{padding:6px 12px;font-size:.82rem}input,textarea{width:100%;border:1px solid var(--line);border-radius:14px;padding:12px 14px;background:var(--surface-strong);color:var(--ink)}.page{max-width:680px;margin:0 auto;padding:16px}.app-global-bar{display:flex;justify-content:flex-end;align-items:center;margin:0 0 10px;min-height:36px}.app-graph-debug-toggle--on{border-color:var(--accent-2);background:#2646531a;color:var(--accent-2)}.tab-bar{position:fixed;bottom:0;left:0;right:0;display:flex;justify-content:center;gap:0;height:var(--tab-height);background:var(--surface-strong);border-top:1px solid var(--line);z-index:90}.tab-btn{flex:1;max-width:180px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;background:transparent;color:var(--muted);border:none;border-radius:0;padding:4px 0;font-size:.72rem;font-weight:600;letter-spacing:.02em;transition:color .1s ease}.tab-btn:hover{transform:none}.tab-btn.active{color:var(--accent)}.tab-icon{font-size:1.3rem;line-height:1}.tab-badge{display:inline-flex;align-items:center;justify-content:center;min-width:18px;height:18px;border-radius:var(--radius-pill);background:var(--accent);color:#fff;font-size:.66rem;font-weight:700;padding:0 5px;position:absolute;top:4px;right:calc(50% - 20px)}.top-bar{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:16px}.top-bar-title{font-size:1.4rem;font-weight:800;letter-spacing:-.03em;line-height:1}.top-bar-right{display:flex;align-items:center;gap:8px;font-size:.82rem;color:var(--muted)}.top-bar-right strong{color:var(--ink)}.card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:20px;box-shadow:0 8px 24px #0000000a}.card-lg{padding:28px 24px;text-align:center}.card-title{margin:0 0 8px;font-size:1.8rem;font-weight:800;letter-spacing:-.03em;line-height:1.1}.card-definition{color:var(--muted);font-size:1rem;line-height:1.5;max-width:480px;margin:0 auto 16px}.kink-pill,.card-cluster{display:inline-block;padding:5px 11px;border-radius:var(--radius-pill);background:#26465314;color:var(--accent-2);font-size:.7rem;font-weight:700;letter-spacing:.06em;text-transform:uppercase;margin-bottom:12px}.card-image-area{width:100%;aspect-ratio:16 / 9;border-radius:var(--radius-sm);background:linear-gradient(135deg,#c9b8a7,#a89584);overflow:hidden;margin-bottom:16px;position:relative;cursor:pointer}.card-image-area img{width:100%;height:100%;object-fit:cover;filter:blur(20px);transition:filter .3s ease}.card-image-area.revealed img{filter:none}.card-counter{font-size:.84rem;color:var(--muted);font-weight:600}.card-image-area--preview{cursor:pointer}.card-image-area--preview .card-image-blur{width:100%;height:100%;object-fit:cover;filter:blur(20px);transform:scale(1.04);display:block}.card-image-cta{position:absolute;top:0;right:0;bottom:0;left:0;display:flex;align-items:center;justify-content:center;color:#fff;font-size:.95rem;font-weight:700;text-shadow:0 1px 8px rgba(0,0,0,.75);pointer-events:none}.card-image-scroll{max-height:340px;overflow-y:auto;overflow-x:hidden;scroll-snap-type:y mandatory;border-radius:var(--radius-sm);margin-bottom:16px;border:1px solid var(--line);-webkit-overflow-scrolling:touch;overscroll-behavior:contain;scrollbar-width:thin}.card-image-slide{min-height:220px;scroll-snap-align:start;scroll-snap-stop:always}.card-image-slide img{width:100%;height:100%;min-height:220px;object-fit:cover;display:block}.discover-card-wrap{margin:0 auto 10px;max-width:420px;border-radius:22px;box-shadow:0 12px 40px #0000001f}.discover-photo-hint{text-align:center;line-height:1.45;max-width:420px;margin:0 auto 8px;padding:0 8px}.discover-photo-hint strong{color:var(--ink);font-weight:800}.discover-reason-row{max-width:420px;margin:0 auto 8px;padding:0 8px}.discover-reason-pill{display:inline-flex;align-items:center;padding:6px 12px;border-radius:var(--radius-pill);background:#1d18150f;color:var(--ink);font-size:.78rem;font-weight:600;line-height:1.3}.discover-reason-pill--partner_influenced{background:#b336361f;color:var(--accent)}.discover-reason-pill--starter{background:#2646531a;color:var(--accent-2)}.discover-group-row{max-width:420px;margin:0 auto 14px;padding:0 4px}.discover-group-label{font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:8px}.partner-group-picker{margin-bottom:8px}.discover-photo-toggle{display:flex;align-items:center;gap:10px;margin:0 auto 12px;max-width:420px;font-size:.86rem;color:var(--muted);cursor:pointer;-webkit-user-select:none;user-select:none}.discover-photo-toggle input{width:1.05rem;height:1.05rem;accent-color:var(--accent, #c62828)}.discover-directions{max-width:420px;margin:4px auto 6px;padding:0 10px}.discover-dir-label{font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-bottom:8px}.discover-direction-sheet{max-width:420px;margin:0 auto 14px;padding:14px;border:1px solid var(--line);border-radius:var(--radius);background:#ffffffe0;display:grid;gap:10px}.discover-direction-actions{display:flex;gap:8px;flex-wrap:wrap}.discover-scenario-hint{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px}.discover-scenario-hint strong{display:block;margin-bottom:2px}.tinder-card{background:var(--surface-strong);border:1px solid var(--line);border-radius:22px;overflow:hidden;text-align:left}.tinder-card__media{position:relative;background:linear-gradient(145deg,#3d3430,#1a1513)}.tinder-card__media-inner{position:relative;height:min(58vh,520px);height:min(58dvh,520px);min-height:280px;overflow:hidden}.tinder-card__carousel{position:relative;width:100%;height:100%}.tinder-card__slide--solo{height:100%}.tinder-card__slide--solo img{width:100%;height:100%;min-height:min(58vh,520px);min-height:min(58dvh,520px);object-fit:cover;display:block}.tinder-edge{position:absolute;top:0;bottom:calc(104px + env(safe-area-inset-bottom,0px));width:30%;max-width:150px;z-index:4;cursor:pointer;background:transparent;border:none;padding:0;-webkit-tap-highlight-color:transparent}.tinder-edge--left{left:0;background:linear-gradient(90deg,rgba(0,0,0,.14),transparent)}.tinder-edge--right{right:0;background:linear-gradient(270deg,rgba(0,0,0,.14),transparent)}.tinder-edge:active{opacity:.85}.tinder-dots{position:absolute;bottom:14px;left:50%;transform:translate(-50%);display:flex;gap:7px;z-index:5;pointer-events:none}.tinder-dots span{width:7px;height:7px;border-radius:50%;background:#ffffff61;transition:transform .12s ease,background .12s ease}.tinder-dots span.on{background:#fff;transform:scale(1.12)}.tinder-card__media-scroll{height:100%;overflow-y:auto;overflow-x:hidden;scroll-snap-type:y mandatory;-webkit-overflow-scrolling:touch;overscroll-behavior:contain;scrollbar-width:thin}.tinder-card__slide{min-height:100%;scroll-snap-align:start;scroll-snap-stop:always;box-sizing:border-box}.tinder-card__slide img{width:100%;height:100%;min-height:min(58vh,520px);object-fit:cover;display:block}.tinder-card__img-blur{filter:blur(22px);transform:scale(1.06)}.tinder-card__media--placeholder{display:flex;align-items:center;justify-content:center;aspect-ratio:3 / 4;max-height:58vh;min-height:280px}.tinder-card__placeholder-letters{font-size:3.5rem;font-weight:800;color:#ffffff38;letter-spacing:-.06em}.tinder-card__photo-badge{position:absolute;top:12px;right:12px;left:auto;bottom:auto;transform:none;z-index:4;padding:5px 12px;border-radius:var(--radius-pill);background:#00000080;color:#fff;font-size:.74rem;font-weight:700;letter-spacing:.04em;pointer-events:none}.tinder-card__gradient{position:absolute;top:0;right:0;bottom:0;left:0;background:linear-gradient(180deg,transparent 38%,rgba(0,0,0,.55) 100%);pointer-events:none;z-index:2}.tinder-card__reveal-row{position:absolute;left:0;right:0;bottom:0;height:calc(104px + env(safe-area-inset-bottom,0px));display:flex;align-items:center;justify-content:center;z-index:6;pointer-events:none;box-sizing:border-box;padding-bottom:env(safe-area-inset-bottom,0px)}.tinder-card__reveal{position:relative;flex:0 0 auto;width:max-content;max-width:min(280px,calc(100% - 32px));margin:0;padding:11px 20px;border-radius:var(--radius-pill);border:1px solid rgba(255,255,255,.5);background:#00000080;color:#fff;font-size:.84rem;font-weight:700;cursor:pointer;box-shadow:0 6px 24px #00000059;touch-action:manipulation;-webkit-user-select:none;user-select:none;pointer-events:auto;outline:none}@media (hover: hover) and (pointer: fine){.tinder-card__reveal:hover{background:#0000009e}}.tinder-card__reveal:focus-visible{box-shadow:0 6px 24px #00000059,0 0 0 2px #ffffff73}.tinder-card__body{padding:16px 18px 20px}button.tinder-card__body.tinder-card__body--tappable{display:block;width:100%;border:none;margin:0;padding:16px 18px 20px;font:inherit;color:inherit;text-align:left;background:var(--surface-strong);-moz-appearance:none;appearance:none;-webkit-appearance:none;border-radius:0;box-sizing:border-box}.tinder-card__body--tappable{cursor:pointer;border-radius:0 0 18px 18px;transition:background .14s ease;touch-action:manipulation;-webkit-tap-highlight-color:transparent}.tinder-card__body--tappable:focus-visible{outline:none;box-shadow:inset 0 0 0 2px #6450c859}.tinder-card__body--tappable:active{background:#0000000b}.tinder-card__body--expanded{max-height:min(52vh,420px);overflow-y:auto;overscroll-behavior:contain;-webkit-overflow-scrolling:touch}.tinder-card__body .kink-pill{margin-bottom:8px}.tinder-card__body .card-title{font-size:1.55rem;text-align:left;margin-bottom:6px}.tinder-card__body:not(.tinder-card__body--expanded) .card-definition{text-align:left;font-size:.92rem;line-height:1.45;display:block;overflow:visible;margin-bottom:0;color:var(--ink)}.tinder-card__body--expanded .card-definition--discover-full{text-align:left;font-size:.92rem;line-height:1.5;display:block;margin-bottom:0;overflow:visible;color:var(--ink)}.tinder-card__notes{margin-top:10px;font-size:.84rem;line-height:1.45;white-space:pre-wrap;word-break:break-word;color:#5c5c5c}.tinder-card__info-cue{margin-top:10px;font-size:.72rem;font-weight:600;letter-spacing:.02em;color:#888;text-align:center}.tinder-card .safety-badge{margin-top:8px}.action-bar{display:flex;flex-wrap:wrap;justify-content:center;gap:10px;padding:16px 8px;max-width:520px;margin:0 auto}.action-btn{display:flex;flex-direction:column;align-items:center;gap:4px;padding:12px 20px;border-radius:16px;border:1px solid var(--line);background:#fff;color:var(--ink);font-size:.76rem;font-weight:600;min-width:64px;transition:transform .1s ease,background .1s ease,border-color .1s ease}.action-btn:hover{transform:translateY(-2px)}.action-btn:active{transform:scale(.94)}.action-btn .action-icon{font-size:1.4rem;line-height:1}.action-btn.love{border-color:#e57373;background:#fff5f5}.action-btn.like{border-color:#81c784;background:#f5fff5}.action-btn.maybe{border-color:#b39ddb;background:#f3e5f5}.action-btn.skip{border-color:var(--line)}.action-btn.nogo{border-color:#bbb;background:#fafafa}.chip-flow{display:flex;flex-wrap:wrap;gap:6px;align-items:flex-start}.chip-flow--with-dnd{flex-direction:column;align-items:stretch}.plays-board-card--inline{width:100%}.play-chip{display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border:1px solid var(--line);border-radius:var(--radius-sm);background:#ffffffd1;font-size:.88rem;font-weight:600;cursor:pointer;transition:border-color .1s ease,box-shadow .1s ease;max-width:100%}.chip-top-row{display:inline-flex;align-items:center;gap:6px;min-width:0;flex:1}.chip-near-title{flex-shrink:0;opacity:.55;font-size:.85em;font-weight:600;cursor:help}.play-chip:hover{border-color:var(--accent)}.play-chip .chip-emoji{flex-shrink:0}.play-chip .chip-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}.play-chip.expanded{width:100%;max-width:100%;min-width:0;box-sizing:border-box;flex-direction:column;align-items:stretch;gap:10px;padding:12px;background:#fff;border-color:var(--accent);box-shadow:0 4px 16px #0000000f}.play-chip.expanded .chip-top-row{align-items:flex-start}.play-chip.expanded .chip-name{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3;overflow:hidden;text-overflow:ellipsis;white-space:normal;word-break:break-word}.plays-inspector{scroll-margin-top:12px}.plays-inspector--tentative{border:1px dashed var(--accent, #888);background:#ffffff05}.plays-inspector-head-actions{display:flex;gap:8px;flex-wrap:wrap;align-items:center}.tentative-badge{display:inline-block;margin-top:4px;padding:2px 8px;font-size:.72rem;border-radius:999px;border:1px dashed var(--accent, #888);color:var(--muted)}.play-chip--expandable{cursor:pointer}.play-chip--expanded{background:#ffffff0a}.play-chip-inspector{width:100%;margin-top:8px}.play-chip-inspector--floating{margin-top:12px}.plays-inspector-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}.plays-inspector-copy{min-width:0}.plays-inspector-label{text-transform:uppercase;letter-spacing:.05em;font-weight:700;color:var(--muted)}.plays-inspector-title-row{display:flex;align-items:flex-start;gap:8px;min-width:0}.plays-inspector-title{margin:0;font-size:1.2rem;line-height:1.2;overflow-wrap:anywhere;word-break:break-word}.plays-inspector-emoji{flex-shrink:0;font-size:1.2rem;line-height:1}.plays-inspector-definition{margin:0}.plays-inspector-actions,.plays-inspector-actions-row,.plays-inspector-section{display:grid;gap:10px}.inline-detail{display:grid;gap:10px;min-width:0;max-width:100%}.inline-detail .detail-def{font-size:.9rem;line-height:1.45;color:var(--muted);font-weight:400;overflow-wrap:anywhere;word-break:break-word}.scenario-panel{display:grid;gap:8px;padding:10px;border:1px solid var(--line);border-radius:var(--radius-sm);background:#00000005}.scenario-panel-head{display:flex;align-items:flex-start;justify-content:space-between;gap:10px}.scenario-panel-mother{display:grid;gap:2px;min-width:0}.scenario-panel-mother-label{text-transform:uppercase;letter-spacing:.05em;font-weight:700;color:var(--muted)}.scenario-panel-mother-name{font-size:.95rem;font-weight:700;line-height:1.35;overflow-wrap:anywhere;word-break:break-word}.scenario-panel-hint{margin:0 0 4px;line-height:1.4}.scenario-list{display:grid;gap:8px}.scenario-list--page{gap:10px}.scenario-row{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:8px;min-width:0}.scenario-row-text{display:grid;gap:2px;min-width:0}.scenario-name{font-size:.84rem;font-weight:650;line-height:1.4;overflow-wrap:anywhere;word-break:break-word;white-space:normal}.scenario-match-chip .chip-name{white-space:normal;overflow-wrap:anywhere}.scenario-row--page{padding:12px;border:1px solid var(--line);border-radius:var(--radius-sm);background:#ffffffd1}.scenario-row--highlight{border-color:var(--accent);box-shadow:0 0 0 2px #b336361f}.scenario-row-actions{display:flex;align-items:center;justify-content:flex-end}.scenarios-parent-head,.scenarios-detail-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap}.scenarios-parent-search{max-width:240px}.scenarios-parent-list{display:grid;gap:8px}.scenarios-parent-item{width:100%;display:grid;gap:3px;justify-items:start;text-align:left;border-radius:var(--radius-sm);border:1px solid var(--line);background:#ffffffd1;color:var(--ink);padding:12px 14px}.scenarios-parent-item.active{border-color:var(--accent);background:#fff}.scenarios-parent-name{font-weight:700;overflow-wrap:anywhere;word-break:break-word}.scenarios-parent-meta{font-size:.78rem;color:var(--muted)}.scenarios-detail-label{text-transform:uppercase;letter-spacing:.05em;font-weight:700;color:var(--muted)}.scenarios-detail-title{margin:4px 0;line-height:1.15}@media (max-width: 560px){.discover-scenario-hint{align-items:stretch;flex-direction:column}.scenario-row{grid-template-columns:1fr}.scenario-row-actions{justify-content:flex-start}}.dir-pills{display:flex;gap:6px;flex-wrap:wrap}.dir-pill{display:inline-flex;align-items:center;gap:5px;padding:5px 10px;border:1px solid var(--line);border-radius:var(--radius-pill);background:#fff;color:var(--ink);font-size:.8rem;font-weight:500;line-height:1.2;touch-action:manipulation;-webkit-tap-highlight-color:transparent}.dir-pill.active{background:var(--accent-2);color:#fff;border-color:transparent}.dir-pill:disabled{opacity:.55;cursor:not-allowed}.discover-dir-strip{margin:.65rem 0 .35rem;padding:0 2px}.discover-dir-strip__label{margin-bottom:6px;text-align:center;line-height:1.35}.discover-dir-strip__pills{justify-content:center}.reaction-bar{display:flex;gap:6px;flex-wrap:wrap}.react-btn{display:inline-flex;align-items:center;justify-content:center;padding:5px 8px;border-radius:var(--radius-pill);border:1px solid var(--line);background:#fff;font-size:.9rem;min-width:32px;min-height:32px;transition:transform 80ms ease,background 80ms ease}.react-btn:active{transform:scale(.92)}.react-btn.active{background:var(--accent-soft);border-color:transparent}.section-header{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:8px}.section-title{margin:0;font-size:.88rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted)}.section-count{font-size:.76rem;color:var(--muted);font-weight:600}.plays-direction-board{margin-bottom:20px}.plays-board-hint{margin:0 0 12px;line-height:1.45}.plays-board-columns{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;align-items:stretch}.plays-board-columns>.plays-board-column{min-width:0}@media (max-width: 780px){.plays-board-columns{grid-template-columns:1fr}.plays-board-card{flex-wrap:wrap;align-items:flex-start}.plays-board-card .plays-dnd-handle{margin-top:4px}.plays-board-card-body{flex:1 1 calc(100% - 48px);min-width:0}.plays-touch-moves{display:flex;order:3}}.plays-board-column{display:flex;flex-direction:column;min-height:140px;border:2px dashed var(--line);border-radius:var(--radius);background:#ffffff8c;padding:8px;transition:border-color .12s ease,background .12s ease,box-shadow .12s ease}.plays-board-column--over{border-color:var(--accent);background:#ffecec73;box-shadow:0 0 0 1px #c6282826}.plays-board-column-head{display:flex;align-items:center;gap:6px;margin-bottom:8px;font-size:.78rem;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted)}.plays-board-column-icon{font-size:1rem}.plays-board-column-title{overflow-wrap:anywhere}.plays-board-column-body{flex:1;min-width:0;display:flex;flex-direction:column;gap:8px}.plays-board-empty{flex:1;display:flex;align-items:center;justify-content:center;padding:16px 8px;text-align:center;color:var(--muted);opacity:.9}.plays-board-card{display:flex;gap:6px;align-items:flex-start;min-width:0;max-width:100%;width:100%;box-sizing:border-box}.plays-dnd-handle{flex-shrink:0;cursor:grab;-webkit-user-select:none;user-select:none;padding:4px 6px;margin-top:2px;border-radius:6px;color:var(--muted);font-size:1rem;line-height:1;touch-action:none}.plays-dnd-handle:hover{background:#0000000a;color:var(--ink)}.plays-dnd-handle:active{cursor:grabbing}.plays-touch-moves{display:none;flex:1 1 100%;flex-wrap:wrap;gap:6px;align-items:stretch;margin-top:2px}.plays-touch-move-btn{flex:1 1 auto;min-width:0;display:inline-flex;align-items:center;justify-content:center;gap:4px;padding:8px 6px;border:1px solid var(--line);border-radius:var(--radius-sm);background:#ffffffeb;font-size:.72rem;font-weight:600;line-height:1.15;text-align:center;color:var(--ink);cursor:pointer;touch-action:manipulation;-webkit-tap-highlight-color:transparent}.plays-touch-move-btn:active{background:#c6282814}.plays-touch-move-icon{font-size:.95rem;line-height:1}.plays-board-card-body{flex:1;min-width:0;overflow-x:hidden}.plays-board-card-body .play-chip,.plays-board-card-body .inline-detail .reaction-bar,.plays-board-card-body .inline-detail .dir-pills,.plays-board-card-body .inline-detail .chips{max-width:100%}.inline-detail .chips .chip{max-width:100%;min-width:0;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;text-overflow:ellipsis;line-height:1.35;text-align:left;white-space:normal;word-break:break-word}.plays-board-card-body .inline-detail button.ghost.sm{max-width:100%;justify-self:start}.plays-board-card-body .play-assets,.plays-board-card-body .play-assets-carousel{max-width:100%;box-sizing:border-box}.collapse-toggle{all:unset;cursor:pointer;display:flex;align-items:center;gap:6px;font-size:.84rem;font-weight:600;color:var(--muted);padding:6px 0}.collapse-toggle .arrow{transition:transform .2s ease}.collapse-toggle.open .arrow{transform:rotate(90deg)}.partner-code{display:flex;align-items:center;gap:10px;padding:12px 14px;border:1px solid var(--line);border-radius:var(--radius);background:#ffffffb8}.partner-code code{font-size:1.1rem;font-weight:700;flex:1;overflow-wrap:anywhere}.sub-tabs{display:flex;gap:0;border-bottom:2px solid var(--line);margin-bottom:12px}.sub-tab{padding:8px 16px;background:transparent;border:none;border-bottom:2px solid transparent;border-radius:0;color:var(--muted);font-weight:600;font-size:.84rem;margin-bottom:-2px;transition:color .1s,border-color .1s}.sub-tab:hover{transform:none;color:var(--ink)}.sub-tab.active{color:var(--accent);border-bottom-color:var(--accent)}.match-indicator{display:inline-flex;align-items:center;gap:4px;padding:3px 8px;border-radius:var(--radius-pill);font-size:.74rem;font-weight:600}.match-indicator.perfect{background:#e8f5e9;color:#2e7d32}.match-indicator.partial{background:#fff3e0;color:#e65100}.together-scenario-card{flex-direction:column;align-items:stretch;gap:10px}.together-scenario-copy{display:grid;gap:4px;min-width:0}.together-mother-label,.together-scenario-label{text-transform:uppercase;letter-spacing:.05em;font-weight:700;color:var(--muted);font-size:.68rem}.together-mother-name{font-size:.95rem;font-weight:700;line-height:1.35;overflow-wrap:anywhere;word-break:break-word}button.together-mother-link{all:unset;cursor:pointer;font-size:.95rem;font-weight:700;line-height:1.35;overflow-wrap:anywhere;word-break:break-word;color:var(--accent);text-decoration:underline;text-underline-offset:2px}button.together-mother-link:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.together-scenario-title{font-size:.88rem;font-weight:650;line-height:1.45;overflow-wrap:anywhere;word-break:break-word;white-space:normal}.together-scenario-meta{display:flex;flex-direction:column;gap:8px;min-width:0}.together-scenario-you{display:grid;gap:4px;padding-top:4px;border-top:1px solid var(--line)}.onboard{min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:24px;text-align:center}.onboard .card{width:100%;max-width:440px;text-align:left}.onboard h1{font-size:2.4rem;letter-spacing:-.04em;line-height:1;margin:0 0 8px}.onboard .sub{color:var(--muted);margin:0 0 24px;font-size:.96rem}.starter-page{padding-top:24px}.starter-hero{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:16px}.starter-hero .sub{margin:8px 0 0;max-width:520px}.creds{border:1px solid var(--line);border-radius:var(--radius);padding:14px;background:#ffffffb8}.creds code{display:block;margin-top:6px;padding:8px 10px;border-radius:var(--radius-sm);background:#f5eee4;overflow-wrap:anywhere;font-weight:600}.settings-code{display:inline-block;margin-top:6px;padding:8px 10px;border-radius:var(--radius-sm);background:#f5eee4;overflow-wrap:anywhere;font-weight:600}.settings-toggle{margin:0;max-width:none}.settings-share-row{padding:10px 12px;border:1px solid var(--line);border-radius:var(--radius-sm);background:#ffffffb8}.settings-share-title{font-size:.9rem;font-weight:700;margin-bottom:4px}.search-overlay{position:fixed;top:0;right:0;bottom:0;left:0;z-index:80;background:#1d18156b;-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);display:flex;flex-direction:column;align-items:center;padding:16px}.search-overlay .search-inner{width:100%;max-width:560px;display:grid;gap:12px}.search-overlay input{font-size:1.1rem;padding:14px 16px}.play-assets{width:100%}.play-assets-carousel{position:relative;border-radius:var(--radius-sm);border:1px solid var(--line);overflow:hidden;background:var(--surface)}.play-assets-carousel .play-assets-slide{min-height:200px;max-height:min(48vh,340px)}.play-assets-carousel .play-assets-slide img{width:100%;height:100%;min-height:200px;max-height:min(48vh,340px);object-fit:cover;display:block}.play-edge{position:absolute;top:0;bottom:0;width:28%;max-width:120px;z-index:2;cursor:pointer;border:none;padding:0;background:transparent;-webkit-tap-highlight-color:transparent}.play-edge--left{left:0;background:linear-gradient(90deg,rgba(0,0,0,.08),transparent)}.play-edge--right{right:0;background:linear-gradient(270deg,rgba(0,0,0,.08),transparent)}.play-assets-dots{position:absolute;bottom:10px;left:50%;transform:translate(-50%);display:flex;gap:6px;z-index:3;pointer-events:none}.play-assets-dots span{width:6px;height:6px;border-radius:50%;background:#ffffff73}.play-assets-dots span.on{background:#fff}.play-assets-badge{position:absolute;top:8px;right:8px;z-index:3;padding:4px 10px;border-radius:var(--radius-pill);background:#0000007a;color:#fff;font-size:.72rem;font-weight:700;pointer-events:none}.image-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:8px}.image-tile{position:relative;aspect-ratio:1;background:#c9b8a7;border-radius:var(--radius-sm);overflow:hidden}.image-tile img{width:100%;height:100%;object-fit:cover}.image-tile .reveal-btn{position:absolute;top:0;right:0;bottom:0;left:0;border-radius:0;background:linear-gradient(180deg,#0000001f,#000000b3);display:flex;align-items:center;justify-content:center;font-size:.82rem}.safety-badge{padding:4px 8px;border-radius:8px;font-size:.78rem;font-weight:600;display:inline-block}.safety-badge.extreme{background:#ffebee;color:#c62828;border:1px solid #ef9a9a}.safety-badge.advanced{background:#fff3e0;color:#e65100;border:1px solid #ffcc80}.stack{display:grid;gap:12px}.stack-sm{display:grid;gap:8px}.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.muted{color:var(--muted)}.tiny{font-size:.82rem;color:var(--muted)}.empty{color:var(--muted);border:1px dashed rgba(219,205,189,.7);border-radius:14px;padding:10px 12px;background:#ffffff59;font-size:.88rem}.chips{display:flex;gap:6px;flex-wrap:wrap}.chip{border:1px solid var(--line);border-radius:var(--radius-pill);padding:5px 10px;font-size:.82rem;background:#fff;color:var(--ink);cursor:pointer;font-weight:500}.chip.active{border-color:var(--accent);background:var(--accent-soft);color:var(--accent);font-weight:600}.status{min-height:1.2em;color:var(--muted);font-size:.86rem}.together-scope-hint{margin:0 0 10px;color:var(--muted)}.together-section-label{margin:8px 0 2px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.04em}.related-match-chip{align-items:flex-start}.related-arrow{color:var(--muted);font-weight:700}.related-match-meta{flex:1 1 240px;color:var(--muted)}.their-board-picker{margin-bottom:12px}.together-react-row{display:inline-flex;flex-wrap:wrap;gap:6px;align-items:center;justify-content:flex-end;max-width:100%}.together-react-pair{display:inline-flex;gap:3px;align-items:center}.together-react-id{font-family:ui-monospace,monospace;font-size:.75em;opacity:.85}@media (min-width: 768px){.page{max-width:720px;padding:24px}.action-btn{padding:14px 28px;min-width:80px;font-size:.82rem}.action-btn .action-icon{font-size:1.6rem}.card-lg{padding:36px 32px}.card-title{font-size:2.2rem}}.desktop-nav{display:none}@media (min-width: 1024px){.page{max-width:880px}.tab-bar{display:none}body{padding-bottom:0}.desktop-nav{display:flex;gap:0;margin-bottom:20px;border-bottom:2px solid var(--line)}.desktop-nav .tab-btn{flex-direction:row;gap:6px;padding:10px 20px;font-size:.88rem;max-width:none;border-bottom:2px solid transparent;margin-bottom:-2px}.desktop-nav .tab-btn.active{border-bottom-color:var(--accent);color:var(--accent)}}
frontend/dist/index.html CHANGED
@@ -5,8 +5,8 @@
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
  <title>Play List</title>
7
  <link rel="icon" href="/favicon.ico" type="image/jpeg" />
8
- <script type="module" crossorigin src="/frontend/assets/index-BsUmRveR.js"></script>
9
- <link rel="stylesheet" crossorigin href="/frontend/assets/index-DiW-h6tN.css">
10
  </head>
11
  <body>
12
  <div id="root"></div>
 
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
  <title>Play List</title>
7
  <link rel="icon" href="/favicon.ico" type="image/jpeg" />
8
+ <script type="module" crossorigin src="/frontend/assets/index-BRxg-Ey6.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/frontend/assets/index-HvMqkWIl.css">
10
  </head>
11
  <body>
12
  <div id="root"></div>
frontend/mutations.js CHANGED
@@ -127,6 +127,46 @@ export function useSavePlayMutation({
127
  });
128
  }
129
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  export function useSaveRoleMutation({ auth, setStatus }) {
131
  const qc = useQueryClient();
132
  return useMutation({
 
127
  });
128
  }
129
 
130
+ export function useDeletePlayMutation({ auth, setStatus }) {
131
+ const qc = useQueryClient();
132
+ return useMutation({
133
+ mutationFn: (kinkId) =>
134
+ api(`/users/${auth.userId}/plays/${encodeURIComponent(kinkId)}`, {
135
+ method: "DELETE",
136
+ headers: authHeaders(auth.token),
137
+ }),
138
+ onMutate: async (kinkId) => {
139
+ const playKey = String(kinkId);
140
+ const previousUser = qc.getQueryData(["user", auth.userId]);
141
+ const previousBoard = qc.getQueryData(["board", auth.userId]);
142
+ qc.setQueryData(["user", auth.userId], (old) => {
143
+ if (!old?.plays) return old;
144
+ const { [playKey]: _drop, ...rest } = old.plays;
145
+ return { ...old, plays: rest };
146
+ });
147
+ qc.setQueryData(["board", auth.userId], (old) => {
148
+ if (!old) return old;
149
+ const next = { ...old };
150
+ for (const col of ["to_me", "by_me", "together", "hidden", "no_go"]) {
151
+ if (Array.isArray(next[col])) next[col] = next[col].filter((item) => String(item?.id) !== playKey);
152
+ }
153
+ return next;
154
+ });
155
+ return { previousUser, previousBoard };
156
+ },
157
+ onSuccess: () => {
158
+ qc.invalidateQueries({ queryKey: ["board", auth.userId] });
159
+ qc.invalidateQueries({ queryKey: ["user", auth.userId] });
160
+ setStatus?.("Removed.");
161
+ },
162
+ onError: (error, _kinkId, context) => {
163
+ if (context?.previousUser) qc.setQueryData(["user", auth.userId], context.previousUser);
164
+ if (context?.previousBoard) qc.setQueryData(["board", auth.userId], context.previousBoard);
165
+ setStatus?.(error.message);
166
+ },
167
+ });
168
+ }
169
+
170
  export function useSaveRoleMutation({ auth, setStatus }) {
171
  const qc = useQueryClient();
172
  return useMutation({
frontend/my-plays-similar.js CHANGED
@@ -15,3 +15,20 @@ export function planSimilarTentativeAdd(currentPlays, similarKinkId, parentDirec
15
  directions: tentativeSimilarDirections(parentDirections),
16
  };
17
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  directions: tentativeSimilarDirections(parentDirections),
16
  };
17
  }
18
+
19
+ export function openSimilarKinkInline({
20
+ similarKinkId,
21
+ parentDirections,
22
+ currentPlays,
23
+ onRate,
24
+ onInspect,
25
+ onMarkTentative,
26
+ }) {
27
+ const plan = planSimilarTentativeAdd(currentPlays, similarKinkId, parentDirections);
28
+ if (plan.shouldTentativeAdd) {
29
+ onRate(similarKinkId, "curious", plan.directions);
30
+ onMarkTentative?.(similarKinkId);
31
+ }
32
+ onInspect(similarKinkId);
33
+ return plan;
34
+ }
frontend/my-plays.js CHANGED
@@ -1,9 +1,10 @@
1
  import React, { useState, useMemo, useCallback, useEffect, useRef } from "react";
2
  import { html, UI_REACTIONS, DIRECTION_PILLS } from "./constants.js";
3
- import { PlayChip, ReactionBar, DirectionPills, SearchOverlay } from "./components.js";
4
  import { useBoardQuery, useSearchQuery, useDetailQuery, useSimilarQuery } from "./queries.js";
5
  import { uiReactionFor } from "./api.js";
6
- import { planSimilarTentativeAdd } from "./my-plays-similar.js";
 
7
  import {
8
  DIRECTION_COLUMN_ORDER,
9
  directionColumnsForPlay,
@@ -33,12 +34,16 @@ function PlaysTouchColumnMoves({ kinkId, onDirectionColumnChange }) {
33
  `;
34
  }
35
 
 
 
 
 
36
  function positivePlaysUnique(board) {
37
  const seen = new Set();
38
  const out = [];
39
  for (const col of DIR_ORDER) {
40
  for (const item of board[col] || []) {
41
- if (seen.has(item.id)) continue;
42
  seen.add(item.id);
43
  out.push(item);
44
  }
@@ -50,21 +55,15 @@ function boardItemsById(board) {
50
  const map = new Map();
51
  for (const col of [...DIR_ORDER, "hidden", "no_go"]) {
52
  for (const item of board?.[col] || []) {
53
- if (!item?.id || map.has(item.id)) continue;
54
  map.set(item.id, item);
55
  }
56
  }
57
  return map;
58
  }
59
 
60
- function visibleSimilarItems(similarData) {
61
- return (similarData?.items || []).filter((item) => item?.kink && !item.kink.is_scenario).slice(0, 6);
62
- }
63
-
64
- function openSimilarKink({ similarKinkId, parentDirections, currentPlays, onRate, onInspect }) {
65
- const plan = planSimilarTentativeAdd(currentPlays, similarKinkId, parentDirections);
66
- if (plan.shouldTentativeAdd) onRate(similarKinkId, "curious", plan.directions);
67
- onInspect(similarKinkId);
68
  }
69
 
70
  function DirectionsColumnBoard({
@@ -259,170 +258,6 @@ function CollapsibleSection(props) {
259
  `;
260
  }
261
 
262
- function ImageGrid({ assets }) {
263
- const [revealed, setRevealed] = useState(false);
264
- const [idx, setIdx] = useState(0);
265
- const [failedAssetUrls, setFailedAssetUrls] = useState(() => new Set());
266
- const visibleAssets = assets.filter((asset) => !failedAssetUrls.has(asset.asset_url));
267
- const n = visibleAssets.length;
268
- const cur = visibleAssets[idx];
269
-
270
- useEffect(() => {
271
- setIdx(0);
272
- setFailedAssetUrls(new Set());
273
- }, [assets?.length, assets?.[0]?.asset_url]);
274
-
275
- const go = useCallback((delta, e) => {
276
- e?.stopPropagation?.();
277
- if (n < 2) return;
278
- setIdx((i) => (i + delta + n) % n);
279
- }, [n]);
280
-
281
- const markAssetFailed = useCallback((assetUrl) => {
282
- if (!assetUrl) return;
283
- setFailedAssetUrls((prev) => {
284
- if (prev.has(assetUrl)) return prev;
285
- const next = new Set(prev);
286
- next.add(assetUrl);
287
- return next;
288
- });
289
- }, []);
290
-
291
- useEffect(() => {
292
- setIdx((i) => (n ? Math.min(Math.max(0, i), n - 1) : 0));
293
- }, [n]);
294
-
295
- if (!n) return null;
296
-
297
- return html`
298
- <div className="play-assets">
299
- ${!revealed
300
- ? html`
301
- <button
302
- type="button"
303
- className="ghost sm"
304
- onClick=${(e) => { e.stopPropagation(); setRevealed(true); }}
305
- >Show ${n} photo${n > 1 ? "s" : ""}</button>
306
- `
307
- : html`
308
- <div className="play-assets-carousel">
309
- ${n > 1
310
- ? html`
311
- <button type="button" className="play-edge play-edge--left" aria-label="Previous photo" onClick=${(e) => go(-1, e)} />
312
- <button type="button" className="play-edge play-edge--right" aria-label="Next photo" onClick=${(e) => go(1, e)} />
313
- `
314
- : null}
315
- <div className="play-assets-slide">
316
- <img
317
- loading="eager"
318
- decoding="async"
319
- src=${cur.asset_url}
320
- alt=""
321
- draggable=${false}
322
- onError=${() => markAssetFailed(cur.asset_url)}
323
- />
324
- </div>
325
- ${n > 1
326
- ? html`
327
- <div className="play-assets-dots" aria-hidden="true">
328
- ${visibleAssets.map((_, i) => html`<span key=${`pd-${i}`} className=${i === idx ? "on" : ""} />`)}
329
- </div>
330
- <div className="play-assets-badge">${idx + 1} / ${n}</div>
331
- `
332
- : null}
333
- </div>
334
- `}
335
- </div>
336
- `;
337
- }
338
-
339
- function PlayInspectorCallout({
340
- item,
341
- play,
342
- detailData,
343
- detailLoading,
344
- similarData,
345
- onRate,
346
- onDirectionToggle,
347
- onOpenSimilar,
348
- onOpenScenarios,
349
- onClose,
350
- showImages,
351
- inspectorRef,
352
- }) {
353
- if (!item?.id) return null;
354
- const reaction = uiReactionFor(play?.interest_state);
355
- const reactionMeta = UI_REACTIONS.find((entry) => entry.ui === reaction);
356
- const directions = play?.directions || [];
357
- const detail = detailData?.id === item.id ? detailData : null;
358
- const defText = (detail?.definition || detail?.detail_summary || item.summary || "").trim();
359
- const similarItems = visibleSimilarItems(similarData);
360
- const scenarioCount = Number(detail?.scenario_child_count || item.scenario_child_count || 0);
361
-
362
- return html`
363
- <section className="plays-inspector card stack" data-testid="plays-inspector" ref=${inspectorRef}>
364
- <div className="plays-inspector-head">
365
- <div className="plays-inspector-copy">
366
- <div className="tiny plays-inspector-label">Selected play</div>
367
- <div className="plays-inspector-title-row">
368
- <span className="plays-inspector-emoji">${reactionMeta?.icon || ""}</span>
369
- <h3 className="plays-inspector-title">${item.name}</h3>
370
- </div>
371
- </div>
372
- <button type="button" className="ghost sm" data-testid="plays-inspector-close" onClick=${onClose}>Close</button>
373
- </div>
374
- ${defText
375
- ? html`<div className="detail-def plays-inspector-definition">${defText}</div>`
376
- : detailLoading
377
- ? html`<div className="tiny muted">Loading details…</div>`
378
- : null}
379
- <div className="plays-inspector-actions">
380
- <${ReactionBar} current=${reaction} onRate=${(rating) => onRate(item.id, rating)} />
381
- <${DirectionPills}
382
- directions=${directions}
383
- locked=${!reaction}
384
- onToggle=${(direction) => onDirectionToggle(item.id, direction)}
385
- />
386
- </div>
387
- <div className="plays-inspector-actions-row">
388
- <button
389
- type="button"
390
- className="ghost sm"
391
- data-testid="plays-open-scenarios"
392
- disabled=${scenarioCount < 1}
393
- onClick=${() => onOpenScenarios?.(item.id)}
394
- >${scenarioCount > 0 ? `Explore scenarios (${scenarioCount})` : "No scenarios yet"}</button>
395
- <button className="ghost sm" onClick=${() => onRate(item.id, "not_interested")}>Remove from list</button>
396
- </div>
397
- ${similarItems.length
398
- ? html`
399
- <div className="plays-inspector-section">
400
- <div className="tiny plays-inspector-label">Similar</div>
401
- <div className="chips">
402
- ${similarItems.map((entry) => html`
403
- <button
404
- key=${entry.kink.id}
405
- type="button"
406
- className="chip"
407
- title=${entry.kink.name}
408
- onClick=${(e) => {
409
- e.stopPropagation();
410
- onOpenSimilar(entry.kink.id, directions);
411
- }}
412
- >
413
- ${entry.kink.name}
414
- </button>
415
- `)}
416
- </div>
417
- </div>
418
- `
419
- : null}
420
- ${detail?.is_extreme ? html`<div className="safety-badge extreme">Significant risk</div>` : null}
421
- ${showImages && detail?.assets?.length ? html`<${ImageGrid} assets=${detail.assets} />` : null}
422
- </section>
423
- `;
424
- }
425
-
426
  export function MyPlaysView({
427
  auth,
428
  currentPlays,
@@ -432,10 +267,12 @@ export function MyPlaysView({
432
  onDirectionColumnChange,
433
  onNavigate,
434
  onOpenScenarios,
 
435
  focusKinkId,
436
  onFocusKinkConsumed,
437
  }) {
438
  const [inspectedId, setInspectedId] = useState("");
 
439
  const [dragOver, setDragOver] = useState(null);
440
  const [searchOpen, setSearchOpen] = useState(false);
441
  const [searchInput, setSearchInput] = useState("");
@@ -445,7 +282,15 @@ export function MyPlaysView({
445
 
446
  const boardQuery = useBoardQuery(auth);
447
  const board = boardQuery.data || {};
448
- const boardById = useMemo(() => boardItemsById(board), [board]);
 
 
 
 
 
 
 
 
449
  const inspectedItem = boardById.get(inspectedId) || null;
450
  const detailQuery = useDetailQuery(inspectedId);
451
  const similarQuery = useSimilarQuery(inspectedId);
@@ -453,10 +298,10 @@ export function MyPlaysView({
453
  const totalCount = Object.keys(currentPlays || {}).length;
454
 
455
  const nearDupHints = useMemo(() => {
456
- const pos = positivePlaysUnique(board);
457
- const combined = [...pos, ...(board.hidden || []), ...(board.no_go || [])];
458
  return nearDuplicateHintsById(combined);
459
- }, [board]);
460
 
461
  useEffect(() => {
462
  if (!inspectedId) return;
@@ -483,17 +328,55 @@ export function MyPlaysView({
483
  searchDebounceRef.current = setTimeout(() => setSearchDebounced(value.trim()), 300);
484
  }, []);
485
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
486
  const handleOpenSimilar = useCallback((similarKinkId, directions) => {
487
- openSimilarKink({
488
  similarKinkId,
489
  parentDirections: directions,
490
  currentPlays,
491
  onRate,
492
  onInspect: setInspectedId,
 
493
  });
494
- }, [currentPlays, onRate]);
 
 
 
 
 
 
 
 
495
 
496
  const empty = totalCount === 0;
 
497
 
498
  return html`
499
  <div data-testid="plays-view">
@@ -514,13 +397,15 @@ export function MyPlaysView({
514
  detailData=${detailQuery.data}
515
  detailLoading=${detailQuery.isFetching}
516
  similarData=${similarQuery.data}
517
- onRate=${onRate}
518
- onDirectionToggle=${onDirectionToggle}
519
  onOpenSimilar=${handleOpenSimilar}
520
  onOpenScenarios=${onOpenScenarios}
521
  onClose=${() => setInspectedId("")}
522
  showImages=${showImages}
523
  inspectorRef=${inspectorRef}
 
 
524
  />
525
  `
526
  : null}
@@ -533,7 +418,7 @@ export function MyPlaysView({
533
  ? html`<div className="empty">No plays yet. Go to Discover to find things you like.</div>`
534
  : html`
535
  <${DirectionsColumnBoard}
536
- board=${board}
537
  currentPlays=${currentPlays}
538
  inspectedId=${inspectedId}
539
  onInspect=${setInspectedId}
@@ -548,8 +433,8 @@ export function MyPlaysView({
548
 
549
  <${CollapsibleSection}
550
  title="Hidden"
551
- emoji="\uD83D\uDE48"
552
- items=${board.hidden || []}
553
  inspectedId=${inspectedId}
554
  onInspect=${setInspectedId}
555
  nearDupHints=${nearDupHints}
@@ -560,8 +445,8 @@ export function MyPlaysView({
560
  />
561
  <${CollapsibleSection}
562
  title="No-go"
563
- emoji="\uD83D\uDEAB"
564
- items=${board.no_go || []}
565
  inspectedId=${inspectedId}
566
  onInspect=${setInspectedId}
567
  nearDupHints=${nearDupHints}
 
1
  import React, { useState, useMemo, useCallback, useEffect, useRef } from "react";
2
  import { html, UI_REACTIONS, DIRECTION_PILLS } from "./constants.js";
3
+ import { PlayChip, SearchOverlay } from "./components.js";
4
  import { useBoardQuery, useSearchQuery, useDetailQuery, useSimilarQuery } from "./queries.js";
5
  import { uiReactionFor } from "./api.js";
6
+ import { openSimilarKinkInline } from "./my-plays-similar.js";
7
+ import { PlayInspectorCallout } from "./play-inspector.js";
8
  import {
9
  DIRECTION_COLUMN_ORDER,
10
  directionColumnsForPlay,
 
34
  `;
35
  }
36
 
37
+ function isScenarioItem(item) {
38
+ return Boolean(item?.is_scenario);
39
+ }
40
+
41
  function positivePlaysUnique(board) {
42
  const seen = new Set();
43
  const out = [];
44
  for (const col of DIR_ORDER) {
45
  for (const item of board[col] || []) {
46
+ if (seen.has(item.id) || isScenarioItem(item)) continue;
47
  seen.add(item.id);
48
  out.push(item);
49
  }
 
55
  const map = new Map();
56
  for (const col of [...DIR_ORDER, "hidden", "no_go"]) {
57
  for (const item of board?.[col] || []) {
58
+ if (!item?.id || map.has(item.id) || isScenarioItem(item)) continue;
59
  map.set(item.id, item);
60
  }
61
  }
62
  return map;
63
  }
64
 
65
+ function filterScenariosFromBucket(items) {
66
+ return (items || []).filter((item) => !isScenarioItem(item));
 
 
 
 
 
 
67
  }
68
 
69
  function DirectionsColumnBoard({
 
258
  `;
259
  }
260
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
  export function MyPlaysView({
262
  auth,
263
  currentPlays,
 
267
  onDirectionColumnChange,
268
  onNavigate,
269
  onOpenScenarios,
270
+ onDeletePlay,
271
  focusKinkId,
272
  onFocusKinkConsumed,
273
  }) {
274
  const [inspectedId, setInspectedId] = useState("");
275
+ const [tentativeIds, setTentativeIds] = useState(() => new Set());
276
  const [dragOver, setDragOver] = useState(null);
277
  const [searchOpen, setSearchOpen] = useState(false);
278
  const [searchInput, setSearchInput] = useState("");
 
282
 
283
  const boardQuery = useBoardQuery(auth);
284
  const board = boardQuery.data || {};
285
+ const filteredBoard = useMemo(() => ({
286
+ ...board,
287
+ to_me: filterScenariosFromBucket(board.to_me),
288
+ by_me: filterScenariosFromBucket(board.by_me),
289
+ together: filterScenariosFromBucket(board.together),
290
+ hidden: filterScenariosFromBucket(board.hidden),
291
+ no_go: filterScenariosFromBucket(board.no_go),
292
+ }), [board]);
293
+ const boardById = useMemo(() => boardItemsById(filteredBoard), [filteredBoard]);
294
  const inspectedItem = boardById.get(inspectedId) || null;
295
  const detailQuery = useDetailQuery(inspectedId);
296
  const similarQuery = useSimilarQuery(inspectedId);
 
298
  const totalCount = Object.keys(currentPlays || {}).length;
299
 
300
  const nearDupHints = useMemo(() => {
301
+ const pos = positivePlaysUnique(filteredBoard);
302
+ const combined = [...pos, ...(filteredBoard.hidden || []), ...(filteredBoard.no_go || [])];
303
  return nearDuplicateHintsById(combined);
304
+ }, [filteredBoard]);
305
 
306
  useEffect(() => {
307
  if (!inspectedId) return;
 
328
  searchDebounceRef.current = setTimeout(() => setSearchDebounced(value.trim()), 300);
329
  }, []);
330
 
331
+ const markTentative = useCallback((id) => {
332
+ setTentativeIds((prev) => {
333
+ if (prev.has(id)) return prev;
334
+ const next = new Set(prev);
335
+ next.add(id);
336
+ return next;
337
+ });
338
+ }, []);
339
+
340
+ const clearTentative = useCallback((id) => {
341
+ setTentativeIds((prev) => {
342
+ if (!prev.has(id)) return prev;
343
+ const next = new Set(prev);
344
+ next.delete(id);
345
+ return next;
346
+ });
347
+ }, []);
348
+
349
+ const handleRateInInspector = useCallback((kinkId, rating, dirs) => {
350
+ clearTentative(kinkId);
351
+ onRate(kinkId, rating, dirs);
352
+ }, [clearTentative, onRate]);
353
+
354
+ const handleDirectionToggleInInspector = useCallback((kinkId, direction) => {
355
+ clearTentative(kinkId);
356
+ onDirectionToggle(kinkId, direction);
357
+ }, [clearTentative, onDirectionToggle]);
358
+
359
  const handleOpenSimilar = useCallback((similarKinkId, directions) => {
360
+ openSimilarKinkInline({
361
  similarKinkId,
362
  parentDirections: directions,
363
  currentPlays,
364
  onRate,
365
  onInspect: setInspectedId,
366
+ onMarkTentative: markTentative,
367
  });
368
+ }, [currentPlays, onRate, markTentative]);
369
+
370
+ const handleUndoTentative = useCallback(() => {
371
+ if (!inspectedId) return;
372
+ const target = inspectedId;
373
+ clearTentative(target);
374
+ setInspectedId("");
375
+ onDeletePlay?.(target);
376
+ }, [inspectedId, clearTentative, onDeletePlay]);
377
 
378
  const empty = totalCount === 0;
379
+ const isInspectedTentative = inspectedItem ? tentativeIds.has(inspectedItem.id) : false;
380
 
381
  return html`
382
  <div data-testid="plays-view">
 
397
  detailData=${detailQuery.data}
398
  detailLoading=${detailQuery.isFetching}
399
  similarData=${similarQuery.data}
400
+ onRate=${handleRateInInspector}
401
+ onDirectionToggle=${handleDirectionToggleInInspector}
402
  onOpenSimilar=${handleOpenSimilar}
403
  onOpenScenarios=${onOpenScenarios}
404
  onClose=${() => setInspectedId("")}
405
  showImages=${showImages}
406
  inspectorRef=${inspectorRef}
407
+ tentative=${isInspectedTentative}
408
+ onUndoTentative=${onDeletePlay ? handleUndoTentative : undefined}
409
  />
410
  `
411
  : null}
 
418
  ? html`<div className="empty">No plays yet. Go to Discover to find things you like.</div>`
419
  : html`
420
  <${DirectionsColumnBoard}
421
+ board=${filteredBoard}
422
  currentPlays=${currentPlays}
423
  inspectedId=${inspectedId}
424
  onInspect=${setInspectedId}
 
433
 
434
  <${CollapsibleSection}
435
  title="Hidden"
436
+ emoji="🙈"
437
+ items=${filteredBoard.hidden || []}
438
  inspectedId=${inspectedId}
439
  onInspect=${setInspectedId}
440
  nearDupHints=${nearDupHints}
 
445
  />
446
  <${CollapsibleSection}
447
  title="No-go"
448
+ emoji="🚫"
449
+ items=${filteredBoard.no_go || []}
450
  inspectedId=${inspectedId}
451
  onInspect=${setInspectedId}
452
  nearDupHints=${nearDupHints}
frontend/play-inspector.js ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect, useCallback } from "react";
2
+ import { html, UI_REACTIONS } from "./constants.js";
3
+ import { ReactionBar, DirectionPills } from "./components.js";
4
+ import { uiReactionFor } from "./api.js";
5
+
6
+ export function visibleSimilarItems(similarData) {
7
+ return (similarData?.items || []).filter((item) => item?.kink && !item.kink.is_scenario).slice(0, 6);
8
+ }
9
+
10
+ function ImageGrid({ assets }) {
11
+ const [revealed, setRevealed] = useState(false);
12
+ const [idx, setIdx] = useState(0);
13
+ const [failedAssetUrls, setFailedAssetUrls] = useState(() => new Set());
14
+ const visibleAssets = assets.filter((asset) => !failedAssetUrls.has(asset.asset_url));
15
+ const n = visibleAssets.length;
16
+ const cur = visibleAssets[idx];
17
+
18
+ useEffect(() => {
19
+ setIdx(0);
20
+ setFailedAssetUrls(new Set());
21
+ }, [assets?.length, assets?.[0]?.asset_url]);
22
+
23
+ const go = useCallback((delta, e) => {
24
+ e?.stopPropagation?.();
25
+ if (n < 2) return;
26
+ setIdx((i) => (i + delta + n) % n);
27
+ }, [n]);
28
+
29
+ const markAssetFailed = useCallback((assetUrl) => {
30
+ if (!assetUrl) return;
31
+ setFailedAssetUrls((prev) => {
32
+ if (prev.has(assetUrl)) return prev;
33
+ const next = new Set(prev);
34
+ next.add(assetUrl);
35
+ return next;
36
+ });
37
+ }, []);
38
+
39
+ useEffect(() => {
40
+ setIdx((i) => (n ? Math.min(Math.max(0, i), n - 1) : 0));
41
+ }, [n]);
42
+
43
+ if (!n) return null;
44
+
45
+ return html`
46
+ <div className="play-assets">
47
+ ${!revealed
48
+ ? html`
49
+ <button
50
+ type="button"
51
+ className="ghost sm"
52
+ onClick=${(e) => { e.stopPropagation(); setRevealed(true); }}
53
+ >Show ${n} photo${n > 1 ? "s" : ""}</button>
54
+ `
55
+ : html`
56
+ <div className="play-assets-carousel">
57
+ ${n > 1
58
+ ? html`
59
+ <button type="button" className="play-edge play-edge--left" aria-label="Previous photo" onClick=${(e) => go(-1, e)} />
60
+ <button type="button" className="play-edge play-edge--right" aria-label="Next photo" onClick=${(e) => go(1, e)} />
61
+ `
62
+ : null}
63
+ <div className="play-assets-slide">
64
+ <img
65
+ loading="eager"
66
+ decoding="async"
67
+ src=${cur.asset_url}
68
+ alt=""
69
+ draggable=${false}
70
+ onError=${() => markAssetFailed(cur.asset_url)}
71
+ />
72
+ </div>
73
+ ${n > 1
74
+ ? html`
75
+ <div className="play-assets-dots" aria-hidden="true">
76
+ ${visibleAssets.map((_, i) => html`<span key=${`pd-${i}`} className=${i === idx ? "on" : ""} />`)}
77
+ </div>
78
+ <div className="play-assets-badge">${idx + 1} / ${n}</div>
79
+ `
80
+ : null}
81
+ </div>
82
+ `}
83
+ </div>
84
+ `;
85
+ }
86
+
87
+ export function PlayInspectorCallout({
88
+ item,
89
+ play,
90
+ detailData,
91
+ detailLoading,
92
+ similarData,
93
+ onRate,
94
+ onDirectionToggle,
95
+ onOpenSimilar,
96
+ onOpenScenarios,
97
+ onClose,
98
+ showImages,
99
+ inspectorRef,
100
+ tentative = false,
101
+ onUndoTentative,
102
+ }) {
103
+ if (!item?.id) return null;
104
+ const reaction = uiReactionFor(play?.interest_state);
105
+ const reactionMeta = UI_REACTIONS.find((entry) => entry.ui === reaction);
106
+ const directions = play?.directions || [];
107
+ const detail = detailData?.id === item.id ? detailData : null;
108
+ const defText = (detail?.definition || detail?.detail_summary || item.summary || "").trim();
109
+ const similarItems = visibleSimilarItems(similarData);
110
+ const scenarioCount = Number(detail?.scenario_child_count || item.scenario_child_count || 0);
111
+ const sectionClass = `plays-inspector card stack${tentative ? " plays-inspector--tentative" : ""}`;
112
+
113
+ return html`
114
+ <section className=${sectionClass} data-testid="plays-inspector" ref=${inspectorRef}>
115
+ <div className="plays-inspector-head">
116
+ <div className="plays-inspector-copy">
117
+ <div className="tiny plays-inspector-label">Selected play</div>
118
+ <div className="plays-inspector-title-row">
119
+ <span className="plays-inspector-emoji">${reactionMeta?.icon || ""}</span>
120
+ <h3 className="plays-inspector-title">${item.name}</h3>
121
+ </div>
122
+ ${tentative ? html`
123
+ <span className="tentative-badge" data-testid="plays-inspector-tentative">
124
+ Tentative — added as Curious
125
+ </span>
126
+ ` : null}
127
+ </div>
128
+ <div className="plays-inspector-head-actions">
129
+ ${tentative && onUndoTentative ? html`
130
+ <button
131
+ type="button"
132
+ className="ghost sm"
133
+ data-testid="plays-inspector-undo"
134
+ onClick=${onUndoTentative}
135
+ >Undo</button>
136
+ ` : null}
137
+ <button type="button" className="ghost sm" data-testid="plays-inspector-close" onClick=${onClose}>Close</button>
138
+ </div>
139
+ </div>
140
+ ${defText
141
+ ? html`<div className="detail-def plays-inspector-definition">${defText}</div>`
142
+ : detailLoading
143
+ ? html`<div className="tiny muted">Loading details…</div>`
144
+ : null}
145
+ <div className="plays-inspector-actions">
146
+ <${ReactionBar} current=${reaction} onRate=${(rating) => onRate(item.id, rating)} />
147
+ <${DirectionPills}
148
+ directions=${directions}
149
+ locked=${!reaction}
150
+ onToggle=${(direction) => onDirectionToggle(item.id, direction)}
151
+ />
152
+ </div>
153
+ <div className="plays-inspector-actions-row">
154
+ <button
155
+ type="button"
156
+ className="ghost sm"
157
+ data-testid="plays-open-scenarios"
158
+ disabled=${scenarioCount < 1}
159
+ onClick=${() => onOpenScenarios?.(item.id)}
160
+ >${scenarioCount > 0 ? `Explore scenarios (${scenarioCount})` : "No scenarios yet"}</button>
161
+ <button className="ghost sm" onClick=${() => onRate(item.id, "not_interested")}>Remove from list</button>
162
+ </div>
163
+ ${similarItems.length
164
+ ? html`
165
+ <div className="plays-inspector-section">
166
+ <div className="tiny plays-inspector-label">Similar</div>
167
+ <div className="chips">
168
+ ${similarItems.map((entry) => html`
169
+ <button
170
+ key=${entry.kink.id}
171
+ type="button"
172
+ className="chip"
173
+ title=${entry.kink.name}
174
+ onClick=${(e) => {
175
+ e.stopPropagation();
176
+ onOpenSimilar(entry.kink.id, directions);
177
+ }}
178
+ >
179
+ ${entry.kink.name}
180
+ </button>
181
+ `)}
182
+ </div>
183
+ </div>
184
+ `
185
+ : null}
186
+ ${detail?.is_extreme ? html`<div className="safety-badge extreme">Significant risk</div>` : null}
187
+ ${showImages && detail?.assets?.length ? html`<${ImageGrid} assets=${detail.assets} />` : null}
188
+ </section>
189
+ `;
190
+ }
frontend/styles.css CHANGED
@@ -836,6 +836,45 @@ button.tinder-card__body.tinder-card__body--tappable {
836
  scroll-margin-top: 12px;
837
  }
838
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
839
  .plays-inspector-head {
840
  display: flex;
841
  align-items: flex-start;
 
836
  scroll-margin-top: 12px;
837
  }
838
 
839
+ .plays-inspector--tentative {
840
+ border: 1px dashed var(--accent, #888);
841
+ background: rgba(255, 255, 255, 0.02);
842
+ }
843
+
844
+ .plays-inspector-head-actions {
845
+ display: flex;
846
+ gap: 8px;
847
+ flex-wrap: wrap;
848
+ align-items: center;
849
+ }
850
+
851
+ .tentative-badge {
852
+ display: inline-block;
853
+ margin-top: 4px;
854
+ padding: 2px 8px;
855
+ font-size: 0.72rem;
856
+ border-radius: 999px;
857
+ border: 1px dashed var(--accent, #888);
858
+ color: var(--muted);
859
+ }
860
+
861
+ .play-chip--expandable {
862
+ cursor: pointer;
863
+ }
864
+
865
+ .play-chip--expanded {
866
+ background: rgba(255, 255, 255, 0.04);
867
+ }
868
+
869
+ .play-chip-inspector {
870
+ width: 100%;
871
+ margin-top: 8px;
872
+ }
873
+
874
+ .play-chip-inspector--floating {
875
+ margin-top: 12px;
876
+ }
877
+
878
  .plays-inspector-head {
879
  display: flex;
880
  align-items: flex-start;
frontend/together.js CHANGED
@@ -1,15 +1,19 @@
1
- import React, { useState, useEffect, useMemo } from "react";
2
  import { html, UI_REACTIONS, VIEWS, partnerGroupChipLabel, partnerGroupPartnerIdsTitle } from "./constants.js";
3
  import { ReactionBar } from "./components.js";
4
- import { useGroupsQuery, useOverlapQuery, usePartnerBoardQuery } from "./queries.js";
5
  import { uiReactionFor } from "./api.js";
6
  import { preferredPartnerGroupId, resolvedPartnerGroups } from "./partner-groups.js";
 
 
7
 
8
  export function TogetherView({
9
  auth,
10
  user,
11
  currentPlays,
12
  onRate,
 
 
13
  onNavigate,
14
  onOpenScenarios,
15
  setStatus,
@@ -254,6 +258,10 @@ export function TogetherView({
254
  currentPlays=${currentPlays}
255
  auth=${auth}
256
  participantCount=${participantCount}
 
 
 
 
257
  onNavigate=${onNavigate}
258
  onOpenScenarios=${onOpenScenarios}
259
  />`
@@ -295,6 +303,10 @@ function SharedTab({
295
  currentPlays,
296
  auth,
297
  participantCount = 2,
 
 
 
 
298
  onNavigate,
299
  onOpenScenarios,
300
  }) {
@@ -304,6 +316,67 @@ function SharedTab({
304
  ? "No play is shared across everyone in this connection yet."
305
  : "No shared plays yet. Both of you need a few overlapping likes first.";
306
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
307
  if (!matches.length && !scenarioMatches.length) return html`<div className="empty">${emptyHint}</div>`;
308
 
309
  return html`
@@ -319,20 +392,23 @@ function SharedTab({
319
  const partnerUi = uiReactionFor(state);
320
  return (myReaction === "love" || myReaction === "like") && (partnerUi === "love" || partnerUi === "like");
321
  });
322
- const openPlay = () => onNavigate?.(VIEWS.PLAYS, kink.id);
 
 
323
  return html`
324
  <div
325
  key=${kink.id}
326
- className="play-chip"
327
  data-testid=${`together-shared-direct-${kink.id}`}
328
- style=${{ width: "100%" }}
329
  role="button"
330
  tabIndex="0"
331
- onClick=${openPlay}
 
332
  onKeyDown=${(e) => {
333
  if (e.key !== "Enter" && e.key !== " ") return;
334
  e.preventDefault();
335
- openPlay();
336
  }}
337
  >
338
  <span className="chip-name" style=${{ flex: 1 }}>${kink.name}</span>
@@ -345,12 +421,66 @@ function SharedTab({
345
  </span>
346
  `)}
347
  </span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
348
  </div>
349
  `;
350
  })}
351
  `
352
  : null}
353
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
354
  ${scenarioMatches.length
355
  ? html`
356
  <div className="tiny together-section-label" data-testid="together-shared-scenarios-section">
@@ -417,7 +547,11 @@ function WorthExploringTab({
417
  const similar = overlap?.similar_matches || [];
418
 
419
  if (!related.length && !scenarioRelated.length && !similar.length) {
420
- return html`<div className="empty">Add more overlap to unlock nearby ideas worth exploring together.</div>`;
 
 
 
 
421
  }
422
 
423
  return html`
@@ -565,25 +699,51 @@ function TheirListTab({
565
  : null;
566
 
567
  if (!boardPartnerId) {
568
- return html`<div>${picker}<div className="empty">No partner has turned on full-list sharing for this connection.</div></div>`;
 
 
 
 
 
 
 
 
 
 
 
569
  }
570
- if (isPending) return html`<div>${picker}<div className="empty">Loading…</div></div>`;
571
  if (isError) {
572
  const forbidden = error?.status === 403;
573
- return html`<div>${picker}<div className="empty">${forbidden ? "This partner has not shared their list here." : (error?.message || "Could not load this list.")}</div></div>`;
 
 
 
 
 
 
 
 
 
 
 
 
574
  }
575
- if (!data) return html`<div>${picker}<div className="empty">Could not load this list.</div></div>`;
576
 
577
  const allPlays = [...(data.to_me || []), ...(data.by_me || []), ...(data.together || [])];
578
  const seen = new Set();
579
  const deduped = allPlays.filter((play) => {
580
- if (seen.has(play.id)) return false;
581
  seen.add(play.id);
582
  return true;
583
  });
584
 
585
  if (!deduped.length) {
586
- return html`<div>${picker}<div className="empty">This shared list is empty.</div></div>`;
 
 
 
 
 
587
  }
588
 
589
  return html`
 
1
+ import React, { useState, useEffect, useMemo, useCallback } from "react";
2
  import { html, UI_REACTIONS, VIEWS, partnerGroupChipLabel, partnerGroupPartnerIdsTitle } from "./constants.js";
3
  import { ReactionBar } from "./components.js";
4
+ import { useGroupsQuery, useOverlapQuery, usePartnerBoardQuery, useDetailQuery, useSimilarQuery } from "./queries.js";
5
  import { uiReactionFor } from "./api.js";
6
  import { preferredPartnerGroupId, resolvedPartnerGroups } from "./partner-groups.js";
7
+ import { PlayInspectorCallout } from "./play-inspector.js";
8
+ import { openSimilarKinkInline } from "./my-plays-similar.js";
9
 
10
  export function TogetherView({
11
  auth,
12
  user,
13
  currentPlays,
14
  onRate,
15
+ onDirectionToggle,
16
+ onDeletePlay,
17
  onNavigate,
18
  onOpenScenarios,
19
  setStatus,
 
258
  currentPlays=${currentPlays}
259
  auth=${auth}
260
  participantCount=${participantCount}
261
+ showImages=${false}
262
+ onRate=${onRate}
263
+ onDirectionToggle=${onDirectionToggle}
264
+ onDeletePlay=${onDeletePlay}
265
  onNavigate=${onNavigate}
266
  onOpenScenarios=${onOpenScenarios}
267
  />`
 
303
  currentPlays,
304
  auth,
305
  participantCount = 2,
306
+ showImages = false,
307
+ onRate,
308
+ onDirectionToggle,
309
+ onDeletePlay,
310
  onNavigate,
311
  onOpenScenarios,
312
  }) {
 
316
  ? "No play is shared across everyone in this connection yet."
317
  : "No shared plays yet. Both of you need a few overlapping likes first.";
318
 
319
+ const [expandedKinkId, setExpandedKinkId] = useState("");
320
+ const [tentativeIds, setTentativeIds] = useState(() => new Set());
321
+ const detailQuery = useDetailQuery(expandedKinkId);
322
+ const similarQuery = useSimilarQuery(expandedKinkId);
323
+
324
+ const matchById = useMemo(() => {
325
+ const map = new Map();
326
+ for (const m of matches) {
327
+ if (m?.id) map.set(m.id, m);
328
+ }
329
+ return map;
330
+ }, [matches]);
331
+ const expandedItem = expandedKinkId ? matchById.get(expandedKinkId) || { id: expandedKinkId, name: detailQuery.data?.name || "" } : null;
332
+
333
+ const markTentative = useCallback((id) => {
334
+ setTentativeIds((prev) => {
335
+ if (prev.has(id)) return prev;
336
+ const next = new Set(prev);
337
+ next.add(id);
338
+ return next;
339
+ });
340
+ }, []);
341
+
342
+ const clearTentative = useCallback((id) => {
343
+ setTentativeIds((prev) => {
344
+ if (!prev.has(id)) return prev;
345
+ const next = new Set(prev);
346
+ next.delete(id);
347
+ return next;
348
+ });
349
+ }, []);
350
+
351
+ const handleRateInline = useCallback((kinkId, rating, dirs) => {
352
+ clearTentative(kinkId);
353
+ onRate?.(kinkId, rating, dirs);
354
+ }, [clearTentative, onRate]);
355
+
356
+ const handleDirectionToggleInline = useCallback((kinkId, direction) => {
357
+ clearTentative(kinkId);
358
+ onDirectionToggle?.(kinkId, direction);
359
+ }, [clearTentative, onDirectionToggle]);
360
+
361
+ const handleOpenSimilarInline = useCallback((similarKinkId, dirs) => {
362
+ openSimilarKinkInline({
363
+ similarKinkId,
364
+ parentDirections: dirs,
365
+ currentPlays,
366
+ onRate,
367
+ onInspect: setExpandedKinkId,
368
+ onMarkTentative: markTentative,
369
+ });
370
+ }, [currentPlays, onRate, markTentative]);
371
+
372
+ const handleUndoInline = useCallback(() => {
373
+ if (!expandedKinkId) return;
374
+ const target = expandedKinkId;
375
+ clearTentative(target);
376
+ setExpandedKinkId("");
377
+ onDeletePlay?.(target);
378
+ }, [expandedKinkId, clearTentative, onDeletePlay]);
379
+
380
  if (!matches.length && !scenarioMatches.length) return html`<div className="empty">${emptyHint}</div>`;
381
 
382
  return html`
 
392
  const partnerUi = uiReactionFor(state);
393
  return (myReaction === "love" || myReaction === "like") && (partnerUi === "love" || partnerUi === "like");
394
  });
395
+ const expanded = expandedKinkId === kink.id;
396
+ const toggle = () => setExpandedKinkId((prev) => (prev === kink.id ? "" : kink.id));
397
+ const isTentative = tentativeIds.has(kink.id);
398
  return html`
399
  <div
400
  key=${kink.id}
401
+ className=${`play-chip play-chip--expandable${expanded ? " play-chip--expanded" : ""}`}
402
  data-testid=${`together-shared-direct-${kink.id}`}
403
+ style=${{ width: "100%", flexWrap: "wrap" }}
404
  role="button"
405
  tabIndex="0"
406
+ aria-expanded=${expanded}
407
+ onClick=${toggle}
408
  onKeyDown=${(e) => {
409
  if (e.key !== "Enter" && e.key !== " ") return;
410
  e.preventDefault();
411
+ toggle();
412
  }}
413
  >
414
  <span className="chip-name" style=${{ flex: 1 }}>${kink.name}</span>
 
421
  </span>
422
  `)}
423
  </span>
424
+ ${expanded
425
+ ? html`
426
+ <div
427
+ className="play-chip-inspector"
428
+ data-testid=${`together-shared-direct-inspector-${kink.id}`}
429
+ style=${{ width: "100%" }}
430
+ onClick=${(e) => e.stopPropagation()}
431
+ onKeyDown=${(e) => e.stopPropagation()}
432
+ >
433
+ <${PlayInspectorCallout}
434
+ item=${expandedItem || kink}
435
+ play=${currentPlays[kink.id] || {}}
436
+ detailData=${detailQuery.data}
437
+ detailLoading=${detailQuery.isFetching}
438
+ similarData=${similarQuery.data}
439
+ onRate=${handleRateInline}
440
+ onDirectionToggle=${handleDirectionToggleInline}
441
+ onOpenSimilar=${handleOpenSimilarInline}
442
+ onOpenScenarios=${onOpenScenarios}
443
+ onClose=${() => setExpandedKinkId("")}
444
+ showImages=${showImages}
445
+ tentative=${isTentative}
446
+ onUndoTentative=${onDeletePlay ? handleUndoInline : undefined}
447
+ />
448
+ </div>
449
+ `
450
+ : null}
451
  </div>
452
  `;
453
  })}
454
  `
455
  : null}
456
 
457
+ ${expandedKinkId && !matchById.has(expandedKinkId)
458
+ ? html`
459
+ <div
460
+ className="play-chip-inspector play-chip-inspector--floating"
461
+ data-testid=${`together-shared-direct-inspector-${expandedKinkId}`}
462
+ onClick=${(e) => e.stopPropagation()}
463
+ onKeyDown=${(e) => e.stopPropagation()}
464
+ >
465
+ <${PlayInspectorCallout}
466
+ item=${expandedItem || { id: expandedKinkId, name: detailQuery.data?.name || "" }}
467
+ play=${currentPlays[expandedKinkId] || {}}
468
+ detailData=${detailQuery.data}
469
+ detailLoading=${detailQuery.isFetching}
470
+ similarData=${similarQuery.data}
471
+ onRate=${handleRateInline}
472
+ onDirectionToggle=${handleDirectionToggleInline}
473
+ onOpenSimilar=${handleOpenSimilarInline}
474
+ onOpenScenarios=${onOpenScenarios}
475
+ onClose=${() => setExpandedKinkId("")}
476
+ showImages=${showImages}
477
+ tentative=${tentativeIds.has(expandedKinkId)}
478
+ onUndoTentative=${onDeletePlay ? handleUndoInline : undefined}
479
+ />
480
+ </div>
481
+ `
482
+ : null}
483
+
484
  ${scenarioMatches.length
485
  ? html`
486
  <div className="tiny together-section-label" data-testid="together-shared-scenarios-section">
 
547
  const similar = overlap?.similar_matches || [];
548
 
549
  if (!related.length && !scenarioRelated.length && !similar.length) {
550
+ return html`
551
+ <div className="empty" data-testid="together-explore-empty">
552
+ No nearby ideas yet. <strong>Rate more in Discover</strong>, or ask your partner to share theirs in Together.
553
+ </div>
554
+ `;
555
  }
556
 
557
  return html`
 
699
  : null;
700
 
701
  if (!boardPartnerId) {
702
+ return html`
703
+ <div data-testid="together-their-list-panel">
704
+ ${picker}
705
+ <div className="empty">
706
+ No partner has turned on full-list sharing for this connection yet.
707
+ <div className="tiny muted">See the <strong>Share link</strong> above to invite or remind them.</div>
708
+ </div>
709
+ </div>
710
+ `;
711
+ }
712
+ if (isPending) {
713
+ return html`<div data-testid="together-their-list-panel">${picker}<div className="empty">Loading…</div></div>`;
714
  }
 
715
  if (isError) {
716
  const forbidden = error?.status === 403;
717
+ return html`
718
+ <div data-testid="together-their-list-panel">
719
+ ${picker}
720
+ <div className="empty">
721
+ ${forbidden
722
+ ? html`This partner has not turned on sharing yet. Ask <code>${boardPartnerId.slice(0, 12)}…</code> to enable it from their Together → Privacy.`
723
+ : (error?.message || "Could not load this list.")}
724
+ </div>
725
+ </div>
726
+ `;
727
+ }
728
+ if (!data) {
729
+ return html`<div data-testid="together-their-list-panel">${picker}<div className="empty">Could not load this list.</div></div>`;
730
  }
 
731
 
732
  const allPlays = [...(data.to_me || []), ...(data.by_me || []), ...(data.together || [])];
733
  const seen = new Set();
734
  const deduped = allPlays.filter((play) => {
735
+ if (seen.has(play.id) || play?.is_scenario) return false;
736
  seen.add(play.id);
737
  return true;
738
  });
739
 
740
  if (!deduped.length) {
741
+ return html`
742
+ <div data-testid="together-their-list-panel">
743
+ ${picker}
744
+ <div className="empty">This shared list is empty. Ask them to rate a few in Discover.</div>
745
+ </div>
746
+ `;
747
  }
748
 
749
  return html`
scripts/playwright_product_flow.py CHANGED
@@ -240,8 +240,13 @@ def create_profile_in_browser(page: Page, *, path: str = "") -> Credentials:
240
  expect(create_btn).to_be_visible(timeout=min(30000, t_onboard))
241
  create_btn.click()
242
  expect(page.get_by_test_id("onboarding-setup")).to_be_visible(timeout=t_onboard)
 
 
 
 
 
243
  creds = Credentials(
244
- user_id=page.get_by_test_id("onboarding-created-user-id").inner_text().strip(),
245
  private_token=page.get_by_test_id("onboarding-created-private-token").inner_text().strip(),
246
  )
247
  page.get_by_test_id("onboarding-start-starter").click()
@@ -763,11 +768,323 @@ def scenario_couple_audit(browser: Browser) -> dict[str, object]:
763
  right_context.close()
764
 
765
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
766
  SCENARIOS: dict[str, Callable[[Browser], dict[str, object]]] = {
767
  "onboarding": lambda browser: _run_in_fresh_context(browser, scenario_onboarding_create_button),
768
  "starter": lambda browser: _run_in_fresh_context(browser, scenario_starter_gate),
769
  "settings": lambda browser: _run_in_fresh_context(browser, scenario_settings_controls),
770
  "couple": scenario_couple_audit,
 
771
  }
772
 
773
 
 
240
  expect(create_btn).to_be_visible(timeout=min(30000, t_onboard))
241
  create_btn.click()
242
  expect(page.get_by_test_id("onboarding-setup")).to_be_visible(timeout=t_onboard)
243
+ user_id = page.get_by_test_id("onboarding-created-user-id").inner_text().strip()
244
+ reveal_btn = page.get_by_test_id("onboarding-reveal-token")
245
+ if reveal_btn.count():
246
+ reveal_btn.click()
247
+ expect(page.get_by_test_id("onboarding-created-private-token")).to_be_visible(timeout=15000)
248
  creds = Credentials(
249
+ user_id=user_id,
250
  private_token=page.get_by_test_id("onboarding-created-private-token").inner_text().strip(),
251
  )
252
  page.get_by_test_id("onboarding-start-starter").click()
 
768
  right_context.close()
769
 
770
 
771
+ def scenario_couple_adversarial(browser: Browser) -> dict[str, object]:
772
+ """Couple flow with many kinks, conflicts, and the Same-Plays / My-Plays similar-tentative-add path.
773
+
774
+ Adds adversarial coverage on top of ``scenario_couple_audit``:
775
+ * both partners seeded past 80 likes (recommender stress + bigger overlap pool)
776
+ * curated love/love overlap of ~9 known-name kinks → asserted in Together > Shared
777
+ * one love(left)/hard_no(right) conflict → must be ABSENT from Same Plays
778
+ * clicking a Same Plays chip lands on the My Plays inspector for that kink
779
+ * clicking a 'Similar' chip in My Plays tentatively adds the similar kink
780
+ as ``curious`` with the parent's directions, then opens it in the inspector
781
+ * ``visibleSimilarItems`` invariant: no scenario kinks in the Similar chip set
782
+ """
783
+ overlap_targets = [
784
+ ("kissing", "Kissing"),
785
+ ("massage", "Massage"),
786
+ ("oral sex", "Oral Sex"),
787
+ ("anal", "Anal"),
788
+ ("spanking", "Spanking"),
789
+ ("blindfolds", "Blindfolds"),
790
+ ("role play", "Role Play"),
791
+ ("hair pulling", "Hair Pulling"),
792
+ ("cuddling", "Cuddling"),
793
+ ]
794
+ conflict_candidates = [("biting", "Biting"), ("hickeys", "Hickeys"), ("tickling", "Tickling")]
795
+ right_only_options = [("foot worship", "Foot Worship"), ("lingerie", "Lingerie"), ("rope bondage", "Rope Bondage")]
796
+
797
+ left = create_user()
798
+ right = create_user()
799
+ seed_user_with_recommendations(left, 80)
800
+ seed_user_with_recommendations(right, 80)
801
+
802
+ overlap_pairs: list[tuple[str, str]] = []
803
+ for query, expected in overlap_targets:
804
+ try:
805
+ kink_id = find_kink_id(query, expected)
806
+ except AssertionError:
807
+ continue
808
+ save_play(left, kink_id, "love", ["together"])
809
+ save_play(right, kink_id, "love", ["together"])
810
+ overlap_pairs.append((kink_id, expected))
811
+ if len(overlap_pairs) < 5:
812
+ raise AssertionError(f"Could not seed enough overlap kinks: only {len(overlap_pairs)} matched")
813
+
814
+ overlap_id_set = {kid for kid, _ in overlap_pairs}
815
+ conflict_pair: tuple[str, str] | None = None
816
+ for query, expected in conflict_candidates:
817
+ try:
818
+ kink_id = find_kink_id(query, expected)
819
+ except AssertionError:
820
+ continue
821
+ if kink_id in overlap_id_set:
822
+ continue
823
+ save_play(left, kink_id, "love", ["together"])
824
+ save_play(right, kink_id, "hard_no", [])
825
+ conflict_pair = (kink_id, expected)
826
+ break
827
+ if conflict_pair is None:
828
+ raise AssertionError("Could not seed a love/hard_no conflict pair (no candidate matched the catalog)")
829
+
830
+ right_only_pair: tuple[str, str] | None = None
831
+ for query, expected in right_only_options:
832
+ try:
833
+ kink_id = find_kink_id(query, expected)
834
+ except AssertionError:
835
+ continue
836
+ if kink_id in overlap_id_set or kink_id == conflict_pair[0]:
837
+ continue
838
+ ensure_play_absent(left, kink_id)
839
+ save_play(right, kink_id, "love", ["together"])
840
+ right_only_pair = (kink_id, expected)
841
+ break
842
+ if right_only_pair is None:
843
+ raise AssertionError("Could not seed a right-only kink for Their List coverage")
844
+
845
+ parent_id, parent_name, scenario_a, scenario_b = find_parent_with_scenario_pair()
846
+ save_play(left, parent_id, "like", ["together"])
847
+ save_play(right, parent_id, "like", ["together"])
848
+ save_scenario(left, parent_id, scenario_a, "like", ["together"])
849
+ save_scenario(right, parent_id, scenario_a, "like", ["together"])
850
+ save_scenario(right, parent_id, scenario_b, "like", ["together"])
851
+
852
+ send_partner_request(right, left.user_id)
853
+ wait_for(
854
+ lambda: right.user_id in get_user(left).get("incoming_partner_requests", []),
855
+ label="left incoming partner request",
856
+ )
857
+ accept_partner_request(left, right.user_id)
858
+ wait_for(
859
+ lambda: right.user_id in get_user(left).get("partners", []),
860
+ label="linked partners",
861
+ )
862
+ group_id = group_id_for_pair(left, right.user_id)
863
+ toggle_share(right, group_id, True)
864
+ wait_for(
865
+ lambda: right.user_id in next((g for g in list_groups(left) if g["id"] == group_id), {}).get("sharing_member_ids", []),
866
+ label="right sharing toggle",
867
+ )
868
+
869
+ overlap_path = f"/users/{left.user_id}/partner-groups/{urllib.parse.quote(group_id)}/overlap"
870
+ wait_for(
871
+ lambda: {item.get("id") for item in get_json(overlap_path, headers=auth_headers(left)).get("direct_matches", [])} >= overlap_id_set,
872
+ timeout_s=45.0,
873
+ label="all curated overlap kinks visible in Same Plays",
874
+ )
875
+
876
+ overlap_payload = get_json(overlap_path, headers=auth_headers(left))
877
+ direct_ids = {item.get("id") for item in overlap_payload.get("direct_matches", [])}
878
+ missing_overlap = sorted(overlap_id_set - direct_ids)
879
+ if missing_overlap:
880
+ raise AssertionError(f"Same Plays missing curated kinks: {missing_overlap}")
881
+ if conflict_pair[0] in direct_ids:
882
+ raise AssertionError(f"Conflict (love/hard_no) kink {conflict_pair[1]!r} leaked into Same Plays")
883
+ scenario_match_ids = {
884
+ item.get("scenario_kink", {}).get("id") for item in overlap_payload.get("scenario_matches", [])
885
+ }
886
+ if scenario_a not in scenario_match_ids:
887
+ raise AssertionError("Shared scenario A missing from scenario_matches")
888
+
889
+ checkpoints: list[dict[str, object]] = [
890
+ {
891
+ "stage": "seed_and_overlap",
892
+ "expected": "both users seeded past 80 likes; curated overlap appears in Same Plays; love/hard_no conflict excluded",
893
+ "actual": {
894
+ "left_play_count": len(get_user(left).get("plays", {})),
895
+ "right_play_count": len(get_user(right).get("plays", {})),
896
+ "overlap_count_api": len(direct_ids),
897
+ "curated_overlap": [name for _, name in overlap_pairs],
898
+ "conflict_excluded": conflict_pair[1],
899
+ "shared_scenario": scenario_a,
900
+ },
901
+ "summary": "API overlap matches curated love/love and excludes love/hard_no conflict",
902
+ }
903
+ ]
904
+
905
+ left_context = browser.new_context(
906
+ viewport={"width": 1280, "height": 720},
907
+ extra_http_headers={"Cache-Control": "no-cache"},
908
+ )
909
+ page = left_context.new_page()
910
+ try:
911
+ login_existing_user(page, left)
912
+ expect(page.get_by_test_id("discover-view")).to_be_visible(timeout=15000)
913
+ open_together(page)
914
+ expect(page.get_by_test_id(f"together-group-chip-{group_id}")).to_be_visible(timeout=15000)
915
+ page.get_by_test_id(f"together-group-chip-{group_id}").click()
916
+ expect(page.get_by_test_id("together-shared-panel")).to_be_visible(timeout=15000)
917
+ expect(page.get_by_test_id("together-shared-direct-section")).to_be_visible(timeout=15000)
918
+
919
+ for kink_id, name in overlap_pairs:
920
+ expect(page.get_by_test_id(f"together-shared-direct-{kink_id}")).to_be_visible(timeout=20000)
921
+ if page.get_by_test_id(f"together-shared-direct-{conflict_pair[0]}").count():
922
+ raise AssertionError(f"Conflict chip rendered in Same Plays for {conflict_pair[1]!r}")
923
+ expect(page.get_by_test_id(f"together-shared-scenario-{scenario_a}")).to_be_visible(timeout=15000)
924
+ checkpoints.append(
925
+ {
926
+ "stage": "shared_panel_render",
927
+ "expected": "every curated overlap kink renders as a Same Plays chip; conflict chip absent; shared scenario chip visible",
928
+ "actual": {
929
+ "overlap_rendered": [name for _, name in overlap_pairs],
930
+ "conflict_absent": conflict_pair[1],
931
+ "scenario_visible": scenario_a,
932
+ },
933
+ "summary": "Same Plays UI matches API overlap; love/hard_no conflict not shown",
934
+ }
935
+ )
936
+
937
+ focus_kink_id, focus_kink_name = overlap_pairs[0]
938
+ page.get_by_test_id(f"together-shared-direct-{focus_kink_id}").click()
939
+ expect(page.get_by_test_id("together-view")).to_be_visible(timeout=15000)
940
+ inline_inspector = page.get_by_test_id(f"together-shared-direct-inspector-{focus_kink_id}")
941
+ expect(inline_inspector).to_be_visible(timeout=15000)
942
+ expect(inline_inspector.locator(".plays-inspector-title")).to_have_text(focus_kink_name, timeout=15000)
943
+ if page.get_by_test_id("plays-view").count():
944
+ raise AssertionError("Same Plays chip should expand inline, not route to plays-view")
945
+ checkpoints.append(
946
+ {
947
+ "stage": "same_plays_click_expands_inline",
948
+ "expected": "clicking a Same Plays chip expands the inspector inline inside Together → Shared (no view switch)",
949
+ "actual": {"focused_kink_id": focus_kink_id, "focused_kink_name": focus_kink_name},
950
+ "summary": "Same Plays chip expanded inline with the inspector body; stayed on Together",
951
+ }
952
+ )
953
+
954
+ similar_chips = inline_inspector.locator(".chips button.chip")
955
+ try:
956
+ similar_chips.first.wait_for(state="visible", timeout=20000)
957
+ except Exception as exc:
958
+ raise AssertionError(f"No Similar chips appeared inline for {focus_kink_name!r}: {exc}") from exc
959
+ chip_count = similar_chips.count()
960
+ chip_labels = [similar_chips.nth(i).inner_text().strip() for i in range(chip_count)]
961
+ first_similar_label = chip_labels[0]
962
+
963
+ plays_before = set(get_user(left).get("plays", {}).keys())
964
+ with page.expect_response(
965
+ lambda res: res.request.method == "POST" and "/plays" in res.url,
966
+ timeout=15000,
967
+ ) as similar_resp:
968
+ similar_chips.first.click()
969
+ body = similar_resp.value
970
+ if not body.ok:
971
+ raise AssertionError(f"Tentative add POST /plays failed: status={body.status}")
972
+
973
+ wait_for(
974
+ lambda: bool(set(get_user(left).get("plays", {}).keys()) - plays_before),
975
+ timeout_s=15.0,
976
+ label="tentative add visible in user plays",
977
+ )
978
+ plays_after = get_user(left).get("plays", {})
979
+ new_ids = sorted(set(plays_after.keys()) - plays_before)
980
+ if len(new_ids) != 1:
981
+ raise AssertionError(f"Expected exactly one new play after similar click, got {new_ids}")
982
+ new_kink_id = new_ids[0]
983
+ new_play = plays_after[new_kink_id]
984
+ if new_play.get("interest_state") != "curious":
985
+ raise AssertionError(f"Tentative add wrong state: {new_play.get('interest_state')!r}")
986
+ if "together" not in (new_play.get("directions") or []):
987
+ raise AssertionError(f"Tentative add did not inherit parent direction 'together': {new_play.get('directions')}")
988
+
989
+ new_kink_detail = get_json(f"/kinks/{urllib.parse.quote(new_kink_id)}")
990
+ if new_kink_detail.get("is_scenario"):
991
+ raise AssertionError("Similar chip surfaced a scenario kink (visibleSimilarItems should filter is_scenario)")
992
+
993
+ # The chip is now expanded on the new kink; expect title to update inline.
994
+ new_inline_inspector = page.get_by_test_id(f"together-shared-direct-inspector-{new_kink_id}")
995
+ expect(new_inline_inspector).to_be_visible(timeout=15000)
996
+ expect(new_inline_inspector.locator(".plays-inspector-title")).to_have_text(
997
+ new_kink_detail.get("name", first_similar_label), timeout=15000
998
+ )
999
+ expect(new_inline_inspector.get_by_test_id("plays-inspector-tentative")).to_be_visible(timeout=10000)
1000
+
1001
+ scenario_chips = [label for label in chip_labels if label.lower().startswith("scenario:")]
1002
+ if scenario_chips:
1003
+ raise AssertionError(f"Similar chip set contained scenario-prefixed entries: {scenario_chips}")
1004
+
1005
+ checkpoints.append(
1006
+ {
1007
+ "stage": "shared_inline_similar_tentative_add",
1008
+ "expected": "clicking a Similar chip on the inline Same-Plays inspector POSTs /plays as 'curious' with parent directions, re-inspects the new kink inline, and shows a Tentative badge",
1009
+ "actual": {
1010
+ "parent_kink_id": focus_kink_id,
1011
+ "parent_kink_name": focus_kink_name,
1012
+ "similar_chip_count": chip_count,
1013
+ "tentative_kink_id": new_kink_id,
1014
+ "tentative_kink_name": new_kink_detail.get("name"),
1015
+ "tentative_state": new_play.get("interest_state"),
1016
+ "tentative_directions": new_play.get("directions"),
1017
+ "similar_is_scenario": bool(new_kink_detail.get("is_scenario")),
1018
+ "chip_labels_sample": chip_labels[:6],
1019
+ },
1020
+ "summary": "Similar-chip click tentatively saved as curious/together inline and showed the Tentative badge",
1021
+ }
1022
+ )
1023
+
1024
+ # Undo the tentative add via the new Undo button; the kink should leave the user's plays.
1025
+ undo_btn = new_inline_inspector.get_by_test_id("plays-inspector-undo")
1026
+ expect(undo_btn).to_be_visible(timeout=5000)
1027
+ with page.expect_response(
1028
+ lambda res: res.request.method == "DELETE" and f"/plays/{urllib.parse.quote(new_kink_id)}" in res.url,
1029
+ timeout=15000,
1030
+ ) as delete_resp:
1031
+ undo_btn.click()
1032
+ if not delete_resp.value.ok:
1033
+ raise AssertionError(f"Undo DELETE /plays failed: status={delete_resp.value.status}")
1034
+ wait_for(
1035
+ lambda: new_kink_id not in get_user(left).get("plays", {}),
1036
+ timeout_s=15.0,
1037
+ label="undo removes tentative play",
1038
+ )
1039
+ checkpoints.append(
1040
+ {
1041
+ "stage": "shared_inline_undo_tentative",
1042
+ "expected": "Undo on a tentative add removes the play row via DELETE /plays",
1043
+ "actual": {"undone_kink_id": new_kink_id, "undone_kink_name": new_kink_detail.get("name")},
1044
+ "summary": "Undo button removed the tentative play; user state matches pre-click",
1045
+ }
1046
+ )
1047
+
1048
+ open_together(page)
1049
+ page.get_by_test_id("together-tab-explore").click()
1050
+ expect(page.get_by_test_id("together-explore-panel")).to_be_visible(timeout=15000)
1051
+ page.get_by_test_id("together-tab-theirs").click()
1052
+ expect(page.get_by_test_id("together-their-list-panel")).to_be_visible(timeout=15000)
1053
+ expect(page.get_by_text(right_only_pair[1])).to_be_visible(timeout=15000)
1054
+ checkpoints.append(
1055
+ {
1056
+ "stage": "explore_and_theirs_render",
1057
+ "expected": "Worth Exploring + Their List populate; right-only kink visible after sharing",
1058
+ "actual": {"right_only_visible": right_only_pair[1]},
1059
+ "summary": "Explore and Their List subtabs both render and surface the partner-only item",
1060
+ }
1061
+ )
1062
+
1063
+ return {
1064
+ "summary": "adversarial couple flow: many likes, curated overlap, conflict suppression, Same-Plays inline expand, Similar tentative-add + Undo, and Worth Exploring / Their List all behaved as expected",
1065
+ "left_user_id": left.user_id,
1066
+ "right_user_id": right.user_id,
1067
+ "group_id": group_id,
1068
+ "left_play_count_final": len(plays_after),
1069
+ "right_play_count_final": len(get_user(right).get("plays", {})),
1070
+ "overlap_kinks": [name for _, name in overlap_pairs],
1071
+ "conflict_kink": conflict_pair[1],
1072
+ "right_only_kink": right_only_pair[1],
1073
+ "shared_scenario_id": scenario_a,
1074
+ "tentative_kink": new_kink_detail.get("name"),
1075
+ "tentative_kink_id": new_kink_id,
1076
+ "checkpoints": checkpoints,
1077
+ }
1078
+ finally:
1079
+ left_context.close()
1080
+
1081
+
1082
  SCENARIOS: dict[str, Callable[[Browser], dict[str, object]]] = {
1083
  "onboarding": lambda browser: _run_in_fresh_context(browser, scenario_onboarding_create_button),
1084
  "starter": lambda browser: _run_in_fresh_context(browser, scenario_starter_gate),
1085
  "settings": lambda browser: _run_in_fresh_context(browser, scenario_settings_controls),
1086
  "couple": scenario_couple_audit,
1087
+ "couple_adversarial": scenario_couple_adversarial,
1088
  }
1089
 
1090
 
tests/test_discover_recommendations_invariant.py CHANGED
@@ -10,7 +10,7 @@ import pytest
10
  from fastapi.testclient import TestClient
11
  from sqlmodel import Session
12
 
13
- from models import Kink, KinkContentType, SimilarityEdge
14
 
15
 
16
  def _seed_large_play_catalog(session: Session, *, n_kinks: int = 80) -> None:
@@ -71,6 +71,46 @@ def test_recommend_non_empty_after_47_distinct_ratings(big_catalog_backend):
71
  assert out_ids.isdisjoint(rated), "recommendations must not repeat already-rated kinks"
72
 
73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  def test_http_recommendations_non_empty_after_47_ratings(
75
  monkeypatch: pytest.MonkeyPatch,
76
  big_catalog_backend,
 
10
  from fastapi.testclient import TestClient
11
  from sqlmodel import Session
12
 
13
+ from models import Kink, KinkContentType, KinkScenarioParent, SimilarityEdge
14
 
15
 
16
  def _seed_large_play_catalog(session: Session, *, n_kinks: int = 80) -> None:
 
71
  assert out_ids.isdisjoint(rated), "recommendations must not repeat already-rated kinks"
72
 
73
 
74
+ def _seed_scenario_under_parent(session: Session, *, parent_id: str, scenario_id: str) -> None:
75
+ session.add(Kink(id=parent_id, name="Parent Play", cluster="fetlife_fetish", short_definition="parent", notes="Kinksters: 1000"))
76
+ session.add(KinkContentType(kink_id=parent_id, content_kind="play", evidence="test"))
77
+ session.add(Kink(id=scenario_id, name="Parent Play scenario", cluster="fetlife_fetish", short_definition="child", notes="Kinksters: 100"))
78
+ session.add(KinkContentType(kink_id=scenario_id, content_kind="scenario", evidence="test"))
79
+ session.add(KinkScenarioParent(scenario_kink_id=scenario_id, parent_kink_id=parent_id, score=0.9, method="test"))
80
+ session.commit()
81
+
82
+
83
+ def test_recommendations_and_search_exclude_scenarios(big_catalog_backend, monkeypatch: pytest.MonkeyPatch) -> None:
84
+ """Flat recommendations and the public search must never surface scenario kinks."""
85
+ b = big_catalog_backend
86
+ parent_id, scenario_id = "scenario_parent_1", "scenario_child_1"
87
+ with Session(b.engine) as session:
88
+ _seed_scenario_under_parent(session, parent_id=parent_id, scenario_id=scenario_id)
89
+ b._invalidate_catalog_cache(force=True)
90
+ b._catalog()
91
+
92
+ monkeypatch.setenv("KINK_STORE_PATH", str(b.path))
93
+ monkeypatch.setenv("KINK_HF_REQUIRE_FULL_CATALOG", "0")
94
+ sys.modules.pop("api", None)
95
+ api_mod = importlib.import_module("api")
96
+ api_mod._backend_impl = None
97
+ client = TestClient(api_mod.app)
98
+
99
+ u = client.post("/users", json={}).json()
100
+ uid, tok = u["id"], u["private_token"]
101
+ headers = {"x-private-token": tok}
102
+
103
+ rec = client.get(f"/users/{uid}/recommendations?limit=48", headers=headers)
104
+ assert rec.status_code == 200, rec.text
105
+ rec_ids = {item["kink"]["id"] for item in rec.json().get("items") or []}
106
+ assert scenario_id not in rec_ids, f"scenario {scenario_id!r} leaked into /recommendations"
107
+
108
+ search = client.get("/search?q=scenario&limit=20")
109
+ assert search.status_code == 200, search.text
110
+ search_ids = {item["kink"]["id"] for item in search.json().get("items") or []}
111
+ assert scenario_id not in search_ids, f"scenario {scenario_id!r} leaked into /search"
112
+
113
+
114
  def test_http_recommendations_non_empty_after_47_ratings(
115
  monkeypatch: pytest.MonkeyPatch,
116
  big_catalog_backend,
tests/test_user_snapshot.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """User-state snapshot dump/restore/fingerprint round-trip tests.
2
+
3
+ The Hub I/O is exercised separately (and is a no-op when KINK_USER_SNAPSHOT_REPO is unset).
4
+ These tests stay offline: they prove the SQLite primitives (USER_TABLES preserved exactly,
5
+ catalog tables untouched) so the deployment-time integration only needs Hub auth to land.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import sqlite3
11
+ from pathlib import Path
12
+
13
+ import pytest
14
+ from sqlmodel import Session
15
+
16
+ from backend.core import Backend
17
+ from backend.user_snapshot import (
18
+ USER_TABLES,
19
+ compute_user_state_fingerprint,
20
+ dump_user_state,
21
+ restore_user_state,
22
+ user_state_is_empty,
23
+ )
24
+ from models import Kink, KinkContentType
25
+
26
+
27
+ @pytest.fixture
28
+ def store_path(tmp_path: Path) -> Path:
29
+ os.environ["KINK_SKIP_HEAVY_WARM"] = "1"
30
+ db = tmp_path / "snap.db"
31
+ b = Backend(db)
32
+ with Session(b.engine) as session:
33
+ for i in range(3):
34
+ kid = f"snap_kink_{i}"
35
+ session.add(Kink(id=kid, name=f"Snap {i}", cluster="fetlife_fetish", short_definition="t", notes="Kinksters: 5"))
36
+ session.add(KinkContentType(kink_id=kid, content_kind="play", evidence="test"))
37
+ session.commit()
38
+ return db
39
+
40
+
41
+ def _table_count(db: Path, table: str) -> int:
42
+ conn = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
43
+ try:
44
+ row = conn.execute(
45
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1",
46
+ (table,),
47
+ ).fetchone()
48
+ if not row:
49
+ return -1
50
+ return conn.execute(f'SELECT COUNT(*) FROM "{table}"').fetchone()[0]
51
+ finally:
52
+ conn.close()
53
+
54
+
55
+ def test_user_state_is_empty_on_fresh_store(store_path: Path) -> None:
56
+ assert user_state_is_empty(store_path) is True
57
+
58
+
59
+ def test_dump_restore_roundtrip_preserves_users_and_plays(store_path: Path, tmp_path: Path) -> None:
60
+ """Dump after writes restores the same rows on a fresh store; catalog rows untouched."""
61
+ b = Backend(store_path)
62
+ user = b.create_user()
63
+ b.save_play_preference(user.id, "snap_kink_0", "like", ["together"])
64
+ b.save_play_preference(user.id, "snap_kink_1", "love", ["to_me"])
65
+
66
+ out = tmp_path / "snap_out.db"
67
+ counts = dump_user_state(store_path, out)
68
+ assert counts.get("user", 0) == 1
69
+ assert counts.get("playpreference", 0) == 2
70
+
71
+ fresh = tmp_path / "fresh.db"
72
+ Backend(fresh)
73
+ with Session(Backend(fresh).engine) as session:
74
+ session.add(Kink(id="snap_kink_0", name="Snap 0", cluster="fetlife_fetish", short_definition="t", notes="Kinksters: 5"))
75
+ session.add(Kink(id="snap_kink_1", name="Snap 1", cluster="fetlife_fetish", short_definition="t", notes="Kinksters: 5"))
76
+ session.add(KinkContentType(kink_id="snap_kink_0", content_kind="play", evidence="test"))
77
+ session.add(KinkContentType(kink_id="snap_kink_1", content_kind="play", evidence="test"))
78
+ session.commit()
79
+
80
+ assert user_state_is_empty(fresh) is True
81
+ catalog_kinks_before = _table_count(fresh, "kink")
82
+
83
+ restored = restore_user_state(fresh, out)
84
+ assert restored.get("user", 0) == 1
85
+ assert restored.get("playpreference", 0) == 2
86
+ assert _table_count(fresh, "user") == 1
87
+ assert _table_count(fresh, "playpreference") == 2
88
+ assert _table_count(fresh, "kink") == catalog_kinks_before
89
+
90
+
91
+ def test_fingerprint_changes_when_user_row_added(store_path: Path) -> None:
92
+ b = Backend(store_path)
93
+ fp_before = compute_user_state_fingerprint(store_path)
94
+ user = b.create_user()
95
+ b.save_play_preference(user.id, "snap_kink_0", "like", ["together"])
96
+ fp_after = compute_user_state_fingerprint(store_path)
97
+ assert fp_before != fp_after
98
+
99
+
100
+ def test_dump_excludes_catalog_tables(store_path: Path, tmp_path: Path) -> None:
101
+ """USER_TABLES must contain no catalog table names — guards against accidentally shipping the catalog."""
102
+ catalog_tables = {"kink", "kinkalias", "kinkasset", "similarityedge", "kinkscenarioparent", "fetlifeuserfetish"}
103
+ assert catalog_tables.isdisjoint(set(USER_TABLES))
104
+
105
+ out = tmp_path / "snap_only.db"
106
+ dump_user_state(store_path, out)
107
+ snap_tables = set()
108
+ conn = sqlite3.connect(out)
109
+ try:
110
+ snap_tables = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")}
111
+ finally:
112
+ conn.close()
113
+ assert snap_tables.isdisjoint(catalog_tables), f"snapshot leaked catalog tables: {snap_tables & catalog_tables}"
114
+
115
+
116
+ def test_restore_is_upsert_not_destructive(store_path: Path, tmp_path: Path) -> None:
117
+ """A second restore on top of existing rows replaces matching PKs without erroring on duplicates."""
118
+ b = Backend(store_path)
119
+ user = b.create_user()
120
+ b.save_play_preference(user.id, "snap_kink_0", "like", ["together"])
121
+ out = tmp_path / "snap_out.db"
122
+ dump_user_state(store_path, out)
123
+ # Restore on top of itself (no-op for content; checks INSERT OR REPLACE accepts duplicate PKs).
124
+ counts = restore_user_state(store_path, out)
125
+ assert counts.get("user", 0) >= 1
126
+ assert _table_count(store_path, "user") == 1
127
+ assert _table_count(store_path, "playpreference") == 1