diff --git a/.gitignore b/.gitignore
index c50ba634d..1ae2878b5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,6 +26,9 @@ secrets.env.*
# Data — all user data stays local
data/
+# Per-worktree runtime state written by `odysseus dev` (its own data dir,
+# logs and stop handle) — disposable, and never shared between checkouts.
+.odysseus-dev/
!services/hwfit/data/
!services/hwfit/data/hf_models.json
logs/
diff --git a/scripts/css_snapshot.py b/scripts/css_snapshot.py
new file mode 100644
index 000000000..ba927775f
--- /dev/null
+++ b/scripts/css_snapshot.py
@@ -0,0 +1,290 @@
+#!/usr/bin/env python3
+"""Computed-style snapshot harness for ``static/style.css``.
+
+``static/style.css`` is a single 51k-line stylesheet whose rendered result
+depends on source order: hundreds of selectors are declared more than once and
+``!important`` is used throughout. Any restructuring - extracting a block into
+its own file, reordering ```` tags, moving an ``@media`` rule - can
+silently change which declaration wins, and nothing else in the suite would
+notice.
+
+This module captures ``getComputedStyle`` for a fixed inventory of elements
+across pages, viewports, themes and density modes, hashes the result, and
+compares it against a committed baseline. It moves no CSS. It only makes a move
+falsifiable.
+
+Usage::
+
+ python scripts/css_snapshot.py --check # compare to the baseline
+ python scripts/css_snapshot.py --write-baseline # re-record it
+ python scripts/css_snapshot.py --dump before.json # raw values, for diffing
+
+With no ``--origin`` the script serves the repository over loopback on an
+ephemeral port for the duration of the run, so it works standalone. Under
+pytest the session static server is reused instead.
+
+To see *which property* moved rather than just which element::
+
+ python scripts/css_snapshot.py --dump after.json
+ git stash && python scripts/css_snapshot.py --dump before.json && git stash pop
+ diff <(python -m json.tool before.json) <(python -m json.tool after.json)
+"""
+import argparse
+import hashlib
+import http.server
+import json
+import os
+import shutil
+import socketserver
+import subprocess
+import sys
+import threading
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+SNAPSHOT_DIR = ROOT / "tests" / "css_snapshot"
+INVENTORY_PATH = SNAPSHOT_DIR / "inventory.json"
+BASELINE_PATH = SNAPSHOT_DIR / "baseline.json"
+CAPTURE_SCRIPT = SNAPSHOT_DIR / "capture.mjs"
+
+# A capture is ~70 page loads; on a warm checkout it runs in well under a
+# minute, but a cold `npx playwright install` machine can be slow to start
+# Chromium the first time.
+CAPTURE_TIMEOUT_SECONDS = 900
+
+# Hash prefix length. 16 hex characters is 64 bits - far past any accidental
+# collision risk for a few thousand entries, and short enough that the baseline
+# stays readable in a diff.
+HASH_LENGTH = 16
+
+
+def load_inventory(path=INVENTORY_PATH):
+ """Load the checked-in element inventory."""
+ return json.loads(Path(path).read_text(encoding="utf-8"))
+
+
+def load_baseline(path=BASELINE_PATH):
+ """Load the committed baseline digest."""
+ return json.loads(Path(path).read_text(encoding="utf-8"))
+
+
+def _canonical(value):
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
+
+
+def _hash(value):
+ return hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest()[:HASH_LENGTH]
+
+
+def node_available(node="node"):
+ """True when the node binary is on PATH."""
+ return shutil.which(node) is not None
+
+
+def playwright_available(node="node", cwd=ROOT):
+ """True when node can resolve the playwright package from the repo root.
+
+ Playwright is a devDependency installed by ``npm ci``; a clean checkout
+ that has not run it cannot drive a browser at all.
+ """
+ if not node_available(node):
+ return False
+ result = subprocess.run(
+ [node, "-e", "require.resolve('playwright')"],
+ cwd=str(cwd), capture_output=True, text=True, check=False,
+ )
+ return result.returncode == 0
+
+
+def capture(origin, inventory=None, *, swap_rule=None, variants=None,
+ node="node", cwd=ROOT, timeout=CAPTURE_TIMEOUT_SECONDS):
+ """Drive the browser capture and return ``{"snapshot": ..., "missing": ...}``.
+
+ ``swap_rule`` swaps the first two top-level declarations of one selector
+ before the stylesheet reaches the browser. It exists for the harness
+ self-test: a snapshot that does not move when two conflicting rules trade
+ places is not evidence of anything.
+
+ ``variants`` restricts the run to the named variants, for a faster
+ focused capture.
+ """
+ inventory = inventory or load_inventory()
+ selected = inventory["variants"]
+ if variants:
+ wanted = set(variants)
+ selected = [v for v in selected if v["name"] in wanted]
+ unknown = wanted - {v["name"] for v in inventory["variants"]}
+ if unknown:
+ raise ValueError(f"unknown variants: {sorted(unknown)}")
+ job = {
+ "origin": origin.rstrip("/"),
+ "properties": inventory["properties"],
+ "variants": selected,
+ "pages": inventory["pages"],
+ "swapRule": swap_rule,
+ }
+ result = subprocess.run(
+ [node, str(CAPTURE_SCRIPT)],
+ input=json.dumps(job), cwd=str(cwd),
+ capture_output=True, text=True, check=False, timeout=timeout,
+ )
+ if result.returncode != 0:
+ raise RuntimeError(f"css snapshot capture failed:\n{result.stderr.strip()}")
+ return json.loads(result.stdout)
+
+
+def summarize(snapshot):
+ """Reduce a raw capture to the committed digest shape.
+
+ Two orthogonal projections are stored rather than one hash per
+ (element, variant) pair: hashing every pair would commit ~5,000 lines that
+ nobody reads, while a single global digest would only ever say "something
+ moved". Per-element and per-variant hashes localise a failure from both
+ directions - which element drifted, and in which variant - for a file small
+ enough to review.
+ """
+ elements = {}
+ variants = {}
+ for page, per_variant in snapshot.items():
+ element_values = {}
+ variants[page] = {}
+ for variant, measured in per_variant.items():
+ variants[page][variant] = _hash(measured)
+ for key, values in measured.items():
+ element_values.setdefault(key, {})[variant] = values
+ elements[page] = {key: _hash(values) for key, values in element_values.items()}
+ return {
+ "digest": _hash(snapshot),
+ "elements": elements,
+ "variants": variants,
+ }
+
+
+def compare(baseline, current):
+ """Return the drift between a committed baseline and a fresh summary."""
+ drift = {"digest_changed": baseline.get("digest") != current["digest"],
+ "elements": [], "variants": []}
+ for section in ("elements", "variants"):
+ old = baseline.get(section, {})
+ new = current.get(section, {})
+ for page in sorted(set(old) | set(new)):
+ old_page = old.get(page, {})
+ new_page = new.get(page, {})
+ for key in sorted(set(old_page) | set(new_page)):
+ if old_page.get(key) != new_page.get(key):
+ drift[section].append(f"{page}/{key}")
+ return drift
+
+
+def serve_repository(root=ROOT):
+ """Serve the repository over loopback on an ephemeral port.
+
+ Mirrors the browser-test static server in ``tests/conftest.py`` so the CLI
+ can run outside pytest. Returns ``(origin, shutdown)``.
+ """
+ root = Path(root).resolve()
+
+ class Handler(http.server.SimpleHTTPRequestHandler):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, directory=str(root), **kwargs)
+
+ def log_message(self, fmt, *args):
+ pass
+
+ def guess_type(self, path):
+ if path.endswith(".js") or path.endswith(".mjs"):
+ return "application/javascript"
+ if path.endswith(".css"):
+ return "text/css"
+ return super().guess_type(path)
+
+ class Server(socketserver.TCPServer):
+ allow_reuse_address = True
+
+ server = Server(("127.0.0.1", 0), Handler)
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+
+ def shutdown():
+ server.shutdown()
+ server.server_close()
+
+ return f"http://127.0.0.1:{server.server_address[1]}", shutdown
+
+
+def _describe(drift, limit=25):
+ lines = []
+ for section in ("elements", "variants"):
+ items = drift[section]
+ if not items:
+ continue
+ shown = items[:limit]
+ suffix = f" (+{len(items) - limit} more)" if len(items) > limit else ""
+ lines.append(f" {section} that moved ({len(items)}): {', '.join(shown)}{suffix}")
+ return "\n".join(lines) or " (no per-element drift; the digest itself changed)"
+
+
+def main(argv=None):
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
+ parser.add_argument("--origin", help="static server origin to capture against; "
+ "one is started on an ephemeral port when omitted")
+ parser.add_argument("--write-baseline", action="store_true",
+ help=f"re-record {BASELINE_PATH.relative_to(ROOT)}")
+ parser.add_argument("--check", action="store_true",
+ help="compare against the committed baseline (default)")
+ parser.add_argument("--dump", metavar="PATH",
+ help="write the raw computed values, for property-level diffing")
+ parser.add_argument("--swap-rule", metavar="SELECTOR",
+ help="swap the first two top-level declarations of SELECTOR "
+ "before capturing (harness self-test)")
+ parser.add_argument("--variants", help="comma-separated variant names to restrict the run to")
+ parser.add_argument("--node", default="node", help="node binary to use")
+ args = parser.parse_args(argv)
+
+ if not playwright_available(args.node):
+ parser.error("node with the playwright package is required; run `npm ci` first")
+
+ variants = [v.strip() for v in args.variants.split(",")] if args.variants else None
+ shutdown = None
+ origin = args.origin or os.environ.get("ODYSSEUS_TEST_STATIC_ORIGIN")
+ if not origin:
+ origin, shutdown = serve_repository()
+ try:
+ captured = capture(origin, swap_rule=args.swap_rule, variants=variants, node=args.node)
+ finally:
+ if shutdown:
+ shutdown()
+
+ if captured["missing"]:
+ print("inventory entries that matched no element:", file=sys.stderr)
+ for scope, keys in sorted(captured["missing"].items()):
+ print(f" {scope}: {', '.join(keys)}", file=sys.stderr)
+
+ summary = summarize(captured["snapshot"])
+
+ if args.dump:
+ Path(args.dump).write_text(json.dumps(captured["snapshot"], indent=1, sort_keys=True) + "\n",
+ encoding="utf-8")
+ print(f"raw values written to {args.dump}")
+
+ if args.write_baseline:
+ if variants or args.swap_rule:
+ parser.error("--write-baseline needs a full, unmutated capture: "
+ "drop --variants and --swap-rule")
+ BASELINE_PATH.write_text(json.dumps(summary, indent=1, sort_keys=True) + "\n",
+ encoding="utf-8")
+ print(f"baseline written: digest {summary['digest']}")
+ return 0
+
+ baseline = load_baseline()
+ drift = compare(baseline, summary)
+ if not drift["digest_changed"] and not drift["elements"] and not drift["variants"]:
+ print(f"computed styles match the baseline (digest {summary['digest']})")
+ return 0
+ print(f"computed styles moved: baseline {baseline.get('digest')} -> {summary['digest']}")
+ print(_describe(drift))
+ return 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/odysseus-dev b/scripts/odysseus-dev
new file mode 100755
index 000000000..38cd59a3a
--- /dev/null
+++ b/scripts/odysseus-dev
@@ -0,0 +1,853 @@
+#!/usr/bin/env python3
+"""odysseus-dev — boot the checkout you are standing in, isolated from every other one.
+
+`start-macos.sh` is the single-instance launcher: it owns the Homebrew
+deps, the venv, and the production-shaped boot. It deliberately shares
+whatever is already listening — an open ChromaDB port is a resource it
+adopts. That is right for one instance and wrong for N worktrees, where
+adopting a port means writing into another checkout's vector store.
+
+This tool is the sibling that owns isolation instead:
+
+ - ports are derived from the worktree path, so two checkouts never
+ pick the same ones and the same checkout always picks its own;
+ - a ChromaDB we did not start is never adopted — we start our own on
+ our own port against our own data dir, or fall closed to keyword
+ mode and say so;
+ - the data dir, the database and the browser-MCP cache all live under
+ `.odysseus-dev/`, so a dev boot leaves `data/` — what a normal launch
+ of this checkout owns — untouched;
+ - readiness is `/api/ready` (database, writable data dir, storage
+ metadata), never a TCP accept and never `/api/health`, which is
+ liveness only;
+ - the app runs detached with durable logs and a recorded stop handle,
+ so closing the terminal does not decide the instance's lifetime.
+
+ odysseus dev up # boot this worktree, print URL + stop handle
+ odysseus dev up --from-pr 42 # fetch PR 42 into a worktree and boot that
+ odysseus dev status # what is running here (JSON)
+ odysseus dev down # stop what `up` started here
+ odysseus dev ports # the derived port set (JSON)
+ odysseus dev env # shell exports for running tests in this worktree
+
+Every subcommand acts on the checkout containing the current working
+directory, so a single copy on $PATH serves every worktree.
+"""
+from __future__ import annotations
+
+import os
+import sys
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), "_lib"))
+from cli import quiet_logs, emit, fail, common_parser, run # noqa: E402
+
+quiet_logs()
+
+import hashlib # noqa: E402
+import json # noqa: E402
+import signal # noqa: E402
+import socket # noqa: E402
+import subprocess # noqa: E402
+import time # noqa: E402
+import urllib.error # noqa: E402
+import urllib.request # noqa: E402
+from pathlib import Path # noqa: E402
+
+# Everything this tool writes lives under one directory inside the
+# worktree, next to but never inside `data/` — a dev boot must not be
+# able to corrupt the data dir a normal `start-macos.sh` run owns.
+DEV_DIR_NAME = ".odysseus-dev"
+STATE_FILE_NAME = "run.json"
+
+# Files that identify a checkout root, so `odysseus dev` from any
+# subdirectory finds the worktree it belongs to.
+ROOT_MARKERS = ("app.py", "setup.py", "requirements.txt")
+
+# Port block derivation. Three consecutive ports per worktree (app,
+# ChromaDB, test static server) starting at 7200; the last block ends at
+# 7799. The range is chosen to exclude every port the project already
+# means something by, so a derived port can never collide with a normal
+# launch on the same machine.
+PORT_BLOCK_BASE = 7200
+PORT_BLOCK_COUNT = 200
+PORTS_PER_BLOCK = 3
+
+# Ports this tool refuses to use even when asked explicitly, with the
+# reason each one is spoken for.
+RESERVED_PORTS = {
+ 7000: "the historical app default (and macOS AirPlay Receiver)",
+ 7011: "the app's own default bind and the compose APP_PORT",
+ 7860: "start-macos.sh's default, i.e. a normal launch of this app",
+ 8100: "the default CHROMADB_PORT, i.e. someone else's vector store",
+}
+
+READY_PATH = "/api/ready"
+HEALTH_PATH = "/api/health"
+LOGIN_PATH = "/api/auth/login"
+SESSION_COOKIE = "odysseus_session"
+DEFAULT_READY_TIMEOUT = 180
+STOP_GRACE_SECONDS = 10
+
+# `/api/ready` is not in app.py's AUTH_EXEMPT_EXACT set, so readiness is
+# only observable with a session. The launcher therefore owns the dev
+# admin account: it generates the password once, hands it to setup.py,
+# keeps it here, and prints it — otherwise a generated password scrolls
+# past on first boot and the instance is unusable afterwards.
+CREDENTIALS_FILE_NAME = "admin.json"
+VENV_FILE_NAME = "venv-path"
+DEV_ADMIN_USER = "admin"
+
+
+# --------------------------------------------------------------------------
+# Locating the worktree
+# --------------------------------------------------------------------------
+
+def find_repo_root(start):
+ """Return the checkout root at or above `start`, or None.
+
+ Resolved from the working directory rather than from this file, so a
+ symlink on $PATH still boots the worktree the user is standing in.
+ """
+ current = Path(start).resolve()
+ for candidate in [current, *current.parents]:
+ if all((candidate / marker).exists() for marker in ROOT_MARKERS):
+ return candidate
+ return None
+
+
+def dev_dir(root):
+ return Path(root) / DEV_DIR_NAME
+
+
+def state_path(root):
+ return dev_dir(root) / STATE_FILE_NAME
+
+
+# --------------------------------------------------------------------------
+# Ports
+# --------------------------------------------------------------------------
+
+def derive_ports(root):
+ """Map a worktree path to its own block of three ports.
+
+ Deterministic: the same checkout gets the same ports on every run, so
+ a bookmarked URL keeps working, and two checkouts only collide if
+ their paths hash into the same block — which `up` detects and refuses
+ rather than papers over.
+ """
+ digest = hashlib.blake2s(str(Path(root).resolve()).encode("utf-8"), digest_size=8).digest()
+ block = int.from_bytes(digest, "big") % PORT_BLOCK_COUNT
+ base = PORT_BLOCK_BASE + block * PORTS_PER_BLOCK
+ return {"app": base, "chroma": base + 1, "test_static": base + 2}
+
+
+def reserved_reason(port):
+ """Return why `port` is off limits, or None if it is usable."""
+ return RESERVED_PORTS.get(int(port))
+
+
+def port_bound(port, host="127.0.0.1", timeout=0.4):
+ """True if something already accepts connections on host:port."""
+ try:
+ with socket.create_connection((host, int(port)), timeout=timeout):
+ return True
+ except OSError:
+ return False
+
+
+def unused_port():
+ """Ask the OS for a free port and release it immediately.
+
+ Used only to point CHROMADB_PORT at something that will refuse the
+ connection, which is how the app falls back to keyword mode.
+ """
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
+ sock.bind(("127.0.0.1", 0))
+ return sock.getsockname()[1]
+
+
+# --------------------------------------------------------------------------
+# Refusing to boot where a real instance lives
+# --------------------------------------------------------------------------
+
+def service_unit_dirs():
+ home = Path.home()
+ if sys.platform == "darwin":
+ return [
+ home / "Library" / "LaunchAgents",
+ Path("/Library/LaunchAgents"),
+ Path("/Library/LaunchDaemons"),
+ ]
+ return [
+ home / ".config" / "systemd" / "user",
+ Path("/etc/systemd/system"),
+ Path("/usr/lib/systemd/system"),
+ ]
+
+
+def managed_by_service(root, unit_dirs=None):
+ """Return the unit file naming a path inside `root`, or None.
+
+ A checkout wired into launchd or systemd is somebody's running
+ instance: booting a second process out of it would share its source
+ tree and, on the first mistake, its data. We refuse rather than trust
+ the user to remember which directory this is. The match is on the
+ path, so a unit pointing anywhere inside the checkout counts.
+ """
+ needle = str(Path(root).resolve())
+ for directory in unit_dirs if unit_dirs is not None else service_unit_dirs():
+ try:
+ entries = sorted(Path(directory).iterdir())
+ except OSError:
+ continue
+ for entry in entries:
+ if entry.suffix not in (".plist", ".service"):
+ continue
+ try:
+ text = entry.read_text(errors="ignore")
+ except OSError:
+ continue
+ if needle in text:
+ return str(entry)
+ return None
+
+
+# --------------------------------------------------------------------------
+# Process ownership
+# --------------------------------------------------------------------------
+
+def pid_command(pid):
+ """Return the full command line of `pid`, or "" if it is not ours to see."""
+ try:
+ result = subprocess.run(
+ ["ps", "-o", "command=", "-p", str(int(pid))],
+ capture_output=True, text=True, timeout=5, check=False,
+ )
+ except (OSError, subprocess.SubprocessError, ValueError):
+ return ""
+ return result.stdout.strip() if result.returncode == 0 else ""
+
+
+def pid_is_ours(pid, fingerprints):
+ """True only when `pid` is alive AND its command line still shows every
+ fingerprint we recorded when we started it.
+
+ A pid alone proves nothing — the number is reused. Everything that
+ kills or adopts a process goes through here.
+ """
+ if not pid:
+ return False
+ command = pid_command(pid)
+ if not command:
+ return False
+ return all(str(mark) in command for mark in fingerprints)
+
+
+def pid_alive(pid):
+ try:
+ os.kill(int(pid), 0)
+ except (OSError, TypeError, ValueError):
+ return False
+ return True
+
+
+def credentials(root):
+ """Return this worktree's dev admin account, generating it once.
+
+ Stored outside the data dir so `down`, a wiped database, or a fresh
+ `up` all keep the same login.
+ """
+ path = dev_dir(root) / CREDENTIALS_FILE_NAME
+ try:
+ with open(path, encoding="utf-8") as handle:
+ return json.load(handle)
+ except (OSError, ValueError):
+ pass
+ import secrets
+
+ account = {"username": DEV_ADMIN_USER, "password": secrets.token_urlsafe(18)}
+ dev_dir(root).mkdir(parents=True, exist_ok=True)
+ with open(os.open(path, os.O_CREAT | os.O_WRONLY | os.O_TRUNC, 0o600), "w",
+ encoding="utf-8") as handle:
+ json.dump(account, handle, indent=2)
+ return account
+
+
+def read_state(root):
+ try:
+ with open(state_path(root), encoding="utf-8") as handle:
+ return json.load(handle)
+ except (OSError, ValueError):
+ return {}
+
+
+def write_state(root, state):
+ dev_dir(root).mkdir(parents=True, exist_ok=True)
+ with open(state_path(root), "w", encoding="utf-8") as handle:
+ json.dump(state, handle, indent=2)
+
+
+def running_app(state):
+ """Return the recorded app entry if that exact process is still alive."""
+ app = (state or {}).get("app") or {}
+ if pid_is_ours(app.get("pid"), app.get("fingerprints") or []):
+ return app
+ return None
+
+
+# --------------------------------------------------------------------------
+# ChromaDB
+# --------------------------------------------------------------------------
+
+def start_chroma(venv_python, port, chroma_path, log_path):
+ """Start our own ChromaDB, or explain why we are going without one.
+
+ Returns (entry_or_None, note). Adopting a foreign server is not one of
+ the outcomes: the caller has already established that the port is free.
+ """
+ binary = Path(venv_python).parent / "chroma"
+ if not binary.exists():
+ return None, (
+ "keyword-only mode: no `chroma` binary in this venv "
+ "(requirements.txt pins chromadb-client, the HTTP client). "
+ f"Install the server with `{venv_python} -m pip install chromadb` to enable vectors."
+ )
+ chroma_path.mkdir(parents=True, exist_ok=True)
+ command = [
+ str(binary), "run",
+ "--host", "127.0.0.1",
+ "--port", str(port),
+ "--path", str(chroma_path),
+ ]
+ with open(log_path, "ab") as log:
+ process = subprocess.Popen(
+ command, stdout=log, stderr=subprocess.STDOUT,
+ start_new_session=True, cwd=str(chroma_path.parent),
+ )
+ entry = {
+ "pid": process.pid,
+ "port": port,
+ "path": str(chroma_path),
+ "log": str(log_path),
+ # The data path is the identity: it is unique to this worktree
+ # and appears in the command line whichever way ps resolves the
+ # console script.
+ "fingerprints": ["chroma", str(chroma_path)],
+ }
+ return entry, f"own server on 127.0.0.1:{port} against {chroma_path}"
+
+
+# --------------------------------------------------------------------------
+# Readiness
+# --------------------------------------------------------------------------
+
+def http_json(port, path, payload=None, cookie=None, timeout=5.0):
+ """One request against the local instance. Returns (status, body)."""
+ url = f"http://127.0.0.1:{port}{path}"
+ data = json.dumps(payload).encode("utf-8") if payload is not None else None
+ headers = {"Content-Type": "application/json"} if data else {}
+ if cookie:
+ headers["Cookie"] = f"{SESSION_COOKIE}={cookie}"
+ request = urllib.request.Request(url, data=data, headers=headers)
+ try:
+ with urllib.request.urlopen(request, timeout=timeout) as response:
+ return response.status, _decode(response.read()), response
+ except urllib.error.HTTPError as exc:
+ return exc.code, _decode(exc.read()), exc
+ except (OSError, ValueError) as exc:
+ return 0, {"error": str(exc)}, None
+
+
+def _decode(raw):
+ try:
+ return json.loads(raw.decode("utf-8"))
+ except (ValueError, UnicodeDecodeError):
+ return {}
+
+
+def login(port, account):
+ """Return a session cookie for the dev admin, or None."""
+ status, _, response = http_json(port, LOGIN_PATH, payload={
+ "username": account["username"], "password": account["password"],
+ })
+ if status != 200 or response is None:
+ return None
+ for header in response.headers.get_all("Set-Cookie") or []:
+ if header.startswith(f"{SESSION_COOKIE}="):
+ return header.split(";", 1)[0].split("=", 1)[1]
+ return None
+
+
+def probe_ready(port, cookie=None, timeout=5.0):
+ """GET /api/ready once. Returns (ready, payload).
+
+ /api/health only proves the process is alive. /api/ready is the one
+ that checks the database, a writable data dir and storage metadata,
+ and it answers 503 until all three hold — which is why a TCP accept
+ is not what this tool waits for.
+ """
+ status, body, _ = http_json(port, READY_PATH, cookie=cookie, timeout=timeout)
+ body = dict(body or {})
+ body.setdefault("status", status)
+ return bool(body.get("ready")), body
+
+
+def wait_ready(port, process, timeout, log_path, account):
+ """Wait for liveness, authenticate, then wait for real readiness."""
+ deadline = time.monotonic() + timeout
+
+ def alive():
+ if process is not None and process.poll() is not None:
+ fail(
+ f"the app exited with code {process.returncode} before becoming ready.\n"
+ f" last lines of {log_path}:\n{tail(log_path, 20)}"
+ )
+
+ while time.monotonic() < deadline:
+ alive()
+ if http_json(port, HEALTH_PATH, timeout=2.0)[0] == 200:
+ break
+ time.sleep(1)
+
+ # One login, not one per poll: the login route is rate limited.
+ cookie, last = None, {}
+ while time.monotonic() < deadline and cookie is None:
+ alive()
+ cookie = login(port, account)
+ if cookie is None:
+ time.sleep(3)
+ if cookie is None:
+ fail(
+ f"could not log in as {account['username']} to read {READY_PATH}.\n"
+ f" The recorded credentials may not match this data dir. Remove "
+ f"{Path(log_path).parent.parent / CREDENTIALS_FILE_NAME} and the data dir "
+ f"to start clean.\n"
+ f" The app is running; stop it with `odysseus dev down`."
+ )
+
+ while time.monotonic() < deadline:
+ alive()
+ ready, last = probe_ready(port, cookie=cookie)
+ if ready:
+ return last
+ time.sleep(1)
+ fail(
+ f"{READY_PATH} did not report ready within {timeout}s.\n"
+ f" last response: {json.dumps(last, default=str)[:400]}\n"
+ f" the app is still running; logs: {log_path}\n"
+ f" stop it with `odysseus dev down`"
+ )
+
+
+def tail(path, lines):
+ try:
+ with open(path, encoding="utf-8", errors="replace") as handle:
+ return "".join(f" {line}" for line in handle.readlines()[-lines:])
+ except OSError:
+ return " (no log)"
+
+
+# --------------------------------------------------------------------------
+# git helpers for --from-pr
+# --------------------------------------------------------------------------
+
+def git(root, *args, check=True):
+ result = subprocess.run(
+ ["git", "-C", str(root), *args],
+ capture_output=True, text=True, check=False,
+ )
+ if check and result.returncode != 0:
+ fail(f"git {' '.join(args)} failed: {result.stderr.strip()}")
+ return result.stdout.strip()
+
+
+def worktree_for_pr(root, number, remote):
+ """Fetch a pull request head into its own worktree and return its path.
+
+ `pull//head` is served by the repository the PR targets, so this
+ works for forks without knowing anything about the fork layout.
+ """
+ target = Path(root).resolve().parent / f"{Path(root).resolve().name}-pr{number}"
+ if target.exists():
+ sys.stdout.write(f" worktree for PR {number} already exists at {target}\n")
+ return target
+ git(root, "fetch", remote, f"pull/{number}/head")
+ head = git(root, "rev-parse", "FETCH_HEAD")
+ git(root, "worktree", "add", "--detach", str(target), head)
+ sys.stdout.write(f" PR {number} checked out at {target} ({head[:8]})\n")
+ return target
+
+
+# --------------------------------------------------------------------------
+# Commands
+# --------------------------------------------------------------------------
+
+def resolve_root(args):
+ root = find_repo_root(Path.cwd())
+ if root is None:
+ fail(
+ "not inside an Odysseus checkout "
+ f"(looked for {', '.join(ROOT_MARKERS)} from {Path.cwd()} upwards)",
+ code=2,
+ )
+ return root
+
+
+def resolve_ports(root, args):
+ ports = derive_ports(root)
+ for name, override in (("app", getattr(args, "port", None)),
+ ("chroma", getattr(args, "chroma_port", None))):
+ if override:
+ ports[name] = int(override)
+ for name, port in ports.items():
+ reason = reserved_reason(port)
+ if reason:
+ fail(f"port {port} is {reason}; refusing to use it as the {name} port")
+ return ports
+
+
+def remembered_venv(root):
+ """The venv a previous `up` borrowed for this worktree, if any."""
+ try:
+ return Path(dev_dir(root).joinpath(VENV_FILE_NAME).read_text(encoding="utf-8").strip())
+ except OSError:
+ return None
+
+
+def remember_venv(root, venv_root):
+ dev_dir(root).mkdir(parents=True, exist_ok=True)
+ dev_dir(root).joinpath(VENV_FILE_NAME).write_text(str(venv_root), encoding="utf-8")
+
+
+def resolve_venv(root, args):
+ """Pick the interpreter to run the app with. This tool never builds a
+ venv — `--venv` pointing at a sibling worktree's environment is what
+ makes booting a PR take seconds rather than minutes, and the choice is
+ remembered so the next `up` in that worktree does not need the flag."""
+ candidates = [
+ Path(args.venv).expanduser().resolve() if args.venv else None,
+ Path(root) / "venv",
+ remembered_venv(root),
+ ]
+ for candidate in candidates:
+ if candidate and (candidate / "bin" / "python").exists():
+ return candidate / "bin" / "python"
+ if args.venv:
+ fail(f"no interpreter at {Path(args.venv).expanduser().resolve() / 'bin' / 'python'}")
+ fail(
+ f"no venv at {Path(root) / 'venv'}.\n"
+ f" build one with ./start-macos.sh, or reuse another worktree's "
+ f"with --venv /path/to/worktree/venv"
+ )
+
+
+def refuse_if_taken(root, args, ports, state):
+ """Stop before anything is started if this worktree cannot own the boot."""
+ unit = managed_by_service(root)
+ if unit:
+ fail(
+ f"{root} is run as a service by {unit}.\n"
+ f" That is a real instance, not a scratch worktree. Boot a separate "
+ f"checkout instead:\n"
+ f" git worktree add ../odysseus-dev [ && cd ../odysseus-dev"
+ )
+ if port_bound(ports["app"]):
+ fail(
+ f"port {ports['app']} is already in use by a process we do not own.\n"
+ f" This worktree derives that port from its path, so something else "
+ f"took it.\n"
+ f" Re-run with --port to pick another."
+ )
+
+
+def resolve_chroma(args, state, ports, venv_python, data_dir, log_dir):
+ """Decide what this worktree talks to for vectors.
+
+ Returns (state_entry, port, note). The one outcome this never
+ produces is a port somebody else is serving: the whole tool exists
+ because `start-macos.sh` treats that as a resource to adopt.
+ """
+ if args.no_chroma:
+ # Point at a port nothing is listening on rather than at the
+ # derived one, which may be exactly the foreign server we are
+ # refusing to touch. Connection refused is what makes the app
+ # fall back to keyword search.
+ return None, unused_port(), "disabled by --no-chroma"
+
+ if port_bound(ports["chroma"]):
+ ours = (state or {}).get("chroma") or {}
+ if pid_is_ours(ours.get("pid"), ours.get("fingerprints") or []):
+ return ours, ours["port"], f"reusing the server we started earlier on {ours['port']}"
+ fail(
+ f"port {ports['chroma']} is serving a ChromaDB this worktree did not start.\n"
+ f" Adopting it would read and write another checkout's vectors, so we "
+ f"will not.\n"
+ f" Re-run with --chroma-port , or with --no-chroma to run in "
+ f"keyword-only mode."
+ )
+
+ entry, note = start_chroma(
+ venv_python, ports["chroma"], data_dir / "chroma", log_dir / "chroma.log"
+ )
+ # If we could not start one, point the app at a port nothing is on
+ # rather than at our derived one: otherwise a ChromaDB that binds
+ # that port later would be adopted by a running app, which is the
+ # exact failure this tool exists to prevent.
+ return entry, (ports["chroma"] if entry else unused_port()), note
+
+
+def boot_environment(account, ports, chroma_port, data_dir):
+ """The environment that makes the child process this worktree's own."""
+ env = dict(os.environ)
+ env.update({
+ "ODYSSEUS_ADMIN_USER": account["username"],
+ "ODYSSEUS_ADMIN_PASSWORD": account["password"],
+ "APP_PORT": str(ports["app"]),
+ "APP_BIND": "127.0.0.1",
+ "ODYSSEUS_DATA_DIR": str(data_dir),
+ "DATABASE_URL": f"sqlite:///{data_dir / 'app.db'}",
+ # src/builtin_mcp.py derives this cache from a literal "data"
+ # under the app root rather than from DATA_DIR, so without an
+ # explicit value a dev boot would write into the checkout's
+ # data/ after all. Pointing it at our own dir keeps the
+ # isolation claim true.
+ "ODYSSEUS_BROWSER_MCP_CACHE": str(data_dir / "playwright-mcp-cache"),
+ "CHROMADB_HOST": "127.0.0.1",
+ "CHROMADB_PORT": str(chroma_port),
+ "ODYSSEUS_TEST_STATIC_PORT": str(ports["test_static"]),
+ "ODYSSEUS_NO_OPEN": "1",
+ "ODYSSEUS_SKIP_RUN_HINT": "1",
+ "ODYSSEUS_SKIP_ADMIN_PROMPT": "1",
+ })
+ return env
+
+
+def run_setup(root, venv_python, env, data_dir, log_dir):
+ """Create the data dir, database and admin account. Idempotent."""
+ sys.stdout.write(f" preparing {data_dir} (setup.py is idempotent)\n")
+ setup = subprocess.run(
+ [str(venv_python), "setup.py"], cwd=str(root), env=env,
+ capture_output=True, text=True, stdin=subprocess.DEVNULL, check=False,
+ )
+ log = log_dir / "setup.log"
+ with open(log, "w", encoding="utf-8") as handle:
+ handle.write(setup.stdout + setup.stderr)
+ if setup.returncode != 0:
+ fail(f"setup.py failed; see {log}\n{tail(log, 15)}")
+
+
+def borrow_venv_for_pr(root, args):
+ """A fresh PR worktree has no venv; the one we came from will do."""
+ if args.venv or (root / "venv" / "bin" / "python").exists():
+ return
+ source_venv = find_repo_root(Path.cwd()) / "venv"
+ if (source_venv / "bin" / "python").exists():
+ args.venv = str(source_venv)
+ sys.stdout.write(f" reusing {source_venv} (the PR worktree has none)\n")
+
+
+def cmd_up(args):
+ root = resolve_root(args)
+ if args.from_pr:
+ root = worktree_for_pr(root, args.from_pr, args.remote)
+ borrow_venv_for_pr(root, args)
+
+ state = read_state(root)
+ already = running_app(state)
+ if already:
+ sys.stdout.write(
+ f"already up: http://127.0.0.1:{already['port']} (pid {already['pid']})\n"
+ f"stop it with `odysseus dev down`, or re-run after that to restart.\n"
+ )
+ return
+
+ ports = resolve_ports(root, args)
+ refuse_if_taken(root, args, ports, state)
+ venv_python = resolve_venv(root, args)
+ remember_venv(root, venv_python.parent.parent)
+
+ data_dir = dev_dir(root) / "data"
+ log_dir = dev_dir(root) / "logs"
+ data_dir.mkdir(parents=True, exist_ok=True)
+ log_dir.mkdir(parents=True, exist_ok=True)
+ app_log = log_dir / "app.log"
+
+ chroma_entry, chroma_port, chroma_note = resolve_chroma(
+ args, state, ports, venv_python, data_dir, log_dir
+ )
+ account = credentials(root)
+ env = boot_environment(account, ports, chroma_port, data_dir)
+ run_setup(root, venv_python, env, data_dir, log_dir)
+
+ command = [
+ str(venv_python), "-m", "uvicorn", "app:app",
+ "--host", "127.0.0.1", "--port", str(ports["app"]),
+ ]
+ if args.foreground:
+ sys.stdout.write(f" starting in the foreground on http://127.0.0.1:{ports['app']}\n")
+ os.execve(str(venv_python), command, env)
+
+ with open(app_log, "ab") as log:
+ process = subprocess.Popen(
+ command, cwd=str(root), env=env, stdout=log, stderr=subprocess.STDOUT,
+ stdin=subprocess.DEVNULL, start_new_session=True,
+ )
+
+ state = {
+ "root": str(root),
+ "started_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
+ "commit": git(root, "rev-parse", "--short", "HEAD", check=False),
+ "branch": git(root, "rev-parse", "--abbrev-ref", "HEAD", check=False),
+ "venv": str(Path(venv_python).parent.parent),
+ "data_dir": str(data_dir),
+ "ports": ports,
+ "app": {
+ "pid": process.pid,
+ "port": ports["app"],
+ "log": str(app_log),
+ # The interpreter path is not one of these on purpose: macOS
+ # reports the framework binary a venv symlinks to, not the
+ # venv path we launched. The port is derived per worktree, so
+ # it is the part that actually identifies this instance.
+ "fingerprints": ["uvicorn", "app:app", f"--port {ports['app']}"],
+ },
+ "chroma": chroma_entry,
+ }
+ write_state(root, state)
+
+ sys.stdout.write(f" waiting for {READY_PATH} (up to {args.timeout}s)\n")
+ report = wait_ready(ports["app"], process, args.timeout, app_log, account)
+
+ sys.stdout.write(
+ f"\nOdysseus is up — this worktree only.\n\n"
+ f" URL http://127.0.0.1:{ports['app']}\n"
+ f" Login {account['username']} / {account['password']}\n"
+ f" Worktree {root} ({state['branch']} @ {state['commit']})\n"
+ f" Data dir {data_dir}\n"
+ f" ChromaDB {chroma_note}\n"
+ f" Test port {ports['test_static']} (ODYSSEUS_TEST_STATIC_PORT; see `odysseus dev env`)\n"
+ f" Logs {app_log}\n"
+ f" Ready {json.dumps({k: v.get('ok') for k, v in report.get('checks', {}).items()})}\n"
+ f" Stop with odysseus dev down\n"
+ )
+
+
+def cmd_down(args):
+ root = resolve_root(args)
+ state = read_state(root)
+ stopped, unclaimed = [], []
+ for name in ("app", "chroma"):
+ entry = (state or {}).get(name) or {}
+ pid = entry.get("pid")
+ if not pid_is_ours(pid, entry.get("fingerprints") or []):
+ if pid_alive(pid):
+ # Alive but no longer recognisable: signalling it would be
+ # signalling a stranger. Say so and keep the record.
+ unclaimed.append(f"{name} (pid {pid})")
+ continue
+ os.kill(pid, signal.SIGTERM)
+ deadline = time.monotonic() + STOP_GRACE_SECONDS
+ while time.monotonic() < deadline and pid_is_ours(pid, entry.get("fingerprints") or []):
+ time.sleep(0.2)
+ if pid_is_ours(pid, entry.get("fingerprints") or []):
+ os.kill(pid, signal.SIGKILL)
+ stopped.append(f"{name} (pid {pid})")
+ if stopped:
+ sys.stdout.write(f"stopped {', '.join(stopped)}.\n")
+ elif not unclaimed:
+ sys.stdout.write("nothing this worktree started is still running.\n")
+ if unclaimed:
+ sys.stdout.write(
+ f"left alone: {', '.join(unclaimed)} — still alive but no longer matching "
+ f"what we recorded. Check it before killing it; {state_path(root)} is kept.\n"
+ )
+ return
+ try:
+ state_path(root).unlink()
+ except OSError:
+ pass
+
+
+def cmd_status(args):
+ root = resolve_root(args)
+ state = read_state(root)
+ app = running_app(state)
+ chroma = (state or {}).get("chroma") or {}
+ ready = False
+ if app:
+ ready = probe_ready(app["port"], cookie=login(app["port"], credentials(root)))[0]
+ emit({
+ "root": str(root),
+ "running": bool(app),
+ "url": f"http://127.0.0.1:{app['port']}" if app else None,
+ "ready": ready,
+ "chroma_running": pid_is_ours(chroma.get("pid"), chroma.get("fingerprints") or []),
+ "ports": (state or {}).get("ports") or derive_ports(root),
+ "state_file": str(state_path(root)),
+ }, args)
+
+
+def cmd_ports(args):
+ root = resolve_root(args)
+ ports = derive_ports(root)
+ emit({
+ "root": str(root),
+ "ports": ports,
+ "in_use": {name: port_bound(port) for name, port in ports.items()},
+ }, args)
+
+
+def cmd_env(args):
+ """Print the isolated environment as shell exports, so a test run in
+ this worktree uses the same ports and data dir the app does."""
+ root = resolve_root(args)
+ ports = derive_ports(root)
+ data = dev_dir(root) / "data"
+ for key, value in (
+ ("APP_PORT", ports["app"]),
+ ("ODYSSEUS_TEST_STATIC_PORT", ports["test_static"]),
+ ("CHROMADB_PORT", ports["chroma"]),
+ ("ODYSSEUS_DATA_DIR", data),
+ ("DATABASE_URL", f"sqlite:///{data / 'app.db'}"),
+ ):
+ sys.stdout.write(f"export {key}={value}\n")
+
+
+def build_parser():
+ parser = common_parser("odysseus-dev", "Boot this worktree in isolation.")
+ common = parser._common_parents[0]
+ sub = parser.add_subparsers(dest="cmd")
+
+ up = sub.add_parser("up", parents=[common], help="boot this worktree")
+ up.add_argument("--port", type=int, help="override the derived app port")
+ up.add_argument("--chroma-port", type=int, help="override the derived ChromaDB port")
+ up.add_argument("--no-chroma", action="store_true",
+ help="run without vectors (keyword mode) instead of starting a server")
+ up.add_argument("--venv", help="use this venv instead of ./venv (e.g. a sibling worktree's)")
+ up.add_argument("--from-pr", type=int, metavar="N",
+ help="fetch pull request N into its own worktree and boot that")
+ up.add_argument("--remote", default="origin", help="remote to fetch the PR from")
+ up.add_argument("--foreground", action="store_true",
+ help="run uvicorn in this terminal instead of detaching")
+ up.add_argument("--timeout", type=int, default=DEFAULT_READY_TIMEOUT,
+ help=f"seconds to wait for {READY_PATH} (default: {DEFAULT_READY_TIMEOUT})")
+ up.set_defaults(func=cmd_up)
+
+ down = sub.add_parser("down", parents=[common], help="stop what `up` started here")
+ down.set_defaults(func=cmd_down)
+
+ status = sub.add_parser("status", parents=[common], help="what is running in this worktree")
+ status.set_defaults(func=cmd_status)
+
+ ports = sub.add_parser("ports", parents=[common], help="the derived port set")
+ ports.set_defaults(func=cmd_ports)
+
+ env = sub.add_parser("env", parents=[common], help="shell exports for this worktree")
+ env.set_defaults(func=cmd_env)
+
+ parser.set_defaults(func=lambda args: parser.print_help())
+ return parser
+
+
+if __name__ == "__main__":
+ sys.exit(run(build_parser()))
diff --git a/specs/frontend.md b/specs/frontend.md
index 4bd58d490..cd3eea540 100644
--- a/specs/frontend.md
+++ b/specs/frontend.md
@@ -136,6 +136,8 @@ The Settings finder and navigation are registry-backed, hide admin-only destinat
Existing frontend coverage is a mix of Node-executed helper tests, `.mjs` tests, static DOM/CSS/source-shape tests, browser exploration specs, and app/static tests. Many tests are useful source-shape regressions but do not replace browser/module-graph execution.
+`tests/test_css_computed_style_snapshot.py` pins `getComputedStyle` for a fixed element inventory across pages, viewports, themes and density modes, so a `static/style.css` restructuring that changes which declaration wins fails a test instead of shipping; see `tests/css_snapshot/README.md` for what it does and does not cover.
+
Recent focused coverage includes model-key matching under Node, document-library counters, chat resend/delete/mobile Enter/ArrowUp, scoped approval continuation and compare routing, route provenance, live-thinking throttling, startup shell/history hydration, shared app-config caching/invalidation, settings registry/navigation/finder/lifecycle, lazy panel loading/offline editor precache, vendored lazy KaTeX/Mermaid rendering, email read dedup/prewarm, Markdown restoration, malformed keybinds, currency-safe inline math, notes/calendar/modal/manifest/admin-log behavior, Markdown XSS helpers, and CardDAV unchanged-password handling.
Missing coverage includes:
@@ -143,7 +145,7 @@ Missing coverage includes:
- SPA route/static auth and no-cache headers;
- CSP header contents and nonce injection for `/` and `/login`;
- service-worker API/non-GET bypass and cache strategy;
-- service-worker precache versus `index.html` script/module tags, including query strings;
+- service-worker precache versus `index.html` script/module tags, including query strings (stylesheet links and their `?v=` strings are covered by `tests/test_static_stylesheet_manifest.py`; script and module tags are not);
- ongoing manifest/icon reference drift;
- module graph/load-order validation;
- degraded vendor-library/browser API behavior, including Pyodide's remaining CDN path.
@@ -151,8 +153,8 @@ Missing coverage includes:
## Current Gaps
- `static/style.css` and large coordinators remain high-risk owners: `static/js/document.js`, `static/js/settings.js`, `static/js/chat.js`, and `static/app.js`.
-- There is no build-time type checking, module graph validation, script-order validation, or service-worker precache validation.
+- There is no build-time type checking, module graph validation, or script-order validation. Service-worker precache validation exists for stylesheets only.
- Frontend state is mostly module/global/localStorage driven, so cross-session and cross-user behavior needs explicit care.
- `window.*` compatibility bridges remain widespread.
- PWA/static-serving behavior may deserve a separate spec if service worker, manifests, route-specific icons, and cache policy keep growing.
-- A static asset/route manifest regression should verify files referenced by `index.html`, `manifest.json`, `sw.js`, and app-owned HTML routes actually exist.
+- The static asset/route manifest regression covers stylesheets referenced by app-owned HTML and `sw.js`; scripts, modules and `manifest.json` icon references are still unverified.
diff --git a/src/agent_tools/web_tools.py b/src/agent_tools/web_tools.py
index 4bd36d96d..84bce8d5c 100644
--- a/src/agent_tools/web_tools.py
+++ b/src/agent_tools/web_tools.py
@@ -119,6 +119,12 @@ def _browser_pid_file_candidates(
)
return list(dict.fromkeys(candidates))
+
+# Linux exposes one command line per pid under /proc; macOS and Windows do not.
+# Kept as a module attribute so the procfs-dependent paths stay testable on a
+# host that has no procfs, and on one that does.
+_PROC_ROOT = Path("/proc")
+
_SCHOLARLY_METADATA_CUE_RE = re.compile(
r"\b(?:accept(?:ed|ance)?|publish(?:ed|ing|cation)?|venue|conference|"
r"journal|proceedings|doi)\b",
@@ -2316,8 +2322,14 @@ class PrivateBrowserTool:
except OSError:
return
profile_prefix = str(tmpdir / "agent-browser-chrome-")
+ if not _PROC_ROOT.is_dir():
+ # Without procfs there is no way to match a reparented Chrome by
+ # its command line, and the sweep is an optimisation rather than a
+ # correctness requirement. Leave those trees to the daemon's own
+ # lifecycle instead of failing the whole shutdown path.
+ return
pids: list[int] = []
- for entry in Path("/proc").iterdir():
+ for entry in _PROC_ROOT.iterdir():
if not entry.name.isdigit():
continue
try:
diff --git a/static/css/cookbook-research-memory-settings.css b/static/css/cookbook-research-memory-settings.css
new file mode 100644
index 000000000..3bcbb08cd
--- /dev/null
+++ b/static/css/cookbook-research-memory-settings.css
@@ -0,0 +1,1423 @@
+/* Cookbook (including What Fits?), deep research, memory, settings and admin.
+ *
+ * Split out of style.css for maintainability, not for performance: this file
+ * is loaded eagerly from index.html immediately after style.css, so the rules
+ * land in the cascade in exactly the order they had inside the single file.
+ * Nothing here is lazy-loaded.
+ *
+ * LOAD ORDER IS PART OF THE CONTRACT. The tags in static/index.html and
+ * the PRECACHE list in static/sw.js must both list the stylesheets in this
+ * order:
+ * 1. static/style.css
+ * 2. static/css/documents-gallery-editor.css
+ * 3. static/css/email-calendar-notes-tasks.css
+ * 4. static/css/cookbook-research-memory-settings.css
+ *
+ * Rules were moved verbatim. A rule stays in style.css when its selector group
+ * covers more than one panel, when the class is shared design language used by
+ * modules outside this panel, or when moving it would change which of two
+ * equally specific rules wins.
+ */
+
+/* Cookbook already shows its own running/served-status dot
+ (#cookbook-notif-dot, toggled with .cookbook-notif-active on the
+ button). Don't stack the tabbed-down pulse on top of it — the two
+ dots overlap. Suppress the minimized dot while the status dot is up. */
+.list-item.rail-minimized.cookbook-notif-active::after { display: none; }
+@keyframes research-done-pulse {
+ 0%, 100% { transform: scale(1); opacity: 1; }
+ 50% { transform: scale(1.5); opacity: 0.7; }
+ }
+@keyframes research-pulse {
+ 0%, 100% { background: color-mix(in srgb, var(--red) 12%, transparent); }
+ 50% { background: color-mix(in srgb, var(--red) 22%, transparent); }
+ }
+#cookbook-gguf-delete-overlay {
+ background:rgba(0,0,0,0.5);
+ backdrop-filter:blur(4px);
+ pointer-events:auto !important;
+ z-index:99999 !important;
+ position:fixed !important;
+ inset:0 !important;
+ }
+.cookbook-gguf-delete-actions {
+ flex-wrap:wrap;
+ }
+/* ── Processing pulse animation (reused by session-star) ── */
+@keyframes research-pulse {
+ 0%, 100% { opacity: 0.3; transform: scale(0.8); }
+ 50% { opacity: 1; transform: scale(1.2); }
+}
+@keyframes memory-synapse-pulse {
+ 0%, 100% { opacity: 0.35; transform: scale(1); }
+ 50% { opacity: 0.65; transform: scale(1.02); }
+}
+.memory-add-input::placeholder {
+ color: color-mix(in srgb, var(--fg) 40%, transparent);
+}
+.memory-toolbar-toggle:has(input:not(:checked)) > span {
+ text-decoration: line-through;
+ text-decoration-color: color-mix(in srgb, var(--fg) 30%, transparent);
+}
+.memory-sort-label { white-space: nowrap; }
+.memory-sort-btn[aria-expanded="true"] .memory-sort-caret { transform: rotate(180deg); }
+@keyframes memory-synapse-sweep {
+ /* Sweep traverses left → right in the first ~12% of the cycle (≈0.7s of
+ a 6.2s loop), then waits offscreen. */
+ 0% { --sweep: -20%; }
+ 12% { --sweep: 120%; }
+ 13%, 100% { --sweep: 120%; }
+}
+.memory-item-edit-input:focus {
+ outline: none;
+}
+.memory-edit-cat-select:focus {
+ outline: none;
+}
+.memory-add-row .memory-edit-cat-select:focus {
+ border-color: var(--red);
+}
+.memory-item-source::before,
+.memory-item-time::before {
+ content: '\00b7 ';
+}
+.adm-check-dot::after {
+ content: '';
+ position: absolute;
+ left: 50%;
+ top: 45%;
+ width: 5px;
+ height: 2.5px;
+ border-left: 1.5px solid #fff;
+ border-bottom: 1.5px solid #fff;
+ transform: translate(-50%, -50%) rotate(-45deg) scale(0);
+ transform-origin: center;
+ transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
+}
+.adm-model-row:active .adm-check-dot {
+ transform: scale(0.9);
+}
+.adm-cb-hidden:checked + .adm-check-dot::after {
+ transform: translate(-50%, -50%) rotate(-45deg) scale(1);
+}
+.adm-cb-hidden:checked + .adm-endpoint-select-dot::after {
+ display: none;
+}
+/* Most recently added endpoint — brief accent glow so the user can
+ spot the new row immediately after Adding / Find. Fades out cleanly. */
+@keyframes adm-ep-just-added-glow {
+ 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent, var(--red)) 55%, transparent); background: color-mix(in srgb, var(--accent, var(--red)) 18%, transparent); }
+ 60% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--accent, var(--red)) 0%, transparent); background: color-mix(in srgb, var(--accent, var(--red)) 8%, transparent); }
+ 100% { box-shadow: 0 0 0 0 transparent; background: transparent; }
+}
+.adm-section-toggle:focus-visible { outline: 1px solid var(--red); outline-offset: 1px; }
+/* Collapsed: hide the form body and point the caret right. */
+.adm-add-section.collapsed .admin-model-form { display: none; }
+.adm-add-section.collapsed .adm-section-caret { transform: rotate(-90deg); }
+.adm-quickstart-section {
+ margin-top: 7px;
+}
+.adm-quickstart-section:not(.collapsed) .adm-quickstart-toggle {
+ border-bottom-left-radius: 0;
+ border-bottom-right-radius: 0;
+}
+.adm-quickstart-section.collapsed .adm-quickstart-body { display: none; }
+.adm-quickstart-section.collapsed .adm-section-caret { transform: rotate(-90deg); }
+.adm-provider-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+@keyframes admin-spin {
+ to { transform: rotate(360deg); }
+}
+.admin-rag-item .admin-btn-delete { font-size: 10px; padding: 2px 6px; }
+/* Cookbook's cached-model list: NO inner-scroll cap. Two nested scroll
+ surfaces (this + the outer .admin-card) trapped the wheel so an expanded
+ serve panel couldn't be reached on tall content. Let the outer
+ .admin-card (overflow-y:auto) be the single scroll surface. */
+.hwfit-cached-list {
+ max-height: none !important;
+ overflow-y: visible !important;
+}
+/* The Servers list reuses .memory-toolbar for layout but must grow with every
+ added server — the 120px cap above was clipping manually-added servers. */
+.memory-toolbar.cookbook-servers-toolbar {
+ max-height: none;
+ overflow: visible;
+}
+#hwfit-cached-list {
+ position: relative;
+ top: -6px;
+}
+#hwfit-server-select,
+#hwfit-search {
+ position: relative;
+ top: -2px !important;
+}
+#hwfit-search {
+ height: 30px;
+ min-height: 30px;
+ font-family: inherit;
+ font-size: 11px;
+ background-image: url("data:image/svg+xml;utf8,");
+ background-repeat: no-repeat;
+ background-position: 9px center;
+ padding-left: 28px;
+}
+.cookbook-body::-webkit-scrollbar {
+ width: 4px;
+}
+.cookbook-body::-webkit-scrollbar-thumb {
+ background: color-mix(in srgb, var(--fg) 15%, transparent);
+ border-radius: 4px;
+}
+.cookbook-body::-webkit-scrollbar-track {
+ background: transparent;
+ padding: 4px 0;
+}
+#hwfit-dl-server.cookbook-server-select-colored,
+#hwfit-server-select.cookbook-server-select-colored,
+#hwfit-cache-server.cookbook-server-select-colored,
+#hwfit-deps-server.cookbook-server-select-colored {
+ color: var(--cookbook-server-color, var(--fg));
+ border-color: color-mix(in srgb, var(--cookbook-server-color) 55%, var(--border));
+ background-color: color-mix(in srgb, var(--cookbook-server-color) 12%, var(--bg));
+}
+.cookbook-output-wrap:hover .cookbook-output-kill { opacity: 0.7; }
+.cookbook-tab-error-dot { --notif-glow: var(--color-error, #f44); }
+/* Brief highlight on the matched task card when jumping from the
+ running pill, so the user can spot it among a long list. */
+.cookbook-task-flash {
+ animation: cookbook-task-flash-anim 1.6s ease-out;
+}
+@keyframes cookbook-task-flash-anim {
+ 0% { box-shadow: 0 0 0 2px var(--accent, var(--red)); }
+ 100% { box-shadow: 0 0 0 2px transparent; }
+}
+/* Cookbook header "downloading" status label sits 2px too far left
+ against the rest of the cookbook chrome — nudge it right. */
+#cookbook-bg-status {
+ left: 2px;
+}
+/* Keep both GPU action labels on one line — they wrapped to two rows on
+ mobile, which looked broken. */
+.cookbook-gpu-probe, .cookbook-gpu-clear { white-space: nowrap; }
+#cookbook-hf-latest-list .cookbook-hf-link:hover {
+ opacity: 1;
+}
+#cookbook-hf-latest-list {
+ min-height: 0;
+ max-height: min(52vh, 480px);
+ overflow-y: auto;
+ overflow-x: hidden;
+ overscroll-behavior: contain;
+ scrollbar-width: thin;
+}
+.cookbook-section-header .cookbook-section-title {
+ flex: 1;
+}
+.cookbook-section-header:hover .cookbook-section-chevron { opacity: 1; }
+.cookbook-slot-wrap:hover .cookbook-slot-del { opacity: 1; }
+/* Keep dependency pills visually centered against the package description.
+ The row itself stays stable; only the controls move down slightly. */
+.cookbook-dep-row > .cookbook-dep-tag {
+ transform: translateY(2px);
+}
+/* Conditional line under the Download h2: only when the section is folded
+ (collapsed). When expanded, the body content provides separation; the
+ underline reads as clutter. */
+#cookbook-dl-tab-fold { border-bottom: none !important; padding-bottom: 0 !important; }
+#cookbook-dl-tab-fold.is-folded {
+ border-bottom: 1px solid color-mix(in srgb, var(--border) 40%, transparent) !important;
+ padding-bottom: 6px !important;
+}
+/* Smooth open/close for the Direct Download body — replaces the
+ instant display:none toggle with a max-height + opacity slide so
+ auto-fold and auto-expand don't feel jarring. 1200px is a safe
+ upper bound for the body height; the slide only fires up to the
+ actual content height because content stops there. */
+#cookbook-dl-tab-fold-body {
+ overflow: hidden;
+ max-height: 1200px;
+ opacity: 1;
+ transition: max-height 0.28s ease, opacity 0.18s ease, margin 0.28s ease;
+}
+#cookbook-dl-tab-fold-body.is-folded {
+ max-height: 0;
+ opacity: 0;
+ margin-top: 0;
+ margin-bottom: 0;
+ pointer-events: none;
+}
+.cookbook-dep-installed-btn .cookbook-dep-installed-label { padding: 0 8px; }
+.cookbook-dep-installed-btn:hover { filter: brightness(1.15); }
+.hwfit-serve-topline .hwfit-serve-runtime-note {
+ flex: 1 1 auto;
+ min-width: 0;
+}
+/* Expanded serve panel — make sure it can be scrolled past when it
+ grows taller than the visible viewport. Caps panel height to viewport
+ minus chrome and gives it its own internal scroll, so the surrounding
+ cached-list scroll isn't the only escape route. */
+.hwfit-cached-item .hwfit-serve-panel {
+ max-height: calc(100vh - 180px);
+ overflow-y: auto;
+ overscroll-behavior: contain;
+}
+@media (max-width: 768px) {
+ .hwfit-cached-item .hwfit-serve-panel {
+ max-height: calc(100svh - 120px);
+ }
+ .hwfit-serve-preset-row {
+ justify-content: flex-end;
+ margin: -2px 0 0;
+ }
+ .hwfit-serve-topline {
+ flex-wrap: wrap;
+ }
+ .hwfit-serve-topline .hwfit-serve-runtime-note {
+ flex-basis: 100%;
+ }
+ .hwfit-serve-topline .hwfit-serve-runtime-text {
+ white-space: normal;
+ }
+}
+.hwfit-serve-row label select,
+.hwfit-serve-row label input {
+ display: block;
+ margin-top: 2px;
+}
+.hwfit-extra-env-label {
+ grid-column: 1 / -1;
+}
+.hwfit-serve-panel[data-backend-active="vllm"] .hwfit-extra-env-label {
+ grid-column: 2 / -1;
+}
+.hwfit-sf.hwfit-sf-full { grid-column: 1 / -1; }
+.hwfit-spec-tokens-bare::-webkit-outer-spin-button,
+.hwfit-spec-tokens-bare::-webkit-inner-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+ display: none;
+}
+.hwfit-numstep .hwfit-spec-tokens::-webkit-outer-spin-button,
+.hwfit-numstep .hwfit-spec-tokens::-webkit-inner-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+}
+.hwfit-numstep-btn:focus { outline: none; }
+.hwfit-spec-group:has(input[type="checkbox"]:not(:checked)) .hwfit-spec-method,
+.hwfit-spec-group:has(input[type="checkbox"]:not(:checked)) .hwfit-spec-tokens,
+.hwfit-spec-group:has(input[type="checkbox"]:not(:checked)) .hwfit-numstep {
+ opacity: 0.45;
+ pointer-events: none;
+}
+.hwfit-serve-extra {
+ margin-bottom: 6px;
+}
+.hwfit-serve-cmd-details {
+ margin: 4px 0 0;
+}
+.hwfit-serve-cmd-summary::-webkit-details-marker {
+ display: none;
+}
+.hwfit-serve-cmd-summary::after {
+ content: '';
+ margin-left: auto;
+ width: 0;
+ height: 0;
+ border-left: 4px solid currentColor;
+ border-top: 3px solid transparent;
+ border-bottom: 3px solid transparent;
+ opacity: 0.6;
+ transform: rotate(0deg);
+ transition: transform 0.18s ease;
+}
+.hwfit-serve-cmd-details[open] > .hwfit-serve-cmd-summary::after {
+ transform: rotate(90deg);
+}
+.hwfit-serve-cmd-details[open] > .hwfit-serve-cmd-summary {
+ border-bottom-left-radius: 0;
+ border-bottom-right-radius: 0;
+}
+/* Same z-index treatment as .cookbook-task-dropdown — cookbook modal's
+ auto-stack climbs past low values; popups append to body and need to
+ sit above the modal regardless. */
+:where(.hwfit-cached-dropdown,
+.cookbook-gpu-split-menu,
+.cookbook-launch-actions-menu,
+.cookbook-saved-menu,
+.cookbook-dep-menu) {
+ z-index: 10000;
+}
+.hwfit-serve-cmd-wrap .hwfit-serve-cmd {
+ /* Just enough breathing room so a cursor at line-end doesn't actually
+ touch the Copy icon — text otherwise uses the full width of the box. */
+ padding-right: 32px;
+}
+.hwfit-serve-cmd-details[open] .hwfit-serve-cmd {
+ border-color: color-mix(in srgb, var(--accent, var(--red)) 48%, var(--border));
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, var(--red)) 16%, transparent),
+ 0 0 12px color-mix(in srgb, var(--accent, var(--red)) 13%, transparent);
+}
+.cookbook-gpu-split .cookbook-gpu-split-main {
+ border-top-right-radius: 0 !important;
+ border-bottom-right-radius: 0 !important;
+}
+.cookbook-gpu-split .cookbook-gpu-split-arrow {
+ border-top-left-radius: 0 !important;
+ border-bottom-left-radius: 0 !important;
+ border-left-width: 0 !important;
+ padding: 0 6px !important;
+ min-width: 24px;
+}
+.cookbook-task[data-type="download"][data-status="running"] .cookbook-task-type[data-type="download"] {
+ background: color-mix(in srgb, var(--color-accent, #00aaff) 18%, transparent);
+ color: var(--color-accent, #00aaff);
+}
+@keyframes cookbook-wave-pulse {
+ 0%, 100% { opacity: 0.45; }
+ 50% { opacity: 1; }
+}
+.cookbook-task[data-status="done"] .cookbook-task-check-ico { display: inline; }
+.cookbook-task[data-status="done"] .cookbook-task-clear-ico { display: none; }
+.cookbook-task-header {
+ cursor: pointer;
+}
+.cookbook-env-row > .cookbook-field-label {
+ flex: 1 1 140px;
+ min-width: 120px;
+}
+.cookbook-extra-label { grid-column: 1 / -1; }
+.cookbook-slider::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ width: 16px;
+ height: 16px;
+ border-radius: 50%;
+ background: var(--fg);
+ border: 2px solid var(--panel);
+ box-shadow: 0 1px 4px rgba(0,0,0,0.3);
+ cursor: pointer;
+ transition: transform 0.1s, box-shadow 0.1s;
+}
+.cookbook-slider:hover::-webkit-slider-thumb {
+ transform: scale(1.15);
+ box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent, var(--red)) 20%, transparent);
+}
+.cookbook-slider:active::-webkit-slider-thumb {
+ transform: scale(1.25);
+ box-shadow: 0 0 0 6px color-mix(in srgb, var(--accent, var(--red)) 25%, transparent);
+}
+.cookbook-slider::-moz-range-thumb {
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ background: var(--fg);
+ border: 2px solid var(--panel);
+ box-shadow: 0 1px 4px rgba(0,0,0,0.3);
+ cursor: pointer;
+}
+.cookbook-slider::-moz-range-progress {
+ background: var(--accent, var(--red));
+ height: 6px;
+ border-radius: 4px;
+}
+.cookbook-slider::-webkit-slider-runnable-track {
+ height: 6px;
+ border-radius: 4px;
+}
+.cookbook-slider::-moz-range-track {
+ height: 6px;
+ border-radius: 4px;
+ background: color-mix(in srgb, var(--fg) 12%, transparent);
+}
+.cookbook-checkbox:checked::after {
+ transform: translateX(14px);
+}
+/* Sliders span full row for alignment */
+.cookbook-field-slider {
+ grid-column: 1 / -1;
+}
+.cookbook-diag-body {
+ margin-top: 7px;
+}
+/* Add Server collapsible */
+.cookbook-server-details {
+ margin: 4px 0 6px;
+}
+.cookbook-server-toggle::-webkit-details-marker { display: none; }
+.cookbook-srv-status:hover { transform: scale(1.3); }
+@keyframes cookbook-srv-pulse {
+ 0%, 100% { opacity: 0.45; }
+ 50% { opacity: 1; }
+}
+.cookbook-server-entry::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ z-index: 2;
+ border-radius: inherit;
+ padding: 1px;
+ pointer-events: none;
+ background: linear-gradient(
+ to right,
+ transparent 0%,
+ transparent calc(var(--sweep, -20%) - 8%),
+ color-mix(in srgb, var(--cookbook-server-color, var(--red)) 85%, transparent) var(--sweep, -20%),
+ transparent calc(var(--sweep, -20%) + 8%),
+ transparent 100%
+ );
+ -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
+ -webkit-mask-composite: xor;
+ mask-composite: exclude;
+ animation: memory-synapse-sweep 6.2s linear infinite;
+ animation-delay: 0.4s;
+}
+#cookbook-servers-list .cookbook-server-entry:nth-child(2n)::after { animation-duration: 7.4s; animation-delay: 1.6s; }
+#cookbook-servers-list .cookbook-server-entry:nth-child(3n)::after { animation-duration: 8.8s; animation-delay: 3.2s; }
+#cookbook-servers-list .cookbook-server-entry:nth-child(5n)::after { animation-duration: 9.3s; animation-delay: 4.7s; }
+#cookbook-servers-list .cookbook-server-entry:nth-child(7n)::after { animation-duration: 5.5s; animation-delay: 2.3s; }
+.cookbook-server-entry:hover::after { animation: none; opacity: 0; }
+@media (prefers-reduced-motion: reduce) {
+ .cookbook-server-entry::after { animation: none; opacity: 0; }
+}
+/* Keep the row inside the card on narrow screens — the fixed-width host/path
+ inputs would otherwise overflow the card's background to the right. */
+@media (max-width: 768px) {
+ .cookbook-server-row .cookbook-srv-host,
+ .cookbook-server-row .cookbook-srv-path { flex: 1 1 100% !important; width: auto !important; min-width: 0 !important; }
+}
+.cookbook-server-row .cookbook-srv-name { width: 60px; flex-shrink: 0; flex-grow: 0; }
+.cookbook-server-row select.cookbook-srv-color {
+ display: none !important;
+}
+.cookbook-server-row .cookbook-srv-color-btn:focus-visible {
+ outline: 2px solid color-mix(in srgb, var(--cookbook-server-color, var(--accent, var(--red))) 70%, transparent);
+ outline-offset: 2px;
+}
+.cookbook-server-row .cookbook-srv-host { flex: 1; min-width: 100px; }
+.cookbook-server-row .cookbook-srv-host[readonly] { opacity: 0.4; cursor: default; }
+.cookbook-server-row .cookbook-srv-port { width: 40px; flex-shrink: 0; flex-grow: 0; }
+.cookbook-server-row .cookbook-srv-env { width: 65px; flex-shrink: 0; flex-grow: 0; }
+.cookbook-server-row .cookbook-srv-path { flex: 1; min-width: 80px; }
+.cookbook-server-row .cookbook-srv-status { width: 8px; height: 8px; align-self: center; }
+/* Brief highlight on the serve command box when a saved config is loaded, so
+ the click clearly registers (loading is otherwise silent). */
+.cookbook-cmd-flash {
+ animation: cookbookCmdFlash 0.6s ease;
+}
+.hwfit-toolbar:has(.hwfit-hw-manual-btn) {
+ gap: 8px;
+}
+.hwfit-toolbar .hwfit-server-select { min-width: 70px; flex-shrink: 0; }
+.hwfit-toolbar .hwfit-usecase { min-width: 70px; flex-shrink: 0; }
+.hwfit-toolbar .hwfit-engine { min-width: 0; width: 86px; flex-shrink: 0; font-size: 10px; }
+.hwfit-toolbar .hwfit-search { flex: 1; min-width: 80px; }
+.hwfit-advanced-panel .hwfit-engine-menu {
+ min-width: 132px;
+}
+/* Lower-opacity "Search models..." placeholder so it reads as a hint, not
+ a label — matches the muted form-field feel of the inline filters. */
+.hwfit-search::placeholder { opacity: 0.5; }
+.hwfit-search::-webkit-input-placeholder { opacity: 0.5; }
+.hwfit-search::-moz-placeholder { opacity: 0.5; }
+.hwfit-quant-wrap .hwfit-quant,
+.hwfit-engine-wrap .hwfit-engine {
+ /* Make room for the ? on the right edge, in addition to the native chevron. */
+ padding-right: 32px;
+}
+.hwfit-toolbar .hwfit-host { width: 110px; flex-shrink: 0; }
+.hwfit-env-row .hwfit-envtype { width: auto; min-width: 70px; flex-shrink: 0; }
+.hwfit-env-row .hwfit-envpath { flex: 1; min-width: 100px; }
+.hwfit-env-row .hwfit-gpus { width: 90px; flex-shrink: 0; }
+.hwfit-hw-chip-dismiss { cursor: pointer; }
+.hwfit-hw-chip-row[data-hw-chip="gpu"] .hwfit-hw-chip-x {
+ transform: translateY(-3px);
+}
+.hwfit-manual-panel .hwfit-hw-manual-save,
+.hwfit-manual-panel .hwfit-hw-manual-clear {
+ /* -3 (was -2) — 1px more up to optically align with the labeled
+ inputs to their left. */
+ transform: translateY(-3px);
+}
+.cookbook-model-list-fade {
+ animation: cookbook-model-list-fade-in 180ms ease-out both;
+}
+@keyframes cookbook-model-list-fade-in {
+ from { opacity: 0; transform: translateY(3px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+@media (prefers-reduced-motion: reduce) {
+ .cookbook-model-list-fade,
+ .hwfit-list.cookbook-hwfit-models-just-loaded > .hwfit-row:not(.hwfit-header) { animation: none; }
+}
+.hwfit-row:has(.hwfit-dl-dot):hover { opacity: 0.9; }
+.hwfit-row:has(.hwfit-dl-dot) .hwfit-dl-dot { opacity: 1; }
+.hwfit-header .hwfit-name { font-size: 9px; }
+.hwfit-sortable { cursor: pointer; user-select: none; }
+.hwfit-c-params { width: 42px; }
+.hwfit-c-quant { width: 52px; }
+.hwfit-c-vram { width: 42px; }
+.hwfit-c-ctx { width: 32px; }
+.hwfit-c-speed { width: 44px; }
+.hwfit-c-mode { width: 72px; }
+@keyframes hwfit-panel-in {
+ from { opacity: 0; transform: translateY(-4px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+.hwfit-cb { cursor: pointer; }
+.settings-appearance-panel {
+ flex-direction: column;
+}
+@keyframes cookbook-modal-enter {
+ 0% {
+ opacity: 0;
+ transform: translateY(12px) scale(0.94);
+ filter: saturate(0.85);
+ }
+ 65% {
+ opacity: 1;
+ transform: translateY(-2px) scale(1.012);
+ filter: saturate(1.05);
+ }
+ 100% {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ filter: none;
+ }
+}
+@keyframes cookbook-modal-enter-mobile {
+ 0% { opacity: 0; transform: translateY(100%); }
+ 100% { opacity: 1; transform: translateY(0); }
+}
+/* Cookbook Serve Advanced fold — wraps the rarely-touched tuning rows
+ (KV/Attention/Swap/Env for vLLM, llama.cpp batch/cache/split, VRAM
+ monitor, speculative, extra args). Matches the existing .hwfit-panel-
+ advanced look: muted-gray label, no caps, no letter-spacing, no
+ warning-y opacity. Content flows into the parent's existing scroll
+ surface (no inner max-height) and inner rows reset their margin so
+ stacking gaps don't double when the fold opens. */
+/* Styled to match the Add Models page collapsible sections
+ (.adm-section-toggle) — same border/background/caret pattern, so the
+ two folds across the app read consistently. */
+details.hwfit-serve-advanced {
+ margin-top: 8px;
+ overflow: visible;
+}
+details.hwfit-serve-advanced > summary.hwfit-serve-advanced-summary {
+ cursor: pointer;
+ user-select: none;
+ list-style: none;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 11px;
+ color: var(--fg);
+ opacity: 0.8;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 6px 9px;
+ background: color-mix(in srgb, var(--fg) 4%, transparent);
+ transition: border-color 0.12s, background 0.12s, opacity 0.12s, border-radius 0s;
+}
+details.hwfit-serve-advanced > summary.hwfit-serve-advanced-summary::-webkit-details-marker {
+ display: none;
+}
+details.hwfit-serve-advanced > summary.hwfit-serve-advanced-summary:hover {
+ opacity: 1;
+ border-color: var(--red);
+ background: color-mix(in srgb, var(--red) 8%, transparent);
+}
+/* Caret on the right, rotates open/closed. SVG-style rectangles via
+ borders keep this glyph-free + crisp at small sizes. */
+details.hwfit-serve-advanced > summary.hwfit-serve-advanced-summary::after {
+ content: '';
+ margin-left: auto;
+ width: 0;
+ height: 0;
+ border-left: 4px solid currentColor;
+ border-top: 3px solid transparent;
+ border-bottom: 3px solid transparent;
+ opacity: 0.6;
+ transform: rotate(90deg);
+ transition: transform 0.18s ease;
+}
+details.hwfit-serve-advanced:not([open]) > summary.hwfit-serve-advanced-summary::after {
+ transform: rotate(0deg);
+}
+/* Body rows below the header — tight rhythm so the fold doesn't
+ feel airy. The cookbook modal's existing .cookbook-body is the
+ scroll surface; nothing inside the fold should add its own scroll. */
+details.hwfit-serve-advanced[open] > summary.hwfit-serve-advanced-summary {
+ margin-bottom: 6px;
+}
+details.hwfit-serve-advanced > .hwfit-serve-row.hwfit-vram-monitor .hwfit-vram-readout {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+details.hwfit-serve-advanced > .hwfit-serve-row label select,
+details.hwfit-serve-advanced > .hwfit-serve-row label input {
+ margin-top: 1px;
+}
+details.hwfit-serve-advanced label:has(.hwfit-sf[data-field="vllm_attn_backend"]) {
+ position: relative;
+ left: -63px;
+}
+details.hwfit-serve-advanced label:has(.hwfit-sf[data-field="vllm_block_size"]) {
+ position: relative;
+ left: -75px;
+}
+details.hwfit-serve-advanced label:has(.hwfit-sf[data-field="swap"]) {
+ position: relative;
+ left: -73px;
+}
+details.hwfit-serve-advanced label:has(.hwfit-sf[data-field="vllm_kv_cache_dtype"]) {
+ position: relative;
+ left: 2px;
+}
+@media (max-width: 768px) {
+ details.hwfit-serve-advanced label:has(.hwfit-sf[data-field="vllm_block_size"]) {
+ left: 1px;
+ }
+ details.hwfit-serve-advanced label:has(.hwfit-sf[data-field="swap"]) {
+ left: -3px;
+ }
+ details.hwfit-serve-advanced > .hwfit-serve-checks .hwfit-sf-cb {
+ flex: 1 1 100%;
+ }
+}
+/* Hide the native number-input spinner arrows (e.g. SMTP/IMAP Port) — they
+ render unstyled and ugly. Field still accepts only numbers. */
+.settings-input[type="number"]::-webkit-outer-spin-button,
+.settings-input[type="number"]::-webkit-inner-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+}
+.settings-input[type="number"] {
+ -moz-appearance: textfield;
+ appearance: textfield;
+}
+.cookbook-output-wrap:hover .cookbook-output-copy {
+ opacity: 0.5;
+}
+.cookbook-output-wrap .cookbook-output-copy:hover {
+ opacity: 1;
+}
+#cookbook-dl-btn-search {
+ position: relative;
+ top: -4px;
+ padding-top: 0;
+ padding-bottom: 0;
+ height: 28px;
+}
+#cookbook-dl-btn {
+ position: relative;
+ top: -4px;
+}
+.research-synapse-compact .rs-stage { height: 288px; }
+@keyframes rs-live {
+ 0%, 100% { opacity: 0.45; }
+ 50% { opacity: 1; }
+}
+.research-synapse .rs-guide ellipse {
+ fill: none;
+ stroke: color-mix(in srgb, var(--fg) 7%, transparent);
+ stroke-width: 1;
+ stroke-dasharray: 2 7;
+}
+.research-synapse .rs-edge-tone-1 { stroke: color-mix(in srgb, var(--gold, #d4a72c) 45%, var(--border)); }
+.research-synapse .rs-edge-tone-2 { stroke: color-mix(in srgb, #5aa7c7 42%, var(--border)); }
+.research-synapse .rs-edge.rs-edge-firing {
+ stroke: var(--accent, var(--red));
+ stroke-width: 2;
+ opacity: 1;
+ filter: drop-shadow(0 0 4px var(--accent, var(--red)));
+ animation: rs-fire 1.1s ease-out;
+}
+@keyframes rs-fire {
+ 0% { stroke-dasharray: 4 200; stroke-dashoffset: 200; opacity: 0; }
+ 20% { opacity: 1; }
+ 100% { stroke-dasharray: 200 4; stroke-dashoffset: -200; opacity: 0.55; }
+}
+.research-synapse .rs-node-root {
+ fill: var(--accent, var(--red));
+ stroke: var(--accent, var(--red));
+ filter: drop-shadow(0 0 5px color-mix(in srgb, var(--accent, var(--red)) 65%, transparent));
+}
+.research-synapse .rs-root-halo {
+ fill: none;
+ stroke: color-mix(in srgb, var(--accent, var(--red)) 28%, transparent);
+ stroke-width: 1;
+}
+/* Sub & leaf nodes stay inside the accent palette so the whole graph
+ reads as one organism. Leaves are softer/lower-contrast dots that
+ float around their sub — not green-on-stem (which read as palm fronds). */
+.research-synapse .rs-node-sub {
+ fill: color-mix(in srgb, var(--accent, var(--red)) 10%, var(--bg));
+ stroke: color-mix(in srgb, var(--accent, var(--red)) 70%, var(--fg));
+}
+.research-synapse .rs-node-sub.rs-node-tone-1 {
+ fill: color-mix(in srgb, var(--gold, #d4a72c) 11%, var(--bg));
+ stroke: color-mix(in srgb, var(--gold, #d4a72c) 76%, var(--fg));
+}
+.research-synapse .rs-node-sub.rs-node-tone-2 {
+ fill: color-mix(in srgb, #5aa7c7 11%, var(--bg));
+ stroke: color-mix(in srgb, #5aa7c7 72%, var(--fg));
+}
+.research-synapse .rs-node-leaf {
+ stroke: color-mix(in srgb, var(--accent, var(--red)) 55%, transparent);
+ fill: color-mix(in srgb, var(--accent, var(--red)) 42%, var(--bg));
+}
+.research-synapse .rs-node-leaf.rs-node-tone-1 { fill: color-mix(in srgb, var(--gold, #d4a72c) 48%, var(--bg)); }
+.research-synapse .rs-node-leaf.rs-node-tone-2 { fill: color-mix(in srgb, #5aa7c7 48%, var(--bg)); }
+.research-synapse .rs-source-node { cursor: help; }
+@keyframes rs-hub-expand {
+ 0% { filter: drop-shadow(0 0 0 transparent); }
+ 35% { filter: drop-shadow(0 0 7px var(--accent, var(--red))); }
+ 100% { filter: drop-shadow(0 0 0 transparent); }
+}
+@keyframes rs-pop {
+ 0% { transform: scale(0); opacity: 0; }
+ 60% { transform: scale(1.25); opacity: 1; }
+ 100% { opacity: 1; }
+}
+@keyframes rs-pulse {
+ 0% { transform: scale(1); opacity: 0.65; }
+ 100% { transform: scale(5); opacity: 0; }
+}
+.research-synapse .rs-meta .rs-sep { display: none; }
+.research-synapse.rs-complete .rs-pulse { animation: none; opacity: 0; }
+.research-synapse.rs-complete .rs-node-root {
+ fill: var(--color-success, #4caf50);
+ stroke: var(--color-success, #4caf50);
+}
+@media (max-width: 480px) {
+ .research-synapse .rs-meta { grid-template-columns: 1fr auto auto; row-gap: 6px; }
+ .research-synapse .rs-meta .rs-status { display: none; }
+}
+.hwfit-cached-menu-btn svg { fill: currentColor; }
+.settings-email-preferences-card > h2 {
+ display: none;
+}
+[data-settings-panel="email"] > .settings-email-preferences-card {
+ padding-top: 0;
+}
+.cookbook-task-menu-btn {
+ top: 0 !important;
+ transform: translateY(2px) scale(1.15) !important;
+}
+/* Settings "+ Add server" matches the model-dir "+ Add" path button (22px). */
+#cookbook-server-add.cal-add-btn-text {
+ height: 28px;
+ min-width: 62px;
+ border-radius: 14px;
+ padding: 0 12px 0 8px;
+ position: relative;
+ top: 1px;
+ font-size: 12px;
+}
+@keyframes research-pane-enter {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+.research-pane-header:active { cursor:grabbing; }
+.research-pane-header h4 {
+ margin: 0;
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--red);
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+.research-tab-panel.hidden,
+.research-tab-panel[hidden] { display: none; }
+.research-tab-heading h2 {
+ margin: 0;
+ font-size: 14px;
+ font-weight: 600;
+ line-height: 1;
+}
+.research-tab-panel:not([data-research-panel="research"]) .research-jobs-list {
+ flex: 1 1 auto;
+ min-height: 0;
+ overflow-y: auto;
+}
+/* Hide the in-body "Research" subtitle on mobile to save vertical space —
+ the toolbar/header carries enough context already. */
+/* Match the .admin-card h2 styling used in Documents/Library so the
+ "Research" sub-title reads the same as those modals' titles. */
+.research-new-job h2 {
+ font-size: 14px;
+ font-weight: 600;
+ letter-spacing: 0;
+}
+.research-new-job > .research-new-job-desc {
+ margin: 6px 0 0;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-wrap: wrap;
+}
+@media (max-width: 600px) {
+ /* Keep the "Research" title visible on mobile (matches the Cookbook tab
+ titles, which show on mobile). It used to be hidden here. */
+ .research-new-job h2 {
+ font-size: 14px;
+ }
+}
+.research-setting:has(#research-model) .research-setting-label {
+ position: relative;
+ top: 1px;
+}
+.research-setting:has(#research-rounds) select,
+.research-setting:has(#research-category) select {
+ position: relative;
+ top: -4px;
+}
+.research-setting:has(#research-rounds) .research-setting-label,
+.research-setting:has(#research-category) .research-setting-label {
+ position: relative;
+ top: 4px;
+}
+.research-setting select {
+ font-size: 11px; padding: 4px 6px;
+ background: var(--bg);
+ color: var(--fg);
+ border: 1px solid var(--border); border-radius: 4px;
+}
+.research-picker-btn:focus-visible,
+.research-picker-option:focus-visible {
+ outline: 2px solid color-mix(in srgb, var(--accent, var(--red)) 65%, transparent);
+ outline-offset: 1px;
+}
+.research-picker-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.research-picker.open .research-picker-btn > svg { transform: rotate(180deg); }
+.research-picker-menu[hidden] { display: none; }
+.research-picker-option > span:nth-child(2):not(.research-picker-check),
+.research-picker-option > span:first-child:not(.research-picker-icon) {
+ overflow: hidden; text-overflow: ellipsis;
+}
+.research-picker-option.active .research-picker-check { opacity: 1; }
+.research-run-mode-popover.rrm-up { transform-origin: bottom right; }
+.research-run-mode-popover .research-run-mode-row:hover {
+ background: color-mix(in srgb, var(--accent-primary, var(--red)) 14%, transparent);
+}
+.research-job-card.done { cursor:pointer; }
+/* Past (library) research used to be dimmed to 0.65 which read as "greyed out".
+ They now look the same as the rest — folding them under "Past research"
+ handles the de-clutter instead. */
+.research-job-card.done.from-library { opacity:1; }
+.research-job-thumb-frame[role="button"] { cursor: pointer; }
+.research-job-thumb-frame[role="button"]:hover {
+ outline: 1px solid color-mix(in srgb, var(--accent, var(--red)) 65%, transparent);
+ outline-offset: 1px;
+}
+.research-job-report-body h1, .research-job-report-body h2, .research-job-report-body h3 {
+ color: var(--cat-color, var(--accent, var(--fg)));
+}
+.research-hero::after {
+ content: ''; position: absolute; right: -40px; top: -40px;
+ width: 160px; height: 160px; border-radius: 50%;
+ background: radial-gradient(circle, color-mix(in srgb, var(--cat-color, var(--accent)) 15%, transparent) 0%, transparent 60%);
+ pointer-events: none;
+}
+.research-hero-icon svg { width: 100%; height: 100%; }
+/* Product: callout-style bullets */
+.research-body-product ul { list-style: none; padding-left: 0; }
+.research-body-product ul li {
+ padding: 6px 10px 6px 28px; margin: 4px 0;
+ border-left: 2px solid color-mix(in srgb, #5b8abf 40%, transparent);
+ background: color-mix(in srgb, #5b8abf 4%, transparent);
+ border-radius: 0 4px 4px 0; position: relative;
+}
+.research-body-product ul li::before {
+ content: '▸'; position: absolute; left: 10px;
+ color: #5b8abf; font-weight: bold;
+}
+/* Comparison: styled table */
+.research-body-comparison table {
+ width: 100%; border-collapse: collapse; margin: 12px 0;
+ border: 1px solid color-mix(in srgb, #e5a33a 25%, transparent);
+ border-radius: 6px; overflow: hidden;
+}
+.research-body-comparison th {
+ background: color-mix(in srgb, #e5a33a 18%, transparent);
+ color: #e5a33a; font-weight: 700;
+ padding: 8px 12px; text-align: left;
+ border-bottom: 2px solid color-mix(in srgb, #e5a33a 40%, transparent);
+}
+.research-body-comparison td {
+ padding: 8px 12px;
+ border-bottom: 1px solid color-mix(in srgb, #e5a33a 12%, transparent);
+}
+.research-body-comparison tr:nth-child(even) td {
+ background: color-mix(in srgb, #e5a33a 3%, transparent);
+}
+/* How-to: big numbered steps */
+.research-body-howto ol { counter-reset: howto-step; list-style: none; padding-left: 0; }
+.research-body-howto ol > li {
+ counter-increment: howto-step; position: relative;
+ padding: 10px 12px 10px 52px; margin: 8px 0;
+ background: color-mix(in srgb, #82c882 5%, transparent);
+ border-radius: 8px; border-left: 2px solid #82c882;
+}
+.research-body-howto ol > li::before {
+ content: counter(howto-step);
+ position: absolute; left: 10px; top: 14px;
+ width: 30px; height: 30px; border-radius: 50%;
+ background: #82c882; color: white;
+ font-weight: 700; font-size: 13px;
+ display: flex; align-items: center; justify-content: center;
+ box-shadow: 0 2px 6px color-mix(in srgb, #82c882 40%, transparent);
+}
+/* Landscape: section banners */
+.research-body-landscape h3 {
+ padding: 8px 14px;
+ background: linear-gradient(90deg, color-mix(in srgb, #a07ae0 15%, transparent), transparent);
+ border-left: 3px solid #a07ae0;
+ border-radius: 0 6px 6px 0;
+ margin: 14px 0 8px;
+}
+/* Fact-check: verdict emphasis */
+.research-body-factcheck blockquote {
+ border-left: 3px solid var(--red);
+ background: color-mix(in srgb, var(--red) 6%, transparent);
+ padding: 10px 14px; margin: 10px 0;
+ border-radius: 0 6px 6px 0;
+}
+.research-body-factcheck strong {
+ color: var(--red);
+ padding: 1px 6px; border-radius: 4px;
+ background: color-mix(in srgb, var(--red) 12%, transparent);
+}
+.research-job-cancel:hover,
+.research-job-remove:hover,
+.research-job-report-link:hover { opacity:1; }
+.research-synapse-toggle:hover,
+.research-synapse-toggle.active { opacity:1; }
+.research-job-synapse-host.synapse-collapsed { display:none; }
+.research-job-summary .research-job-thumb-frame {
+ transform: translateY(-4px);
+}
+.research-job-summary .research-job-failnote {
+ margin: 2px 0 0;
+}
+.research-job-thumb-frame::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ background: radial-gradient(ellipse at center,
+ transparent 42%,
+ color-mix(in srgb, var(--accent, var(--red)) 28%, transparent) 100%);
+ opacity: 0.75;
+}
+/* Push the first dim (dismiss/delete) button — and everything after it — to
+ the right edge of the actions row. `:first-of-type` would match the very
+ first ]