The viewport contract has two ends, and only the floor was enforced. An auto-fill grid answers "more room" with more columns, never bigger ones, so it settles just above its minimum column at any width: measured at 3440px, the feed laid out 11 columns of 264px. A feed diluted across an ultrawide fails the contract as surely as a broken one at the floor. Multi-column feed views now carry a `maxCols`; past it the leftover width flows into the items. At 3440: cards 11x264 -> 8x370, cardsSmall 15 -> 11x264 (still visibly the dense card, and never as wide as cards gets), tiles 7 -> 6x498. Each cap is set so the item tops out near 1.5x its own minCol, which is what keeps a view recognisably itself. A cap can only bind ABOVE the width at which that many columns already fit, so it is a pure ultrawide affordance — 1024, 1920 and 2560 lay out exactly as before. Verified in the browser at all three widths and across all five views. The Channels grid needs no cap: the page's own 96rem --content-mw already bounds it. Also renames VirtualGrid's `maxCol` to `colMaxWidth`. It was never a column count — it is the single-column reading-width cap — and `maxCol`/`maxCols` one letter apart is the kind of drift that has bitten this repo before. The column maths moves to ui/gridColumns.ts as a pure function so both ends of the contract are unit-testable without a layout engine; the tests assert the cap's numbers, that it never binds at the floor, and that the dense card cannot grow into the standard one.
124 lines
5.5 KiB
TypeScript
124 lines
5.5 KiB
TypeScript
// The feed's view vocabulary. Lifted out of Feed (like feedSort.ts) because it has three
|
|
// consumers that can't share module-private state: App owns the pref, the toolbar's
|
|
// ViewSwitcher offers it, and VirtualFeed/VideoCard render it.
|
|
export type FeedView = "cards" | "cardsSmall" | "rows" | "rowsCompact" | "tiles";
|
|
|
|
export const FEED_VIEWS: readonly FeedView[] = [
|
|
"cards",
|
|
"cardsSmall",
|
|
"rows",
|
|
"rowsCompact",
|
|
"tiles",
|
|
] as const;
|
|
|
|
const FEED_VIEW_DEFAULT: FeedView = "cards";
|
|
|
|
export interface FeedViewSpec {
|
|
/** Which shape VideoCard renders: a portrait card (thumbnail on top) or a horizontal block. */
|
|
family: "card" | "row";
|
|
/** Narrowest grid column in px, or null for ONE full-width column. VirtualFeed derives the
|
|
* column count from this and the container width, so every multi-column view reflows for free. */
|
|
minCol: number | null;
|
|
/** Single-column views only: the reading-width cap in px (null = fill). A long line is hard to
|
|
* scan, so the stacked row keeps a cap; the one-line row doesn't need as tight a one. */
|
|
colMaxWidth: number | null;
|
|
/** Multi-column views only: the most columns to lay out (null = as many as fit). The UPPER end of
|
|
* the 1024x768..ultrawide contract (E10 S4). An auto-fill grid always settles just above `minCol`,
|
|
* so without a cap a wider screen buys more, smaller items rather than better ones — measured at
|
|
* 3440px, `cards` laid out 11 columns of 264px. Each cap here is set so the item tops out near
|
|
* 1.5x its own `minCol` on a 3440px ultrawide, which keeps each view recognisably itself (a capped
|
|
* `cardsSmall` must not grow into a `cards`). Below the width where that many columns already fit
|
|
* the cap does nothing at all, so 1024 through 2560 are byte-identical to before it existed. */
|
|
maxCols: number | null;
|
|
/** Show the video thumbnail. False only for the bare row — whose badges (duration, live state,
|
|
* saved, resume bar) then move into the meta line, since Thumb is what draws them. */
|
|
thumb: boolean;
|
|
/** card family: tighter padding and no channel avatar, for the small card's ~180px column. */
|
|
dense: boolean;
|
|
/** row family: actions sit under the meta rather than docked at the right edge. Frees ~168px of
|
|
* width (measured), which is what lets a row block survive in a narrow tile column. */
|
|
actionsBelow: boolean;
|
|
}
|
|
|
|
// The whole matrix in one place. Adding a view here makes TS point at every switch that needs it.
|
|
export const FEED_VIEW_SPEC: Record<FeedView, FeedViewSpec> = {
|
|
cards: {
|
|
family: "card",
|
|
minCol: 260,
|
|
colMaxWidth: null,
|
|
// 8 -> 369px cards at 3440 (vs 11 x 264 uncapped); binds only above ~2560.
|
|
maxCols: 8,
|
|
thumb: true,
|
|
dense: false,
|
|
actionsBelow: false,
|
|
},
|
|
cardsSmall: {
|
|
family: "card",
|
|
minCol: 180,
|
|
colMaxWidth: null,
|
|
// 11 -> 264px at 3440: still visibly the DENSE card, and never as wide as `cards` gets.
|
|
maxCols: 11,
|
|
thumb: true,
|
|
dense: true,
|
|
actionsBelow: false,
|
|
},
|
|
// Single column on purpose: the point of the list is that your eye runs down one edge with the
|
|
// titles stacked. Density must not cost that — `tiles` is the multi-column option instead.
|
|
// Keeps the 896px cap: its actions dock at the right edge, so a wider row would only stretch the
|
|
// dead space between them and the meta.
|
|
rows: {
|
|
family: "row",
|
|
minCol: null,
|
|
colMaxWidth: 896,
|
|
maxCols: null,
|
|
thumb: true,
|
|
dense: false,
|
|
actionsBelow: false,
|
|
},
|
|
// Much wider (1472): everything sits on one line and the columns after the title are fixed, so
|
|
// there's no gap for the extra width to stretch — it all goes to the title. Raised from 1280 at
|
|
// the user's request once they saw how much room was left over with the rail and filters hidden.
|
|
rowsCompact: {
|
|
family: "row",
|
|
minCol: null,
|
|
colMaxWidth: 1472,
|
|
maxCols: null,
|
|
thumb: false,
|
|
dense: false,
|
|
actionsBelow: false,
|
|
},
|
|
// 380 = thumbnail (160) + gap (12) + ~192 for the text + the block's own padding (16). The text
|
|
// floor is the action row's measured 156px plus enough for a title to be worth reading.
|
|
// Tuned by measuring the real layouts, not guessed: 3 columns need 3n+32 px, so 380 turns the
|
|
// channel page (1208px) and the unpinned feed into 3 columns while the filter-pinned feed
|
|
// (~955px) still gets 2. 470 was tried first and needed 956px for two — the common layout fell
|
|
// back to ONE column and the mode looked broken; 400 then left 596px tiles whose titles ended
|
|
// halfway, which is the very waste this mode exists to kill.
|
|
tiles: {
|
|
family: "row",
|
|
minCol: 380,
|
|
colMaxWidth: null,
|
|
// 6 -> 498px tiles at 3440. A tile spends extra width on its TEXT column rather than on a bigger
|
|
// thumbnail, so it tolerates dilution better than a card — but it still bottoms out at its 380px
|
|
// minimum on an ultrawide, which is the same waste, so it gets the same treatment.
|
|
maxCols: 6,
|
|
thumb: true,
|
|
dense: false,
|
|
actionsBelow: true,
|
|
},
|
|
};
|
|
|
|
function isFeedView(v: unknown): v is FeedView {
|
|
return typeof v === "string" && (FEED_VIEWS as readonly string[]).includes(v);
|
|
}
|
|
|
|
/** Coerce a stored pref to a view. Up to 0.44 it was "grid" | "list"; those map forward here so
|
|
* nobody's saved choice is lost. Anything else (a corrupt value, a future view rolled back) falls
|
|
* back to the default rather than rendering nothing. */
|
|
export function parseFeedView(raw: unknown): FeedView {
|
|
if (isFeedView(raw)) return raw;
|
|
if (raw === "grid") return "cards";
|
|
if (raw === "list") return "rows";
|
|
return FEED_VIEW_DEFAULT;
|
|
}
|