diff --git a/src/App.css b/src/App.css
index 41a67ac..b4e1c36 100644
--- a/src/App.css
+++ b/src/App.css
@@ -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;
}
}
diff --git a/src/components/JournalCard.tsx b/src/components/JournalCard.tsx
index 02bdea1..c4c10a8 100644
--- a/src/components/JournalCard.tsx
+++ b/src/components/JournalCard.tsx
@@ -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 = (
e.stopPropagation()}>
+
e.stopPropagation()}>
);
- const embedUrl = dump.richContent?.embedUrl;
+ const footer = (
+
+ {meta}
+ {vote}
+
+ );
- if (tier === "large") {
- return (
-
- {thumbnailUrl && (
- {
- e.stopPropagation();
- play({
- kind: "embed",
- embedUrl,
- title: dump.richContent?.title,
- type: dump.richContent?.type ?? "unknown",
- });
- }
- : undefined}
- >
-
{
- (e.target as HTMLImageElement).style.display = "none";
- }}
- />
- {embedUrl && (
-
- ▶
-
- )}
-
- )}
-
- {titleLink}
- {dump.comment && (
-
{dump.comment}
- )}
-
- {meta}
- {vote}
-
-
-
- );
- }
+ 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 means the text overlay can't cover it.
return (
+ play({
+ kind: "embed",
+ embedUrl,
+ title: dump.richContent?.title,
+ type: dump.richContent?.type ?? "unknown",
+ })
+ : handleNavigate}
>
-
-
- {thumbnailUrl
- ? (
-
{
- (e.target as HTMLImageElement).style.display = "none";
- }}
- />
- )
- :
{fallbackIcon} }
-
-
- {titleLink}
- {dump.comment && (
-
- {dump.comment}
-
- )}
-
- {meta}
- {vote}
-
-
+
+
{
+ (e.target as HTMLImageElement).style.visibility = "hidden";
+ }}
+ />
+ {embedUrl && (
+
+ ▶
+
+ )}
+
+
+ {titleLink}
+ {showComment && (
+
+ {dump.comment!}
+
+ )}
+ {footer}
);
}
- // small
+ if (mode === "quote") {
+ return (
+
+ “
+
+ {dump.comment!}
+
+
+ {titleLink}
+ {footer}
+
+
+ );
+ }
+
+ // text
return (
-
+
+
+ {fallbackIcon}
+
{titleLink}
- {vote}
+ {footer}
);
}
diff --git a/src/locales/en.po b/src/locales/en.po
index 338db72..5816c5b 100644
--- a/src/locales/en.po
+++ b/src/locales/en.po
@@ -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
diff --git a/src/locales/fr.po b/src/locales/fr.po
index 53ec6df..75b5f81 100644
--- a/src/locales/fr.po
+++ b/src/locales/fr.po
@@ -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
diff --git a/src/pages/index/JournalFeed.tsx b/src/pages/index/JournalFeed.tsx
index 95af50d..db648af 100644
--- a/src/pages/index/JournalFeed.tsx
+++ b/src/pages/index/JournalFeed.tsx
@@ -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
;
- if (tiered.length === 0) {
+ if (entries.length === 0) {
return (
No dumps yet. Be the first!
@@ -57,11 +42,11 @@ export function JournalFeed(
return (
<>
- {tiered.map(({ dump, tier }) => (
+ {entries.map(({ dump, shape }) => (
Loading more…
)}
- {!hasMore && tiered.length > 0 && (
+ {!hasMore && entries.length > 0 && (
You've reached the end.
diff --git a/src/utils/journalLayout.ts b/src/utils/journalLayout.ts
new file mode 100644
index 0000000..3016e86
--- /dev/null
+++ b/src/utils/journalLayout.ts
@@ -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) }));
+}