feat: the Latest feed uses a wide window — filmstrip cards, a day gutter and a filter rail (407 A, D, E)
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 2s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 6s
Build images / build-ml (push) Successful in 6s
CI / frontend-build (push) Successful in 25s
CI / backend-lint-and-test (push) Successful in 33s
Build images / build-web (push) Successful in 1m1s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m10s

On the operator's 3432px window the feed was a 900px column, 26% of the width. They picked three options from a to-scale layout study.

A — filmstrip card (PostCard.vue):
- A card measures itself with a ResizeObserver. At 1100px or wider its hero gets a fixed height, clamp(260px, 34vh, 460px), and the extra images move into a 2-column grid of squares beside it. The grid cells are sized from the hero height, so the grid ends flush with the hero.
- The rail cap is 4 cells in this layout (2×2, the last becoming "+N") and 5 in the narrow layout, which is unchanged.
- The description clamp drops to 4 lines, because long reads happen in the expanded view.
- The hero has a height, not a width, so a wide card can't grow into a full-screen post. That was the operator's constraint.

D — day gutter (PostsView.vue):
- The normal feed groups consecutive posts by local day (Today, Yesterday, a weekday, or a date), with post and artist counts for what has loaded.
- Runs rather than date buckets, because the sort key includes resurfaced_at, which the payload doesn't carry. A resurfaced grouping gets its own heading where it actually appears, instead of being pulled out of order.

E — filter rail (PostsView.vue):
- At 1600px and wider, the filters and status ribbon stack in a sticky 280px left rail, and each day's heading sits in a sticky 150px gutter beside its posts.
- Below 1600px the layout is exactly the old one, including the 900px column.

The in-context (post_id) view gets the wide column but no rail or day grouping, so anchor scrolling is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9
This commit is contained in:
2026-09-13 19:48:46 -04:00
co-authored by Claude Opus 5
parent 4b4e532c56
commit 98c3b74260
2 changed files with 221 additions and 22 deletions
+73 -11
View File
@@ -1,5 +1,8 @@
<template>
<v-card class="fc-post-card" variant="outlined">
<v-card
ref="cardEl" class="fc-post-card" variant="outlined"
:class="{ 'fc-post-card--wide': wide }"
>
<div class="fc-post-card__head">
<!-- Posts with no live subscription have source=null (alembic 0030);
show a "filesystem import" affordance instead of a platform chip. -->
@@ -58,7 +61,7 @@
</button>
<div
v-if="rail.length || moreCount" class="fc-post-card__rail"
:style="{ '--fc-rail-cols': railCols }"
:style="{ '--fc-rail-cols': railCols, '--fc-grid-cols': Math.min(2, railCols) }"
>
<button
v-for="t in rail" :key="t.image_id" type="button"
@@ -221,20 +224,33 @@ const synthesisTitle = computed(() => {
})
const hero = computed(() => images.value[0])
// The thumbnail strip spans the hero's full width (CSS grid, equal columns),
// rather than a fixed 3-cell cap. Show up to RAIL_MAX cells; when there are
// more images than fit, the last cell becomes a "+N" overflow tile so the
// count stays accurate.
const RAIL_MAX = 5
// Filmstrip layout (milestone #407, option A). On a very wide window a card
// that just grew would give one post the whole screen, so a WIDE card instead
// pins the hero to a fixed height and moves the extra images into a 2-column
// grid BESIDE it. Measured on the card rather than the viewport because the
// same card renders in the Latest feed, Browse, and the in-context view, each
// at a different width.
const WIDE_CARD_PX = 1100
const cardEl = ref(null)
const wide = ref(false)
// The narrow layout's strip spans the hero's full width (CSS grid, equal
// columns); the wide layout's grid is 2×2. Show up to that many cells; when
// there are more images than fit, the last cell becomes a "+N" overflow tile so
// the count stays accurate.
const RAIL_MAX_NARROW = 5
const RAIL_MAX_WIDE = 4
const serverMore = computed(() => props.post.thumbnails_more || 0)
const afterHero = computed(() => images.value.slice(1))
const railMax = computed(() => (wide.value ? RAIL_MAX_WIDE : RAIL_MAX_NARROW))
const hasOverflow = computed(
() => serverMore.value > 0 || afterHero.value.length > RAIL_MAX,
() => serverMore.value > 0 || afterHero.value.length > railMax.value,
)
const rail = computed(() =>
hasOverflow.value
? afterHero.value.slice(0, RAIL_MAX - 1)
: afterHero.value.slice(0, RAIL_MAX),
? afterHero.value.slice(0, railMax.value - 1)
: afterHero.value.slice(0, railMax.value),
)
const visibleCount = computed(() => (images.value.length ? 1 + rail.value.length : 0))
const moreCount = computed(() => {
@@ -349,8 +365,17 @@ function measureOverflow () {
}
let ro = null
let cardRo = null
onMounted(() => {
nextTick(measureOverflow)
const root = cardEl.value?.$el
if (typeof ResizeObserver !== 'undefined' && root) {
cardRo = new ResizeObserver((entries) => {
const w = entries[0]?.contentRect?.width ?? 0
wide.value = w >= WIDE_CARD_PX
})
cardRo.observe(root)
}
// Re-measure when the card resizes (the container-query clamp differs by
// width). Guarded for happy-dom / older runtimes without ResizeObserver.
if (typeof ResizeObserver !== 'undefined' && descEl.value) {
@@ -358,7 +383,10 @@ onMounted(() => {
ro.observe(descEl.value)
}
})
onBeforeUnmount(() => { if (ro) { ro.disconnect(); ro = null } })
onBeforeUnmount(() => {
if (ro) { ro.disconnect(); ro = null }
if (cardRo) { cardRo.disconnect(); cardRo = null }
})
async function toggleDesc () {
if (!descExpanded.value) {
@@ -495,6 +523,40 @@ function formatBytes (n) {
color: rgb(var(--v-theme-accent));
}
/* --- Filmstrip layout (wide cards, #407 A) ---------------------------------
The hero has a HEIGHT, not a width: a wide card must not turn into a
full-screen post, so its height stays roughly a third of the viewport
whatever the window's width. The extra images sit beside it as square cells
whose size derives from that same height, so the grid always ends flush with
the hero's bottom edge. */
.fc-post-card--wide {
--fc-hero-h: clamp(260px, 34vh, 460px);
--fc-grid-gap: 8px;
--fc-cell: calc((var(--fc-hero-h) - var(--fc-grid-gap)) / 2);
}
.fc-post-card--wide .fc-post-card__body { flex-direction: row; gap: 24px; }
.fc-post-card--wide .fc-post-card__media {
flex: 0 0 auto;
display: flex;
gap: var(--fc-grid-gap);
}
.fc-post-card--wide .fc-post-card__hero {
width: auto;
height: var(--fc-hero-h);
}
.fc-post-card--wide .fc-post-card__rail {
margin-top: 0;
gap: var(--fc-grid-gap);
grid-template-columns: repeat(var(--fc-grid-cols, 2), var(--fc-cell));
grid-auto-rows: var(--fc-cell);
}
.fc-post-card--wide .fc-post-card__rail-cell,
.fc-post-card--wide .fc-post-card__rail-more { height: 100%; }
.fc-post-card--wide .fc-post-card__text { flex: 1 1 0; min-width: 0; }
/* Text is secondary here — long reads happen in the expanded view — so the
clamp keeps the text column no taller than the images beside it. */
.fc-post-card--wide .fc-post-card__desc--clamped { -webkit-line-clamp: 4; }
.fc-post-card__title {
font-family: 'Fraunces', Georgia, serif;
font-size: 18px; font-weight: 700;