feat(feed): add the small-card, compact-row and tile views

Tiles answer the row view's wasted space: the actions move under the meta
into vertical slack the thumbnail already reserves, which frees ~168px of
width, so the block survives in a narrow column and the list can go
multi-column. Rows stay single-column on purpose — reading down one edge
is why that view exists.

The column count now comes from a per-view minimum width, so every
multi-column view reflows on the mechanism the card grid already used.
This commit is contained in:
2026-07-16 03:17:30 +02:00
parent 1a94411a03
commit 78f21eb46b
6 changed files with 127 additions and 54 deletions
+39 -2
View File
@@ -1,12 +1,49 @@
// 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" | "rows";
export type FeedView = "cards" | "cardsSmall" | "rows" | "rowsCompact" | "tiles";
export const FEED_VIEWS: readonly FeedView[] = ["cards", "rows"] as const;
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;
/** card family: a tighter card. row family: no thumbnail at all. */
compact: 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, compact: false, actionsBelow: false },
cardsSmall: { family: "card", minCol: 180, compact: 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.
rows: { family: "row", minCol: null, compact: false, actionsBelow: false },
rowsCompact: { family: "row", minCol: null, compact: true, 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, compact: false, actionsBelow: true },
};
function isFeedView(v: unknown): v is FeedView {
return typeof v === "string" && (FEED_VIEWS as readonly string[]).includes(v);
}