test(css): pin computed styles against a committed baseline

static/style.css is 51,425 lines in one file. Hundreds of selectors are
declared more than once and !important is used throughout, so the rendered
result is a function of source order. Extracting a block into its own file
changes that order, and nothing in the suite would notice - which makes a
51k-line split unfalsifiable and "looks fine to me" the only available
evidence.

This moves no CSS. It captures getComputedStyle over a fixed inventory of
676 elements across three pages, four viewports, both themes and the three
density modes - 16,224 element snapshots - hashes them, and compares against
tests/css_snapshot/baseline.json. A capture takes about 21 seconds.

The bench page synthesises one element per selector from an evidence-driven
list: every selector declared more than once in style.css that can be
expressed as a static compound chain, plus a curated set per feature area.
Redeclared selectors are the ones a reorder can flip. The bench loads
whatever stylesheets index.html ships, so it keeps measuring the real set
once the file is split. tests/test_css_computed_style_snapshot.py also carries
a self-test that swaps two conflicting .attach-strip declarations and asserts
the digest moves, so the harness cannot silently stop watching.

The second half is the asset-manifest check specs/frontend.md asks for,
scoped to stylesheets: every stylesheet referenced by shipped HTML and by the
sw.js precache exists, and index.html and sw.js agree on the ?v= string. They
hardcode it independently today, so a split that updates one and not the
other ships an offline cache nobody notices until a plane.
This commit is contained in:
Léo
2026-09-25 11:32:46 +02:00
parent 0e07d9a675
commit 1863de33c2
10 changed files with 3051 additions and 3 deletions
+290
View File
@@ -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 ``<link>`` 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())
+5 -3
View File
@@ -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.
+19
View File
@@ -137,6 +137,25 @@ The runner propagates pytest's exit code, so it composes with normal local
workflows; "report-only" means it is not a CI gate, not that failures are
swallowed.
## CSS computed-style snapshot
`tests/test_css_computed_style_snapshot.py` pins the rendered result of
`static/style.css` - one 51k-line file whose behavior depends on source order -
by hashing `getComputedStyle` over a fixed element inventory across pages,
viewports, themes and density modes. Any PR that moves CSS has to produce an
identical digest or explain why it did not.
```bash
./venv/bin/python -m pytest tests/test_css_computed_style_snapshot.py
./venv/bin/python scripts/css_snapshot.py --check # standalone, no pytest
./venv/bin/python scripts/css_snapshot.py --write-baseline # re-record, deliberately
```
The inventory, the baseline and the capture live in `tests/css_snapshot/`;
`tests/css_snapshot/README.md` documents what is covered, what is deliberately
not, and how to find the property that moved when it fails. The run takes about
21 seconds and skips when `npm ci` has not been run.
## Core principles
- Keep PRs small and homogeneous: one kind of change per PR.
+129
View File
@@ -0,0 +1,129 @@
# Computed-style snapshot harness
`static/style.css` is 51,425 lines in one file. Hundreds of selectors are
declared more than once and `!important` appears throughout, so the rendered
result is a function of **source order**. Extracting a block into its own file,
reordering `<link>` tags, or moving an `@media` rule can silently change which
declaration wins, and nothing else in the suite would notice.
This harness makes that falsifiable. It captures `getComputedStyle` over a
fixed element inventory, hashes the result, and compares it to a committed
baseline. It moves no CSS itself.
## What it covers
| Dimension | Values |
|---|---|
| Pages | `static/index.html` (app shell, 76 elements), `static/login.html` (14), the bench (586 selectors) |
| Viewports | 1440x900, 820x1000, 768x1024 (touch), 390x844 (touch) |
| Themes | dark (default) and `:root.light` |
| Density | default, `:root.density-compact`, `:root.density-spacious` |
| Properties | 122 pinned properties per element, plus every custom property on `:root` and `body` |
That is 676 elements x 24 variants = 16,224 element snapshots per run, in
about 21 seconds.
The **app shell** page measures real elements in the markup the server sends,
including modals - each one revealed on its own and re-hidden straight after,
so the measurements stay independent.
The **bench** page measures one synthesised element per selector, built from
the selector itself. Its selector list is evidence-driven: every selector
declared **more than once** in `style.css` that can be expressed as a static
compound chain (551 of them), plus a curated set covering chat, documents,
email, notes, calendar, settings, cookbook and gallery. Redeclared selectors
are the ones a reorder can actually flip, so they are the ones worth benching.
A bench element pins the cascade for that class combination; it does not pin
the markup that the JS produces.
Selectors the bench grammar cannot express are the gap: selector lists
(`a, b`), pseudo-elements, pseudo-classes, `:not()` and `:has()`. They are
skipped rather than approximated.
## Files
| File | Role |
|---|---|
| `inventory.json` | The fixed inventory: properties, variants, pages, elements, bench selectors |
| `baseline.json` | The committed digest plus per-element and per-variant hashes |
| `capture.mjs` | Playwright capture; raw values on stdout |
| `bench.html` | Empty page that loads the stylesheet; the capture mounts bench nodes into it |
| `../test_css_computed_style_snapshot.py` | The regression test |
| `../../scripts/css_snapshot.py` | Hashing, comparison, and the CLI |
## Running it
```bash
./venv/bin/python -m pytest tests/test_css_computed_style_snapshot.py
./venv/bin/python scripts/css_snapshot.py --check # same comparison, standalone
./venv/bin/python scripts/css_snapshot.py --write-baseline # re-record
```
The CLI serves the repository on an ephemeral port itself, so it does not need
pytest. Under pytest the session static server is reused through
`ODYSSEUS_TEST_STATIC_ORIGIN`.
`npm ci` is required: the capture drives Playwright's Chromium. Without it the
browser tests skip.
## When the test fails
The failure names the elements and the variants whose hashes moved. To see
which *property* moved, capture both sides and diff:
```bash
./venv/bin/python scripts/css_snapshot.py --dump after.json
git stash && ./venv/bin/python scripts/css_snapshot.py --dump before.json && git stash pop
diff <(python -m json.tool before.json) <(python -m json.tool after.json)
```
Re-record the baseline only when the change in rendered style is **intended**
and reviewed. On a mechanical CSS extraction it never should be: an extraction
that preserves order produces an identical digest, and one that does not has
changed the UI.
## Determinism
The digest is only worth having if an unchanged stylesheet always produces the
same bytes, so the capture:
- strips every `<script>` from the document, leaving exactly the markup the
server sends - no app module can mutate classes underneath the measurement;
- injects the theme and density classes into `<html>` *before* first paint
rather than toggling them afterwards, so no CSS transition is ever
mid-interpolation while `getComputedStyle` runs;
- aborts images, fonts and media, which cost time and change nothing in the
pinned property set;
- hides scrollbars, so a platform's scrollbar width cannot change the width
that percentages and `auto` resolve against;
- pins `prefers-reduced-motion`, `forced-colors`, `prefers-color-scheme` and
the device pixel ratio.
## What it does not cover
- **Layout geometry.** `width`, `height`, `top`/`left`/`right`/`bottom`,
`transform` and `grid-template-*` resolve to used values that depend on text
layout, so they are excluded rather than risk a baseline that only holds on
one machine. A reorder that changes a size through a property in the pinned
set is still caught; one that changes it only through an excluded property is
not.
- **Pseudo-class states.** `:hover`, `:focus` and `:active` are not driven.
- **JS-applied classes.** State the app adds at runtime (collapsed sidebar,
open panels, active tabs) is not represented beyond what the served markup
and the bench selectors already carry.
- **Cross-platform equality has not been measured.** The baseline was recorded
on macOS. The self-hosted Fira Code face means text metrics should not differ
from CI's Linux Chromium, and the layout-derived properties are excluded, but
until a Linux run confirms it, treat a CI-only drift as a possible harness
artifact and diff the dumps before assuming the CSS moved.
- **The stylesheet is only one of the inputs.** `static/login.html` styles
itself from an inline `<style>` block; it is in the inventory so the
hand-mirrored token values there are pinned too.
## Adding coverage
Add an entry to `inventory.json` - an `{key, selector}` object under a page's
`elements` (with `"unhide": true` if it ships hidden, `"custom": true` to
include custom properties), or a selector string under `bench` - then
re-record the baseline. `test_baseline_covers_every_inventory_entry` fails if
the two go out of sync.
+767
View File
@@ -0,0 +1,767 @@
{
"digest": "e8ef2a81ebd7a4ab",
"elements": {
"app-shell": {
"app-loader": "f04ad5bf6312d53f",
"attach-strip": "a6474951e2481214",
"body": "58df17f0afa190b9",
"chat-container": "b00a7c6be4e107b1",
"chat-context-pill": "dd905b87245a9a71",
"chat-history": "e65bdc51f4d1675f",
"confirm-btn-primary": "c906e0be9fe73bef",
"cookbook-modal": "918b17b2e2a311dc",
"cookbook-modal-content": "73700bbe8be47774",
"custom-preset-modal": "9345624780009d14",
"custom-system-prompt": "ef839a436cd42975",
"export-dl-btn": "d3fe28e931b66974",
"export-dropdown-item": "d90f6f513d06a6f7",
"export-dropdown-menu": "0793e06a6aae6a7a",
"hamburger-btn": "34714f6eef3734d8",
"icon-rail": "e3f72205260a4a5f",
"icon-rail-btn": "54ffb10e056ebf44",
"incognito-btn": "ee42bd3f6a32576b",
"input-icon-btn": "3d1c773acd84a825",
"memory-close-btn": "c3f4ba99098a6e4b",
"memory-list": "da9a37a3de3c1a31",
"memory-modal": "5efc81537eab4526",
"memory-modal-content": "3cadc8581361436e",
"memory-modal-header": "a8c822fe8c6d20e7",
"memory-search-input": "8b0fbb33401750d4",
"memory-toolbar-btn": "9f985efa7c896f90",
"message-ghost": "ce9144a7705ee903",
"message-textarea": "ae703a5c12022f08",
"mobile-backdrop": "b2ed3a84835d4916",
"mode-toggle-active": "9dfc0fe8866d86c2",
"mode-toggle-idle": "4a221dded6bec4d6",
"model-picker-btn": "d44aa4f424c3447e",
"model-picker-list": "23a89bcc30d0ccee",
"model-picker-menu": "f3320494f37c1156",
"overflow-menu": "e40a9742168a2a8b",
"overflow-menu-item": "1a7cad32047dc976",
"pinned-tools-bar": "bb0f88ce29ae7637",
"rail-new-chat": "f8f1c2f6be426ab9",
"reasoning-effort-btn": "800d3d3edff6bf85",
"rename-session-modal": "9345624780009d14",
"root": "e3897afe51e821cc",
"save-custom-preset": "443da70ef5fe9b88",
"scroll-bottom-btn": "24602d72e2949991",
"search-input": "53b94f3e5fdb384d",
"search-overlay": "87d5f21c20bdde98",
"session-list-bootstrap": "aaa28ed4edac84a9",
"session-name-input": "c435986f102bee53",
"settings-admin-card": "fcf3c80f20175a3c",
"settings-btn-add": "82aeb52b9fc7de0f",
"settings-btn-sm": "c31bcb9bdfbae10d",
"settings-fallbacks": "e339280590935fe3",
"settings-logs-tab": "e470140373597143",
"settings-modal": "5efc81537eab4526",
"settings-nav-search": "f4484b2980f2ccc5",
"settings-row": "de568724268cec6e",
"settings-select": "a96e8430b0ec6668",
"settings-sidebar-toggle": "6be9025ab24bc434",
"sidebar": "77ec42a646a8db66",
"sidebar-brand": "d3d7ef4b21514e80",
"sidebar-new-chat": "567fae30048b7ff3",
"sidebar-resize-handle": "6607b819ee45409d",
"sidebar-section": "a46f059ca4b0b4b5",
"sidebar-section-header-btn": "eff26568c49f8b40",
"sidebar-user-bar": "b77372dbdcb069ef",
"theme-grid": "4d4b2aeb8952cc6f",
"theme-io-btn": "859f222c79a8c721",
"theme-modal": "0a65ca916656cdbb",
"theme-popup": "3aa65e6f313bbc65",
"theme-tabs": "0cc4987f39876dce",
"toast": "c60c6a60d4982005",
"tool-indicator": "0dee45eaca8b703a",
"user-bar-avatar": "b989bbab5aab3988",
"user-bar-settings": "2d91a65e0ebdd049",
"welcome-screen": "a1dc93c81a7d1899",
"welcome-sub": "89cdafbb95b7c845",
"welcome-tip": "875045a9d2ec1055"
},
"bench": {
"#agent-indicator": "09a1b8d94b1487ee",
"#agent-indicator.active": "67e2ad3fa48acd3b",
"#compare-model-overlay .modal-body": "0b1ee357313551b9",
"#compare-model-overlay .modal-content": "56883147d181bd9c",
"#cookbook-modal .cookbook-body": "20bd8a7268cee788",
"#cookbook-modal .hwfit-cached-list.cookbook-serve-models-just-loaded > .doclib-card": "48f6787a3df3f19e",
"#cookbook-modal .modal-content": "0c4e1e686df0990e",
"#cookbook-modal .modal-content.cookbook-modal-entering": "fba9bbbb406a70c4",
"#cookbook-modal .modal-header": "1445de059f94beb9",
"#cookbook-server-add.cal-add-btn-text": "36d5d25f82aad559",
"#doc-actions-footer": "a517afe6dd10dbf3",
"#doc-actions-footer #doc-copy-export-split": "70b267bc3460ba24",
"#doc-actions-footer #doc-footer-copy-btn": "b339e77a5e9730c1",
"#email-lib-compose-btn": "3b21baa4a3d6bec8",
"#email-lib-compose-btn svg": "6decb15adb02a12b",
"#email-lib-modal .admin-card": "fb6ad83c8330d140",
"#email-lib-modal .doclib-grid": "99515ed7d3c12dc8",
"#email-lib-modal .email-lib-fab.fab-revealed": "9668afab75e5e45d",
"#email-lib-modal .email-lib-sync-status": "6d1a0374a16cf7f4",
"#email-lib-modal .email-reader-atts": "c5d68ecda3c14bb8",
"#email-lib-modal .email-reader-header": "85afca11c8cbd38e",
"#email-lib-modal .modal-content": "d8b8fff38c613c95",
"#email-lib-settings-btn": "9ae15bf04b6a22ce",
"#email-unread-dot.sidebar-notif-dot": "c80f1e03592983b6",
"#export-compact-btn": "7d0325a38e51134b",
"#gallery-editor-drafts-bulk": "9d8c00a765d6689e",
"#gallery-editor-drafts-select": "694fb627310c9295",
"#ge-history-panel": "26ec3f6b3e6d2751",
"#hwfit-cache-select": "62ab492dd8fad73d",
"#memory-modal .doclib-card.skill-card.doclib-card-expanded > .doclib-card-preview": "10834da1800d529b",
"#message": "018b0ab2128d9d34",
"#modal-dock": "bc81bafabc09440c",
"#mode-agent-btn": "5cccc6edda975b2a",
"#mode-chat-btn": "77af83dd6bfcd26c",
"#recording-indicator": "e856ac42970d1a8c",
"#recording-indicator.error": "8a1bcc599c3c237b",
"#recording-indicator.hidden": "c8b3476b1107b2e2",
"#research-pane": "e465bba8b1555f51",
"#research-pane #research-past-list .research-job-card": "721dde2eb52cb7ed",
"#research-pane #research-past-list .research-job-header": "c4d23f4b8bc102a8",
"#research-pane #research-past-list .research-job-more": "414c2b05ad7e45e0",
"#research-pane .research-history-card": "e9d829f0fda26f94",
"#research-pane .research-history-toolbar-row": "33ce111a10432f3b",
"#research-pane .research-history-toolbar-row .memory-sort-select": "6054d616490a49eb",
"#research-pane .research-pane-body": "7e10ca158f86d408",
"#session-select-all-dot": "d72b24787bef1c6a",
"#session-sort-btn": "bb0f88ce29ae7637",
"#sessions-section": "02a1d6939b9ef639",
"#sessions-section #session-list": "5f7dfc0825eec2ee",
"#sessions-section .chats-manage-btn": "63fe8c42ed236f21",
"#sidebar-backdrop": "f46fd74d98873484",
"#stop-recording": "d5489a601cfabf69",
"#styled-confirm-overlay": "64466ea74bcf53ed",
"#theme-popup": "f22c8010b180b42d",
"#theme-tab-customize .color-row": "e3721ab86ea18eef",
"#theme-tab-customize .theme-custom": "9b8fe83f16e039ee",
"#welcome-screen": "7024d434f65886c9",
"#welcome-screen .welcome-name": "01cffb1d086a1ab3",
"#welcome-screen .welcome-sub": "89cdafbb95b7c845",
"#welcome-screen .welcome-tip": "875045a9d2ec1055",
"#welcome-screen .welcome-version": "3a850e9283976c34",
".admin-btn-add": "8a933cbf8ac270ce",
".admin-btn-sm": "1419bf7301e47b66",
".admin-card": "0ae3d86c8cfeb751",
".admin-model-form-row input": "1d820446629f79c4",
".admin-slider": "836a69285985c958",
".admin-switch": "876abcc0f10c2d55",
".agent-thread": "f6198eddb1a9969f",
".agent-thread-cmd": "54d5ece7fa9de68c",
".agent-thread-content": "c60aadf77a2a5883",
".agent-thread-dot": "d59e25a8c69eeac4",
".agent-thread-header": "a336602ef4d31c1c",
".agent-tool-diff summary": "4128de76cb424c63",
".agent-tool-output pre": "530700f4a0699704",
".archive-list": "a180dd2654ef8229",
".archive-menu-btn": "9f508f063b881978",
".ask-user-card": "f2dee20264814c84",
".ask-user-card-attached": "e300c7fb5bc70b1f",
".ask-user-option": "531ea450e6af3121",
".ask-user-option-desc": "22de16b97f42ea38",
".ask-user-option-label": "f555c9bd471d5421",
".ask-user-other": "0b2855d42c5d7f32",
".ask-user-other-send": "2144fc24a8bf91c2",
".ask-user-question": "05a91b0c244960fc",
".attach-image-preview": "b5dd397be5ad69aa",
".attach-ocr-btn": "b0d7e3be6781d365",
".attach-ocr-btn svg": "8b09b0b7140aae98",
".attach-strip": "a6474951e2481214",
".btn-spinner": "14215771d1d171fb",
".cal-add-label": "72fd3ef86c3bbfcb",
".cal-add-plus": "a1b0414e63b6ac40",
".cal-day": "4ca6a688a40d4db2",
".cal-day-num": "a4745da9f0b703bc",
".cal-detail-header": "c81d6378318b5cb9",
".cal-event-item": "e631fe656b59f308",
".cal-event-row": "8dca80590c370f70",
".cal-filter-item": "131d083db2c9c116",
".cal-filter-toggle": "757cfe6e18bc90eb",
".cal-filters": "0e423e393699903b",
".cal-form-bespoke": "91129760df1ca566",
".cal-form-mobile-cancel": "bde3b168ed685bb0",
".cal-grid": "92c99ce3581e92c9",
".cal-hero-time": "7474b3c587ca5d98",
".cal-modal-container": "3ed41f999d50bd8f",
".cal-multiday": "bc5ae2259e9db38a",
".cal-quickadd-row": "b90bdc13d6453de7",
".cal-splitter": "871cd8b0eae95d23",
".cal-splitter-grip": "73e88032078aac16",
".cal-title": "162d427104c49b46",
".cal-toolbar": "77d67b21b75d89fc",
".cal-toolbar-nav": "855c82a6eb672a85",
".cal-toolbar-right": "b6059b5f1a77c077",
".cal-wk-block": "5763c2f0c31ff3d8",
".cal-wk-block-name": "edc7443f13567025",
".cal-wk-block-time": "2fe325de785343a0",
".cal-wk-wrap": "de61e4d3f59cf949",
".cal-wk-zoom": "e5edf7c699287e58",
".cal-year": "76d643a419e7656e",
".cal-year-cell": "25a1642b5b698c0b",
".chat-container": "b00a7c6be4e107b1",
".chat-container.welcome-active .chat-input-bar": "c6510a7083e92ce8",
".chat-history": "4dd22e235be04a73",
".chat-input-bar": "fa08ce1f4b0cdb46",
".chat-meta-overlay": "fc44623332db5eac",
".chat-meta-overlay #current-meta": "1ae4281cd2a008d4",
".chat-top-bar": "28bc79270a878362",
".close-btn": "9c3b6334c7b25fee",
".compare-add-flap": "bcc20ba15ee5b0ed",
".compare-add-flap span": "b40d213f00c806d3",
".compare-grid": "f6f69aa41d74c616",
".compare-grid.sequential-layout .compare-pane .pane-header": "235ba192ba9c7f3e",
".compare-mobile-tabs": "62c5cbb34f339509",
".compare-mode-tabs": "e2177d8b26c19145",
".compare-pane .pane-header": "07ab4c430c694f8a",
".compare-probe-card": "06a67cf386d5bdec",
".compare-probe-detail": "db787590d19ef4cc",
".compare-probe-detail-message": "57eeaebb2a9b0a4f",
".compare-probe-list": "78deccee6c00025e",
".compare-toggle-label": "e200b7dff04bf81b",
".confirm-btn": "a8518c6834fb7ac0",
".cookbook-body": "1a10500fe1e306f0",
".cookbook-btn": "40ad2e8df76f2d9c",
".cookbook-card-desc": "f6ef9079e5e15a2d",
".cookbook-card-title": "3df0f721555953a0",
".cookbook-checkbox-label": "aac8b53d01d979cf",
".cookbook-cmd-preview": "c565845df485518e",
".cookbook-dep-build-deps": "15280be2979ad180",
".cookbook-dep-row": "57e3a98d97f7b9c8",
".cookbook-dep-tag": "97d68d0162f39744",
".cookbook-dl-btn": "cc7fe1bb0c79630a",
".cookbook-dl-repo": "50b7fc43bce8f35c",
".cookbook-field-input": "a12fc90dcf7e9de3",
".cookbook-field-label": "afc196d6dccbbc6f",
".cookbook-gpu-btn": "52b45862dd394f30",
".cookbook-gpu-btn.active": "5bff77f9adcb7448",
".cookbook-official-filter": "6bdea60315f7d0da",
".cookbook-output-pre": "40fc0611f762b039",
".cookbook-run-btn": "c816359f8655bef2",
".cookbook-saved-host": "cf6b4983fa9bebd0",
".cookbook-saved-name": "d434036dd310ca6a",
".cookbook-section-header .cookbook-clear-btn": "e19b1a13059aa249",
".cookbook-section-title": "c7c5399d03a5b9c2",
".cookbook-serve-preset-meta": "1e2786a24b30d019",
".cookbook-serve-preset-name": "abf2efbb491f8eb9",
".cookbook-serve-title": "dbc48ae1de4db056",
".cookbook-server-row .cookbook-srv-color-wrap.has-color .cookbook-srv-color-dot": "ff38b51b211440c0",
".cookbook-settings-hint": "cc67f7db1e73911c",
".cookbook-settings-input": "a6e3b4a498232fcb",
".cookbook-settings-label": "9f811c48f43831c3",
".cookbook-settings-stack": "eb21f8352c23e9d0",
".cookbook-slot-btn": "185943b4d681c18e",
".cookbook-tab": "cbac98e770f1b5c7",
".cookbook-tab-error-dot": "083ff5e013f0f752",
".cookbook-task-check": "203f3f00e300b69a",
".cookbook-task-header": "17633a41a71fb15c",
".cookbook-task-menu-btn": "fbec9d85e73f99c2",
".cookbook-task-name": "b5e36c1e6dfc2196",
".cookbook-task-status": "832d485fa467a2c9",
".cookbook-task-sub": "e67555ec0e2edcfb",
".cookbook-task-wave": "8fdc66c19c2f2169",
".cp-popover": "a2aa1ac1ca1ba392",
".cp-sl": "3332f2879d861c24",
".diff-chunk-btn": "31f68158a063bfc1",
".diff-toolbar": "28e32a3239b7b603",
".diff-toolbar-btn": "14e8f6a294cf4088",
".doc-action-icon-btn": "392100f636cc0f05",
".doc-docx-paper": "c6d5777d00f18064",
".doc-docx-preview": "a25a0a2fc4de255d",
".doc-editor-header": "253267e87c976100",
".doc-email-collapse-btn": "417a42c5c91f9a22",
".doc-email-header": "0cf44218df62de2a",
".doc-email-richbody.doc-font-m": "613a4e9d172bb44d",
".doc-language-select": "90a29013c434753c",
".doc-line-numbers": "a7aa45b481331f5b",
".doc-md-toolbar": "cf5013815486fa9b",
".doc-md-toolbar button": "7790f81a78a6e4cb",
".doc-mobile-footer": "b2ed3a84835d4916",
".doc-outline-item": "5e404c8d680cfc69",
".doc-outline-menu": "f29f6f5a8e53fd57",
".doc-overflow-item": "d10da3bab3e43ce4",
".doc-preview-hover-edit": "b78f58de2e582325",
".doc-rich-empty-import": "7ee18d60aed92aab",
".doc-rich-selection-toolbar": "4f6431c0ab8b4b08",
".doc-rich-selection-toolbar .doc-rich-selection-close": "1df07aa1a7ea60b7",
".doc-rich-selection-toolbar button": "73704ff1c15f0013",
".doc-rich-slash-item": "d8c5cbb3e3e3e5d6",
".doc-rich-slash-menu": "2e31f63219c116aa",
".doc-save-button[data-save-state=\"saving\"] .doc-save-state-saving": "84ecc420b557e35d",
".doc-selection-clear": "b0d9428152a09e39",
".doc-suggestion-card": "b1db7167c4231257",
".doc-suggestion-close": "8fbd4d5f6ba460b6",
".doc-suggestion-nav-btn": "befb117a2fd3fa7d",
".doc-tab": "8f563b5e946c20be",
".doc-tab-bar": "0b65732476210fd4",
".doc-tab-close": "6c1e0e8793444210",
".doc-tab-lang": "8b0efacad728c610",
".doc-tab-new": "def25060720b95fc",
".doc-version-panel": "1073efb40f164f6b",
".doclib-btn-hint": "1e7b736920f0afa7",
".doclib-card": "6771693fee18c8b3",
".doclib-card-action-btn": "4e2f069505c5dbb5",
".doclib-card-expanded-actions": "33a86d25e6b02b9c",
".doclib-card-header": "af0f97b1f3004360",
".doclib-card-preview": "2c6e654086abaa34",
".doclib-card.doclib-card-expanded": "1ecfff38fa7abc2c",
".doclib-card.email-card-removing": "e379729bdba2a0eb",
".doclib-chat-row.doclib-card-expanded": "dfd424102537f179",
".doclib-chat-row.doclib-card-expanded .doclib-chat-preview": "dabb220508f9eae3",
".doclib-chat-row.doclib-card-expanded .doclib-chat-preview-messages": "d9e01bfd21a07b37",
".doclib-chip-scroll-arrow": "40757af6c9e795fb",
".doclib-expanded-delete-btn": "bb0f88ce29ae7637",
".doclib-grid": "34c18c37757a199f",
".doclib-lang-chips": "6d351e1534d7b081",
".doclib-toolbar": "dc9ec6073f2c3605",
".dropdown": "bed9ac24c6696903",
".dropdown-cancel-mobile": "e3b3786fda598403",
".dropdown-divider": "5738d6943e2b555b",
".dropdown-item": "5c8fd729a604d69a",
".dropdown-item-compact": "984fa882578bfc56",
".dropdown-item-compact .dropdown-icon": "b75dab733d89dbc1",
".dropdown-item-compact .dropdown-icon svg": "b39764818e19ca4c",
".email-attach-toggle-inline": "18470eba1fd88166",
".email-attachment-chip.is-expanded": "833a4ff805e57c88",
".email-attachment-open": "b2e7d5021587cf3b",
".email-bubble": "84c884b461b7bfd6",
".email-bubble-avatar": "642766e3dbe32509",
".email-card-unread-dot": "33dea788cbd97824",
".email-compose-chip .compose-chip-name": "58ca58105c731e0b",
".email-draft-btn": "f256eb68fc8b17e3",
".email-field": "981e2a2bef7d2401",
".email-field #doc-email-subject": "0864bcaf3b3dffb0",
".email-field input": "b4b0542ee0ce5b2a",
".email-field label": "c6076082752aed3e",
".email-filter-btn": "cc290eac8042b6f7",
".email-filter-menu": "dc5d1dfacdd26733",
".email-filter-picker": "9ae15bf04b6a22ce",
".email-inline-image-actions": "891b1549d5e6a2ab",
".email-inline-image-skeleton": "0d20c32f79dbe7f7",
".email-odysseus-attach-list": "fc3cf3fa0fe725dd",
".email-odysseus-attach-menu": "ca6e3ea838c14777",
".email-quote-fold .email-fold-summary": "38395d57d7800777",
".email-reader": "bb0f88ce29ae7637",
".email-reader-body": "8ce048cdcb667db7",
".email-reader-body .email-inline-image-placeholder": "deebf724fa3a2a5d",
".email-reader-header": "066a5ffcb140bbc9",
".email-reader-header > .email-reader-actions": "0331e25e809e0d0b",
".email-reader-meta": "6d84db440dcf1dcf",
".email-reader-meta .recipient-chips": "c9ceda20ba24a94b",
".email-reader-meta > .email-reader-actions-inline": "06ecc1c60b199e44",
".email-reader-meta-row strong": "0af50deb999967c3",
".email-reminder-toggle-inline": "18470eba1fd88166",
".email-search-options-menu button": "f112d02b7f4b2a3f",
".email-search-row .memory-search-input": "f5edf94bbb3a2730",
".email-send-btn": "8cd36039dde7b23a",
".email-settings-auto-reply-section .email-settings-section-body": "a09c1e68706d8dc3",
".email-settings-auto-reply-section .email-settings-section-desc": "8d8ce3cc398fc912",
".email-settings-auto-reply-title-row": "bfc6bb8acd83b4c3",
".email-settings-clean-btn": "398f211c2e62b7be",
".email-settings-display-section .email-settings-section-desc": "f540b150a9ba3b16",
".email-settings-grid": "eee5ec449a8f214e",
".email-settings-section[open] > .email-settings-section-body": "88b6ae868ea5a387",
".email-tags-toggle-inline": "8b5bcb3c17df370e",
".email-undone-toggle-inline": "18470eba1fd88166",
".export-dl-btn": "fc2a9d706e560a26",
".export-dropdown-item": "8ec724a091aa55c8",
".export-dropdown-item .dropdown-icon": "4e597b835e44680b",
".export-dropdown-menu": "11a65ef24743b950",
".gallery-album-chips": "9c85df6af61b4447",
".gallery-albums-grid": "19900300e1bee43f",
".gallery-chip": "3df2d7f6ea8bf569",
".gallery-detail-body": "2ccc994e7a9fc0bc",
".gallery-detail-image": "bb4a5fa517c1d077",
".gallery-detail-meta-grid": "0804ee3dd5a92578",
".gallery-detail-nav": "265fadf513a58d78",
".gallery-detail-sidebar": "b47067b4c540d312",
".gallery-detail-title": "3a61a73ed392c545",
".gallery-dl-btn": "afa5e7125e483700",
".gallery-editor-draft-delete": "501e6db6e97bd5fc",
".gallery-editor-drafts": "e28e1aa9423d297b",
".gallery-editor-drafts-header": "14467c19ae89fd16",
".gallery-editor-drafts-search": "456f4e599d692064",
".gallery-editor-drafts-title": "07556469d7e5bfcd",
".gallery-editor-landing": "a5ae5067720f044d",
".gallery-editor-landing-actions": "dec9a0fd207ba1bc",
".gallery-editor-landing-actions .gallery-select-btn": "c92cc6790f517732",
".gallery-editor-template-options": "9b8c6bae4cc423a7",
".gallery-editor-template-picker": "0a04414510047d6f",
".gallery-grid": "9193704237593d45",
".gallery-search": "499b1d5db75d8637",
".gallery-search-enter-hint": "59988183b5e2231e",
".gallery-search-wrap": "a3a65cb89f837b1a",
".gallery-select-btn": "6062fab15408c8d2",
".gallery-tab": "856ba5e8322b2ac3",
".gallery-tabs": "6e77ea3e6cf981c9",
".gallery-toolbar": "cbee92b65715f862",
".gallery-toolbar-break": "b2ed3a84835d4916",
".ge-adj-icon": "405c77944c2f21c7",
".ge-adj-popup": "e1a93233ab11f043",
".ge-adj-row": "fed828d535ba710b",
".ge-adj-row > label": "21bd09c7cf6167e5",
".ge-adj-row input[type=\"range\"]": "3f1690aad291a7da",
".ge-adj-title": "7609b8e4f862ad23",
".ge-ai-command-collapsed": "35a892b0246538ba",
".ge-alpha-badge": "59eb1bb7eddf6df0",
".ge-canvas-area": "b6a7426fcd6ad230",
".ge-canvas-prompt": "97ddee153cdaf1f2",
".ge-canvas-prompt-footer": "153de6a15c89d60d",
".ge-clone-hint-mobile": "fde6e603285602e2",
".ge-controls": "004ef864b98d552f",
".ge-edge-menu": "6734a5a4c658fb59",
".ge-editor-body": "96e859c8c9bfe4f9",
".ge-export-body": "9746be0da055b9cf",
".ge-export-dialog": "07273df75fec40a5",
".ge-export-overlay": "71118be46a543136",
".ge-export-preview": "d15f3deb0fea3bca",
".ge-export-preview-wrap": "022e7646bf83adb1",
".ge-fx-menu": "34ba0908e3d27a7d",
".ge-gradient-color-row": "ae1866697d4fb492",
".ge-inpaint-popup": "375180aa2e257450",
".ge-inpaint-popup-input": "82d3fa4cb683d69e",
".ge-layer-action-flash": "3adf53342ca69b3f",
".ge-layer-drag": "bec7d2f34c0e079b",
".ge-layer-geometry-grid": "d478f4137955e85a",
".ge-layer-group-row .ge-group-inline-thumb": "bb0f88ce29ae7637",
".ge-layer-group-row .ge-layer-controls": "da1a0cafc90aff7f",
".ge-layer-group-row .ge-layer-group-drag": "33cf63103dbea379",
".ge-layer-group-row .ge-layer-group-name": "48642695eec50f01",
".ge-layer-group-row .ge-layer-group-toggle": "cb04950c0a50aacb",
".ge-layer-group-row .ge-layer-lock-btn": "bb0f88ce29ae7637",
".ge-layer-group-row .ge-layer-opacity-row": "e697b236b3b4ba6e",
".ge-layer-group-row .ge-layer-vis": "2e6dbbab8d823b4f",
".ge-layer-lock-menu": "528deb6e31022e89",
".ge-layer-lock-option": "38a3da4a8785330a",
".ge-layers-grab": "094e764489b1b069",
".ge-layers-header": "03ee9ca6c3fc912b",
".ge-layers-list": "18de5e01e079682f",
".ge-main-canvas": "274650d62945ac12",
".ge-mask-sub-item": "546f03f9c46eb507",
".ge-right-panel": "3d0aed961f2df331",
".ge-section-help": "8bd5ecd734f48ca2",
".ge-shortcuts-grid": "84d3ab6453dab9b3",
".ge-slider-bubble": "2bc54e40357f68cf",
".ge-tool-btn": "fd54cca726e51429",
".ge-tool-sep": "4ae61e4e4eddd504",
".ge-toolbar": "ca827cf012ad8863",
".ge-topbar": "6c39a6ade412d5fa",
".ge-transform-field": "76a785f630632652",
".ge-transform-field > input.ge-transform-popup-input": "b870e4e9d5fc2d91",
".ge-transform-popup-body": "d9d70292e5be7acc",
".ge-transform-popup-head .ge-adj-icon": "88ac29d44343e6fa",
".ge-transform-popup-head .ge-adj-title": "1d6ca66f2c39c9ef",
".ge-transform-popup-head .ge-head-btns": "ecd0a09cb27407d7",
".ge-transform-popup-hint": "402aac74aea3c779",
".ge-transform-popup-suffix": "d55491303f919f61",
".ge-transform-quick-btn": "e74ea16e29b9b001",
".ge-transform-spin": "22dc5f2bfab2ec9a",
".ge-transform-spin button": "527810c9f1ac1be5",
".ge-transform-spin button[data-spin=\"down\"]": "c0aff9fcdb755ed2",
".ge-transform-spin button[data-spin=\"up\"]": "4fccf8fbe6c04fa9",
".hamburger": "2b0e4a6cb3333ded",
".hamburger span": "6e2fee4770672a20",
".hamburger-btn": "dc0f91fb82ccd472",
".hljs": "6ecc4f2f88eac570",
".hwfit-cached-item .hwfit-serve-panel": "14fffc9c4952ddd5",
".hwfit-cached-menu-btn svg": "f7ad8ff47d5e5a9e",
".hwfit-context-calc-btn": "30ab84fd32699c1a",
".hwfit-context-control": "31f335a5f5f7093b",
".hwfit-context-control .hwfit-sf[data-field=\"ctx\"]": "c4367757132248ae",
".hwfit-ctx-control": "d175d7225fe97089",
".hwfit-ctx-control input[type=\"range\"]": "947b1970077d4f4a",
".hwfit-ctx-control output": "9a1f476037c00dc2",
".hwfit-ctx-control span": "66e420440d60beee",
".hwfit-help-chip": "d51bfab71de7b80e",
".hwfit-hw-chip-toggle": "e0e58ca582f813cd",
".hwfit-numstep": "0ec0a0c4db53c2cd",
".hwfit-panel-fields": "4e6d730d4a665d2e",
".hwfit-panel-fields input[type=\"text\"]": "caeeb82204c15e3e",
".hwfit-preset-model": "f86026efe0d3498c",
".hwfit-sched-day-chip": "0a2f0ddb74896f9f",
".hwfit-schedule-row": "ee03e04722554137",
".hwfit-serve-cmd-details[open] > .hwfit-serve-cmd-summary": "4952b4e93e125c16",
".hwfit-serve-preset-row": "c6c4d6a2f3c3e6c3",
".hwfit-serve-row .cookbook-serve-slots": "a7be413a7490cc01",
".hwfit-serve-row-core .hwfit-context-label": "a0b969fbae15de72",
".hwfit-serve-topline": "d9feede09cc16f87",
".hwfit-serve-topline .hwfit-serve-runtime-note": "15b3fef4e5ceaffc",
".hwfit-serve-topline .hwfit-serve-runtime-text": "57c6822b0d13147d",
".hwfit-spec-group .hwfit-spec-method": "5d1c329116272cf1",
".icon-rail": "e3f72205260a4a5f",
".icon-rail-btn": "a5f207c3be15401e",
".incognito-btn": "9833a2e86fab6a94",
".incognito-indicator": "b3945c46844c7278",
".input-icon-btn": "2e073a8e266f201e",
".item-drag-handle": "5f889f0059232495",
".list-item": "0653eb1e34c6b7d1",
".list-item .hamburger": "2092eaea70b49de6",
".loading-dot": "686e1bb53f792ac5",
".loading-dots": "997b347b61484fc0",
".loading-indicator": "eb9f3c301fbb4976",
".md-scroll-arrow svg": "6f1ae7652f2dd51a",
".md-toolbar-items #doc-pdf-add-text-btn": "950a49c2ef935d9e",
".md-toolbar-sep": "146c50b38436d02a",
".md-view-toggle": "b092a4e770904171",
".md-view-toggle .md-view-opt": "3192fcae7d1f0ab6",
".md-view-toggle .md-view-opt.active": "3ee0bf92dfab3072",
".memory-item": "85cccfa273eae409",
".memory-item-actions": "8642d181c8fe3c2a",
".memory-item-actions .memory-item-btn": "7a9214669a7cc7b6",
".memory-item-title": "d50a89fe377bbe0d",
".memory-menu-btn": "d7378a97a127f815",
".memory-tab-panel[data-memory-panel=\"skills\"]": "f18376de38243808",
".memory-toolbar": "9285742f00c21d49",
".memory-toolbar-btn": "a67a54d4043e34ab",
".minimize-btn": "d22769aec9c30099",
".minimized-dock-chip": "af70d8f0eb594924",
".minimized-dock-chip svg": "5f94d9cfc3935c34",
".mobile-new-chat-btn": "8505c445195e7b15",
".modal": "9345624780009d14",
".modal-content": "e5b005cecef5d140",
".modal-content.modal-closing": "8bdccec5cd43b27b",
".modal-header": "2c8f003ed4b092ad",
".mode-toggle": "43526722c32158c5",
".mode-toggle-btn": "08df82025829cf7b",
".mode-toggle.mode-toggle-three [data-llama-mode=\"unified\"] > span": "adf60064ba19f5de",
".model-picker-list .model-switch-item": "9077ef9414c73080",
".model-picker-list .mp-fav-dot": "cde6ff8162ad99db",
".models-row": "d882bd9517efbca7",
".msg": "e874630644527764",
".msg .body": "454c7e06516e1201",
".msg .role": "2aa3fc47cc98be04",
".msg-action-btn[data-action=\"shorten\"]": "39875219ab81b49e",
".msg-actions": "015e01ce5213f013",
".msg-ai": "ca278b97f3b29560",
".msg-user": "3964d2a2b7460aa6",
".note-card": "1f346ea173c883c6",
".note-card-colors": "329743d4521bb459",
".note-card-copy-corner": "7759db4e507df9aa",
".note-card-corner-copy": "0874439a31308004",
".note-card-corner-copy svg": "c514aac06948c53e",
".note-card-done": "c1c08dfe20a2319f",
".note-card-edit-corner": "28358acd0d495d56",
".note-card-image": "2d43d9998c0054e2",
".note-check-text": "bf79d1927ab1354c",
".note-color-blue": "a639c83faed486a5",
".note-color-green": "2ec9b93d7e537274",
".note-color-orange": "d6b28667f64299ed",
".note-color-purple": "09413154ae0539b7",
".note-color-red": "3578124008036b4c",
".note-color-yellow": "0375be5e8a17c052",
".note-form-actions-group": "01325dedac4f2135",
".note-form-header": "981e2a2bef7d2401",
".note-form-meta .note-form-label": "4523cb29977907ee",
".note-form-remind-btn": "3d8e742995aab275",
".note-form-type-pill": "c2e1cdc092ce35d2",
".note-fullscreen-overlay .note-cl-add-row .note-cl-add": "6c5a9e646b44c5ad",
".notes-header-btn-label": "6756fa87daa244e9",
".notes-header-text-btn": "0bc6208299bf6f01",
".notes-pane-title": "66bcc3f992df720f",
".notes-pane.notes-view-grid .note-card": "a327d50c6b0bf852",
".notes-pane.notes-view-grid .note-form": "f393ab7b10692ea2",
".notes-pane.notes-view-grid .notes-labels-bar": "71aff777c51126c6",
".notes-pane.notes-view-grid .notes-pane-body": "65a4c9d6644c1c37",
".notes-pane.notes-view-grid .notes-quick-add": "87b5d77fec70f8bd",
".notes-quick-add": "b56d9585019420b5",
".notes-quick-icon": "83f7f73487cc23da",
".notes-quick-input": "8760b2b8588274fd",
".notes-quick-type-pill": "55c9f6527ebc8b3d",
".notes-quick-type-pill svg": "a73e69725b9d97b6",
".notes-quick-type-seg": "de7a4f92c908db4e",
".pdf-export-overlay .modal-content": "3b5f35912509daf2",
".preset-btn": "11527aa662becc84",
".preset-btn.active": "2fc1631701e917c2",
".preset-range": "6c05bf7f8a880758",
".private-browser-preview-frame img": "b1866aa1b67e4626",
".reasoning-effort-prefix": "51c4c138641d61e2",
".recipient-chip": "f88380e8f4c09cc5",
".recipient-chips": "83fad85374d3c475",
".recording-content": "d5e0c053ceaf4f29",
".recording-error": "5a2f9e8095e1b45e",
".recording-icon": "cb60d94934ee919a",
".recording-text": "58e99b27946bafd6",
".red-text": "a93f1ed45d8646c0",
".research-badge": "fcf661967461fc70",
".research-cat": "a523b00cdffd4546",
".research-category-row": "09701efa1598810c",
".research-job-action[data-action=\"delete\"]": "d1bf97c182a9a746",
".research-job-card": "c71a3e1a36a8c265",
".research-new-job h2": "5e67de78656d65dc",
".research-sb-dot": "3fd3b71cb6c521ce",
".research-start-btn": "89df2d121b24f52b",
".research-synapse .rs-meta": "59320709dd4454db",
".research-synapse .rs-meta .rs-status": "2e1a6f4a3fda05dc",
".scroll-nav-btn": "436360e8d744c897",
".search-result-snippet": "fac8292c8f391904",
".section": "37fc733d59519a08",
".section-header-btn": "5d949e304c239db4",
".section-header-flex": "bfcb656a86ca5a6a",
".send-btn": "b29fc333cc66ed9d",
".send-btn.anim-launch svg": "25d7e30ad49d3374",
".session-bulk-bar": "a2833fd7ce35e639",
".session-bulk-btn": "827a6bb9524080c9",
".session-loading-state": "95adb7c1b1e67832",
".session-menu-btn": "43014a6ecb68472a",
".session-skeleton-bubble": "23878c71363d5db7",
".session-skeleton-bubble.is-user": "b0e090855a4865be",
".session-skeleton-line": "05960e43aaa5fa1c",
".settings-layout": "9e37e5723ec05246",
".settings-nav-item": "1db63b881ad2eba4",
".settings-panels": "728f16c852c993b6",
".settings-row": "1a84bec9dc21c226",
".settings-select": "13a40c3922ce8015",
".settings-sidebar": "2b39df3565e83790",
".settings-sidebar-content": "88544f44fdb2ca73",
".settings-sidebar-divider": "cdadb5f574b44236",
".settings-sidebar.settings-sidebar-collapsed": "d9d8f25df95d9ad8",
".settings-sidebar.settings-sidebar-collapsed .settings-sidebar-content": "608e51170864586a",
".settings-system-logs-tab": "d0d90a13ff327ace",
".sidebar": "77ec42a646a8db66",
".sidebar .section": "37fc733d59519a08",
".sidebar-brand-title": "2b20f75768b50346",
".sidebar-header": "0d58816ca65b7216",
".sidebar-inner": "ad5e28fedbfeb5db",
".sidebar-notif-dot": "2673e215d0cd84a6",
".sidebar-user-bar": "b77372dbdcb069ef",
".sidebar.hidden": "0df301b638e2f93f",
".sidebar.right-side": "2b6bcc7d59f7b6bd",
".skill-card > .skill-card-header": "e2408e762818d2fb",
".skill-card-tags": "a327c85e053e6d3f",
".skill-card-textcol": "f1059ed37c120b60",
".skill-card.doclib-card-expanded": "b7f5d0edd75b1422",
".skill-kebab-btn": "5cd557d02656d079",
".spinner": "e403b24f35c58e53",
".stop-recording-btn": "d5489a601cfabf69",
".styled-confirm-box": "90a27b200f7179c1",
".styled-confirm-box .modal-body p": "39733c5420c1521b",
".styled-confirm-box .modal-footer": "0d1b29f6f7487848",
".styled-prompt-box": "1f3925c0236d652e",
".styled-prompt-input": "a4e24959b76553cf",
".task-card .memory-item-actions": "33e67907b02e543c",
".task-log-name": "f29203861c94fb42",
".theme-grid": "0f53a4921d5a478c",
".thumb button": "323943969cfbb194",
".thumb-collapsed": "c967b682b6c604e4",
".thumb.thumb-image .thumb-img": "4a4da275eee6cc70",
".toast": "c60c6a60d4982005",
".toast-action-hint": "13c8621842a92195",
"a": "bb0f88ce29ae7637",
"body": "a402cd1690151695",
"body.notes-drag-mode .note-card": "38ee1aee83dccef6",
"body.notes-view .chat-container": "8ba89017b94cf407",
"button.cal-add-btn.cal-add-btn-text": "7e6817fca9a759dc",
"button.cal-add-btn.cal-add-btn-text.cal-add-btn-sm": "d5d12fbae3d58592",
"button.cal-add-btn.cal-add-btn-text.cal-add-btn-sm .cal-add-label": "9b674e66a8ae6380",
"button.cal-add-btn.cal-add-btn-text.cal-add-btn-sm .cal-add-plus": "69dca313be48c31e",
"button.cal-nav.cal-toolbar-arrow": "7c252b6e2024fdb4",
"code": "0b8a26334c2cdc21",
"details": "1979b4f98bd52f98",
"details a": "5a744551046b2383",
"h2": "8a4d59fbdab8c1ad",
"pre": "a5f2d86c696dc650",
"pre .copy-code": "a9489a5797fcf296",
"pre .edit-code": "a9489a5797fcf296",
"pre .run-code": "a9489a5797fcf296",
"table": "fc1e26a096450665",
"td": "b782f3027093fc82",
"th": "fd81ff4190b2f023"
},
"login": {
"body": "64975bdadce9f6e5",
"card": "5139f16a929fa893",
"error": "3eb05963b0d792b6",
"logo": "15751ee911f586da",
"password": "42f0c35485f59dea",
"pw-toggle": "9e5f0475eb7b6c57",
"remember-dot": "62b8fa66ceaf590e",
"remember-toggle": "87bed84dfe9061e8",
"root": "07533d1e7958a57a",
"setup-note": "028127c742bf5f87",
"submit": "5e371dbe68196bfd",
"toggle-link": "289ae7be0ef6e564",
"username": "a8d417a9a08b204c",
"version-label": "cc443cc7790ff3b4"
}
},
"variants": {
"app-shell": {
"desktop-dark-comfortable": "21afac735b96f31e",
"desktop-dark-compact": "c1690c3291f79cef",
"desktop-dark-spacious": "7dbd8d97ad28a540",
"desktop-light-comfortable": "9e88a1c7a8dfc1d2",
"desktop-light-compact": "4c882ebf427c8ff7",
"desktop-light-spacious": "f74cff517a61dad9",
"laptop-dark-comfortable": "95c85f14bfde85e3",
"laptop-dark-compact": "d7c8522e5f70cc07",
"laptop-dark-spacious": "b7245ea652815c27",
"laptop-light-comfortable": "b34f4282a5ef51b7",
"laptop-light-compact": "38887a9d17175a65",
"laptop-light-spacious": "8f3ad89ae046dfe9",
"phone-dark-comfortable": "1a8c2a51ed18c0cc",
"phone-dark-compact": "2a11e3b08826edef",
"phone-dark-spacious": "1a8c2a51ed18c0cc",
"phone-light-comfortable": "5b0af830cb78676b",
"phone-light-compact": "3ffe9d863f213a49",
"phone-light-spacious": "5b0af830cb78676b",
"tablet-dark-comfortable": "f2ddb9a92ac7c0f6",
"tablet-dark-compact": "8ef6c7958cabb3ad",
"tablet-dark-spacious": "f2ddb9a92ac7c0f6",
"tablet-light-comfortable": "2c846ae8e56f881b",
"tablet-light-compact": "f4bfea7dbcd7df5f",
"tablet-light-spacious": "2c846ae8e56f881b"
},
"bench": {
"desktop-dark-comfortable": "2471a82979dfd8d8",
"desktop-dark-compact": "b2807c0c54345c3e",
"desktop-dark-spacious": "41a6d588192a02ed",
"desktop-light-comfortable": "631778e05aca46ef",
"desktop-light-compact": "340c1bbce11d0b32",
"desktop-light-spacious": "e21051317e9d346d",
"laptop-dark-comfortable": "4c931615f7151fc3",
"laptop-dark-compact": "181a9bb9f0add9a0",
"laptop-dark-spacious": "ad24cee14e456d04",
"laptop-light-comfortable": "d635b3b801ff803d",
"laptop-light-compact": "dbe6446fad9cc2f0",
"laptop-light-spacious": "c81a4f935d0270a3",
"phone-dark-comfortable": "ae0aa5695982a188",
"phone-dark-compact": "8ca1949b9817e3c4",
"phone-dark-spacious": "912fe8e2d490162f",
"phone-light-comfortable": "ccd790243858a150",
"phone-light-compact": "2b008b092bec6fb1",
"phone-light-spacious": "c2465ac50f71a035",
"tablet-dark-comfortable": "1d96addb759bc3e3",
"tablet-dark-compact": "53294ba127a61959",
"tablet-dark-spacious": "c26663bc163aa446",
"tablet-light-comfortable": "b66904a1f69417b7",
"tablet-light-compact": "9f0836a37f087c2e",
"tablet-light-spacious": "645c09fa0e1ddb59"
},
"login": {
"desktop-dark-comfortable": "d3d0512f5223397e",
"desktop-dark-compact": "d3d0512f5223397e",
"desktop-dark-spacious": "d3d0512f5223397e",
"desktop-light-comfortable": "d3d0512f5223397e",
"desktop-light-compact": "d3d0512f5223397e",
"desktop-light-spacious": "d3d0512f5223397e",
"laptop-dark-comfortable": "d614fc150b017e6e",
"laptop-dark-compact": "d614fc150b017e6e",
"laptop-dark-spacious": "d614fc150b017e6e",
"laptop-light-comfortable": "d614fc150b017e6e",
"laptop-light-compact": "d614fc150b017e6e",
"laptop-light-spacious": "d614fc150b017e6e",
"phone-dark-comfortable": "c7f59e5d8c979f02",
"phone-dark-compact": "c7f59e5d8c979f02",
"phone-dark-spacious": "c7f59e5d8c979f02",
"phone-light-comfortable": "c7f59e5d8c979f02",
"phone-light-compact": "c7f59e5d8c979f02",
"phone-light-spacious": "c7f59e5d8c979f02",
"tablet-dark-comfortable": "ee0918e00caa6875",
"tablet-dark-compact": "ee0918e00caa6875",
"tablet-dark-spacious": "ee0918e00caa6875",
"tablet-light-comfortable": "ee0918e00caa6875",
"tablet-light-compact": "ee0918e00caa6875",
"tablet-light-spacious": "ee0918e00caa6875"
}
}
}
+20
View File
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>CSS snapshot bench</title>
<!-- Replaced at capture time by whatever <link rel="stylesheet"> tags
static/index.html ships, in shell order, so the bench keeps measuring
the real stylesheet set after style.css is split. This fallback is only
for opening the page by hand; it is deliberately unversioned so the
cache-bust string has one home (tests/test_static_stylesheet_manifest.py
pins the shipped ones). -->
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<!-- Intentionally empty. tests/css_snapshot/capture.mjs mounts one subtree
per bench selector from tests/css_snapshot/inventory.json, measures the
leaf, and removes it again - so adding coverage is a one-line inventory
change and this file never drifts from the selector list. -->
</body>
</html>
+266
View File
@@ -0,0 +1,266 @@
// Computed-style capture for the CSS snapshot harness.
//
// Reads a capture job as JSON on stdin, drives headless Chromium over the
// loopback static server, and writes the raw computed values as JSON on
// stdout. Hashing, comparison and baseline storage live on the Python side
// (scripts/css_snapshot.py) so there is exactly one canonicalisation.
//
// Determinism rules that matter here, because the digest is only useful if an
// unchanged stylesheet always produces the same bytes:
// - every <script> is stripped from the document, so the DOM stays exactly
// what the server sends and no app module can mutate classes underneath us;
// - the theme/density classes are injected into the <html> tag *before* the
// first paint instead of toggled afterwards, so no CSS transition is ever
// mid-interpolation while getComputedStyle runs;
// - images, fonts and media are aborted: they cost time and change nothing
// in the pinned property set;
// - scrollbars are hidden, so a platform's scrollbar width cannot change the
// width that percentage and auto values resolve against.
import process from 'node:process';
function readStdin() {
return new Promise((resolve, reject) => {
let raw = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => { raw += chunk; });
process.stdin.on('end', () => resolve(raw));
process.stdin.on('error', reject);
});
}
// Swap the first two blocks that declare `selector` at the same nesting level.
// Used by the harness self-test: if reordering two conflicting declarations of
// the same selector does not move the digest, the harness is not watching
// anything worth watching.
function swapRuleOccurrences(css, selector) {
const blocks = [];
let depth = 0;
let start = 0;
for (let i = 0; i < css.length; i += 1) {
const ch = css[i];
if (ch === '{') {
if (depth === 0) {
const sel = css.slice(start, i).trim().replace(/\s+/g, ' ');
blocks.push({ selector: sel, start, bodyStart: i });
}
depth += 1;
} else if (ch === '}') {
depth -= 1;
if (depth === 0) {
blocks[blocks.length - 1].end = i + 1;
start = i + 1;
}
}
}
const matches = blocks.filter(b => b.selector === selector && b.end !== undefined);
if (matches.length < 2) {
throw new Error(`swap-rule: need two top-level blocks for "${selector}", found ${matches.length}`);
}
const [a, b] = matches;
const textA = css.slice(a.start, a.end);
const textB = css.slice(b.start, b.end);
return css.slice(0, a.start) + textB + css.slice(a.end, b.start) + textA + css.slice(b.end);
}
// The whole measurement runs inside one page function: Playwright serialises
// the function source, so anything it calls has to be declared inside it.
//
// buildBenchNode is a minimal selector-to-DOM builder for the bench page. It
// supports descendant and child combinators over compound selectors made of a
// tag, an id, classes and [attr=value] pairs - which is what the
// high-redeclaration selectors in style.css are made of. Anything else is
// reported as missing rather than silently benched as the wrong element.
function pageMeasure(job) {
function buildBenchNode(selector) {
const parts = selector.split(/\s*>\s*|\s+/).filter(Boolean);
let root = null;
let parent = null;
let leaf = null;
for (const part of parts) {
const m = part.match(/^([a-zA-Z][\w-]*)?((?:[#.][\w-]+|\[[^\]]+\])*)$/);
if (!m || (!m[1] && !m[2])) throw new Error('unsupported bench selector: ' + selector);
const el = document.createElement(m[1] || 'div');
const tokens = (m[2] || '').match(/[#.][\w-]+|\[[^\]]+\]/g) || [];
for (const token of tokens) {
if (token[0] === '#') el.id = token.slice(1);
else if (token[0] === '.') el.classList.add(token.slice(1));
else {
const attr = token.slice(1, -1);
const eq = attr.indexOf('=');
if (eq === -1) el.setAttribute(attr, '');
else el.setAttribute(attr.slice(0, eq), attr.slice(eq + 1).replace(/^["']|["']$/g, ''));
}
}
if (parent) parent.appendChild(el); else root = el;
parent = el;
leaf = el;
}
if (!leaf) throw new Error('empty bench selector');
return { root, leaf };
}
function readStyle(el, pseudo, properties, wantCustom) {
const cs = getComputedStyle(el, pseudo || undefined);
const values = {};
for (const prop of properties) values[prop] = cs.getPropertyValue(prop);
if (wantCustom) {
const names = [];
for (let i = 0; i < cs.length; i += 1) {
const name = cs.item(i);
if (name.startsWith('--')) names.push(name);
}
names.sort();
for (const name of names) values[name] = cs.getPropertyValue(name).trim();
}
return values;
}
// Modals and menus ship hidden in the served markup. Revealing one element
// at a time - and putting the class back straight after - keeps each
// measurement independent of the others.
function reveal(el) {
const undo = [];
let node = el;
while (node && node !== document.documentElement) {
if (node.classList && node.classList.contains('hidden')) {
const target = node;
target.classList.remove('hidden');
undo.push(() => target.classList.add('hidden'));
}
if (node.hasAttribute && node.hasAttribute('hidden')) {
const target = node;
target.removeAttribute('hidden');
undo.push(() => target.setAttribute('hidden', ''));
}
node = node.parentElement;
}
return () => { for (const fn of undo.reverse()) fn(); };
}
const measured = {};
const missing = [];
for (const entry of (job.elements || [])) {
const el = document.querySelector(entry.selector);
if (!el) { missing.push(entry.key); continue; }
const restore = entry.unhide ? reveal(el) : null;
// Reading a layout property forces the style and layout pass before the
// computed values are read back.
void document.body.offsetHeight;
measured[entry.key] = readStyle(el, entry.pseudo, job.properties, !!entry.custom);
if (restore) restore();
}
for (const selector of (job.bench || [])) {
let built;
try {
built = buildBenchNode(selector);
} catch (err) {
missing.push(selector);
continue;
}
document.body.appendChild(built.root);
void document.body.offsetHeight;
measured[selector] = readStyle(built.leaf, null, job.properties, false);
built.root.remove();
}
return { measured, missing };
}
// The bench page must load whatever the app shell loads. Once style.css is
// split, the shell will pull in several ordered stylesheets and a bench that
// kept linking style.css alone would measure a stylesheet the app no longer
// serves on its own - and report the extraction as clean when it was not.
function stylesheetLinks(html) {
const links = html.match(/<link\b[^>]*rel=["']stylesheet["'][^>]*>/gi) || [];
return links.join('\n ');
}
async function main() {
const job = JSON.parse(await readStdin());
const { chromium } = await import('playwright');
const browser = await chromium.launch({ headless: true, args: ['--hide-scrollbars'] });
const snapshot = {};
const missing = {};
try {
for (const page of job.pages) {
snapshot[page.name] = {};
let shippedStylesheets = null;
if (page.stylesheetsFrom) {
const source = await fetch(job.origin + page.stylesheetsFrom);
if (!source.ok) throw new Error(`${page.stylesheetsFrom} returned ${source.status}`);
shippedStylesheets = stylesheetLinks(await source.text());
if (!shippedStylesheets) throw new Error(`no stylesheet links found in ${page.stylesheetsFrom}`);
}
for (const variant of job.variants) {
const context = await browser.newContext({
viewport: { width: variant.width, height: variant.height },
deviceScaleFactor: 1,
colorScheme: variant.colorScheme || 'dark',
reducedMotion: 'no-preference',
forcedColors: 'none',
hasTouch: !!variant.touch,
isMobile: false,
javaScriptEnabled: true,
});
const tab = await context.newPage();
// Registered first so the document/stylesheet handlers below win:
// Playwright matches the most recently registered route.
await tab.route('**/*', route => {
const type = route.request().resourceType();
if (type === 'image' || type === 'media' || type === 'font') return route.abort();
return route.continue();
});
if (job.swapRule) {
await tab.route('**/static/style.css*', async route => {
const response = await route.fetch();
const body = swapRuleOccurrences(await response.text(), job.swapRule);
await route.fulfill({ response, body, headers: { ...response.headers(), 'content-type': 'text/css; charset=utf-8' } });
});
}
const documentPath = page.url.split('?')[0];
await tab.route(`**${documentPath}`, async route => {
const response = await route.fetch();
let html = await response.text();
html = html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '');
if (shippedStylesheets !== null) {
html = html.replace(/<link\b[^>]*rel=["']stylesheet["'][^>]*>/gi, '');
html = html.replace(/<\/head>/i, ` ${shippedStylesheets}\n</head>`);
}
const classes = [variant.theme === 'light' ? 'light' : '', variant.density && variant.density !== 'comfortable' ? `density-${variant.density}` : '']
.filter(Boolean).join(' ');
html = html.replace(/<html\b([^>]*)>/i, (match, attrs) => `<html${attrs.replace(/\sclass="[^"]*"/i, '')} class="${classes}">`);
await route.fulfill({ response, body: html, headers: { ...response.headers(), 'content-type': 'text/html; charset=utf-8' } });
});
const response = await tab.goto(job.origin + page.url, { waitUntil: 'load' });
if (!response || !response.ok()) {
throw new Error(`${page.url} returned ${response ? response.status() : 'no response'}`);
}
const result = await tab.evaluate(pageMeasure, {
elements: page.elements || [],
bench: page.bench || [],
properties: job.properties,
});
snapshot[page.name][variant.name] = result.measured;
if (result.missing.length) missing[`${page.name}/${variant.name}`] = result.missing;
await context.close();
}
}
} finally {
await browser.close();
}
process.stdout.write(JSON.stringify({ snapshot, missing }));
}
main().catch(err => {
process.stderr.write(String(err && err.stack ? err.stack : err) + '\n');
process.exit(1);
});
File diff suppressed because it is too large Load Diff
+104
View File
@@ -0,0 +1,104 @@
"""Computed-style snapshot regression for ``static/style.css``.
The stylesheet is one 51k-line file whose result depends on source order, so an
extraction that "looks fine" can still change which declaration wins. These
tests capture ``getComputedStyle`` over a fixed element inventory across pages,
viewports, themes and density modes, and compare the hash against
``tests/css_snapshot/baseline.json``.
Run ``python scripts/css_snapshot.py --write-baseline`` to re-record the
baseline, and only after confirming the change is intended - see
``tests/css_snapshot/README.md``.
"""
import os
import pytest
from tests.helpers.cli_loader import load_script
snapshot = load_script("css_snapshot.py")
_HAS_BROWSER = snapshot.playwright_available()
_requires_browser = pytest.mark.skipif(
not _HAS_BROWSER,
reason="node with the playwright package is required (npm ci)",
)
# The session static server publishes its ephemeral origin here. The literal
# fallback is the fixed port the fixture used before it moved to an ephemeral
# one, so this module works on either revision.
STATIC_ORIGIN = os.environ.get("ODYSSEUS_TEST_STATIC_ORIGIN", "http://127.0.0.1:7011")
# One conflicting selector used to prove the harness is actually sensitive to
# source order. `.attach-strip` is declared three times at the top level of
# style.css with different margin, min-height and padding, so swapping the
# first two changes which declaration wins without changing a single byte of
# any individual rule.
CONFLICTING_SELECTOR = ".attach-strip"
def test_baseline_covers_every_inventory_entry():
"""The committed baseline and the inventory describe the same surface.
Cheap and browserless: it catches an inventory entry added without
re-recording the baseline, which would otherwise look like a pass because
nothing compares an absent key.
"""
inventory = snapshot.load_inventory()
baseline = snapshot.load_baseline()
variant_names = {variant["name"] for variant in inventory["variants"]}
for page in inventory["pages"]:
name = page["name"]
assert name in baseline["elements"], f"{name} missing from the baseline"
expected_keys = {entry["key"] for entry in page.get("elements", [])}
expected_keys |= set(page.get("bench", []))
assert set(baseline["elements"][name]) == expected_keys, (
f"{name}: baseline elements differ from the inventory; "
"re-record with scripts/css_snapshot.py --write-baseline"
)
assert set(baseline["variants"][name]) == variant_names
@_requires_browser
def test_computed_styles_match_the_committed_baseline():
captured = snapshot.capture(STATIC_ORIGIN)
assert captured["missing"] == {}, (
"inventory entries matched no element - the markup moved under the "
f"harness: {captured['missing']}"
)
summary = snapshot.summarize(captured["snapshot"])
drift = snapshot.compare(snapshot.load_baseline(), summary)
assert not drift["elements"] and not drift["variants"] and not drift["digest_changed"], (
"computed styles moved against tests/css_snapshot/baseline.json.\n"
f"elements: {drift['elements']}\n"
f"variants: {drift['variants']}\n"
"If the change is intended, re-record with "
"`python scripts/css_snapshot.py --write-baseline`; if it is not, the "
"restructuring changed which declaration wins."
)
@_requires_browser
def test_reordering_two_conflicting_declarations_moves_the_digest():
"""The harness has to fail when the cascade changes, or it proves nothing.
Captures one variant twice - once normally, once with the first two
top-level `.attach-strip` blocks swapped - and asserts the digest moves and
points at the affected element.
"""
variants = ["desktop-dark-comfortable"]
unchanged = snapshot.summarize(
snapshot.capture(STATIC_ORIGIN, variants=variants)["snapshot"]
)
reordered = snapshot.summarize(
snapshot.capture(STATIC_ORIGIN, variants=variants,
swap_rule=CONFLICTING_SELECTOR)["snapshot"]
)
assert unchanged["digest"] != reordered["digest"]
drift = snapshot.compare(unchanged, reordered)
assert "app-shell/attach-strip" in drift["elements"]
assert "bench/.attach-strip" in drift["elements"]
+102
View File
@@ -0,0 +1,102 @@
"""Stylesheet asset manifest: shipped HTML and the service worker must agree.
``specs/frontend.md`` records that nothing validates the service-worker
precache against what ``index.html`` actually loads, and that a static asset
manifest regression should confirm referenced files exist. Both matter more
once ``static/style.css`` starts being split: every new stylesheet has to be
registered in ``static/index.html`` *and* in the ``static/sw.js`` precache,
with the same ``?v=`` cache-bust string in both. Today those two strings are
hardcoded independently, so a split that updates one and not the other ships an
offline cache that serves a stale or missing stylesheet - invisible until
someone loads the app without a network.
This test reads the shipped files rather than driving a browser: the artifact
under test *is* the URL string in two files, and a browser would only see one
of them at a time.
"""
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
INDEX_HTML = ROOT / "static" / "index.html"
SERVICE_WORKER = ROOT / "static" / "sw.js"
_STYLESHEET_LINK = re.compile(
r"""<link\b[^>]*\brel=["']stylesheet["'][^>]*>""", re.IGNORECASE)
_HREF = re.compile(r"""\bhref=["']([^"']+)["']""", re.IGNORECASE)
def _stylesheet_hrefs(html_path):
"""Every href from a <link rel="stylesheet"> in one HTML file."""
html = html_path.read_text(encoding="utf-8")
hrefs = []
for tag in _STYLESHEET_LINK.findall(html):
match = _HREF.search(tag)
if match:
hrefs.append(match.group(1))
return hrefs
def _precache_entries(name):
"""The quoted URLs of one precache array in sw.js.
Entries built by spreading another list (``...KATEX_FONTS``) are skipped:
they carry no literal URL at this level, and the fonts they add are not
stylesheets.
"""
source = SERVICE_WORKER.read_text(encoding="utf-8")
match = re.search(rf"const {name} = \[(.*?)\n\];", source, re.DOTALL)
assert match, f"{name} array not found in static/sw.js"
return re.findall(r"""['"](/[^'"]+)['"]""", match.group(1))
def _local_path(url):
"""Map an app-absolute URL to the file it is served from."""
return ROOT / url.split("?", 1)[0].lstrip("/")
def test_every_stylesheet_referenced_by_shipped_html_exists():
checked = 0
for html_path in sorted((ROOT / "static").glob("*.html")):
for href in _stylesheet_hrefs(html_path):
if not href.startswith("/"):
continue # external or relative-to-page; not ours to resolve
assert _local_path(href).is_file(), (
f"{html_path.name} links {href}, which does not exist on disk"
)
checked += 1
assert checked, "no app stylesheet links found - the parser or the markup moved"
def test_every_precached_stylesheet_exists():
for name in ("PRECACHE", "PANEL_PRECACHE"):
for url in _precache_entries(name):
if not url.endswith(".css") and ".css?" not in url:
continue
assert _local_path(url).is_file(), (
f"sw.js {name} precaches {url}, which does not exist on disk"
)
def test_index_stylesheets_are_precached_with_the_same_cache_bust_string():
"""The precache must match the request the browser actually makes.
A service-worker entry only matches a fetch when the URL is identical,
query string included, so a stylesheet whose ``?v=`` differs between the
two files is precached under a URL nothing ever requests.
"""
precached = set(_precache_entries("PRECACHE"))
precached_paths = {url.split("?", 1)[0]: url for url in precached}
for href in _stylesheet_hrefs(INDEX_HTML):
if not href.startswith("/static/"):
continue
path = href.split("?", 1)[0]
assert path in precached_paths, (
f"index.html loads {href} but sw.js PRECACHE has no entry for {path}; "
"the app shell would not be available offline"
)
assert precached_paths[path] == href, (
f"cache-bust mismatch for {path}: index.html requests {href!r}, "
f"sw.js precaches {precached_paths[path]!r}"
)