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