release: 0.107.3 (improvement/dev-tooling)

This commit is contained in:
2026-10-17 21:34:53 +02:00
20 changed files with 240 additions and 98 deletions
+10
View File
@@ -7,6 +7,16 @@ 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
+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.2
0.107.3
+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 |
+8 -4
View File
@@ -46,12 +46,9 @@ bash scripts/check.sh lint # the static lanes only (no tests, build or audits)
bash scripts/check.sh --brief # either mode: one line per lane, output only for a red one
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"
bash scripts/lint-changed.sh # ruff + eslint + prettier on the uncommitted files only
```
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
@@ -87,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
+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 —
+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")} />
+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({
+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,14 @@
[
{
"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",
@@ -1,4 +1,14 @@
[
{
"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",
+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;
}
+12 -1
View File
@@ -1377,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)\"",
@@ -1394,6 +1404,7 @@
"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",
@@ -1405,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 \\",
+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([]);
});
+17 -1
View File
@@ -20,6 +20,7 @@
# 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
@@ -121,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.
@@ -143,6 +158,7 @@ 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
@@ -158,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
-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