From 26ed34bc945d3fe41d23a3fccf0b3ee29dae2326 Mon Sep 17 00:00:00 2001 From: Thales <> Date: Sat, 27 Jun 2026 18:50:40 +0100 Subject: [PATCH 1/2] feat: mobile web UI + network access toggle Add a phone-optimized web UI and let other devices on the LAN reach a StemDeck instance, so the app is usable end-to-end from a phone. Mobile UI (static/mobile/, vanilla JS to match the stack): - Library, Mixer, and Extract screens wired to the real API. Library lists /api/jobs with swipe-to-delete; Mixer reuses the desktop Web Audio engine (audioEngine.js, now accepting a shared gesture-unlocked AudioContext for iOS) with faders/mute/solo/seek, real analysis, and mixdown/MP4 export; Extract submits URL/upload and follows SSE progress. - Served by a user-agent check on "/" (phones get mobile, everyone else the DAW; ?ui= overrides). Shared DOM-free helpers in static/js/shared/jobs.js. - Ported from the design prototype kept under design/mobile/. Network access (app/core/settings.py, app/main.py): - Backend always binds 0.0.0.0; a runtime gate decides whether non-host requests are served (default off, opt-in). The host machine (loopback or its own LAN IP) is always allowed, so it can't be locked out. - Settings dialog reorganized into General / Advanced tabs: General holds max track length (<=20 min) and MP4 video quality; Advanced holds the network toggle (with the LAN address list) and out-of-sync resync. - Runtime settings (allow_network, max_duration_sec, video_max_height) are persisted and read live via GET/POST /api/settings, no restart needed. Performance: stem MP3s are transcoded once and cached on disk (was re-encoded on every request), so loading a track on mobile is fast and re-loads instant. Desktop: start_backend binds 0.0.0.0; adds a local_ip command. --- .gitignore | 1 + app/api/jobs.py | 17 +- app/api/stems.py | 65 +- app/core/settings.py | 110 ++ app/main.py | 157 +- app/pipeline/download.py | 13 +- design/mobile/StemDeck-Mobile.dc.html | 457 +++++ design/mobile/support.js | 1595 +++++++++++++++++ .../mobile/uploads/pasted-1782491178712-0.png | Bin 0 -> 196608 bytes desktop/src-tauri/src/main.rs | 23 +- desktop/ui/setup.js | 10 +- static/css/daw.css | 38 + static/js/audioEngine.js | 10 +- static/js/catalog.js | 192 +- static/js/shared/jobs.js | 75 + static/mobile/app.js | 867 +++++++++ static/mobile/index.html | 26 + static/mobile/styles.css | 986 ++++++++++ tests/conftest.py | 21 + tests/test_mobile_routing.py | 66 + tests/test_network_gate.py | 100 ++ 21 files changed, 4791 insertions(+), 38 deletions(-) create mode 100644 app/core/settings.py create mode 100644 design/mobile/StemDeck-Mobile.dc.html create mode 100644 design/mobile/support.js create mode 100644 design/mobile/uploads/pasted-1782491178712-0.png create mode 100644 static/js/shared/jobs.js create mode 100644 static/mobile/app.js create mode 100644 static/mobile/index.html create mode 100644 static/mobile/styles.css create mode 100644 tests/conftest.py create mode 100644 tests/test_mobile_routing.py create mode 100644 tests/test_network_gate.py diff --git a/.gitignore b/.gitignore index 507be5c3..e933e589 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ htmlcov/ # Runtime job artifacts and local build scratch data/ jobs/ +settings.json .run/ .build diff --git a/app/api/jobs.py b/app/api/jobs.py index 17dc365c..db17a2cb 100644 --- a/app/api/jobs.py +++ b/app/api/jobs.py @@ -12,14 +12,7 @@ from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel, field_validator -from app.core.config import ( - JOB_ID_RE, - JOBS_DIR, - MAX_DURATION_SEC, - MAX_PENDING_JOBS, - STEM_NAMES, - ffprobe_executable, -) +from app.core.config import JOB_ID_RE, JOBS_DIR, MAX_PENDING_JOBS, STEM_NAMES, ffprobe_executable from app.core.models import Job from app.core.registry import all_jobs as registry_all_jobs from app.core.registry import get as registry_get @@ -27,6 +20,7 @@ from app.core.registry import persist as registry_persist from app.core.registry import register_if_capacity as registry_register_if_capacity from app.core.registry import remove as registry_remove +from app.core.settings import get_max_duration_sec from app.pipeline import run_local_pipeline, run_pipeline from app.pipeline.download import InvalidYouTubeURL, validate_youtube_url @@ -213,12 +207,11 @@ async def _create_local_job(request: Request) -> dict[str, str]: except Exception as e: raise HTTPException(status_code=422, detail=f"Could not read file duration: {e}") from e - if duration > MAX_DURATION_SEC: + max_duration = get_max_duration_sec() + if duration > max_duration: raise HTTPException( status_code=422, - detail=( - f"File is {int(duration // 60)} min — limit is {MAX_DURATION_SEC // 60} min" - ), + detail=(f"File is {int(duration // 60)} min — limit is {max_duration // 60} min"), ) except HTTPException: shutil.rmtree(job_dir, ignore_errors=True) diff --git a/app/api/stems.py b/app/api/stems.py index 5447ddb3..396baecd 100644 --- a/app/api/stems.py +++ b/app/api/stems.py @@ -6,6 +6,7 @@ import re import subprocess import tempfile +import uuid import zipfile from pathlib import Path @@ -99,6 +100,50 @@ async def _stream_ffmpeg(cmd: list[str]): await proc.wait() +async def _ensure_cached_mp3(src: Path) -> Path: + """Transcode `src` (a stem WAV) to a sibling `.mp3`, cached on disk. + Re-encoding a full song on every request is the slow part of loading a track + on mobile (≈3s/stem × 6 in parallel); caching makes repeat loads instant. + Written atomically (temp + rename) so concurrent fetches can't serve a + partial file.""" + dest = src.with_suffix(".mp3") + if dest.is_file() and dest.stat().st_mtime >= src.stat().st_mtime: + return dest + tmp = dest.with_name(f".{dest.name}.{uuid.uuid4().hex}.tmp") + cmd = [ + ffmpeg_executable(), + "-nostdin", + "-loglevel", + "error", + "-y", + "-i", + str(src), + "-q:a", + "2", # VBR ~190 kbps + "-f", + "mp3", + str(tmp), + ] + proc = await asyncio.create_subprocess_exec( + *cmd, stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.PIPE + ) + try: + _, stderr = await asyncio.wait_for(proc.communicate(), timeout=TIMEOUT_FFMPEG) + except (TimeoutError, asyncio.TimeoutError): + proc.kill() + await proc.wait() + tmp.unlink(missing_ok=True) + raise HTTPException(status_code=504, detail="mp3 transcode timed out") from None + if proc.returncode != 0: + tmp.unlink(missing_ok=True) + logger.warning( + "mp3 transcode failed for %s: %s", src.name, (stderr or b"").decode("utf-8", "replace") + ) + raise HTTPException(status_code=500, detail="mp3 transcode failed") + os.replace(tmp, dest) + return dest + + @router.get("/jobs/{job_id}/stems/peaks.json") async def get_stem_peaks(job_id: str) -> Response: """Return pre-computed waveform peaks for all stems.""" @@ -166,8 +211,9 @@ async def get_stem_mp3( name: str, start: float | None = Query(default=None, ge=0, description="Trim start in seconds"), end: float | None = Query(default=None, gt=0, description="Trim end in seconds"), -) -> StreamingResponse: - """Stream a stem as MP3 (VBR ~190 kbps). Optional ?start=&end= trims to a time region.""" +) -> Response: + """Stem as MP3 (VBR ~190 kbps). Full stems are cached to disk; ?start=&end= + streams a freshly-trimmed region (uncached).""" path = _validate_stem_path(job_id, name) if (start is None) != (end is None) or (start is not None and start >= end): @@ -176,6 +222,21 @@ async def get_stem_mp3( detail="start and end are both required and start must be less than end", ) + # Full-stem requests (no trim) are cached to disk so repeat loads — the + # common case for the mobile player — are instant instead of re-encoding. + if start is None: + cached = await _ensure_cached_mp3(path) + return FileResponse( + cached, + media_type="audio/mpeg", + headers={ + "Content-Disposition": f'attachment; filename="{name}.mp3"', + # Stems are immutable once a job is done — let the phone cache + # them so a re-load is instant and offline-friendly. + "Cache-Control": "public, max-age=31536000, immutable", + }, + ) + pre_seek = ["-ss", str(start)] if start is not None else [] post_seek = ["-t", str(end - start)] if start is not None else [] diff --git a/app/core/settings.py b/app/core/settings.py new file mode 100644 index 00000000..e8b3a62d --- /dev/null +++ b/app/core/settings.py @@ -0,0 +1,110 @@ +"""Runtime, user-toggleable settings (persisted to disk). + +These are read live (unlike the env-var constants in config.py, which are fixed +at startup), so the Settings UI can change them without a restart: + +- `allow_network` — whether StemDeck answers requests from other devices. +- `max_duration_sec` — longest track accepted for processing. +- `video_max_height` — max video resolution for MP4 export / YouTube pulls. + +Defaults fall back to the config.py constants (which honor their env vars), so +nothing changes until the user overrides a value. +""" + +from __future__ import annotations + +import json +import os +import threading + +from app.core.config import DATA_DIR, MAX_DURATION_SEC, VIDEO_MAX_HEIGHT + +_SETTINGS_PATH = DATA_DIR / "settings.json" +_LOCK = threading.RLock() +_state: dict | None = None # whole settings dict, loaded lazily + +# Clamp bounds. Max track length is capped at 20 min (the product ceiling). +_DURATION_MIN, _DURATION_MAX = 60, 1200 # 1 min .. 20 min +_HEIGHT_MIN, _HEIGHT_MAX = 144, 2160 + + +def _default_allow_network() -> bool: + # Off by default everywhere — the user explicitly opts other devices in. + # STEMDECK_ALLOW_NETWORK=1 can pre-enable it (e.g. headless/Docker deploys). + env = os.environ.get("STEMDECK_ALLOW_NETWORK") + if env is not None: + return env.strip() == "1" + return False + + +def _load() -> dict: + try: + data = json.loads(_SETTINGS_PATH.read_text(encoding="utf-8")) + if isinstance(data, dict): + return data + except Exception: + pass + return {} + + +def _ensure() -> dict: + global _state + if _state is None: + _state = _load() + return _state + + +def _save() -> None: + try: + _SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True) + _SETTINGS_PATH.write_text(json.dumps(_ensure()), encoding="utf-8") + except Exception: + pass + + +def _num(v: object) -> int | None: + return int(v) if isinstance(v, (int, float)) and not isinstance(v, bool) else None + + +# ── allow_network ── +def get_allow_network() -> bool: + with _LOCK: + v = _ensure().get("allow_network") + return v if isinstance(v, bool) else _default_allow_network() + + +def set_allow_network(value: bool) -> bool: + with _LOCK: + _ensure()["allow_network"] = bool(value) + _save() + return bool(value) + + +# ── max_duration_sec ── +def get_max_duration_sec() -> int: + with _LOCK: + v = _num(_ensure().get("max_duration_sec")) + return max(_DURATION_MIN, min(_DURATION_MAX, v)) if v is not None else MAX_DURATION_SEC + + +def set_max_duration_sec(value: int) -> int: + with _LOCK: + clamped = max(_DURATION_MIN, min(_DURATION_MAX, int(value))) + _ensure()["max_duration_sec"] = clamped + _save() + return clamped + + +# ── video_max_height ── +def get_video_max_height() -> int: + with _LOCK: + v = _num(_ensure().get("video_max_height")) + return max(_HEIGHT_MIN, min(_HEIGHT_MAX, v)) if v is not None else VIDEO_MAX_HEIGHT + + +def set_video_max_height(value: int) -> int: + with _LOCK: + clamped = max(_HEIGHT_MIN, min(_HEIGHT_MAX, int(value))) + _ensure()["video_max_height"] = clamped + _save() + return clamped diff --git a/app/main.py b/app/main.py index 9ee4c384..452e5b4b 100644 --- a/app/main.py +++ b/app/main.py @@ -2,15 +2,19 @@ import asyncio import ctypes +import functools import logging import os +import re import signal +import socket from collections.abc import AsyncIterator from contextlib import asynccontextmanager from importlib.metadata import PackageNotFoundError from importlib.metadata import version as package_version -from fastapi import FastAPI, Request +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import FileResponse, PlainTextResponse from fastapi.staticfiles import StaticFiles from app.api.router import router @@ -24,6 +28,14 @@ ensure_runtime_dirs, ) from app.core.registry import restore as restore_registry +from app.core.settings import ( + get_allow_network, + get_max_duration_sec, + get_video_max_height, + set_allow_network, + set_max_duration_sec, + set_video_max_height, +) from app.pipeline.collect import sweep_old_jobs # Show our INFO-level logs through uvicorn's root handler. Without this, @@ -137,6 +149,21 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: yield +# Phones hitting the self-hosted server URL get the mobile UI; everything +# else (desktop browsers, and the Tauri webviews, which all report desktop +# user-agents) gets the DAW. Tablets are intentionally treated as desktop — +# the DAW layout is usable there. "Mobi" is the cross-browser marker for a +# phone form factor (Chrome/Firefox/Safari all include it); the rest cover +# vendors that don't. +_MOBILE_UA_RE = re.compile( + r"Mobi|Android|iPhone|iPod|IEMobile|BlackBerry|Opera Mini", re.IGNORECASE +) + + +def _is_mobile_ua(user_agent: str) -> bool: + return bool(user_agent) and _MOBILE_UA_RE.search(user_agent) is not None + + app = FastAPI( title="StemDeck", description="Paste a YouTube URL or upload an audio file, get audio stems split into a DAW-style player.", @@ -145,6 +172,23 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: ) +@app.get("/", include_in_schema=False) +def index(request: Request) -> FileResponse: + """Serve the mobile shell to phones, the DAW to everyone else. `?ui=mobile` + / `?ui=desktop` forces either one (handy for testing from a desktop). This + route is registered before the StaticFiles mount at "/", so it wins for the + bare path while the mount still serves every other asset.""" + ui = request.query_params.get("ui") + if ui == "mobile": + mobile = True + elif ui == "desktop": + mobile = False + else: + mobile = _is_mobile_ua(request.headers.get("user-agent", "")) + page = "mobile/index.html" if mobile else "index.html" + return FileResponse(STATIC_DIR / page) + + @app.get("/health", include_in_schema=False) def health_root() -> dict[str, object]: return health() @@ -162,6 +206,58 @@ def health() -> dict[str, object]: } +def _is_lan_ipv4(ip: str) -> bool: + """A reachable IPv4 LAN address to show another device: not IPv6 (link-local + needs a zone index and won't work in a browser), not loopback, not the + 169.254.x auto-config range.""" + if ":" in ip: # IPv6 + return False + if _is_loopback(ip) or ip.startswith("169.254."): + return False + parts = ip.split(".") + return len(parts) == 4 and all(p.isdigit() for p in parts) + + +def _settings_payload() -> dict[str, object]: + return { + "allow_network": get_allow_network(), + "max_duration_sec": get_max_duration_sec(), + "video_max_height": get_video_max_height(), + } + + +@app.get("/api/settings", tags=["settings"]) +def get_settings(request: Request) -> dict[str, object]: + # LAN addresses other devices can use — loopback excluded (only works on the + # host). The port is whatever this request came in on. + port = request.url.port or 8000 + addresses = sorted(f"http://{ip}:{port}" for ip in _local_ips() if _is_lan_ipv4(ip)) + return {**_settings_payload(), "lan_addresses": addresses} + + +@app.post("/api/settings", tags=["settings"]) +async def update_settings(request: Request) -> dict[str, object]: + """Update runtime settings. Reachable from the host machine always; from a + LAN device only while network access is currently on (the gate below), so a + phone can't change settings once the owner turned access off.""" + try: + body = await request.json() + except Exception: + body = {} + if "allow_network" in body: + set_allow_network(bool(body["allow_network"])) + for key, setter in ( + ("max_duration_sec", set_max_duration_sec), + ("video_max_height", set_video_max_height), + ): + if key in body: + try: + setter(int(body[key])) + except (TypeError, ValueError): + raise HTTPException(status_code=422, detail=f"{key} must be an integer") from None + return _settings_payload() + + # Content-Security-Policy. Defense-in-depth so an injected string in the webview # can't run script (and, in the desktop app, reach the exposed Tauri IPC) — #171. # script-src has no 'unsafe-inline'/'eval': all JS is same-origin modules and the @@ -200,6 +296,65 @@ async def security_and_cache_headers(request: Request, call_next): return response +def _is_loopback(host: str | None) -> bool: + if not host: + return False + if host.startswith("::ffff:"): # IPv4-mapped IPv6 + host = host[7:] + return host in {"127.0.0.1", "::1", "localhost"} or host.startswith("127.") + + +@functools.lru_cache(maxsize=1) +def _local_ips() -> frozenset[str]: + """The machine's own interface IPs. Used so the host always reaches the app + even via its LAN address (e.g. 192.168.x.x), not just 127.0.0.1 — turning + network access off must never cut the host off from its own server.""" + ips: set[str] = set() + try: + hostname = socket.gethostname() + for info in socket.getaddrinfo(hostname, None): + ips.add(info[4][0]) + except Exception: + pass + try: # primary outbound IP, robust when the hostname doesn't resolve them all + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + ips.add(s.getsockname()[0]) + s.close() + except Exception: + pass + return frozenset(ips) + + +def _is_host_request(host: str | None) -> bool: + """True when the request originates from the machine StemDeck runs on — + whether via loopback or one of its own interface addresses.""" + if _is_loopback(host): + return True + if not host: + return False + h = host[7:] if host.startswith("::ffff:") else host + return h in _local_ips() + + +# Network availability gate (Settings → "Make StemDeck available on your +# network"). Added after the headers middleware so it is the OUTERMOST layer and +# short-circuits before anything else. It NEVER stops the server — it only +# refuses requests from OTHER devices when availability is off. The host machine +# (loopback or its own LAN IP) is always served, so the app keeps working +# locally regardless of this setting. +@app.middleware("http") +async def network_gate(request: Request, call_next): + if not get_allow_network(): + client_host = request.client.host if request.client else None + if not _is_host_request(client_host): + return PlainTextResponse( + "StemDeck is not available on the network. Enable it in Settings on the host machine.", + status_code=403, + ) + return await call_next(request) + + app.include_router(router, prefix="/api") app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static") diff --git a/app/pipeline/download.py b/app/pipeline/download.py index 4dac6f30..c69d6095 100644 --- a/app/pipeline/download.py +++ b/app/pipeline/download.py @@ -8,8 +8,9 @@ from yt_dlp import YoutubeDL -from app.core.config import FFMPEG_DIR, MAX_DURATION_SEC, VIDEO_MAX_HEIGHT +from app.core.config import FFMPEG_DIR from app.core.models import Job, JobCancelled, _set +from app.core.settings import get_max_duration_sec, get_video_max_height logger = logging.getLogger("stemdeck.download") @@ -173,10 +174,11 @@ def vhook(d: dict) -> None: # Prefer H.264 (avc1) so the exported MP4 plays everywhere -- YouTube also # serves AV1/VP9 in mp4 containers, which many players (Safari/iOS, older # devices) can't decode. Fall back to any <=cap mp4 only if no avc1 exists. + max_height = get_video_max_height() ydl_opts = { "format": ( - f"bestvideo[height<={VIDEO_MAX_HEIGHT}][vcodec^=avc1]" - f"/bestvideo[height<={VIDEO_MAX_HEIGHT}][ext=mp4]" + f"bestvideo[height<={max_height}][vcodec^=avc1]" + f"/bestvideo[height<={max_height}][ext=mp4]" ), "outtmpl": str(job_dir / "video.%(ext)s"), "quiet": True, @@ -222,8 +224,9 @@ def download(job: Job, url: str, job_dir: Path) -> Path: ) as ydl: meta = ydl.extract_info(url, download=False) or {} duration = meta.get("duration") or 0 - if duration > MAX_DURATION_SEC: - mins = MAX_DURATION_SEC // 60 + max_duration = get_max_duration_sec() + if duration > max_duration: + mins = max_duration // 60 raise RuntimeError(f"Video is {int(duration // 60)} min -- limit is {mins} min") def hook(d: dict) -> None: diff --git a/design/mobile/StemDeck-Mobile.dc.html b/design/mobile/StemDeck-Mobile.dc.html new file mode 100644 index 00000000..f40db764 --- /dev/null +++ b/design/mobile/StemDeck-Mobile.dc.html @@ -0,0 +1,457 @@ + + + + + + + + + + + + + + + + +
+ +
+
+ + +
+ + +
+ 9:41 +
+ + 5G +
+
+
+ + + +
+
+ +
+ + NOW PLAYING + +
+ + +
+
+
+ {{ coverInitial }} +
+
{{ trackTitle }}
+
{{ trackSub }}
+
+ YouTube + 6 stems + High +
+
+ + +
+
+ +
+
+
+
+
+ {{ curTime }} + {{ durTime }} +
+
+ + +
+ + + + + +
+ + +
+ + +
+
+ + + +
+ +
+
+
+
{{ st.name }}
+ + +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+ + + +
+
+ +
+
{{ x.k }}
+
{{ x.v }}
+
{{ x.s }}
+
+
+
+
STEM PRESENCE
+ +
+
{{ p.name }}
+
+ {{ p.val }} +
+
+ +
+
+ +
+
+
+ + + +
+
+
+ Library +
JS
+
+
+ + Search your library +
+
+ All + Favorites + Synthwave + Indie Rock + Lo-Fi +
+ +
RECENT
+ +
+
{{ t.initial }}
+
+
{{ t.title }}
+
{{ t.sub }}
+
{{ t.meta }}
+
+
+ +
+
+ +
COLLECTIONS
+
+ +
+
+
+
+
+
{{ c.name }}
+
{{ c.count }}
+
+
+
+
+
+
+
+ + + +
+
+
Extract stems
+
Paste a link or upload audio to split into stems.
+ +
+ + Paste YouTube or audio URL + Paste +
+ + +
STEMS TO EXTRACT
+
+ + + +
+ +
QUALITY
+
+ + +
+ + + +
IN PROGRESS
+
+
+
+
+
Lunar Tides
+
Separating 6 stems…
+
+ 64% +
+
+
+
+
+
+
+ + + +
+
{{ coverInitial }}
+
+
{{ trackTitle }}
+
{{ trackSub }}
+
+ +
+
+ + +
+ + + +
+ + +
+ +
+
+ +
StemDeck — mobile concept · tap tabs, drag faders, M/S, play
+
+
+ + + diff --git a/design/mobile/support.js b/design/mobile/support.js new file mode 100644 index 00000000..304d1fca --- /dev/null +++ b/design/mobile/support.js @@ -0,0 +1,1595 @@ +// GENERATED from dc-runtime/src/*.ts — do not edit. Rebuild with `cd dc-runtime && bun run build`. +"use strict"; +(() => { + var __defProp = Object.defineProperty; + var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; + var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); + + // src/react.ts + function getReact() { + const R = window.React; + if (!R) throw new Error("dc-runtime: window.React is not available yet"); + return R; + } + function getReactDOM() { + const RD = window.ReactDOM; + if (!RD) throw new Error("dc-runtime: window.ReactDOM is not available yet"); + return RD; + } + var h = ((...args) => getReact().createElement( + ...args + )); + + // src/parse.ts + function parseDcDocument(doc) { + const dc = doc.querySelector("x-dc"); + if (!dc) return null; + const scriptEl = doc.querySelector("script[data-dc-script]"); + const { props, preview } = parseDataProps( + scriptEl?.getAttribute("data-props") ?? null + ); + return { + template: dc.innerHTML, + js: scriptEl ? scriptEl.textContent || "" : "", + props, + preview + }; + } + function parseDcText(src) { + const openMatch = /]*)?>/.exec(src); + if (!openMatch) return null; + const close = src.lastIndexOf(""); + if (close === -1 || close < openMatch.index) return null; + const template = src.slice(openMatch.index + openMatch[0].length, close); + const doc = new DOMParser().parseFromString(src, "text/html"); + const scriptEl = doc.querySelector("script[data-dc-script]"); + const { props, preview } = parseDataProps( + scriptEl?.getAttribute("data-props") ?? null + ); + return { + template, + js: scriptEl ? scriptEl.textContent || "" : "", + props, + preview + }; + } + function parseDataProps(raw) { + if (!raw) return { props: null, preview: null }; + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + return { props: null, preview: null }; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { props: null, preview: null }; + } + const obj = parsed; + const preview = obj.$preview && typeof obj.$preview === "object" ? obj.$preview : null; + const rest = {}; + for (const k of Object.keys(obj)) { + if (k[0] !== "$") rest[k] = obj[k]; + } + return { props: Object.keys(rest).length ? rest : null, preview }; + } + function dcNameFromPath(pathname) { + let p = pathname || ""; + try { + p = decodeURIComponent(p); + } catch { + } + const base = p.split("/").pop() || "Root"; + return base.replace(/\.dc\.html$/, "").replace(/\.html?$/, "") || "Root"; + } + + // src/boot.ts + var BASE_CSS = ` + .sc-placeholder{background:rgba(255,255,255,.3);border:1px solid rgba(0,0,0,.5); + border-radius:2px;box-sizing:border-box;overflow:hidden} + @keyframes sc-shine{0%{background-position:100% 50%}100%{background-position:0% 50%}} + html.sc-dc-streaming .sc-placeholder, + html.sc-dc-streaming .sc-interp.sc-missing{position:relative; + background:color-mix(in srgb,currentColor 5%,transparent); + border-color:transparent} + html.sc-dc-streaming .sc-placeholder::before, + html.sc-dc-streaming .sc-interp.sc-missing::before{content:''; + position:absolute;inset:0;pointer-events:none; + background:linear-gradient(90deg,rgba(217,119,87,0) 25%,rgba(247,225,211,.95) 37%,rgba(217,119,87,0) 63%); + background-size:400% 100%;animation:sc-shine 1.4s ease infinite} + html.sc-dc-streaming .sc-placeholder:nth-child(n+9 of .sc-placeholder)::before, + html.sc-dc-streaming .sc-interp.sc-missing:nth-child(n+9 of .sc-interp.sc-missing)::before{animation:none; + background:color-mix(in srgb,currentColor 8%,transparent)} + .sc-placeholder-error{padding:4px 8px;font:11px/1.4 ui-monospace,monospace; + color:rgba(0,0,0,.7);word-break:break-word} + .sc-interp.sc-missing{display:inline-block;width:2em;height:1em;overflow:hidden; + vertical-align:text-bottom;background:rgba(255,255,255,.3);border:1px solid rgba(0,0,0,.5); + border-radius:2px;box-sizing:border-box;color:transparent; + user-select:none} + .sc-interp.sc-unresolved{font-family:ui-monospace,monospace;font-size:.85em; + color:rgba(0,0,0,.5);background:rgba(0,0,0,.05);border-radius:3px; + padding:0 3px} + .sc-host.sc-has-error{position:relative} + .sc-logic-error{position:absolute;top:8px;left:8px;z-index:2147483647;max-width:60ch; + padding:6px 10px;background:#b00020;color:#fff;font:12px/1.4 ui-monospace,monospace; + border-radius:4px;white-space:pre-wrap;pointer-events:none} + /* Mirrors PRINT_BASELINE_CSS in apps/web deck-stage-export.ts \u2014 keep both + in sync until dc-runtime regains a build step. */ + @media print { + @page { margin: 0.5cm; } + figure, table { break-inside: avoid; } + #dc-root, #dc-root > .sc-host { height: auto; } + *, *::before, *::after { + print-color-adjust: exact; -webkit-print-color-adjust: exact; + backdrop-filter: none !important; -webkit-backdrop-filter: none !important; + animation-delay: -99s !important; animation-duration: .001s !important; + animation-iteration-count: 1 !important; animation-fill-mode: both !important; + animation-play-state: running !important; transition-duration: 0s !important; + } + } + `; + var FULL_PAGE_CSS = "html,body{height:100%;margin:0}#dc-root,#dc-root>.sc-host{height:100%}"; + function rootNameForDocument(doc, loc) { + let bootPath = loc.pathname || ""; + if (!/\.dc\.html?$/i.test(safeDecode(bootPath))) { + try { + bootPath = new URL(doc.baseURI || "/").pathname; + } catch { + } + } + return dcNameFromPath(bootPath); + } + function safeDecode(s) { + try { + return decodeURIComponent(s); + } catch { + return s; + } + } + function boot(runtime, doc = document) { + const parsed = parseDcDocument(doc); + if (!parsed) return null; + const React = getReact(); + const rootName = rootNameForDocument(doc, location); + runtime.markFetched(rootName); + runtime.setRootName(rootName); + runtime.adoptParsed(rootName, parsed); + fetch(location.href).then((res) => res.ok ? res.text() : "").then((t) => { + const raw = t ? parseDcText(t) : null; + if (raw?.template) runtime.updateHtml(rootName, raw.template); + }).catch(() => { + }); + const dc = doc.querySelector("x-dc"); + const hostEl = doc.createElement("div"); + hostEl.id = "dc-root"; + dc.replaceWith(hostEl); + if (!parsed.preview) { + const s = doc.createElement("style"); + s.textContent = FULL_PAGE_CSS; + doc.head.appendChild(s); + } + const Root = runtime.getDC(rootName); + const entry = runtime.registry.get(rootName); + function StandaloneRoot() { + const [, setTick] = React.useState(0); + React.useEffect(() => { + const sub = () => setTick((n) => n + 1); + entry.subs.add(sub); + return () => { + entry.subs.delete(sub); + }; + }, []); + return h(Root, entry.propOverrides || null); + } + const ReactDOM = getReactDOM(); + if (ReactDOM.createRoot) + ReactDOM.createRoot(hostEl).render(h(StandaloneRoot)); + else ReactDOM.render(h(StandaloneRoot), hostEl); + return rootName; + } + + // src/expr.ts + var IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*/; + var NUMBER_RE = /^-?\d+(\.\d+)?$/; + function resolve(vals, src) { + const expr = String(src).trim(); + if (!expr) return void 0; + if (expr[0] === "(" && expr[expr.length - 1] === ")" && parensWrapWhole(expr)) { + return resolve(vals, expr.slice(1, -1)); + } + const eq = findTopLevelEquality(expr); + if (eq) { + const lv = resolve(vals, expr.slice(0, eq.index)); + const rv = resolve(vals, expr.slice(eq.index + eq.op.length)); + switch (eq.op) { + case "===": + return lv === rv; + case "!==": + return lv !== rv; + case "==": + return lv == rv; + default: + return lv != rv; + } + } + if (expr[0] === "!") return !resolve(vals, expr.slice(1)); + if (expr === "true") return true; + if (expr === "false") return false; + if (expr === "null") return null; + if (expr === "undefined") return void 0; + if (NUMBER_RE.test(expr)) return Number(expr); + if (expr.length >= 2 && (expr[0] === '"' || expr[0] === "'") && expr[expr.length - 1] === expr[0]) { + return expr.slice(1, -1); + } + return resolvePath(vals, expr); + } + function parensWrapWhole(expr) { + let depth = 0; + for (let i = 0; i < expr.length - 1; i++) { + if (expr[i] === "(") depth++; + else if (expr[i] === ")") { + depth--; + if (depth === 0) return false; + } + } + return true; + } + function findTopLevelEquality(expr) { + let depth = 0; + for (let i = 0; i < expr.length; i++) { + const c = expr[i]; + if (c === "[" || c === "(") depth++; + else if (c === "]" || c === ")") depth--; + else if (depth === 0 && (c === "=" || c === "!") && expr[i + 1] === "=") { + if (i > 0 && (expr[i - 1] === "=" || expr[i - 1] === "!")) continue; + if (!expr.slice(0, i).trim()) continue; + const op = expr[i + 2] === "=" ? c + "==" : c + "="; + return { index: i, op }; + } + } + return null; + } + function resolvePath(vals, expr) { + const head = expr.match(IDENT_RE); + if (!head) return void 0; + let cur = vals == null ? void 0 : vals[head[0]]; + let i = head[0].length; + while (i < expr.length) { + if (expr[i] === ".") { + const m = expr.slice(i + 1).match(IDENT_RE) || expr.slice(i + 1).match(/^\d+/); + if (!m) return void 0; + cur = cur == null ? void 0 : cur[m[0]]; + i += 1 + m[0].length; + } else if (expr[i] === "[") { + let depth = 1; + let j = i + 1; + while (j < expr.length && depth > 0) { + if (expr[j] === "[") depth++; + else if (expr[j] === "]") { + depth--; + if (depth === 0) break; + } + j++; + } + if (depth !== 0) return void 0; + const key = resolve(vals, expr.slice(i + 1, j)); + cur = cur == null ? void 0 : cur[key]; + i = j + 1; + } else { + return void 0; + } + } + return cur; + } + + // src/encode.ts + var CAMEL_ATTR = "sc-camel-"; + var RAW_WRAP = { + select: "sc-raw-select", + table: "sc-raw-table", + tbody: "sc-raw-tbody", + thead: "sc-raw-thead", + tfoot: "sc-raw-tfoot", + tr: "sc-raw-tr", + td: "sc-raw-td", + th: "sc-raw-th", + caption: "sc-raw-caption" + }; + var RAW_UNWRAP = Object.fromEntries( + Object.entries(RAW_WRAP).map(([k, v]) => [v, k]) + ); + var EVENT_MAP = { + onclick: "onClick", + onchange: "onChange", + oninput: "onInput", + onsubmit: "onSubmit", + onkeydown: "onKeyDown", + onkeyup: "onKeyUp", + onkeypress: "onKeyPress", + onmousedown: "onMouseDown", + onmouseup: "onMouseUp", + onmouseenter: "onMouseEnter", + onmouseleave: "onMouseLeave", + onfocus: "onFocus", + onblur: "onBlur", + ondoubleclick: "onDoubleClick", + oncontextmenu: "onContextMenu" + }; + var ATTRS = `(?:[^>"']|"[^"]*"|'[^']*')*`; + var IMPORT_SELF_CLOSE_RE = new RegExp( + "<(x-import|dc-import)(" + ATTRS + ")/>", + "gi" + ); + var CAMEL_ATTR_RE = /(\s)([a-z]+[A-Z][A-Za-z0-9]*)(\s*=)/g; + function encodeCase(html) { + html = html.replace( + IMPORT_SELF_CLOSE_RE, + (_, t, a) => "<" + t + a + ">" + ); + html = html.replace(/)/gi, "/gi, ""); + html = html.replace( + CAMEL_ATTR_RE, + (_, sp, name, eq) => sp + CAMEL_ATTR + name.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()) + eq + ); + for (const [real, alias] of Object.entries(RAW_WRAP)) { + html = html.replace( + new RegExp("(])", "gi"), + "$1" + alias + ); + } + return html; + } + function kebabToCamel(s) { + return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); + } + function cssToObj(css) { + const o = {}; + for (const decl of css.split(";")) { + const i = decl.indexOf(":"); + if (i < 0) continue; + const prop = decl.slice(0, i).trim(); + o[prop.startsWith("--") ? prop : kebabToCamel(prop)] = decl.slice(i + 1).trim(); + } + return o; + } + function compileAttr(raw) { + const whole = raw.match(/^\s*\{\{([\s\S]+?)\}\}\s*$/); + if (whole) { + const path = whole[1]; + return (vals) => resolve(vals, path); + } + if (raw.includes("{{")) { + const parts = raw.split(/\{\{([\s\S]+?)\}\}/g); + return (vals) => parts.map((s, i) => i & 1 ? resolve(vals, s) ?? "" : s).join(""); + } + return () => raw; + } + + // src/compile.ts + function collectProps(node, kind, host) { + const propGetters = []; + const pseudoClasses = []; + let hintSize = null; + for (const { name, value } of [...node.attributes]) { + if (name === "sc-name" || name === "data-dc-tpl") continue; + let key = name; + if (key.startsWith(CAMEL_ATTR)) + key = kebabToCamel(key.slice(CAMEL_ATTR.length)); + if (key === "hint-size") { + hintSize = value; + continue; + } + if (key.startsWith("style-")) { + pseudoClasses.push(host.pseudoClass(key.slice(6), value)); + continue; + } + if (kind !== "dom") { + if (key.includes("-") && !(kind === "x-import" && (key.startsWith("aria-") || key.startsWith("data-")))) + key = kebabToCamel(key); + } else { + if (key === "class") key = "className"; + else if (key === "for") key = "htmlFor"; + else if (key.startsWith("on")) + key = EVENT_MAP[key] || "on" + key[2].toUpperCase() + key.slice(3); + } + propGetters.push([key, compileAttr(value)]); + } + return { propGetters, pseudoClasses, hintSize }; + } + var HOST_STYLE_PROPS = /* @__PURE__ */ new Set([ + "position", + "left", + "right", + "top", + "bottom", + "inset", + "width", + "height", + "z-index", + "transform" + ]); + function hostPositionStyle(style) { + const all = typeof style === "string" ? cssToObj(style) : style != null && typeof style === "object" ? style : null; + if (!all) return void 0; + const out = {}; + for (const [k, v] of Object.entries(all)) { + const kebab = k.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()); + if (HOST_STYLE_PROPS.has(kebab)) out[k] = v; + } + return Object.keys(out).length ? out : void 0; + } + function compileTemplate(html, host) { + const tpl = document.createElement("template"); + //! nosemgrep: direct-inner-html-assignment + tpl.innerHTML = encodeCase(html); + let tplN = 0; + (function stamp(node) { + if (node.nodeType === Node.ELEMENT_NODE) { + node.setAttribute("data-dc-tpl", String(tplN++)); + } + for (const c of node.childNodes) stamp(c); + })(tpl.content); + const builders = walkChildren(tpl.content, host); + const render = ((vals, ctx) => builders.map((b, i) => b(vals || {}, ctx, i))); + render.__annotated = tpl.innerHTML; + return render; + } + function walkChildren(node, host) { + return [...node.childNodes].map((c) => walk(c, host)).filter((b) => b != null); + } + function walk(node, host) { + if (node.nodeType === Node.TEXT_NODE) return walkText(node); + if (node.nodeType !== Node.ELEMENT_NODE) return null; + const el = node; + const tag = el.tagName.toLowerCase(); + if (tag === "sc-for") return walkFor(el, host); + if (tag === "sc-if") return walkIf(el, host); + if (tag === "x-import") return walkXImport(el, host); + if (tag === "sc-helmet") return host.helmet(el); + if (tag === "dc-import") return walkComponent(el, host); + return walkElement(el, host); + } + var warnedHoles = /* @__PURE__ */ new Set(); + function warnUnresolved(ctx, what) { + const key = (ctx?.__name || "?") + "\0" + what; + if (warnedHoles.has(key)) return; + warnedHoles.add(key); + console.warn("[dc-runtime] " + (ctx?.__name || "template") + ": " + what); + } + function walkText(node) { + const txt = node.nodeValue ?? ""; + if (!txt.includes("{{")) { + if (!txt.trim() && !txt.includes(" ")) return null; + return () => txt; + } + const parts = txt.split(/\{\{([\s\S]+?)\}\}/g); + return (vals, ctx, key) => h( + getReact().Fragment, + { key }, + ...parts.map((p, i) => { + if (!(i & 1)) return p; + const v = resolve(vals, p); + if (v === void 0) { + if (!ctx?.__streamingNow) { + if (document.body?.hasAttribute("data-dc-editor-on")) { + return h( + "span", + { key: i, className: "sc-interp sc-unresolved" }, + "{{ " + p.trim() + " }}" + ); + } + warnUnresolved( + ctx, + "{{ " + p.trim() + " }} never resolved \u2014 rendered as empty" + ); + return null; + } + return h( + "span", + { key: i, className: "sc-interp sc-missing" }, + p.trim() + ); + } + if (getReact().isValidElement(v) || Array.isArray(v)) { + return h(getReact().Fragment, { key: i }, v); + } + if (v === null || typeof v === "boolean") return null; + return h("span", { key: i, className: "sc-interp" }, String(v)); + }) + ); + } + function walkFor(el, host) { + const listGet = compileAttr(el.getAttribute("list") || ""); + const asName = el.getAttribute("as") || "item"; + const hintN = parseInt(el.getAttribute("hint-placeholder-count") || "0", 10); + const kids = walkChildren(el, host); + const listSrc = el.getAttribute("list") || ""; + return (vals, ctx, key) => { + let list = listGet(vals); + if (!Array.isArray(list)) { + if (!ctx?.__streamingNow) { + if (list !== void 0 && list !== null) { + warnUnresolved( + ctx, + 'sc-for list="' + listSrc + '" is not an array (' + typeof list + ")" + ); + } + list = []; + } else { + list = hintN > 0 ? Array(hintN).fill(void 0) : []; + } + } + return h( + getReact().Fragment, + { key }, + list.map((item, i) => { + const sub = { ...vals, [asName]: item, $index: i }; + return h( + getReact().Fragment, + { key: i }, + kids.map((b, j) => b(sub, ctx, j)) + ); + }) + ); + }; + } + function walkIf(el, host) { + const valGet = compileAttr(el.getAttribute("value") || ""); + const hintRaw = el.getAttribute("hint-placeholder-val"); + const hintGet = hintRaw != null ? compileAttr(hintRaw) : null; + const kids = walkChildren(el, host); + return (vals, ctx, key) => { + let v = valGet(vals); + if (v === void 0 && hintGet && ctx?.__streamingNow) v = hintGet(vals); + return v ? h( + getReact().Fragment, + { key }, + kids.map((b, j) => b(vals, ctx, j)) + ) : null; + }; + } + function walkComponent(el, host) { + const name = el.getAttribute("name") || el.getAttribute("component") || ""; + el.removeAttribute("name"); + el.removeAttribute("component"); + const tplId = el.getAttribute("data-dc-tpl"); + const styleRaw = el.getAttribute("style"); + el.removeAttribute("style"); + const styleGet = styleRaw != null ? compileAttr(styleRaw) : null; + const { propGetters, hintSize } = collectProps(el, "dc-import", host); + const kids = walkChildren(el, host); + return (vals, ctx, key) => { + const props = { + key, + __hintSize: hintSize, + __tplId: tplId, + __hostStyle: styleGet ? hostPositionStyle(styleGet(vals)) : void 0 + }; + for (const [k, g] of propGetters) { + const v = g(vals); + if (k === "dcProps") { + if (v && typeof v === "object") Object.assign(props, v); + continue; + } + props[k] = v; + } + if (kids.length) props.children = kids.map((b, j) => b(vals, ctx, j)); + return h(host.component(name), props); + }; + } + function walkXImport(el, host) { + const globalNameGet = compileAttr( + el.getAttribute("component-from-global-scope") || "" + ); + const exportNameGet = compileAttr( + el.getAttribute("component") || el.getAttribute("name") || "" + ); + const fromRaw = el.getAttribute("from") || el.getAttribute("src") || el.getAttribute("import") || ""; + const urls = fromRaw.trim() ? fromRaw.trim().split(/\s+/) : []; + const url = urls.length ? urls[urls.length - 1] : ""; + const kindOf = (u) => /\.(jsx|tsx)(\?|#|$)/i.test(u) ? "jsx" : "js"; + const tplId = el.getAttribute("data-dc-tpl"); + const styleRaw = el.getAttribute("style"); + el.removeAttribute("style"); + const styleGet = styleRaw != null ? compileAttr(styleRaw) : null; + const wrap = tplId != null || styleGet != null; + const { propGetters, hintSize } = collectProps(el, "x-import", host); + const hasContent = el.children.length > 0 || !!(el.textContent || "").trim(); + const kids = hasContent ? walkChildren(el, host) : []; + const urlBindable = fromRaw.includes("{{"); + if (urls.length && !urlBindable) { + let prev; + for (const u of urls) prev = host.loadExternal(kindOf(u), u, prev); + } + const evalName = (g, vals) => { + const v = g(vals); + const s = v == null ? "" : String(v); + return s.includes("{{") ? "" : s; + }; + return (vals, ctx, key) => { + const globalName = evalName(globalNameGet, vals); + const name = globalName || evalName(exportNameGet, vals); + const C = !name || urlBindable ? null : globalName ? host.resolveExternalGlobal(url, globalName) : host.resolveExternal(url, name); + const hostStyle = styleGet ? hostPositionStyle(styleGet(vals)) : void 0; + const wrapper = wrap ? { + key, + className: "sc-host-x", + "data-dc-tpl": tplId, + style: hostStyle || { display: "contents" } + } : null; + if (!C) { + const error = urlBindable ? "x-import `from` cannot contain {{ \u2026 }} \u2014 module URLs are resolved at parse time; use a literal URL" : host.resolveExternalError(url, name); + const ph = host.placeholder({ + key: wrapper ? void 0 : key, + name, + hintSize, + error + }); + return wrapper ? h("div", wrapper, ph) : ph; + } + const props = wrapper ? {} : { key }; + let unresolvedHole = false; + for (const [k, g] of propGetters) { + if (k === "component" || k === "componentFromGlobalScope" || k === "from") { + continue; + } + const v = g(vals); + if (v === void 0) unresolvedHole = true; + if (k === "dcProps") { + if (v && typeof v === "object") Object.assign(props, v); + continue; + } + props[k] = v; + } + if (unresolvedHole && ctx?.__htmlStreamingNow) { + const ph = host.placeholder({ + key: wrapper ? void 0 : key, + name, + hintSize, + error: null + }); + return wrapper ? h("div", wrapper, ph) : ph; + } + if (kids.length) props.children = kids.map((b, j) => b(vals, ctx, j)); + return wrapper ? h("div", wrapper, h(C, props)) : h(C, props); + }; + } + function walkElement(el, host) { + const realTag = RAW_UNWRAP[el.localName] || el.localName; + const tplId = el.getAttribute("data-dc-tpl"); + const { propGetters, pseudoClasses } = collectProps(el, "dom", host); + const kids = walkChildren(el, host); + return (vals, ctx, key) => { + const props = { key, "data-dc-tpl": tplId }; + for (const [k, g] of propGetters) { + let v = g(vals); + if (k === "style" && typeof v === "string") v = cssToObj(v); + if ((k === "value" || k === "checked") && v === void 0) { + v = k === "checked" ? false : ""; + } + props[k] = v; + } + if (pseudoClasses.length) { + props.className = [props.className, ...pseudoClasses].filter(Boolean).join(" "); + } + return h(realTag, props, ...kids.map((b, j) => b(vals, ctx, j))); + }; + } + + // src/logic.ts + var StreamableLogic = class { + constructor(props) { + __publicField(this, "props"); + __publicField(this, "state", {}); + /** Back-pointer to the wrapper component, installed after construction. */ + __publicField(this, "__host"); + this.props = props || {}; + } + setState(update, cb) { + this.__host && this.__host.__setLogicState(update, cb); + } + forceUpdate() { + this.__host && this.__host.forceUpdate(); + } + componentDidMount() { + } + componentDidUpdate(_prevProps) { + } + componentWillUnmount() { + } + /** The flat object the template renders against (merged over props). */ + renderVals() { + return {}; + } + }; + function evalDcLogic(src) { + //! nosemgrep: eval-and-function-constructor + const fn = new Function( + "DCLogic", + "StreamableLogic", + "React", + src + '\n;return (typeof Component!=="undefined"&&Component)||undefined;' + ); + return fn(StreamableLogic, StreamableLogic, getReact()); + } + + // src/component.ts + function shallowEqual(a, b) { + if (!b) return false; + const ak = Object.keys(a).filter((k) => k !== "children"); + const bk = Object.keys(b).filter((k) => k !== "children"); + if (ak.length !== bk.length) return false; + for (const k of ak) if (a[k] !== b[k]) return false; + return true; + } + function Placeholder({ + name, + hintSize, + streaming, + error + }) { + const [w, hgt] = (hintSize || "100%,60px").split(","); + return h( + "div", + { + className: "sc-placeholder" + (streaming ? " sc-streaming" : ""), + style: { width: w.trim(), height: hgt && hgt.trim() }, + title: name + }, + error ? h( + "div", + { className: "sc-placeholder-error" }, + (name ? name + ": " : "") + error + ) : null + ); + } + function hintToMin(hint) { + if (!hint) return void 0; + const [w, hgt] = hint.split(","); + return { minWidth: w.trim(), minHeight: hgt && hgt.trim() }; + } + function createComponentFactory(registry, ensureFetched) { + const React = getReact(); + const AncestorContext = React.createContext([]); + class StreamableComponent extends React.Component { + constructor(props) { + super(props); + __publicField(this, "__name"); + __publicField(this, "__sub"); + __publicField(this, "__needsDidMount", false); + /** Snapshot of the registry's streaming flags taken at render time — + * builders read it off the RenderCtx (this) to pick placeholder vs + * render-nothing for unresolved values. */ + __publicField(this, "__streamingNow", false); + __publicField(this, "__htmlStreamingNow", false); + /** When a construct throws, remember the (class, registry.ver, props) + * triple so render-time reconcile doesn't re-attempt it on every parent + * re-render. A registry bump (new class, template, external module + * resolving via bumpAll) changes `ver` and breaks the memo so an + * env-dependent constructor can self-heal. */ + __publicField(this, "__failedLogic", null); + __publicField(this, "__failedUserProps", null); + __publicField(this, "__failedVer", -1); + /** Per-instance constructor error — kept here (not on the registry entry) + * so one instance's successful construct can't hide a sibling's failure, + * and a construct can never wipe an eval error `updateJs` recorded on + * `r.logicError`. */ + __publicField(this, "__ctorError", null); + __publicField(this, "logic"); + this.__name = props.__name; + this.state = { __v: 0, __err: null }; + this.__sub = () => { + if (this.state.__err) this.setState({ __err: null }); + this.forceUpdate(); + }; + this.__makeLogic(registry.get(this.__name).Logic, null); + ensureFetched(this.__name); + } + /** Error-boundary hook: a render crash anywhere in this DC's subtree + * (its own template, an x-import'd component, a child DC without its + * own deeper boundary) lands here instead of unmounting the page. */ + static getDerivedStateFromError(e) { + return { __err: e instanceof Error && e.message ? e.message : String(e) }; + } + componentDidCatch(e, info) { + console.error( + "[dc-runtime] render error in <" + this.__name + ">:", + e, + info?.componentStack || "" + ); + } + /** Instantiate the logic class (or the no-op base) and adopt `prevState` + * over its initial state — used both at mount and on hot-swap. */ + __makeLogic(Logic, prevState) { + const L = Logic || StreamableLogic; + try { + this.logic = new L(this.__userProps()); + this.__failedLogic = null; + this.__failedUserProps = null; + this.__ctorError = null; + } catch (e) { + console.error(e); + this.__failedLogic = Logic; + this.__failedUserProps = this.__userProps(); + this.__failedVer = registry.get(this.__name).ver; + this.__ctorError = this.__name + ": " + (e instanceof Error && e.message ? e.message : String(e)); + this.logic = new StreamableLogic( + this.__userProps() + ); + } + this.logic.__host = this; + if (prevState) + this.logic.state = { ...this.logic.state || {}, ...prevState }; + } + /** The props the author's logic + template see — internal __-prefixed + * wiring stripped. */ + __userProps() { + const { __name, __hintSize, __tplId, __hostStyle, ...rest } = this.props; + return rest; + } + __setLogicState(update, cb) { + const prev = this.logic.state; + const patch = typeof update === "function" ? update(prev) : update; + this.logic.state = { ...prev, ...patch }; + this.setState((s) => ({ __v: s.__v + 1 }), cb); + } + /** Swap the logic instance when the registry's Logic class changed + * (streaming completion, hot reload). State carries over; didMount + * re-fires after the swap commits so refs exist. */ + __reconcileLogic() { + const r = registry.get(this.__name); + const Next = r.Logic; + const Cur = this.logic.constructor; + if (Next === Cur || !Next && Cur === StreamableLogic || Next === this.__failedLogic && r.ver === this.__failedVer && shallowEqual(this.__userProps(), this.__failedUserProps)) { + return; + } + if (!this.__needsDidMount) { + try { + this.logic.componentWillUnmount(); + } catch (e) { + console.error(e); + } + } + this.__makeLogic(Next, this.logic.state); + this.__needsDidMount = true; + } + componentDidMount() { + registry.get(this.__name).subs.add(this.__sub); + try { + this.logic.componentDidMount(); + } catch (e) { + console.error(e); + } + } + componentDidUpdate(prevProps) { + this.logic.props = this.__userProps(); + if (this.__needsDidMount) { + if (this.state.__err || !registry.get(this.__name).tpl) return; + this.__needsDidMount = false; + try { + this.logic.componentDidMount(); + } catch (e) { + console.error(e); + } + } else { + try { + this.logic.componentDidUpdate(prevProps); + } catch (e) { + console.error(e); + } + } + } + componentWillUnmount() { + registry.get(this.__name).subs.delete(this.__sub); + if (!this.__needsDidMount) { + try { + this.logic.componentWillUnmount(); + } catch (e) { + console.error(e); + } + } + } + render() { + const r = registry.get(this.__name); + const cls = "sc-host" + (r.htmlStreaming ? " sc-streaming-html" : "") + (r.jsStreaming ? " sc-streaming-js" : ""); + const hintStyle = r.htmlStreaming ? hintToMin(this.props.__hintSize) : void 0; + const hostStyle = this.props.__hostStyle || hintStyle ? { ...hintStyle || {}, ...this.props.__hostStyle || {} } : void 0; + const hostBase = { + className: cls, + style: hostStyle, + "data-sc-name": this.__name, + "data-dc-tpl": this.props.__tplId + }; + const chain = Array.isArray(this.context) ? this.context : []; + if (chain.includes(this.__name)) { + const cycle = [ + ...chain.slice(chain.indexOf(this.__name)), + this.__name + ].join(" \u2192 "); + return h( + "div", + { ...hostBase, className: cls + " sc-has-error" }, + h(Placeholder, { + name: this.__name, + hintSize: this.props.__hintSize, + error: "circular import: " + cycle + }) + ); + } + if (this.state.__err) { + return h( + "div", + { ...hostBase, className: cls + " sc-has-error" }, + h( + "div", + { className: "sc-logic-error", "data-omelette-chrome": "" }, + this.__name + ": " + this.state.__err + ), + h(Placeholder, { + name: this.__name, + hintSize: this.props.__hintSize, + error: this.state.__err + }) + ); + } + this.__reconcileLogic(); + if (!r.tpl) { + return h( + "div", + hostBase, + h(Placeholder, { name: this.__name, hintSize: this.props.__hintSize }) + ); + } + const userProps = this.__userProps(); + this.logic.props = userProps; + let vals = userProps; + let renderErr = r.logicError || this.__ctorError; + try { + vals = { ...userProps, ...this.logic.renderVals() || {} }; + } catch (e) { + console.error(e); + renderErr = this.__name + ".renderVals(): " + (e instanceof Error && e.message ? e.message : String(e)); + } + this.__streamingNow = !!(r.htmlStreaming || r.jsStreaming); + this.__htmlStreamingNow = !!r.htmlStreaming; + return h( + "div", + { ...hostBase, className: cls + (renderErr ? " sc-has-error" : "") }, + renderErr && h( + "div", + { className: "sc-logic-error", "data-omelette-chrome": "" }, + renderErr + ), + h( + AncestorContext.Provider, + { value: [...chain, this.__name] }, + r.tpl(vals, this) + ) + ); + } + } + __publicField(StreamableComponent, "contextType", AncestorContext); + const named = /* @__PURE__ */ new Map(); + function getDC(name) { + const hit = named.get(name); + if (hit) return hit; + function Dispatcher(p) { + const [, setTick] = React.useState(0); + React.useEffect(() => { + const sub = () => setTick((n) => n + 1); + registry.get(name).subs.add(sub); + return () => { + registry.get(name).subs.delete(sub); + }; + }, []); + ensureFetched(name); + return h(StreamableComponent, { ...p, __name: name }); + } + Dispatcher.displayName = name; + named.set(name, Dispatcher); + return Dispatcher; + } + return { + getDC, + StreamableComponent + }; + } + + // src/external.ts + var isCustomElementName = (n) => !n.includes(".") && n.includes("-"); + function isRenderableType(g) { + if (typeof g === "function") return !isElementClass(g); + return typeof g === "object" && g !== null && typeof g.$$typeof === "symbol"; + } + function resolveDottedPath(root, name) { + let cur = root; + for (const seg of name.split(".")) { + if (cur == null) return void 0; + cur = cur[seg]; + } + return cur; + } + var BABEL_URL = "https://unpkg.com/@babel/standalone@7.26.4/babel.min.js"; + var GLOBAL_POLL_INTERVAL_MS = 50; + var GLOBAL_POLL_TIMEOUT_MS = 3e4; + function createExternalModules(onResolved) { + const cache = /* @__PURE__ */ new Map(); + let babelLoading = null; + const reportedMissing = /* @__PURE__ */ new Map(); + const polling = /* @__PURE__ */ new Set(); + function ensureBabel() { + if (window.Babel) return Promise.resolve(); + if (babelLoading) return babelLoading; + babelLoading = new Promise((res, rej) => { + const s = document.createElement("script"); + s.src = BABEL_URL; + s.crossOrigin = "anonymous"; + s.onload = () => res(); + s.onerror = rej; + document.head.appendChild(s); + }); + return babelLoading; + } + const pending = /* @__PURE__ */ new Map(); + function load(kind, url, after) { + const existing = pending.get(url); + if (existing) return existing; + cache.set(url, null); + console.info("[dc-runtime] x-import: loading", url, "(" + kind + ")"); + const ready = Promise.all([ + kind === "jsx" ? ensureBabel() : Promise.resolve(), + after ?? Promise.resolve() + ]); + const p = ready.then(() => fetch(url)).then((r) => { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.text(); + }).then((src) => { + const code = kind === "jsx" ? window.Babel.transform(src, { + filename: url, + presets: ["react", "typescript"] + }).code : src; + const module = { exports: {} }; + const before = new Set(Object.keys(window)); + //! nosemgrep: eval-and-function-constructor + new Function("React", "module", "exports", "require", code)( + getReact(), + module, + module.exports, + () => ({}) + ); + const globals = {}; + for (const k of Object.keys(window)) { + if (!before.has(k) && typeof window[k] === "function") { + globals[k] = window[k]; + } + } + cache.set(url, { mod: module.exports, globals }); + console.info( + "[dc-runtime] x-import: loaded", + url, + "\u2014 exports:", + Object.keys(module.exports), + "window globals:", + Object.keys(globals) + ); + onResolved(); + }).catch((e) => { + cache.set(url, { + mod: {}, + globals: {}, + error: "failed to load: " + (e instanceof Error && e.message ? e.message : String(e)) + }); + console.error( + "[dc-runtime] x-import: FAILED to load", + url, + "(" + kind + ")", + e + ); + onResolved(); + }); + pending.set(url, p); + return p; + } + function resolve2(url, name) { + const entry = cache.get(url); + if (!entry) return null; + const { mod, globals } = entry; + const C = mod && mod[name] || globals && globals[name] || typeof window !== "undefined" && window[name] || mod && mod.default; + if (typeof C === "function") return C; + const key = url + "\0" + name; + if (!reportedMissing.has(key)) { + reportedMissing.set( + key, + entry.error || 'no export named "' + name + '" (has: ' + Object.keys(mod).join(", ") + ")" + ); + console.error( + "[dc-runtime] x-import: module", + url, + "loaded but has no component named", + JSON.stringify(name), + "\u2014 available exports:", + Object.keys(mod), + "window globals:", + Object.keys(globals), + ". The module must `module.exports = {" + name + "}` or set `window." + name + "`." + ); + } + return null; + } + function waitForGlobal(name) { + if (polling.has(name)) return; + polling.add(name); + const started = Date.now(); + const isCE = isCustomElementName(name); + const tick = () => { + const found = isCE ? customElements.get(name) : isRenderableType(resolveDottedPath(window, name)); + if (found) { + polling.delete(name); + onResolved(); + return; + } + if (Date.now() - started >= GLOBAL_POLL_TIMEOUT_MS) { + console.warn( + "[dc-runtime] x-import: global", + JSON.stringify(name), + "never appeared on window after " + GLOBAL_POLL_TIMEOUT_MS + "ms" + ); + return; + } + setTimeout(tick, GLOBAL_POLL_INTERVAL_MS); + }; + setTimeout(tick, GLOBAL_POLL_INTERVAL_MS); + } + function resolveGlobal(url, name) { + const isCE = isCustomElementName(name); + if (!url) { + if (isCE) { + if (customElements.get(name)) return name; + waitForGlobal(name); + return null; + } + const g2 = resolveDottedPath(window, name); + if (isRenderableType(g2)) return g2; + waitForGlobal(name); + return null; + } + const entry = cache.get(url); + if (!entry) return null; + if (isCE && customElements.get(name)) return name; + const g = entry.globals[name] ?? resolveDottedPath(window, name); + if (isRenderableType(g)) return g; + if (name.includes(".")) return null; + const key = url + "\0global\0" + name; + if (!reportedMissing.has(key)) { + reportedMissing.set(key, null); + if (isCE && !customElements.get(name)) { + console.warn( + "[dc-runtime] x-import:", + url, + "loaded but no custom element", + JSON.stringify(name), + "is registered and window." + name + " is not a function \u2014 rendering <" + name + "> as an unknown element." + ); + } + } + return name; + } + function getError(url, name) { + const entry = cache.get(url); + if (entry?.error) return entry.error; + return reportedMissing.get(url + "\0" + name) || null; + } + return { load, resolve: resolve2, resolveGlobal, getError }; + } + function isElementClass(g) { + try { + return typeof g === "function" && typeof HTMLElement !== "undefined" && g.prototype instanceof HTMLElement; + } catch { + return false; + } + } + + // src/atomics.ts + var ATOMIC_CSS = ( + // layout + ".fx{display:flex}.col{display:flex;flex-direction:column}.grid{display:grid}.ac{align-items:center}.jc{justify-content:center}.jb{justify-content:space-between}.f1{flex:1}.noshrink{flex-shrink:0}.wrap{flex-wrap:wrap}.fw5{font-weight:500}.fw6{font-weight:600}.fw7{font-weight:700}.fw8{font-weight:800}.fs11{font-size:11px}.fs12{font-size:12px}.fs13{font-size:13px}.fs14{font-size:14px}.fs15{font-size:15px}.fs16{font-size:16px}.fs20{font-size:20px}.fs22{font-size:22px}.upper{text-transform:uppercase}.tc{text-align:center}.nowrap{white-space:nowrap}.gap8{gap:8px}.gap10{gap:10px}.gap12{gap:12px}.gap16{gap:16px}.gap24{gap:24px}.m0{margin:0}.mt8{margin-top:8px}.mt12{margin-top:12px}.mt16{margin-top:16px}.mb8{margin-bottom:8px}.mb12{margin-bottom:12px}.mb16{margin-bottom:16px}.posrel{position:relative}.posabs{position:absolute}.round{border-radius:50%}.ohide{overflow:hidden}.bbox{box-sizing:border-box}.pointer{cursor:pointer}.w100{width:100%}.b0{border:none}" + ); + + // src/helmet.ts + var DESIGN_DOC_MODE_RE = /]*\bname\s*=\s*["']design_doc_mode["'][^>]*\b(?:content|value)\s*=\s*["'](\w+)["']/i; + var CANVAS_BG = "#f0eee9"; + function createHelmetManager(doc, isStreaming) { + const mounted = /* @__PURE__ */ new Set(); + const live = /* @__PURE__ */ new Map(); + let designDocMode = null; + let canvasStyleEl = null; + function postDesignMode(mode) { + if (window.parent === window) return; + try { + window.parent.postMessage({ type: "__dc_design_mode", mode }, "*"); + } catch { + } + } + function setDesignDocMode(mode) { + if (mode === designDocMode) return; + designDocMode = mode; + postDesignMode(mode); + if (mode === "canvas") { + doc.documentElement.setAttribute("data-dc-canvas", ""); + canvasStyleEl = doc.createElement("style"); + canvasStyleEl.setAttribute("data-dc-canvas", ""); + canvasStyleEl.textContent = `html,body{background:${CANVAS_BG}}#dc-root>.sc-host{position:relative}`; + doc.head.appendChild(canvasStyleEl); + } else { + doc.documentElement.removeAttribute("data-dc-canvas"); + canvasStyleEl?.remove(); + canvasStyleEl = null; + } + } + window.addEventListener("message", (e) => { + if (!designDocMode || (e.data && e.data.type) !== "__dc_probe") return; + postDesignMode(designDocMode); + }); + function compile(node) { + const raw = [...node.children]; + const helmetClosed = node.nextSibling != null || node.parentNode?.nextSibling != null; + if (node.hasAttribute("data-dc-atomics") && !mounted.has("__dc-atomics")) { + mounted.add("__dc-atomics"); + const el = doc.createElement("style"); + el.id = "__dc-atomics"; + el.textContent = ATOMIC_CSS; + doc.head.appendChild(el); + } + return (_vals, ctx) => { + const name = ctx && ctx.__name || ""; + const streaming = !!(name && isStreaming(name)); + for (let i = 0; i < raw.length; i++) { + const child = raw[i]; + const tag = child.tagName; + const mayBePartial = streaming && !helmetClosed && i === raw.length - 1; + if (tag === "SCRIPT") { + if (mayBePartial) continue; + const key = "SCRIPT|" + (child.getAttribute("src") || child.textContent || ""); + if (mounted.has(key)) continue; + mounted.add(key); + const el = doc.createElement("script"); + for (const { name: an, value } of [...child.attributes]) + el.setAttribute(an, value); + if (child.textContent) el.textContent = child.textContent; + doc.head.appendChild(el); + } else if (tag === "LINK" || tag === "META") { + if (mayBePartial) continue; + const key = tag + "|" + (child.getAttribute("href") || child.getAttribute("src") || child.outerHTML); + if (mounted.has(key)) continue; + mounted.add(key); + doc.head.appendChild(child.cloneNode(true)); + } else { + const key = name + "|" + i; + let el = live.get(key); + if (!el || el.tagName !== tag) { + if (el) el.remove(); + el = doc.createElement(tag.toLowerCase()); + live.set(key, el); + doc.head.appendChild(el); + } + for (const { name: an, value } of [...child.attributes]) { + if (el.getAttribute(an) !== value) el.setAttribute(an, value); + } + if (el.textContent !== child.textContent) + el.textContent = child.textContent; + } + } + return null; + }; + } + return { compile, setDesignDocMode }; + } + + // src/pseudo.ts + function createPseudoSheet(doc) { + let el = null; + const cache = /* @__PURE__ */ new Map(); + let n = 0; + return (pseudo, css) => { + const k = pseudo + "|" + css; + const hit = cache.get(k); + if (hit) return hit; + if (!el) { + el = doc.createElement("style"); + doc.head.appendChild(el); + } + const cls = "scp" + (n++).toString(36); + const sel = pseudo === "before" || pseudo === "after" ? "." + cls + "::" + pseudo : "." + cls + ":" + pseudo; + el.sheet.insertRule(sel + "{" + css + "}", el.sheet.cssRules.length); + cache.set(k, cls); + return cls; + }; + } + + // src/registry.ts + function createRegistry() { + const entries = /* @__PURE__ */ Object.create(null); + function get(name) { + return entries[name] || (entries[name] = { + html: "", + tpl: null, + Logic: null, + jsStreaming: false, + htmlStreaming: false, + ver: 0, + subs: /* @__PURE__ */ new Set(), + fetched: false + }); + } + function bump(name) { + const r = get(name); + r.ver++; + for (const fn of r.subs) fn(); + } + return { + entries, + get, + bump, + bumpAll() { + for (const n in entries) bump(n); + } + }; + } + + // src/runtime.ts + var COMPONENT_DIR = "."; + function createRuntime(doc = document) { + const registry = createRegistry(); + const pseudoClass = createPseudoSheet(doc); + const helmet = createHelmetManager( + doc, + (name) => registry.get(name).htmlStreaming + ); + const external = createExternalModules(() => registry.bumpAll()); + const factory = createComponentFactory(registry, ensureFetched); + const host = { + component: (name) => factory.getDC(name), + placeholder: (props) => h(Placeholder, props), + helmet: (node) => helmet.compile(node), + loadExternal: (kind, url, after) => external.load(kind, url, after), + resolveExternal: (url, name) => external.resolve(url, name), + resolveExternalGlobal: (url, name) => external.resolveGlobal(url, name), + resolveExternalError: (url, name) => external.getError(url, name), + pseudoClass + }; + function ensureFetched(name) { + const r = registry.get(name); + if (r.fetched) return; + r.fetched = true; + const url = COMPONENT_DIR + "/" + encodeURIComponent(name) + ".dc.html"; + fetch(url).then((res) => { + if (!res.ok) { + console.error( + "[dc-runtime] sibling fetch for <" + name + "/> failed:", + url, + "returned", + res.status, + "\u2014 the reference renders as an empty placeholder." + ); + return ""; + } + return res.text(); + }).then((t) => { + if (!t) return; + const parsed = parseDcText(t); + if (!parsed) { + console.error( + "[dc-runtime] sibling fetch for <" + name + "/>:", + url, + "has no block \u2014 not a Design Component." + ); + return; + } + if (parsed.props) r.propsMeta = parsed.props; + if (parsed.preview) r.preview = parsed.preview; + if (parsed.template && !r.html) updateHtml(name, parsed.template); + if (parsed.js && !r.Logic) updateJs(name, parsed.js); + }).catch( + (e) => console.error( + "[dc-runtime] sibling fetch for <" + name + "/> threw:", + url, + e + ) + ); + } + let rootName = null; + function updateHtml(name, html) { + const r = registry.get(name); + r.html = html; + if (name === rootName) { + const mode = DESIGN_DOC_MODE_RE.exec(html)?.[1] ?? null; + if (mode || !r.htmlStreaming) helmet.setDesignDocMode(mode); + } + try { + r.tpl = compileTemplate(html, host); + } catch (e) { + console.error("[dc-runtime] template compile FAILED for", name, e); + } + registry.bump(name); + } + function updateJs(name, src) { + const r = registry.get(name); + const seq = r.jsSeq = (r.jsSeq || 0) + 1; + try { + const Cls = evalDcLogic(src); + if (r.jsSeq !== seq) return; + if (typeof Cls !== "function") { + r.logicError = name + ".dc.html: + + diff --git a/static/mobile/styles.css b/static/mobile/styles.css new file mode 100644 index 00000000..e9457dc1 --- /dev/null +++ b/static/mobile/styles.css @@ -0,0 +1,986 @@ +/* StemDeck mobile — ported from the Claude Design prototype + (design/mobile/StemDeck-Mobile.dc.html). Full-bleed for real phones: + the device frame / status bar / dynamic island from the design canvas + are dropped; the app fills the viewport and respects safe-area insets. */ + +:root { + --bg: #0b0b0d; + --bg-grad: radial-gradient(120% 70% at 50% 0%, #15131c 0%, #0a0a0c 55%, #08080a 100%); + --card: #101013; + --card-2: #121215; + --card-3: #141417; + --chip: #161619; + --line: rgba(255, 255, 255, 0.06); + --line-2: rgba(255, 255, 255, 0.05); + --txt: #f2f2f4; + --txt-2: #cfcfd4; + --muted: #85858d; + --muted-2: #65656d; + --muted-3: #7a7a82; + --accent: #f5b417; + --accent-grad: linear-gradient(160deg, #fbc94a, #f5a516); + --accent-ink: #1a1206; + --sans: "Space Grotesk", system-ui, sans-serif; + --mono: "JetBrains Mono", ui-monospace, monospace; + --safe-top: env(safe-area-inset-top, 0px); + --safe-bottom: env(safe-area-inset-bottom, 0px); + --tabbar-h: 64px; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + height: 100%; +} + +body { + background: var(--bg); + color: var(--txt); + font-family: var(--sans); + -webkit-font-smoothing: antialiased; + overscroll-behavior: none; +} + +.m-app { + position: relative; + width: 100%; + min-height: 100dvh; + background: var(--bg-grad); + display: flex; + flex-direction: column; +} + +button { + font-family: inherit; +} + +[data-fader] { + touch-action: none; +} + +.scrl::-webkit-scrollbar { + display: none; +} +.scrl { + scrollbar-width: none; +} + +/* ── screens ── */ +.screen { + flex: 1; + overflow-y: auto; + padding: calc(var(--safe-top) + 14px) 0 calc(var(--tabbar-h) + var(--safe-bottom) + 16px); +} +.pad { + padding-left: 20px; + padding-right: 20px; +} + +.eyebrow { + font-size: 11px; + font-weight: 600; + letter-spacing: 1.2px; + color: var(--muted-2); + margin: 24px 0 8px; +} +.h1 { + font-size: 27px; + font-weight: 700; + letter-spacing: -0.5px; + color: var(--txt); +} +.sub { + font-size: 14px; + color: var(--muted); + margin-top: 5px; +} + +/* ── mixer header ── */ +.mx-head { + display: flex; + align-items: center; + justify-content: space-between; + height: 40px; +} +.icon-btn { + width: 34px; + height: 34px; + border-radius: 10px; + border: none; + background: var(--chip); + color: var(--txt-2); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; +} +.now-playing { + font-size: 11px; + font-weight: 600; + letter-spacing: 1.5px; + color: var(--muted-2); +} + +/* ── cover ── */ +.cover-wrap { + display: flex; + flex-direction: column; + align-items: center; + margin-top: 14px; +} +.cover { + position: relative; + width: 172px; + height: 172px; + border-radius: 26px; + box-shadow: + 0 24px 50px -16px rgba(90, 60, 200, 0.45), + inset 0 1px 0 rgba(255, 255, 255, 0.18); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} +.cover::after { + content: ""; + position: absolute; + inset: 0; + background: radial-gradient(80% 60% at 30% 20%, rgba(255, 255, 255, 0.22), transparent 60%); +} +.cover span { + font-size: 60px; + font-weight: 700; + color: rgba(255, 255, 255, 0.92); + text-shadow: 0 2px 20px rgba(0, 0, 0, 0.3); +} +.track-title { + font-size: 21px; + font-weight: 600; + color: var(--txt); + margin-top: 18px; + text-align: center; + letter-spacing: -0.2px; +} +.track-sub { + font-size: 14px; + color: var(--muted); + margin-top: 3px; +} +.tags { + display: flex; + gap: 7px; + margin-top: 12px; +} +.tag { + font-family: var(--mono); + font-size: 10px; + font-weight: 500; + color: #9a9aa2; + background: var(--chip); + border: 1px solid var(--line); + padding: 4px 9px; + border-radius: 7px; + letter-spacing: 0.3px; +} + +/* ── waveform ── */ +.wave { + margin-top: 22px; +} +.wave-bars { + position: relative; + height: 44px; + display: flex; + align-items: center; + gap: 2px; +} +.wave-bars > i { + flex: 1; + min-width: 2px; + border-radius: 2px; + display: block; +} +.playhead { + position: absolute; + top: -3px; + bottom: -3px; + width: 2px; + background: var(--accent); + box-shadow: 0 0 10px var(--accent); + border-radius: 2px; +} +.playhead::after { + content: ""; + position: absolute; + top: -5px; + left: 50%; + transform: translateX(-50%); + width: 11px; + height: 11px; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 8px var(--accent); +} +.wave-times { + display: flex; + justify-content: space-between; + margin-top: 9px; + font-family: var(--mono); + font-size: 11px; + font-weight: 500; +} +.wave-times .cur { + color: var(--accent); +} +.wave-times .dur { + color: var(--muted-2); +} + +/* ── transport ── */ +.transport { + display: flex; + align-items: center; + justify-content: center; + gap: 22px; + margin-top: 8px; +} +.t-ghost { + background: none; + border: none; + color: var(--muted); + cursor: pointer; + display: flex; + align-items: center; + gap: 4px; + font-family: var(--mono); + font-size: 12px; + font-weight: 600; +} +.t-step { + background: none; + border: none; + color: #d8d8dc; + cursor: pointer; + display: flex; +} +.t-play { + width: 66px; + height: 66px; + border-radius: 50%; + border: none; + background: var(--accent-grad); + box-shadow: 0 12px 30px -6px rgba(245, 165, 22, 0.6); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.mx-prep { + text-align: center; + margin-top: 10px; + font-size: 12px; + font-weight: 500; + color: var(--accent); + animation: prep-pulse 1.2s ease-in-out infinite; +} +@keyframes prep-pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.4; + } +} + +/* ── segmented ── */ +.segmented { + display: flex; + gap: 4px; + background: var(--card-3); + border: 1px solid var(--line-2); + border-radius: 12px; + padding: 4px; + margin-top: 24px; +} +.segmented button { + flex: 1; + padding: 9px 0; + border-radius: 9px; + font-size: 13px; + font-weight: 600; + border: none; + cursor: pointer; + background: transparent; + color: var(--muted-3); +} +.segmented button.on { + background: #26262c; + color: #fff; +} + +/* ── stems grid ── */ +.stems-grid { + padding: 14px 16px 0; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} +.stem { + background: var(--card); + border: 1px solid var(--line-2); + border-radius: 13px; + padding: 10px 11px; +} +.stem-top { + display: flex; + align-items: center; + gap: 7px; +} +.stem-dot { + width: 9px; + height: 9px; + border-radius: 50%; + flex-shrink: 0; +} +.stem-name { + flex: 1; + min-width: 0; + font-size: 12.5px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.ms-btn { + width: 26px; + height: 24px; + border-radius: 7px; + font-size: 11px; + font-weight: 600; + border: 1px solid rgba(255, 255, 255, 0.12); + background: transparent; + color: var(--muted-3); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + flex-shrink: 0; +} +.stem-wave { + display: flex; + align-items: center; + gap: 1.5px; + height: 22px; + margin-top: 10px; +} +.stem-wave > i { + flex: 1; + min-width: 1.5px; + border-radius: 1.5px; + display: block; +} +.fader { + position: relative; + height: 18px; + display: flex; + align-items: center; + cursor: pointer; + margin-top: 8px; +} +.fader-track { + position: absolute; + left: 0; + right: 0; + height: 4px; + border-radius: 3px; + background: #202025; +} +.fader-fill { + position: absolute; + left: 0; + top: 50%; + transform: translateY(-50%); + height: 4px; + border-radius: 3px; +} +.fader-knob { + position: absolute; + top: 50%; + transform: translate(-50%, -50%); + width: 15px; + height: 15px; + border-radius: 50%; + background: #fff; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.55); +} + +/* ── analysis ── */ +.stats { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} +.stat { + background: var(--card-2); + border: 1px solid var(--line-2); + border-radius: 14px; + padding: 13px 14px; +} +.stat-k { + font-size: 9.5px; + font-weight: 600; + letter-spacing: 1.2px; + color: var(--muted-2); +} +.stat-v { + font-family: var(--mono); + font-size: 20px; + font-weight: 600; + color: #eaeaec; + margin-top: 5px; +} +.stat-s { + font-size: 11px; + color: var(--muted-3); + margin-top: 2px; +} +.presence-row { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 0; +} +.presence-name { + width: 46px; + font-size: 12.5px; + font-weight: 600; + color: var(--txt-2); + flex-shrink: 0; +} +.presence-bar { + flex: 1; + height: 7px; + border-radius: 4px; + background: #1c1c21; + overflow: hidden; +} +.presence-bar > i { + display: block; + height: 100%; + border-radius: 4px; +} +.presence-val { + width: 38px; + text-align: right; + font-family: var(--mono); + font-size: 12px; + font-weight: 500; + color: #9a9aa2; + flex-shrink: 0; +} + +/* ── primary action button ── */ +.cta { + width: 100%; + height: 54px; + border-radius: 15px; + border: none; + background: var(--accent-grad); + color: var(--accent-ink); + font-size: 16px; + font-weight: 700; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 9px; + box-shadow: 0 12px 30px -8px rgba(245, 165, 22, 0.55); +} +.cta.sm { + height: 52px; + font-size: 15px; + border-radius: 14px; + margin-top: 18px; +} + +/* ── library ── */ +.lib-head { + display: flex; + align-items: center; + justify-content: space-between; +} +.avatar { + width: 38px; + height: 38px; + border-radius: 50%; + background: linear-gradient(150deg, #f5a516, #d2541f); + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + font-weight: 700; + color: var(--accent-ink); +} +.search { + display: flex; + align-items: center; + gap: 10px; + background: var(--card-3); + border: 1px solid var(--line); + border-radius: 13px; + padding: 12px 14px; + margin-top: 16px; + color: var(--muted-2); + font-size: 14px; +} +.filters { + display: flex; + gap: 8px; + margin-top: 16px; + overflow-x: auto; +} +.filter { + flex-shrink: 0; + font-size: 12.5px; + font-weight: 500; + color: #a8a8b0; + background: var(--chip); + border: 1px solid var(--line); + padding: 7px 14px; + border-radius: 20px; +} +.filter.on { + font-weight: 600; + color: var(--accent-ink); + background: var(--accent); + border-color: transparent; +} +.track-wrap { + position: relative; + overflow: hidden; +} +.track-delete { + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 84px; + border: none; + background: #e0344e; + color: #fff; + font-size: 13px; + font-weight: 600; + cursor: pointer; +} +.track { + position: relative; + z-index: 1; + display: flex; + align-items: center; + gap: 13px; + padding: 9px 0; + background: var(--bg); + cursor: pointer; + transition: transform 0.22s cubic-bezier(0.22, 1, 0.36, 1); + will-change: transform; + touch-action: pan-y; +} +.track-wrap.swiped .track { + transform: translateX(-84px); +} +.track-art { + width: 50px; + height: 50px; + border-radius: 13px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + font-weight: 700; + color: rgba(255, 255, 255, 0.9); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15); +} +.track-info { + flex: 1; + min-width: 0; +} +.track-info .t { + font-size: 14.5px; + font-weight: 600; + color: #eaeaec; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.track-info .s { + font-size: 12.5px; + color: var(--muted-3); + margin-top: 1px; +} +.track-info .m { + font-family: var(--mono); + font-size: 10.5px; + color: #5c5c64; + margin-top: 3px; +} +.track-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: #3fcf6e; + box-shadow: 0 0 8px #3fcf6e88; + flex-shrink: 0; +} +.track-dot.processing { + background: #f5b417; + box-shadow: 0 0 8px #f5b41788; + animation: dotpulse 1.2s ease-in-out infinite; +} +.track-dot.unavailable { + background: #5c5c64; + box-shadow: none; +} +@keyframes dotpulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.35; + } +} + +.lib-note { + margin-top: 40px; + text-align: center; + font-size: 14px; + color: var(--muted); + line-height: 1.6; +} +.lib-retry { + margin-top: 12px; + display: inline-block; + background: var(--chip); + border: 1px solid var(--line); + color: var(--accent); + font-size: 13px; + font-weight: 600; + padding: 8px 18px; + border-radius: 10px; + cursor: pointer; +} + +.m-toast { + position: fixed; + left: 50%; + bottom: calc(var(--tabbar-h) + var(--safe-bottom) + 20px); + transform: translate(-50%, 12px); + max-width: 80vw; + background: rgba(28, 28, 33, 0.96); + color: var(--txt); + border: 1px solid var(--line); + border-radius: 12px; + padding: 11px 16px; + font-size: 13px; + font-weight: 500; + text-align: center; + z-index: 80; + opacity: 0; + pointer-events: none; + transition: + opacity 0.2s ease, + transform 0.2s ease; + box-shadow: 0 12px 30px -10px rgba(0, 0, 0, 0.7); +} +.m-toast.show { + opacity: 1; + transform: translate(-50%, 0); +} +.track-load { + flex-shrink: 0; + border: 1px solid rgba(255, 255, 255, 0.12); + background: var(--chip); + color: #e8e8ea; + font-size: 12.5px; + font-weight: 600; + padding: 8px 16px; + border-radius: 10px; + cursor: pointer; +} +.track-load:active { + background: rgba(245, 180, 23, 0.16); + border-color: rgba(245, 180, 23, 0.4); + color: var(--accent); +} +.collections { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} +.collection { + background: var(--card-2); + border: 1px solid var(--line-2); + border-radius: 16px; + padding: 14px; + cursor: pointer; +} +.collection-art { + position: relative; + height: 42px; + width: 54px; +} +.collection-art > .b { + position: absolute; + left: 10px; + top: 2px; + width: 38px; + height: 38px; + border-radius: 10px; + opacity: 0.55; +} +.collection-art > .f { + position: absolute; + left: 0; + top: 5px; + width: 38px; + height: 38px; + border-radius: 10px; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.4); +} +.collection .name { + font-size: 14px; + font-weight: 600; + color: #eaeaec; + margin-top: 12px; +} +.collection .count { + font-family: var(--mono); + font-size: 11px; + color: var(--muted-2); + margin-top: 2px; +} + +/* ── extract ── */ +.paste { + display: flex; + align-items: center; + gap: 10px; + background: var(--card-3); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 14px; + padding: 14px; + margin-top: 22px; + color: var(--muted-2); + font-size: 13.5px; +} +.paste .label { + flex: 1; +} +.paste .act { + font-size: 12px; + font-weight: 600; + color: var(--accent); +} +.ext-input { + flex: 1; + min-width: 0; + background: transparent; + border: none; + outline: none; + color: var(--txt); + font-family: inherit; + font-size: 13.5px; +} +.ext-input::placeholder { + color: var(--muted-2); +} +.upload { + width: 100%; + margin-top: 12px; + display: flex; + align-items: center; + justify-content: center; + gap: 9px; + background: transparent; + border: 1.5px dashed rgba(255, 255, 255, 0.13); + border-radius: 14px; + padding: 15px; + color: #a8a8b0; + font-size: 13.5px; + font-weight: 500; + cursor: pointer; +} +.chips { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} +.chip-btn { + display: flex; + align-items: center; + gap: 9px; + padding: 13px; + border-radius: 13px; + border: 1.5px solid rgba(255, 255, 255, 0.09); + background: transparent; + color: var(--muted); + font-size: 13.5px; + font-weight: 600; + cursor: pointer; +} +.chip-btn .dot { + width: 11px; + height: 11px; + border-radius: 50%; + flex-shrink: 0; + opacity: 0.4; +} +.chip-btn .nm { + flex: 1; + text-align: left; +} +.chip-btn.on { + color: var(--txt); +} +.chip-btn.on .dot { + opacity: 1; +} +.progress-card { + background: var(--card-2); + border: 1px solid var(--line-2); + border-radius: 16px; + padding: 14px; +} +.progress-top { + display: flex; + align-items: center; + gap: 12px; +} +.progress-art { + width: 44px; + height: 44px; + border-radius: 12px; + background: linear-gradient(140deg, #2bd4c4, #1a6d9e); + flex-shrink: 0; +} +.progress-pct { + font-family: var(--mono); + font-size: 13px; + font-weight: 600; + color: var(--accent); +} +.progress-bar { + height: 6px; + border-radius: 4px; + background: #1c1c21; + margin-top: 12px; + overflow: hidden; +} +.progress-bar > i { + display: block; + height: 100%; + border-radius: 4px; + background: linear-gradient(90deg, #f5a516, #fbc94a); +} + +/* ── mini player ── */ +.mini { + position: fixed; + left: 10px; + right: 10px; + bottom: calc(var(--tabbar-h) + var(--safe-bottom) + 8px); + height: 60px; + background: rgba(22, 22, 26, 0.82); + backdrop-filter: blur(20px); + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 18px; + display: flex; + align-items: center; + gap: 12px; + padding: 0 12px; + z-index: 45; + cursor: pointer; + box-shadow: 0 12px 30px -10px rgba(0, 0, 0, 0.6); +} +.mini-art { + width: 42px; + height: 42px; + border-radius: 11px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + font-weight: 700; + color: rgba(255, 255, 255, 0.9); +} +.mini-info { + flex: 1; + min-width: 0; +} +.mini-info .t { + font-size: 13.5px; + font-weight: 600; + color: #eaeaec; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.mini-info .s { + font-size: 12px; + color: var(--muted-3); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.mini-play { + width: 40px; + height: 40px; + border-radius: 50%; + border: none; + background: var(--accent-grad); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + flex-shrink: 0; +} + +/* ── tab bar ── */ +.tabbar { + position: fixed; + left: 0; + right: 0; + bottom: 0; + height: calc(var(--tabbar-h) + var(--safe-bottom)); + padding: 10px 30px var(--safe-bottom); + background: rgba(11, 11, 13, 0.88); + backdrop-filter: blur(24px); + border-top: 1px solid var(--line); + display: flex; + justify-content: space-around; + z-index: 50; +} +.tab { + display: flex; + flex-direction: column; + align-items: center; + gap: 5px; + background: none; + border: none; + cursor: pointer; + font-size: 10.5px; + font-weight: 600; + padding: 0; + color: #62626a; +} +.tab.on { + color: var(--accent); +} diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..9841e4e3 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import pytest + +from app.core import settings as _settings + + +@pytest.fixture(autouse=True) +def _isolate_network_settings(tmp_path, monkeypatch): + """Isolate the runtime network gate for every test. Without this, a stray + settings.json in the repo (written by a local dev server) could flip the + gate off and 403 the whole suite, since TestClient's client host is not + loopback. Each test starts from the env default (on, outside desktop mode).""" + monkeypatch.setattr(_settings, "_SETTINGS_PATH", tmp_path / "settings.json") + # Network access defaults OFF in production, which would 403 TestClient + # (whose client host isn't loopback). Default the suite ON; gate tests set + # it explicitly. Tests checking the real default clear this env var. + monkeypatch.setenv("STEMDECK_ALLOW_NETWORK", "1") + _settings._state = None # force a fresh load from the isolated path + yield + _settings._state = None diff --git a/tests/test_mobile_routing.py b/tests/test_mobile_routing.py new file mode 100644 index 00000000..1ba5e277 --- /dev/null +++ b/tests/test_mobile_routing.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +from app.main import _is_mobile_ua, app + +IPHONE_UA = ( + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) " + "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1" +) +ANDROID_UA = ( + "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36" +) +DESKTOP_UA = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" +) +# Modern iPadOS Safari reports a Mac desktop UA — tablets fall through to the DAW. +IPAD_UA = ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 " + "(KHTML, like Gecko) Version/17.0 Safari/605.1.15" +) + + +@pytest.mark.parametrize("ua", [IPHONE_UA, ANDROID_UA]) +def test_is_mobile_ua_true_for_phones(ua: str): + assert _is_mobile_ua(ua) is True + + +@pytest.mark.parametrize("ua", [DESKTOP_UA, IPAD_UA, "", "curl/8.0"]) +def test_is_mobile_ua_false_for_non_phones(ua: str): + assert _is_mobile_ua(ua) is False + + +def test_root_serves_mobile_shell_to_phones(): + with TestClient(app) as c: + resp = c.get("/", headers={"user-agent": IPHONE_UA}) + assert resp.status_code == 200 + assert "/mobile/app.js" in resp.text + + +def test_root_serves_daw_to_desktop(): + with TestClient(app) as c: + resp = c.get("/", headers={"user-agent": DESKTOP_UA}) + assert resp.status_code == 200 + assert "/css/daw.css" in resp.text + + +def test_ui_query_override_forces_mobile_on_desktop(): + with TestClient(app) as c: + resp = c.get("/", params={"ui": "mobile"}, headers={"user-agent": DESKTOP_UA}) + assert "/mobile/app.js" in resp.text + + +def test_ui_query_override_forces_desktop_on_phone(): + with TestClient(app) as c: + resp = c.get("/", params={"ui": "desktop"}, headers={"user-agent": IPHONE_UA}) + assert "/css/daw.css" in resp.text + + +def test_mobile_assets_are_served(): + with TestClient(app) as c: + assert c.get("/mobile/app.js").status_code == 200 + assert c.get("/mobile/styles.css").status_code == 200 diff --git a/tests/test_network_gate.py b/tests/test_network_gate.py new file mode 100644 index 00000000..48727217 --- /dev/null +++ b/tests/test_network_gate.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +from app.core import settings as settings_mod +from app.main import _is_host_request, _is_loopback, app + +MOBILE_UA = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Mobile/15E148" + + +@pytest.mark.parametrize( + "host,expected", + [ + ("127.0.0.1", True), + ("::1", True), + ("localhost", True), + ("::ffff:127.0.0.1", True), + ("127.0.1.1", True), + ("192.168.1.14", False), + ("10.0.0.5", False), + ("", False), + (None, False), + ], +) +def test_is_loopback(host, expected): + assert _is_loopback(host) is expected + + +def test_host_request_recognizes_own_lan_ip(monkeypatch): + # The host reaching itself via its LAN address must count as local, so + # turning network access off never cuts the host off from its own server. + monkeypatch.setattr("app.main._local_ips", lambda: frozenset({"192.168.1.14"})) + assert _is_host_request("192.168.1.14") is True # the host's own IP + assert _is_host_request("127.0.0.1") is True # loopback + assert _is_host_request("192.168.1.99") is False # a different device + + +def test_default_is_off(monkeypatch): + # Off by default everywhere — the user must opt in. + monkeypatch.delenv("STEMDECK_ALLOW_NETWORK", raising=False) + assert settings_mod._default_allow_network() is False + + +def test_env_var_pre_enables(monkeypatch): + monkeypatch.setenv("STEMDECK_ALLOW_NETWORK", "1") + assert settings_mod._default_allow_network() is True + + +def test_runtime_settings_round_trip_and_clamp(): + with TestClient(app) as c: + r = c.post("/api/settings", json={"max_duration_sec": 600, "video_max_height": 1080}) + assert r.status_code == 200 + body = r.json() + assert body["max_duration_sec"] == 600 + assert body["video_max_height"] == 1080 + # GET reflects the new values. + assert c.get("/api/settings").json()["max_duration_sec"] == 600 + + # Out-of-range values are clamped, not rejected. + assert settings_mod.set_max_duration_sec(5) == 60 # floor + assert settings_mod.set_max_duration_sec(99999) == 1200 # ceiling = 20 min + assert settings_mod.set_video_max_height(99999) == 2160 # ceil + + +def test_settings_reject_non_integer(): + with TestClient(app) as c: + assert c.post("/api/settings", json={"max_duration_sec": "abc"}).status_code == 422 + + +def test_gate_blocks_non_loopback_when_off(): + settings_mod.set_allow_network(False) + # TestClient's client host ("testclient") is treated as non-loopback. + with TestClient(app) as c: + r = c.get("/", headers={"user-agent": MOBILE_UA}) + assert r.status_code == 403 + + +def test_gate_allows_everyone_when_on(): + settings_mod.set_allow_network(True) + with TestClient(app) as c: + assert c.get("/api/health").status_code == 200 + + +def test_loopback_always_allowed_even_when_off(monkeypatch): + settings_mod.set_allow_network(False) + monkeypatch.setattr("app.main._is_loopback", lambda _host: True) + with TestClient(app) as c: + assert c.get("/api/health").status_code == 200 + + +def test_post_toggles_off_then_blocks(): + settings_mod.set_allow_network(True) # so the non-loopback client can reach POST + with TestClient(app) as c: + r = c.post("/api/settings", json={"allow_network": False}) + assert r.status_code == 200 + assert r.json()["allow_network"] is False + # Now off → a non-loopback client is blocked from everything. + with TestClient(app) as c: + assert c.get("/api/settings").status_code == 403 From d25741bc14f322f26f268b6db5e6426185527c59 Mon Sep 17 00:00:00 2001 From: Thales <> Date: Sat, 27 Jun 2026 18:58:48 +0100 Subject: [PATCH 2/2] =?UTF-8?q?chore:=20address=20code-quality=20bot=20?= =?UTF-8?q?=E2=80=94=20document=20suppressed=20excepts;=20untrack=20design?= =?UTF-8?q?=20refs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _local_ips() and settings _load()/_save(): replace bare `except: pass` with an explanatory comment + logging.debug/warning(exc_info=True); behavior unchanged (still best-effort). - _load(): handle the no-file case explicitly (FileNotFoundError) vs. logging genuinely corrupt files. - Untrack design/ (the imported Claude Design prototype) and gitignore it — it's a local spec reference, not shipped code, and the static analyzer's "no-effect expression" flags on its template bindings were false positives. --- .gitignore | 3 + app/core/settings.py | 12 +- app/main.py | 7 +- design/mobile/StemDeck-Mobile.dc.html | 457 ----- design/mobile/support.js | 1595 ----------------- .../mobile/uploads/pasted-1782491178712-0.png | Bin 196608 -> 0 bytes 6 files changed, 18 insertions(+), 2056 deletions(-) delete mode 100644 design/mobile/StemDeck-Mobile.dc.html delete mode 100644 design/mobile/support.js delete mode 100644 design/mobile/uploads/pasted-1782491178712-0.png diff --git a/.gitignore b/.gitignore index e933e589..d86469d3 100644 --- a/.gitignore +++ b/.gitignore @@ -78,3 +78,6 @@ Thumbs.db # Tool versions .python-version + +# Imported design references (kept local, not shipped) +design/ diff --git a/app/core/settings.py b/app/core/settings.py index e8b3a62d..68e7fae9 100644 --- a/app/core/settings.py +++ b/app/core/settings.py @@ -14,11 +14,14 @@ from __future__ import annotations import json +import logging import os import threading from app.core.config import DATA_DIR, MAX_DURATION_SEC, VIDEO_MAX_HEIGHT +_log = logging.getLogger("stemdeck.settings") + _SETTINGS_PATH = DATA_DIR / "settings.json" _LOCK = threading.RLock() _state: dict | None = None # whole settings dict, loaded lazily @@ -42,8 +45,11 @@ def _load() -> dict: data = json.loads(_SETTINGS_PATH.read_text(encoding="utf-8")) if isinstance(data, dict): return data + except FileNotFoundError: + pass # no settings file yet — first run; use defaults except Exception: - pass + # Corrupt/unreadable file: fall back to defaults rather than crash. + _log.warning("could not read settings from %s", _SETTINGS_PATH, exc_info=True) return {} @@ -59,7 +65,9 @@ def _save() -> None: _SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True) _SETTINGS_PATH.write_text(json.dumps(_ensure()), encoding="utf-8") except Exception: - pass + # Persistence is best-effort (read-only FS, permissions): the in-memory + # value still applies for this session, so don't fail the request. + _log.warning("could not persist settings to %s", _SETTINGS_PATH, exc_info=True) def _num(v: object) -> int | None: diff --git a/app/main.py b/app/main.py index 452e5b4b..4558f918 100644 --- a/app/main.py +++ b/app/main.py @@ -315,14 +315,17 @@ def _local_ips() -> frozenset[str]: for info in socket.getaddrinfo(hostname, None): ips.add(info[4][0]) except Exception: - pass + # Best-effort: name resolution can fail on odd hostnames/configs; we + # still try the outbound-socket probe below and fall back to loopback. + _log.debug("hostname IP enumeration failed", exc_info=True) try: # primary outbound IP, robust when the hostname doesn't resolve them all s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.connect(("8.8.8.8", 80)) ips.add(s.getsockname()[0]) s.close() except Exception: - pass + # Best-effort: no default route / offline — just return what we have. + _log.debug("outbound IP probe failed", exc_info=True) return frozenset(ips) diff --git a/design/mobile/StemDeck-Mobile.dc.html b/design/mobile/StemDeck-Mobile.dc.html deleted file mode 100644 index f40db764..00000000 --- a/design/mobile/StemDeck-Mobile.dc.html +++ /dev/null @@ -1,457 +0,0 @@ - - - - - - - - - - - - - - - - -
- -
-
- - -
- - -
- 9:41 -
- - 5G -
-
-
- - - -
-
- -
- - NOW PLAYING - -
- - -
-
-
- {{ coverInitial }} -
-
{{ trackTitle }}
-
{{ trackSub }}
-
- YouTube - 6 stems - High -
-
- - -
-
- -
-
-
-
-
- {{ curTime }} - {{ durTime }} -
-
- - -
- - - - - -
- - -
- - -
-
- - - -
- -
-
-
-
{{ st.name }}
- - -
-
- -
-
-
-
-
-
-
-
-
-
-
-
- - - -
-
- -
-
{{ x.k }}
-
{{ x.v }}
-
{{ x.s }}
-
-
-
-
STEM PRESENCE
- -
-
{{ p.name }}
-
- {{ p.val }} -
-
- -
-
- -
-
-
- - - -
-
-
- Library -
JS
-
-
- - Search your library -
-
- All - Favorites - Synthwave - Indie Rock - Lo-Fi -
- -
RECENT
- -
-
{{ t.initial }}
-
-
{{ t.title }}
-
{{ t.sub }}
-
{{ t.meta }}
-
-
- -
-
- -
COLLECTIONS
-
- -
-
-
-
-
-
{{ c.name }}
-
{{ c.count }}
-
-
-
-
-
-
-
- - - -
-
-
Extract stems
-
Paste a link or upload audio to split into stems.
- -
- - Paste YouTube or audio URL - Paste -
- - -
STEMS TO EXTRACT
-
- - - -
- -
QUALITY
-
- - -
- - - -
IN PROGRESS
-
-
-
-
-
Lunar Tides
-
Separating 6 stems…
-
- 64% -
-
-
-
-
-
-
- - - -
-
{{ coverInitial }}
-
-
{{ trackTitle }}
-
{{ trackSub }}
-
- -
-
- - -
- - - -
- - -
- -
-
- -
StemDeck — mobile concept · tap tabs, drag faders, M/S, play
-
-
- - - diff --git a/design/mobile/support.js b/design/mobile/support.js deleted file mode 100644 index 304d1fca..00000000 --- a/design/mobile/support.js +++ /dev/null @@ -1,1595 +0,0 @@ -// GENERATED from dc-runtime/src/*.ts — do not edit. Rebuild with `cd dc-runtime && bun run build`. -"use strict"; -(() => { - var __defProp = Object.defineProperty; - var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; - var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); - - // src/react.ts - function getReact() { - const R = window.React; - if (!R) throw new Error("dc-runtime: window.React is not available yet"); - return R; - } - function getReactDOM() { - const RD = window.ReactDOM; - if (!RD) throw new Error("dc-runtime: window.ReactDOM is not available yet"); - return RD; - } - var h = ((...args) => getReact().createElement( - ...args - )); - - // src/parse.ts - function parseDcDocument(doc) { - const dc = doc.querySelector("x-dc"); - if (!dc) return null; - const scriptEl = doc.querySelector("script[data-dc-script]"); - const { props, preview } = parseDataProps( - scriptEl?.getAttribute("data-props") ?? null - ); - return { - template: dc.innerHTML, - js: scriptEl ? scriptEl.textContent || "" : "", - props, - preview - }; - } - function parseDcText(src) { - const openMatch = /]*)?>/.exec(src); - if (!openMatch) return null; - const close = src.lastIndexOf("
"); - if (close === -1 || close < openMatch.index) return null; - const template = src.slice(openMatch.index + openMatch[0].length, close); - const doc = new DOMParser().parseFromString(src, "text/html"); - const scriptEl = doc.querySelector("script[data-dc-script]"); - const { props, preview } = parseDataProps( - scriptEl?.getAttribute("data-props") ?? null - ); - return { - template, - js: scriptEl ? scriptEl.textContent || "" : "", - props, - preview - }; - } - function parseDataProps(raw) { - if (!raw) return { props: null, preview: null }; - let parsed; - try { - parsed = JSON.parse(raw); - } catch { - return { props: null, preview: null }; - } - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return { props: null, preview: null }; - } - const obj = parsed; - const preview = obj.$preview && typeof obj.$preview === "object" ? obj.$preview : null; - const rest = {}; - for (const k of Object.keys(obj)) { - if (k[0] !== "$") rest[k] = obj[k]; - } - return { props: Object.keys(rest).length ? rest : null, preview }; - } - function dcNameFromPath(pathname) { - let p = pathname || ""; - try { - p = decodeURIComponent(p); - } catch { - } - const base = p.split("/").pop() || "Root"; - return base.replace(/\.dc\.html$/, "").replace(/\.html?$/, "") || "Root"; - } - - // src/boot.ts - var BASE_CSS = ` - .sc-placeholder{background:rgba(255,255,255,.3);border:1px solid rgba(0,0,0,.5); - border-radius:2px;box-sizing:border-box;overflow:hidden} - @keyframes sc-shine{0%{background-position:100% 50%}100%{background-position:0% 50%}} - html.sc-dc-streaming .sc-placeholder, - html.sc-dc-streaming .sc-interp.sc-missing{position:relative; - background:color-mix(in srgb,currentColor 5%,transparent); - border-color:transparent} - html.sc-dc-streaming .sc-placeholder::before, - html.sc-dc-streaming .sc-interp.sc-missing::before{content:''; - position:absolute;inset:0;pointer-events:none; - background:linear-gradient(90deg,rgba(217,119,87,0) 25%,rgba(247,225,211,.95) 37%,rgba(217,119,87,0) 63%); - background-size:400% 100%;animation:sc-shine 1.4s ease infinite} - html.sc-dc-streaming .sc-placeholder:nth-child(n+9 of .sc-placeholder)::before, - html.sc-dc-streaming .sc-interp.sc-missing:nth-child(n+9 of .sc-interp.sc-missing)::before{animation:none; - background:color-mix(in srgb,currentColor 8%,transparent)} - .sc-placeholder-error{padding:4px 8px;font:11px/1.4 ui-monospace,monospace; - color:rgba(0,0,0,.7);word-break:break-word} - .sc-interp.sc-missing{display:inline-block;width:2em;height:1em;overflow:hidden; - vertical-align:text-bottom;background:rgba(255,255,255,.3);border:1px solid rgba(0,0,0,.5); - border-radius:2px;box-sizing:border-box;color:transparent; - user-select:none} - .sc-interp.sc-unresolved{font-family:ui-monospace,monospace;font-size:.85em; - color:rgba(0,0,0,.5);background:rgba(0,0,0,.05);border-radius:3px; - padding:0 3px} - .sc-host.sc-has-error{position:relative} - .sc-logic-error{position:absolute;top:8px;left:8px;z-index:2147483647;max-width:60ch; - padding:6px 10px;background:#b00020;color:#fff;font:12px/1.4 ui-monospace,monospace; - border-radius:4px;white-space:pre-wrap;pointer-events:none} - /* Mirrors PRINT_BASELINE_CSS in apps/web deck-stage-export.ts \u2014 keep both - in sync until dc-runtime regains a build step. */ - @media print { - @page { margin: 0.5cm; } - figure, table { break-inside: avoid; } - #dc-root, #dc-root > .sc-host { height: auto; } - *, *::before, *::after { - print-color-adjust: exact; -webkit-print-color-adjust: exact; - backdrop-filter: none !important; -webkit-backdrop-filter: none !important; - animation-delay: -99s !important; animation-duration: .001s !important; - animation-iteration-count: 1 !important; animation-fill-mode: both !important; - animation-play-state: running !important; transition-duration: 0s !important; - } - } - `; - var FULL_PAGE_CSS = "html,body{height:100%;margin:0}#dc-root,#dc-root>.sc-host{height:100%}"; - function rootNameForDocument(doc, loc) { - let bootPath = loc.pathname || ""; - if (!/\.dc\.html?$/i.test(safeDecode(bootPath))) { - try { - bootPath = new URL(doc.baseURI || "/").pathname; - } catch { - } - } - return dcNameFromPath(bootPath); - } - function safeDecode(s) { - try { - return decodeURIComponent(s); - } catch { - return s; - } - } - function boot(runtime, doc = document) { - const parsed = parseDcDocument(doc); - if (!parsed) return null; - const React = getReact(); - const rootName = rootNameForDocument(doc, location); - runtime.markFetched(rootName); - runtime.setRootName(rootName); - runtime.adoptParsed(rootName, parsed); - fetch(location.href).then((res) => res.ok ? res.text() : "").then((t) => { - const raw = t ? parseDcText(t) : null; - if (raw?.template) runtime.updateHtml(rootName, raw.template); - }).catch(() => { - }); - const dc = doc.querySelector("x-dc"); - const hostEl = doc.createElement("div"); - hostEl.id = "dc-root"; - dc.replaceWith(hostEl); - if (!parsed.preview) { - const s = doc.createElement("style"); - s.textContent = FULL_PAGE_CSS; - doc.head.appendChild(s); - } - const Root = runtime.getDC(rootName); - const entry = runtime.registry.get(rootName); - function StandaloneRoot() { - const [, setTick] = React.useState(0); - React.useEffect(() => { - const sub = () => setTick((n) => n + 1); - entry.subs.add(sub); - return () => { - entry.subs.delete(sub); - }; - }, []); - return h(Root, entry.propOverrides || null); - } - const ReactDOM = getReactDOM(); - if (ReactDOM.createRoot) - ReactDOM.createRoot(hostEl).render(h(StandaloneRoot)); - else ReactDOM.render(h(StandaloneRoot), hostEl); - return rootName; - } - - // src/expr.ts - var IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*/; - var NUMBER_RE = /^-?\d+(\.\d+)?$/; - function resolve(vals, src) { - const expr = String(src).trim(); - if (!expr) return void 0; - if (expr[0] === "(" && expr[expr.length - 1] === ")" && parensWrapWhole(expr)) { - return resolve(vals, expr.slice(1, -1)); - } - const eq = findTopLevelEquality(expr); - if (eq) { - const lv = resolve(vals, expr.slice(0, eq.index)); - const rv = resolve(vals, expr.slice(eq.index + eq.op.length)); - switch (eq.op) { - case "===": - return lv === rv; - case "!==": - return lv !== rv; - case "==": - return lv == rv; - default: - return lv != rv; - } - } - if (expr[0] === "!") return !resolve(vals, expr.slice(1)); - if (expr === "true") return true; - if (expr === "false") return false; - if (expr === "null") return null; - if (expr === "undefined") return void 0; - if (NUMBER_RE.test(expr)) return Number(expr); - if (expr.length >= 2 && (expr[0] === '"' || expr[0] === "'") && expr[expr.length - 1] === expr[0]) { - return expr.slice(1, -1); - } - return resolvePath(vals, expr); - } - function parensWrapWhole(expr) { - let depth = 0; - for (let i = 0; i < expr.length - 1; i++) { - if (expr[i] === "(") depth++; - else if (expr[i] === ")") { - depth--; - if (depth === 0) return false; - } - } - return true; - } - function findTopLevelEquality(expr) { - let depth = 0; - for (let i = 0; i < expr.length; i++) { - const c = expr[i]; - if (c === "[" || c === "(") depth++; - else if (c === "]" || c === ")") depth--; - else if (depth === 0 && (c === "=" || c === "!") && expr[i + 1] === "=") { - if (i > 0 && (expr[i - 1] === "=" || expr[i - 1] === "!")) continue; - if (!expr.slice(0, i).trim()) continue; - const op = expr[i + 2] === "=" ? c + "==" : c + "="; - return { index: i, op }; - } - } - return null; - } - function resolvePath(vals, expr) { - const head = expr.match(IDENT_RE); - if (!head) return void 0; - let cur = vals == null ? void 0 : vals[head[0]]; - let i = head[0].length; - while (i < expr.length) { - if (expr[i] === ".") { - const m = expr.slice(i + 1).match(IDENT_RE) || expr.slice(i + 1).match(/^\d+/); - if (!m) return void 0; - cur = cur == null ? void 0 : cur[m[0]]; - i += 1 + m[0].length; - } else if (expr[i] === "[") { - let depth = 1; - let j = i + 1; - while (j < expr.length && depth > 0) { - if (expr[j] === "[") depth++; - else if (expr[j] === "]") { - depth--; - if (depth === 0) break; - } - j++; - } - if (depth !== 0) return void 0; - const key = resolve(vals, expr.slice(i + 1, j)); - cur = cur == null ? void 0 : cur[key]; - i = j + 1; - } else { - return void 0; - } - } - return cur; - } - - // src/encode.ts - var CAMEL_ATTR = "sc-camel-"; - var RAW_WRAP = { - select: "sc-raw-select", - table: "sc-raw-table", - tbody: "sc-raw-tbody", - thead: "sc-raw-thead", - tfoot: "sc-raw-tfoot", - tr: "sc-raw-tr", - td: "sc-raw-td", - th: "sc-raw-th", - caption: "sc-raw-caption" - }; - var RAW_UNWRAP = Object.fromEntries( - Object.entries(RAW_WRAP).map(([k, v]) => [v, k]) - ); - var EVENT_MAP = { - onclick: "onClick", - onchange: "onChange", - oninput: "onInput", - onsubmit: "onSubmit", - onkeydown: "onKeyDown", - onkeyup: "onKeyUp", - onkeypress: "onKeyPress", - onmousedown: "onMouseDown", - onmouseup: "onMouseUp", - onmouseenter: "onMouseEnter", - onmouseleave: "onMouseLeave", - onfocus: "onFocus", - onblur: "onBlur", - ondoubleclick: "onDoubleClick", - oncontextmenu: "onContextMenu" - }; - var ATTRS = `(?:[^>"']|"[^"]*"|'[^']*')*`; - var IMPORT_SELF_CLOSE_RE = new RegExp( - "<(x-import|dc-import)(" + ATTRS + ")/>", - "gi" - ); - var CAMEL_ATTR_RE = /(\s)([a-z]+[A-Z][A-Za-z0-9]*)(\s*=)/g; - function encodeCase(html) { - html = html.replace( - IMPORT_SELF_CLOSE_RE, - (_, t, a) => "<" + t + a + ">" - ); - html = html.replace(/)/gi, "/gi, ""); - html = html.replace( - CAMEL_ATTR_RE, - (_, sp, name, eq) => sp + CAMEL_ATTR + name.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()) + eq - ); - for (const [real, alias] of Object.entries(RAW_WRAP)) { - html = html.replace( - new RegExp("(])", "gi"), - "$1" + alias - ); - } - return html; - } - function kebabToCamel(s) { - return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); - } - function cssToObj(css) { - const o = {}; - for (const decl of css.split(";")) { - const i = decl.indexOf(":"); - if (i < 0) continue; - const prop = decl.slice(0, i).trim(); - o[prop.startsWith("--") ? prop : kebabToCamel(prop)] = decl.slice(i + 1).trim(); - } - return o; - } - function compileAttr(raw) { - const whole = raw.match(/^\s*\{\{([\s\S]+?)\}\}\s*$/); - if (whole) { - const path = whole[1]; - return (vals) => resolve(vals, path); - } - if (raw.includes("{{")) { - const parts = raw.split(/\{\{([\s\S]+?)\}\}/g); - return (vals) => parts.map((s, i) => i & 1 ? resolve(vals, s) ?? "" : s).join(""); - } - return () => raw; - } - - // src/compile.ts - function collectProps(node, kind, host) { - const propGetters = []; - const pseudoClasses = []; - let hintSize = null; - for (const { name, value } of [...node.attributes]) { - if (name === "sc-name" || name === "data-dc-tpl") continue; - let key = name; - if (key.startsWith(CAMEL_ATTR)) - key = kebabToCamel(key.slice(CAMEL_ATTR.length)); - if (key === "hint-size") { - hintSize = value; - continue; - } - if (key.startsWith("style-")) { - pseudoClasses.push(host.pseudoClass(key.slice(6), value)); - continue; - } - if (kind !== "dom") { - if (key.includes("-") && !(kind === "x-import" && (key.startsWith("aria-") || key.startsWith("data-")))) - key = kebabToCamel(key); - } else { - if (key === "class") key = "className"; - else if (key === "for") key = "htmlFor"; - else if (key.startsWith("on")) - key = EVENT_MAP[key] || "on" + key[2].toUpperCase() + key.slice(3); - } - propGetters.push([key, compileAttr(value)]); - } - return { propGetters, pseudoClasses, hintSize }; - } - var HOST_STYLE_PROPS = /* @__PURE__ */ new Set([ - "position", - "left", - "right", - "top", - "bottom", - "inset", - "width", - "height", - "z-index", - "transform" - ]); - function hostPositionStyle(style) { - const all = typeof style === "string" ? cssToObj(style) : style != null && typeof style === "object" ? style : null; - if (!all) return void 0; - const out = {}; - for (const [k, v] of Object.entries(all)) { - const kebab = k.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()); - if (HOST_STYLE_PROPS.has(kebab)) out[k] = v; - } - return Object.keys(out).length ? out : void 0; - } - function compileTemplate(html, host) { - const tpl = document.createElement("template"); - //! nosemgrep: direct-inner-html-assignment - tpl.innerHTML = encodeCase(html); - let tplN = 0; - (function stamp(node) { - if (node.nodeType === Node.ELEMENT_NODE) { - node.setAttribute("data-dc-tpl", String(tplN++)); - } - for (const c of node.childNodes) stamp(c); - })(tpl.content); - const builders = walkChildren(tpl.content, host); - const render = ((vals, ctx) => builders.map((b, i) => b(vals || {}, ctx, i))); - render.__annotated = tpl.innerHTML; - return render; - } - function walkChildren(node, host) { - return [...node.childNodes].map((c) => walk(c, host)).filter((b) => b != null); - } - function walk(node, host) { - if (node.nodeType === Node.TEXT_NODE) return walkText(node); - if (node.nodeType !== Node.ELEMENT_NODE) return null; - const el = node; - const tag = el.tagName.toLowerCase(); - if (tag === "sc-for") return walkFor(el, host); - if (tag === "sc-if") return walkIf(el, host); - if (tag === "x-import") return walkXImport(el, host); - if (tag === "sc-helmet") return host.helmet(el); - if (tag === "dc-import") return walkComponent(el, host); - return walkElement(el, host); - } - var warnedHoles = /* @__PURE__ */ new Set(); - function warnUnresolved(ctx, what) { - const key = (ctx?.__name || "?") + "\0" + what; - if (warnedHoles.has(key)) return; - warnedHoles.add(key); - console.warn("[dc-runtime] " + (ctx?.__name || "template") + ": " + what); - } - function walkText(node) { - const txt = node.nodeValue ?? ""; - if (!txt.includes("{{")) { - if (!txt.trim() && !txt.includes(" ")) return null; - return () => txt; - } - const parts = txt.split(/\{\{([\s\S]+?)\}\}/g); - return (vals, ctx, key) => h( - getReact().Fragment, - { key }, - ...parts.map((p, i) => { - if (!(i & 1)) return p; - const v = resolve(vals, p); - if (v === void 0) { - if (!ctx?.__streamingNow) { - if (document.body?.hasAttribute("data-dc-editor-on")) { - return h( - "span", - { key: i, className: "sc-interp sc-unresolved" }, - "{{ " + p.trim() + " }}" - ); - } - warnUnresolved( - ctx, - "{{ " + p.trim() + " }} never resolved \u2014 rendered as empty" - ); - return null; - } - return h( - "span", - { key: i, className: "sc-interp sc-missing" }, - p.trim() - ); - } - if (getReact().isValidElement(v) || Array.isArray(v)) { - return h(getReact().Fragment, { key: i }, v); - } - if (v === null || typeof v === "boolean") return null; - return h("span", { key: i, className: "sc-interp" }, String(v)); - }) - ); - } - function walkFor(el, host) { - const listGet = compileAttr(el.getAttribute("list") || ""); - const asName = el.getAttribute("as") || "item"; - const hintN = parseInt(el.getAttribute("hint-placeholder-count") || "0", 10); - const kids = walkChildren(el, host); - const listSrc = el.getAttribute("list") || ""; - return (vals, ctx, key) => { - let list = listGet(vals); - if (!Array.isArray(list)) { - if (!ctx?.__streamingNow) { - if (list !== void 0 && list !== null) { - warnUnresolved( - ctx, - 'sc-for list="' + listSrc + '" is not an array (' + typeof list + ")" - ); - } - list = []; - } else { - list = hintN > 0 ? Array(hintN).fill(void 0) : []; - } - } - return h( - getReact().Fragment, - { key }, - list.map((item, i) => { - const sub = { ...vals, [asName]: item, $index: i }; - return h( - getReact().Fragment, - { key: i }, - kids.map((b, j) => b(sub, ctx, j)) - ); - }) - ); - }; - } - function walkIf(el, host) { - const valGet = compileAttr(el.getAttribute("value") || ""); - const hintRaw = el.getAttribute("hint-placeholder-val"); - const hintGet = hintRaw != null ? compileAttr(hintRaw) : null; - const kids = walkChildren(el, host); - return (vals, ctx, key) => { - let v = valGet(vals); - if (v === void 0 && hintGet && ctx?.__streamingNow) v = hintGet(vals); - return v ? h( - getReact().Fragment, - { key }, - kids.map((b, j) => b(vals, ctx, j)) - ) : null; - }; - } - function walkComponent(el, host) { - const name = el.getAttribute("name") || el.getAttribute("component") || ""; - el.removeAttribute("name"); - el.removeAttribute("component"); - const tplId = el.getAttribute("data-dc-tpl"); - const styleRaw = el.getAttribute("style"); - el.removeAttribute("style"); - const styleGet = styleRaw != null ? compileAttr(styleRaw) : null; - const { propGetters, hintSize } = collectProps(el, "dc-import", host); - const kids = walkChildren(el, host); - return (vals, ctx, key) => { - const props = { - key, - __hintSize: hintSize, - __tplId: tplId, - __hostStyle: styleGet ? hostPositionStyle(styleGet(vals)) : void 0 - }; - for (const [k, g] of propGetters) { - const v = g(vals); - if (k === "dcProps") { - if (v && typeof v === "object") Object.assign(props, v); - continue; - } - props[k] = v; - } - if (kids.length) props.children = kids.map((b, j) => b(vals, ctx, j)); - return h(host.component(name), props); - }; - } - function walkXImport(el, host) { - const globalNameGet = compileAttr( - el.getAttribute("component-from-global-scope") || "" - ); - const exportNameGet = compileAttr( - el.getAttribute("component") || el.getAttribute("name") || "" - ); - const fromRaw = el.getAttribute("from") || el.getAttribute("src") || el.getAttribute("import") || ""; - const urls = fromRaw.trim() ? fromRaw.trim().split(/\s+/) : []; - const url = urls.length ? urls[urls.length - 1] : ""; - const kindOf = (u) => /\.(jsx|tsx)(\?|#|$)/i.test(u) ? "jsx" : "js"; - const tplId = el.getAttribute("data-dc-tpl"); - const styleRaw = el.getAttribute("style"); - el.removeAttribute("style"); - const styleGet = styleRaw != null ? compileAttr(styleRaw) : null; - const wrap = tplId != null || styleGet != null; - const { propGetters, hintSize } = collectProps(el, "x-import", host); - const hasContent = el.children.length > 0 || !!(el.textContent || "").trim(); - const kids = hasContent ? walkChildren(el, host) : []; - const urlBindable = fromRaw.includes("{{"); - if (urls.length && !urlBindable) { - let prev; - for (const u of urls) prev = host.loadExternal(kindOf(u), u, prev); - } - const evalName = (g, vals) => { - const v = g(vals); - const s = v == null ? "" : String(v); - return s.includes("{{") ? "" : s; - }; - return (vals, ctx, key) => { - const globalName = evalName(globalNameGet, vals); - const name = globalName || evalName(exportNameGet, vals); - const C = !name || urlBindable ? null : globalName ? host.resolveExternalGlobal(url, globalName) : host.resolveExternal(url, name); - const hostStyle = styleGet ? hostPositionStyle(styleGet(vals)) : void 0; - const wrapper = wrap ? { - key, - className: "sc-host-x", - "data-dc-tpl": tplId, - style: hostStyle || { display: "contents" } - } : null; - if (!C) { - const error = urlBindable ? "x-import `from` cannot contain {{ \u2026 }} \u2014 module URLs are resolved at parse time; use a literal URL" : host.resolveExternalError(url, name); - const ph = host.placeholder({ - key: wrapper ? void 0 : key, - name, - hintSize, - error - }); - return wrapper ? h("div", wrapper, ph) : ph; - } - const props = wrapper ? {} : { key }; - let unresolvedHole = false; - for (const [k, g] of propGetters) { - if (k === "component" || k === "componentFromGlobalScope" || k === "from") { - continue; - } - const v = g(vals); - if (v === void 0) unresolvedHole = true; - if (k === "dcProps") { - if (v && typeof v === "object") Object.assign(props, v); - continue; - } - props[k] = v; - } - if (unresolvedHole && ctx?.__htmlStreamingNow) { - const ph = host.placeholder({ - key: wrapper ? void 0 : key, - name, - hintSize, - error: null - }); - return wrapper ? h("div", wrapper, ph) : ph; - } - if (kids.length) props.children = kids.map((b, j) => b(vals, ctx, j)); - return wrapper ? h("div", wrapper, h(C, props)) : h(C, props); - }; - } - function walkElement(el, host) { - const realTag = RAW_UNWRAP[el.localName] || el.localName; - const tplId = el.getAttribute("data-dc-tpl"); - const { propGetters, pseudoClasses } = collectProps(el, "dom", host); - const kids = walkChildren(el, host); - return (vals, ctx, key) => { - const props = { key, "data-dc-tpl": tplId }; - for (const [k, g] of propGetters) { - let v = g(vals); - if (k === "style" && typeof v === "string") v = cssToObj(v); - if ((k === "value" || k === "checked") && v === void 0) { - v = k === "checked" ? false : ""; - } - props[k] = v; - } - if (pseudoClasses.length) { - props.className = [props.className, ...pseudoClasses].filter(Boolean).join(" "); - } - return h(realTag, props, ...kids.map((b, j) => b(vals, ctx, j))); - }; - } - - // src/logic.ts - var StreamableLogic = class { - constructor(props) { - __publicField(this, "props"); - __publicField(this, "state", {}); - /** Back-pointer to the wrapper component, installed after construction. */ - __publicField(this, "__host"); - this.props = props || {}; - } - setState(update, cb) { - this.__host && this.__host.__setLogicState(update, cb); - } - forceUpdate() { - this.__host && this.__host.forceUpdate(); - } - componentDidMount() { - } - componentDidUpdate(_prevProps) { - } - componentWillUnmount() { - } - /** The flat object the template renders against (merged over props). */ - renderVals() { - return {}; - } - }; - function evalDcLogic(src) { - //! nosemgrep: eval-and-function-constructor - const fn = new Function( - "DCLogic", - "StreamableLogic", - "React", - src + '\n;return (typeof Component!=="undefined"&&Component)||undefined;' - ); - return fn(StreamableLogic, StreamableLogic, getReact()); - } - - // src/component.ts - function shallowEqual(a, b) { - if (!b) return false; - const ak = Object.keys(a).filter((k) => k !== "children"); - const bk = Object.keys(b).filter((k) => k !== "children"); - if (ak.length !== bk.length) return false; - for (const k of ak) if (a[k] !== b[k]) return false; - return true; - } - function Placeholder({ - name, - hintSize, - streaming, - error - }) { - const [w, hgt] = (hintSize || "100%,60px").split(","); - return h( - "div", - { - className: "sc-placeholder" + (streaming ? " sc-streaming" : ""), - style: { width: w.trim(), height: hgt && hgt.trim() }, - title: name - }, - error ? h( - "div", - { className: "sc-placeholder-error" }, - (name ? name + ": " : "") + error - ) : null - ); - } - function hintToMin(hint) { - if (!hint) return void 0; - const [w, hgt] = hint.split(","); - return { minWidth: w.trim(), minHeight: hgt && hgt.trim() }; - } - function createComponentFactory(registry, ensureFetched) { - const React = getReact(); - const AncestorContext = React.createContext([]); - class StreamableComponent extends React.Component { - constructor(props) { - super(props); - __publicField(this, "__name"); - __publicField(this, "__sub"); - __publicField(this, "__needsDidMount", false); - /** Snapshot of the registry's streaming flags taken at render time — - * builders read it off the RenderCtx (this) to pick placeholder vs - * render-nothing for unresolved values. */ - __publicField(this, "__streamingNow", false); - __publicField(this, "__htmlStreamingNow", false); - /** When a construct throws, remember the (class, registry.ver, props) - * triple so render-time reconcile doesn't re-attempt it on every parent - * re-render. A registry bump (new class, template, external module - * resolving via bumpAll) changes `ver` and breaks the memo so an - * env-dependent constructor can self-heal. */ - __publicField(this, "__failedLogic", null); - __publicField(this, "__failedUserProps", null); - __publicField(this, "__failedVer", -1); - /** Per-instance constructor error — kept here (not on the registry entry) - * so one instance's successful construct can't hide a sibling's failure, - * and a construct can never wipe an eval error `updateJs` recorded on - * `r.logicError`. */ - __publicField(this, "__ctorError", null); - __publicField(this, "logic"); - this.__name = props.__name; - this.state = { __v: 0, __err: null }; - this.__sub = () => { - if (this.state.__err) this.setState({ __err: null }); - this.forceUpdate(); - }; - this.__makeLogic(registry.get(this.__name).Logic, null); - ensureFetched(this.__name); - } - /** Error-boundary hook: a render crash anywhere in this DC's subtree - * (its own template, an x-import'd component, a child DC without its - * own deeper boundary) lands here instead of unmounting the page. */ - static getDerivedStateFromError(e) { - return { __err: e instanceof Error && e.message ? e.message : String(e) }; - } - componentDidCatch(e, info) { - console.error( - "[dc-runtime] render error in <" + this.__name + ">:", - e, - info?.componentStack || "" - ); - } - /** Instantiate the logic class (or the no-op base) and adopt `prevState` - * over its initial state — used both at mount and on hot-swap. */ - __makeLogic(Logic, prevState) { - const L = Logic || StreamableLogic; - try { - this.logic = new L(this.__userProps()); - this.__failedLogic = null; - this.__failedUserProps = null; - this.__ctorError = null; - } catch (e) { - console.error(e); - this.__failedLogic = Logic; - this.__failedUserProps = this.__userProps(); - this.__failedVer = registry.get(this.__name).ver; - this.__ctorError = this.__name + ": " + (e instanceof Error && e.message ? e.message : String(e)); - this.logic = new StreamableLogic( - this.__userProps() - ); - } - this.logic.__host = this; - if (prevState) - this.logic.state = { ...this.logic.state || {}, ...prevState }; - } - /** The props the author's logic + template see — internal __-prefixed - * wiring stripped. */ - __userProps() { - const { __name, __hintSize, __tplId, __hostStyle, ...rest } = this.props; - return rest; - } - __setLogicState(update, cb) { - const prev = this.logic.state; - const patch = typeof update === "function" ? update(prev) : update; - this.logic.state = { ...prev, ...patch }; - this.setState((s) => ({ __v: s.__v + 1 }), cb); - } - /** Swap the logic instance when the registry's Logic class changed - * (streaming completion, hot reload). State carries over; didMount - * re-fires after the swap commits so refs exist. */ - __reconcileLogic() { - const r = registry.get(this.__name); - const Next = r.Logic; - const Cur = this.logic.constructor; - if (Next === Cur || !Next && Cur === StreamableLogic || Next === this.__failedLogic && r.ver === this.__failedVer && shallowEqual(this.__userProps(), this.__failedUserProps)) { - return; - } - if (!this.__needsDidMount) { - try { - this.logic.componentWillUnmount(); - } catch (e) { - console.error(e); - } - } - this.__makeLogic(Next, this.logic.state); - this.__needsDidMount = true; - } - componentDidMount() { - registry.get(this.__name).subs.add(this.__sub); - try { - this.logic.componentDidMount(); - } catch (e) { - console.error(e); - } - } - componentDidUpdate(prevProps) { - this.logic.props = this.__userProps(); - if (this.__needsDidMount) { - if (this.state.__err || !registry.get(this.__name).tpl) return; - this.__needsDidMount = false; - try { - this.logic.componentDidMount(); - } catch (e) { - console.error(e); - } - } else { - try { - this.logic.componentDidUpdate(prevProps); - } catch (e) { - console.error(e); - } - } - } - componentWillUnmount() { - registry.get(this.__name).subs.delete(this.__sub); - if (!this.__needsDidMount) { - try { - this.logic.componentWillUnmount(); - } catch (e) { - console.error(e); - } - } - } - render() { - const r = registry.get(this.__name); - const cls = "sc-host" + (r.htmlStreaming ? " sc-streaming-html" : "") + (r.jsStreaming ? " sc-streaming-js" : ""); - const hintStyle = r.htmlStreaming ? hintToMin(this.props.__hintSize) : void 0; - const hostStyle = this.props.__hostStyle || hintStyle ? { ...hintStyle || {}, ...this.props.__hostStyle || {} } : void 0; - const hostBase = { - className: cls, - style: hostStyle, - "data-sc-name": this.__name, - "data-dc-tpl": this.props.__tplId - }; - const chain = Array.isArray(this.context) ? this.context : []; - if (chain.includes(this.__name)) { - const cycle = [ - ...chain.slice(chain.indexOf(this.__name)), - this.__name - ].join(" \u2192 "); - return h( - "div", - { ...hostBase, className: cls + " sc-has-error" }, - h(Placeholder, { - name: this.__name, - hintSize: this.props.__hintSize, - error: "circular import: " + cycle - }) - ); - } - if (this.state.__err) { - return h( - "div", - { ...hostBase, className: cls + " sc-has-error" }, - h( - "div", - { className: "sc-logic-error", "data-omelette-chrome": "" }, - this.__name + ": " + this.state.__err - ), - h(Placeholder, { - name: this.__name, - hintSize: this.props.__hintSize, - error: this.state.__err - }) - ); - } - this.__reconcileLogic(); - if (!r.tpl) { - return h( - "div", - hostBase, - h(Placeholder, { name: this.__name, hintSize: this.props.__hintSize }) - ); - } - const userProps = this.__userProps(); - this.logic.props = userProps; - let vals = userProps; - let renderErr = r.logicError || this.__ctorError; - try { - vals = { ...userProps, ...this.logic.renderVals() || {} }; - } catch (e) { - console.error(e); - renderErr = this.__name + ".renderVals(): " + (e instanceof Error && e.message ? e.message : String(e)); - } - this.__streamingNow = !!(r.htmlStreaming || r.jsStreaming); - this.__htmlStreamingNow = !!r.htmlStreaming; - return h( - "div", - { ...hostBase, className: cls + (renderErr ? " sc-has-error" : "") }, - renderErr && h( - "div", - { className: "sc-logic-error", "data-omelette-chrome": "" }, - renderErr - ), - h( - AncestorContext.Provider, - { value: [...chain, this.__name] }, - r.tpl(vals, this) - ) - ); - } - } - __publicField(StreamableComponent, "contextType", AncestorContext); - const named = /* @__PURE__ */ new Map(); - function getDC(name) { - const hit = named.get(name); - if (hit) return hit; - function Dispatcher(p) { - const [, setTick] = React.useState(0); - React.useEffect(() => { - const sub = () => setTick((n) => n + 1); - registry.get(name).subs.add(sub); - return () => { - registry.get(name).subs.delete(sub); - }; - }, []); - ensureFetched(name); - return h(StreamableComponent, { ...p, __name: name }); - } - Dispatcher.displayName = name; - named.set(name, Dispatcher); - return Dispatcher; - } - return { - getDC, - StreamableComponent - }; - } - - // src/external.ts - var isCustomElementName = (n) => !n.includes(".") && n.includes("-"); - function isRenderableType(g) { - if (typeof g === "function") return !isElementClass(g); - return typeof g === "object" && g !== null && typeof g.$$typeof === "symbol"; - } - function resolveDottedPath(root, name) { - let cur = root; - for (const seg of name.split(".")) { - if (cur == null) return void 0; - cur = cur[seg]; - } - return cur; - } - var BABEL_URL = "https://unpkg.com/@babel/standalone@7.26.4/babel.min.js"; - var GLOBAL_POLL_INTERVAL_MS = 50; - var GLOBAL_POLL_TIMEOUT_MS = 3e4; - function createExternalModules(onResolved) { - const cache = /* @__PURE__ */ new Map(); - let babelLoading = null; - const reportedMissing = /* @__PURE__ */ new Map(); - const polling = /* @__PURE__ */ new Set(); - function ensureBabel() { - if (window.Babel) return Promise.resolve(); - if (babelLoading) return babelLoading; - babelLoading = new Promise((res, rej) => { - const s = document.createElement("script"); - s.src = BABEL_URL; - s.crossOrigin = "anonymous"; - s.onload = () => res(); - s.onerror = rej; - document.head.appendChild(s); - }); - return babelLoading; - } - const pending = /* @__PURE__ */ new Map(); - function load(kind, url, after) { - const existing = pending.get(url); - if (existing) return existing; - cache.set(url, null); - console.info("[dc-runtime] x-import: loading", url, "(" + kind + ")"); - const ready = Promise.all([ - kind === "jsx" ? ensureBabel() : Promise.resolve(), - after ?? Promise.resolve() - ]); - const p = ready.then(() => fetch(url)).then((r) => { - if (!r.ok) throw new Error("HTTP " + r.status); - return r.text(); - }).then((src) => { - const code = kind === "jsx" ? window.Babel.transform(src, { - filename: url, - presets: ["react", "typescript"] - }).code : src; - const module = { exports: {} }; - const before = new Set(Object.keys(window)); - //! nosemgrep: eval-and-function-constructor - new Function("React", "module", "exports", "require", code)( - getReact(), - module, - module.exports, - () => ({}) - ); - const globals = {}; - for (const k of Object.keys(window)) { - if (!before.has(k) && typeof window[k] === "function") { - globals[k] = window[k]; - } - } - cache.set(url, { mod: module.exports, globals }); - console.info( - "[dc-runtime] x-import: loaded", - url, - "\u2014 exports:", - Object.keys(module.exports), - "window globals:", - Object.keys(globals) - ); - onResolved(); - }).catch((e) => { - cache.set(url, { - mod: {}, - globals: {}, - error: "failed to load: " + (e instanceof Error && e.message ? e.message : String(e)) - }); - console.error( - "[dc-runtime] x-import: FAILED to load", - url, - "(" + kind + ")", - e - ); - onResolved(); - }); - pending.set(url, p); - return p; - } - function resolve2(url, name) { - const entry = cache.get(url); - if (!entry) return null; - const { mod, globals } = entry; - const C = mod && mod[name] || globals && globals[name] || typeof window !== "undefined" && window[name] || mod && mod.default; - if (typeof C === "function") return C; - const key = url + "\0" + name; - if (!reportedMissing.has(key)) { - reportedMissing.set( - key, - entry.error || 'no export named "' + name + '" (has: ' + Object.keys(mod).join(", ") + ")" - ); - console.error( - "[dc-runtime] x-import: module", - url, - "loaded but has no component named", - JSON.stringify(name), - "\u2014 available exports:", - Object.keys(mod), - "window globals:", - Object.keys(globals), - ". The module must `module.exports = {" + name + "}` or set `window." + name + "`." - ); - } - return null; - } - function waitForGlobal(name) { - if (polling.has(name)) return; - polling.add(name); - const started = Date.now(); - const isCE = isCustomElementName(name); - const tick = () => { - const found = isCE ? customElements.get(name) : isRenderableType(resolveDottedPath(window, name)); - if (found) { - polling.delete(name); - onResolved(); - return; - } - if (Date.now() - started >= GLOBAL_POLL_TIMEOUT_MS) { - console.warn( - "[dc-runtime] x-import: global", - JSON.stringify(name), - "never appeared on window after " + GLOBAL_POLL_TIMEOUT_MS + "ms" - ); - return; - } - setTimeout(tick, GLOBAL_POLL_INTERVAL_MS); - }; - setTimeout(tick, GLOBAL_POLL_INTERVAL_MS); - } - function resolveGlobal(url, name) { - const isCE = isCustomElementName(name); - if (!url) { - if (isCE) { - if (customElements.get(name)) return name; - waitForGlobal(name); - return null; - } - const g2 = resolveDottedPath(window, name); - if (isRenderableType(g2)) return g2; - waitForGlobal(name); - return null; - } - const entry = cache.get(url); - if (!entry) return null; - if (isCE && customElements.get(name)) return name; - const g = entry.globals[name] ?? resolveDottedPath(window, name); - if (isRenderableType(g)) return g; - if (name.includes(".")) return null; - const key = url + "\0global\0" + name; - if (!reportedMissing.has(key)) { - reportedMissing.set(key, null); - if (isCE && !customElements.get(name)) { - console.warn( - "[dc-runtime] x-import:", - url, - "loaded but no custom element", - JSON.stringify(name), - "is registered and window." + name + " is not a function \u2014 rendering <" + name + "> as an unknown element." - ); - } - } - return name; - } - function getError(url, name) { - const entry = cache.get(url); - if (entry?.error) return entry.error; - return reportedMissing.get(url + "\0" + name) || null; - } - return { load, resolve: resolve2, resolveGlobal, getError }; - } - function isElementClass(g) { - try { - return typeof g === "function" && typeof HTMLElement !== "undefined" && g.prototype instanceof HTMLElement; - } catch { - return false; - } - } - - // src/atomics.ts - var ATOMIC_CSS = ( - // layout - ".fx{display:flex}.col{display:flex;flex-direction:column}.grid{display:grid}.ac{align-items:center}.jc{justify-content:center}.jb{justify-content:space-between}.f1{flex:1}.noshrink{flex-shrink:0}.wrap{flex-wrap:wrap}.fw5{font-weight:500}.fw6{font-weight:600}.fw7{font-weight:700}.fw8{font-weight:800}.fs11{font-size:11px}.fs12{font-size:12px}.fs13{font-size:13px}.fs14{font-size:14px}.fs15{font-size:15px}.fs16{font-size:16px}.fs20{font-size:20px}.fs22{font-size:22px}.upper{text-transform:uppercase}.tc{text-align:center}.nowrap{white-space:nowrap}.gap8{gap:8px}.gap10{gap:10px}.gap12{gap:12px}.gap16{gap:16px}.gap24{gap:24px}.m0{margin:0}.mt8{margin-top:8px}.mt12{margin-top:12px}.mt16{margin-top:16px}.mb8{margin-bottom:8px}.mb12{margin-bottom:12px}.mb16{margin-bottom:16px}.posrel{position:relative}.posabs{position:absolute}.round{border-radius:50%}.ohide{overflow:hidden}.bbox{box-sizing:border-box}.pointer{cursor:pointer}.w100{width:100%}.b0{border:none}" - ); - - // src/helmet.ts - var DESIGN_DOC_MODE_RE = /]*\bname\s*=\s*["']design_doc_mode["'][^>]*\b(?:content|value)\s*=\s*["'](\w+)["']/i; - var CANVAS_BG = "#f0eee9"; - function createHelmetManager(doc, isStreaming) { - const mounted = /* @__PURE__ */ new Set(); - const live = /* @__PURE__ */ new Map(); - let designDocMode = null; - let canvasStyleEl = null; - function postDesignMode(mode) { - if (window.parent === window) return; - try { - window.parent.postMessage({ type: "__dc_design_mode", mode }, "*"); - } catch { - } - } - function setDesignDocMode(mode) { - if (mode === designDocMode) return; - designDocMode = mode; - postDesignMode(mode); - if (mode === "canvas") { - doc.documentElement.setAttribute("data-dc-canvas", ""); - canvasStyleEl = doc.createElement("style"); - canvasStyleEl.setAttribute("data-dc-canvas", ""); - canvasStyleEl.textContent = `html,body{background:${CANVAS_BG}}#dc-root>.sc-host{position:relative}`; - doc.head.appendChild(canvasStyleEl); - } else { - doc.documentElement.removeAttribute("data-dc-canvas"); - canvasStyleEl?.remove(); - canvasStyleEl = null; - } - } - window.addEventListener("message", (e) => { - if (!designDocMode || (e.data && e.data.type) !== "__dc_probe") return; - postDesignMode(designDocMode); - }); - function compile(node) { - const raw = [...node.children]; - const helmetClosed = node.nextSibling != null || node.parentNode?.nextSibling != null; - if (node.hasAttribute("data-dc-atomics") && !mounted.has("__dc-atomics")) { - mounted.add("__dc-atomics"); - const el = doc.createElement("style"); - el.id = "__dc-atomics"; - el.textContent = ATOMIC_CSS; - doc.head.appendChild(el); - } - return (_vals, ctx) => { - const name = ctx && ctx.__name || ""; - const streaming = !!(name && isStreaming(name)); - for (let i = 0; i < raw.length; i++) { - const child = raw[i]; - const tag = child.tagName; - const mayBePartial = streaming && !helmetClosed && i === raw.length - 1; - if (tag === "SCRIPT") { - if (mayBePartial) continue; - const key = "SCRIPT|" + (child.getAttribute("src") || child.textContent || ""); - if (mounted.has(key)) continue; - mounted.add(key); - const el = doc.createElement("script"); - for (const { name: an, value } of [...child.attributes]) - el.setAttribute(an, value); - if (child.textContent) el.textContent = child.textContent; - doc.head.appendChild(el); - } else if (tag === "LINK" || tag === "META") { - if (mayBePartial) continue; - const key = tag + "|" + (child.getAttribute("href") || child.getAttribute("src") || child.outerHTML); - if (mounted.has(key)) continue; - mounted.add(key); - doc.head.appendChild(child.cloneNode(true)); - } else { - const key = name + "|" + i; - let el = live.get(key); - if (!el || el.tagName !== tag) { - if (el) el.remove(); - el = doc.createElement(tag.toLowerCase()); - live.set(key, el); - doc.head.appendChild(el); - } - for (const { name: an, value } of [...child.attributes]) { - if (el.getAttribute(an) !== value) el.setAttribute(an, value); - } - if (el.textContent !== child.textContent) - el.textContent = child.textContent; - } - } - return null; - }; - } - return { compile, setDesignDocMode }; - } - - // src/pseudo.ts - function createPseudoSheet(doc) { - let el = null; - const cache = /* @__PURE__ */ new Map(); - let n = 0; - return (pseudo, css) => { - const k = pseudo + "|" + css; - const hit = cache.get(k); - if (hit) return hit; - if (!el) { - el = doc.createElement("style"); - doc.head.appendChild(el); - } - const cls = "scp" + (n++).toString(36); - const sel = pseudo === "before" || pseudo === "after" ? "." + cls + "::" + pseudo : "." + cls + ":" + pseudo; - el.sheet.insertRule(sel + "{" + css + "}", el.sheet.cssRules.length); - cache.set(k, cls); - return cls; - }; - } - - // src/registry.ts - function createRegistry() { - const entries = /* @__PURE__ */ Object.create(null); - function get(name) { - return entries[name] || (entries[name] = { - html: "", - tpl: null, - Logic: null, - jsStreaming: false, - htmlStreaming: false, - ver: 0, - subs: /* @__PURE__ */ new Set(), - fetched: false - }); - } - function bump(name) { - const r = get(name); - r.ver++; - for (const fn of r.subs) fn(); - } - return { - entries, - get, - bump, - bumpAll() { - for (const n in entries) bump(n); - } - }; - } - - // src/runtime.ts - var COMPONENT_DIR = "."; - function createRuntime(doc = document) { - const registry = createRegistry(); - const pseudoClass = createPseudoSheet(doc); - const helmet = createHelmetManager( - doc, - (name) => registry.get(name).htmlStreaming - ); - const external = createExternalModules(() => registry.bumpAll()); - const factory = createComponentFactory(registry, ensureFetched); - const host = { - component: (name) => factory.getDC(name), - placeholder: (props) => h(Placeholder, props), - helmet: (node) => helmet.compile(node), - loadExternal: (kind, url, after) => external.load(kind, url, after), - resolveExternal: (url, name) => external.resolve(url, name), - resolveExternalGlobal: (url, name) => external.resolveGlobal(url, name), - resolveExternalError: (url, name) => external.getError(url, name), - pseudoClass - }; - function ensureFetched(name) { - const r = registry.get(name); - if (r.fetched) return; - r.fetched = true; - const url = COMPONENT_DIR + "/" + encodeURIComponent(name) + ".dc.html"; - fetch(url).then((res) => { - if (!res.ok) { - console.error( - "[dc-runtime] sibling fetch for <" + name + "/> failed:", - url, - "returned", - res.status, - "\u2014 the reference renders as an empty placeholder." - ); - return ""; - } - return res.text(); - }).then((t) => { - if (!t) return; - const parsed = parseDcText(t); - if (!parsed) { - console.error( - "[dc-runtime] sibling fetch for <" + name + "/>:", - url, - "has no block \u2014 not a Design Component." - ); - return; - } - if (parsed.props) r.propsMeta = parsed.props; - if (parsed.preview) r.preview = parsed.preview; - if (parsed.template && !r.html) updateHtml(name, parsed.template); - if (parsed.js && !r.Logic) updateJs(name, parsed.js); - }).catch( - (e) => console.error( - "[dc-runtime] sibling fetch for <" + name + "/> threw:", - url, - e - ) - ); - } - let rootName = null; - function updateHtml(name, html) { - const r = registry.get(name); - r.html = html; - if (name === rootName) { - const mode = DESIGN_DOC_MODE_RE.exec(html)?.[1] ?? null; - if (mode || !r.htmlStreaming) helmet.setDesignDocMode(mode); - } - try { - r.tpl = compileTemplate(html, host); - } catch (e) { - console.error("[dc-runtime] template compile FAILED for", name, e); - } - registry.bump(name); - } - function updateJs(name, src) { - const r = registry.get(name); - const seq = r.jsSeq = (r.jsSeq || 0) + 1; - try { - const Cls = evalDcLogic(src); - if (r.jsSeq !== seq) return; - if (typeof Cls !== "function") { - r.logicError = name + ".dc.html: