fix(sources): keep the page header in view; mark the active source's row

Local Greptile (two P2s) on the PR round, both regressions against master:

- The redesigned Sources page scrolled as one, so the title, Clean up and
  Sort left the view in a long library (master kept a sticky header, and
  the UI guidelines say headers stay and lists scroll). The header now
  stays and only the list below it scrolls, which also keeps a row reached
  by keyboard from scrolling under the header.
- Master marked the active playlist on the Sources page; the new row only
  tinted the current row in the compact (switcher) size. The active
  source's row is now tinted with the same --app-list-selected-* token on
  the page too; the switcher keeps its check.

The design handoff called the header non-sticky and the current tint
switcher-only; both followed master and the guidelines instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-13 21:31:51 +02:00
1 parent 13de38ebe5
commit e3a4ea7259
5 files changed
+33 -26

No files matched your search

+6 -4
View File
@@ -75,7 +75,7 @@ in `apps/web/src/m3-theme.scss`):
- `--app-success-color` — done/watched status (episode watched toggle)
- `--app-list-selected-bg` / `--app-list-selected-fg` /
`--app-list-selected-outline` — the chosen row of a side list and the
switcher's current row (see Selection Pattern)
active source's row on Sources and in the switcher (see Selection Pattern)
- `--app-line-strong` — popover and menu borders, a step above
`--app-widget-header-border`
- `--app-danger-text` / `--app-danger-surface` — a failing source's tag and
@@ -183,7 +183,8 @@ Use this pattern for:
Do not add extra badges, left rails, or second selection systems unless there is a strong reason.
Side lists styled with `_side-list.scss` (the Sources filters) and the
playlist switcher's current row use the Settings navigation's variant
active source's row (Sources page and playlist switcher) use the Settings
navigation's variant
instead, through `--app-list-selected-*`: a soft tint with no lift or glow,
outlined in light theme. Its text is the heading colour in light theme (the
selection blue on its own tint is 3.3:1) and the selection colour in dark.
@@ -1354,8 +1355,9 @@ the source with Enter, and deletes and undoes a source keyboard-only.
One `app-source-row` serves both lists: `size="regular"` on the Sources page
(56px, 36px tile, hover actions Refresh, Edit and ⋯; a failing row keeps
Retry in view; touch screens always show the actions) and `size="compact"`
in the switcher (52px, 32px tile, ⋯ only; the current row is tinted with a
check). The meta line is always type · channels · added · updated · URL, with
in the switcher (52px, 32px tile, ⋯ only). In both the active source's row
is tinted with the selected-row token; the switcher adds a check. The page
header (title, Clean up, Sort) stays; only the list below it scrolls. The meta line is always type · channels · added · updated · URL, with
empty parts left out; only M3U playlists store a channel count. The helpers
in `@iptvnator/playlist/shared/util` (`source-display`, `source-format`,
`source-list`) own that text, the filters and the counts.
@@ -65,18 +65,19 @@
// option is the focus indicator.
@include focus-ring.focus-ring-declarations($offset: -2px);
}
}
&.source-row--current {
// The same selected-row look as the side lists: a soft tint, and an
// outline in light theme. The dot's ring approximates the tint.
--source-row-ring: color-mix(
in srgb,
var(--app-selection-color) 16%,
var(--source-row-base)
);
background: var(--app-list-selected-bg);
box-shadow: inset 0 0 0 1px var(--app-list-selected-outline);
}
// The active source, on the Sources page and in the switcher: the same
// selected-row look as the side lists (a soft tint, and an outline in light
// theme). The dot's ring approximates the tint.
.source-row--current {
--source-row-ring: color-mix(
in srgb,
var(--app-selection-color) 16%,
var(--source-row-base)
);
background: var(--app-list-selected-bg);
box-shadow: inset 0 0 0 1px var(--app-list-selected-outline);
}
// ── Tile column ──────────────────────────────────────────────────────────
@@ -1,7 +1,4 @@
<section
class="sources-page app-scrollbar"
[class.sources-page--empty]="!hasPlaylists()"
>
<section class="sources-page" [class.sources-page--empty]="!hasPlaylists()">
@if (hasPlaylists()) {
<header class="sources-header">
<div class="sources-header__title">
@@ -92,7 +89,7 @@
</div>
</header>
}
<div class="sources-content">
<div class="sources-content app-scrollbar">
<app-recent-playlists
[searchQueryInput]="searchQuery()"
(addPlaylistClicked)="onAddPlaylist($event)"
@@ -6,16 +6,19 @@
min-height: 100%;
}
// The page scrolls as one: the header is not sticky.
// The header stays and the list scrolls, as on every list page: Sort and
// Clean up stay in reach in a long library, and a row reached by keyboard
// never scrolls under the header.
.sources-page {
display: flex;
flex-direction: column;
height: 100%;
min-width: 0;
overflow-y: auto;
overflow: hidden;
}
.sources-header {
flex: none;
display: flex;
flex-wrap: wrap;
align-items: center;
@@ -80,6 +83,8 @@
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
overflow-y: auto;
padding: 0 20px 32px;
}
@@ -241,10 +241,12 @@ describe('WorkspaceSourcesComponent', () => {
expect(
element.querySelector('.sources-count')?.textContent?.trim()
).toBe('2');
// The page scrolls as one; the header is not a separate bar.
expect(element.querySelector('.sources-page')?.classList).toContain(
'app-scrollbar'
);
// The header stays; the list below it scrolls.
const scroller = element.querySelector('.sources-content');
expect(scroller?.classList).toContain('app-scrollbar');
expect(
scroller?.contains(element.querySelector('.sources-header'))
).toBe(false);
});
it('titles the page by the status filter and counts what it shows', async () => {