diff --git a/CHANGELOG.md b/CHANGELOG.md
index cad73ee5..8092db61 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/CLAUDE.md b/CLAUDE.md
index b9353518..2acf4a0d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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 `
` |
| FE static label / button-like link | `Badge` (flat, no edge) · `LinkButton` / `IconLink` | a glass pill that reads as a chip; an `` 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 ``) 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 ``
-(`SegmentedToggle` / `FilterSelect`), a picture (`ui/RemoteImage`, never a bare ` `), and network calls (`req` / `uploadReq` / `beacon`, `lib/http.ts`).
+(`SegmentedToggle` / `FilterSelect`), a picture (`ui/RemoteImage`, never a bare ` `), 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)
diff --git a/VERSION b/VERSION
index 7d0edd39..775915d4 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.107.2
+0.107.3
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index 1d7f250a..f8774b57 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -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 |
diff --git a/docs/development.md b/docs/development.md
index 13dd22f4..842f3cef 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -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
diff --git a/frontend/e2e/README.md b/frontend/e2e/README.md
index 2fa68d23..6f8116fc 100644
--- a/frontend/e2e/README.md
+++ b/frontend/e2e/README.md
@@ -45,6 +45,11 @@ login-free `/watch/` 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;
diff --git a/frontend/e2e/a11y.spec.ts b/frontend/e2e/a11y.spec.ts
new file mode 100644
index 00000000..82984fc9
--- /dev/null
+++ b/frontend/e2e/a11y.spec.ts
@@ -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 {
+ await expect(
+ page.locator('[role="status"] .animate-spin, [aria-hidden="true"].animate-pulse'),
+ ).toHaveCount(0);
+}
+
+async function violations(page: Page): Promise {
+ 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([]);
+ });
+});
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 880bebdb..3467e0bf 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -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": {
diff --git a/frontend/package.json b/frontend/package.json
index 0fc26506..8b8604a3 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -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",
diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts
index 6fe0aa9e..0acd2290 100644
--- a/frontend/playwright.config.ts
+++ b/frontend/playwright.config.ts
@@ -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 —
diff --git a/frontend/src/components/FilesRail.tsx b/frontend/src/components/FilesRail.tsx
index c26ba92d..79471ae9 100644
--- a/frontend/src/components/FilesRail.tsx
+++ b/frontend/src/components/FilesRail.tsx
@@ -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",
)}
>
diff --git a/frontend/src/components/ui/Badge.tsx b/frontend/src/components/ui/Badge.tsx
index be4b8b60..4619c18f 100644
--- a/frontend/src/components/ui/Badge.tsx
+++ b/frontend/src/components/ui/Badge.tsx
@@ -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({
diff --git a/frontend/src/components/ui/ItemCount.tsx b/frontend/src/components/ui/ItemCount.tsx
index 2d7c7ce2..61761f36 100644
--- a/frontend/src/components/ui/ItemCount.tsx
+++ b/frontend/src/components/ui/ItemCount.tsx
@@ -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"
>
diff --git a/frontend/src/content/release-notes/en.json b/frontend/src/content/release-notes/en.json
index 63462bfa..bbe9d46f 100644
--- a/frontend/src/content/release-notes/en.json
+++ b/frontend/src/content/release-notes/en.json
@@ -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",
diff --git a/frontend/src/content/release-notes/hu.json b/frontend/src/content/release-notes/hu.json
index 3081cbc7..659354b0 100644
--- a/frontend/src/content/release-notes/hu.json
+++ b/frontend/src/content/release-notes/hu.json
@@ -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",
diff --git a/frontend/src/index.css b/frontend/src/index.css
index ba9de5d1..7c9533b2 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -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;
}
diff --git a/frontend/src/lib/gates.snapshot.json b/frontend/src/lib/gates.snapshot.json
index d6230d64..f6a147c5 100644
--- a/frontend/src/lib/gates.snapshot.json
+++ b/frontend/src/lib/gates.snapshot.json
@@ -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 \\",
diff --git a/frontend/src/lib/theme.contract.test.ts b/frontend/src/lib/theme.contract.test.ts
index 6628b61e..69473822 100644
--- a/frontend/src/lib/theme.contract.test.ts
+++ b/frontend/src/lib/theme.contract.test.ts
@@ -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([]);
});
diff --git a/scripts/check.sh b/scripts/check.sh
index 626255be..4a64f0d9 100644
--- a/scripts/check.sh
+++ b/scripts/check.sh
@@ -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
diff --git a/scripts/lint-changed.sh b/scripts/lint-changed.sh
deleted file mode 100644
index 817b48de..00000000
--- a/scripts/lint-changed.sh
+++ /dev/null
@@ -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