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;
+148 -11
View File
@@ -1,5 +1,8 @@
<template>
<v-container class="pt-2 pb-6" max-width="900">
<!-- Width is set in CSS, not with `max-width` here: below 1600px it is
today's 900px column, and above it the feed widens and gains the rail
and day gutter (milestone #407). -->
<v-container fluid class="pt-2 pb-6 fc-posts">
<!-- In-context view: deep-linked to one post, with bidirectional infinite
scroll — newer posts load above, older posts below. -->
<template v-if="postIdFilter != null">
@@ -48,15 +51,19 @@
</template>
<!-- Normal feed -->
<template v-else>
<FeedStatusRibbon v-if="statusRibbon" />
<PostsFilterBar
:artist-id="artistFilter"
:platform="platformFilter"
@update:filters="onFilters"
/>
<div v-else class="fc-posts__layout">
<!-- On a wide window this is a sticky left rail (#407 E); below the
breakpoint it lays out exactly as the old inline header did. -->
<aside class="fc-posts__rail">
<PostsFilterBar
:artist-id="artistFilter"
:platform="platformFilter"
@update:filters="onFilters"
/>
<FeedStatusRibbon v-if="statusRibbon" />
</aside>
<div class="fc-posts__main">
<v-alert v-if="store.error" type="error" variant="tonal" closable class="mb-3">
{{ String(store.error) }}
</v-alert>
@@ -74,14 +81,28 @@
</div>
<div v-else>
<PostCard v-for="p in store.items" :key="p.id" :post="p" />
<!-- Day groups (#407 D). The heading sits above its posts on a narrow
window and in a sticky left gutter on a wide one. -->
<section v-for="d in days" :key="d.key" class="fc-posts__day">
<header class="fc-posts__day-head">
<span class="fc-posts__day-label">{{ d.label }}</span>
<span class="fc-posts__day-count">
{{ d.posts.length }} post{{ d.posts.length === 1 ? '' : 's' }}
· {{ d.artistCount }} artist{{ d.artistCount === 1 ? '' : 's' }}
</span>
</header>
<div class="fc-posts__day-posts">
<PostCard v-for="p in d.posts" :key="p.id" :post="p" />
</div>
</section>
<div ref="sentinel" class="fc-posts__sentinel">
<v-progress-circular v-if="store.loading" indeterminate color="accent" size="24" />
<span v-else-if="store.done" class="fc-posts__end">End of stream</span>
</div>
</div>
</template>
</div>
</div>
</v-container>
</template>
@@ -121,6 +142,48 @@ const hasActiveFilter = computed(() =>
artistFilter.value != null || platformFilter.value != null || searchFilter.value != null
)
// --- day groups (#407 D) ---
// CONSECUTIVE runs, not a bucket per date. The feed's sort key includes
// `resurfaced_at` (a Discord grouping that grew moves back to the top), which
// the payload does not carry, so a resurfaced post can sit above newer ones.
// Bucketing by date would pull it out of order; a run gives it its own heading
// where it actually appears. Counts cover what has LOADED, and grow as the
// infinite scroll fetches more of the same day.
function dayKey (iso) {
const d = new Date(iso)
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`
}
function dayLabel (iso) {
const d = new Date(iso)
const today = new Date()
const startOf = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime()
const days = Math.round((startOf(today) - startOf(d)) / 86400000)
if (days === 0) return 'Today'
if (days === 1) return 'Yesterday'
if (days > 1 && days < 7) return d.toLocaleDateString(undefined, { weekday: 'long' })
const sameYear = d.getFullYear() === today.getFullYear()
return d.toLocaleDateString(undefined, {
month: 'short', day: 'numeric', ...(sameYear ? {} : { year: 'numeric' }),
})
}
const days = computed(() => {
const groups = []
for (const p of store.items) {
const iso = p.post_date || p.downloaded_at
const key = dayKey(iso)
let g = groups[groups.length - 1]
if (!g || g.dayKey !== key) {
// Suffix with the run index so a day that appears twice (see above)
// still has a unique v-for key.
g = { key: `${key}#${groups.length}`, dayKey: key, label: dayLabel(iso), posts: [], artists: new Set() }
groups.push(g)
}
g.posts.push(p)
if (p.artist?.id != null) g.artists.add(p.artist.id)
}
return groups.map((g) => ({ ...g, artistCount: g.artists.size }))
})
// Drop only `post_id` and stay where we are — keeps Browse's `tab=posts` (and
// any active artist/platform scope) intact instead of resetting the surface.
const allPostsTarget = computed(() => {
@@ -232,6 +295,80 @@ onUnmounted(() => { teardownFeed(); teardownAround() })
</script>
<style scoped>
/* Below the breakpoint: today's layout exactly — a 900px column with the
filters and ribbon inline above the feed. */
.fc-posts { max-width: 900px; }
.fc-posts__day-head {
display: flex;
align-items: baseline;
gap: 10px;
padding: 4px 0 8px;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-posts__day-label {
font-family: 'Fraunces', Georgia, serif;
font-weight: 600;
font-size: 1rem;
color: rgb(var(--v-theme-accent));
}
.fc-posts__day-count { font-size: 0.78rem; }
/* Wide window (#407 D + E). The rail holds filters and status; each day's
heading moves into a sticky gutter beside its posts; the column widens and
the cards switch to their filmstrip layout on their own (PostCard measures
itself). 1600px is where a 280px rail and a 150px gutter still leave a card
wide enough to be worth the change. */
@media (min-width: 1600px) {
.fc-posts { max-width: 2360px; }
.fc-posts__layout {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
gap: 40px;
align-items: start;
}
.fc-posts__rail {
position: sticky;
top: calc(var(--fc-nav-h, 64px) + 16px);
display: flex;
flex-direction: column;
gap: 16px;
}
.fc-posts__rail :deep(.fc-posts-filters) {
flex-direction: column;
align-items: stretch;
padding-bottom: 0;
}
.fc-posts__rail :deep(.fc-posts-filters__artist),
.fc-posts__rail :deep(.fc-posts-filters__platform) {
flex: none;
width: 100%;
min-width: 0;
max-width: none;
}
.fc-posts__rail :deep(.fc-ribbon) {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.fc-posts__main { max-width: 1900px; }
.fc-posts__day {
display: grid;
grid-template-columns: 150px minmax(0, 1fr);
gap: 0 24px;
}
.fc-posts__day-head {
position: sticky;
top: calc(var(--fc-nav-h, 64px) + 16px);
align-self: start;
flex-direction: column;
align-items: flex-end;
gap: 2px;
padding-top: 12px;
text-align: right;
}
.fc-posts__day-label { font-size: 1.1rem; }
}
.fc-posts__loading,
.fc-posts__empty {
display: flex;