fix(ui): make the app fit the window it enforces a minimum for

The track list shared out its whole clientWidth across the resizable
columns while every row spends 24px on the favourite column and 2x8px
on its own padding before the first one starts, so the grid was always
exactly 40px wider than the box holding it and the last column was
clipped at every size (scrollWidth 1280 vs clientWidth 1240, measured).
Both numbers now live in one place and are read by the two call sites
that had written them out separately, which is how they came to
disagree.

The enforced minimum was 512x384, which the layout had never
supported: at 700x480 the eleven sidebar items needed 406px of a 352px
pane, overflow:hidden cut the last two off with nothing to scroll, and
Settings and Jobs could not be reached at all. The pane scrolls now,
the sidebar collapses to icons below 900px (its .collapsed mode existed
and only a manual drag ever reached it), the subtitle hides at the same
breakpoint so the title stops wrapping out of the 4em bar, and the
minimum is 800x600 - measured as where the shell still works rather
than picked as a round number.
This commit is contained in:
2026-08-12 01:44:56 -04:00
parent 9e92721bb7
commit e9ca16362f
6 changed files with 354 additions and 10 deletions
@@ -18,6 +18,14 @@ const MAX_WIDTH = 400;
const DEFAULT_WIDTH = 200;
const COLLAPSE_WIDTH = 142;
/**
* Below this viewport width the sidebar collapses itself to icons.
* `.collapsed` existed and only a manual drag ever reached it (H-11),
* so a small window kept a 200 px sidebar it could not afford and the
* content pane wore the whole loss.
*/
const AUTO_COLLAPSE_VIEWPORT = 900;
@customElement('app-sidebar')
export class AppSidebar extends LitElement {
static override styles = [designTokens, css`
@@ -28,6 +36,14 @@ export class AppSidebar extends LitElement {
background-color: var(--yj-bg-surface, #212529);
min-width: ${MIN_WIDTH}px;
max-width: ${MAX_WIDTH}px;
/* Eleven items need ~406 px and the pane is whatever the
window leaves it — 352 px at 700x480, which clipped Jobs
and Settings behind the player bar with no way to reach
them (H-11). Collapsing to icons does not help: it is a
width mode, and this is the height. */
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
}
.resize-handle {
@@ -147,6 +163,12 @@ export class AppSidebar extends LitElement {
@state()
private collapsed = false;
/** The width the user chose, restored when the window grows back. */
private userWidth = DEFAULT_WIDTH;
private narrowViewport: MediaQueryList | null =
null;
/** Whether a track drag is in progress somewhere in the app. */
@state()
private trackDragActive = false;
@@ -176,6 +198,14 @@ export class AppSidebar extends LitElement {
override connectedCallback() {
super.connectedCallback();
this.style.width = `${DEFAULT_WIDTH}px`;
this.narrowViewport = window.matchMedia(
`(max-width: ${AUTO_COLLAPSE_VIEWPORT - 1}px)`,
);
this.narrowViewport.addEventListener(
'change',
this.onViewportChange,
);
this.applyViewportWidth();
document.addEventListener(
'mousemove',
this.handleMouseMove,
@@ -192,6 +222,11 @@ export class AppSidebar extends LitElement {
override disconnectedCallback() {
super.disconnectedCallback();
this.narrowViewport?.removeEventListener(
'change',
this.onViewportChange,
);
this.narrowViewport = null;
document.removeEventListener(
'mousemove',
this.handleMouseMove,
@@ -284,8 +319,29 @@ export class AppSidebar extends LitElement {
this.style.width = `${clampedWidth}px`;
this.collapsed = clampedWidth < COLLAPSE_WIDTH;
this.userWidth = clampedWidth;
};
private onViewportChange = () => {
this.applyViewportWidth();
};
/**
* Icons below the breakpoint, the user's own width above it. The
* width is inline (set here and by the drag handle), so this cannot
* be a media query in the stylesheet.
*/
private applyViewportWidth() {
const narrow =
this.narrowViewport?.matches ?? false;
const width = narrow
? MIN_WIDTH
: this.userWidth;
this.style.width = `${width}px`;
this.collapsed = width < COLLAPSE_WIDTH;
}
private handleMouseUp = () => {
this.isDragging = false;
};
@@ -70,6 +70,18 @@ const SORT_DIR_KEY = 'track-list-sort-direction';
const MIN_COLUMN_WIDTH = 50;
const DEFAULT_FIXED_WIDTH = 80;
/**
* Chrome the resizable columns cannot use: the fixed favourite column
* and the row's own horizontal padding. Both numbers also appear in
* the CSS below — the `24px` first track of `--grid-cols` and
* `.track-row`/`.header-row`'s `padding: 8px` — so they live here and
* are read from both places rather than written out four times.
*/
const FAV_COL_WIDTH = 24;
const ROW_PADDING_X = 8;
const ROW_CHROME_WIDTH =
FAV_COL_WIDTH + ROW_PADDING_X * 2;
// Inline SVG paths for favorite icons — eliminates wa-icon shadow DOM
// overhead (30-50 shadow roots during scroll). Font Awesome 6 paths.
const FAV_ICONS = {
@@ -448,7 +460,7 @@ export class TrackList
private get gridTemplateColumns(): string {
const cols = this.activeColumns;
const favCol = '24px';
const favCol = `${FAV_COL_WIDTH}px`;
if (this.columnWidths.length === 0 || this.columnWidths.length !== cols.length) {
return (
@@ -472,10 +484,9 @@ export class TrackList
private get colBoundaryPositions(): number[] {
if (this.columnWidths.length === 0) return [];
const padding = 8;
const favColWidth = 24;
const positions: number[] = [];
let cumulative = padding + favColWidth;
let cumulative =
ROW_PADDING_X + FAV_COL_WIDTH;
for (
let i = 0;
@@ -502,8 +513,18 @@ export class TrackList
this.computeDefaultWidths();
}
/**
* Width the resizable columns may share. H-7: this was the raw
* `clientWidth`, which the columns then summed to exactly — so
* every row was `ROW_CHROME_WIDTH` (40 px) wider than the box it
* had to fit in and the last column was always clipped.
*/
private get availableColumnWidth(): number {
return this.clientWidth - ROW_CHROME_WIDTH;
}
private computeDefaultWidths() {
const totalWidth = this.clientWidth;
const totalWidth = this.availableColumnWidth;
if (totalWidth <= 0) return;
@@ -631,13 +652,14 @@ export class TrackList
}
/**
* Scale widths so they sum to exactly the container width.
* Scale widths so they sum to exactly the width available to the
* columns (the host minus the row chrome).
* Every column is guaranteed at least MIN_COLUMN_WIDTH.
*/
private normalizeWidths(
widths: number[],
): number[] {
const container = this.clientWidth;
const container = this.availableColumnWidth;
if (container <= 0 || widths.length === 0) {
return widths;
@@ -731,7 +753,7 @@ export class TrackList
private onColResizeMove = (e: MouseEvent) => {
if (this.resizingColumn === null) return;
const container = this.clientWidth;
const container = this.availableColumnWidth;
if (container <= 0) return;