fix(ui): make the workspace usable on phone-sized screens (#1326)

* fix(ui): make the workspace usable on phone-sized screens

The shell was half-adapted below 640px: the rail flipped to a horizontal
bar but the link lists inside it kept stacking downwards, so the navigation
was drawn outside the bar and over the header (#1100).

Three resizable rails — the shell context panel, the live-layout channel
sidebar and the M3U channel drawer — kept their persisted desktop width,
which left the content around 50px on a 375px screen. They now span the
full width and stack above the content. The inline width written by
ResizableDirective is why these rules need `!important`.

Found while walking the rest of the UI at 375px and 768px:

- The detail hero kept poster and details side by side, squeezing the
  action row below its own labels until "Play" was clipped to its icon.
- The settings section list did not scroll and painted over the footer,
  which also affected short desktop windows.
- Hiding the M3U channel list on a phone was one-way: the restore handle
  was hidden and only Cmd/Ctrl+B could bring it back.
- The live header drew the channel count and the paginator on top of each
  other up to tablet width, because the paginator does not shrink and the
  meta collapsed to zero width and overflowed its box.
- The search scope checkbox was pushed off the right edge.

Live TV states a floor for the player instead of a ceiling for the lists,
so the video keeps a usable share of the screen under the categories panel
and the channel list.

Closes #1100

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ui): address review — keep the palette reachable and the video visible

Two findings from the Codex review on #1326.

Hiding the command-palette trigger on phones removed the only pointer-driven
way to open it: the rail renders route links plus Settings and emits nothing,
so `commandPaletteRequested` had exactly one source. The button stays and its
keyboard-shortcut label is swapped for an icon instead. Doing that exposed a
latent flex trap in the same row — an <input> keeps an intrinsic min-width
from its `size`, and `min-width: auto` honours it, so the field refused to
shrink and pushed the trigger out onto the buttons beside it.

The M3U drawer released the shared player floor, which on a short landscape
phone (600-640px wide) left the content container at half the shell body.
The inline guide inside it is `flex: 0 0 <basis>` and took its full 180px out
of a container that no longer had it, so the video could reach zero height.
The floor is restored and now yields on short viewports, the video states its
own minimum, and the guide is what gives way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ui): let the channel list keep its height on a landscape phone

Follow-up to the review: the player floor added in the previous commit was
measured against the viewport, not against what the shell had left. On a
640x360 landscape phone the stacked categories panel already takes 30vh, so
claiming another 50vh here drove the channel sidebar to zero height while it
was still marked expanded — no way to pick another channel — and pushed the
layout past the viewport.

The floor now applies only where the screen can afford it (`min-height:
600px`), the sidebar states a floor of its own so it cannot be squeezed out,
and the collapsed rule clears that floor so hiding the list still works.
Below that height the two panes simply share what is left.

Portrait is unchanged: categories 244px, channel list 220px, player 240px on
a 375x812 screen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ui): address review — settings nav on landscape, poster dead space

Two more findings from the Codex review.

The stacked settings context panel capped itself at 30vh, which on a 360px
screen is 108px — less than the panel's own title and footer, so the seven
section rows collapsed to nothing behind an overlapping footer. On short
screens the caption gives way (the rail already labels the page), the footer
sheds its tall-screen padding, and the settings variant gets a slightly
larger cap: unlike the live routes there is no player below competing for
height, only a scrollable form.

The poster kept a 330px minimum from the skeleton fallback at the bottom of
the file — sized for the 220px desktop poster — while the stacked phone hero
renders it 140px wide with a ~210px aspect-ratio height. Every loaded detail
page carried ~120px of empty space between the poster and the title. The
override sits after that rule because it wins on source order, not
specificity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ui): let the playlist switcher yield to the search field on narrow phones

Codex review of d6133da9: on a 320px header a route that contributes its
shortcut button left the search field less than its own chrome needs (~74px
of icon, palette trigger, gaps and padding), so the field's contents spilled
onto the buttons beside it.

The switcher is the one header region whose content can ellipsize, so it is
what shrinks — down to an 88px floor — while the field states its chrome as
a minimum. The field's basis moves from auto to zero so the input's intrinsic
size stops counting as content: with basis auto the field claimed its
intrinsic width even when room was ample and squeezed the switcher to ~115px
on a 375px screen that could fit all 140.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ui): fit the switcher's own chrome inside its phone floor

Follow-up to the Codex note that the trigger's fixed chrome (type icon,
refresh, chevron, gaps, padding) exceeds the 88px floor the shell now allows
the switcher to shrink to. The flagged scenario itself cannot occur — the
Multi-EPG shortcut needs Electron bridge methods the PWA lacks, and Electron
enforces a 900px minimum window width so it never sees the phone breakpoint —
but the floor should hold on its own terms rather than by accident of which
buttons happen to render. Dropping the decorative type icon on phones brings
the fixed chrome under the floor, and the name gets the space instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 authored and GitHub committed 2026-08-01 19:23:20 +02:00
1 parent 760099358b
commit 8f861a3a1b
19 files changed
+518 -19

No files matched your search

@@ -26,10 +26,17 @@
flex: 1;
min-height: 0;
padding-top: 16px;
display: flex;
flex-direction: column;
overflow: hidden;
}
// `height: 100%` here does not resolve against a stretched flex item, so the
// list kept its full content height and painted over the footer whenever the
// panel was shorter than its sections (a phone, or a short desktop window).
.settings-sections-list {
height: 100%;
flex: 1;
min-height: 0;
}
.settings-panel-footer {
@@ -42,3 +49,22 @@
.settings-back-button:hover:not(.active):not(.selected) {
background: var(--app-selection-surface);
}
// On a landscape phone the stacked panel gets ~108-148px, and this panel's
// fixed chrome (title + footer) alone is about that tall — the section list
// was collapsing to nothing behind an overlapping footer. The rail already
// labels the page, so the caption is what gives way, and the footer sheds
// the padding it carries for taller screens.
@media (max-width: 640px) and (max-height: 599px) {
.panel-title {
display: none;
}
.settings-panel-body {
padding-top: 8px;
}
.settings-panel-footer {
padding: 6px 8px calc(6px + env(safe-area-inset-bottom, 0px));
}
}
@@ -57,3 +57,57 @@
min-height: 0;
overflow: hidden;
}
.context-panel--settings app-workspace-settings-context-panel {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
// Below this width the shell stacks the panel above the content instead of
// beside it. `!important` is required because ResizableDirective writes the
// persisted desktop width as an inline style; a 300px panel on a 375px screen
// leaves ~50px for the actual content. Dragging is meaningless when the panel
// spans the full width, so the handle goes away with it.
@media (max-width: 640px) {
.context-panel {
width: 100% !important;
max-width: none !important;
// There is no global border-box reset, so the panel's own padding
// would push a 100%-wide panel past the viewport.
box-sizing: border-box;
height: auto;
// Roughly a third of the screen: enough to show several entries
// without pushing the route's own content off the bottom.
max-height: 30vh;
border-right: none;
border-bottom: 1px solid var(--mat-sys-outline-variant);
}
.context-panel--collapsed {
max-height: 0;
padding-top: 0;
padding-bottom: 0;
border-bottom-color: transparent;
}
.context-panel :is(.resize-handle) {
display: none;
}
.context-panel--route,
.context-panel--sources {
overflow: hidden;
}
}
// The settings route has no player competing for height — its content is one
// scrollable form — so on a short landscape phone the panel can take a larger
// share. 30vh of a 360px screen is 108px, which the panel's own footer and
// padding consume before a single section row renders.
@media (max-width: 640px) and (max-height: 599px) {
.context-panel--settings {
max-height: max(30vh, 148px);
}
}
@@ -55,7 +55,14 @@
'WORKSPACE.SHELL.OPEN_COMMAND_PALETTE' | translate
"
>
{{ commandShortcutLabel }}
<!-- The shortcut label is the affordance on a keyboard; on a
phone it means nothing, but this button is the only way
to open the palette, so it becomes an icon rather than
disappearing. -->
<span class="command-trigger__shortcut">{{
commandShortcutLabel
}}</span>
<mat-icon class="command-trigger__icon">bolt</mat-icon>
</button>
</label>
}
@@ -68,6 +75,7 @@
<div class="header-actions header-actions--leading">
<button
type="button"
class="shortcuts-trigger"
mat-icon-button
(click)="onShortcutsRequested()"
[matTooltip]="'WORKSPACE.SHORTCUTS.OPEN_TOOLTIP' | translate"
@@ -116,6 +116,13 @@
}
}
.command-trigger__icon {
display: none;
font-size: 16px;
width: 16px;
height: 16px;
}
.command-trigger {
display: inline-flex;
align-items: center;
@@ -313,14 +320,64 @@
}
@media (max-width: 640px) {
.workspace-header {
padding-inline: 8px;
gap: 8px;
}
.playlist-switcher {
width: auto;
min-width: 140px;
flex: 1;
// Fixed at 140px this crowded the search field off a 320px header
// whenever a route contributed its shortcut button; the switcher is
// the one region whose content can ellipsize, so it is what shrinks.
min-width: 88px;
flex: 0 1 140px;
}
.search-field {
.search-chip--status {
// The floor is the field's own chrome — icon, palette trigger, gaps
// and padding. Below it the contents spill onto the buttons beside
// the field; above it the input just gets whatever space remains.
// Zero basis, not auto: the input's intrinsic size would otherwise
// count as content and squeeze the switcher even when room is ample.
min-width: 74px;
flex: 1 1 0;
padding-inline: 8px;
.search-chip--status,
.search-chip--scope {
display: none;
}
// An <input> carries an intrinsic min-width from its `size`, and
// `min-width: auto` on a flex item honours it — so the input refused
// to shrink and pushed the trigger out of the field and onto the
// buttons beside it.
input {
min-width: 0;
}
// This button is the only pointer-driven way into the command
// palette, so it stays; only its keyboard-shortcut label is swapped
// for an icon that means something without a keyboard.
.command-trigger {
min-width: 28px;
padding: 0 4px;
}
.command-trigger__shortcut {
display: none;
}
.command-trigger__icon {
display: inline-flex;
}
}
.header-actions--leading {
margin-left: 0;
.shortcuts-trigger {
display: none;
}
}
@@ -97,3 +97,34 @@
background: var(--rail-link-active-icon, var(--app-selection-color));
box-shadow: 0 0 10px var(--rail-link-active-glow, var(--app-selection-glow));
}
// The rail turns into a horizontal top bar below this width, so the links
// have to follow. Without the row direction the host keeps stacking its
// links downwards out of the 52px bar and they overlap the header.
@media (max-width: 640px) {
:host {
width: auto;
}
.rail-links {
flex-direction: row;
width: auto;
}
.portal-rail-link {
width: 40px;
height: 40px;
flex: 0 0 auto;
}
.portal-rail-link.is-active::after,
.portal-rail-link.active::after {
right: auto;
top: auto;
bottom: -3px;
left: 50%;
transform: translateX(-50%);
width: 18px;
height: 3px;
}
}
@@ -227,13 +227,39 @@
@media (max-width: 640px) {
.app-rail {
flex-direction: row;
justify-content: space-between;
padding: 6px 10px;
justify-content: flex-start;
gap: 4px;
padding: 6px 8px;
border-right: none;
border-bottom: 1px solid var(--mat-sys-outline-variant);
// A phone cannot fit every portal section at once, so the bar
// scrolls sideways instead of squeezing the icons into overlap.
overflow-x: auto;
overflow-y: hidden;
&.is-macos {
padding-top: 6px;
}
// The brand links to the dashboard, which the first workspace link
// already does. On a bar this narrow that duplicate costs a section.
.brand {
margin-bottom: 0;
display: none;
}
a {
flex: 0 0 auto;
}
a.is-active::after,
a.active::after {
right: auto;
top: auto;
bottom: -2px;
left: 50%;
transform: translateX(-50%);
width: 18px;
height: 3px;
}
}
@@ -245,6 +271,10 @@
.rail-context-region {
width: auto;
// The vertical rail caps this at 48px; in a row that cap would
// stack every portal section on top of itself.
max-width: none;
flex: 0 0 auto;
flex-direction: row;
margin: 0;
padding: 2px 4px;
@@ -261,7 +291,19 @@
}
}
// Settings sits at the end of a bar that scrolls sideways once a portal
// adds its sections, so it is pinned instead of scrolling out of reach.
.rail-footer {
margin-top: 0;
margin-left: auto;
flex: 0 0 auto;
position: sticky;
right: 0;
padding-left: 4px;
background: linear-gradient(
to right,
transparent,
var(--app-rail-bg, var(--mat-sys-surface-container-low)) 25%
);
}
}
@@ -92,6 +92,19 @@ app-workspace-shell-context-sidebar {
.workspace-body {
grid-column: 1;
grid-row: 3;
// Side by side there is no room for both: a 300px context panel on a
// 375px screen leaves the content unusable, so they stack instead.
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto minmax(0, 1fr);
&.single-pane {
grid-template-rows: minmax(0, 1fr);
}
}
.workspace-content {
border-radius: 0;
box-shadow: none;
}
.workspace-now-playing {