fix(files): an upload is said once in any order, copy buttons speak, a link list that failed says so

- The queue holds an upload it sees land done while this tab's upload still waits for its answer, so
  the dialog's notice is the only one whichever comes first; another device's upload follows after.
- One `useCopyFeedback` for every Copy beside a value (token, share link, set-password link): Copied
  for a moment, a notice a screen reader hears, a warning when the clipboard refuses; the token
  dialog's two Copy buttons are named by what they copy.
- The share dialog's link list shows loading, and a load error with Retry, never "no links" — and no
  New link beside a list it has not seen.
- The settings texts say that signing out elsewhere or a password change revokes the API tokens.
- DESIGN.md logs the folder-link decisions; the design lab keeps only the chosen renderings of the
  Links, Tokens and Files rounds.
This commit is contained in:
2026-10-15 04:58:17 +02:00
parent 827df7494c
commit 2efa7d5a75
23 changed files with 505 additions and 773 deletions
+122 -121
View File
@@ -661,124 +661,125 @@ of a full-width panel and nothing on the right.
## Decision log ## Decision log
| Date | Decision | Chosen | Over | Why | | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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-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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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-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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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-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 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 | 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 | 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-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 | 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-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 | 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 | C3 + C5 as built | A tap toggles the bar through `pointerType` from `pointerdown`; a double-tap takes back its first tap's toggle; the card's pair is the video card's `MediaResumeButtons` (Continue / Restart), a sibling over the picture; the YT description is an ⓘ click popover | A delayed single tap (wait out the double-tap window); new "Resume / From the start" words for Plex; the pair inside the picture's button | A delayed tap feels dead; one media card says one pair of words; a button inside a button is invalid and the hover row already sits beside the picture |
| 2026-09-27 | C2 as built | The side panel's drawer is `ui/Modal` `drawer-start`; drawer = a coarse pointer or below 768; the pin offered in its header ≥ 1024 | A drawer of its own inside `SidePanel` | Esc, Back, the scrim, the trap and the scroll lock are the dialog's; a second implementation is how two shells drift | | 2026-09-27 | 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 | 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-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-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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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-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 | 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 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 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 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 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 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 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 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 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 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-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 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-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 | 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-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-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 | 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 | 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 | 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 | 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 | 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 | 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-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-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 | 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 | 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-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 | 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 | 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 | 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 | 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-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 | 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-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-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 |
+7 -15
View File
@@ -9,13 +9,13 @@ import { displayName } from "../lib/api/account";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { notifyActionError } from "../lib/actionErrors"; import { notifyActionError } from "../lib/actionErrors";
import { copyText } from "../lib/clipboard";
import { formatDateTime } from "../lib/format"; import { formatDateTime } from "../lib/format";
import { invalidateUserAccess } from "../lib/adminUsers"; import { invalidateUserAccess } from "../lib/adminUsers";
import type { AdminUserAccess, AdminUserRow } from "../lib/api/admin"; import type { AdminUserAccess, AdminUserRow } from "../lib/api/admin";
import { Button } from "./ui/Button"; import { Button } from "./ui/Button";
import { SURFACE } from "./ui/surfaces"; import { SURFACE } from "./ui/surfaces";
import { SectionLabel } from "./ui/Headings"; import { SectionLabel } from "./ui/Headings";
import { useCopyFeedback } from "./useCopyFeedback";
// The admin's answer to "why can this person not get in?", and the place every recovery action for // The admin's answer to "why can this person not get in?", and the place every recovery action for
// one account lives. // one account lives.
@@ -59,7 +59,7 @@ export default function AccountPanel({
const lang = i18n.language; const lang = i18n.language;
const qc = useQueryClient(); const qc = useQueryClient();
const [link, setLink] = useState<{ url: string; expires_at: string | null } | null>(null); const [link, setLink] = useState<{ url: string; expires_at: string | null } | null>(null);
const [copied, setCopied] = useState(false); const { copied, copy: copyValue } = useCopyFeedback();
const q = useQuery({ const q = useQuery({
queryKey: qk.adminUserAccess(user.id), queryKey: qk.adminUserAccess(user.id),
@@ -88,19 +88,11 @@ export default function AccountPanel({
onError: (err) => notifyActionError(err, t("users.pendingAccess.activateFailed")), onError: (err) => notifyActionError(err, t("users.pendingAccess.activateFailed")),
}); });
const copy = async () => { // Via the shared helper, whose synchronous `execCommand` path is what makes copying work on a
if (!link) return; // plain-http LAN origin — where `navigator.clipboard` is undefined, and which is exactly the
// Via the shared helper, whose synchronous `execCommand` path is what makes copying work on a // minimal self-host this no-email link was built for.
// plain-http LAN origin — where `navigator.clipboard` is undefined, and which is exactly the const copy = () => {
// minimal self-host this no-email link was built for. if (link) void copyValue(link.url);
if (await copyText(link.url)) {
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
return;
}
// The link is on screen and selectable, so a failed copy is a convenience that failed — not the
// action failing. Say so rather than leaving the button looking inert.
notify({ level: "warning", message: t("users.account.copyFailed") });
}; };
const a: AdminUserAccess | undefined = q.data; const a: AdminUserAccess | undefined = q.data;
@@ -187,6 +187,11 @@ describe("API tokens · making one", () => {
); );
// The keyboard lands on copying it, not on the closed form's button. // The keyboard lands on copying it, not on the closed form's button.
expect(document.activeElement?.textContent).toBe("settings.tokens.copy"); expect(document.activeElement?.textContent).toBe("settings.tokens.copy");
// Two Copy buttons in one dialog: each is named by the value it copies.
expect(
screen.getByRole("button", { name: "settings.tokens.copyField(settings.tokens.token)" }),
).toBe(document.activeElement);
screen.getByRole("button", { name: "settings.tokens.copyField(settings.tokens.tryIt)" });
fireEvent.click(screen.getByRole("button", { name: "settings.tokens.done" })); fireEvent.click(screen.getByRole("button", { name: "settings.tokens.done" }));
expect(screen.queryByRole("dialog")).toBeNull(); expect(screen.queryByRole("dialog")).toBeNull();
}); });
+5 -13
View File
@@ -14,11 +14,9 @@ import {
type TokenRight, type TokenRight,
} from "../lib/api"; } from "../lib/api";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { copyText } from "../lib/clipboard";
import { compareText } from "../lib/collate"; import { compareText } from "../lib/collate";
import { formatDate } from "../lib/format"; import { formatDate } from "../lib/format";
import { navItemAttrs } from "../lib/listNav"; import { navItemAttrs } from "../lib/listNav";
import { notify } from "../lib/notifications";
import { useConfirm } from "./confirmContext"; import { useConfirm } from "./confirmContext";
import { folderIcon, folderLabel, takesUploads } from "./fileFolders"; import { folderIcon, folderLabel, takesUploads } from "./fileFolders";
import { type ActionMenuItem } from "./ui/ActionMenu"; import { type ActionMenuItem } from "./ui/ActionMenu";
@@ -37,6 +35,7 @@ import { RelativeTime } from "./ui/RelativeTime";
import SegmentedToggle from "./ui/SegmentedToggle"; import SegmentedToggle from "./ui/SegmentedToggle";
import { SURFACE } from "./ui/surfaces"; import { SURFACE } from "./ui/surfaces";
import { useContextMenu } from "./ui/useContextMenu"; import { useContextMenu } from "./ui/useContextMenu";
import { useCopyFeedback } from "./useCopyFeedback";
// Settings → API tokens: a machine's key to the user's Files library (docs/DESIGN.md decision log, // Settings → API tokens: a machine's key to the user's Files library (docs/DESIGN.md decision log,
// "API tokens"). A row per token in three lines — what it is, what it opens, when it was used and // "API tokens"). A row per token in three lines — what it is, what it opens, when it was used and
@@ -477,16 +476,7 @@ function CopyField({
autoFocus?: boolean; autoFocus?: boolean;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [copied, setCopied] = useState(false); const { copied, copy } = useCopyFeedback();
const copy = async () => {
if (await copyText(value)) {
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
return;
}
// The value is on screen and selectable: a failed copy is a convenience that failed.
notify({ level: "warning", message: t("common.copy.failed") });
};
return ( return (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="text-xs text-muted">{label}</span> <span className="text-xs text-muted">{label}</span>
@@ -499,7 +489,9 @@ function CopyField({
size="sm" size="sm"
autoFocus={autoFocus} autoFocus={autoFocus}
icon={copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />} icon={copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
onClick={() => void copy()} // Two of these sit in one dialog: each is named by the value it copies.
aria-label={t("settings.tokens.copyField", { field: label })}
onClick={() => void copy(value)}
> >
{copied ? t("settings.tokens.copied") : t("settings.tokens.copy")} {copied ? t("settings.tokens.copied") : t("settings.tokens.copy")}
</Button> </Button>
@@ -177,6 +177,27 @@ describe("ShareDialog · a folder", () => {
expect((allow() as HTMLInputElement).checked).toBe(true); expect((allow() as HTMLInputElement).checked).toBe(true);
}); });
it("a list that did not load says so with Retry, and offers no New link beside it", async () => {
h.folderLinks
.mockReset()
.mockRejectedValueOnce(new Error("503"))
.mockResolvedValue([link(4)]);
h.createFolderLink.mockReset();
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<ShareDialog folder={{ id: 7, name: "Friends" }} onClose={() => {}} />
</QueryClientProvider>,
);
expect(await screen.findByText("downloads.share.linksFailed")).toBeTruthy();
expect(screen.queryByText("downloads.share.noLinks")).toBeNull();
expect(screen.queryByRole("button", { name: "downloads.share.newLink" })).toBeNull();
expect(h.createFolderLink).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "common.retry" }));
expect(await screen.findByDisplayValue(`${window.location.origin}/watch/f4`)).toBeTruthy();
expect(screen.getByRole("button", { name: "downloads.share.newLink" })).toBeTruthy();
});
it("makes a link on opening with none, but never right after the last one was revoked", async () => { it("makes a link on opening with none, but never right after the last one was revoked", async () => {
h.folderLinks.mockReset().mockResolvedValue([]); h.folderLinks.mockReset().mockResolvedValue([]);
h.createFolderLink.mockReset().mockResolvedValue(link(6)); h.createFolderLink.mockReset().mockResolvedValue(link(6));
+26 -18
View File
@@ -7,7 +7,6 @@ import Modal from "./ui/Modal";
import { useConfirm } from "./confirmContext"; import { useConfirm } from "./confirmContext";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { notifyActionError } from "../lib/actionErrors"; import { notifyActionError } from "../lib/actionErrors";
import { copyText } from "../lib/clipboard";
import { formatDate } from "../lib/format"; import { formatDate } from "../lib/format";
import { api, type DownloadJob, type ShareLink, type ShareRecipient } from "../lib/api"; import { api, type DownloadJob, type ShareLink, type ShareRecipient } from "../lib/api";
import { fetchLinks, invalidateLinks, linksKey, makeLink, type LinkTarget } from "../lib/downloads"; import { fetchLinks, invalidateLinks, linksKey, makeLink, type LinkTarget } from "../lib/downloads";
@@ -17,6 +16,8 @@ import { Input, InnerInput } from "./ui/Input";
import { CheckRow } from "./ui/NativeControls"; import { CheckRow } from "./ui/NativeControls";
import { SURFACE } from "./ui/surfaces"; import { SURFACE } from "./ui/surfaces";
import UserPicker from "./UserPicker"; import UserPicker from "./UserPicker";
import { useCopyFeedback } from "./useCopyFeedback";
import { LoadingState, StateMessage } from "./ui/QueryState";
const EXPIRY_DAYS = [null, 1, 7, 30] as const; const EXPIRY_DAYS = [null, 1, 7, 30] as const;
// --- share with a registered user (ACL) ----------------------------------------------------- // --- share with a registered user (ACL) -----------------------------------------------------
@@ -68,18 +69,13 @@ function UserShare({ job }: { job: DownloadJob }) {
function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }) { function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const confirm = useConfirm(); const confirm = useConfirm();
const [copied, setCopied] = useState(false); const { copied, copy: copyValue } = useCopyFeedback();
const [editPass, setEditPass] = useState(false); const [editPass, setEditPass] = useState(false);
const [newPass, setNewPass] = useState(""); const [newPass, setNewPass] = useState("");
const fullUrl = link.url && window.location.origin + link.url; const fullUrl = link.url && window.location.origin + link.url;
const copy = async () => { const copy = () => {
if (fullUrl && (await copyText(fullUrl))) { if (fullUrl) void copyValue(fullUrl);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} else {
notify({ level: "error", message: t("downloads.share.copyFailed") });
}
}; };
const toggle = useMutation({ const toggle = useMutation({
mutationFn: () => api.updateDownloadLink(link.id, { allow_download: !link.allow_download }), mutationFn: () => api.updateDownloadLink(link.id, { allow_download: !link.allow_download }),
@@ -299,16 +295,28 @@ function LinkShare({ target }: { target: LinkTarget }) {
)} )}
</p> </p>
<div className="space-y-2"> {/* A list that did not load is said so, with Retry — never shown as "no links", and with no
{(links.data ?? []).map((l) => ( "New link" beside it, which would add a second public link next to ones not seen. */}
<LinkRow key={l.id} link={l} onChanged={invalidate} /> {!links.data ? (
))} links.isError ? (
{links.data && links.data.length === 0 && !creating && !create.isPending && ( <StateMessage tone="error" compact onRetry={() => void links.refetch()}>
<p className="text-xs text-muted">{t("downloads.share.noLinks")}</p> {t("downloads.share.linksFailed")}
)} </StateMessage>
</div> ) : (
<LoadingState label={t("common.loading")} className="py-4" />
)
) : (
<div className="space-y-2">
{links.data.map((l) => (
<LinkRow key={l.id} link={l} onChanged={invalidate} />
))}
{links.data.length === 0 && !creating && !create.isPending && (
<p className="text-xs text-muted">{t("downloads.share.noLinks")}</p>
)}
</div>
)}
{creating ? ( {!links.data ? null : creating ? (
<div className="mt-2 rounded-lg border border-border p-3 space-y-3"> <div className="mt-2 rounded-lg border border-border p-3 space-y-3">
<CheckRow checked={allowDownload} onChange={setAllowDownload} className="text-sm"> <CheckRow checked={allowDownload} onChange={setAllowDownload} className="text-sm">
{t("downloads.share.allowDownload")} {t("downloads.share.allowDownload")}
@@ -16,7 +16,8 @@ const h = vi.hoisted(() => ({
filesUsage: vi.fn(async () => ({ upload_max_bytes: 1_000_000 })), filesUsage: vi.fn(async () => ({ upload_max_bytes: 1_000_000 })),
notify: vi.fn(), notify: vi.fn(),
showInTray: vi.fn(), showInTray: vi.fn(),
noteAnnounced: vi.fn(), uploadStarted: vi.fn(),
uploadEnded: vi.fn(),
openFile: vi.fn(), openFile: vi.fn(),
})); }));
@@ -38,7 +39,8 @@ vi.mock("../lib/notifications", async (importOriginal) => ({
})); }));
vi.mock("../lib/downloadQueue", () => ({ vi.mock("../lib/downloadQueue", () => ({
showInTray: h.showInTray, showInTray: h.showInTray,
noteAnnounced: h.noteAnnounced, uploadStarted: h.uploadStarted,
uploadEnded: h.uploadEnded,
})); }));
import UploadDialog from "./UploadDialog"; import UploadDialog from "./UploadDialog";
@@ -117,7 +119,7 @@ describe("UploadDialog", () => {
message: "downloads.upload.addedConverting", message: "downloads.upload.addedConverting",
}); });
// Its finish is still to come, and the tray's to announce. // Its finish is still to come, and the tray's to announce.
expect(h.noteAnnounced).not.toHaveBeenCalledWith(43); expect(h.uploadEnded).toHaveBeenCalledWith(null);
}); });
it("puts the file in the folder it was opened from, and its notice opens where it now is", async () => { it("puts the file in the folder it was opened from, and its notice opens where it now is", async () => {
@@ -132,7 +134,8 @@ describe("UploadDialog", () => {
expect(h.openFile).toHaveBeenLastCalledWith(42, 5); expect(h.openFile).toHaveBeenLastCalledWith(42, 5);
expect(h.showInTray).not.toHaveBeenCalled(); expect(h.showInTray).not.toHaveBeenCalled();
// Done and said so here: the queue's next look must not announce it again, as a download. // Done and said so here: the queue's next look must not announce it again, as a download.
expect(h.noteAnnounced).toHaveBeenCalledWith(42); expect(h.uploadStarted).toHaveBeenCalled();
expect(h.uploadEnded).toHaveBeenCalledWith(42);
cleanup(); cleanup();
h.notify.mockClear(); h.notify.mockClear();
+6 -2
View File
@@ -10,7 +10,7 @@ import { isAborted } from "../lib/api/core";
import { notifyActionError } from "../lib/actionErrors"; import { notifyActionError } from "../lib/actionErrors";
import { invalidateDownloads } from "../lib/downloads"; import { invalidateDownloads } from "../lib/downloads";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { noteAnnounced, showInTray } from "../lib/downloadQueue"; import { showInTray, uploadEnded, uploadStarted } from "../lib/downloadQueue";
import type { DownloadJob } from "../lib/api"; import type { DownloadJob } from "../lib/api";
import { useFilesActions } from "./filesContext"; import { useFilesActions } from "./filesContext";
import { formatBytes } from "../lib/format"; import { formatBytes } from "../lib/format";
@@ -28,6 +28,8 @@ import { Input } from "./ui/Input";
// skip the whole dialog. // skip the whole dialog.
const uploadedKey = (status: string) => const uploadedKey = (status: string) =>
status === "done" ? "downloads.upload.added" : "downloads.upload.addedConverting"; 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);
export default function UploadDialog({ export default function UploadDialog({
folderId = null, folderId = null,
initialFile = null, initialFile = null,
@@ -96,6 +98,7 @@ export default function UploadDialog({
abortRef.current = ac; abortRef.current = ac;
setPct(0); setPct(0);
const displayName = name.trim() || undefined; const displayName = name.trim() || undefined;
uploadStarted();
try { try {
const job = await api.uploadFile(file, { const job = await api.uploadFile(file, {
displayName, displayName,
@@ -105,7 +108,7 @@ export default function UploadDialog({
}); });
// Said here, so the queue does not say it again; one the worker still converts is the tray's // Said here, so the queue does not say it again; one the worker still converts is the tray's
// to announce when it is done. // to announce when it is done.
if (job.status === "done") noteAnnounced(job.id); uploadEnded(announces(job));
invalidateDownloads(qc); invalidateDownloads(qc);
notify({ notify({
level: "success", level: "success",
@@ -123,6 +126,7 @@ export default function UploadDialog({
// a long upload, or an nginx 413, snapped the dialog back to its pre-upload state with nothing // a long upload, or an nginx 413, snapped the dialog back to its pre-upload state with nothing
// named and no job in the list. The comment here claimed the global error modal had it; it // named and no job in the list. The comment here claimed the global error modal had it; it
// had not since the shared `reportFailure` landed. // had not since the shared `reportFailure` landed.
uploadEnded(null);
if (!isAborted(e)) notifyActionError(e, t("downloads.upload.failed")); if (!isAborted(e)) notifyActionError(e, t("downloads.upload.failed"));
// Re-arm the form so the user can retry or pick again. // Re-arm the form so the user can retry or pick again.
setPct(null); setPct(null);
@@ -0,0 +1,42 @@
// @vitest-environment jsdom
//
// A Copy button beside its value: "Copied" for a moment and a notice a screen reader hears, or a
// warning when the clipboard refused — the same at every such button.
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
const h = vi.hoisted(() => ({ copyText: vi.fn(async (_t: string) => true), notify: vi.fn() }));
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
}));
vi.mock("../lib/clipboard", () => ({ copyText: h.copyText }));
vi.mock("../lib/notifications", () => ({ notify: h.notify }));
import { useCopyFeedback } from "./useCopyFeedback";
afterEach(() => {
vi.useRealTimers();
h.notify.mockClear();
});
describe("useCopyFeedback", () => {
it("a copy that worked reads Copied for a moment and is said out loud", async () => {
vi.useFakeTimers();
const { result } = renderHook(() => useCopyFeedback());
await act(() => result.current.copy("sfl_x"));
expect(h.copyText).toHaveBeenCalledWith("sfl_x");
expect(result.current.copied).toBe(true);
expect(h.notify).toHaveBeenCalledWith({ level: "success", message: "common.copy.done" });
act(() => void vi.advanceTimersByTime(2000));
expect(result.current.copied).toBe(false);
});
it("a copy the clipboard refused is a warning, and nothing reads Copied", async () => {
h.copyText.mockResolvedValueOnce(false);
const { result } = renderHook(() => useCopyFeedback());
await act(() => result.current.copy("sfl_x"));
expect(result.current.copied).toBe(false);
expect(h.notify).toHaveBeenCalledWith({ level: "warning", message: "common.copy.failed" });
});
});
@@ -0,0 +1,30 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { copyText } from "../lib/clipboard";
import { notify } from "../lib/notifications";
// How long a Copy button reads "Copied" after it worked.
const COPIED_MS = 2000;
/** The feedback of a Copy button that sits beside the value it copies (a token, a share link, a
* set-password link), the same at every such button: it reads "Copied" for a moment, and a notice
* says it too — the notices are a live region, so a screen reader hears what a swapped label would
* not tell it. A copy that failed is a warning, not an error: the value is on screen and
* selectable, so it is a convenience that failed, not the action.
*
* A feature file, not `ui/`: it speaks the app's notifications (as `copyItems` does). */
export function useCopyFeedback(): { copied: boolean; copy: (value: string) => Promise<void> } {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const copy = async (value: string) => {
if (await copyText(value)) {
setCopied(true);
notify({ level: "success", message: t("common.copy.done") });
window.setTimeout(() => setCopied(false), COPIED_MS);
return;
}
notify({ level: "warning", message: t("common.copy.failed") });
};
return { copied, copy };
}
+15 -10
View File
@@ -77,11 +77,14 @@ export default function DesignLab() {
/> />
</label> </label>
<nav className="flex flex-wrap gap-1 text-xs"> <nav className="flex flex-wrap gap-1 text-xs">
<a href="#open-tokens" className="rounded-md px-1.5 py-0.5 text-accent state-layer"> <a href="#open-links" className="rounded-md px-1.5 py-0.5 text-success state-layer">
Tokens Links ✓
</a> </a>
<a href="#open-files" className="rounded-md px-1.5 py-0.5 text-accent state-layer"> <a href="#open-tokens" className="rounded-md px-1.5 py-0.5 text-success state-layer">
Files Tokens ✓
</a>
<a href="#open-files" className="rounded-md px-1.5 py-0.5 text-success state-layer">
Files ✓
</a> </a>
<a href="#decided" className="rounded-md px-1.5 py-0.5 text-success state-layer"> <a href="#decided" className="rounded-md px-1.5 py-0.5 text-success state-layer">
A ✓ A ✓
@@ -109,23 +112,25 @@ export default function DesignLab() {
<header id="open-links" className="mt-6 scroll-mt-20"> <header id="open-links" className="mt-6 scroll-mt-20">
<h2 className="text-xl font-bold">Decided · Folder links</h2> <h2 className="text-xl font-bold">Decided · Folder links</h2>
<p className="text-sm text-muted"> <p className="text-sm text-muted">
A /watch link that opens a whole folder. Picked: A, A, A — the variants with the accent A /watch link that opens a whole folder. Picked: Copy link beside Share with link… in
edge (built; on the public list a picture-less row keeps its glyph above the length). the folder menu, a list the link opens with the player above it, a link mark in the
rail.
</p> </p>
</header> </header>
<LinksRound /> <LinksRound />
<header id="open-tokens" className="mt-6 scroll-mt-20"> <header id="open-tokens" className="mt-6 scroll-mt-20">
<h2 className="text-xl font-bold">Decided · API tokens, a machine&apos;s key to Files</h2> <h2 className="text-xl font-bold">Decided · API tokens, a machine&apos;s key to Files</h2>
<p className="text-sm text-muted"> <p className="text-sm text-muted">
Settings → API tokens. Picked: A, A, A — the variants with the accent edge (built; the Settings → API tokens. Picked: a row per token in three lines, the kind asked first (Hub
expiry is a segmented control, DESIGN.md "A choice between a few values"). csere or Egyedi, rights as chips per folder), the token shown once in the same dialog.
</p> </p>
</header> </header>
<TokensRound /> <TokensRound />
<header id="open-files" className="mt-6 scroll-mt-20"> <header id="open-files" className="mt-6 scroll-mt-20">
<h2 className="text-xl font-bold">Open · Files, the one library</h2> <h2 className="text-xl font-bold">Decided · Files, the one library</h2>
<p className="text-sm text-muted"> <p className="text-sm text-muted">
The page that replaces Downloads. The recommended variant has the accent edge. The page that replaced Downloads. Picked: list rows (the grid a toggle), an app-wide
download tray, a done notification that copies the link.
</p> </p>
</header> </header>
<FilesRound /> <FilesRound />
+69 -181
View File
@@ -34,15 +34,16 @@ import {
X, X,
} from "lucide-react"; } from "lucide-react";
import { YouTubeIcon } from "../lib/brandIcons"; import { YouTubeIcon } from "../lib/brandIcons";
import { Decision, Sample, Variant } from "./labKit"; import { Reference, Sample } from "./labKit";
import { Badge } from "../components/ui/Badge"; import { Badge } from "../components/ui/Badge";
import { SURFACE } from "../components/ui/surfaces"; import { SURFACE } from "../components/ui/surfaces";
import { buttonClass, iconButtonClass } from "../components/ui/buttonStyles"; import { buttonClass, iconButtonClass } from "../components/ui/buttonStyles";
import { fieldClass } from "../components/ui/fieldStyles"; import { fieldClass } from "../components/ui/fieldStyles";
import { MEDIA_BADGE } from "../components/ui/mediaCardStyles"; import { MEDIA_BADGE } from "../components/ui/mediaCardStyles";
// The Files round: the one library that replaces the Downloads page. Static markup on purpose (the // Files, the one library, DECIDED (owner, 2026-10-11): the page that replaced Downloads. Static markup
// lab's rule): the sample rows are the owner's own kind of content, nothing here talks to the server. // on purpose (the lab's rule): the sample rows are the owner's own kind of content, nothing here talks
// to the server.
const ICON = iconButtonClass({ variant: "ghost", size: "sm" }); const ICON = iconButtonClass({ variant: "ghost", size: "sm" });
@@ -158,47 +159,7 @@ function SourceBadge({ item }: { item: Item }) {
); );
} }
function StateBadges({ item }: { item: Item }) { function Thumb({ item, className }: { item: Item; className: string }) {
return (
<>
{item.state === "phone" && (
<Badge title="Telefonon is lejátszható">
<Smartphone />
<span className="text-success">✓</span>
</Badge>
)}
{item.state === "converting" && (
<Badge tone="accent">
<Loader2 className="animate-spin" />
Telefonra alakítás…
</Badge>
)}
{item.retention === "kept" && (
<Badge>
<Pin />
Megtartva
</Badge>
)}
{item.retention && item.retention !== "kept" && item.retention.days <= 7 && (
<Badge tone="warn">
<Clock />
Lejár {item.retention.days} nap múlva
</Badge>
)}
</>
);
}
function Thumb({
item,
className,
clipOnPicture = false,
}: {
item: Item;
className: string;
/** A grid card marks a clip on its picture; a small list thumb has no room beside the length. */
clipOnPicture?: boolean;
}) {
const Glyph = item.kind === "video" ? null : FileText; const Glyph = item.kind === "video" ? null : FileText;
return ( return (
<div <div
@@ -210,31 +171,37 @@ function Thumb({
> >
{Glyph && <Glyph className="absolute inset-0 m-auto size-6 text-muted" />} {Glyph && <Glyph className="absolute inset-0 m-auto size-6 text-muted" />}
{item.length && <span className={clsx(MEDIA_BADGE, "bottom-1 right-1")}>{item.length}</span>} {item.length && <span className={clsx(MEDIA_BADGE, "bottom-1 right-1")}>{item.length}</span>}
{clipOnPicture && item.clip && (
<span className={clsx(MEDIA_BADGE, "left-1 top-1 inline-flex items-center gap-0.5")}>
<Scissors className="size-3" />
klip
</span>
)}
</div> </div>
); );
} }
function RowActions({ item }: { item: Item }) { // Only the ⋯ holds a place: the quick icons come up over the date while the row is hovered or holds
// the keyboard (the mail-list pattern), so the name gets their room. An action that does not apply
// keeps its slot empty (a file has no "copy the play link").
function RowActions({ item, focused }: { item: Item; focused: boolean }) {
return ( return (
<span className="flex shrink-0 items-center gap-0.5"> <span className="relative flex items-center justify-end">
{item.kind === "video" ? ( <span
<span className={ICON} title="Link másolása"> className={clsx(
<Link /> "absolute right-full top-1/2 flex -translate-y-1/2 items-center gap-0.5 rounded-md bg-bg transition-opacity",
focused
? "opacity-100"
: "pointer-events-none opacity-0 group-hover:pointer-events-auto group-hover:opacity-100",
)}
>
{item.kind === "video" ? (
<span className={ICON} title="Link másolása">
<Link />
</span>
) : (
<span className="size-7" aria-hidden />
)}
<span className={ICON} title="Megosztás">
<Share2 />
</span>
<span className={ICON} title="Letöltés">
<Download />
</span> </span>
) : (
<span className="size-7" aria-hidden />
)}
<span className={ICON} title="Megosztás">
<Share2 />
</span>
<span className={ICON} title="Letöltés">
<Download />
</span> </span>
<span className={ICON} title="Továbbiak"> <span className={ICON} title="Továbbiak">
<MoreHorizontal /> <MoreHorizontal />
@@ -243,7 +210,7 @@ function RowActions({ item }: { item: Item }) {
); );
} }
// ── The page frame: sidebar + toolbar, shared by both layouts ──────────────────────────────────── // ── The page frame: sidebar + toolbar ────────────────────────────────────────────────────────────
function SideEntry({ function SideEntry({
icon: Icon, icon: Icon,
@@ -303,7 +270,7 @@ function Sidebar() {
); );
} }
function Toolbar({ grid }: { grid: boolean }) { function Toolbar() {
return ( return (
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className={clsx(fieldClass({ size: "sm" }), "flex min-w-64 flex-1 items-center gap-2")}> <span className={clsx(fieldClass({ size: "sm" }), "flex min-w-64 flex-1 items-center gap-2")}>
@@ -325,10 +292,10 @@ function Toolbar({ grid }: { grid: boolean }) {
Feltöltés Feltöltés
</span> </span>
<span className="flex"> <span className="flex">
<span className={iconButtonClass({ variant: "ghost", size: "sm" })} aria-pressed={!grid}> <span className={iconButtonClass({ variant: "ghost", size: "sm" })} aria-pressed>
<List /> <List />
</span> </span>
<span className={iconButtonClass({ variant: "ghost", size: "sm" })} aria-pressed={grid}> <span className={iconButtonClass({ variant: "ghost", size: "sm" })} aria-pressed={false}>
<LayoutGrid /> <LayoutGrid />
</span> </span>
</span> </span>
@@ -336,7 +303,7 @@ function Toolbar({ grid }: { grid: boolean }) {
); );
} }
function Frame({ grid, children }: { grid: boolean; children: ReactNode }) { function Frame({ children }: { children: ReactNode }) {
return ( return (
<div className="flex gap-4 overflow-x-auto rounded-xl border border-border/60 p-3"> <div className="flex gap-4 overflow-x-auto rounded-xl border border-border/60 p-3">
<Sidebar /> <Sidebar />
@@ -348,21 +315,20 @@ function Frame({ grid, children }: { grid: boolean; children: ReactNode }) {
Legújabb elöl <ChevronDown className="size-3.5" /> Legújabb elöl <ChevronDown className="size-3.5" />
</span> </span>
</div> </div>
<Toolbar grid={grid} /> <Toolbar />
{children} {children}
</div> </div>
</div> </div>
); );
} }
// ── F1: the list and the grid ──────────────────────────────────────────────────────────────────── // ── F1: the list ─────────────────────────────────────────────────────────────────────────────────
// One grid for the whole list, each row a subgrid: every kind of value sits in its own column, so the // One grid for the whole list, each row a subgrid: every kind of value sits in its own column, so the
// badges, the sizes and the action icons line up down the page. An action that does not apply keeps // badges, the sizes and the dates line up down the page.
// its slot empty (a file has no "copy the play link").
const LIST_COLUMNS = const LIST_COLUMNS =
"grid grid-cols-[4rem_minmax(0,1fr)_7rem_6.5rem_7.5rem_4.5rem_4.5rem_auto] items-center gap-x-3"; "grid grid-cols-[4rem_minmax(0,1fr)_8rem_6.5rem_6.75rem_5.5rem_6.5rem_3rem] items-center gap-x-3";
const ROW = "col-span-full grid grid-cols-subgrid items-center rounded-lg px-2 py-1.5"; const ROW = "group col-span-full grid grid-cols-subgrid items-center rounded-lg px-2 py-1.5";
function ListHead() { function ListHead() {
const th = "text-2xs uppercase tracking-wider text-muted"; const th = "text-2xs uppercase tracking-wider text-muted";
@@ -447,7 +413,7 @@ function ListRows() {
</span> </span>
<span className="text-right text-xs tabular-nums text-muted">{item.size}</span> <span className="text-right text-xs tabular-nums text-muted">{item.size}</span>
<span className="text-right text-xs text-muted">{item.when}</span> <span className="text-right text-xs text-muted">{item.when}</span>
<RowActions item={item} /> <RowActions item={item} focused={i === 3} />
</div> </div>
))} ))}
</div> </div>
@@ -496,32 +462,6 @@ function RowMenu() {
); );
} }
function GridCards() {
return (
<div className="grid grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-3">
{ITEMS.map((item, i) => (
<div
key={item.name}
className={clsx(
"flex flex-col gap-1.5 rounded-xl p-1.5",
i === 3 && "bg-accent/8 ring-1 ring-accent/40",
)}
>
<Thumb item={item} className="aspect-video w-full" clipOnPicture />
<span className="line-clamp-2 text-sm font-medium leading-5">{item.name}</span>
<span className="flex flex-wrap items-center gap-1 text-xs text-muted">
<SourceBadge item={item} />
{item.by && <span className="truncate">{item.by}</span>}
</span>
<span className="flex flex-wrap items-center gap-1">
<StateBadges item={item} />
</span>
</div>
))}
</div>
);
}
// ── F2: the download tray ──────────────────────────────────────────────────────────────────────── // ── F2: the download tray ────────────────────────────────────────────────────────────────────────
function TrayRow({ title, site, children }: { title: string; site: string; children: ReactNode }) { function TrayRow({ title, site, children }: { title: string; site: string; children: ReactNode }) {
@@ -536,13 +476,15 @@ function TrayRow({ title, site, children }: { title: string; site: string; child
); );
} }
function Progress({ pct }: { pct: number }) { function Progress({ step, pct }: { step: string; pct: number }) {
return ( return (
<span className="flex items-center gap-2 text-xs text-muted"> <span className="flex items-center gap-2 text-xs text-muted">
<span className="h-1 flex-1 overflow-hidden rounded-full bg-fg/10"> <span className="h-1 flex-1 overflow-hidden rounded-full bg-fg/10">
<span className="block h-full rounded-full bg-accent" style={{ width: `${pct}%` }} /> <span className="block h-full rounded-full bg-accent" style={{ width: `${pct}%` }} />
</span> </span>
<span className="w-8 text-right tabular-nums">{pct}%</span> <span className="tabular-nums">
{step} · {pct}%
</span>
</span> </span>
); );
} }
@@ -561,7 +503,7 @@ function TrayPanel() {
</span> </span>
</div> </div>
<TrayRow title="Ez a nap is eljött" site="tiktok.com"> <TrayRow title="Ez a nap is eljött" site="tiktok.com">
<Progress pct={64} /> <Progress step="Videó letöltése" pct={64} />
</TrayRow> </TrayRow>
<TrayRow title="Rainbow Six Mobile – teljes meccs" site="YouTube"> <TrayRow title="Rainbow Six Mobile – teljes meccs" site="YouTube">
<span className="text-xs text-muted">Sorban áll…</span> <span className="text-xs text-muted">Sorban áll…</span>
@@ -603,20 +545,6 @@ function TrayPill() {
); );
} }
function RailBell() {
return (
<div className="flex items-start gap-3">
<span className="relative grid size-10 place-items-center rounded-xl bg-accent/12 text-accent">
<Download className="size-5" />
<span className="absolute -right-1 -top-1 grid size-4 place-items-center rounded-full bg-accent text-2xs font-bold text-accent-fg">
3
</span>
</span>
<TrayPanel />
</div>
);
}
// ── F3: the done notification ──────────────────────────────────────────────────────────────────── // ── F3: the done notification ────────────────────────────────────────────────────────────────────
function DoneToast() { function DoneToast() {
@@ -647,84 +575,44 @@ function DoneToast() {
export function FilesRound() { export function FilesRound() {
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<Decision <Reference
id="files-layout" id="files-layout"
code="F1" code="F1"
title="The Files page" title="The Files page"
question="One library for everything: how does a folder show its items? Both have the same sidebar (sources, own folders, shared with me, storage), the paste box and the same badges: source + channel, phone-safe ✓, expiry when ≤7 days, kept. The highlighted item is the one just downloaded (focus on arrival)." rule="One library for videos and plain files alike. 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. Every kind of value in its own column (name, source, phone-safe, retention, size, added), one grid with a subgrid per row so they line up. 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 on a phone keeps them in its own line), and an action that does not apply keeps its slot empty. Every row has a right-click menu, the ⋯ opens the same one. The item just downloaded arrives focused (highlighted)."
now="Downloads › Library: one tall card per item, no source, no folders, a row of three icons; the paste box lives on the Queue tab."
> >
<Variant <Frame>
letter="A" <ListRows />
name="List rows (grid one click away)" </Frame>
recommended <Sample label="Right click on a row (the ⋯ opens the same menu)">
why="A library holds videos AND files (a hub report, a PDF): rows read both, show 12–15 items a screen, and put size and date in columns you can scan. The grid stays a toggle, remembered per folder, for a folder of clips." <RowMenu />
> </Sample>
<Frame grid={false}> </Reference>
<ListRows />
</Frame>
<Sample label="Right click on a row (the ⋯ opens the same menu)">
<RowMenu />
</Sample>
</Variant>
<Variant
letter="B"
name="Media grid (list one click away)"
why="Pictures first, like the feed: best for a folder of clips, but a text file or a PDF becomes a big grey card, and 6–8 items fit a screen."
>
<Frame grid>
<GridCards />
</Frame>
</Variant>
</Decision>
<Decision <Reference
id="files-tray" id="files-tray"
code="F2" code="F2"
title="Running downloads" title="Running downloads"
question="Downloading is an action now, so where do the running and stuck ones show — on every page, not only in Files?" rule="An app-wide tray, bottom right, shown only while something runs or failed: a failure stays with its reason and a retry, and it goes when all is done. A running row names its step beside its bar. It folds to a pill, so it never covers a player control."
now="the Queue tab of the Downloads page: you have to go there to see it."
> >
<div className="grid grid-cols-[repeat(auto-fit,minmax(22rem,1fr))] gap-4"> <div className="flex flex-wrap items-start gap-4">
<Variant <Sample label="Open">
letter="A" <TrayPanel />
name="A tray, bottom right (Drive)" </Sample>
recommended <Sample label="Folded">
why="It appears by itself while something runs and goes when all is done; a failure stays with its reason and a retry. Folds to a pill, so it never covers a player control." <TrayPill />
> </Sample>
<Sample label="Open">
<TrayPanel />
</Sample>
<Sample label="Folded">
<TrayPill />
</Sample>
</Variant>
<Variant
letter="B"
name="A rail icon with a count, the list in a popover"
why="Nothing floats over the page, but a running download is invisible until you look, and the rail grows a sixth always-there icon."
>
<RailBell />
</Variant>
</div> </div>
</Decision> </Reference>
<Decision <Reference
id="files-done" id="files-done"
code="F3" code="F3"
title="When a download is done" title="When a download is done"
question="The daily path is paste → done → copy the link → send. Can the notification finish it?" rule="The notification says whether it plays on a phone and carries Link másolása and Megnyitás (its folder, the item focused), so paste → done → copy the link ends there. Never an automatic jump: it would pull you out of what you were doing."
now="a generic 'ready' notification; you open Library, check it plays, then press the share icon."
> >
<Variant <DoneToast />
letter="A" </Reference>
name="The notification carries the link"
recommended
why="It says whether it plays on a phone and copies the link in one click; Megnyitás opens its folder with the item focused. No jump on its own — it would pull you out of what you were doing."
>
<DoneToast />
</Variant>
</Decision>
</div> </div>
); );
} }
+5 -75
View File
@@ -1,12 +1,12 @@
import { useSyncExternalStore, type ReactNode } from "react"; import { useSyncExternalStore, type ReactNode } from "react";
import clsx from "clsx"; import clsx from "clsx";
import { Badge } from "../components/ui/Badge";
import { SURFACE } from "../components/ui/surfaces"; import { SURFACE } from "../components/ui/surfaces";
// Building blocks of the design lab. A taken decision is a <Reference>: the chosen rendering with the // Building blocks of the design lab. Every decision in it is taken, so the lab is the reference half
// rule it became. An open design round shows its questions as <Decision>s with their <Variant>s side // only: each <Reference> is the chosen rendering with the rule it became. A new design round brings
// by side, until the owner picks. Everything // back a side-by-side <Decision>/<Variant> pair (see git history) for its open questions, and deletes
// is static markup on purpose: a rendering that only exists here must not need app state. // it again once the owner picks. Everything is static markup on purpose: a rendering that only exists
// here must not need app state.
/** A decision already taken: the chosen rendering only, with the rule it became. The lab's /** A decision already taken: the chosen rendering only, with the rule it became. The lab's
* reference half — the living style guide. */ * reference half — the living style guide. */
@@ -37,76 +37,6 @@ export function Reference({
); );
} }
/** An open question of a design round: what the app does today, then the variants side by side. */
export function Decision({
id,
code,
title,
question,
now,
children,
}: {
id: string;
code: string;
title: string;
question: string;
/** What the app does today. */
now: ReactNode;
children: ReactNode;
}) {
return (
<section id={id} className={`${SURFACE.panel} p-5 scroll-mt-20`}>
<header className="mb-4">
<div className="flex items-baseline gap-3">
<span className="text-xs font-bold tracking-widest text-accent">{code}</span>
<h3 className="text-lg font-bold">{title}</h3>
</div>
<p className="mt-1 text-sm">{question}</p>
<p className="mt-1 text-xs text-muted">
<span className="font-semibold">Today: </span>
{now}
</p>
</header>
<div className="flex flex-col gap-4">{children}</div>
</section>
);
}
/** One answer to a Decision; the recommended one carries the accent edge. */
export function Variant({
letter,
name,
recommended = false,
why,
children,
}: {
letter: string;
name: string;
recommended?: boolean;
why: string;
children: ReactNode;
}) {
return (
<article
className={clsx(
SURFACE.card,
"flex flex-col gap-3 border p-4",
recommended ? "border-accent" : "border-transparent",
)}
>
<div className="flex items-center gap-2">
<span className="grid h-6 w-6 place-items-center rounded-full bg-fg/8 text-xs font-bold">
{letter}
</span>
<h4 className="flex-1 text-sm font-semibold">{name}</h4>
{recommended && <Badge tone="accent">Recommended</Badge>}
</div>
<div className="flex flex-col gap-3">{children}</div>
<p className="mt-auto border-t border-border/60 pt-2 text-xs text-muted">{why}</p>
</article>
);
}
/** A small caption above a sample inside a variant. */ /** A small caption above a sample inside a variant. */
export function Sample({ label, children }: { label: string; children: ReactNode }) { export function Sample({ label, children }: { label: string; children: ReactNode }) {
return ( return (
+38 -141
View File
@@ -16,13 +16,13 @@ import {
Users, Users,
X, X,
} from "lucide-react"; } from "lucide-react";
import { Decision, Sample, Variant } from "./labKit"; import { Reference, Sample } from "./labKit";
import { SURFACE } from "../components/ui/surfaces"; import { SURFACE } from "../components/ui/surfaces";
import { buttonClass, iconButtonClass } from "../components/ui/buttonStyles"; import { buttonClass, iconButtonClass } from "../components/ui/buttonStyles";
import { MEDIA_BADGE } from "../components/ui/mediaCardStyles"; import { MEDIA_BADGE } from "../components/ui/mediaCardStyles";
// The folder links round: a /watch link that opens a whole folder, not one file. Static markup on // Folder links, DECIDED (owner A/A/A): a /watch link that opens a whole folder, not one file. Static
// purpose (the lab's rule); the folder and its files are the owner's own kind of content. // markup on purpose (the lab's rule); the folder and its files are the owner's own kind of content.
const ICON = iconButtonClass({ variant: "ghost", size: "sm" }); const ICON = iconButtonClass({ variant: "ghost", size: "sm" });
@@ -91,15 +91,13 @@ function Rail({ marked }: { marked: boolean }) {
); );
} }
function FolderMenu({ quickCopy }: { quickCopy: boolean }) { function FolderMenu() {
const row = "flex items-center gap-2.5 rounded-lg px-2 py-1.5 text-sm"; const row = "flex items-center gap-2.5 rounded-lg px-2 py-1.5 text-sm";
const groups: [typeof Folder, string, boolean?][][] = [ const groups: [typeof Folder, string, boolean?][][] = [
quickCopy [
? [ [Link2, "Link másolása"],
[Link2, "Link másolása"], [Users, "Megosztás linkkel…"],
[Users, "Megosztás linkkel…"], ],
]
: [[Users, "Megosztás linkkel…"]],
[ [
[Clock, "Megőrzés"], [Clock, "Megőrzés"],
[Pencil, "Átnevezés"], [Pencil, "Átnevezés"],
@@ -266,155 +264,54 @@ function ListPage({ phone, playing }: { phone?: boolean; playing?: boolean }) {
); );
} }
function GridPage() {
return (
<PageFrame>
<Header />
<div className="grid grid-cols-2 gap-3">
{FILES.map((f) => (
<div key={f.name} className="flex flex-col gap-1">
<Thumb file={f} className="aspect-video w-full" />
<span className="truncate text-sm">{f.name}</span>
<span className="flex items-center gap-1 text-xs text-muted">
<span className="flex-1">{f.size}</span>
<Download className="size-3.5" />
</span>
</div>
))}
</div>
</PageFrame>
);
}
function DownloadOnlyPage() {
return (
<PageFrame>
<Header />
<div className="flex flex-col divide-y divide-border/60">
{FILES.map((f) => (
<div key={f.name} className="flex items-center gap-3 py-2">
<FileText className="size-4 shrink-0 text-muted" />
<span className="min-w-0 flex-1 truncate text-sm">{f.name}</span>
<span className="text-xs text-muted tabular-nums">{f.size}</span>
<span className={buttonClass({ variant: "outline", size: "xs" })}>
<Download className="size-3.5" /> Letöltés
</span>
</div>
))}
</div>
</PageFrame>
);
}
// ── The round ─────────────────────────────────────────────────────────────────────────────────── // ── The round ───────────────────────────────────────────────────────────────────────────────────
export function LinksRound() { export function LinksRound() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-4">
<Decision <Reference
id="open-links-l1" id="links-l1"
code="L1" code="L1"
title="Making a folder link" title="Making a folder link"
question="A folder's right-click menu gets the share. Does it also get the one-click “Link másolása” a file row has, or only the dialog? The dialog is the file's Share dialog with only its link half (sharing with a user stays per file)." rule="An own folder's right-click menu has Link másolása and Megosztás linkkel…, the two entries a file row has, in the same order: one click copies the folder's link (making one the first time), the dialog is for a password, an expiry or revoking it. The dialog is the file's Share dialog with only its link half (sharing with a user stays per file), and it says what a folder link means: whoever has it sees the folder as it is NOW, files put in later included."
now="Only a file can be shared; a folder menu has Retention, Rename and Delete."
> >
<div className="grid gap-4 xl:grid-cols-3"> <div className="flex flex-wrap items-start gap-4">
<Variant <Sample label="the folder's right-click menu">
letter="A" <div className="flex gap-3">
name="Copy link + Share with link…" <Rail marked={false} />
recommended <FolderMenu />
why="The same two entries a file row has, in the same order: one click copies the folder's link (making one the first time), the dialog is for a password, an expiry or revoking it." </div>
> </Sample>
<Sample label="the folder's right-click menu"> <Sample label="the dialog Megosztás linkkel… opens">
<div className="flex gap-3">
<Rail marked={false} />
<FolderMenu quickCopy />
</div>
</Sample>
</Variant>
<Variant
letter="B"
name="Share with link… only"
why="One entry: every folder link goes through the dialog. One more click for the common case, nothing gained but a shorter menu."
>
<Sample label="the folder's right-click menu">
<FolderMenu quickCopy={false} />
</Sample>
</Variant>
<Variant
letter="A/B"
name="…the dialog either opens"
recommended
why="It says what a folder link means: whoever has it sees the folder as it is NOW, files put in later included."
>
<FolderShareDialog /> <FolderShareDialog />
</Variant> </Sample>
</div> </div>
</Decision> </Reference>
<Decision <Reference
id="open-links-l2" id="links-l2"
code="L2" code="L2"
title="The page a folder link opens" title="The page a folder link opens"
question="Signed out, often on a phone: the folder's name and its files. Each video plays right there, each file downloads (when the link allows it); a document is never opened as a web page, only downloaded." rule="Signed out, often on a phone: the folder's name, then a list of its files as it is now. Play opens the watch page's player above the list with the playing row lit, the next one a tap away; each file downloads when the link allows it, and a document is never opened as a web page, only downloaded. A row without a picture keeps its glyph above the length."
now="A /watch link opens one file: its player, title and Download."
> >
<div className="grid gap-4 xl:grid-cols-3"> <div className="grid items-start gap-4 xl:grid-cols-[minmax(0,42rem)_auto]">
<Variant <Sample label="desktop, a video playing">
letter="A" <ListPage playing />
name="A list; Play opens the player above it" </Sample>
recommended <Sample label="phone">
why="The watch page's player, over a list you can read on a phone: a long name gets a full line, the playing row stays lit, the next one is one tap away. Mostly videos from a phone — this is the YouTube-playlist shape people know." <ListPage phone />
> </Sample>
<Sample label="desktop, a video playing">
<ListPage playing />
</Sample>
<Sample label="phone">
<ListPage phone />
</Sample>
</Variant>
<Variant
letter="B"
name="Cards"
why="Pictures first, like the Files grid. Nicer for a folder of photos-like clips, but names truncate to a line and a document is a grey card."
>
<GridPage />
</Variant>
<Variant
letter="C"
name="A download list only"
why="Like a file-transfer page: name, size, Letöltés. Simplest, but a friend has to download a clip to see it — on a phone, on mobile data."
>
<DownloadOnlyPage />
</Variant>
</div> </div>
</Decision> </Reference>
<Decision <Reference
id="open-links-l3" id="links-l3"
code="L3" code="L3"
title="Seeing which folders are shared" title="Seeing which folders are shared"
question="A folder link keeps showing whatever you put in the folder later. Does the rail mark a folder that has a live link, so you know where you drop files in public?" rule="The rail marks a folder that has a live link with a quiet link icon beside its count: a folder link is the one place where moving a file in publishes it. The mark goes when the last link is revoked or runs out."
now="Nothing marks a shared file or folder."
> >
<div className="grid gap-4 xl:grid-cols-2"> <Rail marked />
<Variant </Reference>
letter="A"
name="A link mark beside the count"
recommended
why="A folder link is the one place where moving a file in publishes it. The mark is quiet (one small icon), and it goes when the last link is revoked or runs out."
>
<Rail marked />
</Variant>
<Variant
letter="B"
name="No mark"
why="A cleaner rail. You learn a folder is shared only from its Share dialog."
>
<Rail marked={false} />
</Variant>
</div>
</Decision>
</div> </div>
); );
} }
+30 -175
View File
@@ -2,7 +2,6 @@ import clsx from "clsx";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { import {
AlertTriangle, AlertTriangle,
Check,
Copy, Copy,
Folder, Folder,
KeyRound, KeyRound,
@@ -12,15 +11,16 @@ import {
SlidersHorizontal, SlidersHorizontal,
X, X,
} from "lucide-react"; } from "lucide-react";
import { Decision, Sample, Variant } from "./labKit"; import { Reference, Sample } from "./labKit";
import { Badge } from "../components/ui/Badge"; import { Badge } from "../components/ui/Badge";
import { Chip } from "../components/ui/Chip"; import { Chip } from "../components/ui/Chip";
import { SURFACE } from "../components/ui/surfaces"; import { SURFACE } from "../components/ui/surfaces";
import { buttonClass, iconButtonClass } from "../components/ui/buttonStyles"; import { buttonClass, iconButtonClass } from "../components/ui/buttonStyles";
import { fieldClass } from "../components/ui/fieldStyles"; import { fieldClass } from "../components/ui/fieldStyles";
// The API tokens round: how a machine (the hub) gets a key to the Files library. Static markup on // API tokens, DECIDED (owner A/A/A): how a machine (the hub) gets a key to the Files library. Static
// purpose (the lab's rule); the sample tokens are the owner's own kind (the hub, a phone script). // markup on purpose (the lab's rule); the sample tokens are the owner's own kind (the hub, a phone
// script).
const ICON = iconButtonClass({ variant: "ghost", size: "sm" }); const ICON = iconButtonClass({ variant: "ghost", size: "sm" });
const noop = () => {}; const noop = () => {};
@@ -143,49 +143,6 @@ function ListRows() {
); );
} }
function ListTable() {
return (
<div className="overflow-x-auto">
<table className="w-full text-left text-xs">
<thead className="text-muted">
<tr className="border-b border-border/60">
<th className="py-1.5 pr-3 font-medium">Név</th>
<th className="py-1.5 pr-3 font-medium">Mappák</th>
<th className="py-1.5 pr-3 font-medium">Használva</th>
<th className="py-1.5 pr-3 font-medium">Lejár</th>
<th />
</tr>
</thead>
<tbody>
{TOKENS.map((tk) => (
<tr key={tk.prefix} className="border-b border-border/40 align-top">
<td className="py-2 pr-3">
<div className="text-sm font-medium">{tk.name}</div>
<code className="text-muted">{tk.prefix}…</code>
</td>
<td className="py-2 pr-3 text-muted">{tk.grants.map((g) => g.folder).join(", ")}</td>
<td className="py-2 pr-3 text-muted whitespace-nowrap">{tk.used}</td>
<td
className={clsx(
"py-2 pr-3 whitespace-nowrap",
tk.soon ? "text-warn" : "text-muted",
)}
>
{tk.expires}
</td>
<td className="py-1">
<span className={ICON} aria-label="Műveletek">
<MoreHorizontal className="size-4" />
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
// ── T2 · the new-token dialog ─────────────────────────────────────────────────────────────────── // ── T2 · the new-token dialog ───────────────────────────────────────────────────────────────────
function Dialog({ function Dialog({
@@ -220,22 +177,16 @@ function Field({ label, children }: { label: string; children: ReactNode }) {
); );
} }
function Expiry({ never = false }: { never?: boolean }) { function Expiry() {
return ( return (
<Field label="Lejárat"> <Field label="Lejárat">
<span className="flex flex-wrap gap-1"> <span className="flex flex-wrap gap-1">
{["30 nap", "90 nap", "1 év", "Soha"].map((l) => ( {["30 nap", "90 nap", "1 év", "Soha"].map((l) => (
<Chip key={l} pressed={never ? l === "Soha" : l === "1 év"} onClick={noop}> <Chip key={l} pressed={l === "1 év"} onClick={noop}>
{l} {l}
</Chip> </Chip>
))} ))}
</span> </span>
{never && (
<span className="flex items-start gap-1.5 text-xs text-warn">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
Egy soha le nem járó token addig nyitja a mappát, amíg vissza nem vonod.
</span>
)}
</Field> </Field>
); );
} }
@@ -325,30 +276,6 @@ function PresetFirst({ custom }: { custom: boolean }) {
); );
} }
function OneForm() {
return (
<Dialog
title="Új API-token"
foot={
<>
<span className={buttonClass({ variant: "outline", size: "sm" })}>Mégse</span>
<span className={buttonClass({ variant: "primary", size: "sm" })}>Létrehozás</span>
</>
}
>
<Field label="Név">
<span className={clsx(fieldClass({ size: "sm" }), "flex items-center")}>Hub csere</span>
</Field>
<span className="flex items-center gap-2 text-xs text-muted">
Mappák és jogok
<span className={buttonClass({ variant: "link", size: "none" })}>Hub csere kitöltése</span>
</span>
<RightsMatrix />
<Expiry never />
</Dialog>
);
}
// ── T3 · the token, shown once ────────────────────────────────────────────────────────────────── // ── T3 · the token, shown once ──────────────────────────────────────────────────────────────────
const RAW = "sfl_7Qm2xK9aVt3pLc8RzN0eWq5uYb1HdJ6gFs4kMx2oTi9a3F7kQe"; const RAW = "sfl_7Qm2xK9aVt3pLc8RzN0eWq5uYb1HdJ6gFs4kMx2oTi9a3F7kQe";
@@ -396,120 +323,48 @@ function ShownInDialog() {
); );
} }
function ShownAsBanner() {
return (
<SettingsFrame>
<div className="flex flex-col gap-2 rounded-lg border border-accent/50 bg-accent/6 p-3">
<span className="flex items-center gap-1.5 text-sm font-medium">
<Check className="size-4 text-success" />
„Hub csere” elkészült — másold ki most, később nem látod.
</span>
<CopyRow>{RAW}</CopyRow>
<span className={clsx(buttonClass({ variant: "link", size: "none" }), "self-start")}>
curl példa másolása
</span>
</div>
<ListRows />
</SettingsFrame>
);
}
// ── The round ─────────────────────────────────────────────────────────────────────────────────── // ── The round ───────────────────────────────────────────────────────────────────────────────────
export function TokensRound() { export function TokensRound() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-4">
<Decision <Reference
id="open-tokens-t1" id="tokens-t1"
code="T1" code="T1"
title="The token list (Settings → API tokens)" title="The token list (Settings → API tokens)"
question="How does the tab list the tokens: what each opens, when it was last used, when it runs out? Revoke is in each row's “…” menu, behind a confirm." rule="A row per token in three lines: name + prefix (the “soon expires” badge beside the name), then every folder with its rights, then last use and expiry — nothing truncated at the ~36rem Settings column, and it fits a phone as is. Revoke is in the row's ⋯ menu, behind a confirm."
now="There is no token UI; tokens exist only through the API."
> >
<div className="grid gap-4 xl:grid-cols-2"> <SettingsFrame>
<Variant <ListRows />
letter="A" </SettingsFrame>
name="Rows, three lines each" </Reference>
recommended
why="The Settings column is ~36rem: a row reads name + prefix, then every folder with its rights, then last use and expiry — nothing truncated, and the “soon expires” badge sits beside the name. Fits a phone as is."
>
<SettingsFrame>
<ListRows />
</SettingsFrame>
</Variant>
<Variant
letter="B"
name="A table"
why="Scans in columns like the Download formats table, but at this width the folders column truncates to names (rights drop out) and a phone needs the card fallback anyway."
>
<SettingsFrame>
<ListTable />
</SettingsFrame>
</Variant>
</div>
</Decision>
<Decision <Reference
id="open-tokens-t2" id="tokens-t2"
code="T2" code="T2"
title="Making a token" title="Making a token"
question="How do you make one: the one-click Hub csere, or a token of your own folders and rights? Expiry: 30 days, 90 days, 1 year (default) or never (with a warning)." rule="The dialog asks the kind first. Hub csere: one tile, the name filled in, Create — the common token never shows a matrix to get wrong. Egyedi: each folder a row, its rights as chips; a source folder has no Write (nothing can be uploaded into it, the server refuses it too). Expiry is a segmented control (DESIGN.md “A choice between a few values”): 30 days, 90 days, 1 year (the default) or never, which warns that the token opens the folder until revoked."
now="Nothing in the app."
> >
<div className="grid gap-4 xl:grid-cols-3"> <div className="grid items-start gap-4 xl:grid-cols-2">
<Variant <Sample label="Hub csere">
letter="A"
name="Pick the kind first — Hub csere"
recommended
why="The hub is the case you have: one tile, the name filled in, Create. The rights grid only appears for “Egyedi”, so the common token never shows a matrix to get wrong."
>
<PresetFirst custom={false} /> <PresetFirst custom={false} />
</Variant> </Sample>
<Variant <Sample label="Egyedi">
letter="A"
name="…the same, Egyedi"
recommended
why="Each folder a row, its rights as chips. A source folder has no Write: nothing can be uploaded into it (the server refuses it too)."
>
<PresetFirst custom /> <PresetFirst custom />
</Variant> </Sample>
<Variant
letter="B"
name="One form, a preset link"
why="Everything at once, with “Hub csere kitöltése” filling the grid. Fewer clicks for a custom token, but the hub case starts from a matrix. Shown with “never” picked, and its warning."
>
<OneForm />
</Variant>
</div> </div>
</Decision> </Reference>
<Decision <Reference
id="open-tokens-t3" id="tokens-t3"
code="T3" code="T3"
title="The token, shown once" title="The token, shown once"
question="Only its fingerprint is stored, so the token can be copied once. Where does it show, with a ready curl line to try it?" rule="Only its fingerprint is stored, so the token shows once, as the dialog's last step: the token with a Copy, a curl line to try it, and Kész. Closing the dialog is plainly the moment it is gone."
now="Nothing in the app."
> >
<div className="grid gap-4 xl:grid-cols-2"> <Sample label="after Létrehozás">
<Variant <ShownInDialog />
letter="A" </Sample>
name="The dialog's last step" </Reference>
recommended
why="You are already looking at the dialog: the token, a Copy, a curl line to try it, and “Kész”. Closing is the moment it is gone, and it is plain that it is."
>
<Sample label="after Létrehozás">
<ShownInDialog />
</Sample>
</Variant>
<Variant
letter="B"
name="A banner above the list"
why="The dialog closes and the token waits above the list until you leave the tab. The new row is right under it, but a banner is easy to scroll past — and leaving the tab loses it for good."
>
<ShownAsBanner />
</Variant>
</div>
</Decision>
</div> </div>
); );
} }
@@ -94,6 +94,7 @@
"linkCopied": "Share link copied to clipboard", "linkCopied": "Share link copied to clipboard",
"openNewTab": "Open in new tab", "openNewTab": "Open in new tab",
"linkFailed": "Couldn’t create the share link", "linkFailed": "Couldn’t create the share link",
"linksFailed": "Couldn’t load the links",
"noLinks": "No links yet.", "noLinks": "No links yet.",
"newLink": "Create a link", "newLink": "Create a link",
"createLink": "Create link", "createLink": "Create link",
+5 -4
View File
@@ -99,16 +99,16 @@
"new": "New password (min. 10 characters)", "new": "New password (min. 10 characters)",
"save": "Save password", "save": "Save password",
"saving": "Saving…", "saving": "Saving…",
"saved": "Password updated for {{email}}", "saved": "Password updated for {{email}} — other sessions were signed out and your API tokens revoked",
"failed": "Couldn't update the password" "failed": "Couldn't update the password"
}, },
"sessions": { "sessions": {
"title": "Active sessions", "title": "Active sessions",
"hint": "Sign this account out everywhere else — handy if you left it signed in on a shared or lost device. You stay signed in here.", "hint": "Sign this account out everywhere else, and revoke its API tokens — handy if you left it signed in on a shared or lost device. You stay signed in here.",
"action": "Log out other sessions", "action": "Log out other sessions",
"confirmTitle": "Log out other sessions?", "confirmTitle": "Log out other sessions?",
"confirmBody": "This signs you out of every other browser and device. You'll stay signed in here.", "confirmBody": "This signs you out of every other browser and device, and revokes every API token of yours (make new ones for your machines). You'll stay signed in here.",
"done": "Signed out of all other sessions", "done": "Signed out of all other sessions; API tokens revoked",
"failed": "Couldn't sign out other sessions. Please try again." "failed": "Couldn't sign out other sessions. Please try again."
} }
}, },
@@ -284,6 +284,7 @@
"tryIt": "Try it (curl)", "tryIt": "Try it (curl)",
"copy": "Copy", "copy": "Copy",
"copied": "Copied", "copied": "Copied",
"copyField": "Copy: {{field}}",
"done": "Done" "done": "Done"
} }
} }
-1
View File
@@ -71,7 +71,6 @@
"approvalWaitingOnYou": "waiting on you", "approvalWaitingOnYou": "waiting on you",
"copied": "Copied", "copied": "Copied",
"copy": "Copy", "copy": "Copy",
"copyFailed": "Couldn't copy — select the link and copy it by hand",
"email": "Email", "email": "Email",
"emailConfirmed": "confirmed", "emailConfirmed": "confirmed",
"emailUnconfirmed": "not confirmed", "emailUnconfirmed": "not confirmed",
@@ -94,6 +94,7 @@
"linkCopied": "Megosztási link a vágólapra másolva", "linkCopied": "Megosztási link a vágólapra másolva",
"openNewTab": "Megnyitás új lapon", "openNewTab": "Megnyitás új lapon",
"linkFailed": "Nem sikerült a megosztási link létrehozása", "linkFailed": "Nem sikerült a megosztási link létrehozása",
"linksFailed": "Nem sikerült betölteni a linkeket",
"noLinks": "Még nincs link.", "noLinks": "Még nincs link.",
"newLink": "Link létrehozása", "newLink": "Link létrehozása",
"createLink": "Létrehozás", "createLink": "Létrehozás",
+5 -4
View File
@@ -99,16 +99,16 @@
"new": "Új jelszó (min. 10 karakter)", "new": "Új jelszó (min. 10 karakter)",
"save": "Jelszó mentése", "save": "Jelszó mentése",
"saving": "Mentés…", "saving": "Mentés…",
"saved": "Jelszó frissítve ehhez: {{email}}", "saved": "Jelszó frissítve ehhez: {{email}} — a többi munkamenet kijelentkezett, az API-tokenjeid visszavonva",
"failed": "Nem sikerült frissíteni a jelszót" "failed": "Nem sikerült frissíteni a jelszót"
}, },
"sessions": { "sessions": {
"title": "Aktív munkamenetek", "title": "Aktív munkamenetek",
"hint": "Jelentkeztesd ki ezt a fiókot minden más helyen — hasznos, ha megosztott vagy elveszett eszközön maradt bejelentkezve. Itt bejelentkezve maradsz.", "hint": "Jelentkeztesd ki ezt a fiókot minden más helyen, és vond vissza az API-tokenjeit — hasznos, ha megosztott vagy elveszett eszközön maradt bejelentkezve. Itt bejelentkezve maradsz.",
"action": "Kijelentkezés máshonnan", "action": "Kijelentkezés máshonnan",
"confirmTitle": "Kijelentkezés a többi munkamenetből?", "confirmTitle": "Kijelentkezés a többi munkamenetből?",
"confirmBody": "Ez kijelentkeztet minden más böngészőből és eszközről. Itt bejelentkezve maradsz.", "confirmBody": "Ez kijelentkeztet minden más böngészőből és eszközről, és visszavonja minden API-tokenedet (a gépeidnek újakat kell készítened). Itt bejelentkezve maradsz.",
"done": "Kijelentkeztetve minden más munkamenetből", "done": "Kijelentkeztetve minden más munkamenetből; API-tokenek visszavonva",
"failed": "Nem sikerült kijelentkeztetni a többi munkamenetet. Próbáld újra." "failed": "Nem sikerült kijelentkeztetni a többi munkamenetet. Próbáld újra."
} }
}, },
@@ -284,6 +284,7 @@
"tryIt": "Kipróbálás (curl)", "tryIt": "Kipróbálás (curl)",
"copy": "Másolás", "copy": "Másolás",
"copied": "Másolva", "copied": "Másolva",
"copyField": "Másolás: {{field}}",
"done": "Kész" "done": "Kész"
} }
} }
-1
View File
@@ -71,7 +71,6 @@
"approvalWaitingOnYou": "rád vár", "approvalWaitingOnYou": "rád vár",
"copied": "Másolva", "copied": "Másolva",
"copy": "Másolás", "copy": "Másolás",
"copyFailed": "Nem sikerült másolni — jelöld ki a linket, és másold kézzel",
"email": "E-mail", "email": "E-mail",
"emailConfirmed": "megerősítve", "emailConfirmed": "megerősítve",
"emailUnconfirmed": "nincs megerősítve", "emailUnconfirmed": "nincs megerősítve",
+33 -3
View File
@@ -7,15 +7,18 @@ import type { QueueJob } from "./api";
import { import {
clearFinished, clearFinished,
forgetFinished, forgetFinished,
noteAnnounced,
noteQueue, noteQueue,
resetQueueWatch, resetQueueWatch,
takeUnannounced, takeUnannounced,
uploadEnded,
uploadStarted,
useFinished, useFinished,
} from "./downloadQueue"; } from "./downloadQueue";
import { renderHook } from "@testing-library/react"; import { renderHook } from "@testing-library/react";
const job = (id: number, status: string) => ({ id, status }) as unknown as QueueJob; const job = (id: number, status: string) => ({ id, status }) as unknown as QueueJob;
const upload = (id: number) =>
({ id, status: "done", source_kind: "upload" }) as unknown as QueueJob;
const finishedIds = () => renderHook(() => useFinished()).result.current.map((j) => j.id); const finishedIds = () => renderHook(() => useFinished()).result.current.map((j) => j.id);
beforeEach(resetQueueWatch); beforeEach(resetQueueWatch);
@@ -44,12 +47,39 @@ describe("downloadQueue · a finish", () => {
it("a finish its own action announced is kept for the tray but not announced again", () => { it("a finish its own action announced is kept for the tray but not announced again", () => {
noteQueue([]); noteQueue([]);
noteAnnounced(7); // an upload, done in the answer that made it uploadStarted();
noteQueue([job(7, "done"), job(8, "done")]); uploadEnded(7); // an upload, done in the answer that made it
noteQueue([upload(7), job(8, "done")]);
expect(takeUnannounced().map((j) => j.id)).toEqual([8]); expect(takeUnannounced().map((j) => j.id)).toEqual([8]);
expect(finishedIds()).toEqual([7, 8]); expect(finishedIds()).toEqual([7, 8]);
}); });
it("an upload the queue sees done before its own answer arrives is still said once, by it", () => {
noteQueue([]);
uploadStarted();
noteQueue([upload(7)]); // the server committed it; the answer is still on its way
expect(takeUnannounced()).toEqual([]);
uploadEnded(7);
expect(takeUnannounced()).toEqual([]);
expect(finishedIds()).toEqual([7]);
});
it("another device's upload landing meanwhile is announced once this tab's upload answers", () => {
noteQueue([]);
uploadStarted();
noteQueue([upload(7), upload(9)]);
uploadEnded(7);
expect(takeUnannounced().map((j) => j.id)).toEqual([9]);
});
it("an upload that failed announced nothing: what landed meanwhile is announced", () => {
noteQueue([]);
uploadStarted();
noteQueue([upload(9)]);
uploadEnded(null);
expect(takeUnannounced().map((j) => j.id)).toEqual([9]);
});
it("a failure is not a finish; closing the tray forgets what finished", () => { it("a failure is not a finish; closing the tray forgets what finished", () => {
noteQueue([job(1, "running"), job(2, "running")]); noteQueue([job(1, "running"), job(2, "running")]);
noteQueue([job(1, "error"), job(2, "done")]); noteQueue([job(1, "error"), job(2, "done")]);
+32 -5
View File
@@ -35,6 +35,11 @@ const unannounced = createStore<QueueJob[]>([]);
const revealing = createStore<Reveal | null>(null); const revealing = createStore<Reveal | null>(null);
// Finishes this tab already said out loud where they happened: an upload that answered done. // Finishes this tab already said out loud where they happened: an upload that answered done.
const announcedHere = new Set<number>(); const announcedHere = new Set<number>();
// This tab's uploads still waiting for their answer, and the uploads the queue saw land done
// meanwhile: one of them may be the answer's own job, done before the answer arrived. Whose it is,
// only the answer can tell (`uploadEnded`), so until then none of them is announced.
let uploading = 0;
let held: QueueJob[] = [];
/** A job to show in the tray, asked for at `at` (ms). */ /** A job to show in the tray, asked for at `at` (ms). */
export interface Reveal { export interface Reveal {
@@ -54,15 +59,35 @@ export function noteQueue(jobs: QueueJob[]): void {
finished.set((list) => [...done, ...list.filter((j) => !ids.has(j.id))]); finished.set((list) => [...done, ...list.filter((j) => !ids.has(j.id))]);
// `delete` answers whether it was there: each id is spent on the one look that finds it done. // `delete` answers whether it was there: each id is spent on the one look that finds it done.
const news = done.filter((j) => !announcedHere.delete(j.id)); const news = done.filter((j) => !announcedHere.delete(j.id));
if (news.length) unannounced.set((list) => [...list, ...news]); const landed = (j: QueueJob) =>
uploading > 0 && j.source_kind === "upload" && before.get(j.id) === undefined;
held = [...held, ...news.filter(landed)];
const now = news.filter((j) => !landed(j));
if (now.length) unannounced.set((list) => [...list, ...now]);
} }
/** A finish its own action announced already (an upload done in the answer that made it): the tray /** An upload from this tab is on its way: what the queue sees land done may be its own job. */
* still keeps it, but the done notice would say it a second time — and as a download. */ export const uploadStarted = (): void => {
export const noteAnnounced = (id: number): void => { uploading += 1;
announcedHere.add(id);
}; };
/** An upload's answer came: `said` is the job it announced itself (done in that answer — the tray
* still keeps it, but its done notice would say it a second time, and as a download), null when it
* announced none (still converting, failed, cancelled). In whichever order the queue saw it. The
* rest held meanwhile (another device's uploads) are announced once no upload is waiting. */
export function uploadEnded(said: number | null): void {
uploading = Math.max(0, uploading - 1);
if (said !== null) {
const before = held.length;
held = held.filter((j) => j.id !== said);
if (held.length === before) announcedHere.add(said);
}
if (uploading > 0 || held.length === 0) return;
const release = held;
held = [];
unannounced.set((list) => [...list, ...release]);
}
/** The queue, noted (see above). Every reader of `qk.downloads()` fetches with this. */ /** The queue, noted (see above). Every reader of `qk.downloads()` fetches with this. */
export async function fetchQueue(): Promise<QueueJob[]> { export async function fetchQueue(): Promise<QueueJob[]> {
const jobs = await api.downloads(); const jobs = await api.downloads();
@@ -107,5 +132,7 @@ export function resetQueueWatch(): void {
finished.set([]); finished.set([]);
unannounced.set([]); unannounced.set([]);
announcedHere.clear(); announcedHere.clear();
uploading = 0;
held = [];
revealing.set(null); revealing.set(null);
} }