feat(web): state ramp on status and counts chips

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.
This commit is contained in:
Miguel Palhas
2026-08-24 13:57:17 +01:00
parent 8221ea633c
commit 2dbb0f48f7
2 changed files with 98 additions and 61 deletions
+59 -44
View File
@@ -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(
+39 -17
View File
@@ -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 */