commit 088ccc391c38445f6ad69b91f54ef653284b1e55
parent 8cd04b1b9e220effdd4d13542ecd298dd7906224
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sun, 28 Jun 2026 22:42:06 -0400
Merge feat/hub-landing-iter3: Source fonts, minimal chrome, day/2-week Counts default
Diffstat:
9 files changed, 112 insertions(+), 117 deletions(-)
diff --git a/common/components/charts/CrossSiteChart.tsx b/common/components/charts/CrossSiteChart.tsx
@@ -33,12 +33,15 @@ import {
const TOP_CHANNELS = 8;
+type Gran = "day" | "week" | "month";
+
// How many trailing buckets each range shows, per granularity.
-function windowSize(range: Range, weekly: boolean): number | null {
- if (range === "all") return null;
- if (range === "12mo") return weekly ? 52 : 12;
- return weekly ? 13 : 3; // 90d
-}
+const WINDOW: Record<Range, Record<Gran, number | null>> = {
+ "2w": { day: 14, week: 2, month: 1 },
+ "90d": { day: 90, week: 13, month: 3 },
+ "12mo": { day: 365, week: 52, month: 12 },
+ all: { day: null, week: null, month: null },
+};
function runningSum(v: number[]): number[] {
let acc = 0;
@@ -66,12 +69,13 @@ export function CrossSiteChart({
}) {
const { rows, series, labels } = useMemo(() => {
const ms = summary.series[metric];
- const weekly = bucket !== "month"; // week + cumulative bin weekly
- const src: BucketSeries = weekly ? ms.week : ms.month;
const cumulative = bucket === "cumulative";
- const labelOf = weekly ? weekLabel : monthLabel;
+ // Day + cumulative bin on the day series; week/month on their own.
+ const gran: Gran = bucket === "week" ? "week" : bucket === "month" ? "month" : "day";
+ const src: BucketSeries = ms[gran];
+ const labelOf = gran === "month" ? monthLabel : weekLabel;
- const win = windowSize(range, weekly);
+ const win = WINDOW[range][gran];
const start = win == null ? 0 : Math.max(0, src.buckets.length - win);
const len = src.buckets.length;
const pad = (vals?: number[]) =>
diff --git a/common/lib/homepageChart.ts b/common/lib/homepageChart.ts
@@ -7,8 +7,8 @@ import type { MetricKey } from "./homepageSummary";
// Chart control axes (each a small segmented button group on the landing).
export type Breakdown = "site" | "channel";
-export type Bucket = "week" | "month" | "cumulative";
-export type Range = "90d" | "12mo" | "all";
+export type Bucket = "day" | "week" | "month" | "cumulative";
+export type Range = "2w" | "90d" | "12mo" | "all";
export type Metric = MetricKey; // "transcribed" | "downloaded"
export type Display = "share" | "counts";
diff --git a/common/lib/homepageSummary.ts b/common/lib/homepageSummary.ts
@@ -16,7 +16,11 @@ import type { Site } from "./site";
// Two metrics are pre-binned at two granularities; Cumulative and Share (100%)
// are derived client-side from these, so no extra precompute is needed.
-export const HOMEPAGE_SUMMARY_VERSION = 2;
+export const HOMEPAGE_SUMMARY_VERSION = 3;
+
+// Day buckets are capped to this many trailing days so the embedded summary stays
+// small regardless of archive age (daily detail is only useful recently).
+const DAY_WINDOW = 182;
// channel slug -> ids of the content sites that expose it (same map
// compose-homepage builds for the cross-site charts).
@@ -25,8 +29,9 @@ export type ChannelSitesMap = Record<string, string[]>;
export type MetricKey = "transcribed" | "downloaded";
// Per-granularity counts. `buckets` are ascending and continuous (gaps are 0):
-// week buckets are Monday-anchored UTC dates ("YYYY-MM-DD"); month buckets are
-// "YYYY-MM". `total`/`bySite[id]`/`byChannel[slug]` are all aligned to `buckets`.
+// day buckets are UTC dates ("YYYY-MM-DD"), capped to the last DAY_WINDOW days;
+// week buckets are Monday-anchored UTC dates; month buckets are "YYYY-MM".
+// `total`/`bySite[id]`/`byChannel[slug]` are all aligned to `buckets`.
export type BucketSeries = {
buckets: string[];
total: number[];
@@ -34,7 +39,11 @@ export type BucketSeries = {
byChannel: Record<string, number[]>;
};
-export type MetricSeries = { week: BucketSeries; month: BucketSeries };
+export type MetricSeries = {
+ day: BucketSeries;
+ week: BucketSeries;
+ month: BucketSeries;
+};
export type SiteMetricStat = {
total: number;
@@ -139,6 +148,23 @@ function weekRange(start: string, end: string): string[] {
return out;
}
+// "YYYYMMDD" -> the calendar UTC day "YYYY-MM-DD" (or null).
+function dayOf(date: string | null): string | null {
+ if (!date || date.length < 8) return null;
+ return `${date.slice(0, 4)}-${date.slice(4, 6)}-${date.slice(6, 8)}`;
+}
+
+function dayRange(start: string, end: string): string[] {
+ const out: string[] = [];
+ const dt = new Date(`${start}T00:00:00Z`);
+ const endDt = new Date(`${end}T00:00:00Z`);
+ while (dt.getTime() <= endDt.getTime()) {
+ out.push(isoDate(dt));
+ dt.setUTCDate(dt.getUTCDate() + 1);
+ }
+ return out;
+}
+
// ─── helpers ───
type Attributed = { date: string; channelSlug: string; siteId: string };
@@ -149,12 +175,15 @@ function bucketize(
keyOf: (date: string) => string | null,
rangeOf: (min: string, max: string) => string[],
nowKey: string,
+ floor?: string,
): BucketSeries {
let min: string | null = null;
for (const it of items) {
const k = keyOf(it.date);
if (k && (min === null || k < min)) min = k;
}
+ // Clamp the start to `floor` (used to cap the day series to a recent window).
+ if (floor && (min === null || min < floor)) min = floor;
const buckets = min ? rangeOf(min < nowKey ? min : nowKey, nowKey) : [];
const index = new Map(buckets.map((b, i) => [b, i]));
const total = new Array(buckets.length).fill(0);
@@ -192,9 +221,9 @@ export function buildHomepageSummary(
now: Date,
): HomepageSummary {
const nowMonth = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}`;
- const nowWeek = weekOf(
- `${now.getUTCFullYear()}${String(now.getUTCMonth() + 1).padStart(2, "0")}${String(now.getUTCDate()).padStart(2, "0")}`,
- )!;
+ const nowDay = isoDate(now);
+ const nowWeek = weekOf(nowDay.replace(/-/g, ""))!;
+ const dayFloor = isoDate(new Date(now.getTime() - (DAY_WINDOW - 1) * 86400000));
// Public sites only (need a link target + a stable place on the chart).
const publicSites = sites.filter((s): s is Site & { siteUrl: string } => !!s.siteUrl);
@@ -246,6 +275,7 @@ export function buildHomepageSummary(
}
const metricSeries = (items: Attributed[]): MetricSeries => ({
+ day: bucketize(items, dayOf, dayRange, nowDay, dayFloor),
week: bucketize(items, weekOf, weekRange, nowWeek),
month: bucketize(items, monthOf, monthRange, nowMonth),
});
diff --git a/homepage/CHANGELOG.md b/homepage/CHANGELOG.md
@@ -7,31 +7,35 @@
from a small summary pre-computed at build (`compose-homepage` writes
`public/homepage-summary.json`, embedded into the static HTML — no multi-MB
client fetch). It leads with headline KPIs (transcripts, downloads, sites,
- channels, hours archived), then one prominent activity chart driven by five
- small segmented controls: **Metric** (Transcribed / Downloaded), **Breakdown**
- (By site / By channel), **Bucket** (Week / Month / Cumulative), **Range** (90d /
- 12mo / All), and **Display** (Share / Counts). It's always a stacked chart — Week
- /Month are stacked bars, Cumulative a stacked area — so per-series composition
- *and* the combined total read at once; **Share** (100% stacked, the default)
- keeps small sites legible next to a dominant one, while the KPIs carry the
- absolute totals. By-channel groups to the top-8 channels + an "Other" band.
- Below the chart a responsive **site grid** links every *public* content site
- (cards follow the active metric — total, "+N this month", inline-SVG sparkline —
- sorted by this-month, with a **"#1 this month"** badge); in By-site the cards
- double as the chart legend with Show/Hide toggles. The data universe is public
- sites only (URL-less sites are hidden), each video attributed to one primary
- site so combined totals stay honest. (A recent-additions feed is still built
- into the summary but not rendered — kept for easy re-enable.) The `/stats` route
- and the trailing "Stats" header link are gone.
-- **Distinctive "archive room" visual identity.** The landing moves off the
- generic zinc/white look to a committed warm-ink dark theme: an atmospheric
- background (overhead brass glow + film grain), a **Fraunces** serif for the
- wordmark / headline numerals / section heads paired with Geist Mono instrument
- labels, a single brass/gold signature accent (eyebrows, active controls, the
- "#1 this month" badge), glass panels with hairline borders, enriched card
- sparklines (area fill + endpoint dot), and one orchestrated staggered page-load
- reveal. The hub is dark-committed (a class-based `dark` variant, not OS-driven).
- Chart entry animation is disabled so the stacked bars/areas paint immediately.
+ channels, hours archived), then one activity chart driven by five small
+ segmented controls: **Metric** (Transcribed / Downloaded), **Breakdown** (By
+ site / By channel), **Bucket** (Day / Week / Month / Cumulative), **Range** (2w /
+ 90d / 12mo / All), and **Display** (Counts / Share). Day/Week/Month are stacked
+ bars, Cumulative a stacked area, so per-series composition *and* the combined
+ total read at once. The default is **Day · 2-week · Counts** — a short recent
+ window where the per-site magnitudes are comparable, so real counts read clearly
+ (the data is bursty, which bars represent honestly); **Share** (100% stacked)
+ stays as the fallback for the long ranges where the historical backlog skews one
+ site huge. By-channel groups to the top-8 channels + an "Other" band. A
+ responsive **site grid** links every *public* content site (cards follow the
+ active metric — total, "+N this month", inline-SVG sparkline — sorted by
+ this-month, with a **"#1 this month"** badge); in By-site the cards double as
+ the chart legend with Show/Hide toggles. The data universe is public sites only
+ (URL-less sites hidden), each video attributed to one primary site so combined
+ totals stay honest. (A recent-additions feed is still built into the summary but
+ not rendered — kept for easy re-enable.) The `/stats` route and the trailing
+ "Stats" header link are gone.
+- **Distinctive, minimal "archive" visual identity.** The landing commits to a
+ warm-ink dark theme (class-based `dark`, not OS-driven) with an atmospheric
+ background (overhead brass glow + film grain), the **Adobe Source superfamily**
+ — Source Serif 4 for the wordmark + headline numerals, Source Sans 3 for body,
+ Source Code Pro for the tracked uppercase micro-labels — a single brass/gold
+ signature accent (active controls, the "#1 this month" badge), glass panels with
+ hairline borders, enriched card sparklines (area fill + endpoint dot), and one
+ orchestrated staggered page-load reveal. Deliberately sparse "less is more"
+ chrome: the masthead is just the wordmark (no eyebrow/subtitle) and the chart
+ and site-grid zones carry no section labels — separation is by space alone.
+ Chart entry animation is disabled so bars/areas paint immediately.
- **Hub Markdown pages dropped.** The `[slug]` doc routes, the `index`-page home
body, the `PageBody` renderer, and the doc nav are removed — the hub is now a
single page (re-addable later if needed). See `app/page.tsx`, the new hub
diff --git a/homepage/app/components/Header.tsx b/homepage/app/components/Header.tsx
@@ -7,16 +7,13 @@ export default function Header() {
const config = currentHomepage();
return (
<header className="sticky top-0 z-20 border-b border-[var(--border)] bg-[rgba(12,10,8,0.72)] backdrop-blur-md">
- <div className="max-w-6xl mx-auto px-5 sm:px-6 h-14 flex items-center justify-between gap-3">
+ <div className="max-w-6xl mx-auto px-5 sm:px-6 h-14 flex items-center gap-3">
<Link href="/" className="group flex items-center gap-2.5">
<span className="h-2.5 w-2.5 rotate-45 rounded-[2px] bg-[var(--accent)] shadow-[0_0_14px_var(--accent)] transition group-hover:bg-[var(--accent-strong)]" />
<span className="font-display text-[1.35rem] leading-none font-semibold tracking-tight text-[var(--foreground)]">
{config.headerTitle}
</span>
</Link>
- <span className="hidden sm:block font-mono text-[0.6875rem] uppercase tracking-[0.22em] text-[var(--faint)]">
- Cross-site transcript archive
- </span>
</div>
</header>
);
diff --git a/homepage/app/components/HomeLanding.tsx b/homepage/app/components/HomeLanding.tsx
@@ -55,25 +55,12 @@ function Segmented<T extends string>({
);
}
-function Eyebrow({ index, children }: { index: string; children: string }) {
- return (
- <div className="flex items-center gap-3">
- <span className="font-mono text-[0.6875rem] tracking-[0.28em] text-[var(--accent)]">
- {index}
- </span>
- <span className="font-mono text-[0.6875rem] uppercase tracking-[0.28em] text-[var(--faint)]">
- {children}
- </span>
- </div>
- );
-}
-
export function HomeLanding({ summary }: { summary: HomepageSummary }) {
const [breakdown, setBreakdown] = useState<Breakdown>("site");
- const [bucket, setBucket] = useState<Bucket>("week");
- const [range, setRange] = useState<Range>("90d");
+ const [bucket, setBucket] = useState<Bucket>("day");
+ const [range, setRange] = useState<Range>("2w");
const [metric, setMetric] = useState<Metric>("transcribed");
- const [display, setDisplay] = useState<Display>("share");
+ const [display, setDisplay] = useState<Display>("counts");
const [hidden, setHidden] = useState<ReadonlySet<string>>(new Set());
const toggleSite = useCallback((siteId: string) => {
@@ -85,10 +72,8 @@ export function HomeLanding({ summary }: { summary: HomepageSummary }) {
});
}, []);
- const metricNoun = metric === "downloaded" ? "Downloads" : "Transcriptions";
-
return (
- <div className="flex flex-col gap-12 sm:gap-16">
+ <div className="flex flex-col gap-14 sm:gap-20">
<div className="reveal" style={{ "--d": "120ms" } as React.CSSProperties}>
<KpiHeader summary={summary} />
</div>
@@ -97,13 +82,6 @@ export function HomeLanding({ summary }: { summary: HomepageSummary }) {
className="reveal flex flex-col gap-5"
style={{ "--d": "200ms" } as React.CSSProperties}
>
- <div className="flex flex-col gap-2">
- <Eyebrow index="01">Activity</Eyebrow>
- <h2 className="font-display text-3xl sm:text-4xl font-semibold tracking-[-0.01em] text-[var(--foreground)]">
- {metricNoun} over time
- </h2>
- </div>
-
<div className="flex flex-wrap items-center gap-2.5">
<Segmented
label="Metric"
@@ -128,6 +106,7 @@ export function HomeLanding({ summary }: { summary: HomepageSummary }) {
value={bucket}
onChange={setBucket}
options={[
+ { value: "day", label: "Day" },
{ value: "week", label: "Week" },
{ value: "month", label: "Month" },
{ value: "cumulative", label: "Cumulative" },
@@ -138,6 +117,7 @@ export function HomeLanding({ summary }: { summary: HomepageSummary }) {
value={range}
onChange={setRange}
options={[
+ { value: "2w", label: "2w" },
{ value: "90d", label: "90d" },
{ value: "12mo", label: "12mo" },
{ value: "all", label: "All" },
@@ -148,8 +128,8 @@ export function HomeLanding({ summary }: { summary: HomepageSummary }) {
value={display}
onChange={setDisplay}
options={[
- { value: "share", label: "Share" },
{ value: "counts", label: "Counts" },
+ { value: "share", label: "Share" },
]}
/>
</div>
@@ -166,23 +146,12 @@ export function HomeLanding({ summary }: { summary: HomepageSummary }) {
hidden={hidden}
/>
</div>
- {breakdown === "site" && summary.sites.length > 0 && (
- <p className="font-mono text-[0.6875rem] uppercase tracking-[0.16em] text-[var(--faint)]">
- Toggle a site card below to focus the chart
- </p>
- )}
</section>
<section
- className="reveal flex flex-col gap-5"
+ className="reveal"
style={{ "--d": "280ms" } as React.CSSProperties}
>
- <div className="flex flex-col gap-2">
- <Eyebrow index="02">Directory</Eyebrow>
- <h2 className="font-display text-3xl sm:text-4xl font-semibold tracking-[-0.01em] text-[var(--foreground)]">
- Sites
- </h2>
- </div>
<SiteGrid
sites={summary.sites}
metric={metric}
diff --git a/homepage/app/globals.css b/homepage/app/globals.css
@@ -44,9 +44,9 @@
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
- --font-sans: var(--font-geist-sans);
- --font-mono: var(--font-geist-mono);
- --font-display: var(--font-fraunces);
+ --font-sans: var(--font-source-sans);
+ --font-mono: var(--font-source-code);
+ --font-display: var(--font-source-serif);
}
html {
@@ -55,7 +55,7 @@ html {
body {
color: var(--foreground);
- font-family: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
+ font-family: var(--font-source-sans), ui-sans-serif, system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
}
diff --git a/homepage/app/layout.tsx b/homepage/app/layout.tsx
@@ -1,27 +1,26 @@
import type { Metadata } from "next";
-import { Geist, Geist_Mono, Fraunces } from "next/font/google";
+import { Source_Serif_4, Source_Sans_3, Source_Code_Pro } from "next/font/google";
import { currentHomepage } from "./lib/homepage";
import Header from "./components/Header";
import Footer from "./components/Footer";
import "./globals.css";
-const geistSans = Geist({
- variable: "--font-geist-sans",
+// Adobe Source superfamily — one coherent voice across three roles.
+// Serif: the wordmark + headline numerals. Sans: body/UI. Code: tracked labels.
+const sourceSerif = Source_Serif_4({
+ variable: "--font-source-serif",
subsets: ["latin"],
+ style: ["normal", "italic"],
});
-const geistMono = Geist_Mono({
- variable: "--font-geist-mono",
+const sourceSans = Source_Sans_3({
+ variable: "--font-source-sans",
subsets: ["latin"],
});
-// Distinctive high-contrast serif for the wordmark, headline numerals and
-// section heads — the "editorial archive" voice.
-const fraunces = Fraunces({
- variable: "--font-fraunces",
+const sourceCode = Source_Code_Pro({
+ variable: "--font-source-code",
subsets: ["latin"],
- axes: ["opsz", "SOFT"],
- style: ["normal", "italic"],
});
export function generateMetadata(): Metadata {
@@ -43,7 +42,7 @@ export default function RootLayout({
return (
<html
lang="en"
- className={`dark ${geistSans.variable} ${geistMono.variable} ${fraunces.variable} h-full antialiased`}
+ className={`dark ${sourceSerif.variable} ${sourceSans.variable} ${sourceCode.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col bg-[var(--background)] text-[var(--foreground)] font-sans selection:bg-[var(--accent-soft)] selection:text-[var(--accent-strong)]">
<Header />
diff --git a/homepage/app/page.tsx b/homepage/app/page.tsx
@@ -7,15 +7,15 @@ import {
import { currentHomepage } from "./lib/homepage";
import { HomeLanding } from "./components/HomeLanding";
-// The hub home route IS the cross-site landing: a branding line, headline KPIs,
-// one big "transcriptions over time" chart with a minimal control strip, the
-// site grid, and a recent-additions feed. All of it renders from a small
+// The hub home route IS the cross-site landing: the wordmark, headline KPIs, one
+// stacked activity chart with a minimal control strip, and the site grid — no
+// section labels, kept deliberately sparse. All of it renders from a small
// summary pre-computed at build (compose-homepage writes public/homepage-
// summary.json), so the page is static HTML with no multi-MB client fetch.
function emptySummary(): HomepageSummary {
const emptyBucket = { buckets: [], total: [], bySite: {}, byChannel: {} };
- const emptyMetric = { week: emptyBucket, month: emptyBucket };
+ const emptyMetric = { day: emptyBucket, week: emptyBucket, month: emptyBucket };
return {
version: HOMEPAGE_SUMMARY_VERSION,
generatedAt: "",
@@ -50,20 +50,12 @@ export default function Home() {
const config = currentHomepage();
const summary = loadSummary();
return (
- <div className="flex flex-col gap-10 sm:gap-14">
- <section className="reveal relative flex flex-col gap-4 pt-2">
- <div className="flex items-center gap-3">
- <span className="h-px w-8 rule-accent" />
- <span className="font-mono text-[0.6875rem] uppercase tracking-[0.28em] text-[var(--accent)]">
- The archive room
- </span>
- </div>
+ <div className="flex flex-col gap-12 sm:gap-16">
+ <section className="reveal flex items-center gap-3.5 pt-2">
+ <span className="h-3.5 w-3.5 rotate-45 rounded-[3px] bg-[var(--accent)] shadow-[0_0_18px_var(--accent)]" />
<h1 className="font-display text-5xl sm:text-7xl font-semibold leading-[0.95] tracking-[-0.02em] text-[var(--foreground)]">
{config.siteTitle}
</h1>
- <p className="max-w-2xl font-display text-xl sm:text-2xl italic leading-snug text-[var(--muted-foreground)]">
- {config.homeTagline || config.siteDescription}
- </p>
</section>
<HomeLanding summary={summary} />
</div>