release: 0.117.0 (improvement/e57-files-and-small-ideas)

This commit is contained in:
2026-11-06 03:09:58 +01:00
76 changed files with 2397 additions and 279 deletions
+13
View File
@@ -7,6 +7,19 @@ edit the release notes and re-run the generator.
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
## 0.117.0 — 2026-11-06
### New
- A share link's address now says what it opens: /file/ for a file, /folder/ for a folder, /watch/ for a video. Links you already sent keep working.
- The upload dialog shows which folder the file goes to, and you can pick another one or make a new folder there. Opening Share copies a link at once.
- The Channels tag chips show how many followed channels each holds. Continue watching in Plex has a See all, and a Plex item's Info shows the file size.
### Fixed
- An expired share link is marked as expired and is never copied. A crop of a video with wide pixels keeps exactly what its frame showed.
- A Plex auto-skip waits 3 seconds into the marker before its countdown starts.
## 0.116.3 — 2026-11-04
### Fixed
+1 -1
View File
@@ -74,7 +74,7 @@ only along a declared edge and writes them only through that unit's service; cor
cannot (an ORM write on another unit's loaded row, SQL in `text()`, a commit by `begin()`) is review's to catch, listed in
[`backend/app/README.md`](backend/app/README.md) with the conventions.
Frontend, by folder under `src/`: `main.tsx` provider tree → `shell/` (`App`, header, nav rail, palette,
boot; a shared `/watch` link boots from its own entry, `watch.html` → `watchMain.tsx` → `watch/`, held
boot; a share link (`/watch`, `/file`, `/folder`) boots from its own entry, `watch.html` → `watchMain.tsx` → `watch/`, held
app-free by a contract test) → **`modules/<page>/`** (one folder per page: the page, its rail, its store, its tests,
and its `module.ts` definition — label, icon, gate, the lazy page and rail, its store — collected in nav order by
`features/navigation/moduleRegistry`, which also mounts every store at the root; a new page is its folder, its id in
+1 -1
View File
@@ -1 +1 @@
0.116.3
0.117.0
+10
View File
@@ -201,6 +201,16 @@ def _ratio(v: object) -> float | None:
return n / d if n > 0 and d > 0 else None
def quarter_turned(probe: dict) -> bool | None:
"""Whether the first video stream of an ffprobe `-show_streams` JSON is stored turned a quarter
(ffmpeg stands it upright before any filter, so its filters see width and height swapped). None
when there is no video stream."""
for s in probe.get("streams") or []:
if s.get("codec_type") == "video":
return _quarter_turned(s)
return None
def _quarter_turned(stream: dict) -> bool:
rotation: object = (stream.get("tags") or {}).get("rotate")
for sd in stream.get("side_data_list") or []:
+48 -5
View File
@@ -11,7 +11,9 @@ with the worker branching on the asset's `source_kind` to run ffmpeg instead of
(the `-ss` seeks first), so a short clip is cheap even from a long source
* crop (± trim) → always re-encode (a filter can't be stream-copied), frame-accurate
`edit_spec = {trim?: {start_s, end_s}, crop?: {x, y, w, h}, accurate?: bool}`. `accurate` is the
`edit_spec = {trim?: {start_s, end_s}, crop?: {x, y, w, h, frame_w?, frame_h?}, accurate?: bool}`. A
crop with `frame_w`/`frame_h` counts the picture as it is SHOWN, measured on a frame of that size
(`crop_filter`); one without them is in the file's stored pixels (its older form). `accurate` is the
per-edit user choice for a trim-only cut (ignored when a crop forces a re-encode anyway). `split`
is a frontend concept: the
UI fans a split out into N trim jobs, so there is one output file per job here.
@@ -60,6 +62,46 @@ class EditAborted(Exception):
_MAX_SEGMENTS = 100
def _crop_frame(crop: dict) -> dict | None:
"""The picture size a crop was measured on, or None when it has none it could be scaled by (a
spec from before it was sent, which `crop_filter` cuts in the file's own pixels)."""
try:
w, h = int(crop["frame_w"]), int(crop["frame_h"])
# silent-ok: no frame is a crop's older form, and it is still the crop that was asked for. An
# infinite number (JSON `1e400`) is OverflowError, and it is no more a frame than "x" is.
except (KeyError, TypeError, ValueError, OverflowError):
return None
return {"frame_w": w, "frame_h": h} if w > 0 and h > 0 else None
def crop_filter(crop: dict) -> str:
"""The ffmpeg `crop` of an edit. Its numbers count the picture as the browser reports it, which
is its SHOWN size: a video with wide pixels (320 stored, 640 shown) would be cut off to one side
in ffmpeg's stored ones, so they are scaled to `iw` / `ih` by the size they were measured on. A
rotated file needs nothing more: both the browser and ffmpeg's filters see it upright."""
frame_w, frame_h = crop.get("frame_w"), crop.get("frame_h")
if not frame_w or not frame_h:
return f"crop={crop['w']}:{crop['h']}:{crop['x']}:{crop['y']}"
return (
f"crop=iw*{crop['w']}/{frame_w}:ih*{crop['h']}/{frame_h}"
f":iw*{crop['x']}/{frame_w}:ih*{crop['y']}/{frame_h}"
)
def crop_stored_size(crop: dict, src_w: int | None, src_h: int | None) -> tuple[int, int] | None:
"""The size `crop_filter` cuts out of a `src_w` x `src_h` frame, evened as `EVEN_FRAME` evens it —
the clip's size when ffprobe cannot tell. The frame is the one ffmpeg's filters see: a rotated
file's stood upright, its stored size turned (the caller's to turn). A crop counted in the shown
picture is a fraction of that frame, not its own pixel count; None when the size is unknown."""
if crop.get("frame_w") and crop.get("frame_h"):
if not src_w or not src_h:
return None
w, h = src_w * crop["w"] // crop["frame_w"], src_h * crop["h"] // crop["frame_h"]
else:
w, h = crop["w"], crop["h"]
return w // 2 * 2, h // 2 * 2
def normalize_edit_spec(spec: dict | None) -> dict:
"""Canonicalize a raw edit spec: clamp/round trim, coerce crop to ints, drop empties.
@@ -72,7 +114,7 @@ def normalize_edit_spec(spec: dict | None) -> dict:
if crop:
try:
c = {k: int(round(float(crop[k]))) for k in ("x", "y", "w", "h")}
except (KeyError, TypeError, ValueError) as exc:
except (KeyError, TypeError, ValueError, OverflowError) as exc:
# The clip still renders — uncropped. The user asked for a crop, got a whole frame,
# and nothing anywhere called it a failure; this line is the only trace there is.
log.warning(
@@ -81,7 +123,8 @@ def normalize_edit_spec(spec: dict | None) -> dict:
)
c = None
if c and c["w"] > 0 and c["h"] > 0 and c["x"] >= 0 and c["y"] >= 0:
out["crop"] = c
frame = _crop_frame(crop)
out["crop"] = {**c, **frame} if frame else c
# Multi-segment JOIN (cut-list → one concatenated file). Takes precedence over `trim`.
segs = spec.get("segments")
@@ -268,7 +311,7 @@ def build_edit_cmd(src: Path, dest: Path, spec: dict, out_ext: str) -> list[str]
if reencode:
# Input -ss + re-encode is frame-accurate (decodes from the prior keyframe, discards up to
# the exact cut). A crop adds the filter; an accurate trim only evens the frame.
cropf = f"crop={crop['w']}:{crop['h']}:{crop['x']}:{crop['y']}," if crop else ""
cropf = f"{crop_filter(crop)}," if crop else ""
cmd += ["-vf", cropf + EVEN_FRAME, *X264_ARGS, *AAC_ARGS]
else:
cmd += ["-c", "copy", "-avoid_negative_ts", "make_zero"]
@@ -313,7 +356,7 @@ def build_concat_plan(src: Path, dest: Path, spec: dict, out_ext: str, staging:
]
if needs_reencode(spec):
cropf = f",crop={crop['w']}:{crop['h']}:{crop['x']}:{crop['y']}" if crop else ""
cropf = f",{crop_filter(crop)}" if crop else ""
parts, labels = [], []
for i, s in enumerate(segs):
st, en = s["start_s"], s["end_s"]
+23 -2
View File
@@ -127,14 +127,35 @@ def check_grant(token: str, grant: str | None, password_version: int) -> bool:
# --- serialization -------------------------------------------------------------------------
def owner_view(link: DownloadLink, base_url: str = "", token: str | None = None) -> dict:
def opens(db: Session, link: DownloadLink) -> str:
"""What a link opens, in the words its address and its preview use: "folder", "video" or
"file". A file still being made reads as the video it mostly is."""
from app.downloads import mediakind
if link.folder_id is not None:
return "folder"
job = db.get(DownloadJob, link.job_id)
asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None
kind = asset.media_kind if asset else None
return "file" if kind and kind != mediakind.VIDEO else "video"
def share_path(db: Session, link: DownloadLink, token: str) -> str:
"""The address a link is given out on: its path says what it opens (`opens`). Only a word for the
reader, the token is the whole key: every prefix of `main.SHARE_PREFIXES` opens every link, so
the links already sent on /watch/ work for good."""
prefix = {"folder": "folder", "file": "file", "video": "watch"}[opens(db, link)]
return f"/{prefix}/{token}"
def owner_view(db: Session, link: DownloadLink, token: str | None = None) -> dict:
"""What the file owner sees when managing their links. `token` is the one just made (it is in
hand even where no copy is kept); else it is read back, and `url` is None when it cannot be."""
token = token or recover(link)
return {
"id": link.id,
"token": token,
"url": f"{base_url}/watch/{token}" if token else None,
"url": share_path(db, link, token) if token else None,
"allow_download": link.allow_download,
"has_password": link.password_hash is not None,
"expires_at": link.expires_at.isoformat() if link.expires_at else None,
+3 -1
View File
@@ -234,7 +234,9 @@ class DownloadJob(Base, TimestampMixin, UpdatedAtMixin):
source_asset_id: Mapped[int | None] = mapped_column(
ForeignKey("media_assets.id", ondelete="SET NULL")
)
# For edit jobs: the trim/crop recipe {trim?:{start_s,end_s}, crop?:{x,y,w,h}} in pixels. For clip
# For edit jobs: the trim/crop recipe {trim?:{start_s,end_s}, crop?:{x,y,w,h,frame_w?,frame_h?}}, its
# crop in the SHOWN picture's pixels when it carries the frame it was measured on (`edit.crop_filter`),
# else in the file's stored ones. For clip
# jobs: the clip spec {source, ref, start_s, end_s, audio_track?, crop?:{x,y,w,h,aspect}}, its crop
# in fractions of the box it was drawn in (`downloads.clip.normalize`).
edit_spec: Mapped[dict | None] = mapped_column(JSON)
+12 -7
View File
@@ -1,4 +1,4 @@
"""Server-rendered Open Graph tags for the public /watch/{token} page.
"""Server-rendered Open Graph tags for the public page of a share link (/watch/, /file/, /folder/).
The watch page is a client-side SPA, so a social crawler (facebookexternalhit, Twitterbot, …) that
fetches /watch/{token} would otherwise only see the generic index.html — a blank/greyed link card.
@@ -76,16 +76,21 @@ def _watch_tags(token: str, db: Session) -> str | None:
link = linksmod.find(db, token)
if link is None or linksmod.is_expired(link):
return None
url = f"{settings.app_base}/watch/{token}"
folder = link.folder_id is not None
url = f"{settings.app_base}{linksmod.share_path(db, link, token)}"
# What the card calls it, as its address does.
kind = linksmod.opens(db, link)
folder, video = kind == "folder", kind == "video"
if link.password_hash:
# Password-gated: don't leak the title/thumbnail in the unfurl.
return "\n ".join(
[
"<title>Siftlode</title>",
_tag("og:title", "Password-protected " + ("folder" if folder else "video")),
_tag(
"og:title",
"Password-protected " + ("folder" if folder else "video" if video else "file"),
),
_tag("og:description", "Shared via Siftlode"),
_tag("og:type", "website" if folder else "video.other"),
_tag("og:type", "video.other" if video else "website"),
_tag("og:url", url),
_tag("og:site_name", "Siftlode"),
]
@@ -96,7 +101,7 @@ def _watch_tags(token: str, db: Session) -> str | None:
asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None
if job is None or asset is None or asset.status != "ready":
return None
title = (job.display_name or asset.title or "Video").strip()
title = (job.display_name or asset.title or ("Video" if video else "File")).strip()
channel = (job.display_uploader or asset.uploader or "").strip()
# Pick the link-preview image. Facebook's scraper REJECTS our own poster ("Corrupted Image" in the
# Sharing Debugger): the poster is an ffmpeg-produced JPEG whose non-standard marker order FB's
@@ -118,7 +123,7 @@ def _watch_tags(token: str, db: Session) -> str | None:
f"<title>{html.escape(title)}</title>",
_tag("og:title", title),
_tag("og:description", channel or "Shared via Siftlode"),
_tag("og:type", "video.other"),
_tag("og:type", "video.other" if video else "website"),
_tag("og:url", url),
_tag("og:site_name", "Siftlode"),
_tag("twitter:title", title, attr="name"),
+21 -4
View File
@@ -837,6 +837,18 @@ def _fail_edit(
)
def _filter_frame(
path: Path, width: int | None, height: int | None
) -> tuple[int | None, int | None]:
"""A file's frame as ffmpeg's filters see it: the stored size, turned when the stream is stored
turned a quarter (ffmpeg stands it upright first). Unknown when the file cannot be read."""
data = probe.probe_json(path)
turned = clipmod.quarter_turned(data) if data else None
if turned is None:
return None, None
return (height, width) if turned else (width, height)
def _process_edit(job_id: int, asset_id: int) -> None:
"""Produce a per-user trim/crop clip from the source asset's downloaded file via ffmpeg."""
with SessionLocal() as db:
@@ -905,6 +917,12 @@ def _process_edit(job_id: int, asset_id: int) -> None:
size = final.stat().st_size if final.exists() else None
# The clip's real frame: a re-encode evens an odd crop or source (`editmod.EVEN_FRAME`).
_, _, out_w, out_h, _ = probe.probe_media(final)
# Unsized, a crop's own size stands in, cut from the frame the filters saw.
cut = (
editmod.crop_stored_size(edit_spec["crop"], *_filter_frame(src_path, src_w, src_h))
if crop and not (out_w and out_h)
else None
)
with SessionLocal() as db:
asset = db.get(MediaAsset, asset_id)
@@ -916,11 +934,10 @@ def _process_edit(job_id: int, asset_id: int) -> None:
asset.duration_s = total_s or asset.duration_s
if out_w and out_h:
asset.width, asset.height = out_w, out_h
elif crop:
asset.width = edit_spec["crop"]["w"]
asset.height = edit_spec["crop"]["h"]
elif cut:
asset.width, asset.height = cut
else:
asset.width, asset.height = src_w, src_h
asset.width, asset.height = (None, None) if crop else (src_w, src_h)
asset.error = None
asset.last_access_at = datetime.now(timezone.utc)
job = db.get(DownloadJob, job_id)
+2 -1
View File
@@ -16,7 +16,8 @@ That is a per-job ceiling, not an aggregate ledger: concurrent jobs of one user
remainder (in-flight bytes are not `ready`, so `footprint` cannot see them), so a wave of
max_concurrent downloads can still land over the cap — the deliberate trade, since a download's size
is unknown until it finishes. CLIP jobs consult it before their fetch or encode and again with
their output's real size before it is placed. Edit jobs do not consult it at all (tracked as SEC-19).
their output's real size before it is placed. Edit jobs consult it with their output's real size
before it is placed (`test_edit_cap.py`).
"""
from dataclasses import dataclass
+10 -4
View File
@@ -368,6 +368,10 @@ INDEX_HTML = STATIC_DIR / "index.html"
# A public share link's own page: the player alone, without the app's bundle (frontend/watch.html).
WATCH_HTML = STATIC_DIR / "watch.html"
INDEX_HEADERS = {"Cache-Control": "no-cache"}
# The paths a share link opens on, by what it opens (`links.share_path`). Any of them opens any link.
# A new one also goes in frontend/public/robots.txt, the Vite dev entry and WatchPage's token parsing
# (watchEntry.contract.test.ts).
SHARE_PREFIXES = ("watch/", "file/", "folder/")
app.mount(
"/assets",
StaticFiles(directory=STATIC_DIR / "assets", check_dir=False),
@@ -381,8 +385,10 @@ async def index() -> FileResponse:
@app.get("/watch/{token}")
async def watch_page(token: str):
"""Serve the public player page for a share link, with per-video Open Graph tags injected so the
@app.get("/file/{token}")
@app.get("/folder/{token}")
async def share_page(token: str):
"""Serve the public page of a share link, with per-link Open Graph tags injected so the
link unfurls richly (title/channel/thumbnail) in Messenger/social crawlers. A real browser
ignores the extra tags and boots WatchPage; invalid/expired/password links fall back to the
generic card."""
@@ -409,8 +415,8 @@ async def spa_fallback(full_path: str) -> FileResponse:
# Real SPA-root assets (welcome images, favicon, robots.txt) rarely change and have
# stable names, so a moderate cache is safe and satisfies the cache-lifetime audit.
return FileResponse(candidate, headers={"Cache-Control": "public, max-age=604800"})
# A /watch/ path the route above does not match (a trailing segment, no token) is still the
# A share path the route above does not match (a trailing segment, no token) is still the
# player's: WatchPage reads the token off the path and says when a link is gone.
if full_path.startswith("watch/"):
if full_path.startswith(SHARE_PREFIXES):
return FileResponse(WATCH_HTML, headers=INDEX_HEADERS)
return FileResponse(INDEX_HTML, headers=INDEX_HEADERS)
+18 -4
View File
@@ -41,11 +41,25 @@ def video(db: Session, video_id: str) -> Video | None:
# --- tags ------------------------------------------------------------------------------------
def tags_with_counts(db: Session, user: User) -> list[tuple[Tag, int]]:
"""The tags this user sees (the shared system ones and their own), each with how many channel
links it has, by category then name."""
def tags_with_counts(db: Session, user: User) -> list[tuple[Tag, int, int]]:
"""The tags this user sees (the shared system ones and their own), by category then name, each
with how many channel links it has and how many of the user's own links sit on a channel they
subscribe to and have not hidden — the rows the Channels page's tag filter shows (a hidden
channel has its own tab). A link outlives an unsubscribe and a hide, so the two differ."""
subscribed = select(Subscription.channel_id).where(
owned_by(Subscription.user_id, user), Subscription.hidden.is_(False)
)
on_subscribed = dict(
db.execute(
select(ChannelTag.tag_id, func.count(ChannelTag.id))
.where(owned_by(ChannelTag.user_id, user), ChannelTag.channel_id.in_(subscribed))
.group_by(ChannelTag.tag_id)
)
.tuples()
.all()
)
return [
(tag, count)
(tag, count, on_subscribed.get(tag.id, 0))
for tag, count in db.execute(
select(Tag, func.count(ChannelTag.id))
.outerjoin(ChannelTag, ChannelTag.tag_id == Tag.id)
+5 -5
View File
@@ -611,7 +611,7 @@ def list_links(
job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> list[dict]:
own_job(db, user, job_id)
return [linksmod.owner_view(link) for link in queries.links_to(db, job_id=job_id)]
return [linksmod.owner_view(db, link) for link in queries.links_to(db, job_id=job_id)]
class CreateLinkIn(BaseModel):
@@ -653,7 +653,7 @@ def create_link(
raise refused(409, "share_unfinished")
link, token = _new_link(db, user, body, job_id=job_id)
db.commit()
return linksmod.owner_view(link, token=token)
return linksmod.owner_view(db, link, token=token)
def _shareable_job(db: Session, user: User, job_id: int) -> "DownloadJob":
@@ -684,7 +684,7 @@ def list_folder_links(
folder_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> list[dict]:
own_folder(db, user, folder_id)
return [linksmod.owner_view(link) for link in queries.links_to(db, folder_id=folder_id)]
return [linksmod.owner_view(db, link) for link in queries.links_to(db, folder_id=folder_id)]
@router.post("/folders/{folder_id}/links")
@@ -697,7 +697,7 @@ def create_folder_link(
_shareable_folder(db, user, folder_id)
link, token = _new_link(db, user, body, folder_id=folder_id)
db.commit()
return linksmod.owner_view(link, token=token)
return linksmod.owner_view(db, link, token=token)
class UpdateLinkIn(BaseModel):
@@ -724,7 +724,7 @@ def update_link(
sent=frozenset(body.model_fields_set),
)
db.commit()
return linksmod.owner_view(link)
return linksmod.owner_view(db, link)
@router.delete("/links/{link_id}")
+1
View File
@@ -432,6 +432,7 @@ def item_detail(
"summary": (it.summary_hu or it.summary) if hu else it.summary,
"year": it.year,
"duration_seconds": it.duration_s,
"size_bytes": it.size_bytes,
"playable": it.playable,
# Playback-class detail: HW/SW transcode split + the codec/bit-depth the info-page chip shows.
"transcode_hw": it.transcode_hw,
+9 -2
View File
@@ -176,8 +176,15 @@ def watch_meta(token: str, db: Session = Depends(get_db)) -> dict:
returns only `needs_password` until the viewer unlocks it (no title/thumbnail leak)."""
link = _link_or_404(db, token)
if link.password_hash:
# Which kind it opens, for the password prompt's title — never what is in it.
return {"needs_password": True, "kind": "folder" if link.folder_id is not None else "file"}
# Which kind it opens, for the password prompt's words — never what is in it. `video` tells
# a video (watched) from any other file (opened); the address is only a hint, every
# prefix opens every link.
opens = linksmod.opens(db, link)
return {
"needs_password": True,
"kind": "folder" if opens == "folder" else "file",
"video": opens == "video",
}
meta = _opened(db, link, token)
db.commit()
return meta
+2 -1
View File
@@ -48,8 +48,9 @@ def list_tags(user: User = Depends(current_user), db: Session = Depends(get_db))
"category": tag.category,
"system": tag.user_id is None,
"channel_count": count,
"subscribed_count": subscribed,
}
for tag, count in queries.tags_with_counts(db, user)
for tag, count, subscribed in queries.tags_with_counts(db, user)
]
+209
View File
@@ -0,0 +1,209 @@
"""The editor's crop lands on the part of the picture the frame was drawn on, also when a video's
pixels are not square. The browser reports such a video by its shown size (320 stored, 640 shown),
and ffmpeg's `crop` counts stored pixels: measured on a real clip, a frame over the middle half kept
half the frame's red surroundings. So a crop carries the size it was measured on.
Real ffmpeg: the pixels of the output are the oracle.
"""
import shutil
import subprocess
from pathlib import Path
import pytest
from app.config import settings
from app.downloads import edit, pipeline, service, storage
from app.downloads import upload as uploadmod
from app.downloads.models import DownloadJob, MediaAsset
pytestmark = pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not available")
def _source(path: Path, size: str, box: str, sar: int, rotate: int = 0) -> None:
"""Red, with a green box: a crop of the box holds no red. `rotate` tags the file to be shown
turned by that many degrees counter-clockwise, as a phone records a portrait video."""
tag = ["-display_rotation:v:0", str(rotate)] if rotate else []
subprocess.run(
[
"ffmpeg", "-y", "-loglevel", "error",
"-f", "lavfi", "-i", f"color=c=red:s={size}:d=2:r=10",
"-f", "lavfi", "-i", "sine=duration=2",
"-vf", f"drawbox={box}:c=lime:t=fill,setsar={sar}",
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", str(path),
],
check=True,
) # fmt: skip
if tag:
tagged = path.with_name("tagged-" + path.name)
subprocess.run(
["ffmpeg", "-y", "-loglevel", "error", *tag, "-i", str(path), "-c", "copy", str(tagged)],
check=True,
) # fmt: skip
tagged.replace(path)
def _red_and_green(path: Path) -> tuple[int, int]:
raw = subprocess.run(
["ffmpeg", "-loglevel", "error", "-i", str(path), "-frames:v", "1",
"-f", "rawvideo", "-pix_fmt", "rgb24", "-"],
check=True,
capture_output=True,
).stdout # fmt: skip
red = green = 0
for i in range(0, len(raw) - 2, 3):
if raw[i] > 180 and raw[i + 1] < 80:
red += 1
elif raw[i + 1] > 180 and raw[i] < 80:
green += 1
return red, green
def _cut_asset(db, make_user, src: Path, spec: dict, then=lambda: None) -> MediaAsset:
user = make_user()
source = uploadmod.ingest_upload(
db, user.id, filename="clip.mp4", src_path=src, size=src.stat().st_size
)
assert source.status == "done"
then()
job = service.enqueue_edit(db, user.id, source, spec)
asset = db.get(MediaAsset, job.asset_id)
job.status, asset.status = "running", "downloading" # the worker's claim
db.commit()
pipeline._process_edit(job.id, asset.id)
db.expire_all()
job, asset = db.get(DownloadJob, job.id), db.get(MediaAsset, job.asset_id)
assert (job.status, job.error, asset.status) == ("done", None, "ready")
return asset
def _cut(db, make_user, tmp_path, src: Path, crop: dict, **spec) -> Path:
asset = _cut_asset(db, make_user, src, {"crop": crop, **spec})
return storage.abs_path(str(tmp_path), asset.rel_path)
def test_a_crop_of_a_video_with_wide_pixels_keeps_what_the_frame_showed(
db, make_user, tmp_path, monkeypatch
):
monkeypatch.setattr(settings, "files_root", str(tmp_path))
src = tmp_path / "in.mp4"
_source(src, "320x240", "x=80:y=60:w=160:h=120", sar=2)
# What the editor sends for the middle half of this video: the browser shows it at 640 x 240.
final = _cut(
db, make_user, tmp_path, src,
{"x": 160, "y": 60, "w": 320, "h": 120, "frame_w": 640, "frame_h": 240},
) # fmt: skip
red, green = _red_and_green(final)
assert (red, green) == (0, 160 * 120)
def test_a_crop_of_square_pixels_is_cut_where_it_was_drawn(db, make_user, tmp_path, monkeypatch):
monkeypatch.setattr(settings, "files_root", str(tmp_path))
src = tmp_path / "in.mp4"
_source(src, "480x640", "x=120:y=160:w=240:h=320", sar=1)
final = _cut(
db, make_user, tmp_path, src,
{"x": 120, "y": 160, "w": 240, "h": 320, "frame_w": 480, "frame_h": 640},
) # fmt: skip
assert _red_and_green(final) == (0, 240 * 320)
def test_a_crop_stored_before_it_carried_its_frame_is_cut_as_it_always_was():
spec = edit.normalize_edit_spec({"crop": {"x": 4, "y": 6, "w": 100, "h": 50}})
assert spec["crop"] == {"x": 4, "y": 6, "w": 100, "h": 50}
cmd = edit.build_edit_cmd(Path("in.mp4"), Path("out.mp4"), spec, "mp4")
assert cmd[cmd.index("-vf") + 1].startswith("crop=100:50:4:6,")
@pytest.mark.parametrize("frame", [{"frame_w": 0, "frame_h": 240}, {"frame_w": "x", "frame_h": 2}])
def test_a_frame_that_cannot_be_read_is_not_divided_by(frame):
spec = edit.normalize_edit_spec({"crop": {"x": 4, "y": 6, "w": 100, "h": 50, **frame}})
assert spec["crop"] == {"x": 4, "y": 6, "w": 100, "h": 50}
def test_a_crop_of_a_join_keeps_what_the_frame_showed(db, make_user, tmp_path, monkeypatch):
"""A cut-list joins through `filter_complex`, a crop path of its own."""
monkeypatch.setattr(settings, "files_root", str(tmp_path))
src = tmp_path / "in.mp4"
_source(src, "320x240", "x=80:y=60:w=160:h=120", sar=2)
final = _cut(
db, make_user, tmp_path, src,
{"x": 160, "y": 60, "w": 320, "h": 120, "frame_w": 640, "frame_h": 240},
segments=[{"start_s": 0, "end_s": 0.5}, {"start_s": 1, "end_s": 1.5}], accurate=True,
) # fmt: skip
assert _red_and_green(final) == (0, 160 * 120)
def test_a_crop_of_a_rotated_video_is_cut_where_it_was_drawn(db, make_user, tmp_path, monkeypatch):
"""The browser shows a rotated file upright, so the frame was drawn on the upright picture."""
monkeypatch.setattr(settings, "files_root", str(tmp_path))
src = tmp_path / "in.mp4"
# Stored 320 x 240, green on its left half; shown turned a quarter, 240 x 320.
_source(src, "320x240", "x=0:y=0:w=160:h=240", sar=1, rotate=90)
# Turned counter-clockwise, the stored left half is the shown bottom half.
final = _cut(
db, make_user, tmp_path, src,
{"x": 0, "y": 160, "w": 240, "h": 160, "frame_w": 240, "frame_h": 320},
) # fmt: skip
assert _red_and_green(final) == (0, 240 * 160)
def test_a_clip_ffprobe_cannot_size_gets_the_size_its_crop_cut(
db, make_user, tmp_path, monkeypatch
):
monkeypatch.setattr(settings, "files_root", str(tmp_path))
src = tmp_path / "in.mp4"
_source(src, "320x240", "x=80:y=60:w=160:h=120", sar=2)
def probe_fails():
monkeypatch.setattr(pipeline.probe, "probe_media", lambda _p: (None,) * 5)
asset = _cut_asset(
db, make_user, src,
{"crop": {"x": 160, "y": 60, "w": 320, "h": 120, "frame_w": 640, "frame_h": 240}},
then=probe_fails,
) # fmt: skip
# Half the shown width of a 320-wide file: 160 stored, not the 320 the frame counted.
assert (asset.width, asset.height) == (160, 120)
def test_a_rotated_clip_ffprobe_cannot_size_gets_the_upright_size_its_crop_cut(
db, make_user, tmp_path, monkeypatch
):
"""The frame was drawn on the upright picture, which is the stored one turned: so is its cut."""
monkeypatch.setattr(settings, "files_root", str(tmp_path))
src = tmp_path / "in.mp4"
_source(src, "320x240", "x=0:y=0:w=160:h=240", sar=1, rotate=90)
def probe_fails():
monkeypatch.setattr(pipeline.probe, "probe_media", lambda _p: (None,) * 5)
asset = _cut_asset(
db, make_user, src,
{"crop": {"x": 0, "y": 160, "w": 240, "h": 160, "frame_w": 240, "frame_h": 320}},
then=probe_fails,
) # fmt: skip
# What ffmpeg cut (the rotated test above): 240 x 160, not the stored 320 x 120.
assert (asset.width, asset.height) == (240, 160)
@pytest.mark.parametrize("key", ["frame_w", "x"])
def test_an_infinite_number_in_a_crop_is_dropped_not_a_crash(key):
crop = {"x": 4, "y": 6, "w": 100, "h": 50, "frame_w": 640, "frame_h": 240}
spec = edit.normalize_edit_spec({"crop": {**crop, key: float("inf")}})
if key == "frame_w":
assert spec["crop"] == {"x": 4, "y": 6, "w": 100, "h": 50}
else:
assert "crop" not in spec
+2 -1
View File
@@ -150,6 +150,7 @@ def test_a_password_folder_link_shows_nothing_until_unlocked(shelf, client):
assert client.get(f"/api/public/watch/{token}").json() == {
"needs_password": True,
"kind": "folder",
"video": False,
}
bare = f"/api/public/watch/{token}/files/{shelf['inside'][0]}/file"
assert client.get(bare).status_code == 403
@@ -312,7 +313,7 @@ def test_a_password_file_link_says_it_opens_a_file(shelf, client):
.json()
)
meta = client.get(f"/api/public/watch/{made['token']}").json()
assert meta == {"needs_password": True, "kind": "file"}
assert meta == {"needs_password": True, "kind": "file", "video": True}
def test_a_file_whose_bytes_are_gone_is_not_offered(shelf, client, db):
+2
View File
@@ -219,6 +219,7 @@ def test_item_detail_movie_serves_from_row_despite_live_outage(db, monkeypatch,
library_id=lib.id,
kind="movie",
title="The Godfather",
size_bytes=15_000_000_000,
genres=["Crime", "Drama"],
directors=["Francis Ford Coppola"],
studio="Paramount",
@@ -238,6 +239,7 @@ def test_item_detail_movie_serves_from_row_despite_live_outage(db, monkeypatch,
resp = _get(db, user, monkeypatch, override_dep, "/api/plex/item/m1")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["size_bytes"] == 15_000_000_000
assert body["imdb_rating"] == 8.7
assert body["imdb_id"] == "tt0068646"
assert body["imdb_url"] == "https://www.imdb.com/title/tt0068646/"
+34 -2
View File
@@ -14,7 +14,7 @@ from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from app.discover import pins
from app.catalog.models import Channel, Video
from app.catalog.models import Channel, Subscription, Video
from app.reader import queries, service
from app.reader.models import ChannelTag, ComfortPin, Playlist, PlaylistItem, Tag, VideoState
@@ -60,12 +60,44 @@ def test_a_system_tag_is_nobodys_to_edit(db, make_user):
service.create_tag(db, other, "Theirs", "other", None) # another user's tag stays theirs
db.commit()
assert queries.own_tag(db, user, system.id) is None
assert [t.id for t, _ in queries.tags_with_counts(db, user)] == [system.id]
assert [t.id for t, *_ in queries.tags_with_counts(db, user)] == [system.id]
# --- tags ------------------------------------------------------------------------------------
def _music_on_four(db, make_user):
"""A tag on four channels: two followed, one followed but hidden, one no longer followed."""
user, other = make_user(), make_user()
tag = service.create_tag(db, user, "Music", "other", None)
for cid in ("UCa", "UCb", "UChid", "UCgone"):
db.add(Channel(id=cid, title=cid))
db.flush()
for cid in ("UCa", "UCb"):
db.add(Subscription(user_id=user.id, channel_id=cid))
db.add(Subscription(user_id=user.id, channel_id="UChid", hidden=True))
db.add(Subscription(user_id=other.id, channel_id="UCgone"))
for cid in ("UCa", "UCb", "UChid", "UCgone"):
db.add(ChannelTag(channel_id=cid, tag_id=tag.id, user_id=user.id, source="user"))
db.commit()
return user, tag
def test_a_tag_counts_apart_the_links_on_channels_the_user_still_follows(db, make_user):
"""An unsubscribe and a hide keep the channel's tag links; the Channels page's filter lists only
the followed channels not hidden, so its count must not hold the others."""
user, tag = _music_on_four(db, make_user)
[(_, count, subscribed)] = [r for r in queries.tags_with_counts(db, user) if r[0].id == tag.id]
assert (count, subscribed) == (4, 2)
def test_the_tag_list_sends_each_count_under_its_own_name(db, make_user, as_user):
"""Both counts are ints, so a swap would type-check: the answer itself pins which is which."""
user, tag = _music_on_four(db, make_user)
[row] = [t for t in as_user(user).get("/api/tags").json() if t["id"] == tag.id]
assert (row["channel_count"], row["subscribed_count"]) == (4, 2)
def test_a_tag_is_made_once_per_name_and_an_unknown_category_is_other(db, make_user):
user = make_user()
tag = service.create_tag(db, user, "Later", "nonsense", "#fff")
+148
View File
@@ -0,0 +1,148 @@
"""A share link's address says what it opens: /watch/ a video, /file/ anything else, /folder/ a folder.
Only a word for the reader: the token is the key, so every path opens every link, and the links
already sent out on /watch/ keep working for good. DB-lane.
"""
import pytest
from cryptography.fernet import Fernet
from app import main, security
from app.config import settings
from app.downloads import og
from app.downloads import probe as probemod
_PAGE = """<!doctype html><html><head>
<!--OG:START--><meta property="og:title" content="Siftlode" /><!--OG:END-->
</head><body><script src="/assets/{entry}.js"></script></body></html>"""
_VIDEO = {
"streams": [
{"codec_type": "video", "codec_name": "h264", "pix_fmt": "yuv420p"},
{"codec_type": "audio", "codec_name": "aac"},
],
"format": {"format_name": "mov,mp4", "duration": "5"},
}
_AUDIO = {
"streams": [{"codec_type": "audio", "codec_name": "aac"}],
"format": {"format_name": "mov,mp4", "duration": "5"},
}
@pytest.fixture
def pages(tmp_path, monkeypatch):
index, watch = tmp_path / "index.html", tmp_path / "watch.html"
index.write_text(_PAGE.format(entry="app"), encoding="utf-8")
watch.write_text(_PAGE.format(entry="watch"), encoding="utf-8")
monkeypatch.setattr(main, "INDEX_HTML", index)
monkeypatch.setattr(main, "WATCH_HTML", watch)
@pytest.fixture
def key(monkeypatch):
"""A link's address is read back from its encrypted token."""
monkeypatch.setattr(security, "_fernet", Fernet(Fernet.generate_key()))
@pytest.fixture
def shared(as_user, make_user, tmp_path, monkeypatch, upload_headers):
"""Uploads a file ffprobe reads as `data` (None: it cannot read it) and shares it."""
monkeypatch.setattr(settings, "files_root", str(tmp_path / "media"))
owner = as_user(make_user())
def share(name: str, data: dict | None, **link: object) -> dict:
monkeypatch.setattr(probemod, "probe_json", lambda _p: data)
job = owner.post(
"/api/files/upload",
content=b"some bytes",
headers=upload_headers(name, display_name=name),
)
assert job.status_code == 200, job.text
made = owner.post(f"/api/downloads/{job.json()['id']}/links", json=link)
assert made.status_code == 200, made.text
return made.json()
share.owner = owner # type: ignore[attr-defined]
return share
@pytest.mark.parametrize(
("name", "data", "prefix"),
[
("clip.mp4", _VIDEO, "/watch/"),
("song.m4a", _AUDIO, "/file/"),
("backup.tar.gz", None, "/file/"),
],
)
def test_a_link_is_given_out_on_the_path_of_what_it_opens(shared, name, data, prefix):
made = shared(name, data)
assert made["url"] == f"{prefix}{made['token']}"
def test_a_folder_link_is_given_out_on_folder(shared, key):
owner = shared.owner
folder = owner.post("/api/files/folders", json={"name": "Friends"}).json()
made = owner.post(f"/api/downloads/folders/{folder['id']}/links", json={}).json()
assert made["url"] == f"/folder/{made['token']}"
listed = owner.get(f"/api/downloads/folders/{folder['id']}/links").json()
assert [entry["url"] for entry in listed] == [made["url"]]
def test_the_list_gives_the_same_address_as_the_making(shared, key):
made = shared("backup.tar.gz", None)
owner = shared.owner
files = owner.get("/api/files").json()
job_id = next(f["id"] for f in files if f["display_name"] == "backup.tar.gz")
listed = owner.get(f"/api/downloads/{job_id}/links").json()
assert [entry["url"] for entry in listed] == [made["url"]]
@pytest.mark.parametrize("path", ["watch", "file", "folder"])
@pytest.mark.parametrize(
("name", "data"), [("clip.mp4", _VIDEO), ("backup.tar.gz", None)], ids=["video", "file"]
)
def test_every_path_opens_every_link(pages, shared, client, path, name, data):
"""The links already sent on /watch/ open whatever they open, for good."""
token = shared(name, data)["token"]
r = client.get(f"/{path}/{token}")
assert r.status_code == 200
assert "/assets/watch.js" in r.text and "/assets/app.js" not in r.text
assert f'content="{name}"' in r.text
@pytest.mark.parametrize("path", ["/file/no-such-token", "/folder/no-such-token", "/file/a/b"])
def test_a_new_path_without_a_live_link_still_gets_the_player(pages, client, path):
r = client.get(path)
assert r.status_code == 200
assert "/assets/watch.js" in r.text and "/assets/app.js" not in r.text
def test_a_card_names_a_file_as_a_file(shared, db):
made = shared("backup.tar.gz", None)
tags = og._watch_tags(made["token"], db)
assert f'{made["url"]}"' in tags
assert "video.other" not in tags
locked = shared("notes.tar.gz", None, password="pw")
hidden = og._watch_tags(locked["token"], db)
assert "Password-protected file" in hidden and "notes" not in hidden
assert "video.other" not in hidden
def test_a_card_of_a_video_stays_a_video(shared, db):
made = shared("clip.mp4", _VIDEO)
tags = og._watch_tags(made["token"], db)
assert f"/watch/{made['token']}" in tags and "video.other" in tags
locked = shared("other.mp4", _VIDEO, password="pw")
assert "Password-protected video" in og._watch_tags(locked["token"], db)
@pytest.mark.parametrize(
("name", "data", "video"),
[("clip.mp4", _VIDEO, True), ("song.m4a", _AUDIO, False), ("backup.tar.gz", None, False)],
)
def test_a_password_link_says_whether_it_opens_a_video(shared, client, name, data, video):
"""The lock screen asks to watch a video and to open anything else, whichever path it is on."""
token = shared(name, data, password="pw")["token"]
meta = client.get(f"/api/public/watch/{token}").json()
assert meta == {"needs_password": True, "kind": "file", "video": video}
+154 -146
View File
@@ -301,7 +301,8 @@ Also in force:
`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
scroll, so no row has clipped ends, and a filter row puts its picked chips first so the fold never
holds the only sign that a filter is on; 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).
@@ -717,148 +718,155 @@ of a full-width panel and nothing on the right.
## 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-30 | Composition and the reader (E30 S6) | A chapter and a Conformance step: the space is used, one focal point, worst real data, where am I, a click answers at once, zoom and text size first-class; the person page as two columns (facts and bio left, "Known for" right) | Rules for pieces only; the bio in a full-width panel; a narrower panel | The person page passed every piece rule with half its panel empty; the owner reads zoomed in, so zoom is a requirement, not a nicety |
| 2026-09-30 | Hungarian words (E30 S5) | The D3 list: Feed, Shorts, Admin, Token, Port stay; stream, Prio, Job, Audit, Demo, Auto, Info, Frontend/Backend, Backfill, transcode get the Hungarian word; one name per thing (kérelem, behúzás, one "Test connection") | Keep every loanword; translate Feed/Shorts too | The owner chose the list (D3); the final sweep found the same feature under two names in three places |
| 2026-09-29 | Numbers and order in the UI language (E30 S3) | Every number through `lib/format.ts` in the UI language (decimal mark, grouping, compact, units); A→Z by the shown name in the UI language's alphabet, client and server alike; the language comes off `useTranslation`, never the singleton | `toFixed`, hand-built "1.2K", `localeCompare` in the browser's locale, byte-order server sorts | A Hungarian reader saw "8.3", "1.2K", "Életrajzi" after "Z"; the singleton read also left values in the old language after a switch (React Compiler) |
| 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 |
| 2026-10-01 | Zoom target 125–133% | Screens are held at 125% and 133% browser zoom (1536 × 729, 1444 × 685 on a 1080p window) and the largest text size | 200% browser zoom | The owner: 200% was a guess; 125–133% is what they read Siftlode and most sites at, so 200% would be an upper bound nobody uses |
| 2026-10-01 | E31 Nav rail at short heights | One "Admin" rail item opening a panel of the admin pages | A denser rail when short; a visible "+4" hint | The rail fits at 133–150% without a hidden scroll; the admin pages are rare and admin-only |
| 2026-10-01 | E31 Configuration sections | A vertical, grouped section list, as Settings has | Group tabs + sub-tabs; one long page with a contents list | Sixteen tabs wrapped to three rows at 133%; one pattern for both settings pages |
| 2026-10-01 | E31 Statistics overview | Number tiles, and the per-operation counts as horizontal bars | Three narrow cards; a 30-day chart | The label sits by its value; what spends the quota is seen at a glance |
| 2026-10-01 | E31 Channel page header | The full banner, folding to one row once the grid scrolls | Always compact; no banner | The header is fixed and took 40% of a 685px window; the channel keeps its face at first sight |
| 2026-10-01 | E31 Channel page watch state | A fresh channel page shows the unwatched videos; a picked state is remembered | Show all, forgotten on reload | The owner: open a channel to see what is new; a choice made there should stick |
| 2026-10-01 | E31 Audit log runs | A job's successful runs fold into one row that opens, whatever ran between; only that job's failure (never folded, danger colour) breaks it | A filter defaulting to admin actions; both | Nothing hidden, the noise collapsed, the failure seen |
| 2026-10-01 | E31 Users row | Role and state as badges, the actions in the row's one menu | An inline role toggle; icon-only buttons | Action placement: the row shows state, rare and destructive actions live in the menu |
| 2026-10-01 | E31 YouTube player | Video beside an info panel at 1280px and wider (the video as large as the height allows); stacked below | Today's stack with a one-line bar; full-window like Plex | The owner's eyesight: about 1460px of video on 1080p instead of 1008, the actions still visible |
| 2026-10-01 | E31 Plex info and show pages | A second column for the facts, keywords and crew, as the person page; a collection's hero sized to its content | Full-width text; a larger poster and fanart | The right ~40% was empty; one composition for every media page |
| 2026-10-01 | E31 Nav rail rows are links | Each page row is a real link: a middle-click or Ctrl-click opens the page in a new tab | A button | The owner: open a page in a tab of its own, as on any site |
| 2026-10-02 | E31 Plex detail second column (refined) | The facts over the key crew beside the text; the keywords, chips that filter like the genres, under the text; the show page gets all three too | All three in the column, as the mockup drew it | At 20 keywords and 12 crew the column ran to twice the text's height (849 vs ~400 px at 133%): a hole under the text instead of beside it |
| 2026-10-02 | E31 Plex detail poster and crew | The movie and show poster keeps its 2:3 and grows a rung in a wide hero (w-56 at a 64rem panel); the crew shows its first 6 names and "Show N more" (a list of 8 or fewer shows whole) | Poster stretched to the row; a "More" on the keywords | The row is as tall as the crew column, so the poster drew ~175×590 and cropped; the crew, not the keywords, set the hero's height |
| 2026-10-04 | E32 `field` focus skips a mode switch | A form opens on its first field; a control above it that is not a field (the composer's Edit / Preview) is passed over by marking the field `data-initial-focus` | The first text field everywhere | That moved the focus in the download dialog off its profile choice and in the upload dialog off its drop zone; the mark changes only the form that needs it |
| 2026-10-04 | E35 Lint for z-levels and edged labels | Both rules stay; the two eslint selectors (`Z_RAW`, `EDGED_LABEL`) that refused them go | Keep the selectors | 0 hits since the E28 sweep that fixed the sites: a check that never fires costs a probe test and a snapshot each, and the UI review holds both rules |
| 2026-10-06 | `player` focus | A dialog that is a player opens focused on its panel (the YouTube player's rule, now `Modal`'s too); on the media element itself, Space and the arrows stay the element's own | `close`, or the media element | On the ✕ Space closed the player; on the element the browser's own controls acted on the same key as the player's (one Space played and paused, measured in Chrome) |
| 2026-10-08 | Captions on touch | A `Tooltip` opens for a mouse and a key's focus only, never for a finger | A caption on tap; a long-press caption | On an iPad the tap that showed the caption was taken as a hover and lost its click (every rail button took two taps), and no finger sends the mouseleave that hides it; the rail's labels setting names the buttons on touch |
| 2026-10-08 | Header search on touch | No key cap on a touch screen; the pill grows to 52 px so a ring shows round the 44 px Go; Go drops to its icon by the column's width | Keep the key cap; a 44 px Go overhanging a 40 px pill | On a tablet the 44 px key cap and Go sat on the pill's edge and cut the placeholder; an overhanging Go breaks the nested radii |
| 2026-10-08 | Plex subtitles and the control bar | The player draws the cues itself; while the bar shows they rise above it (200 ms), then settle back | The browser's own cues, moved by `line` | On an iPad the bar covered the text; a native cue cannot be animated or measured, and our layer behaves the same on every engine |
| 2026-10-08 | Hover on a button over a picture | The `media` variant's hover is a 2px accent ring over a darker body (Plex + YT cards, rail arrows; option B) | An accent fill (reads as "selected"); a white wash (weak on a bright poster) | The 60 → 75% black step was invisible, and over a card a rail arrow could not be told from the card's own Play under it |
| 2026-10-08 | Plex "Add to collection" | A movie or show card's menu row opens a panel beside the card: its library's manual collections tick in place, a name + Enter creates one with the title in it (admin; option A) | An Add submenu with a "New…" dialog; the per-title editor modal from the menu | A collection took a trip to the Collections page, a name, a title search, then a second search per title; the panel is the video card's "Add to playlist" |
| 2026-10-08 | YouTube modal chrome | No prev/next strips beside the card; Close an icon (the panel's top right; stacked, the title row's end); the description clamped to three lines + More; the panel shuts to a 2.75rem strip of close, expand, prev/next (option D1) | Keep the strips; a "Close" text button; the full description in a scroller; a panel that hides outright | The strips cost the video 7rem of width for a second copy of the panel's buttons; a long description pushed the channel and the actions out of sight; a hidden panel left no way to step the queue without the keyboard |
| 2026-10-08 | The wheel over the YouTube modal | Over the video: volume. Over a panel with something to scroll: scroll only, never volume, not even at its end. Over a panel with nothing to scroll: volume (owner) | Volume everywhere; a scroll that hands over to the volume at its end | Reading a long description turned the volume down once it hit the end |
| 2026-10-09 | A Plex show card plays | Its picture plays the show's next episode everywhere (an episode with a resume point → Continue + Restart, else the first unwatched, else S1·E1), resolved on the click; the preview names it once up; title and ⓘ open the show (option A + hover ask) | The picture opens the show page (as before); every card carrying `next` | A show card on Discover had no Play while Home's had one; carrying `next` costs a grid page up to 180 queries for the few cards anyone plays |
| 2026-10-10 | Clip mode in the players (E38) | Scissors in the control row + `C`, `[` `]`; the panel in the player with the watched video as preview; overview + zoomed strip on `ui/RangeTrack` (ends are sliders); a toast + the Ready notification after Cut | A ⋯ menu entry; permanent In / Out buttons; a big dialog with its own preview (a second Plex transcode / YouTube iframe) | Owner's picks A, P1, a; a film is hours and a clip seconds, so only a zoomed strip gives a hand the precision. The Downloads editor's cuts moved onto the same track (keyboard-steppable now) |
| 2026-10-10 | Crop in clip mode (E38) | A Crop toggle in the clip panel: a frame over the playing picture (`ui/CropFrame`, the editor's crop too), Free / 16:9 / 9:16 / 1:1, Whole picture; maximised YouTube keeps the panel in the bar's place; the wide side panel shuts while clipping | A frame always on the picture in clip mode (K2); a free shape only | Owner's K1 + a: a crop is rare, so it never covers the picture unasked; a phone or square clip needs a held shape. The iframe hides its picture's size: drawn on the 16:9 box, the cut fits it to the real picture |
| 2026-10-10 | Marks on the player chrome | `--media-mark` (amber) and `--media-danger` (light red), the same in both themes; `media-marks` points the focus ring (`--focus`) and the chosen mark (`--chosen`: a track's tick, a tab's underline; the label stays white) at the amber on the player and every chrome surface; `media-scope` (a form) also the errors at the red | The theme's accent ring, chosen text, warn and danger | On the chrome (#171717) a light theme's accent ring (Slate 2.84:1), warn (2.99:1) and danger (2.77:1) all fall under 3:1; the accent as chosen text under 4.5:1 |
| 2026-10-11 | Files page (one library) | List rows by default, the media grid a toggle remembered per folder; a sidebar of automatic source folders, own folders, Shared with me and the storage meter; the paste-a-link box in the toolbar | A media grid by default | The library holds videos AND plain files: rows read both, show more per screen and scan by size and date |
| 2026-10-11 | Library list columns | Every kind of value in its own column (name, source, phone-safe, retention, size, added, actions), one grid with a subgrid per row so they line up; an action that does not apply keeps its slot empty; a right-click menu on every row (the ⋯ opens the same menu) | Badges flowing after the title; icons that shift when one is missing | Owner: values of one kind must fall in one vertical line; a row's every action reachable by right click |
| 2026-10-11 | Running downloads | An app-wide tray, bottom right, shown only while something runs or failed (reason + retry), foldable to a pill | A rail icon with a count and a popover | Downloading became an action you start anywhere; a running one must be visible without going to look |
| 2026-10-11 | Download done | The notification says whether it plays on a phone and carries Link másolása + Megnyitás (the folder, the item focused); never an automatic jump | A plain "ready" notice; auto-navigation to the item | The daily path is paste → done → copy the link; a jump would pull the user out of what they were doing |
| 2026-10-11 | Library row quick icons (owner A) | On a wide list only the ⋯ holds a place; Copy link, Share and Save come up over the date while the row is hovered or holds the keyboard; a card (phone) keeps them in its own line; Keeps narrower | Three icons always in sight (the name kept ~150px at 1200); dropping Added below 64rem | Owner: the name is what a row is read by; every action stays in the ⋯ and the right-click menu |
| 2026-10-12 | Files preview (owner ★) | A file that is not media opens from its name in the dialog shell, wide: an image, a PDF in the browser's own viewer, text as numbered plain text (HTML and SVG too) with a wrap toggle, else a no-preview card with Save; ← / → page the list's such files in its order; an image under 8 MB is its own thumbnail | A side panel beside the list; a new browser tab; pdf.js; syntax highlighting; a click that saves a file without a preview | Owner: one way to open anything, paged like the list; the browser's PDF viewer is free and isolated; a page is never rendered on our origin |
| 2026-10-12 | A running download's step (owner ★) | The tray row names the step beside its bar ("Downloading audio · 12%", "Merging" with a pulse); a paused-only tray shows a pause mark, never the done check | A bare bar and % | A download fetches video and audio as two streams: unnamed, the second fill reads as a restart; the tray is the only place a download is followed |
| 2026-10-14 | API tokens (owner A/A/A) | Settings → API tokens: a row per token in three lines (name + prefix, its folders and rights, last use and expiry), revoked from its ⋯; a new token asks the kind first (Hub exchange or Custom: rights as chips per folder), expiry 1 year by default; the token shows once in the same dialog, with Copy and a curl line | A table; one form with a preset link; the token in a banner above the list | A table truncates the rights at this width; the common token never shows a matrix to get wrong; closing the dialog is plainly when the token is gone |
| 2026-10-15 | Folder links (owner A/A/A) | An own folder's menu has Copy link and Share with link… as a file row does (the dialog is the link half of the file's); the link opens a list of the folder's files as it is now, Play opening the watch player above it with the playing row lit, a document only downloading; the rail marks a folder with a live link beside its count | Share with link… alone; cards; a download-only list; no mark | Mostly videos from a phone, read on a phone: a list gives a long name its line and the next one is a tap away; the folder link is the one place where moving a file in publishes it, so where it is must show |
| 2026-10-17 | Copy-only changes (owner ★) | Step 4 of the Conformance check alone (not 1–3 or 5): every state the changed text renders in, seen in both languages | The whole five-step check; no screenshot at all (DOM test + key parity) | Steps 1–3 and 5 judge controls, patterns and composition, which a text change does not touch; the one risk left is a longer Hungarian string wrapping or clipping, and only a screenshot shows that |
| 2026-10-17 | Text on glass (owner ★) | Light `--muted` 15% toward `--fg` in every scheme (≥ 6:1 on the bare page); the item count, the badges and the current Files row on opaque tints mixed into the surface | Only the two failing components; the item count's number in `--fg` | Pixel-measured over every scheme × mode × backdrop, the count fell to 3.8:1 (dark too: thumbnails scroll under the toolbar's glass) and Channels' tabs to 4.1:1; an `--fg` count outshouts the filters beside it |
| 2026-10-18 | Scheduler run history (owner D4, E1, E2 ★) | A row's last three runs as dots, newest first (interrupted: a hollow warn ring, set apart from skipped and failed), and a history button that opens the job's runs on the Activity page; a user's ad-hoc runs in their own block under the jobs, with no interval and no run button | An inline expandable history per row; a dark-grey interrupted dot; ad-hoc runs only on the Activity page | Three dots answer "did it run, did it work" at a glance; the whole history needs filters, which the Activity page has |
| 2026-10-18 | Activity page (owner D1, D2, G1–G3 ★) | One page, "Napló" / "Activity": four tabs (Runs, Admin actions, Server log, Quota) under one filter bar (text, a period of 1 h · 24 h · 7 d · 30 d · 90 d defaulting to 7 days, an account), the view in the URL; runs and admin actions as dense rows that open in place as a log line does, filtered and paged on the server; a run's or a request's log lines one pivot away, as a history entry | One merged timeline; a page per source; a default period per tab; card rows; the audit log's client-side table | Each source keeps its own table and retention, read in one place; one bar keeps what you narrowed across tabs; about 1.8k runs a day need a log's density and a server-side filter |
| 2026-10-24 | Related videos (owner U1–U3 ★, D1 A, D3) | On demand: a tab beside the description (wide), a drawer (stacked); the narrow thumbnailed row, its menu alone, a followed check; plays in place and the list stays the opened video's; an on-topic / Hungarian-trending switch kept on the account | Always shown; the end-of-video grid alone; a dialog; a big card or a text row; a list that follows the playing video | The modal is full on a small screen; a picture is what a choice among strangers rests on; a list that stays put is tried one by one, and spends no more of the day's cap |
| 2026-10-25 | Related: one played; off (owner D1 a, D2 a) | Played from the list, it is the player's item: its title, controls and progress, marked watched at its end, the queue left where it was; a daily limit of 0 takes the tab and the button away | A linked video's in-place play (no controls, nothing kept); a tab that only shows a refusal | What is watched should count as watched, wherever it was started from; a control that can only be refused is not offered |
| 2026-10-25 | Player panel glyphs (owner 1A, 2A, 3) | In the side panel the queue row is glyph-only on one line, and Mark watched is a glyph toggle among the actions (✓ / ✓✓); the docked bar keeps its labels; the related switch at the default size, "Trending" | Labelled buttons wrapping onto three lines; glyphs on the docked bar too; Mark watched on its own line | The panel is narrow and the list controls are the same few every time; each glyph keeps its full name and shortcut in its tooltip; the docked bar has the width for words |
| 2026-10-25 | Auto-advance glyphs (owner E A, H a) | Auto-advance wears list glyphs (`ListEnd` next and off, `ListStart` previous, `Shuffle` random), in the panel and the docked bar; the auto-advance and loop cycles are `lit`, not `pressed` | The skip arrows for the mode; an X'd list for off; `aria-pressed` on a cycle | Beside Previous / Next the skip arrows read as a second Next; a cycle of three or four modes is no two-state switch, and its name already says which |
| 2026-10-25 | Restart and Reset glyphs (owner L A) | A menu's Play from the beginning wears `RotateCcw`, as the card's own Restart does; Reset (clear progress and status) wears `Eraser`; Redownload wears `RefreshCw`, the retry arrow | The skip arrow ⏮ on the menu's Restart; `RotateCcw` on both Restart and Reset; `TimerReset` for Restart | ⏮ is Previous beside the player; one action wore two glyphs, and two actions in one menu would have worn one |
| 2026-10-28 | Notification order (owner A-D1 a, A-D2 a, ★ a) | What asks for the reader first, from either source ("Needs you · n"), then one stream newest first; read rows older than a week fold under "Earlier (n)", closed, its label in the section-heading style with a chevron | One stream with no band; this browser's activity above the server's (the two fixed sections); a server-side retention that deletes old rows | An admin's access requests sat under up to a hundred server rows; week-old read notices crowded what is new; folding keeps the rows (nothing is deleted) and an unread row never folds, whatever its age |
| 2026-11-01 | Docked bar glyphs (owner D8 a) | The maximised view's docked bar is glyph-only too (tooltips carry the names and keys): transport, auto-advance, loop, Mark watched (✓ / ✓✓); its controls scroll on a narrow window while Fullscreen and Exit stay pinned at its right end | Labels from `xl` up | On a tablet the labelled bar ran past the right edge and took Exit with it; the side panel had already lost its labels |
| 2026-11-01 | Toolbar group glyphs (owner D9 a, D10 a, D11 a) | The feed's, channel page's and Plex Browse's filter pills name their group by a glyph (`Eye` watch state, `Shapes` content types, `ArrowDownUp` sort), the word in the tooltip and the accessible name; Sort + View end the toolbar's row, the chip row shows only with a chip; a channel page has no count pill | The value alone; Sort + View on a row of their own | "Watch state:" and "Sort:" cost a tablet row its room; the value says enough once the glyph names the group; the channel header already counts the videos |
| 2026-11-01 | Channel header on a tablet (owner E2) | Below `xl` the action row drops its two words: "Prio" (the stepper's tooltip names it) and "Subscribed" (a ✓ glyph; its name in the aria-label, Unsubscribe in the tooltip); the tags keep the row's rest, folding into "+N" | The tag lane on a full line of its own below ~12rem; the tags under the channel name | On an iPad (820 px, 44 px targets) the lane got 29 px, a sliver; now 130 px, and every tag shows at 1180 |
| 2026-11-01 | Clear watch progress (owner E a) | A video menu's Reset reads "Clear watch progress" in English (HU "Megtekintés visszaállítása" stays); "Reset view" is only a page's view reset (sort, filters, grouping) | "Reset view" for both; the playlist page's view reset renamed instead | On a playlist page the two stood one right-click apart under one name, and one of them erases the video's progress with no undo |
| 2026-11-01 | Clear watch progress clears only that (owner A) | The row shows only on a video with a resume point and clears that alone: a hidden video stays hidden, a watched one watched. Un-hiding and un-watching have rows of their own | The old Reset, which also un-hid and un-watched | A hidden video stays hidden: what the row's name says is all it does |
| 2026-11-01 | Selecting a list's rows (owner D2 a) | An always visible box first in the row and one over the column for the rows shown; Shift+click a range, `x` the row under the keyboard, Esc clears; the selected row wears `accent/12` with its tick. Rows a filter hides stay selected, an action covers those shown; Watch later offers no selecting. | A "Select" button that shows the boxes on demand | Found at once and works on touch, with no mode to leave; costs about 2 rem a row |
| 2026-11-01 | Selection band (owner D1 a, D3 a, D4 a) | The toolbar's second row while rows are selected, no floating bar: move (the last target as one click) and remove, no copy; both undo back to the places, a video the target held before stays there; a single row's move undoes too. | A floating bar at the bottom; copying to a list too; no undo on a move | It covers no row, and the player and the notices live at the bottom; twenty videos sent to the wrong list need a way back to their places |
| 2026-11-01 | Grip on a phone (owner A) | Below a 24rem table a playlist row has no grip: the title keeps its width, and the row menu keeps the moves. | The grip and its drag kept, the title at 44 px | The title gets its width back, and the rail is shut on a phone, so a drag had nowhere to go |
| 2026-11-01 | Selection band on a tablet (owner A) | Below `xl` the band is glyphs on one row, the names in tooltip and aria-label, as on the channel header; the replaced row stays in the same grid cell, invisible, so the first tick moves no row under the second tap. | The buttons in words, wrapping to a second row | The first tick pushed the list from under the second tap |
| 2026-11-01 | Playlist heading on a narrow window (owner A) | The list's name had no width left: below `xl` Unsynced is its dot (the word read out, the hint in the tooltip); on a phone the cover is gone and Play all is its glyph, named in the tooltip. | Every word kept, the list's name at no width | A heading without its name says nothing |
| 2026-11-02 | Dragging onto a list (owner D5 a, D6 a) | The grip carries a row, or every selected row with a selected one, onto a list in the rail; the toast undoes. Many dropped inside the list stay; a coarse pointer's drawer never peeks, so its rail is a target only when pinned. | Only the grabbed row going along; the grip off under a view | One row moving out of three lit ones misleads, and a filter is how the rows to move are found |
| 2026-11-02 | Delete with every tick hidden (owner a) | It removes nothing and a warning says the filter hides them | Taking the row under the keyboard | While the band shows a selection, Delete means that selection: a row no one ticked must not go |
| 2026-11-02 | Undo of a video gone further (owner a) | A video the move put in the target comes back only if the target still holds it, and a warning says how many | Everything back, whatever it did since | A move skips a video that left the source; the same rule back keeps a video out of lists the reader never put it in |
| 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-30 | Composition and the reader (E30 S6) | A chapter and a Conformance step: the space is used, one focal point, worst real data, where am I, a click answers at once, zoom and text size first-class; the person page as two columns (facts and bio left, "Known for" right) | Rules for pieces only; the bio in a full-width panel; a narrower panel | The person page passed every piece rule with half its panel empty; the owner reads zoomed in, so zoom is a requirement, not a nicety |
| 2026-09-30 | Hungarian words (E30 S5) | The D3 list: Feed, Shorts, Admin, Token, Port stay; stream, Prio, Job, Audit, Demo, Auto, Info, Frontend/Backend, Backfill, transcode get the Hungarian word; one name per thing (kérelem, behúzás, one "Test connection") | Keep every loanword; translate Feed/Shorts too | The owner chose the list (D3); the final sweep found the same feature under two names in three places |
| 2026-09-29 | Numbers and order in the UI language (E30 S3) | Every number through `lib/format.ts` in the UI language (decimal mark, grouping, compact, units); A→Z by the shown name in the UI language's alphabet, client and server alike; the language comes off `useTranslation`, never the singleton | `toFixed`, hand-built "1.2K", `localeCompare` in the browser's locale, byte-order server sorts | A Hungarian reader saw "8.3", "1.2K", "Életrajzi" after "Z"; the singleton read also left values in the old language after a switch (React Compiler) |
| 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 |
| 2026-10-01 | Zoom target 125–133% | Screens are held at 125% and 133% browser zoom (1536 × 729, 1444 × 685 on a 1080p window) and the largest text size | 200% browser zoom | The owner: 200% was a guess; 125–133% is what they read Siftlode and most sites at, so 200% would be an upper bound nobody uses |
| 2026-10-01 | E31 Nav rail at short heights | One "Admin" rail item opening a panel of the admin pages | A denser rail when short; a visible "+4" hint | The rail fits at 133–150% without a hidden scroll; the admin pages are rare and admin-only |
| 2026-10-01 | E31 Configuration sections | A vertical, grouped section list, as Settings has | Group tabs + sub-tabs; one long page with a contents list | Sixteen tabs wrapped to three rows at 133%; one pattern for both settings pages |
| 2026-10-01 | E31 Statistics overview | Number tiles, and the per-operation counts as horizontal bars | Three narrow cards; a 30-day chart | The label sits by its value; what spends the quota is seen at a glance |
| 2026-10-01 | E31 Channel page header | The full banner, folding to one row once the grid scrolls | Always compact; no banner | The header is fixed and took 40% of a 685px window; the channel keeps its face at first sight |
| 2026-10-01 | E31 Channel page watch state | A fresh channel page shows the unwatched videos; a picked state is remembered | Show all, forgotten on reload | The owner: open a channel to see what is new; a choice made there should stick |
| 2026-10-01 | E31 Audit log runs | A job's successful runs fold into one row that opens, whatever ran between; only that job's failure (never folded, danger colour) breaks it | A filter defaulting to admin actions; both | Nothing hidden, the noise collapsed, the failure seen |
| 2026-10-01 | E31 Users row | Role and state as badges, the actions in the row's one menu | An inline role toggle; icon-only buttons | Action placement: the row shows state, rare and destructive actions live in the menu |
| 2026-10-01 | E31 YouTube player | Video beside an info panel at 1280px and wider (the video as large as the height allows); stacked below | Today's stack with a one-line bar; full-window like Plex | The owner's eyesight: about 1460px of video on 1080p instead of 1008, the actions still visible |
| 2026-10-01 | E31 Plex info and show pages | A second column for the facts, keywords and crew, as the person page; a collection's hero sized to its content | Full-width text; a larger poster and fanart | The right ~40% was empty; one composition for every media page |
| 2026-10-01 | E31 Nav rail rows are links | Each page row is a real link: a middle-click or Ctrl-click opens the page in a new tab | A button | The owner: open a page in a tab of its own, as on any site |
| 2026-10-02 | E31 Plex detail second column (refined) | The facts over the key crew beside the text; the keywords, chips that filter like the genres, under the text; the show page gets all three too | All three in the column, as the mockup drew it | At 20 keywords and 12 crew the column ran to twice the text's height (849 vs ~400 px at 133%): a hole under the text instead of beside it |
| 2026-10-02 | E31 Plex detail poster and crew | The movie and show poster keeps its 2:3 and grows a rung in a wide hero (w-56 at a 64rem panel); the crew shows its first 6 names and "Show N more" (a list of 8 or fewer shows whole) | Poster stretched to the row; a "More" on the keywords | The row is as tall as the crew column, so the poster drew ~175×590 and cropped; the crew, not the keywords, set the hero's height |
| 2026-10-04 | E32 `field` focus skips a mode switch | A form opens on its first field; a control above it that is not a field (the composer's Edit / Preview) is passed over by marking the field `data-initial-focus` | The first text field everywhere | That moved the focus in the download dialog off its profile choice and in the upload dialog off its drop zone; the mark changes only the form that needs it |
| 2026-10-04 | E35 Lint for z-levels and edged labels | Both rules stay; the two eslint selectors (`Z_RAW`, `EDGED_LABEL`) that refused them go | Keep the selectors | 0 hits since the E28 sweep that fixed the sites: a check that never fires costs a probe test and a snapshot each, and the UI review holds both rules |
| 2026-10-06 | `player` focus | A dialog that is a player opens focused on its panel (the YouTube player's rule, now `Modal`'s too); on the media element itself, Space and the arrows stay the element's own | `close`, or the media element | On the ✕ Space closed the player; on the element the browser's own controls acted on the same key as the player's (one Space played and paused, measured in Chrome) |
| 2026-10-08 | Captions on touch | A `Tooltip` opens for a mouse and a key's focus only, never for a finger | A caption on tap; a long-press caption | On an iPad the tap that showed the caption was taken as a hover and lost its click (every rail button took two taps), and no finger sends the mouseleave that hides it; the rail's labels setting names the buttons on touch |
| 2026-10-08 | Header search on touch | No key cap on a touch screen; the pill grows to 52 px so a ring shows round the 44 px Go; Go drops to its icon by the column's width | Keep the key cap; a 44 px Go overhanging a 40 px pill | On a tablet the 44 px key cap and Go sat on the pill's edge and cut the placeholder; an overhanging Go breaks the nested radii |
| 2026-10-08 | Plex subtitles and the control bar | The player draws the cues itself; while the bar shows they rise above it (200 ms), then settle back | The browser's own cues, moved by `line` | On an iPad the bar covered the text; a native cue cannot be animated or measured, and our layer behaves the same on every engine |
| 2026-10-08 | Hover on a button over a picture | The `media` variant's hover is a 2px accent ring over a darker body (Plex + YT cards, rail arrows; option B) | An accent fill (reads as "selected"); a white wash (weak on a bright poster) | The 60 → 75% black step was invisible, and over a card a rail arrow could not be told from the card's own Play under it |
| 2026-10-08 | Plex "Add to collection" | A movie or show card's menu row opens a panel beside the card: its library's manual collections tick in place, a name + Enter creates one with the title in it (admin; option A) | An Add submenu with a "New…" dialog; the per-title editor modal from the menu | A collection took a trip to the Collections page, a name, a title search, then a second search per title; the panel is the video card's "Add to playlist" |
| 2026-10-08 | YouTube modal chrome | No prev/next strips beside the card; Close an icon (the panel's top right; stacked, the title row's end); the description clamped to three lines + More; the panel shuts to a 2.75rem strip of close, expand, prev/next (option D1) | Keep the strips; a "Close" text button; the full description in a scroller; a panel that hides outright | The strips cost the video 7rem of width for a second copy of the panel's buttons; a long description pushed the channel and the actions out of sight; a hidden panel left no way to step the queue without the keyboard |
| 2026-10-08 | The wheel over the YouTube modal | Over the video: volume. Over a panel with something to scroll: scroll only, never volume, not even at its end. Over a panel with nothing to scroll: volume (owner) | Volume everywhere; a scroll that hands over to the volume at its end | Reading a long description turned the volume down once it hit the end |
| 2026-10-09 | A Plex show card plays | Its picture plays the show's next episode everywhere (an episode with a resume point → Continue + Restart, else the first unwatched, else S1·E1), resolved on the click; the preview names it once up; title and ⓘ open the show (option A + hover ask) | The picture opens the show page (as before); every card carrying `next` | A show card on Discover had no Play while Home's had one; carrying `next` costs a grid page up to 180 queries for the few cards anyone plays |
| 2026-10-10 | Clip mode in the players (E38) | Scissors in the control row + `C`, `[` `]`; the panel in the player with the watched video as preview; overview + zoomed strip on `ui/RangeTrack` (ends are sliders); a toast + the Ready notification after Cut | A ⋯ menu entry; permanent In / Out buttons; a big dialog with its own preview (a second Plex transcode / YouTube iframe) | Owner's picks A, P1, a; a film is hours and a clip seconds, so only a zoomed strip gives a hand the precision. The Downloads editor's cuts moved onto the same track (keyboard-steppable now) |
| 2026-10-10 | Crop in clip mode (E38) | A Crop toggle in the clip panel: a frame over the playing picture (`ui/CropFrame`, the editor's crop too), Free / 16:9 / 9:16 / 1:1, Whole picture; maximised YouTube keeps the panel in the bar's place; the wide side panel shuts while clipping | A frame always on the picture in clip mode (K2); a free shape only | Owner's K1 + a: a crop is rare, so it never covers the picture unasked; a phone or square clip needs a held shape. The iframe hides its picture's size: drawn on the 16:9 box, the cut fits it to the real picture |
| 2026-10-10 | Marks on the player chrome | `--media-mark` (amber) and `--media-danger` (light red), the same in both themes; `media-marks` points the focus ring (`--focus`) and the chosen mark (`--chosen`: a track's tick, a tab's underline; the label stays white) at the amber on the player and every chrome surface; `media-scope` (a form) also the errors at the red | The theme's accent ring, chosen text, warn and danger | On the chrome (#171717) a light theme's accent ring (Slate 2.84:1), warn (2.99:1) and danger (2.77:1) all fall under 3:1; the accent as chosen text under 4.5:1 |
| 2026-10-11 | Files page (one library) | List rows by default, the media grid a toggle remembered per folder; a sidebar of automatic source folders, own folders, Shared with me and the storage meter; the paste-a-link box in the toolbar | A media grid by default | The library holds videos AND plain files: rows read both, show more per screen and scan by size and date |
| 2026-10-11 | Library list columns | Every kind of value in its own column (name, source, phone-safe, retention, size, added, actions), one grid with a subgrid per row so they line up; an action that does not apply keeps its slot empty; a right-click menu on every row (the ⋯ opens the same menu) | Badges flowing after the title; icons that shift when one is missing | Owner: values of one kind must fall in one vertical line; a row's every action reachable by right click |
| 2026-10-11 | Running downloads | An app-wide tray, bottom right, shown only while something runs or failed (reason + retry), foldable to a pill | A rail icon with a count and a popover | Downloading became an action you start anywhere; a running one must be visible without going to look |
| 2026-10-11 | Download done | The notification says whether it plays on a phone and carries Link másolása + Megnyitás (the folder, the item focused); never an automatic jump | A plain "ready" notice; auto-navigation to the item | The daily path is paste → done → copy the link; a jump would pull the user out of what they were doing |
| 2026-10-11 | Library row quick icons (owner A) | On a wide list only the ⋯ holds a place; Copy link, Share and Save come up over the date while the row is hovered or holds the keyboard; a card (phone) keeps them in its own line; Keeps narrower | Three icons always in sight (the name kept ~150px at 1200); dropping Added below 64rem | Owner: the name is what a row is read by; every action stays in the ⋯ and the right-click menu |
| 2026-10-12 | Files preview (owner ★) | A file that is not media opens from its name in the dialog shell, wide: an image, a PDF in the browser's own viewer, text as numbered plain text (HTML and SVG too) with a wrap toggle, else a no-preview card with Save; ← / → page the list's such files in its order; an image under 8 MB is its own thumbnail | A side panel beside the list; a new browser tab; pdf.js; syntax highlighting; a click that saves a file without a preview | Owner: one way to open anything, paged like the list; the browser's PDF viewer is free and isolated; a page is never rendered on our origin |
| 2026-10-12 | A running download's step (owner ★) | The tray row names the step beside its bar ("Downloading audio · 12%", "Merging" with a pulse); a paused-only tray shows a pause mark, never the done check | A bare bar and % | A download fetches video and audio as two streams: unnamed, the second fill reads as a restart; the tray is the only place a download is followed |
| 2026-10-14 | API tokens (owner A/A/A) | Settings → API tokens: a row per token in three lines (name + prefix, its folders and rights, last use and expiry), revoked from its ⋯; a new token asks the kind first (Hub exchange or Custom: rights as chips per folder), expiry 1 year by default; the token shows once in the same dialog, with Copy and a curl line | A table; one form with a preset link; the token in a banner above the list | A table truncates the rights at this width; the common token never shows a matrix to get wrong; closing the dialog is plainly when the token is gone |
| 2026-10-15 | Folder links (owner A/A/A) | An own folder's menu has Copy link and Share with link… as a file row does (the dialog is the link half of the file's); the link opens a list of the folder's files as it is now, Play opening the watch player above it with the playing row lit, a document only downloading; the rail marks a folder with a live link beside its count | Share with link… alone; cards; a download-only list; no mark | Mostly videos from a phone, read on a phone: a list gives a long name its line and the next one is a tap away; the folder link is the one place where moving a file in publishes it, so where it is must show |
| 2026-10-17 | Copy-only changes (owner ★) | Step 4 of the Conformance check alone (not 1–3 or 5): every state the changed text renders in, seen in both languages | The whole five-step check; no screenshot at all (DOM test + key parity) | Steps 1–3 and 5 judge controls, patterns and composition, which a text change does not touch; the one risk left is a longer Hungarian string wrapping or clipping, and only a screenshot shows that |
| 2026-10-17 | Text on glass (owner ★) | Light `--muted` 15% toward `--fg` in every scheme (≥ 6:1 on the bare page); the item count, the badges and the current Files row on opaque tints mixed into the surface | Only the two failing components; the item count's number in `--fg` | Pixel-measured over every scheme × mode × backdrop, the count fell to 3.8:1 (dark too: thumbnails scroll under the toolbar's glass) and Channels' tabs to 4.1:1; an `--fg` count outshouts the filters beside it |
| 2026-10-18 | Scheduler run history (owner D4, E1, E2 ★) | A row's last three runs as dots, newest first (interrupted: a hollow warn ring, set apart from skipped and failed), and a history button that opens the job's runs on the Activity page; a user's ad-hoc runs in their own block under the jobs, with no interval and no run button | An inline expandable history per row; a dark-grey interrupted dot; ad-hoc runs only on the Activity page | Three dots answer "did it run, did it work" at a glance; the whole history needs filters, which the Activity page has |
| 2026-10-18 | Activity page (owner D1, D2, G1–G3 ★) | One page, "Napló" / "Activity": four tabs (Runs, Admin actions, Server log, Quota) under one filter bar (text, a period of 1 h · 24 h · 7 d · 30 d · 90 d defaulting to 7 days, an account), the view in the URL; runs and admin actions as dense rows that open in place as a log line does, filtered and paged on the server; a run's or a request's log lines one pivot away, as a history entry | One merged timeline; a page per source; a default period per tab; card rows; the audit log's client-side table | Each source keeps its own table and retention, read in one place; one bar keeps what you narrowed across tabs; about 1.8k runs a day need a log's density and a server-side filter |
| 2026-10-24 | Related videos (owner U1–U3 ★, D1 A, D3) | On demand: a tab beside the description (wide), a drawer (stacked); the narrow thumbnailed row, its menu alone, a followed check; plays in place and the list stays the opened video's; an on-topic / Hungarian-trending switch kept on the account | Always shown; the end-of-video grid alone; a dialog; a big card or a text row; a list that follows the playing video | The modal is full on a small screen; a picture is what a choice among strangers rests on; a list that stays put is tried one by one, and spends no more of the day's cap |
| 2026-10-25 | Related: one played; off (owner D1 a, D2 a) | Played from the list, it is the player's item: its title, controls and progress, marked watched at its end, the queue left where it was; a daily limit of 0 takes the tab and the button away | A linked video's in-place play (no controls, nothing kept); a tab that only shows a refusal | What is watched should count as watched, wherever it was started from; a control that can only be refused is not offered |
| 2026-10-25 | Player panel glyphs (owner 1A, 2A, 3) | In the side panel the queue row is glyph-only on one line, and Mark watched is a glyph toggle among the actions (✓ / ✓✓); the docked bar keeps its labels; the related switch at the default size, "Trending" | Labelled buttons wrapping onto three lines; glyphs on the docked bar too; Mark watched on its own line | The panel is narrow and the list controls are the same few every time; each glyph keeps its full name and shortcut in its tooltip; the docked bar has the width for words |
| 2026-10-25 | Auto-advance glyphs (owner E A, H a) | Auto-advance wears list glyphs (`ListEnd` next and off, `ListStart` previous, `Shuffle` random), in the panel and the docked bar; the auto-advance and loop cycles are `lit`, not `pressed` | The skip arrows for the mode; an X'd list for off; `aria-pressed` on a cycle | Beside Previous / Next the skip arrows read as a second Next; a cycle of three or four modes is no two-state switch, and its name already says which |
| 2026-10-25 | Restart and Reset glyphs (owner L A) | A menu's Play from the beginning wears `RotateCcw`, as the card's own Restart does; Reset (clear progress and status) wears `Eraser`; Redownload wears `RefreshCw`, the retry arrow | The skip arrow ⏮ on the menu's Restart; `RotateCcw` on both Restart and Reset; `TimerReset` for Restart | ⏮ is Previous beside the player; one action wore two glyphs, and two actions in one menu would have worn one |
| 2026-10-28 | Notification order (owner A-D1 a, A-D2 a, ★ a) | What asks for the reader first, from either source ("Needs you · n"), then one stream newest first; read rows older than a week fold under "Earlier (n)", closed, its label in the section-heading style with a chevron | One stream with no band; this browser's activity above the server's (the two fixed sections); a server-side retention that deletes old rows | An admin's access requests sat under up to a hundred server rows; week-old read notices crowded what is new; folding keeps the rows (nothing is deleted) and an unread row never folds, whatever its age |
| 2026-11-01 | Docked bar glyphs (owner D8 a) | The maximised view's docked bar is glyph-only too (tooltips carry the names and keys): transport, auto-advance, loop, Mark watched (✓ / ✓✓); its controls scroll on a narrow window while Fullscreen and Exit stay pinned at its right end | Labels from `xl` up | On a tablet the labelled bar ran past the right edge and took Exit with it; the side panel had already lost its labels |
| 2026-11-01 | Toolbar group glyphs (owner D9 a, D10 a, D11 a) | The feed's, channel page's and Plex Browse's filter pills name their group by a glyph (`Eye` watch state, `Shapes` content types, `ArrowDownUp` sort), the word in the tooltip and the accessible name; Sort + View end the toolbar's row, the chip row shows only with a chip; a channel page has no count pill | The value alone; Sort + View on a row of their own | "Watch state:" and "Sort:" cost a tablet row its room; the value says enough once the glyph names the group; the channel header already counts the videos |
| 2026-11-01 | Channel header on a tablet (owner E2) | Below `xl` the action row drops its two words: "Prio" (the stepper's tooltip names it) and "Subscribed" (a ✓ glyph; its name in the aria-label, Unsubscribe in the tooltip); the tags keep the row's rest, folding into "+N" | The tag lane on a full line of its own below ~12rem; the tags under the channel name | On an iPad (820 px, 44 px targets) the lane got 29 px, a sliver; now 130 px, and every tag shows at 1180 |
| 2026-11-01 | Clear watch progress (owner E a) | A video menu's Reset reads "Clear watch progress" in English (HU "Megtekintés visszaállítása" stays); "Reset view" is only a page's view reset (sort, filters, grouping) | "Reset view" for both; the playlist page's view reset renamed instead | On a playlist page the two stood one right-click apart under one name, and one of them erases the video's progress with no undo |
| 2026-11-01 | Clear watch progress clears only that (owner A) | The row shows only on a video with a resume point and clears that alone: a hidden video stays hidden, a watched one watched. Un-hiding and un-watching have rows of their own | The old Reset, which also un-hid and un-watched | A hidden video stays hidden: what the row's name says is all it does |
| 2026-11-01 | Selecting a list's rows (owner D2 a) | An always visible box first in the row and one over the column for the rows shown; Shift+click a range, `x` the row under the keyboard, Esc clears; the selected row wears `accent/12` with its tick. Rows a filter hides stay selected, an action covers those shown; Watch later offers no selecting. | A "Select" button that shows the boxes on demand | Found at once and works on touch, with no mode to leave; costs about 2 rem a row |
| 2026-11-01 | Selection band (owner D1 a, D3 a, D4 a) | The toolbar's second row while rows are selected, no floating bar: move (the last target as one click) and remove, no copy; both undo back to the places, a video the target held before stays there; a single row's move undoes too. | A floating bar at the bottom; copying to a list too; no undo on a move | It covers no row, and the player and the notices live at the bottom; twenty videos sent to the wrong list need a way back to their places |
| 2026-11-01 | Grip on a phone (owner A) | Below a 24rem table a playlist row has no grip: the title keeps its width, and the row menu keeps the moves. | The grip and its drag kept, the title at 44 px | The title gets its width back, and the rail is shut on a phone, so a drag had nowhere to go |
| 2026-11-01 | Selection band on a tablet (owner A) | Below `xl` the band is glyphs on one row, the names in tooltip and aria-label, as on the channel header; the replaced row stays in the same grid cell, invisible, so the first tick moves no row under the second tap. | The buttons in words, wrapping to a second row | The first tick pushed the list from under the second tap |
| 2026-11-01 | Playlist heading on a narrow window (owner A) | The list's name had no width left: below `xl` Unsynced is its dot (the word read out, the hint in the tooltip); on a phone the cover is gone and Play all is its glyph, named in the tooltip. | Every word kept, the list's name at no width | A heading without its name says nothing |
| 2026-11-02 | Dragging onto a list (owner D5 a, D6 a) | The grip carries a row, or every selected row with a selected one, onto a list in the rail; the toast undoes. Many dropped inside the list stay; a coarse pointer's drawer never peeks, so its rail is a target only when pinned. | Only the grabbed row going along; the grip off under a view | One row moving out of three lit ones misleads, and a filter is how the rows to move are found |
| 2026-11-02 | Delete with every tick hidden (owner a) | It removes nothing and a warning says the filter hides them | Taking the row under the keyboard | While the band shows a selection, Delete means that selection: a row no one ticked must not go |
| 2026-11-02 | Undo of a video gone further (owner a) | A video the move put in the target comes back only if the target still holds it, and a warning says how many | Everything back, whatever it did since | A move skips a video that left the source; the same rule back keeps a video out of lists the reader never put it in |
| 2026-11-04 | Tag chip counts (owner D5a) | A Channels tag chip shows how many followed channels it holds: a muted number after the name, no badge | The number in brackets after the name | The owner's pick; the number is a detail of the name, so it takes the name's own text, muted, rather than a `CountBadge` |
| 2026-11-05 | Picked tag chips lead the row (owner c) | On the Channels tag row the picked chips come first, in the tags' order, then the rest | Keep the order and fold the unpicked first; mark the "+N" when it holds a pick | The row folds from its end, so a picked tag late in the list sat only inside "+N" with nothing on the row saying a filter was on |
| 2026-11-05 | A toggled tag chip keeps the focus (owner a) | A chip that moves when toggled takes the keyboard focus with it: to its new place, or to "+N" when it went into the closed fold | Keep a picked chip in the "+N" popover until it closes | The row always shows the filters as they are; the focus, not the order, was what got lost |
| 2026-11-06 | Tag chip count after a dot (owner a) | The count keeps the chip's own colour, set off from the name by a "·" | The number dimmed (it fell under the 4.5:1 text floor, resting and pressed); the number in a tinted inner badge | Contrast holds in every scheme without a new token, and the dot still reads the number as a detail of the name |
| 2026-11-06 | A pick in the open fold closes it (owner a) | Toggling a chip inside the "+N" popover closes the popover; the focus goes with the chip | Keep the popover open and focus the chip now in the picked one's place | The focus never sits outside a popover left open, and a picked chip leads the row anyway |
| 2026-11-06 | An expired share link offers no Copy (owner a) | An expired row in the share dialog has a warn badge "expired <date>" and its address struck through; Open and Revoke stay, Copy goes | The warn badge alone, Copy kept | An expired link opens nothing, so it is no link to hand out; Open still lets the owner check it |
| 2026-11-06 | The shared page's unlock button is a noun in Hungarian (owner a) | "Megtekintés" for a video, "Megnyitás" for a file or folder | Both verbs, "Megnézem" / "Megnyitom" | The app's Hungarian buttons are nouns; one button should not change grammatical form by link kind |
+4 -2
View File
@@ -1,14 +1,16 @@
# Siftlode is mostly a private, login-gated app; only the public landing and legal
# pages are worth indexing. Allow crawling but keep bots out of the API surface.
# Share links (/watch/) are unguessable capability URLs: kept out of search indexes,
# Share links (/watch/, /file/, /folder/) are unguessable capability URLs: kept out of search indexes,
# but link-preview crawlers are allowed below so shared links unfurl in chat apps.
User-agent: *
Allow: /
Disallow: /api/
Disallow: /auth/
Disallow: /watch/
Disallow: /file/
Disallow: /folder/
# Link-preview / unfurl bots — allowed to fetch /watch/ so shared links render a rich
# Link-preview / unfurl bots — allowed to fetch share links so shared links render a rich
# card (title/channel/thumbnail). They don't build a search index, so this doesn't
# expose share links to search engines.
User-agent: facebookexternalhit
@@ -9,7 +9,13 @@ import { act, render, screen, fireEvent, cleanup } from "@testing-library/react"
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (k: string) => k }) };
return {
...actual,
useTranslation: () => ({
t: (k: string, o?: { action?: string; title?: string }) =>
o?.title ? `${o.action}: ${o.title}` : k,
}),
};
});
import { Carousel } from "./Carousel";
@@ -54,7 +60,10 @@ describe("Carousel", () => {
);
expect(screen.getByText("card-a")).toBeTruthy();
expect(screen.getByRole("heading", { name: "Recently added" })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "common.seeAll" }));
// Named with its rail, the visible words first: a page of rails has a See all on each.
const seeAll = screen.getByRole("button", { name: "common.seeAll: Recently added" });
expect(seeAll.textContent).toBe("common.seeAll");
fireEvent.click(seeAll);
expect(onSeeAll).toHaveBeenCalledOnce();
});
+15 -1
View File
@@ -109,7 +109,21 @@ export function Carousel({
<div className="mb-3 flex items-baseline gap-2">
<h2 className="text-sm font-semibold">{title}</h2>
{onSeeAll && (
<Button variant="link-muted" onClick={onSeeAll} className="ml-auto gap-0.5 text-xs">
<Button
variant="link-muted"
onClick={onSeeAll}
// Named with its rail: a page of rails has a See all on each, and a screen reader's
// list of buttons (or a voice command) must tell them apart. The visible words lead.
aria-label={
label
? t("common.seeAllOf", {
action: seeAllLabel ?? t("common.seeAll"),
title: label,
})
: undefined
}
className="ml-auto gap-0.5 text-xs"
>
{seeAllLabel ?? t("common.seeAll")}
<ChevronRight className="h-3.5 w-3.5" />
</Button>
@@ -1,4 +1,17 @@
[
{
"version": "0.117.0",
"date": "2026-11-06",
"new": [
"A share link's address now says what it opens: /file/ for a file, /folder/ for a folder, /watch/ for a video. Links you already sent keep working.",
"The upload dialog shows which folder the file goes to, and you can pick another one or make a new folder there. Opening Share copies a link at once.",
"The Channels tag chips show how many followed channels each holds. Continue watching in Plex has a See all, and a Plex item's Info shows the file size."
],
"fixed": [
"An expired share link is marked as expired and is never copied. A crop of a video with wide pixels keeps exactly what its frame showed.",
"A Plex auto-skip waits 3 seconds into the marker before its countdown starts."
]
},
{
"version": "0.116.3",
"date": "2026-11-04",
@@ -1,4 +1,17 @@
[
{
"version": "0.117.0",
"date": "2026-11-06",
"new": [
"A megosztási link címe most megmondja, mit nyit meg: /file/ egy fájlt, /folder/ egy mappát, /watch/ egy videót. A már elküldött linkek továbbra is működnek.",
"A feltöltési ablak mutatja, melyik mappába kerül a fájl, és ott választhatsz másikat vagy új mappát hozhatsz létre. A Megosztás megnyitáskor rögtön kimásol egy linket.",
"A Csatornák címkéi mutatják, hány követett csatorna tartozik hozzájuk. A Plex Folytatás sávjának is van Összes gombja, és az elem adatlapja mutatja a fájl méretét."
],
"fixed": [
"A lejárt megosztási link lejártként látszik, és sosem kerül a vágólapra. A széles képpontú videó vágása pontosan azt tartja meg, amit a keret mutatott.",
"A Plex automatikus átugrása a marker után 3 másodpercet vár, mielőtt a visszaszámlálás indul."
]
},
{
"version": "0.116.3",
"date": "2026-11-04",
@@ -45,6 +45,7 @@ const tag = (id: number, name: string): Tag => ({
category: "other",
system: false,
channel_count: 0,
subscribed_count: 0,
});
const docu = tag(1, "docu");
const history = tag(2, "history");
@@ -0,0 +1,69 @@
// @vitest-environment jsdom
//
// A tag's row in the manager says how many channels it is on, beside a hover list of them, over the
// Channels page whose chip names the same tag with a number: all three count the same channels —
// the followed ones the Subscriptions tab shows, hidden ones left out. Every link (the number a
// delete removes) is only the delete's confirm.
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, screen, cleanup, fireEvent } 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, o?: { count?: number }) => (o?.count != null ? `${key}:${o.count}` : key),
i18n: { language: "en" },
}),
}));
import TagManager from "./TagManager";
import { api, type ManagedChannel, type Tag } from "../../lib/api";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
const tag = {
id: 5,
name: "Music",
color: null,
category: "other",
system: false,
channel_count: 4,
subscribed_count: 2,
} as Tag;
const ch = (id: string, hidden: boolean) =>
({ id, title: id, hidden, tag_ids: [5] }) as unknown as ManagedChannel;
describe("TagManager · a tag's count", () => {
it("counts the followed channels the Subscriptions tab shows, not every link", async () => {
vi.spyOn(api, "tags").mockResolvedValue([tag]);
vi.spyOn(api, "channels").mockResolvedValue([
ch("UCa", false),
ch("UCb", false),
ch("UChid", true),
]);
render(
<QueryClientProvider client={new QueryClient()}>
<TagManager onClose={() => {}} onFocusChannel={() => {}} />
</QueryClientProvider>,
);
expect(await screen.findByText("tagManager.channels:2")).toBeTruthy();
expect(screen.queryByText("tagManager.channels:4")).toBeNull();
fireEvent.mouseEnter(screen.getByText("tagManager.channels:2"));
expect(await screen.findByText("UCa")).toBeTruthy();
expect(screen.queryByText("UChid")).toBeNull();
});
});
+2 -2
View File
@@ -71,7 +71,7 @@ function TagRow({
: ""
}`}
>
{t("tagManager.channels", { count: tag.channel_count })}
{t("tagManager.channels", { count: tag.subscribed_count })}
</span>
<IconButton variant="danger" onClick={onDelete} label={t("tagManager.delete")}>
<Trash2 />
@@ -119,7 +119,7 @@ export default function TagManager({
const userTags = (tagsQuery.data ?? []).filter((tg) => !tg.system);
const channelsForTag = (tagId: number) =>
(channelsQuery.data ?? [])
.filter((c) => c.tag_ids.includes(tagId))
.filter((c) => !c.hidden && c.tag_ids.includes(tagId))
.map((c) => ({ id: c.id, title: c.title ?? c.id }));
const invalidate = () => {
+1
View File
@@ -28,6 +28,7 @@
"loadError": "Couldn't load this — check your connection and try again",
"deletedTag": "Deleted tag",
"seeAll": "See all",
"seeAllOf": "{{action}}: {{title}}",
"scrollLeft": "Scroll left",
"scrollRight": "Scroll right",
"search": "Search",
+5 -1
View File
@@ -32,6 +32,9 @@
"uploadProgress": "Upload progress",
"added": "File uploaded",
"addedConverting": "File uploaded — converting it so it plays on any phone",
"targetLabel": "Upload to",
"makingFolder": "Making “{{name}}”…",
"targetFailed": "The folders could not be loaded",
"failed": "Couldn't upload the file"
},
"status": {
@@ -90,7 +93,7 @@
"pickPlaceholder": "Search a user…",
"noUsers": "No other users yet.",
"linkSection": "Share a link",
"linkHint": "Anyone with the link can watch — no account needed.",
"linkHint": "Anyone with the link can open it — no account needed.",
"linkCopied": "Share link copied to clipboard",
"openNewTab": "Open in new tab",
"linkFailed": "Couldn’t create the share link",
@@ -111,6 +114,7 @@
"revokeConfirm": "This link will stop working immediately.",
"views_one": "{{count}} view",
"views_other": "{{count}} views",
"expiredOn": "expired {{date}}",
"expiresOn": "expires {{date}}",
"hasPassword": "password",
"downloadable": "downloadable",
+1
View File
@@ -28,6 +28,7 @@
"loadError": "Nem sikerült betölteni — ellenőrizd a kapcsolatot, és próbáld újra",
"deletedTag": "Törölt címke",
"seeAll": "Összes",
"seeAllOf": "{{action}}: {{title}}",
"scrollLeft": "Görgetés balra",
"scrollRight": "Görgetés jobbra",
"search": "Keresés",
+5 -1
View File
@@ -32,6 +32,9 @@
"uploadProgress": "Feltöltés folyamata",
"added": "Fájl feltöltve",
"addedConverting": "Fájl feltöltve — átalakítjuk, hogy minden telefonon lejátszható legyen",
"targetLabel": "Feltöltés ide",
"makingFolder": "„{{name}}” létrehozása…",
"targetFailed": "A mappákat nem sikerült betölteni",
"failed": "Nem sikerült feltölteni a fájlt"
},
"status": {
@@ -90,7 +93,7 @@
"pickPlaceholder": "Keress egy felhasználót…",
"noUsers": "Nincs más felhasználó.",
"linkSection": "Megosztás linkkel",
"linkHint": "A link birtokában bárki megnézheti — fiók nélkül is.",
"linkHint": "A link birtokában bárki megnyithatja — fiók nélkül is.",
"linkCopied": "Megosztási link a vágólapra másolva",
"openNewTab": "Megnyitás új lapon",
"linkFailed": "Nem sikerült a megosztási link létrehozása",
@@ -111,6 +114,7 @@
"revokeConfirm": "A link azonnal használhatatlanná válik.",
"views_one": "{{count}} megtekintés",
"views_other": "{{count}} megtekintés",
"expiredOn": "lejárt: {{date}}",
"expiresOn": "lejár: {{date}}",
"hasPassword": "jelszó",
"downloadable": "letölthető",
+4 -2
View File
@@ -70,7 +70,9 @@ export interface ClipRequest {
export interface EditSpec {
trim?: { start_s: number; end_s?: number };
segments?: { start_s: number; end_s: number }[];
crop?: { x: number; y: number; w: number; h: number };
// In the picture's size as the browser reports it, which `frame_w` x `frame_h` says: the server
// scales them to the file's own pixels (a video's stored and shown sizes can differ).
crop?: { x: number; y: number; w: number; h: number; frame_w?: number; frame_h?: number };
accurate?: boolean;
}
@@ -143,7 +145,7 @@ export interface ShareLink {
// Null when the server cannot show this link's address again (no encrypted copy its key reads): the
// link still opens for whoever has it; to share it anew, make a new link.
token: string | null;
url: string | null; // path like "/watch/<token>" — prepend window.location.origin to share
url: string | null; // path like "/watch/<token>" or "/file/<token>" — prepend window.location.origin to share
allow_download: boolean;
has_password: boolean;
expires_at: string | null;
+1
View File
@@ -154,6 +154,7 @@ export interface PlexItemDetail {
summary?: string | null;
year?: number | null;
duration_seconds: number | null;
size_bytes?: number | null; // the file's, null while unknown
playable: string; // direct | remux | transcode
transcode_hw?: boolean | null; // transcode items only: true = HW-capable, false = SW-only, null = n/a
codec_video?: string | null; // e.g. "hevc" / "h264" (info-chip subtitle)
+5
View File
@@ -7,7 +7,12 @@ export interface Tag {
color: string | null;
category: string;
system: boolean;
/** Every channel link it has, a channel the user no longer follows included (an unsubscribe keeps
* the link) — what deleting the tag removes. */
channel_count: number;
/** The user's own links on channels they follow and have not hidden: the rows the Channels page's
* filter shows (a hidden channel has its own tab). */
subscribed_count: number;
}
// One suggested user-tag for a channel, learned from the user's own manual-tag pattern.
@@ -18,6 +18,7 @@ import { api, HttpError } from "./api";
import { msg, notify } from "./notifications";
import { useChannelTagActions } from "./useChannelTagActions";
import { useChannelPriority } from "./useChannelPriority";
import { qk } from "./queryKeys";
const notifyMock = vi.mocked(notify);
beforeEach(() => notifyMock.mockClear());
@@ -44,6 +45,23 @@ describe("useChannelTagActions", () => {
});
});
// A tag counts the followed channels it is on: a toggle that lands changes that number.
it("refreshes the tags once a toggle lands", async () => {
vi.spyOn(api, "attachChannelTag").mockResolvedValue({ ok: true } as never);
const qc = new QueryClient();
const spy = vi.spyOn(qc, "invalidateQueries");
const { result } = renderHook(
() => useChannelTagActions({ optimisticToggle: () => {}, onError: () => {} }),
{
wrapper: ({ children }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
),
},
);
act(() => result.current.toggleTag("UC1", 7, false));
await waitFor(() => expect(spy.mock.calls.map(([f]) => f?.queryKey)).toContainEqual(qk.tags()));
});
it("names a refused create-and-attach", async () => {
vi.spyOn(api, "createTag").mockRejectedValue(new HttpError(429, "slow"));
const { result } = renderHook(
+16
View File
@@ -94,6 +94,22 @@ describe("quickShareLink", () => {
expect(createDownloadLink).not.toHaveBeenCalled();
});
// An expired link stays listed for its owner, but its recipient would land on "link gone".
it("passes over an expired link, and mints one when every link has expired", async () => {
const lastWeek = new Date(Date.now() - 7 * 86400_000).toISOString();
const nextWeek = new Date(Date.now() + 7 * 86400_000).toISOString();
downloadLinks.mockResolvedValue([
{ id: 5, url: "/watch/gone", expires_at: lastWeek },
{ id: 3, url: "/watch/live", expires_at: nextWeek },
]);
expect(await quickShareLink(qc(), { kind: "file", id: 1 })).toBe(`${origin}/watch/live`);
expect(createDownloadLink).not.toHaveBeenCalled();
downloadLinks.mockResolvedValue([{ id: 5, url: "/watch/gone", expires_at: lastWeek }]);
createDownloadLink.mockResolvedValue({ id: 9, url: "/watch/fresh" });
expect(await quickShareLink(qc(), { kind: "file", id: 2 })).toBe(`${origin}/watch/fresh`);
});
it("mints a new link when none it has can be shown, keeping the old ones listed", async () => {
const hidden = { id: 4, url: null };
downloadLinks.mockResolvedValue([hidden]);
+12 -3
View File
@@ -1,6 +1,6 @@
import type { QueryClient } from "@tanstack/react-query";
import { qk } from "./queryKeys";
import { api, type NewShareLink } from "./api";
import { api, type NewShareLink, type ShareLink } from "./api";
import { copyText } from "./clipboard";
import { msg, notify } from "./notifications";
import { notifyActionError } from "./actionErrors";
@@ -38,6 +38,16 @@ export function invalidateLinks(qc: QueryClient, target: LinkTarget) {
if (target.kind === "folder") void qc.invalidateQueries({ queryKey: qk.fileFolders() });
}
/** Has a link stopped opening? The list still holds an expired link (its owner may want to see or
* revoke it), but it is no link to hand out. */
export const linkExpired = (link: ShareLink, now = Date.now()) =>
link.expires_at !== null && Date.parse(link.expires_at) <= now;
/** The link to hand out of a list (newest first): the newest that still opens and whose address can
* be shown — an expired one sends the recipient to "link gone", one without an address is no use
* to copy. None: make one. */
export const liveLink = (links: ShareLink[]) => links.find((l) => l.url && !linkExpired(l));
// One-click share: the row's Share button turns "share this" into a single action.
// Ensure the file or folder has a public link (minting one on demand — omitting `allow_download` so
// the backend default makes it downloadable), copy it to the clipboard, and toast with an "open in
@@ -51,8 +61,7 @@ export async function quickShareLink(qc: QueryClient, target: LinkTarget): Promi
queryKey: linksKey(target),
queryFn: () => fetchLinks(target),
});
// The newest link whose address can still be shown; one that cannot is no use to copy.
path = existing.find((l) => l.url)?.url;
path = liveLink(existing)?.url;
if (!path) {
const made = await makeLink(target, {}); // omit allow_download → backend default (true)
qc.setQueryData(linksKey(target), [made, ...existing]);
@@ -0,0 +1,16 @@
// @vitest-environment jsdom
//
// An auto-skip's countdown starts below zero: the wait before it, while the Skip button already
// shows. The bar it draws must read empty through the wait, not a negative width.
import { describe, expect, it } from "vitest";
import { skipCountdown } from "./plexPlayerHooks";
describe("skipCountdown", () => {
it("stays empty through the wait, runs over the delay, and stops at full", () => {
expect(skipCountdown(-3, 5)).toBe(0);
expect(skipCountdown(-0.1, 5)).toBe(0);
expect(skipCountdown(2.5, 5)).toBe(0.5);
expect(skipCountdown(7, 5)).toBe(1);
});
});
+22 -2
View File
@@ -434,6 +434,19 @@ export function usePlayerVolume(opts: {
return { setVol, nudgeVolume, toggleMute, onWheel };
}
// How long a marker plays before an auto-skip's countdown starts (seconds), at most: on a marker
// too short for both, the wait gives way, so the countdown still ends inside it (`useAutoSkip`).
const AUTO_SKIP_WAIT_S = 3;
// What the countdown keeps in hand before the marker's end: it ticks on wall time, the playhead on
// the media's, and the marker stops counting a second before its end.
const AUTO_SKIP_SLACK_S = 0.5;
/** How far an auto-skip's countdown has run (0→1) after `elapsed` playing seconds of it. Through
* the wait `elapsed` is below zero, and the bar it draws stays empty: a negative width is invalid
* CSS, which the browser drops and keeps whatever it drew before. */
export const skipCountdown = (elapsed: number, delay: number) =>
Math.max(0, Math.min(1, elapsed / delay));
// Auto-skip intro/credits. Entering a marker whose auto-skip is on runs a countdown (skipProgress
// 0→1 over autoSkipDelay s, shown on the Skip button); on completion it skips — to the marker end, or
// for the credits that end the item to the next item (binge). Which credits those are, and which the
@@ -481,6 +494,7 @@ export function useAutoSkip(opts: {
(m) => abs >= m.start_s && abs < m.end_s - 1,
);
const activeMarkerRef = useLatest(activeMarker);
const absRef = useLatest(abs);
// A boolean, not the list, goes into the countdown's deps: a refetch hands back an equal-but-new
// list and must not restart a countdown under way (see below); doSkip reads the list from a ref.
const activeAuto = activeMarker != null && markerSkip(activeMarker, markers ?? []).auto;
@@ -538,14 +552,19 @@ export function useAutoSkip(opts: {
const enabled = (m.type === "intro" ? autoSkipIntro : autoSkipCredits) && activeAuto;
if (!enabled || suspended || cancelledMarkerRef.current === key) return setSkipProgress(null);
if (autoSkipDelay <= 0) return doSkip(m); // immediate
let elapsed = 0;
// Below zero is the wait: time to see where the marker has landed and press the button, which
// shows from the start, before anything counts down to leaving. A delay of 0 asked for none.
// It is cut to what the marker has left over the countdown (a short intro, or one resumed near
// its end): a wait the countdown cannot finish after is an auto-skip that never fires.
const left = m.end_s - 1 - absRef.current - autoSkipDelay - AUTO_SKIP_SLACK_S;
let elapsed = -Math.max(0, Math.min(AUTO_SKIP_WAIT_S, left));
setSkipProgress(0);
const iv = window.setInterval(() => {
// Freeze the countdown while the video is paused — otherwise it would fire and auto-seek a
// deliberately-paused video. It resumes from where it stopped when playback resumes.
if (videoRef.current?.paused) return;
elapsed += 0.1;
const p = Math.min(1, elapsed / autoSkipDelay);
const p = skipCountdown(elapsed, autoSkipDelay);
setSkipProgress(p);
if (p >= 1) {
window.clearInterval(iv);
@@ -568,6 +587,7 @@ export function useAutoSkip(opts: {
doSkip,
videoRef,
activeMarkerRef,
absRef,
activeAuto,
]);
@@ -21,9 +21,23 @@ describe("a subscription change", () => {
qk.feedSummary(),
qk.discoveredChannels(),
qk.watchedChannels(),
qk.tags(),
]);
});
// A tag counts the followed channels it is on, not hidden: a hide changes the number as an
// unsubscribe does, and both places that hide a channel must refresh it.
it("a hide refreshes the tags wherever it is made", () => {
const root = new URL("../", import.meta.url);
const sites = readdirSync(root, { recursive: true, encoding: "utf8" })
.map((f) => f.split("\\").join("/"))
.filter((f) => f.endsWith(".tsx") && !f.includes(".test.") && !f.startsWith("design-lab/"))
.map((f) => [f, readFileSync(new URL(f, root), "utf8")] as const)
.filter(([, src]) => /api\.updateChannel\([^)]*hidden/.test(src));
expect(sites.length).toBeGreaterThanOrEqual(2);
for (const [f, src] of sites) expect(src, f).toContain("queryKey: qk.tags()");
});
it("is refreshed through that one function wherever it is made", () => {
const root = new URL("../", import.meta.url);
const sites = readdirSync(root, { recursive: true, encoding: "utf8" })
+3 -2
View File
@@ -3,8 +3,8 @@ import type { QueryClient } from "@tanstack/react-query";
import { qk } from "./queryKeys";
/** Everything a subscribe or an unsubscribe changes: the channel itself, the manager's list and its
* counts, the feed and its facets (the channel's videos come or go), and the two discovery lists (a
* subscribed channel leaves them). ONE set for every site that makes the change: each used to keep
* counts, the feed and its facets (the channel's videos come or go), the two discovery lists (a
* subscribed channel leaves them), and the tags (a tag counts the followed channels it is on). ONE set for every site that makes the change: each used to keep
* its own list, and each list missed something. */
export function invalidateSubscription(qc: QueryClient, channelId: string): void {
for (const queryKey of [
@@ -15,6 +15,7 @@ export function invalidateSubscription(qc: QueryClient, channelId: string): void
qk.feedSummary(),
qk.discoveredChannels(),
qk.watchedChannels(),
qk.tags(),
])
void qc.invalidateQueries({ queryKey });
}
+5 -1
View File
@@ -28,7 +28,11 @@ export function useChannelTagActions(opts: {
? api.detachChannelTag(channelId, tagId)
: api.attachChannelTag(channelId, tagId);
call
.then(() => opts.onSuccess?.(channelId))
.then(() => {
// A tag counts the followed channels it is on: the toggle changed that number.
void qc.invalidateQueries({ queryKey: qk.tags() });
opts.onSuccess?.(channelId);
})
.catch((err: unknown) => {
notifyActionError(err, msg("channels.tags.updateFailed"));
opts.onError(channelId);
+1
View File
@@ -25,6 +25,7 @@ const tag = (id: number, name: string, system: boolean): Tag => ({
color: null,
category: "topic",
channel_count: 1,
subscribed_count: 1,
});
function wrapper({ children }: { children: ReactNode }) {
@@ -11,7 +11,8 @@
// The fit hook is mocked so `visible` is drivable: in jsdom every box measures 0, so the real
// measurement can only ever produce one answer.
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import { useState } from "react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
@@ -37,7 +38,7 @@ const chips = [chip("alpha"), chip("beta"), chip("gamma")];
// The surface mounts with `visibility: hidden` until floating-ui has positioned it, which never
// happens in jsdom (every box measures 0) — so it is out of the accessibility tree and only a
// `hidden: true` query can see it. Presence is the whole question here, not visibility.
const menu = () => screen.queryByRole("menu", { hidden: true });
const menu = () => screen.queryByRole("dialog", { hidden: true });
const moreBtn = () => screen.queryByRole("button", { name: /channels.tags.moreCount/ });
afterEach(() => {
@@ -100,3 +101,114 @@ describe("TagFilterChips — the overflow menu", () => {
expect(menu()).toBeNull();
});
});
describe("TagFilterChips counts", () => {
it("draws a chip's count after its name, and none when it has none", () => {
fit.visible = 2;
render(
<TagFilterChips
chips={[{ ...chip("alpha"), count: 42 }, { ...chip("beta"), count: 0 }, chip("gamma")]}
/>,
);
const name = (label: string) =>
screen.getAllByRole("button").find((b) => b.textContent?.startsWith(label));
expect(name("alpha")?.textContent).toBe("alpha · 42");
expect(name("beta")?.textContent).toBe("beta · 0");
// In the chip's own colour: a dimmed count falls under the text contrast floor.
expect(name("alpha")?.querySelector('[class*="opacity"]')).toBeNull();
});
it("draws nothing after the name of a chip that has no count", () => {
fit.visible = 3;
render(<TagFilterChips chips={[{ ...chip("alpha"), count: 42 }, chip("gamma")]} />);
const gamma = screen.getAllByRole("button").find((b) => b.textContent?.startsWith("gamma"));
expect(gamma?.textContent).toBe("gamma");
expect(gamma?.querySelector("span")).toBeNull();
});
});
// The channel manager puts the picked chips first, so a toggle moves its chip: out of the "+N" fold,
// into it, or out of the list. The keyboard's focus goes with the chip, not to <body>.
describe("TagFilterChips focus on a toggle that moves the chip", () => {
function Row({ start }: { start: string[] }) {
const [picked, setPicked] = useState(start);
const all = ["alpha", "beta", "gamma"].map((k) => ({
...chip(k),
active: picked.includes(k),
onToggle: () => setPicked((p) => (p.includes(k) ? p.filter((x) => x !== k) : [...p, k])),
}));
return (
<TagFilterChips chips={[...all.filter((c) => c.active), ...all.filter((c) => !c.active)]} />
);
}
const press = (el: HTMLElement) => {
el.focus();
fireEvent.click(el);
};
const named = (label: string) =>
screen.getAllByRole("button", { hidden: true }).find((b) => b.textContent === label)!;
// Along the row needs no test of its own: a keyed move within one parent keeps its node, and
// React gives the focus back to it after the commit. Only a remount (row ↔ fold) or a chip that
// leaves the list loses it.
it("out of the fold: picked in the popover, it is focused on the row", async () => {
fit.visible = 1;
render(<Row start={[]} />);
fireEvent.click(moreBtn()!);
const inFold = named("beta");
press(inFold);
// The closing popover hands the focus back to "+N" a microtask later: past that too.
await act(async () => {});
const onRow = named("beta");
expect(onRow).not.toBe(inFold);
expect(document.activeElement).toBe(onRow);
// …and the fold is shut: the focus is not left outside a popover still open.
expect(menu()).toBeNull();
expect(moreBtn()).toHaveProperty("ariaExpanded", "false");
});
// A stored pick of a tag that no longer exists is an active chip that an un-pick removes for good.
function WithGhost() {
const [ghost, setGhost] = useState(true);
const rest = ["alpha", "beta"].map(chip);
return (
<TagFilterChips
chips={
ghost
? [{ ...chip("ghost"), active: true, onToggle: () => setGhost(false) }, ...rest]
: rest
}
/>
);
}
it.each([
["with room for all", 3],
["with a fold", 1],
])("a chip that leaves the list hands the focus to the one now in its place (%s)", (_, n) => {
fit.visible = n;
render(<WithGhost />);
press(named("ghost"));
expect(document.activeElement).toBe(named("alpha"));
});
it("into a closed fold: the focus goes to the +N that now holds it", () => {
fit.visible = 1;
render(<Row start={["gamma"]} />);
press(named("gamma"));
expect(named("alpha").closest('[role="dialog"]')).toBeNull(); // alpha leads the row now
expect(document.activeElement).toBe(moreBtn());
});
it("a mouse press that leaves the focus elsewhere takes nothing", () => {
fit.visible = 3;
render(<Row start={[]} />);
const outside = document.body.appendChild(document.createElement("input"));
outside.focus();
fireEvent.click(named("gamma"));
act(() => {});
expect(document.activeElement).toBe(outside);
outside.remove();
});
});
@@ -1,4 +1,4 @@
import { useRef, useState } from "react";
import { useLayoutEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { fitChipsWithPill } from "../../lib/chipOverflow";
import { chipLaneClass, useChipOverflowFit } from "../../lib/useChipOverflowFit";
@@ -19,6 +19,8 @@ import { ChipLabelPending } from "../../components/ui/Skeleton";
export interface FilterChip {
key: string;
label: string;
/** How many it holds, drawn muted after the name. */
count?: number;
/** The name is still on its way: the chip draws a placeholder, not `label` (a raw id). */
pending?: boolean;
active: boolean;
@@ -28,12 +30,40 @@ export interface FilterChip {
// A chip's text — the visible chip and the hidden measuring mirror both draw it, so the mirror
// measures what shows (ActiveFilterChips' ChipText, for this row's chips).
function ChipText({ chip }: { chip: FilterChip }) {
return chip.pending ? <ChipLabelPending value={chip.label} /> : <>{chip.label}</>;
if (chip.pending) return <ChipLabelPending value={chip.label} />;
return (
<>
{chip.label}
{/* The count in the chip's own colour, set off by a dot rather than dimmed: a dimmed count
falls under the text contrast floor, on a resting chip and on a pressed one. */}
{chip.count != null && (
<>
{" "}
<span aria-hidden="true">·</span> <span className="tabular-nums">{chip.count}</span>
</>
)}
</>
);
}
function TagChip({ chip }: { chip: FilterChip }) {
function TagChip({
chip,
onToggled,
}: {
chip: FilterChip;
onToggled: (key: string, from: HTMLElement) => void;
}) {
return (
<Chip onClick={chip.onToggle} pressed={chip.active}>
<Chip
data-chip-key={chip.key}
onClick={(e) => {
// Before the toggle: a pressed chip may move (the caller's order can put picks first), and
// the focus must go where it went.
if (e.currentTarget === document.activeElement) onToggled(chip.key, e.currentTarget);
chip.onToggle();
}}
pressed={chip.active}
>
<ChipText chip={chip} />
</Chip>
);
@@ -53,7 +83,7 @@ export default function TagFilterChips({
// Re-fit when the set, the labels, OR the pressed state change (the pressed paint can change a chip's
// width via the `.pill` recipe, so an aria-pressed flip must re-measure too).
const key = chips
.map((c) => `${c.key}:${c.label}:${c.active ? 1 : 0}${c.pending ? "…" : ""}`)
.map((c) => `${c.key}:${c.label}:${c.count ?? ""}:${c.active ? 1 : 0}${c.pending ? "…" : ""}`)
.join("|");
const visible = useChipOverflowFit<number>({
@@ -90,12 +120,54 @@ export default function TagFilterChips({
const pop = useAnchoredPopover({
open,
onOpenChange: setOpen,
mode: "menu",
// A fold of toggle buttons, not a menu of items: each chip is a pressed/unpressed button in the
// Tab order, which a role=menu with no menu items would hide from a screen reader.
mode: "dialog",
role: "dialog",
placement: "bottom-end",
});
const { setReference } = pop.refs;
const moreLabel = t("channels.tags.moreCount", { count: hidden });
// A toggle may move its chip between the row and the "+N" fold (two parents: the button is
// remounted and the focus falls to <body>), or take it out of the list altogether (a stored pick of
// a tag that no longer exists). A move along the row keeps its node, and React gives it the focus
// back. The focus follows the chip: onto it where it now stands, onto "+N" when it went into a
// closed fold, or onto the chip now in its old place when it is gone. A toggle in the open fold
// closes it, so the focus never sits outside a popover left open. Held for the frame, since the fit
// re-decides in layout effects after the toggle's render.
const follow = useRef<{ key: string; at: number; closed: boolean } | null>(null);
const foldRef = useRef<HTMLDivElement | null>(null);
const moreRef = useRef<HTMLButtonElement | null>(null);
const onToggled = (chipKey: string, from: HTMLElement) => {
const closed = !!foldRef.current?.contains(from);
follow.current = { key: chipKey, at: chips.findIndex((c) => c.key === chipKey), closed };
requestAnimationFrame(() => (follow.current = null));
if (closed) setOpen(false);
};
useLayoutEffect(() => {
const target = follow.current;
if (!target) return;
const inBox = (box: HTMLElement | null) => [
...(box?.querySelectorAll<HTMLElement>("[data-chip-key]") ?? []),
];
const row = inBox(rowRef.current);
const find = (box: HTMLElement[]) => box.find((b) => b.dataset.chipKey === target.key);
const gone = !chips.some((c) => c.key === target.key);
const el =
find(row) ??
find(inBox(foldRef.current)) ??
(gone ? (row[Math.min(target.at, row.length - 1)] ?? moreRef.current) : moreRef.current);
if (el && document.activeElement !== el) el.focus();
// The closing popover gives the focus back to "+N" in a microtask it queued as it unmounted
// (seen in Chrome): this one, queued after it, has the last word.
if (el && target.closed) queueMicrotask(() => el.isConnected && el.focus());
});
const setMore = (el: HTMLButtonElement | null) => {
moreRef.current = el;
setReference(el);
};
return (
<div className={`${chipLaneClass} flex items-center gap-1.5 min-w-0 ${className ?? ""}`}>
{/* Hidden measurer: all chips + a sample "+N" pill at natural widths, so the shared fit core can
@@ -124,10 +196,10 @@ export default function TagFilterChips({
<div ref={rowRef} className="flex items-center gap-1.5 min-w-0 flex-1 overflow-hidden">
{chips.slice(0, visible).map((c) => (
<TagChip key={c.key} chip={c} />
<TagChip key={c.key} chip={c} onToggled={onToggled} />
))}
{hidden > 0 && (
<Chip ref={setReference} aria-label={moreLabel} {...pop.getReferenceProps()}>
<Chip ref={setMore} aria-label={moreLabel} {...pop.getReferenceProps()}>
+{hidden}
</Chip>
)}
@@ -138,9 +210,11 @@ export default function TagFilterChips({
ariaLabel={moreLabel}
className={`${SURFACE.popover} flex max-w-[22rem] flex-wrap items-center gap-1.5 p-2 animate-[popIn_0.16s_ease]`}
>
{chips.slice(visible).map((c) => (
<TagChip key={c.key} chip={c} />
))}
<div ref={foldRef} className="contents">
{chips.slice(visible).map((c) => (
<TagChip key={c.key} chip={c} onToggled={onToggled} />
))}
</div>
</PopoverSurface>
</div>
);
@@ -186,3 +186,58 @@ describe("buildChannelColumns — Added", () => {
expect(screen.getByText(formatDate("2026-10-12T08:00:00+00:00", "en"))).toBeTruthy();
});
});
describe("tagFilterChips", () => {
// An unsubscribe keeps a channel's tag links: the chip counts the followed ones, the rows the
// filter shows, not every link the tag has.
it("counts a tag's followed channels, and only a tag's", () => {
const tag = {
id: 3,
name: "Tech",
color: null,
category: "topic",
system: false,
channel_count: 17,
subscribed_count: 15,
};
const chips = tagFilterChips({
userTags: [tag],
tags: { isPending: false } as never,
picked: ["99"],
noTagOn: false,
toggle: () => {},
toggleNoTag: () => {},
t: ((k: string) => k) as never,
});
expect(chips.find((c) => c.label === "Tech")?.count).toBe(15);
expect(chips.find((c) => c.key === "t99")?.count).toBeUndefined();
expect(chips[chips.length - 1]?.count).toBeUndefined();
});
// The row folds from its end into "+N", so a picked tag late in the list was folded away: at 1024 px
// "music" sat behind "+11" with nothing on the row saying a filter was on. The picks lead the row.
it("puts the picked tags first, keeping their order, then the rest in theirs", () => {
const tag = (id: number, name: string) => ({ id, name, system: false }) as Tag;
const row = (picked: string[], noTagOn = false) =>
tagFilterChips({
userTags: [tag(1, "ai"), tag(2, "docu"), tag(3, "music"), tag(4, "nature")],
tags: { isPending: false } as never,
picked,
noTagOn,
toggle: () => {},
toggleNoTag: () => {},
t: ((k: string) => k) as never,
}).map((c) => c.label + (c.active ? "*" : ""));
expect(row(["4", "3"])).toEqual(["music*", "nature*", "ai", "docu", "channels.tags.noTag"]);
expect(row(["99", "3"])).toEqual([
"99*",
"music*",
"ai",
"docu",
"nature",
"channels.tags.noTag",
]);
expect(row([], true)).toEqual(["channels.tags.noTag*", "ai", "docu", "music", "nature"]);
expect(row([])).toEqual(["ai", "docu", "music", "nature", "channels.tags.noTag"]);
});
});
@@ -51,11 +51,12 @@ export function tagsColumnTest(c: ManagedChannel, values: string[]): boolean {
}
/** The manager's tag-filter chip row (the Tags column filter's values): any picked id that is not one
* of the user's tags, then each user tag, then the "No tag" sentinel. A stored pick can outlive its
* of the user's tags, then the picked ones of the user's tags and "No tag", then the rest, each group
* in the tags' order with "No tag" last. The row folds from its end into "+N", so every pick leads it:
* a filter that is on stays in sight, never only inside the fold. A stored pick can outlive its
* tag (deleted here or on another device) and still narrows the table to nothing, so it stays a
* visible, removable chip — FIRST, so the row's "+N" overflow never folds the reason away —
* and so does every pick while the tag list loads or after it failed, named by lib/tagPicks' rule
* (a placeholder, then the name, "Deleted tag" or the id), not hidden. */
* visible, removable chip, and so does every pick while the tag list loads or after it failed, named
* by lib/tagPicks' rule (a placeholder, then the name, "Deleted tag" or the id), not hidden. */
export function tagFilterChips(o: {
userTags: Tag[];
tags: TagList;
@@ -67,17 +68,11 @@ export function tagFilterChips(o: {
}): FilterChip[] {
const mine = new Set(o.userTags.map((tg) => String(tg.id)));
const others = o.picked.filter((id) => id !== NO_TAG_SENTINEL && !mine.has(id));
return [
...others.map((id) => ({
key: `t${id}`,
label: tagPickLabel(Number(id), o.tags, o.t),
pending: o.tags.isPending,
active: true,
onToggle: () => o.toggle(Number(id)),
})),
const known: FilterChip[] = [
...o.userTags.map((tg) => ({
key: `t${tg.id}`,
label: tg.name,
count: tg.subscribed_count,
active: o.picked.includes(String(tg.id)),
onToggle: () => o.toggle(tg.id),
})),
@@ -88,6 +83,17 @@ export function tagFilterChips(o: {
onToggle: o.toggleNoTag,
},
];
return [
...others.map((id) => ({
key: `t${id}`,
label: tagPickLabel(Number(id), o.tags, o.t),
pending: o.tags.isPending,
active: true,
onToggle: () => o.toggle(Number(id)),
})),
...known.filter((c) => c.active),
...known.filter((c) => !c.active),
];
}
/** Everything the Channels-manager column set closes over. The manager owns the state + mutations;
@@ -50,8 +50,10 @@ export function useChannelActions({
const nameOf = (c: ManagedChannel) =>
c.title == null ? msg("channels.row.thisChannel") : raw(c.title);
// A hide also changes the tags' counts: a tag counts only the channels the Subscriptions tab shows.
const refresh = () => {
qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: qk.tags() });
qc.invalidateQueries({ queryKey: qk.myStatus() });
qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: qk.feedSummary() });
@@ -36,6 +36,7 @@ const tag = (id: number, name: string): Tag => ({
category: "other",
system: false,
channel_count: 0,
subscribed_count: 0,
});
const docu = tag(1, "docu");
const history = tag(2, "history");
@@ -194,6 +194,8 @@ export default function ChannelPage({
qc.invalidateQueries({ queryKey: qk.channel(channelId) });
qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: qk.channels() });
// A tag counts only the channels the Subscriptions tab shows, which a hide leaves out.
qc.invalidateQueries({ queryKey: qk.tags() });
},
});
+3 -2
View File
@@ -6,6 +6,7 @@ import { navItemAttrs } from "../../lib/listNav";
import { JobThumb } from "../../features/downloads/downloadRow";
import { jobTitle } from "../../features/downloads/downloadTitle";
import { PhoneBadge, RetentionBadge, SourceBadge } from "./fileBadges";
import { fileMeta } from "./fileMeta";
import { MediaCard, MediaPlayGlyph } from "../../components/ui/MediaCard";
import {
MEDIA_BADGE,
@@ -46,7 +47,7 @@ export function FileCard({
}) {
const { t } = useTranslation();
const title = jobTitle(file);
const channel = file.display_uploader || file.asset?.uploader || null;
const meta = fileMeta(file);
const clip = file.job_kind === "edit" || file.job_kind === "clip";
const media = file.asset?.media_kind !== "file";
const thumb = <JobThumb job={file} box="h-full w-full" />;
@@ -102,7 +103,7 @@ export function FileCard({
<span className="flex flex-col gap-1">
<span className="flex min-w-0 items-center gap-1.5 text-xs text-muted">
<SourceBadge file={file} />
{channel && <span className="truncate">{channel}</span>}
{meta && <span className="truncate">{meta}</span>}
</span>
<span className="flex flex-wrap items-center gap-1 empty:hidden">
<PhoneBadge file={file} />
+2 -2
View File
@@ -41,6 +41,7 @@ import { jobTitle } from "../../features/downloads/downloadTitle";
import { FileCard } from "./FileCard";
import { PhoneBadge, RetentionBadge, SourceBadge } from "./fileBadges";
import { folderLabel, takesUploads } from "../../features/folders/fileFolders";
import { fileMeta } from "./fileMeta";
import { ALL_FILES, useFiles, useFilesActions, viewParam, type FilesView } from "./filesContext";
import { useFileActions } from "./useFileActions";
import { useFolderActions } from "./useFolderActions";
@@ -157,7 +158,6 @@ function NameCell({
onRenamed: (name: string | null, byKey: boolean) => void;
}) {
const { t } = useTranslation();
const channel = file.display_uploader || file.asset?.uploader || null;
const clip = file.job_kind === "edit" || file.job_kind === "clip";
if (renaming)
return (
@@ -180,7 +180,7 @@ function NameCell({
<span title={jobTitle(file)}>{title}</span>
)}
<span className="flex min-w-0 items-center gap-1.5 text-xs text-muted">
<span className="truncate">{channel ?? "—"}</span>
<span className="truncate">{fileMeta(file) || "—"}</span>
{clip && (
<Badge>
<Scissors />
@@ -24,7 +24,9 @@ vi.mock("../../components/ui/Modal", () => ({
),
}));
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => h.confirm }));
vi.mock("../../lib/clipboard", () => ({ copyText: h.copyText }));
const h = vi.hoisted(() => ({
copyText: vi.fn(async (_t: string) => true),
confirm: false,
revokeDownloadLink: vi.fn(),
links: null as unknown[] | null,
@@ -72,6 +74,9 @@ afterEach(cleanup);
beforeEach(() => {
h.links = null;
h.confirm = false;
h.createDownloadLink.mockReset();
h.copyText.mockReset();
h.copyText.mockResolvedValue(true);
});
const iconClass = (button: HTMLElement) => button.querySelector("svg")?.getAttribute("class") ?? "";
@@ -96,6 +101,157 @@ describe("ShareDialog · a link row's state colours", () => {
});
});
describe("ShareDialog · opened to share", () => {
const open = () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<ShareDialog job={{ id: 9 } as DownloadJob} onClose={() => {}} />
</QueryClientProvider>,
);
};
const row = (id: number, url: string | null, expires_at: string | null = null) => ({
id,
token: "t",
url,
allow_download: true,
has_password: false,
expires_at,
view_count: 0,
created_at: null,
});
const origin = window.location.origin;
const status = () => screen.getByRole("status").textContent;
const lastWeek = new Date(Date.now() - 7 * 86400_000).toISOString();
const nextWeek = new Date(Date.now() + 7 * 86400_000).toISOString();
it("copies the newest link it can show, and says so", async () => {
h.links = [row(1, null), row(2, "/file/b"), row(3, "/file/c")];
open();
await waitFor(() => expect(h.copyText).toHaveBeenCalledWith(`${origin}/file/b`));
expect(h.copyText).toHaveBeenCalledTimes(1);
await waitFor(() => expect(status()).toBe("downloads.share.linkCopied"));
});
// The list keeps an expired link for its owner to see and revoke; the recipient of one lands on
// "link gone".
it("copies the newest link that still opens, and says an expired one has expired", async () => {
h.links = [row(1, "/file/old", lastWeek), row(2, "/file/b", nextWeek)];
open();
await waitFor(() => expect(h.copyText).toHaveBeenCalledWith(`${origin}/file/b`));
expect(h.copyText).toHaveBeenCalledTimes(1);
expect(screen.getByText("downloads.share.expiredOn", { exact: false })).toBeTruthy();
expect(screen.getByText("downloads.share.expiresOn", { exact: false })).toBeTruthy();
});
it("makes a link when every one it has expired, and copies that", async () => {
h.links = [row(1, "/file/old", lastWeek)];
h.createDownloadLink.mockResolvedValue(row(4, "/file/new"));
open();
await waitFor(() => expect(h.copyText).toHaveBeenCalledWith(`${origin}/file/new`));
expect(h.copyText).toHaveBeenCalledTimes(1);
});
// An expired link opens nothing: it is no link to hand out, and reads as dead at a glance.
it("offers no Copy on an expired link, and marks it with a warning", async () => {
h.links = [row(1, "/file/old", lastWeek), row(2, "/file/b", nextWeek)];
open();
await waitFor(() => expect(h.copyText).toHaveBeenCalledWith(`${origin}/file/b`));
expect(screen.getAllByRole("button", { name: "downloads.share.copy" })).toHaveLength(1);
expect(screen.getByText("downloads.share.expiredOn", { exact: false }).className).toContain(
"text-warn",
);
expect(screen.getAllByRole("link", { name: "downloads.share.openNewTab" })).toHaveLength(2);
});
// Closed before the link it made came back, the dialog must not open next time on the list it
// had and make another.
it("makes one link, closed before it came back and opened again", async () => {
h.links = [];
let made: (l: unknown) => void = () => {};
h.createDownloadLink.mockReturnValue(new Promise((res) => (made = res)));
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const dialog = () => (
<QueryClientProvider client={qc}>
<ShareDialog job={{ id: 9 } as DownloadJob} onClose={() => {}} />
</QueryClientProvider>
);
const first = render(dialog());
await waitFor(() => expect(h.createDownloadLink).toHaveBeenCalledTimes(1));
first.unmount();
made(row(4, "/file/new"));
await new Promise((r) => setTimeout(r, 20));
h.links = [row(4, "/file/new")];
render(dialog());
await screen.findByDisplayValue(`${origin}/file/new`);
await new Promise((r) => setTimeout(r, 20));
expect(h.createDownloadLink).toHaveBeenCalledTimes(1);
});
it("leaves a New link form the user opened while the first link was on its way", async () => {
h.links = [];
let made: (l: unknown) => void = () => {};
h.createDownloadLink.mockReturnValue(new Promise((res) => (made = res)));
open();
fireEvent.click(await screen.findByText("downloads.share.newLink"));
fireEvent.change(screen.getByPlaceholderText("downloads.share.password"), {
target: { value: "s3cret" },
});
h.links = [row(4, "/file/new")];
made(row(4, "/file/new"));
await screen.findByDisplayValue(`${origin}/file/new`);
expect(screen.getByPlaceholderText<HTMLInputElement>("downloads.share.password").value).toBe(
"s3cret",
);
});
// The clipboard holds what was copied last: the link copied on opening stops saying so.
it("stops claiming the opening copy once another link is copied", async () => {
h.links = [row(2, "/file/b"), row(3, "/file/c")];
open();
await waitFor(() => expect(status()).toBe("downloads.share.linkCopied"));
expect(screen.getAllByText("downloads.share.linkCopied")).toHaveLength(2); // the row + the region
fireEvent.click(screen.getAllByRole("button", { name: "downloads.share.copy" })[1]!);
await waitFor(() => expect(h.copyText).toHaveBeenLastCalledWith(`${origin}/file/c`));
expect(status()).toBe("");
expect(screen.queryByText("downloads.share.linkCopied")).toBeNull();
});
it("copies the link it made when there was none", async () => {
h.links = [];
h.createDownloadLink.mockResolvedValue(row(4, "/file/new"));
open();
await waitFor(() => expect(h.copyText).toHaveBeenCalledWith(`${origin}/file/new`));
});
it("claims nothing when the browser refused the copy", async () => {
h.links = [row(2, "/file/b")];
h.copyText.mockResolvedValue(false);
open();
await waitFor(() => expect(h.copyText).toHaveBeenCalled());
await screen.findByRole("button", { name: "downloads.share.copy" });
expect(status()).toBe("");
expect(screen.queryByText("downloads.share.linkCopied")).toBeNull();
});
it("offers the link in a new tab, beside Copy", async () => {
h.links = [row(2, "/file/b")];
open();
const openLink = await screen.findByRole("link", { name: "downloads.share.openNewTab" });
expect(openLink.getAttribute("href")).toBe(`${origin}/file/b`);
expect(openLink.getAttribute("target")).toBe("_blank");
expect(openLink.getAttribute("rel")).toContain("noopener");
});
it("has no Open for a link whose address cannot be shown", async () => {
h.links = [row(1, null)];
open();
await screen.findByText("downloads.share.urlHidden");
expect(screen.queryByRole("link", { name: "downloads.share.openNewTab" })).toBeNull();
expect(h.copyText).not.toHaveBeenCalled();
});
});
describe("ShareDialog · a link whose address cannot be shown again", () => {
it("says so in place of the address, with nothing to copy", async () => {
const link = (id: number, url: string | null) => ({
+104 -18
View File
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { qk } from "../../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Copy, Link2, Lock, Trash2, UserPlus, Eye } from "lucide-react";
import { Check, Copy, ExternalLink, Link2, Lock, Trash2, UserPlus, Eye } from "lucide-react";
import Modal from "../../components/ui/Modal";
import { useConfirm } from "../../components/ui/confirmContext";
import { msg, notify, raw } from "../../lib/notifications";
@@ -12,15 +12,20 @@ import { api, type DownloadJob, type ShareLink, type ShareRecipient } from "../.
import {
fetchLinks,
invalidateLinks,
linkExpired,
linksKey,
liveLink,
makeLink,
type LinkTarget,
} from "../../lib/downloads";
import { Button } from "../../components/ui/Button";
import { IconButton } from "../../components/ui/IconButton";
import { IconLink } from "../../components/ui/LinkButton";
import { copyText } from "../../lib/clipboard";
import { Input, InnerInput } from "../../components/ui/Input";
import { CheckRow } from "../../components/ui/NativeControls";
import { SURFACE } from "../../components/ui/surfaces";
import { Badge } from "../../components/ui/Badge";
import UserPicker from "./UserPicker";
import { useCopyFeedback } from "../../components/ui/useCopyFeedback";
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
@@ -75,7 +80,19 @@ function UserShare({ job }: { job: DownloadJob }) {
// --- share by public link -------------------------------------------------------------------
function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }) {
function LinkRow({
link,
copiedOnOpen,
onCopied,
onChanged,
}: {
link: ShareLink;
/** The dialog put this link on the clipboard when it opened, and nothing was copied since. */
copiedOnOpen: boolean;
/** Its Copy was pressed: the clipboard holds this link now, not the one the dialog copied. */
onCopied: () => void;
onChanged: () => void;
}) {
const { t, i18n } = useTranslation();
const confirm = useConfirm();
const { copied, copy: copyValue } = useCopyFeedback();
@@ -84,7 +101,9 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
const fullUrl = link.url && window.location.origin + link.url;
const copy = () => {
if (fullUrl) void copyValue(fullUrl);
if (!fullUrl) return;
onCopied();
void copyValue(fullUrl);
};
const toggle = useMutation({
mutationFn: () => api.updateDownloadLink(link.id, { allow_download: !link.allow_download }),
@@ -108,10 +127,13 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
},
});
// An expired link opens nothing, so it is no link to hand out: no Copy, and it reads as dead at a
// glance (a warn badge, its address struck through). Open stays, to check it; Revoke, to clear it.
const expired = linkExpired(link);
const badges: string[] = [];
if (link.has_password) badges.push(t("downloads.share.hasPassword"));
if (link.allow_download) badges.push(t("downloads.share.downloadable"));
if (link.expires_at)
if (link.expires_at && !expired)
badges.push(
t("downloads.share.expiresOn", { date: formatDate(link.expires_at, i18n.language) }),
);
@@ -122,10 +144,33 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
<Link2 className="w-4 h-4 text-muted shrink-0" />
{fullUrl ? (
<>
<InnerInput className="text-xs text-muted truncate" readOnly value={fullUrl} />
<IconButton label={t("downloads.share.copy")} size="sm" variant="ghost" onClick={copy}>
{copied ? <Check className="w-4 h-4 text-success" /> : <Copy className="w-4 h-4" />}
</IconButton>
<InnerInput
className={`text-xs text-muted truncate ${expired ? "line-through" : ""}`}
readOnly
value={fullUrl}
/>
{!expired && (
<IconButton
label={t("downloads.share.copy")}
size="sm"
variant="ghost"
onClick={copy}
>
{copied || copiedOnOpen ? (
<Check className="w-4 h-4 text-success" />
) : (
<Copy className="w-4 h-4" />
)}
</IconButton>
)}
<IconLink
label={t("downloads.share.openNewTab")}
href={fullUrl}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="w-4 h-4" />
</IconLink>
</>
) : (
// The server keeps no copy of this link's address it can read: the link still opens for
@@ -175,10 +220,18 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
<Trash2 className="w-4 h-4 text-danger" />
</IconButton>
</div>
{/* Seen here, heard from the dialog's own live region (`LinkShare`): one mounted already
filled may never be announced. */}
{copiedOnOpen && <p className="text-xs text-success">{t("downloads.share.linkCopied")}</p>}
<div className="flex items-center gap-2 text-xs text-muted flex-wrap">
<span className="inline-flex items-center gap-1">
<Eye className="w-3 h-3" /> {t("downloads.share.views", { count: link.view_count })}
</span>
{expired && link.expires_at && (
<Badge tone="warn">
{t("downloads.share.expiredOn", { date: formatDate(link.expires_at, i18n.language) })}
</Badge>
)}
{badges.map((b) => (
<span
key={b}
@@ -243,9 +296,18 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
);
}
/** Puts a link on the clipboard for the dialog that just opened, and answers whether it got there:
* a browser may refuse a copy no click asked for, and then nothing may claim it. */
async function copiedLink(link: ShareLink | undefined): Promise<number | null> {
if (!link?.url) return null;
return (await copyText(window.location.origin + link.url)) ? link.id : null;
}
function LinkShare({ target }: { target: LinkTarget }) {
const { t } = useTranslation();
const qc = useQueryClient();
// The link the dialog copied when it opened: sharing is almost always why it was opened.
const [copiedOnOpen, setCopiedOnOpen] = useState<number | null>(null);
const links = useQuery({
queryKey: linksKey(target),
queryFn: () => fetchLinks(target),
@@ -265,19 +327,29 @@ function LinkShare({ target }: { target: LinkTarget }) {
expires_days: expiryDays,
password: password.trim() || undefined,
}),
onSuccess: () => {
// In the list at once, as the one-click Share puts it: closed before the refetch, the dialog
// would open next time on the cached list without it and make another.
onSuccess: (made) => {
qc.setQueryData<ShareLink[]>(linksKey(target), (old) => [made, ...(old ?? [])]);
invalidate();
setCreating(false);
// Back to the form's own default for the next link: downloadable.
setAllowDownload(true);
setExpiryDays(null);
setPassword("");
},
meta: { failTitle: "downloads.share.linkFailed" },
});
// The form's own success, not the mutation's: the link made on open goes through the same
// mutation, and must not close and clear a form the user opened while it was on its way.
const createFromForm = () =>
create.mutate(undefined, {
onSuccess: () => {
setCreating(false);
// Back to the form's own default for the next link: downloadable.
setAllowDownload(true);
setExpiryDays(null);
setPassword("");
},
});
// Open with a link already generated: sharing is almost always the goal, so mint a default
// (no password, no expiry, downloadable) link the moment the dialog finds none — as if the
// (no password, no expiry, downloadable) link the moment the dialog finds none that opens — as if the
// user had pressed "New link" → "Create". All further controls (password, download toggle, expiry,
// extra links, revoke) stay available on the created link and via the "New link" button.
const autoTried = useRef(false);
@@ -290,11 +362,19 @@ function LinkShare({ target }: { target: LinkTarget }) {
useEffect(() => {
if (!links.data || autoTried.current) return;
autoTried.current = true;
if (links.data.length === 0) createLink();
// The link to hand out is one that still opens (`liveLink`, as the one-click Share picks it):
// with only expired ones, or none whose address can be shown, a new one is made as for none.
const live = liveLink(links.data);
if (!live)
createLink(undefined, { onSuccess: (made) => void copiedLink(made).then(setCopiedOnOpen) });
else void copiedLink(live).then(setCopiedOnOpen);
}, [links.data, createLink]);
return (
<div>
<p role="status" className="sr-only">
{copiedOnOpen !== null ? t("downloads.share.linkCopied") : ""}
</p>
<div className="flex items-center gap-2 text-sm font-medium mb-1">
<Link2 className="w-4 h-4 text-muted" /> {t("downloads.share.linkSection")}
</div>
@@ -317,7 +397,13 @@ function LinkShare({ target }: { target: LinkTarget }) {
) : (
<div className="space-y-2">
{links.data.map((l) => (
<LinkRow key={l.id} link={l} onChanged={invalidate} />
<LinkRow
key={l.id}
link={l}
copiedOnOpen={copiedOnOpen === l.id}
onCopied={() => setCopiedOnOpen(null)}
onChanged={invalidate}
/>
))}
{links.data.length === 0 && !creating && !create.isPending && (
<p className="text-xs text-muted">{t("downloads.share.noLinks")}</p>
@@ -361,7 +447,7 @@ function LinkShare({ target }: { target: LinkTarget }) {
<Button
variant="primary"
size="sm"
onClick={() => create.mutate()}
onClick={createFromForm}
disabled={create.isPending}
>
{t("downloads.share.createLink")}
@@ -19,6 +19,8 @@ const h = vi.hoisted(() => ({
uploadStarted: vi.fn(),
uploadEnded: vi.fn(),
openFile: vi.fn(),
fileFolders: vi.fn(),
createFolder: vi.fn(),
}));
vi.mock("react-i18next", async (importOriginal) => {
@@ -29,7 +31,12 @@ vi.mock("react-i18next", async (importOriginal) => {
};
});
vi.mock("../../lib/api", () => ({
api: { uploadFile: h.uploadFile, filesUsage: h.filesUsage },
api: {
uploadFile: h.uploadFile,
filesUsage: h.filesUsage,
fileFolders: h.fileFolders,
createFolder: h.createFolder,
},
}));
vi.mock("../../lib/downloads", () => ({ invalidateDownloads: vi.fn() }));
// The rest of the module stays real: the dialog is a ui/Modal, which hosts its own toasts.
@@ -47,12 +54,29 @@ import UploadDialog from "./UploadDialog";
import { FilesActionsContext } from "./filesContext";
import { msg, type NotifText } from "../../lib/notifications";
const folder = (id: number, kind: string, source_key: string | null, name: string | null) => ({
id,
kind,
source_key,
name,
retention_days: null,
count: 0,
size_bytes: 0,
created_at: null,
has_link: false,
});
const FOLDERS = [
folder(1, "source", "upload", null),
folder(5, "own", null, "peat"),
folder(9, "source", "site:reddit.com", null),
];
const wrap = (
onClose = vi.fn(),
onClose: () => void = vi.fn(),
folderId: number | null = null,
initialFile: File | null = null,
) => {
const qc = new QueryClient();
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const files = {
setSearch: vi.fn(),
setView: vi.fn(),
@@ -78,9 +102,207 @@ beforeEach(() => {
h.notify.mockClear();
h.filesUsage.mockClear();
h.filesUsage.mockResolvedValue({ upload_max_bytes: 1_000_000 });
h.createFolder.mockReset();
h.fileFolders.mockReset();
h.fileFolders.mockResolvedValue(FOLDERS);
});
afterEach(cleanup);
describe("UploadDialog · where the file goes", () => {
const pick = () =>
fireEvent.change(fileInput(), { target: { files: [new File(["x"], "a.pdf")] } });
const target = () => screen.findByTestId("upload-target");
const sentTo = async () => {
fireEvent.click(screen.getByText("downloads.upload.submit"));
await waitFor(() => expect(h.uploadFile).toHaveBeenCalledTimes(1));
return (h.uploadFile.mock.calls[0]![1] as { folderId: number | null }).folderId;
};
it("says the folder it was opened on", async () => {
wrap(vi.fn(), 5);
expect((await target()).textContent).toContain("peat");
});
it("says Uploads when it was opened on no folder, and sends there", async () => {
wrap();
expect((await target()).textContent).toContain("files.source.upload");
pick();
expect(await sentTo()).toBeNull();
});
it("says Uploads before the user has a folder of that name yet", async () => {
h.fileFolders.mockResolvedValue([FOLDERS[1]]);
wrap();
expect((await target()).textContent).toContain("files.source.upload");
});
it("offers only the folders that take an upload", async () => {
wrap();
fireEvent.click(await target());
expect(await screen.findByTestId("upload-target-5")).toBeTruthy();
expect(screen.getByTestId("upload-target-1")).toBeTruthy();
expect(screen.queryByTestId("upload-target-9")).toBeNull(); // a site's folder claims an origin
});
it("sends to the folder picked", async () => {
wrap();
fireEvent.click(await target());
fireEvent.click(await screen.findByTestId("upload-target-5"));
pick();
expect(await sentTo()).toBe(5);
});
it("makes a new folder from the same list and sends there", async () => {
h.createFolder.mockResolvedValue({ ...FOLDERS[1], id: 7, name: "New one" });
wrap();
fireEvent.click(await target());
fireEvent.click(await screen.findByTestId("upload-target-new"));
const field = await screen.findByLabelText("files.folder.newName");
fireEvent.change(field, { target: { value: "New one" } });
fireEvent.keyDown(field, { key: "Enter" });
await waitFor(() => expect(h.createFolder).toHaveBeenCalledWith("New one"));
await waitFor(async () => expect((await target()).textContent).toContain("New one"));
pick();
expect(await sentTo()).toBe(7);
});
it("will not upload while a new folder is on its way, to the wrong place", async () => {
let made: (f: unknown) => void = () => {};
h.createFolder.mockReturnValue(new Promise((res) => (made = res)));
wrap();
pick();
fireEvent.click(await target());
fireEvent.click(await screen.findByTestId("upload-target-new"));
const field = await screen.findByLabelText("files.folder.newName");
const submit = screen.getByText("downloads.upload.submit").closest("button")!;
expect(submit.disabled || submit.getAttribute("aria-disabled") === "true").toBe(true);
fireEvent.change(field, { target: { value: "New one" } });
fireEvent.keyDown(field, { key: "Enter" });
await waitFor(() => expect(h.createFolder).toHaveBeenCalled());
fireEvent.click(submit);
expect(h.uploadFile).not.toHaveBeenCalled();
made({ ...FOLDERS[1], id: 7, name: "New one" });
await waitFor(() => expect(submit.disabled).toBe(false));
expect(await sentTo()).toBe(7);
});
// The name field saves on blur, and a press blurs it before the press acts: one that closes the
// dialog (Cancel, the ✕, the backdrop) makes no folder; one that leaves it open keeps the name.
const naming = async (onClose: () => void) => {
const r = wrap(onClose);
fireEvent.click(await target());
fireEvent.click(await screen.findByTestId("upload-target-new"));
const field = await screen.findByLabelText("files.folder.newName");
fireEvent.change(field, { target: { value: "Trips" } });
return { r, field };
};
const pressOn = (el: Element, field: HTMLElement, click = true) => {
fireEvent.pointerDown(el);
fireEvent.mouseDown(el);
fireEvent.blur(field);
fireEvent.pointerUp(el);
fireEvent.mouseUp(el);
if (click) fireEvent.click(el);
};
const settled = () => new Promise((r) => setTimeout(r, 20)); // a mutation calls its function a tick later
it.each([
["Cancel", () => screen.getByText("downloads.actions.cancel")],
["the close button", () => screen.getByRole("button", { name: "common.close" })],
["the backdrop", () => screen.getByRole("dialog")],
])("dismissing with %s while naming makes no folder", async (_n, dismiss) => {
let unmount = () => {};
const { r, field } = await naming(() => unmount());
unmount = r.unmount;
pressOn(dismiss(), field);
await settled();
expect(screen.queryByRole("dialog")).toBeNull();
expect(h.createFolder).not.toHaveBeenCalled();
});
// A right-button press on the backdrop blurs the field but closes nothing: the name stands.
it("a press that closes nothing makes the folder it left", async () => {
h.createFolder.mockResolvedValue({ ...FOLDERS[1], id: 7, name: "Trips" });
const { field } = await naming(vi.fn());
pressOn(screen.getByRole("dialog"), field, false);
await waitFor(() => expect(h.createFolder).toHaveBeenCalledWith("Trips"));
});
it("leaving the name for anything else in the dialog makes the folder", async () => {
h.createFolder.mockResolvedValue({ ...FOLDERS[1], id: 7, name: "Trips" });
const { field } = await naming(vi.fn());
pressOn(screen.getByText("downloads.upload.targetLabel"), field);
await waitFor(() => expect(h.createFolder).toHaveBeenCalledWith("Trips"));
});
it("leaving the name by Tab makes the folder at once", async () => {
h.createFolder.mockResolvedValue({ ...FOLDERS[1], id: 7, name: "Trips" });
const { field } = await naming(vi.fn());
fireEvent.blur(field);
await waitFor(() => expect(h.createFolder).toHaveBeenCalledWith("Trips"));
});
it("says a new folder is being made, then gives the keyboard back the list", async () => {
let made: (f: unknown) => void = () => {};
h.createFolder.mockReturnValue(new Promise((res) => (made = res)));
wrap();
fireEvent.click(await target());
fireEvent.click(await screen.findByTestId("upload-target-new"));
const field = await screen.findByLabelText("files.folder.newName");
fireEvent.change(field, { target: { value: "Trips" } });
fireEvent.keyDown(field, { key: "Enter" });
const making = await screen.findByText("downloads.upload.makingFolder");
// Said in a live region, and the keyboard is held on its place, not dropped to <body>.
expect(making.closest('[role="status"]')).toBeTruthy();
await waitFor(() => expect(document.activeElement).toBe(making.parentElement));
made({ ...FOLDERS[1], id: 7, name: "Trips" });
await waitFor(async () => expect(document.activeElement).toBe(await target()));
});
// A failed refetch keeps the list it had: the target the file goes to stays shown.
it("keeps the target when a later read of the folders fails", async () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<FilesActionsContext.Provider
value={{
setSearch: vi.fn(),
setView: vi.fn(),
openFile: h.openFile,
consumeFocus: vi.fn(),
}}
>
<UploadDialog folderId={5} initialFile={null} onClose={vi.fn()} />
</FilesActionsContext.Provider>
</QueryClientProvider>,
);
expect((await target()).textContent).toContain("peat");
h.fileFolders.mockRejectedValue(new Error("down"));
await qc.refetchQueries({ queryKey: ["file-folders"] }).catch(() => {});
await waitFor(() => expect(qc.getQueryState(["file-folders"])?.status).toBe("error"));
expect((await target()).textContent).toContain("peat");
expect(screen.queryByText("downloads.upload.targetFailed")).toBeNull();
});
it("an empty name makes no folder and leaves the target", async () => {
wrap(vi.fn(), 5);
fireEvent.click(await target());
fireEvent.click(await screen.findByTestId("upload-target-new"));
fireEvent.keyDown(await screen.findByLabelText("files.folder.newName"), { key: "Enter" });
expect((await target()).textContent).toContain("peat");
expect(h.createFolder).not.toHaveBeenCalled();
});
it("says when the folders could not be read, and offers Retry", async () => {
h.fileFolders.mockRejectedValue(new Error("down"));
wrap();
expect(await screen.findByText("downloads.upload.targetFailed")).toBeTruthy();
h.fileFolders.mockResolvedValue(FOLDERS);
fireEvent.click(screen.getByRole("button", { name: /retry/i }));
expect((await target()).textContent).toContain("files.source.upload");
});
});
describe("UploadDialog", () => {
it("shows the picked file's name and uploads it with the optional name", async () => {
const onClose = vi.fn();
+140 -5
View File
@@ -1,10 +1,10 @@
import { useEffect, useId, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { UploadCloud, File as FileIcon } from "lucide-react";
import Modal, { ModalFooter } from "../../components/ui/Modal";
import { Button } from "../../components/ui/Button";
import { api } from "../../lib/api";
import { api, type FileFolder } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
import { isAborted } from "../../lib/api/core";
import { notifyActionError } from "../../lib/actionErrors";
@@ -17,6 +17,10 @@ import { formatBytes } from "../../lib/format";
import { DISPLAY_NAME_MAX } from "../../lib/api/downloads";
import { DropZone } from "../../components/ui/DropZone";
import { Input } from "../../components/ui/Input";
import FilterSelect from "../../components/ui/FilterSelect";
import { RenameField } from "../../components/ui/RenameField";
import { StateMessage } from "../../components/ui/QueryState";
import { folderLabel, takesUploads } from "../../features/folders/fileFolders";
// Local-file ingest. A general upload — ANY file type, no format list: media
// (video/audio) joins the library with the full function set, a generic file is download-only. The
@@ -30,6 +34,12 @@ const uploadedKey = (status: string) =>
status === "done" ? "downloads.upload.added" : "downloads.upload.addedConverting";
// The job the upload's own notice announces (a done one), for the queue not to say it again.
const announces = (job: { id: number; status: string }) => (job.status === "done" ? job.id : null);
// What stands in the target list for Uploads before the user has that folder (the first upload makes
// it), and for the row that makes a folder.
const UPLOADS = "uploads";
const NEW_FOLDER = "new";
const isUploads = (f: FileFolder) => f.kind === "source" && f.source_key === "upload";
export default function UploadDialog({
folderId = null,
initialFile = null,
@@ -65,6 +75,82 @@ export default function UploadDialog({
// same reason, and `formAlerts.dom.test.tsx` is the test that exists because of it.
const [pickSeq, setPickSeq] = useState(0);
const { openFile } = useFilesActions();
// Where it goes: the folder the dialog was opened on, until the user says otherwise. Null = Uploads.
const [target, setTarget] = useState(folderId);
const foldersQ = useQuery({ queryKey: qk.fileFolders(), queryFn: api.fileFolders });
const [naming, setNaming] = useState(false);
const targetBox = useRef<HTMLDivElement>(null);
const refocusTarget = useRef(false);
// The name field saves on blur, and a press blurs it before the press does anything: a press on
// Cancel, the ✕ or the backdrop that then closes the dialog is "never mind", not "make it". So a
// name left by a press is made once the press is over, and only if the dialog is still here — its
// close unmounts this and drops the wait. A name left without a press (Tab) is made at once.
const pressing = useRef(false);
const afterPress = useRef<(() => void) | null>(null);
useEffect(() => {
let wait = 0;
const down = () => {
pressing.current = true;
};
const up = () => {
pressing.current = false;
const run = afterPress.current;
afterPress.current = null;
// After the click the press ends in, where a dismissal closes the dialog.
if (run) wait = window.setTimeout(run);
};
document.addEventListener("pointerdown", down, true);
document.addEventListener("pointerup", up, true);
document.addEventListener("pointercancel", up, true);
return () => {
document.removeEventListener("pointerdown", down, true);
document.removeEventListener("pointerup", up, true);
document.removeEventListener("pointercancel", up, true);
window.clearTimeout(wait);
afterPress.current = null;
};
}, []);
const makeFolder = useMutation({
mutationFn: (folder: string) => api.createFolder(folder),
onSuccess: (made) => {
// In the list at once, so the target reads its name rather than waiting for the refetch.
qc.setQueryData<FileFolder[]>(qk.fileFolders(), (old) => [...(old ?? []), made]);
invalidateDownloads(qc);
setTarget(made.id);
},
meta: { failTitle: "errors.writeFailed.folder.create" },
});
// A new folder's name ended by a key leaves the keyboard's focus nowhere: back on the list once it
// is back, as the folder is made (or fails). Until then on its place, which says it is being made.
useEffect(() => {
if (naming || !refocusTarget.current) return;
const box = targetBox.current;
const list = box?.querySelector("button");
if (!list) return box?.focus();
refocusTarget.current = false;
list.focus();
}, [naming, makeFolder.isPending]);
// Not while a folder is being named or made: the file would go to the last target, not that one.
const targetSettling = naming || makeFolder.isPending;
const targets = (foldersQ.data ?? []).filter(takesUploads);
const uploadsRow = targets.find(isUploads);
const targetOptions = [
...(uploadsRow ? [] : [{ value: UPLOADS, label: t("files.source.upload") }]),
...[...targets]
.sort((a, b) => Number(isUploads(b)) - Number(isUploads(a)))
.map((f) => ({ value: String(f.id), label: folderLabel(f, t) })),
{ value: NEW_FOLDER, label: t("files.folder.new") },
];
const targetValue = String(target ?? uploadsRow?.id ?? UPLOADS);
const pickTarget = ([value]: string[]) => {
// After the menu has closed: it hands the focus back as it goes, which took it off a field already
// there, and a name field that loses the focus is done, with no name.
if (value === NEW_FOLDER) {
window.setTimeout(() => setNaming(true));
} else if (value === UPLOADS) setTarget(null);
else setTarget(Number(value));
};
// A done file is in Files, opened on it; one the worker still converts is in the tray until it is.
const openUploaded = (job: DownloadJob) => {
if (job.status === "done") openFile(job.id, job.folder_id ?? null);
@@ -86,7 +172,7 @@ export default function UploadDialog({
// button holds focus it swallows the activation itself, and while it does not it is natively
// disabled. Measured — deleting this term breaks nothing, which is exactly why it must not be
// described as the guard. It is here for the next caller of `submit`, not for the only one.
if (!file || busy || tooBig) return;
if (!file || busy || tooBig || targetSettling) return;
const ac = new AbortController();
abortRef.current = ac;
setPct(0);
@@ -95,7 +181,7 @@ export default function UploadDialog({
try {
const job = await api.uploadFile(file, {
displayName,
folderId,
folderId: target,
onProgress: setPct,
signal: ac.signal,
});
@@ -182,6 +268,55 @@ export default function UploadDialog({
maxLength={DISPLAY_NAME_MAX}
/>
<p className="block text-sm font-medium mt-4 mb-1">{t("downloads.upload.targetLabel")}</p>
{foldersQ.isError && !foldersQ.data ? (
// The file still goes where the dialog was opened on: only the list could not be read. A
// failed REFETCH keeps the list it had (react-query sets isError beside the data): the
// target the file goes to stays shown, as the rail does it.
<StateMessage tone="error" compact onRetry={() => void foldersQ.refetch()}>
{t("downloads.upload.targetFailed")}
</StateMessage>
) : naming ? (
<RenameField
initial=""
label={t("files.folder.newName")}
onDone={(folder, byKey) => {
refocusTarget.current = byKey;
setNaming(false);
const name = folder?.trim();
if (!name) return;
if (!byKey && pressing.current) afterPress.current = () => makeFolder.mutate(name);
else makeFolder.mutate(name);
}}
/>
) : (
// The select's own height held while the list loads, so it does not push the form down.
// It holds the keyboard's focus while a folder is made (tabIndex -1: never a Tab stop), and
// says it in a live region, as the list it replaces is gone until then.
<div ref={targetBox} tabIndex={-1} className="min-h-8 flex items-center outline-none">
<span role="status" className="text-sm text-muted">
{/* Upload waits for it (`targetSettling`): this says why. */}
{makeFolder.isPending &&
t("downloads.upload.makingFolder", { name: makeFolder.variables })}
</span>
{foldersQ.data && !busy && !makeFolder.isPending && (
<FilterSelect
hideLabel
label={t("downloads.upload.targetLabel")}
options={targetOptions}
selected={[targetValue]}
onChange={pickTarget}
testId="upload-target"
/>
)}
{busy && (
<span className="text-sm text-muted">
{targetOptions.find((o) => o.value === targetValue)?.label}
</span>
)}
</div>
)}
{busy && (
<div className="mt-5" aria-live="polite">
<div
@@ -223,7 +358,7 @@ export default function UploadDialog({
variant="primary"
size="sm"
onClick={submit}
disabled={!file || busy || tooBig}
disabled={!file || busy || tooBig || targetSettling}
aria-describedby={tooBig ? refusalId : undefined}
>
{t("downloads.upload.submit")}
@@ -0,0 +1,60 @@
// @vitest-environment jsdom
//
// A crop's numbers count the picture as the browser reports it, its shown size, which differs from the
// file's own on a video with wide pixels. The size goes along, or the server cuts the wrong part.
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" } }),
}));
import VideoEditor from "./VideoEditor";
import { api, type DownloadJob } 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();
});
// Wide pixels: stored 960 across, shown 1920 — the browser reports the shown size, the asset the
// stored one, and the two must not be mistaken for each other.
const job = {
id: 7,
display_name: "Talk",
asset: { duration_s: 100, width: 960, height: 1080, title: "Talk" },
} as unknown as DownloadJob;
describe("VideoEditor crop", () => {
it("sends the size the crop was measured on with it", async () => {
vi.spyOn(HTMLVideoElement.prototype, "videoWidth", "get").mockReturnValue(1920);
vi.spyOn(HTMLVideoElement.prototype, "videoHeight", "get").mockReturnValue(1080);
const enqueue = vi
.spyOn(api, "enqueueEdit")
.mockResolvedValue({ id: 8 } as unknown as DownloadJob);
render(
<QueryClientProvider client={new QueryClient()}>
<VideoEditor job={job} onClose={() => {}} />
</QueryClientProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "editor.cropOff" }));
fireEvent.click(screen.getByRole("button", { name: "editor.create" }));
await waitFor(() => expect(enqueue).toHaveBeenCalledTimes(1));
const crop = enqueue.mock.calls[0]?.[0].edit_spec.crop;
expect(crop).toMatchObject({ frame_w: 1920, frame_h: 1080, w: 1536, h: 864 });
});
});
+4 -2
View File
@@ -226,14 +226,16 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
const willJoin = output === "join" && kept.length > 1;
const reencode = cropOn || accurate;
const cropPx = (): { x: number; y: number; w: number; h: number } | undefined => {
const cropPx = (): EditSpec["crop"] => {
const v = videoRef.current;
const vw = v?.videoWidth || vdim.w;
const vh = v?.videoHeight || vdim.h;
if (!cropOn) return undefined;
// crop fractions are relative to the picture (the overlay is anchored to it), so this is a
// straight fraction→source-px scale; see cropGeometry for the even-rounding + in-bounds clamp.
return cropToSourcePx(crop, vw, vh) ?? undefined;
const px = cropToSourcePx(crop, vw, vh);
// The size they count in goes along: it is the shown one, and the file's own can differ.
return px ? { ...px, frame_w: vw, frame_h: vh } : undefined;
};
const create = useMutation({
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { fileMeta } from "./fileMeta";
import type { LibraryFile } from "../../lib/api";
const file = (uploader: string | null, container: string | null) =>
({ display_uploader: uploader, asset: { uploader: null, container } }) as unknown as LibraryFile;
describe("fileMeta", () => {
it("names who made it and its format", () => {
expect(fileMeta(file("Someone", "mp4"))).toBe("Someone · MP4");
});
it("has the format alone when nobody is named", () => {
expect(fileMeta(file(null, "gz"))).toBe("GZ");
});
it("has the name alone when no format is known", () => {
expect(fileMeta(file("Someone", null))).toBe("Someone");
});
it("is empty when neither is known", () => {
expect(fileMeta(file(null, null))).toBe("");
});
it("reads the file's own uploader when none was typed", () => {
const f = { display_uploader: null, asset: { uploader: "Site", container: "webm" } };
expect(fileMeta(f as unknown as LibraryFile)).toBe("Site · WEBM");
});
});
+8
View File
@@ -0,0 +1,8 @@
import type { LibraryFile } from "../../lib/api";
/** A file's line under its name, in the table and on its card: who made it and its format. */
export function fileMeta(file: LibraryFile): string {
const who = file.display_uploader || file.asset?.uploader;
const format = file.asset?.container?.toUpperCase();
return [who, format].filter(Boolean).join(" · ");
}
@@ -0,0 +1,148 @@
// @vitest-environment jsdom
//
// Discover's See all opens the list its rail is the start of, sorted the way the rail is: Recently
// added newest first, Continue watching in progress by last watched. The rails only call back; what
// each opens is PlexBrowse's, against the Plex store's show and sort, which outlive the Discover /
// Browse toggle — so a sort picked earlier must not survive the press.
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
import { render, screen, cleanup, fireEvent, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
}));
vi.mock("./plexSavedViews", async (importOriginal) => ({
...(await importOriginal<typeof import("./plexSavedViews")>()),
usePlexSavedViews: () => ({ views: [], displayView: null, isDirty: false, activeViewId: null }),
}));
// The rails themselves are PlexDiscover's own test: here only their two See all presses.
vi.mock("./PlexDiscover", () => ({
default: (p: { onSeeAllRecent: () => void; onSeeAllContinue: () => void }) => (
<div>
<button onClick={p.onSeeAllRecent}>recent</button>
<button onClick={p.onSeeAllContinue}>continue</button>
</div>
),
}));
import PlexBrowse from "./PlexBrowse";
import { api, EMPTY_PLEX_FILTERS } from "../../lib/api";
import { PageToolbarSlot } from "../../components/ui/pageToolbarSlot";
import {
PlexActionsContext,
PlexStateContext,
type PlexActions,
type PlexState,
} from "./plexContext";
import type { SortState } from "../../lib/columnSort";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
beforeEach(() => window.history.replaceState({}, "", "/"));
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
const TITLE: SortState = { key: "title", dir: "asc" };
const slot = document.body.appendChild(document.createElement("div"));
// The store's show and sort, real enough for PlexBrowse's own effect to act on them.
function mount(start: { show: string; sort: SortState }) {
vi.spyOn(api, "plexLibrary").mockResolvedValue({ items: [], total: 0, offset: 0 } as never);
const now = { state: null as PlexState | null, actions: null as PlexActions | null };
function Store() {
const [show, setShow] = useState(start.show);
const [sort, setSort] = useState<SortState>(start.sort);
const state: PlexState = {
scope: "both",
show,
sort,
filters: EMPTY_PLEX_FILTERS,
q: "",
field: "all",
playlistOpen: null,
jumpTo: null,
} as PlexState;
const actions = {
setShow,
setSort,
setScope: () => {},
setFilters: () => {},
setQ: () => {},
setField: () => {},
setPlaylistOpen: () => {},
setJumpTo: () => {},
} as PlexActions;
now.state = state;
now.actions = actions;
return (
<PlexStateContext.Provider value={state}>
<PlexActionsContext.Provider value={actions}>
<PlexBrowse />
</PlexActionsContext.Provider>
</PlexStateContext.Provider>
);
}
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<PageToolbarSlot.Provider value={slot}>
<Store />
</PageToolbarSlot.Provider>
</QueryClientProvider>,
);
return now;
}
describe("PlexBrowse · Discover's See all", () => {
it("Recently added opens every title, newest first, over a sort picked before", async () => {
const store = mount({ show: "in_progress", sort: TITLE });
fireEvent.click(await screen.findByText("recent"));
expect(store.state!.show).toBe("all");
expect(store.state!.sort).toEqual({ key: "added", dir: "desc" });
});
// Entering in-progress keeps the sort from before it, to give back on the way out: leaving for
// Recently added must not hand it back over newest first.
it("Recently added from in progress does not give back the sort from before it", async () => {
const store = mount({ show: "all", sort: TITLE });
act(() => store.actions!.setShow("in_progress"));
expect(store.state!.sort).toEqual({ key: "lastwatched", dir: "desc" });
fireEvent.click(await screen.findByText("recent"));
expect(store.state!.show).toBe("all");
expect(store.state!.sort).toEqual({ key: "added", dir: "desc" });
});
it("Continue watching opens in progress, last watched first", async () => {
const store = mount({ show: "all", sort: TITLE });
fireEvent.click(await screen.findByText("continue"));
expect(store.state!.show).toBe("in_progress");
expect(store.state!.sort).toEqual({ key: "lastwatched", dir: "desc" });
});
// Already in progress, with another sort picked there: no entering edge brings the sort.
it("Continue watching sorts by last watched when in progress was already on", async () => {
const store = mount({ show: "all", sort: TITLE });
act(() => store.actions!.setShow("in_progress"));
act(() => store.actions!.setSort(TITLE));
fireEvent.click(await screen.findByText("continue"));
expect(store.state!.show).toBe("in_progress");
expect(store.state!.sort).toEqual({ key: "lastwatched", dir: "desc" });
// …and the sort from before in-progress still comes back on the way out.
act(() => store.actions!.setShow("all"));
expect(store.state!.sort).toEqual(TITLE);
});
});
+19 -1
View File
@@ -80,6 +80,9 @@ const LASTWATCHED_SORT: NonNullable<SortState> = Object.freeze({
dir: "desc" as const,
});
// What the Recently added rail's See all opens on.
const ADDED_SORT: NonNullable<SortState> = Object.freeze({ key: "added", dir: "desc" as const });
// Watch-state options for the toolbar pill (moved out of the filter rail — it's a top-level "what am
// I looking at" control now, like the feed's Watch-state pill, not a rail widget).
const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] as const;
@@ -579,7 +582,22 @@ function PlexBrowseView({ sub }: { sub: ReturnType<typeof useHistorySubview<Sub>
onRestartCard={onRestartCard}
onInfoCard={onInfo}
onGenre={(g) => applyFilter({ genres: [g] })}
onSeeAllRecent={() => sub.open({ kind: "grid" })}
// Each opens the list its rail is the start of: the watch state goes with it, and
// entering in-progress brings the last-watched sort (the effect above).
onSeeAllRecent={() => {
// Newest first is the list's point, so no earlier sort comes back as it leaves in-progress.
preInProgressSort.current = null;
setSort(ADDED_SORT);
setShow("all");
sub.open({ kind: "grid" });
}}
onSeeAllContinue={() => {
// Already in progress (with a sort picked since), no edge brings the sort: set it here,
// keeping the sort from before in-progress for the way out.
if (show === "in_progress") setSort(LASTWATCHED_SORT);
else setShow("in_progress");
sub.open({ kind: "grid" });
}}
/>
</div>
);
@@ -105,6 +105,7 @@ describe("PlexDiscover", () => {
onInfoCard={noop}
onGenre={noop}
onSeeAllRecent={noop}
onSeeAllContinue={noop}
/>,
);
await screen.findByText("plex.discover.continueWatching");
@@ -144,6 +145,7 @@ describe("PlexDiscover", () => {
onInfoCard={noop}
onGenre={noop}
onSeeAllRecent={noop}
onSeeAllContinue={noop}
/>
</QueryClientProvider>,
);
@@ -179,12 +181,38 @@ describe("PlexDiscover", () => {
onInfoCard={noop}
onGenre={onGenre}
onSeeAllRecent={noop}
onSeeAllContinue={noop}
/>,
);
fireEvent.click(await screen.findByText("Drama"));
expect(onGenre).toHaveBeenCalledWith("Drama");
});
it("fires onSeeAllContinue from the Continue watching rail's see-all", async () => {
const onSeeAllContinue = vi.fn();
const onSeeAllRecent = vi.fn();
plexDiscover.mockResolvedValue({ ...fullPayload, recently_added: [] });
plexSurprise.mockResolvedValue({ scope: "both", items: [] });
wrap(
<PlexDiscover
scope="both"
onOpenCard={noop}
onRestartCard={() => {}}
onInfoCard={noop}
onGenre={noop}
onSeeAllRecent={onSeeAllRecent}
onSeeAllContinue={onSeeAllContinue}
/>,
);
fireEvent.click(
await screen.findByRole("button", {
name: "common.seeAllOf:plex.discover.continueWatching",
}),
);
expect(onSeeAllContinue).toHaveBeenCalledOnce();
expect(onSeeAllRecent).not.toHaveBeenCalled();
});
it("fires onSeeAllRecent from the recently-added rail's see-all", async () => {
plexDiscover.mockResolvedValue(fullPayload);
plexSurprise.mockResolvedValue({ scope: "both", items: [] });
@@ -197,9 +225,13 @@ describe("PlexDiscover", () => {
onInfoCard={noop}
onGenre={noop}
onSeeAllRecent={onSeeAllRecent}
onSeeAllContinue={noop}
/>,
);
fireEvent.click(await screen.findByRole("button", { name: "common.seeAll" }));
// Each See all is named with its rail: Continue watching, above it, has its own.
fireEvent.click(
await screen.findByRole("button", { name: "common.seeAllOf:plex.discover.recentlyAdded" }),
);
expect(onSeeAllRecent).toHaveBeenCalledOnce();
});
@@ -214,6 +246,7 @@ describe("PlexDiscover", () => {
onInfoCard={noop}
onGenre={noop}
onSeeAllRecent={noop}
onSeeAllContinue={noop}
/>,
);
await screen.findByText("Random Pick");
@@ -250,6 +283,7 @@ describe("PlexDiscover", () => {
onInfoCard={noop}
onGenre={noop}
onSeeAllRecent={noop}
onSeeAllContinue={noop}
/>
</QueryClientProvider>
);
@@ -297,6 +331,7 @@ describe("PlexDiscover", () => {
onInfoCard={noop}
onGenre={noop}
onSeeAllRecent={noop}
onSeeAllContinue={noop}
/>
</QueryClientProvider>,
);
@@ -338,6 +373,7 @@ describe("PlexDiscover", () => {
onInfoCard={noop}
onGenre={noop}
onSeeAllRecent={noop}
onSeeAllContinue={noop}
/>,
);
await screen.findByText("plex.discover.empty");
@@ -363,6 +399,7 @@ describe("PlexDiscover", () => {
onInfoCard={noop}
onGenre={noop}
onSeeAllRecent={noop}
onSeeAllContinue={noop}
/>,
);
fireEvent.click(await screen.findByLabelText("plex.markWatched"));
@@ -386,6 +423,7 @@ describe("PlexDiscover · keyboard", () => {
onInfoCard={noop}
onGenre={noop}
onSeeAllRecent={noop}
onSeeAllContinue={noop}
/>,
);
await screen.findAllByText("Random Pick");
@@ -101,6 +101,7 @@ export default function PlexDiscover({
onInfoCard,
onGenre,
onSeeAllRecent,
onSeeAllContinue,
}: {
scope: string;
onOpenCard: (c: PlexCard) => void;
@@ -108,6 +109,7 @@ export default function PlexDiscover({
onInfoCard: (c: PlexCard) => void;
onGenre: (genre: string) => void;
onSeeAllRecent: () => void;
onSeeAllContinue: () => void;
}) {
const { t, i18n } = useTranslation();
const qc = useQueryClient();
@@ -233,6 +235,7 @@ export default function PlexDiscover({
<CardRail
title={t("plex.discover.continueWatching")}
ariaLabel={t("plex.discover.continueWatching")}
onSeeAll={onSeeAllContinue}
cards={d.continue_watching}
{...railProps}
/>
@@ -426,3 +426,17 @@ describe("a collection strip's card", () => {
await vi.waitFor(() => expect(onOpenInfo).toHaveBeenCalledWith("m2"));
});
});
describe("PlexInfo size", () => {
const show = (over: Partial<PlexItemDetail>) =>
wrap(<PlexInfo onPlayItem={noop} detail={{ ...detail, ...over }} variant="page" />);
it("names the file's size beside the runtime", () => {
show({ size_bytes: 15_000_000_000 });
expect(screen.getByText(/^1[45](\.\d)? GB$/)).toBeTruthy();
});
it("names none when it is not known", () => {
show({ size_bytes: null });
expect(screen.getByText("Test Movie")).toBeTruthy();
expect(screen.queryByText(/ GB$/)).toBeNull();
});
});
+4 -1
View File
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } from "lucide-react";
import { api, type PlexCard, type PlexFilters, type PlexItemDetail } from "../../lib/api";
import { formatCompact, formatDecimal, formatRuntime } from "../../lib/format";
import { formatBytes, formatCompact, formatDecimal, formatRuntime } from "../../lib/format";
import { notifyActionError } from "../../lib/actionErrors";
import { DetailCustomizeMenu, Filterable, PrefToggle } from "./plexDetailUi";
import { DetailAside, DetailAsideToggles, DetailKeywords } from "./plexDetailAside";
@@ -452,6 +452,9 @@ export default function PlexInfo({
{detail.duration_seconds ? (
<span className={mutedCls}>{formatRuntime(detail.duration_seconds, lang)}</span>
) : null}
{detail.size_bytes ? (
<span className={mutedCls}>{formatBytes(detail.size_bytes, lang)}</span>
) : null}
{detail.studio && (
<Filterable
tag
@@ -1121,6 +1121,80 @@ describe("PlexPlayer — clip mode", () => {
expect(screen.getByTestId("clip-panel")).toBeTruthy();
});
describe("the wait before an auto-skip's countdown", () => {
const withCredits = {
...item("a", 0, "b"),
markers: [{ type: "credits", start_s: 2900, end_s: 2995, final: true }],
} as unknown as PlexItemDetail;
const advance = (ms: number) =>
act(() => {
vi.advanceTimersByTime(ms);
});
// Into the marker with the clock already faked, so the countdown's interval is the fake one's.
const enterMarker = (paused: boolean) => {
vi.useFakeTimers();
const v = video();
v._paused = paused;
v._t = 2910;
act(() => void v.dispatchEvent(new Event("timeupdate")));
};
it("holds the countdown for 3 s after the marker starts, then runs it", async () => {
prefs({ autoSkipCredits: true, autoSkipDelay: 1 });
// Leaving a final marker writes "watched" as it skips, in the same tick: the next item's
// session comes only after promises this clock does not run.
const left = vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never);
mount({ details: { a: withCredits, b: item("b") }, queue: ["a", "b"] });
await playingAt("/media/a", 2800);
enterMarker(false);
advance(3500); // past what the delay alone would have waited
expect(left).not.toHaveBeenCalled();
advance(1000);
expect(left).toHaveBeenCalledWith("a", "watched");
});
it("a delay of 0 is still immediate", async () => {
prefs({ autoSkipCredits: true, autoSkipDelay: 0 });
const { session } = mount({ details: { a: withCredits, b: item("b") }, queue: ["a", "b"] });
await playingAt("/media/a", 2910);
await waitFor(() => expect(lastStart(session)?.[0]).toBe("b"));
});
it("waits out the pause: a paused video does not use up the 3 s", async () => {
prefs({ autoSkipCredits: true, autoSkipDelay: 1 });
const left = vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never);
mount({ details: { a: withCredits, b: item("b") }, queue: ["a", "b"] });
await playingAt("/media/a", 2800);
enterMarker(true);
advance(10_000);
expect(left).not.toHaveBeenCalled();
video()._paused = false;
advance(3500); // the whole wait is still ahead, then the 1 s countdown
expect(left).not.toHaveBeenCalled();
advance(1000);
expect(left).toHaveBeenCalledWith("a", "watched");
});
// An 8 s intro with the default 5 s countdown: 3 s of wait on top would run past its end, and the
// countdown stops there with nothing skipped.
it("shortens the wait on a marker too short for it, so the skip still fires", async () => {
prefs({ autoSkipCredits: true, autoSkipDelay: 5 });
const short = {
...withCredits,
markers: [{ type: "credits", start_s: 2909, end_s: 2917, final: true }],
} as unknown as PlexItemDetail;
const left = vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never);
mount({ details: { a: short, b: item("b") }, queue: ["a", "b"] });
await playingAt("/media/a", 2800);
enterMarker(false); // at 2910: 6 s before the marker stops counting
advance(5000);
expect(left).not.toHaveBeenCalled(); // a wait is still kept: 0.5 s of it here
advance(700);
expect(left).toHaveBeenCalledWith("a", "watched");
});
});
it("does not auto-skip while a clip is marked: the skip would carry the player off it", async () => {
prefs({ autoSkipCredits: true, autoSkipDelay: 0 });
const withCredits = {
+43
View File
@@ -24,6 +24,20 @@ afterEach(() => {
localStorage.clear();
});
describe("WatchPage share paths", () => {
it.each(["/watch/tok-p", "/file/tok-p", "/folder/tok-p", "/file/tok-p/extra"])(
"reads the token off %s",
async (path) => {
window.history.replaceState({}, "", path);
mockMeta({ needs_password: false, audio: false, file_url: "/f.mp4", title: "A clip" });
render(<WatchPage />);
await waitFor(() =>
expect(fetch).toHaveBeenCalledWith("/api/public/watch/tok-p", expect.anything()),
);
},
);
});
describe("WatchPage background play", () => {
it("hands its <video> to the background-play hook, so Brave iOS keeps it playing", async () => {
window.history.replaceState({}, "", "/watch/tok-bg");
@@ -267,6 +281,35 @@ describe("WatchPage honest failures", () => {
return page;
}
it.each([
[{ kind: "file", video: true }, "This video is password-protected", "Watch"],
[{ kind: "file", video: false }, "This file is password-protected", "Open"],
[{ kind: "folder", video: false }, "This folder is password-protected", "Open"],
[{}, "This video is password-protected", "Watch"],
])("a password link that opens %o asks in its words", async (kind, title, button) => {
window.history.replaceState({}, "", "/file/tok-lock");
mockMeta({ needs_password: true, ...kind });
const page = render(<WatchPage />);
await page.findByText(title);
expect(page.getByRole("button", { name: button })).toBeTruthy();
});
it("a repeated Enter sends one try while it is on its way", async () => {
window.history.replaceState({}, "", "/watch/tok-enter");
const fetchMock = vi.fn().mockResolvedValueOnce(res(200, { needs_password: true }));
fetchMock.mockImplementation(() => new Promise(() => {}));
vi.stubGlobal("fetch", fetchMock);
const page = render(<WatchPage />);
const field = await page.findByPlaceholderText("Enter password");
fireEvent.change(field, { target: { value: "guess" } });
fireEvent.keyDown(field, { key: "Enter" });
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
fireEvent.keyDown(field, { key: "Enter" });
fireEvent.keyDown(field, { key: "Enter" });
await new Promise((r) => setTimeout(r, 20));
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("too many password tries says so, and keeps the form", async () => {
window.history.replaceState({}, "", "/watch/tok-429");
const page = await tryPassword(async () => res(429));
+22 -9
View File
@@ -28,7 +28,7 @@ import { SURFACE } from "../components/ui/surfaces";
import { IconLink, LinkButton } from "../components/ui/LinkButton";
import { T, lang } from "./watchStrings";
// Standalone, login-free page for a shared /watch/<token> link: one file, or a folder's list of them.
// Standalone, login-free page for a share link (/watch/, /file/, /folder/): one file, or a folder's list.
// Its own entry (watchMain.tsx, watch.html): no auth, no providers, none of the app's bundle. Uses
// plain fetch and a tiny self-contained i18n dict (`watchStrings`: the public viewer has no app
// language preference).
@@ -64,7 +64,7 @@ type Meta = FileMeta | FolderMeta;
// What the link answers before it is opened: a password link says only which kind it is.
type Answer =
| (Meta & { needs_password?: false })
| { needs_password: true; kind?: "file" | "folder" };
| { needs_password: true; kind?: "file" | "folder"; video?: boolean };
// Compact display of a URL: drop the protocol + trailing slash so it fits on one line.
function displayUrl(url: string): string {
@@ -83,16 +83,23 @@ async function notFoundState(r: Response): Promise<"preparing" | "gone"> {
return body?.detail?.reason === "video_preparing" ? "preparing" : "gone";
}
/** The token of a share path: /watch/ for a video, /file/ or /folder/ for the rest, any of them for
* any link (`main.SHARE_PREFIXES`). */
function shareToken(path: string): string {
return /^\/(?:watch|file|folder)\/([^/?#]+)/.exec(path)?.[1] ?? "";
}
export default function WatchPage() {
const token = window.location.pathname.split("/watch/")[1]?.split(/[/?#]/)[0] ?? "";
const token = shareToken(window.location.pathname);
// Seeded from the URL rather than corrected by the effect below: with no token there is nothing
// to load, and "loading" was committed and painted before the effect said so.
const [status, setStatus] = useState<
"loading" | "ready" | "password" | "gone" | "preparing" | "error"
>(token ? "loading" : "gone");
const [meta, setMeta] = useState<Meta | null>(null);
// A password link's kind, for its title, until it is unlocked.
const [locked, setLocked] = useState<"file" | "folder">("file");
// What a password link opens, for its prompt's words, until it is unlocked. A server from before
// it said `video` only had video links' words, so a file without it reads as one.
const [locked, setLocked] = useState<"video" | "file" | "folder">("video");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const errorId = useId();
@@ -118,7 +125,7 @@ export default function WatchPage() {
.then((m) => {
if (typeof m === "string") setStatus(m);
else if (m.needs_password) {
setLocked(m.kind ?? "file");
setLocked(m.kind === "folder" ? "folder" : m.video === false ? "file" : "video");
setStatus("password");
} else {
setMeta(m);
@@ -240,7 +247,11 @@ export default function WatchPage() {
<div className={`${SURFACE.panel} w-full max-w-sm p-6`}>
<div className="flex items-center gap-2 font-medium mb-4">
<Lock className="w-4 h-4 text-accent" />{" "}
{locked === "folder" ? T.passwordTitleFolder : T.passwordTitle}
{locked === "folder"
? T.passwordTitleFolder
: locked === "file"
? T.passwordTitleFile
: T.passwordTitle}
</div>
<Input
className="w-full"
@@ -248,7 +259,9 @@ export default function WatchPage() {
value={password}
autoFocus
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && password && unlock()}
// Not again while one is on its way, as the button: a held or repeated Enter would
// spend the link's tries (and its view count) once per press.
onKeyDown={(e) => e.key === "Enter" && password && !unlocking && unlock()}
placeholder={T.passwordPlaceholder}
aria-describedby={error ? errorId : undefined}
/>
@@ -266,7 +279,7 @@ export default function WatchPage() {
disabled={!password || unlocking}
className="mt-4"
>
{T.watch}
{locked === "video" ? T.watch : T.open}
</Button>
</div>
</div>
@@ -28,3 +28,12 @@ describe("the watch page's document language", () => {
expect(document.documentElement.lang).toBe(want);
});
});
// One button, two kinds of link: it says the same kind of word for both, as the app's own buttons
// do in Hungarian — a noun (Letöltés, Megnyitás), not a first-person verb on one of them.
describe("the password form's button", () => {
it("reads as a noun for a video and for anything else in Hungarian", async () => {
const { T } = await pageIn("hu-HU");
expect([T.watch, T.open]).toEqual(["Megtekintés", "Megnyitás"]);
});
});
+7 -1
View File
@@ -27,6 +27,8 @@ const STR = {
crashHint: "Reload the page to try again.",
reload: "Reload",
passwordTitleFolder: "This folder is password-protected",
passwordTitleFile: "This file is password-protected",
open: "Open",
folderEmpty: "This folder is empty.",
play: "Play",
files: (n: number) => (n === 1 ? "1 file" : `${n} files`),
@@ -37,7 +39,7 @@ const STR = {
goneHint: "Lehet, hogy lejárt, vagy a tulajdonos visszavonta.",
passwordTitle: "Ez a videó jelszóval védett",
passwordPlaceholder: "Add meg a jelszót",
watch: "Megnézem",
watch: "Megtekintés",
wrong: "Hibás jelszó.",
download: "Letöltés",
file: "Fájl",
@@ -55,6 +57,8 @@ const STR = {
crashHint: "Töltsd újra az oldalt, és próbáld újra.",
reload: "Újratöltés",
passwordTitleFolder: "Ez a mappa jelszóval védett",
passwordTitleFile: "Ez a fájl jelszóval védett",
open: "Megnyitás",
folderEmpty: "Ez a mappa üres.",
play: "Lejátszás",
files: (n: number) => `${n} fájl`,
@@ -83,6 +87,8 @@ const STR = {
crashHint: "Lade die Seite neu und versuche es erneut.",
reload: "Neu laden",
passwordTitleFolder: "Dieser Ordner ist passwortgeschützt",
passwordTitleFile: "Diese Datei ist passwortgeschützt",
open: "Öffnen",
folderEmpty: "Dieser Ordner ist leer.",
play: "Abspielen",
files: (n: number) => (n === 1 ? "1 Datei" : `${n} Dateien`),
+23
View File
@@ -99,6 +99,29 @@ function chain(seen: Map<string, string | null>, mod: string): string {
return path.join(" → ");
}
describe("the paths a share link opens on", () => {
// The server's list: each of the places below that has to know them follows it.
const main = readFileSync(join(SRC, "..", "..", "backend", "app", "main.py"), "utf8");
const prefixes = [
...(/SHARE_PREFIXES = \(([^)]*)\)/.exec(main)?.[1] ?? "").matchAll(/"(\w+)\/"/g),
].map((m) => m[1]);
it("are read off the server", () => {
expect(prefixes).toEqual(["watch", "file", "folder"]);
});
it("are all kept out of search indexes", () => {
const robots = readFileSync(join(SRC, "..", "public", "robots.txt"), "utf8");
for (const p of prefixes) expect(robots).toContain(`Disallow: /${p}/`);
});
it("all boot the player alone in `vite dev` and all give up their token", () => {
const vite = readFileSync(join(SRC, "..", "vite.config.ts"), "utf8");
const page = readFileSync(join(SRC, "watch", "WatchPage.tsx"), "utf8");
for (const src of [vite, page]) expect(src).toContain(`(?:${prefixes.join("|")})`);
});
});
describe("the /watch entry", () => {
it("loads nothing the app alone may load", () => {
const seen = reach(join(SRC, "watchMain.tsx"));
+3 -3
View File
@@ -32,18 +32,18 @@ const proxy = {
"/healthz": proxyOpts,
};
// Two HTML entries: the app (index.html) and a public /watch/<token> link (watch.html), which loads
// Two HTML entries: the app (index.html) and a public share link (watch.html), which loads
// only the player page. In a build the backend picks the file by path (`main.py`); this does the
// same for `vite dev`, whose history fallback would otherwise hand /watch/… the app.
const watchEntry = (): Plugin => ({
name: "siftlode-watch-entry",
configureServer(server) {
server.middlewares.use((req, _res, next) => {
if (req.url?.startsWith("/watch/")) req.url = "/watch.html";
if (req.url && /^\/(?:watch|file|folder)\//.test(req.url)) req.url = "/watch.html";
next();
});
},
// The server answers every /watch/ link with the built watch.html, so a build without it would
// The server answers every share link with the built watch.html, so a build without it would
// ship a 500 for each one, and nothing downstream reads `dist` before prod does. Fail it here —
// after Vite's own HTML plugin has emitted the pages (`enforce: "post"`), or none is there yet.
enforce: "post",