Compare commits

...
19 Commits
Author SHA1 Message Date
peter 4b1f6d7ec2 release: 0.107.3 (improvement/dev-tooling) 2026-10-17 21:34:53 +02:00
peter 51bf780100 chore(release): 0.107.3 — text on glass holds 4.5:1; the e2e suite runs axe, the gate lints every shell script 2026-10-17 21:21:37 +02:00
peter 3ada34073b fix(theme): text on glass holds 4.5:1 — light --muted kept at 6:1 on the page, tonal fills opaque
Pixel-measured over every scheme x mode x backdrop: the item count fell to 3.8:1 (dark too: its 8%
fill let the toolbar glass and the thumbnails under it through), Channels' tabs to 4.1:1 and the
current Files row to 4.4:1. Light --muted moves 15% toward --fg in every scheme; the badges, the item
count and the current rail row paint opaque bg-tint* fills mixed into the surface. The token contract
now asks 6:1 of --muted on the bare page and checks those three sites use the tints.
2026-10-17 20:06:54 +02:00
peter d74f2a9ca1 fix(deps): source-map-js 1.2.2 — the indexed-source-map denial-of-service advisory (dev tooling only) 2026-10-17 17:51:10 +02:00
peter a8dddc8c24 test(e2e): axe-core's WCAG A + AA rules over every page and the player (chromium only); contrast stays with the visual check 2026-10-17 17:48:53 +02:00
peter 460bdec406 chore(tooling): drop scripts/lint-changed.sh — the shared gate runner's Stop hook lints the turn's files, shellcheck included 2026-10-17 17:22:35 +02:00
peter 66181d1394 chore(gate): shellcheck joins the gate — every tracked shell script, the host binary held to a pin 2026-10-17 15:31:34 +02:00
peter 0a1fef28ac docs(claude): the YouTube write-error helper, the overlay and z-scale rule, and px-frozen sizes join the canonical patterns 2026-10-17 14:47:57 +02:00
peter fe572d9207 docs: a copy-only change runs the Conformance check's step 4 alone, in both languages 2026-10-17 14:27:45 +02:00
peter c8247c0dde release: 0.107.2 (improvement/gate-runner) 2026-10-17 04:37:30 +02:00
peter 722c37a6d0 chore(release): 0.107.2 — the gate lists and runs single lanes, the backend tests use every core 2026-10-17 04:30:33 +02:00
peter 5ba5679239 test: tests that assumed an idle machine wait for what they assert, or carry a budget for their work
Under a busy machine (the gate's lanes side by side, 12 busy cores) these went red at random: a press landed while the button was still busy, an answer was settled before it was asked for, a page grew past one wait's budget, a lazy chunk was transformed inside a 1 s wait, focus was taken a frame after the press. They now wait for the condition they depend on, preload the chunks they open, and the tests that do heavy work (whole-repo scans, the type-aware linter, module resets) carry an explicit budget naming that work. The whole suite four times under 12 busy-loop processes: red 4/4 before, 0/4 after.
2026-10-17 01:08:15 +02:00
peter 926e5e9899 chore(gate): check.sh can list its lanes and run one of them
--list prints the lane names, --only "<lane>" runs that one lane, so a runner can schedule the lanes itself and a red lane can be re-run on its own. An --only run reports that lane, never the gate, and an unknown name exits 2 instead of passing with nothing run; the contract test holds both.
2026-10-16 02:53:30 +02:00
peter 9251918e08 chore(test): the DB lane runs on every core — each pytest-xdist worker gets its own database 2026-10-16 01:13:31 +02:00
peter 2b6d968833 release: 0.107.1 (improvement/gate-timing) 2026-10-16 00:45:45 +02:00
peter 95e6b2c3d9 chore(release): 0.107.1 — a failed download's explanation says press Retry 2026-10-16 00:33:59 +02:00
peter 80c55fbf1b merge bug/download-retry-copy into improvement/gate-timing 2026-10-16 00:33:38 +02:00
peter 011f269152 chore(gate): every lane reports how long it took, and a run can log the times for comparison 2026-10-16 00:13:47 +02:00
peter dcb244dd04 fix(downloads): a failed download's explanation says press Retry, not retry it from a list that is gone 2026-10-15 22:56:40 +02:00
45 changed files with 734 additions and 228 deletions
+22
View File
@@ -7,6 +7,28 @@ edit the release notes and re-run the generator.
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
## 0.107.3 — 2026-10-17
### New
- Development: the end-to-end suite runs the WCAG A and AA accessibility checks over every page and the player, and every shell script is linted in the gate.
### Fixed
- Readability: muted text in the light themes is a shade darker, and the item count, the labels and the selected Files folder sit on a solid tint, so text on the frosted-glass bars stays readable (4.5:1) in every scheme.
## 0.107.2 — 2026-10-17
### New
- Development: the gate script can list its checks (scripts/check.sh --list) and run just one (--only), and the backend tests run on every CPU core, each worker with its own database.
## 0.107.1 — 2026-10-16
### Fixed
- Downloads: a failed download's explanation now says to press Retry, instead of pointing to a download list that no longer exists.
## 0.107.0 — 2026-10-15
### New
+7 -4
View File
@@ -12,7 +12,7 @@ router-free navigation provider.
## The gate — never merge or ship red
The local gate is **ruff (`check` + `format --check`) + basedpyright (backend) + the lock check + typecheck (3 tsc projects) +
The local gate is **ruff (`check` + `format --check`) + shellcheck + basedpyright (backend) + the lock check + typecheck (3 tsc projects) +
eslint + prettier --check + knip + vitest + vite build + pytest + pip-audit + npm audit**. It runs
before every `release` merges and on every `publish`, which **refuses to build on failure — there is deliberately no `--skip`
flag**; a release also requires the Playwright suite, and the image must pass a Trivy scan before it is pushed. Run it and get it
@@ -55,7 +55,8 @@ how it gets built. Concretely:
- **Challenge unsound requests.** If a request is technically ill-advised and you can back it with real
arguments, push back or propose an alternative — don't comply blindly.
- **Verify UI in the browser before calling it done.** tsc/unit/DOM tests miss visual and interaction
bugs; do a real visual check of any UI change, and run DESIGN.md's four-step Conformance check.
bugs; do a real visual check of any UI change, and run DESIGN.md's Conformance check (a copy-only
change runs its step 4 alone: every state the new text renders in, in both languages).
- **Be terse everywhere else.** Outside those decision points, lead with the answer or decision. During
implementation and debugging especially: no restating the same point reworded, no filler.
@@ -101,7 +102,8 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
| FE view push (history) | `rememberNavPosition()` right before `history.pushState` | a push that forgets: Back strands focus on `<body>` |
| FE static label / button-like link | `Badge` (flat, no edge) · `LinkButton` / `IconLink` | a glass pill that reads as a chip; an `<a>` hand-painted like a button |
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
| FE failed write | `meta.failTitle`, else `notifyActionError(err, title)` | a bare `catch {}` or `notify()` with no error attached |
| FE failed write | `meta.failTitle`, else `notifyActionError(err, title)`; a YouTube write action (subscribe, sync, …): `notifyYouTubeActionError` — a 403 offers Connect | a bare `catch {}` or `notify()` with no error attached |
| FE floating or full-screen layer | `ui/Overlay` (portals to `<body>`) or the popover engine (`AnchoredPopover` portals itself); stacking by the z-index scale (`z-menu` … `z-popover`, docs/DESIGN.md "Layers") | one rendered in place inside a virtualized row or a masked/transformed scroller — clipped or painted over; a magic `z-[n]` |
| FE player on a native media element | `lib/mediaSession` (`useMediaSession`) + `lib/mediaKeys` + `lib/resume` (`useResume`), the element held as STATE | a private `navigator.mediaSession` / `keydown` / position-saving effect per player |
| FE diagnostics | `lib/log.ts` (`log.info("event.code", msg, attrs)`); a filter change → `lib/filterLog.ts`, a player → `lib/mediaLog.ts` | `console.*` (lint-enforced); a value only a higher level may carry, logged unguarded |
| FE state that MIRRORS a prop/query | derive it, or hold a draft (`draft ?? server`) | a copy an effect re-syncs — that paints the stale value first |
@@ -114,7 +116,8 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
Not repeated here, because a lint rule refuses the wrong one and its message names the right one:
buttons (`ui/Button`, `IconButton`, `Pressable`, `Chip`, `DragHandle`), text fields and native
controls (`ui/Input`, `Checkbox`, `Slider`, …), glass surfaces (`SURFACE.*`), a native `<select>`
(`SegmentedToggle` / `FilterSelect`), a picture (`ui/RemoteImage`, never a bare `<img>`), and network calls (`req` / `uploadReq` / `beacon`, `lib/http.ts`).
(`SegmentedToggle` / `FilterSelect`), a picture (`ui/RemoteImage`, never a bare `<img>`), a box or font size (the spacing scale or rem,
never a px-frozen `w-[Npx]` / `text-[Npx]`: the text-size slider scales the root font), and network calls (`req` / `uploadReq` / `beacon`, `lib/http.ts`).
## House rules that bite (learned from real bugs)
+1 -1
View File
@@ -1 +1 @@
0.107.0
0.107.3
+4 -1
View File
@@ -16,6 +16,9 @@ services:
POSTGRES_DB: siftlode_test
tmpfs:
- /var/lib/postgresql/data # ephemeral + fast; the schema is rebuilt per run
# One database per pytest-xdist worker, each through the app's own pool (5 + 10 overflow): the default 100
# connections would run out at 8 workers.
command: ["postgres", "-c", "max_connections=300"]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U siftlode -d siftlode_test"]
interval: 1s
@@ -37,4 +40,4 @@ services:
- ./:/app
working_dir: /app
# Coverage is on demand, not in the gate: see docs/development.md.
command: ["python", "-m", "pytest"]
command: ["python", "-m", "pytest", "-n", "auto"]
+6 -1
View File
@@ -7,4 +7,9 @@
# not an unknown marker that quietly does nothing.
testpaths = tests
pythonpath = .
addopts = -q -rs --strict-markers
# `-p tests.db_per_worker`: under pytest-xdist (`-n`) every worker gets its own database — DATABASE_URL + `_gw<N>`,
# created on first use — because the `db` fixture's TRUNCATE-per-test isolation assumes one process per database.
# It is a plugin so it runs before conftest imports the app (whose settings read DATABASE_URL once). Serial: no-op.
# The file is a verbatim copy of a shared tool (see its docstring); change the source, not the copy.
addopts = -q -rs --strict-markers -p tests.db_per_worker
db_per_worker_env = DATABASE_URL
+3
View File
@@ -16,3 +16,6 @@ pytest-cov==7.1.0
ruff==0.15.21
httpx2==2.13.0
pip-audit==2.10.1
# pytest-xdist: the DB lane runs on every core (`-n auto` in docker-compose.test.yml); tests/db_per_worker.py gives
# each worker its own database. Same tests, same strictness — only side by side.
pytest-xdist==3.8.0
+94
View File
@@ -0,0 +1,94 @@
"""Give every pytest-xdist worker its own database, so a suite that shares one test database can run in parallel.
Generic (source of truth: the memory repo's tools/pytest-parallel/db_per_worker.py); a project carries a verbatim copy
next to its conftest — tests often run in a container that cannot see this repo — and
`tools/pytest-parallel/sync-check.sh <copy>` reports drift. Keep the copy byte-identical; change it here.
Use, as a pytest plugin (it then runs before any conftest, i.e. before the app reads its settings):
pytest.ini: addopts = -p tests.db_per_worker (the copy's import path; `python -m pytest` puts cwd on sys.path)
db_per_worker_env = DATABASE_URL (the environment variable that holds the test database)
or call `apply("<ENV VAR>")` yourself before the app is imported. The variable may hold:
- a Postgres URL (`postgresql://…/name`, `postgresql+psycopg://…`): the worker gets `name_gw<N>`, created from the
base database's server if missing (empty — the project's own schema fixture migrates it, as for one process);
- a SQLite URL (`sqlite:///path.db`) or a plain file path: the worker gets `path-gw<N>.db`.
Without xdist (no PYTEST_XDIST_WORKER) or with the variable unset it changes nothing, so a serial run is unaffected.
"""
from __future__ import annotations
import os
import re
from urllib.parse import urlsplit, urlunsplit
import pytest
_PG = re.compile(r"^postgres(?:ql)?(?:\+\w+)?://", re.I)
def pytest_addoption(parser: pytest.Parser) -> None:
parser.addini(
"db_per_worker_env",
"env var holding the test database (a Postgres URL, SQLite URL or file path)",
)
@pytest.hookimpl(tryfirst=True)
def pytest_load_initial_conftests(early_config: pytest.Config) -> None:
var = early_config.getini("db_per_worker_env")
if var:
apply(str(var))
def apply(var: str) -> str | None:
worker = os.environ.get("PYTEST_XDIST_WORKER")
value = os.environ.get(var)
if not worker or not value:
return value
if _PG.match(value):
new = _postgres(value, worker)
else:
new = _file(value, worker)
os.environ[var] = new
return new
def _postgres(url: str, worker: str) -> str:
parts = urlsplit(url)
base = parts.path.lstrip("/")
name = f"{base}_{worker}"
if not re.fullmatch(r"\w+", name):
raise ValueError(f"db_per_worker: refusing an unusual database name {name!r}")
# The server, through the BASE database (it exists; `postgres` may not be reachable for this role).
plain = urlunsplit(
(re.sub(r"\+\w+$", "", parts.scheme), parts.netloc, parts.path, parts.query, "")
)
_create_if_missing(plain, name)
return urlunsplit((parts.scheme, parts.netloc, f"/{name}", parts.query, parts.fragment))
def _create_if_missing(dsn: str, name: str) -> None:
try:
import psycopg # psycopg 3
conn = psycopg.connect(dsn, autocommit=True)
except ImportError:
import psycopg2 # fallback for projects on psycopg2
conn = psycopg2.connect(dsn)
conn.autocommit = True
try:
cur = conn.cursor()
cur.execute("SELECT 1 FROM pg_database WHERE datname = %s", (name,))
if cur.fetchone() is None:
cur.execute(f'CREATE DATABASE "{name}"') # name checked to be \w+ above
finally:
conn.close()
def _file(value: str, worker: str) -> str:
prefix = ""
m = re.match(r"^(sqlite(?:\+\w+)?:///)(.*)$", value, re.I)
if m:
prefix, value = m.group(1), m.group(2)
root, ext = os.path.splitext(value)
return f"{prefix}{root}-{worker}{ext}"
+10 -1
View File
@@ -44,6 +44,10 @@ runs this check before it is called done (E28 S3g):
A rule that holds is not the same as a screen that works: the person page passed every rule above with
half its panel empty.
A **copy-only** change — translated text and nothing else: no markup, class, component or logic — runs step 4
alone: a screenshot of every state the changed text renders in, in Hungarian and in English (Hungarian runs about
a fifth longer, so it is the one that wraps or clips). The key parity is the gate's.
## The design lab
`npm run dev`, then open **`http://localhost:5173/design-lab`**. The lab renders each open decision's variants
@@ -126,7 +130,10 @@ style guide.
to name one of the two. Black and white are the media scrims and their text. Five schemes × dark
and light (B2): **Midnight** (blue), **Forest** (cyan), **Slate** (fuchsia), **Matrix** (moss) each earn their
place with their own hue family; **Fjord** (Midnight's navy with a calm steel-blue accent) is the one kept
exception, the calm twin of the vivid Midnight. A new scheme still needs its own hue family. Normal text ≥ 4.5:1 and a control's edge ≥ 3:1 in **every** scheme and mode.
exception, the calm twin of the vivid Midnight. A new scheme still needs its own hue family. Normal text ≥ 4.5:1 and a control's edge ≥ 3:1 in **every** scheme and mode;
`--muted` keeps ≥ 6:1 on the bare `--bg`, because it mostly sits on glass, which costs up to ~1.5 of the ratio.
A tonal fill under text (a badge, the item count, the current rail row) is an opaque `bg-tint*` (its colour
mixed into `--surface`), never a translucent `bg-fg/8`: the glass and what scrolls under it show through.
[WCAG 1.4.3, 1.4.11] The input edge token is set under Controls (A4). The ≥ 3:1 edge is the field
family's (a field, the form's dropdown trigger), whose edge is the only thing that says "type here". A
labelled pill (a chip, a toolbar trigger, a tab, a segment) keeps the faint `--border`: its text names it,
@@ -783,3 +790,5 @@ of a full-width panel and nothing on the right.
| 2026-10-12 | A running download's step (owner ★) | The tray row names the step beside its bar ("Downloading audio · 12%", "Merging" with a pulse); a paused-only tray shows a pause mark, never the done check | A bare bar and % | A download fetches video and audio as two streams: unnamed, the second fill reads as a restart; the tray is the only place a download is followed |
| 2026-10-14 | API tokens (owner A/A/A) | Settings → API tokens: a row per token in three lines (name + prefix, its folders and rights, last use and expiry), revoked from its ⋯; a new token asks the kind first (Hub exchange or Custom: rights as chips per folder), expiry 1 year by default; the token shows once in the same dialog, with Copy and a curl line | A table; one form with a preset link; the token in a banner above the list | A table truncates the rights at this width; the common token never shows a matrix to get wrong; closing the dialog is plainly when the token is gone |
| 2026-10-15 | Folder links (owner A/A/A) | An own folder's menu has Copy link and Share with link… as a file row does (the dialog is the link half of the file's); the link opens a list of the folder's files as it is now, Play opening the watch player above it with the playing row lit, a document only downloading; the rail marks a folder with a live link beside its count | Share with link… alone; cards; a download-only list; no mark | Mostly videos from a phone, read on a phone: a list gives a long name its line and the next one is a tap away; the folder link is the one place where moving a file in publishes it, so where it is must show |
| 2026-10-17 | Copy-only changes (owner ★) | Step 4 of the Conformance check alone (not 1–3 or 5): every state the changed text renders in, seen in both languages | The whole five-step check; no screenshot at all (DOM test + key parity) | Steps 1–3 and 5 judge controls, patterns and composition, which a text change does not touch; the one risk left is a longer Hungarian string wrapping or clipping, and only a screenshot shows that |
| 2026-10-17 | Text on glass (owner ★) | Light `--muted` 15% toward `--fg` in every scheme (≥ 6:1 on the bare page); the item count, the badges and the current Files row on opaque tints mixed into the surface | Only the two failing components; the item count's number in `--fg` | Pixel-measured over every scheme × mode × backdrop, the count fell to 3.8:1 (dark too: thumbnails scroll under the toolbar's glass) and Channels' tabs to 4.1:1; an `--fg` count outshouts the filters beside it |
+13 -4
View File
@@ -44,12 +44,11 @@ A change is done when this is green, from the repo root:
bash scripts/check.sh # every lane
bash scripts/check.sh lint # the static lanes only (no tests, build or audits) — the quick loop
bash scripts/check.sh --brief # either mode: one line per lane, output only for a red one
bash scripts/lint-changed.sh # ruff + eslint + prettier on the uncommitted files only
bash scripts/check.sh --list # the lane names, one per line (runs nothing)
bash scripts/check.sh --only "vitest" # one lane, by its exact name — never "the gate"
```
It runs every lane even after one fails and names each red one at the end. `lint-changed.sh` is a
convenience, not the gate; with `--hook` it doubles as a Claude Code Stop hook (silent when clean,
the findings handed back otherwise).
It runs every lane even after one fails and names each red one at the end.
The codebase's health numbers — size, complexity, test ratio, gate weight, comment noise — come from
`python scripts/audit_metrics.py` (seconds; it reports, it never fails). `--speed` adds the speed
@@ -85,6 +84,13 @@ docker compose -f backend/docker-compose.test.yml run --rm --no-deps test \
pip-audit -r requirements.lock --no-deps --disable-pip
```
**Shell scripts**
```bash
# every tracked *.sh at shellcheck's default bar (info included); the version is pinned in scripts/check.sh
git ls-files -z -- '*.sh' | xargs -0 shellcheck
```
A missing database or ffmpeg is a hard error, not a skip — so the gate can't print green while the
DB-backed tests were quietly not run (`SIFTLODE_TEST_REQUIRE_DB=0` is the explicit "pure-logic only"
run for a machine without Postgres). Tests that need a database take the `db` fixture
@@ -107,6 +113,9 @@ Coverage is on demand, never enforced and not in the gate (E35): `npm run test -
`frontend/`, and for the backend
`docker compose -f backend/docker-compose.test.yml run --rm test python -m pytest --cov=app` (then
`… down -v`).
The DB lane runs on every core (`pytest -n auto`, pytest-xdist): `tests/db_per_worker.py` gives each worker its own
database (`siftlode_test_gw<N>`, created and migrated on first use), so the same tests run side by side. A serial
run (no `-n`) still works and uses `siftlode_test` itself.
The Playwright suite (`npm run e2e`) runs against the `:8080` stack built from your tree; the release
requires it. Before an image is pushed, it is scanned with Trivy (HIGH/CRITICAL with a fix fails;
exceptions in `.trivyignore.yaml`, each with a reason and an expiry).
+5
View File
@@ -45,6 +45,11 @@ login-free `/watch/<token>` share link).
PW_BASE_URL=http://localhost:5173 siftlode e2e
```
**Accessibility** — `a11y.spec.ts` runs axe-core's WCAG 2.x A + AA rules (`@axe-core/playwright`)
over every rail page, every admin page and the player, in `chromium` only (the rules read the DOM,
which the engines build alike). It does not judge colour contrast: axe files text on a gradient —
every glass surface — as "incomplete", not as a pass or a hit.
## Account & fixture
Tests run as a dedicated **admin** account `e2e@siftlode.local` (never a real account;
+64
View File
@@ -0,0 +1,64 @@
import AxeBuilder from "@axe-core/playwright";
import { test, expect, type Page } from "@playwright/test";
import { gotoPage, openApp, openFirstVideo } from "./helpers";
// Accessibility, the machine-checkable half: axe-core's WCAG 2.x A + AA rules over every page the
// owner reaches and the player. A hit fails with the rule, its impact and the elements.
//
// What it does NOT cover — colour contrast, mostly: axe cannot compute a contrast over a gradient,
// and the glass surfaces are gradients, so it files most text as "incomplete" instead of judging it
// (measured 2026-10-17: 110 nodes on the feed, 9-32 on the other pages). A green here says nothing
// about contrast; that stays with the visual check.
//
// Chromium only (playwright.config.ts leaves it out of the other projects).
const TAGS = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"];
/** Wait until nothing is loading: no QueryState spinner and no skeleton bone left. */
async function settled(page: Page): Promise<void> {
await expect(
page.locator('[role="status"] .animate-spin, [aria-hidden="true"].animate-pulse'),
).toHaveCount(0);
}
async function violations(page: Page): Promise<string[]> {
const result = await new AxeBuilder({ page }).withTags(TAGS).analyze();
return result.violations.map((v) => {
const shown = v.nodes.slice(0, 5).map((n) => n.target.join(" "));
const more = v.nodes.length > 5 ? ` (+${v.nodes.length - 5})` : "";
return `${v.id} (${v.impact ?? "?"}): ${v.help} — ${shown.join(" | ")}${more}`;
});
}
test.describe("accessibility (axe)", () => {
const RAIL = ["feed", "channels", "playlists", "files", "stats", "settings"];
const ADMIN = ["scheduler", "config", "users", "audit"];
for (const id of RAIL) {
test(`page: ${id}`, async ({ page }) => {
await openApp(page);
await gotoPage(page, id);
await settled(page);
expect(await violations(page)).toEqual([]);
});
}
for (const id of ADMIN) {
test(`admin page: ${id}`, async ({ page }) => {
await openApp(page);
await page.getByTestId("nav-admin").click();
await page.getByTestId(`nav-admin-${id}`).click();
await expect(page.getByTestId("nav-admin")).toHaveAttribute("aria-current", "page");
await settled(page);
expect(await violations(page)).toEqual([]);
});
}
test("player", async ({ page }) => {
// hermetic: the YouTube iframe is not loaded
await page.route(/youtube\.com/, (route) => route.abort());
await openApp(page);
await openFirstVideo(page);
await settled(page);
expect(await violations(page)).toEqual([]);
});
});
+27 -3
View File
@@ -23,6 +23,7 @@
"react-i18next": "^15.7.4"
},
"devDependencies": {
"@axe-core/playwright": "4.13.0",
"@babel/core": "7.29.7",
"@eslint-community/eslint-plugin-eslint-comments": "4.8.1",
"@eslint/js": "9.39.1",
@@ -91,6 +92,19 @@
"dev": true,
"license": "ISC"
},
"node_modules/@axe-core/playwright": {
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.13.0.tgz",
"integrity": "sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"axe-core": "~4.13.0"
},
"peerDependencies": {
"playwright-core": ">= 1.0.0"
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -3631,6 +3645,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/axe-core": {
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz",
"integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==",
"dev": true,
"license": "MPL-2.0",
"engines": {
"node": ">=4"
}
},
"node_modules/babel-plugin-react-compiler": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz",
@@ -6163,9 +6187,9 @@
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
+1
View File
@@ -39,6 +39,7 @@
"react-i18next": "^15.7.4"
},
"devDependencies": {
"@axe-core/playwright": "4.13.0",
"@babel/core": "7.29.7",
"@eslint-community/eslint-plugin-eslint-comments": "4.8.1",
"@eslint/js": "9.39.1",
+5
View File
@@ -17,6 +17,9 @@ import { defineConfig, devices } from "@playwright/test";
const BASE_URL = process.env.PW_BASE_URL || "http://127.0.0.1:8080";
const STORAGE_STATE = "e2e/.auth/state.json";
// The axe scan reads the DOM and the accessibility tree, which the engines build alike: one engine
// answers for all, so the other projects leave it out instead of tripling its time.
const CHROMIUM_ONLY = /a11y\.spec\.ts/;
export default defineConfig({
testDir: "./e2e",
@@ -54,11 +57,13 @@ export default defineConfig({
{
name: "webkit",
use: { ...devices["Desktop Safari"], storageState: STORAGE_STATE },
testIgnore: CHROMIUM_ONLY,
dependencies: ["setup"],
},
{
name: "ipad",
use: { ...devices["iPad (gen 7) landscape"], storageState: STORAGE_STATE },
testIgnore: CHROMIUM_ONLY,
dependencies: ["setup"],
},
// The speed baseline (e2e/speed.measure.ts): readings, not tests. Only when PW_SPEED is set —
@@ -75,10 +75,12 @@ describe("Feed — the count while a new search's count loads", () => {
it("dims the previous count and marks it busy until the new one lands", async () => {
vi.spyOn(api, "feed").mockResolvedValue({ items: [], next_cursor: null } as never);
let settle: (v: unknown) => void = () => {};
vi.spyOn(api, "feedSummary").mockImplementation(((f: FeedFilters) =>
f.q === "kitchen"
? Promise.resolve({ count: 1937, facets: {} })
: new Promise((r) => (settle = r))) as never);
const summary = vi
.spyOn(api, "feedSummary")
.mockImplementation(((f: FeedFilters) =>
f.q === "kitchen"
? Promise.resolve({ count: 1937, facets: {} })
: new Promise((r) => (settle = r))) as never);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} });
const pill = () => screen.getByTestId("item-count");
@@ -91,6 +93,9 @@ describe("Feed — the count while a new search's count loads", () => {
await waitFor(() => expect(pill().getAttribute("aria-busy")).toBe("true"));
expect(pill().getAttribute("title")).toBe("1,937"); // still the old figure, now marked as such
// The pill turns busy in the render that changes the search, and the count is asked for in an
// effect after it: answer only once it has been asked, or the answer goes to no one.
await waitFor(() => expect(summary).toHaveBeenCalledWith(expect.objectContaining({ q: "a" })));
settle({ count: 178991, facets: {} });
await waitFor(() => expect(pill().getAttribute("title")).toBe("178,991"));
expect(pill().getAttribute("aria-busy")).toBeNull();
@@ -7,7 +7,7 @@
// back home; a shared file is the owner's, so its menu only plays, saves, cuts or drops it from my
// list. i18n is key-passthrough, so labels read as keys.
import { useState } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
act,
cleanup,
@@ -118,6 +118,17 @@ import Files from "./Files";
import { FILES_LIST_CAP, type QueueJob } from "../lib/api";
import { noteQueue, resetQueueWatch, useFinished } from "../lib/downloadQueue";
// The page loads its dialogs lazily, and the first test to open one would pay for transforming the
// chunk inside its own 1 s wait for the dialog — over that on a busy machine. Loaded once, up front.
beforeAll(async () => {
await Promise.all([
import("./FilePreview"),
import("./ShareDialog"),
import("./UploadDialog"),
import("./VideoEditor"),
]);
}, 60_000);
const file = (id: number, extra: Partial<LibraryFile> = {}): LibraryFile =>
({
id,
@@ -452,7 +463,9 @@ describe("Files · my file's row", () => {
});
});
describe("Files · a library past the list's cap", () => {
// Its cases draw a full page of FILES_LIST_CAP rows, some of them twice (a sort, then its answer):
// up to ~0.8 s idle, past 5 s when the full suite's workers share a busy machine.
describe("Files · a library past the list's cap", { timeout: 30_000 }, () => {
it("a search under the cap runs on the rows; one past it asks the server, so an old file is found", () => {
mocks.folders = [YT];
mocks.files = [file(1, { display_name: "Recept" }), file(2)];
@@ -35,6 +35,12 @@ import FilesRail from "./FilesRail";
import { api, type FileFolder } from "../lib/api";
import { defaultLayout } from "../lib/panelLayout";
// The share dialog is a lazy chunk, and the test that opens it would pay for transforming it inside
// its own wait for the dialog — over that on a busy machine. Loaded once, up front.
beforeAll(async () => {
await import("./ShareDialog");
}, 60_000);
beforeAll(() => {
class Stub {
observe() {}
@@ -251,10 +257,7 @@ describe("FilesRail · a folder shared by link", () => {
.getAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === "files.folder.shareLink")!,
);
// The dialog is its own chunk, fetched on first use: slower than a re-render under a full run.
expect(
await screen.findByText("downloads.share.folderLinkHint", {}, { timeout: 5000 }),
).toBeTruthy();
expect(await screen.findByText("downloads.share.folderLinkHint")).toBeTruthy();
expect(await screen.findByDisplayValue(`${window.location.origin}/watch/f`)).toBeTruthy();
});
});
+1 -1
View File
@@ -63,7 +63,7 @@ function Entry({
onClick={() => setView(view)}
className={clsx(
"flex w-full min-h-8 items-center gap-2 rounded-lg px-2 text-sm",
active ? "bg-accent/12 font-medium text-accent" : "text-fg",
active ? "bg-tint-current font-medium text-accent" : "text-fg",
)}
>
<Icon className={clsx("size-4 shrink-0", !active && "text-muted")} />
@@ -394,6 +394,11 @@ describe("a Plex title's menu — Add to collection", () => {
// Retry from the keyboard: the focused button goes as the list loads, and focus must not be left
// on <body> with the menu open — it lands on the first row that came back.
const retry = (await screen.findByText("common.retry")).closest("button")!;
// The menu takes the focus a frame after it opens. Wait for that: a frame that came only after the
// press would put the focus back on the panel over the hand-over below — a test faster than a
// frame, not a reader, who cannot press a Retry they have not seen painted yet.
const panel = retry.closest("[data-floating-ui-portal]")!;
await vi.waitFor(() => expect(panel.contains(document.activeElement)).toBe(true));
retry.focus();
fireEvent.click(retry);
const beta = await checkRow("Beta");
@@ -28,6 +28,11 @@ import { pushLayer, removeLayer } from "../lib/layerStack";
type Media = HTMLMediaElement & { _t?: number; _paused?: boolean };
// The info overlay is a lazy chunk, and the first test to open it would pay for transforming it
// inside its own wait for the overlay — over that on a busy machine. Loaded once, up front.
beforeAll(async () => {
await import("./PlexInfo");
}, 60_000);
beforeAll(() => {
class Stub {
observe() {}
@@ -504,8 +509,7 @@ describe("a collection sibling in the info overlay", () => {
await playingAt("/media/A", 100);
fireEvent.click(screen.getByLabelText("plex.player.more"));
fireEvent.click(await menuItem("plex.player.infoBtn"));
// The overlay is a lazy chunk: its first import takes a while under the test runner.
const restart = await screen.findByRole("button", { name: "card.restart" }, { timeout: 5000 });
const restart = await screen.findByRole("button", { name: "card.restart" });
fireEvent.click(restart);
await waitFor(() => expect(lastStart(session)).toEqual(["B", 0]));
expect(screen.queryByRole("heading", { name: "Saga" })).toBeNull(); // the overlay closed
@@ -526,9 +530,7 @@ describe("a collection sibling in the info overlay", () => {
const asked = session.mock.calls.length;
fireEvent.click(screen.getByLabelText("plex.player.more"));
fireEvent.click(await menuItem("plex.player.infoBtn"));
fireEvent.click(
await screen.findByRole("button", { name: "card.restart" }, { timeout: 5000 }),
);
fireEvent.click(await screen.findByRole("button", { name: "card.restart" }));
await waitFor(() => expect(video()._t).toBe(0));
expect(session.mock.calls.length).toBe(asked); // the attached session seeks, no reload
expect(video().paused).toBe(false);
@@ -578,7 +580,7 @@ describe("a Restart of the paused film whose session starts past 0", () => {
act(() => void video().pause());
fireEvent.click(screen.getByLabelText("plex.player.more"));
fireEvent.click(await menuItem("plex.player.infoBtn"));
const restart = await screen.findByRole("button", { name: "card.restart" }, { timeout: 5000 });
const restart = await screen.findByRole("button", { name: "card.restart" });
const plays = play.mock.calls.length;
fireEvent.click(restart);
await waitFor(() => expect(session.mock.calls.length).toBe(asked + 1));
@@ -25,6 +25,10 @@ beforeAll(async () => {
await import("./Welcome");
}, 60_000);
// Warm or not, each case still loads and evaluates the whole Welcome tree afresh after its module
// reset: ~0.1 s idle, past 5 s when the full suite's workers share a busy machine (seen at 5.01 s).
const CASE_BUDGET = 30_000;
afterEach(() => {
cleanup();
vi.resetModules();
@@ -85,7 +89,7 @@ function refusal(
return () => Promise.reject(new HttpError(403, "ignored", { code: "login", ...detail }));
}
describe("a refused sign-in becomes a named state", () => {
describe("a refused sign-in becomes a named state", { timeout: CASE_BUDGET }, () => {
it("names the address the account is on, not the one that was typed", async () => {
const passwordLogin = vi.fn();
const { HttpError } = await mountCard("/", { passwordLogin });
@@ -164,7 +168,7 @@ describe("a refused sign-in becomes a named state", () => {
// password" form purely because a `#reset=` fragment was present, and only told the user after they
// had typed one. Opening a dead reset link is ordinary — the token lives an hour, mail is slow, and
// whoever got two mails clicks the older one.
describe("an emailed reset link that is already finished", () => {
describe("an emailed reset link that is already finished", { timeout: CASE_BUDGET }, () => {
it("refuses to draw a form that cannot succeed", async () => {
await mountCard("/#reset=dead-token", {
checkPasswordReset: () => Promise.resolve({ ok: false }),
@@ -250,7 +254,7 @@ describe("an emailed reset link that is already finished", () => {
});
});
describe("the access-request notice from a denied Google sign-in", () => {
describe("the access-request notice from a denied Google sign-in", { timeout: CASE_BUDGET }, () => {
it("names the address the request was recorded for", async () => {
await mountCard("/#access=requested&email=nptemu%40gmail.com");
@@ -6,7 +6,7 @@
//
// i18n is mocked to a key-passthrough; the API is stubbed so nothing reaches out.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
@@ -42,6 +42,9 @@ async function failTwice(fail: () => void, message: string) {
fail();
const first = await screen.findByRole("alert");
expect(first.textContent).toBe(message);
// The wizard's Next is busy until its failed step returns (a tick after the message shows), and a
// busy button ignores a press: press again only once nothing on the form says it is working.
await waitFor(() => expect(document.querySelector('[aria-busy="true"]')).toBeNull());
fail();
const second = screen.getByRole("alert");
expect(second.textContent).toBe(message);
+5 -3
View File
@@ -7,10 +7,12 @@ import type { ReactNode } from "react";
// at 8%, so it reads on the dark schemes and on white alike; `onDark` is the always-dark player's. A
// `tone` says which kind of label it is (a role, a state to notice): its own colour at 6%, the most
// that keeps its text at 4.5:1 on light (theme.contract.test.ts measures all three in every scheme).
// Each tint is mixed into the surface (`bg-tint*`), opaque: a translucent one lets the glass and what
// scrolls under it through, and the text's contrast goes with them.
const TONES = {
neutral: "bg-fg/8 text-muted",
accent: "bg-accent/6 text-accent",
warn: "bg-warn/6 text-warn",
neutral: "bg-tint text-muted",
accent: "bg-tint-accent text-accent",
warn: "bg-tint-warn text-warn",
} as const;
export function Badge({
@@ -47,6 +47,15 @@ afterEach(() => {
const bodyRows = () => host!.querySelectorAll("tbody tr").length;
// Waits for the page to grow to `n` rows, one background step at a time: each step must come, but on
// a busy machine all of them together can outlast one wait's budget (seen at 190 of 200).
async function grownTo(n: number) {
for (let had = bodyRows(); had < n; had = bodyRows()) {
await waitFor(() => expect(bodyRows()).toBeGreaterThan(had));
}
expect(bodyRows()).toBe(n);
}
// Mounts synchronously, so what the DOM holds right after is the first render alone.
function mount(rows: Row[], columns: Column<Row>[], narrowedBy = "") {
host = document.createElement("div");
@@ -68,7 +77,7 @@ describe("DataTable — a long page mounts in steps", () => {
it("paints its first rows at once and the rest after", async () => {
mount(many(200), plain);
expect(bodyRows()).toBe(FIRST_ROWS);
await waitFor(() => expect(bodyRows()).toBe(200));
await grownTo(200);
});
it("does not re-render the rows it already holds as it grows", async () => {
@@ -84,21 +93,21 @@ describe("DataTable — a long page mounts in steps", () => {
},
];
mount(many(200), counted);
await waitFor(() => expect(bodyRows()).toBe(200));
await grownTo(200);
// The first row was drawn by the first render, not again by each step after it.
expect(renders.get("r0")).toBe(1);
});
it("starts over from the first rows when the caller narrows them, not on new data", async () => {
const draw = mount(many(200), plain, "");
await waitFor(() => expect(bodyRows()).toBe(200));
await grownTo(200);
// New data for the same view (a refetch): the page keeps what it shows.
draw(many(200), "");
expect(bodyRows()).toBe(200);
// A search the caller applied: the urgent render is small again.
draw(many(150, "s"), "s");
expect(bodyRows()).toBe(FIRST_ROWS);
await waitFor(() => expect(bodyRows()).toBe(150));
await grownTo(150);
});
// Channels turns the page to the row a priority pick moved and focuses it in the commit
+3 -2
View File
@@ -8,7 +8,8 @@ import { formatNumber } from "../../lib/format";
const OVERFLOW_AT = 1_000_000;
// A compact, FIXED-WIDTH count pill for a toolbar: the filtered item count at the toolbar's 28, flat
// and tonal like a `Badge` — no edge, because it does nothing (edged = pressable, flat = just
// and tonal like a `Badge` (the same opaque `bg-tint`: the toolbar is glass over scrolling thumbnails)
// — no edge, because it does nothing (edged = pressable, flat = just
// says; it used to wear the filter dropdowns' glass pill and read as one more). The fixed width is the point — a
// changing number must never shove the controls beside it, so the box is sized to the widest value
// ("999,999 items") and the content is centred within it. A count of a million or more collapses to
@@ -46,7 +47,7 @@ export default function ItemCount({
failed ? t("common.countFailed") : count == null ? undefined : formatNumber(count, lang)
}
aria-busy={stale || undefined}
className="inline-flex h-7 min-w-[9rem] shrink-0 items-center justify-center gap-1.5 rounded-full bg-fg/8 px-3 text-sm whitespace-nowrap text-muted"
className="inline-flex h-7 min-w-[9rem] shrink-0 items-center justify-center gap-1.5 rounded-full bg-tint px-3 text-sm whitespace-nowrap text-muted"
>
<Layers className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
<span className={clsx("tabular-nums transition-opacity", stale && "opacity-60")}>
@@ -1,4 +1,30 @@
[
{
"version": "0.107.3",
"date": "2026-10-17",
"new": [
"Development: the end-to-end suite runs the WCAG A and AA accessibility checks over every page and the player, and every shell script is linted in the gate."
],
"fixed": [
"Readability: muted text in the light themes is a shade darker, and the item count, the labels and the selected Files folder sit on a solid tint, so text on the frosted-glass bars stays readable (4.5:1) in every scheme."
]
},
{
"version": "0.107.2",
"date": "2026-10-17",
"new": [
"Development: the gate script can list its checks (scripts/check.sh --list) and run just one (--only), and the backend tests run on every CPU core, each worker with its own database."
],
"fixed": []
},
{
"version": "0.107.1",
"date": "2026-10-16",
"new": [],
"fixed": [
"Downloads: a failed download's explanation now says to press Retry, instead of pointing to a download list that no longer exists."
]
},
{
"version": "0.107.0",
"date": "2026-10-15",
@@ -1,4 +1,30 @@
[
{
"version": "0.107.3",
"date": "2026-10-17",
"new": [
"Fejlesztés: a végponttól végpontig tartó tesztek minden oldalon és a lejátszóban lefuttatják a WCAG A és AA akadálymentességi ellenőrzéseit, és a gate minden shell-szkriptet ellenőriz."
],
"fixed": [
"Olvashatóság: a halvány szöveg a világos témákban egy árnyalattal sötétebb, a darabszám, a címkék és a Fájlok kijelölt mappája pedig tömör árnyalaton ül, így az üvegszerű sávokon is olvasható (4,5:1) minden színsémában."
]
},
{
"version": "0.107.2",
"date": "2026-10-17",
"new": [
"Fejlesztés: a gate-szkript kilistázza az ellenőrzéseit (scripts/check.sh --list), és egyet is futtathat közülük (--only); a backend tesztjei minden processzormagon futnak, mindegyik saját adatbázissal."
],
"fixed": []
},
{
"version": "0.107.1",
"date": "2026-10-16",
"new": [],
"fixed": [
"Letöltés: egy sikertelen letöltés magyarázata most az Újra gombot mondja, nem egy már nem létező letöltési listát."
]
},
{
"version": "0.107.0",
"date": "2026-10-15",
+5 -5
View File
@@ -316,11 +316,11 @@
"facebook": {
"login_wall": {
"short": "Facebook sign-in required",
"body": "Facebook only shows this to signed-in users (e.g. 18+ content or a closed group). Add your Facebook cookies under Settings → Account → Download authentication, then retry the download from the list."
"body": "Facebook only shows this to signed-in users (e.g. 18+ content or a closed group). Add your Facebook cookies under Settings → Account → Download authentication, then press Retry."
},
"cookie_rejected": {
"short": "Facebook didn't accept the cookies",
"body": "The cookies have probably expired, or you signed out in that browser. Export them again, paste them in under Settings → Account → Download authentication, then retry the download from the list. If it still fails, the content has been removed or isn't visible to you."
"body": "The cookies have probably expired, or you signed out in that browser. Export them again, paste them in under Settings → Account → Download authentication, then press Retry. If it still fails, the content has been removed or isn't visible to you."
},
"group_post_video": {
"short": "No video found in this post",
@@ -330,15 +330,15 @@
"youtube": {
"bot_wall": {
"short": "YouTube sign-in required",
"body": "YouTube only serves this video to a signed-in viewer — it asks the downloader to confirm it isn't a bot. Other YouTube videos are usually unaffected. Add your YouTube cookies under Settings → Account → Download authentication, then retry the download from the list."
"body": "YouTube only serves this video to a signed-in viewer — it asks the downloader to confirm it isn't a bot. Other YouTube videos are usually unaffected. Add your YouTube cookies under Settings → Account → Download authentication, then press Retry."
},
"cookie_rejected": {
"short": "YouTube didn't accept the cookies",
"body": "YouTube rotates exported cookies as a security measure, so yours have probably expired. Export them again from a private browsing window, close that window without browsing on, paste them in under Settings → Account → Download authentication, then retry the download from the list."
"body": "YouTube rotates exported cookies as a security measure, so yours have probably expired. Export them again from a private browsing window, close that window without browsing on, paste them in under Settings → Account → Download authentication, then press Retry."
},
"media_forbidden": {
"short": "YouTube refused the video stream",
"body": "YouTube found the video but refused to send the file to a signed-out download. It comes and goes, so a retry from the list is worth one try — and adding your YouTube cookies under Settings → Account → Download authentication is what gets a stubborn one through."
"body": "YouTube found the video but refused to send the file to a signed-out download. It comes and goes, so pressing Retry once is worth it — and adding your YouTube cookies under Settings → Account → Download authentication is what gets a stubborn one through."
}
}
}
+5 -5
View File
@@ -316,11 +316,11 @@
"facebook": {
"login_wall": {
"short": "Facebook-bejelentkezés szükséges",
"body": "A Facebook ezt csak bejelentkezve mutatja (pl. 18+ tartalom vagy zárt csoport). Illeszd be a Facebook-cookie-kat a Beállítások → Fiók → Letöltés-hitelesítés alatt, majd a listában indítsd újra a letöltést."
"body": "A Facebook ezt csak bejelentkezve mutatja (pl. 18+ tartalom vagy zárt csoport). Illeszd be a Facebook-cookie-kat a Beállítások → Fiók → Letöltés-hitelesítés alatt, majd nyomd meg az Újra gombot."
},
"cookie_rejected": {
"short": "A Facebook nem fogadta el a cookie-kat",
"body": "A cookie-k valószínűleg lejártak, vagy kijelentkeztél abban a böngészőben. Exportáld újra őket, illeszd be a Beállítások → Fiók → Letöltés-hitelesítés alatt, majd a listában indítsd újra a letöltést. Ha így sem megy, a tartalmat törölték, vagy nem látható számodra."
"body": "A cookie-k valószínűleg lejártak, vagy kijelentkeztél abban a böngészőben. Exportáld újra őket, illeszd be a Beállítások → Fiók → Letöltés-hitelesítés alatt, majd nyomd meg az Újra gombot. Ha így sem megy, a tartalmat törölték, vagy nem látható számodra."
},
"group_post_video": {
"short": "Ebben a posztban nem található videó",
@@ -330,15 +330,15 @@
"youtube": {
"bot_wall": {
"short": "YouTube-bejelentkezés szükséges",
"body": "A YouTube ezt a videót csak bejelentkezett nézőnek adja ki — a letöltőprogramtól azt kéri, igazolja, hogy nem robot. A többi YouTube-videót ez általában nem érinti. Illeszd be a YouTube-cookie-kat a Beállítások → Fiók → Letöltés-hitelesítés alatt, majd a listában indítsd újra a letöltést."
"body": "A YouTube ezt a videót csak bejelentkezett nézőnek adja ki — a letöltőprogramtól azt kéri, igazolja, hogy nem robot. A többi YouTube-videót ez általában nem érinti. Illeszd be a YouTube-cookie-kat a Beállítások → Fiók → Letöltés-hitelesítés alatt, majd nyomd meg az Újra gombot."
},
"cookie_rejected": {
"short": "A YouTube nem fogadta el a cookie-kat",
"body": "A YouTube biztonsági okból lecseréli az exportált cookie-kat, ezért a tiéid valószínűleg lejártak. Exportáld őket újra egy privát böngészőablakból, majd zárd be azt az ablakot további böngészés nélkül, illeszd be a Beállítások → Fiók → Letöltés-hitelesítés alatt, majd a listában indítsd újra a letöltést."
"body": "A YouTube biztonsági okból lecseréli az exportált cookie-kat, ezért a tiéid valószínűleg lejártak. Exportáld őket újra egy privát böngészőablakból, majd zárd be azt az ablakot további böngészés nélkül, illeszd be a Beállítások → Fiók → Letöltés-hitelesítés alatt, majd nyomd meg az Újra gombot."
},
"media_forbidden": {
"short": "A YouTube nem adta ki a videófájlt",
"body": "A YouTube visszaadta a videó adatait, de a fájlt már nem adta ki bejelentkezés nélkül. Ez hol van, hol nincs, ezért érdemes újrapróbálni a listából — a makacs eseteken pedig a YouTube-cookie-k segítenek, a Beállítások → Fiók → Letöltés-hitelesítés alatt."
"body": "A YouTube visszaadta a videó adatait, de a fájlt már nem adta ki bejelentkezés nélkül. Ez hol van, hol nincs, ezért érdemes egyszer megnyomni az Újra gombot — a makacs eseteken pedig a YouTube-cookie-k segítenek, a Beállítások → Fiók → Letöltés-hitelesítés alatt."
}
}
}
+12 -5
View File
@@ -51,6 +51,13 @@
--color-live-fg: var(--live-fg);
--color-media-chrome: var(--media-chrome);
--color-media-mark: var(--media-mark);
/* A tonal fill under text (a Badge, the item count, the current rail row): its colour mixed into the
surface, OPAQUE — a `bg-fg/8` lets the glass and whatever scrolls under it through, which pixel-measured
the item count down to 3.8:1. theme.contract.test.ts measures each one's text on it. */
--color-tint: color-mix(in srgb, var(--fg) 8%, var(--surface));
--color-tint-accent: color-mix(in srgb, var(--accent) 6%, var(--surface));
--color-tint-warn: color-mix(in srgb, var(--warn) 6%, var(--surface));
--color-tint-current: color-mix(in srgb, var(--accent) 12%, var(--surface));
--font-sans: Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
@@ -1032,7 +1039,7 @@ html[data-scheme="midnight"][data-theme="light"] {
--card: #ffffff;
--border: #dde3f0;
--fg: #1a2238;
--muted: #5b6685;
--muted: #515c79;
--accent: #3b5bdb;
--accent-fg: #ffffff;
}
@@ -1054,7 +1061,7 @@ html[data-scheme="forest"][data-theme="light"] {
--card: #ffffff;
--border: #d6e7e0;
--fg: #102a22;
--muted: #4d6b60;
--muted: #446157;
--accent: #0e7490;
--accent-fg: #ffffff;
}
@@ -1076,7 +1083,7 @@ html[data-scheme="slate"][data-theme="light"] {
--card: #ffffff;
--border: #e3e6eb;
--fg: #1c1f26;
--muted: #5d636e;
--muted: #535963;
--accent: #a21caf;
--accent-fg: #ffffff;
}
@@ -1100,7 +1107,7 @@ html[data-scheme="matrix"][data-theme="light"] {
--card: #ffffff;
--border: #dfe3d8;
--fg: #161a14;
--muted: #5a6352;
--muted: #505849;
--accent: #556b2f;
--accent-fg: #ffffff;
}
@@ -1126,7 +1133,7 @@ html[data-scheme="fjord"][data-theme="light"] {
--card: #ffffff;
--border: #c9d6e3;
--fg: #152232;
--muted: #4f6278;
--muted: #46586e;
--accent: #256b8f;
--accent-fg: #ffffff;
}
@@ -33,13 +33,18 @@ beforeAll(async () => {
await import("./api/core");
}, 60_000);
// Warm or not, each case still loads and evaluates the modules it imports afresh after its module
// reset: tens of ms idle, but the same reload put sibling suites past 5 s when the full suite's
// workers share a busy machine.
const CASE_BUDGET = 30_000;
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
vi.resetModules(); // both stores load at import: each test needs a fresh one
});
describe("stores read at import, in a tab not yet pinned", () => {
describe("stores read at import, in a tab not yet pinned", { timeout: CASE_BUDGET }, () => {
it("keep the account's notification history and settings once the tab is pinned", async () => {
localStorage.setItem("siftlode.notifications.5", JSON.stringify([entry(7, "older")]));
localStorage.setItem("siftlode.notifSettings.5", JSON.stringify({ durationMs: 0 }));
@@ -65,7 +70,7 @@ describe("stores read at import, in a tab not yet pinned", () => {
});
});
describe("a pinned tab pinned to another account", () => {
describe("a pinned tab pinned to another account", { timeout: CASE_BUDGET }, () => {
it("leaves the previous account's notices with that account", async () => {
// BootGate moves a tab whose account was signed out elsewhere onto the one the server answers as.
sessionStorage.setItem("siftlode.activeAccount", "4");
+3 -1
View File
@@ -71,7 +71,9 @@ beforeAll(async () => {
slices[name] = slice;
}
api = ((await import("../api")) as { api: Slice }).api;
});
// The imports above transform the whole api graph and i18n with every locale: under a ~0.6 s idle,
// but past the 10 s hook budget when the full suite's workers share a busy machine.
}, 60_000);
describe("api façade is a collision-free spread of domain slices", () => {
it("defines no method name in more than one slice (object spread would silently drop one)", () => {
@@ -13,19 +13,25 @@ async function hits(source: string, filePath = "src/components/__probe.tsx"): Pr
}
describe("a hand-rolled button", () => {
it("is refused in a feature component, the lab and lib, raw or focus-safe", async () => {
const raw = 'export const A = () => <button className="px-3">x</button>;\n';
expect(await hits(raw)).toBe(1);
expect(await hits(raw, "src/design-lab/__probe.tsx")).toBe(1);
expect(await hits(raw, "src/lib/__probe.tsx")).toBe(1);
// …and in a ui/ COMPOSITE: a pager or a side panel is built from the primitives too.
expect(await hits(raw, "src/components/ui/Pager.tsx")).toBe(1);
expect(
await hits(
'import { FocusSafeButton } from "./ui/FocusSafeButton";\nexport const B = () => <FocusSafeButton>x</FocusSafeButton>;\n',
),
).toBe(1);
});
// Each new path's first lint makes the type-aware project service load it (0.4–1 s apiece, a repeat
// ~0): this case lints four, ~1.4 s idle, past 5 s on a busy machine.
it(
"is refused in a feature component, the lab and lib, raw or focus-safe",
{ timeout: 30_000 },
async () => {
const raw = 'export const A = () => <button className="px-3">x</button>;\n';
expect(await hits(raw)).toBe(1);
expect(await hits(raw, "src/design-lab/__probe.tsx")).toBe(1);
expect(await hits(raw, "src/lib/__probe.tsx")).toBe(1);
// …and in a ui/ COMPOSITE: a pager or a side panel is built from the primitives too.
expect(await hits(raw, "src/components/ui/Pager.tsx")).toBe(1);
expect(
await hits(
'import { FocusSafeButton } from "./ui/FocusSafeButton";\nexport const B = () => <FocusSafeButton>x</FocusSafeButton>;\n',
),
).toBe(1);
},
);
it("is allowed only in a control primitive, and the primitives pass everywhere", async () => {
for (const file of ["IconButton.tsx", "SegmentedToggle.tsx", "AnchoredPopover.tsx"])
+6 -1
View File
@@ -33,6 +33,11 @@ beforeAll(async () => {
await import("./api");
}, 60_000);
// Warm or not, each case still loads and evaluates the modules it imports afresh after its module
// reset: tens of ms idle, but the same reload put sibling suites past 5 s when the full suite's
// workers share a busy machine.
const CASE_BUDGET = 30_000;
beforeEach(() => {
vi.useFakeTimers();
vi.resetModules();
@@ -69,7 +74,7 @@ const raised = (level: "DEBUG" | "TRACE", hours = 24) => ({
const codes = (batch: unknown[] | undefined) =>
(batch ?? []).map((e) => (e as { event: string }).event);
describe("diagnostics level", () => {
describe("diagnostics level", { timeout: CASE_BUDGET }, () => {
it("the base level at boot sets INFO, streams nothing and logs nothing", async () => {
const d = await fresh();
d.applyDiagnostics(
@@ -54,6 +54,11 @@ beforeAll(async () => {
}, 60_000);
let current: typeof import("./diagnostics") | null = null;
// Warm or not, each case still loads and evaluates the modules it imports afresh after its module
// reset: tens of ms idle, but the same reload put sibling suites past 5 s when the full suite's
// workers share a busy machine.
const CASE_BUDGET = 30_000;
beforeEach(() => {
vi.useFakeTimers();
vi.resetModules();
@@ -73,7 +78,7 @@ async function fresh() {
const flat = () => sent.flat();
describe("a debug session applied to the tab", () => {
describe("a debug session applied to the tab", { timeout: CASE_BUDGET }, () => {
it("raises the base level to its own, and tags and streams what it keeps", async () => {
const d = await fresh();
d.applyDiagnostics(BASE, recording("DEBUG"), () => {});
+37 -2
View File
@@ -1,6 +1,7 @@
import { execFileSync, spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
import { relative } from "node:path";
import { relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { ESLint, type Linter } from "eslint";
import prettierConfig from "eslint-config-prettier";
@@ -375,6 +376,37 @@ describe("the gate snapshot (gates.snapshot.json)", () => {
}, 120_000);
});
// Git for Windows' own bash: on a Windows PATH `bash` is often WSL's, which cannot run the script.
function bash(): string {
if (process.platform !== "win32") return "bash";
const exec = execFileSync("git", ["--exec-path"], { encoding: "utf8" }).trim();
return resolve(exec, "../../../bin/bash.exe");
}
const checkSh = (...args: string[]) =>
spawnSync(bash(), ["scripts/check.sh", ...args], { cwd: REPO, encoding: "utf8" });
// A runner that schedules lanes one by one learns them from `--list` and runs each with `--only`; a
// lane missing from the list would be a lane it never runs, and an `--only` that passed with nothing
// run would be a green about nothing.
describe("the gate script's lane list (check.sh --list / --only)", () => {
it("lists every lane the script runs, in order", () => {
const declared = [...read("scripts/check.sh").matchAll(/^\s*lane "([^"]+)"/gm)].map(
(m) => m[1],
);
const listed = checkSh("--list");
expect(listed.status).toBe(0);
expect(listed.stdout.trim().split("\n")).toEqual(declared);
const lint = checkSh("lint", "--list").stdout.trim().split("\n");
expect(lint.length).toBeGreaterThan(0);
expect(declared.slice(0, lint.length)).toEqual(lint);
});
it("refuses an unknown lane instead of passing with nothing run", () => {
const r = checkSh("--only", "no such lane");
expect(r.status).toBe(2);
expect(r.stdout).not.toContain("green");
});
});
describe("the gate register (docs/gates.md)", () => {
it("lists every ESLint rule narrowed, by scope", async () => {
expect(missingFrom(eslintNarrowings(await linted()))).toEqual([]);
@@ -552,7 +584,10 @@ describe("the gate register (docs/gates.md)", () => {
"[pytest]",
"testpaths = tests",
"pythonpath = .",
"addopts = -q -rs --strict-markers",
// `-p tests.db_per_worker` selects nothing and filters nothing: under `-n` it hands each worker its
// own database (DATABASE_URL + `_gw<N>`), so the same tests can run side by side
"addopts = -q -rs --strict-markers -p tests.db_per_worker",
"db_per_worker_env = DATABASE_URL",
]);
});
+63 -11
View File
@@ -1232,6 +1232,7 @@
" POSTGRES_DB: siftlode_test",
" tmpfs:",
" - /var/lib/postgresql/data # ephemeral + fast; the schema is rebuilt per run",
" command: [\"postgres\", \"-c\", \"max_connections=300\"]",
" healthcheck:",
" test: [\"CMD-SHELL\", \"pg_isready -U siftlode -d siftlode_test\"]",
" interval: 1s",
@@ -1250,7 +1251,7 @@
" volumes:",
" - ./:/app",
" working_dir: /app",
" command: [\"python\", \"-m\", \"pytest\"]"
" command: [\"python\", \"-m\", \"pytest\", \"-n\", \"auto\"]"
],
"frontend/.prettierignore": [
"dist",
@@ -1298,30 +1299,59 @@
"cd \"$(dirname \"$0\")/..\" || exit 2",
"mode=all",
"brief=0",
"for arg in \"$@\"; do",
" case \"$arg\" in",
" all | lint) mode=\"$arg\" ;;",
"list=0",
"only=\"\"",
"usage() { echo 'usage: scripts/check.sh [lint] [--brief] [--list | --only \"<lane>\"]' >&2; exit 2; }",
"while [ $# -gt 0 ]; do",
" case \"$1\" in",
" all | lint) mode=\"$1\" ;;",
" --brief) brief=1 ;;",
" --list) list=1 ;;",
" --only) [ $# -ge 2 ] || usage; only=\"$2\"; shift ;;",
" esac",
" shift",
"done",
"[ \"$list\" = 1 ] && [ -n \"$only\" ] && usage",
"export MSYS_NO_PATHCONV=1 # Git Bash: do not rewrite the in-container paths below",
"TESTCOMPOSE=backend/docker-compose.test.yml",
"failed=()",
"timings=()",
"ran=0",
"ms() { date +%s%3N; }",
"secs() { awk -v t=\"$1\" 'BEGIN { printf \"%.1f\", t / 1000 }'; }",
"took=\"\"",
"timed() { # timed <name> <ok|red> <start-ms> — sets $took (no subshell: it must add to $timings)",
" took=\"$(secs $(($(ms) - $3)))\"",
" timings+=(\"$(printf '%8s s %s' \"$took\" \"$1\")$([ \"$2\" = red ] && echo ' (red)')\")",
" if [ -n \"${GATE_TIMES_FILE:-}\" ]; then",
" printf '%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' \"$(date -u +%FT%TZ)\" \"${only:+only}${only:-$mode}\" \"$1\" \"$took\" \"$2\" \\",
" \"$(git rev-parse --short 'HEAD^{tree}' 2>/dev/null)\" >>\"$GATE_TIMES_FILE\"",
" fi",
" return 0",
"}",
"lane() { # lane <name> <command…> — run it, remember a failure, never stop",
" local name=\"$1\"",
" local name=\"$1\" t0 result=ok",
" shift",
" if [ \"$list\" = 1 ]; then echo \"$name\"; return 0; fi",
" [ -n \"$only\" ] && [ \"$name\" != \"$only\" ] && return 0",
" ran=$((ran + 1))",
" t0=\"$(ms)\"",
" if [ \"$brief\" = 0 ]; then",
" echo \">> $name…\"",
" \"$@\" || failed+=(\"$name\")",
" \"$@\" || { failed+=(\"$name\"); result=red; }",
" timed \"$name\" \"$result\" \"$t0\"",
" echo \"<< $name: $took s\"",
" return 0",
" fi",
" local out",
" out=\"$(mktemp)\"",
" if \"$@\" >\"$out\" 2>&1; then",
" echo \">> $name: ok\"",
" timed \"$name\" ok \"$t0\"",
" echo \">> $name: ok ($took s)\"",
" else",
" failed+=(\"$name\")",
" echo \">> $name: FAILED\"",
" timed \"$name\" red \"$t0\"",
" echo \">> $name: FAILED ($took s)\"",
" brief_excerpt \"$out\"",
" fi",
" rm -f \"$out\"",
@@ -1347,6 +1377,16 @@
"}",
"ruff_check() { ruff_pinned && python -m ruff check --config backend/ruff.toml backend scripts; }",
"ruff_format() { ruff_pinned && python -m ruff format --check --config backend/ruff.toml backend scripts; }",
"SHELLCHECK_PIN=0.11.0",
"shellcheck_lane() {",
" local host",
" host=\"$(shellcheck --version 2>/dev/null | sed -n 's/^version: //p')\"",
" if [ \"$host\" != \"$SHELLCHECK_PIN\" ]; then",
" echo \"shellcheck ${host:-missing} on the host, $SHELLCHECK_PIN pinned — https://github.com/koalaman/shellcheck/releases\" >&2",
" return 1",
" fi",
" git ls-files -z -- '*.sh' | xargs -0 shellcheck",
"}",
"basedpyright() {",
" local before",
" before=\"$(git hash-object backend/.basedpyright/baseline.json)\"",
@@ -1361,8 +1401,10 @@
" return 1",
"}",
"frontend() { (cd frontend && \"$@\"); }",
"gate_t0=\"$(ms)\"",
"lane \"ruff\" ruff_check",
"lane \"ruff format --check\" ruff_format",
"lane \"shellcheck (every shell script)\" shellcheck_lane",
"lane \"basedpyright (new errors only)\" basedpyright",
"lane \"lock check (frontend/node_modules == package-lock.json)\" node scripts/check-node-modules.mjs",
"lane \"tsc (src + configs + e2e)\" frontend npm run --silent typecheck",
@@ -1374,7 +1416,7 @@
" local rc=0",
" docker compose -f \"$TESTCOMPOSE\" up --build --abort-on-container-exit --exit-code-from test || rc=1",
" docker compose -f \"$TESTCOMPOSE\" down -v >/dev/null 2>&1",
" return $rc",
" return \"$rc\"",
"}",
"pip_audit() {",
" docker compose -f \"$TESTCOMPOSE\" run --rm --no-deps test \\",
@@ -1387,11 +1429,21 @@
" lane \"pip-audit (backend lock)\" pip_audit",
" lane \"npm audit (production dependencies)\" frontend npm audit --omit=dev --audit-level=high",
"fi",
"[ \"$list\" = 1 ] && exit 0",
"if [ -n \"$only\" ] && [ \"$ran\" = 0 ]; then",
" echo \"no lane named '$only' in mode $mode — see: scripts/check.sh $mode --list\" >&2",
" exit 2",
"fi",
"scope=\"gate ($mode)\"",
"[ -n \"$only\" ] && scope=\"lane '$only' only (not the gate)\"",
"echo \">> lane times:\"",
"printf '%s\\n' \"${timings[@]}\"",
"echo \"$(printf '%8s' \"$(secs $(($(ms) - gate_t0)))\") s total\"",
"if [ ${#failed[@]} -eq 0 ]; then",
" echo \">> gate ($mode): green ✓\"",
" echo \">> $scope: green ✓\"",
" exit 0",
"fi",
"echo \">> gate ($mode): RED — ${failed[*]}\" >&2",
"echo \">> $scope: RED — ${failed[*]}\" >&2",
"exit 1"
]
}
@@ -14,16 +14,22 @@ async function hits(source: string, filePath = "src/components/__probe.tsx"): Pr
}
describe("a hand-rolled field", () => {
it("is refused anywhere outside the field primitives, text box or native control", async () => {
const text = 'export const A = () => <input className="border px-2" />;\n';
expect(await hits(text)).toBe(1);
expect(await hits(text, "src/design-lab/__probe.tsx")).toBe(1);
// …and in a ui/ composite: the pager's page box is an Input too.
expect(await hits(text, "src/components/ui/Pager.tsx")).toBe(1);
expect(await hits("export const B = () => <textarea />;\n")).toBe(1);
expect(await hits('export const C = () => <input type="checkbox" />;\n')).toBe(1);
expect(await hits('export const D = () => <input type="range" />;\n')).toBe(1);
});
// Each new path's first lint makes the type-aware project service load it (0.4–1 s apiece, a repeat
// ~0): this case lints three, ~1 s idle, past 5 s on a busy machine.
it(
"is refused anywhere outside the field primitives, text box or native control",
{ timeout: 30_000 },
async () => {
const text = 'export const A = () => <input className="border px-2" />;\n';
expect(await hits(text)).toBe(1);
expect(await hits(text, "src/design-lab/__probe.tsx")).toBe(1);
// …and in a ui/ composite: the pager's page box is an Input too.
expect(await hits(text, "src/components/ui/Pager.tsx")).toBe(1);
expect(await hits("export const B = () => <textarea />;\n")).toBe(1);
expect(await hits('export const C = () => <input type="checkbox" />;\n')).toBe(1);
expect(await hits('export const D = () => <input type="range" />;\n')).toBe(1);
},
);
it("passes in a field primitive, for a file picker, and for the primitives themselves", async () => {
for (const file of ["Input.tsx", "NativeControls.tsx"])
+2 -1
View File
@@ -103,7 +103,8 @@ describe("plan codes", () => {
expect(pattern("PLAN_CODE")).toBe(PLAN_CODES.map(js).join("|"));
});
it("are not cited in the code", () => {
// Reads every tracked and new file of the repo: ~0.6 s idle, past 5 s on a busy machine's disk and CPU.
it("are not cited in the code", { timeout: 30_000 }, () => {
const files = tracked();
expect(files.length).toBeGreaterThan(1000); // a scan that reads nothing is green over nothing
const hits = files.flatMap((f) =>
@@ -38,7 +38,8 @@ function files(dir: string): string[] {
}
describe("removed features stay removed", () => {
it("leaves no trace of the demo account", () => {
// Reads every source file of the repo: ~0.4 s idle, past 5 s on a busy machine's disk and CPU.
it("leaves no trace of the demo account", { timeout: 30_000 }, () => {
const hits = ROOTS.flatMap(files)
.filter((f) => !ALLOWED.has(f))
.flatMap((f) =>
+32 -8
View File
@@ -4,8 +4,8 @@ import { hexContrast, hueGap, mixHex } from "./colorMath";
import { SCHEMES } from "./theme";
// docs/DESIGN.md's colour rules, held in code instead of prose. Every scheme × mode in index.css:
// - body text, muted text and accent text ≥ 4.5:1 on the page, and the accent's own text ≥ 4.5:1 on
// an accent fill [WCAG 1.4.3];
// - body text and accent text ≥ 4.5:1 on the page, muted text ≥ 6:1 there (the headroom glass costs),
// and the accent's own text ≥ 4.5:1 on an accent fill [WCAG 1.4.3];
// - a control's edge (--edge-strong, the fg mixed 55% into the bg) ≥ 3:1 on the page [WCAG 1.4.11];
// - the accent ≥ 30° of OKLCH hue away from danger, warn and success, so a primary button never
// reads as a status (decision B2: the old YouTube, Slate, Forest and Matrix accents all did).
@@ -177,6 +177,22 @@ it("text on a solid accent fill uses --accent-fg", () => {
expect(offenders).toEqual([]);
});
// The tints measured below are only what renders while the tonal fills USE them: a translucent
// `bg-fg/8` there passes every token check and still lets the glass under a toolbar or a rail through.
it("a tonal fill under text is an opaque tint", () => {
const sites = [
"components/ui/Badge.tsx",
"components/ui/ItemCount.tsx",
"components/FilesRail.tsx",
];
const offenders = sites.flatMap((f) => {
const src = readFileSync(new URL(`../${f}`, import.meta.url), "utf8");
const bad = [...src.matchAll(/\bbg-(?:fg|accent|warn)\/\d+/g)].map((m) => `${f}: ${m[0]}`);
return /\bbg-tint/.test(src) ? bad : [...bad, `${f}: no bg-tint`];
});
expect(offenders).toEqual([]);
});
describe.each(SCHEMES.map((s) => s.id))("scheme %s", (id) => {
it.each(MODES)("%s: backdrop rule and file", (mode) => {
const file = `backdrops/${mode === "light" ? "light/" : ""}${id}.svg`;
@@ -200,15 +216,23 @@ describe.each(SCHEMES.map((s) => s.id))("scheme %s", (id) => {
// A score is text, on the page and on its own 15% chip (PlexInfo); the live glyph is not text.
rating: hexContrast(s.rating!, bg),
ratingOnChip: hexContrast(s.rating!, mixHex(s.rating!, bg, 0.15)),
// A Badge (a role, a status, a count, F29): the neutral one's muted text on the fg at 8%, a toned
// one's text on its own colour at 6% (ui/Badge).
mutedOnBadge: hexContrast(t.muted!, mixHex(t.fg!, bg, 0.08)),
accentOnBadge: hexContrast(t.accent!, mixHex(t.accent!, bg, 0.06)),
warnOnBadge: hexContrast(s.warn!, mixHex(s.warn!, bg, 0.06)),
// A tonal fill under text (the `tint` tokens: a Badge, the item count, the current rail row) is
// OPAQUE — its colour mixed into --surface, so the glass and what scrolls under it never show
// through: the neutral one's muted text on the fg at 8%, a toned one's text on its own colour at
// 6%, the current row's accent on the accent at 12%.
mutedOnBadge: hexContrast(t.muted!, mixHex(t.fg!, t.surface!, 0.08)),
accentOnBadge: hexContrast(t.accent!, mixHex(t.accent!, t.surface!, 0.06)),
warnOnBadge: hexContrast(s.warn!, mixHex(s.warn!, t.surface!, 0.06)),
accentOnCurrent: hexContrast(t.accent!, mixHex(t.accent!, t.surface!, 0.12)),
live: hexContrast(media().live!, bg),
};
const nonText = new Set(["edge", "live"]);
const failing = Object.entries(measured).filter(([k, v]) => v < (nonText.has(k) ? 3 : 4.5));
// Muted text mostly sits on GLASS (a toolbar, a tab row, a panel): the scrim, the blur and the
// backdrop under it cost up to ~1.5 of the ratio a bare --bg gives (pixel-measured over every
// scheme × mode × backdrop), so on the bare page it keeps that headroom over 4.5.
const onGlass = new Set(["muted"]);
const need = (k: string) => (nonText.has(k) ? 3 : onGlass.has(k) ? 6 : 4.5);
const failing = Object.entries(measured).filter(([k, v]) => v < need(k));
expect(failing.map(([k, v]) => `${k} ${v.toFixed(2)}:1`)).toEqual([]);
});
+50 -41
View File
@@ -18,6 +18,11 @@ beforeAll(async () => {
await import("./useVerifyEntry");
}, 60_000);
// Warm or not, each case still loads and evaluates the hook's graph afresh after its module reset:
// ~60 ms idle, past 5 s when the full suite's workers share a busy machine — and a case cut off
// mid-mount leaves its render behind for the next one to trip over.
const CASE_BUDGET = 30_000;
afterEach(() => {
cleanup();
vi.resetModules();
@@ -52,7 +57,7 @@ async function mountAt(url: string, verifyEmail: ReturnType<typeof vi.fn>) {
return () => screen.getByTestId("outcome").textContent;
}
describe("confirming an emailed verification link", () => {
describe("confirming an emailed verification link", { timeout: CASE_BUDGET }, () => {
it("posts the fragment token and reports the confirmed address", async () => {
const verifyEmail = vi.fn().mockResolvedValue({ ok: true, email: "someone@example.com" });
@@ -253,52 +258,56 @@ describe("confirming an emailed verification link", () => {
// already runs the app to a URL differing only in the fragment is a same-document navigation, so
// nothing reloads, the import-time snapshot never sees the token, and it sat unconsumed in the
// address bar — the same silence the sprint set out to remove, wearing a different hat.
describe("a verification link that arrives as a fragment-only navigation", () => {
it("consumes a token delivered by hashchange, and clears it from the address bar", async () => {
const verifyEmail = vi.fn().mockResolvedValue({ ok: true, email: "pasted@example.com" });
const outcome = await mountAt("/", verifyEmail);
expect(verifyEmail).not.toHaveBeenCalled();
describe(
"a verification link that arrives as a fragment-only navigation",
{ timeout: CASE_BUDGET },
() => {
it("consumes a token delivered by hashchange, and clears it from the address bar", async () => {
const verifyEmail = vi.fn().mockResolvedValue({ ok: true, email: "pasted@example.com" });
const outcome = await mountAt("/", verifyEmail);
expect(verifyEmail).not.toHaveBeenCalled();
await act(async () => {
window.history.replaceState(null, "", "/#verify=pasted-token");
window.dispatchEvent(new HashChangeEvent("hashchange"));
});
expect(verifyEmail).toHaveBeenCalledWith("pasted-token");
expect(outcome()).toBe("ok:pasted@example.com");
expect(window.location.hash).toBe("");
});
it("replaces a standing outcome rather than leaving the previous banner up", async () => {
const verifyEmail = vi
.fn()
.mockResolvedValueOnce({ ok: true, email: "first@example.com" })
// The server's own refusal, not a bare Error: only a 400 carrying {code:"verify"} means the
// token is spent, and this case is about the SECOND outcome replacing the first.
.mockImplementationOnce(async () => {
const { HttpError } = await import("./api/core");
throw new HttpError(400, "verify", { code: "verify" });
await act(async () => {
window.history.replaceState(null, "", "/#verify=pasted-token");
window.dispatchEvent(new HashChangeEvent("hashchange"));
});
const outcome = await mountAt("/#verify=first-token", verifyEmail);
expect(outcome()).toBe("ok:first@example.com");
await act(async () => {
window.history.replaceState(null, "", "/#verify=spent-token");
window.dispatchEvent(new HashChangeEvent("hashchange"));
expect(verifyEmail).toHaveBeenCalledWith("pasted-token");
expect(outcome()).toBe("ok:pasted@example.com");
expect(window.location.hash).toBe("");
});
expect(outcome()).toBe("invalid:-");
});
it("replaces a standing outcome rather than leaving the previous banner up", async () => {
const verifyEmail = vi
.fn()
.mockResolvedValueOnce({ ok: true, email: "first@example.com" })
// The server's own refusal, not a bare Error: only a 400 carrying {code:"verify"} means the
// token is spent, and this case is about the SECOND outcome replacing the first.
.mockImplementationOnce(async () => {
const { HttpError } = await import("./api/core");
throw new HttpError(400, "verify", { code: "verify" });
});
const outcome = await mountAt("/#verify=first-token", verifyEmail);
expect(outcome()).toBe("ok:first@example.com");
it("posts a given token once even if the same fragment arrives again", async () => {
const verifyEmail = vi.fn().mockResolvedValue({ ok: true, email: "same@example.com" });
await mountAt("/#verify=same-token", verifyEmail);
await act(async () => {
window.history.replaceState(null, "", "/#verify=spent-token");
window.dispatchEvent(new HashChangeEvent("hashchange"));
});
await act(async () => {
window.history.replaceState(null, "", "/#verify=same-token");
window.dispatchEvent(new HashChangeEvent("hashchange"));
expect(outcome()).toBe("invalid:-");
});
expect(verifyEmail).toHaveBeenCalledTimes(1);
});
});
it("posts a given token once even if the same fragment arrives again", async () => {
const verifyEmail = vi.fn().mockResolvedValue({ ok: true, email: "same@example.com" });
await mountAt("/#verify=same-token", verifyEmail);
await act(async () => {
window.history.replaceState(null, "", "/#verify=same-token");
window.dispatchEvent(new HashChangeEvent("hashchange"));
});
expect(verifyEmail).toHaveBeenCalledTimes(1);
});
},
);
+2 -1
View File
@@ -103,7 +103,8 @@ describe("the /watch entry", () => {
expect(leaks).toEqual([]);
});
it("catches a leak one import deep (the walk is not vacuous)", () => {
// Parses every module the whole app reaches: ~1.3 s idle, past 5 s on a busy machine.
it("catches a leak one import deep (the walk is not vacuous)", { timeout: 30_000 }, () => {
// main.tsx is the app: walking it must find the app's i18n, and a page it only loads lazily.
const app = reach(join(SRC, "main.tsx"));
expect(app.has("src/i18n/index.ts")).toBe(true);
+76 -11
View File
@@ -9,41 +9,80 @@
# bash scripts/check.sh lint the static lanes only: no tests, no build, no audits
# … --brief (either mode) a lane's output only when it fails, and only its first
# error lines — for an agent, whose context the full log would flood
# … --list the lane names of that mode, one per line — runs nothing
# … --only "<lane>" that one lane (an exact name from --list): a re-run of a red lane, or a
# runner that schedules lanes itself. Its green is that lane's, never "the
# gate"; an unknown name exits 2 rather than passing with nothing run
# GATE_TIMES_FILE=<file> … also append each lane's time as a TSV row (UTC, mode, lane, seconds,
# ok|red, HEAD's tree) — to compare runs; every run prints the times anyway
#
# Each lane runs even when an earlier one failed, and the summary names every red one: a gate that
# stops at the first failure hides the second until the first is fixed. The backend lanes run in the
# test image (backend/Dockerfile.test): requirements.lock will not install on Windows (uvloop), and a
# type checker or auditor without the real packages reports nonsense.
# shellcheck disable=SC2329 # the lane functions below are called by name, through lane()
set -u
cd "$(dirname "$0")/.." || exit 2
mode=all
brief=0
for arg in "$@"; do
case "$arg" in
all | lint) mode="$arg" ;;
list=0
only=""
usage() { echo 'usage: scripts/check.sh [lint] [--brief] [--list | --only "<lane>"]' >&2; exit 2; }
while [ $# -gt 0 ]; do
case "$1" in
all | lint) mode="$1" ;;
--brief) brief=1 ;;
*) echo "usage: scripts/check.sh [lint] [--brief]" >&2; exit 2 ;;
--list) list=1 ;;
--only) [ $# -ge 2 ] || usage; only="$2"; shift ;;
*) usage ;;
esac
shift
done
[ "$list" = 1 ] && [ -n "$only" ] && usage
export MSYS_NO_PATHCONV=1 # Git Bash: do not rewrite the in-container paths below
TESTCOMPOSE=backend/docker-compose.test.yml
failed=()
timings=()
ran=0
ms() { date +%s%3N; }
secs() { awk -v t="$1" 'BEGIN { printf "%.1f", t / 1000 }'; }
# How long each lane took: a line after the lane, a table at the end, and — when GATE_TIMES_FILE names a
# file — one tab-separated row per lane appended to it (UTC time, mode, lane, seconds, ok|red, tree), so
# runs can be compared. The script writes no file unless asked to.
took=""
timed() { # timed <name> <ok|red> <start-ms> — sets $took (no subshell: it must add to $timings)
took="$(secs $(($(ms) - $3)))"
timings+=("$(printf '%8s s %s' "$took" "$1")$([ "$2" = red ] && echo ' (red)')")
if [ -n "${GATE_TIMES_FILE:-}" ]; then
printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$(date -u +%FT%TZ)" "${only:+only}${only:-$mode}" "$1" "$took" "$2" \
"$(git rev-parse --short 'HEAD^{tree}' 2>/dev/null)" >>"$GATE_TIMES_FILE"
fi
return 0
}
lane() { # lane <name> <command…> — run it, remember a failure, never stop
local name="$1"
local name="$1" t0 result=ok
shift
if [ "$list" = 1 ]; then echo "$name"; return 0; fi
[ -n "$only" ] && [ "$name" != "$only" ] && return 0
ran=$((ran + 1))
t0="$(ms)"
if [ "$brief" = 0 ]; then
echo ">> $name…"
"$@" || failed+=("$name")
"$@" || { failed+=("$name"); result=red; }
timed "$name" "$result" "$t0"
echo "<< $name: $took s"
return 0
fi
local out
out="$(mktemp)"
if "$@" >"$out" 2>&1; then
echo ">> $name: ok"
timed "$name" ok "$t0"
echo ">> $name: ok ($took s)"
else
failed+=("$name")
echo ">> $name: FAILED"
timed "$name" red "$t0"
echo ">> $name: FAILED ($took s)"
brief_excerpt "$out"
fi
rm -f "$out"
@@ -83,6 +122,20 @@ ruff_pinned() {
ruff_check() { ruff_pinned && python -m ruff check --config backend/ruff.toml backend scripts; }
ruff_format() { ruff_pinned && python -m ruff format --check --config backend/ruff.toml backend scripts; }
# Every tracked shell script (the gate itself, backup/restore, the installer, the image's entrypoint) at
# the linter's full default bar, info included. Host shellcheck, held to this pin like ruff: a newer one
# adds checks, and a gate must not change under a green commit.
SHELLCHECK_PIN=0.11.0
shellcheck_lane() {
local host
host="$(shellcheck --version 2>/dev/null | sed -n 's/^version: //p')"
if [ "$host" != "$SHELLCHECK_PIN" ]; then
echo "shellcheck ${host:-missing} on the host, $SHELLCHECK_PIN pinned — https://github.com/koalaman/shellcheck/releases" >&2
return 1
fi
git ls-files -z -- '*.sh' | xargs -0 shellcheck
}
# New errors only: the pre-existing ones are in backend/.basedpyright/baseline.json. basedpyright
# TIGHTENS that file by itself when errors go, and the tree is bind-mounted read-write, so a green run
# can leave it rewritten: that fails here until it is committed, or the next run's stack reads stale.
@@ -102,8 +155,10 @@ basedpyright() {
frontend() { (cd frontend && "$@"); }
gate_t0="$(ms)"
lane "ruff" ruff_check
lane "ruff format --check" ruff_format
lane "shellcheck (every shell script)" shellcheck_lane
lane "basedpyright (new errors only)" basedpyright
# every frontend lane judges the HOST node_modules: this proves it is the tree the lock pins
lane "lock check (frontend/node_modules == package-lock.json)" node scripts/check-node-modules.mjs
@@ -119,7 +174,7 @@ pytest_lane() { # the full suite against a throwaway Postgres; --pull keeps the
local rc=0
docker compose -f "$TESTCOMPOSE" up --build --abort-on-container-exit --exit-code-from test || rc=1
docker compose -f "$TESTCOMPOSE" down -v >/dev/null 2>&1
return $rc
return "$rc"
}
# Every pinned backend package against the advisory databases, at any severity — the lock is a full
# `pip freeze`, so it is audited as written, without a resolver. An advisory with no fix is answered
@@ -139,9 +194,19 @@ if [ "$mode" = all ]; then
lane "npm audit (production dependencies)" frontend npm audit --omit=dev --audit-level=high
fi
[ "$list" = 1 ] && exit 0
if [ -n "$only" ] && [ "$ran" = 0 ]; then
echo "no lane named '$only' in mode $mode — see: scripts/check.sh $mode --list" >&2
exit 2
fi
scope="gate ($mode)"
[ -n "$only" ] && scope="lane '$only' only (not the gate)"
echo ">> lane times:"
printf '%s\n' "${timings[@]}"
echo "$(printf '%8s' "$(secs $(($(ms) - gate_t0)))") s total"
if [ ${#failed[@]} -eq 0 ]; then
echo ">> gate ($mode): green ✓"
echo ">> $scope: green ✓"
exit 0
fi
echo ">> gate ($mode): RED — ${failed[*]}" >&2
echo ">> $scope: RED — ${failed[*]}" >&2
exit 1
-64
View File
@@ -1,64 +0,0 @@
#!/usr/bin/env bash
# The fast lint lanes on the uncommitted files only — ruff (check + format) on Python, eslint on the
# frontend's code and prettier on all of the frontend — for a check after every edit instead of a
# full gate run. It is a convenience, never the gate: scripts/check.sh stays what a merge or a
# release requires.
#
# bash scripts/lint-changed.sh lint what `git status` shows as changed or new
# bash scripts/lint-changed.sh --hook the same, as a Claude Code Stop hook: silent and exit 0 when
# clean; otherwise the findings on stderr and exit 2, which
# hands them back to the agent before it ends its turn
#
# Measured 2026-10-04: one cold eslint run (type-aware) takes 3.4–4.7 s, so this runs once per turn
# (Stop), not after every edit — a warm daemon (eslint_d) would add a dependency and a stray process.
set -u
cd "$(dirname "$0")/.." || exit 2
hook=0
[ "${1:-}" = --hook ] && hook=1
if [ "$hook" = 1 ]; then
# A Stop hook that blocked once is told so on the next stop; let that one through, or an unfixable
# finding would keep the agent in a loop.
grep -q '"stop_hook_active": *true' && exit 0
fi
mapfile -t changed < <(git status --porcelain --untracked-files=all | sed -E 's/^.. //; s/.* -> //' |
while read -r f; do [ -f "$f" ] && printf '%s\n' "$f"; done)
# eslint reads code; prettier reads every file the gate's `prettier --check .` does (a locale JSON,
# a stylesheet), and `--ignore-unknown` passes over the ones it has no parser for.
py=() fe=() pretty=()
for f in "${changed[@]}"; do
case "$f" in
backend/*.py | scripts/*.py) py+=("$f") ;;
esac
case "$f" in
frontend/*.ts | frontend/*.tsx | frontend/*.js | frontend/*.mjs) fe+=("${f#frontend/}") ;;
esac
case "$f" in
frontend/*) pretty+=("${f#frontend/}") ;;
esac
done
report="$(
if [ ${#py[@]} -gt 0 ]; then
python -m ruff check --quiet --config backend/ruff.toml "${py[@]}" 2>&1
python -m ruff format --check --quiet --config backend/ruff.toml "${py[@]}" 2>&1
fi
if [ ${#fe[@]} -gt 0 ]; then
(cd frontend && npx eslint --max-warnings 0 "${fe[@]}" 2>&1)
fi
if [ ${#pretty[@]} -gt 0 ]; then
(cd frontend && npx prettier --check --ignore-unknown --log-level warn "${pretty[@]}" 2>&1)
fi
)"
if [ -z "$report" ]; then
[ "$hook" = 1 ] || echo "lint-changed: clean (${#py[@]} py, ${#pretty[@]} frontend)"
exit 0
fi
if [ "$hook" = 1 ]; then
printf 'lint-changed found problems in the uncommitted files:\n%s\n' "$report" >&2
exit 2
fi
printf '%s\n' "$report"
exit 1