v3: src/ is documented like api/ and shared/, and the reference covers the whole repository — the frontend had no module docs, JSDoc on some components and hooks and not on others, props typed inline in half the components and in private interfaces in the rest, // comments on declarations, and no return types on 147 exported functions, so deno doc --lint over src/ found 637 errors. The rule api/ follows is applied to every non-test module of src/: a /** … @module */ block, JSDoc on every export and every field of an exported interface, /** */ for declarations and // for notes, verbs for functions ("Renders…" for a component), parameters named in the prose, {@link} only for symbols in scope, and a page's JSDoc names its route and who may see it (App.tsx's module doc lists the route table). For deno doc to show them, every exported function states its return type: components React.JSX.Element (or | null, or a portal), memo and forwardRef components their exotic types, contexts React.Context<…>, and a hook returning several things a documented Use<Name>Result interface (useAuth, useRequiredAuth, useFading, useEmojiTrigger, useMentionAutocomplete, useDumpCardLink, usePlayRichContent; FeedCacheResult, YouTubePlaylistControls and UseApiFormReturn are renamed to match). Every component that takes props takes an exported, documented <Name>Props interface — the 64 inline prop types are lifted into one, DumpFeed gets DumpFeedProps extending MainFeedProps. React types go through the global React namespace: the four default imports of react are gone, since deno doc can't follow them. Two duplicates go too: useMentionAutocomplete's UserResult was shared/'s UserSuggestion, and JournalCard re-exported JournalShape for no one; i18n.ts no longer re-exports Lingui's i18n, which its three users now import from @lingui/core. deno task doc and doc:lint now take src/ (but its tests and the generated locales), the index page gains a Frontend section linking every module by layer and domain, mod.test.ts walks src/ as well, and the docs workflow runs on changes to src/. deno doc doesn't document npm packages, so it takes a type imported from one — react-hook-form's FieldValues in the form fields, frimousse's SkinTone — for a private one; doc:lint now goes through doc-lint.ts, which fails on every diagnostic but those, 25 of them. The README states the frontend's part of the rule. No behaviour changes; 205 backend and 628 frontend tests pass, and tsc, deno check and deno lint are clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,12 @@
|
||||
/**
|
||||
* The ranking of the hot feed.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { Dump } from "../../../shared/dump.ts";
|
||||
|
||||
/** Returns how hot `dump` is: its votes, decaying with its age in hours. */
|
||||
export function hotScore(dump: Dump): number {
|
||||
const ageHours = (Date.now() - dump.createdAt.getTime()) / 3_600_000;
|
||||
return (dump.voteCount + 1) / Math.pow(ageHours + 2, 1.5);
|
||||
|
||||
@@ -1,24 +1,41 @@
|
||||
/**
|
||||
* The journal feed's layout: dumps ranked by hotness and laid out as an
|
||||
* editorial mosaic, cut into blocks.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { Dump } from "../../../shared/dump.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.
|
||||
*
|
||||
* - `feature` — 2×2 hero
|
||||
* - `wide` — 2×1 banner
|
||||
* - `tall` — 1×2 portrait
|
||||
* - `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";
|
||||
|
||||
/** A dump placed in the mosaic. */
|
||||
export interface JournalEntry {
|
||||
/** The dump. */
|
||||
dump: Dump;
|
||||
/** The footprint it takes. */
|
||||
shape: JournalShape;
|
||||
}
|
||||
|
||||
/** A run of entries rendered as one grid, separated from the next by a break. */
|
||||
/**
|
||||
* A run of entries rendered as one grid, separated from the next by a break.
|
||||
*/
|
||||
export interface JournalBlock {
|
||||
/** Stable list key — the id of the dump the block opens on. */
|
||||
key: string;
|
||||
/** The block's entries, in reading order. */
|
||||
entries: JournalEntry[];
|
||||
}
|
||||
|
||||
@@ -28,6 +45,7 @@ const COLUMNS = 4;
|
||||
/** Cards a sub-block gathers before a break comes due. */
|
||||
const BLOCK_CARDS = 12;
|
||||
|
||||
/** The columns and rows each shape spans. */
|
||||
const FOOTPRINT: Record<JournalShape, { cols: number; rows: number }> = {
|
||||
feature: { cols: 2, rows: 2 },
|
||||
wide: { cols: 2, rows: 1 },
|
||||
@@ -35,7 +53,10 @@ const FOOTPRINT: Record<JournalShape, { cols: number; rows: number }> = {
|
||||
square: { cols: 1, rows: 1 },
|
||||
};
|
||||
|
||||
/** A dump that can carry a real preview image (file image or rich thumbnail). */
|
||||
/**
|
||||
* Checks whether `dump` can carry a real preview image (file image or rich
|
||||
* thumbnail).
|
||||
*/
|
||||
export function hasThumbnail(dump: Dump): boolean {
|
||||
if (dump.kind === "file") {
|
||||
const mime = dump.fileMime ?? "";
|
||||
@@ -47,13 +68,16 @@ export function hasThumbnail(dump: Dump): boolean {
|
||||
return !!dump.richContent?.thumbnailUrl;
|
||||
}
|
||||
|
||||
/** A dump whose comment is substantial enough to feature as a pull-quote. */
|
||||
/**
|
||||
* Checks whether the comment of `dump` is substantial enough to feature as a
|
||||
* pull-quote.
|
||||
*/
|
||||
export function hasQuote(dump: Dump): boolean {
|
||||
return !!dump.comment && dump.comment.trim().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Occupancy of the mosaic, walked in reading order.
|
||||
* Returns the occupancy of a mosaic `columns` wide, walked in reading order.
|
||||
*
|
||||
* `next()` returns the first cell no card has claimed yet, along with the run
|
||||
* of free cells to its right — the width the next card is allowed to take.
|
||||
@@ -84,7 +108,8 @@ function createMosaic(columns: number) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The footprint an entry would *like*, before it's clamped to the grid.
|
||||
* Returns the footprint the entry at `index` would *like*, before it's clamped
|
||||
* to the grid.
|
||||
*
|
||||
* 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
|
||||
@@ -108,7 +133,8 @@ function desiredShape(dump: Dump, index: number): JournalShape {
|
||||
}
|
||||
|
||||
/**
|
||||
* Shrink a footprint until it fits the cells actually on offer.
|
||||
* Shrinks `shape` until it fits the `free` cells actually on offer, with
|
||||
* `remaining` cards left to place.
|
||||
*
|
||||
* Two cuts. Horizontally, a 2-column card that meets a 1-column gap gives up a
|
||||
* column rather than wrapping — wrapping is what leaves a hole behind. And a
|
||||
@@ -128,10 +154,11 @@ function fit(
|
||||
}
|
||||
|
||||
/**
|
||||
* A section front leads with a picture. When the hottest dump is a bare note,
|
||||
* promote the first piece of art from just behind it rather than opening the
|
||||
* page on an empty banner — the top few are within noise of each other on
|
||||
* score anyway, and which one leads is an editorial call, not a ranking one.
|
||||
* Returns `sorted` with a picture in the lead. A section front leads with a
|
||||
* picture: when the hottest dump is a bare note, the first piece of art from
|
||||
* just behind it is promoted rather than opening the page on an empty banner —
|
||||
* the top few are within noise of each other on score anyway, and which one
|
||||
* leads is an editorial call, not a ranking one.
|
||||
*/
|
||||
function leadWithArt(sorted: Dump[]): Dump[] {
|
||||
if (sorted.length < 2 || hasThumbnail(sorted[0])) return sorted;
|
||||
@@ -141,7 +168,7 @@ function leadWithArt(sorted: Dump[]): Dump[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort dumps by hotness, then lay them out as an editorial mosaic: one hero
|
||||
* Sorts `dumps` by hotness, then lays them out as an editorial mosaic: one hero
|
||||
* feature followed by an asymmetric mix of banners, portraits, squares and
|
||||
* pull-quotes, composed so the four columns close on every row — and cut into
|
||||
* sub-blocks so the feed breathes instead of running on as one slab.
|
||||
@@ -151,18 +178,18 @@ function leadWithArt(sorted: Dump[]): Dump[] {
|
||||
* flush, so the only boundaries ruled out are the ones a portrait or a hero
|
||||
* straddles. Past that, two things set the rhythm.
|
||||
*
|
||||
* It comes due on a count of *cards*, not of rows. A block is a set of
|
||||
* stories, and a hero is one story however many cells it takes, so a stretch
|
||||
* thick with big footprints reaches the count over more rows than a stretch
|
||||
* of squares does. Block heights vary with the size mix rather than beating
|
||||
* out a fixed measure — and because the mix is itself content-driven (only
|
||||
* dumps with art may claim the big footprints), so is the rhythm.
|
||||
* It comes due on a count of *cards*, not of rows. A block is a set of stories,
|
||||
* and a hero is one story however many cells it takes, so a stretch thick with
|
||||
* big footprints reaches the count over more rows than a stretch of squares
|
||||
* does. Block heights vary with the size mix rather than beating out a fixed
|
||||
* measure — and because the mix is itself content-driven (only dumps with art
|
||||
* may claim the big footprints), so is the rhythm.
|
||||
*
|
||||
* And the break *makes* its own section front rather than waiting for one:
|
||||
* the card that opens a new block is promoted to a hero. Waiting was the
|
||||
* mistake — a banner almost never lands at the start of a row on its own, so
|
||||
* a rule preferring one would almost never fire. Promotion always fits: the
|
||||
* break only falls on a row with all four cells free.
|
||||
* And the break *makes* its own section front rather than waiting for one: the
|
||||
* card that opens a new block is promoted to a hero. Waiting was the mistake —
|
||||
* a banner almost never lands at the start of a row on its own, so a rule
|
||||
* preferring one would almost never fire. Promotion always fits: the break only
|
||||
* falls on a row with all four cells free.
|
||||
*
|
||||
* The whole thing stays a pure function of the ranking, so no break shuffles
|
||||
* under the reader when infinite scroll appends the next page.
|
||||
|
||||
Reference in New Issue
Block a user