v3: overhauled journal view
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 42s

This commit is contained in:
khannurien
2026-06-27 10:57:12 +00:00
parent 4cb537f4d9
commit 01940e14be
6 changed files with 377 additions and 239 deletions

View File

@@ -3998,106 +3998,55 @@ body.has-player .fab-new {
/* ── Journal masonry grid ── */
/* ══ Journal — editorial mosaic ══════════════════════════════════════
A composed scrapbook: one hero feature plus an asymmetric mix of
banners, portraits, squares and pull-quotes. Footprints are set by the
layout composer; how a card paints (image / quote / text) follows its
content. Dense auto-flow backfills the gaps the varied spans leave. */
.journal-grid {
list-style: none;
margin: 0;
padding: 1rem 1.25rem 0;
margin: 0 auto;
padding: 1.25rem;
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(4, 1fr);
grid-auto-rows: 9rem;
grid-auto-flow: dense;
gap: 0.875rem;
max-width: 1100px;
gap: 0.75rem;
max-width: 1180px;
width: 100%;
box-sizing: border-box;
align-self: center;
}
/* ── Footprints ── */
.journal-card--feature {
grid-column: span 2;
grid-row: span 2;
}
.journal-card--wide {
grid-column: span 2;
}
.journal-card--tall {
grid-row: span 2;
}
/* ── Shared shell ── */
.journal-card {
position: relative;
display: flex;
flex-direction: column;
border: 2px solid var(--color-border);
border-radius: 10px;
border-radius: 12px;
background: var(--color-surface);
overflow: hidden;
cursor: pointer;
transition: border-color 0.15s, opacity 0.15s;
min-width: 0;
transition: border-color 0.15s, transform 0.15s, box-shadow 0.15s;
}
.journal-card:hover {
border-color: var(--color-accent);
}
/* Large: spans 2 columns, media header + body */
.journal-card--large {
grid-column: span 2;
}
.journal-card--large .journal-card-media {
width: 100%;
aspect-ratio: 16 / 9;
overflow: hidden;
background: var(--color-bg);
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.journal-card--large .journal-card-media .rich-content-play-overlay {
font-size: 2rem;
}
.journal-card--large .journal-card-media:hover .rich-content-play-overlay {
background: rgba(0, 0, 0, 0.5);
}
.journal-card--large .journal-card-media img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* Medium: icon left + content right */
.journal-card--medium .journal-card-inner {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem;
}
.journal-card-icon {
flex-shrink: 0;
width: 44px;
height: 44px;
border-radius: 6px;
overflow: hidden;
background: var(--color-bg);
border: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.4rem;
}
.journal-card-icon img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* Shared body */
.journal-card-body {
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.3rem;
min-width: 0;
flex: 1;
}
.journal-card--medium .journal-card-body {
padding: 0;
transform: translateY(-2px);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.18);
}
.journal-card-title {
@@ -4105,66 +4054,191 @@ body.has-player .fab-new {
font-size: 0.95rem;
color: var(--color-text);
text-decoration: none;
line-height: 1.35;
line-height: 1.3;
word-break: break-word;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
}
.journal-card-title:hover {
color: var(--color-accent);
}
.journal-card-comment {
font-size: 0.82rem;
opacity: 0.65;
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
margin: 0;
line-height: 1.4;
}
.journal-card--large .journal-card-comment {
-webkit-line-clamp: 3;
line-clamp: 3;
}
.journal-card--medium .journal-card-comment {
-webkit-line-clamp: 1;
line-clamp: 1;
}
.journal-card-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-top: 0.2rem;
}
.journal-card-meta {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.78rem;
font-size: 0.74rem;
color: var(--color-text-secondary);
flex-wrap: wrap;
min-width: 0;
}
/* Small: single compact row */
.journal-card--small {
.journal-card-vote {
flex-shrink: 0;
}
/* ── Image cards — full-bleed thumbnail, text laid over a scrim ── */
.journal-card--image .journal-card-image {
position: absolute;
inset: 0;
background: var(--color-bg);
}
.journal-card--image .journal-card-image img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* The play affordance is decorative — clicks fall through to the card, which
plays the embed. This keeps the hit target from being covered by the text
overlay on smaller card sizes. */
.journal-card--image .rich-content-play-overlay {
pointer-events: none;
}
.journal-card--image:hover .rich-content-play-overlay {
background: rgba(0, 0, 0, 0.5);
}
.journal-card--image .journal-card-overlay {
position: relative;
margin-top: auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.55rem 0.875rem;
flex-direction: column;
gap: 0.3rem;
padding: 1.4rem 0.8rem 0.65rem;
background: linear-gradient(
to top,
rgba(0, 0, 0, 0.85) 0%,
rgba(0, 0, 0, 0.55) 55%,
rgba(0, 0, 0, 0) 100%
);
}
.journal-card--small .journal-card-title {
flex: 1;
white-space: nowrap;
.journal-card--image .journal-card-title {
color: #fff;
}
.journal-card--image .journal-card-title:hover {
color: var(--color-accent);
}
.journal-card--image .journal-card-meta {
color: rgba(255, 255, 255, 0.78);
}
.journal-card--image .journal-card-comment {
font-size: 0.82rem;
line-height: 1.4;
margin: 0;
color: rgba(255, 255, 255, 0.85);
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
text-overflow: ellipsis;
font-size: 0.87rem;
}
.journal-card--feature .journal-card-title {
font-size: 1.25rem;
-webkit-line-clamp: 3;
line-clamp: 3;
}
.journal-card--square.journal-card--image .journal-card-title {
font-size: 0.85rem;
}
/* ── Quote cards — the note is the subject, on an accent wash ── */
.journal-card--quote {
padding: 0.9rem;
gap: 0.4rem;
background: color-mix(in srgb, var(--color-accent) 12%, var(--color-surface));
}
.journal-card-quotemark {
position: absolute;
top: -0.5rem;
right: 0.4rem;
font-family: Georgia, "Times New Roman", serif;
font-size: 4.5rem;
line-height: 1;
color: var(--color-accent);
opacity: 0.22;
pointer-events: none;
}
.journal-card-quote-body {
position: relative;
z-index: 1;
margin: 0;
font-size: 0.92rem;
line-height: 1.45;
color: var(--color-text);
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
line-clamp: 3;
overflow: hidden;
}
.journal-card--wide.journal-card--quote .journal-card-quote-body,
.journal-card--feature.journal-card--quote .journal-card-quote-body {
font-size: 1rem;
-webkit-line-clamp: 2;
line-clamp: 2;
}
.journal-card-attribution {
margin-top: auto;
display: flex;
flex-direction: column;
gap: 0.3rem;
padding-top: 0.5rem;
}
.journal-card--quote .journal-card-title {
font-size: 0.8rem;
font-weight: 600;
color: var(--color-text-secondary);
-webkit-line-clamp: 1;
line-clamp: 1;
}
.journal-card--quote .journal-card-title::before {
content: "— ";
}
/* ── Text cards — typographic fallback for note-less, image-less dumps ── */
.journal-card--text {
padding: 0.85rem;
gap: 0.4rem;
}
.journal-card-glyph {
font-size: 1.5rem;
line-height: 1;
}
.journal-card--text .journal-card-title {
font-size: 0.92rem;
-webkit-line-clamp: 3;
line-clamp: 3;
}
.journal-card--text .journal-card-footer {
margin-top: auto;
}
/* feed-end message */
@@ -4176,20 +4250,37 @@ body.has-player .fab-new {
opacity: 0.55;
}
@media (max-width: 860px) {
@media (max-width: 980px) {
.journal-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 680px) {
.journal-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 540px) {
@media (max-width: 460px) {
.journal-grid {
grid-template-columns: 1fr;
padding: 0.75rem 1rem 0;
grid-auto-rows: auto;
padding: 0.85rem 1rem;
}
.journal-card--large {
grid-column: span 1;
/* Collapse the mosaic into a single readable column. */
.journal-card {
grid-column: auto;
grid-row: auto;
}
.journal-card--image {
aspect-ratio: 16 / 10;
}
.journal-card--feature.journal-card--image {
aspect-ratio: 4 / 3;
}
}

View File

@@ -7,16 +7,17 @@ import { relativeTime } from "../utils/relativeTime.ts";
import { dumpFileUrl, dumpUrl } from "../utils/urls.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { isDumpVisited, isRecent, markDumpVisited } from "../utils/visited.ts";
import { hasQuote, hasThumbnail, type JournalShape } from "../utils/journalLayout.ts";
import { VoteButton } from "./VoteButton.tsx";
import { Markdown } from "./Markdown.tsx";
import { Tooltip } from "./Tooltip.tsx";
import { PlayerContext } from "../contexts/PlayerContext.ts";
export type JournalTier = "large" | "medium" | "small";
export type { JournalShape };
interface JournalCardProps {
dump: Dump;
tier: JournalTier;
shape: JournalShape;
voteCount: number;
voted: boolean;
canVote: boolean;
@@ -26,7 +27,7 @@ interface JournalCardProps {
}
export function JournalCard(
{ dump, tier, voteCount, voted, canVote, castVote, removeVote, isOwner }:
{ dump, shape, voteCount, voted, canVote, castVote, removeVote, isOwner }:
JournalCardProps,
) {
const navigate = useNavigate();
@@ -63,6 +64,15 @@ export function JournalCard(
return rawThumbnail;
})();
// Content mode is independent of grid footprint: a thumbnailed dump reads as
// an image card, a thumbnail-less dump with a note becomes a pull-quote, and
// everything else falls back to a typographic text card.
const mode: "image" | "quote" | "text" = hasThumbnail(dump) && thumbnailUrl
? "image"
: hasQuote(dump)
? "quote"
: "text";
const fallbackIcon = dump.kind === "file"
? (() => {
const m = dump.fileMime ?? "";
@@ -72,6 +82,8 @@ export function JournalCard(
})()
: "🔗";
const embedUrl = dump.richContent?.embedUrl;
const titleLink = (
<Link
to={dumpUrl(dump)}
@@ -111,7 +123,7 @@ export function JournalCard(
);
const vote = (
<div onClick={(e) => e.stopPropagation()}>
<div className="journal-card-vote" onClick={(e) => e.stopPropagation()}>
<VoteButton
dumpId={dump.id}
count={voteCount}
@@ -123,96 +135,85 @@ export function JournalCard(
</div>
);
const embedUrl = dump.richContent?.embedUrl;
const footer = (
<div className="journal-card-footer">
{meta}
{vote}
</div>
);
if (tier === "large") {
return (
<li className="journal-card journal-card--large" onClick={handleNavigate}>
{thumbnailUrl && (
<div
className="journal-card-media"
onClick={embedUrl
? (e) => {
e.stopPropagation();
play({
kind: "embed",
embedUrl,
title: dump.richContent?.title,
type: dump.richContent?.type ?? "unknown",
});
}
: undefined}
>
<img
src={thumbnailUrl}
alt={dump.title}
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
{embedUrl && (
<span className="rich-content-play-overlay" aria-hidden="true">
</span>
)}
</div>
)}
<div className="journal-card-body">
{titleLink}
{dump.comment && (
<Markdown className="journal-card-comment">{dump.comment}</Markdown>
)}
<div className="journal-card-footer">
{meta}
{vote}
</div>
</div>
</li>
);
}
const className =
`journal-card journal-card--${shape} journal-card--${mode}`;
if (tier === "medium") {
if (mode === "image") {
// feature shows its note; tall/wide/square keep the image as the subject.
const showComment = shape === "feature" && !!dump.comment;
// For playable media the whole card plays; the title link still navigates.
// Keeping the handler on the <li> means the text overlay can't cover it.
return (
<li
className="journal-card journal-card--medium"
onClick={handleNavigate}
className={className}
onClick={embedUrl
? () =>
play({
kind: "embed",
embedUrl,
title: dump.richContent?.title,
type: dump.richContent?.type ?? "unknown",
})
: handleNavigate}
>
<div className="journal-card-inner">
<div className="journal-card-icon">
{thumbnailUrl
? (
<img
src={thumbnailUrl}
alt=""
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
)
: <span>{fallbackIcon}</span>}
</div>
<div className="journal-card-body">
{titleLink}
{dump.comment && (
<Markdown className="journal-card-comment">
{dump.comment}
</Markdown>
)}
<div className="journal-card-footer">
{meta}
{vote}
</div>
</div>
<div className="journal-card-image">
<img
src={thumbnailUrl ?? undefined}
alt=""
loading="lazy"
onError={(e) => {
(e.target as HTMLImageElement).style.visibility = "hidden";
}}
/>
{embedUrl && (
<span className="rich-content-play-overlay" aria-hidden="true">
</span>
)}
</div>
<div className="journal-card-overlay">
{titleLink}
{showComment && (
<Markdown className="journal-card-comment" inline>
{dump.comment!}
</Markdown>
)}
{footer}
</div>
</li>
);
}
// small
if (mode === "quote") {
return (
<li className={className} onClick={handleNavigate}>
<span className="journal-card-quotemark" aria-hidden="true"></span>
<Markdown className="journal-card-quote-body" inline>
{dump.comment!}
</Markdown>
<div className="journal-card-attribution">
{titleLink}
{footer}
</div>
</li>
);
}
// text
return (
<li className="journal-card journal-card--small" onClick={handleNavigate}>
<li className={className} onClick={handleNavigate}>
<span className="journal-card-glyph" aria-hidden="true">
{fallbackIcon}
</span>
{titleLink}
{vote}
{footer}
</li>
);
}

View File

@@ -19,7 +19,7 @@ msgstr "[deleted]"
#. placeholder {0}: dump.commentCount
#: src/components/DumpCard.tsx:105
#: src/components/JournalCard.tsx:98
#: src/components/JournalCard.tsx:110
msgid "{0, plural, one {# comment} other {# comments}}"
msgstr "{0, plural, one {# comment} other {# comments}}"
@@ -395,7 +395,7 @@ msgstr "Failed to generate invite"
#: src/pages/index/FollowedFeed.tsx:82
#: src/pages/index/HotFeed.tsx:36
#: src/pages/index/JournalFeed.tsx:48
#: src/pages/index/JournalFeed.tsx:33
#: src/pages/index/NewFeed.tsx:36
#: src/pages/Notifications.tsx:367
#: src/pages/UserPublicProfile.tsx:984
@@ -566,7 +566,7 @@ msgstr "Loading dump…"
#: src/pages/index/FollowedFeed.tsx:110
#: src/pages/index/HotFeed.tsx:64
#: src/pages/index/JournalFeed.tsx:77
#: src/pages/index/JournalFeed.tsx:62
#: src/pages/index/NewFeed.tsx:64
#: src/pages/Search.tsx:242
#: src/pages/UserDumps.tsx:93
@@ -590,7 +590,7 @@ msgstr "Loading profile…"
#: src/components/UserListPopover.tsx:230
#: src/pages/index/FollowedFeed.tsx:77
#: src/pages/index/HotFeed.tsx:32
#: src/pages/index/JournalFeed.tsx:44
#: src/pages/index/JournalFeed.tsx:29
#: src/pages/index/NewFeed.tsx:32
#: src/pages/Notifications.tsx:363
#: src/pages/Notifications.tsx:439
@@ -667,7 +667,7 @@ msgid "No dumps match \"{q}\"."
msgstr "No dumps match \"{q}\"."
#: src/pages/index/HotFeed.tsx:40
#: src/pages/index/JournalFeed.tsx:52
#: src/pages/index/JournalFeed.tsx:37
#: src/pages/index/NewFeed.tsx:40
msgid "No dumps yet. Be the first!"
msgstr "No dumps yet. Be the first!"
@@ -791,7 +791,7 @@ msgid "Posting…"
msgstr "Posting…"
#: src/components/DumpCard.tsx:114
#: src/components/JournalCard.tsx:107
#: src/components/JournalCard.tsx:119
#: src/components/PlaylistCard.tsx:73
#: src/components/PlaylistMembershipPanel.tsx:55
#: src/pages/Dump.tsx:416
@@ -1082,7 +1082,7 @@ msgstr "You'll be notified when someone follows your playlists, upvotes your dum
#: src/pages/index/FollowedFeed.tsx:115
#: src/pages/index/HotFeed.tsx:69
#: src/pages/index/JournalFeed.tsx:82
#: src/pages/index/JournalFeed.tsx:67
#: src/pages/index/NewFeed.tsx:69
#: src/pages/Search.tsx:247
#: src/pages/UserDumps.tsx:98

View File

@@ -19,7 +19,7 @@ msgstr "[supprimé]"
#. placeholder {0}: dump.commentCount
#: src/components/DumpCard.tsx:105
#: src/components/JournalCard.tsx:98
#: src/components/JournalCard.tsx:110
msgid "{0, plural, one {# comment} other {# comments}}"
msgstr "{0, plural, one {# commentaire} other {# commentaires}}"
@@ -395,7 +395,7 @@ msgstr "Impossible de générer une invitation"
#: src/pages/index/FollowedFeed.tsx:82
#: src/pages/index/HotFeed.tsx:36
#: src/pages/index/JournalFeed.tsx:48
#: src/pages/index/JournalFeed.tsx:33
#: src/pages/index/NewFeed.tsx:36
#: src/pages/Notifications.tsx:367
#: src/pages/UserPublicProfile.tsx:984
@@ -566,7 +566,7 @@ msgstr "Chargement de la reco…"
#: src/pages/index/FollowedFeed.tsx:110
#: src/pages/index/HotFeed.tsx:64
#: src/pages/index/JournalFeed.tsx:77
#: src/pages/index/JournalFeed.tsx:62
#: src/pages/index/NewFeed.tsx:64
#: src/pages/Search.tsx:242
#: src/pages/UserDumps.tsx:93
@@ -590,7 +590,7 @@ msgstr "Chargement du profil…"
#: src/components/UserListPopover.tsx:230
#: src/pages/index/FollowedFeed.tsx:77
#: src/pages/index/HotFeed.tsx:32
#: src/pages/index/JournalFeed.tsx:44
#: src/pages/index/JournalFeed.tsx:29
#: src/pages/index/NewFeed.tsx:32
#: src/pages/Notifications.tsx:363
#: src/pages/Notifications.tsx:439
@@ -667,7 +667,7 @@ msgid "No dumps match \"{q}\"."
msgstr "Aucune reco ne correspond à « {q} »."
#: src/pages/index/HotFeed.tsx:40
#: src/pages/index/JournalFeed.tsx:52
#: src/pages/index/JournalFeed.tsx:37
#: src/pages/index/NewFeed.tsx:40
msgid "No dumps yet. Be the first!"
msgstr "Pas encore de recos. Soyez le premier !"
@@ -791,7 +791,7 @@ msgid "Posting…"
msgstr "Publication…"
#: src/components/DumpCard.tsx:114
#: src/components/JournalCard.tsx:107
#: src/components/JournalCard.tsx:119
#: src/components/PlaylistCard.tsx:73
#: src/components/PlaylistMembershipPanel.tsx:55
#: src/pages/Dump.tsx:416
@@ -1082,7 +1082,7 @@ msgstr "Vous serez notifié lorsque quelqu'un suit vos collections, vote pour vo
#: src/pages/index/FollowedFeed.tsx:115
#: src/pages/index/HotFeed.tsx:69
#: src/pages/index/JournalFeed.tsx:82
#: src/pages/index/JournalFeed.tsx:67
#: src/pages/index/NewFeed.tsx:69
#: src/pages/Search.tsx:247
#: src/pages/UserDumps.tsx:98

View File

@@ -2,11 +2,8 @@ import { useMemo } from "react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { ErrorCard } from "../../components/ErrorCard.tsx";
import {
JournalCard,
type JournalTier,
} from "../../components/JournalCard.tsx";
import { hotScore } from "../../utils/hotScore.ts";
import { JournalCard } from "../../components/JournalCard.tsx";
import { composeJournal } from "../../utils/journalLayout.ts";
import type { MainFeedProps } from "./types.ts";
export function JournalFeed(
@@ -24,19 +21,7 @@ export function JournalFeed(
removeVote,
}: MainFeedProps,
) {
const tiered = useMemo(() => {
const sorted = [...dumps].sort((a, b) => hotScore(b) - hotScore(a));
const n = sorted.length;
return sorted.map((dump, i) => {
const rank = i / n;
const tier: JournalTier = rank < 0.2
? "large"
: rank < 0.5
? "medium"
: "small";
return { dump, tier };
});
}, [dumps]);
const entries = useMemo(() => composeJournal(dumps), [dumps]);
if (loading) {
return (
@@ -46,7 +31,7 @@ export function JournalFeed(
);
}
if (error) return <ErrorCard title={t`Failed to load`} message={error} />;
if (tiered.length === 0) {
if (entries.length === 0) {
return (
<p className="index-status">
<Trans>No dumps yet. Be the first!</Trans>
@@ -57,11 +42,11 @@ export function JournalFeed(
return (
<>
<ul className="journal-grid">
{tiered.map(({ dump, tier }) => (
{entries.map(({ dump, shape }) => (
<JournalCard
key={dump.id}
dump={dump}
tier={tier}
shape={shape}
voteCount={voteCounts[dump.id] ?? dump.voteCount}
voted={myVotes.has(dump.id)}
canVote={!!user}
@@ -77,7 +62,7 @@ export function JournalFeed(
<Trans>Loading more</Trans>
</p>
)}
{!hasMore && tiered.length > 0 && (
{!hasMore && entries.length > 0 && (
<p className="feed-end">
<Trans>You've reached the end.</Trans>
</p>

View File

@@ -0,0 +1,61 @@
import type { Dump } from "../model.ts";
import { hotScore } from "./hotScore.ts";
/**
* Grid footprint of a journal entry in the editorial mosaic.
* feature — 2×2 hero tall — 1×2 portrait
* wide — 2×1 banner square — 1×1 unit
* The footprint only describes the grid span; how a card is *rendered*
* (image / quote / text) is derived separately from its content.
*/
export type JournalShape = "feature" | "wide" | "tall" | "square";
export interface JournalEntry {
dump: Dump;
shape: JournalShape;
}
/** A dump that can carry a real preview image (file image or rich thumbnail). */
export function hasThumbnail(dump: Dump): boolean {
if (dump.kind === "file" && dump.fileMime?.startsWith("image/")) return true;
return !!dump.richContent?.thumbnailUrl;
}
/** A dump whose comment is substantial enough to feature as a pull-quote. */
export function hasQuote(dump: Dump): boolean {
return !!dump.comment && dump.comment.trim().length > 0;
}
/**
* Pick a grid footprint for a single entry.
*
* Variety is composed, not tiered: a repeating 11-step rhythm spreads tall and
* wide accents through the feed instead of bucketing every card by score. The
* rhythm is content-aware — only dumps with a thumbnail are allowed to claim the
* image-forward `tall`/`wide`/`feature` footprints; everything else stays a
* compact `square` so quote and text cards never stretch awkwardly.
*/
function pickShape(dump: Dump, index: number): JournalShape {
const thumb = hasThumbnail(dump);
if (index === 0) return thumb ? "feature" : "wide";
const beat = index % 11;
if (thumb) {
if (beat === 2 || beat === 7) return "tall";
if (beat === 0 || beat === 5) return "wide";
return "square";
}
// No thumbnail: an occasional wide quote keeps the rhythm from flattening.
if (beat === 3) return "wide";
return "square";
}
/**
* Sort dumps by hotness, then lay them out as an editorial mosaic: one hero
* feature followed by an asymmetric mix of banners, portraits, squares and
* pull-quotes. Dense grid auto-flow backfills the gaps these spans create.
*/
export function composeJournal(dumps: Dump[]): JournalEntry[] {
const sorted = [...dumps].sort((a, b) => hotScore(b) - hotScore(a));
return sorted.map((dump, index) => ({ dump, shape: pickShape(dump, index) }));
}