Perplexed7675 commited on
Commit
2c11dc3
·
verified ·
1 Parent(s): b7d5e1e

Sync from kink_cli (Docker Space)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +2 -0
  2. .gitignore +6 -0
  3. .ruff_cache/0.15.11/14315220232248816887 +0 -0
  4. .ruff_cache/0.15.11/1920814609139379236 +0 -0
  5. .ruff_cache/0.15.11/5434035610807442273 +0 -0
  6. .ruff_cache/0.15.11/7290032257184656914 +0 -0
  7. .semgrepignore +3 -0
  8. Dockerfile +6 -1
  9. Makefile +15 -1
  10. README.md +2 -1
  11. api.py +25 -14
  12. backend/enrichment.py +1 -1
  13. backend/hf_bootstrap.py +11 -4
  14. deploy/hf/README.md +2 -1
  15. deploy/hf/entrypoint.sh +3 -1
  16. deploy/hf/seed/hf_bundled_store.db +1 -1
  17. frontend/api.js +1 -1
  18. frontend/app.js +19 -19
  19. frontend/board-utils.test.mjs +191 -1
  20. frontend/components.js +8 -5
  21. frontend/constants.js +2 -2
  22. frontend/direction-logic.test.mjs +11 -0
  23. frontend/discover-flow.test.mjs +5 -0
  24. frontend/discover-scenarios.test.mjs +21 -0
  25. frontend/discover-state.test.mjs +13 -0
  26. frontend/discover.js +9 -9
  27. frontend/dist/assets/index-B1No5j6D.css +1 -0
  28. frontend/dist/assets/index-CselhGyO.js +0 -0
  29. frontend/dist/assets/web-vitals.attribution-C_HyTrBb.js +1 -0
  30. frontend/dist/index.html +14 -0
  31. frontend/index.html +2 -4
  32. frontend/mutations.js +3 -3
  33. frontend/my-plays.js +8 -8
  34. frontend/partner-groups.test.mjs +15 -0
  35. frontend/queries.js +2 -2
  36. frontend/scenarios.js +6 -6
  37. frontend/scenarios.test.mjs +52 -0
  38. frontend/settings.js +4 -4
  39. frontend/starter-flow.test.mjs +66 -0
  40. frontend/stryker.conf.mjs +28 -0
  41. frontend/telemetry.js +1 -1
  42. frontend/together.js +64 -7
  43. package-lock.json +0 -0
  44. package.json +22 -0
  45. pyproject.toml +29 -0
  46. scripts/__init__.py +1 -0
  47. scripts/audit_data_quality.py +2 -1
  48. scripts/audit_runner.py +45 -8
  49. scripts/build_hf_demo_db.py +7 -7
  50. scripts/check_mutmut_stats.py +49 -0
.dockerignore CHANGED
@@ -1,6 +1,8 @@
1
  .venv
2
  .hypothesis
3
  .git
 
 
4
  **/__pycache__
5
  .pytest_cache
6
  data/
 
1
  .venv
2
  .hypothesis
3
  .git
4
+ .env
5
+ .env.local
6
  **/__pycache__
7
  .pytest_cache
8
  data/
.gitignore CHANGED
@@ -19,10 +19,16 @@ data/recsys/
19
  __pycache__/
20
  *.pyc
21
  *.egg-info/
 
 
22
 
23
  # Node
24
  node_modules/
25
  audit/node_modules/
 
 
 
 
26
 
27
  # IDE (Cursor): track `.cursor/hooks`, `.cursor/rules`, and `hooks.json`; ignore volatile state only.
28
  .cursor/hooks/state/
 
19
  __pycache__/
20
  *.pyc
21
  *.egg-info/
22
+ mutants/
23
+ .mutmut-cache/
24
 
25
  # Node
26
  node_modules/
27
  audit/node_modules/
28
+ frontend/dist/
29
+ .stryker-tmp/
30
+ frontend/.stryker-tmp/
31
+ stryker.log
32
 
33
  # IDE (Cursor): track `.cursor/hooks`, `.cursor/rules`, and `hooks.json`; ignore volatile state only.
34
  .cursor/hooks/state/
.ruff_cache/0.15.11/14315220232248816887 CHANGED
Binary files a/.ruff_cache/0.15.11/14315220232248816887 and b/.ruff_cache/0.15.11/14315220232248816887 differ
 
.ruff_cache/0.15.11/1920814609139379236 CHANGED
Binary files a/.ruff_cache/0.15.11/1920814609139379236 and b/.ruff_cache/0.15.11/1920814609139379236 differ
 
.ruff_cache/0.15.11/5434035610807442273 CHANGED
Binary files a/.ruff_cache/0.15.11/5434035610807442273 and b/.ruff_cache/0.15.11/5434035610807442273 differ
 
.ruff_cache/0.15.11/7290032257184656914 CHANGED
Binary files a/.ruff_cache/0.15.11/7290032257184656914 and b/.ruff_cache/0.15.11/7290032257184656914 differ
 
.semgrepignore CHANGED
@@ -1,6 +1,9 @@
1
  .venv/
 
2
  data/
3
  audit/
 
 
4
  frontend/node_modules/
5
  **/__pycache__/
6
  *.egg-info/
 
1
  .venv/
2
+ node_modules/
3
  data/
4
  audit/
5
+ .cursor/
6
+ frontend/dist/
7
  frontend/node_modules/
8
  **/__pycache__/
9
  *.egg-info/
Dockerfile CHANGED
@@ -20,7 +20,12 @@ ENV KINK_MEDIA_STRICT=0
20
  ENV KINK_HF_SPACE_IMAGE=1
21
 
22
  COPY . /app
23
- RUN pip install --no-cache-dir pip setuptools wheel \
 
 
 
 
 
24
  && pip install --no-cache-dir . \
25
  && chmod +x /app/deploy/hf/entrypoint.sh
26
 
 
20
  ENV KINK_HF_SPACE_IMAGE=1
21
 
22
  COPY . /app
23
+ RUN apt-get update \
24
+ && apt-get install -y --no-install-recommends nodejs npm \
25
+ && rm -rf /var/lib/apt/lists/* \
26
+ && npm ci \
27
+ && npm run build \
28
+ && pip install --no-cache-dir pip setuptools wheel \
29
  && pip install --no-cache-dir . \
30
  && chmod +x /app/deploy/hf/entrypoint.sh
31
 
Makefile CHANGED
@@ -5,6 +5,11 @@ PARSER_ROOT ?= $(HOME)/PycharmProjects/parser
5
  PYTHON ?= $(shell if test -x "$(CURDIR)/.venv/bin/python"; then echo "$(CURDIR)/.venv/bin/python"; else command -v python3; fi)
6
  SEMGREP_BIN ?= $(CURDIR)/.venv/bin/semgrep
7
  SEMGREP_CONFIG ?= $(CURDIR)/semgrep.yml
 
 
 
 
 
8
  # Broad-scan excludes: parser-parity rules that are not yet adopted repo-wide (trim over time).
9
  SEMGREP_KINK_EXCLUDE_RULES ?= \
10
  python-enforce-readable-line-length \
@@ -26,7 +31,7 @@ SEMGREP_KINK_EXCLUDE_RULES ?= \
26
 
27
  SEMGREP_EXCLUDE_FLAGS := $(foreach r,$(SEMGREP_KINK_EXCLUDE_RULES),--exclude-rule $(r))
28
 
29
- .PHONY: semgrep-kink semgrep-recsys-core semgrep-recsys-strict ruff-kink dead-kink policy-kink test recsys-benchmark adv-probes-http prove-hf-media-placeholder publish-hf-verify verify-hf-live
30
 
31
  # Python sources only (exclude data/, audit/node_modules, deploy artifacts).
32
  semgrep-kink:
@@ -79,6 +84,15 @@ policy-kink: ruff-kink semgrep-recsys-strict
79
  test:
80
  "$(PYTHON)" -m pytest -q
81
 
 
 
 
 
 
 
 
 
 
82
  # Build HF Docker image and assert missing ``/media/fetlife_fetishes/...`` returns JPEG fallback
83
  # (``KINK_HF_SPACE_IMAGE`` + strict env ignored). Requires Docker.
84
  prove-hf-media-placeholder:
 
5
  PYTHON ?= $(shell if test -x "$(CURDIR)/.venv/bin/python"; then echo "$(CURDIR)/.venv/bin/python"; else command -v python3; fi)
6
  SEMGREP_BIN ?= $(CURDIR)/.venv/bin/semgrep
7
  SEMGREP_CONFIG ?= $(CURDIR)/semgrep.yml
8
+ MUTMUT_STATS ?= $(CURDIR)/mutants/mutmut-cicd-stats.json
9
+ MUTMUT_MIN_SCORE ?= 100
10
+ MUTMUT_MAX_SURVIVED ?= 0
11
+ MUTMUT_MAX_TIMEOUT ?= 0
12
+ MUTMUT_MAX_NO_TESTS ?= 0
13
  # Broad-scan excludes: parser-parity rules that are not yet adopted repo-wide (trim over time).
14
  SEMGREP_KINK_EXCLUDE_RULES ?= \
15
  python-enforce-readable-line-length \
 
31
 
32
  SEMGREP_EXCLUDE_FLAGS := $(foreach r,$(SEMGREP_KINK_EXCLUDE_RULES),--exclude-rule $(r))
33
 
34
+ .PHONY: semgrep-kink semgrep-recsys-core semgrep-recsys-strict ruff-kink dead-kink policy-kink test mutation-python recsys-benchmark adv-probes-http prove-hf-media-placeholder publish-hf-verify verify-hf-live
35
 
36
  # Python sources only (exclude data/, audit/node_modules, deploy artifacts).
37
  semgrep-kink:
 
84
  test:
85
  "$(PYTHON)" -m pytest -q
86
 
87
+ mutation-python:
88
+ "$(PYTHON)" -m mutmut run
89
+ "$(PYTHON)" -m mutmut export-cicd-stats
90
+ "$(PYTHON)" "$(CURDIR)/scripts/check_mutmut_stats.py" "$(MUTMUT_STATS)" \
91
+ --min-score "$(MUTMUT_MIN_SCORE)" \
92
+ --max-survived "$(MUTMUT_MAX_SURVIVED)" \
93
+ --max-timeout "$(MUTMUT_MAX_TIMEOUT)" \
94
+ --max-no-tests "$(MUTMUT_MAX_NO_TESTS)"
95
+
96
  # Build HF Docker image and assert missing ``/media/fetlife_fetishes/...`` returns JPEG fallback
97
  # (``KINK_HF_SPACE_IMAGE`` + strict env ignored). Requires Docker.
98
  prove-hf-media-placeholder:
README.md CHANGED
@@ -35,8 +35,9 @@ This repo runs as a **Docker Space** with the **same FastAPI backend** as local
35
  | `KINK_CATALOG_URL_BEARER` | Optional bearer for private **HTTP** catalog URLs. |
36
  | `B2_KEY_ID`, `B2_APPLICATION_KEY`, `B2_BUCKET`, `B2_REGION` | B2 S3 credentials (Space secrets; **parser**-compatible). With `KINK_B2_OBJECT_KEY`, downloads the catalog without a public URL. |
37
  | `KINK_B2_OBJECT_KEY` | Object path in the bucket (default `kink/catalog/store_slim.db`). |
38
- | `KINK_HF_DATASET_REPO` | Hub dataset id (set with full-catalog mode when you do not use URL or B2). |
39
  | `KINK_HF_DATASET_FILENAME` | Remote filename in the dataset (e.g. `store_slim.db`). |
 
40
  | `HF_TOKEN` | Required if the Hub dataset is **private** (not needed for public HTTPS URLs). |
41
  | `KINK_CORS_ORIGINS` | Only if you split frontend/API origins. Same-origin Space UI usually needs no change. |
42
  | `KINK_RECSYS_SOURCE` | Optional. When set and the app is **not** constructed with `recsys_settings=`, overrides `recsys/settings.json` for `source` (`graph` / `ots` / `hybrid`). |
 
35
  | `KINK_CATALOG_URL_BEARER` | Optional bearer for private **HTTP** catalog URLs. |
36
  | `B2_KEY_ID`, `B2_APPLICATION_KEY`, `B2_BUCKET`, `B2_REGION` | B2 S3 credentials (Space secrets; **parser**-compatible). With `KINK_B2_OBJECT_KEY`, downloads the catalog without a public URL. |
37
  | `KINK_B2_OBJECT_KEY` | Object path in the bucket (default `kink/catalog/store_slim.db`). |
38
+ | `KINK_HF_DATASET_REPO` | Hub dataset id (with `KINK_HF_REQUIRE_FULL_CATALOG=1`, or with `KINK_HF_USE_HUB_DATASET=1` when full catalog is off). |
39
  | `KINK_HF_DATASET_FILENAME` | Remote filename in the dataset (e.g. `store_slim.db`). |
40
+ | `KINK_HF_USE_HUB_DATASET` | **`1`** = Hub download even when `KINK_HF_REQUIRE_FULL_CATALOG=0`. If unset, a leftover `KINK_HF_DATASET_REPO` variable is ignored and the **bundled** seed is used. |
41
  | `HF_TOKEN` | Required if the Hub dataset is **private** (not needed for public HTTPS URLs). |
42
  | `KINK_CORS_ORIGINS` | Only if you split frontend/API origins. Same-origin Space UI usually needs no change. |
43
  | `KINK_RECSYS_SOURCE` | Optional. When set and the app is **not** constructed with `recsys_settings=`, overrides `recsys/settings.json` for `source` (`graph` / `ots` / `hybrid`). |
api.py CHANGED
@@ -18,13 +18,15 @@ from sse_starlette.sse import EventSourceResponse
18
  from backend import Backend, VALID_RATINGS
19
  from backend.hf_bootstrap import ensure_store_db
20
 
21
- _data_dir = Path(__file__).resolve().parent / "data"
 
22
  # App uses the slim DB (local: scripts/build_slim_store.py). Override with KINK_STORE_PATH.
23
  # On HF: point KINK_STORE_PATH at a mounted bucket path; Dockerfile defaults to bundled seed (set KINK_HF_DATASET_* for Hub catalog).
24
  _default_store = Path(os.environ["KINK_STORE_PATH"]) if os.environ.get("KINK_STORE_PATH") else _data_dir / "store_slim.db"
25
- FRONTEND_PATH = Path(__file__).resolve().parent / "frontend" / "index.html"
26
- FRONTEND_DIR = Path(__file__).resolve().parent / "frontend"
27
- MEDIA_ROOT = Path(__file__).resolve().parent / "data" / "cached_assets"
 
28
  ADMIN_SECRET = os.environ.get("KINK_ADMIN_SECRET", "")
29
 
30
  _MEDIA_CACHE = "public, max-age=86400, immutable"
@@ -32,14 +34,23 @@ _MEDIA_FALLBACK_CACHE = "public, max-age=3600"
32
 
33
 
34
  def _frontend_static_cache_control(target: Path) -> str:
35
- """Browser e2e and local iteration can set ``KINK_FRONTEND_NO_CACHE=1`` so module graphs always match disk."""
36
  if os.environ.get("KINK_FRONTEND_NO_CACHE") == "1":
37
  return "no-store"
38
- if target.suffix in {".js", ".css"}:
39
- return "public, max-age=300, stale-while-revalidate=60"
40
  return "no-store"
41
 
42
 
 
 
 
 
 
 
 
 
 
43
  def _hf_space_published_image() -> bool:
44
  """True in the Hugging Face Space Docker image (``Dockerfile`` sets ``KINK_HF_SPACE_IMAGE=1``).
45
 
@@ -172,21 +183,19 @@ app.add_middleware(
172
  @app.middleware("http")
173
  async def add_security_headers(request: Request, call_next):
174
  response = await call_next(request)
175
- # connect-src: ES module graph (esm.sh), dynamic import (e.g. web-vitals from unpkg), fetch, beacon, WS.
176
  connect_src = " ".join(
177
  sorted(
178
  {
179
  "'self'",
180
- "https://esm.sh",
181
- "https://unpkg.com",
182
  *(origin.strip() for origin in cors_origins if origin.strip()),
183
  }
184
  )
185
  )
186
  csp = (
187
- "default-src 'self' https://esm.sh https://unpkg.com data:; "
188
  "base-uri 'self'; frame-ancestors 'none'; form-action 'self'; frame-src 'none'; object-src 'none'; "
189
- "script-src 'self' https://esm.sh https://unpkg.com; script-src-attr 'none'; "
190
  "style-src 'self' 'unsafe-inline'; style-src-attr 'unsafe-inline'; "
191
  "img-src 'self' data: https:; media-src 'self' data: https:; font-src 'self' data: https:; "
192
  f"connect-src {connect_src}; "
@@ -361,13 +370,15 @@ async def run_blocking(func, /, *args, **kwargs):
361
 
362
  @app.get("/", response_class=HTMLResponse, responses={200: {"content": {"text/html": {}}}})
363
  def frontend() -> FileResponse:
 
364
  return FileResponse(FRONTEND_PATH, headers={"Cache-Control": "no-store"})
365
 
366
 
367
  @app.get("/frontend/{filepath:path}")
368
  def frontend_static(filepath: str) -> FileResponse:
369
- target = (FRONTEND_DIR / filepath).resolve()
370
- if FRONTEND_DIR.resolve() not in target.parents and target != FRONTEND_DIR.resolve():
 
371
  raise HTTPException(status_code=404, detail="Not found")
372
  if not target.exists() or not target.is_file():
373
  raise HTTPException(status_code=404, detail="Not found")
 
18
  from backend import Backend, VALID_RATINGS
19
  from backend.hf_bootstrap import ensure_store_db
20
 
21
+ _app_dir = Path(__file__).resolve().parent
22
+ _data_dir = _app_dir / "data"
23
  # App uses the slim DB (local: scripts/build_slim_store.py). Override with KINK_STORE_PATH.
24
  # On HF: point KINK_STORE_PATH at a mounted bucket path; Dockerfile defaults to bundled seed (set KINK_HF_DATASET_* for Hub catalog).
25
  _default_store = Path(os.environ["KINK_STORE_PATH"]) if os.environ.get("KINK_STORE_PATH") else _data_dir / "store_slim.db"
26
+ FRONTEND_DIR = _app_dir / "frontend"
27
+ FRONTEND_DIST_DIR = FRONTEND_DIR / "dist"
28
+ FRONTEND_PATH = FRONTEND_DIST_DIR / "index.html"
29
+ MEDIA_ROOT = _app_dir / "data" / "cached_assets"
30
  ADMIN_SECRET = os.environ.get("KINK_ADMIN_SECRET", "")
31
 
32
  _MEDIA_CACHE = "public, max-age=86400, immutable"
 
34
 
35
 
36
  def _frontend_static_cache_control(target: Path) -> str:
37
+ """Browser e2e and local iteration can disable static caching for built assets."""
38
  if os.environ.get("KINK_FRONTEND_NO_CACHE") == "1":
39
  return "no-store"
40
+ if target.suffix in {".js", ".css"} and target.parent.name == "assets":
41
+ return "public, max-age=31536000, immutable"
42
  return "no-store"
43
 
44
 
45
+ def _require_frontend_build() -> None:
46
+ if FRONTEND_PATH.is_file():
47
+ return
48
+ raise HTTPException(
49
+ status_code=503,
50
+ detail="Frontend build missing. Run `npm ci && npm run build` before serving the app.",
51
+ )
52
+
53
+
54
  def _hf_space_published_image() -> bool:
55
  """True in the Hugging Face Space Docker image (``Dockerfile`` sets ``KINK_HF_SPACE_IMAGE=1``).
56
 
 
183
  @app.middleware("http")
184
  async def add_security_headers(request: Request, call_next):
185
  response = await call_next(request)
186
+ # connect-src covers fetch/beacon/WS. Frontend dependencies are bundled by Vite and served same-origin.
187
  connect_src = " ".join(
188
  sorted(
189
  {
190
  "'self'",
 
 
191
  *(origin.strip() for origin in cors_origins if origin.strip()),
192
  }
193
  )
194
  )
195
  csp = (
196
+ "default-src 'self' data:; "
197
  "base-uri 'self'; frame-ancestors 'none'; form-action 'self'; frame-src 'none'; object-src 'none'; "
198
+ "script-src 'self'; script-src-attr 'none'; "
199
  "style-src 'self' 'unsafe-inline'; style-src-attr 'unsafe-inline'; "
200
  "img-src 'self' data: https:; media-src 'self' data: https:; font-src 'self' data: https:; "
201
  f"connect-src {connect_src}; "
 
370
 
371
  @app.get("/", response_class=HTMLResponse, responses={200: {"content": {"text/html": {}}}})
372
  def frontend() -> FileResponse:
373
+ _require_frontend_build()
374
  return FileResponse(FRONTEND_PATH, headers={"Cache-Control": "no-store"})
375
 
376
 
377
  @app.get("/frontend/{filepath:path}")
378
  def frontend_static(filepath: str) -> FileResponse:
379
+ _require_frontend_build()
380
+ target = (FRONTEND_DIST_DIR / filepath).resolve()
381
+ if FRONTEND_DIST_DIR.resolve() not in target.parents and target != FRONTEND_DIST_DIR.resolve():
382
  raise HTTPException(status_code=404, detail="Not found")
383
  if not target.exists() or not target.is_file():
384
  raise HTTPException(status_code=404, detail="Not found")
backend/enrichment.py CHANGED
@@ -218,7 +218,7 @@ def _detail_summary_text(self, definition: str, summary: str, examples: list[str
218
  ex = _finalize_kink_card_blurb(kink_name, example.strip())
219
  if ex:
220
  return ex
221
- return "No sourced summary yet."
222
 
223
  def _image_trust_state(self, raw_asset_count: int, filtered_asset_count: int) -> str:
224
  if filtered_asset_count > 0:
 
218
  ex = _finalize_kink_card_blurb(kink_name, example.strip())
219
  if ex:
220
  return ex
221
+ return ""
222
 
223
  def _image_trust_state(self, raw_asset_count: int, filtered_asset_count: int) -> str:
224
  if filtered_asset_count > 0:
backend/hf_bootstrap.py CHANGED
@@ -6,8 +6,9 @@ Priority when the store file is missing:
6
  2. **Backblaze B2 (S3 API)** — if ``B2_KEY_ID``, ``B2_APPLICATION_KEY``, ``B2_BUCKET``, ``B2_REGION`` are set
7
  (same names as ``parser`` ``hf_deploy.py`` Space secrets), download ``KINK_B2_OBJECT_KEY`` (default
8
  ``kink/catalog/store_slim.db``). Works with **private** buckets; requires ``boto3``.
9
- 3. ``KINK_HF_DATASET_REPO`` — Hub **dataset** download.
10
- 4. Bundled seed (only when ``KINK_HF_REQUIRE_FULL_CATALOG`` is off).
 
11
 
12
  On Spaces: mount persistent storage and set ``KINK_STORE_PATH`` so the file survives restarts.
13
  """
@@ -80,8 +81,9 @@ def _download_store_from_http(url: str, dest: Path) -> None:
80
  if token:
81
  headers["Authorization"] = f"Bearer {token}"
82
 
 
83
  try:
84
- with httpx.Client(timeout=None, follow_redirects=True, headers=headers) as client:
85
  with client.stream("GET", url) as response:
86
  response.raise_for_status()
87
  with partial.open("wb") as out:
@@ -122,6 +124,10 @@ def ensure_store_db(target: Path) -> Path:
122
  return target
123
 
124
  repo = os.environ.get("KINK_HF_DATASET_REPO", "").strip()
 
 
 
 
125
 
126
  if require_full and not repo:
127
  repo = (
@@ -139,7 +145,8 @@ def ensure_store_db(target: Path) -> Path:
139
  f"SQLite database not found: {target}. "
140
  "Locally: run python scripts/build_slim_store.py. "
141
  "On Hugging Face: set KINK_CATALOG_URL (public HTTPS), or B2_KEY_ID/B2_APPLICATION_KEY/B2_BUCKET/B2_REGION "
142
- "(and KINK_B2_OBJECT_KEY), or KINK_HF_DATASET_REPO (Hub dataset), "
 
143
  "KINK_STORE_PATH on a mounted bucket if you need persistence, "
144
  "or upload an existing DB to the bucket first. "
145
  "For the Docker image, ensure deploy/hf/seed/hf_bundled_store.db is included in the build context."
 
6
  2. **Backblaze B2 (S3 API)** — if ``B2_KEY_ID``, ``B2_APPLICATION_KEY``, ``B2_BUCKET``, ``B2_REGION`` are set
7
  (same names as ``parser`` ``hf_deploy.py`` Space secrets), download ``KINK_B2_OBJECT_KEY`` (default
8
  ``kink/catalog/store_slim.db``). Works with **private** buckets; requires ``boto3``.
9
+ 3. ``KINK_HF_DATASET_REPO`` — Hub **dataset** download (only when ``KINK_HF_REQUIRE_FULL_CATALOG`` is on, or when
10
+ ``KINK_HF_USE_HUB_DATASET=1`` while full catalog is off — see below).
11
+ 4. Bundled seed when full catalog is off and Hub dataset is not explicitly requested.
12
 
13
  On Spaces: mount persistent storage and set ``KINK_STORE_PATH`` so the file survives restarts.
14
  """
 
81
  if token:
82
  headers["Authorization"] = f"Bearer {token}"
83
 
84
+ timeout = httpx.Timeout(connect=30.0, read=None, write=30.0, pool=30.0)
85
  try:
86
+ with httpx.Client(timeout=timeout, follow_redirects=True, headers=headers) as client:
87
  with client.stream("GET", url) as response:
88
  response.raise_for_status()
89
  with partial.open("wb") as out:
 
124
  return target
125
 
126
  repo = os.environ.get("KINK_HF_DATASET_REPO", "").strip()
127
+ if not require_full and repo and not _env_truthy("KINK_HF_USE_HUB_DATASET"):
128
+ # HF Space "Variables" often keep KINK_HF_DATASET_REPO after switching the image to bundled-only.
129
+ # That triggered anonymous hf_hub_download (slow / OOM) and ignored the bundled seed.
130
+ repo = ""
131
 
132
  if require_full and not repo:
133
  repo = (
 
145
  f"SQLite database not found: {target}. "
146
  "Locally: run python scripts/build_slim_store.py. "
147
  "On Hugging Face: set KINK_CATALOG_URL (public HTTPS), or B2_KEY_ID/B2_APPLICATION_KEY/B2_BUCKET/B2_REGION "
148
+ "(and KINK_B2_OBJECT_KEY), or KINK_HF_REQUIRE_FULL_CATALOG=1 plus KINK_HF_DATASET_REPO (Hub dataset), "
149
+ "or KINK_HF_USE_HUB_DATASET=1 with KINK_HF_DATASET_REPO when full catalog is off, "
150
  "KINK_STORE_PATH on a mounted bucket if you need persistence, "
151
  "or upload an existing DB to the bucket first. "
152
  "For the Docker image, ensure deploy/hf/seed/hf_bundled_store.db is included in the build context."
deploy/hf/README.md CHANGED
@@ -53,8 +53,9 @@ For the Space, set **`KINK_CATALOG_URL`** (public HTTPS), or **`B2_*`** + **`KIN
53
  | `KINK_CATALOG_URL_BEARER` | Optional bearer token for private HTTP object URLs |
54
  | `B2_KEY_ID`, `B2_APPLICATION_KEY`, `B2_BUCKET`, `B2_REGION` | Backblaze S3 credentials (Space secrets; same names as **parser**). Used when `KINK_CATALOG_URL` is unset. |
55
  | `KINK_B2_OBJECT_KEY` | Object key inside the bucket (default `kink/catalog/store_slim.db`) |
56
- | `KINK_HF_DATASET_REPO` | Hub dataset id (set on the Space with `KINK_HF_REQUIRE_FULL_CATALOG=1` when using Hub instead of URL/B2) |
57
  | `KINK_HF_DATASET_FILENAME` | Remote filename in the dataset (e.g. `store_slim.db`) |
 
58
  | `HF_TOKEN` | If the Hub dataset is private |
59
  | `KINK_SKIP_HEAVY_WARM` | **`1` in the published Docker image** skips only **PPR / similarity-graph** warm-up (saves RAM on **cpu-basic**). The **in-memory catalog** is still built at process start so the first recommendations request is not blocked. Set to **`0`** on larger hardware for full graph warm-up (better cold recs for heavy graph users, higher memory). |
60
  | `KINK_CORS_ORIGINS` | Your Space URL for browser API calls |
 
53
  | `KINK_CATALOG_URL_BEARER` | Optional bearer token for private HTTP object URLs |
54
  | `B2_KEY_ID`, `B2_APPLICATION_KEY`, `B2_BUCKET`, `B2_REGION` | Backblaze S3 credentials (Space secrets; same names as **parser**). Used when `KINK_CATALOG_URL` is unset. |
55
  | `KINK_B2_OBJECT_KEY` | Object key inside the bucket (default `kink/catalog/store_slim.db`) |
56
+ | `KINK_HF_DATASET_REPO` | Hub dataset id (used when `KINK_HF_REQUIRE_FULL_CATALOG=1`, or with `KINK_HF_USE_HUB_DATASET=1` while full catalog is off) |
57
  | `KINK_HF_DATASET_FILENAME` | Remote filename in the dataset (e.g. `store_slim.db`) |
58
+ | `KINK_HF_USE_HUB_DATASET` | Set **`1`** only if you want a Hub download while **`KINK_HF_REQUIRE_FULL_CATALOG=0`**. Otherwise a leftover `KINK_HF_DATASET_REPO` Space variable is **ignored** so the bundled seed is used (avoids anonymous Hub traffic and OOMs). |
59
  | `HF_TOKEN` | If the Hub dataset is private |
60
  | `KINK_SKIP_HEAVY_WARM` | **`1` in the published Docker image** skips only **PPR / similarity-graph** warm-up (saves RAM on **cpu-basic**). The **in-memory catalog** is still built at process start so the first recommendations request is not blocked. Set to **`0`** on larger hardware for full graph warm-up (better cold recs for heavy graph users, higher memory). |
61
  | `KINK_CORS_ORIGINS` | Your Space URL for browser API calls |
deploy/hf/entrypoint.sh CHANGED
@@ -8,7 +8,9 @@ export KINK_STORE_PATH
8
  if [ ! -f "$KINK_STORE_PATH" ]; then
9
  mkdir -p "$(dirname "$KINK_STORE_PATH")"
10
  # Full-catalog images: never copy the bundled seed. If Hub dataset or HTTPS URL is set, leave DB missing for bootstrap.
11
- if [ "${KINK_HF_REQUIRE_FULL_CATALOG:-0}" != "1" ] && [ -z "${KINK_HF_DATASET_REPO:-}" ] && [ -z "${KINK_CATALOG_URL:-}" ] && [ -z "${B2_BUCKET:-}" ] && [ -z "${B2_KEY_ID:-}" ] && [ -f "$KINK_SEED_PATH" ]; then
 
 
12
  cp "$KINK_SEED_PATH" "$KINK_STORE_PATH"
13
  fi
14
  fi
 
8
  if [ ! -f "$KINK_STORE_PATH" ]; then
9
  mkdir -p "$(dirname "$KINK_STORE_PATH")"
10
  # Full-catalog images: never copy the bundled seed. If Hub dataset or HTTPS URL is set, leave DB missing for bootstrap.
11
+ # Do not require KINK_HF_DATASET_REPO to be unset: Spaces often keep a stale repo id; bundled mode still wins in api.
12
+ # Skip copy when opting into Hub dataset without full-catalog flag (KINK_HF_USE_HUB_DATASET=1).
13
+ if [ "${KINK_HF_REQUIRE_FULL_CATALOG:-0}" != "1" ] && [ "${KINK_HF_USE_HUB_DATASET:-0}" != "1" ] && [ -z "${KINK_CATALOG_URL:-}" ] && [ -z "${B2_BUCKET:-}" ] && [ -z "${B2_KEY_ID:-}" ] && [ -f "$KINK_SEED_PATH" ]; then
14
  cp "$KINK_SEED_PATH" "$KINK_STORE_PATH"
15
  fi
16
  fi
deploy/hf/seed/hf_bundled_store.db CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:ab62f5134733efd6d8dec33f0a4d034549e3ca255eef494a4802ba7d3eae7267
3
  size 376832
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c2984f10e868979a832daedf7c51ae45c1e37214fe312d700140a457227eeb2a
3
  size 376832
frontend/api.js CHANGED
@@ -75,4 +75,4 @@ export function currentDirectionsFor(plays, kinkId) {
75
  return plays?.[kinkId]?.directions || [];
76
  }
77
 
78
- export { directionColumnsForPlay, directionsAfterColumnDrop } from "./direction-logic.js?v=32";
 
75
  return plays?.[kinkId]?.directions || [];
76
  }
77
 
78
+ export { directionColumnsForPlay, directionsAfterColumnDrop } from "./direction-logic.js";
frontend/app.js CHANGED
@@ -1,21 +1,21 @@
1
- import React, { useEffect, useMemo, useRef, useState, useCallback } from "https://esm.sh/react@18.3.1";
2
- import { createRoot } from "https://esm.sh/react-dom@18.3.1/client";
3
  import {
4
  QueryClient,
5
  QueryClientProvider,
6
  useQueryClient,
7
- } from "https://esm.sh/@tanstack/react-query@5.59.20?deps=react@18.3.1";
8
 
9
- import { html, VIEWS } from "./constants.js?v=33";
10
- import { currentDirectionsFor } from "./api.js?v=33";
11
- import { directionsAfterColumnDrop } from "./direction-logic.js?v=32";
12
- import { StatusText, RoleChip } from "./components.js?v=32";
13
  import {
14
  useUserQuery,
15
  useGroupsQuery,
16
  useRolesQuery,
17
  useRoleSearchQuery,
18
- } from "./queries.js?v=41";
19
  import {
20
  useLoginMutation,
21
  useCreateMutation,
@@ -28,17 +28,17 @@ import {
28
  useRemoveGroupMemberMutation,
29
  useShareToggleMutation,
30
  usePartnerRequestMutations,
31
- } from "./mutations.js?v=52";
32
- import { optimisticBoard, removeKinkFromItemsPayload, playCommitKey } from "./board-utils.js?v=33";
33
- import { DiscoverView } from "./discover.js?v=65";
34
- import { normalizeDiscoverSheetDirections } from "./discover-flow.js?v=1";
35
- import { ScenariosView } from "./scenarios.js?v=1";
36
- import { MyPlaysView } from "./my-plays.js?v=37";
37
- import { TogetherView } from "./together.js?v=37";
38
- import { SettingsView } from "./settings.js?v=32";
39
- import { initTelemetry } from "./telemetry.js?v=33";
40
- import { preferredPartnerGroupId, resolvedPartnerGroups } from "./partner-groups.js?v=33";
41
- import { nextStarterCompleted, starterViewState } from "./starter-flow.js?v=34";
42
 
43
  const queryClient = new QueryClient();
44
  const EMPTY_BOARD = { to_me: [], by_me: [], together: [], hidden: [], no_go: [] };
 
1
+ import React, { useEffect, useMemo, useRef, useState, useCallback } from "react";
2
+ import { createRoot } from "react-dom/client";
3
  import {
4
  QueryClient,
5
  QueryClientProvider,
6
  useQueryClient,
7
+ } from "@tanstack/react-query";
8
 
9
+ import { html, VIEWS } from "./constants.js";
10
+ import { currentDirectionsFor } from "./api.js";
11
+ import { directionsAfterColumnDrop } from "./direction-logic.js";
12
+ import { StatusText, RoleChip } from "./components.js";
13
  import {
14
  useUserQuery,
15
  useGroupsQuery,
16
  useRolesQuery,
17
  useRoleSearchQuery,
18
+ } from "./queries.js";
19
  import {
20
  useLoginMutation,
21
  useCreateMutation,
 
28
  useRemoveGroupMemberMutation,
29
  useShareToggleMutation,
30
  usePartnerRequestMutations,
31
+ } from "./mutations.js";
32
+ import { optimisticBoard, removeKinkFromItemsPayload, playCommitKey } from "./board-utils.js";
33
+ import { DiscoverView } from "./discover.js";
34
+ import { normalizeDiscoverSheetDirections } from "./discover-flow.js";
35
+ import { ScenariosView } from "./scenarios.js";
36
+ import { MyPlaysView } from "./my-plays.js";
37
+ import { TogetherView } from "./together.js";
38
+ import { SettingsView } from "./settings.js";
39
+ import { initTelemetry } from "./telemetry.js";
40
+ import { preferredPartnerGroupId, resolvedPartnerGroups } from "./partner-groups.js";
41
+ import { nextStarterCompleted, starterViewState } from "./starter-flow.js";
42
 
43
  const queryClient = new QueryClient();
44
  const EMPTY_BOARD = { to_me: [], by_me: [], together: [], hidden: [], no_go: [] };
frontend/board-utils.test.mjs CHANGED
@@ -5,7 +5,24 @@
5
  import assert from "node:assert/strict";
6
  import test from "node:test";
7
 
8
- import { optimisticBoard } from "./board-utils.js";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
  test("optimisticBoard places stub when no server board and no query cache", () => {
11
  const b = optimisticBoard(null, "fetlife_999", "like", ["by_me", "to_me"], null, null);
@@ -13,3 +30,176 @@ test("optimisticBoard places stub when no server board and no query cache", () =
13
  const inBy = b.by_me.some((x) => x.id === "fetlife_999");
14
  assert.ok(inTo && inBy, "stub row must appear in every direction bucket requested");
15
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  import assert from "node:assert/strict";
6
  import test from "node:test";
7
 
8
+ import {
9
+ findBoardItemInData,
10
+ optimisticBoard,
11
+ playCommitKey,
12
+ removeKinkFromItemsPayload,
13
+ sortedBoardItems,
14
+ } from "./board-utils.js";
15
+
16
+ test("sortedBoardItems orders by popularity descending then name ascending without mutating input", () => {
17
+ const input = [
18
+ { id: "low", name: "Zed", popularity: 5 },
19
+ { id: "b", name: "Beta", popularity: 10 },
20
+ { id: "a", name: "Alpha", popularity: 10 },
21
+ ];
22
+
23
+ assert.deepEqual(sortedBoardItems(input).map((item) => item.id), ["a", "b", "low"]);
24
+ assert.deepEqual(input.map((item) => item.id), ["low", "b", "a"]);
25
+ });
26
 
27
  test("optimisticBoard places stub when no server board and no query cache", () => {
28
  const b = optimisticBoard(null, "fetlife_999", "like", ["by_me", "to_me"], null, null);
 
30
  const inBy = b.by_me.some((x) => x.id === "fetlife_999");
31
  assert.ok(inTo && inBy, "stub row must appear in every direction bucket requested");
32
  });
33
+
34
+ test("optimisticBoard removes stale copies and routes hard_no / not_interested to private buckets", () => {
35
+ const previous = {
36
+ to_me: [{ id: "k1", name: "Alpha", popularity: 1 }],
37
+ by_me: [{ id: "k1", name: "Alpha", popularity: 1 }],
38
+ together: [{ id: "k2", name: "Beta", popularity: 2 }],
39
+ hidden: [],
40
+ no_go: [],
41
+ };
42
+
43
+ const hidden = optimisticBoard(previous, "k1", "not_interested", ["to_me"], null, null);
44
+ assert.deepEqual(hidden.to_me, []);
45
+ assert.deepEqual(hidden.by_me, []);
46
+ assert.deepEqual(hidden.hidden.map((item) => item.id), ["k1"]);
47
+
48
+ const noGo = optimisticBoard(previous, "k1", "hard_no", ["to_me"], null, null);
49
+ assert.deepEqual(noGo.to_me, []);
50
+ assert.deepEqual(noGo.by_me, []);
51
+ assert.deepEqual(noGo.no_go.map((item) => item.id), ["k1"]);
52
+ });
53
+
54
+ test("optimisticBoard uses together when positive directions are empty", () => {
55
+ const b = optimisticBoard(null, "k1", "love", [], null, null);
56
+ assert.deepEqual(b.together.map((item) => item.id), ["k1"]);
57
+ assert.deepEqual(b.to_me, []);
58
+ assert.deepEqual(b.by_me, []);
59
+ });
60
+
61
+ test("optimisticBoard can hydrate item details from query caches", () => {
62
+ const queries = [
63
+ { queryKey: ["search", "Bond"], state: { data: { items: [{ kink: { id: "k1", name: "Bondage", definition: "Fixture", popularity: 42 } }] } } },
64
+ ];
65
+ const qc = {
66
+ getQueryData(key) {
67
+ if (JSON.stringify(key) === JSON.stringify(["kink", "k2"])) {
68
+ return { id: "k2", name: "Spanking", detail_summary: "Fixture", popularity: 11 };
69
+ }
70
+ return null;
71
+ },
72
+ getQueryCache() {
73
+ return { findAll: ({ predicate }) => queries.filter(predicate) };
74
+ },
75
+ };
76
+
77
+ const fromSearch = optimisticBoard(null, "k1", "like", ["to_me"], qc, { userId: "u1" });
78
+ assert.deepEqual(fromSearch.to_me[0], {
79
+ id: "k1",
80
+ name: "Bondage",
81
+ summary: "Fixture",
82
+ popularity: 42,
83
+ interest_state: "like",
84
+ directions: ["to_me"],
85
+ });
86
+
87
+ const fromDetail = optimisticBoard(null, "k2", "like", ["by_me"], qc, { userId: "u1" });
88
+ assert.equal(fromDetail.by_me[0].name, "Spanking");
89
+ assert.equal(fromDetail.by_me[0].summary, "Fixture");
90
+ });
91
+
92
+ test("optimisticBoard hydrates from scoped rec and prompt caches before falling back to global scans", () => {
93
+ const scoped = new Map([
94
+ [
95
+ JSON.stringify(["recs", "u1", ""]),
96
+ { items: [{ kink: { id: "rec-hit", name: "Rec Hit", summary: "rec", popularity: 9 } }] },
97
+ ],
98
+ [
99
+ JSON.stringify(["prompts", "u1", ""]),
100
+ { items: [{ kink: { id: "prompt-hit", name: "Prompt Hit", definition: "prompt", popularity: 8 } }] },
101
+ ],
102
+ ]);
103
+ const qc = {
104
+ getQueryData(key) {
105
+ return scoped.get(JSON.stringify(key)) || null;
106
+ },
107
+ getQueryCache() {
108
+ return { findAll: () => [] };
109
+ },
110
+ };
111
+
112
+ const fromRec = optimisticBoard(null, "rec-hit", "like", ["to_me"], qc, { userId: "u1" });
113
+ const fromPrompt = optimisticBoard(null, "prompt-hit", "like", ["by_me"], qc, { userId: "u1" });
114
+
115
+ assert.equal(fromRec.to_me[0].name, "Rec Hit");
116
+ assert.equal(fromRec.to_me[0].summary, "rec");
117
+ assert.equal(fromPrompt.by_me[0].name, "Prompt Hit");
118
+ assert.equal(fromPrompt.by_me[0].summary, "prompt");
119
+ });
120
+
121
+ test("optimisticBoard hydrates from group-overlap direct and similar caches only", () => {
122
+ const queries = [
123
+ {
124
+ queryKey: ["group-overlap", "u1", "g1"],
125
+ state: {
126
+ data: {
127
+ direct_matches: [{ id: "direct-hit", name: "Direct Hit", summary: "direct", popularity: 7 }],
128
+ similar_matches: [{ kink: { id: "similar-hit", name: "Similar Hit", detail_summary: "similar", popularity: 6 } }],
129
+ },
130
+ },
131
+ },
132
+ {
133
+ queryKey: ["other", "u1"],
134
+ state: {
135
+ data: {
136
+ direct_matches: [{ id: "wrong-hit", name: "Wrong Hit", popularity: 99 }],
137
+ },
138
+ },
139
+ },
140
+ ];
141
+ const qc = {
142
+ getQueryData() {
143
+ return null;
144
+ },
145
+ getQueryCache() {
146
+ return { findAll: ({ predicate }) => queries.filter(predicate) };
147
+ },
148
+ };
149
+
150
+ const direct = optimisticBoard(null, "direct-hit", "love", ["together"], qc, { userId: "u1" });
151
+ const similar = optimisticBoard(null, "similar-hit", "like", ["to_me"], qc, { userId: "u1" });
152
+ const wrong = optimisticBoard(null, "wrong-hit", "like", ["by_me"], qc, { userId: "u1" });
153
+
154
+ assert.equal(direct.together[0].name, "Direct Hit");
155
+ assert.equal(direct.together[0].summary, "direct");
156
+ assert.equal(similar.to_me[0].name, "Similar Hit");
157
+ assert.equal(similar.to_me[0].summary, "similar");
158
+ assert.equal(wrong.by_me[0].name, "wrong-hit");
159
+ });
160
+
161
+ test("findBoardItemInData scans every bucket and tolerates missing boards", () => {
162
+ const board = {
163
+ to_me: [],
164
+ by_me: [{ id: 7, name: "Seven" }],
165
+ together: [],
166
+ hidden: [],
167
+ no_go: [],
168
+ };
169
+ assert.equal(findBoardItemInData(null, 7), null);
170
+ assert.deepEqual(findBoardItemInData(board, "7"), { id: 7, name: "Seven" });
171
+ });
172
+
173
+ test("removeKinkFromItemsPayload filters by kink id while preserving payload metadata", () => {
174
+ const payload = {
175
+ cursor: "next",
176
+ items: [{ kink: { id: "keep" } }, { kink: { id: 12 } }, { kink: { id: "12" } }],
177
+ };
178
+
179
+ assert.equal(removeKinkFromItemsPayload(null, "x"), null);
180
+ assert.deepEqual(removeKinkFromItemsPayload(payload, 12), {
181
+ cursor: "next",
182
+ items: [{ kink: { id: "keep" } }],
183
+ });
184
+ });
185
+
186
+ test("removeKinkFromItemsPayload preserves malformed rows that are not the removed kink", () => {
187
+ const payload = {
188
+ items: [
189
+ {},
190
+ { kink: null },
191
+ { kink: { id: "remove" } },
192
+ { kink: { id: "keep" } },
193
+ ],
194
+ };
195
+
196
+ assert.deepEqual(removeKinkFromItemsPayload(payload, "remove"), {
197
+ items: [{}, { kink: null }, { kink: { id: "keep" } }],
198
+ });
199
+ });
200
+
201
+ test("playCommitKey is stable for direction ordering and empty directions", () => {
202
+ assert.equal(playCommitKey("k1", "like", ["by_me", "to_me"]), "k1::like::by_me|to_me");
203
+ assert.equal(playCommitKey("k1", "like", ["to_me", "by_me"]), "k1::like::by_me|to_me");
204
+ assert.equal(playCommitKey("k1", "hard_no"), "k1::hard_no::");
205
+ });
frontend/components.js CHANGED
@@ -1,6 +1,6 @@
1
- import React, { useState, useCallback, useEffect } from "https://esm.sh/react@18.3.1";
2
- import { html, UI_REACTIONS, DIRECTION_PILLS } from "./constants.js?v=32";
3
- import { kinkCategoryLabel } from "./ui-labels.js?v=32";
4
 
5
  /** One photo at a time; tap left/right edges to change. */
6
  function KinkMediaScroll({ assets, imageRevealed, kinkName, onReveal, resetKey }) {
@@ -178,10 +178,13 @@ export function KinkCard({
178
 
179
  if (layout === "tinder") {
180
  const expanded = Boolean(infoExpanded);
181
- const tappable = typeof onToggleInfo === "function";
182
  const blurb = discoverCollapsedBlurb(kink);
183
  const fullDef = discoverFullDefinition(kink);
184
- const notes = (kink.notes || "").trim();
 
 
 
185
  const tinderBody = html`
186
  ${category ? html`<div className="kink-pill">${category}</div>` : null}
187
  <h2 className="card-title">${kink.name}</h2>
 
1
+ import React, { useState, useCallback, useEffect } from "react";
2
+ import { html, UI_REACTIONS, DIRECTION_PILLS } from "./constants.js";
3
+ import { kinkCategoryLabel } from "./ui-labels.js";
4
 
5
  /** One photo at a time; tap left/right edges to change. */
6
  function KinkMediaScroll({ assets, imageRevealed, kinkName, onReveal, resetKey }) {
 
178
 
179
  if (layout === "tinder") {
180
  const expanded = Boolean(infoExpanded);
181
+ const notes = (kink.notes || "").trim();
182
  const blurb = discoverCollapsedBlurb(kink);
183
  const fullDef = discoverFullDefinition(kink);
184
+ /** No "Tap for full info" when collapsed blurb, full text, and notes are all empty (or full text equals blurb). */
185
+ const tinderExpandable =
186
+ Boolean(notes) || (Boolean(fullDef) && (!blurb || fullDef !== blurb));
187
+ const tappable = typeof onToggleInfo === "function" && tinderExpandable;
188
  const tinderBody = html`
189
  ${category ? html`<div className="kink-pill">${category}</div>` : null}
190
  <h2 className="card-title">${kink.name}</h2>
frontend/constants.js CHANGED
@@ -1,5 +1,5 @@
1
- import React from "https://esm.sh/react@18.3.1";
2
- import htm from "https://esm.sh/htm@3.1.1";
3
 
4
  export const html = htm.bind(React.createElement);
5
 
 
1
+ import React from "react";
2
+ import htm from "htm";
3
 
4
  export const html = htm.bind(React.createElement);
5
 
frontend/direction-logic.test.mjs CHANGED
@@ -22,6 +22,11 @@ test("direction columns: empty directions defaults to together", () => {
22
  assert.deepEqual(directionColumnsForPlay("x", {}), ["together"]);
23
  });
24
 
 
 
 
 
 
25
  test("REGRESSION: primary-first-column collapses dual directions — must NOT match contract", () => {
26
  /** Old UI bug: only the first DIR_ORDER hit was used, hiding by_me when to_me was also set. */
27
  function legacyPrimaryDirectionColumn(playId, currentPlays) {
@@ -46,3 +51,9 @@ test("directionsAfterColumnDrop: dragging onto by_me merges with existing to_me"
46
  test("directionsAfterColumnDrop: together column replaces specifics", () => {
47
  assert.deepEqual(directionsAfterColumnDrop(["to_me", "by_me"], "together"), ["together"]);
48
  });
 
 
 
 
 
 
 
22
  assert.deepEqual(directionColumnsForPlay("x", {}), ["together"]);
23
  });
24
 
25
+ test("direction columns: invalid stored directions are ignored", () => {
26
+ assert.deepEqual(directionColumnsForPlay("x", { x: { directions: ["sideways"] } }), ["together"]);
27
+ assert.deepEqual(directionColumnsForPlay("x", { x: { directions: ["sideways", "by_me"] } }), ["by_me"]);
28
+ });
29
+
30
  test("REGRESSION: primary-first-column collapses dual directions — must NOT match contract", () => {
31
  /** Old UI bug: only the first DIR_ORDER hit was used, hiding by_me when to_me was also set. */
32
  function legacyPrimaryDirectionColumn(playId, currentPlays) {
 
51
  test("directionsAfterColumnDrop: together column replaces specifics", () => {
52
  assert.deepEqual(directionsAfterColumnDrop(["to_me", "by_me"], "together"), ["together"]);
53
  });
54
+
55
+ test("directionsAfterColumnDrop: together and invalid existing directions do not survive specific drops", () => {
56
+ assert.deepEqual(directionsAfterColumnDrop(["together"], "to_me"), ["to_me"]);
57
+ assert.deepEqual(directionsAfterColumnDrop(["sideways", "to_me"], "by_me"), ["by_me", "to_me"]);
58
+ assert.deepEqual(directionsAfterColumnDrop(["by_me", "to_me"], "by_me"), ["by_me", "to_me"]);
59
+ });
frontend/discover-flow.test.mjs CHANGED
@@ -11,6 +11,7 @@ import {
11
 
12
  test("positive discover reactions require direction follow-up", () => {
13
  assert.equal(isPositiveDiscoverRating("love"), true);
 
14
  assert.equal(isPositiveDiscoverRating("like"), true);
15
  assert.equal(isPositiveDiscoverRating("curious"), true);
16
  assert.equal(isPositiveDiscoverRating("not_interested"), false);
@@ -57,3 +58,7 @@ test("applyDiscoverDirectionChoice: together to together then to_me", () => {
57
  assert.deepEqual(applyDiscoverDirectionChoice(["together"], "to_me"), ["to_me"]);
58
  assert.deepEqual(applyDiscoverDirectionChoice(["to_me"], "together"), ["together"]);
59
  });
 
 
 
 
 
11
 
12
  test("positive discover reactions require direction follow-up", () => {
13
  assert.equal(isPositiveDiscoverRating("love"), true);
14
+ assert.equal(isPositiveDiscoverRating(" like "), true);
15
  assert.equal(isPositiveDiscoverRating("like"), true);
16
  assert.equal(isPositiveDiscoverRating("curious"), true);
17
  assert.equal(isPositiveDiscoverRating("not_interested"), false);
 
58
  assert.deepEqual(applyDiscoverDirectionChoice(["together"], "to_me"), ["to_me"]);
59
  assert.deepEqual(applyDiscoverDirectionChoice(["to_me"], "together"), ["together"]);
60
  });
61
+
62
+ test("applyDiscoverDirectionChoice: invalid keys reset to together", () => {
63
+ assert.deepEqual(applyDiscoverDirectionChoice(["to_me"], "sideways"), ["together"]);
64
+ });
frontend/discover-scenarios.test.mjs CHANGED
@@ -28,3 +28,24 @@ test("discoverScenarioWrites only emits changed draft scenario choices", () => {
28
  { scenarioKinkId: "scene_a", interestState: "love", directions: ["together"] },
29
  ]);
30
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  { scenarioKinkId: "scene_a", interestState: "love", directions: ["together"] },
29
  ]);
30
  });
31
+
32
+ test("discoverScenarioWrites accepts kink/raw scenario shapes and normalizes directions", () => {
33
+ const writes = discoverScenarioWrites({
34
+ draftStates: { scene_a: "love", scene_b: "like", scene_c: "curious" },
35
+ scenarioItems: [
36
+ { kink: { id: "scene_a" } },
37
+ { id: "scene_b" },
38
+ { scenario: { id: "" } },
39
+ null,
40
+ ],
41
+ scenarioPreferences: {
42
+ scene_c: { interest_state: "hard_no" },
43
+ },
44
+ directions: ["to_me", "", "to_me", "by_me"],
45
+ });
46
+
47
+ assert.deepEqual(writes, [
48
+ { scenarioKinkId: "scene_a", interestState: "love", directions: ["to_me", "by_me"] },
49
+ { scenarioKinkId: "scene_b", interestState: "like", directions: ["to_me", "by_me"] },
50
+ ]);
51
+ });
frontend/discover-state.test.mjs CHANGED
@@ -27,6 +27,19 @@ test("visibleDiscoveryItems drops already-rated raw recommendation rows", () =>
27
  assert.deepEqual(visibleDiscoveryItems(rawItems, currentPlays).map((row) => row.kink.id), ["b"]);
28
  });
29
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  test("empty visible deck refetches even when raw cache still has only already-rated rows", () => {
31
  const rawItems = [{ kink: { id: "a" } }, { kink: { id: "b" } }];
32
  const currentPlays = {
 
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,
33
+ { kink: null },
34
+ { kink: { id: "" } },
35
+ { kink: { id: "a" } },
36
+ { kink: { id: "a" } },
37
+ { kink: { id: 9 } },
38
+ ];
39
+
40
+ assert.deepEqual(visibleDiscoveryItems(rawItems, {}).map((row) => row.kink.id), ["a", 9]);
41
+ });
42
+
43
  test("empty visible deck refetches even when raw cache still has only already-rated rows", () => {
44
  const rawItems = [{ kink: { id: "a" } }, { kink: { id: "b" } }];
45
  const currentPlays = {
frontend/discover.js CHANGED
@@ -1,12 +1,12 @@
1
- import React, { useState, useCallback, useEffect, useRef, useMemo } from "https://esm.sh/react@18.3.1";
2
- import { useQueryClient } from "https://esm.sh/@tanstack/react-query@5.59.20?deps=react@18.3.1";
3
- import { html, partnerGroupChipLabel, partnerGroupPartnerIdsTitle, DIRECTION_PILLS } from "./constants.js?v=33";
4
- import { KinkCard, ActionBar, SearchOverlay } from "./components.js?v=41";
5
- import { normalizeDiscoverSheetDirections, applyDiscoverDirectionChoice } from "./discover-flow.js?v=1";
6
- import { useRecsQuery, useSearchQuery, useDetailQuery } from "./queries.js?v=41";
7
- import { shouldRefetchEmptyDeck, visibleDiscoveryItems } from "./discover-state.js?v=33";
8
- import { api } from "./api.js?v=33";
9
- import { sendTelemetry } from "./telemetry.js?v=33";
10
 
11
  export function DiscoverView({
12
  auth,
 
1
+ import React, { useState, useCallback, useEffect, useRef, useMemo } from "react";
2
+ import { useQueryClient } from "@tanstack/react-query";
3
+ import { html, partnerGroupChipLabel, partnerGroupPartnerIdsTitle, DIRECTION_PILLS } from "./constants.js";
4
+ import { KinkCard, ActionBar, SearchOverlay } from "./components.js";
5
+ import { normalizeDiscoverSheetDirections, applyDiscoverDirectionChoice } from "./discover-flow.js";
6
+ import { useRecsQuery, useSearchQuery, useDetailQuery } from "./queries.js";
7
+ import { shouldRefetchEmptyDeck, visibleDiscoveryItems } from "./discover-state.js";
8
+ import { api } from "./api.js";
9
+ import { sendTelemetry } from "./telemetry.js";
10
 
11
  export function DiscoverView({
12
  auth,
frontend/dist/assets/index-B1No5j6D.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-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;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-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;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/assets/index-CselhGyO.js ADDED
The diff for this file is too large to render. See raw diff
 
frontend/dist/assets/web-vitals.attribution-C_HyTrBb.js ADDED
@@ -0,0 +1 @@
 
 
1
+ var D,z,k=function(){var e=self.performance&&performance.getEntriesByType&&performance.getEntriesByType("navigation")[0];if(e&&e.responseStart>0&&e.responseStart<performance.now())return e},Q=function(e){if(document.readyState==="loading")return"loading";var n=k();if(n){if(e<n.domInteractive)return"loading";if(n.domContentLoadedEventStart===0||e<n.domContentLoadedEventStart)return"dom-interactive";if(n.domComplete===0||e<n.domComplete)return"dom-content-loaded"}return"complete"},oe=function(e){var n=e.nodeName;return e.nodeType===1?n.toLowerCase():n.toUpperCase().replace(/^#/,"")},N=function(e,n){var i="";try{for(;e&&e.nodeType!==9;){var r=e,t=r.id?"#"+r.id:oe(r)+(r.classList&&r.classList.value&&r.classList.value.trim()&&r.classList.value.trim().length?"."+r.classList.value.trim().replace(/\s+/g,"."):"");if(i.length+t.length>(n||100)-1)return i||t;if(i=i?t+">"+i:t,r.id)break;e=r.parentNode}}catch{}return i},X=-1,ue=function(){return X},L=function(e){addEventListener("pageshow",function(n){n.persisted&&(X=n.timeStamp,e(n))},!0)},O=function(){var e=k();return e&&e.activationStart||0},v=function(e,n){var i=k(),r="navigate";return ue()>=0?r="back-forward-cache":i&&(document.prerendering||O()>0?r="prerender":document.wasDiscarded?r="restore":i.type&&(r=i.type.replace(/_/g,"-"))),{name:e,value:n===void 0?-1:n,rating:"good",delta:0,entries:[],id:"v4-".concat(Date.now(),"-").concat(Math.floor(8999999999999*Math.random())+1e12),navigationType:r}},S=function(e,n,i){try{if(PerformanceObserver.supportedEntryTypes.includes(e)){var r=new PerformanceObserver(function(t){Promise.resolve().then(function(){n(t.getEntries())})});return r.observe(Object.assign({type:e,buffered:!0},i||{})),r}}catch{}},h=function(e,n,i,r){var t,u;return function(a){n.value>=0&&(a||r)&&((u=n.value-(t||0))||t===void 0)&&(t=n.value,n.delta=u,n.rating=function(o,s){return o>s[1]?"poor":o>s[0]?"needs-improvement":"good"}(n.value,i),e(n))}},B=function(e){requestAnimationFrame(function(){return requestAnimationFrame(function(){return e()})})},w=function(e){document.addEventListener("visibilitychange",function(){document.visibilityState==="hidden"&&e()})},j=function(e){var n=!1;return function(){n||(e(),n=!0)}},T=-1,U=function(){return document.visibilityState!=="hidden"||document.prerendering?1/0:0},P=function(e){document.visibilityState==="hidden"&&T>-1&&(T=e.type==="visibilitychange"?e.timeStamp:0,se())},V=function(){addEventListener("visibilitychange",P,!0),addEventListener("prerenderingchange",P,!0)},se=function(){removeEventListener("visibilitychange",P,!0),removeEventListener("prerenderingchange",P,!0)},Y=function(){return T<0&&(T=U(),V(),L(function(){setTimeout(function(){T=U(),V()},0)})),{get firstHiddenTime(){return T}}},q=function(e){document.prerendering?addEventListener("prerenderingchange",function(){return e()},!0):e()},_=[1800,3e3],ce=function(e,n){n=n||{},q(function(){var i,r=Y(),t=v("FCP"),u=S("paint",function(a){a.forEach(function(o){o.name==="first-contentful-paint"&&(u.disconnect(),o.startTime<r.firstHiddenTime&&(t.value=Math.max(o.startTime-O(),0),t.entries.push(o),i(!0)))})});u&&(i=h(e,t,_,n.reportAllChanges),L(function(a){t=v("FCP"),i=h(e,t,_,n.reportAllChanges),B(function(){t.value=performance.now()-a.timeStamp,i(!0)})}))})},G=[.1,.25],ge=function(e,n){(function(i,r){r=r||{},ce(j(function(){var t,u=v("CLS",0),a=0,o=[],s=function(f){f.forEach(function(d){if(!d.hadRecentInput){var m=o[0],b=o[o.length-1];a&&d.startTime-b.startTime<1e3&&d.startTime-m.startTime<5e3?(a+=d.value,o.push(d)):(a=d.value,o=[d])}}),a>u.value&&(u.value=a,u.entries=o,t())},c=S("layout-shift",s);c&&(t=h(i,u,G,r.reportAllChanges),w(function(){s(c.takeRecords()),t(!0)}),L(function(){a=0,u=v("CLS",0),t=h(i,u,G,r.reportAllChanges),B(function(){return t()})}),setTimeout(t,0))}))})(function(i){var r=function(t){var u,a={};if(t.entries.length){var o=t.entries.reduce(function(c,f){return c&&c.value>f.value?c:f});if(o&&o.sources&&o.sources.length){var s=(u=o.sources).find(function(c){return c.node&&c.node.nodeType===1})||u[0];s&&(a={largestShiftTarget:N(s.node),largestShiftTime:o.startTime,largestShiftValue:o.value,largestShiftSource:s,largestShiftEntry:o,loadState:Q(o.startTime)})}}return Object.assign(t,{attribution:a})}(i);e(r)},n)},Z=0,x=1/0,I=0,fe=function(e){e.forEach(function(n){n.interactionId&&(x=Math.min(x,n.interactionId),I=Math.max(I,n.interactionId),Z=I?(I-x)/7+1:0)})},$=function(){return D?Z:performance.interactionCount||0},de=function(){"interactionCount"in performance||D||(D=S("event",fe,{type:"event",buffered:!0,durationThreshold:0}))},l=[],C=new Map,ee=0,le=function(){var e=Math.min(l.length-1,Math.floor(($()-ee)/50));return l[e]},ne=[],me=function(e){if(ne.forEach(function(t){return t(e)}),e.interactionId||e.entryType==="first-input"){var n=l[l.length-1],i=C.get(e.interactionId);if(i||l.length<10||e.duration>n.latency){if(i)e.duration>i.latency?(i.entries=[e],i.latency=e.duration):e.duration===i.latency&&e.startTime===i.entries[0].startTime&&i.entries.push(e);else{var r={id:e.interactionId,latency:e.duration,entries:[e]};C.set(r.id,r),l.push(r)}l.sort(function(t,u){return u.latency-t.latency}),l.length>10&&l.splice(10).forEach(function(t){return C.delete(t.id)})}}},H=function(e){var n=self.requestIdleCallback||self.setTimeout,i=-1;return e=j(e),document.visibilityState==="hidden"?e():(i=n(e),w(e)),i},J=[200,500],pe=function(e,n){"PerformanceEventTiming"in self&&"interactionId"in PerformanceEventTiming.prototype&&(n=n||{},q(function(){var i;de();var r,t=v("INP"),u=function(o){H(function(){o.forEach(me);var s=le();s&&s.latency!==t.value&&(t.value=s.latency,t.entries=s.entries,r())})},a=S("event",u,{durationThreshold:(i=n.durationThreshold)!==null&&i!==void 0?i:40});r=h(e,t,J,n.reportAllChanges),a&&(a.observe({type:"first-input",buffered:!0}),w(function(){u(a.takeRecords()),r(!0)}),L(function(){ee=$(),l.length=0,C.clear(),t=v("INP"),r=h(e,t,J,n.reportAllChanges)}))}))},y=[],p=[],F=0,W=new WeakMap,E=new Map,R=-1,ve=function(e){y=y.concat(e),te()},te=function(){R<0&&(R=H(he))},he=function(){E.size>10&&E.forEach(function(a,o){C.has(o)||E.delete(o)});var e=l.map(function(a){return W.get(a.entries[0])}),n=p.length-50;p=p.filter(function(a,o){return o>=n||e.includes(a)});for(var i=new Set,r=0;r<p.length;r++){var t=p[r];re(t.startTime,t.processingEnd).forEach(function(a){i.add(a)})}var u=y.length-1-50;y=y.filter(function(a,o){return a.startTime>F&&o>u||i.has(a)}),R=-1};ne.push(function(e){e.interactionId&&e.target&&!E.has(e.interactionId)&&E.set(e.interactionId,e.target)},function(e){var n,i=e.startTime+e.duration;F=Math.max(F,e.processingEnd);for(var r=p.length-1;r>=0;r--){var t=p[r];if(Math.abs(i-t.renderTime)<=8){(n=t).startTime=Math.min(e.startTime,n.startTime),n.processingStart=Math.min(e.processingStart,n.processingStart),n.processingEnd=Math.max(e.processingEnd,n.processingEnd),n.entries.push(e);break}}n||(n={startTime:e.startTime,processingStart:e.processingStart,processingEnd:e.processingEnd,renderTime:i,entries:[e]},p.push(n)),(e.interactionId||e.entryType==="first-input")&&W.set(e,n),te()});var re=function(e,n){for(var i,r=[],t=0;i=y[t];t++)if(!(i.startTime+i.duration<e)){if(i.startTime>n)break;r.push(i)}return r},Te=function(e,n){z||(z=S("long-animation-frame",ve)),pe(function(i){var r=function(t){var u=t.entries[0],a=W.get(u),o=u.processingStart,s=a.processingEnd,c=a.entries.sort(function(g,ae){return g.processingStart-ae.processingStart}),f=re(u.startTime,s),d=t.entries.find(function(g){return g.target}),m=d&&d.target||E.get(u.interactionId),b=[u.startTime+u.duration,s].concat(f.map(function(g){return g.startTime+g.duration})),M=Math.max.apply(Math,b),ie={interactionTarget:N(m),interactionTargetElement:m,interactionType:u.name.startsWith("key")?"keyboard":"pointer",interactionTime:u.startTime,nextPaintTime:M,processedEventEntries:c,longAnimationFrameEntries:f,inputDelay:o-u.startTime,processingDuration:s-o,presentationDelay:Math.max(M-s,0),loadState:Q(u.startTime)};return Object.assign(t,{attribution:ie})}(i);e(r)},n)},K=[2500,4e3],A={},ye=function(e,n){(function(i,r){r=r||{},q(function(){var t,u=Y(),a=v("LCP"),o=function(f){r.reportAllChanges||(f=f.slice(-1)),f.forEach(function(d){d.startTime<u.firstHiddenTime&&(a.value=Math.max(d.startTime-O(),0),a.entries=[d],t())})},s=S("largest-contentful-paint",o);if(s){t=h(i,a,K,r.reportAllChanges);var c=j(function(){A[a.id]||(o(s.takeRecords()),s.disconnect(),A[a.id]=!0,t(!0))});["keydown","click"].forEach(function(f){addEventListener(f,function(){return H(c)},{once:!0,capture:!0})}),w(c),L(function(f){a=v("LCP"),t=h(i,a,K,r.reportAllChanges),B(function(){a.value=performance.now()-f.timeStamp,A[a.id]=!0,t(!0)})})}})})(function(i){var r=function(t){var u={timeToFirstByte:0,resourceLoadDelay:0,resourceLoadDuration:0,elementRenderDelay:t.value};if(t.entries.length){var a=k();if(a){var o=a.activationStart||0,s=t.entries[t.entries.length-1],c=s.url&&performance.getEntriesByType("resource").filter(function(M){return M.name===s.url})[0],f=Math.max(0,a.responseStart-o),d=Math.max(f,c?(c.requestStart||c.startTime)-o:0),m=Math.max(d,c?c.responseEnd-o:0),b=Math.max(m,s.startTime-o);u={element:N(s.element),timeToFirstByte:f,resourceLoadDelay:d-f,resourceLoadDuration:m-d,elementRenderDelay:b-m,navigationEntry:a,lcpEntry:s},s.url&&(u.url=s.url),c&&(u.lcpResourceEntry=c)}}return Object.assign(t,{attribution:u})}(i);e(r)},n)};export{G as CLSThresholds,_ as FCPThresholds,J as INPThresholds,K as LCPThresholds,ge as onCLS,Te as onINP,ye as onLCP};
frontend/dist/index.html ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-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-CselhGyO.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/frontend/assets/index-B1No5j6D.css">
10
+ </head>
11
+ <body>
12
+ <div id="root"></div>
13
+ </body>
14
+ </html>
frontend/index.html CHANGED
@@ -5,12 +5,10 @@
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
- <link rel="preconnect" href="https://esm.sh" />
9
- <link rel="preconnect" href="https://unpkg.com" />
10
- <link rel="stylesheet" href="/frontend/styles.css?v=41" />
11
  </head>
12
  <body>
13
  <div id="root"></div>
14
- <script type="module" src="/frontend/app.js?v=79"></script>
15
  </body>
16
  </html>
 
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
+ <link rel="stylesheet" href="/styles.css" />
 
 
9
  </head>
10
  <body>
11
  <div id="root"></div>
12
+ <script type="module" src="/app.js"></script>
13
  </body>
14
  </html>
frontend/mutations.js CHANGED
@@ -1,6 +1,6 @@
1
- import { useMutation, useQueryClient } from "https://esm.sh/@tanstack/react-query@5.59.20?deps=react@18.3.1";
2
- import { api, authHeaders } from "./api.js?v=33";
3
- import { optimisticBoard, removeKinkFromItemsPayload } from "./board-utils.js?v=33";
4
 
5
  const EMPTY_BOARD = { to_me: [], by_me: [], together: [], hidden: [], no_go: [] };
6
 
 
1
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
2
+ import { api, authHeaders } from "./api.js";
3
+ import { optimisticBoard, removeKinkFromItemsPayload } from "./board-utils.js";
4
 
5
  const EMPTY_BOARD = { to_me: [], by_me: [], together: [], hidden: [], no_go: [] };
6
 
frontend/my-plays.js CHANGED
@@ -1,15 +1,15 @@
1
- import React, { useState, useMemo, useCallback, useEffect, useRef } from "https://esm.sh/react@18.3.1";
2
- import { html, UI_REACTIONS, DIRECTION_PILLS } from "./constants.js?v=33";
3
- import { PlayChip, ReactionBar, DirectionPills, SearchOverlay } from "./components.js?v=41";
4
- import { useBoardQuery, useSearchQuery, useDetailQuery, useSimilarQuery } from "./queries.js?v=41";
5
- import { uiReactionFor } from "./api.js?v=33";
6
- import { planSimilarTentativeAdd } from "./my-plays-similar.js?v=33";
7
  import {
8
  DIRECTION_COLUMN_ORDER,
9
  directionColumnsForPlay,
10
  directionsAfterColumnDrop,
11
- } from "./direction-logic.js?v=32";
12
- import { nearDuplicateHintsById } from "./name-fingerprint.js?v=32";
13
 
14
  const DIR_ORDER = [...DIRECTION_COLUMN_ORDER];
15
 
 
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,
10
  directionsAfterColumnDrop,
11
+ } from "./direction-logic.js";
12
+ import { nearDuplicateHintsById } from "./name-fingerprint.js";
13
 
14
  const DIR_ORDER = [...DIRECTION_COLUMN_ORDER];
15
 
frontend/partner-groups.test.mjs CHANGED
@@ -34,3 +34,18 @@ test("resolvedPartnerGroups uses groups GET when user list not loaded yet", () =
34
  const groupsQuery = { items: [{ id: "g2" }] };
35
  assert.deepEqual(resolvedPartnerGroups(null, groupsQuery), [{ id: "g2" }]);
36
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  const groupsQuery = { items: [{ id: "g2" }] };
35
  assert.deepEqual(resolvedPartnerGroups(null, groupsQuery), [{ id: "g2" }]);
36
  });
37
+
38
+ test("resolvedPartnerGroups returns an empty array for empty or malformed sources", () => {
39
+ assert.deepEqual(resolvedPartnerGroups({ partner_groups: [] }, { items: [] }), []);
40
+ assert.deepEqual(resolvedPartnerGroups({ partner_groups: "bad" }, { items: "bad" }), []);
41
+ });
42
+
43
+ test("preferredPartnerGroupId ignores stale selections and empty member placeholders", () => {
44
+ const groups = [
45
+ { id: "solo", member_ids: [] },
46
+ { id: "empty-member", member_ids: [""] },
47
+ { id: "pair", member_ids: ["u2"] },
48
+ ];
49
+
50
+ assert.equal(preferredPartnerGroupId(groups, "missing"), "pair");
51
+ });
frontend/queries.js CHANGED
@@ -1,5 +1,5 @@
1
- import { useQuery } from "https://esm.sh/@tanstack/react-query@5.59.20?deps=react@18.3.1";
2
- import { api, authHeaders } from "./api.js?v=33";
3
 
4
  const protectedQueryOptions = { retry: false, refetchOnWindowFocus: false };
5
 
 
1
+ import { useQuery } from "@tanstack/react-query";
2
+ import { api, authHeaders } from "./api.js";
3
 
4
  const protectedQueryOptions = { retry: false, refetchOnWindowFocus: false };
5
 
frontend/scenarios.js CHANGED
@@ -1,9 +1,9 @@
1
- import React, { useEffect, useMemo, useState } from "https://esm.sh/react@18.3.1";
2
- import { html } from "./constants.js?v=33";
3
- import { uiReactionFor } from "./api.js?v=33";
4
- import { useScenarioParentsQuery, useScenariosQuery } from "./queries.js?v=41";
5
- import { ReactionBar } from "./components.js?v=41";
6
- import { sortScenarioItems } from "./scenarios-state.js?v=1";
7
 
8
  export function ScenariosView({
9
  auth,
 
1
+ import React, { useEffect, useMemo, useState } from "react";
2
+ import { html } from "./constants.js";
3
+ import { uiReactionFor } from "./api.js";
4
+ import { useScenarioParentsQuery, useScenariosQuery } from "./queries.js";
5
+ import { ReactionBar } from "./components.js";
6
+ import { sortScenarioItems } from "./scenarios-state.js";
7
 
8
  export function ScenariosView({
9
  auth,
frontend/scenarios.test.mjs CHANGED
@@ -19,3 +19,55 @@ test("sortScenarioItems pins saved positives first for the active parent", () =>
19
 
20
  assert.deepEqual(ordered.map((item) => item.scenario.id), ["a", "b", "c"]);
21
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
  assert.deepEqual(ordered.map((item) => item.scenario.id), ["a", "b", "c"]);
21
  });
22
+
23
+ test("sortScenarioItems ranks all local states and falls back to names", () => {
24
+ const items = [
25
+ { scenario: { id: "hard", name: "Zulu" } },
26
+ { kink: { id: "love", name: "Bravo" } },
27
+ { id: "none", name: "Alpha" },
28
+ { scenario: { id: "like", name: "Charlie" } },
29
+ { scenario: { id: "other_parent", name: "Able" } },
30
+ ];
31
+ const prefs = {
32
+ love: { parent_kink_id: "anal", interest_state: "love" },
33
+ like: { parent_kink_id: "anal", interest_state: "like" },
34
+ hard: { parent_kink_id: "anal", interest_state: "hard_no" },
35
+ other_parent: { parent_kink_id: "touching", interest_state: "love" },
36
+ };
37
+
38
+ const ordered = sortScenarioItems(items, prefs, "anal");
39
+
40
+ assert.deepEqual(ordered.map((item) => (item.scenario || item.kink || item).id), [
41
+ "love",
42
+ "like",
43
+ "other_parent",
44
+ "none",
45
+ "hard",
46
+ ]);
47
+ });
48
+
49
+ test("sortScenarioItems does not mutate input and ignores preferences from other parents", () => {
50
+ const items = [
51
+ { scenario: { id: "z", name: "Zulu" } },
52
+ { kink: { id: "a", name: "Alpha" } },
53
+ ];
54
+ const prefs = {
55
+ z: { parent_kink_id: "other", interest_state: "love" },
56
+ a: { parent_kink_id: "anal", interest_state: "curious" },
57
+ };
58
+
59
+ const ordered = sortScenarioItems(items, prefs, "anal");
60
+
61
+ assert.deepEqual(items.map((item) => (item.scenario || item.kink).id), ["z", "a"]);
62
+ assert.deepEqual(ordered.map((item) => (item.scenario || item.kink).id), ["a", "z"]);
63
+ });
64
+
65
+ test("sortScenarioItems orders raw, kink, and scenario shapes by normalized names", () => {
66
+ const ordered = sortScenarioItems([
67
+ { id: "raw", name: "Charlie" },
68
+ { kink: { id: "kink", name: "Bravo" } },
69
+ { scenario: { id: "scenario", name: "Alpha" } },
70
+ ]);
71
+
72
+ assert.deepEqual(ordered.map((item) => (item.scenario || item.kink || item).id), ["scenario", "kink", "raw"]);
73
+ });
frontend/settings.js CHANGED
@@ -1,7 +1,7 @@
1
- import React, { useMemo, useState } from "https://esm.sh/react@18.3.1";
2
- import { html, partnerGroupChipLabel } from "./constants.js?v=32";
3
- import { api, authHeaders } from "./api.js?v=33";
4
- import { RoleChip, StatusText } from "./components.js?v=32";
5
 
6
  export function SettingsView({
7
  auth,
 
1
+ import React, { useMemo, useState } from "react";
2
+ import { html, partnerGroupChipLabel } from "./constants.js";
3
+ import { api, authHeaders } from "./api.js";
4
+ import { RoleChip, StatusText } from "./components.js";
5
 
6
  export function SettingsView({
7
  auth,
frontend/starter-flow.test.mjs CHANGED
@@ -18,6 +18,72 @@ test("starterViewState keeps the starter gate up while target-crossing saves are
18
  );
19
  });
20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  test("starterViewState does not reactivate the starter gate after completion", () => {
22
  assert.equal(
23
  starterViewState({
 
18
  );
19
  });
20
 
21
+ test("starterViewState is inactive until an authenticated loaded user exists", () => {
22
+ assert.equal(
23
+ starterViewState({
24
+ hasAuth: false,
25
+ hasCreateResult: false,
26
+ userLoaded: true,
27
+ savedCount: 0,
28
+ starterTarget: 12,
29
+ pendingSaveCount: 0,
30
+ starterCompleted: false,
31
+ }),
32
+ "inactive",
33
+ );
34
+ assert.equal(
35
+ starterViewState({
36
+ hasAuth: true,
37
+ hasCreateResult: true,
38
+ userLoaded: true,
39
+ savedCount: 0,
40
+ starterTarget: 12,
41
+ pendingSaveCount: 0,
42
+ starterCompleted: false,
43
+ }),
44
+ "inactive",
45
+ );
46
+ assert.equal(
47
+ starterViewState({
48
+ hasAuth: true,
49
+ hasCreateResult: false,
50
+ userLoaded: false,
51
+ savedCount: 0,
52
+ starterTarget: 12,
53
+ pendingSaveCount: 0,
54
+ starterCompleted: false,
55
+ }),
56
+ "inactive",
57
+ );
58
+ });
59
+
60
+ test("starterViewState returns starter below target and main once settled", () => {
61
+ assert.equal(
62
+ starterViewState({
63
+ hasAuth: true,
64
+ hasCreateResult: false,
65
+ userLoaded: true,
66
+ savedCount: 11,
67
+ starterTarget: 12,
68
+ pendingSaveCount: 0,
69
+ starterCompleted: false,
70
+ }),
71
+ "starter",
72
+ );
73
+ assert.equal(
74
+ starterViewState({
75
+ hasAuth: true,
76
+ hasCreateResult: false,
77
+ userLoaded: true,
78
+ savedCount: 12,
79
+ starterTarget: 12,
80
+ pendingSaveCount: 0,
81
+ starterCompleted: false,
82
+ }),
83
+ "main",
84
+ );
85
+ });
86
+
87
  test("starterViewState does not reactivate the starter gate after completion", () => {
88
  assert.equal(
89
  starterViewState({
frontend/stryker.conf.mjs ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export default {
2
+ packageManager: "npm",
3
+ reporters: ["clear-text", "progress"],
4
+ testRunner: "command",
5
+ commandRunner: {
6
+ command: "node --test *.test.mjs",
7
+ },
8
+ coverageAnalysis: "off",
9
+ disableTypeChecks: false,
10
+ concurrency: 4,
11
+ cleanTempDir: "always",
12
+ thresholds: {
13
+ high: 85,
14
+ low: 80,
15
+ break: 80,
16
+ },
17
+ mutate: [
18
+ "board-utils.js",
19
+ "direction-logic.js",
20
+ "discover-flow.js",
21
+ "discover-scenarios.js",
22
+ "discover-state.js",
23
+ "my-plays-similar.js",
24
+ "partner-groups.js",
25
+ "scenarios-state.js",
26
+ "starter-flow.js",
27
+ ],
28
+ };
frontend/telemetry.js CHANGED
@@ -12,7 +12,7 @@ export function sendTelemetry(event) {
12
  }
13
 
14
  export function initTelemetry() {
15
- import("https://unpkg.com/web-vitals@4/dist/web-vitals.attribution.js?module").then(({ onCLS, onLCP, onINP }) => {
16
  onCLS((metric) => {
17
  if (metric.value > 0.05) {
18
  send({
 
12
  }
13
 
14
  export function initTelemetry() {
15
+ import("web-vitals/attribution").then(({ onCLS, onLCP, onINP }) => {
16
  onCLS((metric) => {
17
  if (metric.value > 0.05) {
18
  send({
frontend/together.js CHANGED
@@ -1,9 +1,9 @@
1
- import React, { useState, useEffect, useMemo } from "https://esm.sh/react@18.3.1";
2
- import { html, UI_REACTIONS, VIEWS, partnerGroupChipLabel, partnerGroupPartnerIdsTitle } from "./constants.js?v=33";
3
- import { ReactionBar } from "./components.js?v=32";
4
- import { useGroupsQuery, useOverlapQuery, usePartnerBoardQuery } from "./queries.js?v=41";
5
- import { uiReactionFor } from "./api.js?v=33";
6
- import { preferredPartnerGroupId, resolvedPartnerGroups } from "./partner-groups.js?v=33";
7
 
8
  export function TogetherView({
9
  auth,
@@ -264,6 +264,7 @@ export function TogetherView({
264
  currentPlays=${currentPlays}
265
  auth=${auth}
266
  onRate=${onRate}
 
267
  graphDebugUi=${graphDebugUi}
268
  />`
269
  : null}
@@ -408,12 +409,14 @@ function WorthExploringTab({
408
  currentPlays,
409
  auth,
410
  onRate,
 
411
  graphDebugUi = false,
412
  }) {
413
  const related = overlap?.related_matches || [];
 
414
  const similar = overlap?.similar_matches || [];
415
 
416
- if (!related.length && !similar.length) {
417
  return html`<div className="empty">Add more overlap to unlock nearby ideas worth exploring together.</div>`;
418
  }
419
 
@@ -457,6 +460,60 @@ function WorthExploringTab({
457
  `
458
  : null}
459
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
460
  ${similar.length
461
  ? html`
462
  <div className="tiny together-section-label" data-testid="together-explore-similar-section">Suggested next</div>
 
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,
 
264
  currentPlays=${currentPlays}
265
  auth=${auth}
266
  onRate=${onRate}
267
+ onOpenScenarios=${onOpenScenarios}
268
  graphDebugUi=${graphDebugUi}
269
  />`
270
  : null}
 
409
  currentPlays,
410
  auth,
411
  onRate,
412
+ onOpenScenarios,
413
  graphDebugUi = false,
414
  }) {
415
  const related = overlap?.related_matches || [];
416
+ const scenarioRelated = overlap?.scenario_related_matches || [];
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
 
 
460
  `
461
  : null}
462
 
463
+ ${scenarioRelated.length
464
+ ? html`
465
+ <div className="tiny together-section-label" data-testid="together-explore-scenarios-section">
466
+ Connected scenarios
467
+ </div>
468
+ ${scenarioRelated.map((item) => {
469
+ const parent = item.parent_kink;
470
+ const myScenario = item.left_user_id === auth.userId ? item.left_scenario : item.right_scenario;
471
+ const theirScenario = item.left_user_id === auth.userId ? item.right_scenario : item.left_scenario;
472
+ const theirUserId = item.left_user_id === auth.userId ? item.right_user_id : item.left_user_id;
473
+ const theirReaction = item.partner_reactions?.[theirUserId]?.interest_state || "";
474
+ const reason = item.reasons?.[0] || `Both are scenarios under ${parent?.name || "the same base kink"}`;
475
+ const openScenario = () => {
476
+ if (parent?.id && theirScenario?.id) onOpenScenarios?.(parent.id, theirScenario.id);
477
+ };
478
+ return html`
479
+ <div
480
+ key=${`${item.left_scenario?.id || ""}~${item.right_scenario?.id || ""}`}
481
+ className="play-chip related-match-chip scenario-match-chip together-scenario-card"
482
+ data-testid=${`together-explore-scenario-${theirScenario?.id || "unknown"}`}
483
+ role="button"
484
+ tabIndex="0"
485
+ onClick=${openScenario}
486
+ onKeyDown=${(e) => {
487
+ if (e.key !== "Enter" && e.key !== " ") return;
488
+ e.preventDefault();
489
+ openScenario();
490
+ }}
491
+ >
492
+ <div className="together-scenario-copy">
493
+ <div className="tiny together-mother-label">Base kink</div>
494
+ <div className="together-mother-name">${parent?.name || "Shared base kink"}</div>
495
+ <div className="tiny together-scenario-label">You</div>
496
+ <div className="together-scenario-title" title=${myScenario?.name || ""}>${myScenario?.name || "Scenario"}</div>
497
+ <div className="tiny together-scenario-label">Them</div>
498
+ <div className="together-scenario-title" title=${theirScenario?.name || ""}>${theirScenario?.name || "Scenario"}</div>
499
+ </div>
500
+ <div className="together-scenario-meta">
501
+ <span className="tiny together-react-row">
502
+ <span className="together-react-pair" title=${theirUserId}>
503
+ <span className="together-react-id">${theirUserId.slice(0, 8)}…</span>
504
+ ${UI_REACTIONS.find((reaction) => reaction.api === theirReaction)?.icon || ""}
505
+ </span>
506
+ </span>
507
+ ${graphDebugUi
508
+ ? html`<div className="tiny muted" data-testid="together-scenario-related-reason">${reason}</div>`
509
+ : html`<div className="tiny muted">Open their scenario under the same base kink.</div>`}
510
+ </div>
511
+ </div>
512
+ `;
513
+ })}
514
+ `
515
+ : null}
516
+
517
  ${similar.length
518
  ? html`
519
  <div className="tiny together-section-label" data-testid="together-explore-similar-section">Suggested next</div>
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "private": true,
3
+ "name": "kink-cli-frontend-quality",
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "build": "vite build --config vite.config.mjs",
8
+ "test:frontend:node": "node --test frontend/*.test.mjs",
9
+ "test:frontend:mutation": "cd frontend && ../node_modules/.bin/stryker run stryker.conf.mjs"
10
+ },
11
+ "dependencies": {
12
+ "@tanstack/react-query": "^5.90.10",
13
+ "htm": "^3.1.1",
14
+ "react": "^18.3.1",
15
+ "react-dom": "^18.3.1",
16
+ "web-vitals": "^4.2.4"
17
+ },
18
+ "devDependencies": {
19
+ "@stryker-mutator/core": "^9.6.1",
20
+ "vite": "^5.4.21"
21
+ }
22
+ }
pyproject.toml CHANGED
@@ -36,6 +36,7 @@ include = ["backend*"]
36
  [dependency-groups]
37
  dev = [
38
  "dead>=2.1.0",
 
39
  "ruff>=0.15.11",
40
  "semgrep>=1.161.0",
41
  ]
@@ -43,6 +44,34 @@ dev = [
43
  [tool.setuptools]
44
  py-modules = ["api", "models"]
45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  [project.optional-dependencies]
47
  test = [
48
  "blockbuster>=1.5",
 
36
  [dependency-groups]
37
  dev = [
38
  "dead>=2.1.0",
39
+ "mutmut>=3.5.0",
40
  "ruff>=0.15.11",
41
  "semgrep>=1.161.0",
42
  ]
 
44
  [tool.setuptools]
45
  py-modules = ["api", "models"]
46
 
47
+ [tool.pytest.ini_options]
48
+ pythonpath = ["."]
49
+ testpaths = ["tests"]
50
+
51
+ [tool.mutmut]
52
+ paths_to_mutate = [
53
+ "backend/partners.py",
54
+ "backend/scenarios.py",
55
+ "backend/shared_play.py",
56
+ "backend/starter_policy.py",
57
+ ]
58
+ also_copy = [
59
+ "api.py",
60
+ "models.py",
61
+ "backend/",
62
+ "scripts/",
63
+ "tests/fixtures/",
64
+ ]
65
+ pytest_add_cli_args_test_selection = [
66
+ "tests/test_adversarial_api.py",
67
+ "tests/test_partner_groups.py",
68
+ "tests/test_partner_related_matches.py",
69
+ "tests/test_scenarios.py",
70
+ "tests/test_starter_policy.py",
71
+ "tests/test_two_user_flow_tree.py",
72
+ ]
73
+ runner = "python -m pytest -q"
74
+
75
  [project.optional-dependencies]
76
  test = [
77
  "blockbuster>=1.5",
scripts/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Importable maintenance and audit scripts."""
scripts/audit_data_quality.py CHANGED
@@ -1,11 +1,12 @@
1
  from __future__ import annotations
2
 
3
  import json
 
4
  import sqlite3
5
  from pathlib import Path
6
 
7
  ROOT = Path(__file__).resolve().parents[1]
8
- DB_PATH = ROOT / "data" / "store.db"
9
 
10
 
11
  def scalar(conn: sqlite3.Connection, query: str, params: tuple = ()):
 
1
  from __future__ import annotations
2
 
3
  import json
4
+ import os
5
  import sqlite3
6
  from pathlib import Path
7
 
8
  ROOT = Path(__file__).resolve().parents[1]
9
+ DB_PATH = Path(os.environ["KINK_STORE_PATH"]) if os.environ.get("KINK_STORE_PATH") else ROOT / "data" / "store.db"
10
 
11
 
12
  def scalar(conn: sqlite3.Connection, query: str, params: tuple = ()):
scripts/audit_runner.py CHANGED
@@ -5,6 +5,7 @@ import json
5
  import os
6
  import shutil
7
  import subprocess
 
8
  import time
9
  from pathlib import Path
10
 
@@ -64,7 +65,7 @@ def header_probe() -> dict:
64
  def build_steps(tier: str, artifact_dir: Path) -> list[dict]:
65
  steps: list[dict] = []
66
  env = {"KINK_AUDIT_ARTIFACT_DIR": str(artifact_dir), "KINK_AUDIT_BASE_URL": BASE_URL}
67
- python = str(ROOT / ".venv" / "bin" / "python")
68
 
69
  core = [
70
  ("diagnose_ui_flow", [python, "scripts/diagnose_ui_flow.py"], False),
@@ -89,33 +90,69 @@ def build_steps(tier: str, artifact_dir: Path) -> list[dict]:
89
 
90
  if which("npm"):
91
  for name, cmd, optional in [
92
- ("axe_core", ["npm", "--prefix", str(AUDIT_DIR), "run", "axe"], True),
93
- ("lhci", ["npm", "--prefix", str(AUDIT_DIR), "run", "lhci"], True),
94
  ]:
95
  steps.append(run_step(name, cmd, artifact_dir, env=env, optional=optional))
96
 
97
  if which("bandit"):
98
- steps.append(run_step("bandit", ["bandit", "-c", str(AUDIT_DIR / "config" / "bandit.yaml"), "-r", str(ROOT)], artifact_dir, optional=True))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  if which("pip-audit"):
100
- steps.append(run_step("pip_audit", ["pip-audit"], artifact_dir, optional=True))
101
  if which("semgrep"):
102
- steps.append(run_step("semgrep", ["semgrep", "--config", str(AUDIT_DIR / "config" / "semgrep.yml"), str(ROOT)], artifact_dir, optional=True))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  if which("schemathesis"):
104
  steps.append(run_step(
105
  "schemathesis",
106
  [
107
  "schemathesis", "run", f"{BASE_URL}/openapi.json",
108
- "--config", str(AUDIT_DIR / "config" / "schemathesis.toml"),
109
  "--phases", "coverage",
110
  "--checks", "not_a_server_error,status_code_conformance,content_type_conformance,response_headers_conformance,response_schema_conformance,negative_data_rejection,unsupported_method",
111
  "--include-path-regex", r"^/$|^/health$|^/stats$|^/users$",
112
  "--max-failures=10",
113
  "--request-timeout", "5",
114
  "--max-examples", "3",
 
115
  "--workers", "1",
116
  ],
117
  artifact_dir,
118
- optional=True,
119
  ))
120
  return steps
121
 
 
5
  import os
6
  import shutil
7
  import subprocess
8
+ import sys
9
  import time
10
  from pathlib import Path
11
 
 
65
  def build_steps(tier: str, artifact_dir: Path) -> list[dict]:
66
  steps: list[dict] = []
67
  env = {"KINK_AUDIT_ARTIFACT_DIR": str(artifact_dir), "KINK_AUDIT_BASE_URL": BASE_URL}
68
+ python = sys.executable
69
 
70
  core = [
71
  ("diagnose_ui_flow", [python, "scripts/diagnose_ui_flow.py"], False),
 
90
 
91
  if which("npm"):
92
  for name, cmd, optional in [
93
+ ("axe_core", ["npm", "--prefix", str(AUDIT_DIR), "run", "axe"], False),
94
+ ("lhci", ["npm", "--prefix", str(AUDIT_DIR), "run", "lhci"], False),
95
  ]:
96
  steps.append(run_step(name, cmd, artifact_dir, env=env, optional=optional))
97
 
98
  if which("bandit"):
99
+ steps.append(
100
+ run_step(
101
+ "bandit",
102
+ [
103
+ "bandit",
104
+ "-c",
105
+ str(AUDIT_DIR / "config" / "bandit.yaml"),
106
+ "-r",
107
+ str(ROOT / "api.py"),
108
+ str(ROOT / "backend"),
109
+ ],
110
+ artifact_dir,
111
+ optional=False,
112
+ )
113
+ )
114
  if which("pip-audit"):
115
+ steps.append(run_step("pip_audit", ["pip-audit"], artifact_dir, optional=False))
116
  if which("semgrep"):
117
+ steps.append(
118
+ run_step(
119
+ "semgrep",
120
+ [
121
+ "semgrep",
122
+ "--config",
123
+ str(AUDIT_DIR / "config" / "semgrep.yml"),
124
+ "--exclude",
125
+ "node_modules",
126
+ "--exclude",
127
+ ".venv",
128
+ "--exclude",
129
+ ".cursor",
130
+ "--exclude",
131
+ "data",
132
+ "--exclude",
133
+ "frontend/dist",
134
+ str(ROOT),
135
+ ],
136
+ artifact_dir,
137
+ optional=False,
138
+ )
139
+ )
140
  if which("schemathesis"):
141
  steps.append(run_step(
142
  "schemathesis",
143
  [
144
  "schemathesis", "run", f"{BASE_URL}/openapi.json",
 
145
  "--phases", "coverage",
146
  "--checks", "not_a_server_error,status_code_conformance,content_type_conformance,response_headers_conformance,response_schema_conformance,negative_data_rejection,unsupported_method",
147
  "--include-path-regex", r"^/$|^/health$|^/stats$|^/users$",
148
  "--max-failures=10",
149
  "--request-timeout", "5",
150
  "--max-examples", "3",
151
+ "--generation-allow-x00=false",
152
  "--workers", "1",
153
  ],
154
  artifact_dir,
155
+ optional=False,
156
  ))
157
  return steps
158
 
scripts/build_hf_demo_db.py CHANGED
@@ -54,13 +54,13 @@ def main() -> int:
54
  ("demo_kink_3", "Kissing", "Kissing and making out."),
55
  ("demo_kink_4", "Spanking", "Impact on the butt."),
56
  ("demo_kink_5", "Cuddles", "Non-sexual closeness."),
57
- ("demo_kink_6", "Anal sex", "Starter demo row."),
58
- ("demo_kink_7", "Blindfolds", "Starter demo row."),
59
- ("demo_kink_8", "Breast nipple play", "Starter demo row."),
60
- ("demo_kink_9", "Hair pulling", "Starter demo row."),
61
- ("demo_kink_10", "Handcuffs", "Starter demo row."),
62
- ("demo_kink_11", "Mutual masturbation", "Starter demo row."),
63
- ("demo_kink_12", "Oral sex", "Starter demo row."),
64
  ]
65
  for kid, name, short in kinks:
66
  b.ensure_kink(kid, name, "fetlife_fetish", short, "low", False, "")
 
54
  ("demo_kink_3", "Kissing", "Kissing and making out."),
55
  ("demo_kink_4", "Spanking", "Impact on the butt."),
56
  ("demo_kink_5", "Cuddles", "Non-sexual closeness."),
57
+ ("demo_kink_6", "Anal sex", ""),
58
+ ("demo_kink_7", "Blindfolds", ""),
59
+ ("demo_kink_8", "Breast nipple play", ""),
60
+ ("demo_kink_9", "Hair pulling", ""),
61
+ ("demo_kink_10", "Handcuffs", ""),
62
+ ("demo_kink_11", "Mutual masturbation", ""),
63
+ ("demo_kink_12", "Oral sex", ""),
64
  ]
65
  for kid, name, short in kinks:
66
  b.ensure_kink(kid, name, "fetlife_fetish", short, "low", False, "")
scripts/check_mutmut_stats.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+
8
+
9
+ def main() -> int:
10
+ parser = argparse.ArgumentParser()
11
+ parser.add_argument("stats_path", type=Path)
12
+ parser.add_argument("--min-score", type=float, default=100.0)
13
+ parser.add_argument("--max-survived", type=int, default=0)
14
+ parser.add_argument("--max-timeout", type=int, default=0)
15
+ parser.add_argument("--max-no-tests", type=int, default=0)
16
+ args = parser.parse_args()
17
+
18
+ stats = json.loads(args.stats_path.read_text())
19
+ killed = int(stats["killed"])
20
+ total = int(stats["total"])
21
+ survived = int(stats["survived"])
22
+ timeout = int(stats["timeout"])
23
+ no_tests = int(stats["no_tests"])
24
+ score = killed / total * 100
25
+
26
+ print(
27
+ "mutmut score: "
28
+ f"{score:.2f}% ({killed}/{total} killed, "
29
+ f"{survived} survived, {timeout} timeout, {no_tests} no tests)"
30
+ )
31
+
32
+ failures = []
33
+ if score < args.min_score:
34
+ failures.append(f"score {score:.2f}% < {args.min_score:.2f}%")
35
+ if survived > args.max_survived:
36
+ failures.append(f"survived {survived} > {args.max_survived}")
37
+ if timeout > args.max_timeout:
38
+ failures.append(f"timeout {timeout} > {args.max_timeout}")
39
+ if no_tests > args.max_no_tests:
40
+ failures.append(f"no_tests {no_tests} > {args.max_no_tests}")
41
+
42
+ if failures:
43
+ print("mutmut gate failed: " + "; ".join(failures), file=sys.stderr)
44
+ return 1
45
+ return 0
46
+
47
+
48
+ if __name__ == "__main__":
49
+ raise SystemExit(main())