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:
+122
-121
@@ -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 |
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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's key to Files</h2>
|
<h2 className="text-xl font-bold">Decided · API tokens, a machine'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 />
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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")]);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user