refactor(multi-epg): match app theme, widen channels, label the now-line

Four UX-audit fixes for the Multi-channel EPG view:

1. Theme harmonization
   - $bg-deep / $bg-surface / $bg-elevated / $bg-hover now resolve through
     --app-content-bg / --app-widget-bg / --app-widget-header-bg /
     --app-card-hover-bg. Multi-EPG was the only screen that switched to
     near-black (#0a0a0f) instead of sitting on the app's dark surface, so
     the audit described it as "visually detaching from the rest of the
     app". It now inherits the same dark graphite chain.
   - Cleaned up 29 remaining hard-coded rgba(139,92,246,…) purples and
     rgba(0,212,170,…) teal accents that survived the earlier color
     unification commit. Purple shadows/glows are gone; the cyan glow
     used by the now-line is now driven by the unified $5cd6ff token.

2. Channel column widened 140px → 180px
   - Long names like "13th Street Universal HD" no longer truncate to 5
     characters. Logo size dropped to 24px so the name actually has room.
     The label allows up to two lines and wraps cleanly on long titles.
   - Responsive breakpoint mirrors the change: 100px → 140px at <768px.

3. Now-line time badge
   - New `currentTimeLabel` computed signal renders an "HH:MM" pill
     pinned to the top of the now-line. Recomputes on the same 60s tick
     as the line position so they always stay in lockstep.

4. "Airing now" program highlight
   - New `isProgramAiringNow(program)` returns true when the now-line's
     x-coordinate falls inside [startPosition, startPosition + width].
     Programs that match get a .is-now class → 1.5px cyan border. Users
     can now see what's on every channel at a glance without tracing the
     line down each row.

Toolbar harmonization (audit item 3 for this screen) intentionally
deferred — that's part of the app-wide toolbar unification work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 4.7 committed 2026-05-19 01:21:10 +02:00
1 parent d59fa74650
commit 7399fbfa9e
3 files changed
+168 -61

No files matched your search

@@ -123,16 +123,16 @@
<svg id="channels-column" [attr.height]="filteredChannels().length * barHeight + barHeight + 50">
@for (item of filteredChannels(); track item.id; let i = $index) {
<g>
<!-- channel name -->
<!-- channel name (column widened to 180px per UX audit) -->
<g class="channel" [class.active]="item.id === activeChannelId">
<rect
width="100"
width="180"
[attr.height]="barHeight"
[attr.y]="barHeight * i + barHeight"
stroke="black"
/>
<foreignObject
width="100"
width="180"
[attr.height]="barHeight"
[attr.y]="barHeight * i + barHeight"
>
@@ -141,7 +141,6 @@
<img
[src]="getChannelIcon(item)"
[alt]="getChannelName(item)"
style="height: 20px; width: 20px; margin-right: 8px;"
>
}
<span>{{ getChannelName(item) }}</span>
@@ -191,6 +190,7 @@
[attr.transform]="'translate(' + program.startPosition + ',' + (barHeight * i + barHeight) + ')'"
class="program"
[class.highlighted]="highlightedProgramKey() === getProgramKey(program)"
[class.is-now]="isProgramAiringNow(program)"
(click)="showDescription(program)"
>
<rect
@@ -209,7 +209,10 @@
</g>
}
<!-- current time line -->
<!-- current time line + label badge.
The badge sits on top of the time-header row so users can read the
current time at a glance — addresses the UX-audit complaint that
the now-line was a "decorative gradient" without a time anchor. -->
<line
[attr.x1]="currentTimeLine()"
y1="0"
@@ -217,7 +220,19 @@
[attr.y2]="filteredChannels().length * barHeight + barHeight"
stroke="red"
stroke-width="2"
id="current-time-line"
></line>
<g class="now-label" [attr.transform]="'translate(' + currentTimeLine() + ', ' + (barHeight - 14) + ')'">
<rect
x="-26"
y="-9"
width="52"
height="18"
rx="9"
ry="9"
></rect>
<text x="0" y="4" text-anchor="middle">{{ currentTimeLabel() }}</text>
</g>
</svg>
</div>
</div>
@@ -1,23 +1,31 @@
// Color palette - Broadcast control room aesthetic
$bg-deep: #0a0a0f;
$bg-surface: #12121a;
$bg-elevated: #1a1a24;
$bg-hover: #24243a;
$border-subtle: rgba(255, 255, 255, 0.06);
$border-medium: rgba(255, 255, 255, 0.12);
// Color palette — every value resolves through the app's theme tokens so
// Multi-EPG looks like part of IPTVnator instead of a separate broadcast
// utility. Previously hard-coded #0a0a0f / #12121a / #1a1a24 made the grid
// switch to near-black while the rest of the app sat on --app-content-bg,
// which the UX audit called out as the screen "visually detaching" from
// the rest of the app.
$bg-deep: var(--app-content-bg);
$bg-surface: var(--app-widget-bg);
$bg-elevated: var(--app-widget-header-bg);
$bg-hover: var(--app-card-hover-bg);
$border-subtle: var(--app-widget-header-border);
$border-medium: var(--app-separator);
// Accent colors — unified with the rest of the app:
// cyan is reserved for the EPG "now" indicator, blue is the app primary,
// purple/pink had no semantic role and are dropped per the UX audit.
// cyan stays on the "now" indicator only; blue is the primary; the old
// $8b5cf6 purple and $00d4aa teal are dropped per the UX audit.
$accent-primary: #5cd6ff; // Cyan — reserved for "now" indicator
$accent-secondary: #4f8eff; // Blue — programs (matches --app-selection-color)
$accent-tertiary: #4f8eff; // Blue — highlights
$accent-now-glow: rgba(92, 214, 255, 0.45); // Cyan glow for the now-line
$accent-secondary: var(--app-selection-color); // Blue — programs
$accent-tertiary: var(--app-selection-color); // Blue — highlights
$accent-warning: #fbbf24;
// Text colors
$text-primary: #f8fafc;
$text-secondary: rgba(248, 250, 252, 0.7);
$text-muted: rgba(248, 250, 252, 0.4);
// Text colors — derived from app heading/body so light and dark themes
// stay in sync without a separate `.light-theme` override at the bottom
// (which was duplicating responsibility with the same token chain).
$text-primary: var(--app-heading-color);
$text-secondary: var(--app-body-color);
$text-muted: var(--app-muted-color);
// Gradients
$gradient-program: linear-gradient(
@@ -56,11 +64,11 @@ $gradient-header: linear-gradient(
}
&::-webkit-scrollbar-thumb {
background: rgba(139, 92, 246, 0.4);
background: rgba(47, 123, 255, 0.4);
border-radius: 4px;
&:hover {
background: rgba(139, 92, 246, 0.6);
background: rgba(47, 123, 255, 0.6);
}
}
}
@@ -89,8 +97,8 @@ $gradient-header: linear-gradient(
background: linear-gradient(
90deg,
transparent 0%,
rgba(0, 212, 170, 0.3) 20%,
rgba(139, 92, 246, 0.3) 80%,
rgba(92, 214, 255, 0.3) 20%,
rgba(47, 123, 255, 0.3) 80%,
transparent 100%
);
}
@@ -104,7 +112,7 @@ $gradient-header: linear-gradient(
-webkit-app-region: no-drag;
&:not([disabled]):hover {
background: rgba(139, 92, 246, 0.15);
background: rgba(47, 123, 255, 0.15);
}
&[disabled] {
@@ -138,7 +146,7 @@ $gradient-header: linear-gradient(
transition: all 0.2s ease;
&:focus-within {
border-color: rgba(139, 92, 246, 0.5);
border-color: rgba(47, 123, 255, 0.5);
background: rgba(255, 255, 255, 0.08);
}
@@ -241,7 +249,7 @@ $gradient-header: linear-gradient(
transition: all 0.2s ease;
&:focus-within {
border-color: rgba(139, 92, 246, 0.5);
border-color: rgba(47, 123, 255, 0.5);
background: rgba(255, 255, 255, 0.08);
}
@@ -328,7 +336,7 @@ $gradient-header: linear-gradient(
}
&::-webkit-scrollbar-thumb {
background: rgba(139, 92, 246, 0.3);
background: rgba(47, 123, 255, 0.3);
border-radius: 3px;
}
@@ -446,11 +454,11 @@ $gradient-header: linear-gradient(
@keyframes highlightPulse {
0%, 100% {
filter: drop-shadow(0 0 8px rgba(0, 212, 170, 0.6));
filter: drop-shadow(0 0 8px rgba(92, 214, 255, 0.6));
}
50% {
filter: drop-shadow(0 0 20px rgba(0, 212, 170, 0.9))
drop-shadow(0 0 40px rgba(0, 212, 170, 0.4));
filter: drop-shadow(0 0 20px rgba(92, 214, 255, 0.9))
drop-shadow(0 0 40px rgba(92, 214, 255, 0.4));
}
}
@@ -498,12 +506,14 @@ $gradient-header: linear-gradient(
text-transform: uppercase;
// Glowing text effect
text-shadow: 0 0 20px rgba(0, 212, 170, 0.3);
text-shadow: 0 0 20px rgba(92, 214, 255, 0.3);
}
// Channel column
// Channel column — bumped from 140px to 180px per the UX audit so long
// channel names ("13th Street Universal HD", "ARD alpha", etc.) stop being
// truncated to 5 characters.
#channels-column {
width: 140px;
width: 180px;
flex: none;
border-right: 1px solid $border-medium;
background: $bg-surface;
@@ -529,8 +539,8 @@ $gradient-header: linear-gradient(
}
&.active rect {
fill: rgba(139, 92, 246, 0.2);
stroke: rgba(139, 92, 246, 0.4);
fill: rgba(47, 123, 255, 0.2);
stroke: rgba(47, 123, 255, 0.4);
}
&.active .channel-name {
@@ -542,25 +552,40 @@ $gradient-header: linear-gradient(
.channel-name {
color: $text-primary;
padding: 8px 12px;
font-size: 11px;
font-size: 12.5px;
font-weight: 500;
display: flex;
align-items: center;
gap: 10px;
height: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
letter-spacing: 0.02em;
letter-spacing: 0.01em;
line-height: 1.2;
img {
// Smaller logo (24px) per audit recommendation so the channel
// name has room to breathe in the 180px column.
width: 24px !important;
height: 24px !important;
margin-right: 0 !important;
flex: 0 0 auto;
border-radius: 4px;
background: rgba(255, 255, 255, 0.05);
padding: 2px;
object-fit: contain;
}
span {
// Allow up to two lines so "13th Street Universal HD" and
// similar long names wrap instead of disappearing into an
// ellipsis after 5 characters.
min-width: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
}
}
@@ -572,7 +597,7 @@ $gradient-header: linear-gradient(
// EPG container
#epg-container {
width: calc(100vw - 140px);
width: calc(100vw - 180px);
overflow-x: scroll;
overflow-y: clip;
background: $bg-deep;
@@ -588,11 +613,11 @@ $gradient-header: linear-gradient(
}
&::-webkit-scrollbar-thumb {
background: rgba(139, 92, 246, 0.4);
background: rgba(47, 123, 255, 0.4);
border-radius: 4px;
&:hover {
background: rgba(139, 92, 246, 0.6);
background: rgba(47, 123, 255, 0.6);
}
}
}
@@ -633,13 +658,13 @@ $gradient-header: linear-gradient(
&:hover rect {
fill: $bg-hover;
stroke: rgba(139, 92, 246, 0.4);
filter: drop-shadow(0 0 8px rgba(139, 92, 246, 0.2));
stroke: rgba(47, 123, 255, 0.4);
filter: drop-shadow(0 0 8px rgba(47, 123, 255, 0.2));
}
// Highlighted program (from search)
&.highlighted rect {
fill: rgba(0, 212, 170, 0.25);
fill: rgba(92, 214, 255, 0.25);
stroke: $accent-primary;
stroke-width: 2px;
animation: highlightPulse 1s ease-in-out 3;
@@ -653,12 +678,12 @@ $gradient-header: linear-gradient(
// Active channel row highlighting
.active-row .program rect {
fill: rgba(139, 92, 246, 0.15);
stroke: rgba(139, 92, 246, 0.3);
fill: rgba(47, 123, 255, 0.15);
stroke: rgba(47, 123, 255, 0.3);
}
.active-row .program:hover rect {
fill: rgba(139, 92, 246, 0.25);
fill: rgba(47, 123, 255, 0.25);
}
.program-title {
@@ -683,12 +708,45 @@ $gradient-header: linear-gradient(
line[stroke='red'] {
stroke: $accent-primary !important;
stroke-width: 2px;
filter: drop-shadow(0 0 8px rgba(0, 212, 170, 0.6))
drop-shadow(0 0 16px rgba(0, 212, 170, 0.3));
filter: drop-shadow(0 0 8px rgba(92, 214, 255, 0.6))
drop-shadow(0 0 16px rgba(92, 214, 255, 0.3));
// Animated pulse
animation: timePulse 2s ease-in-out infinite;
}
// Now-line time badge — a small filled pill sitting on top of the time
// header that reads "HH:MM". Keeps the cyan visual language of the line
// itself so the relationship is unmistakable.
.now-label {
pointer-events: none;
rect {
fill: $accent-primary;
stroke: none;
filter: drop-shadow(0 0 6px $accent-now-glow);
}
text {
fill: var(--app-shell-bg, #0d0f12);
font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo,
monospace;
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.04em;
}
}
// Currently-airing program — a thin cyan border on every program block
// whose time window contains the now-line. Audit recommended this to
// make "what's airing right now" scannable without finding the line
// first then tracing it down each row.
.program.is-now {
rect {
stroke: $accent-primary;
stroke-width: 1.5px;
}
}
}
// Pulse animation for time line
@@ -696,13 +754,13 @@ $gradient-header: linear-gradient(
0%,
100% {
opacity: 1;
filter: drop-shadow(0 0 8px rgba(0, 212, 170, 0.6))
drop-shadow(0 0 16px rgba(0, 212, 170, 0.3));
filter: drop-shadow(0 0 8px rgba(92, 214, 255, 0.6))
drop-shadow(0 0 16px rgba(92, 214, 255, 0.3));
}
50% {
opacity: 0.8;
filter: drop-shadow(0 0 12px rgba(0, 212, 170, 0.8))
drop-shadow(0 0 24px rgba(0, 212, 170, 0.4));
filter: drop-shadow(0 0 12px rgba(92, 214, 255, 0.8))
drop-shadow(0 0 24px rgba(92, 214, 255, 0.4));
}
}
@@ -810,7 +868,7 @@ rect {
&:hover rect {
fill: #f1f5f9;
stroke: rgba(139, 92, 246, 0.4);
stroke: rgba(47, 123, 255, 0.4);
}
}
@@ -819,8 +877,8 @@ rect {
}
line[stroke='red'] {
stroke: #059669 !important;
filter: drop-shadow(0 0 6px rgba(5, 150, 105, 0.4));
stroke: $accent-primary !important;
filter: drop-shadow(0 0 6px $accent-now-glow);
}
}
}
@@ -833,16 +891,22 @@ rect {
}
#channels-column {
width: 100px;
width: 140px;
.channel-name {
font-size: 10px;
font-size: 11.5px;
padding: 6px 8px;
gap: 8px;
img {
width: 20px !important;
height: 20px !important;
}
}
}
#epg-container {
width: calc(100vw - 100px);
width: calc(100vw - 140px);
}
.today-date {
@@ -145,6 +145,19 @@ export class MultiEpgContainerComponent
return (now.getHours() + now.getMinutes() / 60) * this.hourWidth();
});
// "16:32"-style label rendered as a badge above the now-line. Recomputes
// on the same 60-second tick that drives `currentTimeLine` (both depend
// on hourWidth's update pulse from `ngOnInit`).
readonly currentTimeLabel = computed(() => {
// Subscribe to the same tick source as currentTimeLine.
this.hourWidth();
const now = new Date();
return `${now.getHours().toString().padStart(2, '0')}:${now
.getMinutes()
.toString()
.padStart(2, '0')}`;
});
// Constants
readonly timeHeader = Array.from({ length: 24 }, (_, i) => i);
readonly barHeight = 50;
@@ -228,6 +241,21 @@ export class MultiEpgContainerComponent
return `${program.start}|${program?.title?.toString() ?? ''}`;
}
/**
* Returns true when the now-line falls within this program's rendered
* span — used to add a `.is-now` class so the cyan border lights up
* every airing program at once. The template reads `currentTimeLine()`
* (a signal) here so the highlight refreshes on the same 60-second tick
* as the line itself.
*/
isProgramAiringNow(program: EnrichedProgram): boolean {
const nowX = this.currentTimeLine();
return (
nowX >= program.startPosition &&
nowX <= program.startPosition + program.width
);
}
async requestPrograms(): Promise<void> {
if (!window.electron) {
console.warn('Multi-EPG not available: Electron not detected');