From 2dbb0f48f73bfdc43f096cb0856f364eb2c6574e Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 13:57:17 +0100 Subject: [PATCH 1/2] feat(web): state ramp on status and counts chips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per amended DESIGN.md §4.2: green --signal-ok for on disk/complete, amber --signal-warn for wanted-but-missing, violet --status-airing for downloading, neutral for untracked and waiting. Stars go amber. Words stay in every chip; verdict chips untouched. --- web/src/main.ts | 103 ++++++++++++++++++++++++++-------------------- web/src/style.css | 56 +++++++++++++++++-------- 2 files changed, 98 insertions(+), 61 deletions(-) diff --git a/web/src/main.ts b/web/src/main.ts index 85f90f6..7bac268 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -715,6 +715,37 @@ function chip(text: string, extra?: (chip: HTMLSpanElement) => void): HTMLSpanEl return span; } +/** §4.2 state ramp on a media-state chip; the word carries, colour accelerates. */ +function mediaStateChip(state: string, wanted: boolean): HTMLSpanElement { + return chip(state, (span) => { + span.dataset.movieState = state; + span.dataset.wanted = String(wanted); + }); +} + +/** The ramp read off N/M itself: green when full, amber while a gap remains. */ +function countsTone(available: number, wanted: number): "complete" | "partial" | null { + if (wanted === 0) { + return null; + } + return available >= wanted ? "complete" : "partial"; +} + +function countsChip( + label: string, + available: number, + wanted: number, + ariaLabel: string, +): HTMLSpanElement { + return chip(label, (span) => { + const tone = countsTone(available, wanted); + if (tone !== null) { + span.dataset.counts = tone; + } + span.setAttribute("aria-label", ariaLabel); + }); +} + function rowTitle(title: string, year: number | null): HTMLElement { const wrap = document.createElement("span"); wrap.className = "row-id"; @@ -873,11 +904,7 @@ function libraryRow( const { item, row, body, chips } = richRow(); const root = roots.find((candidate) => candidate.id === movie.root_id); chips.append(chip(root ? root.audience : `root ${movie.root_id}`)); - chips.append( - chip(movie.state, (span) => { - span.dataset.movieState = movie.state; - }), - ); + chips.append(mediaStateChip(movie.state, movie.wanted)); // §5.7 honesty: a waived import is never presented as a clean match const waiver = waiverLabel(movie.waiver); if (waiver !== null) { @@ -1412,11 +1439,7 @@ function movieMain(board: HTMLElement, views: HideableView[]): MovieView { chipsEl.replaceChildren(); const root = roots.find((candidate) => candidate.id === movie.root_id); chipsEl.append(chip(root ? root.audience : `root ${movie.root_id}`)); - chipsEl.append( - chip(movie.state, (span) => { - span.dataset.movieState = movie.state; - }), - ); + chipsEl.append(mediaStateChip(movie.state, movie.wanted)); // §5.7 honesty: a waived import is never presented as a clean match const waiver = waiverLabel(movie.waiver); if (waiver !== null) { @@ -2573,12 +2596,12 @@ function seriesRow( chips.append(chip(root ? root.audience : `root ${series.root_id}`)); if (series.wanted_episodes > 0) { chips.append( - chip(`${series.available_episodes}/${series.wanted_episodes} eps`, (span) => { - span.setAttribute( - "aria-label", - `${series.available_episodes} of ${series.wanted_episodes} wanted episodes on disk`, - ); - }), + countsChip( + `${series.available_episodes}/${series.wanted_episodes} eps`, + series.available_episodes, + series.wanted_episodes, + `${series.available_episodes} of ${series.wanted_episodes} wanted episodes on disk`, + ), ); } if (series.blocked) { @@ -2685,12 +2708,12 @@ function seriesCard( const chips = [chip(root ? root.audience : `root ${series.root_id}`)]; if (series.wanted_episodes > 0) { chips.push( - chip(`${series.available_episodes}/${series.wanted_episodes} eps`, (span) => { - span.setAttribute( - "aria-label", - `${series.available_episodes} of ${series.wanted_episodes} wanted episodes on disk`, - ); - }), + countsChip( + `${series.available_episodes}/${series.wanted_episodes} eps`, + series.available_episodes, + series.wanted_episodes, + `${series.available_episodes} of ${series.wanted_episodes} wanted episodes on disk`, + ), ); } if (series.blocked) { @@ -2717,11 +2740,7 @@ function movieCard( ): HTMLLIElement { const root = roots.find((candidate) => candidate.id === movie.root_id); const chips = [chip(root ? root.audience : `root ${movie.root_id}`)]; - chips.push( - chip(movie.state, (span) => { - span.dataset.movieState = movie.state; - }), - ); + chips.push(mediaStateChip(movie.state, movie.wanted)); // §5.7 honesty: a waived import is never presented as a clean match const waiver = waiverLabel(movie.waiver); if (waiver !== null) { @@ -3020,12 +3039,12 @@ function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableV const root = roots.find((candidate) => candidate.id === current.root_id); chipsEl.append(chip(root ? root.audience : `root ${current.root_id}`)); chipsEl.append( - chip(`${current.available_episodes}/${current.wanted_episodes} eps`, (span) => { - span.setAttribute( - "aria-label", - `${current.available_episodes} of ${current.wanted_episodes} wanted episodes on disk`, - ); - }), + countsChip( + `${current.available_episodes}/${current.wanted_episodes} eps`, + current.available_episodes, + current.wanted_episodes, + `${current.available_episodes} of ${current.wanted_episodes} wanted episodes on disk`, + ), ); chipsEl.append( chip(current.status, (span) => { @@ -3132,12 +3151,12 @@ function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableV function seasonCountsChip(season: ApiSeason): HTMLSpanElement { const counts = seasonCounts(season); - return chip(`${counts.available}/${counts.wanted} on disk`, (span) => { - span.setAttribute( - "aria-label", - `${counts.available} of ${counts.wanted} wanted episodes on disk`, - ); - }); + return countsChip( + `${counts.available}/${counts.wanted} on disk`, + counts.available, + counts.wanted, + `${counts.available} of ${counts.wanted} wanted episodes on disk`, + ); } function seasonRow(season: ApiSeason): HTMLLIElement { @@ -3287,11 +3306,7 @@ function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableV const chips = document.createElement("span"); chips.className = "row-chips ep-chips"; - chips.append( - chip(episode.state, (span) => { - span.dataset.movieState = episode.state; - }), - ); + chips.append(mediaStateChip(episode.state, episode.wanted)); // issue 122: gone upstream while its file remained — a conflict, not a state if (episode.vanished) { chips.append( diff --git a/web/src/style.css b/web/src/style.css index f2cff9a..6f4e96e 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -61,8 +61,9 @@ --score-5: oklch(0.79 0.165 129); --score-6: oklch(0.8 0.17 150); - /* library status (§4.2) — informational, never a severity ramp: - activity in one violet family, satisfaction in neutrals */ + /* library state ramp (§4.2): green satisfied, amber gap, violet active, + neutral nothing-happening. --status-airing doubles as the downloading + hue; the other four stay declared as the design.json mirror. */ --status-airing: oklch(0.78 0.14 305); --status-incomplete: oklch(0.72 0.09 305); --status-waiting: oklch(0.64 0.045 305); @@ -665,32 +666,52 @@ body { color: var(--verdict-rejected); } +/* media state ramp (§4.2): green on disk, violet downloading, amber wanted + and still missing. Unwanted-missing and parked are nothing-happening and + stay neutral; `parked` exists so a vanished grab never reads as a gap. */ .chip[data-movie-state="available"] { - color: var(--ink); + color: var(--signal-ok); + border-color: oklch(from var(--signal-ok) l c h / 45%); } -/* library status (§4.2): informational, never a severity ramp — activity in - the channel-violet family, satisfaction in neutrals; the word is always - present, so the state survives with colour removed */ +.chip[data-movie-state="downloading"] { + color: var(--status-airing); + border-color: oklch(from var(--status-airing) l c h / 45%); +} + +.chip[data-movie-state="missing"][data-wanted="true"] { + color: var(--signal-warn); + border-color: oklch(from var(--signal-warn) l c h / 55%); +} + +/* library status ramp (§4.2): violet while activity is live, green once + everything wanted is on disk, amber for a grabbable gap; waiting is + between seasons with nothing to do, so it stays neutral */ .chip[data-status="airing"] { color: var(--status-airing); border-color: oklch(from var(--status-airing) l c h / 45%); } .chip[data-status="incomplete"] { - color: var(--status-incomplete); -} - -.chip[data-status="waiting"] { - color: var(--status-waiting); -} - -.chip[data-status="complete"] { - color: var(--status-complete); + color: var(--signal-warn); + border-color: oklch(from var(--signal-warn) l c h / 55%); } +.chip[data-status="complete"], .chip[data-status="ended"] { - color: var(--status-ended); + color: var(--signal-ok); + border-color: oklch(from var(--signal-ok) l c h / 45%); +} + +/* season/series counts chip (§4.2): the same ramp read off N/M itself */ +.chip[data-counts="complete"] { + color: var(--signal-ok); + border-color: oklch(from var(--signal-ok) l c h / 45%); +} + +.chip[data-counts="partial"] { + color: var(--signal-warn); + border-color: oklch(from var(--signal-warn) l c h / 55%); } /* a TMDB row is the affordance: the whole row opens the add flow */ @@ -1598,10 +1619,11 @@ body { color: var(--ink); } +/* the star is amber (§4.2): rating reads at a glance, the number stays ink */ .star { width: 0.75rem; height: 0.75rem; - fill: var(--ink-muted); + fill: var(--signal-warn); } /* a row chip is smaller than the detail page's rating readout */ From fa5d876c0964af2c9d320d9b7958cba416029048 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 14:37:52 +0100 Subject: [PATCH 2/2] chore(web): sync design.json to the state ramp The token system still mirrored the pre-amendment palette and carried the rule 'Don't colour complete green or incomplete amber', which now contradicts DESIGN.md 4.2 and would have had the next session revert the ramp. Drops the four --status-* tokens no rule consumes any more; satisfaction reuses --signal-ok and a gap --signal-warn. Committed by the blitz orchestrator: the authoring session wedged. --- .impeccable/design.json | 4 ++-- web/src/style.css | 9 +++------ 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/.impeccable/design.json b/.impeccable/design.json index cc14e9b..9117aa9 100644 --- a/.impeccable/design.json +++ b/.impeccable/design.json @@ -205,7 +205,7 @@ "refersTo": "status-channel", "description": "Library status (§4.2): airing / incomplete / waiting / complete / ended. Informational, not severity — colour marks activity in one channel-violet family (airing brightest, incomplete, waiting dimmest), satisfaction is neutral (complete bright ink, ended faint). The mono word is always present.", "html": "airing", - "css": ".chip[data-status=\"airing\"] { color: var(--status-airing); border-color: oklch(from var(--status-airing) l c h / 45%); } .chip[data-status=\"incomplete\"] { color: var(--status-incomplete); } .chip[data-status=\"waiting\"] { color: var(--status-waiting); } .chip[data-status=\"complete\"] { color: var(--status-complete); } .chip[data-status=\"ended\"] { color: var(--status-ended); }" + "css": ".chip[data-status=\"airing\"] { color: var(--status-airing); border-color: oklch(from var(--status-airing) l c h / 45%); } .chip[data-status=\"incomplete\"] { color: var(--signal-warn); border-color: oklch(from var(--signal-warn) l c h / 55%); } .chip[data-status=\"complete\"], .chip[data-status=\"ended\"] { color: var(--signal-ok); border-color: oklch(from var(--signal-ok) l c h / 45%); } .chip[data-counts=\"complete\"] { color: var(--signal-ok); } .chip[data-counts=\"partial\"] { color: var(--signal-warn); }" }, { "name": "Module (channel strip)", @@ -300,7 +300,7 @@ "Do take every colour, spacing and font-size from the :root tokens; the token check (web/scripts/check-tokens.mjs) fails CI on literals." ], "donts": [ - "Don't colour `complete` green or `incomplete` amber — library status is informational, not a severity ramp.", + "Colour state chips on the ramp — `complete` and `ended` green, `incomplete` and a partial count amber, `airing` and `downloading` violet, `waiting` and `untracked` neutral. The word stays in every chip, so state survives with colour removed.", "Don't use fault red for a rejected release; red means the daemon itself is broken.", "Don't introduce a second interactive hue or use cyan for a status.", "Don't box content in soft-shadowed rounded cards or add glass/blur.", diff --git a/web/src/style.css b/web/src/style.css index 6f4e96e..9dc8985 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -62,13 +62,10 @@ --score-6: oklch(0.8 0.17 150); /* library state ramp (§4.2): green satisfied, amber gap, violet active, - neutral nothing-happening. --status-airing doubles as the downloading - hue; the other four stay declared as the design.json mirror. */ + neutral nothing-happening. Satisfaction reuses --signal-ok and a gap + --signal-warn, so the only hue this family still owns is the active one, + which doubles as the downloading colour. */ --status-airing: oklch(0.78 0.14 305); - --status-incomplete: oklch(0.72 0.09 305); - --status-waiting: oklch(0.64 0.045 305); - --status-complete: oklch(0.85 0.01 250); - --status-ended: oklch(0.62 0.015 250); /* type */ --font-label: system-ui, "Segoe UI", sans-serif;