release: 0.96.0 (feature/e28-design-constitution)
gate / frontend (push) Failing after 6m12s
gate / backend (push) Successful in 7m22s

This commit is contained in:
2026-09-27 22:46:39 +02:00
496 changed files with 33187 additions and 12607 deletions
+811 -1400
View File
File diff suppressed because it is too large Load Diff
+9 -4
View File
@@ -46,11 +46,13 @@ how it gets built. Concretely:
behind each option and the weight of the decision. These decision points are where the product owner
engages with the build and learns from it, so make them understandable — a reasoned menu, not a bare one.
- **Mockups for design/UX decisions.** For any visual or interaction choice, show mockup options to
choose from before building.
choose from before building. How the UI looks and behaves is decided in
[`docs/DESIGN.md`](docs/DESIGN.md) (its variants render in the dev-only `/design-lab`): build to it,
and change it only through its decision log.
- **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.
bugs; do a real visual check of any UI change, and run DESIGN.md's four-step Conformance check.
- **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.
@@ -79,7 +81,7 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| ORM | SQLAlchemy 2.0 `select()` | `db.query(...)` |
| Request body | a Pydantic model | `payload: dict` |
| Request SIZE | nothing — `bodylimit` caps every route (SEC-20); need more? add a ceiling to `ROUTE_MAX_BODY_BYTES` | `capped_body` on a route that does not read raw bytes itself |
| Request SIZE | nothing — `bodylimit` caps every route (SEC-20); need more? add a ceiling to `ROUTE_MAX_BODY_BYTES` | `capped_body` on a route that does not read raw bytes itself |
| Ownership check | `owned_or_404` / `get_or_404` | ad-hoc `_own_playlist`/`_owned`/`_own_job` |
| Admin gate import | `app.auth` | the `app.routes.admin` re-export |
| Upstream failure status | `422` with JSON detail | `502` (reserve 502 for raw byte-proxies) |
@@ -90,10 +92,13 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
| Job status word | the `downloads/status.py` StrEnum | loose `"canceled"`/`"cancelled"` literals |
| Current time | inline `datetime.now(timezone.utc)` | new `now_utc`/`_now` helpers |
| FE per-account state | `usePref` | raw `readAccount`/`writeAccount` or bespoke hooks |
| FE action button | `ui/Button` — pick a `SIZES` rung | a hand-rolled `<button className="px-3 py-2 …">` |
| FE button | `ui/Button` (a `SIZES` rung) · `IconButton` · `Pressable` (a clickable card/row) · `Chip` · `DragHandle` | a raw `<button>` or `FocusSafeButton` outside the control primitives (lint-enforced) |
| FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals |
| FE list/grid keys | `NavGrid`/`keyboardNav` + `navItemAttrs` + `data-nav-open` | a private arrow-key / roving-tabindex handler |
| FE view push (history) | `rememberNavPosition()` right before `history.pushState` | a push that forgets: Back strands focus on `<body>` |
| FE text field / checkbox / slider | `ui/Input` · `Textarea` · `InnerInput` in a `fieldClass` frame · `Checkbox`/`CheckRow` · `Slider` | a raw `<input>`/`<textarea>` anywhere but those primitives (lint-enforced) |
| FE glass surface | a role: `SURFACE.panel` / `card` / `menu` / `popover` / `tooltip` (`ui/surfaces.ts`) | a bare `glass`/`glass-card`/`glass-menu` beside a hand-picked radius (lint-enforced) |
| 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 control | `SegmentedToggle`; a long or user's list: `FilterSelect` | a native `<select>` (lint-enforced) |
| 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 |
+1 -1
View File
@@ -1 +1 @@
0.95.0
0.96.0
+3 -1
View File
@@ -12,7 +12,9 @@ class Base(DeclarativeBase):
# Pool sized above the sync-endpoint threadpool (~40) so a burst of short DB sessions can't starve on
# checkout. The image proxies deliberately DON'T hold a connection during their slow external fetch
# (see routes/plex/), so this headroom is only for the many brief sessions, not long ones.
# (see routes/plex/), so this headroom is only for the many brief sessions, not long ones. The one
# long hold — a playlist sync's advisory claim, for a whole push — opens its own unpooled connection
# (sync/playlists.py `_claim_engine`) instead of drawing a third one from here.
engine = create_engine(
settings.database_url,
pool_pre_ping=True,
+84 -4
View File
@@ -32,6 +32,7 @@ window still took the full 145 s and emitted the whole track), so windows are ex
zero-based clock and shifted back to absolute time here with `shift_vtt`.
"""
import codecs
import json
import re
import subprocess
@@ -217,7 +218,11 @@ _FULL_END_S = 1_000_000_000.0 # stand-in for "to the end of the file" (JSON can
def cache_key(rating_key: str, sub_ord: int) -> str:
return f"sub_{rating_key}_{sub_ord}"
# Versioned whenever `decode_subtitle` changes what a track decodes to — `sub2_` when legacy code
# pages were cached as U+FFFD, `sub3_` when a stray byte, an untagged file or Serbian still went
# to the wrong code page. The cache has no eviction, so every track is read again once rather
# than a broken one being served for good; the older files are just left behind.
return f"sub3_{rating_key}_{sub_ord}"
def _vtt_path(key: str) -> Path:
@@ -904,15 +909,90 @@ def _full_pass_start(rating_key: str, targets: dict[int, int]) -> float:
return max(0.0, float(f"{min(ends) - WINDOW_PRE_S:.3f}"))
# The legacy code page a subtitle in this language (Plex's ISO 639-2 `languageCode`) was most likely
# saved in. Serbian sits here, not with Cyrillic: its releases are overwhelmingly Latin script in
# Windows-1250, like Croatian and Bosnian.
_CP1250 = {
"hun",
"ces",
"cze",
"pol",
"slk",
"slo",
"slv",
"hrv",
"bos",
"srp",
"ron",
"rum",
"sqi",
"alb",
}
_CP1251 = {"rus", "ukr", "bel", "bul", "mkd", "mac"}
# Western European languages keep Windows-1252 (cp1250 has no à/è/ñ). An UNTAGGED file falls to
# Windows-1250: its ASCII and á/é/í/ó/ö/ú/ü match Windows-1252 byte for byte, and it also gets ő/ű
# right, which Windows-1252 turns into õ/û — the library this reads is mostly Hungarian.
_CP1252 = {
"eng",
"fre",
"fra",
"ger",
"deu",
"spa",
"ita",
"por",
"dut",
"nld",
"swe",
"dan",
"nor",
"fin",
"ice",
"isl",
"cat",
}
# A file that is almost all valid UTF-8 is UTF-8 with a few stray bytes (a pasted Windows-1252 quote),
# not a legacy file: accents in a legacy code page are nearly never valid UTF-8 sequences, so a real
# legacy file has hardly any. At least this many valid multi-byte characters per invalid byte.
_UTF8_VALID_PER_STRAY = 10
def decode_subtitle(raw: bytes, language_code: str | None) -> str:
"""A sidecar subtitle file as text. SubRip carries no charset, and a Hungarian release is
typically Windows-1250: decoded as UTF-8 with `replace`, every ő/ű/é came out as U+FFFD (found
live, 1391 of them in one episode). So: a BOM decides; else valid UTF-8 stays UTF-8 (a legacy
code page with accents is almost never valid UTF-8), and so does NEARLY valid UTF-8 — one stray
byte must not send a whole UTF-8 file to a legacy code page; else the language's legacy code page."""
if raw.startswith(codecs.BOM_UTF8):
return raw[len(codecs.BOM_UTF8) :].decode("utf-8", "replace")
if raw.startswith((codecs.BOM_UTF16_LE, codecs.BOM_UTF16_BE)):
return raw.decode("utf-16", "replace")
try:
return raw.decode("utf-8")
except UnicodeDecodeError:
# silent-ok: not strictly UTF-8 — nearly-UTF-8 or a legacy code page, told apart below.
pass
lenient = raw.decode("utf-8", "replace")
stray = lenient.count("�")
valid = sum(1 for ch in lenient if ch > "" and ch != "�")
if valid >= stray * _UTF8_VALID_PER_STRAY:
return lenient
lang = (language_code or "").lower()
legacy = "cp1251" if lang in _CP1251 else "cp1252" if lang in _CP1252 else "cp1250"
return raw.decode(legacy, "replace")
def _sidecar_vtt(db: Session, stream: dict) -> str:
"""An external subtitle file, fetched through Plex and converted to WebVTT."""
"""An external subtitle file, fetched through Plex and converted to WebVTT. Its bytes are made
UTF-8 first (`decode_subtitle`), on the ffmpeg path too: ffmpeg reads a text subtitle as UTF-8."""
with PlexClient(db) as plex:
raw = plex.raw_get(stream["key"])
text = decode_subtitle(raw, stream.get("languageCode"))
codec = (stream.get("codec") or stream.get("format") or "").lower()
if codec in ("srt", "subrip", ""):
return srt_to_vtt(raw.decode("utf-8", "replace"))
return srt_to_vtt(text)
with tempfile.NamedTemporaryFile(suffix="." + (codec or "sub"), delete=False) as fh:
fh.write(raw)
fh.write(text.encode("utf-8"))
tmp = fh.name
try:
return ffmpeg_to_vtt(tmp, None)
+9 -5
View File
@@ -28,10 +28,13 @@ def not_found(what: str = "generic") -> HTTPException:
return HTTPException(status_code=404, detail={"code": "not_found", "reason": what})
def get_or_404(db: Session, model: type[M], id_: Any, *, what: str = "generic") -> M:
def get_or_404(
db: Session, model: type[M], id_: Any, *, what: str = "generic", lock: bool = False
) -> M:
"""Fetch a row by primary key or raise 404. Replaces the `db.get(Model, id); if None: 404`
idiom repeated across routes."""
obj = db.get(model, id_)
idiom repeated across routes. `lock` takes it FOR UPDATE (bypassing the identity map), so two
requests that read-then-write its children (a playlist's item positions) run one after the other."""
obj = db.get(model, id_, with_for_update=True if lock else None)
if obj is None:
raise not_found(what)
return obj
@@ -45,12 +48,13 @@ def owned_or_404(
*,
owner_attr: str = "user_id",
what: str = "generic",
lock: bool = False,
) -> M:
"""Fetch a row by primary key that `user` owns, or raise 404. A row owned by someone
"""Fetch a row by primary key that `user` owns, or raise 404 (`lock`: see `get_or_404`). A row owned by someone
else 404s (NOT 403) on purpose — a 403 would confirm the id exists to a stranger.
It replaced the near-identical per-route ownership helpers, and is called directly — a thin
`_own_x` wrapper around it is the ad-hoc shape CLAUDE.md's patterns table rules out."""
obj = get_or_404(db, model, id_, what=what)
obj = get_or_404(db, model, id_, what=what, lock=lock)
if getattr(obj, owner_attr) != user.id:
raise not_found(what)
return obj
+15 -14
View File
@@ -28,7 +28,7 @@ from app.models import (
from app.routes._common import get_or_404, not_found, owned_or_404, youtube_refusal
from app.sync.explore import explore_ingest_page
from app.sync.runner import backfill_channel_now, run_recent_backfill
from app.sync.subscriptions import apply_channel_details
from app.sync.subscriptions import apply_channel_details, details_stale
from app.utils import iso
from app.youtube.client import YouTubeClient, YouTubeError
from app.refusal import refused
@@ -190,7 +190,7 @@ def discover_channels(
quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER),
YouTubeClient(db, user) as yt,
):
apply_channel_details(db, yt.get_channels(need))
apply_channel_details(db, yt.get_channels(need), need)
db.commit()
# Match _discovery_rows' ORDER BY exactly: video-count DESC, then title (NULLs LAST,
# as Postgres orders them — coercing NULL→"" would wrongly float untitled channels up).
@@ -296,17 +296,20 @@ def channel_detail(
"""One channel's "About" detail for the channel page header/About tab, plus this user's
relationship to it (subscribed / explored) and how many of its videos are in the catalog.
Lazily fills the About metadata (view count, join date, banner, links) the first time it's
needed — a stub, or a channel last enriched before those fields existed (published_at is a
reliable sentinel: every real channel has a creation date). The enrich uses the API key
(1 unit, cached after), so it's skipped for the quota-less demo account."""
needed (a stub) and refreshes it once it is stale (`DETAILS_MAX_AGE`), which is how a channel nobody subscribes to (an explored one, outside the
subscription import) gets a banner that still resolves. The enrich uses the API key (1 unit,
cached after), so it's skipped for the quota-less demo account."""
channel = get_or_404(db, Channel, channel_id, what="channel")
if channel.published_at is None and not user.is_demo:
# No `published_at is None` sentinel: a terminated channel never gets one, so it re-fetched on
# every view; rows enriched before that field existed are long past DETAILS_MAX_AGE anyway.
stale = details_stale(channel, datetime.now(timezone.utc))
if stale and not user.is_demo:
try:
with (
quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER),
YouTubeClient(db, user) as yt,
):
apply_channel_details(db, yt.get_channels([channel_id]))
apply_channel_details(db, yt.get_channels([channel_id]), [channel_id])
db.commit()
channel = db.get(Channel, channel_id)
except YouTubeError as exc:
@@ -412,12 +415,10 @@ def explore_channel(
YouTubeClient(db, user) as yt,
):
# Enrich for the About panel + the uploads playlist id (needed to ingest videos).
if (
channel.details_synced_at is None
or channel.uploads_playlist_id is None
or channel.published_at is None
):
apply_channel_details(db, yt.get_channels([channel_id]))
# Stale, not "a field is missing": a terminated channel never gets an uploads playlist
# or a creation date, and asking again on every explore would spend quota on a miss.
if details_stale(channel, datetime.now(timezone.utc)):
apply_channel_details(db, yt.get_channels([channel_id]), [channel_id])
db.commit()
channel = db.get(Channel, channel_id)
result = explore_ingest_page(db, yt, channel, page_token)
@@ -590,7 +591,7 @@ def subscribe(
# must not block recording the subscription the user asked for.
if channel.details_synced_at is None:
try:
apply_channel_details(db, yt.get_channels([channel_id]))
apply_channel_details(db, yt.get_channels([channel_id]), [channel_id])
except YouTubeError as exc:
log.warning(
"subscribe: stub enrich failed: %s",
+128 -71
View File
@@ -4,9 +4,8 @@ mirror the user's own YouTube playlists in (`/sync-youtube`) and push/revert loc
out (`/{id}/push-plan`, `/{id}/revert-youtube`), behind the optional write scope."""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from pydantic import BaseModel, Field
from sqlalchemy import func, select, and_
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, aliased
from app import quota
@@ -15,14 +14,18 @@ from app.db import get_db
from app.feedquery import feed_columns, serialize
from app.models import Channel, Playlist, PlaylistItem, User, Video, VideoState
from app.sync.playlists import (
PlaylistGone,
lock_playlists,
merge_playlist,
plan_merge,
recompute_dirty,
pull_user_playlists,
repull_playlist,
sync_user_playlists,
sync_claim,
user_sync_claim,
)
from app.ratelimit import RateLimiter
from app.routes._common import get_or_404, owned_or_404, youtube_refusal
from app.routes._common import get_or_404, not_found, owned_or_404, youtube_refusal
from app.youtube.client import YouTubeClient, YouTubeError
router = APIRouter(prefix="/api/playlists", tags=["playlists"])
@@ -79,7 +82,10 @@ def _next_playlist_position(db: Session, user_id: int) -> int:
def _next_item_position(db: Session, playlist_id: int) -> int:
"""Append position for a new item at the end of a playlist."""
"""Append position for a new item at the end of a playlist. Locks the playlist row first: two
adds (two tabs, the rail and the page) otherwise both read the same max and land on one
position, and the list's order between them is then arbitrary."""
lock_playlists(db, playlist_id)
return (
db.scalar(
select(func.coalesce(func.max(PlaylistItem.position), -1)).where(
@@ -92,7 +98,10 @@ def _next_item_position(db: Session, playlist_id: int) -> int:
def _add_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) -> bool:
"""Append a video to a playlist if not already present (idempotent). Returns whether it
was added. `mark_dirty` recomputes the YouTube-sync dirty flag (skip for Watch later)."""
was added. `mark_dirty` recomputes the YouTube-sync dirty flag (skip for Watch later).
Locked BEFORE the presence check: two adds of one video both saw "absent" and the second one's
insert hit uq_playlist_video as a 500."""
lock_playlists(db, pl.id)
exists = db.scalar(
select(PlaylistItem).where(
PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id
@@ -113,6 +122,7 @@ def _add_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) ->
def _remove_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) -> bool:
"""Remove a video from a playlist if present (idempotent). Returns whether it was removed.
`mark_dirty` recomputes the YouTube-sync dirty flag (skip for Watch later)."""
lock_playlists(db, pl.id)
item = db.scalar(
select(PlaylistItem).where(
PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id
@@ -321,13 +331,18 @@ def sync_youtube(
Also runs automatically on the scheduler; this is the manual 'sync now'."""
if not has_read_scope(user):
raise _refused(403, "youtube_read_scope")
_check_pull_cooldown(user)
try:
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
return {"ok": True, **sync_user_playlists(db, user)}
except YouTubeError as exc:
db.rollback()
raise youtube_refusal(exc)
with user_sync_claim(db, user.id) as free:
if not free:
# Another run (the scheduler, another tab) is pulling right now: "0 synced" would lie.
raise _refused(409, "sync_all_busy")
# After the claim, like the re-pull's: a refused attempt must not spend one of the pulls.
_check_pull_cooldown(user)
try:
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
return {"ok": True, **pull_user_playlists(db, user)}
except YouTubeError as exc:
db.rollback()
raise youtube_refusal(exc)
@router.post("/{playlist_id}/revert-youtube")
@@ -344,13 +359,20 @@ def revert_youtube(
raise _refused(400, "not_on_youtube")
if not has_read_scope(user):
raise _refused(403, "youtube_read_scope")
_check_pull_cooldown(user)
try:
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
return {"ok": True, **repull_playlist(db, user, pl)}
except YouTubeError as exc:
db.rollback()
raise youtube_refusal(exc)
with sync_claim(db, pl.id) as free:
if not free:
raise _refused(409, "sync_busy")
# The cooldown is spent only by a pull that runs: checked after the claim, so clicking
# Revert while a push is in flight does not use up the three pulls per ten minutes.
_check_pull_cooldown(user)
try:
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
return {"ok": True, **repull_playlist(db, user, pl)}
except PlaylistGone:
raise not_found("playlist")
except YouTubeError as exc:
db.rollback()
raise youtube_refusal(exc)
def _pushable(db: Session, user: User, playlist_id: int) -> Playlist:
@@ -392,25 +414,32 @@ def push(
neither side loses an item). Refuses if the estimated quota exceeds what's left today, so a
big sync can't strand half-applied. Write scope required (require_write_scope)."""
pl = _pushable(db, user, playlist_id)
try:
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH):
# Read the live playlist ONCE (linked playlists only) and share it with both the
# plan and the merge, halving read-quota and closing the second read→write TOCTOU
# window. A fresh export (no yt_playlist_id) reads nothing.
live = None
if pl.yt_playlist_id:
with YouTubeClient(db, user) as yt:
live = (
list(yt.iter_playlist_items_with_ids(pl.yt_playlist_id)),
yt.get_playlist_snippet(pl.yt_playlist_id),
)
plan = plan_merge(db, user, pl, live=live)
if plan["units_estimate"] > quota.remaining_today(db):
raise _refused(429, "quota_exceeded")
return {"ok": True, **merge_playlist(db, user, pl, live=live)}
except YouTubeError as exc:
db.rollback()
raise youtube_refusal(exc)
# One sync of a list at a time, across tabs, devices and the scheduler (`sync_claim`): the
# client's own in-flight flag only covers the menus of one page.
with sync_claim(db, pl.id) as free:
if not free:
raise _refused(409, "sync_busy")
try:
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH):
# Read the live playlist ONCE (linked playlists only) and share it with both the
# plan and the merge, halving read-quota and closing the second read→write TOCTOU
# window. A fresh export (no yt_playlist_id) reads nothing.
live = None
if pl.yt_playlist_id:
with YouTubeClient(db, user) as yt:
live = (
list(yt.iter_playlist_items_with_ids(pl.yt_playlist_id)),
yt.get_playlist_snippet(pl.yt_playlist_id),
)
plan = plan_merge(db, user, pl, live=live)
if plan["units_estimate"] > quota.remaining_today(db):
raise _refused(429, "quota_exceeded")
return {"ok": True, **merge_playlist(db, user, pl, live=live)}
except PlaylistGone:
raise not_found("playlist")
except YouTubeError as exc:
db.rollback()
raise youtube_refusal(exc)
@router.get("/{playlist_id}")
@@ -427,7 +456,8 @@ def get_playlist(
.join(Channel, Channel.id == Video.channel_id)
.outerjoin(state, and_(state.video_id == Video.id, state.user_id == user.id))
.where(PlaylistItem.playlist_id == pl.id)
.order_by(PlaylistItem.position)
# `id` breaks a position tie the same way on every read (the renumbering add does too).
.order_by(PlaylistItem.position, PlaylistItem.id)
).all()
return {
"id": pl.id,
@@ -451,7 +481,8 @@ def rename_playlist(
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
# Locked: the new name moves `dirty`, which the scheduled mirror refresh reads under this lock.
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist", lock=True)
if "name" in body.model_fields_set:
name = (body.name or "").strip()
if not name:
@@ -491,33 +522,65 @@ def delete_playlist(
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
if pl.kind == "watch_later":
raise _refused(400, "watch_later_undeletable")
if on_youtube:
if not pl.yt_playlist_id:
raise _refused(400, "not_on_youtube")
if not has_write_scope(user):
raise _refused(403, "youtube_editing_off")
try:
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_DELETE):
with YouTubeClient(db, user) as yt:
yt.delete_playlist(pl.yt_playlist_id)
except YouTubeError as exc:
raise youtube_refusal(exc)
db.delete(pl) # items cascade
db.commit()
# Not in the middle of a sync of it (`sync_claim`): a push that read the list before the delete
# and wrote YouTube after it planned from no items at all, so it emptied the YouTube playlist a
# local-only delete means to keep. The push checks for a vanished list too (`PlaylistGone`);
# this refuses the delete while a sync runs rather than race it.
with sync_claim(db, pl.id) as free:
if not free:
raise _refused(409, "sync_busy")
if on_youtube:
if not pl.yt_playlist_id:
raise _refused(400, "not_on_youtube")
if not has_write_scope(user):
raise _refused(403, "youtube_editing_off")
try:
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_DELETE):
with YouTubeClient(db, user) as yt:
yt.delete_playlist(pl.yt_playlist_id)
except YouTubeError as exc:
raise youtube_refusal(exc)
# The playlist first, as every item write: the cascade deletes the items before the list row.
lock_playlists(db, pl.id)
db.delete(pl) # items cascade
db.commit()
return {"deleted": playlist_id}
class AddItemIn(VideoRefIn):
# Where the video lands among the list's items, in their stored order (0 = first). Omitted, it
# is appended. The list page's "Undo" of a removal puts the row back where it was with this —
# ONE request, because append-then-reorder leaves it at the end whenever the second call fails.
position: int | None = Field(default=None, ge=0)
@router.post("/{playlist_id}/items")
def add_item(
playlist_id: int,
body: VideoRefIn,
body: AddItemIn,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
video_id = body.video_id
get_or_404(db, Video, video_id, what="video")
_add_item(db, pl, video_id, mark_dirty=True)
added = _add_item(db, pl, video_id, mark_dirty=False)
if added and body.position is not None:
# Renumber the whole list with the new row at `position` (clamped to the end): positions
# can have gaps after removals, so "index N" is only meaningful over the ordered rows.
rows = list(
db.execute(
select(PlaylistItem)
.where(PlaylistItem.playlist_id == pl.id)
.order_by(PlaylistItem.position, PlaylistItem.id)
).scalars()
)
new = next(r for r in rows if r.video_id == video_id)
rows.remove(new)
rows.insert(min(body.position, len(rows)), new)
for pos, row in enumerate(rows):
row.position = pos
recompute_dirty(db, pl)
db.commit()
return {"ok": True, "playlist_id": pl.id, "video_id": video_id}
@@ -562,6 +625,8 @@ def move_item(
raise HTTPException(
status_code=400, detail={"code": "playlist_move", "reason": "same_playlist"}
)
# Both lists, in id order: a move A→B beside a move B→A would otherwise lock crosswise.
lock_playlists(db, src.id, dst.id)
item = db.scalar(
select(PlaylistItem).where(
PlaylistItem.playlist_id == src.id, PlaylistItem.video_id == video_id
@@ -581,20 +646,11 @@ def move_item(
if already is None:
# Re-parent the existing row rather than delete+insert: `added_at` ("when I saved this")
# survives the move, and there is no window where the video belongs to neither list. The
# check above is not a lock: a second tab's add or the scheduler's YouTube merge can insert
# the same (playlist, video) in between, and uq_playlist_video then refuses the re-parent.
# That is the already-present outcome arriving late, not a failure — answer it the same way
# instead of a raw 500.
try:
with db.begin_nested():
item.playlist_id = dst.id
item.position = _next_item_position(db, dst.id)
db.flush()
dst_changed = True
# silent-ok: the conflict IS the answer — the target already holds the video, which is the
# idempotent outcome; the savepoint put the row back in the source list, dropped below.
except IntegrityError:
pass
# check above holds: both lists are locked, and an insert into the target (another tab's
# add, a YouTube merge) waits for the lock — its foreign key needs the target row.
item.playlist_id = dst.id
item.position = _next_item_position(db, dst.id)
dst_changed = True
if not dst_changed:
db.delete(item)
# The source always changed; the target only when the row landed in it. `_touch` on a target
@@ -619,7 +675,8 @@ def reorder_items(
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
# Locked: a concurrent add between reading the items and writing the order would collide.
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist", lock=True)
order = body.video_ids
items = {
it.video_id: it
+94 -17
View File
@@ -3,8 +3,10 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy import delete, func, select, update
from typing import Annotated
from pydantic import BaseModel, Field, model_validator
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from app.auth import current_user
@@ -217,7 +219,8 @@ def playlist_add_item(
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
# Locked (like every add / reorder here): two adds otherwise read the same max position.
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist", lock=True)
it = by_rating_key(db, PlexItem, body.item_rating_key)
if it is None:
raise not_found("item")
@@ -249,7 +252,9 @@ def playlist_remove_item(
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
# Locked like the adds and the reorder: the list row first, then its items — a remove that
# locked an item row before a reorder locked the list deadlocked against it.
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist", lock=True)
it = by_rating_key(db, PlexItem, item_rating_key)
if it is not None:
db.execute(
@@ -285,16 +290,75 @@ def _items_by_rks(db: Session, rks: list) -> list[PlexItem]:
return out
class PlexItemAddBulkIn(PlexItemKeysIn):
# Where each key goes back (E28 S3e2, the list page's "Undo" of a removal): an index over the
# ORDERED rows, one per key. The two-call shape (append, then send the whole order) leaves the rows
# at the end whenever the second call fails, and the order it sends would overwrite anything
# another tab changed in between.
positions: list[Annotated[int, Field(ge=0)]] | None = None
@model_validator(mode="after")
def _one_position_per_key(self):
if self.positions is not None and len(self.positions) != len(self.item_rating_keys):
raise ValueError("positions must pair one-to-one with item_rating_keys")
return self
def _insert_at(db: Session, pid: int, placed: list[tuple[int, PlexItem]]) -> int:
"""Insert new rows at their indices (ascending, so each index is where the row stood when the
rows before it were back), renumber the whole list 0..n-1, and say how many were added. An item
already in the list does not move: the add stays idempotent."""
rows = list(
db.execute(
select(PlexPlaylistItem)
.where(PlexPlaylistItem.playlist_id == pid)
.order_by(PlexPlaylistItem.position, PlexPlaylistItem.id)
).scalars()
)
present = {r.item_id for r in rows}
added = 0
for at, it in sorted(placed, key=lambda p: p[0]):
if it.id in present:
continue
row = PlexPlaylistItem(playlist_id=pid, item_id=it.id, position=0)
db.add(row)
rows.insert(min(at, len(rows)), row)
present.add(it.id)
added += 1
for i, r in enumerate(rows):
r.position = i
return added
@router.post("/playlists/{pid}/items/bulk")
def playlist_add_bulk(
pid: int,
body: PlexItemKeysIn,
body: PlexItemAddBulkIn,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Append many items at once (a whole season/show). Already-present items are skipped; the input
order is preserved for the newly-added ones. Returns how many were added + the new total."""
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
order is preserved for the newly-added ones. With `positions`, each key is put back at its index
instead of appended. Returns how many were added + the new total."""
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist", lock=True)
if body.positions is not None:
by_rk = {it.rating_key: it for it in _items_by_rks(db, body.item_rating_keys)}
placed = [
(at, by_rk[str(rk)])
for rk, at in zip(body.item_rating_keys, body.positions, strict=True)
if str(rk) in by_rk
]
added = _insert_at(db, pid, placed)
if added:
pl.updated_at = datetime.now(timezone.utc)
db.commit()
total = (
db.scalar(
select(func.count(PlexPlaylistItem.id)).where(PlexPlaylistItem.playlist_id == pid)
)
or 0
)
return {"ok": True, "added": added, "item_count": total}
items = _items_by_rks(db, body.item_rating_keys)
existing = set(
db.execute(
@@ -337,7 +401,7 @@ def playlist_remove_bulk(
) -> dict:
"""Remove many items at once (a whole season/show, or a multi-select). Returns how many were
removed + the new total."""
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist", lock=True)
ids = [it.id for it in _items_by_rks(db, body.item_rating_keys)]
removed = 0
if ids:
@@ -367,7 +431,7 @@ def reorder_playlist(
db: Session = Depends(get_db),
) -> dict:
"""Set the playlist order from an explicit list of item rating_keys (0-based positions)."""
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist", lock=True)
order = body.item_rating_keys
id_by_rk = {
it.rating_key: it.id
@@ -375,14 +439,27 @@ def reorder_playlist(
select(PlexItem).where(PlexItem.rating_key.in_(order or [""]))
).scalars()
}
for pos, rk in enumerate(order):
iid = id_by_rk.get(rk)
if iid is not None:
db.execute(
update(PlexPlaylistItem)
.where(PlexPlaylistItem.playlist_id == pid, PlexPlaylistItem.item_id == iid)
.values(position=pos)
)
rows = {
r.item_id: r
for r in db.execute(
select(PlexPlaylistItem).where(PlexPlaylistItem.playlist_id == pid)
).scalars()
}
pos = 0
seen: set[int] = set()
for rk in order:
row = rows.get(id_by_rk.get(rk, -1))
if row is not None and row.item_id not in seen:
row.position = pos
pos += 1
seen.add(row.item_id)
# Rows the payload omitted (added in another tab meanwhile) keep their relative order after
# the given ones, so no two rows share a position (the YouTube reorder does the same).
for row in sorted(
(r for iid, r in rows.items() if iid not in seen), key=lambda r: (r.position, r.id)
):
row.position = pos
pos += 1
pl.updated_at = datetime.now(timezone.utc)
db.commit()
return {"ok": True}
+1 -1
View File
@@ -130,7 +130,7 @@ def _ingest_candidates(
try:
with quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER):
details = yt.get_channels(new_channel_ids)
apply_channel_details(db, details)
apply_channel_details(db, details, new_channel_ids)
db.commit()
except YouTubeError:
# The stubs remain and the details can be backfilled later — but until they are, those
+185 -31
View File
@@ -8,13 +8,15 @@ ingested (with a stub channel) so the mirror is faithful."""
import hashlib
import json
from collections.abc import Callable
from collections.abc import Callable, Iterator
from contextlib import AbstractContextManager, contextmanager
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import TypeVar
from sqlalchemy import delete, select
from sqlalchemy import Engine, create_engine, delete, func, select
from sqlalchemy.orm import Session
from sqlalchemy.pool import NullPool
from app import logs, progress, quota
from app.auth import has_read_scope
@@ -27,6 +29,87 @@ log = logs.get_logger(__name__)
# Cost of a single YouTube write op (playlists/playlistItems insert/update/delete).
WRITE_UNIT_COST = 50
# The advisory-lock namespaces ("PL" per playlist, "PU" per user), so a key cannot meet another
# feature's lock on the same number (Postgres keeps the two-int keys apart from the one-bigint ones).
_SYNC_LOCK_NS = 0x504C
_USER_SYNC_LOCK_NS = 0x5055
# The claims' own connections, outside the app pool: a claim is held for a whole push (seconds of
# YouTube calls) beside the request's pooled session and quota's, so a burst of pushes drawing a
# third pooled connection each ran the pool dry and stalled every other request for pool_timeout.
# NullPool opens one per claim and closes it after, so a lock can never ride back into a pool either.
_claim_engines: dict[str, Engine] = {}
def _claim_engine(db: Session) -> Engine:
url = db.get_bind().engine.url
key = url.render_as_string(hide_password=False)
eng = _claim_engines.get(key)
if eng is None:
eng = _claim_engines[key] = create_engine(url, poolclass=NullPool, hide_parameters=True)
return eng
@contextmanager
def _advisory_claim(db: Session, ns: int, key: int) -> Iterator[bool]:
"""Try a session-level advisory lock for the block on a connection of its own; yields whether it
was free. It survives the block's commits, and a crashed worker's lock dies with its connection
— no claim column that a crash leaves stuck."""
conn = _claim_engine(db).connect()
held = False
try:
held = bool(conn.scalar(select(func.pg_try_advisory_lock(ns, key))))
conn.commit()
yield held
finally:
try:
if held:
conn.scalar(select(func.pg_advisory_unlock(ns, key)))
conn.commit()
finally:
conn.close()
def sync_claim(db: Session, playlist_id: int) -> AbstractContextManager[bool]:
"""Claim one playlist's YouTube sync (push, re-pull, the scheduled mirror refresh) for the block;
yields whether it was free. A push reads YouTube, then writes both sides for seconds and commits
step by step, so a row lock ends at its first commit and two tabs (or a push and the scheduler)
interleave into a doubled YouTube item or a baseline recorded over the other's writes."""
return _advisory_claim(db, _SYNC_LOCK_NS, playlist_id)
class PlaylistGone(Exception):
"""The list a sync was working on was deleted meanwhile (another tab, the mirror drop). Its
items went with it, so a plan made now reads "every item removed here" — and would delete them
all from the YouTube playlist the reader chose to keep. The route answers 404."""
def lock_playlists(db: Session, *playlist_ids: int) -> set[int]:
"""Row-lock playlists before touching their items, several in id order. Every item write takes
the PLAYLIST first — the route edits, a push, a re-pull, the scheduled mirror refresh and a
delete alike: a writer that locked an item row first and the playlist second (a push's item
replace beside a remove) deadlocks against one that went the other way, and the loser is a
500. The rows are re-read, so `dirty`, the name and the sync baseline are the ones a finished
writer committed. Held to the caller's next commit — so no commit may come between this and
the write it guards. Returns the ids it found: a list deleted meanwhile is not among them."""
rows = db.scalars(
select(Playlist)
.where(Playlist.id.in_(sorted(set(playlist_ids))))
.order_by(Playlist.id)
.with_for_update()
.execution_options(populate_existing=True)
).all() # consumed: rows fetched are what refreshes the loaded objects
return {pl.id for pl in rows}
def _lock_or_gone(db: Session, pl: Playlist) -> None:
"""`lock_playlists` for a sync that goes on to write YouTube or the items: a list deleted since
it was read raises `PlaylistGone` (rolled back) instead of being planned as an empty one."""
pl_id = pl.id # read first: the rollback expires `pl`, and its row is gone
if pl_id not in lock_playlists(db, pl_id):
db.rollback()
raise PlaylistGone(pl_id)
def fingerprint(name: str, ids: list[str]) -> str:
"""A stable checksum of a playlist's name + ordered video ids. Stored as the synced
@@ -91,11 +174,18 @@ def parse_unavailable(pl: Playlist) -> set[str]:
return _load_baseline(pl)[1]
def _set_baseline(pl: Playlist, ids: list[str], unavailable: list[str] | None = None) -> None:
def _set_baseline(
pl: Playlist,
ids: list[str],
unavailable: list[str] | None = None,
*,
name: str | None = None,
) -> None:
"""Record `ids` as both the hash (drives `dirty`, over the local ids only) and the raw ordered
set (drives merge). `unavailable` = un-ingestable-but-live YouTube ids to carry forward so the
next merge skips them; stored only when non-empty (keeps the common case a plain list)."""
pl.synced_fingerprint = fingerprint(pl.name, ids)
next merge skips them; stored only when non-empty (keeps the common case a plain list). `name`:
the title YouTube now has, when that may differ from the list's own (see the push)."""
pl.synced_fingerprint = fingerprint(pl.name if name is None else name, ids)
if unavailable:
pl.synced_video_ids = json.dumps({"ids": ids, "unavailable": list(unavailable)})
else:
@@ -103,6 +193,25 @@ def _set_baseline(pl: Playlist, ids: list[str], unavailable: list[str] | None =
pl.dirty = False
def _set_baseline_under_lock(
db: Session,
pl: Playlist,
ids: list[str],
unavailable: list[str] | None = None,
*,
name: str,
) -> None:
"""A push's closing baseline: `ids` and `name` are what YouTube now holds. The push spent
seconds on YouTube writes since it replaced the local items, so an edit may have landed
meanwhile — lock the list and derive `dirty` from what is there, or the edit would read as
synced and never reach YouTube. The name too: the lock re-reads a rename made meanwhile, and a
baseline taken over THAT name matched it, so the list read clean with YouTube's title still the
old one (E28 review round 4)."""
_lock_or_gone(db, pl)
_set_baseline(pl, ids, unavailable, name=name)
recompute_dirty(db, pl)
def recompute_dirty(db: Session, pl: Playlist) -> None:
"""Set `dirty` by comparing the current state to the synced baseline. Unlinked lists
(and Watch later) are never dirty; a missing baseline counts as dirty (unknown YT state)."""
@@ -159,15 +268,13 @@ def _ensure_videos(db: Session, yt: YouTubeClient, video_ids: list[str]) -> None
db.commit()
def _replace_items_from_youtube(
db: Session, yt: YouTubeClient, pl: Playlist, ids: list[str]
) -> list[str]:
def _replace_items_from_youtube(db: Session, pl: Playlist, ids: list[str]) -> list[str]:
"""Mirror a YouTube playlist's ordered video ids into `pl`'s local items (authoritative
replace): ensure the videos exist in the catalog, keep only those present in their YT
order (de-duplicated), swap the items out, and reset the synced fingerprint + dirty flag.
`pl.name` must already be set to the desired name. The caller commits. Returns the final
ordered ids."""
_ensure_videos(db, yt, ids)
replace): keep only those present in the catalog in their YT order (de-duplicated), swap the
items out, and reset the synced fingerprint + dirty flag. The caller has ingested `ids`
(`_ensure_videos`, which commits) and THEN taken `lock_playlists`, set `pl.name`, and commits
after: an ingest in here re-fetched every id YouTube will not return (a private or deleted
video) and its commit let go of the lock before the replace. Returns the final ordered ids."""
present = (
set(db.execute(select(Video.id).where(Video.id.in_(ids))).scalars().all()) if ids else set()
)
@@ -196,6 +303,21 @@ def sync_user_playlists(db: Session, user: User) -> dict:
the user's local playlists and the built-in Watch later untouched."""
if not has_read_scope(user):
return {"synced": 0, "reason": "no read scope"}
with user_sync_claim(db, user.id) as free:
if not free:
return {"synced": 0, "reason": "busy"}
return pull_user_playlists(db, user)
def user_sync_claim(db: Session, user_id: int) -> AbstractContextManager[bool]:
"""Claim a user's bulk mirror pull for the block; yields whether it was free. Two runs at once
(the scheduler beside a manual "sync now", two tabs) both saw a YouTube list unmirrored and
each created its mirror — a duplicate row."""
return _advisory_claim(db, _USER_SYNC_LOCK_NS, user_id)
def pull_user_playlists(db: Session, user: User) -> dict:
"""The mirror pull itself; the caller holds `user_sync_claim`."""
synced = 0
with YouTubeClient(db, user) as yt:
yt_playlists = list(yt.iter_my_playlists())
@@ -219,11 +341,15 @@ def sync_user_playlists(db: Session, user: User) -> dict:
.scalars()
.all()
)
# Drop mirrors whose YouTube playlist no longer exists.
for ytid, pl in existing.items():
if ytid not in yt_ids:
db.delete(pl)
db.flush()
# Drop mirrors whose YouTube playlist no longer exists — locked first, as every item write.
gone = [pl for ytid, pl in existing.items() if ytid not in yt_ids]
if gone:
lock_playlists(db, *(pl.id for pl in gone))
for pl in gone:
db.delete(pl)
# Committed now: a run where no list below commits (all dirty, or all claimed) would
# otherwise drop the deletions with the session.
db.commit()
for idx, p in enumerate(yt_playlists):
ytid = p.get("id")
@@ -245,12 +371,27 @@ def sync_user_playlists(db: Session, user: User) -> dict:
)
db.add(pl)
db.flush()
else:
with sync_claim(db, pl.id) as free:
if not free:
# The user is pushing or re-pulling this very list: it writes the same rows.
# The next scheduled run refreshes it.
continue
ids = list(yt.iter_my_playlist_video_ids(ytid))
# Ingest first — it commits — so the row lock below is held from the dirty check
# to the commit that replaces the items.
_ensure_videos(db, yt, ids)
# `dirty` read above is a snapshot from before the YouTube calls (sessions keep
# loaded rows past a commit): a remove or reorder made meanwhile must not be
# replaced by YouTube's order. Item writes lock the playlist row, so this waits for
# one in flight and sees what it committed.
lock_playlists(db, pl.id)
if pl.dirty:
db.rollback()
continue
pl.name = p.get("title") or pl.name
# Mirror is authoritative: replace the items to match YouTube's order.
ids = list(yt.iter_my_playlist_video_ids(ytid))
_replace_items_from_youtube(db, yt, pl, ids)
db.commit()
# Mirror is authoritative: replace the items to match YouTube's order.
_replace_items_from_youtube(db, pl, ids)
db.commit()
synced += 1
return {"synced": synced}
@@ -488,7 +629,8 @@ def merge_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
breaker = _Breaker(playlist_id=pl.id)
with YouTubeClient(db, user) as yt:
if not pl.yt_playlist_id:
pl.yt_playlist_id = yt.create_playlist(pl.name)
pushed_name = pl.name
pl.yt_playlist_id = yt.create_playlist(pushed_name)
db.commit()
created = 1
# Brand-new playlist: just append every local video in order.
@@ -501,7 +643,7 @@ def merge_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
if not isinstance(added, _Failed):
inserted += 1
if not failures:
_set_baseline(pl, local)
_set_baseline_under_lock(db, pl, local, name=pushed_name)
db.commit()
return {
"created": created,
@@ -542,6 +684,14 @@ def merge_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
# merged order authoritatively. Do this first so local reflects the merge even if a later
# YouTube write trips the breaker.
_ensure_videos(db, yt, mp.add_to_local)
# `local` was read before the YouTube calls above: an add or remove committed meanwhile
# (another tab) would be undone by the replace below. Lock the list — the ingest's commit is
# behind us — and plan again from what is there now. Nothing new needs ingesting: a video
# the re-read adds is already a local row. A list deleted meanwhile stops here, before a plan
# over its now-empty items removes them all from YouTube.
_lock_or_gone(db, pl)
local = _local_video_ids(db, pl)
mp = merge_plan(parse_baseline(pl), local, yt_ids, effective_unavailable)
# Only the ids that actually exist in the catalog can become local rows (a private/deleted
# YouTube video won't ingest); keep the merged order, dropping any that didn't materialize.
present = (
@@ -555,10 +705,12 @@ def merge_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
_set_local_items(db, pl, final_local)
db.commit()
# --- YouTube side (metered, breaker-guarded).
if snippet is not None and (snippet.get("title") or "") != pl.name:
# --- YouTube side (metered, breaker-guarded). The name as of the lock above: the closing
# baseline records THIS one, whatever a rename made during the writes below says.
pushed_name = pl.name
if snippet is not None and (snippet.get("title") or "") != pushed_name:
try:
yt.update_playlist_title(pl.yt_playlist_id, pl.name)
yt.update_playlist_title(pl.yt_playlist_id, pushed_name)
except YouTubeError as exc:
# All the caller passes on is the word "title", which says the rename did not
# happen but not that YouTube refused it, or why.
@@ -626,7 +778,7 @@ def merge_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
(effective_unavailable | {v for v in mp.add_to_local if v not in present})
& yt_id_set
)
_set_baseline(pl, final_local, unavailable)
_set_baseline_under_lock(db, pl, final_local, unavailable, name=pushed_name)
db.commit()
return {
"created": created,
@@ -652,9 +804,11 @@ def repull_playlist(db: Session, user: User, pl: Playlist) -> dict:
snippet = yt.get_playlist_snippet(pl.yt_playlist_id)
if snippet is None:
raise YouTubeError("Playlist no longer exists on YouTube")
pl.name = snippet.get("title") or pl.name
ids = list(yt.iter_my_playlist_video_ids(pl.yt_playlist_id))
ordered = _replace_items_from_youtube(db, yt, pl, ids)
_ensure_videos(db, yt, ids)
_lock_or_gone(db, pl)
pl.name = snippet.get("title") or pl.name
ordered = _replace_items_from_youtube(db, pl, ids)
db.commit()
return {"items": len(ordered)}
+39 -8
View File
@@ -1,8 +1,9 @@
"""Import the authenticated user's YouTube subscriptions and channel metadata."""
from datetime import datetime, timezone
from collections.abc import Iterable
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from app import logs
@@ -47,8 +48,37 @@ def _external_links(branding: dict) -> list | None:
return out or None
def apply_channel_details(db: Session, items: list[dict]) -> None:
# How old a channel's metadata may get before it is fetched again. Banners, avatars, descriptions and
# counts change on YouTube, and an old banner URL stops resolving at all — without a refresh a channel
# kept whatever it had the day it was first seen. A week keeps counts roughly current at almost no
# cost: `channels.list` is 1 quota unit per 50 channels, and only the stale ones are asked for.
DETAILS_MAX_AGE = timedelta(days=7)
def details_stale(channel: Channel, now: datetime) -> bool:
"""Whether `channel`'s metadata is missing or older than DETAILS_MAX_AGE (see `details_stale_clause`)."""
synced = channel.details_synced_at
return synced is None or synced < now - DETAILS_MAX_AGE
def details_stale_clause(now: datetime):
"""`details_stale` as a WHERE clause, so the batch import and a single channel agree on "stale"."""
return or_(
Channel.details_synced_at.is_(None),
Channel.details_synced_at < now - DETAILS_MAX_AGE,
)
def apply_channel_details(db: Session, items: list[dict], asked: Iterable[str]) -> None:
"""Write `channels.list`'s answer onto the rows. `asked` is every id the call asked for: one
YouTube did not return (terminated, deleted) is stamped as synced too — otherwise it stays
stale forever and every view / import asks for it again, spending quota on a known miss."""
now = datetime.now(timezone.utc)
returned = {item["id"] for item in items}
for cid in set(asked) - returned:
missing = db.get(Channel, cid)
if missing is not None:
missing.details_synced_at = now
for item in items:
channel = db.get(Channel, item["id"])
if channel is None:
@@ -92,7 +122,7 @@ def should_prune(fetched_count: int, existing_count: int) -> bool:
def import_subscriptions(db: Session, user: User) -> dict:
"""Pull all subscriptions for `user`, upsert channels + subscription links, prune
channels the user has unsubscribed from on YouTube, and fetch channel details for
any channel we don't have details for yet."""
any channel whose details are missing or stale (`DETAILS_MAX_AGE`)."""
fetched: dict[str, dict] = {}
new_channels = 0
@@ -145,12 +175,12 @@ def import_subscriptions(db: Session, user: User) -> dict:
removed += 1
db.commit()
# Fetch details for channels that don't have them yet.
# Fetch details for channels that don't have them yet, or had them too long ago.
need_details = (
db.execute(
select(Channel).where(
Channel.id.in_(fetched.keys()),
Channel.details_synced_at.is_(None),
details_stale_clause(datetime.now(timezone.utc)),
)
)
.scalars()
@@ -158,8 +188,9 @@ def import_subscriptions(db: Session, user: User) -> dict:
)
detailed = 0
if need_details:
items = yt.get_channels([c.id for c in need_details])
apply_channel_details(db, items)
ids = [c.id for c in need_details]
items = yt.get_channels(ids)
apply_channel_details(db, items, ids)
detailed = len(items)
db.commit()
+1 -1
View File
@@ -100,7 +100,7 @@ def test_the_single_playlist_repull_shares_the_pull_cooldown(db, as_user, make_u
db.commit()
monkeypatch.setattr(plroutes, "has_read_scope", lambda u: True)
monkeypatch.setattr(plroutes, "repull_playlist", lambda d, u, p: {"ok": True})
monkeypatch.setattr(plroutes, "sync_user_playlists", lambda d, u: {"ok": True})
monkeypatch.setattr(plroutes, "pull_user_playlists", lambda d, u: {"ok": True})
c = as_user(user)
codes = [c.post(f"/api/playlists/{pl.id}/revert-youtube").status_code for _ in range(4)]
@@ -0,0 +1,143 @@
"""A channel's metadata is fetched again once it is stale, not only the first time it is seen.
Banners, avatars, descriptions and counts change on YouTube, and an old banner URL stops resolving:
a channel that kept the metadata of the day it was first imported showed a dead banner forever. The
subscription import and the channel page both refresh what is older than `DETAILS_MAX_AGE`, and
leave fresh metadata alone (each refresh spends quota). DB lane.
"""
from datetime import datetime, timedelta, timezone
import pytest
from app.models import Channel, Subscription
from app.routes import channels
from app.sync import subscriptions
from app.sync.subscriptions import DETAILS_MAX_AGE, import_subscriptions
NOW = datetime.now(timezone.utc)
FRESH = NOW - timedelta(days=1)
STALE = NOW - DETAILS_MAX_AGE - timedelta(hours=1)
def _item(cid: str) -> dict:
return {
"id": cid,
"snippet": {"title": f"{cid} renamed", "publishedAt": "2010-01-01T00:00:00Z"},
"brandingSettings": {"image": {"bannerExternalUrl": f"https://yt3.example/{cid}-new"}},
}
def _youtube(asked: list[list[str]], subscribed: list[str], gone: tuple[str, ...] = ()):
class _Client:
def __init__(self, db, user):
pass
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def iter_subscriptions(self):
for cid in subscribed:
yield {"channel_id": cid, "title": cid, "yt_subscription_id": f"sub-{cid}"}
def get_channels(self, ids):
asked.append(sorted(ids))
# A terminated / deleted channel is simply absent from `channels.list`'s answer.
return [_item(cid) for cid in ids if cid not in gone]
return _Client
def _channel(db, cid: str, synced: datetime | None) -> None:
db.add(
Channel(
id=cid,
title=cid,
banner_url=f"https://yt3.example/{cid}-old",
published_at=datetime(2010, 1, 1, tzinfo=timezone.utc),
details_synced_at=synced,
)
)
def test_the_import_refreshes_stale_and_missing_details_and_leaves_fresh_ones(
db, make_user, monkeypatch
):
user = make_user()
_channel(db, "UCfresh", FRESH)
_channel(db, "UCstale", STALE)
_channel(db, "UCnever", None)
db.flush()
for cid in ("UCfresh", "UCstale", "UCnever"):
db.add(Subscription(user_id=user.id, channel_id=cid))
db.commit()
asked: list[list[str]] = []
monkeypatch.setattr(
subscriptions, "YouTubeClient", _youtube(asked, ["UCfresh", "UCstale", "UCnever"])
)
result = import_subscriptions(db, user)
assert asked == [["UCnever", "UCstale"]]
assert result["channels_detailed"] == 2
stale = db.get(Channel, "UCstale")
assert stale is not None
assert stale.banner_url == "https://yt3.example/UCstale-new"
assert stale.details_synced_at is not None and stale.details_synced_at > STALE
fresh = db.get(Channel, "UCfresh")
assert fresh is not None and fresh.banner_url == "https://yt3.example/UCfresh-old"
@pytest.mark.parametrize(("synced", "refreshed"), [(STALE, True), (FRESH, False)])
def test_the_channel_page_refreshes_a_stale_channel_only(
db, make_user, monkeypatch, synced, refreshed
):
user = make_user()
_channel(db, "UCpage", synced)
db.commit()
asked: list[list[str]] = []
monkeypatch.setattr(channels, "YouTubeClient", _youtube(asked, []))
body = channels.channel_detail("UCpage", user=user, db=db)
assert asked == ([["UCpage"]] if refreshed else [])
suffix = "new" if refreshed else "old"
assert body["banner_url"] == f"https://yt3.example/UCpage-{suffix}"
def test_a_channel_youtube_no_longer_returns_is_not_asked_for_on_every_view(
db, make_user, monkeypatch
):
"""A terminated channel is missing from `channels.list`'s answer, so nothing was stamped and the
channel page asked for it again (1 quota unit) on every single view. The miss is stamped like a
fetch, so the next ask waits for `DETAILS_MAX_AGE` like any other channel."""
user = make_user()
db.add(Channel(id="UCgone", title="UCgone"))
db.commit()
asked: list[list[str]] = []
monkeypatch.setattr(channels, "YouTubeClient", _youtube(asked, [], gone=("UCgone",)))
channels.channel_detail("UCgone", user=user, db=db)
channels.channel_detail("UCgone", user=user, db=db)
assert asked == [["UCgone"]]
def test_the_import_stamps_a_channel_youtube_no_longer_returns(db, make_user, monkeypatch):
user = make_user()
_channel(db, "UCgone", STALE)
db.flush()
db.add(Subscription(user_id=user.id, channel_id="UCgone"))
db.commit()
asked: list[list[str]] = []
monkeypatch.setattr(
subscriptions, "YouTubeClient", _youtube(asked, ["UCgone"], gone=("UCgone",))
)
import_subscriptions(db, user)
import_subscriptions(db, user)
assert asked == [["UCgone"]]
+105
View File
@@ -0,0 +1,105 @@
"""E28 S3e2 — adding a video AT a position: the list page's "Undo" of a removal.
The row has to come back where it was in ONE request. The two-call shape (append, then send the
whole order) leaves it at the end of the list whenever the second call fails, and the reorder would
also overwrite any change another tab made in between. Positions can have gaps after removals, so
the position is an index over the ORDERED rows, and the list is renumbered around it.
"""
from datetime import datetime, timezone
from app.models import Channel, Playlist, PlaylistItem, Video
def _seed(db, user, ids, *, positions=None, **kw):
db.add(Channel(id="UCtest", title="Test"))
now = datetime.now(timezone.utc)
for v in [*ids, "vX"]:
db.add(Video(id=v, channel_id="UCtest", published_at=now))
pl = Playlist(user_id=user.id, name="L", **kw)
db.add(pl)
db.flush()
for i, vid in enumerate(ids):
db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=(positions or range(99))[i]))
db.commit()
return pl
def _ids(db, pl_id):
db.expire_all()
return [
r.video_id
for r in db.execute(
PlaylistItem.__table__.select()
.where(PlaylistItem.playlist_id == pl_id)
.order_by(PlaylistItem.position)
).all()
]
def _add(client, pl_id, body):
return client.post(f"/api/playlists/{pl_id}/items", json=body)
def test_a_position_puts_the_video_back_in_its_place(db, make_user, as_user):
user = make_user()
pl = _seed(db, user, ["a", "b", "c"])
r = _add(as_user(user), pl.id, {"video_id": "vX", "position": 1})
assert r.status_code == 200
assert _ids(db, pl.id) == ["a", "vX", "b", "c"]
def test_the_position_counts_rows_not_stored_numbers(db, make_user, as_user):
# A removal leaves a gap (0, 5, 9): index 2 is before the THIRD row, not "position 2".
user = make_user()
pl = _seed(db, user, ["a", "b", "c"], positions=[0, 5, 9])
_add(as_user(user), pl.id, {"video_id": "vX", "position": 2})
assert _ids(db, pl.id) == ["a", "b", "vX", "c"]
def test_a_position_past_the_end_appends(db, make_user, as_user):
user = make_user()
pl = _seed(db, user, ["a", "b"])
_add(as_user(user), pl.id, {"video_id": "vX", "position": 40})
assert _ids(db, pl.id) == ["a", "b", "vX"]
def test_no_position_still_appends(db, make_user, as_user):
user = make_user()
pl = _seed(db, user, ["a", "b"])
_add(as_user(user), pl.id, {"video_id": "vX"})
assert _ids(db, pl.id) == ["a", "b", "vX"]
def test_a_video_already_in_the_list_does_not_move(db, make_user, as_user):
# The add is idempotent; a position must not turn it into a reorder.
user = make_user()
pl = _seed(db, user, ["a", "b", "c"])
_add(as_user(user), pl.id, {"video_id": "c", "position": 0})
assert _ids(db, pl.id) == ["a", "b", "c"]
def test_a_negative_position_is_refused(db, make_user, as_user):
user = make_user()
pl = _seed(db, user, ["a"])
r = _add(as_user(user), pl.id, {"video_id": "vX", "position": -1})
assert r.status_code == 422
assert _ids(db, pl.id) == ["a"]
def test_putting_a_removed_video_back_leaves_a_linked_list_clean(db, make_user, as_user):
# Remove + undo is no change at all: a YouTube-linked list must not stay "unsynced" after it.
user = make_user()
pl = _seed(db, user, ["a", "b", "c"], yt_playlist_id="PLx")
client = as_user(user)
from app.sync.playlists import current_fingerprint
pl.synced_fingerprint = current_fingerprint(db, pl)
pl.dirty = False
db.commit()
client.delete(f"/api/playlists/{pl.id}/items/b")
db.expire_all()
assert db.get(Playlist, pl.id).dirty is True
_add(client, pl.id, {"video_id": "b", "position": 1})
db.expire_all()
assert db.get(Playlist, pl.id).dirty is False
+16 -19
View File
@@ -13,6 +13,9 @@ Two properties are load-bearing and neither is visible from the happy path alone
from datetime import datetime, timedelta, timezone
import pytest
from sqlalchemy.exc import OperationalError
from app.models import Channel, Playlist, PlaylistItem, Video
@@ -269,12 +272,12 @@ def test_a_target_that_already_had_it_is_not_recently_changed(db, make_user, as_
assert db.get(Playlist, dst.id).updated_at == old
def test_a_concurrent_add_to_the_target_lands_as_the_already_present_move(
db, make_user, as_user, monkeypatch
):
"""The already-in-target check is a read, not a lock: another tab's add (or the scheduler's
YouTube merge) can commit the same video to the target in between, and the re-parent then
hits uq_playlist_video. That is the idempotent outcome arriving late — not a raw 500."""
def test_no_add_can_land_in_the_target_while_a_move_holds_it(db, make_user):
"""The already-in-target check is a read; it holds because the move locks both lists first and
an insert into the target — another tab's add, a YouTube merge — needs the target row for its
foreign key, so it waits for the move's commit instead of hitting uq_playlist_video under it."""
from sqlalchemy import text
from app.db import SessionLocal
from app.routes import playlists as plroutes
@@ -282,16 +285,10 @@ def test_a_concurrent_add_to_the_target_lands_as_the_already_present_move(
_seed_videos(db, ["v1"])
src = _playlist(db, user, "Source", ["v1"])
dst = _playlist(db, user, "Target", [])
real = plroutes._next_item_position
def add_first(session, playlist_id):
with SessionLocal() as other: # the other tab, committing between check and re-parent
other.add(PlaylistItem(playlist_id=dst.id, video_id="v1", position=0))
other.commit()
return real(session, playlist_id)
monkeypatch.setattr(plroutes, "_next_item_position", add_first)
assert _move(as_user(user), src.id, "v1", dst.id).status_code == 200
db.expire_all()
assert _ids(db, src.id) == []
assert _ids(db, dst.id) == ["v1"]
plroutes.lock_playlists(db, src.id, dst.id) # what the move holds until its commit
with SessionLocal() as other:
other.execute(text("SET LOCAL lock_timeout = '300ms'"))
other.add(PlaylistItem(playlist_id=dst.id, video_id="v1", position=0))
with pytest.raises(OperationalError, match="lock timeout"):
other.flush()
db.rollback()
@@ -0,0 +1,262 @@
"""E28 review — every request that reads a playlist's item positions and writes new ones locks the
playlist row first.
Without it two adds (two tabs; the rail and the page) both read the same `max(position)` and land
on one position, and a reorder racing an add collides with it — the list's order between the two
rows is then whatever the planner returns. A true two-connection race is not reproducible on
demand, so the test holds the mechanism: the parent row is taken `FOR UPDATE` by each such route.
A Plex reorder that omits a row (added in another tab meanwhile) must not collide with it either.
Round 2: the lock also comes FIRST — before the request reads or writes a single item row. A remove
that deleted an item and then touched the playlist beside a reorder that went the other way was a
deadlock (a 500 for one of them), and an add that checked "already there?" before the lock let two
adds of one video both insert (a 500 on the unique key).
"""
import re
from datetime import datetime, timezone
import pytest
from sqlalchemy import event
from app.models import (
Channel,
Playlist,
PlaylistItem,
PlexItem,
PlexLibrary,
PlexPlaylist,
PlexPlaylistItem,
Video,
)
@pytest.fixture
def locks(db):
"""The tables each request took a row lock on (`SELECT … FROM <t> … FOR UPDATE`)."""
seen: list[str] = []
def _record(conn, cursor, statement, *_):
if "FOR UPDATE" in statement:
seen.extend(re.findall(r"FROM\s+(\w+)", statement)[:1])
engine = db.get_bind()
event.listen(engine, "before_cursor_execute", _record)
yield seen
event.remove(engine, "before_cursor_execute", _record)
@pytest.fixture
def statements(db):
"""Every statement a request ran, in order."""
seen: list[str] = []
def _record(conn, cursor, statement, *_):
seen.append(" ".join(statement.split()))
engine = db.get_bind()
event.listen(engine, "before_cursor_execute", _record)
yield seen
event.remove(engine, "before_cursor_execute", _record)
def _locked_before_items(seen: list[str], parent: str, items: str) -> str | None:
"""The first statement touching `items` must come after a `FOR UPDATE` of `parent`; returns
that lock statement (None when it came too late or never)."""
lock = None
for st in seen:
if lock is None and "FOR UPDATE" in st and re.search(rf"FROM {parent}\b", st):
lock = st
if re.search(rf"\b{items}\b", st):
return lock
return lock
def _yt(db, user):
db.add(Channel(id="UCtest", title="Test"))
now = datetime.now(timezone.utc)
for v in ("v1", "v2", "v3"):
db.add(Video(id=v, channel_id="UCtest", published_at=now))
pl = Playlist(user_id=user.id, name="L")
other = Playlist(user_id=user.id, name="M")
db.add_all([pl, other])
db.flush()
db.add(PlaylistItem(playlist_id=pl.id, video_id="v1", position=0))
db.commit()
return pl.id, other.id
def _plex(db, user):
lib = PlexLibrary(plex_key="p1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
items = [PlexItem(rating_key=rk, library_id=lib.id, kind="movie", title=rk) for rk in "abcd"]
db.add_all(items)
pl = PlexPlaylist(user_id=user.id, title="L")
db.add(pl)
db.flush()
for i, it in enumerate(items[:3]):
db.add(PlexPlaylistItem(playlist_id=pl.id, item_id=it.id, position=i))
db.commit()
return pl.id
YT = Playlist.__table__.name
PLEX = PlexPlaylist.__table__.name
@pytest.mark.parametrize(
"call",
[
lambda c, pid, other: c.post(f"/api/playlists/{pid}/items", json={"video_id": "v2"}),
lambda c, pid, other: c.post(
f"/api/playlists/{pid}/items", json={"video_id": "v2", "position": 0}
),
lambda c, pid, other: c.post("/api/playlists/watch-later", json={"video_id": "v2"}),
lambda c, pid, other: c.post(
f"/api/playlists/{pid}/items/v1/move", json={"target_playlist_id": other}
),
lambda c, pid, other: c.put(f"/api/playlists/{pid}/order", json={"video_ids": ["v1"]}),
],
ids=["add", "add-at", "watch-later", "move", "reorder"],
)
def test_a_youtube_playlist_write_locks_the_playlist(db, make_user, as_user, locks, call):
user = make_user()
pid, other = _yt(db, user)
r = call(as_user(user), pid, other)
assert r.status_code == 200, r.text
assert YT in locks
@pytest.mark.parametrize(
"call",
[
lambda c, pid: c.post(f"/api/plex/playlists/{pid}/items", json={"item_rating_key": "d"}),
lambda c, pid: c.post(
f"/api/plex/playlists/{pid}/items/bulk", json={"item_rating_keys": ["d"]}
),
lambda c, pid: c.post(
f"/api/plex/playlists/{pid}/items/bulk",
json={"item_rating_keys": ["d"], "positions": [0]},
),
lambda c, pid: c.put(
f"/api/plex/playlists/{pid}/order", json={"item_rating_keys": ["c", "b", "a"]}
),
],
ids=["add", "bulk", "bulk-at", "reorder"],
)
def test_a_plex_playlist_write_locks_the_playlist(db, make_user, as_user, locks, call):
user = make_user()
pid = _plex(db, user)
r = call(as_user(user), pid)
assert r.status_code == 200, r.text
assert PLEX in locks
def test_a_plex_reorder_that_omits_a_row_does_not_collide_with_it(db, make_user, as_user):
"""The row "a" was added in another tab after this page loaded its order ["c", "b"]: it keeps
its place after the given rows instead of sharing position 0 with "c"."""
user = make_user()
pid = _plex(db, user)
r = as_user(user).put(f"/api/plex/playlists/{pid}/order", json={"item_rating_keys": ["c", "b"]})
assert r.status_code == 200
db.expire_all()
rows = db.query(PlexPlaylistItem).filter_by(playlist_id=pid).all()
by_id = {it.id: it.rating_key for it in db.query(PlexItem).all()}
got = sorted((r.position, by_id[r.item_id]) for r in rows)
assert got == [(0, "c"), (1, "b"), (2, "a")]
YT_ITEMS = PlaylistItem.__table__.name
PLEX_ITEMS = PlexPlaylistItem.__table__.name
@pytest.mark.parametrize(
"call",
[
lambda c, pid, other: c.post(f"/api/playlists/{pid}/items", json={"video_id": "v1"}),
lambda c, pid, other: c.post(
f"/api/playlists/{pid}/items", json={"video_id": "v2", "position": 0}
),
lambda c, pid, other: c.delete(f"/api/playlists/{pid}/items/v1"),
lambda c, pid, other: c.post(
f"/api/playlists/{pid}/items/v1/move", json={"target_playlist_id": other}
),
lambda c, pid, other: c.put(f"/api/playlists/{pid}/order", json={"video_ids": ["v1"]}),
# the ORM cascade deletes the items before the list row
lambda c, pid, other: c.delete(f"/api/playlists/{pid}"),
],
ids=["add-present", "add-at", "remove", "move", "reorder", "delete"],
)
def test_a_youtube_item_write_locks_the_playlist_before_any_item(
db, make_user, as_user, statements, call
):
user = make_user()
pid, other = _yt(db, user)
c = as_user(user)
statements.clear()
r = call(c, pid, other)
assert r.status_code == 200, r.text
assert _locked_before_items(statements, YT, YT_ITEMS), statements
def test_a_move_locks_both_lists_in_one_ordered_statement(db, make_user, as_user, statements):
"""Two moves A→B and B→A each locking "its" list first would lock crosswise."""
user = make_user()
pid, other = _yt(db, user)
statements.clear()
r = as_user(user).post(
f"/api/playlists/{pid}/items/v1/move", json={"target_playlist_id": other}
)
assert r.status_code == 200, r.text
lock = _locked_before_items(statements, YT, YT_ITEMS)
assert lock and "ORDER BY" in lock, lock
# BOTH lists in that one statement: one bound id per list in its IN (…) (a single-id lock
# renders the same IN and ORDER BY)
listed = re.search(r" IN \((.*?)\) ORDER BY", lock)
assert listed and len(re.findall(r"%\(\w+\)s", listed.group(1))) == 2, lock
def test_watch_later_add_and_remove_lock_it_before_any_item(db, make_user, as_user, statements):
user = make_user()
_yt(db, user)
c = as_user(user)
assert c.post("/api/playlists/watch-later", json={"video_id": "v2"}).status_code == 200
statements.clear()
assert c.post("/api/playlists/watch-later", json={"video_id": "v2"}).status_code == 200
assert _locked_before_items(statements, YT, YT_ITEMS), statements
statements.clear()
assert c.delete("/api/playlists/watch-later/v2").status_code == 200
assert _locked_before_items(statements, YT, YT_ITEMS), statements
def test_a_rename_locks_the_playlist(db, make_user, as_user, locks):
"""A rename moves `dirty`, which the scheduled mirror refresh re-reads under this lock."""
user = make_user()
pid, _ = _yt(db, user)
r = as_user(user).patch(f"/api/playlists/{pid}", json={"name": "New"})
assert r.status_code == 200, r.text
assert YT in locks
@pytest.mark.parametrize(
"call",
[
lambda c, pid: c.delete(f"/api/plex/playlists/{pid}/items/a"),
lambda c, pid: c.post(
f"/api/plex/playlists/{pid}/items/remove-bulk", json={"item_rating_keys": ["a", "b"]}
),
lambda c, pid: c.post(f"/api/plex/playlists/{pid}/items", json={"item_rating_key": "a"}),
],
ids=["remove", "remove-bulk", "add-present"],
)
def test_a_plex_item_write_locks_the_playlist_before_any_item(
db, make_user, as_user, statements, call
):
user = make_user()
pid = _plex(db, user)
c = as_user(user)
statements.clear()
r = call(c, pid)
assert r.status_code == 200, r.text
assert _locked_before_items(statements, PLEX, PLEX_ITEMS), statements
+210
View File
@@ -0,0 +1,210 @@
"""E28 review C1 — one YouTube sync of a playlist at a time, whoever starts it.
A push reads YouTube and then writes both sides for seconds, committing step by step, so the
client's in-flight flag (one page's menus) and a row lock (gone at the first commit) both leave two
tabs, or a push and the scheduled mirror refresh, free to interleave. The claim is an advisory lock
on a connection of its own; the test holds it from outside, the way a second request would.
"""
from sqlalchemy import text
from app.auth import require_write_scope
from app.db import SessionLocal
from app.models import Playlist
from app.routes import playlists as plroutes
from app.sync import playlists as plsync
def _linked(db, user, *, source="local"):
pl = Playlist(user_id=user.id, name="L", source=source, yt_playlist_id="PL123")
db.add(pl)
db.commit()
return pl
class FakeYT:
"""YouTube as far as these routes read it: two playlists, none with items."""
def __init__(self, *_):
pass
def __enter__(self):
return self
def __exit__(self, *_):
return False
def iter_my_playlists(self):
return [{"id": "PL123", "title": "Renamed"}, {"id": "PL999", "title": "New"}]
def iter_my_playlist_video_ids(self, _ytid):
return []
def iter_playlist_items_with_ids(self, _ytid):
return []
def get_playlist_snippet(self, _ytid):
return {}
def test_push_and_revert_refuse_while_the_list_is_claimed(
db, as_user, make_user, override_dep, monkeypatch
):
user = make_user()
pl = _linked(db, user)
override_dep(require_write_scope, lambda: user)
monkeypatch.setattr(plroutes, "has_read_scope", lambda u: True)
ran: list[str] = []
monkeypatch.setattr(plroutes, "YouTubeClient", FakeYT)
monkeypatch.setattr(plroutes, "plan_merge", lambda d, u, p, live: {"units_estimate": 0})
monkeypatch.setattr(plroutes, "merge_playlist", lambda d, u, p, live: ran.append("push") or {})
monkeypatch.setattr(plroutes, "repull_playlist", lambda d, u, p: ran.append("revert") or {})
c = as_user(user)
with plsync.sync_claim(db, pl.id) as free:
assert free
for path in ("push", "revert-youtube"):
r = c.post(f"/api/playlists/{pl.id}/{path}")
assert r.status_code == 409
assert r.json()["detail"] == {"code": "playlist", "reason": "sync_busy"}
# a delete too: a push mid-way would plan the deleted list as emptied (E28 round 4)
r = c.delete(f"/api/playlists/{pl.id}")
assert r.status_code == 409
assert r.json()["detail"] == {"code": "playlist", "reason": "sync_busy"}
assert ran == []
assert db.get(Playlist, pl.id) is not None
# Released on the way out: the same list syncs again, and a claim is per list.
assert c.post(f"/api/playlists/{pl.id}/push").status_code == 200
assert c.post(f"/api/playlists/{pl.id}/revert-youtube").status_code == 200
assert ran == ["push", "revert"]
def test_a_claim_is_released_when_its_block_raises(db, make_user):
pl = _linked(db, make_user())
try:
with plsync.sync_claim(db, pl.id) as free:
assert free
raise RuntimeError("boom")
except RuntimeError:
pass
# Asked of pg_locks, not by claiming again: a lock leaked on a pooled connection would be granted
# again to that same connection (advisory locks are re-entrant per session).
held = db.scalar(
text("SELECT count(*) FROM pg_locks WHERE locktype = 'advisory' AND objid = :id"),
{"id": pl.id},
)
assert held == 0
def test_a_claim_draws_no_connection_from_the_app_pool(db, make_user):
"""A claim is held for a whole push beside the request's pooled session; drawn from the pool
too, a burst of pushes ran it dry and stalled every other request for pool_timeout."""
from app.db import engine
pl = _linked(db, make_user())
before = engine.pool.checkedout()
with plsync.sync_claim(db, pl.id) as free:
assert free
assert engine.pool.checkedout() == before
def test_the_scheduled_refresh_skips_a_mirror_being_synced(db, make_user, monkeypatch):
user = make_user()
busy = _linked(db, user, source="youtube")
replaced: list[int] = []
monkeypatch.setattr(plsync, "has_read_scope", lambda u: True)
monkeypatch.setattr(plsync, "YouTubeClient", FakeYT)
monkeypatch.setattr(
plsync, "_replace_items_from_youtube", lambda d, p, ids: replaced.append(p.id)
)
with plsync.sync_claim(db, busy.id):
out = plsync.sync_user_playlists(db, user)
assert out == {"synced": 1}
assert busy.id not in replaced and len(replaced) == 1
db.refresh(busy)
assert busy.name == "L"
def test_refused_reverts_do_not_spend_the_pull_cooldown(
db, as_user, make_user, override_dep, monkeypatch
):
"""Round 2: the cooldown (three pulls per ten minutes) is checked after the claim, so clicking
Revert while a push runs is refused as busy without using up a pull."""
user = make_user()
pl = _linked(db, user)
monkeypatch.setattr(plroutes, "has_read_scope", lambda u: True)
monkeypatch.setattr(plroutes, "repull_playlist", lambda d, u, p: {})
c = as_user(user)
with plsync.sync_claim(db, pl.id):
for _ in range(4):
assert c.post(f"/api/playlists/{pl.id}/revert-youtube").status_code == 409
assert c.post(f"/api/playlists/{pl.id}/revert-youtube").status_code == 200
def test_a_bulk_pull_runs_once_per_user_at_a_time(db, as_user, make_user, monkeypatch):
"""Two bulk pulls at once both saw a YouTube list unmirrored and each created its mirror."""
user = make_user()
monkeypatch.setattr(plsync, "has_read_scope", lambda u: True)
monkeypatch.setattr(plroutes, "has_read_scope", lambda u: True)
monkeypatch.setattr(plsync, "YouTubeClient", FakeYT)
monkeypatch.setattr(plsync, "_replace_items_from_youtube", lambda d, p, ids: None)
c = as_user(user)
with plsync.user_sync_claim(db, user.id):
assert plsync.sync_user_playlists(db, user) == {"synced": 0, "reason": "busy"}
for _ in range(4):
r = c.post("/api/playlists/sync-youtube")
assert r.status_code == 409
assert r.json()["detail"] == {"code": "playlist", "reason": "sync_all_busy"}
assert c.post("/api/playlists/sync-youtube").json() == {"ok": True, "synced": 2}
def test_a_gone_mirror_is_deleted_even_when_no_list_refreshes(db, make_user, monkeypatch):
"""Every remaining mirror has unpushed edits, so nothing below the deletion commits."""
user = make_user()
gone = Playlist(user_id=user.id, name="Gone", source="youtube", yt_playlist_id="PLgone")
kept = Playlist(user_id=user.id, name="K", source="youtube", yt_playlist_id="PL123", dirty=True)
other = Playlist(
user_id=user.id, name="O", source="youtube", yt_playlist_id="PL999", dirty=True
)
db.add_all([gone, kept, other])
db.commit()
gone_id = gone.id
monkeypatch.setattr(plsync, "has_read_scope", lambda u: True)
monkeypatch.setattr(plsync, "YouTubeClient", FakeYT)
assert plsync.sync_user_playlists(db, user) == {"synced": 0}
db.rollback() # what the request's session close does to an uncommitted deletion
assert db.get(Playlist, gone_id) is None
def test_a_mirror_edited_during_the_pull_is_not_replaced(db, make_user, monkeypatch):
"""The loop's `dirty` is a snapshot from before the YouTube calls; a remove committed by another
request meanwhile must win over YouTube's order."""
user = make_user()
pl = _linked(db, user, source="youtube")
replaced: list[int] = []
class EditedMeanwhile(FakeYT):
def iter_my_playlists(self):
return [{"id": "PL123", "title": "Renamed"}]
def iter_my_playlist_video_ids(self, _ytid):
with SessionLocal() as other:
other.get(Playlist, pl.id).dirty = True
other.commit()
return []
monkeypatch.setattr(plsync, "has_read_scope", lambda u: True)
monkeypatch.setattr(plsync, "YouTubeClient", EditedMeanwhile)
monkeypatch.setattr(
plsync, "_replace_items_from_youtube", lambda d, p, ids: replaced.append(p.id)
)
assert plsync.sync_user_playlists(db, user) == {"synced": 0}
assert replaced == []
db.expire_all()
assert db.get(Playlist, pl.id).name == "L"
@@ -0,0 +1,292 @@
"""E28 review round 3 — the YouTube sync paths take the playlist row BEFORE they touch an item, and
hold it to the commit that writes them.
Round 2 made every route edit lock the playlist first. The sync paths were left item-first (a push's
and a re-pull's item replace, a delete's cascade), so a push beside a remove could deadlock (a 500),
and the scheduled mirror refresh re-ingested inside its lock — its commit let go of the lock before
the replace, so a remove made then was undone and the list read as synced. A push also replaced the
items from a snapshot read before its YouTube calls, and marked the list clean over an edit made
during its YouTube writes. The "other tab" here is a second session that commits while the push is
talking to YouTube — when the push holds no lock, as a real one does not.
"""
import json
import re
from datetime import datetime, timezone
import pytest
from sqlalchemy import delete, event, text
from app.db import SessionLocal
from app.models import Channel, Playlist, PlaylistItem, Video
from app.sync import playlists as pl_mod
ITEMS = PlaylistItem.__table__.name
LISTS = Playlist.__table__.name
@pytest.fixture
def trace(db):
"""Every statement and every COMMIT on the test engine, in order."""
seen: list[str] = []
def _stmt(conn, cursor, statement, *_):
seen.append(" ".join(statement.split()))
def _commit(conn):
seen.append("COMMIT")
engine = db.get_bind()
event.listen(engine, "before_cursor_execute", _stmt)
event.listen(engine, "commit", _commit)
yield seen
event.remove(engine, "before_cursor_execute", _stmt)
event.remove(engine, "commit", _commit)
def _held_to_the_item_write(seen: list[str], verb: str) -> bool:
"""The first `<verb> … playlist_items` comes after a `FOR UPDATE` of playlists, with no COMMIT
in between (a commit ends the lock)."""
locked = False
for st in seen:
if "FOR UPDATE" in st and re.search(rf"FROM {LISTS}\b", st):
locked = True
elif st == "COMMIT":
locked = False
elif st.startswith(verb) and re.search(rf"\b{ITEMS}\b", st):
return locked
return False
class FakeYT:
"""YouTube for the sync paths: one playlist, its items, and ids the Data API will not return."""
def __init__(self, items, *, unavailable=(), title="Mix", on_get=None, on_add=None):
self.items = [(f"it{i}", v) for i, v in enumerate(items)]
self.unavailable = set(unavailable)
self.title = title
self.get_calls = 0
self._on_get = on_get
self._on_add = on_add
def __enter__(self):
return self
def __exit__(self, *_):
return False
def iter_my_playlists(self):
return [{"id": "PLyt", "title": self.title}]
def iter_my_playlist_video_ids(self, _pid):
return [v for _, v in self.items]
def iter_playlist_items_with_ids(self, _pid):
return [{"item_id": i, "video_id": v} for i, v in self.items]
def get_playlist_snippet(self, _pid):
return {"title": self.title}
def get_videos(self, ids):
self.get_calls += 1
if self._on_get:
self._on_get()
return [
{"id": v, "snippet": {"channelId": "UCtest", "publishedAt": "2020-01-01T00:00:00Z"}}
for v in ids
if v not in self.unavailable
]
def create_playlist(self, title, *_, **__):
return "PLyt"
def update_playlist_title(self, _pid, title):
self.title = title
def add_playlist_item(self, _pid, vid, position=None):
if self._on_add:
self._on_add()
iid = f"it{len(self.items) + 100}"
self.items.append((iid, vid))
return iid
def delete_playlist_item(self, item_id):
self.items = [r for r in self.items if r[0] != item_id]
def move_playlist_item(self, item_id, _pid, _vid, position):
row = next(r for r in self.items if r[0] == item_id)
self.items.remove(row)
self.items.insert(position, row)
@pytest.fixture
def fake_yt(monkeypatch):
def install(fake):
monkeypatch.setattr(pl_mod, "YouTubeClient", lambda *_: fake)
return fake
return install
def _seed(db, ids):
db.add(Channel(id="UCtest", title="Test"))
now = datetime.now(timezone.utc)
for v in ids:
db.add(Video(id=v, channel_id="UCtest", published_at=now))
db.commit()
def _playlist(db, user, local, *, source="local", baseline=None):
pl = Playlist(
user_id=user.id,
name="Mix",
source=source,
yt_playlist_id="PLyt",
synced_video_ids=None if baseline is None else pl_mod._dump_ids(baseline),
)
db.add(pl)
db.flush()
for pos, v in enumerate(local):
db.add(PlaylistItem(playlist_id=pl.id, video_id=v, position=pos))
db.commit()
return pl
def _other_tab(pl_id, *, add=None, remove=None, rename=None, drop=False):
"""A second session's edit, committed at once — what another tab's request does. It runs on
the push's own thread: a lock the push wrongly held here would be waited on forever, so the
wait is bounded and a regression fails instead of hanging the gate."""
with SessionLocal() as s:
s.execute(text("SET LOCAL lock_timeout = '2s'"))
if drop:
s.execute(delete(Playlist).where(Playlist.id == pl_id))
if rename:
s.get(Playlist, pl_id).name = rename
if add:
pos = len(s.query(PlaylistItem).filter_by(playlist_id=pl_id).all())
s.add(PlaylistItem(playlist_id=pl_id, video_id=add, position=pos))
if remove:
s.query(PlaylistItem).filter_by(playlist_id=pl_id, video_id=remove).delete()
s.commit()
def test_the_mirror_refresh_holds_its_lock_through_a_list_with_a_private_video(
db, make_user, fake_yt, trace
):
user = make_user()
_seed(db, ["a"])
pl = _playlist(db, user, ["a"], source="youtube")
fake = fake_yt(FakeYT(["a", "ghost"], unavailable={"ghost"}))
trace.clear()
pl_mod.pull_user_playlists(db, user)
assert _held_to_the_item_write(trace, "DELETE"), trace
# the private video is asked for once, not again under the lock
assert fake.get_calls == 1
assert pl_mod._local_video_ids(db, pl) == ["a"]
def test_the_mirror_drop_locks_the_list_before_its_items(db, make_user, fake_yt, trace):
user = make_user()
_seed(db, ["a"])
pl = _playlist(db, user, ["a"], source="youtube")
pl.yt_playlist_id = "PLgone"
db.commit()
fake_yt(FakeYT([]))
trace.clear()
pl_mod.pull_user_playlists(db, user)
assert _held_to_the_item_write(trace, "DELETE"), trace
def test_a_repull_ingests_before_its_lock_and_holds_it_to_the_replace(
db, make_user, fake_yt, trace
):
user = make_user()
_seed(db, ["a"])
pl = _playlist(db, user, ["a"])
fake = fake_yt(FakeYT(["b", "a", "ghost"], unavailable={"ghost"}, title="Renamed"))
trace.clear()
pl_mod.repull_playlist(db, user, pl)
assert _held_to_the_item_write(trace, "DELETE"), trace
assert fake.get_calls == 1
db.refresh(pl)
assert (pl.name, pl.dirty) == ("Renamed", False)
assert pl_mod._local_video_ids(db, pl) == ["b", "a"]
def test_a_push_locks_the_list_before_it_replaces_the_items(db, make_user, fake_yt, trace):
user = make_user()
_seed(db, ["a", "b"])
pl = _playlist(db, user, ["a", "b"], baseline=["a"])
fake_yt(FakeYT(["a", "y"]))
trace.clear()
pl_mod.merge_playlist(db, user, pl)
assert _held_to_the_item_write(trace, "DELETE"), trace
def test_an_add_made_while_a_push_reads_youtube_survives_it(db, make_user, fake_yt):
user = make_user()
_seed(db, ["a", "x"])
pl = _playlist(db, user, ["a"], baseline=["a"])
# 'y' is new on YouTube, so the push ingests it — and the other tab adds 'x' meanwhile.
fake = fake_yt(FakeYT(["a", "y"], on_get=lambda: _other_tab(pl.id, add="x")))
pl_mod.merge_playlist(db, user, pl)
local = pl_mod._local_video_ids(db, pl)
assert set(local) == {"a", "x", "y"}
assert "x" in [v for _, v in fake.items] # and it reached YouTube as a local add
@pytest.mark.parametrize("linked", [True, False], ids=["linked", "first-push"])
def test_a_rename_made_during_a_push_s_youtube_writes_stays_dirty(db, make_user, fake_yt, linked):
"""The closing baseline is YouTube's title, not the one the lock re-reads: over the new name it
matched the rename, and the list read synced with YouTube still on the old title."""
user = make_user()
_seed(db, ["a", "b"])
pl = _playlist(db, user, ["a", "b"], baseline=["a"] if linked else None)
if not linked:
pl.yt_playlist_id = None
db.commit()
fake = fake_yt(
FakeYT(["a"] if linked else [], on_add=lambda: _other_tab(pl.id, rename="Mix 2"))
)
pl_mod.merge_playlist(db, user, pl)
db.refresh(pl)
assert (pl.name, fake.title) == ("Mix 2", "Mix")
assert pl.dirty is True # the rename has not reached YouTube; the list must say so
def test_a_push_stops_at_a_list_deleted_while_it_read_youtube(db, make_user, fake_yt):
"""A delete landing mid-push left a plan over no items: every item "removed here", so the push
emptied the YouTube playlist the delete meant to keep."""
user = make_user()
_seed(db, ["a", "b"])
pl = _playlist(db, user, ["a", "b"])
# a video YouTube would not return last time, retried now — the read the delete lands in
pl.synced_video_ids = json.dumps({"ids": ["a", "b"], "unavailable": ["ghost"]})
db.commit()
fake = fake_yt(
FakeYT(
["a", "b", "ghost"], unavailable={"ghost"}, on_get=lambda: _other_tab(pl.id, drop=True)
)
)
raised = None
try:
pl_mod.merge_playlist(db, user, pl)
except Exception as exc: # noqa: BLE001 — the regression raises too, AFTER its YouTube writes
raised = exc
assert [v for _, v in fake.items] == ["a", "b", "ghost"] # YouTube untouched
assert isinstance(raised, pl_mod.PlaylistGone) # which the route answers with a 404
@pytest.mark.parametrize("linked", [True, False], ids=["linked", "first-push"])
def test_an_edit_made_during_a_push_s_youtube_writes_stays_dirty(db, make_user, fake_yt, linked):
user = make_user()
_seed(db, ["a", "b"])
pl = _playlist(db, user, ["a", "b"], baseline=["a"] if linked else None)
if not linked:
pl.yt_playlist_id = None
db.commit()
fake_yt(FakeYT(["a"] if linked else [], on_add=lambda: _other_tab(pl.id, remove="a")))
pl_mod.merge_playlist(db, user, pl)
db.refresh(pl)
assert pl_mod._local_video_ids(db, pl) == ["b"]
assert pl.dirty is True # the removal has not reached YouTube; the list must say so
@@ -0,0 +1,113 @@
"""E28 S3e2 — putting removed Plex playlist rows back WHERE THEY WERE: the list page's "Undo".
A removal can take one row, a season or a whole show, and a season's episodes need not sit together
(a reorder inside the show interleaves them), so each key carries its own index — over the ORDERED
rows, since positions have gaps after a removal. One request: an append plus a second reorder call
leaves the rows at the end whenever that call fails, and would overwrite another tab's changes.
"""
from app.models import PlexItem, PlexLibrary, PlexPlaylist, PlexPlaylistItem
def _seed(db, user, rks, *, positions=None, extra=("x", "y")):
lib = PlexLibrary(plex_key="p1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
items = {}
for rk in [*rks, *extra]:
it = PlexItem(rating_key=rk, library_id=lib.id, kind="movie", title=rk.upper())
db.add(it)
items[rk] = it
pl = PlexPlaylist(user_id=user.id, title="L")
db.add(pl)
db.flush()
for i, rk in enumerate(rks):
pos = (positions or range(99))[i]
db.add(PlexPlaylistItem(playlist_id=pl.id, item_id=items[rk].id, position=pos))
db.commit()
return pl
def _order(db, pid):
db.expire_all()
rows = db.execute(
PlexPlaylistItem.__table__.select()
.where(PlexPlaylistItem.playlist_id == pid)
.order_by(PlexPlaylistItem.position, PlexPlaylistItem.id)
).all()
by_id = {it.id: it.rating_key for it in db.query(PlexItem).all()}
return [by_id[r.item_id] for r in rows], [r.position for r in rows]
def _add(client, pid, keys, positions=None):
body = {"item_rating_keys": keys}
if positions is not None:
body["positions"] = positions
return client.post(f"/api/plex/playlists/{pid}/items/bulk", json=body)
def test_one_row_goes_back_to_its_index(db, make_user, as_user):
user = make_user()
pl = _seed(db, user, ["a", "b", "c"])
r = _add(as_user(user), pl.id, ["x"], [1])
assert r.status_code == 200
assert r.json()["added"] == 1
assert _order(db, pl.id) == (["a", "x", "b", "c"], [0, 1, 2, 3])
def test_scattered_rows_go_back_to_each_of_their_indices(db, make_user, as_user):
# A season whose episodes were interleaved with another season's: removed rows at 1 and 3 of
# a, x, b, y, c. Given in any order, each lands where it stood.
user = make_user()
pl = _seed(db, user, ["a", "b", "c"])
_add(as_user(user), pl.id, ["y", "x"], [3, 1])
assert _order(db, pl.id)[0] == ["a", "x", "b", "y", "c"]
def test_indices_count_rows_not_stored_numbers(db, make_user, as_user):
user = make_user()
pl = _seed(db, user, ["a", "b", "c"], positions=[0, 7, 20])
_add(as_user(user), pl.id, ["x"], [2])
assert _order(db, pl.id) == (["a", "b", "x", "c"], [0, 1, 2, 3])
def test_an_index_past_the_end_appends(db, make_user, as_user):
user = make_user()
pl = _seed(db, user, ["a"])
_add(as_user(user), pl.id, ["x"], [50])
assert _order(db, pl.id)[0] == ["a", "x"]
def test_a_row_already_in_the_list_does_not_move(db, make_user, as_user):
user = make_user()
pl = _seed(db, user, ["a", "b", "c"])
r = _add(as_user(user), pl.id, ["c"], [0])
assert r.json()["added"] == 0
assert _order(db, pl.id)[0] == ["a", "b", "c"]
def test_without_positions_it_still_appends(db, make_user, as_user):
user = make_user()
pl = _seed(db, user, ["a", "b"])
_add(as_user(user), pl.id, ["y", "x"])
assert _order(db, pl.id)[0] == ["a", "b", "y", "x"]
def test_positions_must_pair_with_the_keys(db, make_user, as_user):
user = make_user()
pl = _seed(db, user, ["a"])
assert _add(as_user(user), pl.id, ["x", "y"], [0]).status_code == 422
assert _add(as_user(user), pl.id, ["x"], [-1]).status_code == 422
assert _order(db, pl.id)[0] == ["a"]
def test_remove_then_put_back_is_no_change(db, make_user, as_user):
user = make_user()
pl = _seed(db, user, ["a", "b", "c", "d"], extra=())
client = as_user(user)
client.post(
f"/api/plex/playlists/{pl.id}/items/remove-bulk", json={"item_rating_keys": ["b", "d"]}
)
assert _order(db, pl.id)[0] == ["a", "c"]
_add(client, pl.id, ["b", "d"], [1, 3])
assert _order(db, pl.id)[0] == ["a", "b", "c", "d"]
@@ -0,0 +1,115 @@
"""A sidecar subtitle in a legacy code page must render its accents, not U+FFFD.
Found live on Arrow S03E15 (a Hungarian .srt beside the .mp4): the served WebVTT held 1391 U+FFFD and
not one accented letter, because `_sidecar_vtt` decoded the bytes as UTF-8 with `errors="replace"`
while the file was Windows-1250 (ő = 0xF5, ű = 0xFB). Legacy-code-page .srt files are the norm for
Hungarian releases, and SubRip carries no charset of its own, so the stream's language is the only hint.
No Postgres: Plex and ffmpeg are stubbed.
"""
from pathlib import Path
from typing import cast
import pytest
from sqlalchemy.orm import Session
from app.plex import subtitles
HU = "1\r\n00:00:01,000 --> 00:00:02,000\r\nMiért nem szóltál? Ősz, űr.\r\n"
FR = "1\r\n00:00:01,000 --> 00:00:02,000\r\nDéjà vu, garçon.\r\n"
RU = "1\r\n00:00:01,000 --> 00:00:02,000\r\nПривет, мир.\r\n"
class _Plex:
def __init__(self, raw: bytes):
self.raw = raw
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def raw_get(self, key: str) -> bytes:
return self.raw
def _sidecar(monkeypatch, raw: bytes, lang: str | None, codec: str = "srt") -> str:
monkeypatch.setattr(subtitles, "PlexClient", lambda db: _Plex(raw))
stream = {"key": "/library/streams/1", "codec": codec, "languageCode": lang}
return subtitles._sidecar_vtt(cast(Session, None), stream)
@pytest.mark.parametrize(
("text", "encoding", "lang"),
[
(HU, "cp1250", "hun"), # the file that was found broken
(FR, "cp1252", "fre"), # a Western legacy file
(RU, "cp1251", "rus"), # a Cyrillic one
(HU, "utf-8", "hun"), # already UTF-8: untouched
(HU, "utf-8-sig", "hun"), # UTF-8 with a BOM
(HU, "utf-16", "hun"), # UTF-16 (carries its BOM)
],
ids=["cp1250-hu", "cp1252-fr", "cp1251-ru", "utf8", "utf8-bom", "utf16"],
)
def test_a_sidecar_srt_is_decoded_in_its_own_encoding(monkeypatch, text, encoding, lang):
vtt = _sidecar(monkeypatch, text.encode(encoding), lang)
assert "\ufffd" not in vtt
assert vtt.startswith("WEBVTT\n\n")
assert text.splitlines()[2] in vtt
def test_a_non_srt_sidecar_reaches_ffmpeg_as_utf8(monkeypatch):
seen: dict[str, bytes] = {}
def fake_ffmpeg(src: str, sub_ord):
seen["bytes"] = Path(src).read_bytes()
return "WEBVTT\n"
monkeypatch.setattr(subtitles, "ffmpeg_to_vtt", fake_ffmpeg)
_sidecar(monkeypatch, "[Events]\nDialogue: Ősz\n".encode("cp1250"), "hun", codec="ass")
assert seen["bytes"].decode("utf-8") == "[Events]\nDialogue: Ősz\n"
def test_a_track_cached_before_the_encoding_fix_is_not_served(tmp_path, monkeypatch):
"""The cache has no eviction, so a mis-decoded track would have stayed broken for good: the key
is versioned, and a track cached under the old one is read again (once) instead of served."""
monkeypatch.setattr(subtitles, "CACHE_DIR", tmp_path)
(tmp_path / "sub_14881_1.vtt").write_text(
"WEBVTT\n\n1\n00:00:01.000 --> 00:00:02.000\nAz�rt\n", "utf-8"
)
assert subtitles.cached_track("14881", 1)[0] is None
def test_one_stray_byte_does_not_send_a_utf8_file_to_a_legacy_code_page(monkeypatch):
"""A UTF-8 release with ONE bad byte (a cut-and-paste Windows-1252 quote) failed strict UTF-8 as a
whole, and the whole file was then read as Windows-1250: every correct ő/é became mojibake. Nearly
all of it is valid UTF-8, so it stays UTF-8 and only the one byte is replaced."""
raw = (HU * 20).encode("utf-8") + b"\x93quoted\x94\r\n"
vtt = _sidecar(monkeypatch, raw, "hun")
assert "Miért nem szóltál? Ősz, űr." in vtt
assert "Miért" not in vtt
def test_an_untagged_legacy_sidecar_is_read_as_central_european(monkeypatch):
"""No language tag: the fallback was Windows-1252, which turns a Hungarian ő/ű into õ/û."""
vtt = _sidecar(monkeypatch, HU.encode("cp1250"), None)
assert "Ősz, űr." in vtt
def test_a_latin_serbian_sidecar_is_read_as_central_european(monkeypatch):
"""Serbian releases are overwhelmingly Latin script in Windows-1250 (like hrv/bos); read as
Windows-1251 every č/ć/š/ž/đ turned into Cyrillic noise."""
sr = "1\r\n00:00:01,000 --> 00:00:02,000\r\nŠta ćeš, đače? Čekaj.\r\n"
vtt = _sidecar(monkeypatch, sr.encode("cp1250"), "srp")
assert "Šta ćeš, đače? Čekaj." in vtt
def test_a_track_cached_under_the_previous_decoder_is_not_served(tmp_path, monkeypatch):
"""The decoder changed again (stray-byte UTF-8, cp1250 fallbacks): the key is versioned once more
so a track the old rules mis-decoded is read again instead of served for good."""
monkeypatch.setattr(subtitles, "CACHE_DIR", tmp_path)
(tmp_path / "sub2_14881_1.vtt").write_text(
"WEBVTT\n\n1\n00:00:01.000 --> 00:00:02.000\nAzõrt\n", "utf-8"
)
assert subtitles.cached_track("14881", 1)[0] is None
+3 -2
View File
@@ -311,8 +311,9 @@ def test_covered_ranges_coalesce_and_answer_membership():
def test_a_cached_vtt_from_an_older_build_counts_as_complete(tmp_path):
"""Before this change the cache only ever held whole tracks, and none of them has a sidecar.
Reading one as "covers nothing" would re-extract every already-cached track after the upgrade."""
(tmp_path / "sub_98614_0.vtt").write_text(_vtt(_cue("00:01.000", "00:02.000", "x")), "utf-8")
assert subtitles.read_meta("sub_98614_0") == ([[0.0, 1_000_000_000.0]], True)
key = subtitles.cache_key("98614", 0)
(tmp_path / f"{key}.vtt").write_text(_vtt(_cue("00:01.000", "00:02.000", "x")), "utf-8")
assert subtitles.read_meta(key) == ([[0.0, 1_000_000_000.0]], True)
# --- the deep-review round: the seams between the window and the full pass ----------------------
+1 -1
View File
@@ -37,7 +37,7 @@ def test_a_failure_is_sorted_by_what_the_user_can_do(exc, reason):
("route", "target"),
[
("/api/sync/subscriptions", "app.routes.sync.import_subscriptions"),
("/api/playlists/sync-youtube", "app.routes.playlists.sync_user_playlists"),
("/api/playlists/sync-youtube", "app.routes.playlists.pull_user_playlists"),
],
)
def test_a_manual_sync_youtube_refuses_is_a_422_not_a_500(
+638
View File
@@ -0,0 +1,638 @@
# DESIGN.md — how Siftlode looks and behaves
This is the one written source of truth for the UI: principles, tokens, the canonical solution per pattern,
and the interaction rules. Screens are measured against it. A pattern that has more than one look in the
code is either a decision still open here (marked **Pending**) or a violation to fix.
**Status:** written in E28; every chapter is _Decided_ and in force. The design lab (below) renders each rule's
chosen form. A new question opens a **Pending** item here, with its variants in the lab, until the product
owner settles it.
## How this document changes
- A rule changes only through an explicit decision recorded in the [decision log](#decision-log): what was
decided, the options it was chosen from, and why. No mid-sprint taste changes. A new idea goes on the
list for the next design round.
- Every rule rests on a named standard: Nielsen's heuristics, WCAG 2.2 AA, WAI-ARIA APG, Apple HIG or
Material. The tag is in brackets after the rule.
- A rule stated here should also be enforced in code (an eslint rule, a primitive's type, a test). A rule
that lives only in prose drifts. That is the finding that started this document.
## Conformance — the check before a UI change is done
A screen is measured against this document, not against taste. Every change that renders something new
runs this check before it is called done (E28 S3g):
1. **Primitives at a named rung.** Each control goes through its primitive (lint refuses the raw element,
and a `<div role="button">` with it), at a rung or role this document names: a `Button` size, an
`IconButton` size, a `Chip` size, the `SegmentedToggle` rung, a `SURFACE` role. The lint holds the
element, not the rung, so the rung is checked by eye and by measure.
2. **The pattern's chapter, re-read.** The change matches the chapter of every pattern it touches, or it
opens a **Pending** item and a log row. It never quietly adds a second look.
3. **Measured.** On the changed pages, the rendered elements are grouped by kind (button, icon button,
chip, field, tab, segment, menu row, surface) and by their computed look (radius, height, type, fill,
edge). Each distinct look maps to a rung or role named here; a look that maps to none is a violation
or a new decision. The S3g baseline across the 15 pages at 1920 × 1080: every button, icon button,
chip, field, tab, segment and menu row is drawn by its primitive. What the measure still counts as
hand-drawn is by design: text links (no primitive), surfaces painted by their `SURFACE` role (a class,
which the measure cannot trace to a component) and the playlist table's own rows.
4. **Seen.** A screenshot of every changed state, thin edges at 1:1; the tablet widths (1024 × 768, a
narrow split screen) when the layout can reflow. Phones are the E29 epic's, not this check's.
## The design lab
`npm run dev`, then open **`http://localhost:5173/design-lab`**. The lab renders each open decision's variants
with the app's real CSS, tokens, glass and themes, and has its own switches for theme, scheme, text size and
backdrop. It is dev-only: `main.tsx` reaches it behind `import.meta.env.DEV`, and the build plugin
`frontend/devOnlyGuard.ts` fails a production build that would contain it. Once a decision is taken, the
losing variants are deleted and the chosen one stays as the reference rendering: the lab is the living
style guide.
## Principles
1. **Glass over a calm background is the look.** Surfaces are translucent layers (`.glass`,
`.glass-card`, `.glass-menu`, `.glass-media`) over a per-scheme backdrop. Depth comes from the
**header + fade**: a crisp sticky header with a short gradient just below it, so content dissolves as it
slides under. The reference is `DataTable`'s sticky header. A flat opaque box where a glass surface
belongs is a violation.
2. **Built for one known, daily user, on a desktop and a tablet.** Density is welcome where a mouse is
the pointer; on touch every target grows. Nothing is designed for phones.
3. **One primitive per pattern.** Where a `components/ui/` primitive exists and is used, the looks don't
drift. The drift lives in hand-rolled markup. A new generic control goes into `ui/`, and a screen
composes primitives rather than restyling them.
4. **Measured, not eyeballed.** Contrast, target sizes and layout at the contract sizes are measured in a
real browser before a UI change is called done.
5. **Honest states.** An error never renders as an empty state; loading, empty and error are three
different screens (`QueryState` / `StateMessage`).
## Device adaptation — _Decided_
- **Supported:** tablet portrait and landscape and every size above them (from 768px wide). Phones are not a
target. Measure a UI change at 1024×768, 820×1180 (portrait) and a desktop size.
- **Adapt by capability, never by user agent:** `@media (pointer: coarse)` and `(hover: none)` decide touch
behaviour; container queries decide how a panel lays itself out. Breakpoints are named tokens, never
literal pixel values scattered in call sites. [HIG, WCAG 1.4.10]
- **Touch never loses a function.** Anything revealed on hover is visible at rest on `hover: none` (the
`.reveal` class), or it is removed on touch because the same action is reachable another way
(`.reveal-hover`). [WCAG 1.4.13, Nielsen #6]
- **Targets:** at least 2.75rem (44px) on a coarse pointer, as a real box, not an invisible hit area:
overlapping hit areas fire the wrong control. It covers every control primitive, not only `Button`
(A3, under Controls). [HIG, WCAG 2.5.8]
- **Text inputs** use at least 1rem text on a coarse pointer, so a tablet browser doesn't zoom the page
when an input gets focus.
- **Side panels (C2):** on touch, and at any width below 768 (a narrow window, split screen), the rail tab
or the band's "Filters · n" button slides the panel **over** the content with a scrim; a tap outside, Esc
or Back closes it, and the content never reflows. Pinning is its own button in the panel header, offered
only where a pinned panel leaves the content room (≥ 1024 wide). The mouse keeps hover-to-peek and
click-to-pin. A filter is never unreachable at any supported width. Built in S3f3: `ui/SidePanel`
opens `ui/Modal`'s `drawer-start` placement (the dialog's own Esc, Back, scrim, focus trap and scroll
lock); the tab shows at every width and is 44 wide on touch; a pin saved on a desktop is left alone
below 1024, not honoured. [Nielsen #7, WCAG 1.4.10]
- **A row answers to its own width.** A list row that must fit a narrow column (the download row, the
playlist table, the Channels tag row) stacks by a container query on its own box, not by the
window: the same list sits beside a rail, in a split-screen tablet and in a zoomed-in browser. A
line of facts (the download row's "Ready · MP4 · 18.9 MB · 1:11 · 1w ago") is one line cut at its
end, never a flex row that breaks its pieces apart.
- **A table gives its secondary columns back.** By the table's own width, as a row does: below 42rem
a column marked `narrowHidden` steps aside, below 64rem (a 1024 window) one marked `tabletHidden`
does, so the name keeps its room. The Channels table drops Prio, About and Length there (at 1024 the
name had 60 px); the row's "…" still sets the priority.
- **Players on touch (C3):** see [Players](#players).
## Tokens
- **Colour:** only semantic tokens (`--bg --surface --card --fg --muted --accent --accent-fg --danger
--warn --success --info --border`, each with a `-fg` partner where text sits on it), and no palette class
(`text-amber-500`: lint-refused). A score's gold is `--rating` (theme-scoped, 4.5:1 on the page and on its
own chip), never `--warn`: a score is not a warning. What sits on a picture looks the same in both themes,
because the picture does: the broadcast red `--live` under `--live-fg` (never `--danger`: "live" is not an
error), `--rating-media` on a poster's scrim, and `--media-chrome` for the always-dark player's menus
(owner decision F16). 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.
[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,
and 1.4.11 does not ask an edge of a control its label already identifies (owner decision F14).
- **The accent never looks like a status.** A scheme's accent is at least 30° of OKLCH hue away from
`--danger`, `--warn` and `--success`, and passes 4.5:1 both as text on `--bg` and under `--accent-fg` on
the fill, in dark and light. A primary button must never read as a destructive one. A new scheme is
measured against this before it ships (the lab's B2 tiles do the arithmetic). [WCAG 1.4.1, 1.4.3]
- **Held in code:** `frontend/src/lib/theme.contract.test.ts` measures every scheme × mode in `index.css`
against the two rules above (text 4.5:1, a control edge 3:1, the status and rating colours as text, the accent's
hue gap), and the media colours against what they sit on. A scheme or a retuned colour that breaks them does not pass the gate.
- **No per-user colour tuning.** The schemes are the only colour choice; the old floating glass tuner was
retired (E28), because free tuning is exactly the drift these rules exist to stop.
- **Backdrops:** one per scheme and mode (`public/backdrops/`), soft pools in the accent's family, with a
grey `overlay` grain that breaks the gradient banding (measured by pixel-scanning a canvas, never a
screenshot, which rescales).
- **State layers (B3):** hover and pressed are a layer, not a colour swap: the text colour mixed at **10%**
(hover) / **16%** (pressed) over whatever the control sits on, so it reads on the page, a card and glass
alike. A filled control mixes its own text colour into its fill at 14% / 24%. Opacity dimming and
`bg-card` hovers are violations (invisible on a light card). Selected stays the solid accent. The one
exception is an edged pill (a chip, a toolbar or form trigger, a segment): its hover is the accent edge
(`.pill:hover`, `glass-hover`), which reads on every surface too (owner decision F14).
A card that is one row with actions answers as a whole: the layer is the card's, under its badges and
buttons, and the row's open button spans the card beneath the actions (a hover that stopped at the first
badge read as broken; owner, E28 — `AdminUsers` user rows).
- **Tokens as utilities:** the decided values live once in `index.css` — `--hover` / `--press`,
`--edge-strong`, `--touch-target`, and the utilities `state-layer`, `state-filled`, `state-danger`,
`edge-strong`, `fade-below`, `fade-below-panel`, `fade-above-panel`, `fade-edges-x`. A primitive or a lab
rendering uses them; restating one as an arbitrary class is a violation.
- **Type and size:** everything is rem-derived off `--font-scale` (the Settings text-size slider), so a
px font size or a px-frozen box is an eslint error.
- **Radius:** chips and segmented controls `rounded-full`; tabs `rounded-lg`. Buttons, icon buttons and
form fields `rounded-lg`; toolbar triggers and search a pill (Controls). Surfaces: see Surfaces.
- **Layers:** the fixed z-index scale in `index.css` (`base < menu < chrome < paneltab < panel < rail <
overlay < popover < tooltip`), plus `under`, a surface's own layer under its content inside its `isolate`.
No ad-hoc z-index. [maintainability]
## Controls — _Decided_ (batches A and B)
The chosen rendering of each rule is in the lab under "Decided". The primitives are built to it in S3.
- **Buttons (A1):** `rounded-lg` (r8): a control's corner is smaller than the surface it sits on (card r12,
dialog r16). The rung follows the context: `md` in forms and dialog footers, `sm` in toolbars and panel
headers, `xs` inside a table row. Glass is a `Button` variant, not a hand-built glass button. [HIG, Material]
- **Density (B1): compact, by explicit height.** `md` 2rem (32), `sm` 1.75rem (28), `xs` 1.5rem (24); icon
buttons 1.75rem / 2rem. A toolbar's search pill, dropdown trigger, `sm` button and small icon button are
the same 28, so the row lines up. Nothing goes under 24 (WCAG 2.5.8); touch grows it to 44 (A3).
- **Icon buttons (A2):** two sizes (28 / 32), r8: the smaller in rows and dense toolbars, the larger on its own. On a
picture, a round, dark translucent button, readable on any thumbnail. Round means "floats over something":
on media, or the one floating button over the page (back to top, a 48 glass circle, the FAB); a button
inside a pill field is round too, so the radii nest. A text
button on a picture is a pill (the card's Continue in the accent, Restart dark translucent, C5), and
the one 48px glyph is the Play in the middle of a not-started card (C5).
- **Touch (A3):** on a coarse pointer every control primitive (`Button`, `SegmentedToggle`, `FilterSelect`,
`Tabs`, `Switch`, chips, the nav rail) grows to 2.75rem (44px) through one token, as a real box. Mouse
sizes don't change. [HIG, WCAG 2.5.8]
- **Input (A4):** one bordered input primitive, `ui/Input` (`Textarea` for several lines; a frame that also
holds an icon or chips is painted with `fieldClass` around an `InnerInput`). The edge is
`color-mix(in srgb, var(--fg) 55%, var(--bg))`, ≥ 3:1 against the page in every scheme and mode; focus
turns it into a 2px accent edge, `invalid` into the danger edge. A form field is r8 at the `md` height
(32); a toolbar search, a field in a popover or an inline rename is `sm` (28), the toolbar search a pill;
the page's one hero field is `lg` (40): the header search, a glass pill floating over the page (the
YouTube/Gmail shape), and the command palette's field;
a field in a table row or the pager `xs` (24); an inline rename of a page title keeps the heading's type
(`heading`). Text is 1rem and the box 44 on a coarse pointer. [WCAG 1.4.11]
- **Dropdown trigger (A5):** `ui/SelectTrigger`. A pill in a toolbar (where the pill means "a filter or a
choice"), r8 and full width in a form, where it wears the field's frame (see-through, the ≥ 3:1 edge) and
lines up with the inputs. Open is the accent edge and the chevron turned over.
- **Checkbox, switch, slider (A6):** the browser's own controls tinted with `accent-color` (`Checkbox`,
`CheckRow`, `Slider`); `ColorInput` is the browser's colour well, which `accent-color` does not reach (it
tints only checkboxes, radios, ranges and progress bars). The whole labelled row is the 44px target. Keyboard, screen reader and
forced-colours support come from the browser. The switch stays `form`'s `Switch`: no browser has a native
one yet. A raw `<input>` or `<textarea>` outside those primitives is a lint error (a hidden file picker
passes). A checkable row that cannot hold an `<input>` — a menu row, a whole-row button whose role says
checkbox — draws the ONE `ui/MenuCheck` in its leading icon slot: a box filled with the accent for a set
(tags, playlists, a filter's values; a minus for a group only partly in), a bare tick for one choice (a
language, a sort, a priority step), an unticked choice keeping the slot empty so the labels line up.
- **In-place editing:** a field that replaces its trigger (a title rename, the priority number) hands the
focus back to that trigger when Enter or Escape closes it (`useReturnFocus`), never to `<body>`.
[WCAG 2.4.3]
Also in force:
- **One primitive per button family** (E28 S3b): a labelled action is `Button` (a rung from the ladder,
plus `lg` 40 for a page's single hero call to action, and nothing else); a glyph is `IconButton` (a
required `label`); a card, row, tile or menu row whose content is the control is `Pressable`; a filter or
tag chip is `Chip`; a sortable row's grip is `DragHandle`. On the always-dark player chrome both
`Button` and `IconButton` use the `overlay` variant (white, a white layer), whatever the theme; the Plex
control row is IconButton's `player` rung (36, a 20px glyph, round, 44 on touch). A pressed toggle is
`pressed`: the solid accent on a `Button`, the accent glyph on an `IconButton`. A raw `<button>` (or a bare
`FocusSafeButton`) outside the control primitives (eslint `BUTTON_PRIMITIVES`) is a lint error. A text-less button must have an accessible name, and the type enforces
it. [WCAG 4.1.2]
- **The chip family** (`Chip`, E28 S3b): rounded-full, never under 24 (A3 grows it to 44 on touch).
`filter` is a selectable option, solid accent when `pressed`; the "+N" and "More…" triggers are filter
chips too, and while their popover is open they show it as an open trigger does, the accent edge and
text (not the solid fill, which would say "selected"); `token` is an applied narrowing, the accent tint, the whole chip removing
it; `tag` is a user's tag, the solid accent; `suggest` is a suggestion not yet taken (dashed accent);
`ghost` the quiet "add a tag" (dashed neutral). A chip whose label is not the control but carries its
own × is `DismissibleChip`: two sibling controls at most, never a button in a button. An overflowing chip
lane measures a hidden mirror painted with `chipClass`, so the mirror cannot drift from the chips.
- **Unavailable with a reason** stays focusable: `aria-disabled` plus the reason in the label or tooltip,
never native `disabled`, which drops it from the tab order with its explanation. [WCAG 2.1.1]
- A choice between a few values is `SegmentedToggle`; a long list or a user's own list is `FilterSelect`.
Never a native `<select>` (lint-enforced). Its rung is the toolbar's, 28 with `text-xs`, so it lines up
with the triggers and the pager's buttons; the 24 rung with the smallest type is only a side panel's
captioned match toggle (S3g: the old small default had put a pager and a toolbar count on 10px type).
- **A multi-value filter's any/all switch** (tags, topics, languages, genres, collections, actors) is
`ui/MatchToggle`, on the panel right above its picker, from two picks on — never in the picker's
popover, where a closed picker hid that "all" was on. One wording: Match · Any / All. [Nielsen #1]
- **The pager** is four glyphs — first, previous, next, last — round the page box (`IconButton`s, their
names as the tooltip); no words, at any width.
- A single-select row of filter chips goes through `FilterChipGroup` (its options are `Chip`s).
- A button that starts slow work shows `loading` (a spinner, `aria-busy`) and can't be pressed twice.
[Nielsen #1]
- **A glyph means one thing.** Play (▷) starts playback, or resumes a paused transfer; a retry is the
refresh arrow (`RefreshCw`); a reset or revert is the counter-clockwise arrow (`RotateCcw`). A failed
download's Retry had worn the Play glyph, which promised a video. [Nielsen #4]
## Surfaces and page chrome — _Decided_ (batch B)
- **Surface per role (B5):** a dialog or panel is `.glass` r16; a card or picker `.glass-card` r12; a menu
`.glass-menu` r12 with 4px padding and r8 items (12 − 4, so the corners nest); a tooltip `.glass-menu` r8.
Translucency comes only from the tiers (the app backdrop, Plex media, light); a per-site alpha override is a
violation. A call site names the ROLE (`SURFACE.panel` / `drawer` / `card` / `menu` / `popover` / `tooltip`,
`ui/surfaces.ts`), which brings the tier AND the radius; a page section is a panel, and what floats over
undimmed content (a menu, a hover card, a status popover, a toast, a tooltip) is `.glass-menu`. A bare glass
class is a lint error. A role's depth is its own shadow (the top highlight + the drop); a `shadow-*` size beside
a role would replace both and is a lint error too — a field that floats takes `shadow-float`, the same shadow.
- **Static vs pressable (owner, S3d):** a label that does nothing (a topic, a keyword, a status, a count) is a
flat, tonal `Badge` with no edge; everything that does something has an edge (a chip, a trigger, a field).
A genre that filters the library is a chip; the same genre where it can't is a badge. A badge's `tone`
(neutral / accent / warn) is its own colour at 6%, measured at 4.5:1 in every scheme; a `<span>` drawn with a
round edge is a lint error (F29). A toggle button states itself through `pressed` (the solid accent), never a
bare `aria-pressed`, which the type refuses (F28).
- **A link that looks like a button** (a download, a sign-in that leaves the app) is `LinkButton` /
`IconLink`: still an `<a>`, wearing the button's own face.
- **Page chrome (B4):** a page's tabs and filters sit in ONE sticky band of the page colour, with the fade
under it (below). Below ~600px of height the filter row folds into one "Filters · n" button. Every page has
a visually hidden `h1`; the nav rail and the browser tab title are the visible identity. [WCAG 2.4.6]
Built as: the band is `PageShell`'s toolbar slot (`PageToolbar`), above the one scroller; a console
whose toolbar lives inside its frame (the server log) makes that toolbar `sticky` with `fade-below`
instead. The band holds controls only: an intro paragraph scrolls with the content (in the band it
ate three lines of a short window). The fold is `useFilterFold` + `FilterFoldButton`: the same row
shown or hidden in place, never copied into a popover. The hidden `h1` is `PageShell`'s `title`; a
view with its own visible title (a channel, a Plex show, a playlist) renders it as `PageHeading`
and the hidden one steps aside, so a page has exactly one `h1` (a hand `<h1>` inside the shell is
refused by a contract test). A section's caption is `SectionLabel`, an `h2` (an `h3` in a dialog),
never a styled `div`. The first Tab stop is "Skip to content" (`SkipLink`), which focuses the
scroller's `<main>`. [WCAG 2.4.1, 1.3.1]
- **The header + fade edge (B9):** a crisp band and a 1rem gradient of the colour it sits on, so content
dissolves as it slides under; it shows only once something is under it, never over a list at rest. It goes on sticky toolbars and table headers, tabs over a scrolling panel, a
long dialog's header and footer, and the clipped ends of an overflowing chip row. Not on thumbnails (they
are the content) and not on buttons (it becomes a dated gloss). Built as: a sticky element takes the
`fade-below` utility (`fade-below-panel` / `fade-above-panel` on a panel); the page band and a
scrolling panel under its header fade by the scroller's own edge mask (`useScrollFade`), because what
they sit on is the backdrop, not a colour. The chip rows fold their overflow into "+N" rather than
scroll, so no row has clipped ends; the carousels fade theirs. The dialog edges come with C4's pinned
footer (S3f).
- **Viewport height:** `dvh`, never `vh` / `h-screen`: `100vh` is the viewport with a mobile browser's
toolbar retracted, so an `h-screen` shell runs its last rows under the toolbar (contract-tested).
- **Nav rail (B7):** the `ui/NavRail` primitive, slim: 3.75rem wide. "You are here" is a soft accent tint plus a
3px edge bar. Labels under the icons are optional, a per-user switch in Settings (off by default); they add
height, never width, so each module has a short rail label that fits, and the full name stays in the
tooltip. No wide padding around the icons. The switch is Settings → Appearance → "Labels in the side
menu"; with the labels on, a row's accessible name is its visible short label (WCAG 2.5.3), and an
unread count is part of the name, not only drawn.
- **Loading (B8):** a first load of a grid, list or table shows its skeleton (`LoadingState shape="grid" |
"poster" | "list" | "table"`, built from the `Skeleton` primitive), so
nothing jumps when the data lands; a control's own work shows its spinner (`loading`). Neither appears
before ~300ms. [Nielsen #1]
- **The public watch page (B6)** follows these same rules (glass, `Button`, the pressed-pill toggle, the
scheme's backdrop); only the video keeps the browser's own controls, because a stranger's device is
unknown.
Already in force:
- The glass role is load-bearing: a dropdown or menu is `.glass-menu` (it floats over undimmed content, so a
card alpha would bleed through), a dialog is `.glass`, and a grid card is `.glass-card`.
- Every overlay is a `Modal`/`Overlay` or an anchored popover from the one popover engine. Escape closes the
topmost layer only, and focus returns to the control that opened it. [WCAG 2.4.3, 2.1.2]
## Action placement — _Decided_
Which actions are visible and which live in a menu.
- **One menu, several triggers.** An object's actions are ONE list. The visible "…" button, a right-click,
a long-press (touch) and Shift+F10 / the Menu key all open the same `ContextMenu` built from that list.
A second, hand-made menu for the same object is a violation.
- **Nothing is reachable only by a gesture.** Every menu action also has a visible path: the "…" button,
the page toolbar, or the object's detail view. A right-click is a shortcut, never the only door.
[Nielsen #6, #7]
- **What stays visible:** the object's primary action (play or open) and any state the user needs to see
(watched, saved, downloading). **What goes into the menu:** rare, secondary and destructive actions.
Destructive ones keep their confirmation. [Nielsen #8, #5]
- **On touch, a card shows fewer actions and a "…" button.** The rest are in the menu.
- **The classification (C1)** says, per object, what is visible at rest, on hover and on touch; its full
table is the lab's C1 reference, and S3 turns it into each object's one action list in code. In short:
- A video or Plex card: play and its state (watched, progress) at rest; the everyday toggles (watched,
save, add to playlist) and "…" on hover; on touch the state and a 44px "…" beside the title. Details,
download, hide and reset live in the menu.
- A row (playlist item, channel, download): its primary action and its values (priority, tags, error)
stay visible; destructive and rare row buttons (unsubscribe, delete, cancel, remove) move into the menu.
A playlist item's X column goes: Delete key plus an undo toast.
- Downloads, Notifications and the Scheduler get a menu (they have none today); a page's destructive
header action ("Clear everything", "Purge discovery") goes into the header's "…".
- **Built as:** one `useContextMenu` per list; the row spreads `triggerProps` (right-click, long-press,
Shift+F10) and its "…" is `ui/MoreButton` with `buttonProps` (the same menu, under the button; a second
press closes it). An object's list is one builder (`useVideoActions` for a video, `usePlexCardActions`
for a Plex title, composed by their lists and by Home's rails); a row that opens a panel ("Add to playlist…", "Details") anchors it to
`menu.anchor`, the element the menu came from. "Copy title" / "Copy link" come from `useCopyItems` and
run inside the click (`inGesture`, Safari's clipboard rule). The card's hover row sits on its picture
(the duration steps aside while it shows); on touch the toggles are gone and a 44px "…" sits beside
the title (`.touch-only`). The hover chrome and what steps aside for it are one pair of class strings
(`ui/reveal.ts`), shared by the video and the Plex cards. A table or rail ROW shows its "…" at the
row's end on hover or focus and always on touch (`.reveal`). A playlist's rows take Remove from the
Delete key too (the `listDelete` key scope, registered only by a list that can remove) and answer it
with an undo toast whose Undo puts the row back at its index in one request; a playlist itself has
one list (`usePlaylistActions.items`) that its rail row and its page header's "…" both open. A
subscribed channel's row ends in its "…" (`useChannelActions`: open, pin, Tags, Priority, YouTube,
copy, full history, hide with an undo toast, unsubscribe behind its confirm), docked beside the name
in the card layout. Its Tags submenu lists the reader's tags A–Z, each a check that toggles in place
(the menu stays open for the next); its Priority submenu offers the fixed steps 10 · 5 · 3 · 2 · 1 · 0
(an off-scale current value shows ticked in its place), and a pick re-sorts the list at once — only
that row moves — with the pager and the focus (its "…") following it. The row's stepper still sets any
value and leaves the order until the next load. A Plex playlist's title, show group and season each open the page's one row menu
(a title's is its `usePlexCardActions` list plus the removal); removals take the same undo toast,
`j`/`k` walk the visible rows and Delete removes the walked one; its rename and delete sit in the
header's "…". A toast that carries an action lives at least 8 s, whatever the toast setting. A
download row keeps play (pause / resume / retry in the queue) and the save link, a library row also
Share (the link copy, ahead of save), and ends in its "…" (`useDownloadActions`: the editors,
managing the shares, re-download, the copies, cancel / delete / remove
behind their confirms); a link inside a row keeps the browser's own right-click menu. A notification
row keeps its link and its unread mark, shows its dismiss on hover (in the "…" on touch), and its
"…" holds find / undo for an activity notice. The notification center's band keeps Mark all read
and puts Clear everything, now behind a confirm, in its "…"; the Scheduler's strip keeps Run all
and Pause and puts Purge discovery in its "…". A list whose row objects are rebuilt each render or replaced by a
poll passes `useContextMenu({ keyOf })` (its id), so the "…" still closes its own menu. A menu's
groups are `ui/menuGroups`: a rule between
two non-empty groups only.
- **Background menu:** a right-click on EMPTY content area (not over text, inputs, links or media) may open
a page menu. It only mirrors page-scope actions that already exist in the toolbar (sort, view, quick
filters, select all); it adds no new function. Shift+right-click falls through to the browser's own menu.
Desktop only: on touch a long-press on the background clashes with scrolling and text selection, so the
toolbar stays the touch path. Built (`ui/PageMenu`; PageShell listens, `lib/backgroundTarget` decides
"empty") on five pages: the **feed** (sort, reshuffle, view, watch state, content types, source, clear
filters), the **Plex library grid** (sort, watch state, clear filters), the **channel manager** (layout,
the cards' sort, sync status, edit tags, clear the tag filter, read subscriptions from YouTube), a
**playlist** (play all, group by channel, reset view, save order, undo / redo, and the list's own "…")
and the **notification center** (mark all read). Never a destructive row (delete, revert, clear
everything) and never a long quota run (backfill): a stray right-click must not be one click from those.
The tab-only pages keep the browser's menu; `pageMenuScope.contract.test.ts` holds the list.
### Text stays selectable — _Decided_
Wherever there is text, the user can select it and copy it, and no click action overrides that.
- A title or a name inside a click surface stays selectable: the card, row, person, channel or log line
that opens is `selectable` (`ui/FocusSafeButton`, under `Pressable` and `Button`). Chrome selects no
text inside a `<button>` unless it is `user-select: text` (measured), so the flag sets that and the
click guard below. A control's own label (a menu row, an option, a tab, a nav item, a switch) is not
a title and stays plain; `pressableSelectable.contract.test.ts` makes every new `Pressable` choose.
- A click that ends a text selection does nothing: if the selection inside the element is non-empty, or
the pointer moved past the drag slop, no play or open fires.
- A right-click on selected words opens the browser's own menu (Copy, Search, Translate), not the
object's; a right-click beside the selection still opens the object's menu (`ui/useContextMenu`).
- On touch the long-press belongs to the context menu, so the object's menu offers **Copy title** and
**Copy link**.
## Media card — _Decided_ (S3h)
A film, a show, an episode, a video or a pinned channel is ONE card wherever it appears: the Plex grid,
the feed, Home's shelves and hero, the Plex rails, the media-info page's collection strip. A place may
pick a density step; it never changes the anatomy or loses an action. [Nielsen #4]
- **Anatomy (the owner's pick C).** One frame: the picture, the title, the meta line pinned to the
frame's bottom edge, so a one-line title leaves no hole above the meta. What is ON the picture: the
length (a film's runtime, a show's season count, a video's duration) as a dark badge in the corner,
the watched mark, the progress bar along the bottom. Built as `ui/MediaCard` + the classes in
`ui/mediaCardStyles` (`MEDIA_PICTURE`, `MEDIA_IMG`, `MEDIA_BADGE`, `MEDIA_HOVER_LAYER`, `MEDIA_BAR`,
`mediaTitleClass`, `mediaPictureBox`).
- **Play only where the click plays.** The hover layer dims the picture and shows `MediaPlayGlyph`, the
one round media Play (the button family's `media` variant), only on a card whose click starts
playback; a show without a next episode opens instead and shows none. The hover row (watched, save or
add, "…") docks at the picture's bottom-right; the length badge steps aside while it shows.
- **Density.** A grid card: a two-line title and the full meta (Plex: year · ★ score; a video: the
channel, then views · age). On a shelf (any horizontal strip) the title is one line, cut with an
ellipsis, over ONE meta line (Plex: year · ★; a video: the channel; a tile: its count), so every card on
the strip is the same height. A Home shelf slot is 232px: at 200 the Play and the hover row
overlapped by 2px, at 232 they stand 6px apart; a landscape shelf picture takes the smaller Play.
- **A mixed shelf is landscape.** Beside videos a Plex title shows its wide art, not its poster; a
rail's slot is a grid cell, so each card fills the height the strip stretches to. A carousel turns
the cards' content-visibility off (`mediaRail.contract.test.ts`): an off-screen card's reserved height
had become the strip's.
- **The preview (the owner's pick C).** Resting the mouse on a Plex card for ~0.9 s opens a panel beside
it: the whole title, year · length · ★, and the synopsis in short lines (`ui/MediaPreview` +
`useMediaPreview`). Mouse only (a keyboard walk or a tap never opens it; "…" → Media info has the same),
passive (it never takes a click), and any press on the card closes it. Long text never goes in a
native `title` tooltip.
- **Home's hero is a card** (the owner's pick A): the same hover row and Play on its picture, the same
whole menu on right-click, long-press and a touch "…" in its button row. In a wide window it stands
three quarters wide and centred, with even padding; its text is one centred stack — kicker, title,
subtitle (dropped when it is only the year), one fact line, a summary only when there is one.
- **Home's shelf tiles act like their object.** A pinned channel or playlist: Play when it has a next
video, ↗ to open it, "…" with an Open row. A show carries its next episode: the Play, the watched
toggle and add act on that episode ("Next: S6·E16" is its meta line), and an ⓘ opens the show. A
channel's Home menu is play, open and the shelf's own rows; its tags, priority, hide and unsubscribe
stay on the Channels page.
- **The compact list row (the owner's pick B).** The title leads; a faint "…" is always at the row's end;
on hover or focus the quick toggles float in beside it over the date, which steps aside (the Gmail
row). On touch only the "…".
- **The media-info page (layout B).** Under the hero a main column — the collection strips (the same
card), then the crew, then the cast — beside a 20rem sidebar of facts (details, keywords). Every
section is the reader's to hide; a column with nothing in it is not drawn and the other takes the
width. A show's page uses the same meta row: a value that filters is a chip, a plain fact is flat
text, spaced by the gap, no "·".
- **Browse tiles** (genres, playlists, collections) answer the pointer as a card does: the edge takes
the accent, the shadow lifts and the art zooms.
- **A missing picture says what the item is.** No picture at all (a link that failed before its
details, a poster that never came) shows a quiet glyph — a film, a note, a file — never an empty box,
which reads as "still loading". A download with no name yet is titled by its short link, once: no
channel line, no second link line.
- **Reading measure.** Prose (a synopsis, a biography, a channel's description) is capped at ~65
characters a line (the `measure` utility) wherever its container can be wider. [WCAG 1.4.8]
## Interaction rules — _Decided_
- **Keyboard:** everything works by keyboard, in visual order, with no traps. A shortcut is a row in
`lib/keymap.ts`; list and grid navigation is `NavGrid`/`keyboardNav`. [WCAG 2.1.1, 2.4.3, 2.1.2]
- **Focus:** every control has a visible focus ring (the base ring in `index.css`; don't add a second). A
sticky or fixed layer never covers the focused element. A list item the keyboard lands on rings 2 px
outside itself where it has a gap round it (a card); a table row, a log line or a row flush in a
clipping card rings inside itself, following its corners, since outside the ring ran under the table
head or was clipped away. [WCAG 2.4.7, 2.4.11]
- **Menus and tabs follow WAI-ARIA APG:** arrow keys move, Home/End jump, Escape closes and returns focus;
tabs carry `tablist`/`tab`/`tabpanel` roles.
- **Feedback:** a finished action is confirmed briefly (a toast that leaves by itself); anything that must
be read or acted on is not only in a toast. Status changes are announced through a live region.
[Nielsen #1, WCAG 2.2.1, 4.1.3]
- **Errors:** a failed write goes through `meta.failTitle` / `notifyActionError`, never a silent `catch`.
An error sits next to what failed and says how to recover; a field error is under its field and linked
with `aria-describedby`. Colour is never the only signal. [Nielsen #9, WCAG 3.3.1, 1.4.1]
- **Loading:** anything slower than ~300ms shows a loading state; a near-instant load doesn't flash one.
Space is reserved for late content so the layout doesn't jump; the form is under Surfaces (B8).
- **Reordering:** anything you can drag also has a button or menu way to move it (Top / Up / Down / Bottom),
and a drag handle carries `touch-action: none` so a finger drags instead of scrolling. [WCAG 2.5.7]
A list whose rows have a "…" (a YouTube playlist, a Plex playlist: a block among the blocks, an
episode within its show) puts the four moves there (`ui/useOrderItems`); the saved views' editor
shows them as four buttons with the item's place beside the selected view; a side panel in edit mode
gives each group an Up / Down pair (`ui/OrderButtons`, `compact`). A move that would go nowhere is
shown off, not hidden. A grip also picks up from the keyboard (space, the arrows, space).
- **Motion:** 150–300ms micro-interactions, transform/opacity only; `prefers-reduced-motion` is respected.
[WCAG 2.3.3]
## Players — _Decided_ (C3, C5, built in S3f)
- **A tap on the Plex video only shows or hides the controls** (C3); play/pause is the button. The mouse keeps
click-to-pause. Measured live: before this, a tap with the bar hidden both revealed it and paused. [Nielsen #3]
- **The hide timer starts with playback,** not with the first touch (before, on touch, the bar stayed up until
the first tap however long the video played).
- **The bars stay up while the pointer rests on them** (F22, owner); leaving starts a fresh 3 s. YouTube and
Plex Web do the same: before, the row faded out from under a mouse parked on Next.
- **Volume is the native `Slider`** (A6); the hand-drawn bar had no hover, focus, keyboard or screen-reader
state. A pointer drag gives the focus back, so Space and the arrows keep driving the player.
- **The seek bar:** the track stays thin inside a 2.75rem (44px) hit area with `touch-action: none`, so a finger
drag scrubs instead of panning; the scrub head is always visible on touch. Controls are 44 on touch. [WCAG 2.5.8]
- **The Plex control set (C5, the owner's model):** one row that fits a portrait tablet.
- No Stop (closing the player is stopping), no Restart, no ±10 s buttons.
- **Back:** more than 3 s in → back to the start; within the first 3 s → the previous item, at its resume
point if it has one, else its start. The first item has only the restart.
- **Next** shows only when there is a next item (an episode, a playlist queue). It starts that item at its
resume point and leaves one behind on the current item, which does not count as watched unless it is
already inside the finish margin (the credits).
- **±10 s:** the keyboard keeps ← / →; on touch a double-tap on the left or right third of the picture skips
∓10 s, while a single tap still only shows the controls. Touch never loses a function. [WCAG 2.5.1]
- The row: play/pause, Back, Next, mute, the time, audio & subtitles, a fixed **⋯** (player settings, media
info, stats, keyboard shortcuts, download the original file), fullscreen. The volume slider is
mouse only: a tablet's volume is its buttons.
- **Restarting lives on the media card** (C5): not started → Play; started → **Continue** (primary) +
**Restart**, the video card's own pair and words (`MediaResumeButtons`, one component for every card and
Home's hero). A poster stacks the pair; a landscape picture sets it side by side, icon-only on a small one.
On touch a tap continues and the card's "…" has "Play from the beginning" under its Play row. The restart
applies to the item the player opened on (`fromStart`); every item it moves on to resumes.
- **The YouTube modal:** its chrome uses the `Button` rungs (44 on touch), the side arrows stay inside the
screen, and the description opens from an ⓘ button, because a hover popover never opens on a tap (measured
live). [WCAG 1.4.13]
## Dialogs — _Decided_ (C4, built in S3f)
- **Initial focus by purpose** (`Modal`'s required `initialFocus`, so no dialog inherits "the first
focusable", which is always the ✕): `field` — a form opens on the body's first control, and one that
loads after opening moves the focus there when it arrives, as long as the focus still rests on the ✕;
`action` — a confirm, where the least destructive button takes `autoFocus`; `close` — anything to read,
browse or manage. [WAI-ARIA APG]
- **The header and the footer are fixed; only the body scrolls,** with the B9 fade at each edge, so the
primary action stays on screen when the on-screen keyboard takes half of it (the viewport's
`interactive-widget=resizes-content` makes the keyboard shrink the layout, where Chrome would otherwise
slide it over a fixed dialog). A fade shows only while content is under it (the body's scroll timeline):
a dialog that fits draws none. [Nielsen #3, WCAG 2.4.11]
- **The footer is the dialog's commit row** — Cancel / Save, Start, Retry / Close, a confirm's choices —
placed with `ModalFooter` from any depth of the body. A section's own buttons (a sub-form inside a
manager, a list row's Delete) stay in the body; a manager with no commit row has no footer.
## Copy — _Decided_ (S3g)
- Every user-facing string is in both Hungarian and English with zero key drift, and a user-facing error is
a structured `{code, reason}` translated on the client. Hungarian speaks to the user as "te".
- **One name per feature.** The nav rail's label is the feature's name; page titles, menus, shortcuts,
settings tabs and onboarding use the same words. The rail may shorten them ("Config" for
"Configuration", "Listák" for "Lejátszási listák"), never swap in another word. [Nielsen #4]
- **One verb per meaning.** Delete / Törlés: gone for good. Remove / Eltávolítás: out of a list, the
thing itself survives. Dismiss / Elrejtés: a notice leaves the view. Discard / Elvetés: unsaved edits
are thrown away. A confirm uses its action's verb. [Nielsen #4]
- **Sentence case.** Capitals only for brand names (YouTube, Plex, Google) and the app's places used as
places ("Hide from Feed", "Megnézés a Könyvtárban"). [Material, HIG]
- **"…" when the command asks first.** A label ends in "…" when it opens a dialog, form or picker before
anything happens ("Edit tags…"); not on a submenu (its chevron says it) or an immediate action. Progress
text ("Loading…") keeps its own. [HIG, Windows]
- **Messages.** A toast or error of one sentence has no full stop and no "✓": "Couldn't sync
subscriptions" / "Nem sikerült szinkronizálni". Two sentences keep their stops. Help text is prose.
- **Quotes** around a name are curly: “abc” in English, „abc” in Hungarian.
- **Dates**, in the UI language, only from `lib/format.ts` (lint: `local/date-format`): recency in a list
is relative ("3d ago" / "3 napja") through `ui/RelativeTime`, which carries the exact minute on hover;
a date is "Sep 27, 2026" / "2026. szept. 27."; a moment "Sep 27, 2026, 08:30 AM" /
"2026. szept. 27. 08:30"; seconds only in the server log, the audit and the recorder.
- **Units.** A figure with a unit (a length, a countdown, a range of days) is the browser's short form
(`Intl`), through `formatUnit` / `formatRuntime`: "90 min", "2 hr 13 min" / "90 p", "2 ó 13 p"; a fixed
label holding one is written in that same form ("Under 90 min"). Never a bare letter ("90m", "24h",
"7d"; test-held). In a sentence, and in a list of time spans named in words ("24 hours", "1 week"), the
unit is a word. A clock-style length on a picture stays "2:34:11".
- A label says what happens ("Delete playlist"), not "OK"; a destructive confirm names the object.
## Anti-patterns
- A flat `bg-card` / `bg-surface` box where a glass surface belongs.
- A hand-rolled control that restyles what a primitive already does.
- Information or actions that exist only on hover, or only behind a right-click.
- A click handler on text that swallows the user's text selection.
- A colour literal, a px font size, a px-frozen box.
- An empty state shown for an error.
## Decision log
| Date | Decision | Chosen | Over | Why |
| ---------- | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 2026-09-24 | Floating button | Back to top: a round 48 glass FAB; "round" = floats over something (media or the page) | A Material 3 rounded square; a labelled pill; the old 64 circle | The common web convention for back to top; round tells it apart from every toolbar button; 64 was oversized |
| 2026-09-24 | Device contract | Tablet portrait + landscape and up; phones out | A 1024px floor with a "too small" message | Portrait tablet use is real, and the app already renders at 820 portrait |
| 2026-09-24 | Card actions on touch | Fewer visible, the rest in a "…" menu | 44px buttons that no longer fit the card | Six 44px targets need more width than a card has |
| 2026-09-24 | Action placement | One menu with several triggers, never gesture-only, a desktop background menu that mirrors the toolbar | Visible buttons for everything | Fewer non-critical buttons on screen, without hiding any function |
| 2026-09-24 | Text selection | Text stays selectable; a click that ends a selection does nothing | Click-to-play on titles | The user must be able to copy any text the app shows |
| 2026-09-24 | A1 Button shape | r8 + a rung per context; glass is a Button variant | r12; a pill | Nested corners (r8 in r12 in r16); the pill already means "a filter or a choice" |
| 2026-09-24 | A2 Icon buttons | Two sizes, r8; a round button only on media | One size; all round | A lone toolbar icon at the row size looks lost; round rows read as tags |
| 2026-09-24 | A3 Touch size | 44 for every control primitive | 36 + gaps; Button only | One rule (HIG 44) instead of two sizes nobody remembers |
| 2026-09-24 | A4 Input | Bordered, a 55% fg-into-bg edge (≥ 3:1) | Filled; today's look as a primitive | The only option that clears WCAG 1.4.11 without a heavy fill on glass |
| 2026-09-24 | A5 Dropdown trigger | Pill in a toolbar, r8 in a form | Pill everywhere; r8 everywhere | Matches the neighbours in each context |
| 2026-09-24 | A6 Checkbox, switch, slider | Native, accent-tinted, the row is the target | Drawn primitives | Free keyboard / screen-reader / forced-colours support; only 3 sites |
| 2026-09-24 | B1 Density | Compact 32 / 28 / 24 by explicit height | Standard 38 / 32 / 26 | The owner's read of the lab; touch is covered separately (A3), and one toolbar row lines up at 28 |
| 2026-09-24 | B2 Scheme set | Midnight, Forest→cyan, Slate→fuchsia, Matrix→lime | Today's six | The accent collided with danger (YouTube), warn (Slate) and success (Forest, Matrix); Midnight and Starship were one scheme twice; 4 of 6 light accents failed 4.5:1 |
| 2026-09-24 | B3 State layers | Text colour at 10% / 16% over anything | Material's 8 / 12%; today's opacity + bg-card | Visible in every scheme and mode (≥ 1.2:1); today's row hover was 1.00:1 on a card |
| 2026-09-24 | B4 Page chrome | One sticky band + fade; filters fold when short | A floating glass bar; everything scrolls | Tabs and filters always in reach, in one place; a keyboard-up tablet keeps its content |
| 2026-09-24 | B5 Surfaces | One class + radius per role | Today's per-site mix | Nested corners and no per-site alpha drift |
| 2026-09-24 | B6 /watch | The app's surfaces, native video controls | Flat and minimal | The page strangers see should look like the product; native controls work on any device |
| 2026-09-24 | B7 Nav rail | Soft tint + edge bar, optional labels (a user setting), slim | Solid accent (recommended); a Material rail | The owner's combination: calmer than a solid fill, labels for who wants them, no width cost |
| 2026-09-24 | B8 Loading | Skeleton by shape, spinner in a control | A spinner everywhere | Nothing jumps when data lands; a long wait doesn't look stuck |
| 2026-09-24 | B9 Fade | Toolbars / headers, panel tabs, dialog edges, chip rows | Thumbnails; buttons | The north-star depth where content scrolls under chrome; not on content or small controls |
| 2026-09-24 | C1 Action classification | The table (lab C1): state + primary at rest, everyday toggles on hover, "…" on touch, destructive into the menu | Visible buttons per site, as today | One action list per object, nothing gesture-only, fewer buttons without losing a function |
| 2026-09-24 | C2 Side panels | A tap opens an overlay drawer; pin is a separate button ≥ 1024 | A tap pins and the content reflows | Below 768 the filters were unreachable; on touch a tap pinned and pushed the grid |
| 2026-09-24 | C3 Players on touch | Tap = show the controls; 44px seek hit area; 44 buttons; the YT description behind ⓘ | Today (tap also pauses; 7.5px seek bar) | Measured live: you couldn't reach the seek bar without pausing, and the hover-only description never opened on a tap |
| 2026-09-24 | C4 Dialogs | Focus by purpose; a pinned footer | Focus on Close; the footer scrolls | A form starts in its first field, and Save stays on screen with the keyboard up |
| 2026-09-24 | C5 Plex player controls | The owner's lean set: no Stop / Restart / ±10 s buttons, Back by position, Next only with a next item, Resume + From the start on the card, touch double-tap ±10 s | Every control in one row (it ran off-screen below 1024); Back by a press counter; ±10 s keyboard-only | The row fits a portrait tablet; the decision to restart is made on the card; a tablet keeps the 10 s skip |
| 2026-09-24 | Glass tuner | Retired; the schemes are the only colour choice | Keep it with ranges clamped to the rules | Free tuning is the drift the constitution exists to stop (owner) |
| 2026-09-24 | Matrix toned down | Moss #86a05a at Midnight's lightness, near-neutral greys; every white-on-accent part → `--accent-fg` | The B2 lime #a3e635 | The owner found the lime neon and tiring; it was the loudest accent (L 0.85, C 0.21), and fixed white parts sat on it at 1.5:1 |
| 2026-09-24 | Field primitives | `Input` / `Textarea` / `InnerInput` + `fieldClass`, `SelectTrigger`, the native controls; the lint rule "no raw `<input>`" | `inputCls` and ~40 hand-styled boxes | Six edges, four radii and two focus looks for one control; the ≥ 3:1 edge held nowhere |
| 2026-09-25 | Surface roles | `SURFACE` roles in `ui/surfaces.ts`; the lint rule "no bare glass class"; menus p-1 with r8 rows | Three tiers paired ad hoc with five radii and three menu paddings | ~140 sites drifted; nested corners only hold if the radius comes with the role |
| 2026-09-25 | Static labels | Flat tonal `Badge`, no edge; a filtering tag is an edged chip | A glass pill with an edge, like the chips | Edged = pressable at a glance; a static tag that looks like a chip gets clicked and does nothing |
| 2026-09-25 | Header search | A 40 glass pill with the field edge (`lg`); what sits inside it is round | 44, r16, a glass box with a faint edge | The one rounded box among pills, and its edge under 3:1; the hero search is the YouTube/Gmail pill |
| 2026-09-25 | Switch row (F12) | The whole settings row toggles (`SwitchRow`; a hand-laid row points a label at the switch) | Only the 36×24 switch toggles | The iOS / Material settings row; the text was a dead click beside the control it names |
| 2026-09-25 | Edges and hovers (F14) | The ≥ 3:1 edge on the field family (fields, the form trigger); labelled pills keep the faint edge and the accent-edge hover | The ≥ 3:1 edge and a state-layer hover on every control | 1.4.11 asks an edge only where nothing else identifies the control; a heavy edge on every chip and tab weighs the whole UI down |
| 2026-09-25 | Rating and live (F16) | Own tokens: `--rating` (theme-scoped gold), `--live`/`--live-fg`, `--rating-media`, `--media-chrome`; palette classes lint-refused | Map them to `--warn` / `--danger` | A gold star and a red LIVE are what the eye expects; as warn/danger they would read as a status. The old amber score was 2:1 on light |
| 2026-09-25 | Fjord trial (B2 exception) | A fifth scheme, temporary: Midnight's neutrals, Playnite's steel accent (#337599 lifted to #5aa1cb) and steel rules; exit = fold into Midnight or remove | Retune Midnight straight away; borrow only the qualities | The owner wants to live with the calmer palette app-wide before it replaces the default; it breaks "own hue family" on purpose, and only until that call |
| 2026-09-25 | Whole-card row (owner) | A card row's B3 layer is the card's `::before` under its content; the open button's hit layer spans the card, the actions sit above it | The name button's own hover, ending at the first badge or button | A row that half-answers the pointer reads as broken; a badge click did nothing |
| 2026-09-25 | Badge tones (F29) | `Badge tone` neutral / accent / warn at 6%; light `--warn` #b45309 → #a14a07; edged label spans lint-refused | Keep edged status pills (a private Badge in Users, Settings "Granted", the suggestions count) | Edged = pressable at a glance; amber-700 was 4.58:1 as text and fell under 4.5 on its own tint |
| 2026-09-26 | Page band built (B4) | Controls only in the band (intros scroll); the fold shows the same row in place; one `h1` per page (`PageHeading` steps the hidden one aside); `SectionLabel` headings; Skip to content | A popover of the folded filters; the page title kept visible | A folded row is still the same controls, nothing duplicated; the rail and the tab title already name the page |
| 2026-09-26 | Fade built (B9) | `fade-below` on sticky toolbars and table headers; the scroller mask where the band sits on the backdrop | A painted band over the backdrop | The backdrop is the colour under the band, so fading the content into it is the same edge |
| 2026-09-26 | Viewport units | `dvh` everywhere (the Plex player stage with S3f) | `vh` / `h-screen` | `100vh` hides the last rows under a tablet browser's toolbar |
| 2026-09-26 | Video actions built (C1) | One list per video (menu, right-click, long-press, "…"); the card keeps watched / save / add to playlist on its picture on hover; download, details, copy, hide, reset in the menu | The six-button row under the card and the hover ⓘ | The menu is the one door every device has; the card loses a line of height and the touch card its row of 28px buttons |
| 2026-09-26 | Plex actions built (C1) | One list per Plex title (play/open, watched, add to playlist, media info, copy title, pin); the poster keeps watched / add / "…" on its picture on hover; seasons get open / watched / add / copy | The hover ⓘ, the pin-only menu, an episode's add button at rest, a collection's "Remove" X on the card | The same door as a video's; the collection grid goes to two columns on a phone so the touch "…" leaves the title its line |
| 2026-09-26 | Playlist actions built (C1) | A playlist row's "…" (play, pin, copy title / link, move, order, remove), Delete + an undo toast; one list per playlist (rename, pin, YouTube open / push / revert, delete) from its rail row and its header's "…" | The row's X column and its confirm; the header's pencil, YouTube link, push, revert and trash buttons | Remove is reversible, so it needs no confirm; the header keeps Play all; a narrow table drops "#" and Channel so the title keeps a width |
| 2026-09-26 | Channel + Plex list actions (C1) | A channel row's "…" (open, pin, YouTube, copy, full history, hide + undo, unsubscribe); a Plex playlist's title / show / season "…", Delete + undo; its header's "…" (rename, delete); an action toast lasts ≥ 8 s | The channel row's full-history, hide and unsubscribe buttons; the Plex list's red X on every row, show and season; the header's pencil and trash | Hiding and removing are reversible, so they need no confirm and the undo needs time; unsubscribe and deleting a playlist keep theirs |
| 2026-09-26 | Download / notification / Scheduler actions (C1) | A download row's "…" (play, share, save, edit, details, re-download, manage sharing, copies, cancel / delete / remove); a library row's Share stays in sight, ahead of save; a notification row's "…" and hover dismiss; the inbox band's and the Scheduler strip's "…" | The download row's five rare buttons and its cancel / delete / remove; the source link's copy button; an activity notice's Find / Undo links; the band's Clear everything; the strip's Purge discovery | The destructive rows keep their confirm, and Clear everything gains one; a row link keeps the browser's own menu; Find in feed drops the tag / search / age filters that could hide its video |
| 2026-09-26 | Channel menu: Tags + Priority (C1) | Tags A–Z as in-place checks; Priority as fixed steps 10 · 5 · 3 · 2 · 1 · 0, the current one ticked; a pick re-sorts at once and the page + focus follow the row; one drawn `MenuCheck` for every checkable row that cannot be an `<input>` | Relative steps (+1 / +5); straight to the top; the stepper only; seven hand-drawn check marks (three box sizes, a tinted box, a × for "on", a trailing tick) | Bubbling a channel up is one pick; fixed levels keep the scale the reader already uses; the list moving where the reader can see it is the feedback |
| 2026-09-26 | Reorder without a drag (WCAG 2.5.7) | Row menus: Top / Up / Down / Bottom (YouTube + Plex playlists); saved views: four buttons with the place beside the selected view (owner: A); side panels in edit mode: an Up / Down pair per group (owner: A), the name wrapping there | A "…" on every saved-view row; a "…" on every panel group; drag only | A visible, one-click path where the list is edited; the edge moves shown off so the buttons never shift |
| 2026-09-26 | Titles in click surfaces | The surface stays a button, flagged `selectable`: its text selects, and the click that ends a selection does nothing; every plain `Pressable` pinned with its reason | Rebuilding each card and row so the title sits outside the button; exceptions for master-detail lists | Chrome selects inside a button once it is `user-select: text` (measured), so one flag on the primitive keeps the keyboard, focus and list walking every surface already has |
| 2026-09-26 | Right-click on a selection | The browser's own menu opens on selected words; beside them the object's menu | A "Copy selection" row in the app menu | The reader selected the words to do something with them; the native menu copies, searches and translates, and changes nothing else |
| 2026-09-26 | Keyboard ring on a flush row | Inside the row (a table row by its tag, a log line, a show header in its clipping card, with the card's corners) | The ring outside, as on a card | Outside it ran under the table head and past the table, and a clipping card cut it away entirely (invisible focus) |
| 2026-09-26 | Fade at rest | The sticky header's fade appears with the first 1rem of scroll (a scroll-driven animation; no fade where unsupported) | A fade that is always drawn | At rest it dimmed the first row and hid its ring's top edge; nothing is under the header yet |
| 2026-09-26 | Narrow playlist table | "#" and Channel hide by the table's own width (under 42rem), not the window's | Hiding below the `sm` window width | Beside the playlist rail a desktop window left the table 432 px and the title 0 px |
| 2026-09-26 | Background menu built | Five list pages (feed, Plex grid, channels, playlist, notifications); the toolbar's sort / view / filters and the band's actions; no destructive row | Every page with a toolbar; the "…" menus copied whole, delete included | The tab-only pages have nothing a menu would add; a blank-area right-click must not be one click from delete |
| 2026-09-26 | Media card anatomy (C) | One frame; the meta line pinned to its bottom; the length a badge on the picture; the Plex card on the same primitive as the video card | A: the year as an overline over the title; B: the year inline after it; a fixed two-line title slot | A one-line title left a hole the eye reads as a missing line; C puts nothing under a variable-height title |
| 2026-09-26 | One Play | `MediaPlayGlyph` (the media button variant) and only where the click plays | The Plex card's hand-painted accent circle; a playable dot on every card | One Play for every card; a dot that only a colour and a tooltip explain says nothing |
| 2026-09-26 | Filtering values on the info page | A value that filters (year, certificate, studio, director) is an edged chip; a fact is flat text | Everything flat, the clickable ones only hover-tinted | The S3d rule: edged = pressable, so the reader sees which values do something |
| 2026-09-26 | Shelf density | On any strip: one title line over one meta line | The grid's two lines and full meta on a strip | The tallest card set the strip's height and left holes in the cards with less to say |
| 2026-09-26 | Compact list row (B) | The title leads; a faint "…" at the end; the toggles float over the date on hover | A leading 128px action column | That column sat empty at rest, a hole at the start of every row |
| 2026-09-26 | Media-info layout (B) | A main column (collections first, crew, cast) beside a facts sidebar; an empty column is not drawn | Every section full width, stacked | The collection is the most used; the keywords' column left a void beside a short details box |
| 2026-09-26 | Mixed Home shelf (A) | Plex titles as wide art beside videos | Posters, with the videos not stretched | Stretching a video card to a poster's height left an ~80px gap |
| 2026-09-27 | Home hero | A card (hover row + Play on the picture, the whole menu, a touch "…"; owner: A); three quarters wide and centred on a wide window; one fact line; the summary at the measure | The hero's pin row alone; full width; an 18rem picture column | The one answer stands in focus, and it offers what the same card offers anywhere |
| 2026-09-27 | Home shelf tiles | A channel / playlist tile: Play, ↗ open, "…" with Open; a show acts on its next episode, ⓘ opens it; the shelf slot 232px | The tile's pin row alone; 200px | A tile without its object's actions read as broken; at 200 the Play touched the hover row |
| 2026-09-27 | Synopsis preview (C) | A ~0.9 s mouse rest opens a passive panel beside the card | The synopsis as the picture's `title` tooltip; an ⓘ to hover; none | One long tooltip line fired anywhere on the art and clashed with the cut title's own tooltip |
| 2026-09-27 | Reading measure | Prose capped at ~65 characters a line (`measure`) | The container's full width | At 110–130 characters a line the eye loses the next line's start |
| 2026-09-24 | Chip family | One `Chip` (filter / token / tag / suggest / ghost) + `DismissibleChip`; nothing under 24; the lint rule "no raw `<button>` outside ui/" | Five chip geometries in five files | The applied-filter row, the tag cells, the year picker and the saved-view editor each drew their own chip; a one-letter tag was 21px wide |
| 2026-09-24 | Button families | `Button` / `IconButton` / `Pressable` / chips / `DragHandle`; `lg` 40 for a hero CTA; pills and a 48 Play on media (from C5) | Every button hand-painted | 268 hand-rolled buttons in 79 files drifted into as many looks; the ladder only holds if every button stands on it |
| 2026-09-27 | C4 as built | `initialFocus` required on every `Modal`; the commit row in `ModalFooter`, portalled under the body from any depth; the fades driven by the body's scroll timeline; `interactive-widget=resizes-content` | An optional prop defaulting to the ✕; a `footer` prop (the issue composer's buttons live three components down); always-on fades | A default is how 21 dialogs came to open on the ✕; lifting the composer's state to reach a prop is the larger change; a resting fade dimmed the first line |
| 2026-09-27 | C3 + C5 as built | A tap toggles the bar through `pointerType` from `pointerdown`; a double-tap takes back its first tap's toggle; the card's pair is the video card's `MediaResumeButtons` (Continue / Restart), a sibling over the picture; the YT description is an ⓘ click popover | A delayed single tap (wait out the double-tap window); new "Resume / From the start" words for Plex; the pair inside the picture's button | A delayed tap feels dead; one media card says one pair of words; a button inside a button is invalid and the hover row already sits beside the picture |
| 2026-09-27 | C2 as built | The side panel's drawer is `ui/Modal` `drawer-start`; drawer = a coarse pointer or below 768; the pin offered in its header ≥ 1024 | A drawer of its own inside `SidePanel` | Esc, Back, the scrim, the trap and the scroll lock are the dialog's; a second implementation is how two shells drift |
| 2026-09-27 | Narrow rows by container | The download row, the playlist table and the Channels tag row stack by a container query; a fact line never wraps | Breakpoints on the window | The iPad zoomed in, split screen and a column beside a rail are narrow while the window is not; the zoomed download row broke "18.9 / MB" apart |
| 2026-09-27 | Phones deferred | A phone-only layout (the hamburger nav, a folded feed band) waits for its own epic; E28 stays desktop + tablet | Fix the phone views inside E28 | Principle 2; the first phone attempts made the feed worse, and the epic's review grows with every sprint |
| 2026-09-27 | Conformance check (S3g) | A four-step check before a UI change is done: primitive at a named rung, the chapter re-read, the rendered page measured by kind and look, every state seen | A reviewer's eye per sprint | The S1 probe found 69 button looks; after S3 every control kind is drawn by its primitive, and the check keeps a new look from arriving unnoticed |
| 2026-09-27 | Segmented rung | `SegmentedToggle` defaults to the toolbar rung (28, `text-xs`); 24 only for a panel's captioned match toggle | The small default | Two toolbars had inherited 10px type from the default beside 28px buttons |
| 2026-09-27 | Copy rule (S3g) | One name per feature (the rail may shorten it); four verbs for four meanings; sentence case with the app's places capitalised; "…" when a command asks first; one-sentence messages without a stop; curly quotes; dates relative in lists with the exact minute on hover, three absolute forms; units from Intl, short | Word-for-word names everywhere; the verbs as they were; all lower case; no "…"; always a stop; always absolute dates; long or compact units | The copy survey found 7 EN and 6 HU double names, 22 Delete against 27 Remove, five date shapes and three spellings of a minute; the owner picked every recommended option |
| 2026-09-27 | One glyph, one meaning (S4) | Play only for playback or resuming a paused transfer; Retry = `RefreshCw`; reset / revert = `RotateCcw` | Retry drawn with Play | The first UI-review run (Downloads) found a failed row whose Retry showed ▷ in the row and its menu |
| 2026-09-27 | Fjord kept | Fjord stays a fifth scheme beside Midnight; the trial wording goes | Fold it into Midnight; remove it | The owner lived with it and likes it; the vivid Midnight stays for those who want it. B2's own-hue rule still binds any new scheme |
| 2026-09-27 | Tablet columns | A table hides `tabletHidden` columns below 64rem of its own width; Channels drops Prio, About, Length | Hide About only; give the name a bigger share | At 1024 the channel name had 60 px; the owner chose to drop all three there |
| 2026-09-27 | Glyph pager | First / previous / next / last as glyph buttons round the page box, names as tooltips | Words beside the arrows (hidden only on a phone) | The owner's call: the words widened the row; four arrows are the shared convention |
| 2026-09-27 | Match switch on the panel | `ui/MatchToggle` above the picker from two picks, everywhere; one wording (Any / All) | The picker's popover footer (Feed, Plex collection) | A closed picker hid an active "all"; six pickers had drawn it two ways |
| 2026-09-27 | Missing picture, bare link | A glyph of the kind for no picture; a download with no name is titled by its short link, once | An empty box; the URL as title, channel and link | The owner's picks 4A and 3A: an empty box read as loading, the URL showed three times |
| 2026-09-27 | Copy on a selection | Right-click on selected words keeps the browser's menu; no app Copy row | An app menu with Copy first | A real browser's menu has copy, search and translate; only the desktop pane's is thin |
+4
View File
@@ -32,6 +32,10 @@ For a fast frontend edit loop, run the Vite dev server with HMR against that API
cd frontend && npm install && npm run dev # http://localhost:5173, proxying the API on :8080
```
The dev server also serves the **design lab** at `http://localhost:5173/design-lab`: the open design
decisions and the chosen patterns, rendered with the app's real CSS. It never reaches a production build
(see [`DESIGN.md`](DESIGN.md)).
## The gate
A change is done when all of this is green. CI runs the same checks server-side
+29
View File
@@ -0,0 +1,29 @@
import type { Plugin } from "vite";
// The design lab (`src/design-lab/`) is a dev-only page: main.tsx reaches it behind
// `import.meta.env.DEV`, which Vite folds to `false` in a production build, so the dynamic import
// is dead code and the chunk is never emitted. That rule used to be a sentence; this makes it a
// build failure. If a future edit imports the lab unguarded (a static import, a shared helper, a
// re-export), the production build refuses instead of shipping the lab to every visitor.
const DEV_ONLY_DIR = "/src/design-lab/";
export function devOnlyGuard(): Plugin {
return {
name: "siftlode-dev-only-guard",
apply: "build",
generateBundle(_options, bundle) {
for (const [fileName, output] of Object.entries(bundle)) {
if (output.type !== "chunk") continue;
const leaked = Object.keys(output.modules).find((id) =>
id.replaceAll("\\", "/").includes(DEV_ONLY_DIR),
);
if (leaked) {
this.error(
`dev-only module ${leaked} reached the production bundle (${fileName}). ` +
"Import src/design-lab only behind `import.meta.env.DEV`.",
);
}
}
},
};
}
+306 -3
View File
@@ -69,7 +69,7 @@ const focusSafeDisabled = {
meta: {
type: "problem",
messages: {
raw: "A raw <button disabled> throws a focused user to <body> when it turns disabled. Use ui/FocusSafeButton (or ui/Button).",
raw: "A raw <button disabled> throws a focused user to <body> when it turns disabled. Build the primitive on ui/FocusSafeButton; anywhere else use ui/Button, IconButton or Pressable.",
},
},
create(context) {
@@ -82,6 +82,199 @@ const focusSafeDisabled = {
};
},
};
// E28 S3g (docs/DESIGN.md "Copy"): the app shows a date in three forms plus the relative one, all
// built in lib/format.ts in the UI language. A bare `toLocaleString()` formats in the BROWSER's locale
// and as numbers with seconds; seven call sites had drifted into five shapes. Outside lib/format.ts
// a `toLocale*String` call or an `Intl.DateTimeFormat` / `Intl.RelativeTimeFormat` is refused (a
// number goes through formatNumber, which the same rule covers).
const dateFormat = {
meta: {
type: "problem",
messages: {
date: "Format dates and numbers through lib/format.ts (formatDate, formatDateTime, formatDateTimeSec, relativeTime, formatNumber), in the UI language.",
},
},
create(context) {
const path = context.filename.split("\\").join("/");
// lib/format.ts is where the forms live; a test computes its expectations; the Plex player's
// on-screen clock (lib/plexPlayerPrefs.ts) is a clock face with its own 12/24h switch, not a date.
if (/\/src\/lib\/(format|plexPlayerPrefs)\.ts$|\.test\.tsx?$/.test(path)) return {};
return {
"CallExpression > MemberExpression.callee[property.name=/^toLocale(Date|Time)?String$/]"(
node,
) {
context.report({ node, messageId: "date" });
},
"MemberExpression[object.name='Intl'][property.name=/^(DateTimeFormat|RelativeTimeFormat)$/]"(
node,
) {
context.report({ node, messageId: "date" });
},
};
},
};
// E28 S3b: one primitive per button family (docs/DESIGN.md). 268 hand-rolled buttons in 79 files had
// drifted into as many looks, heights and hover styles; they were swept onto ui/Button, IconButton,
// Pressable, Chip and DragHandle, and this keeps the next one out. The raw element lives only in the
// files that ARE a control primitive — not in the rest of components/ui/, whose composites (a pager, a
// side panel, a toast) are built from the primitives like any feature component.
const BUTTON_PRIMITIVES = new Set([
"FocusSafeButton.tsx", // the focus-keeping <button> everything stands on
"Button.tsx",
"IconButton.tsx",
"Pressable.tsx",
"Chip.tsx",
"DragHandle.tsx",
"AnchoredPopover.tsx", // the menu row: floating-ui's roving focus reads its native `disabled`
"SegmentedToggle.tsx",
"Tabs.tsx",
"form.tsx", // Switch
"PriorityStepper.tsx",
"SortControl.tsx",
"SelectTrigger.tsx", // the dropdown trigger (A5)
"FilterSelect.tsx", // its option rows
"Combobox.tsx", // the field's chevron (A4)
]);
const buttonPrimitive = {
meta: {
type: "problem",
messages: {
raw: "A raw <{{name}}> outside the control primitives: use ui/Button (a labelled action), IconButton (a glyph), Pressable (a card or row that is the control), Chip or DragHandle.",
role: 'A <{{name}} role="button"> is a hand-built button (its own Enter/Space, no focus-safe disabled, no touch target): use ui/Pressable for a surface that is the control.',
},
},
create(context) {
const path = context.filename.split("\\").join("/");
const file = path.slice(path.lastIndexOf("/") + 1);
if (path.includes("/src/components/ui/") && BUTTON_PRIMITIVES.has(file)) return {};
return {
JSXOpeningElement(node) {
if (node.name.type !== "JSXIdentifier") return;
const name = node.name.name;
if (name === "button" || name === "FocusSafeButton")
context.report({ node, messageId: "raw", data: { name } });
// S3g: the same button spelled as a div with a role slipped past the rule above (my own
// S3h Plex picture). A component (capitalised) may take a role for its own reasons.
else if (
/^[a-z]/.test(name) &&
node.attributes.some(
(a) =>
a.type === "JSXAttribute" &&
a.name.name === "role" &&
a.value?.type === "Literal" &&
a.value.value === "button",
)
)
context.report({ node, messageId: "role", data: { name } });
},
};
},
};
// The same for a text field and the native checkbox / slider / colour well (docs/DESIGN.md A4, A6;
// E28 S3c): ~100 hand-styled boxes carried six edges, four radii and two focus looks between them. A
// file picker has no look (it is hidden behind a Button), so it passes.
const INPUT_PRIMITIVES = new Set([
"Input.tsx",
"NativeControls.tsx",
"PriorityStepper.tsx", // the number box laid over the stepper's own face
]);
const inputPrimitive = {
meta: {
type: "problem",
messages: {
raw: "A raw <{{name}}> outside the field primitives: use ui/Input, Textarea or InnerInput (inside a fieldClass frame), or Checkbox / CheckRow / Slider / ColorInput.",
},
},
create(context) {
const path = context.filename.split("\\").join("/");
const file = path.slice(path.lastIndexOf("/") + 1);
if (path.includes("/src/components/ui/") && INPUT_PRIMITIVES.has(file)) return {};
return {
JSXOpeningElement(node) {
if (node.name.type !== "JSXIdentifier") return;
const name = node.name.name;
if (name !== "input" && name !== "textarea") return;
const type = node.attributes.find(
(a) => a.type === "JSXAttribute" && a.name.name === "type",
);
const v = type?.value?.type === "Literal" ? type.value.value : null;
if (name === "input" && (v === "file" || v === "hidden")) return;
context.report({ node, messageId: "raw", data: { name } });
},
};
},
};
// B5 (docs/DESIGN.md; E28 S3d): a glass surface is a ROLE from ui/surfaces.ts, which brings its radius.
// ~140 sites had paired the three glass tiers with five radii and three menu paddings. A class string
// (two or more tokens, so `variant="glass"` passes) holding a bare glass tier is refused outside the
// role table and the control primitives that paint a glass BUTTON face.
const SURFACE_OWNERS = new Set([
"surfaces.ts",
"buttonStyles.ts", // the `glass` button face
"SelectTrigger.tsx",
"fieldStyles.ts", // the header search floats over the page
]);
const GLASS_TIER = /^glass(-card|-menu)?$/;
// A glass surface's depth is its own box-shadow, whose inset top highlight a `shadow-*` utility would
// replace wholesale (the header search and the debug pill lost theirs). A shadow COLOUR only sets a
// variable, and `shadow-float` IS the surface's shadow, so both pass.
const SHADOW_SIZE = /^shadow-(xs|sm|md|lg|xl|2xl|\[.+\]|\(.+\))$/;
const isShadowSize = (w) => SHADOW_SIZE.test(w.slice(w.lastIndexOf(":") + 1));
const isSurfaceRole = (n) =>
n?.type === "MemberExpression" && n.object.type === "Identifier" && n.object.name === "SURFACE";
const surfaceRole = {
meta: {
type: "problem",
messages: {
raw: "A bare `{{tier}}` class: use a role from ui/surfaces.ts (SURFACE.panel / drawer / card / menu / popover / tooltip), which brings the radius too.",
shadow:
"`{{shadow}}` on a glass surface replaces its own shadow and drops the top highlight; the role's depth is the intended one.",
},
},
create(context) {
const path = context.filename.split("\\").join("/");
const file = path.slice(path.lastIndexOf("/") + 1);
if (path.includes("/src/components/ui/") && SURFACE_OWNERS.has(file)) return {};
if (path.includes("/src/lib/")) return {}; // no classes live there, only prose ("a glass tuner")
const check = (node, text) => {
if (typeof text !== "string") return;
const words = text.split(/\s+/).filter(Boolean);
// A lone word is a value (`variant="glass"`); a class string has company, or is a template
// fragment that a space joins to its neighbour.
if (words.length < 2 && text === text.trim()) return;
const tier = words.find((w) => GLASS_TIER.test(w));
if (tier) context.report({ node, messageId: "raw", data: { tier } });
};
// A role plus a shadow size in one class list: `${SURFACE.x} shadow-lg` or clsx(SURFACE.x, "shadow-lg").
const shadowBeside = (node, parts) => {
if (!parts.some(isSurfaceRole)) return;
for (const part of parts) {
const text =
part.type === "TemplateElement"
? part.value.cooked
: part.type === "Literal" && typeof part.value === "string"
? part.value
: "";
const shadow = text.split(/\s+/).find(isShadowSize);
if (shadow) context.report({ node, messageId: "shadow", data: { shadow } });
}
};
return {
Literal(node) {
check(node, node.value);
},
TemplateElement(node) {
check(node, node.value.cooked);
},
TemplateLiteral(node) {
shadowBeside(node, [...node.quasis, ...node.expressions]);
},
CallExpression(node) {
shadowBeside(node, node.arguments);
},
};
},
};
// The words a screen reader or a tooltip says are user-facing text like any other, and the HU/EN
// parity rule covers them too. Five `aria-label="remove"`-style literals survived every lane (the
// Plex playlist rows, a filter chip, the player's reset) and a whole opt-in panel was English only —
@@ -152,11 +345,46 @@ const sortableA11y = {
};
},
};
// An <a> whose content is only a glyph is a link that looks like a button (DESIGN.md): `IconLink`, with
// a name, a 24+ box and the icon button's layer. The E28 audit found five bare 14px ones. Text or an
// expression anywhere in its CHILDREN (not its attributes: every link has an `href={…}`) is a text
// link or a card, and passes; a self-closing <a> is a `Trans` slot that i18n fills with text.
const glyphLink = {
meta: {
type: "problem",
messages: {
glyph:
"a glyph-only <a> is a link that looks like a button: use ui/LinkButton's IconLink (a name, a 24+ box, the state layer).",
},
},
create(context) {
const hasText = (children) =>
children.some(
(c) =>
(c.type === "JSXText" && /\S/.test(c.value)) ||
(c.type === "JSXExpressionContainer" && c.expression.type !== "JSXEmptyExpression") ||
((c.type === "JSXElement" || c.type === "JSXFragment") && hasText(c.children)),
);
return {
JSXElement(node) {
const open = node.openingElement;
if (open.name.type !== "JSXIdentifier" || open.name.name !== "a" || open.selfClosing)
return;
if (!hasText(node.children)) context.report({ node: open, messageId: "glyph" });
},
};
},
};
const localRules = {
rules: {
"glyph-link": glyphLink,
"sortable-a11y": sortableA11y,
"jsx-comment-text": jsxCommentText,
"focus-safe-disabled": focusSafeDisabled,
"button-primitive": buttonPrimitive,
"date-format": dateFormat,
"input-primitive": inputPrimitive,
"surface-role": surfaceRole,
"literal-a11y-text": literalA11yText,
},
};
@@ -167,6 +395,43 @@ const BOX_PX =
"/(^|[\\s:])(min-|max-)?(w|h|size|left|right|top|bottom|inset|gap|p[xytrbl]?|m[xytrbl]?)-\\[[\\d.]+px\\]/";
const BOX_PX_MESSAGE =
"px-frozen box size doesn't scale with --font-scale: use the spacing scale (w-37.5, max-w-60), not w-[Npx].";
// A stacking order outside the layer scale (DESIGN.md Tokens: `base < menu < chrome < … < tooltip` in
// index.css). A numeric `z-10` equals a level today but doesn't follow it when the scale moves; name it.
const Z_RAW = "/(^|[\\s:])-?z-([1-9]\\d*|\\[[^\\]]+\\])(?![\\w-])/";
const Z_RAW_MESSAGE =
"z-index off the layer scale: use a named level (z-under, z-base, z-menu, z-chrome, z-panel, z-rail, z-overlay, z-popover, z-tooltip), not z-N or z-[…].";
// A hover that swaps a colour instead of laying the B3 state layer (DESIGN.md Tokens): `bg-card`,
// `bg-border` and `bg-surface` vanish on a light card, an opacity dim reads as "disabled", and
// `bg-(--hover)` restates the `state-layer` utility by hand. `group-hover:` (a reveal on a parent's
// hover, C1) and an edged pill's accent hover (F14) are not matched.
const HOVER_SWAP =
"/(^|[\\s:])hover:(bg-(card|border|surface|muted)(?![\\w-])|bg-\\(--(hover|press)\\)|opacity-)/";
const HOVER_SWAP_MESSAGE =
"hover by a colour swap: use the B3 layer (`state-layer`, Pressable `layer`, `state-filled` on a fill), not hover:bg-card / bg-border / opacity-N.";
// A palette colour (DESIGN.md Tokens "only semantic tokens", audit F16): nothing measures `text-amber-500`,
// so a gold that reads on dark ships at 2:1 on light. Black and white are not matched: they are the
// media scrims and the text on them, the same in every theme.
const PALETTE =
"/(^|[\\s:])(text|bg|border|fill|stroke|from|via|to|ring|outline|decoration|divide|caret|shadow)-(red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose|slate|gray|zinc|neutral|stone)-\\d/";
const PALETTE_MESSAGE =
"a palette colour: use a semantic token (text-rating, bg-live, bg-media-chrome, text-danger, …; DESIGN.md Tokens), not text-amber-500.";
// A static label with an edge (DESIGN.md "Static vs pressable", audit F29): a <span> drawn round with a
// border reads as a chip, is clicked and does nothing. A label that does nothing is ui/Badge (flat).
// A small `rounded` box (a check mark drawn inside a menu row) is a glyph, not a label: only the label
// radii match.
const EDGED_LABEL = "/^(?=.*(^|\\s)rounded-(full|md|lg|xl|2xl)(\\s|$))(?=.*(^|\\s)border(\\s|$))/";
const EDGED_LABEL_MESSAGE =
"a static label with an edge reads as a pressable chip: use ui/Badge (flat, tonal) for a label that does nothing.";
// A class name split by an interpolation: the text right after `${…}` starts with a variant (`hover:`),
// or the interpolation IS the variant and the text goes on with its class (`${NARROW}:w-24`, E28 S3f3).
// A clock (`${m}:00`) and a URL (`${u}://`) are not classes.
const SPLIT_CLASS = "/^([a-z][\\w-]*:[\\w\\[!-]|:[a-z\\[!])/";
const SPLIT_CLASS_MESSAGE =
"a class split by an interpolation (`${x}hover:…`): Tailwind never sees it whole and emits no CSS; write the class literally (a shared guard is the `act:` variant).";
export default tseslint.config(
{ ignores: ["dist", "node_modules", "playwright-report", "test-results", "e2e/.auth"] },
@@ -211,7 +476,12 @@ export default tseslint.config(
"react-hooks/refs": "off",
"local/jsx-comment-text": "error",
"local/focus-safe-disabled": "error",
"local/button-primitive": "error",
"local/date-format": "error",
"local/input-primitive": "error",
"local/surface-role": "error",
"local/literal-a11y-text": "error",
"local/glyph-link": "error",
"local/sortable-a11y": "error",
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
// tsc already enforces these under strict + noUnusedLocals/Parameters — let it own them so a
@@ -243,12 +513,19 @@ export default tseslint.config(
{
selector: "Literal[value=/(^|\\s)enabled:hover:/]",
message:
"`enabled:hover:` misses a focused FocusSafeButton (aria-disabled): use `enabled:not-aria-disabled:hover:`.",
"`enabled:hover:` misses a focused FocusSafeButton (aria-disabled): use `act:hover:` (index.css).",
},
{
selector: "TemplateElement[value.raw=/(^|\\s)enabled:hover:/]",
message:
"`enabled:hover:` misses a focused FocusSafeButton (aria-disabled): use `enabled:not-aria-disabled:hover:`.",
"`enabled:hover:` misses a focused FocusSafeButton (aria-disabled): use `act:hover:` (index.css).",
},
// Tailwind emits CSS only for a class it finds WHOLE in the source. A variant glued onto an
// interpolated prefix (`${ACT}hover:text-fg`) is never found, so it ships with no rule: every
// hover colour of the button and chip primitives was dead that way (E28 audit).
{
selector: `TemplateLiteral > TemplateElement:not(:first-child)[value.raw=${SPLIT_CLASS}]`,
message: SPLIT_CLASS_MESSAGE,
},
// The canonical "FE control" row (CLAUDE.md): a short fixed set is a SegmentedToggle, a
// longer one or a user's own list a FilterSelect. Six native selects had drifted in; the
@@ -277,6 +554,20 @@ export default tseslint.config(
// 150px at the default size) — 32 of these were swept in the 2026-09 debt burn.
{ selector: `Literal[value=${BOX_PX}]`, message: BOX_PX_MESSAGE },
{ selector: `TemplateElement[value.raw=${BOX_PX}]`, message: BOX_PX_MESSAGE },
{ selector: `Literal[value=${Z_RAW}]`, message: Z_RAW_MESSAGE },
{ selector: `TemplateElement[value.raw=${Z_RAW}]`, message: Z_RAW_MESSAGE },
{ selector: `Literal[value=${HOVER_SWAP}]`, message: HOVER_SWAP_MESSAGE },
{ selector: `TemplateElement[value.raw=${HOVER_SWAP}]`, message: HOVER_SWAP_MESSAGE },
{ selector: `Literal[value=${PALETTE}]`, message: PALETTE_MESSAGE },
{ selector: `TemplateElement[value.raw=${PALETTE}]`, message: PALETTE_MESSAGE },
{
selector: `JSXOpeningElement[name.name='span'] JSXAttribute[name.name='className'] Literal[value=${EDGED_LABEL}]`,
message: EDGED_LABEL_MESSAGE,
},
{
selector: `JSXOpeningElement[name.name='span'] JSXAttribute[name.name='className'] TemplateElement[value.raw=${EDGED_LABEL}]`,
message: EDGED_LABEL_MESSAGE,
},
],
},
},
@@ -357,6 +648,13 @@ export default tseslint.config(
files: ["src/components/ui/FocusSafeButton.tsx", "src/components/ui/AnchoredPopover.tsx"],
rules: { "local/focus-safe-disabled": "off" },
},
{
// The design lab (src/design-lab, E28) is a dev-only page that never reaches the production
// bundle (devOnlyGuard fails the build if it does). Its copy is the developer's and the product
// owner's working text, not the product's, so the translate-every-label rule does not apply.
files: ["src/design-lab/**/*.{ts,tsx}"],
rules: { "local/literal-a11y-text": "off" },
},
{
// Tests stand doubles in for the network (`vi.stubGlobal("fetch", …)`) and may reach it directly.
// …and render throwaway markup whose labels no user ever hears.
@@ -365,6 +663,11 @@ export default tseslint.config(
"no-restricted-globals": "off",
"no-restricted-properties": "off",
"local/literal-a11y-text": "off",
// A test renders a raw button as a stand-in trigger or a focus target, not as product UI.
"local/button-primitive": "off",
"local/input-primitive": "off",
"local/surface-role": "off",
"local/glyph-link": "off",
},
},
prettier,
+7 -1
View File
@@ -2,7 +2,13 @@
<html lang="en" data-theme="dark" data-scheme="midnight">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- resizes-content: the on-screen keyboard shrinks the layout viewport, not only the visual one,
so a dialog's pinned footer (DESIGN C4) sits above the keyboard instead of under it. Chrome's
default since 108 only shrinks the visual viewport; Safari ignores the key. -->
<meta
name="viewport"
content="width=device-width, initial-scale=1, interactive-widget=resizes-content"
/>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<!-- OG:START — the server replaces this block for /watch/{token} so shared links unfurl with
the video's title/channel/thumbnail; every other route keeps these generic defaults. -->
@@ -1,18 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
<defs>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#7a1620" stop-opacity=".9"/><stop offset="100%" stop-color="#7a1620" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#ff3b46" stop-opacity=".4"/><stop offset="100%" stop-color="#ff3b46" stop-opacity="0"/></radialGradient>
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#3a0d24" stop-opacity=".9"/><stop offset="100%" stop-color="#3a0d24" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#2a1010" stop-opacity=".85"/><stop offset="100%" stop-color="#2a1010" stop-opacity="0"/></radialGradient>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#1d4a66" stop-opacity=".95"/><stop offset="100%" stop-color="#1d4a66" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#337599" stop-opacity=".5"/><stop offset="100%" stop-color="#337599" stop-opacity="0"/></radialGradient>
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#1f2d57" stop-opacity=".9"/><stop offset="100%" stop-color="#1f2d57" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#173c52" stop-opacity=".85"/><stop offset="100%" stop-color="#173c52" stop-opacity="0"/></radialGradient>
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .5 0"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter>
</defs>
<rect width="1600" height="1000" fill="#0d0708"/>
<rect width="1600" height="1000" fill="#080d1a"/>
<g filter="url(#s)">
<rect width="1600" height="1000" fill="url(#a)"/>
<rect width="1600" height="1000" fill="url(#c)"/>
<rect width="1600" height="1000" fill="url(#d)"/>
<rect width="1600" height="1000" fill="url(#b)"/>
</g>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.05"/>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.35" style="mix-blend-mode:overlay"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

+4 -4
View File
@@ -1,11 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
<defs>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#0f5245" stop-opacity=".95"/><stop offset="100%" stop-color="#0f5245" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#2dd4bf" stop-opacity=".5"/><stop offset="100%" stop-color="#2dd4bf" stop-opacity="0"/></radialGradient>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#0e4a5a" stop-opacity=".95"/><stop offset="100%" stop-color="#0e4a5a" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#22d3ee" stop-opacity=".5"/><stop offset="100%" stop-color="#22d3ee" stop-opacity="0"/></radialGradient>
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#134a2c" stop-opacity=".9"/><stop offset="100%" stop-color="#134a2c" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#0d3a52" stop-opacity=".85"/><stop offset="100%" stop-color="#0d3a52" stop-opacity="0"/></radialGradient>
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .5 0"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter>
</defs>
<rect width="1600" height="1000" fill="#06120e"/>
<g filter="url(#s)">
@@ -14,5 +14,5 @@
<rect width="1600" height="1000" fill="url(#d)"/>
<rect width="1600" height="1000" fill="url(#b)"/>
</g>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.05"/>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.35" style="mix-blend-mode:overlay"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -1,18 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
<defs>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#f0a3aa" stop-opacity=".85"/><stop offset="100%" stop-color="#f0a3aa" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#ff5b64" stop-opacity=".4"/><stop offset="100%" stop-color="#ff5b64" stop-opacity="0"/></radialGradient>
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#e096a8" stop-opacity=".8"/><stop offset="100%" stop-color="#e096a8" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#eec2c2" stop-opacity=".8"/><stop offset="100%" stop-color="#eec2c2" stop-opacity="0"/></radialGradient>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#a8c6dc" stop-opacity=".9"/><stop offset="100%" stop-color="#a8c6dc" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#5aa1cb" stop-opacity=".5"/><stop offset="100%" stop-color="#5aa1cb" stop-opacity="0"/></radialGradient>
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#b9c6e0" stop-opacity=".85"/><stop offset="100%" stop-color="#b9c6e0" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#a9d0dc" stop-opacity=".8"/><stop offset="100%" stop-color="#a9d0dc" stop-opacity="0"/></radialGradient>
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .35 0"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter>
</defs>
<rect width="1600" height="1000" fill="#faeef0"/>
<rect width="1600" height="1000" fill="#eaf0f6"/>
<g filter="url(#s)">
<rect width="1600" height="1000" fill="url(#a)"/>
<rect width="1600" height="1000" fill="url(#c)"/>
<rect width="1600" height="1000" fill="url(#d)"/>
<rect width="1600" height="1000" fill="url(#b)"/>
</g>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.04"/>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.35" style="mix-blend-mode:overlay"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

+4 -4
View File
@@ -1,11 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
<defs>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#7fd0bf" stop-opacity=".9"/><stop offset="100%" stop-color="#7fd0bf" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#2dd4bf" stop-opacity=".5"/><stop offset="100%" stop-color="#2dd4bf" stop-opacity="0"/></radialGradient>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#8fd3e0" stop-opacity=".9"/><stop offset="100%" stop-color="#8fd3e0" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#22d3ee" stop-opacity=".5"/><stop offset="100%" stop-color="#22d3ee" stop-opacity="0"/></radialGradient>
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#8fc9a5" stop-opacity=".85"/><stop offset="100%" stop-color="#8fc9a5" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#88c2d6" stop-opacity=".8"/><stop offset="100%" stop-color="#88c2d6" stop-opacity="0"/></radialGradient>
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .35 0"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter>
</defs>
<rect width="1600" height="1000" fill="#e9f5f0"/>
<g filter="url(#s)">
@@ -14,5 +14,5 @@
<rect width="1600" height="1000" fill="url(#d)"/>
<rect width="1600" height="1000" fill="url(#b)"/>
</g>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.04"/>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.35" style="mix-blend-mode:overlay"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

+7 -7
View File
@@ -1,18 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
<defs>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#8fd8ae" stop-opacity=".9"/><stop offset="100%" stop-color="#8fd8ae" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#46b87c" stop-opacity=".45"/><stop offset="100%" stop-color="#46b87c" stop-opacity="0"/></radialGradient>
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#7fc9c9" stop-opacity=".85"/><stop offset="100%" stop-color="#7fc9c9" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#96d69c" stop-opacity=".8"/><stop offset="100%" stop-color="#96d69c" stop-opacity="0"/></radialGradient>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#d9e2c4" stop-opacity=".9"/><stop offset="100%" stop-color="#d9e2c4" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#86a05a" stop-opacity=".35"/><stop offset="100%" stop-color="#86a05a" stop-opacity="0"/></radialGradient>
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#cdd6bf" stop-opacity=".85"/><stop offset="100%" stop-color="#cdd6bf" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#c3cfb3" stop-opacity=".8"/><stop offset="100%" stop-color="#c3cfb3" stop-opacity="0"/></radialGradient>
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .35 0"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter>
</defs>
<rect width="1600" height="1000" fill="#eaf5ec"/>
<rect width="1600" height="1000" fill="#eef0e8"/>
<g filter="url(#s)">
<rect width="1600" height="1000" fill="url(#a)"/>
<rect width="1600" height="1000" fill="url(#c)"/>
<rect width="1600" height="1000" fill="url(#d)"/>
<rect width="1600" height="1000" fill="url(#b)"/>
</g>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.04"/>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.35" style="mix-blend-mode:overlay"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

+2 -2
View File
@@ -5,7 +5,7 @@
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#b3a8ec" stop-opacity=".85"/><stop offset="100%" stop-color="#b3a8ec" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#9fd6e0" stop-opacity=".8"/><stop offset="100%" stop-color="#9fd6e0" stop-opacity="0"/></radialGradient>
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .35 0"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter>
</defs>
<rect width="1600" height="1000" fill="#edf1fc"/>
<g filter="url(#s)">
@@ -14,5 +14,5 @@
<rect width="1600" height="1000" fill="url(#d)"/>
<rect width="1600" height="1000" fill="url(#b)"/>
</g>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.04"/>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.35" style="mix-blend-mode:overlay"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

+7 -7
View File
@@ -1,18 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
<defs>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#e6c393" stop-opacity=".9"/><stop offset="100%" stop-color="#e6c393" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#e8913c" stop-opacity=".45"/><stop offset="100%" stop-color="#e8913c" stop-opacity="0"/></radialGradient>
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#dba98c" stop-opacity=".85"/><stop offset="100%" stop-color="#dba98c" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#cabf9e" stop-opacity=".8"/><stop offset="100%" stop-color="#cabf9e" stop-opacity="0"/></radialGradient>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#e0c2ea" stop-opacity=".9"/><stop offset="100%" stop-color="#e0c2ea" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#e879f9" stop-opacity=".45"/><stop offset="100%" stop-color="#e879f9" stop-opacity="0"/></radialGradient>
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#d6c0dc" stop-opacity=".85"/><stop offset="100%" stop-color="#d6c0dc" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#c9ccd6" stop-opacity=".8"/><stop offset="100%" stop-color="#c9ccd6" stop-opacity="0"/></radialGradient>
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .35 0"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter>
</defs>
<rect width="1600" height="1000" fill="#f4efe6"/>
<rect width="1600" height="1000" fill="#f3f1f5"/>
<g filter="url(#s)">
<rect width="1600" height="1000" fill="url(#a)"/>
<rect width="1600" height="1000" fill="url(#c)"/>
<rect width="1600" height="1000" fill="url(#d)"/>
<rect width="1600" height="1000" fill="url(#b)"/>
</g>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.04"/>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.35" style="mix-blend-mode:overlay"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -1,18 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
<defs>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#8fb6e8" stop-opacity=".9"/><stop offset="100%" stop-color="#8fb6e8" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#38b6ff" stop-opacity=".45"/><stop offset="100%" stop-color="#38b6ff" stop-opacity="0"/></radialGradient>
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#94a0e0" stop-opacity=".85"/><stop offset="100%" stop-color="#94a0e0" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#8fc3d2" stop-opacity=".8"/><stop offset="100%" stop-color="#8fc3d2" stop-opacity="0"/></radialGradient>
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .35 0"/></filter>
</defs>
<rect width="1600" height="1000" fill="#e9f1fc"/>
<g filter="url(#s)">
<rect width="1600" height="1000" fill="url(#a)"/>
<rect width="1600" height="1000" fill="url(#c)"/>
<rect width="1600" height="1000" fill="url(#d)"/>
<rect width="1600" height="1000" fill="url(#b)"/>
</g>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.04"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

+7 -7
View File
@@ -1,18 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
<defs>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#0f4a30" stop-opacity=".95"/><stop offset="100%" stop-color="#0f4a30" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#46b87c" stop-opacity=".45"/><stop offset="100%" stop-color="#46b87c" stop-opacity="0"/></radialGradient>
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#0c3a3a" stop-opacity=".9"/><stop offset="100%" stop-color="#0c3a3a" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#164a26" stop-opacity=".85"/><stop offset="100%" stop-color="#164a26" stop-opacity="0"/></radialGradient>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#34431f" stop-opacity=".95"/><stop offset="100%" stop-color="#34431f" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#86a05a" stop-opacity=".35"/><stop offset="100%" stop-color="#86a05a" stop-opacity="0"/></radialGradient>
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#1d2a18" stop-opacity=".9"/><stop offset="100%" stop-color="#1d2a18" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#22301c" stop-opacity=".85"/><stop offset="100%" stop-color="#22301c" stop-opacity="0"/></radialGradient>
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .5 0"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter>
</defs>
<rect width="1600" height="1000" fill="#020805"/>
<rect width="1600" height="1000" fill="#0a0c09"/>
<g filter="url(#s)">
<rect width="1600" height="1000" fill="url(#a)"/>
<rect width="1600" height="1000" fill="url(#c)"/>
<rect width="1600" height="1000" fill="url(#d)"/>
<rect width="1600" height="1000" fill="url(#b)"/>
</g>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.05"/>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.35" style="mix-blend-mode:overlay"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

+2 -2
View File
@@ -5,7 +5,7 @@
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#3b2f80" stop-opacity=".9"/><stop offset="100%" stop-color="#3b2f80" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#145e6b" stop-opacity=".85"/><stop offset="100%" stop-color="#145e6b" stop-opacity="0"/></radialGradient>
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .5 0"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter>
</defs>
<rect width="1600" height="1000" fill="#070b16"/>
<g filter="url(#s)">
@@ -14,5 +14,5 @@
<rect width="1600" height="1000" fill="url(#d)"/>
<rect width="1600" height="1000" fill="url(#b)"/>
</g>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.05"/>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.35" style="mix-blend-mode:overlay"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

+7 -7
View File
@@ -1,18 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
<defs>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#7a4d18" stop-opacity=".95"/><stop offset="100%" stop-color="#7a4d18" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#e8913c" stop-opacity=".45"/><stop offset="100%" stop-color="#e8913c" stop-opacity="0"/></radialGradient>
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#5a2f14" stop-opacity=".9"/><stop offset="100%" stop-color="#5a2f14" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#403820" stop-opacity=".85"/><stop offset="100%" stop-color="#403820" stop-opacity="0"/></radialGradient>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#5a2a6a" stop-opacity=".95"/><stop offset="100%" stop-color="#5a2a6a" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#e879f9" stop-opacity=".45"/><stop offset="100%" stop-color="#e879f9" stop-opacity="0"/></radialGradient>
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#3a2050" stop-opacity=".9"/><stop offset="100%" stop-color="#3a2050" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#2c3040" stop-opacity=".85"/><stop offset="100%" stop-color="#2c3040" stop-opacity="0"/></radialGradient>
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .5 0"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter>
</defs>
<rect width="1600" height="1000" fill="#141009"/>
<rect width="1600" height="1000" fill="#111217"/>
<g filter="url(#s)">
<rect width="1600" height="1000" fill="url(#a)"/>
<rect width="1600" height="1000" fill="url(#c)"/>
<rect width="1600" height="1000" fill="url(#d)"/>
<rect width="1600" height="1000" fill="url(#b)"/>
</g>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.05"/>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.35" style="mix-blend-mode:overlay"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

-18
View File
@@ -1,18 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
<defs>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#123b6b" stop-opacity=".95"/><stop offset="100%" stop-color="#123b6b" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#38b6ff" stop-opacity=".45"/><stop offset="100%" stop-color="#38b6ff" stop-opacity="0"/></radialGradient>
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#1f2a72" stop-opacity=".9"/><stop offset="100%" stop-color="#1f2a72" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#0f5566" stop-opacity=".85"/><stop offset="100%" stop-color="#0f5566" stop-opacity="0"/></radialGradient>
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .5 0"/></filter>
</defs>
<rect width="1600" height="1000" fill="#040914"/>
<g filter="url(#s)">
<rect width="1600" height="1000" fill="url(#a)"/>
<rect width="1600" height="1000" fill="url(#c)"/>
<rect width="1600" height="1000" fill="url(#d)"/>
<rect width="1600" height="1000" fill="url(#b)"/>
</g>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.05"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

+8
View File
@@ -18,9 +18,17 @@ import reactCompiler from "babel-plugin-react-compiler";
// reasons, both about the gate: a string is invisible to knip, which then reports the dependency as
// unused, and — worse — a typo in that string is a silent no-op that ships an uncompiled bundle,
// while a bad import is a resolve error on the tsc lane.
//
// The price of the import: plugin-react finds the compiler only by that string, so it doesn't know the
// compiler is there. Its one compiler-specific switch is Babel's `retainLines`, which it turns on for
// dev (and so for vitest) unless the compiler is present. With the compiler's re-printed JSX that eats
// the space at the end of a text line ("for example <code>" rendered "for example/api/feed"). The
// production build never had it on. `generatorOpts` wins over the top-level option, so it is switched
// off here, for every mode. Pinned by src/jsxWhitespace.dom.test.tsx.
export const reactWithCompiler = () =>
react({
babel: {
plugins: [[reactCompiler, { target: "19" }]],
generatorOpts: { retainLines: false },
},
});
+7 -6
View File
@@ -32,7 +32,8 @@ import Header from "./components/Header";
import NavSidebar from "./components/NavSidebar";
import BackToTop from "./components/ui/BackToTop";
import PageShell from "./components/ui/PageShell";
import GlassTuner from "./components/GlassTuner";
import { SkipLink } from "./components/ui/SkipLink";
import { pageTitleKey } from "./lib/pageMeta";
import ChatDock from "./components/ChatDock";
import DebugRecorder from "./components/DebugRecorder";
import Toaster from "./components/ui/Toaster";
@@ -45,8 +46,8 @@ import DemoBanner from "./components/DemoBanner";
import VerifyBanner from "./components/VerifyBanner";
import { focusAccessRequestsTab } from "./lib/adminUsersTab";
// The built app version, NOT imported from releaseNotes — importing anything from that module
// pulls its whole ~900-line changelog into the eager App chunk and defeats the lazy ReleaseNotes
// split. FRONTEND_VERSION equals RELEASE_NOTES[0].version by release convention (both bump together).
// pulls the release-notes loader into the eager App chunk and defeats the lazy ReleaseNotes
// split. FRONTEND_VERSION equals the newest release note's version by release convention (both bump together).
import { FRONTEND_VERSION } from "./lib/version";
import { applyDiagnostics } from "./lib/diagnostics";
@@ -368,7 +369,8 @@ export default function App() {
const railDef = channelView ? undefined : PAGE_RAIL[activePage];
return (
<div className="h-screen flex text-fg">
<div className="h-dvh flex text-fg">
<SkipLink label={t("nav.skipToContent")} />
<NavSidebar
me={me!}
onOpenAbout={() => setAboutOpen(true)}
@@ -429,6 +431,7 @@ export default function App() {
</PageShell>
) : (
<PageShell
title={t(pageTitleKey(activePage))}
scrollKey={normalScrollKey}
maxWidth={maxWidth}
header={<Header me={me!} onYtSearch={enterYtSearch} />}
@@ -475,8 +478,6 @@ export default function App() {
<ErrorDialog />
{/* Re-binds to the active page's scroll container on navigation (page or channel change). */}
<BackToTop dep={channelView ? `chan:${channelView.id}` : page} />
{/* Opt-in live appearance tuner (Settings → Appearance; off by default). */}
<GlassTuner />
</div>
);
}
+1
View File
@@ -45,6 +45,7 @@ export default function About({
return (
<Modal
initialFocus="close"
title={
<span className="flex items-center gap-2">
<Info className="w-5 h-5 text-accent" /> {t("about.title")}
+2 -1
View File
@@ -5,6 +5,7 @@ import { api } from "../lib/api";
import ChannelAboutContent from "./ChannelAbout";
import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { SURFACE } from "./ui/surfaces";
/**
* A channel-description cell: one truncated line in the table, revealing the FULL channel "About"
@@ -56,7 +57,7 @@ export default function AboutCell({ text, channelId }: { text: string | null; ch
</span>
<PopoverSurface
popover={pop}
className="glass w-120 overflow-y-auto px-3.5 py-3 rounded-lg animate-[fadeIn_0.12s_ease]"
className={`${SURFACE.popover} w-120 overflow-y-auto px-3.5 py-3 animate-[fadeIn_0.12s_ease]`}
// min(design cap, available space): keeps the 28rem cap on a tall viewport, shrinks to fit on a
// short one (high zoom). --fit-max-h is set by the fitHeight `size` middleware once positioned.
style={{ maxHeight: "min(28rem, var(--fit-max-h, 28rem))" }}
+43 -27
View File
@@ -13,7 +13,9 @@ import { copyText } from "../lib/clipboard";
import { formatDateTime } from "../lib/format";
import { invalidateUserAccess } from "../lib/adminUsers";
import type { AdminUserAccess, AdminUserRow } from "../lib/api/admin";
import { FocusSafeButton } from "./ui/FocusSafeButton";
import { Button } from "./ui/Button";
import { SURFACE } from "./ui/surfaces";
import { SectionLabel } from "./ui/Headings";
// The admin's answer to "why can this person not get in?", and the place every recovery action for
// one account lives (E12 · S5, design option C).
@@ -103,8 +105,14 @@ export default function AccountPanel({
const a: AdminUserAccess | undefined = q.data;
return (
<Modal title={displayName(user)} onClose={onClose} placement="drawer" maxWidth="max-w-md">
<div className="text-2xs text-muted truncate -mt-2 mb-4">{user.email}</div>
<Modal
initialFocus="close"
title={displayName(user)}
onClose={onClose}
placement="drawer"
maxWidth="max-w-md"
>
<div className="text-2xs text-muted truncate mb-4">{user.email}</div>
{q.isError && !a ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
@@ -114,10 +122,10 @@ export default function AccountPanel({
<LoadingState />
) : (
<>
<div className="text-2xs uppercase tracking-wider text-muted mb-2">
<SectionLabel level={3} className="mb-2">
{t("users.account.access")}
</div>
<div className="glass-card rounded-xl p-3 space-y-2 mb-5">
</SectionLabel>
<div className={`${SURFACE.card} p-3 space-y-2 mb-5`}>
<AccessRow
icon={Mail}
label={t("users.account.email")}
@@ -191,29 +199,33 @@ export default function AccountPanel({
)}
</div>
<div className="text-2xs uppercase tracking-wider text-muted mb-2">
<SectionLabel level={3} className="mb-2">
{t("users.account.recovery")}
</div>
</SectionLabel>
<div className="space-y-2">
{a.can_activate && (
<FocusSafeButton
<Button
variant="glass"
fullWidth
icon={<Check />}
loading={activate.isPending}
onClick={() => activate.mutate()}
disabled={activate.isPending}
className="w-full glass-card glass-hover flex items-center gap-2 px-3 py-2.5 rounded-lg text-xs border border-accent/40 text-accent disabled:opacity-50 transition"
className="border border-accent/40 text-accent"
>
<Check className="w-4 h-4 shrink-0" />
<span className="flex-1 text-left">{t("users.account.activate")}</span>
</FocusSafeButton>
</Button>
)}
<FocusSafeButton
<Button
variant="glass"
fullWidth
icon={<Send />}
loading={resend.isPending}
onClick={() => resend.mutate()}
// The demo account's address is never confirmed, and the server refuses to mail it — as
// it refuses it a set-password link below (deep-review E11 round 3).
disabled={user.is_demo || !a.email_configured || a.email_verified || resend.isPending}
className="w-full glass-card glass-hover flex items-center gap-2 px-3 py-2.5 rounded-lg text-xs disabled:opacity-45 transition"
disabled={user.is_demo || !a.email_configured || a.email_verified}
>
<Send className="w-4 h-4 shrink-0" />
<span className="flex-1 text-left">{t("users.account.resend")}</span>
{/* Says WHY it is unavailable instead of just greying out — the no-mail case is the
whole reason the link below exists, so naming it here is the explanation. */}
@@ -223,18 +235,21 @@ export default function AccountPanel({
{a.email_configured && a.email_verified && (
<span className="text-muted">{t("users.account.alreadyConfirmed")}</span>
)}
</FocusSafeButton>
</Button>
<FocusSafeButton
<Button
variant="glass"
fullWidth
icon={<KeyRound />}
loading={issue.isPending}
disabled={user.is_demo}
onClick={() => issue.mutate()}
disabled={issue.isPending || user.is_demo}
className="w-full glass-card glass-hover flex items-center gap-2 px-3 py-2.5 rounded-lg text-xs border border-accent/40 text-accent disabled:opacity-50 transition"
className="border border-accent/40 text-accent"
>
<KeyRound className="w-4 h-4 shrink-0" />
<span className="flex-1 text-left">
{link ? t("users.account.setPasswordAgain") : t("users.account.setPassword")}
</span>
</FocusSafeButton>
</Button>
</div>
{link && (
@@ -243,13 +258,14 @@ export default function AccountPanel({
<div className="flex-1 min-w-0 bg-surface border border-border rounded-lg px-3 py-2 text-2xs font-mono truncate">
{link.url}
</div>
<button
<Button
variant="glass"
icon={copied ? <Check /> : <Copy />}
onClick={copy}
className="shrink-0 glass-card glass-hover inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs border border-accent/40 text-accent transition"
className="border border-accent/40 text-accent"
>
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
{copied ? t("users.account.copied") : t("users.account.copy")}
</button>
</Button>
</div>
<p className="text-2xs text-muted leading-relaxed">{t("users.account.linkNote")}</p>
</div>
+27 -28
View File
@@ -6,6 +6,10 @@ import { decideChipLayout } from "../lib/chipOverflow";
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { Button } from "./ui/Button";
import { Chip } from "./ui/Chip";
import { chipClass } from "./ui/chipStyles";
import { SURFACE } from "./ui/surfaces";
// The applied-filter row above the feed: every narrowing currently in force, each removable on its
// own. It exists because the filter panel now sits collapsed as a tab — without this you'd have to
@@ -22,23 +26,19 @@ import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
// the "fit some + N more" treatment used on wider lanes.
const NARROW_PX = 420;
const chipCls =
"chip group inline-flex items-center gap-1 pl-2.5 pr-1.5 py-1 text-sm whitespace-nowrap hover:border-accent hover:bg-accent/20 transition";
function ChipButton({ f, testId }: { f: ActiveFilter; testId?: string }) {
const { t } = useTranslation();
return (
<button
<Chip
variant="token"
size="md"
data-testid={testId}
data-filter-key={f.key}
onClick={f.remove}
title={t("feed.chips.remove", { name: f.label })}
aria-label={t("feed.chips.remove", { name: f.label })}
className={chipCls}
removeLabel={t("feed.chips.remove", { name: f.label })}
>
<span className="max-w-56 truncate">{f.label}</span>
<X className="w-3 h-3 shrink-0 opacity-60 group-hover:opacity-100" />
</button>
</Chip>
);
}
@@ -69,22 +69,20 @@ function OverflowPopover({
const name = ariaLabel ?? label;
return (
<>
<button
type="button"
<Chip
variant="token"
size="md"
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
aria-label={name}
className={`chip inline-flex shrink-0 items-center gap-1 px-2.5 py-1 text-sm whitespace-nowrap transition ${
open ? "border-accent text-accent" : "hover:border-accent"
}`}
icon={icon ? <Filter className="size-3.5" aria-hidden="true" /> : undefined}
>
{icon && <Filter className="w-3.5 h-3.5 shrink-0" aria-hidden="true" />}
{label}
</button>
</Chip>
<PopoverSurface
popover={pop}
ariaLabel={name}
className="glass-menu min-w-48 max-w-72 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
className={`${SURFACE.menu} min-w-48 max-w-72 animate-[popIn_0.16s_ease]`}
>
<div role="group" aria-label={name} className="max-h-72 overflow-y-auto no-scrollbar">
{chips.map((f) => (
@@ -93,7 +91,7 @@ function OverflowPopover({
role="menuitem"
onSelect={f.remove}
label={f.label}
className="group flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-fg transition"
className="group flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm text-fg transition"
>
<span className="flex-1 truncate">{f.label}</span>
<X className="w-3.5 h-3.5 shrink-0 opacity-60 group-hover:opacity-100" />
@@ -101,17 +99,17 @@ function OverflowPopover({
))}
</div>
<div className="mt-1 border-t border-border px-1 pt-1.5">
<button
type="button"
<Button
variant="link-muted"
data-testid="active-filter-clear-all"
onClick={() => {
onClearAll();
setOpen(false);
}}
className="text-xs text-muted hover:text-fg underline underline-offset-2 transition"
className="text-xs underline underline-offset-2"
>
{t("sidebar.clearAll")}
</button>
</Button>
</div>
</PopoverSurface>
</>
@@ -191,13 +189,13 @@ export default function ActiveFilterChips({
ref={(el) => {
chipRefs.current[i] = el;
}}
className={chipCls}
className={chipClass("token", "md") + " pr-1.5"}
>
<span className="max-w-56 truncate">{f.label}</span>
<X className="w-3 h-3 shrink-0" />
<X className="size-3" />
</span>
))}
<span ref={plusRef} className="chip inline-flex items-center gap-1 px-2.5 py-1 text-sm">
<span ref={plusRef} className={chipClass("token", "md")}>
+{n}
</span>
<span ref={clearRef} className="ml-0.5 text-sm underline underline-offset-2">
@@ -230,13 +228,14 @@ export default function ActiveFilterChips({
/>
)}
{layout.mode === "all" && n > 1 && (
<button
<Button
variant="link-muted"
data-testid="active-filter-clear-all"
onClick={onClearAll}
className="ml-0.5 shrink-0 text-sm text-muted hover:text-fg underline underline-offset-2 transition"
className="ml-0.5 text-sm underline underline-offset-2"
>
{t("sidebar.clearAll")}
</button>
</Button>
)}
</>
)}
+78 -38
View File
@@ -1,14 +1,18 @@
import { useState } from "react";
import { useLayoutEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { Check, ListPlus, Plus, Youtube } from "lucide-react";
import { ListPlus, Plus, Youtube } from "lucide-react";
import { MenuCheck } from "./ui/MenuCheck";
import { api, type Playlist } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { useScrollFade } from "../lib/useScrollFade";
import { playlistName } from "../lib/playlistName";
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { IconButton } from "./ui/IconButton";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
// A small anchored menu for toggling a video's membership in the user's playlists, with inline
// "new playlist" creation. Same shape as the channel tag editor (R8 S1's pilot): radio-style
@@ -16,26 +20,82 @@ import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
// the input swallowing non-Escape keys, since character and arrow keys belong to the text field.
export default function AddToPlaylist({
videoId,
className,
variant = "ghost",
size = "sm",
}: {
videoId: string;
className?: string;
variant?: "ghost" | "outline" | "overlay" | "media";
size?: "sm" | "md";
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const fade = useScrollFade();
const [open, setOpen] = useState(false);
const [newName, setNewName] = useState("");
const [busy, setBusy] = useState(false);
const pop = useMembershipPopover(open, setOpen);
return (
<>
<IconButton
ref={pop.refs.setReference}
variant={variant}
size={size}
{...pop.getReferenceProps({
// The trigger sits on a feed card that opens the player on click, so the toggle must not
// reach it.
onClick: (e) => {
e.preventDefault();
e.stopPropagation();
},
})}
label={t("playlists.addToPlaylist")}
>
<ListPlus />
</IconButton>
<MembershipPanel videoId={videoId} pop={pop} />
</>
);
}
const pop = useAnchoredPopover({
/** The same panel, opened from an object's menu ("Add to playlist…", E28 C1) rather than its own
* button: anchored to the element the menu came from, and gone when it closes. */
export function AddToPlaylistAt({
videoId,
anchor,
onClose,
}: {
videoId: string;
anchor: Element;
onClose: () => void;
}) {
const pop = useMembershipPopover(true, (next) => {
if (!next) onClose();
});
const { setPositionReference } = pop.refs;
useLayoutEffect(() => setPositionReference(anchor), [anchor, setPositionReference]);
return <MembershipPanel videoId={videoId} pop={pop} />;
}
function useMembershipPopover(open: boolean, onOpenChange: (open: boolean) => void) {
return useAnchoredPopover({
open,
onOpenChange: setOpen,
onOpenChange,
offset: 6,
// The menu holds a "new playlist" text field — keep typed chars and pointer-hover out of the
// roving list so they don't steal the caret.
hostsInput: true,
});
}
function MembershipPanel({
videoId,
pop,
}: {
videoId: string;
pop: ReturnType<typeof useAnchoredPopover>;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const fade = useScrollFade();
const open = pop.open;
const [newName, setNewName] = useState("");
const [busy, setBusy] = useState(false);
const membership = useQuery({
queryKey: qk.playlistMembership(videoId),
@@ -90,28 +150,13 @@ export default function AddToPlaylist({
return (
<>
<button
ref={pop.refs.setReference}
{...pop.getReferenceProps({
// The trigger sits on a feed card that opens the player on click, so the toggle must not
// reach it.
onClick: (e) => {
e.preventDefault();
e.stopPropagation();
},
})}
title={t("playlists.addToPlaylist")}
aria-label={t("playlists.addToPlaylist")}
className={className ?? "p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg"}
>
<ListPlus className="w-4 h-4" />
</button>
<PopoverSurface popover={pop} className="glass-menu w-60 rounded-xl p-2">
<PopoverSurface popover={pop} className={`${SURFACE.popover} w-60 p-2`}>
{/* A React portal still propagates events up the REACT tree, so a click inside this panel
would otherwise reach the feed card that hosts the trigger and start playback. */}
<div onClick={(e) => e.stopPropagation()}>
<div className="text-xs text-muted px-1.5 pb-1.5">{t("playlists.addToPlaylist")}</div>
<div className="text-xs text-muted px-1.5 pb-1.5">
{t("playlists.addToPlaylistTitle")}
</div>
<div
ref={fade.ref}
style={fade.style}
@@ -135,13 +180,7 @@ export default function AddToPlaylist({
label={playlistName(pl, t)}
className="w-full flex items-center gap-2 text-sm px-1.5 py-1.5 rounded-lg text-fg outline-hidden transition"
>
<span
className={`grid place-items-center w-4 h-4 rounded border ${
pl.has_video ? "bg-accent border-accent text-accent-fg" : "border-border"
}`}
>
{pl.has_video && <Check className="w-3 h-3" />}
</span>
<MenuCheck kind="many" on={!!pl.has_video} />
<span className="truncate flex-1 text-left">{playlistName(pl, t)}</span>
{(pl.source === "youtube" || pl.yt_playlist_id) && (
<Youtube className="w-3 h-3 shrink-0 text-muted" />
@@ -151,7 +190,9 @@ export default function AddToPlaylist({
</div>
<div className="flex items-center gap-1.5 border-t border-border mt-1.5 pt-1.5">
<Plus className="w-3.5 h-3.5 text-muted shrink-0" />
<input
<Input
className="flex-1"
size="sm"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
@@ -165,7 +206,6 @@ export default function AddToPlaylist({
}
}}
placeholder={t("playlists.newPlaylist")}
className="flex-1 min-w-0 bg-surface border border-border rounded-md px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
</div>
</div>
+140 -119
View File
@@ -1,3 +1,4 @@
import clsx from "clsx";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
@@ -6,11 +7,11 @@ import {
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { Copy, Radio, Search, X } from "lucide-react";
import { Copy, Radio, Search } from "lucide-react";
import { api, type LogFacet, type LogQuery, type ServerLogRow } from "../lib/api";
import { copyText } from "../lib/clipboard";
import { formatDateTime } from "../lib/format";
import { formatDateTimeSec } from "../lib/format";
import { nextNavId, notifyNav, rememberNavPosition } from "../lib/history";
import { LEVEL_GROUPS, type LevelGroup } from "../lib/log";
import {
@@ -31,8 +32,14 @@ import NavGrid from "./ui/NavGrid";
import { Button } from "./ui/Button";
import FilterChipGroup from "./ui/FilterChipGroup";
import FilterSelect from "./ui/FilterSelect";
import { FilterFoldButton } from "./ui/FilterFold";
import { useFilterFold } from "../lib/useFilterFold";
import { useStickyTop } from "./ui/stickyTop";
import { Section } from "./ui/form";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { DismissibleChip } from "./ui/Chip";
import { InnerInput } from "./ui/Input";
import { fieldClass } from "./ui/fieldStyles";
// Admin → Szervernapló: the server's stored log (`log_events`, E22.6). One list, newest first; a line
// opens in place to its full record, and its request id / job id narrow the list to that request or
@@ -73,6 +80,14 @@ export default function AdminLogs() {
logsParamsToState(new URLSearchParams(window.location.search)),
);
const [live, setLiveState] = useState(false);
// The toolbar sticks at the scroller's top (./ui/stickyTop), and folds its facet row when short.
useStickyTop();
const fold = useFilterFold();
const facetFilters =
state.sources.length +
(state.user ? 1 : 0) +
(state.event ? 1 : 0) +
(state.window !== DEFAULT_LOGS_STATE.window ? 1 : 0);
// Read by `getNextPageParam` when a page is fetched, not when it renders: while live, a refetch
// stops after the newest page, however many pages the cache holds — a "Load older" that lands
// after Live was switched on, or a filter set revisited with older pages still cached.
@@ -241,104 +256,119 @@ export default function AdminLogs() {
<div
data-testid="admin-logs"
className="overflow-hidden rounded-xl border border-border bg-bg"
// overflow-clip, not -hidden: a hidden box is a scroll container, and the toolbar would stick
// to it instead of to the page.
className="overflow-clip rounded-xl border border-border bg-bg"
>
<div className="flex flex-wrap items-center gap-2 border-b border-border p-2">
<label className="flex min-w-[12rem] flex-1 items-center gap-1.5 rounded-lg border border-border bg-card px-2 py-1 text-sm focus-within:border-accent">
<Search className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden />
<input
type="search"
value={state.q}
onChange={(e) => update({ q: e.target.value.slice(0, 200) })}
placeholder={t("adminLogs.search")}
aria-label={t("adminLogs.search")}
className="min-w-0 flex-1 bg-transparent outline-none"
{/* The console's toolbar is the page's band (B4, B9): it sticks at the top with the fade
under it while the lines scroll, so a filter is in reach at line 500. */}
<div className="sticky top-0 z-base bg-bg fade-below">
<div className="flex flex-wrap items-center gap-2 border-b border-border p-2">
<label
className={clsx(
fieldClass({ size: "sm", pill: true }),
"flex min-w-[12rem] flex-1 items-center gap-1.5",
)}
>
<Search className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden />
<InnerInput
type="search"
value={state.q}
onChange={(e) => update({ q: e.target.value.slice(0, 200) })}
placeholder={t("adminLogs.search")}
aria-label={t("adminLogs.search")}
/>
</label>
<FilterChipGroup
ariaLabel={t("adminLogs.levelLabel")}
value={state.group}
onChange={(group) => update({ group })}
options={GROUPS.map((g) => ({
value: g,
label: t(`settings.myLog.filters.${g}`),
count: groupCount(g),
}))}
/>
</label>
<FilterChipGroup
ariaLabel={t("adminLogs.levelLabel")}
value={state.group}
onChange={(group) => update({ group })}
options={GROUPS.map((g) => ({
value: g,
label: t(`settings.myLog.filters.${g}`),
count: groupCount(g),
}))}
/>
</div>
<div className="flex flex-wrap items-center gap-2 border-b border-border p-2">
<FilterSelect
label={t("adminLogs.source")}
options={facetOptions(facets.data?.sources, state.sources[0] ?? null, (v) => v)}
selected={[state.sources[0] ?? ""]}
onChange={([source]) => update({ sources: source ? [source] : [] })}
testId="logs-source"
/>
<FilterSelect
label={t("adminLogs.account")}
options={facetOptions(facets.data?.users, state.user, accountLabel)}
selected={[state.user ?? ""]}
onChange={([user]) => update({ user: user || null })}
testId="logs-user"
/>
<FilterSelect
label={t("adminLogs.event")}
options={facetOptions(facets.data?.events, state.event, (v) =>
v === "none" ? t("adminLogs.noEvent") : v,
)}
selected={[state.event ?? ""]}
onChange={([event]) => update({ event: event || null })}
testId="logs-event"
/>
<FilterSelect
label={t("adminLogs.window")}
options={WINDOWS.map((w) => ({ value: w, label: t(`adminLogs.windows.${w}`) }))}
selected={[state.window]}
onChange={([window]) => update({ window: window as LogWindow })}
testId="logs-window"
/>
<span className="ml-auto flex gap-1.5">
<Button
variant={polling ? "outline-accent" : "outline"}
size="xs"
icon={<Radio className="h-3.5 w-3.5" />}
aria-pressed={polling}
onClick={toggleLive}
>
{t("adminLogs.live")}
</Button>
<Button
variant="outline"
size="xs"
icon={<Copy className="h-3.5 w-3.5" />}
// While a filter change loads, the rows on screen are the previous filter's.
disabled={rows.length === 0 || logs.isPlaceholderData}
onClick={() => void copy()}
>
{t("adminLogs.copy")}
</Button>
</span>
</div>
{(state.requestId || state.jobId !== null) && (
<div className="flex flex-wrap items-center gap-2 border-b border-border px-2 py-1.5 text-xs">
{state.requestId && (
<PivotChip
label={t("adminLogs.pivotRequest", { id: state.requestId })}
clearLabel={t("adminLogs.clearPivot")}
onClear={() => update({ requestId: null })}
/>
)}
{state.jobId !== null && (
<PivotChip
label={t("adminLogs.pivotJob", { id: state.jobId })}
clearLabel={t("adminLogs.clearPivot")}
onClear={() => update({ jobId: null })}
/>
)}
</div>
)}
<div className="flex flex-wrap items-center gap-2 border-b border-border p-2">
<FilterFoldButton fold={fold} label={t("sidebar.filters")} count={facetFilters} />
{!fold.folded && (
<>
<FilterSelect
label={t("adminLogs.source")}
options={facetOptions(facets.data?.sources, state.sources[0] ?? null, (v) => v)}
selected={[state.sources[0] ?? ""]}
onChange={([source]) => update({ sources: source ? [source] : [] })}
testId="logs-source"
/>
<FilterSelect
label={t("adminLogs.account")}
options={facetOptions(facets.data?.users, state.user, accountLabel)}
selected={[state.user ?? ""]}
onChange={([user]) => update({ user: user || null })}
testId="logs-user"
/>
<FilterSelect
label={t("adminLogs.event")}
options={facetOptions(facets.data?.events, state.event, (v) =>
v === "none" ? t("adminLogs.noEvent") : v,
)}
selected={[state.event ?? ""]}
onChange={([event]) => update({ event: event || null })}
testId="logs-event"
/>
<FilterSelect
label={t("adminLogs.window")}
options={WINDOWS.map((w) => ({ value: w, label: t(`adminLogs.windows.${w}`) }))}
selected={[state.window]}
onChange={([window]) => update({ window: window as LogWindow })}
testId="logs-window"
/>
</>
)}
<span className="ml-auto flex gap-1.5">
<Button
variant="outline"
size="xs"
icon={<Radio className="h-3.5 w-3.5" />}
pressed={polling}
onClick={toggleLive}
>
{t("adminLogs.live")}
</Button>
<Button
variant="outline"
size="xs"
icon={<Copy className="h-3.5 w-3.5" />}
// While a filter change loads, the rows on screen are the previous filter's.
disabled={rows.length === 0 || logs.isPlaceholderData}
onClick={() => void copy()}
>
{t("adminLogs.copy")}
</Button>
</span>
</div>
{(state.requestId || state.jobId !== null) && (
<div className="flex flex-wrap items-center gap-2 border-b border-border px-2 py-1.5 text-xs">
{state.requestId && (
<PivotChip
label={t("adminLogs.pivotRequest", { id: state.requestId })}
clearLabel={t("adminLogs.clearPivot")}
onClear={() => update({ requestId: null })}
/>
)}
{state.jobId !== null && (
<PivotChip
label={t("adminLogs.pivotJob", { id: state.jobId })}
clearLabel={t("adminLogs.clearPivot")}
onClear={() => update({ jobId: null })}
/>
)}
</div>
)}
</div>
{failure && (
<StateMessage
@@ -361,7 +391,7 @@ export default function AdminLogs() {
{t("common.loadError")}
</StateMessage>
) : logs.isLoading ? (
<LoadingState />
<LoadingState shape="table" />
) : rows.length === 0 ? (
<p className="px-3 py-6 text-center font-sans text-sm text-muted">
{t("adminLogs.empty")}
@@ -376,7 +406,7 @@ export default function AdminLogs() {
level={row.level}
event={row.event ?? "—"}
msg={row.msg}
title={formatDateTime(row.ts)}
title={formatDateTimeSec(row.ts)}
open={open === row.id}
onToggle={() => setOpen(open === row.id ? null : row.id)}
>
@@ -421,18 +451,9 @@ function PivotChip({
onClear: () => void;
}) {
return (
<span className="chip py-0.5 pl-2.5 pr-1">
<span className="font-mono">{label}</span>
<button
type="button"
onClick={onClear}
aria-label={clearLabel}
title={clearLabel}
className="rounded-full p-0.5 hover:bg-accent/20"
>
<X className="h-3 w-3" aria-hidden />
</button>
</span>
<DismissibleChip onDismiss={onClear} dismissLabel={clearLabel} className="font-mono">
{label}
</DismissibleChip>
);
}
@@ -449,12 +470,12 @@ function RowDetail({
}) {
const { t } = useTranslation();
const pivotCls =
"font-mono text-accent underline decoration-dotted underline-offset-2 hover:decoration-solid";
"font-mono underline decoration-dotted underline-offset-2 hover:decoration-solid";
return (
<div className="flex flex-col gap-2">
<dl className="grid grid-cols-[max-content_minmax(0,1fr)] gap-x-3 gap-y-0.5 font-sans text-xs">
<dt>{t("adminLogs.fields.when")}</dt>
<dd className="tabular-nums">{formatDateTime(row.ts)}</dd>
<dd className="tabular-nums">{formatDateTimeSec(row.ts)}</dd>
<dt>{t("adminLogs.fields.source")}</dt>
<dd className="font-mono">
{row.source} · {row.logger}
@@ -465,14 +486,14 @@ function RowDetail({
<>
<dt>{t("adminLogs.fields.request")}</dt>
<dd>
<button
type="button"
<Button
variant="link"
className={pivotCls}
title={t("adminLogs.showRequest")}
onClick={() => onRequest(row.request_id!)}
>
{row.request_id}
</button>
</Button>
</dd>
</>
)}
@@ -480,14 +501,14 @@ function RowDetail({
<>
<dt>{t("adminLogs.fields.job")}</dt>
<dd>
<button
type="button"
<Button
variant="link"
className={pivotCls}
title={t("adminLogs.showJob")}
onClick={() => onJob(row.job_id!)}
>
{row.job_id}
</button>
</Button>
</dd>
</>
)}
@@ -98,6 +98,24 @@ describe("the last sign-in column (E22 S1)", () => {
});
});
// E28 (owner): the hover ended wherever the row's first badge or button began, because the layer was
// the name button's own. The card is the row: it paints the layer under its content, and the name
// button's hit layer spans the card, under the actions that sit above it.
describe("a user row", () => {
it("answers as a whole card, with its actions above the card's hit layer", async () => {
mount();
const open = await screen.findByTitle("users.account.open");
const card = open.parentElement!;
expect(card.className).toMatch(/(^|\s)isolate(\s|$)/);
expect(card.className).toMatch(/(^|\s)hover:before:bg-\(--hover\)(\s|$)/);
expect(open.className).toMatch(/(^|\s)after:inset-0(\s|$)/);
expect(open.className).not.toMatch(/selectable|hover:/);
const actions = [...card.querySelectorAll("button")].filter((b) => b !== open);
expect(actions.length).toBeGreaterThan(0);
for (const b of actions) expect(b.className).toMatch(/(^|\s)relative(\s|$)/);
});
});
describe("a user action that fails", () => {
it("says a user who is gone, and refetches the list", async () => {
const users = mount();
+141 -127
View File
@@ -28,7 +28,9 @@ import { notify } from "../lib/notifications";
import { formatDateTime, relativeTime } from "../lib/format";
import { invalidateUserAccess } from "../lib/adminUsers";
import { ADMIN_USERS_TAB_KEY } from "../lib/adminUsersTab";
import { Badge } from "./ui/Badge";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import Tooltip from "./ui/Tooltip";
import InlineNotice from "./ui/InlineNotice";
import { Section } from "./ui/form";
@@ -38,11 +40,14 @@ import { LoadingState, StateMessage } from "./ui/QueryState";
import AccountPanel from "./AccountPanel";
import { useConfirm } from "./confirmContext";
import Tabs from "./ui/Tabs";
import { useTabPanel } from "./ui/tabPanel";
import { useAccountPersistedState } from "../lib/storage";
import { PageToolbar } from "./ui/PageShell";
import { useMe } from "../lib/useMe";
import { notifyActionError, notifyKnownFailure } from "../lib/actionErrors";
import { FocusSafeButton } from "./ui/FocusSafeButton";
import { Pressable } from "./ui/Pressable";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
// Admin-only user & access management: roles, access requests (the Invite whitelist), and the
// demo whitelist + reset. Each section is its own tab (persisted across reloads); the Access tab
@@ -50,10 +55,18 @@ import { FocusSafeButton } from "./ui/FocusSafeButton";
// (The tab key + a "focus the Access tab" helper live in lib/adminUsersTab so callers can
// pre-select the tab without importing this lazy-loaded page — see that file.)
// A user row's B3 layer (DESIGN.md "State layers"), painted by the card under its content, and the name
// button's stretched hit layer over the whole card. Why the row is built so: at the row below.
const ROW_LAYER =
"relative isolate before:absolute before:inset-0 before:z-under before:rounded-[inherit] before:transition-colors before:content-[''] hover:before:bg-(--hover) has-[[data-row-open]:active]:before:bg-(--press)";
const ROW_OPEN =
"focus-visible:outline-none after:absolute after:inset-0 after:rounded-xl after:content-[''] focus-visible:after:outline-2 focus-visible:after:-outline-offset-2 focus-visible:after:outline-accent";
export default function AdminUsers() {
const me = useMe();
const { t } = useTranslation();
const [tab, setTab] = useAccountPersistedState(ADMIN_USERS_TAB_KEY, "roles");
const panel = useTabPanel();
const tabs = [
{ id: "roles", label: t("users.tabs.roles") },
// Both kinds of "waiting on the admin": a request needing a decision, and a sign-up whose
@@ -72,10 +85,10 @@ export default function AdminUsers() {
{/* The tab bar is fixed chrome; the tab content scrolls under it. */}
<PageToolbar>
<div className="px-4 pt-2 max-w-3xl w-full mx-auto">
<Tabs tabs={tabs} active={active} onChange={setTab} />
<Tabs idBase={panel.idBase} tabs={tabs} active={active} onChange={setTab} />
</div>
</PageToolbar>
<div className="px-4 pb-4 pt-3 max-w-3xl w-full mx-auto">
<div {...panel.panelProps(active)} className="px-4 pb-4 pt-3 max-w-3xl w-full mx-auto">
{active === "roles" && <UsersRoles me={me} />}
{active === "access" && (
<>
@@ -89,24 +102,6 @@ export default function AdminUsers() {
);
}
function Badge({
tone,
children,
}: {
tone: "accent" | "muted" | "warning";
children: React.ReactNode;
}) {
const cls =
tone === "accent"
? "border-accent/40 text-accent"
: tone === "warning"
? "border-warn/40 text-warn"
: "border-border text-muted";
return (
<span className={`shrink-0 text-2xs px-2 py-0.5 rounded-full border ${cls}`}>{children}</span>
);
}
function UsersRoles({ me }: { me: Me }) {
const { t } = useTranslation();
const qc = useQueryClient();
@@ -221,7 +216,7 @@ function UsersRoles({ me }: { me: Me }) {
{t("common.loadError")}
</StateMessage>
) : q.isLoading && !q.data ? (
<LoadingState />
<LoadingState shape="list" />
) : rows.length === 0 ? (
<p className="text-sm text-muted">{t("users.roles.empty")}</p>
) : (
@@ -229,28 +224,39 @@ function UsersRoles({ me }: { me: Me }) {
{rows.map((u) => {
const self = u.id === me.id;
return (
<div key={u.id} className="glass-card flex items-center gap-2.5 p-2.5 rounded-xl">
<button
// The whole card is the row, so the whole card answers (owner, E28): the name's button
// stretches an invisible layer over the card (`after:inset-0`), so a click on a badge or
// a gap opens the account too, and the B3 hover layer is the card's own `::before`, under
// the badges and buttons (`isolate` + `z-under`). The actions are `relative`, above the
// stretched layer. The hover used to be the name button's own and ended at the first badge.
<div
key={u.id}
className={`${SURFACE.card} ${ROW_LAYER} flex items-center gap-2.5 p-2.5`}
>
<Pressable
selectable
data-row-open=""
onClick={() => setPanelFor(u)}
title={t("users.account.open")}
className="selectable cursor-pointer min-w-0 flex-1 text-left rounded-lg -m-1 p-1"
className={`min-w-0 flex-1 cursor-pointer ${ROW_OPEN}`}
>
<div className="text-sm truncate">
{displayName(u)}
{self && <span className="text-muted"> · {t("users.roles.you")}</span>}
</div>
<div className="text-2xs text-muted truncate">{u.email}</div>
</button>
</Pressable>
{u.role === "admin" && <Badge tone="accent">{t("users.roles.admin")}</Badge>}
{u.is_demo && <Badge tone="muted">{t("users.roles.demo")}</Badge>}
{u.is_suspended && <Badge tone="warning">{t("users.roles.suspended")}</Badge>}
{!u.is_active && <Badge tone="warning">{t("users.roles.pending")}</Badge>}
{u.is_demo && <Badge>{t("users.roles.demo")}</Badge>}
{u.is_suspended && <Badge tone="warn">{t("users.roles.suspended")}</Badge>}
{!u.is_active && <Badge tone="warn">{t("users.roles.pending")}</Badge>}
{/* A Google sign-in proves the email, so "unverified" only applies to a
password-only account that hasn't clicked its verification link. */}
{u.is_active && !u.email_verified && !u.has_google && (
<Badge tone="warning">{t("users.roles.unverified")}</Badge>
<Badge tone="warn">{t("users.roles.unverified")}</Badge>
)}
<Tooltip
always={u.is_demo || self}
hint={
u.is_demo
? t("users.roles.demoLocked")
@@ -264,15 +270,19 @@ function UsersRoles({ me }: { me: Me }) {
<Button
variant="glass"
size="xs"
className="shrink-0"
className="relative shrink-0"
onClick={() => onToggle(u)}
disabled={u.is_demo || self || pendingIds.has(u.id)}
// Locked (demo / self) is unavailable WITH a reason, the tooltip's: aria-disabled keeps it
// focusable so a keyboard user reaches the reason. A pending write is transient.
aria-disabled={u.is_demo || self}
disabled={pendingIds.has(u.id)}
icon={<Shield className="w-3.5 h-3.5" />}
>
{u.role === "admin" ? t("users.roles.makeUser") : t("users.roles.makeAdmin")}
</Button>
</Tooltip>
<Tooltip
always={u.is_demo || self}
hint={
u.is_demo
? t("users.suspend.demoLocked")
@@ -283,36 +293,41 @@ function UsersRoles({ me }: { me: Me }) {
: t("users.suspend.action")
}
>
<Button
variant={u.is_suspended ? "outline-warn" : "outline"}
size="icon-sm"
className="shrink-0"
onClick={() => onSuspend(u)}
disabled={u.is_demo || self || pendingIds.has(u.id)}
aria-label={
<IconButton
noTitle
label={
u.is_suspended ? t("users.suspend.undoAction") : t("users.suspend.action")
}
icon={
u.is_suspended ? (
<RotateCcw className="w-4 h-4" />
) : (
<Ban className="w-4 h-4" />
)
}
/>
size="sm"
variant={u.is_suspended ? "outline-warn" : "outline"}
className="relative"
onClick={() => onSuspend(u)}
aria-disabled={u.is_demo || self}
disabled={pendingIds.has(u.id)}
>
{u.is_suspended ? (
<RotateCcw className="w-4 h-4" />
) : (
<Ban className="w-4 h-4" />
)}
</IconButton>
</Tooltip>
<Tooltip
always={u.is_demo || self}
hint={u.is_demo || self ? t("users.delete.locked") : t("users.delete.action")}
>
<Button
<IconButton
noTitle
label={t("users.delete.action")}
size="sm"
variant="outline-danger"
size="icon-sm"
className="shrink-0"
className="relative"
onClick={() => onDelete(u)}
disabled={u.is_demo || self || pendingIds.has(u.id)}
aria-label={t("users.delete.action")}
icon={<Trash2 className="w-4 h-4" />}
/>
aria-disabled={u.is_demo || self}
disabled={pendingIds.has(u.id)}
>
<Trash2 className="w-4 h-4" />
</IconButton>
</Tooltip>
{/* How long ago this account last signed in. LAST in the row on purpose: the badges and
the role button before it vary in width row to row, so only the row's own right
@@ -327,7 +342,7 @@ function UsersRoles({ me }: { me: Me }) {
: t("users.roles.lastLoginNone")
}
>
<span className="hidden sm:inline-block w-20 shrink-0 text-right text-2xs text-muted tabular-nums">
<span className="relative hidden sm:inline-block w-20 shrink-0 text-right text-2xs text-muted tabular-nums">
{u.last_login_at ? relativeTime(u.last_login_at) : "—"}
</span>
</Tooltip>
@@ -392,7 +407,7 @@ function PendingAccess() {
{t("common.loadError")}
</StateMessage>
) : rows.isLoading && !rows.data ? (
<LoadingState />
<LoadingState shape="list" />
) : (
<div className="space-y-1.5">
{list.map((u) => (
@@ -427,7 +442,7 @@ function PendingAccessRowView({
}) {
const { t } = useTranslation();
return (
<div className="glass-card flex items-center gap-2 p-2.5 rounded-xl">
<div className={`${SURFACE.card} flex items-center gap-2 p-2.5`}>
<div className="min-w-0 flex-1">
<div className="text-sm truncate">{row.email}</div>
<div className="text-2xs text-muted flex items-center gap-2 mt-0.5 flex-wrap">
@@ -459,15 +474,16 @@ function PendingAccessRowView({
</div>
{!row.email_verified && (
<Tooltip hint={t("users.pendingAccess.resendHint")}>
<Button
<IconButton
noTitle
label={t("users.pendingAccess.resend")}
size="sm"
variant="outline"
size="icon-sm"
className="shrink-0"
onClick={onResend}
disabled={busy}
aria-label={t("users.pendingAccess.resend")}
icon={<Send className="w-4 h-4" />}
/>
>
<Send className="w-4 h-4" />
</IconButton>
</Tooltip>
)}
{row.can_activate && (
@@ -660,7 +676,7 @@ function AdminInvites() {
{t("common.loadError")}
</StateMessage>
) : invites.isLoading && !invites.data ? (
<LoadingState />
<LoadingState shape="list" />
) : pending.length === 0 ? (
<p className="text-sm text-muted">{t("settings.invites.noPending")}</p>
) : (
@@ -685,12 +701,12 @@ function AdminInvites() {
}}
className="flex items-center gap-2 mt-3"
>
<input
<Input
className="flex-1"
type="email"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
placeholder={t("settings.invites.addPlaceholder")}
className="flex-1 min-w-0 bg-surface border border-border rounded-xl px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
<Button
type="submit"
@@ -729,18 +745,15 @@ function AdminInvites() {
: t("settings.invites.inviteHint")
}
>
<FocusSafeButton
<IconButton
noTitle
onClick={() => onInvite(i)}
disabled={invite.isPending}
className={`shrink-0 p-1 rounded-md disabled:opacity-50 transition ${
i.invited_at
? "text-muted/50 hover:text-fg"
: "text-muted hover:text-accent"
}`}
aria-label={t("settings.invites.inviteLabel", { email: i.email })}
label={t("settings.invites.inviteLabel", { email: i.email })}
>
<Mails className="w-3.5 h-3.5" />
</FocusSafeButton>
{/* Already invited: the glyph fades, the action stays (a resend). */}
<Mails className={i.invited_at ? "opacity-50" : ""} />
</IconButton>
</Tooltip>
)}
{/* An entry with an account behind it stays read-only: deleting it would revoke that
@@ -748,26 +761,24 @@ function AdminInvites() {
{i.has_account ? (
// `disabled` would drop it from the tab order AND suppress the title, so the
// reason reached nobody — same call as the Playlists drag handle.
<button
type="button"
<IconButton
aria-disabled
onClick={(e) => e.preventDefault()}
title={t("settings.invites.deleteHasAccount")}
aria-label={t("settings.invites.deleteHasAccount")}
className="shrink-0 p-1 text-muted/50 cursor-not-allowed"
label={t("settings.invites.deleteHasAccount")}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
<Trash2 />
</IconButton>
) : (
<Tooltip hint={t("settings.invites.deleteHint")}>
<FocusSafeButton
<IconButton
noTitle
variant="danger"
onClick={() => onDelete(i)}
disabled={remove.isPending}
className="shrink-0 p-1 rounded-md text-muted hover:text-danger disabled:opacity-50 transition"
aria-label={t("settings.invites.deleteLabel", { email: i.email })}
label={t("settings.invites.deleteLabel", { email: i.email })}
>
<Trash2 className="w-3.5 h-3.5" />
</FocusSafeButton>
<Trash2 />
</IconButton>
</Tooltip>
)}
</div>
@@ -831,7 +842,7 @@ function AdminDemo() {
{rows.length > 0 && (
<div className="space-y-1.5 mb-3">
{rows.map((r) => (
<div key={r.id} className="glass-card flex items-center gap-2 p-2.5 rounded-xl">
<div key={r.id} className={`${SURFACE.card} flex items-center gap-2 p-2.5`}>
<div className="min-w-0 flex-1">
<div className="text-sm truncate">{r.email}</div>
{r.added_by && (
@@ -841,15 +852,16 @@ function AdminDemo() {
)}
</div>
<Tooltip hint={t("settings.demo.removeHint")}>
<Button
<IconButton
noTitle
label={t("settings.demo.remove")}
size="sm"
variant="outline-danger"
size="icon-sm"
className="shrink-0"
onClick={() => onRemove(r)}
disabled={remove.isPending}
aria-label={t("settings.demo.remove")}
icon={<Trash2 className="w-4 h-4" />}
/>
>
<Trash2 className="w-4 h-4" />
</IconButton>
</Tooltip>
</div>
))}
@@ -863,12 +875,12 @@ function AdminDemo() {
}}
className="flex items-center gap-2"
>
<input
<Input
className="flex-1"
type="email"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
placeholder={t("settings.demo.addPlaceholder")}
className="flex-1 min-w-0 bg-surface border border-border rounded-xl px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
<Button variant="glass" size="md" className="shrink-0" type="submit">
<UserPlus className="w-4 h-4" /> {t("settings.demo.add")}
@@ -877,7 +889,11 @@ function AdminDemo() {
<div className="mt-4 pt-3 border-t border-border">
<Tooltip hint={t("settings.demo.resetHint")}>
<FocusSafeButton
<Button
variant="glass"
size="sm"
icon={<RotateCcw />}
loading={reset.isPending}
onClick={async () => {
if (
await confirm({
@@ -889,12 +905,9 @@ function AdminDemo() {
)
reset.mutate();
}}
disabled={reset.isPending}
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
<RotateCcw className={`w-4 h-4 ${reset.isPending ? "animate-spin" : ""}`} />
{t("settings.demo.reset")}
</FocusSafeButton>
</Button>
</Tooltip>
</div>
</Section>
@@ -916,7 +929,7 @@ function InviteRow({
}) {
const { t } = useTranslation();
return (
<div className="glass-card flex items-center gap-2 p-2.5 rounded-xl">
<div className={`${SURFACE.card} flex items-center gap-2 p-2.5`}>
<div className="min-w-0 flex-1">
<div className="text-sm truncate">{inv.email}</div>
{inv.requested_at && (
@@ -928,26 +941,28 @@ function InviteRow({
)}
</div>
<Tooltip hint={t("settings.invites.approveHint")}>
<Button
<IconButton
noTitle
label={t("settings.invites.approve")}
size="sm"
variant="outline-accent"
size="icon-sm"
className="shrink-0"
onClick={onApprove}
disabled={busy}
aria-label={t("settings.invites.approve")}
icon={<Check className="w-4 h-4" />}
/>
>
<Check className="w-4 h-4" />
</IconButton>
</Tooltip>
<Tooltip hint={t("settings.invites.denyHint")}>
<Button
<IconButton
noTitle
label={t("settings.invites.deny")}
size="sm"
variant="outline-danger"
size="icon-sm"
className="shrink-0"
onClick={onDeny}
disabled={busy}
aria-label={t("settings.invites.deny")}
icon={<X className="w-4 h-4" />}
/>
>
<X className="w-4 h-4" />
</IconButton>
</Tooltip>
{/* Deny records a decision and keeps the row; delete forgets the address. Once an account
exists for it the row is that account's approval gate, so it is managed from Users & roles
@@ -955,27 +970,26 @@ function InviteRow({
silently missing a button their other rows have. */}
{inv.has_account ? (
// aria-disabled, not `disabled`: the reason has to stay reachable by keyboard.
<button
type="button"
<IconButton
variant="outline"
aria-disabled
onClick={(e) => e.preventDefault()}
title={t("settings.invites.deleteHasAccount")}
aria-label={t("settings.invites.deleteHasAccount")}
className="shrink-0 p-1.5 rounded-lg border border-border/50 text-muted/50 cursor-not-allowed"
label={t("settings.invites.deleteHasAccount")}
>
<Trash2 className="w-4 h-4" />
</button>
<Trash2 />
</IconButton>
) : (
<Tooltip hint={t("settings.invites.deleteHint")}>
<Button
<IconButton
noTitle
label={t("settings.invites.deleteLabel", { email: inv.email })}
size="sm"
variant="outline-danger"
size="icon-sm"
className="shrink-0"
onClick={onDelete}
disabled={busy}
aria-label={t("settings.invites.deleteLabel", { email: inv.email })}
icon={<Trash2 className="w-4 h-4" />}
/>
>
<Trash2 className="w-4 h-4" />
</IconButton>
</Tooltip>
)}
</div>
+3 -1
View File
@@ -62,7 +62,7 @@ export default function AuditLog() {
{t("common.loadError")}
</StateMessage>
) : q.isLoading ? (
<LoadingState />
<LoadingState shape="table" />
) : rows.length === 0 ? (
<p className="text-sm text-muted">{t("audit.empty")}</p>
) : (
@@ -73,6 +73,8 @@ export default function AuditLog() {
rowKey={(r) => String(r.id)}
persistKey={LS.auditTable}
controlsPosition="top"
controlsInBand
controlsBandClassName="px-4 max-w-5xl mx-auto"
controlsLeading={
<Button
variant="outline-danger"
+2 -2
View File
@@ -30,7 +30,7 @@ import { LoadingState, StateMessage } from "./ui/QueryState";
* Suspense fallback after it and the first page's own loading state keep one clock (see LoadingState). */
export function BootScreen() {
return (
<div className="min-h-screen grid place-items-center">
<div className="min-h-dvh grid place-items-center">
<LoadingState since={0} delayMs={400} />
</div>
);
@@ -121,7 +121,7 @@ export default function BootGate({ children }: { children: ReactNode }) {
: null;
if (failed)
return (
<div className="min-h-screen grid place-items-center">
<div className="min-h-dvh grid place-items-center">
<StateMessage tone="error" onRetry={() => failed.refetch()}>
{t("common.somethingWrong")}
</StateMessage>
+4 -7
View File
@@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next";
import { ExternalLink } from "lucide-react";
import type { ChannelDetail } from "../lib/api";
import { linkify } from "../lib/linkify";
import { Badge } from "./ui/Badge";
// --- About-tab metadata helpers (shared by the channel page and the manager's About overlay) ---
function displayName(code: string, lang: string, type: "region" | "language"): string {
@@ -50,7 +51,7 @@ export default function ChannelAboutContent({ ch }: { ch: ChannelDetail }) {
return (
<>
{ch.description ? (
<p className="text-sm whitespace-pre-wrap wrap-break-word leading-relaxed">
<p className="measure text-sm whitespace-pre-wrap wrap-break-word leading-relaxed">
{linkify(ch.description)}
</p>
) : (
@@ -101,9 +102,7 @@ export default function ChannelAboutContent({ ch }: { ch: ChannelDetail }) {
<div className="flex flex-wrap gap-1.5">
{/* Future search epic: these topic chips become clickable filters. */}
{topics.map((tp) => (
<span key={tp} className="glass-card px-2 py-0.5 rounded-full text-xs">
{tp}
</span>
<Badge key={tp}>{tp}</Badge>
))}
</div>
</div>
@@ -113,9 +112,7 @@ export default function ChannelAboutContent({ ch }: { ch: ChannelDetail }) {
<span className="text-muted w-24 shrink-0 pt-0.5">{t("channel.keywords")}</span>
<div className="flex flex-wrap gap-1.5">
{keywords.map((kw, i) => (
<span key={i} className="glass-card px-2 py-0.5 rounded-full text-xs">
{kw}
</span>
<Badge key={i}>{kw}</Badge>
))}
</div>
</div>
+32 -33
View File
@@ -2,6 +2,7 @@ import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { invalidateSubscription } from "../lib/subscriptionCache";
import { UserPlus } from "lucide-react";
import { api, type DiscoveredChannel } from "../lib/api";
import { accountKey, LS } from "../lib/storage";
@@ -23,7 +24,7 @@ import Pager from "./ui/Pager";
import { PageToolbar } from "./ui/PageShell";
import { useConfirm } from "./confirmContext";
import { useChannelLayout } from "./channelsContext";
import { FocusSafeButton } from "./ui/FocusSafeButton";
import { Button } from "./ui/Button";
// The Channel manager's "Discovery" tab: channels that turn up in the user's playlists but
// that they don't subscribe to. Subscribing here only creates the subscription + enriches
@@ -58,9 +59,7 @@ export default function ChannelDiscovery({
onSuccess: (_data, c) => {
// The channel moves from "discovery" to "subscribed", and its videos will start
// arriving — refresh both lists plus the per-user status.
qc.invalidateQueries({ queryKey: qk.discoveredChannels() });
qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: qk.myStatus() });
invalidateSubscription(qc, c.id);
const name = c.title ?? c.id;
// Name the channel and ship a typed payload so the inbox can offer "open in the
// Channel manager" / "open on YouTube" links — kept after reload, when live
@@ -93,8 +92,8 @@ export default function ChannelDiscovery({
);
const columns: Column<DiscoveredChannel>[] = [
// Actions first, mirroring the Subscriptions table (where it sits right after Prio) so the two
// tabs' action clusters line up in the same place.
// Subscribe first: it is this table's primary action, the one thing a discovery row is for (the
// Subscriptions table has no such button — its row ends in the channel's "…", E28 C1).
{
key: "actions",
header: t("channels.cols.actions"),
@@ -104,20 +103,21 @@ export default function ChannelDiscovery({
cardLabel: false,
render: (c) => (
<Tooltip
always={!canWrite}
hint={
canWrite ? t("channels.discovery.subscribeHint") : t("channels.discovery.needWriteHint")
}
>
<FocusSafeButton
// Busy blocks the click, not the focus (FocusSafeButton).
<Button
variant="glass"
size="xs"
icon={<UserPlus />}
onClick={() => onSubscribe(c)}
disabled={!canWrite}
busy={subscribe.isPending}
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs disabled:opacity-50 aria-disabled:opacity-50 transition"
aria-disabled={!canWrite}
loading={subscribe.isPending}
>
<UserPlus className="w-3.5 h-3.5" />
{t("channels.discovery.subscribe")}
</FocusSafeButton>
</Button>
</Tooltip>
),
},
@@ -201,34 +201,33 @@ export default function ChannelDiscovery({
return (
<>
{/* Intro + the table controls are fixed chrome; only the table rows scroll (sticky header). */}
{/* The table controls are the page's band (B4); only the rows scroll (sticky header). The intro
scrolls with them: in the band it held two lines of a short window for text read once. */}
<PageToolbar>
<div className="px-4 pt-2 max-w-384 mx-auto flex items-start justify-between gap-4">
<p className="text-xs text-muted leading-relaxed">{t("channels.discovery.intro")}</p>
{/* Cards has no column headers, so a dropdown drives the sort + a pager (table uses its own). */}
{layout === "cards" && (
<div className="flex items-center gap-3 shrink-0">
<Pager {...pagerProps} />
<ColumnSortControl
columns={columns}
sort={sort}
onChange={setSort}
label={t("channels.sort.label")}
defaultLabel={t("channels.sort.default")}
ascLabel={t("channels.sort.asc")}
descLabel={t("channels.sort.desc")}
/>
</div>
)}
</div>
{/* Cards has no column headers, so a dropdown drives the sort + a pager (table uses its own). */}
{layout === "cards" && (
<div className="px-4 pt-2 max-w-384 mx-auto flex items-center justify-end gap-3">
<Pager {...pagerProps} />
<ColumnSortControl
columns={columns}
sort={sort}
onChange={setSort}
label={t("channels.sort.label")}
defaultLabel={t("channels.sort.default")}
ascLabel={t("channels.sort.asc")}
descLabel={t("channels.sort.desc")}
/>
</div>
)}
</PageToolbar>
<div className="px-4 pb-4 max-w-384 mx-auto">
<p className="text-xs text-muted leading-relaxed pt-2">{t("channels.discovery.intro")}</p>
{query.isError && !query.data ? (
<StateMessage tone="error" onRetry={() => query.refetch()}>
{t("common.loadError")}
</StateMessage>
) : query.isLoading ? (
<LoadingState label={t("channels.discovery.loading")} />
<LoadingState shape="list" label={t("channels.discovery.loading")} />
) : layout === "table" ? (
<DataTable
rows={rows}
@@ -85,3 +85,20 @@ describe("ChannelLayoutGrid · keyboard", () => {
expect(opened).toEqual(["UCb"]);
});
});
describe('ChannelLayoutGrid · the card\'s "…"', () => {
it("docks it in the heading row beside the name, not in the meta line", () => {
const columns: Column<Ch>[] = [
{ key: "channel", header: "Channel", cardPrimary: true, render: (c) => <b>{c.title}</b> },
{ key: "stored", header: "Stored", render: () => <i>12</i> },
{ key: "actions", header: "", render: (c) => <u data-testid={`more-${c.id}`}>…</u> },
];
const { getByTestId, getByText } = render(
<ChannelLayoutGrid rows={rows} columns={columns} rowKey={(c) => c.id} moreKey="actions" />,
);
const heading = getByText("Alpha").closest("div")!.parentElement!;
expect(heading.contains(getByTestId("more-UCa"))).toBe(true);
// Exactly once: a slotted column is not also painted in the meta line.
expect(document.querySelectorAll('[data-testid="more-UCa"]')).toHaveLength(1);
});
});
@@ -4,6 +4,7 @@ import { type Column } from "./ui/DataTable";
import VirtualGrid from "./ui/VirtualGrid";
import { CHANNEL_LAYOUT_SPEC } from "../lib/channelLayout";
import { navItemAttrs } from "../lib/listNav";
import { SURFACE } from "./ui/surfaces";
// The channel manager's `cards` layout (E4 S3), built by reusing the SAME `Column<T>` render closures
// the DataTable draws — so the subscribed and discovery tables (which have different columns) both get
@@ -27,6 +28,9 @@ interface ChannelLayoutGridProps<T> {
aboutKey?: string;
/** Column key rendered as the action cluster, pinned to the card's bottom edge. */
actionsKey?: string;
/** Column key docked at the top-right corner after the lead: the card's "…" (E28 C1), which
* belongs beside the name it acts on, not under the card's last line. */
moreKey?: string;
rowClassName?: (row: T) => string;
/** Extra DOM props per card — same escape hatch, same single consumer, as `DataTable.rowProps`, so
* a channel answers the right-click in the card layout exactly as it does in the table. */
@@ -41,6 +45,7 @@ export default function ChannelLayoutGrid<T>({
leadKey,
aboutKey,
actionsKey,
moreKey,
rowClassName,
rowProps,
emptyText,
@@ -48,11 +53,12 @@ export default function ChannelLayoutGrid<T>({
const { t } = useTranslation();
const spec = CHANNEL_LAYOUT_SPEC.cards;
const slotted = new Set([leadKey, aboutKey, actionsKey].filter(Boolean) as string[]);
const slotted = new Set([leadKey, aboutKey, actionsKey, moreKey].filter(Boolean) as string[]);
const primary = columns.filter((c) => c.cardPrimary);
const lead = leadKey ? columns.find((c) => c.key === leadKey) : undefined;
const about = aboutKey ? columns.find((c) => c.key === aboutKey) : undefined;
const actions = actionsKey ? columns.find((c) => c.key === actionsKey) : undefined;
const more = moreKey ? columns.find((c) => c.key === moreKey) : undefined;
// Everything else flows in the meta line: not the heading, not a slotted column.
const meta = columns.filter((c) => !c.cardPrimary && !slotted.has(c.key));
@@ -60,7 +66,7 @@ export default function ChannelLayoutGrid<T>({
<div
{...rowProps?.(row)}
{...navItemAttrs(index)}
className={`glass-card rounded-xl p-3 h-full flex flex-col gap-2 ${rowClassName?.(row) ?? ""}`}
className={`${SURFACE.card} p-3 h-full flex flex-col gap-2 ${rowClassName?.(row) ?? ""}`}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1 font-medium">
@@ -69,6 +75,7 @@ export default function ChannelLayoutGrid<T>({
))}
</div>
{lead && <div className="shrink-0">{lead.render(row)}</div>}
{more && <div className="shrink-0 -my-1">{more.render(row)}</div>}
</div>
{about && <div className="min-w-0 text-sm">{about.render(row)}</div>}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 text-sm min-w-0">
+15 -15
View File
@@ -1,8 +1,9 @@
import { useTranslation } from "react-i18next";
import { ExternalLink } from "lucide-react";
import Avatar from "./ui/Avatar";
import Tooltip from "./ui/Tooltip";
import { channelYouTubeUrl } from "../lib/format";
import { Pressable } from "./ui/Pressable";
import { IconLink } from "./ui/LinkButton";
// Avatar + channel name + "open on YouTube" link, shared by the Channel manager and the
// discovery list (and anywhere else that lists a channel). When `onView` is given the name
@@ -28,7 +29,8 @@ export default function ChannelLink({
<div className="flex items-center gap-2 min-w-0">
<Avatar src={thumbnailUrl} fallback={title ?? ""} className="w-8 h-8 rounded-full shrink-0" />
{onView ? (
<button
<Pressable
selectable
onClick={onView}
// The channel's "open" element: in a keyboard-walkable list (lib/listNav) Enter on the row or
// card clicks this.
@@ -46,26 +48,24 @@ export default function ChannelLink({
}}
// title = the full name, so a truncated fluid-column name is still readable on hover.
title={name}
className="text-sm font-medium truncate hover:text-accent text-left min-w-0"
className="min-h-6 min-w-0 truncate text-sm font-medium hover:text-accent pointer-coarse:min-h-(--touch-target)"
>
{name}
</button>
</Pressable>
) : (
<span title={name} className="text-sm font-medium truncate min-w-0">
{name}
</span>
)}
<Tooltip hint={t("channels.row.openOnYouTube")}>
<a
href={ytUrl}
target="_blank"
rel="noopener noreferrer"
className="text-muted hover:text-accent shrink-0"
aria-label={t("channels.row.openOnYouTube")}
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
</Tooltip>
<IconLink
href={ytUrl}
target="_blank"
rel="noopener noreferrer"
label={t("channels.row.openOnYouTube")}
className="shrink-0"
>
<ExternalLink />
</IconLink>
</div>
);
}
@@ -0,0 +1,89 @@
// @vitest-environment jsdom
//
// The channel banner is served from Google's image CDN (yt3.googleusercontent.com), which refuses a
// request that carries a Referer from our origin — every time, not only under load (measured
// 2026-09-26: 6/6 refused with the page's referrer or its bare origin, 8/8 served without one). So the
// banner <img> must send none, as ui/Avatar does for the same CDN; with a referrer the banner never
// showed and the page fell back to the blank bar.
import type { ReactNode } from "react";
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, cleanup, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
}));
vi.mock("./navigationContext", () => ({
useNavigationActions: () => ({ closeChannel: vi.fn(), openChannel: vi.fn() }),
}));
vi.mock("./confirmContext", () => ({ useConfirm: () => async () => true }));
vi.mock("./feedFiltersContext", () => ({
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
}));
vi.mock("./Feed", () => ({ default: () => null }));
vi.mock("./ChannelAbout", () => ({ default: () => null }));
// The page's chrome portals into the shell's fixed toolbar band, which this test has no shell for.
vi.mock("./ui/PageShell", async (importOriginal) => ({
...(await importOriginal<typeof import("./ui/PageShell")>()),
PageToolbar: ({ children }: { children?: ReactNode }) => <>{children}</>,
}));
import ChannelPage from "./ChannelPage";
import { api, type ChannelDetail, type Me } from "../lib/api";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
const CH = "UCqoAEDirJPjEUFcF2FklnBA";
describe("ChannelPage — the banner", () => {
it("is requested without a referrer, which Google's image CDN refuses", async () => {
vi.spyOn(api, "channelDetail").mockResolvedValue({
id: CH,
title: "StarTalk",
banner_url: "https://yt3.googleusercontent.com/banner",
subscribed: true,
blocked: false,
hidden: false,
deep_requested: false,
backfill_done: true,
stored_videos: 10,
} as unknown as ChannelDetail);
vi.spyOn(api, "tags").mockResolvedValue([] as never);
const { container } = render(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<ChannelPage
channelId={CH}
initialName="StarTalk"
me={{ id: 1, can_write: true } as unknown as Me}
view="cards"
setView={() => {}}
onShowInFeed={() => {}}
/>
</QueryClientProvider>,
);
const banner = await waitFor(() => {
const img = container.querySelector<HTMLImageElement>('img[src^="https://yt3."]');
expect(img).not.toBeNull();
return img!;
});
expect(banner.getAttribute("referrerpolicy")).toBe("no-referrer");
});
});
@@ -6,7 +6,7 @@
// ui/Button in E11 S2), so a busy toggle blocks the click with aria-disabled instead — focusable, and
// still pressed only once.
import type { ReactNode } from "react";
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { describe, it, expect, vi, beforeAll, afterEach, onTestFinished } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -97,3 +97,53 @@ describe("ChannelPage — a busy toggle keeps the keyboard", () => {
},
);
});
// The banner is retried on error (googleusercontent drops a burst); while an attempt fails and the
// next waits, a visible <img> shows the browser's broken-image glyph (seen live, E28). It stays
// invisible until a source has actually loaded.
describe("ChannelPage — the banner", () => {
it("is invisible until it loads, and again while a failed attempt is retried", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
onTestFinished(() => {
vi.useRealTimers();
});
vi.spyOn(api, "channelDetail").mockResolvedValue({
id: CH,
title: "LEMMiNO",
subscribed: true,
blocked: false,
hidden: false,
deep_requested: false,
backfill_done: true,
stored_videos: 10,
banner_url: "https://yt3.example/banner",
} as unknown as ChannelDetail);
vi.spyOn(api, "tags").mockResolvedValue([] as never);
const { container } = render(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<ChannelPage
channelId={CH}
initialName="LEMMiNO"
me={{ id: 1, can_write: true } as unknown as Me}
view="cards"
setView={() => {}}
onShowInFeed={() => {}}
/>
</QueryClientProvider>,
);
const banner = () =>
container.querySelector<HTMLImageElement>('img[src^="https://yt3.example/banner"]')!;
await waitFor(() => expect(banner()).toBeTruthy());
expect(banner().classList.contains("opacity-0")).toBe(true);
fireEvent.error(banner());
await act(async () => {
await vi.advanceTimersByTimeAsync(600);
});
expect(banner().getAttribute("src")).toContain("?r=1");
expect(banner().classList.contains("opacity-0")).toBe(true);
fireEvent.load(banner());
expect(banner().classList.contains("opacity-0")).toBe(false);
});
});
+101 -108
View File
@@ -4,6 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useLiveQuery } from "../lib/useLiveQuery";
import { channelDetailPollMs, useBackfillFeedRefresh } from "../lib/channelDetailPoll";
import { qk } from "../lib/queryKeys";
import { invalidateSubscription } from "../lib/subscriptionCache";
import { useNavigationActions } from "./navigationContext";
import { PriorityStepper } from "./ui/PriorityStepper";
import { useChannelPriority } from "../lib/useChannelPriority";
@@ -22,6 +23,7 @@ import {
} from "lucide-react";
import Avatar from "./ui/Avatar";
import Tabs from "./ui/Tabs";
import { useTabPanel } from "./ui/tabPanel";
import Feed from "./Feed";
import ChannelAboutContent from "./ChannelAbout";
import { PageToolbar } from "./ui/PageShell";
@@ -33,11 +35,13 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { notifyActionError } from "../lib/actionErrors";
import { api, DEFAULT_TAG_MODES, type FeedFilters, type Me, type ChannelDetail } from "../lib/api";
import type { FeedView } from "../lib/feedView";
import { channelYouTubeUrl, formatViews } from "../lib/format";
import { channelYouTubeUrl, formatDate, formatViews } from "../lib/format";
import { ChannelTagsControl } from "./ChannelTags";
import { useChannelTagActions } from "../lib/useChannelTagActions";
import { Button } from "./ui/Button";
import { FocusSafeButton } from "./ui/FocusSafeButton";
import { IconButton } from "./ui/IconButton";
import { IconLink } from "./ui/LinkButton";
import { PageHeading } from "./ui/Headings";
// A dedicated channel page: an "About"-style header (banner, avatar, stats, subscribe) over the
// channel's videos (the catalog filtered to this channel). For an un-subscribed channel it
@@ -74,18 +78,22 @@ export default function ChannelPage({
// inherit it instead of forcing Live/Upcoming on. The channel toolbar can still toggle these.
const globalFilters = useFeedFilters();
const [tab, setTab] = useState<"videos" | "about">("videos");
const panel = useTabPanel();
// The whole channel chrome (banner, identity, tabs, toolbar) is fixed: it portals into the shell's
// fixed band so only the video grid scrolls. This node sits at the bottom of that chrome and is
// where the channelScoped Feed portals ITS toolbar (via the overridden PageToolbarSlot below), so
// the toolbar lands under the tabs inside the fixed band rather than in the scroller.
const [chromeSlot, setChromeSlot] = useState<HTMLDivElement | null>(null);
// The banner URL is valid, but googleusercontent intermittently throttles it when the channel
// page fires the banner + avatar + ~16 thumbnails in one burst — the request is dropped and the
// browser then NEGATIVE-CACHES the miss, so the banner stays broken even though a retry would
// succeed. So on error we retry a few times with a cache-buster (forcing a fresh request), and
// only fall back to the blank bar if it keeps failing (a genuinely dead URL — rare).
// The banner comes from Google's image CDN, which refuses any request carrying a Referer from our
// origin — every time, not only in a burst (measured 2026-09-26) — so the <img> sends none
// (`referrerPolicy`, as ui/Avatar). A load can still drop, and the browser NEGATIVE-CACHES the miss,
// so on error we retry a few times with a cache-buster (forcing a fresh request), and only fall
// back to the blank bar if it keeps failing (a genuinely dead URL — rare).
const MAX_BANNER_RETRIES = 3;
const [bannerAttempt, setBannerAttempt] = useState(0);
// The banner source that has actually loaded. Until then the <img> is invisible: an attempt that
// fails shows the browser's broken-image glyph for as long as the retry waits (seen live, E28).
const [bannerLoaded, setBannerLoaded] = useState<string | null>(null);
// A LIVE query: while THIS user's full-history backfill is running, poll so the stored count (and
// the N/S/L breakdown) climb on their own — the immediate backfill fills it in seconds, and the
@@ -174,12 +182,7 @@ export default function ChannelPage({
const subscribe = useMutation({
mutationFn: () => api.subscribeChannel(channelId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.channel(channelId) });
qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: qk.channels() });
// Match ChannelDiscovery: the channel leaves discovery and the manager's stats move.
qc.invalidateQueries({ queryKey: qk.myStatus() });
qc.invalidateQueries({ queryKey: qk.discoveredChannels() });
invalidateSubscription(qc, channelId);
notify({ level: "info", message: t("channel.subscribed", { name: ch?.title ?? "" }) });
},
// A 403 (missing write scope) otherwise fails silently on the channel page (no wizard handle
@@ -188,11 +191,7 @@ export default function ChannelPage({
});
const unsubscribe = useMutation({
mutationFn: () => api.unsubscribeChannel(channelId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.channel(channelId) });
qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: qk.channels() });
},
onSuccess: () => invalidateSubscription(qc, channelId),
onError: (err) => notifyYouTubeActionError(err, t("channels.notify.unsubscribeFailed")),
});
// "Get full history": the catalog only holds the recent-backfill/RSS subset of a channel's
@@ -280,12 +279,7 @@ export default function ChannelPage({
const name = ch?.title ?? initialName ?? channelId;
const ytUrl = channelYouTubeUrl(channelId, ch?.handle);
const joined = ch?.published_at
? new Date(ch.published_at).toLocaleDateString(i18n.language, {
year: "numeric",
month: "short",
})
: null;
const joined = ch?.published_at ? formatDate(ch.published_at, i18n.language) : null;
// Handle + stats on one compact meta line under the name (saves the separate stats row).
const metaParts = [
ch?.handle ? `@${ch.handle.replace(/^@/, "")}` : null,
@@ -317,12 +311,15 @@ export default function ChannelPage({
// The channel page owns no scroller of its own (PageShell provides the one app scroller). Its
// whole chrome is fixed — wrapped in <PageToolbar> it portals into the shell's fixed band, so the
// banner/identity/tabs/toolbar stay put and only the video grid scrolls.
const bannerSrc = ch?.banner_url
? `${ch.banner_url}=w1707${bannerAttempt ? `?r=${bannerAttempt}` : ""}`
: null;
return (
<>
<PageToolbar>
{/* Banner + back — OPTION C: inset, rounded card (not full-bleed) */}
<div className="relative px-4 sm:px-6 pt-3">
{ch?.banner_url && bannerAttempt <= MAX_BANNER_RETRIES ? (
{bannerSrc && bannerAttempt <= MAX_BANNER_RETRIES ? (
// Match YouTube's banner: the stored bannerExternalUrl is the full 16:9 template at a
// low default size, so request a crisp wide version (=w1707) and object-cover the
// desktop "safe area" — the centre 2560×423 (~6:1) band.
@@ -334,9 +331,13 @@ export default function ChannelPage({
// key forces a fresh <img> per attempt; the ?r= cache-buster on a retry bypasses the
// browser's negative cache (googleusercontent accepts an extra query param).
key={bannerAttempt}
src={`${ch.banner_url}=w1707${bannerAttempt ? `?r=${bannerAttempt}` : ""}`}
src={bannerSrc}
referrerPolicy="no-referrer"
alt=""
className="w-full h-full object-cover object-center"
className={`w-full h-full object-cover object-center ${
bannerLoaded === bannerSrc ? "" : "opacity-0"
}`}
onLoad={() => setBannerLoaded(bannerSrc)}
onError={() => {
if (bannerAttempt <= MAX_BANNER_RETRIES) {
window.setTimeout(
@@ -356,14 +357,15 @@ export default function ChannelPage({
style={{ aspectRatio: "2560 / 423", maxHeight: "150px" }}
/>
)}
<button
<Button
variant="media"
data-testid="channel-back"
icon={<ArrowLeft />}
onClick={closeChannel}
className="absolute top-5 left-7 sm:left-9 z-base inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-black/45 text-white hover:bg-black/65 backdrop-blur-xs"
className="absolute top-5 left-7 sm:left-9 z-base backdrop-blur-xs"
>
<ArrowLeft className="w-4 h-4" />
{t("channel.back")}
</button>
</Button>
</div>
<div className="px-4 sm:px-6">
@@ -377,9 +379,9 @@ export default function ChannelPage({
/>
<div className="flex-1 min-w-0 pb-1">
<div className="flex items-center gap-2 flex-wrap">
<h1 data-testid="channel-title" className="text-xl font-semibold truncate">
<PageHeading data-testid="channel-title" className="text-xl font-semibold truncate">
{name}
</h1>
</PageHeading>
{ch?.blocked ? (
<span className="text-xs px-2 py-0.5 rounded-full bg-danger/15 text-danger">
{t("channel.blockedBadge")}
@@ -416,6 +418,7 @@ export default function ChannelPage({
within a few pixels of each other instead of across the page. */}
<div className="flex items-center gap-2 mt-3 border-b border-border pb-2">
<Tabs
idBase={panel.idBase}
className="flex gap-1"
active={tab}
onChange={(id) => setTab(id as "videos" | "about")}
@@ -425,25 +428,25 @@ export default function ChannelPage({
]}
/>
<div className="ml-2 flex items-center gap-2">
<a
<IconLink
href={ytUrl}
target="_blank"
rel="noreferrer"
title={t("channels.row.openOnYouTube")}
aria-label={t("channels.row.openOnYouTube")}
className="inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg border border-border"
label={t("channels.row.openOnYouTube")}
size="md"
variant="outline"
>
<ExternalLink className="w-4 h-4" />
</a>
<ExternalLink />
</IconLink>
{ch?.subscribed && (
<Button
<IconButton
label={t("channel.showInFeed")}
size="md"
variant="outline"
size="icon"
onClick={onShowInFeed}
title={t("channel.showInFeed")}
aria-label={t("channel.showInFeed")}
icon={<SlidersHorizontal className="w-4 h-4" />}
/>
>
<SlidersHorizontal className="w-4 h-4" />
</IconButton>
)}
{ch?.subscribed && !me.is_demo && (
<div className="inline-flex items-center gap-1.5 h-9 px-2.5 rounded-lg border border-border">
@@ -488,39 +491,32 @@ export default function ChannelPage({
</Button>
)}
{!me.is_demo && (
<FocusSafeButton
<IconButton
// Busy blocks the click, not the focus (FocusSafeButton). Nor does a busy toggle
// light up on hover, as if a click would do something.
size="md"
variant={ch?.blocked ? "danger-soft" : "outline-danger"}
onClick={() => block.mutate()}
busy={block.isPending}
title={ch?.blocked ? t("channel.unblock") : t("channel.block")}
aria-label={ch?.blocked ? t("channel.unblock") : t("channel.block")}
className={`inline-flex items-center justify-center w-9 h-9 rounded-lg border aria-disabled:opacity-50 transition ${
ch?.blocked
? "border-danger text-danger"
: "border-border text-muted not-aria-disabled:hover:text-danger not-aria-disabled:hover:border-danger"
}`}
loading={block.isPending}
label={ch?.blocked ? t("channel.unblock") : t("channel.block")}
>
<Ban className="w-4 h-4" />
</FocusSafeButton>
<Ban />
</IconButton>
)}
{/* Hide-from-feed toggle — subscription-scoped, so offered only on a followed channel
(mirrors the manager's Eye/EyeOff). Keeps the subscription; only drops its videos
from the feed. EyeOff = currently hidden (click to unhide). */}
{ch?.subscribed && !me.is_demo && (
<FocusSafeButton
<IconButton
size="md"
variant={ch.hidden ? "outline-accent" : "outline"}
onClick={() => toggleHidden.mutate()}
busy={toggleHidden.isPending}
loading={toggleHidden.isPending}
label={ch.hidden ? t("channel.unhide") : t("channel.hide")}
title={ch.hidden ? t("channel.hiddenHint") : t("channel.hideHint")}
aria-label={ch.hidden ? t("channel.unhide") : t("channel.hide")}
className={`inline-flex items-center justify-center w-9 h-9 rounded-lg border aria-disabled:opacity-50 transition ${
ch.hidden
? "border-accent text-accent"
: "border-border text-muted not-aria-disabled:hover:text-fg not-aria-disabled:hover:border-fg/40"
}`}
>
{ch.hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</FocusSafeButton>
{ch.hidden ? <EyeOff /> : <Eye />}
</IconButton>
)}
{!me.is_demo &&
!ch?.blocked &&
@@ -568,48 +564,45 @@ export default function ChannelPage({
{/* Tab content — the feed's toolbar portals up into the fixed chrome via this slot override;
only the grid (and About) render here, in the scroller. */}
<PageToolbarSlot.Provider value={chromeSlot}>
{tab === "videos" ? (
<>
<Feed
filters={filters}
setFilters={setFilters}
view={view}
setView={setView}
canRead={me.can_read}
isDemo={me.is_demo}
ytSearch={null}
onYtSearch={() => {}}
onExitYtSearch={() => {}}
channelScoped
/>
{!ch?.subscribed && nextToken && (
<div className="flex justify-center pb-6">
<button
// Busy blocks the click, not the focus (see Block above).
onClick={() => !exploring && runExplore(nextToken)}
aria-disabled={exploring || undefined}
aria-busy={exploring || undefined}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm border border-border not-aria-disabled:hover:bg-surface aria-disabled:opacity-50"
>
{exploring ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<RefreshCw className="w-4 h-4" />
)}
{t("channel.loadMore")}
</button>
</div>
)}
</>
) : (
<div className="px-4 sm:px-6 py-4 max-w-3xl">
{ch ? (
<ChannelAboutContent ch={ch} />
) : (
<p className="text-sm text-muted">{t("channel.noDescription")}</p>
)}
</div>
)}
<div {...panel.panelProps(tab)}>
{tab === "videos" ? (
<>
<Feed
filters={filters}
setFilters={setFilters}
view={view}
setView={setView}
canRead={me.can_read}
isDemo={me.is_demo}
ytSearch={null}
onYtSearch={() => {}}
onExitYtSearch={() => {}}
channelScoped
/>
{!ch?.subscribed && nextToken && (
<div className="flex justify-center pb-6">
<Button
// Busy blocks the click, not the focus (see Block above).
variant="outline"
icon={<RefreshCw />}
loading={exploring}
onClick={() => runExplore(nextToken)}
>
{t("channel.loadMore")}
</Button>
</div>
)}
</>
) : (
<div className="px-4 sm:px-6 py-4 max-w-3xl">
{ch ? (
<ChannelAboutContent ch={ch} />
) : (
<p className="text-sm text-muted">{t("channel.noDescription")}</p>
)}
</div>
)}
</div>
</PageToolbarSlot.Provider>
</>
);
@@ -40,6 +40,8 @@ export default function ChannelPicker({
selected={filters.channelIds}
onChange={(channelIds) => setFilters({ ...filters, channelIds })}
loading={channelsQuery.isLoading}
error={channelsQuery.isError && !channelsQuery.data ? t("common.loadError") : undefined}
onRetry={() => void channelsQuery.refetch()}
emptyText={t("sidebar.noChannelMatch")}
selectedLabels={selectedLabels}
/>
@@ -14,6 +14,8 @@ import { type MyStatus } from "../lib/api";
import { formatEta, formatNumber } from "../lib/format";
import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { Button } from "./ui/Button";
import { SURFACE } from "./ui/surfaces";
// The channel-manager catalog stats, collapsed from a crowded inline row into a compact "Stats" chip
// that reveals the numbers on hover (reuses the AnchoredPopover hover mode). One row per stat, each
@@ -77,19 +79,19 @@ export default function ChannelStatsPopover({ status }: { status: MyStatus }) {
return (
<>
<button
type="button"
<Button
variant="glass"
size="sm"
icon={<BarChart3 aria-hidden="true" />}
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm text-muted transition"
>
<BarChart3 className="h-4 w-4" aria-hidden="true" />
{t("channels.stats.label")}
</button>
</Button>
<PopoverSurface
popover={pop}
ariaLabel={t("channels.stats.label")}
className="glass-menu w-60 rounded-xl p-2 text-xs animate-[popIn_0.16s_ease]"
className={`${SURFACE.popover} w-60 p-2 text-xs animate-[popIn_0.16s_ease]`}
>
{rows.map((r) => (
<div
+56 -78
View File
@@ -1,5 +1,6 @@
import { useRef, useState } from "react";
import { Check, Plus, Sparkles, X } from "lucide-react";
import { Plus, Sparkles } from "lucide-react";
import { MenuCheck } from "./ui/MenuCheck";
import { useTranslation } from "react-i18next";
import { type Tag } from "../lib/api";
import { PopoverSurface, PopoverItem } from "./ui/AnchoredPopover";
@@ -7,7 +8,11 @@ import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { useScrollFade } from "../lib/useScrollFade";
import { fitChipsWithPill } from "../lib/chipOverflow";
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
import { FocusSafeButton } from "./ui/FocusSafeButton";
import { IconButton } from "./ui/IconButton";
import { Chip, DismissibleChip } from "./ui/Chip";
import { chipClass } from "./ui/chipStyles";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
// The channel tag surface, shared by the channel manager (table + cards) and the channel detail
// page. Built on the R8 popover platform. Two design goals it fixes over the old inline TagsCell:
@@ -25,20 +30,15 @@ interface TagChipProps {
title?: string;
}
// `block truncate` on the name, not the chip: the chip is inline-flex, where text-overflow cannot bite.
function TagChip({ tag, onClick, title }: TagChipProps) {
const cls =
"chip-solid shrink-0 text-2xs font-medium px-1.5 py-0.5 rounded-full max-w-36 truncate";
const name = <span className="block max-w-32 truncate">{tag.name}</span>;
return onClick ? (
<button
type="button"
onClick={onClick}
title={title}
className={`${cls} hover:opacity-80 transition`}
>
{tag.name}
</button>
<Chip variant="tag" size="xs" onClick={onClick} title={title}>
{name}
</Chip>
) : (
<span className={cls}>{tag.name}</span>
<span className={chipClass("tag", "xs")}>{name}</span>
);
}
@@ -63,33 +63,22 @@ function SuggestedChip({
const { t } = useTranslation();
const pct = Math.round(suggestion.confidence * 100);
return (
<span className="shrink-0 inline-flex items-center rounded-full border border-dashed border-accent/50 text-accent/90 text-2xs font-medium pl-1 pr-0.5 py-0.5 max-w-36">
<button
type="button"
onClick={onAccept}
title={t("channels.tags.suggestedHint", { pct })}
aria-label={t("channels.tags.acceptSuggestion", { name: suggestion.tag.name })}
className="inline-flex items-center gap-1 min-w-0 hover:text-accent transition"
>
<Sparkles className="w-2.5 h-2.5 shrink-0" />
<span className="truncate">{suggestion.tag.name}</span>
</button>
<button
type="button"
onClick={onDismiss}
title={t("channels.tags.dismissSuggestion")}
aria-label={t("channels.tags.dismissSuggestion")}
className="shrink-0 ml-0.5 rounded-full text-accent/60 hover:text-danger transition"
>
<X className="w-2.5 h-2.5" />
</button>
</span>
<DismissibleChip
variant="suggest"
size="xs"
className="max-w-36 font-medium"
icon={<Sparkles />}
onClick={onAccept}
bodyTitle={t("channels.tags.suggestedHint", { pct })}
bodyLabel={t("channels.tags.acceptSuggestion", { name: suggestion.tag.name })}
onDismiss={onDismiss}
dismissLabel={t("channels.tags.dismissSuggestion")}
>
{suggestion.tag.name}
</DismissibleChip>
);
}
const GHOST_PILL =
"shrink-0 text-3xs px-1.5 py-0.5 rounded-full border border-dashed border-accent/50 text-accent/90";
/** A single-line lane of suggested ghost chips that collapses its overflow into a "+N" pill (hover
* reveals the rest, still accept/dismiss-able), reusing the SAME fit machinery as the attached-chip
* lane. Rendered in the untagged-channel case only, so it owns the full tags-cell width — this is what
@@ -158,7 +147,7 @@ function SuggestedChipsLane({
<SuggestedChip suggestion={s} onAccept={() => {}} onDismiss={() => {}} />
</span>
))}
<span ref={pillRef} className={GHOST_PILL}>
<span ref={pillRef} className={chipClass("suggest", "xs")}>
+{suggestions.length}
</span>
</div>
@@ -173,22 +162,22 @@ function SuggestedChipsLane({
/>
))}
{hidden > 0 && (
<button
<Chip
variant="suggest"
size="xs"
ref={overflowPop.refs.setReference}
type="button"
aria-label={t("channels.tags.moreCount", { count: hidden })}
className={`${GHOST_PILL} hover:text-accent transition`}
{...overflowPop.getReferenceProps()}
>
+{hidden}
</button>
</Chip>
)}
</div>
{/* Hover: the overflowed suggestions, each still one-tap accept / × dismiss. */}
<PopoverSurface
popover={overflowPop}
className="glass-menu max-w-[18rem] rounded-xl p-1.5 flex flex-wrap items-center gap-1 animate-[popIn_0.16s_ease]"
className={`${SURFACE.popover} max-w-[18rem] p-1.5 flex flex-wrap items-center gap-1 animate-[popIn_0.16s_ease]`}
>
{suggestions.slice(visible).map((s) => (
<SuggestedChip
@@ -240,17 +229,11 @@ function TagEditorMenu({
checked={on}
onSelect={() => onToggle(tg.id)}
label={tg.name}
className={`w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md outline-hidden transition ${
className={`w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-lg outline-hidden transition ${
on ? "text-fg" : "text-muted"
}`}
>
<span
className={`w-3.5 h-3.5 rounded border flex items-center justify-center shrink-0 ${
on ? "bg-accent border-accent text-accent-fg" : "border-border"
}`}
>
{on && <Check className="w-2.5 h-2.5" />}
</span>
<MenuCheck kind="many" on={on} />
<span className="truncate">{tg.name}</span>
</PopoverItem>
);
@@ -260,7 +243,9 @@ function TagEditorMenu({
)}
</div>
<div className="mt-1 pt-1.5 border-t border-border flex items-center gap-1">
<input
<Input
className="flex-1"
size="sm"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
@@ -276,18 +261,15 @@ function TagEditorMenu({
}}
maxLength={64}
placeholder={t("channels.tags.createPlaceholder")}
className="flex-1 min-w-0 bg-transparent text-xs px-2 py-1 rounded-md border border-border focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
<FocusSafeButton
type="button"
<IconButton
variant="outline"
onClick={submit}
disabled={!draft.trim()}
title={t("channels.tags.create")}
aria-label={t("channels.tags.create")}
className="shrink-0 w-6 h-6 inline-flex items-center justify-center rounded-md border border-border text-muted hover:text-accent hover:border-accent disabled:opacity-40 transition"
label={t("channels.tags.create")}
>
<Plus className="w-3.5 h-3.5" />
</FocusSafeButton>
<Plus />
</IconButton>
</div>
</div>
);
@@ -381,8 +363,8 @@ export function ChannelTagsControl({
placement: "bottom",
});
const pillClass =
"shrink-0 text-3xs px-1.5 py-0.5 rounded-full bg-card text-muted border border-border";
// The "+N" is a filter-look chip (neutral): the tags themselves are the accent.
const pillClass = chipClass("filter", "xs");
return (
// When `bounded`, a FIXED width keeps the cell's footprint constant under `table-layout:auto`: a
@@ -396,21 +378,18 @@ export function ChannelTagsControl({
{/* The add trigger LEADS the row — a stable left anchor that never moves as chips are added
(they grow to its right), so its editor popover can't drift. Compact icon in the width-
bounded manager cell; a labelled "+ Tag" pill where there's free width (the detail header). */}
<button
<Chip
variant="ghost"
size="xs"
ref={editPop.refs.setReference}
type="button"
title={t("channels.row.editTags")}
aria-label={t("channels.row.editTags")}
className={
bounded
? "shrink-0 w-5 h-5 inline-flex items-center justify-center rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
: "shrink-0 inline-flex items-center gap-1 h-6 pl-1.5 pr-2 rounded-full border border-dashed border-border text-2xs font-medium text-muted hover:text-accent hover:border-accent transition"
}
icon={<Plus className="size-3" />}
className={`font-medium ${bounded ? "w-6 justify-center px-0" : ""}`}
{...editPop.getReferenceProps()}
>
<Plus className="w-3 h-3 shrink-0" />
{!bounded && <span className="whitespace-nowrap">{t("channels.tags.add")}</span>}
</button>
{!bounded && t("channels.tags.add")}
</Chip>
{/* Untagged + has suggestions → the ghost-chip lane (its own overflow "+N"); otherwise the
attached-chip lane. The two are mutually exclusive: suggestions are computed for untagged
@@ -459,15 +438,14 @@ export function ChannelTagsControl({
/>
))}
{hidden > 0 && (
<button
<Chip
size="xs"
ref={overflowPop.refs.setReference}
type="button"
aria-label={t("channels.tags.moreCount", { count: hidden })}
className={`${pillClass} hover:text-fg transition`}
{...overflowPop.getReferenceProps()}
>
+{hidden}
</button>
</Chip>
)}
</div>
</>
@@ -476,7 +454,7 @@ export function ChannelTagsControl({
{/* Hover: reveal every attached tag when some are collapsed into "+N". */}
<PopoverSurface
popover={overflowPop}
className="glass-menu max-w-[18rem] rounded-xl p-1.5 flex flex-wrap items-center gap-1 animate-[popIn_0.16s_ease]"
className={`${SURFACE.popover} max-w-[18rem] p-1.5 flex flex-wrap items-center gap-1 animate-[popIn_0.16s_ease]`}
>
{attached.map((tg) => (
<TagChip
@@ -491,7 +469,7 @@ export function ChannelTagsControl({
{/* Click: the shared editor (toggle existing + create inline). */}
<PopoverSurface
popover={editPop}
className="glass-menu w-56 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
className={`${SURFACE.menu} w-56 animate-[popIn_0.16s_ease]`}
>
<TagEditorMenu
userTags={userTags}
+301 -173
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
@@ -10,18 +10,24 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { accountKey, LS } from "../lib/storage";
import { buildChannelColumns, applySyncFilter } from "./channelColumns";
import { notify } from "../lib/notifications";
import { Badge } from "./ui/Badge";
import { Button } from "./ui/Button";
import Tooltip from "./ui/Tooltip";
import DataTable from "./ui/DataTable";
import Tabs, { type TabDef } from "./ui/Tabs";
import { useTabPanel } from "./ui/tabPanel";
import ChannelLayoutGrid from "./ChannelLayoutGrid";
import { ComfortPinMenu } from "./ComfortPinMenu";
import { pinChannel } from "./comfortPin";
import { pinChannel, useComfortPin } from "./comfortPin";
import { ContextMenu } from "./ui/ContextMenu";
import { MoreButton } from "./ui/MoreButton";
import { useChannelActions } from "./useChannelActions";
import { useContextMenu } from "./ui/useContextMenu";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { useChannelTagActions } from "../lib/useChannelTagActions";
import { useTagSuggestions } from "./useTagSuggestions";
import { useChannelPriority } from "../lib/useChannelPriority";
import { placeByPriority } from "../lib/channelOrder";
import { focusNavItem } from "../lib/listNav";
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
import ColumnSortControl from "./ui/ColumnSortControl";
import FilterChipGroup from "./ui/FilterChipGroup";
@@ -31,12 +37,18 @@ import ChannelStatsPopover from "./ChannelStatsPopover";
import Pager from "./ui/Pager";
import { CHANNEL_LAYOUTS, type ChannelLayout } from "../lib/channelLayout";
import { sortRows } from "../lib/columnSort";
import { filterRows } from "../lib/tableColumns";
import { filterRows, isActive } from "../lib/tableColumns";
import { useFilterFold } from "../lib/useFilterFold";
import { FilterFoldButton } from "./ui/FilterFold";
import { HiddenFilterCue } from "./ui/HiddenFilterCue";
import { useCardPager } from "../lib/useCardPager";
import { PageToolbar } from "./ui/PageShell";
import { PageMenu } from "./ui/PageMenu";
import { menuGroups } from "./ui/menuGroups";
import { choiceSubmenu, sortSubmenu } from "./ui/pageMenuItems";
import { optionsFromColumns } from "../lib/sortOptions";
import ChannelDiscovery from "./ChannelDiscovery";
import TagManager from "./TagManager";
import { useConfirm } from "./confirmContext";
import { useChannelLayout, useChannels, useChannelsActions } from "./channelsContext";
import { useNavigationActions } from "./navigationContext";
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
@@ -45,6 +57,9 @@ import { useMe } from "../lib/useMe";
export type ChannelsView = "subscribed" | "discovery" | "hidden" | "blocked";
// The tag filter has its own chip row above the list, so the hidden-filter cue does not name it again.
const SURFACED_FILTERS = ["tags"];
// The top sync chips are a controlled view over the subscribed table's `sync` column filter (shared
// via ChannelsProvider): each chip writes `filters.sync`, using the SAME vocab + predicate as the
// Sync column funnel (channelColumns.tsx) so the two can't drift. "" = all (isActive treats it as
@@ -62,8 +77,8 @@ const LAYOUT_ICON: Record<ChannelLayout, typeof Table> = {
export default function Channels() {
const me = useMe();
const panel = useTabPanel();
const canWrite = me.can_write;
const isAdmin = me.role === "admin";
// "Connect YouTube" onboarding lives in WizardProvider (a notify action / a mutation-error CTA).
const { openWizard: onOpenWizard } = useWizard();
// The manager's name filter, status chip, active tab, reset token, and focus-channel intent live
@@ -93,7 +108,6 @@ export default function Channels() {
const { setFilters: setFeedFilters } = useFeedFiltersActions();
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
// Clicking a tag chip filters the feed by that tag and jumps there. Internalised (was an App
// callback) now that Channels reads the feed filters + navigation straight from their providers.
@@ -129,24 +143,10 @@ export default function Channels() {
const userTags = (tagsQuery.data ?? []).filter((t) => !t.system);
const patch = useMutation({
mutationFn: (v: {
id: string;
body: { priority?: number; hidden?: boolean; deep_requested?: boolean };
}) => api.updateChannel(v.id, v.body),
meta: { failTitle: "errors.writeFailed.channelSettings" },
// Requesting full history may have just pulled in a channel's recent uploads, so
// refresh the per-user status and feed too — not only the channel list.
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: qk.myStatus() });
qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: qk.feedSummary() });
},
});
// Priority is updated optimistically in place and NOT re-sorted/refetched, so the list doesn't
// jump (the server list is priority-sorted; refetching would reorder rows and lose your scroll
// position). The new order takes effect next time the page loads. Press-and-hold is debounced +
// The row's stepper updates priority optimistically in place and does NOT re-sort/refetch, so the
// list doesn't jump under a press-and-hold (the server list is priority-sorted; refetching would
// reorder rows and lose your scroll position) — its new order shows next time the page loads. A
// pick from the row's menu DOES re-sort (`pickPriority` below). Press-and-hold is debounced +
// flushed on unmount by the shared hook (same flow the channel detail page uses).
const { setPriority, bumpPriority } = useChannelPriority({
read: (id) =>
@@ -158,6 +158,21 @@ export default function Channels() {
onError: () => qc.invalidateQueries({ queryKey: qk.channels() }),
});
// A priority step picked from the row's menu (E28 S3e3b, owner): the one thing a pick is FOR is
// to move the channel up (or down) the list, so the list re-sorts at once — only that row moves
// (`placeByPriority`, the server's order) — and the page and the focus follow it (`follow`): the
// table/card pager turns to its page, and the effect below hands the focus to its "…" (its node
// may have been re-inserted, which drops the focus to <body>, or be a virtualized card not yet
// mounted). Picked again from there, the next step is one Enter away.
const [follow, setFollow] = useState<{ key: string; seq: number } | null>(null);
const moreRefs = useRef(new Map<string, HTMLButtonElement>());
const listRef = useRef<HTMLDivElement>(null);
const pickPriority = (c: ManagedChannel, next: number) => {
setPriority(c.id, next);
qc.setQueryData<ManagedChannel[]>(qk.channels(), (old) => old && placeByPriority(old, c.id));
setFollow((f) => ({ key: c.id, seq: (f?.seq ?? 0) + 1 }));
};
// Tagging is updated optimistically in place (no refetch of the whole channel list, which
// made the toggle feel ~2s slow); only revert by refetching if the server call fails. The
// attach/detach + create-and-attach flow is shared with the channel detail page.
@@ -215,25 +230,6 @@ export default function Channels() {
},
onError: (e) => notifyYouTubeActionError(e, t("channels.notify.syncFailed"), onOpenWizard),
});
const unsubscribe = useMutation({
mutationFn: (v: { id: string; name: string }) => api.unsubscribeChannel(v.id),
onSuccess: (_d, v) => {
qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: qk.myStatus() });
notify({ level: "success", message: t("channels.notify.unsubscribed", { name: v.name }) });
},
onError: (e) =>
notifyYouTubeActionError(e, t("channels.notify.unsubscribeFailed"), onOpenWizard),
});
const resetBackfill = useMutation({
mutationFn: (v: { id: string; name: string }) => api.resetChannelBackfill(v.id),
onSuccess: (_d, v) => {
qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: qk.myStatus() });
notify({ level: "success", message: t("channels.notify.resetDone", { name: v.name }) });
},
onError: (e) => notifyYouTubeActionError(e, t("channels.notify.resetFailed"), onOpenWizard),
});
const deepAll = useMutation({
mutationFn: () => api.deepAll(true),
onSuccess: (r: { updated?: number }) => {
@@ -253,6 +249,13 @@ export default function Channels() {
// funnel share ONE state and stay in sync (bidirectional). Toggling a chip rewrites `filters.tags`;
// filterRows / DataTable then applies it. Channel-name search stays separate (the shared top box).
const tagFilter = Array.isArray(filters.tags) ? filters.tags : [];
// B4 (E28 review L7): on a short window the band's filters — the tag chip lane and the status /
// pager row — fold behind one "Filters · n"; the tabs and the TAGS editor stay. n = each picked tag,
// plus every other active column filter (the status chips are the Sync column's).
const fold = useFilterFold();
const foldCount =
tagFilter.length +
Object.entries(filters).filter(([k, v]) => k !== "tags" && isActive(v)).length;
// Picking a real tag drops the "No tag" sentinel (item B) — they're mutually exclusive, as in the feed.
const toggleTagFilter = (tagId: number) => {
const id = String(tagId);
@@ -327,24 +330,32 @@ export default function Channels() {
const onView = (c: ManagedChannel) =>
onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"));
const onUnsub = async (c: ManagedChannel) => {
const ok = await confirm({
title: t("channels.row.unsubscribeOnYoutube"),
message: t("channels.confirmUnsubscribe", { name: c.title ?? c.id }),
confirmLabel: t("channels.row.unsubscribeOnYoutube"),
danger: true,
});
if (ok) unsubscribe.mutate({ id: c.id, name: c.title ?? c.id });
};
const onReset = async (c: ManagedChannel) => {
const ok = await confirm({
title: t("channels.row.backfillThis"),
message: t("channels.confirmReset", { name: c.title ?? c.id }),
confirmLabel: t("channels.row.backfillThis"),
});
if (ok) resetBackfill.mutate({ id: c.id, name: c.title ?? c.id });
};
// The channel's ONE action list (E28 C1) — ONE menu for the list, shared by the table and the card
// layout: they are two paints of the same rows, so a right-click, a long-press and the row's "…"
// must mean the same thing in both. Only mounted under the SUBSCRIBED branch below: a discovery
// result is not a `Channel` row yet, so pinning or hiding one could only 404.
// Keyed by id: a tag toggled in the menu, a priority picked, a poll — each rebuilds the row object
// under the open menu, and its "…" must still know the menu is its own.
const rowMenu = useContextMenu<ManagedChannel>({ keyOf: (c) => c.id });
const rowPin = useComfortPin(rowMenu.open);
const channelActions = useChannelActions({
onView,
onOpenWizard,
tags: userTags,
tagsState: tagsQuery.isPending ? "loading" : tagsQuery.isError ? "error" : "ready",
onToggleTag: (c, tagId) => toggleTag(c.id, tagId, c.tag_ids.includes(tagId)),
onPickPriority: pickPriority,
});
// The row as the cache holds it NOW, not as it was when the menu opened: the Tags submenu stays
// open across toggles, and each tick (and the next toggle's attach-or-detach) reads this.
const menuRow =
rowMenu.target &&
((channelsQuery.data ?? []).find((c) => c.id === rowMenu.target!.id) ?? rowMenu.target);
const menuItems = menuRow
? channelActions.items(menuRow, {
pin: rowPin.item(pinChannel(menuRow.id), menuRow.title ?? t("channels.row.thisChannel")),
})
: [];
// Distinct priority levels present in the catalog → the priority column's filter options.
const prioOptions = [...new Set((channelsQuery.data ?? []).map((c) => c.priority))]
.sort((a, b) => b - a)
@@ -352,16 +363,25 @@ export default function Channels() {
const columns = buildChannelColumns({
t,
isAdmin,
canWrite,
prioOptions,
userTags,
bumpPriority,
setPriority,
onHide: (c) => patch.mutate({ id: c.id, body: { hidden: !c.hidden } }),
onDeep: (c) => patch.mutate({ id: c.id, body: { deep_requested: !c.deep_requested } }),
onReset,
onUnsub,
more: (c) => {
const mine = rowMenu.open && rowMenu.target?.id === c.id;
return (
<MoreButton
ref={(el) => {
if (el) moreRefs.current.set(c.id, el);
else moreRefs.current.delete(c.id);
}}
label={t("channels.row.menu")}
expanded={mine}
menuProps={rowMenu.buttonProps(c)}
className={mine ? undefined : "reveal"}
/>
);
},
onView,
toggleTag,
createAndAttachTag,
@@ -429,11 +449,6 @@ export default function Channels() {
/>
);
const hasLayout = view === "subscribed" || view === "discovery";
// The comfort-shelf pin on a channel row (E24 S3). ONE menu for the list, shared by the table and
// the card layout — they are two paints of the same rows, so a right-click must mean the same
// thing in both. Only mounted under the SUBSCRIBED branch below: a discovery result is not a
// `Channel` row yet, so pinning one could only 404.
const pinMenu = useContextMenu<ManagedChannel>();
// In cards there are no column headers to sort by, so a dropdown drives the SAME shared sort state
// the table header uses (table ↔ cards stay in sync). Table mode keeps the header clicks.
const sortControl = (
@@ -461,7 +476,32 @@ export default function Channels() {
paged: pagedCards,
pagerProps,
resetPage,
} = useCardPager(sortedChannels, LS.channelCardSize, isCards);
} = useCardPager(
sortedChannels,
LS.channelCardSize,
isCards,
follow && { seq: follow.seq, match: (c) => c.id === follow.key },
);
// The focus half of `follow` (the pagers took the page half during render). A mounted row names
// its own place; a card the virtualized grid has not mounted yet is found by its place on the page.
const focusFollowed = useEffectEvent((key: string) => {
const wrap = listRef.current;
if (!wrap) return;
const button = () => moreRefs.current.get(key) ?? null;
const item = button()?.closest("[data-nav-index]");
const at = item
? Number(item.getAttribute("data-nav-index"))
: isCards
? sortedChannels.findIndex((c) => c.id === key) -
pagerProps.page * Math.max(0, pagerProps.size)
: -1;
// Filtered out by its new priority (a Prio column filter): nothing to follow — honest, not a
// jump to some other row.
if (at >= 0) focusNavItem(wrap, at, button);
});
useEffect(() => {
if (follow) focusFollowed(follow.key);
}, [follow]);
// Changing what you're looking at starts over at the first page, the way DataTable resets its own
// page on a filter/sort change. Deliberately NOT keyed on the row count: the qk.channels() query
// refetches on window focus and after every sync, so a catalogue that merely gained a channel would
@@ -483,6 +523,7 @@ export default function Channels() {
const tabs = (
<div className="px-4 pt-2 max-w-384 mx-auto flex items-center justify-between gap-4 flex-wrap">
<Tabs
idBase={panel.idBase}
tabs={viewTabs}
active={view}
onChange={(id) => setView(id as ChannelsView)}
@@ -496,19 +537,78 @@ export default function Channels() {
</div>
);
// The page's background menu (DESIGN "Background menu"): the toolbar's own controls, no more — the
// layout, the cards' sort, the status and tag filters, the tag editor and the catalog sync. Backfill
// stays in the "…" only: a long quota-heavy run is not a thing to start from a stray right-click.
const syncOptions = SYNC_CHIPS.map((c) => ({ value: c.value, label: t(c.labelKey) }));
const pageMenuItems = menuGroups(
hasLayout
? [
choiceSubmenu({
id: "layout",
label: t("channels.layoutLabel"),
options: layoutOptions.map((o) => ({ value: o.id, label: o.label })),
selected: layout,
onPick: setLayout,
}),
]
: [],
view === "subscribed" && isCards
? [
sortSubmenu({
id: "sort",
label: t("channels.sort.label"),
options: optionsFromColumns(columns),
sort,
onChange: setSort,
ascLabel: t("channels.sort.asc"),
descLabel: t("channels.sort.desc"),
defaultLabel: t("channels.sort.default"),
}),
]
: [],
view === "subscribed"
? [
choiceSubmenu({
id: "status",
label: t("channels.filters.syncLabel"),
options: syncOptions,
selected: syncFilter,
onPick: (sync) => setFilters({ ...filters, sync }),
}),
{ id: "tags", label: t("channels.tags.edit"), onSelect: () => setTagManagerOpen(true) },
...(tagFilter.length > 0
? [
{
id: "clearTags",
label: t("channels.tags.clearFilter"),
onSelect: () => setFilters({ ...filters, tags: [] }),
},
]
: []),
]
: [],
view === "subscribed" ? catalogActions.filter((a) => a.id === "sync") : [],
);
return (
<>
{pageMenuItems.length > 0 && (
<PageMenu ariaLabel={t("common.pageMenu")} items={pageMenuItems} />
)}
{/* The tab bar is always fixed chrome (it portals into the shell's fixed band, so it never
scrolls and doesn't jump between tabs). On Subscriptions the stats/description/tags ride
with it, and the status-chip + pager row lands just below (DataTable's controlsInBand). */}
<PageToolbar>
{tabs}
{view === "subscribed" && (
<div className="px-4 pt-2 pb-2 max-w-384 mx-auto">
{/* One non-wrapping row: the TAGS editor trigger (left) + the tag-filter chip lane (folds
overflow into "+N") + Clear, then the catalog "…" actions pinned right. It never wraps, so the
"…" menu always stays on the first line. Channel-name search lives in the shared top SearchBar. */}
<div className="flex items-center gap-2 min-w-0">
<div className="@container/tagrow px-4 pt-2 pb-2 max-w-384 mx-auto">
{/* One row: the TAGS editor trigger (left) + the tag-filter chip lane (folds overflow into
"+N") + Clear, then the catalog "…" actions pinned right, so the "…" menu always stays on the
first line. Only the suggestions block wraps: below 40rem it takes a line of its own under
the row (E28 S3f3, the owner's pick A) — at 375 it had squeezed the chip lane to 0 px and
pushed the "…" off the screen. Channel-name search lives in the shared top SearchBar. */}
<div className="flex flex-wrap items-center gap-2 min-w-0">
{/* The "TAGS" label IS the tags-editor trigger (pencil), replacing the separate
"Manage tags" button; the tooltip explains what tags are + that it's clickable. */}
<Tooltip hint={t("channels.tags.yourTagsHint")}>
@@ -522,25 +622,30 @@ export default function Channels() {
{t("channels.tags.label")}
</Button>
</Tooltip>
<TagFilterChips chips={tagFilterChips} className="flex-1" />
{tagFilter.length > 0 && (
<button
<FilterFoldButton fold={fold} label={t("sidebar.filters")} count={foldCount} />
{!fold.folded && <TagFilterChips chips={tagFilterChips} className="flex-1" />}
{!fold.folded && tagFilter.length > 0 && (
<Button
variant="link-muted"
onClick={() => setFilters({ ...filters, tags: [] })}
className="shrink-0 text-xs px-2 py-1 text-muted hover:text-accent transition"
className="text-xs px-2"
>
{t("datatable.clear")}
</button>
</Button>
)}
{/* Suggested-tags affordance (E8·S4): a sparkle count of the visible suggestions, with a
one-click bulk-accept for the high-confidence ones. The per-row ghost chips handle the
rest. Hidden when there's nothing to suggest. */}
{visibleSuggestions.length > 0 && (
<div className="shrink-0 flex items-center gap-1.5">
<div
data-tag-suggestions
className="shrink-0 flex items-center gap-1.5 @max-[40rem]/tagrow:order-last @max-[40rem]/tagrow:basis-full"
>
<Tooltip hint={t("channels.tags.suggestionsHint")}>
<span className="inline-flex items-center gap-1 rounded-lg border border-dashed border-accent/50 px-2 py-1 text-xs text-accent/90">
<Sparkles className="h-3 w-3" />
<Badge tone="accent">
<Sparkles />
{t("channels.tags.suggestionsCount", { count: visibleSuggestions.length })}
</span>
</Badge>
</Tooltip>
{highConfidence.length > 0 && (
<Button variant="outline" size="xs" onClick={acceptAllHighConfidence}>
@@ -555,7 +660,7 @@ export default function Channels() {
)}
{/* In table mode the status chips + pager ride in DataTable's own in-band controls row. The card
layout has no such row, so surface the same status chips (left) and its pager (right) here. */}
{view === "subscribed" && layout === "cards" && (
{view === "subscribed" && layout === "cards" && !fold.folded && (
<div className="px-4 pb-2 max-w-384 mx-auto flex items-center justify-between gap-3 flex-wrap">
{statusChips}
{cardPager}
@@ -566,97 +671,120 @@ export default function Channels() {
<TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
)}
{view === "discovery" ? (
<ChannelDiscovery
canWrite={canWrite}
search={search}
onOpenWizard={onOpenWizard}
onViewChannel={onViewChannel}
/>
) : view === "blocked" ? (
/* Blocked channels — videos from these are hidden everywhere and dropped from live search. */
<div className="px-4 pb-4 pt-3 max-w-384 mx-auto">
<ExcludedChannelList
channels={blockedQuery.data ?? []}
icon={<Ban className="w-4 h-4 text-muted" />}
title={t("channels.blocked.title")}
hint={t("channels.blocked.hint")}
emptyText={t("channels.blocked.empty")}
actionLabel={t("channel.unblock")}
actionIcon={<X className="w-3.5 h-3.5" />}
onAction={(id) => unblock.mutate(id)}
onView={(c) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))}
<div {...panel.panelProps(view)}>
{view === "discovery" ? (
<ChannelDiscovery
canWrite={canWrite}
search={search}
onOpenWizard={onOpenWizard}
onViewChannel={onViewChannel}
/>
</div>
) : view === "hidden" ? (
/* Hidden channels — kept out of the Mine feed but still in the shared Library; managed here
) : view === "blocked" ? (
/* Blocked channels — videos from these are hidden everywhere and dropped from live search. */
<div className="px-4 pb-4 pt-3 max-w-384 mx-auto">
<ExcludedChannelList
channels={blockedQuery.data ?? []}
icon={<Ban className="w-4 h-4 text-muted" />}
title={t("channels.blocked.title")}
hint={t("channels.blocked.hint")}
emptyText={t("channels.blocked.empty")}
actionLabel={t("channel.unblock")}
actionIcon={<X className="w-3.5 h-3.5" />}
onAction={(id) => unblock.mutate(id)}
onView={(c) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))}
/>
</div>
) : view === "hidden" ? (
/* Hidden channels — kept out of the Mine feed but still in the shared Library; managed here
instead of dimmed in the subscribed table. Same card grid as Blocked (ExcludedChannelList). */
<div className="px-4 pb-4 pt-3 max-w-384 mx-auto">
<ExcludedChannelList
channels={hiddenChannels}
icon={<EyeOff className="w-4 h-4 text-muted" />}
title={t("channels.hidden.title")}
hint={t("channels.hidden.hint")}
emptyText={t("channels.hidden.empty")}
actionLabel={t("channels.hidden.unhide")}
actionIcon={<Eye className="w-3.5 h-3.5" />}
onAction={(id) => patch.mutate({ id, body: { hidden: false } })}
onView={(c) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))}
/>
</div>
) : (
/* No top padding: the sticky header sits flush at the scroller top so it doesn't shift when
<div className="px-4 pb-4 pt-3 max-w-384 mx-auto">
<ExcludedChannelList
channels={hiddenChannels}
icon={<EyeOff className="w-4 h-4 text-muted" />}
title={t("channels.hidden.title")}
hint={t("channels.hidden.hint")}
emptyText={t("channels.hidden.empty")}
actionLabel={t("channels.hidden.unhide")}
actionIcon={<Eye className="w-3.5 h-3.5" />}
onAction={channelActions.unhide}
onView={(c) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))}
/>
</div>
) : (
/* No top padding: the sticky header sits flush at the scroller top so it doesn't shift when
`position: sticky` engages on the first scroll (the band's controls row above gives the
visual gap). */
<div className="px-4 pb-4 max-w-384 mx-auto">
{/* Channel table — only the rows scroll; the header row is sticky, and the controls row
<div ref={listRef} className="px-4 pb-4 max-w-384 mx-auto">
{/* Channel table — only the rows scroll; the header row is sticky, and the controls row
(status chips + pager) rides in the fixed band right above it (controlsInBand). The
card layout swaps the table for a virtualized grid over the same rows (its pager and
status chips ride in the fixed band above instead). */}
{channelsQuery.isError && !channelsQuery.data ? (
<StateMessage tone="error" onRetry={() => channelsQuery.refetch()}>
{t("common.loadError")}
</StateMessage>
) : channelsQuery.isLoading ? (
<LoadingState label={t("channels.loading")} />
) : layout === "table" ? (
<DataTable
rows={syncedChannels}
columns={columns}
rowKey={(c) => c.id}
persistKey={accountKey(LS.channelsTable) ?? undefined}
controlsPosition="both"
controlsLeading={statusChips}
controlsInBand
controlsBandClassName="px-4 max-w-384 mx-auto"
sort={sort}
onSortChange={setSort}
filters={filters}
onFiltersChange={setFilters}
fixedLayout
emptyText={t("channels.empty")}
rowProps={pinMenu.triggerProps}
keyboardNav
/>
) : (
<ChannelLayoutGrid
rows={pagedCards}
columns={columns}
rowKey={(c) => c.id}
leadKey="priority"
aboutKey="about"
actionsKey="actions"
emptyText={t("channels.empty")}
rowProps={pinMenu.triggerProps}
/>
)}
<ComfortPinMenu
menu={pinMenu}
target={(c) => pinChannel(c.id)}
title={(c) => c.title ?? t("channels.row.thisChannel")}
/>
</div>
)}
{channelsQuery.isError && !channelsQuery.data ? (
<StateMessage tone="error" onRetry={() => channelsQuery.refetch()}>
{t("common.loadError")}
</StateMessage>
) : channelsQuery.isLoading ? (
<LoadingState shape="table" label={t("channels.loading")} />
) : layout === "table" ? (
<DataTable
rows={syncedChannels}
columns={columns}
rowKey={(c) => c.id}
persistKey={accountKey(LS.channelsTable) ?? undefined}
// Folded, the status / pager row leaves the band; the list's own end keeps it.
controlsPosition={fold.folded ? "bottom" : "both"}
controlsLeading={statusChips}
controlsInBand
controlsBandClassName="px-4 max-w-384 mx-auto"
sort={sort}
onSortChange={setSort}
filters={filters}
onFiltersChange={setFilters}
fixedLayout
emptyText={t("channels.empty")}
rowProps={rowMenu.triggerProps}
// `group`: the row's "…" reveals on its hover or focus (`.reveal`), and stays on touch.
rowClassName={() => "group"}
keyboardNav
reveal={follow}
surfacedFilters={SURFACED_FILTERS}
/>
) : (
<>
{/* Cards have no funnels: a Priority or name filter set in the table would narrow them
unseen. The tags have their own chip row. */}
<HiddenFilterCue
columns={columns}
filters={filters}
by="cards"
surfaced={SURFACED_FILTERS}
onFilter={(key, value) => setFilters({ ...filters, [key]: value })}
/>
<ChannelLayoutGrid
rows={pagedCards}
columns={columns}
rowKey={(c) => c.id}
leadKey="priority"
aboutKey="about"
moreKey="actions"
emptyText={t("channels.empty")}
rowProps={rowMenu.triggerProps}
rowClassName={() => "group"}
/>
{/* Folded, the status / pager row leaves the band, as the table's does — and the
list's own end keeps it here too (round 2, F2: the cards had lost their pager). */}
{fold.folded && (
<div className="pt-3 flex items-center justify-between gap-3 flex-wrap">
{statusChips}
{cardPager}
</div>
)}
</>
)}
<ContextMenu menu={rowMenu} ariaLabel={t("channels.row.menu")} items={menuItems} />
</div>
)}
</div>
</>
);
}
+17 -16
View File
@@ -16,6 +16,9 @@ import * as e2ee from "../lib/e2ee";
import Avatar from "./ui/Avatar";
import ChatThread from "./ChatThread";
import Overlay from "./ui/Overlay";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
import { SURFACE } from "./ui/surfaces";
// The Messenger-style floating chat dock: open conversations live here, bottom-right, across
// page navigation (state persisted per user). Always mounted (for human users) so it also owns
@@ -73,7 +76,9 @@ export default function ChatDock({ meId }: { meId: number }) {
function ChatWindow({ chat, meId }: { chat: DockChat; meId: number }) {
const { t } = useTranslation();
return (
<div className="pointer-events-auto relative w-80 max-w-[calc(100vw-2rem)] glass rounded-2xl flex flex-col overflow-hidden">
<div
className={`${SURFACE.panel} pointer-events-auto relative w-80 max-w-[calc(100vw-2rem)] flex flex-col overflow-hidden`}
>
{/* One-shot attention flash on a new message; keyed by the counter so it replays each time. */}
{chat.flash > 0 && (
<div
@@ -82,9 +87,10 @@ function ChatWindow({ chat, meId }: { chat: DockChat; meId: number }) {
/>
)}
<div className="flex items-center gap-2 px-3 py-2 border-b border-border bg-card/50">
<button
<Pressable
selectable
onClick={() => toggleMinimize(chat.partnerId)}
className="flex items-center gap-2 min-w-0 flex-1 text-left"
className="flex items-center gap-2 min-w-0 flex-1"
title={chat.minimized ? t("messages.expand") : t("messages.minimize")}
>
<Avatar
@@ -93,21 +99,16 @@ function ChatWindow({ chat, meId }: { chat: DockChat; meId: number }) {
className="w-7 h-7 rounded-full shrink-0 text-xs"
/>
<span className="font-semibold text-sm truncate">{chat.name}</span>
</button>
<button
</Pressable>
<IconButton
onClick={() => toggleMinimize(chat.partnerId)}
className="shrink-0 p-1 rounded-md text-muted hover:text-fg hover:bg-bg transition"
aria-label={chat.minimized ? t("messages.expand") : t("messages.minimize")}
label={chat.minimized ? t("messages.expand") : t("messages.minimize")}
>
{chat.minimized ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
<button
onClick={() => closeChat(chat.partnerId)}
className="shrink-0 p-1 rounded-md text-muted hover:text-fg hover:bg-bg transition"
aria-label={t("messages.close")}
>
<X className="w-4 h-4" />
</button>
{chat.minimized ? <ChevronUp /> : <ChevronDown />}
</IconButton>
<IconButton onClick={() => closeChat(chat.partnerId)} label={t("messages.close")}>
<X />
</IconButton>
</div>
{!chat.minimized && (
<div className="flex flex-col h-96">
+15 -14
View File
@@ -6,13 +6,14 @@ import { Send } from "lucide-react";
import { api, type Message, type MessageUser } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery";
import { useScrollFade } from "../lib/useScrollFade";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { relativeTime } from "../lib/format";
import { notifyActionError } from "../lib/actionErrors";
import { partnerPub, POLL_MS, refreshPartnerPub, useKeyState } from "../lib/messaging";
import * as e2ee from "../lib/e2ee";
import KeyGate from "./KeyGate";
import { Textarea } from "./ui/Input";
import { RelativeTime } from "./ui/RelativeTime";
// The message list + composer for one conversation, shared by the full Messages page and the
// floating dock window. Handles client-side decryption and encrypt-on-send; the surrounding
@@ -193,9 +194,10 @@ export default function ChatThread({
}`}
>
{text}
<div className={`text-3xs mt-1 ${mine ? "text-accent-fg/70" : "text-muted"}`}>
{relativeTime(m.created_at)}
</div>
<RelativeTime
at={m.created_at}
className={`block text-3xs mt-1 ${mine ? "text-accent-fg/70" : "text-muted"}`}
/>
</div>
</div>
);
@@ -210,7 +212,8 @@ export default function ChatThread({
</div>
) : (
<div className="flex items-end gap-2 p-2 border-t border-border">
<textarea
<Textarea
className="flex-1 resize-none max-h-28"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
@@ -221,18 +224,16 @@ export default function ChatThread({
}}
rows={1}
placeholder={t("messages.writePlaceholder")}
className="flex-1 resize-none max-h-28 bg-surface border border-border rounded-xl px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
<Button
<IconButton
label={t("messages.send")}
size="md"
variant="primary"
size="icon"
className="shrink-0"
onClick={submit}
disabled={!draft.trim() || send.isPending}
aria-label={t("messages.send")}
title={t("messages.send")}
icon={<Send className="w-4 h-4" />}
/>
>
<Send className="w-4 h-4" />
</IconButton>
</div>
)}
</>
-109
View File
@@ -1,109 +0,0 @@
import { useTranslation } from "react-i18next";
import { Bookmark, BookmarkX, Check, Eye, EyeOff, RotateCcw, Undo2 } from "lucide-react";
import { type PlexCard, type Video, type VideoStatus } from "../lib/api";
import { isResettable } from "../lib/api/feed";
import { pinPlex, pinVideo, useComfortPin, type PinTarget } from "./comfortPin";
import { type ActionMenuItem } from "./ui/ActionMenu";
import { ContextMenu } from "./ui/ContextMenu";
import { type ContextMenuController } from "./ui/useContextMenu";
// The menu SURFACE for the comfort-shelf pin (E24 S3). Split from `comfortPin.tsx` for the same
// reason `ContextMenu.tsx` is split from `useContextMenu.ts`: a file that exports components and
// nothing else keeps react-refresh working for every surface that imports it.
/**
* The whole right-click menu for a surface whose only context action is the pin — the card grids and
* rails. Render ONE per list, outside the rows (see `ui/ContextMenu`), and spread
* `menu.triggerProps` on each row.
*
* `target` maps the row the user right-clicked to its pin; `title` names it in the confirmation
* toast. Both come from the call site because only it knows what its rows are.
*/
export function ComfortPinMenu<T>({
menu,
target,
title,
}: {
menu: ContextMenuController<T>;
target: (row: T) => PinTarget | null;
title: (row: T) => string;
}) {
const { t } = useTranslation();
const pin = useComfortPin(menu.open);
const row = menu.target;
return (
<ContextMenu
menu={menu}
ariaLabel={t("home.pin.menu")}
items={row ? pin.item(target(row), title(row)) : []}
/>
);
}
/** The Plex poster grids and rails — five of them, all pinning the same way. One component so the
* show-vs-item decision and the toast's name are written once rather than five times. */
export function PlexPinMenu({ menu }: { menu: ContextMenuController<PlexCard> }) {
return <ComfortPinMenu menu={menu} target={pinPlex} title={(c) => c.title} />;
}
/** The feed's menu (the grid and a channel page): the card's own state actions — the hover row's
* buttons, which a keyboard or a touch user otherwise reaches only by Tabbing through every card —
* then the pin. Playlists and downloads stay on the card: each opens its own anchored dialog. */
export function FeedVideoMenu({
menu,
onState,
onToggleSave,
onResetState,
}: {
menu: ContextMenuController<Video>;
onState: (id: string, status: VideoStatus) => void;
onToggleSave: (id: string, saved: boolean) => void;
onResetState: (id: string) => void;
}) {
const { t } = useTranslation();
const pin = useComfortPin(menu.open);
const v = menu.target;
const icon = "w-4 h-4";
const items: ActionMenuItem[] = v
? [
v.status === "watched"
? {
id: "watched",
label: t("card.menu.unmarkWatched"),
icon: <Undo2 className={icon} />,
onSelect: () => onState(v.id, "new"),
}
: {
id: "watched",
label: t("card.markWatched"),
icon: <Check className={icon} />,
onSelect: () => onState(v.id, "watched"),
},
{
id: "save",
label: v.saved ? t("card.menu.unsave") : t("card.saveForLater"),
icon: v.saved ? <BookmarkX className={icon} /> : <Bookmark className={icon} />,
onSelect: () => onToggleSave(v.id, !v.saved),
},
{
id: "hide",
label: v.status === "hidden" ? t("card.unhide") : t("card.hide"),
icon: v.status === "hidden" ? <Eye className={icon} /> : <EyeOff className={icon} />,
onSelect: () => onState(v.id, v.status === "hidden" ? "new" : "hidden"),
},
...(isResettable(v)
? [
{
id: "reset",
label: t("card.resetState"),
icon: <RotateCcw className={icon} />,
onSelect: () => onResetState(v.id),
},
]
: []),
...pin.item(pinVideo(v.id), v.title ?? ""),
]
: [];
return <ContextMenu menu={menu} ariaLabel={t("card.menu.label")} items={items} />;
}
@@ -9,7 +9,7 @@
// jsdom does no layout, so what is asserted is the class contract those rules rest on, the same
// trade SearchBar.dom.test.tsx already makes for the narrow-header rules.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ConfigItem } from "../lib/api";
@@ -42,7 +42,12 @@ vi.mock("../lib/api", async (importOriginal) => {
api: {
...actual.api,
adminConfig: () =>
Promise.resolve({ groups: { g: [text("site_name")] }, secrets_manageable: true }),
Promise.resolve({
groups: {
g: [text("site_name"), { ...text("signups_open"), type: "bool", value: "false" }],
},
secrets_manageable: true,
}),
},
};
});
@@ -73,4 +78,21 @@ describe("ConfigPanel field row (E22 round 2)", () => {
expect(control.className).toContain("sm:shrink-0");
expect(control.className).not.toMatch(/(^|\s)shrink-0(\s|$)/);
});
it("toggles a boolean key from its title and its hint, not only from the switch (F12)", async () => {
render(
<QueryClientProvider client={new QueryClient()}>
<ConfigPanel />
</QueryClientProvider>,
);
const sw = await screen.findByRole("switch", { name: "config.fields.signups_open.label" });
expect(sw.getAttribute("aria-checked")).toBe("false");
fireEvent.click(screen.getByText("config.fields.signups_open.label"));
expect(sw.getAttribute("aria-checked")).toBe("true");
fireEvent.click(screen.getByText("config.fields.signups_open.hint"));
expect(sw.getAttribute("aria-checked")).toBe("false");
// The switch itself still toggles exactly once, not once more through its label.
fireEvent.click(sw);
expect(sw.getAttribute("aria-checked")).toBe("true");
});
});
+65 -47
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -9,6 +9,7 @@ import Tooltip from "./ui/Tooltip";
import InlineNotice from "./ui/InlineNotice";
import { useMe } from "../lib/useMe";
import Tabs from "./ui/Tabs";
import { useTabPanel } from "./ui/tabPanel";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { PageToolbar } from "./ui/PageShell";
import { Switch } from "./ui/form";
@@ -17,7 +18,10 @@ import FilterSelect from "./ui/FilterSelect";
import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar";
import { LS, useAccountPersistedState } from "../lib/storage";
import { notifyActionError } from "../lib/actionErrors";
import { FocusSafeButton } from "./ui/FocusSafeButton";
import { Button } from "./ui/Button";
import { CheckRow } from "./ui/NativeControls";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
// Admin Configuration page: edit DB-overridable operational settings (registry-driven from the
// backend — adding a key there makes it appear here automatically). Edits are drafted and
@@ -27,6 +31,7 @@ import { FocusSafeButton } from "./ui/FocusSafeButton";
export default function ConfigPanel() {
const { t } = useTranslation();
const panel = useTabPanel();
const qc = useQueryClient();
const me = useMe();
const q = useQuery({ queryKey: qk.adminConfig(), queryFn: api.adminConfig });
@@ -184,16 +189,22 @@ export default function ConfigPanel() {
return (
<>
{/* Intro + tab bar are fixed chrome; the config fields scroll under them. */}
{/* The tab bar is the page's band (B4); the intro scrolls with the fields — in the band it
took three lines of a short window for text read once. */}
<PageToolbar>
<div className="px-4 pt-3 max-w-3xl w-full mx-auto">
<p className="text-xs text-muted mb-3 leading-relaxed">{t("config.intro")}</p>
<Tabs tabs={groupTabs} active={activeGroup ?? ""} onChange={setTab} />
<Tabs
idBase={panel.idBase}
tabs={groupTabs}
active={activeGroup ?? ""}
onChange={setTab}
/>
</div>
</PageToolbar>
<div className="px-4 pb-24 pt-3 max-w-3xl w-full mx-auto">
<div className="px-4 pb-24 pt-1 max-w-3xl w-full mx-auto">
<p className="text-xs text-muted mb-3 leading-relaxed">{t("config.intro")}</p>
{activeGroup && (
<div className="glass rounded-2xl p-4 mb-4">
<div {...panel.panelProps(activeGroup ?? "")} className={`${SURFACE.panel} p-4 mb-4`}>
<div className="divide-y divide-border/60">
{(data.groups[activeGroup] ?? []).map((item) => (
<Field
@@ -218,47 +229,51 @@ export default function ConfigPanel() {
with no error anywhere — say so next to the fields that fix it. */}
{!me.email_configured && <InlineNotice>{t("config.emailOff")}</InlineNotice>}
<Tooltip hint={t("config.testEmailHint")}>
<FocusSafeButton
<Button
variant="glass"
icon={<Send />}
loading={testEmail.isPending}
aria-disabled={dirty}
onClick={() => testEmail.mutate()}
disabled={testEmail.isPending || dirty}
className="glass-card glass-hover inline-flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
<Send className={`w-4 h-4 ${testEmail.isPending ? "animate-pulse" : ""}`} />
{t("config.testEmail")}
</FocusSafeButton>
</Button>
</Tooltip>
{dirty && <p className="text-2xs text-muted">{t("config.testDirty")}</p>}
</div>
)}
{activeGroup === "issues" && (
<div className="mt-3 pt-3 border-t border-border/60">
<Tooltip hint={t("config.issuesTestHint")}>
<FocusSafeButton
<Button
variant="glass"
icon={<Plug />}
loading={testIssues.isPending}
aria-disabled={dirty}
onClick={() => testIssues.mutate()}
disabled={testIssues.isPending || dirty}
className="glass-card glass-hover inline-flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
<Plug className={`w-4 h-4 ${testIssues.isPending ? "animate-pulse" : ""}`} />
{t("config.issuesTest")}
</FocusSafeButton>
</Button>
</Tooltip>
{dirty && <p className="text-2xs text-muted mt-2">{t("config.plexTestDirty")}</p>}
{dirty && <p className="text-2xs text-muted mt-2">{t("config.testDirty")}</p>}
</div>
)}
{activeGroup === "plex" && (
<div className="mt-3 pt-3 border-t border-border/60">
<Tooltip hint={t("config.plexTestHint")}>
<FocusSafeButton
<Button
variant="glass"
icon={<Plug />}
loading={testPlex.isPending}
aria-disabled={dirty}
onClick={() => testPlex.mutate()}
disabled={testPlex.isPending || dirty}
className="glass-card glass-hover inline-flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
<Plug className={`w-4 h-4 ${testPlex.isPending ? "animate-pulse" : ""}`} />
{t("config.plexTest")}
</FocusSafeButton>
</Button>
</Tooltip>
{dirty && <p className="text-2xs text-muted mt-2">{t("config.plexTestDirty")}</p>}
{dirty && <p className="text-2xs text-muted mt-2">{t("config.testDirty")}</p>}
{plexResult && (
<div className="mt-3">
<p className="text-xs text-muted mb-2">
@@ -280,22 +295,18 @@ export default function ConfigPanel() {
<p className="text-2xs text-muted mb-1.5">{t("config.plexPickLibraries")}</p>
<div className="flex flex-col gap-1.5">
{plexResult.sections.map((s) => (
<label
<CheckRow
key={s.key}
className="inline-flex items-center gap-2 text-sm cursor-pointer"
checked={plexLibs.length === 0 || plexLibs.includes(s.key)}
onChange={() => togglePlexLib(s.key)}
className="text-sm"
>
<input
type="checkbox"
checked={plexLibs.length === 0 || plexLibs.includes(s.key)}
onChange={() => togglePlexLib(s.key)}
className="accent-accent"
/>
<span>{s.title}</span>
<span className="text-2xs text-muted">
{s.type === "movie" ? t("config.plexMovies") : t("config.plexShows")}
{s.count != null ? ` · ${s.count}` : ""}
</span>
</label>
</CheckRow>
))}
</div>
<p className="text-2xs text-muted mt-2">{t("config.plexPickHint")}</p>
@@ -367,22 +378,30 @@ function Field({
// toggles a secret's reset-on-save (applied on Save, not immediately). A boolean toggle is
// its own control — storing its value is equivalent to the default, so no reset link.
const showReset = item.is_set && !isBool;
const switchId = useId();
const Text = isBool ? "label" : "div";
return (
// Stacked below `sm`: the control column cannot shrink, and beside a wide control it left the
// label and hint a sliver of a phone's width.
<div className="flex flex-col gap-2 py-3 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">{label}</div>
<p className="text-xs text-muted leading-relaxed mt-0.5">
{/* A switch's whole text column is its label, so the title or the hint toggles it (F12); a
label may only hold phrasing content, hence the block spans. */}
<Text
{...(isBool && { htmlFor: switchId })}
className={`min-w-0 flex-1 ${isBool ? "cursor-pointer select-none" : ""}`}
>
<span className="block text-sm font-medium">{label}</span>
<span className="block text-xs text-muted leading-relaxed mt-0.5">
{t(`config.fields.${item.key}.hint`, "")}
</p>
<p className="text-2xs text-muted mt-1">{status}</p>
</div>
</span>
<span className="block text-2xs text-muted mt-1">{status}</span>
</Text>
<div className="flex max-w-full flex-col items-start gap-1.5 sm:shrink-0 sm:items-end">
{isBool ? (
<Switch
id={switchId}
label={label}
checked={value === "true"}
onChange={(v) => onChange(v ? "true" : "false")}
@@ -390,7 +409,8 @@ function Field({
) : item.type === "choice" ? (
<ChoiceField item={item} label={label} value={value} onChange={onChange} />
) : (
<input
<Input
className="w-52"
type={item.secret ? "password" : isNum ? "number" : "text"}
value={value}
disabled={secretDisabled || (item.secret && pendingSecretReset)}
@@ -399,20 +419,18 @@ function Field({
max={isNum && item.max != null ? item.max : undefined}
placeholder={item.secret ? "••••••••" : String(item.default ?? "")}
aria-label={label}
className="w-52 bg-surface border border-border rounded-xl px-3 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40 disabled:opacity-50"
/>
)}
{showReset && !secretDisabled && (
<Tooltip hint={t("config.resetHint")}>
<button
<Button
variant={item.secret && pendingSecretReset ? "link" : "link-muted"}
icon={<RotateCcw className="size-3.5!" />}
onClick={onResetField}
className={`inline-flex items-center gap-1 text-xs transition ${
item.secret && pendingSecretReset ? "text-accent" : "text-muted hover:text-fg"
}`}
className="text-xs gap-1"
>
<RotateCcw className="w-3.5 h-3.5" />
{t("config.reset")}
</button>
</Button>
</Tooltip>
)}
</div>
+5 -4
View File
@@ -1,6 +1,6 @@
import { useCallback, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import Modal from "./ui/Modal";
import Modal, { ModalFooter } from "./ui/Modal";
import { Button } from "./ui/Button";
import { ChoiceContext, type ChoiceFn, type ChoiceOptions } from "./confirmContext";
@@ -35,6 +35,7 @@ export function ConfirmProvider({ children }: { children: ReactNode }) {
{children}
{state && (
<Modal
initialFocus="action"
title={state.opts.title ?? t("common.confirmTitle")}
onClose={() => close(null)}
maxWidth={choices.length > 1 ? "max-w-md" : "max-w-sm"}
@@ -43,8 +44,8 @@ export function ConfirmProvider({ children }: { children: ReactNode }) {
{/* One action: the familiar Cancel/Confirm row. Two or more: a column, because side by
side they wrap raggedly and a wrapped button reads as a second, lesser group — and
these are peers. Ordered least to most destructive, top to bottom. */}
<div
className={multi ? "flex flex-col gap-2 mt-5" : "flex justify-end gap-2 mt-5 flex-wrap"}
<ModalFooter
className={multi ? "flex flex-col gap-2" : "flex flex-wrap justify-end gap-2"}
>
<Button
variant="outline"
@@ -66,7 +67,7 @@ export function ConfirmProvider({ children }: { children: ReactNode }) {
{c.label || t("common.confirm")}
</Button>
))}
</div>
</ModalFooter>
</Modal>
)}
</ChoiceContext.Provider>
@@ -0,0 +1,58 @@
// @vitest-environment jsdom
//
// E28 (owner): a native date field reports a complete, valid date on every keystroke of its year, so
// typing 2026 applied 0002, 0020, 0202 and 2026 — four feed refetches for one year.
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, cleanup, screen, fireEvent } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
};
});
import DateFilterSelect from "./DateFilterSelect";
import { type FeedFilters } from "../lib/api";
afterEach(cleanup);
function mount(filters: Partial<FeedFilters>) {
const setFilters = vi.fn();
render(
<DateFilterSelect
filters={filters as FeedFilters}
setFilters={setFilters}
presets={[{ days: 7, key: "1week" }]}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /sidebar\.datePresetLabel/ }));
const [from] = document.querySelectorAll<HTMLInputElement>('input[type="date"]');
return { setFilters, from: from! };
}
describe("a custom range bound", () => {
it("is applied once, when the typed year becomes a plausible upload year", () => {
const { setFilters, from } = mount({ dateTo: "2026-09-01" });
for (const day of ["0002-01-15", "0020-01-15", "0202-01-15", "2026-01-15"]) {
fireEvent.change(from, { target: { value: day } });
}
expect(setFilters).toHaveBeenCalledTimes(1);
expect(setFilters.mock.calls[0]![0]).toMatchObject({ dateFrom: "2026-01-15" });
});
it("is cleared when the field is left empty, and kept when it is left half-typed", () => {
const { setFilters, from } = mount({ dateFrom: "2026-01-15" });
fireEvent.change(from, { target: { value: "0202-01-15" } });
fireEvent.blur(from);
expect(setFilters).not.toHaveBeenCalled();
expect(from.value).toBe("2026-01-15");
fireEvent.change(from, { target: { value: "" } });
expect(setFilters).not.toHaveBeenCalled();
fireEvent.blur(from);
expect(setFilters).toHaveBeenCalledTimes(1);
expect(setFilters.mock.calls[0]![0]).toMatchObject({ dateFrom: undefined });
});
});
+70 -55
View File
@@ -1,10 +1,21 @@
import { useLayoutEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { CalendarDays, Check, ChevronDown } from "lucide-react";
import { CalendarDays } from "lucide-react";
import { MenuCheck } from "./ui/MenuCheck";
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { type FeedFilters } from "../lib/api";
import { deriveDateKey, selectDateChip, type DatePreset } from "../lib/dateFilter";
import {
deriveDateKey,
isCommittableDay,
selectDateChip,
type DatePreset,
} from "../lib/dateFilter";
import { formatDate } from "../lib/format";
import { Button } from "./ui/Button";
import { SelectTrigger } from "./ui/SelectTrigger";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
// The feed rail's "Upload date" control: a single-select dropdown (the SortControl/FilterSelect pill,
// but full-width for the rail) over "Any time" + the day presets + a "Custom range". Picking Custom
@@ -47,10 +58,7 @@ export default function DateFilterSelect({
const key = deriveDateKey(filters, customOpen, presets);
const fmt = (s: string) => {
const d = parseDay(s);
return d
? new Intl.DateTimeFormat(i18n.language, { month: "short", day: "numeric" }).format(d)
: s;
return parseDay(s) ? formatDate(s, i18n.language) : s;
};
const rangeSummary = () => {
const { dateFrom: f, dateTo: to } = filters;
@@ -91,29 +99,23 @@ export default function DateFilterSelect({
if (value !== "custom") setOpen(false); // Custom stays open to reveal the range fields
};
const dateInputCls =
"bg-surface border border-border rounded-md px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40";
return (
<>
<button
type="button"
<SelectTrigger
context="form"
open={open}
icon={<CalendarDays className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />}
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
aria-label={`${t("sidebar.datePresetLabel")}: ${summary}`}
className={`glass-card glass-hover flex w-full items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-sm transition ${
open ? "border-accent" : "border-border"
}`}
>
<CalendarDays className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
<span className="flex-1 truncate text-left text-fg">{summary}</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
</button>
{summary}
</SelectTrigger>
<PopoverSurface
popover={pop}
ariaLabel={t("sidebar.datePresetLabel")}
className="glass-menu min-w-52 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
className={`${SURFACE.menu} min-w-52 animate-[popIn_0.16s_ease]`}
>
<div role="group" aria-label={t("sidebar.datePresetLabel")}>
{options.map((o) => {
@@ -125,67 +127,46 @@ export default function DateFilterSelect({
checked={on}
onSelect={() => pick(o.value)}
label={o.label}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ${
on ? "text-fg" : "text-muted"
}`}
>
<Check className={`h-3.5 w-3.5 shrink-0 ${on ? "text-accent" : "opacity-0"}`} />
<MenuCheck kind="one" on={on} />
<span className="flex-1 truncate">{o.label}</span>
</PopoverItem>
);
})}
</div>
{/* Reveal-on-select: the from/to fields appear once Custom is the chosen option. The inputs
stop non-Escape keys so the date-segment arrows act on the field, not the menu's list nav. */}
{/* Reveal-on-select: the from/to fields appear once Custom is the chosen option. */}
{key === "custom" && (
<div className="mt-1 flex flex-col gap-1.5 border-t border-border px-1 pt-2">
<label className="flex items-center justify-between gap-2 text-xs">
<span className="text-muted">{t("sidebar.from")}</span>
<input
type="date"
value={filters.dateFrom ?? ""}
onKeyDown={(e) => {
if (e.key !== "Escape") e.stopPropagation();
}}
onChange={(e) =>
setFilters({
...filters,
dateFrom: e.target.value || undefined,
maxAgeDays: undefined,
year: undefined,
})
<DayField
value={filters.dateFrom}
onCommit={(v) =>
setFilters({ ...filters, dateFrom: v, maxAgeDays: undefined, year: undefined })
}
className={dateInputCls}
/>
</label>
<label className="flex items-center justify-between gap-2 text-xs">
<span className="text-muted">{t("sidebar.to")}</span>
<input
type="date"
value={filters.dateTo ?? ""}
onKeyDown={(e) => {
if (e.key !== "Escape") e.stopPropagation();
}}
onChange={(e) =>
setFilters({
...filters,
dateTo: e.target.value || undefined,
maxAgeDays: undefined,
year: undefined,
})
<DayField
value={filters.dateTo}
onCommit={(v) =>
setFilters({ ...filters, dateTo: v, maxAgeDays: undefined, year: undefined })
}
className={dateInputCls}
/>
</label>
{(filters.dateFrom || filters.dateTo) && (
<button
type="button"
<Button
variant="link-muted"
onClick={() => setFilters({ ...filters, dateFrom: undefined, dateTo: undefined })}
className="text-2xs text-muted hover:text-accent self-end"
className="text-2xs self-end"
>
{t("sidebar.clearDates")}
</button>
</Button>
)}
</div>
)}
@@ -193,3 +174,37 @@ export default function DateFilterSelect({
</>
);
}
// One bound of the custom range. What the field shows is a draft while it has focus (`draft ?? value`):
// the bound is applied only once the draft is a plausible day (`isCommittableDay` says why), so typing a
// year no longer refetches the feed per digit; a date picked from the calendar applies at once. Leaving
// the field settles it: emptied, the bound is cleared; half-typed or implausible, it shows the bound again.
// It stops non-Escape keys so the date-segment arrows act on the field, not the menu's list navigation.
function DayField({
value,
onCommit,
}: {
value: string | undefined;
onCommit: (day: string | undefined) => void;
}) {
const [draft, setDraft] = useState<string>();
return (
<Input
size="sm"
type="date"
value={draft ?? value ?? ""}
onKeyDown={(e) => {
if (e.key !== "Escape") e.stopPropagation();
}}
onChange={(e) => {
const day = e.target.value;
setDraft(day);
if (isCommittableDay(day) && day !== value) onCommit(day);
}}
onBlur={() => {
if (draft === "" && value) onCommit(undefined);
setDraft(undefined);
}}
/>
);
}
@@ -143,7 +143,7 @@ function DialogToggle() {
<>
<button onClick={() => setOpen((v) => !v)}>toggle dialog</button>
{open && (
<Modal title="dialog" onClose={() => setOpen(false)}>
<Modal initialFocus="close" title="dialog" onClose={() => setOpen(false)}>
<p>inside</p>
</Modal>
)}
@@ -197,7 +197,7 @@ describe("the recorder pill", () => {
const onClose = vi.fn();
mount(
{ debug_session: session() },
<Modal title="dialog" onClose={onClose}>
<Modal initialFocus="close" title="dialog" onClose={onClose}>
<p>inside</p>
</Modal>,
);
@@ -215,7 +215,7 @@ describe("the recorder pill", () => {
it("Tab from the step prompt stays inside the dialog the pill sits in", () => {
mount(
{ debug_session: session() },
<Modal title="dialog" onClose={() => {}}>
<Modal initialFocus="close" title="dialog" onClose={() => {}}>
<p>inside</p>
</Modal>,
);
+16 -15
View File
@@ -16,19 +16,20 @@ import {
useStepCount,
useStepPrompt,
} from "../lib/debugRecorder";
import { formatDuration } from "../lib/format";
import { formatDateTimeSec, formatDuration } from "../lib/format";
import { useLayer } from "../lib/layerStack";
import { notify } from "../lib/notifications";
import { qk } from "../lib/queryKeys";
import { dockUnderToasts } from "../lib/toastDock";
import { useMe } from "../lib/useMe";
import { Button } from "./ui/Button";
import Modal from "./ui/Modal";
import Modal, { ModalFooter } from "./ui/Modal";
import { LoadingState, StateMessage } from "./ui/QueryState";
import SegmentedToggle from "./ui/SegmentedToggle";
import { inputCls } from "./ui/form";
import IssueComposer from "./IssueComposer";
import { useDebugRecording } from "./useDebugRecording";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
// The debug recorder (E16): Start → reproduce → Stop.
//
@@ -96,7 +97,7 @@ function StartDialog() {
const minutes = me.diagnostics.session_minutes;
const levels: readonly DebugSessionLevel[] = ["DEBUG", "TRACE"];
return (
<Modal title={t("recorder.start.title")} onClose={closeRecorderStart}>
<Modal initialFocus="field" title={t("recorder.start.title")} onClose={closeRecorderStart}>
<div className="flex flex-col gap-4">
<p className="text-sm text-muted leading-relaxed">{t("recorder.start.intro")}</p>
<div className="flex flex-col items-start gap-1.5">
@@ -112,7 +113,7 @@ function StartDialog() {
onChange={setLevel}
/>
</div>
<div className="rounded-lg border border-border bg-surface/60 px-4 py-3 flex flex-col gap-2 text-sm leading-relaxed">
<div className={`${SURFACE.card} px-4 py-3 flex flex-col gap-2 text-sm leading-relaxed`}>
<div className="font-semibold">{t("recorder.start.whatTitle")}</div>
<p>{t(`recorder.start.what.${level}`)}</p>
<p className="text-muted">{t("settings.diagnostics.consent.never")}</p>
@@ -121,7 +122,7 @@ function StartDialog() {
{t("recorder.start.cap", { count: minutes })}
</p>
</div>
<div className="flex justify-end gap-2">
<ModalFooter>
<Button variant="ghost" onClick={closeRecorderStart}>
{t("common.cancel")}
</Button>
@@ -133,7 +134,7 @@ function StartDialog() {
>
{t("recorder.start.confirm")}
</Button>
</div>
</ModalFooter>
</div>
</Modal>
);
@@ -152,7 +153,7 @@ function RecorderPill() {
<div
role="group"
aria-label={t("recorder.pill.label")}
className={`relative glass rounded-full h-12 pl-3 sm:pl-4 pr-1.5 flex items-center gap-1.5 sm:gap-2 shadow-lg ${
className={`relative ${SURFACE.floating} h-12 pl-3 sm:pl-4 pr-1.5 flex items-center gap-1.5 sm:gap-2 ${
hidden ? "invisible" : ""
}`}
>
@@ -304,18 +305,20 @@ function StepPrompt({ id, onDone }: { id: string; onDone: (label: string | null)
}
};
return (
<div className="absolute bottom-full left-0 mb-2 w-80 max-w-[calc(100vw-2rem)] glass rounded-xl p-3 flex flex-col gap-2 shadow-lg">
<div
className={`${SURFACE.popover} absolute bottom-full left-0 mb-2 w-80 max-w-[calc(100vw-2rem)] p-3 flex flex-col gap-2`}
>
<label htmlFor="debug-step-label" className="text-sm text-muted">
{t("recorder.pill.stepPrompt", { n })}
</label>
<input
<Input
className="w-full"
id="debug-step-label"
autoFocus
value={label}
onChange={(e) => setStepPrompt(e.target.value)}
onKeyDown={onKeyDown}
maxLength={200}
className={`${inputCls} w-full`}
/>
<div className="flex items-center justify-between gap-2 text-xs text-muted">
<span>{t("recorder.pill.stepHint")}</span>
@@ -369,7 +372,7 @@ const chipTone = (s: DebugSession): string => {
/** The last sessions, newest first — reopening one opens its composer, with what was written. */
function SessionHistory() {
const { t, i18n } = useTranslation();
const { t } = useTranslation();
const q = useQuery({ queryKey: qk.debugSessions(), queryFn: api.debugSessions });
const items = q.data?.items.filter((s) => s.ended_at) ?? [];
if (q.isError && !q.data)
@@ -391,9 +394,7 @@ function SessionHistory() {
key={s.id}
className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-sm"
>
<span className="tabular-nums">
{new Date(s.started_at).toLocaleString(i18n.language)}
</span>
<span className="tabular-nums">{formatDateTimeSec(s.started_at)}</span>
<span className="min-w-0 flex-1 truncate text-muted">
{s.draft?.title || t(`settings.diagnostics.levels.${s.level}`)}
</span>
@@ -122,9 +122,7 @@ describe("DiagnosticsSettings", () => {
fireEvent.click(levelButton("settings.diagnostics.levels.DEBUG"));
fireEvent.click(screen.getByTestId("diagnostics-consent-confirm"));
await waitFor(() => expect(screen.getByTestId("diagnostics-status")).toBeTruthy());
expect(screen.getByTestId("diagnostics-status").textContent).toContain(
"settings.diagnostics.hoursMinutes:24h0m",
);
expect(screen.getByTestId("diagnostics-status").textContent).toContain("24 hr 0 min");
} finally {
vi.useRealTimers();
}
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import { api, type DiagnosticsLevel } from "../lib/api";
import { levelInForce } from "../lib/diagnostics";
import { focusIsUnclaimed } from "../lib/useFocusKeeper";
import { updateMeCache, useMe } from "../lib/useMe";
import { Button } from "./ui/Button";
import InlineNotice from "./ui/InlineNotice";
@@ -11,6 +12,7 @@ import SegmentedToggle from "./ui/SegmentedToggle";
import { Section, SettingRow } from "./ui/form";
import MyLogViewer from "./MyLogViewer";
import { RecorderSettings } from "./DebugRecorder";
import { formatUnit } from "../lib/format";
const LEVELS: readonly DiagnosticsLevel[] = ["INFO", "DEBUG", "TRACE"];
@@ -60,7 +62,7 @@ export default function DiagnosticsSettings() {
const from = closedFrom.current;
if (!from || from.isConnected) return;
closedFrom.current = null;
if (document.activeElement && document.activeElement !== document.body) return;
if (!focusIsUnclaimed()) return;
pickerRef.current?.querySelector<HTMLElement>('[aria-pressed="true"]')?.focus();
});
const watchFocus = () => {
@@ -171,8 +173,8 @@ export default function DiagnosticsSettings() {
level: levelName(current),
time:
remaining.h > 0
? t("settings.diagnostics.hoursMinutes", remaining)
: t("settings.diagnostics.minutes", remaining),
? `${formatUnit(remaining.h, "hour")} ${formatUnit(remaining.m, "minute")}`
: formatUnit(remaining.m, "minute"),
})}
</span>
</span>
+14 -10
View File
@@ -8,6 +8,7 @@ import clsx from "clsx";
// clicked, so it never ships in the feed chunk for the majority who just browse.
const DownloadDialog = lazy(() => import("./DownloadDialog"));
import { api, type Me } from "../lib/api";
import { IconButton } from "./ui/IconButton";
// Self-contained download affordance for a video card / the player. Hidden for the demo account
// (downloads spend server disk + need a real identity). Reflects the video's current state from
@@ -15,11 +16,13 @@ import { api, type Me } from "../lib/api";
export default function DownloadButton({
videoId,
title,
className,
variant = "ghost",
size = "sm",
}: {
videoId: string;
title?: string | null;
className?: string;
variant?: "ghost" | "outline" | "overlay";
size?: "sm" | "md";
}) {
const { t } = useTranslation();
const qc = useQueryClient();
@@ -47,20 +50,21 @@ export default function DownloadButton({
return (
<>
<button
<IconButton
variant={variant}
size={size}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setOpen(true);
}}
title={label}
className={clsx(
className ?? "p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
(downloaded || inQueue) && "text-accent",
)}
label={label}
>
<Download className={clsx("w-4 h-4", inQueue && "animate-pulse")} />
</button>
{/* The state is in the glyph: downloaded or queued is the accent, a running one pulses. */}
<Download
className={clsx((downloaded || inQueue) && "text-accent", inQueue && "animate-pulse")}
/>
</IconButton>
{open && (
<Suspense fallback={null}>
<DownloadDialog source={videoId} title={title} onClose={() => setOpen(false)} />
@@ -77,6 +77,24 @@ afterEach(() => {
mocks.confirm.mockClear();
});
/** Pick `label` from the row's menu, opened by its "…". The surface is visibility:hidden in jsdom
* (floating-ui never positions it), so the row is matched on its text. */
async function pick(row: string, label: string) {
const more = screen
.getByText(row)
.closest<HTMLElement>("[data-nav-index]")!
.querySelector<HTMLElement>('button[aria-label="downloads.rowMenu"]')!;
fireEvent.click(more);
const item = await waitFor(() => {
const found = screen
.getAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === label);
expect(found).toBeTruthy();
return found!;
});
fireEvent.click(item);
}
describe("DownloadCenter confirm dialogs", () => {
it("label the confirm button with the action, not the dialog's question", async () => {
mocks.jobs = [job(1, "running"), job(2, "done")];
@@ -86,12 +104,15 @@ describe("DownloadCenter confirm dialogs", () => {
</QueryClientProvider>,
);
fireEvent.click(screen.getByTitle("downloads.actions.cancel"));
// E28 C1: the destructive and rare row buttons live in the row's "…".
await pick("Clip 1", "downloads.actions.cancel");
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
// The tab's label shares its button with a count badge.
fireEvent.click(screen.getByRole("button", { name: /downloads\.tabs\.done/ }));
fireEvent.click(await screen.findByTitle("downloads.actions.redownload"));
fireEvent.click(screen.getByTitle("downloads.actions.delete"));
fireEvent.click(screen.getByRole("tab", { name: /downloads\.tabs\.done/ }));
await screen.findByText("Clip 2");
await pick("Clip 2", "downloads.actions.redownload");
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(2));
await pick("Clip 2", "downloads.actions.delete");
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(3));
expect(mocks.confirm.mock.calls.map(([o]) => [o.title, o.confirmLabel])).toEqual([
@@ -0,0 +1,333 @@
// @vitest-environment jsdom
//
// E28 C1 on the Download Center: a download row keeps its primary action (play; pause / resume /
// retry in the queue) and the save link in sight, and everything else — the editors, sharing,
// re-download, the copies, cancel / delete / remove — is its ONE menu, opened by the row's "…" (it
// shows on hover or focus) or a right-click on the row. A link inside the row keeps the browser's own
// menu. i18n is key-passthrough, so labels read as keys.
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { api, type DownloadJob } from "../lib/api";
import { menuGroups } from "./ui/menuGroups";
const mocks = vi.hoisted(() => ({
jobs: [] as unknown[],
confirm: vi.fn(async (_o?: unknown) => true),
}));
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
vi.mock("./confirmContext", () => ({ useConfirm: () => mocks.confirm }));
vi.mock("../lib/useLiveQuery", () => ({
useLiveQuery: (key: readonly unknown[]) => ({
data: key[0] === "downloads" ? mocks.jobs : undefined,
isError: false,
isLoading: false,
refetch: vi.fn(),
}),
}));
vi.mock("../lib/useMe", () => ({ useMe: () => ({ role: "user" }) }));
vi.mock("./ui/PageShell", async (importOriginal) => ({
...(await importOriginal<typeof import("./ui/PageShell")>()),
PageToolbar: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
import DownloadCenter from "./DownloadCenter";
const job = (
id: number,
status: DownloadJob["status"],
extra: Partial<DownloadJob> = {},
): DownloadJob =>
({
id,
status,
progress: 0,
speed_bps: null,
eta_s: null,
phase: null,
error: null,
error_code: null,
error_detail: null,
queue_pos: 0,
created_at: null,
source_kind: "youtube",
source_ref: `v${id}`,
source_url: null,
poster_url: null,
profile_id: null,
spec: { mode: "av" },
display_name: `Clip ${id}`,
display_uploader: null,
display_uploader_url: null,
extra_links: [],
can_download: status === "done",
expired: false,
job_kind: "download",
asset: { media_kind: "video", duration_s: 60 },
...extra,
}) as unknown as DownloadJob;
afterEach(() => {
cleanup();
vi.restoreAllMocks();
mocks.confirm.mockClear();
});
function mount(tab?: string) {
render(
<QueryClientProvider client={new QueryClient()}>
<DownloadCenter />
</QueryClientProvider>,
);
if (tab)
fireEvent.click(screen.getByRole("tab", { name: new RegExp(`downloads\\.tabs\\.${tab}`) }));
}
const rowOf = (text: string) => screen.getByText(text).closest<HTMLElement>("[data-nav-index]")!;
const moreOf = (text: string) =>
rowOf(text).querySelector<HTMLElement>('button[aria-label="downloads.rowMenu"]')!;
const menuRows = () =>
screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent ?? "");
async function openMenu(open: () => void) {
act(open);
await waitFor(() =>
expect(screen.getAllByRole("menuitem", { hidden: true }).length).toBeGreaterThan(0),
);
}
const rowButtons = (text: string) =>
[...rowOf(text).querySelectorAll<HTMLElement>("button, a[aria-label]")].map((b) =>
b.getAttribute("aria-label"),
);
describe("DownloadCenter · the queue row", () => {
it("shows pause and its '…' only; the '…' hides until hover or focus", async () => {
mocks.jobs = [job(1, "running")];
mount();
expect(rowButtons("Clip 1")).toEqual(["downloads.actions.pause", "downloads.rowMenu"]);
expect(moreOf("Clip 1").classList.contains("reveal")).toBe(true);
expect(rowOf("Clip 1").classList.contains("group")).toBe(true);
});
it("holds pause, the copies and cancel; a failed job's first row is Retry", async () => {
mocks.jobs = [job(1, "running", { source_url: "https://youtu.be/x" }), job(2, "error")];
mount();
await openMenu(() => fireEvent.click(moreOf("Clip 1")));
expect(menuRows()).toEqual([
"downloads.actions.pause",
"common.copy.title",
"common.copy.link",
"downloads.actions.cancel",
]);
expect(moreOf("Clip 1").getAttribute("aria-expanded")).toBe("true");
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
expect(menuRows()).toEqual([
"downloads.actions.retry",
"common.copy.title",
"downloads.actions.cancel",
]);
});
it("draws Retry as 'do it again', never the Play glyph (row and menu)", async () => {
mocks.jobs = [job(1, "error"), job(2, "paused")];
mount();
const glyph = (el: Element) => el.querySelector("svg")?.getAttribute("class") ?? "";
const retry = rowOf("Clip 1").querySelector('[aria-label="downloads.actions.retry"]')!;
expect(glyph(retry)).toContain("lucide-refresh-cw");
// A paused job resumes: that one keeps the transport glyph.
const resume = rowOf("Clip 2").querySelector('[aria-label="downloads.actions.resume"]')!;
expect(glyph(resume)).toContain("lucide-play");
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 1")));
const row = screen
.getAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === "downloads.actions.retry")!;
expect(glyph(row)).toContain("lucide-refresh-cw");
});
it("closes on a second press of the same '…'", async () => {
mocks.jobs = [job(1, "running")];
mount();
await openMenu(() => fireEvent.click(moreOf("Clip 1")));
// The press lands outside the panel, which floating-ui's dismiss closes on; the click after
// it must not open it again.
act(() => {
fireEvent.pointerDown(moreOf("Clip 1"), { pointerType: "mouse" });
fireEvent.mouseDown(moreOf("Clip 1"));
fireEvent.click(moreOf("Clip 1"), { detail: 1 });
});
await waitFor(() =>
expect(screen.queryAllByRole("menuitem", { hidden: true })).toHaveLength(0),
);
expect(moreOf("Clip 1").getAttribute("aria-expanded")).toBe("false");
});
it("still closes when a poll replaced the job under the open menu", async () => {
mocks.jobs = [job(1, "running")];
mount();
await openMenu(() => fireEvent.click(moreOf("Clip 1")));
// The queue polls while a job runs: the next render lists a NEW object for the same job.
mocks.jobs = [job(1, "running", { progress: 40 })];
fireEvent.change(screen.getByPlaceholderText("downloads.page.addUrl"), {
target: { value: "x" },
});
act(() => {
fireEvent.pointerDown(moreOf("Clip 1"), { pointerType: "mouse" });
fireEvent.mouseDown(moreOf("Clip 1"));
fireEvent.click(moreOf("Clip 1"), { detail: 1 });
});
await waitFor(() =>
expect(screen.queryAllByRole("menuitem", { hidden: true })).toHaveLength(0),
);
});
it("cancels only through the confirm", async () => {
mocks.jobs = [job(1, "queued")];
const cancel = vi.spyOn(api, "cancelDownload").mockResolvedValue({} as never);
mount();
const pickCancel = async () => {
await openMenu(() => fireEvent.click(moreOf("Clip 1")));
fireEvent.click(
screen
.getAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === "downloads.actions.cancel")!,
);
};
mocks.confirm.mockResolvedValueOnce(false);
await pickCancel();
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
expect(mocks.confirm).toHaveBeenCalledWith(expect.objectContaining({ danger: true }));
expect(cancel).not.toHaveBeenCalled();
await pickCancel();
await waitFor(() => expect(cancel).toHaveBeenCalledWith(1));
});
});
describe("DownloadCenter · the library row", () => {
it("shows play, share, save and its '…'; the editors, managing shares and delete are in the menu", async () => {
mocks.jobs = [job(2, "done", { source_url: "https://youtu.be/x" })];
mount("done");
await screen.findByText("Clip 2");
expect(rowButtons("Clip 2")).toEqual([
"downloads.actions.play",
"downloads.actions.share",
"downloads.actions.download",
"downloads.rowMenu",
]);
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
expect(menuRows()).toEqual([
"downloads.actions.play",
"downloads.actions.share",
"downloads.actions.download",
"downloads.actions.edit",
"downloads.actions.editDetails",
"downloads.actions.redownload",
"downloads.actions.shareManage",
"common.copy.title",
"common.copy.link",
"downloads.actions.delete",
]);
});
it("copies the share link from the row's own Share button", async () => {
mocks.jobs = [job(2, "done")];
vi.spyOn(api, "downloadLinks").mockResolvedValue([] as never);
const create = vi
.spyOn(api, "createDownloadLink")
.mockResolvedValue({ id: 1, url: "/s/tok" } as never);
mount("done");
await screen.findByText("Clip 2");
fireEvent.click(
rowOf("Clip 2").querySelector<HTMLElement>('button[aria-label="downloads.actions.share"]')!,
);
await waitFor(() => expect(create).toHaveBeenCalledWith(2, {}));
expect(screen.queryAllByRole("menuitem", { hidden: true })).toHaveLength(0);
});
it("saves the file through the same link as the row's save button", async () => {
mocks.jobs = [job(2, "done")];
const followed: string[] = [];
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (
this: HTMLAnchorElement,
) {
followed.push(this.href);
});
mount("done");
await screen.findByText("Clip 2");
await openMenu(() => fireEvent.click(moreOf("Clip 2")));
fireEvent.click(
screen
.getAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === "downloads.actions.download")!,
);
const button = rowOf("Clip 2").querySelector<HTMLAnchorElement>(
'a[aria-label="downloads.actions.download"]',
)!;
expect(followed).toEqual([button.href]);
});
it("leaves out what a file or a clip cannot do, and no rule is left behind", async () => {
mocks.jobs = [job(3, "done", { job_kind: "upload", asset: { media_kind: "file" } } as never)];
mount("done");
await screen.findByText("Clip 3");
await openMenu(() => fireEvent.click(moreOf("Clip 3")));
expect(menuRows()).toEqual([
"downloads.actions.share",
"downloads.actions.download",
"downloads.actions.shareManage",
"common.copy.title",
"downloads.actions.delete",
]);
});
it("gives a link inside the row the browser's own menu", async () => {
mocks.jobs = [job(2, "done", { source_url: "https://youtu.be/x" })];
mount("done");
const link = await screen.findByText("youtu.be/x");
const ev = new MouseEvent("contextmenu", { bubbles: true, cancelable: true });
act(() => void link.dispatchEvent(ev));
expect(ev.defaultPrevented).toBe(false);
expect(screen.queryAllByRole("menuitem", { hidden: true })).toHaveLength(0);
});
});
describe("DownloadCenter · the shared row", () => {
it("holds play, save, edit, the copies and Remove from my list", async () => {
mocks.jobs = [];
vi.spyOn(api, "downloadsShared").mockResolvedValue([
job(4, "done", { can_download: true }),
] as never);
const remove = vi.spyOn(api, "removeSharedDownload").mockResolvedValue({} as never);
mount("shared");
await screen.findByText("Clip 4");
await openMenu(() => fireEvent.click(moreOf("Clip 4")));
expect(menuRows()).toEqual([
"downloads.actions.play",
"downloads.actions.download",
"downloads.actions.edit",
"common.copy.title",
"downloads.actions.removeShared",
]);
fireEvent.click(
screen
.getAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === "downloads.actions.removeShared")!,
);
await waitFor(() => expect(remove).toHaveBeenCalledWith(4));
});
});
describe("menuGroups", () => {
const row = (id: string) => ({ id, label: id, onSelect: () => {} });
it("rules between non-empty groups only", () => {
const out = menuGroups([], [row("a"), row("b")], [], [row("c")]);
expect(out.map((r) => [r.id, !!r.separatorBefore])).toEqual([
["a", false],
["b", false],
["c", true],
]);
});
});
+114 -205
View File
@@ -1,25 +1,21 @@
import { lazy, Suspense, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import {
Ban,
ClipboardPaste,
Download,
Link2,
Pause,
Play,
PlayCircle,
Plus,
RotateCcw,
Scissors,
RefreshCw,
Settings2,
Share2,
Pencil,
Trash2,
UploadCloud,
} from "lucide-react";
import Tabs from "./ui/Tabs";
import { useTabPanel } from "./ui/tabPanel";
import { PageToolbar } from "./ui/PageShell";
import { LoadingState, StateMessage } from "./ui/QueryState";
// Lazy: these dialogs/editors open on demand, so they stay out of the Downloads page chunk until
@@ -30,28 +26,32 @@ const ProfileEditor = lazy(() => import("./ProfileEditor"));
const VideoEditor = lazy(() => import("./VideoEditor"));
const ShareDialog = lazy(() => import("./ShareDialog"));
const LibraryPlayer = lazy(() => import("./LibraryPlayer"));
import { useConfirm } from "./confirmContext";
import { useLiveQuery } from "../lib/useLiveQuery";
import { useMe } from "../lib/useMe";
import { api, type DownloadJob } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { invalidateDownloads, quickShareLink } from "../lib/downloads";
import { libraryRowActions, sharedRowActions } from "../lib/downloadActions";
import { inputCls } from "./ui/form";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { DownloadRow, IconBtn, UsageBar } from "./downloadRow";
import { MetaEditModal, ErrorModal } from "./downloadModals";
import { AdminSystem } from "./downloadAdmin";
import NavGrid from "./ui/NavGrid";
import { Input } from "./ui/Input";
import { IconLink } from "./ui/LinkButton";
import { useContextMenu } from "./ui/useContextMenu";
import { ContextMenu } from "./ui/ContextMenu";
import { MoreButton } from "./ui/MoreButton";
import { useDownloadActions, type DownloadTab } from "./useDownloadActions";
const jobKey = (job: DownloadJob) => job.id;
export default function DownloadCenter() {
const me = useMe();
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
// Always land on the queue tab (not a persisted last-tab) — the queue is where new work and
// any failures are, so it's the useful default every visit.
const [tab, setTab] = useState("queue");
const panel = useTabPanel();
const [addUrl, setAddUrl] = useState("");
const [addSource, setAddSource] = useState<string | null>(null);
const [upload, setUpload] = useState(false);
@@ -78,56 +78,35 @@ export default function DownloadCenter() {
const queue = jobs.filter((j) => ["queued", "running", "paused", "error"].includes(j.status));
const library = jobs.filter((j) => j.status === "done");
const invalidate = () => invalidateDownloads(qc);
const act = useMutation({
mutationFn: ({
id,
op,
}: {
id: number;
op: "pause" | "resume" | "cancel" | "delete" | "redownload";
}) =>
op === "pause"
? api.pauseDownload(id)
: op === "resume"
? api.resumeDownload(id)
: op === "cancel"
? api.cancelDownload(id)
: op === "redownload"
? api.redownloadDownload(id)
: api.deleteDownload(id),
onSuccess: invalidate,
// A 400/409/422/500 opens the global error dialog with the backend's own message (and is then
// skipped here); a job that is gone (404) or a lost connection is said here, naming the action.
onError: (e, v) => notifyActionError(e, t(`errors.writeFailed.job.${v.op}`)),
// --- The rows' ONE menu (E28 C1) -------------------------------------------------------------
// Keyed by the job's id: the queue polls every 2 s while a job runs, replacing the object under an
// open menu, and its "…" must still close it. The tab is the one on screen.
const rowMenu = useContextMenu<DownloadJob>({ keyOf: jobKey });
const actions = useDownloadActions({
onPlay: setPlayJob,
onEdit: setEditJob,
onEditDetails: setMetaJob,
onManageShares: setShareJob,
});
// Remove a shared-with-me item from your list (only your grant; the owner's file is untouched).
const removeShared = useMutation({
mutationFn: (id: number) => api.removeSharedDownload(id),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.downloadsShared() }),
meta: { failTitle: "errors.writeFailed.removeShared" },
});
// The confirm button names the ACTION ("Remove"), never the dialog's question: labelled with the title
// it read "Re-download this file?", and the cancel dialog's own "Cancel" would sit beside "Cancel".
const confirmThen = async (title: string, body: string, action: string, fn: () => void) => {
if (await confirm({ title, message: body, confirmLabel: action, danger: true })) fn();
};
// One-click share (E7 S3 · D2-A): ensure a downloadable link, copy it, toast with "open in new tab"
// (see quickShareLink). Re-clicking reuses the existing link; a per-job in-flight guard also stops a
// double-click from racing two POSTs into two links before the cache is primed. Advanced controls
// (password, expiry, revoke, share-with-a-user) live in the Share dialog, via the manage-share button.
const [sharingId, setSharingId] = useState<number | null>(null);
const quickShare = async (job: DownloadJob) => {
if (sharingId !== null) return;
setSharingId(job.id);
try {
await quickShareLink(qc, job, t);
} finally {
setSharingId(null);
}
const menuTarget = rowMenu.target;
const listTab: DownloadTab | null =
tab === "queue" || tab === "done" || tab === "shared" ? tab : null;
const menuItems = menuTarget && listTab ? actions.items(menuTarget, listTab) : [];
// The row's menu triggers and its "…".
const rowMenuProps = (job: DownloadJob) => {
const mine = rowMenu.open && rowMenu.target?.id === job.id;
return {
rowProps: rowMenu.triggerProps(job),
more: (
<MoreButton
label={t("downloads.rowMenu")}
size="md"
expanded={mine}
menuProps={rowMenu.buttonProps(job)}
className={mine ? undefined : "reveal"}
/>
),
};
};
// Pull a link off the clipboard into the add-URL box. Clipboard reads need a secure context +
@@ -158,56 +137,57 @@ export default function DownloadCenter() {
if (!tabs.some((tb) => tb.id === tab)) setTab("queue");
const saveBtn = (job: DownloadJob) => (
<a
href={api.downloadFileUrl(job.id)}
title={t("downloads.actions.download")}
className="p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg transition"
>
<Download className="w-4 h-4" />
</a>
<IconLink href={api.downloadFileUrl(job.id)} label={t("downloads.actions.download")} size="md">
<Download />
</IconLink>
);
return (
<>
{/* Page title + subtitle + tabs are fixed chrome; the tab content scrolls under them. */}
{/* The tabs are the page's band (B4); the tab content scrolls under them. No visible title: the
nav rail and the tab title name the page, the shell's hidden h1 names it to a screen reader. */}
<PageToolbar>
<div className="px-4 pt-2 max-w-4xl w-full mx-auto">
<div className="flex items-center justify-between gap-3 mb-1">
<h1 className="text-xl font-semibold">{t("downloads.page.title")}</h1>
<button
onClick={() => setManage(true)}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition"
>
<Settings2 className="w-4 h-4" /> {t("downloads.page.manage")}
</button>
</div>
<p className="text-sm text-muted mb-4">{t("downloads.page.subtitle")}</p>
<Tabs tabs={tabs} active={tab} onChange={setTab} />
<div className="px-4 pt-2 pb-2 max-w-4xl w-full mx-auto flex flex-wrap items-center justify-between gap-2">
<Tabs
idBase={panel.idBase}
tabs={tabs}
active={tab}
onChange={setTab}
className="flex flex-wrap gap-1"
/>
<Button
variant="ghost"
size="sm"
icon={<Settings2 />}
onClick={() => setManage(true)}
className="shrink-0"
>
{t("downloads.page.manage")}
</Button>
</div>
</PageToolbar>
<div className="px-4 pb-4 pt-3 max-w-4xl w-full mx-auto">
<div {...panel.panelProps(tab)} className="px-4 pb-4 pt-3 max-w-4xl w-full mx-auto">
{tab === "queue" && (
<>
<div className="flex gap-2 mb-4">
<input
{/* The field takes the whole line when the buttons would squeeze it (at 375 it had 57px). */}
<div className="flex flex-wrap gap-2 mb-4">
<Input
className="min-w-0 flex-[1_1_16rem]"
value={addUrl}
onChange={(e) => setAddUrl(e.target.value)}
onFocus={() => pasteFromClipboard()}
onKeyDown={(e) => e.key === "Enter" && addUrl.trim() && setAddSource(addUrl.trim())}
placeholder={t("downloads.page.addUrl")}
className={inputCls}
/>
<Button
variant="ghost"
size="sm"
iconOnly
icon={<ClipboardPaste className="w-4 h-4" />}
<IconButton
label={t("downloads.page.paste")}
size="sm"
variant="ghost"
onClick={() => pasteFromClipboard(true)}
className="shrink-0"
/>
>
<ClipboardPaste className="w-4 h-4" />
</IconButton>
<Button
variant="primary"
size="sm"
@@ -230,41 +210,38 @@ export default function DownloadCenter() {
</div>
<NavGrid className="space-y-2">
{queue.map((job, i) => (
<DownloadRow key={job.id} job={job} navIndex={i} onShowError={setErrorJob}>
{(job.status === "queued" || job.status === "running") && (
<DownloadRow
key={job.id}
job={job}
navIndex={i}
onShowError={setErrorJob}
{...rowMenuProps(job)}
>
{/* The queue's primary action stays in sight; cancel moved into the "…". */}
{job.status === "queued" || job.status === "running" ? (
<IconBtn
onClick={() => act.mutate({ id: job.id, op: "pause" })}
onClick={() => actions.run(job, "pause")}
title={t("downloads.actions.pause")}
>
<Pause className="w-4 h-4" />
</IconBtn>
)}
{(job.status === "paused" || job.status === "error") && (
) : (
<IconBtn
onClick={() => act.mutate({ id: job.id, op: "resume" })}
onClick={() => actions.run(job, "resume")}
title={t(
job.status === "error"
? "downloads.actions.retry"
: "downloads.actions.resume",
)}
>
<Play className="w-4 h-4" />
{/* Retry is "do it again", not playback: the Play glyph promised a video. */}
{job.status === "error" ? (
<RefreshCw className="w-4 h-4" />
) : (
<Play className="w-4 h-4" />
)}
</IconBtn>
)}
<IconBtn
onClick={() =>
confirmThen(
t("downloads.confirm.cancelTitle"),
t("downloads.confirm.cancelBody"),
t("downloads.confirm.cancelAction"),
() => act.mutate({ id: job.id, op: "cancel" }),
)
}
title={t("downloads.actions.cancel")}
danger
>
<Ban className="w-4 h-4" />
</IconBtn>
</DownloadRow>
))}
{queue.length === 0 &&
@@ -273,7 +250,7 @@ export default function DownloadCenter() {
{t("common.loadError")}
</StateMessage>
) : jobsQ.isLoading ? (
<LoadingState />
<LoadingState shape="list" />
) : (
<div className="text-sm text-muted py-10 text-center">
{t("downloads.empty.queue")}
@@ -287,82 +264,31 @@ export default function DownloadCenter() {
<>
<UsageBar />
<NavGrid className="space-y-2">
{library.map((job, i) => {
// Row actions gated on the stored media_kind + job_kind (see downloadActions).
const a = libraryRowActions(job);
return (
<DownloadRow key={job.id} job={job} navIndex={i}>
{a.play && (
<IconBtn
open
onClick={() => setPlayJob(job)}
title={t("downloads.actions.play")}
>
<PlayCircle className="w-4 h-4" />
</IconBtn>
)}
{saveBtn(job)}
{a.edit && (
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
<Scissors className="w-4 h-4" />
</IconBtn>
)}
{a.editDetails && (
<IconBtn
onClick={() => setMetaJob(job)}
title={t("downloads.actions.editDetails")}
>
<Pencil className="w-4 h-4" />
</IconBtn>
)}
<IconBtn onClick={() => quickShare(job)} title={t("downloads.actions.share")}>
<Share2 className="w-4 h-4" />
</IconBtn>
{library.map((job, i) => (
<DownloadRow key={job.id} job={job} navIndex={i} {...rowMenuProps(job)}>
{libraryRowActions(job).play && (
<IconBtn
onClick={() => setShareJob(job)}
title={t("downloads.actions.shareManage")}
open
onClick={() => setPlayJob(job)}
title={t("downloads.actions.play")}
>
<Link2 className="w-4 h-4" />
<PlayCircle className="w-4 h-4" />
</IconBtn>
{a.redownload && (
<IconBtn
onClick={() =>
confirmThen(
t("downloads.confirm.redownloadTitle"),
t("downloads.confirm.redownloadBody"),
t("downloads.confirm.redownloadAction"),
() => act.mutate({ id: job.id, op: "redownload" }),
)
}
title={t("downloads.actions.redownload")}
>
<RotateCcw className="w-4 h-4" />
</IconBtn>
)}
<IconBtn
onClick={() =>
confirmThen(
t("downloads.confirm.deleteTitle"),
t("downloads.confirm.deleteBody"),
t("downloads.actions.delete"),
() => act.mutate({ id: job.id, op: "delete" }),
)
}
title={t("downloads.actions.delete")}
danger
>
<Trash2 className="w-4 h-4" />
</IconBtn>
</DownloadRow>
);
})}
)}
{/* Sharing the link is the library's main errand: in sight, ahead of save. */}
<IconBtn onClick={() => actions.share(job)} title={t("downloads.actions.share")}>
<Share2 className="w-4 h-4" />
</IconBtn>
{saveBtn(job)}
</DownloadRow>
))}
{library.length === 0 &&
(jobsQ.isError && !jobsQ.data ? (
<StateMessage tone="error" onRetry={() => jobsQ.refetch()}>
{t("common.loadError")}
</StateMessage>
) : jobsQ.isLoading ? (
<LoadingState />
<LoadingState shape="list" />
) : (
<div className="text-sm text-muted py-10 text-center">
{t("downloads.empty.done")}
@@ -378,7 +304,7 @@ export default function DownloadCenter() {
// Same media_kind + can_download gate as the library, via the shared helper (S5b).
const a = sharedRowActions(job);
return (
<DownloadRow key={job.id} job={job} navIndex={i}>
<DownloadRow key={job.id} job={job} navIndex={i} {...rowMenuProps(job)}>
{a.play && (
<IconBtn
open
@@ -389,25 +315,6 @@ export default function DownloadCenter() {
</IconBtn>
)}
{a.save && saveBtn(job)}
{a.edit && (
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
<Scissors className="w-4 h-4" />
</IconBtn>
)}
<IconBtn
onClick={() =>
confirmThen(
t("downloads.confirm.removeSharedTitle"),
t("downloads.confirm.removeSharedBody"),
t("downloads.actions.removeShared"),
() => removeShared.mutate(job.id),
)
}
title={t("downloads.actions.removeShared")}
danger
>
<Trash2 className="w-4 h-4" />
</IconBtn>
</DownloadRow>
);
})}
@@ -417,7 +324,7 @@ export default function DownloadCenter() {
{t("common.loadError")}
</StateMessage>
) : sharedQ.isLoading ? (
<LoadingState />
<LoadingState shape="list" />
) : (
<div className="text-sm text-muted py-10 text-center">
{t("downloads.empty.shared")}
@@ -428,6 +335,8 @@ export default function DownloadCenter() {
{tab === "system" && me.role === "admin" && <AdminSystem />}
<ContextMenu menu={rowMenu} ariaLabel={t("downloads.rowMenu")} items={menuItems} />
<Suspense fallback={null}>
{addSource && (
<DownloadDialog
@@ -447,7 +356,7 @@ export default function DownloadCenter() {
{errorJob && (
<ErrorModal
job={errorJob}
onRetry={() => act.mutate({ id: errorJob.id, op: "resume" })}
onRetry={() => actions.run(errorJob, "resume")}
onClose={() => setErrorJob(null)}
/>
)}
+9 -13
View File
@@ -2,7 +2,7 @@ import { lazy, Suspense, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import Modal from "./ui/Modal";
import Modal, { ModalFooter } from "./ui/Modal";
import { Button } from "./ui/Button";
import { api } from "../lib/api";
import { invalidateDownloads } from "../lib/downloads";
@@ -12,8 +12,8 @@ const ProfileEditor = lazy(() => import("./ProfileEditor"));
import { notify } from "../lib/notifications";
import { notifyActionError } from "../lib/actionErrors";
import { navigateTo } from "../lib/nav";
import { inputCls } from "./ui/form";
import FilterSelect from "./ui/FilterSelect";
import { Input } from "./ui/Input";
// Preset picker shown when the user hits Download on a card / in the player, or adds a URL from
// the Download Center. Enqueues via the API and points the user at the Download Center.
@@ -63,7 +63,7 @@ export default function DownloadDialog({
};
return (
<Modal title={t("downloads.dialog.title")} onClose={onClose}>
<Modal initialFocus="field" title={t("downloads.dialog.title")} onClose={onClose}>
{title && <p className="text-sm text-muted mb-4 line-clamp-2">{title}</p>}
<span className="block text-sm font-medium mb-1" aria-hidden="true">
@@ -77,30 +77,26 @@ export default function DownloadDialog({
onChange={([v]) => v && setProfileId(Number(v))}
options={profiles.map((p) => ({ value: String(p.id), label: p.name }))}
/>
<button
type="button"
onClick={() => setManage(true)}
className="text-xs text-accent hover:underline mt-1 mb-4"
>
<Button variant="link" onClick={() => setManage(true)} className="text-xs mt-1 mb-4">
{t("downloads.dialog.manage")}
</button>
</Button>
<label className="block text-sm font-medium mb-1">{t("downloads.dialog.nameLabel")}</label>
<input
<Input
className="w-full"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("downloads.dialog.namePlaceholder")}
className={`${inputCls} mb-5`}
/>
<div className="flex justify-end gap-2">
<ModalFooter>
<Button variant="ghost" size="sm" onClick={onClose}>
{t("downloads.actions.cancel")}
</Button>
<Button variant="primary" size="sm" onClick={submit} disabled={busy || !chosen}>
{t("downloads.dialog.submit")}
</Button>
</div>
</ModalFooter>
{manage && (
<Suspense fallback={null}>
+4 -6
View File
@@ -3,6 +3,7 @@ import i18n from "../i18n";
import { notify } from "../lib/notifications";
import { errorAttrs, log } from "../lib/log";
import { reloadingForNewBuild } from "../lib/staleBuild";
import { Button } from "./ui/Button";
interface Props {
children: ReactNode;
@@ -40,18 +41,15 @@ export default class ErrorBoundary extends Component<Props, State> {
if (this.state.crashed) {
if (reloadingForNewBuild()) return null;
return (
<div className="min-h-screen grid place-items-center text-muted p-6 text-center">
<div className="min-h-dvh grid place-items-center text-muted p-6 text-center">
<div>
<div className="text-lg font-semibold text-fg mb-1">
{i18n.t("errors.boundary.title")}
</div>
<div className="text-sm mb-3">{i18n.t("errors.boundary.subtitle")}</div>
<button
onClick={() => location.reload()}
className="text-accent hover:underline text-sm font-semibold"
>
<Button variant="link" onClick={() => location.reload()} className="text-sm">
{i18n.t("errors.boundary.reload")}
</button>
</Button>
</div>
</div>
);
+4 -4
View File
@@ -1,7 +1,7 @@
import { useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { dismissError, getError, subscribeError } from "../lib/errorDialog";
import Modal from "./ui/Modal";
import Modal, { ModalFooter } from "./ui/Modal";
import { Button } from "./ui/Button";
// Renders the current app error (if any) as a modal the user must acknowledge.
@@ -10,11 +10,11 @@ export default function ErrorDialog() {
const err = useSyncExternalStore(subscribeError, getError, getError);
if (!err) return null;
return (
<Modal title={err.title} onClose={dismissError} maxWidth="max-w-md">
<Modal initialFocus="close" title={err.title} onClose={dismissError} maxWidth="max-w-md">
<p className="text-sm leading-relaxed text-fg/90">{err.message}</p>
<div className="flex justify-end mt-5">
<ModalFooter>
<Button onClick={dismissError}>{t("errors.ok")}</Button>
</div>
</ModalFooter>
</Modal>
);
}
@@ -2,6 +2,9 @@ import { type ReactNode } from "react";
import ChannelLink from "./ChannelLink";
import NavGrid from "./ui/NavGrid";
import { navItemAttrs } from "../lib/listNav";
import { Button } from "./ui/Button";
import { SURFACE } from "./ui/surfaces";
import { SectionLabel } from "./ui/Headings";
// The presentation shared by the channel manager's "excluded channel" tabs — Hidden and Blocked.
// A responsive grid of proper channel cards (avatar + name + open-on-YouTube, via ChannelLink) each
@@ -40,14 +43,14 @@ export default function ExcludedChannelList({
<>
<div className="flex items-center gap-2 mb-3">
{icon}
<span className="text-xs uppercase tracking-wide text-muted">{title}</span>
<SectionLabel>{title}</SectionLabel>
</div>
<NavGrid className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{channels.map((c, i) => (
<div
key={c.id}
{...navItemAttrs(i)}
className="glass-card flex items-center gap-2 rounded-xl px-3 py-2"
className={`${SURFACE.card} flex items-center gap-2 px-3 py-2`}
>
<div className="min-w-0 flex-1">
<ChannelLink
@@ -58,15 +61,9 @@ export default function ExcludedChannelList({
onView={onView ? () => onView(c) : undefined}
/>
</div>
<button
onClick={() => onAction(c.id)}
title={actionLabel}
aria-label={actionLabel}
className="shrink-0 inline-flex items-center gap-1 rounded-full border border-border px-2 py-1 text-xs text-muted transition hover:border-accent hover:text-accent"
>
{actionIcon}
<Button variant="outline" size="xs" icon={actionIcon} onClick={() => onAction(c.id)}>
{actionLabel}
</button>
</Button>
</div>
))}
</NavGrid>
@@ -0,0 +1,107 @@
// @vitest-environment jsdom
//
// "Reset — clear watch progress/status" on a half-watched video. The optimistic layer overrode only
// the STATUS, so the card kept its resume position — its progress bar and its Continue button — until
// the next refetch (found in the E28 S3e1 UAT). The grid is replaced by a list that shows what Feed
// hands it: each video's position, and a button that fires the reset.
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
}));
vi.mock("./wizardContext", () => ({ useWizard: () => ({ openWizard: vi.fn() }) }));
vi.mock("./VirtualFeed", () => ({
default: ({
items,
onResetState,
}: {
items: { id: string; position_seconds: number }[];
onResetState: (id: string) => void;
}) => (
<ol aria-label="results">
{items.map((v) => (
<li key={v.id}>
{v.id}@{v.position_seconds}
<button onClick={() => onResetState(v.id)}>reset {v.id}</button>
</li>
))}
</ol>
),
}));
import Feed from "./Feed";
import { api, DEFAULT_TAG_MODES, type FeedFilters, type Video } from "../lib/api";
import { qk } from "../lib/queryKeys";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
const FILTERS: FeedFilters = {
tags: [],
tagModes: DEFAULT_TAG_MODES,
channelIds: [],
q: "",
sort: "newest",
scope: "my",
includeNormal: true,
includeShorts: false,
includeLive: false,
show: "unwatched",
};
const halfWatched = {
id: "v1",
title: "v1",
channel_id: "UCx",
channel_title: "Channel",
status: "new",
position_seconds: 95,
duration_seconds: 600,
published_at: "2026-01-01T00:00:00Z",
watch_url: "https://www.youtube.com/watch?v=v1",
} as unknown as Video;
describe("Feed — resetting a half-watched video", () => {
it("clears its resume position at once, not only its status", async () => {
vi.spyOn(api, "feed").mockResolvedValue({ items: [halfWatched], next_cursor: null } as never);
vi.spyOn(api, "feedSummary").mockResolvedValue({ count: 1, facets: {} } as never);
// Never settles: what the card shows before the server answers is the optimistic layer alone.
vi.spyOn(api, "clearState").mockReturnValue(new Promise(() => {}) as never);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} });
render(
<QueryClientProvider client={qc}>
<Feed
filters={FILTERS}
setFilters={() => {}}
view="cards"
setView={() => {}}
canRead
ytSearch=""
onYtSearch={() => {}}
onExitYtSearch={() => {}}
/>
</QueryClientProvider>,
);
await waitFor(() => expect(screen.getByText("v1@95")).toBeTruthy());
fireEvent.click(screen.getByText("reset v1"));
await waitFor(() => expect(screen.getByText("v1@0")).toBeTruthy());
});
});
+205 -101
View File
@@ -44,14 +44,19 @@ import SelectedViewChip from "./SelectedViewChip";
import { Button } from "./ui/Button";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { PageToolbar } from "./ui/PageShell";
import { PageMenu } from "./ui/PageMenu";
import { menuGroups } from "./ui/menuGroups";
import { choiceSubmenu, multiSubmenu, sortSubmenu } from "./ui/pageMenuItems";
import { useWizard } from "./wizardContext";
import SortControl from "./ui/SortControl";
import FilterSelect from "./ui/FilterSelect";
import ItemCount from "./ui/ItemCount";
import { FilterFoldButton } from "./ui/FilterFold";
import { useFilterFold } from "../lib/useFilterFold";
import ViewSwitcher from "./ViewSwitcher";
import VirtualFeed from "./VirtualFeed";
import { FocusSafeButton } from "./ui/FocusSafeButton";
import SegmentedToggle from "./ui/SegmentedToggle";
import { IconButton } from "./ui/IconButton";
// Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened.
const PlayerModal = lazy(() => import("./PlayerModal"));
@@ -136,11 +141,16 @@ export default function Feed({
const { openWizard: onOpenWizard } = useWizard();
// Same list the panel's badge counts — see lib/useActiveFilters.
const activeFilters = useActiveFilters(filters, setFilters);
// B4: on a short window the filter row folds into one "Filters · n" button (ui/FilterFold).
const fold = useFilterFold();
const sortKeys = channelScoped
? SORT_KEYS.filter((k) => k !== "subscribers" && k !== "priority")
: SORT_KEYS;
const [overrides, setOverrides] = useState<Record<string, VideoStatus>>({});
const [savedOverrides, setSavedOverrides] = useState<Record<string, boolean>>({});
// A reset drops the resume position too, which a status override cannot say: without this the card
// kept its progress bar and its Continue button until the next refetch.
const [resetIds, setResetIds] = useState<Record<string, true>>({});
// The open player: which video and where to start (null = resume from saved position).
const [activeVideo, setActiveVideo] = useState<{ video: Video; startAt: number | null } | null>(
null,
@@ -234,6 +244,7 @@ export default function Feed({
setOverridesFor(filters);
setOverrides({});
setSavedOverrides({});
setResetIds({});
}
// ...but NOT on infinite-scroll appends: fetchNextPage also bumps dataUpdatedAt while page 1's
// rows are unchanged, so clearing overrides there would make a just-hidden card flash back.
@@ -246,6 +257,7 @@ export default function Feed({
if (!appended) {
setOverrides({});
setSavedOverrides({});
setResetIds({});
}
}, [query.dataUpdatedAt, pagesLen]);
@@ -346,6 +358,7 @@ export default function Feed({
// Reset to pristine (drop the whole VideoState row, incl. resume position) — the
// un-watch toggle can't clear an in-progress position, this can.
setOverrides((o) => ({ ...o, [id]: "new" }));
setResetIds((r) => ({ ...r, [id]: true }));
api
.clearState(id)
// Stale-mark only (see setState above): the optimistic override already reset the card.
@@ -357,6 +370,11 @@ export default function Feed({
delete next[id];
return next;
});
setResetIds((r) => {
const next = { ...r };
delete next[id];
return next;
});
});
},
[qc],
@@ -391,6 +409,7 @@ export default function Feed({
const status = overrides[v.id]; // read once so the narrowing sticks
return status ? { ...v, status } : v;
})
.map((v) => (resetIds[v.id] ? { ...v, position_seconds: 0 } : v))
.map((v) => {
const saved = savedOverrides[v.id];
return saved !== undefined ? { ...v, saved } : v;
@@ -436,7 +455,10 @@ export default function Feed({
<div className="p-4">
<div className="pb-3 mb-1 border-b border-border">
<div className="flex items-center gap-2 flex-wrap">
<button
<Button
variant="ghost"
size="sm"
icon={<ArrowLeft />}
onClick={() => {
// Surface the just-searched videos in the feed you return to: switch the Source
// filter to "search" (your own search finds) and rank them by relevance.
@@ -444,11 +466,9 @@ export default function Feed({
onExitYtSearch();
dropFeedCaches();
}}
className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-accent transition shrink-0"
>
<ArrowLeft className="w-4 h-4" />
{t("feed.yt.back")}
</button>
</Button>
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
<Youtube className="w-4 h-4 text-accent shrink-0" />
<span className="text-sm font-semibold truncate">
@@ -462,6 +482,7 @@ export default function Feed({
<div className="ml-auto flex items-center gap-1.5 text-xs text-muted shrink-0">
<span aria-hidden="true">{t("feed.yt.show")}</span>
<SegmentedToggle
size="md"
ariaLabel={t("feed.yt.show")}
title={t("feed.yt.showHint")}
value={String(ytCount)}
@@ -484,7 +505,10 @@ export default function Feed({
<div className="flex items-center gap-2 flex-wrap mt-2 text-xs text-muted">
<Info className="w-3.5 h-3.5 shrink-0" />
<span>{t("feed.yt.ephemeralNote")}</span>
<button
<Button
variant="outline-danger"
size="xs"
icon={<Trash2 />}
onClick={() => {
const ids = ytItems.map((v) => v.id);
api
@@ -500,11 +524,9 @@ export default function Feed({
dropFeedCaches();
qc.removeQueries({ queryKey: qk.ytSearch() });
}}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full border border-border text-muted hover:text-danger hover:border-danger transition"
>
<Trash2 className="w-3 h-3" />
{t("feed.yt.clearNow")}
</button>
</Button>
</div>
)}
</div>
@@ -530,17 +552,17 @@ export default function Feed({
/>
{ytQuery.hasNextPage && (
<div className="text-center pt-4">
<FocusSafeButton
<Button
variant="outline"
onClick={() => ytQuery.fetchNextPage()}
disabled={ytQuery.isFetchingNextPage}
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl border border-border bg-card text-sm font-medium hover:border-accent hover:text-accent disabled:opacity-50 transition"
loading={ytQuery.isFetchingNextPage}
>
{ytQuery.isFetchingNextPage
? t("feed.loadingMore")
: zeroQuota
? t("feed.yt.loadMoreFree")
: t("feed.yt.loadMore")}
</FocusSafeButton>
</Button>
</div>
)}
</>
@@ -561,7 +583,7 @@ export default function Feed({
);
}
if (query.isLoading) return <LoadingState label={t("feed.loading")} />;
if (query.isLoading) return <LoadingState shape="grid" label={t("feed.loading")} />;
if (query.isError && !query.data)
return (
<StateMessage tone="error" onRetry={() => query.refetch()}>
@@ -594,12 +616,13 @@ export default function Feed({
<Button variant="primary" size="lg" onClick={onOpenWizard}>
{t("feed.setUp")}
</Button>
<button
<Button
variant="link-muted"
className="mx-auto mt-3 flex text-sm"
onClick={() => setFilters({ ...filters, scope: "all" })}
className="block mx-auto mt-3 text-sm text-muted hover:text-accent transition"
>
{t("feed.browseShared")}
</button>
</Button>
</>
)}
</div>
@@ -617,43 +640,60 @@ export default function Feed({
// Sort + view mode are tightly bound to the grid below, so they live pinned to the right of the
// applied-filter row (directly above the cards), not up in the filter toolbar. Extracted here so the
// same controls render on both the feed and channel-scoped rows.
// One setter per toolbar control, shared with the page's background menu (the same choices, the
// same effect, two doors).
const setSort = (next: SortState) => {
if (!next) return; // the feed is always sorted — no "no sort" option is offered
setFilters({
...filters,
sort: buildSort(next.key as SortKey, next.dir),
seed: next.key === "shuffle" ? rollSeed() : undefined,
});
};
const reshuffle = () => setFilters({ ...filters, seed: rollSeed() });
const showOptions = SHOW_IDS.map((id) => ({ value: id, label: t("sidebar.show." + id) }));
const setShow = (show: string) => setFilters({ ...filters, show: show as FeedFilters["show"] });
const contentOptions = CONTENT.map((c) => ({ value: c.key, label: t(c.label) }));
const contentSelected = CONTENT.filter((c) => filters[c.key]).map((c) => c.key);
const setContent = (next: string[]) => {
const patch = { ...filters };
for (const c of CONTENT) patch[c.key] = next.includes(c.key);
setFilters(patch);
};
const sourceOptions = [
{ value: "organic", label: t("feed.source.organic") },
{ value: "all", label: t("feed.source.all") },
{ value: "search", label: t("feed.source.only") },
];
const setSource = (source: string) =>
setFilters({ ...filters, librarySource: source as FeedFilters["librarySource"] });
const viewOptions = FEED_VIEWS.map((id) => ({
id,
label: t("feed.view." + id),
icon: VIEW_ICON[id],
}));
const sortView = (
<>
<SortControl
options={sortOptions}
sort={{ key: sortKey, dir: sortDir }}
onChange={(next) => {
if (!next) return; // the feed is always sorted — no "no sort" option is offered
setFilters({
...filters,
sort: buildSort(next.key as SortKey, next.dir),
seed: next.key === "shuffle" ? rollSeed() : undefined,
});
}}
onChange={setSort}
label={t("feed.sortLabel")}
ascLabel={t("feed.dirAsc")}
descLabel={t("feed.dirDesc")}
trailing={
sortKey === "shuffle" ? (
<button
onClick={() => setFilters({ ...filters, seed: rollSeed() })}
title={t("sidebar.reshuffle")}
aria-label={t("sidebar.reshuffle")}
className="shrink-0 p-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
>
<RefreshCw className="w-4 h-4" />
</button>
<IconButton variant="outline" onClick={reshuffle} label={t("sidebar.reshuffle")}>
<RefreshCw />
</IconButton>
) : undefined
}
/>
<span className="mx-0.5 h-5 w-px bg-border" aria-hidden="true" />
<ViewSwitcher
value={view}
options={FEED_VIEWS.map((id) => ({
id,
label: t("feed.view." + id),
icon: VIEW_ICON[id],
}))}
options={viewOptions}
onChange={setView}
label={t("feed.viewLabel")}
// The mode's name only earns its place once the window is wide.
@@ -668,55 +708,117 @@ export default function Feed({
{/* The item count is the fixed leading element — a fixed-width pill so a changing number
never shoves the controls beside it (see ItemCount). */}
<ItemCount count={summaryQuery.data?.count} />
{/* Watch state is single-select (always exactly one); content types are an independent
multi-select. Both compress to one labeled dropdown pill via FilterSelect. */}
<FilterSelect
label={t("feed.showLabel")}
testId="feed-show"
options={SHOW_IDS.map((id) => ({ value: id, label: t("sidebar.show." + id) }))}
selected={[filters.show]}
onChange={(next) => setFilters({ ...filters, show: next[0] as FeedFilters["show"] })}
<FilterFoldButton
fold={fold}
label={t("sidebar.filters")}
count={channelScoped ? 0 : activeFilters.length}
/>
<FilterSelect
label={t("feed.contentLabel")}
testId="feed-content"
multiple
options={CONTENT.map((c) => ({ value: c.key, label: t(c.label) }))}
selected={CONTENT.filter((c) => filters[c.key]).map((c) => c.key)}
onChange={(next) => {
const patch = { ...filters };
for (const c of CONTENT) patch[c.key] = next.includes(c.key);
setFilters(patch);
}}
/>
{/* The Source filter (organic / include-search / search-only) is a global-catalog concept —
it selects how videos ENTERED the library. On a channel-scoped view we already show all
of this one channel's videos (librarySource is pinned to "all"), so the selector would
only offer a near-empty, confusing slice — hide it here. */}
{!channelScoped && (
<FilterSelect
label={t("feed.source.label")}
testId="feed-source"
options={[
{ value: "organic", label: t("feed.source.organic") },
{ value: "all", label: t("feed.source.all") },
{ value: "search", label: t("feed.source.only") },
]}
selected={[filters.librarySource ?? "organic"]}
onChange={(next) =>
setFilters({
...filters,
librarySource: next[0] as FeedFilters["librarySource"],
})
}
/>
{!fold.folded && (
<>
{/* Watch state is single-select (always exactly one); content types are an
independent multi-select. Both compress to one labeled dropdown pill via FilterSelect. */}
<FilterSelect
label={t("feed.showLabel")}
testId="feed-show"
options={showOptions}
selected={[filters.show]}
onChange={(next) => next[0] && setShow(next[0])}
/>
<FilterSelect
label={t("feed.contentLabel")}
testId="feed-content"
multiple
options={contentOptions}
selected={contentSelected}
onChange={setContent}
/>
{/* The Source filter (organic / include-search / search-only) is a global-catalog
concept — it selects how videos ENTERED the library. On a channel-scoped view we
already show all of this one channel's videos (librarySource is pinned to "all"), so
the selector would only offer a near-empty, confusing slice — hide it here. */}
{!channelScoped && (
<FilterSelect
label={t("feed.source.label")}
testId="feed-source"
options={sourceOptions}
selected={[filters.librarySource ?? "organic"]}
onChange={(next) => next[0] && setSource(next[0])}
/>
)}
</>
)}
{/* Folded, the band is one row: Sort + View join it on the right. */}
{fold.folded && (
<div className="ml-auto flex shrink-0 items-center gap-1.5">{sortView}</div>
)}
</div>
</div>
);
// The page's background menu (DESIGN "Background menu"): the toolbar's own controls, no more.
const pageMenuItems = menuGroups(
[
sortSubmenu({
id: "sort",
label: t("feed.sortLabel"),
options: sortOptions,
sort: { key: sortKey, dir: sortDir },
onChange: setSort,
ascLabel: t("feed.dirAsc"),
descLabel: t("feed.dirDesc"),
}),
...(sortKey === "shuffle"
? [{ id: "reshuffle", label: t("sidebar.reshuffle"), onSelect: reshuffle }]
: []),
choiceSubmenu({
id: "view",
label: t("feed.viewLabel"),
options: viewOptions.map((o) => ({ value: o.id, label: o.label })),
selected: view,
onPick: setView,
}),
],
[
choiceSubmenu({
id: "show",
label: t("feed.showLabel"),
options: showOptions,
selected: filters.show,
onPick: setShow,
}),
multiSubmenu({
id: "content",
label: t("feed.contentLabel"),
options: contentOptions,
selected: contentSelected,
onChange: setContent,
}),
...(channelScoped
? []
: [
choiceSubmenu({
id: "source",
label: t("feed.source.label"),
options: sourceOptions,
selected: filters.librarySource ?? "organic",
onPick: setSource,
}),
]),
],
!channelScoped && activeFilters.length > 0
? [
{
id: "clear",
label: t("sidebar.clearAll"),
onSelect: () => setFilters(clearedFilters(filters)),
},
]
: [],
);
return (
<div className={channelScoped ? "px-4 sm:px-6 pb-4" : "px-4 pb-4 pt-1"}>
<PageMenu ariaLabel={t("common.pageMenu")} items={pageMenuItems} />
{/* The toolbar (+ active-filter chips) is FIXED chrome: it portals into a PageToolbarSlot so
it stays put while the cards scroll (bug 4). On the feed page that slot is the shell's top
band; on a channel page ChannelPage overrides the slot with its own sticky sub-header, so
@@ -732,20 +834,22 @@ export default function Feed({
applied"), but still get the right-pinned Sort/View. */}
{/* Wraps on a phone: the chips and Sort + View did not fit one line, and the row pushed the
page 21px wide (prod UAT 2026-09-19). Sort + View then take a line of their own. */}
<div className="flex flex-wrap items-center gap-2 pb-2">
{!channelScoped && (
<>
{/* The active personal view (if any) leads the applied-filter row. */}
<SelectedViewChip />
<ActiveFilterChips
filters={activeFilters}
onClearAll={() => setFilters(clearedFilters(filters))}
className="flex-1"
/>
</>
)}
<div className="ml-auto flex shrink-0 items-center gap-1.5">{sortView}</div>
</div>
{!fold.folded && (
<div className="flex flex-wrap items-center gap-2 pb-2">
{!channelScoped && (
<>
{/* The active personal view (if any) leads the applied-filter row. */}
<SelectedViewChip />
<ActiveFilterChips
filters={activeFilters}
onClearAll={() => setFilters(clearedFilters(filters))}
className="flex-1"
/>
</>
)}
<div className="ml-auto flex shrink-0 items-center gap-1.5">{sortView}</div>
</div>
)}
</div>
</PageToolbar>
{items.length === 0 ? (
@@ -757,23 +861,23 @@ export default function Feed({
{filters.channelIds.length > 0 && filters.scope === "my" && (
<div className="mt-4">
<p className="text-sm">{t("feed.channelScopeHint")}</p>
<button
<Button
className="mt-3"
icon={<Library />}
onClick={() => setFilters({ ...filters, scope: "all" })}
className="mt-3 inline-flex items-center gap-2 px-4 py-2 rounded-xl font-semibold btn-primary transition"
>
<Library className="w-4 h-4" />
{t("feed.viewInLibrary")}
</button>
</Button>
</div>
)}
{filters.q.trim() && !isDemo && (
<button
<Button
className="mt-4"
icon={<Youtube />}
onClick={() => onYtSearch(filters.q.trim())}
className="mt-4 inline-flex items-center gap-2 px-4 py-2 rounded-xl font-semibold btn-primary transition"
>
<Youtube className="w-4 h-4" />
{t("feed.yt.searchFor", { query: filters.q.trim() })}
</button>
</Button>
)}
</div>
) : (
@@ -1,134 +0,0 @@
// @vitest-environment jsdom
//
// The feed card's right-click menu (prod UAT 2026-09-19): it carried only the comfort-shelf pin, so
// the card's own state actions — watched, saved, hidden, reset — were reachable only by hovering or
// by Tabbing through every card. Each row fires the same handler the card's button does.
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { FloatingTree } from "@floating-ui/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Video } from "../lib/api";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
};
});
vi.mock("../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/api")>();
return { ...actual, api: { ...actual.api, comfortPins: () => Promise.resolve({ items: [] }) } };
});
import { FeedVideoMenu } from "./ComfortPinMenu";
import { useContextMenu } from "./ui/useContextMenu";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
afterEach(cleanup);
// The surface is visibility:hidden in jsdom (floating-ui never positions), so an item has no
// accessible name there: matched on its text, as ContextMenu.dom.test.tsx does.
const labels = () => screen.queryAllByRole("menuitem", { hidden: true }).map((b) => b.textContent);
const item = (label: string) => {
const found = screen
.queryAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === label);
if (!found) throw new Error(`no menuitem "${label}" in [${labels().join(", ")}]`);
return found;
};
const video = (extra: Partial<Video>): Video =>
({
id: "v1",
title: "A video",
status: "new",
saved: false,
position_seconds: 0,
...extra,
}) as Video;
type Handlers = {
onState: (id: string, status: string) => void;
onToggleSave: (id: string, saved: boolean) => void;
onResetState: (id: string) => void;
};
function Feed({ v, handlers }: { v: Video; handlers: Handlers }) {
const menu = useContextMenu<Video>();
return (
<>
<div {...menu.triggerProps(v)}>card</div>
<FeedVideoMenu
menu={menu}
onState={handlers.onState}
onToggleSave={handlers.onToggleSave}
onResetState={handlers.onResetState}
/>
</>
);
}
function openMenu(v: Video) {
const handlers = { onState: vi.fn(), onToggleSave: vi.fn(), onResetState: vi.fn() };
render(
<QueryClientProvider client={new QueryClient()}>
<FloatingTree>
<Feed v={v} handlers={handlers} />
</FloatingTree>
</QueryClientProvider>,
);
fireEvent.contextMenu(screen.getByText("card"));
return { handlers };
}
describe("the feed card's menu", () => {
it("offers the card's own actions on a fresh video, and each fires the card's handler", async () => {
const { handlers } = openMenu(video({}));
expect(labels()).toEqual([
"card.markWatched",
"card.saveForLater",
"card.hide",
"home.pin.add",
]);
fireEvent.click(item("card.markWatched"));
await vi.waitFor(() => expect(handlers.onState).toHaveBeenLastCalledWith("v1", "watched"));
});
it("offers the way back on a watched, saved one — and the reset", async () => {
const { handlers } = openMenu(video({ status: "watched", saved: true }));
expect(labels()).toEqual([
"card.menu.unmarkWatched",
"card.menu.unsave",
"card.hide",
"card.resetState",
"home.pin.add",
]);
fireEvent.click(item("card.menu.unsave"));
await vi.waitFor(() => expect(handlers.onToggleSave).toHaveBeenLastCalledWith("v1", false));
});
it("unhides a hidden one", async () => {
const { handlers } = openMenu(video({ status: "hidden", position_seconds: 30 }));
fireEvent.click(item("card.unhide"));
await vi.waitFor(() => expect(handlers.onState).toHaveBeenLastCalledWith("v1", "new"));
});
it("clears the progress of a half-watched one", async () => {
const { handlers } = openMenu(video({ position_seconds: 30 }));
fireEvent.click(item("card.resetState"));
await vi.waitFor(() => expect(handlers.onResetState).toHaveBeenLastCalledWith("v1"));
});
});
-465
View File
@@ -1,465 +0,0 @@
import { useEffect, useId, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import Overlay from "./ui/Overlay";
import { copyText } from "../lib/clipboard";
import { usePrefs } from "./prefsContext";
/**
* A floating live appearance tuner. Mounted at the app root so it's reachable on every page; it
* writes the glass CSS custom properties (index.css `:root`) inline on the document element for
* instant, build-free feedback, and persists to localStorage.
*
* Opt-in via Settings → Appearance (`theme.showTuner`, off by default) — a secondary "geek toy"
* for dialling in the surface values. "Copy CSS" exports the values.
*/
const LS_KEY = "siftlode.devGlassTuner";
type Slider = {
k: string;
/** Its name under `settings.tuner.slider.*` — every user-facing word here is translated. */
id: string;
min: number;
max: number;
step: number;
unit: string;
def: number;
};
// def = the BAKED baseline in index.css (:root). "Current" preset + the export diff both key off it.
// def = the default rendered baseline = the "glass over image" (backdrop-on) tier from index.css.
const SLIDERS: Slider[] = [
{ k: "--glass-blur", id: "blur", min: 0, max: 40, step: 1, unit: "px", def: 10 },
{ k: "--glass-saturate", id: "saturate", min: 1, max: 2.5, step: 0.05, unit: "", def: 1.6 },
{
k: "--glass-dark-bright",
id: "darkBright",
min: 1,
max: 2,
step: 0.05,
unit: "",
def: 1.2,
},
{
k: "--glass-surface-alpha",
id: "surfaceAlpha",
min: 40,
max: 100,
step: 1,
unit: "%",
def: 50,
},
{
k: "--glass-card-alpha",
id: "cardAlpha",
min: 40,
max: 100,
step: 1,
unit: "%",
def: 60,
},
{
k: "--glass-menu-alpha",
id: "menuAlpha",
min: 40,
max: 100,
step: 1,
unit: "%",
def: 66,
},
{
k: "--glass-border-alpha",
id: "borderAlpha",
min: 20,
max: 100,
step: 1,
unit: "%",
def: 80,
},
{ k: "--glass-edge", id: "edge", min: 0, max: 60, step: 1, unit: "%", def: 25 },
{ k: "--glass-inset", id: "inset", min: 0, max: 40, step: 1, unit: "%", def: 15 },
{ k: "--glass-scrim", id: "scrim", min: 0, max: 85, step: 1, unit: "%", def: 30 },
{ k: "--ambient", id: "ambient", min: 0, max: 2, step: 0.05, unit: "", def: 1.1 },
];
const PALETTE: { k: string; id: string }[] = [
{ k: "--bg", id: "bg" },
{ k: "--surface", id: "surface" },
{ k: "--card", id: "card" },
{ k: "--border", id: "border" },
{ k: "--fg", id: "fg" },
{ k: "--muted", id: "muted" },
{ k: "--accent", id: "accent" },
];
// Each preset resets every slider to its default, then applies these overrides.
// Named under `settings.tuner.preset.<id>`.
const PRESETS: { id: string; over: Record<string, number> }[] = [
{ id: "current", over: {} },
{
id: "solid",
over: {
"--glass-blur": 20,
"--glass-dark-bright": 1.25,
"--glass-surface-alpha": 94,
"--glass-card-alpha": 94,
"--glass-menu-alpha": 98,
"--glass-edge": 10,
"--glass-inset": 16,
"--glass-scrim": 14,
},
},
{
id: "readable",
over: {
"--glass-edge": 42,
"--glass-scrim": 45,
"--glass-surface-alpha": 96,
"--glass-card-alpha": 96,
},
},
];
const ALL = SLIDERS;
const root = () => document.documentElement;
const readVar = (k: string) => getComputedStyle(root()).getPropertyValue(k).trim();
type Saved = {
vars?: Record<string, number>;
palette?: Record<string, string>;
open?: boolean;
};
function loadSaved(): Saved {
try {
return JSON.parse(localStorage.getItem(LS_KEY) || "{}") as Saved;
} catch {
return {};
}
}
export default function GlassTuner() {
// Opt-in via Settings → Appearance (theme.showTuner, off by default); the flag lives in PrefsProvider.
const enabled = usePrefs().theme.showTuner;
const { t } = useTranslation();
// Read once at mount. useState's lazy initializer rather than a useMemo with an empty dep list:
// a memo is a caching hint React may discard and recompute, while "the saved values as they were
// when this mounted" is state — and the effect below applies them exactly once.
const [initial] = useState(loadSaved);
const [vars, setVars] = useState<Record<string, number>>(() => {
const base: Record<string, number> = {};
ALL.forEach((s) => (base[s.k] = s.def));
return { ...base, ...(initial.vars || {}) };
});
const [palette, setPalette] = useState<Record<string, string>>(() => initial.palette || {});
const [open, setOpen] = useState<boolean>(() => initial.open ?? true);
const [tab, setTab] = useState<"glass" | "palette">("glass");
const [copied, setCopied] = useState(false);
const [themeTick, setThemeTick] = useState(0);
// Seed the colour pickers from the live scheme for display (without forcing them onto <html>).
const [swatches, setSwatches] = useState<Record<string, string>>({});
// Apply saved var overrides (and any saved palette edits) once on mount.
useEffect(() => {
Object.entries(initial.vars || {}).forEach(([k, v]) => {
const unit = ALL.find((s) => s.k === k)?.unit ?? "";
root().style.setProperty(k, v + unit);
});
Object.entries(initial.palette || {}).forEach(([k, v]) => root().style.setProperty(k, v));
const sw: Record<string, string> = {};
PALETTE.forEach((p) => {
const v = (initial.palette?.[p.k] || readVar(p.k)).trim();
sw[p.k] = /^#[0-9a-fA-F]{6}$/.test(v) ? v : "#000000";
});
// eslint-disable-next-line react-hooks/set-state-in-effect -- the swatches are READ FROM THE DOM (readVar on the computed style) after the saved overrides have been applied to it, so they cannot exist before a commit.
setSwatches(sw);
}, [initial]);
function persist(next: Partial<Saved>) {
const cur = loadSaved();
localStorage.setItem(LS_KEY, JSON.stringify({ ...cur, ...next }));
}
function setVar(k: string, value: number) {
const unit = ALL.find((s) => s.k === k)?.unit ?? "";
root().style.setProperty(k, value + unit);
setVars((prev) => {
const next = { ...prev, [k]: value };
persist({ vars: next });
return next;
});
}
function setColor(k: string, hex: string) {
root().style.setProperty(k, hex);
setSwatches((s) => ({ ...s, [k]: hex }));
setPalette((prev) => {
const next = { ...prev, [k]: hex };
persist({ palette: next });
return next;
});
}
function applyPreset(over: Record<string, number>) {
const next: Record<string, number> = { ...vars };
SLIDERS.forEach((s) => {
const v = over[s.k] ?? s.def;
next[s.k] = v;
root().style.setProperty(s.k, v + s.unit);
});
setVars(next);
persist({ vars: next });
}
function reset() {
[...ALL, ...PALETTE].forEach((s) => root().style.removeProperty(s.k));
const base: Record<string, number> = {};
ALL.forEach((s) => (base[s.k] = s.def));
setVars(base);
setPalette({});
localStorage.removeItem(LS_KEY);
const sw: Record<string, string> = {};
PALETTE.forEach((p) => {
const v = readVar(p.k);
sw[p.k] = /^#[0-9a-fA-F]{6}$/.test(v) ? v : "#000000";
});
setSwatches(sw);
}
// Re-read the LIVE, theme-driven glass values into the sliders (clearing inline overrides first),
// so the panel reflects whatever theme is active — switch to light, hit this, and the opacity
// sliders show the light-theme values (index.css `html[data-theme="light"]`) ready to tune.
function syncToTheme() {
ALL.forEach((s) => root().style.removeProperty(s.k));
const next: Record<string, number> = { ...vars };
ALL.forEach((s) => {
const n = parseFloat(readVar(s.k));
if (!Number.isNaN(n)) next[s.k] = n;
});
setVars(next);
persist({ vars: {} });
setThemeTick((t) => t + 1);
}
// Export the values that differ from the baseline, headered for the active theme's scope so a
// light-theme tune pastes into the right block.
const cssOut = useMemo(() => {
void themeTick; // re-run when the active theme is re-synced
const lines: string[] = [];
ALL.forEach((s) => {
if (vars[s.k] !== s.def) lines.push(` ${s.k}: ${vars[s.k]}${s.unit};`);
});
PALETTE.forEach((p) => {
if (palette[p.k]) lines.push(` ${p.k}: ${palette[p.k]};`);
});
const scope = root().dataset.theme === "light" ? 'html[data-theme="light"]' : ":root";
return lines.length ? `${scope} {\n${lines.join("\n")}\n}` : "/* all values at defaults */";
}, [vars, palette, themeTick]);
async function copy() {
if (await copyText(cssOut)) {
setCopied(true);
setTimeout(() => setCopied(false), 1200);
}
}
function persistOpen(v: boolean) {
setOpen(v);
persist({ open: v });
}
if (!enabled) return null;
return (
<Overlay>
<div
className="fixed right-0 top-24 z-tuner flex items-start"
style={{ pointerEvents: "none" }}
>
{!open && (
<button
onClick={() => persistOpen(true)}
style={{ pointerEvents: "auto" }}
className="glass-menu glass-hover rounded-l-xl rounded-r-none px-2 py-3 text-xs font-semibold tracking-wide text-fg"
title={t("settings.tuner.open")}
>
<span style={{ writingMode: "vertical-rl" }}>{t("settings.tuner.tab")}</span>
</button>
)}
{open && (
<div
style={{ pointerEvents: "auto" }}
className="glass-menu rounded-l-2xl rounded-r-none w-75 max-h-[82vh] flex flex-col overflow-hidden"
>
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60">
<span className="text-sm font-semibold text-fg">{t("settings.tuner.title")}</span>
<span className="text-3xs uppercase tracking-wider text-muted">
{t("settings.tuner.lab")}
</span>
<button
onClick={() => persistOpen(false)}
className="ml-auto text-muted hover:text-fg text-lg leading-none px-1"
title={t("settings.tuner.collapse")}
aria-label={t("settings.tuner.collapse")}
>
×
</button>
</div>
<div className="overflow-y-auto px-3 py-3 flex flex-col gap-4 text-sm">
{/* Treatment presets */}
<div className="flex flex-col gap-1.5">
<div className="text-3xs uppercase tracking-wider text-muted font-semibold">
{t("settings.tuner.treatment")}
</div>
<div className="grid grid-cols-2 gap-1.5">
{PRESETS.map((p) => (
<button
key={p.id}
onClick={() => applyPreset(p.over)}
title={t(`settings.tuner.preset.${p.id}.hint`)}
className="glass-card glass-hover rounded-lg px-2 py-1.5 text-left text-xs text-fg"
>
<b className="block font-semibold">
{t(`settings.tuner.preset.${p.id}.name`)}
</b>
<span className="text-muted text-3xs">
{t(`settings.tuner.preset.${p.id}.hint`)}
</span>
</button>
))}
</div>
</div>
{/* Tab switch */}
<div className="flex gap-1.5">
{(["glass", "palette"] as const).map((id) => (
<button
key={id}
onClick={() => setTab(id)}
aria-pressed={tab === id}
className={`flex-1 rounded-lg px-2 py-1 text-xs ${
tab === id
? "bg-accent text-(--accent-fg) font-semibold"
: "glass-card text-muted"
}`}
>
{t(`settings.tuner.tabs.${id}`)}
</button>
))}
</div>
{tab === "glass" && (
<div className="flex flex-col gap-2.5">
{SLIDERS.map((s) => (
<Ctl
key={s.k}
s={s}
label={t(`settings.tuner.slider.${s.id}`)}
value={vars[s.k] ?? s.def}
onChange={(v) => setVar(s.k, v)}
/>
))}
</div>
)}
{tab === "palette" && (
<div className="flex flex-col gap-2">
<p className="text-3xs text-muted leading-relaxed">
{t("settings.tuner.paletteHint")}
</p>
{PALETTE.map((p) => (
<label key={p.k} className="flex items-center gap-2 text-xs text-fg">
<input
type="color"
value={swatches[p.k] || "#000000"}
onChange={(e) => setColor(p.k, e.target.value)}
className="w-8 h-6 rounded-sm border border-border bg-transparent cursor-pointer"
/>
<span className="flex-1">{t(`settings.tuner.color.${p.id}`)}</span>
<code className="text-3xs text-muted">{swatches[p.k]}</code>
</label>
))}
</div>
)}
{/* Export */}
<div className="flex flex-col gap-1.5 pt-1 border-t border-border/50">
<div className="text-3xs uppercase tracking-wider text-muted font-semibold">
{t("settings.tuner.export")}
</div>
<button
onClick={syncToTheme}
title={t("settings.tuner.syncHint")}
className="glass-card glass-hover rounded-lg px-2 py-1.5 text-xs text-fg text-left"
>
{t("settings.tuner.sync")}
</button>
<div className="flex gap-1.5">
<button
onClick={copy}
className="flex-1 rounded-lg px-2 py-1.5 text-xs font-semibold bg-accent text-(--accent-fg)"
>
{copied ? t("settings.tuner.copied") : t("settings.tuner.copy")}
</button>
<button
onClick={reset}
className="glass-card glass-hover rounded-lg px-3 py-1.5 text-xs text-fg"
>
{t("settings.tuner.reset")}
</button>
</div>
<pre className="text-[0.59375rem] leading-relaxed bg-(--bg) border border-border/60 rounded-lg p-2 overflow-x-auto text-muted whitespace-pre">
{cssOut}
</pre>
</div>
</div>
</div>
)}
</div>
</Overlay>
);
}
function Ctl({
s,
label,
value,
onChange,
}: {
s: Slider;
label: string;
value: number;
onChange: (v: number) => void;
}) {
// The label names the number box, and the range beside it says the same thing: both controls
// are one setting, and the label was not tied to either before (a screen reader read "spin
// button" and "slider" with no name at all).
const id = useId();
return (
<div className="grid grid-cols-[1fr_auto] items-center gap-x-2 gap-y-1">
<label htmlFor={id} className="text-xs text-fg">
{label}
</label>
<input
id={id}
type="number"
min={s.min}
max={s.max}
step={s.step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="w-16 text-right text-2xs tabular-nums rounded-sm bg-card border border-border px-1.5 py-0.5 text-accent"
/>
<input
type="range"
min={s.min}
max={s.max}
step={s.step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
aria-label={label}
className="col-span-2 w-full accent-accent"
/>
</div>
);
}
+95 -3
View File
@@ -11,7 +11,7 @@
// a Carousel (useHScroll → ResizeObserver), stubbed for jsdom.
import { StrictMode } from "react";
import { describe, it, expect, afterEach, beforeAll, beforeEach, vi } from "vitest";
import { act, render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
import { act, render, screen, fireEvent, cleanup, waitFor, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
@@ -700,11 +700,48 @@ describe("Home", () => {
comfort: [],
});
mount();
fireEvent.click(await screen.findByLabelText("plex.info.title"));
expect(setJumpTo).toHaveBeenCalledWith({ id: "m1", type: "movie", replace: true });
// Since E28 C1 the tile's ⓘ is its menu's "Media info" row, opened from the tile's "…".
fireEvent.click((await screen.findAllByRole("button", { name: "card.menu.more" }))[0]!);
const info = screen
.getAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === "plex.info.title");
fireEvent.click(info!);
await waitFor(() =>
expect(setJumpTo).toHaveBeenCalledWith({ id: "m1", type: "movie", replace: true }),
);
expect(setPage).toHaveBeenCalledWith("plex");
});
// C5: what a tile's Play would continue, its menu can start over — the one way to on touch.
it("a started film's menu starts it over: the player opens from the start", async () => {
const movie = {
...ytCard("m1", "A Film"),
source: "plex",
kind: "movie",
aspect: "poster",
position_seconds: 600,
video: null,
plex: { id: "m1", type: "movie", title: "A Film", thumb: "", position_seconds: 600 },
};
discover.mockResolvedValue({
budget: "any",
hero: null,
continue_watching: [movie],
comfort: [],
});
mount();
fireEvent.click((await screen.findAllByRole("button", { name: "card.menu.more" }))[0]!);
const restart = screen
.getAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === "card.restartTitle");
fireEvent.click(restart!);
await waitFor(() =>
expect(setJumpTo).toHaveBeenCalledWith(
expect.objectContaining({ id: "m1", type: "play", fromStart: true }),
),
);
});
it("rotates the tier on each press so the button offers a different KIND of answer", async () => {
// Measured 2026-09-06: exclusion alone left it walking the resume rail and cycling nine cards,
// never once reaching the shelf. The press count travels to the server as the rotation.
@@ -778,3 +815,58 @@ describe("Home · keyboard", () => {
expect(rails.map((rail) => rail.querySelectorAll("[data-nav-index]").length)).toEqual([2, 2]);
});
});
// E28 S3h (the owner, 2026-09-26/27): the hero stands in three quarters of a wide page with its text
// centred beside the picture; one fact line, no repeated year, and prose only where there is some,
// at a readable measure.
describe("Home's hero text", () => {
const film = {
source: "plex",
kind: "movie",
id: "m1",
title: "A Film",
subtitle: "2003",
thumb: null,
aspect: "poster",
duration_seconds: 9240,
position_seconds: 0,
remaining_seconds: null,
status: "new",
video: null,
plex: {
id: "m1",
type: "movie",
title: "A Film",
thumb: "",
year: 2003,
rating: 7.8,
summary: "What the film is about.",
},
};
it("a film: the year once, in the fact line with the length and score; its summary at the measure", async () => {
discover.mockResolvedValue({ ...payload("v1"), hero: { tier: "surprise", card: film } });
mount();
const title = await screen.findByRole("heading", { name: "A Film" });
const text = title.parentElement!;
expect(within(text).getByText(/^2003 · \S/)).toBeTruthy();
expect(within(text).getByText("7.8")).toBeTruthy();
// the subtitle was the year alone: it is not printed a second time
expect(within(text).queryByText("2003")).toBeNull();
expect(within(text).getByText("What the film is about.").className).toContain("measure");
expect(title.closest("section")!.className).toContain("lg:w-3/4");
});
it("a video: the channel as the subtitle, views and age as the facts, and no summary line", async () => {
discover.mockResolvedValue({
...payload("v1"),
hero: { tier: "surprise", card: ytCard("v9", "A video") },
continue_watching: [],
});
mount();
const text = (await screen.findByRole("heading", { name: "A video" })).parentElement!;
expect(within(text).getByText("A channel")).toBeTruthy();
expect(within(text).getByText(/^10 card\.views · /)).toBeTruthy();
expect(text.querySelector("p.measure")).toBeNull();
});
});
+35 -1
View File
@@ -65,7 +65,13 @@ vi.mock("../lib/api", () => ({
discoverCardKey: (c: { source: string; id: string }) => `${c.source}:${c.id}`,
}));
vi.mock("./navigationContext", () => ({ useNavigationActions: () => ({ setPage: vi.fn() }) }));
const openChannel = vi.fn();
vi.mock("./navigationContext", () => ({
useNavigationActions: () => ({
setPage: vi.fn(),
openChannel: (...a: unknown[]) => openChannel(...a),
}),
}));
vi.mock("./plexContext", () => ({ usePlexActions: () => ({ setJumpTo: vi.fn() }) }));
import Home from "./Home";
@@ -150,6 +156,7 @@ const item = (label: string) => {
};
beforeEach(() => {
openChannel.mockReset();
// Home keeps its question on the history entry (Back restores it): each test starts a fresh one.
window.history.replaceState(null, "");
discover.mockReset().mockResolvedValue(payload());
@@ -166,6 +173,8 @@ describe("Home — the comfort shelf's context menu", () => {
fireEvent.contextMenu(await screen.findByText("Comfort B"));
expect(labels()).toEqual([
"home.hero.play",
// A channel tile opens its channel (the owner, 2026-09-27: it had nothing a channel offers).
"card.menu.openChannel",
"home.shelf.moveFirst",
"home.shelf.moveLeft",
"home.shelf.moveRight",
@@ -341,3 +350,28 @@ describe("Home — the comfort shelf's context menu", () => {
expect(item("home.pin.add").disabled).toBe(true);
});
});
// The owner, 2026-09-27: every Home card offers what the same card offers elsewhere.
describe("Home — a card's own actions", () => {
it("opens a pinned channel from its tile's ↗", async () => {
mount();
await screen.findByText("Comfort B");
fireEvent.click(
screen.getAllByRole("button", { name: "card.menu.openChannel" })[1] as HTMLElement,
);
expect(openChannel).toHaveBeenCalledWith("UCb", "Comfort B");
});
it("gives the hero the card's whole menu, not the pin row alone", async () => {
discover.mockResolvedValue({
...payload(),
hero: { tier: "surprise", card: base("h1", "Hero video") },
});
mount();
fireEvent.contextMenu(await screen.findByRole("heading", { name: "Hero video" }));
const rows = labels();
expect(rows[0]).toBe("home.hero.play");
expect(rows).toContain("card.markWatched");
expect(rows).toContain("card.menu.openChannel");
});
});
+446 -137
View File
@@ -6,9 +6,12 @@ import {
ChevronRight,
ChevronsLeft,
ChevronsRight,
ArrowUpRight,
PinOff,
Play,
RotateCcw,
SkipBack,
Star,
} from "lucide-react";
import { qk } from "../lib/queryKeys";
import {
@@ -26,10 +29,9 @@ import {
} from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { takeSurprise, useSurpriseRequest } from "../lib/homeIntent";
import { formatDuration } from "../lib/format";
import { formatDuration, formatRuntime, formatViews, relativeTime } from "../lib/format";
import { toggleWatchState } from "../lib/plexWatch";
import { useComfortPin, pinTargetForCard, shelfMoveTarget, type ShelfMove } from "./comfortPin";
import { ComfortPinMenu } from "./ComfortPinMenu";
import { type ActionMenuItem } from "./ui/ActionMenu";
import { Button } from "./ui/Button";
import { Carousel } from "./ui/Carousel";
@@ -37,11 +39,31 @@ import { ContextMenu } from "./ui/ContextMenu";
import { useContextMenu, useItemTriggers } from "./ui/useContextMenu";
import SegmentedToggle from "./ui/SegmentedToggle";
import { LoadingState, StateMessage } from "./ui/QueryState";
import VideoCard from "./VideoCard";
import { PlexPosterCard } from "./plexCards";
import VideoCard, { VideoHoverActions } from "./VideoCard";
import { useVideoActions } from "./useVideoActions";
import { usePlexCardActions } from "./usePlexCardActions";
import { PlexHoverBar, PlexPosterCard } from "./plexCards";
import { usePlaylistsActions } from "./playlistsContext";
import { useNavigationActions } from "./navigationContext";
import { usePlexActions } from "./plexContext";
import { navItemAttrs } from "../lib/listNav";
import { Pressable } from "./ui/Pressable";
import { SURFACE } from "./ui/surfaces";
import clsx from "clsx";
import { IconButton } from "./ui/IconButton";
import { MoreButton } from "./ui/MoreButton";
import { HOVER_REVEAL, HOVER_YIELD } from "./ui/reveal";
import { MediaResumeButtons } from "./MediaResumeButtons";
import { MediaCard, MediaPlayGlyph } from "./ui/MediaCard";
import {
MEDIA_BAR,
MEDIA_BADGE,
MEDIA_HOVER_LAYER,
MEDIA_IMG,
MEDIA_PICTURE,
mediaPictureBox,
mediaTitleClass,
} from "./ui/mediaCardStyles";
// The Home surface (E24) — "Mit nézzek most?".
//
@@ -64,11 +86,12 @@ const PlayerModal = lazy(() => import("./PlayerModal"));
// SUBSET, so a band added server-side and to `DISCOVER_BUDGETS` typechecked green while this row
// silently kept offering four.
// A rail tile's slot width. `cardsSmall` is the feed's own dense card (its column bottoms out at
// 180px), so a 200px slot is the same object the feed shows — not a Home-only variant that would
// drift from it. Px on purpose, like that column (`VirtualGrid`'s `minCol` is a layout-engine px value,
// A rail tile's slot width. `cardsSmall` is the feed's own dense card, so the slot holds the same
// object the feed shows — not a Home-only variant that would drift from it. 232, not the 200 it was:
// at 200 the picture was 184×103 and its centred Play overlapped the hover row docked under it by
// 2px (the owner saw them run together, 2026-09-27); at 232 they stand ~7px apart. Px on purpose, like that column (`VirtualGrid`'s `minCol` is a layout-engine px value,
// see ui/README): the card also takes it as its MEASURED width, which must be real pixels.
const YT_SLOT = 200;
const YT_SLOT = 232;
// How many already-offered cards the reroll remembers. Matched to the server's own cap
// (`routes/discover.MAX_EXCLUDE`) rather than to a smaller number of our own: at 8 the window rolled
@@ -85,6 +108,162 @@ const SHELF_MOVES: readonly [ShelfMove, string, typeof ChevronLeft][] = [
["last", "home.shelf.moveLast", ChevronsRight],
];
/** A Home card's ONE action list — a shelf tile's and the hero's alike: Play (it resolves `next`),
* the card's own rows (a video's or a Plex title's, as the feed and the library show them; a
* channel's or a playlist's open row), then the shelf's rows. */
function useHomeCardMenu({
cards,
onPlay,
onInfo,
onState,
onToggleSave,
onToggleWatched,
onMove,
moveSaving = false,
}: {
cards: DiscoverCard[];
onPlay: (card: DiscoverCard, startAt?: number | null) => void;
onInfo: (card: DiscoverCard) => void;
onState: (id: string, status: VideoStatus) => void;
onToggleSave: (id: string, saved: boolean) => void;
onToggleWatched: (card: PlexCard) => void;
onMove?: (card: DiscoverCard, to: ShelfMove, rendered: DiscoverCard[]) => void;
moveSaving?: boolean;
}) {
const { t } = useTranslation();
// ONE right-click menu for the rail (see ui/ContextMenu). It is the shelf's whole editor: menu
// rows rather than drag & drop, because dragging inside a horizontal, scrolling Carousel fights
// the scroll, and because these rows are keyboard-reachable (Shift+F10) for free.
const menu = useContextMenu<DiscoverCard>();
const pin = useComfortPin(menu.open);
// A video tile's own action list (E28 C1), the same one the feed's menu shows; the rail keeps its
// Play row (it resolves `next`) and its shelf rows around it.
const videoActions = useVideoActions({ onState, onToggleSave }, menu.open);
// A Plex tile's, likewise (its "Media info" row goes where the tile's `onInfo` does).
const plexActions = usePlexCardActions(
{
onOpen: (p) => {
const c = cards.find((x) => x.plex === p);
if (c) onPlay(c);
},
onInfo: (p) => {
const c = cards.find((x) => x.plex === p);
if (c) onInfo(c);
},
onToggleWatched,
},
menu.open,
);
const openPage = useOpenShelfPage();
const target = menu.target;
// What the pin row acts on: a comfort hero's pin is the shelf entry it came from — a pinned show's
// next episode is not itself pinned, and looking the episode up offered "Pin" under "From your
// shelf", a second pin (the owner's call, 2026-09-19). Any other card is its own.
const pinned = target ? (target.pinned_from ?? target) : null;
const shelfIdx = target?.pin_id != null ? cards.findIndex((c) => c.pin_id === target.pin_id) : -1;
const items: ActionMenuItem[] = !target
? []
: [
{
id: "play",
label: target.position_seconds > 0 ? t("home.hero.continue") : t("home.hero.play"),
icon: <Play className="w-4 h-4" />,
// A pinned channel with nothing unwatched left, or an empty playlist, resolves to no
// `next` — the same condition that stands its tile down. Kept visible and disabled rather
// than dropped, so the menu does not change shape from tile to tile.
disabled: !(target.next ?? target).video && !(target.next ?? target).plex,
onSelect: () => onPlay(target),
},
// What the Play row would continue can also start over (C5), with the card menus' words.
...((target.next ?? target).position_seconds > 0
? [
{
id: "restart",
label: t("card.restartTitle"),
icon: <SkipBack className="w-4 h-4" />,
onSelect: () => onPlay(target, 0),
},
]
: []),
...(target.video
? videoActions
.items(target.video, menu.anchor, { pin: false })
.map((it, i) => (i === 0 ? { ...it, separatorBefore: true } : it))
: target.plex
? plexActions
.items(target.plex, { play: false, pin: false })
.map((it, i) => (i === 0 ? { ...it, separatorBefore: true } : it))
: target.kind === "channel" || target.kind === "playlist"
? [{ ...openPage.item(target), separatorBefore: true }]
: []),
...(onMove && target.pin_id != null
? [
...SHELF_MOVES.map(([to, key, Icon], i) => ({
id: `shelf-${to}`,
label: t(key),
icon: <Icon className="w-4 h-4" />,
separatorBefore: i === 0,
// The same rule the move itself runs, so a row is never enabled onto a no-op.
disabled: moveSaving || shelfMoveTarget(shelfIdx, to, cards.length) < 0,
note: moveSaving && i === 0 ? t("home.shelf.moveSaving") : undefined,
onSelect: () => onMove(target, to, cards),
})),
{
id: "shelf-unpin",
label: t("home.pin.remove"),
icon: <PinOff className="w-4 h-4" />,
separatorBefore: true,
// The only row here that removes a curated entry, and it sat in the same colour as
// the four harmless reorders. `danger` matches the shared unpin row in
// `comfortPin.item()`, which is what every OTHER surface (including the hero and the
// rail below) composes for the same command — the two must not disagree.
danger: true,
// The pin id travels on the shelf card, so this needs no lookup — and unlike every
// other surface it can never be in the "not known yet" state.
onSelect: () => pin.remove(target.pin_id as number, target.title),
},
]
: pin.item(pinTargetForCard(pinned ?? target), (pinned ?? target).title)),
];
return {
menu,
items,
panels: (
<>
{videoActions.panel}
{plexActions.panel}
</>
),
plexAdd: plexActions.add,
};
}
/** A pinned channel's or playlist's own page: the shelf tile's ↗ and its menu's open row. */
function useOpenShelfPage() {
const { t } = useTranslation();
const { openChannel, setPage } = useNavigationActions();
const { setSelectedId } = usePlaylistsActions();
const open = (card: DiscoverCard) => {
if (card.kind === "channel") openChannel(card.id, card.title);
else {
setSelectedId(Number(card.id));
setPage("playlists");
}
};
const label = (card: DiscoverCard) =>
card.kind === "channel" ? t("card.menu.openChannel") : t("home.shelf.openPlaylist");
return {
open,
label,
item: (card: DiscoverCard): ActionMenuItem => ({
id: "open",
label: label(card),
icon: <ArrowUpRight className="w-4 h-4" />,
onSelect: () => open(card),
}),
};
}
function Rail({
title,
ariaLabel,
@@ -114,60 +293,21 @@ function Rail({
/** A reorder is being written: the move rows wait for it (one write at a time). */
moveSaving?: boolean;
}) {
const { t } = useTranslation();
// ONE right-click menu for the rail (see ui/ContextMenu). It is the shelf's whole editor: menu
// rows rather than drag & drop, because dragging inside a horizontal, scrolling Carousel fights
// the scroll, and because these rows are keyboard-reachable (Shift+F10) for free.
const menu = useContextMenu<DiscoverCard>();
const pin = useComfortPin(menu.open);
const target = menu.target;
const shelfIdx = target?.pin_id != null ? cards.findIndex((c) => c.pin_id === target.pin_id) : -1;
const items: ActionMenuItem[] = !target
? []
: [
{
id: "play",
label: target.position_seconds > 0 ? t("home.hero.continue") : t("home.hero.play"),
icon: <Play className="w-4 h-4" />,
// A pinned channel with nothing unwatched left, or an empty playlist, resolves to no
// `next` — the same condition that stands its tile down. Kept visible and disabled rather
// than dropped, so the menu does not change shape from tile to tile.
disabled: !(target.next ?? target).video && !(target.next ?? target).plex,
onSelect: () => onPlay(target),
},
...(onMove && target.pin_id != null
? [
...SHELF_MOVES.map(([to, key, Icon], i) => ({
id: `shelf-${to}`,
label: t(key),
icon: <Icon className="w-4 h-4" />,
separatorBefore: i === 0,
// The same rule the move itself runs, so a row is never enabled onto a no-op.
disabled: moveSaving || shelfMoveTarget(shelfIdx, to, cards.length) < 0,
note: moveSaving && i === 0 ? t("home.shelf.moveSaving") : undefined,
onSelect: () => onMove(target, to, cards),
})),
{
id: "shelf-unpin",
label: t("home.pin.remove"),
icon: <PinOff className="w-4 h-4" />,
separatorBefore: true,
// The only row here that removes a curated entry, and it sat in the same colour as
// the four harmless reorders. `danger` matches the shared unpin row in
// `comfortPin.item()`, which is what every OTHER surface (including the hero and the
// rail below) composes for the same command — the two must not disagree.
danger: true,
// The pin id travels on the shelf card, so this needs no lookup — and unlike every
// other surface it can never be in the "not known yet" state.
onSelect: () => pin.remove(target.pin_id as number, target.title),
},
]
: pin.item(pinTargetForCard(target), target.title)),
];
const { menu, items, panels, plexAdd } = useHomeCardMenu({
cards,
onPlay,
onInfo,
onState,
onToggleSave,
onToggleWatched,
onMove,
moveSaving,
});
// One stable trigger per card (the menu acts on the unified card, not the `Video`/`PlexCard` the
// card component holds) — a fresh lambda per render defeated `memo(VideoCard)` on every tile.
const menuFor = useItemTriggers(cards, menu.triggerProps);
const buttonFor = useItemTriggers(cards, menu.buttonProps);
// An empty rail says why it is empty rather than vanishing: a shelf that silently disappears when
// you have not pinned anything teaches nothing about how to fill it.
@@ -189,36 +329,59 @@ function Rail({
{cards.map((c, i) => (
<div
key={`${c.source}:${c.kind}:${c.id}`}
className={c.aspect === "poster" ? "w-37.5 shrink-0" : "shrink-0"}
style={c.aspect === "poster" ? undefined : { width: YT_SLOT }}
// Every shelf card is landscape, a Plex movie or show too (its wide art): a shelf mixes
// YouTube and Plex, and a poster beside a 16:9 card stretched the whole row to the
// poster's height, leaving a hole in every video card (the owner's pick A, 2026-09-26 —
// Plex's and Netflix's own "Continue watching" rows do the same). A grid cell, so the card
// fills the slot: every card's meta line (pinned to its frame's bottom) sits on one line.
className="grid shrink-0 grid-cols-1"
style={{ width: YT_SLOT }}
>
{c.video ? (
<VideoCard
video={c.video}
view="cardsSmall"
shelf
width={YT_SLOT}
navIndex={i}
onState={onState}
onToggleSave={onToggleSave}
onOpen={(v, startAt) => onPlay({ ...c, video: v }, startAt)}
menuProps={menuFor[i]}
menuButtonProps={buttonFor[i]}
menuOpen={menu.open && menu.target === c}
/>
) : c.plex ? (
<PlexPosterCard
card={c.plex}
wide
shelf
navIndex={i}
onOpen={() => onPlay(c)}
onInfo={() => onInfo(c)}
onRestart={() => onPlay(c, 0)}
onToggleWatched={onToggleWatched}
onAdd={plexAdd}
next={c.next?.plex}
onInfo={() => onInfo(c)}
menuProps={menuFor[i]}
menuButtonProps={buttonFor[i]}
menuOpen={menu.open && menu.target === c}
/>
) : (
<ShelfTile card={c} onPlay={onPlay} menuProps={menu.triggerProps} navIndex={i} />
<ShelfTile
card={c}
onPlay={onPlay}
menuProps={menuFor[i]?.()}
menuButtonProps={buttonFor[i]?.()}
menuOpen={menu.open && menu.target === c}
navIndex={i}
/>
)}
</div>
))}
</Carousel>
<ContextMenu menu={menu} ariaLabel={ariaLabel} items={items} />
{panels}
</section>
);
}
@@ -230,45 +393,90 @@ function ShelfTile({
card,
onPlay,
menuProps,
menuButtonProps,
menuOpen = false,
navIndex,
}: {
card: DiscoverCard;
onPlay: (card: DiscoverCard) => void;
menuProps?: (c: DiscoverCard) => Record<string, unknown>;
menuProps?: Record<string, unknown>;
/** The rail menu's "…" for this tile. */
menuButtonProps?: Record<string, unknown>;
menuOpen?: boolean;
navIndex?: number;
}) {
const { t } = useTranslation();
const openPage = useOpenShelfPage();
const count = card.kind === "channel" ? card.unwatched_count : card.item_count;
const countLabel =
card.kind === "channel"
? t("home.shelf.unwatchedCount", { count: count ?? 0 })
: t("home.shelf.itemCount", { count: count ?? 0 });
// The one media card (ui/MediaCard), like the videos and posters beside it on the shelf: the
// picture is the play button, the count the meta line pinned to the frame's bottom.
return (
<button
type="button"
onClick={() => card.next && onPlay(card)}
{...menuProps?.(card)}
{...(navIndex !== undefined && navItemAttrs(navIndex))}
// `aria-disabled`, not `disabled`: a pinned channel with nothing unwatched left is not
// playable, but it IS the tile you most want to take off the shelf, and the only way to do
// that is its context menu. MEASURED in Chromium (2026-09-06), because the reason is narrower
// than it looks: a real `disabled` button DOES still receive `contextmenu` and `pointerdown`,
// so mouse and long-press would both survive it — what it loses is FOCUS, and with it
// Shift+F10, the only keyboard route to the menu. Inert to the click (the handler guards),
// alive to all three input paths.
aria-disabled={!card.next}
className="block w-full text-left rounded-xl overflow-hidden border border-border bg-surface transition-colors aria-disabled:opacity-60 [&:not([aria-disabled='true'])]:hover:border-accent"
>
<span className="block aspect-video bg-card overflow-hidden">
{card.thumb ? (
<img src={card.thumb} alt="" loading="lazy" className="w-full h-full object-cover" />
) : null}
</span>
<span className="block p-2">
<span className="block text-xs font-medium truncate">{card.title}</span>
<span className="block text-2xs text-muted">{countLabel}</span>
</span>
</button>
<MediaCard
dense
rootProps={{
...menuProps,
...(navIndex !== undefined && navItemAttrs(navIndex)),
}}
className={!card.next ? "opacity-60" : undefined}
picture={
// The ratio lives on a wrapper, as in the other cards: MEDIA_PICTURE fills its parent, and
// on the stretched frame itself that would be the whole card's height.
<div className={mediaPictureBox("wide")}>
<Pressable
onClick={() => card.next && onPlay(card)}
aria-label={card.title}
// `aria-disabled`, not `disabled`: a pinned channel with nothing unwatched left is not
// playable, but it IS the tile you most want to take off the shelf, and the only way to
// do that is its context menu. MEASURED in Chromium (2026-09-06), because the reason is
// narrower than it looks: a real `disabled` button DOES still receive `contextmenu` and
// `pointerdown`, so mouse and long-press would both survive it — what it loses is FOCUS,
// and with it Shift+F10, the only keyboard route to the menu. Inert to the click (the
// handler guards), alive to all three input paths.
aria-disabled={!card.next}
data-nav-open=""
className={MEDIA_PICTURE}
>
{card.thumb ? (
<img src={card.thumb} alt="" loading="lazy" className={MEDIA_IMG} />
) : null}
{/* The Play only when there is something to play: the click plays the channel's newest
unwatched video, the playlist's first. */}
<div className={MEDIA_HOVER_LAYER}>{card.next && <MediaPlayGlyph small />}</div>
</Pressable>
{/* The rest of what a tile like this offers, as the video and Plex cards beside it do:
its own page, and the "…" to the whole list (a sibling of the picture's button). */}
<div className={clsx(MEDIA_BAR, "flex gap-1")}>
<IconButton
variant="media"
onClick={() => openPage.open(card)}
label={openPage.label(card)}
>
<ArrowUpRight />
</IconButton>
{menuButtonProps && (
<MoreButton
variant="media"
label={t("card.menu.more")}
expanded={menuOpen}
menuProps={menuButtonProps}
/>
)}
</div>
</div>
}
title={
<span className={`min-h-6 ${mediaTitleClass(true, true)} select-text`} title={card.title}>
{card.title}
</span>
}
meta={<span className="block text-xs leading-6 text-muted">{countLabel}</span>}
more={menuButtonProps && { props: menuButtonProps, open: menuOpen }}
moreLabel={t("card.menu.more")}
/>
);
}
@@ -276,19 +484,34 @@ function Hero({
tier,
card,
onPlay,
onInfo,
onState,
onToggleSave,
onToggleWatched,
onReroll,
rerolling,
}: {
tier: HeroTier;
card: DiscoverCard;
onPlay: (card: DiscoverCard) => void;
onPlay: (card: DiscoverCard, startAt?: number | null) => void;
onInfo: (card: DiscoverCard) => void;
onState: (id: string, status: VideoStatus) => void;
onToggleSave: (id: string, saved: boolean) => void;
onToggleWatched: (card: PlexCard) => void;
onReroll: () => void;
rerolling: boolean;
}) {
const { t } = useTranslation();
// The hero is the likeliest thing on the page to be comfort media — it is what the surface just
// answered with — so it answers the same right-click every card in the app does.
const pinMenu = useContextMenu<DiscoverCard>();
// The hero is a card like any other (the owner, 2026-09-27): the same hover row on its picture and
// the same whole action list on its "…", right-click and long-press — it was a pin row alone.
const { menu, items, panels, plexAdd } = useHomeCardMenu({
cards: [card],
onPlay,
onInfo,
onState,
onToggleSave,
onToggleWatched,
});
const started = card.position_seconds > 0;
const left = card.remaining_seconds;
// The kicker is driven by the tier the SERVER chose, never guessed from the card's shape: "where
@@ -302,42 +525,120 @@ function Hero({
? t("home.hero.comfort")
: t("home.hero.surprise");
return (
<section
{...pinMenu.triggerProps(card)}
className="mb-6 rounded-xl border border-border bg-surface p-4 grid gap-4 sm:grid-cols-[minmax(0,18rem)_1fr]"
>
<button
type="button"
onClick={() => onPlay(card)}
className={`relative block w-full overflow-hidden rounded-lg border border-border bg-card ${
card.aspect === "poster" ? "aspect-[2/3] max-w-[12rem]" : "aspect-video"
}`}
aria-label={`${started ? t("home.hero.continue") : t("home.hero.play")}: ${card.title}`}
>
{card.thumb ? <img src={card.thumb} alt="" className="w-full h-full object-cover" /> : null}
{card.duration_seconds != null && (
<span className="absolute bottom-1.5 right-1.5 rounded-sm bg-black/80 px-1.5 py-0.5 text-2xs font-medium text-white tabular-nums">
{formatDuration(card.duration_seconds)}
</span>
)}
{started && card.duration_seconds ? (
<span className="absolute inset-x-0 bottom-0 h-1 bg-black/60">
<span
className="block h-full bg-accent"
style={{
width: `${Math.min(100, (card.position_seconds / card.duration_seconds) * 100)}%`,
}}
/>
</span>
) : null}
</button>
// One line of facts under the title: a film's year, length and score; a video's views and age.
// The picture's column is exactly the picture's width (it was 18rem around a 12rem poster: a 6rem
// hole), and the text stands centred beside it, so a video — shorter picture, fewer facts — fills
// its hero as well as a film does (the owner's pick, 2026-09-26).
const plex = card.plex;
const video = card.video;
const facts = plex
? [plex.year, formatRuntime(card.duration_seconds)]
: video
? // The channel is the subtitle already.
[
video.view_count != null && `${formatViews(video.view_count)} ${t("card.views")}`,
relativeTime(video.published_at),
]
: [];
const factLine = facts.filter(Boolean).join(" · ");
// A feed video carries no description, so only a film or an episode has a summary line.
const summary = plex?.summary;
// A film's subtitle is its year, which the fact line already says.
const subtitle = card.subtitle !== String(plex?.year ?? "") ? card.subtitle : null;
<div className="flex min-w-0 flex-col">
return (
// Three quarters of a wide page, centred, so the one answer stands in focus (the owner, 2026-09-27);
// a narrower window gives it the whole width back. The padding is even on every side.
<section
{...menu.triggerProps(card)}
className={`${SURFACE.panel} mb-6 flex flex-col gap-5 p-5 sm:flex-row sm:items-center lg:mx-auto lg:w-3/4`}
>
<div
className={`group relative shrink-0 ${
card.aspect === "poster" ? "aspect-[2/3] w-40 sm:w-48" : "aspect-video w-full sm:w-72"
}`}
>
<Pressable
onClick={() => onPlay(card)}
className="relative block h-full w-full cursor-pointer overflow-hidden rounded-lg border border-border bg-card"
aria-label={`${started ? t("home.hero.continue") : t("home.hero.play")}: ${card.title}`}
>
{card.thumb ? (
<img src={card.thumb} alt="" className="w-full h-full object-cover" />
) : null}
{card.duration_seconds != null && (
// It steps aside while the hover row covers its corner, as a card's length does.
<span className={`${MEDIA_BADGE} bottom-1.5 right-1.5 ${HOVER_YIELD}`}>
{formatDuration(card.duration_seconds)}
</span>
)}
{started && card.duration_seconds ? (
<span className="absolute inset-x-0 bottom-0 h-1 bg-black/60">
<span
className="block h-full bg-accent"
style={{
width: `${Math.min(100, (card.position_seconds / card.duration_seconds) * 100)}%`,
}}
/>
</span>
) : null}
<div className={MEDIA_HOVER_LAYER}>{!started && <MediaPlayGlyph />}</div>
</Pressable>
{/* A started hero continues on a click, and offers the start as a card does (C5): the pair
is a sibling over the picture, never inside its button. */}
{started && (
<div
className={clsx(
HOVER_REVEAL,
"pointer-events-none absolute inset-0 z-base flex items-center justify-center gap-2 *:pointer-events-auto",
card.aspect === "poster" && "flex-col",
)}
>
<MediaResumeButtons onContinue={() => onPlay(card)} onRestart={() => onPlay(card, 0)} />
</div>
)}
{/* The card's own hover row, as on every card: a sibling of the picture's button. */}
{card.video ? (
<div className={MEDIA_BAR}>
<VideoHoverActions
video={card.video}
onState={onState}
onToggleSave={onToggleSave}
media
more={{ props: menu.buttonProps(card), open: menu.open }}
/>
</div>
) : card.plex ? (
<PlexHoverBar
card={card.plex}
onToggleWatched={onToggleWatched}
onAdd={plexAdd}
moreProps={menu.buttonProps(card)}
moreOpen={menu.open}
/>
) : null}
</div>
<div className="flex min-w-0 flex-1 flex-col">
<p className="text-2xs font-semibold uppercase tracking-wider text-accent">{kicker}</p>
<h2 className="mt-1 text-lg font-semibold leading-tight">{card.title}</h2>
{card.subtitle && <p className="mt-0.5 text-xs text-muted truncate">{card.subtitle}</p>}
<div className="mt-auto flex flex-wrap items-center gap-2 pt-3">
{subtitle && <p className="mt-0.5 text-xs text-muted truncate">{subtitle}</p>}
{(factLine || plex?.rating != null) && (
<p className="mt-1 flex flex-wrap items-center gap-x-1.5 text-xs text-muted">
{factLine && <span>{factLine}</span>}
{plex?.rating != null && (
<span className="inline-flex items-center gap-0.5">
{factLine && <span aria-hidden>·</span>}
<Star className="h-3 w-3 fill-current text-rating" aria-hidden />
{plex.rating.toFixed(1)}
</span>
)}
</p>
)}
{/* Only when there is one (a missing summary leaves no gap, the stack is centred), at the
reading measure: the eye finds the next line's start again (it ran ~130 characters). */}
{summary && <p className="mt-2 measure line-clamp-2 text-sm text-muted">{summary}</p>}
<div className="flex flex-wrap items-center gap-2 pt-4">
<Button icon={<Play className="h-4 w-4" />} onClick={() => onPlay(card)}>
{started ? t("home.hero.continue") : t("home.hero.play")}
</Button>
@@ -349,16 +650,18 @@ function Hero({
>
{t("home.hero.reroll")}
</Button>
{/* On touch the picture's hover row is gone; the "…" stands here instead, as it sits
beside a card's title. */}
<MoreButton
className="touch-only"
label={t("card.menu.more")}
expanded={menu.open}
menuProps={menu.buttonProps(card)}
/>
</div>
</div>
{/* A comfort hero acts on the pin it came from: a pinned show's next episode is not itself
pinned, and looking the episode up offered "Pin" under "From your shelf" — a second pin.
The owner's call (2026-09-19): the row unpins the show. */}
<ComfortPinMenu
menu={pinMenu}
target={(c) => pinTargetForCard(c.pinned_from ?? c)}
title={(c) => (c.pinned_from ?? c).title}
/>
<ContextMenu menu={menu} ariaLabel={card.title} items={items} />
{panels}
</section>
);
}
@@ -572,6 +875,7 @@ export default function Home() {
setJumpTo({
id: target.plex.id,
type: target.plex.type === "show" ? "show" : "play",
fromStart: startAt === 0,
replace: true,
});
setPage("plex");
@@ -580,8 +884,9 @@ export default function Home() {
[setJumpTo, setPage],
);
// The ⓘ affordance on a Plex tile: the item's own page in the Plex module, exactly where the same
// click goes from the Plex grid. A show tile's card click lands there too — a show is not playable.
// The "Media info" row of a Plex tile's menu: the item's own page in the Plex module, exactly where
// the same row goes from the Plex grid. A show tile's card click lands there too — a show is not
// playable.
const onInfo = useCallback(
(card: DiscoverCard) => {
if (!card.plex) return;
@@ -700,7 +1005,7 @@ export default function Home() {
if (surpriseRequest !== null && takeSurprise()) reroll("surprise");
}, [surpriseRequest, reroll]);
if (q.isPending) return <LoadingState />;
if (q.isPending) return <LoadingState shape="grid" />;
// `&& !data`, the codebase-wide convention (and the one `ui/QueryState` documents): react-query
// flips `status` to error on a FAILED REFETCH while keeping the cached payload, and every mutation
// on this page invalidates `qk.discover()` — so without the guard, marking one card watched over a
@@ -738,13 +1043,17 @@ export default function Home() {
tier={hero.tier}
card={hero.card}
onPlay={onPlay}
onInfo={onInfo}
onState={onState}
onToggleSave={onToggleSave}
onToggleWatched={onToggleWatched}
onReroll={() => reroll()}
rerolling={rerolling}
/>
) : (
// Honest: nothing in the library fits what was asked. Offering something that does not fit
// would make the one control on this page meaningless.
<section className="mb-6 rounded-xl border border-border bg-surface p-4">
<section className={`${SURFACE.panel} mb-6 p-4`}>
{/* Not `StateMessage`'s `onRetry` slot: that button is labelled "Retry", which promises
a re-run of something that failed. Nothing failed — the library simply has no answer
this short, and the honest offer is to ask a different question. */}
+44 -35
View File
@@ -5,7 +5,7 @@ import { ExternalLink, FileText, Plus, Server, X } from "lucide-react";
import { api, type DebugScreenshot, type DebugSession, type ServerLogRow } from "../lib/api";
import { closeComposer } from "../lib/debugRecorder";
import { formatBytes } from "../lib/format";
import { formatBytes, formatDateTimeSec, formatUnit } from "../lib/format";
import { notify } from "../lib/notifications";
import { useMe } from "../lib/useMe";
import {
@@ -19,12 +19,16 @@ import {
import { moduleDef } from "../lib/modules";
import type { Page } from "../lib/urlState";
import { qk } from "../lib/queryKeys";
import { focusIsUnclaimed } from "../lib/useFocusKeeper";
import { LEVEL_TAG, logClock } from "./logLineParts";
import { Button } from "./ui/Button";
import Modal from "./ui/Modal";
import { IconButton } from "./ui/IconButton";
import Modal, { ModalFooter } from "./ui/Modal";
import { LoadingState, StateMessage } from "./ui/QueryState";
import SegmentedToggle from "./ui/SegmentedToggle";
import { inputCls } from "./ui/form";
import { Textarea, Input } from "./ui/Input";
import { Checkbox } from "./ui/NativeControls";
import { SURFACE } from "./ui/surfaces";
// The issue composer (E16 S3, D4 A): one dialog — the draft on the left (title, description, the
// steps drafted from the session, the environment), what goes with it on the right (the two logs,
@@ -51,14 +55,19 @@ export default function IssueComposer({ id }: { id: string }) {
useEffect(
() => () => {
window.setTimeout(() => {
if (document.activeElement === document.body)
if (focusIsUnclaimed())
document.querySelector<HTMLElement>('nav [aria-current="page"]')?.focus();
}, 0);
},
[],
);
return (
<Modal title={t("recorder.composer.title")} onClose={closeComposer} maxWidth="max-w-5xl">
<Modal
initialFocus="field"
title={t("recorder.composer.title")}
onClose={closeComposer}
maxWidth="max-w-5xl"
>
{failed && !(sessionQ.data && eventsQ.data) ? (
<StateMessage
tone="error"
@@ -295,8 +304,8 @@ function ComposerBody({
<div ref={root} className="flex flex-col gap-4">
<p className="text-sm text-muted">
{t("recorder.composer.meta", {
start: started.toLocaleString(i18n.language),
minutes,
start: formatDateTimeSec(started.toISOString()),
minutes: formatUnit(minutes, "minute"),
level: t(`settings.diagnostics.levels.${session.level}`),
})}
</p>
@@ -407,7 +416,7 @@ function ComposerBody({
/>
</div>
)}
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-border pt-3">
<ModalFooter>
<span className="mr-auto flex flex-col text-xs text-muted">
{target && <span>{t("recorder.composer.target", { repo: target.repo })}</span>}
<span aria-live="polite">
@@ -444,7 +453,7 @@ function ComposerBody({
{t("recorder.composer.send")}
</Button>
)}
</div>
</ModalFooter>
</div>
);
}
@@ -471,28 +480,28 @@ function DraftFields({
<label htmlFor="issue-title" className="text-sm text-muted">
{t("recorder.composer.fieldTitle")}
</label>
<input
<Input
className="w-full"
id="issue-title"
value={form.title}
readOnly={readOnly}
maxLength={200}
placeholder={t("recorder.composer.titlePlaceholder")}
onChange={(e) => edit((f) => ({ ...f, title: e.target.value }))}
className={inputCls}
/>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="issue-description" className="text-sm text-muted">
{t("recorder.composer.description")}
</label>
<textarea
<Textarea
className="w-full resize-y"
id="issue-description"
value={form.description}
readOnly={readOnly}
rows={4}
placeholder={t("recorder.composer.descriptionPlaceholder")}
onChange={(e) => edit((f) => ({ ...f, description: e.target.value }))}
className={`${inputCls} resize-y`}
/>
</div>
<fieldset className="flex flex-col gap-1.5 min-w-0">
@@ -506,30 +515,31 @@ function DraftFields({
<span className="w-6 shrink-0 text-sm font-semibold text-accent tabular-nums">
{i + 1}.
</span>
<input
<Input
className="flex-1"
size="sm"
aria-label={t("recorder.composer.stepLabel", { n: i + 1 })}
value={step.text}
readOnly={readOnly}
placeholder={t("recorder.composer.stepPlaceholder")}
onChange={(e) => setStep(i, { text: e.target.value })}
className="min-w-0 flex-1 bg-transparent text-sm outline-none focus-visible:ring-2 focus-visible:ring-accent/60 rounded px-1 py-1"
/>
<span className="chip shrink-0 px-2 py-0.5 text-2xs">
{t(`recorder.composer.stepKind.${step.kind}`)}
{step.shot ? ` · ${t("recorder.composer.withShot")}` : ""}
</span>
{!readOnly && (
<Button
variant="ghost"
size="icon-sm"
iconOnly
icon={<X className="w-4 h-4" />}
<IconButton
label={t("recorder.composer.removeStep", { n: i + 1 })}
size="sm"
variant="ghost"
onClick={() => {
edit((f) => ({ ...f, steps: f.steps.filter((_, j) => j !== i) }));
addStep.current?.focus();
}}
/>
>
<X className="w-4 h-4" />
</IconButton>
)}
</li>
))}
@@ -624,7 +634,7 @@ function Attachments({
const logRow = (source: Source, icon: React.ReactNode) => {
const [kept, total] = counts[source];
return (
<div className="flex items-center gap-3 rounded-xl border border-border bg-card/60 px-3 py-2.5">
<div className="flex items-center gap-3 rounded-lg border border-border px-3 py-2.5">
{icon}
<div className="flex min-w-0 flex-1 flex-col">
<span className="text-sm">
@@ -654,7 +664,7 @@ function Attachments({
};
return (
<div className="flex flex-col gap-3 min-w-0 rounded-xl bg-surface/40 p-3 md:p-4">
<div className={`${SURFACE.card} flex flex-col gap-3 min-w-0 p-3 md:p-4`}>
<div className="text-sm text-muted">{t("recorder.composer.attachments")}</div>
{logRow("web", <FileText className="w-4.5 h-4.5 shrink-0 text-muted" aria-hidden />)}
{logRow("server", <Server className="w-4.5 h-4.5 shrink-0 text-muted" aria-hidden />)}
@@ -666,7 +676,7 @@ function Attachments({
{shots.map((shot, i) => (
<div
key={shot.id}
className="flex items-center gap-3 rounded-xl border border-border bg-card/60 px-3 py-2"
className="flex items-center gap-3 rounded-lg border border-border px-3 py-2"
>
<img
src={api.debugScreenshotUrl(session.id, shot.id)}
@@ -678,18 +688,18 @@ function Attachments({
<span className="text-xs text-muted tabular-nums">{formatBytes(shot.size)}</span>
</div>
{!readOnly && (
<Button
variant="ghost"
size="icon-sm"
iconOnly
icon={<X className="w-4 h-4" />}
<IconButton
label={t("recorder.composer.removeShot", { n: i + 1 })}
size="sm"
variant="ghost"
loading={remove.isPending && remove.variables === shot.id}
onClick={() => {
remove.mutate(shot.id);
pick.current?.focus();
}}
/>
>
<X className="w-4 h-4" />
</IconButton>
)}
</div>
))}
@@ -752,19 +762,18 @@ function ReviewList({
{t("recorder.composer.reviewDone")}
</Button>
</div>
<ul className="max-h-[60vh] overflow-y-auto rounded-xl border border-border bg-bg/60 font-mono text-xs">
<ul className="max-h-[60dvh] overflow-y-auto rounded-xl border border-border bg-bg/60 font-mono text-xs">
{rows.map((row) => {
const tag = LEVEL_TAG[row.level];
const off = excluded.has(row.id);
return (
<li key={row.id} className="border-b border-border/50 last:border-b-0">
<label
className={`grid cursor-pointer grid-cols-[auto_6.5em_3.5em_minmax(0,1fr)] items-baseline gap-x-2 px-3 py-1 hover:bg-card sm:grid-cols-[auto_6.5em_3.5em_minmax(0,10em)_minmax(0,1fr)] ${
className={`grid cursor-pointer grid-cols-[auto_6.5em_3.5em_minmax(0,1fr)] items-baseline gap-x-2 px-3 py-1 state-layer sm:grid-cols-[auto_6.5em_3.5em_minmax(0,10em)_minmax(0,1fr)] ${
off ? "opacity-50 line-through" : ""
}`}
>
<input
type="checkbox"
<Checkbox
checked={!off}
disabled={readOnly}
aria-label={t("recorder.composer.keepRow", {
@@ -810,7 +819,7 @@ function IssuePreview({
return (
<div className="flex flex-col gap-1.5">
<p className="text-xs text-muted">{t("recorder.composer.previewLocal")}</p>
<pre className="max-h-[60vh] overflow-auto whitespace-pre-wrap rounded-xl border border-border bg-bg/60 p-3 font-mono text-xs">
<pre className="max-h-[60dvh] overflow-auto whitespace-pre-wrap rounded-xl border border-border bg-bg/60 p-3 font-mono text-xs">
{text}
</pre>
</div>
+12 -11
View File
@@ -6,7 +6,8 @@ import { AlertTriangle, Lock, ShieldCheck } from "lucide-react";
import { api, HttpError } from "../lib/api";
import * as e2ee from "../lib/e2ee";
import { Button } from "./ui/Button";
import { inputCls } from "./ui/form";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
// Secure-messaging key gate: first-run setup (choose a passphrase) or unlock on this device.
// Self-contained — on success it updates the shared e2ee unlock state, so any observing view
@@ -95,20 +96,20 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
if (view === "reset")
return (
<div className={`glass rounded-2xl space-y-3 ${compact ? "p-3" : "p-4"}`}>
<div className={`${SURFACE.panel} space-y-3 ${compact ? "p-3" : "p-4"}`}>
<div className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-danger shrink-0" />
<div className="font-semibold text-sm">{t("messages.resetTitle")}</div>
</div>
<p className="text-sm text-muted">{t("messages.resetWarn")}</p>
{hasPassword && (
<input
<Input
className="w-full"
type="password"
autoComplete="current-password"
value={curPass}
onChange={(e) => setCurPass(e.target.value)}
placeholder={t("messages.resetCurrentPass")}
className={inputCls}
/>
)}
{err && (
@@ -133,7 +134,7 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
);
return (
<div className={`glass rounded-2xl space-y-3 ${compact ? "p-3" : "p-4"}`}>
<div className={`${SURFACE.panel} space-y-3 ${compact ? "p-3" : "p-4"}`}>
<div className="flex items-center gap-2">
<ShieldCheck className="w-5 h-5 text-accent shrink-0" />
<div className="font-semibold text-sm">
@@ -145,21 +146,21 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
{configured ? t("messages.unlockBody") : t("messages.setupBody")}
</p>
)}
<input
<Input
className="w-full"
type="password"
value={pass}
onChange={(e) => setPass(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && configured && submit()}
placeholder={t("messages.passphrase")}
className={inputCls}
/>
{!configured && (
<input
<Input
className="w-full"
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
placeholder={t("messages.passphraseConfirm")}
className={inputCls}
/>
)}
{err && (
@@ -172,9 +173,9 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
{configured ? t("messages.unlock") : t("messages.setUp")}
</Button>
{configured && (
<button onClick={toReset} className="block text-xs text-muted hover:text-fg">
<Button variant="link-muted" onClick={toReset} className="text-xs">
{t("messages.forgotPass")}
</button>
</Button>
)}
{!configured && !compact && <p className="text-xs text-muted">{t("messages.setupWarn")}</p>}
</div>
+24 -17
View File
@@ -1,8 +1,12 @@
import { useState } from "react";
import { Check, Globe } from "lucide-react";
import { Globe } from "lucide-react";
import { MenuCheck } from "./ui/MenuCheck";
import { LANGUAGES, type LangCode } from "../i18n";
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { Pressable } from "./ui/Pressable";
import { RAIL_GLYPH, RAIL_ROW } from "./ui/NavRail";
import { SURFACE } from "./ui/surfaces";
// Compact language picker (globe + current code). Presentational: the parent decides what
// changing the language does (set it; persist server-side when signed in).
@@ -42,33 +46,36 @@ export default function LanguageSwitcher({
return (
<>
<button
<Pressable
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
title={current.label}
aria-label={current.label}
layer
className={
rail
? "relative p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
: "flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-card transition"
? `${RAIL_ROW} h-9 text-muted hover:text-fg`
: "flex h-7 items-center gap-1.5 px-2.5 rounded-lg text-sm text-muted hover:text-fg transition touch-target"
}
>
<Globe className={rail ? "w-5 h-5" : "w-4 h-4"} />
{rail ? (
/* Current-language badge (HU/EN) so the active language reads at a glance without
opening the menu. The ring matches the page background to detach it from the icon. */
<span className="absolute -bottom-1 -right-1 text-[0.5rem] font-bold leading-none px-1 py-px rounded-sm bg-accent text-accent-fg ring-2 ring-bg">
{current.code.toUpperCase()}
/* A rail row (F32), with the current-language badge (HU/EN) on the glyph's corner so the
active language reads at a glance. The ring matches the page to detach it from the icon;
its type is the rail count badge's (text-3xs), the smallest step on the scale (it was an
8px one-off). */
<span className="relative">
<Globe className={RAIL_GLYPH} />
<span className="absolute -bottom-1.5 -right-2.5 text-3xs font-bold leading-none px-1 py-px rounded-sm bg-accent text-accent-fg ring-2 ring-bg">
{current.code.toUpperCase()}
</span>
</span>
) : (
<span className="text-xs font-semibold uppercase">{current.code}</span>
<Globe className="w-4 h-4" />
)}
</button>
{rail ? null : <span className="text-xs font-semibold uppercase">{current.code}</span>}
</Pressable>
<PopoverSurface
popover={pop}
className="glass-menu w-40 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
>
<PopoverSurface popover={pop} className={`${SURFACE.menu} w-40 animate-[popIn_0.16s_ease]`}>
{LANGUAGES.map((l) => (
<PopoverItem
key={l.code}
@@ -79,10 +86,10 @@ export default function LanguageSwitcher({
setOpen(false);
}}
label={l.label}
className="w-full flex items-center justify-between gap-2 text-sm px-2 py-2 rounded-lg text-muted outline-hidden transition hover:text-fg"
className="w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted outline-hidden transition hover:text-fg"
>
<MenuCheck kind="one" on={l.code === value} />
<span>{l.label}</span>
{l.code === value && <Check className="w-4 h-4 text-accent" />}
</PopoverItem>
))}
</PopoverSurface>
+2 -2
View File
@@ -41,7 +41,7 @@ export default function LibraryPlayer({ job, onClose }: { job: DownloadJob; onCl
);
return (
<Modal title={jobTitle(job)} onClose={onClose} maxWidth="max-w-4xl">
<Modal initialFocus="close" title={jobTitle(job)} onClose={onClose} maxWidth="max-w-4xl">
{isAudio ? (
<div className="flex flex-col items-center gap-4 py-2">
{poster ? (
@@ -63,7 +63,7 @@ export default function LibraryPlayer({ job, onClose }: { job: DownloadJob; onCl
controls
autoPlay
playsInline
className="w-full max-h-[70vh] rounded-lg bg-black object-contain"
className="w-full max-h-[70dvh] rounded-lg bg-black object-contain"
>
{t("downloads.player.unsupported")}
</video>
+7 -5
View File
@@ -3,6 +3,7 @@ import type { ReactNode } from "react";
import type { Level } from "../lib/log";
import { navItemAttrs } from "../lib/listNav";
import { LEVEL_TAG, logClock } from "./logLineParts";
import { Pressable } from "./ui/Pressable";
// One log line as both log viewers draw it — "Saját napló" (this tab's ring) and the admin
// diagnostics console (the server's stored log): time, level, event code, message, and the line opens
@@ -37,15 +38,16 @@ export default function LogLine({
return (
<div
className="border-b border-border/50 last:border-b-0"
{...navItemAttrs(navIndex, `${event} ${msg}`)}
{...navItemAttrs(navIndex, `${event} ${msg}`, { inset: true })}
>
<button
type="button"
<Pressable
selectable
layer={!open}
data-nav-open
aria-expanded={open}
onClick={onToggle}
title={title}
className={`grid w-full grid-cols-[6.5em_3.5em_minmax(0,1fr)] items-baseline sm:grid-cols-[6.5em_3.5em_minmax(0,10em)_minmax(0,1fr)] gap-x-2 px-3 py-1 text-left hover:bg-card ${
className={`grid w-full grid-cols-[6.5em_3.5em_minmax(0,1fr)] items-baseline sm:grid-cols-[6.5em_3.5em_minmax(0,10em)_minmax(0,1fr)] gap-x-2 px-3 py-1 touch-target pointer-coarse:content-center ${
open ? "bg-accent/10" : ""
}`}
>
@@ -55,7 +57,7 @@ export default function LogLine({
{/* A phone has no room for a fourth column: the message takes a line of its own under the
code, full width — sharing the code's line left it a few characters. */}
<span className="col-span-3 truncate sm:col-span-1">{msg}</span>
</button>
</Pressable>
{open && <div className="bg-accent/5 px-3 py-2 text-muted">{children}</div>}
</div>
);
@@ -0,0 +1,66 @@
import type { MouseEvent } from "react";
import { useTranslation } from "react-i18next";
import { Play, RotateCcw } from "lucide-react";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
// The started card's hover pair (DESIGN "Players", C5: restarting lives on the card): Continue in the
// accent, Restart dark translucent. ONE component for the YouTube card and the Plex cards, so the one
// media card says the same two words in the same two looks. A small picture (a shelf's landscape, the
// dense video card) takes the icon-only pair, which fits beside the hover row. Touch has no hover: a
// tap on the picture continues, and the card's "…" carries the restart.
export function MediaResumeButtons({
small = false,
onContinue,
onRestart,
}: {
small?: boolean;
onContinue: (e: MouseEvent) => void;
onRestart: (e: MouseEvent) => void;
}) {
const { t } = useTranslation();
return small ? (
<>
<IconButton
variant="primary"
size="md"
round
onClick={onContinue}
label={t("card.continue")}
title={t("card.continueTitle")}
>
<Play className="fill-current" />
</IconButton>
<IconButton
variant="media"
size="md"
onClick={onRestart}
label={t("card.restart")}
title={t("card.restartTitle")}
>
<RotateCcw />
</IconButton>
</>
) : (
<>
<Button
size="sm"
round
onClick={onContinue}
title={t("card.continueTitle")}
icon={<Play className="fill-current" />}
>
{t("card.continue")}
</Button>
<Button
variant="media"
size="sm"
onClick={onRestart}
title={t("card.restartTitle")}
icon={<RotateCcw />}
>
{t("card.restart")}
</Button>
</>
);
}
+52 -45
View File
@@ -6,18 +6,22 @@ import { ArrowLeft, ShieldCheck, SquarePen, ExternalLink } from "lucide-react";
import { api, type Conversation, type MessageUser } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { relativeTime } from "../lib/format";
import { partnerPub, POLL_MS, SYSTEM_ID, useKeyState } from "../lib/messaging";
import { useHistorySubview } from "../lib/history";
import { openChat } from "../lib/chatDock";
import * as e2ee from "../lib/e2ee";
import Avatar from "./ui/Avatar";
import KeyGate from "./KeyGate";
import { inputCls } from "./ui/form";
import ChatThread from "./ChatThread";
import NavGrid from "./ui/NavGrid";
import { navItemAttrs } from "../lib/listNav";
import { Pressable } from "./ui/Pressable";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
import { SectionLabel } from "./ui/Headings";
import { RelativeTime } from "./ui/RelativeTime";
type View =
| "list"
@@ -126,9 +130,7 @@ function ConversationList({
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="text-xs uppercase tracking-wide text-muted">
{t("messages.conversations")}
</div>
<SectionLabel>{t("messages.conversations")}</SectionLabel>
{ready && (
<Button variant="primary" size="xs" onClick={onNew}>
<SquarePen className="w-4 h-4" />
@@ -142,9 +144,9 @@ function ConversationList({
{t("common.loadError")}
</StateMessage>
) : q.isLoading && !q.data ? (
<LoadingState />
<LoadingState shape="list" />
) : items.length === 0 ? (
<div className="glass rounded-2xl p-10 text-center text-muted">{t("messages.empty")}</div>
<div className={`${SURFACE.panel} p-10 text-center text-muted`}>{t("messages.empty")}</div>
) : (
// A keyboard list (E11): `j`/`k` walk the conversations, Enter opens the one the name button
// opens; the pop-out button beside it keeps its own Enter.
@@ -155,12 +157,13 @@ function ConversationList({
<div
key={c.partner.id}
{...navItemAttrs(i, c.partner.name)}
className="group flex items-center gap-1 rounded-xl hover:bg-card transition"
className="group flex items-center gap-1 rounded-xl state-layer transition"
>
<button
<Pressable
selectable
data-nav-open=""
onClick={() => onOpen(c)}
className="flex items-center gap-3 p-2.5 text-left min-w-0 flex-1"
className="flex items-center gap-3 p-2.5 min-w-0 flex-1"
>
<Avatar
src={c.partner.avatar_url}
@@ -174,9 +177,10 @@ function ConversationList({
>
{c.partner.name}
</span>
<span className="ml-auto text-2xs text-muted shrink-0">
{relativeTime(c.last_message.created_at)}
</span>
<RelativeTime
at={c.last_message.created_at}
className="ml-auto text-2xs text-muted shrink-0"
/>
</div>
<div className="flex items-center gap-2">
<span
@@ -191,16 +195,16 @@ function ConversationList({
)}
</div>
</div>
</button>
</Pressable>
{!isSystem && ready && (
<button
<IconButton
size="md"
onClick={() => openChat(c.partner)}
title={t("messages.popOut")}
aria-label={t("messages.popOut")}
className="shrink-0 mr-1.5 p-1.5 rounded-lg text-muted reveal hover:text-accent hover:bg-bg transition"
label={t("messages.popOut")}
className="mr-1.5 reveal"
>
<ExternalLink className="w-4 h-4" />
</button>
<ExternalLink />
</IconButton>
)}
</div>
);
@@ -221,40 +225,43 @@ function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBac
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Button
<IconButton
label={t("channel.back")}
size="sm"
variant="ghost"
size="icon-sm"
// An icon-only button needs a name: both of these announced themselves as a bare "button",
// and they are the only way out of the sub-view (E10 review round 1).
label={t("channel.back")}
onClick={onBack}
icon={<ArrowLeft className="w-4 h-4" />}
/>
<div className="text-xs uppercase tracking-wide text-muted">{t("messages.newTitle")}</div>
>
<ArrowLeft className="w-4 h-4" />
</IconButton>
<SectionLabel>{t("messages.newTitle")}</SectionLabel>
</div>
<input
<Input
className="w-full"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder={t("messages.searchPeople")}
className={inputCls}
/>
{q.isError && !q.data ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading ? (
<LoadingState />
<LoadingState shape="list" />
) : users.length === 0 ? (
<div className="glass rounded-2xl p-10 text-center text-muted">
<div className={`${SURFACE.panel} p-10 text-center text-muted`}>
{t("messages.noPeople")}
</div>
) : (
<div className="flex flex-col gap-1">
{users.map((u) => (
<button
<Pressable
selectable
key={u.id}
layer
onClick={() => onPick(u)}
className="flex items-center gap-3 p-2.5 rounded-xl text-left hover:bg-card transition"
className="flex items-center gap-3 p-2.5 rounded-xl"
>
<Avatar
src={u.avatar_url}
@@ -262,7 +269,7 @@ function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBac
className="w-9 h-9 rounded-full shrink-0 text-sm"
/>
<span className="truncate font-medium">{u.name}</span>
</button>
</Pressable>
))}
</div>
)}
@@ -289,17 +296,18 @@ function PageThread({
const ready = useKeyState() === "ready";
const name = seedName ?? "";
return (
<div className="flex flex-col h-[65vh] min-h-80">
<div className="flex flex-col h-[65dvh] min-h-80">
<div className="flex items-center gap-3 pb-3 border-b border-border">
<Button
<IconButton
label={t("channel.back")}
size="sm"
variant="ghost"
size="icon-sm"
// An icon-only button needs a name: both of these announced themselves as a bare "button",
// and they are the only way out of the sub-view (E10 review round 1).
label={t("channel.back")}
onClick={onBack}
icon={<ArrowLeft className="w-4 h-4" />}
/>
>
<ArrowLeft className="w-4 h-4" />
</IconButton>
<Avatar
src={seedAvatar}
fallback={name}
@@ -314,14 +322,13 @@ function PageThread({
)}
<div className="flex-1" />
{!isSystem && ready && (
<button
<IconButton
size="md"
onClick={() => openChat({ id: partnerId, name, avatar_url: seedAvatar ?? null })}
title={t("messages.popOut")}
aria-label={t("messages.popOut")}
className="p-1.5 rounded-lg text-muted hover:text-accent hover:bg-card transition"
label={t("messages.popOut")}
>
<ExternalLink className="w-4 h-4" />
</button>
<ExternalLink />
</IconButton>
)}
</div>
<ChatThread meId={meId} partnerId={partnerId} isSystem={isSystem} />
+10 -3
View File
@@ -1,3 +1,4 @@
import clsx from "clsx";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Copy, Search } from "lucide-react";
@@ -16,6 +17,8 @@ import LogLine from "./LogLine";
import NavGrid from "./ui/NavGrid";
import { Button } from "./ui/Button";
import FilterChipGroup from "./ui/FilterChipGroup";
import { InnerInput } from "./ui/Input";
import { fieldClass } from "./ui/fieldStyles";
// Rows rendered at once. The ring holds up to 2000 events; drawing all of them on every new event
// would make the settings page the slowest thing in the app while it is watching the app.
@@ -123,15 +126,19 @@ export default function MyLogViewer() {
<div data-testid="my-log" className="overflow-hidden rounded-xl border border-border bg-bg">
<p className="px-3 pt-2 text-xs text-muted">{t("settings.myLog.hint")}</p>
<div className="flex flex-wrap items-center gap-2 border-b border-border p-2">
<label className="flex min-w-[10rem] flex-1 items-center gap-1.5 rounded-lg border border-border bg-card px-2 py-1 text-sm focus-within:border-accent">
<label
className={clsx(
fieldClass({ size: "sm", pill: true }),
"flex min-w-[10rem] flex-1 items-center gap-1.5",
)}
>
<Search className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden />
<input
<InnerInput
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("settings.myLog.search")}
aria-label={t("settings.myLog.search")}
className="min-w-0 flex-1 bg-transparent outline-none"
/>
</label>
<FilterChipGroup
+67 -99
View File
@@ -11,15 +11,30 @@ import * as e2ee from "../lib/e2ee";
import { useLiveQuery } from "../lib/useLiveQuery";
import { getUnreadCount, subscribe } from "../lib/notifications";
import type { Page } from "../lib/urlState";
import { moduleDef, moduleLabelKey, moduleOrder, SYSTEM_PAGES } from "../lib/modules";
import {
moduleDef,
moduleLabelKey,
moduleOrder,
moduleRailKey,
SYSTEM_PAGES,
} from "../lib/modules";
import { useScrollFade } from "../lib/useScrollFade";
import { type LangCode } from "../i18n";
import { Button } from "./ui/Button";
import AvatarImg from "./ui/Avatar";
import Tooltip from "./ui/Tooltip";
import LanguageSwitcher from "./LanguageSwitcher";
import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { Pressable } from "./ui/Pressable";
import { SURFACE } from "./ui/surfaces";
import {
NavRail,
NavRailAction,
NavRailDivider,
NavRailItem,
NavRailList,
RAIL_ROW,
} from "./ui/NavRail";
import { usePrefs } from "./prefsContext";
// Primary app navigation: a thin icon-only left rail. The modules used to hide under the avatar
// dropdown; they now live here, each revealing its name in a hover/focus flyout label. Account
@@ -155,107 +170,60 @@ export default function NavSidebar({
.filter((p) => sys.has(p))
.map((p) => ({ page: p, ...META(p) }));
const rowBase =
"selectable w-full flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm transition";
const { railLabels } = usePrefs();
const name = displayName(me);
// Role is surfaced as a plain title tooltip on the account row (no visual badge).
const roleLabel = t(`nav.role.${roleKey(me)}`);
const renderItem = ({ page: p, icon: Icon, label, badge }: NavItem) => {
const active = page === p;
// On the active row the background is the accent colour, so a same-accent badge would be
// red-on-red. Invert it there (light pill, accent-coloured number) for clean contrast.
const badgeColor = active ? "bg-accent-fg text-accent" : "bg-accent text-accent-fg";
const show = !!badge && badge > 0;
return (
// The name rides in a right-side flyout on hover (rest-to-show), so the rail stays icon-only.
<Tooltip key={p} hint={label} side="right" always openDelay={350}>
<button
data-testid={`nav-${p}`}
onClick={() => setPage(p)}
aria-label={label}
aria-current={active ? "page" : undefined}
className={`${rowBase} justify-center px-0 relative is-current`}
>
<span className="relative shrink-0">
<Icon className="w-4.5 h-4.5" />
{/* A numeric badge centred on a circle at the icon corner; the ring matches the row
background so it reads cleanly whether the row is active or not. */}
{show && (
<span
className={`absolute -top-2 -right-2 min-w-4 h-4 px-1 rounded-full text-3xs font-bold leading-none grid place-items-center ring-2 ${badgeColor} ${
active ? "ring-accent" : "ring-bg"
}`}
>
{badge > 9 ? "9+" : badge}
</span>
)}
</span>
</button>
</Tooltip>
);
};
const renderItem = ({ page: p, icon, label, badge }: NavItem) => (
<NavRailItem
key={p}
testId={`nav-${p}`}
icon={icon}
label={label}
shortLabel={t(moduleRailKey[p])}
showLabel={railLabels}
active={page === p}
badge={badge}
onClick={() => setPage(p)}
/>
);
return (
// Slim icon rail, flush against the left viewport edge (only top/bottom margin), never grows.
// The slot is exactly the card's width so the filter tab/bar docks flush against its right edge.
<div className="relative shrink-0 w-14">
<nav
className="glass absolute top-0 bottom-0 left-0 z-rail rounded-2xl flex flex-col py-3 my-3 w-14 px-2"
aria-label={t("nav.primary")}
>
<div
ref={listFade.ref}
className="flex-1 min-h-0 overflow-y-auto no-scrollbar flex flex-col gap-1"
style={listFade.style}
>
// Slim rail (B7), flush against the left viewport edge (only top/bottom margin), never grows —
// labels on or off. The slot is exactly the rail's width so the filter tab/bar docks flush
// against its right edge.
<div className="relative shrink-0 w-15">
<NavRail label={t("nav.primary")} className="absolute top-0 bottom-0 left-0 z-rail my-3">
<NavRailList ref={listFade.ref} style={listFade.style}>
{userItems.map(renderItem)}
{systemItems.length > 0 && (
// A short, thicker centred rule is the clearest "new section" cue on an icon-only rail.
<div className="my-2 mx-auto w-7 border-t-2 border-border" />
)}
{systemItems.length > 0 && <NavRailDivider />}
{systemItems.map(renderItem)}
</div>
</NavRailList>
<div className="mt-2 pt-2 border-t border-border flex flex-col gap-1">
<div className="flex flex-col items-center justify-center gap-2 pb-2 mb-1 border-b border-border">
<div className="flex flex-col gap-1 pb-2 mb-1 border-b border-border">
<LanguageSwitcher variant="rail" value={language} onChange={onChangeLanguage} />
<Button
variant="ghost"
size="icon"
onClick={onOpenAbout}
title={t("header.account.about")}
aria-label={t("header.account.about")}
icon={<Info className="w-5 h-5" />}
/>
<Button
variant="ghost"
size="icon"
onClick={logout}
title={t("header.account.signOut")}
aria-label={t("header.account.signOut")}
icon={<LogOut className="w-5 h-5" />}
/>
<NavRailAction icon={Info} label={t("header.account.about")} onClick={onOpenAbout} />
<NavRailAction icon={LogOut} label={t("header.account.signOut")} onClick={logout} />
</div>
<Tooltip hint={t("header.account.settings")} side="right" always openDelay={350}>
<button
data-testid="nav-settings"
onClick={() => setPage("settings")}
aria-label={t("header.account.settings")}
aria-current={page === "settings" ? "page" : undefined}
className={`${rowBase} justify-center px-0 is-current`}
>
<Settings className="w-4.5 h-4.5 shrink-0" />
</button>
</Tooltip>
<NavRailItem
testId="nav-settings"
icon={Settings}
label={t("header.account.settings")}
shortLabel={t(moduleRailKey.settings)}
showLabel={railLabels}
active={page === "settings"}
onClick={() => setPage("settings")}
/>
<div>
<button
<Pressable
ref={acctPop.refs.setReference}
{...acctPop.getReferenceProps()}
title={`${name} · ${roleLabel}`}
aria-label={`${name} · ${roleLabel}`}
className={`${rowBase} justify-center px-0 text-muted hover:text-fg hover:bg-card`}
className={`${RAIL_ROW} h-9 text-muted state-layer hover:text-fg`}
>
<span className="relative shrink-0">
<AvatarImg
@@ -264,12 +232,12 @@ export default function NavSidebar({
className="w-5.5 h-5.5 rounded-full text-3xs"
/>
</span>
</button>
</Pressable>
<PopoverSurface
popover={acctPop}
ariaLabel={name}
className="glass w-60 rounded-xl p-3 animate-[popIn_0.16s_ease]"
className={`${SURFACE.popover} w-60 p-3 animate-[popIn_0.16s_ease]`}
>
<div className="flex items-center gap-3 pb-3 border-b border-border">
<AvatarImg
@@ -305,29 +273,29 @@ export default function NavSidebar({
{otherAccounts.length > 0 && (
<div className="mt-2 pt-2 border-t border-border flex flex-col gap-0.5">
{otherAccounts.map((a) => (
<button
<Pressable
key={a.id}
onClick={() => switchTo(a.id)}
title={t("header.account.switchTo", { name: a.display_name ?? a.email })}
className="selectable w-full flex items-center gap-2.5 px-2 py-1.5 rounded-lg hover:bg-card transition"
className="selectable w-full flex items-center gap-2.5 px-2 py-1.5 rounded-lg transition touch-target"
>
<AvatarImg
src={a.avatar_url}
fallback={a.display_name ?? a.email}
className="w-6 h-6 rounded-full text-3xs shrink-0"
/>
<span className="min-w-0 text-left">
<span className="min-w-0">
<span className="block text-[0.8125rem] text-fg truncate">
{displayName(a)}
</span>
<span className="block text-2xs text-muted truncate">{a.email}</span>
</span>
</button>
</Pressable>
))}
</div>
)}
<div className="mt-2 pt-2 border-t border-border flex flex-col">
<button
<Pressable
onClick={() => {
// Adding an account switches THIS tab to it: drop the tab's pin so that on
// return from Google it adopts the freshly-added account (the new default)
@@ -335,23 +303,23 @@ export default function NavSidebar({
pinOnNextPage(null);
window.location.href = "/auth/login";
}}
className="selectable w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
className="selectable w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg transition touch-target"
>
<UserPlus className="w-4 h-4" />
{t("header.account.addAccount")}
</button>
<button
</Pressable>
<Pressable
onClick={logout}
className="selectable w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
className="selectable w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg transition touch-target"
>
<LogOut className="w-4 h-4" />
{t("header.account.signOut")}
</button>
</Pressable>
</div>
</PopoverSurface>
</div>
</div>
</nav>
</NavRail>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More