Spaces:
Sleeping
Sleeping
Sync from kink_cli (Docker Space)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +2 -0
- .gitignore +6 -0
- .ruff_cache/0.15.11/14315220232248816887 +0 -0
- .ruff_cache/0.15.11/1920814609139379236 +0 -0
- .ruff_cache/0.15.11/5434035610807442273 +0 -0
- .ruff_cache/0.15.11/7290032257184656914 +0 -0
- .semgrepignore +3 -0
- Dockerfile +6 -1
- Makefile +15 -1
- README.md +2 -1
- api.py +25 -14
- backend/enrichment.py +1 -1
- backend/hf_bootstrap.py +11 -4
- deploy/hf/README.md +2 -1
- deploy/hf/entrypoint.sh +3 -1
- deploy/hf/seed/hf_bundled_store.db +1 -1
- frontend/api.js +1 -1
- frontend/app.js +19 -19
- frontend/board-utils.test.mjs +191 -1
- frontend/components.js +8 -5
- frontend/constants.js +2 -2
- frontend/direction-logic.test.mjs +11 -0
- frontend/discover-flow.test.mjs +5 -0
- frontend/discover-scenarios.test.mjs +21 -0
- frontend/discover-state.test.mjs +13 -0
- frontend/discover.js +9 -9
- frontend/dist/assets/index-B1No5j6D.css +1 -0
- frontend/dist/assets/index-CselhGyO.js +0 -0
- frontend/dist/assets/web-vitals.attribution-C_HyTrBb.js +1 -0
- frontend/dist/index.html +14 -0
- frontend/index.html +2 -4
- frontend/mutations.js +3 -3
- frontend/my-plays.js +8 -8
- frontend/partner-groups.test.mjs +15 -0
- frontend/queries.js +2 -2
- frontend/scenarios.js +6 -6
- frontend/scenarios.test.mjs +52 -0
- frontend/settings.js +4 -4
- frontend/starter-flow.test.mjs +66 -0
- frontend/stryker.conf.mjs +28 -0
- frontend/telemetry.js +1 -1
- frontend/together.js +64 -7
- package-lock.json +0 -0
- package.json +22 -0
- pyproject.toml +29 -0
- scripts/__init__.py +1 -0
- scripts/audit_data_quality.py +2 -1
- scripts/audit_runner.py +45 -8
- scripts/build_hf_demo_db.py +7 -7
- 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 (
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 26 |
-
|
| 27 |
-
|
|
|
|
| 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
|
| 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=
|
| 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
|
| 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'
|
| 188 |
"base-uri 'self'; frame-ancestors 'none'; form-action 'self'; frame-src 'none'; object-src 'none'; "
|
| 189 |
-
"script-src 'self'
|
| 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 |
-
|
| 370 |
-
|
|
|
|
| 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 "
|
| 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 |
-
|
|
|
|
| 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=
|
| 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 (
|
| 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 |
-
|
|
|
|
|
|
|
| 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:
|
| 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
|
|
|
|
| 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 "
|
| 2 |
-
import { createRoot } from "
|
| 3 |
import {
|
| 4 |
QueryClient,
|
| 5 |
QueryClientProvider,
|
| 6 |
useQueryClient,
|
| 7 |
-
} from "
|
| 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,17 +28,17 @@ import {
|
|
| 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: [] };
|
|
|
|
| 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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 "
|
| 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,10 +178,13 @@ export function KinkCard({
|
|
| 178 |
|
| 179 |
if (layout === "tinder") {
|
| 180 |
const expanded = Boolean(infoExpanded);
|
| 181 |
-
const
|
| 182 |
const blurb = discoverCollapsedBlurb(kink);
|
| 183 |
const fullDef = discoverFullDefinition(kink);
|
| 184 |
-
|
|
|
|
|
|
|
|
|
|
| 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 "
|
| 2 |
-
import htm from "
|
| 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 "
|
| 2 |
-
import { useQueryClient } from "
|
| 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,
|
|
|
|
| 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="
|
| 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="/
|
| 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 "
|
| 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 |
|
|
|
|
| 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 "
|
| 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 |
|
|
|
|
| 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 "
|
| 2 |
-
import { api, authHeaders } from "./api.js
|
| 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 "
|
| 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,
|
|
|
|
| 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 "
|
| 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,
|
|
|
|
| 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("
|
| 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 "
|
| 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,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 =
|
| 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"],
|
| 93 |
-
("lhci", ["npm", "--prefix", str(AUDIT_DIR), "run", "lhci"],
|
| 94 |
]:
|
| 95 |
steps.append(run_step(name, cmd, artifact_dir, env=env, optional=optional))
|
| 96 |
|
| 97 |
if which("bandit"):
|
| 98 |
-
steps.append(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
if which("pip-audit"):
|
| 100 |
-
steps.append(run_step("pip_audit", ["pip-audit"], artifact_dir, optional=
|
| 101 |
if which("semgrep"):
|
| 102 |
-
steps.append(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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", "
|
| 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, "")
|
|
|
|
| 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())
|