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/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..9dc8985 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -61,13 +61,11 @@ --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. 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; @@ -665,32 +663,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 +1616,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 */