Marginalia: magazine
An editorial five-page magazine: a masthead between double rules, a lead story, an article with reading progress, drop cap and margin notes, section pages with sort and load more, author pages and an issues shelf with subscriptions.
// Ballmac UI: Publication template fonts. https://ui.ballmac.com/templates/template-publication
import { DM_Serif_Display, Lora, Public_Sans } from "next/font/google"
export const pubDisplay = DM_Serif_Display({ variable: "--pub-display", subsets: ["latin"], weight: "400", style: ["normal", "italic"], display: "swap", preload: false })
export const pubBody = Lora({ variable: "--pub-body", subsets: ["latin"], display: "swap", preload: false })
export const pubSans = Public_Sans({ variable: "--pub-sans", subsets: ["latin"], display: "swap", preload: false })import type { MediaSource } from "@/components/ballmac/media"
// Ballmac UI: Publication sample content. https://ui.ballmac.com/templates/template-publication
export type Section = "Essays" | "Culture" | "Science" | "Technology" | "Interviews"
export const sections: Section[] = ["Essays", "Culture", "Science", "Technology", "Interviews"]
export type Author = { id: string; name: string; role: string; bio: string; tone: number }
export const authors: Author[] = [
{ id: "ines", name: "Inês Marques", role: "Senior editor", bio: "Writes about cities, memory and the objects we keep. Previously at a daily paper in Porto.", tone: 1 },
{ id: "tomas", name: "Tomás Reyes", role: "Science correspondent", bio: "Reports on ecology and the people who count things for a living.", tone: 2 },
{ id: "amara", name: "Amara Okafor", role: "Contributing writer", bio: "Essays on technology, work and the slow end of the internet.", tone: 3 },
{ id: "elena", name: "Elena Rossi", role: "Culture editor", bio: "Edits the culture desk. Cooks, reads, rarely sleeps in October.", tone: 4 },
]
export type Article = { id: string; section: Section; kicker: string; title: string; dek: string; author: string; date: string; read: number; art: number; popular?: number; image?: MediaSource; imageAlt?: string }
export const articles: Article[] = [
{ id: "a1", section: "Essays", kicker: "The long view", title: "The case for the unfinished city", dek: "What a scaffold-covered street teaches us about patience, and why the cities we love are never done.", author: "ines", date: "2026-09-28", read: 14, art: 0, popular: 1 },
{ id: "a2", section: "Science", kicker: "Field notes", title: "Counting every bird in a parish of three hundred", dek: "A volunteer census, a very cold spring, and the surprising comeback of the corn bunting.", author: "tomas", date: "2026-09-26", read: 11, art: 1, popular: 3 },
{ id: "a3", section: "Technology", kicker: "Slow tech", title: "The internet we left in the drawer", dek: "On forums, feeds and the small websites that quietly still work.", author: "amara", date: "2026-09-25", read: 9, art: 2, popular: 2 },
{ id: "a4", section: "Culture", kicker: "Table talk", title: "A defence of the weeknight dinner", dek: "Nobody photographs it. That is exactly the point.", author: "elena", date: "2026-09-24", read: 6, art: 3 },
{ id: "a5", section: "Interviews", kicker: "In conversation", title: "“I only write in the margins”", dek: "The novelist Dalia Costa on notebooks, naps and finishing nothing for eleven years.", author: "ines", date: "2026-09-22", read: 18, art: 4, popular: 4 },
{ id: "a6", section: "Essays", kicker: "On work", title: "Against the optimised life", dek: "A year without metrics, and what was left when I stopped counting.", author: "amara", date: "2026-09-20", read: 12, art: 5 },
{ id: "a7", section: "Science", kicker: "Deep time", title: "The rock that remembers the sea", dek: "A geologist reads a cliff face like a diary.", author: "tomas", date: "2026-09-18", read: 8, art: 0 },
{ id: "a8", section: "Culture", kicker: "Listening", title: "What the radio knew", dek: "Why the old weather forecast still feels like poetry.", author: "elena", date: "2026-09-15", read: 7, art: 2 },
{ id: "a9", section: "Technology", kicker: "Craft", title: "Write the manual first", dek: "How good software gets made backwards.", author: "amara", date: "2026-09-12", read: 10, art: 1 },
{ id: "a10", section: "Essays", kicker: "Small things", title: "In praise of the folding chair", dek: "The most democratic object ever designed.", author: "ines", date: "2026-09-09", read: 5, art: 3 },
]
export type Issue = { n: number; season: string; theme: string; art: number; image?: MediaSource; imageAlt?: string }
export const issues: Issue[] = [
{ n: 48, season: "Autumn 2026", theme: "Patience", art: 0 },
{ n: 47, season: "Summer 2026", theme: "Heat", art: 1 },
{ n: 46, season: "Spring 2026", theme: "Return", art: 2 },
{ n: 45, season: "Winter 2025", theme: "Quiet", art: 3 },
{ n: 44, season: "Autumn 2025", theme: "Maps", art: 4 },
{ n: 43, season: "Summer 2025", theme: "Water", art: 5 },
]
export const getAuthor = (id: string) => authors.find((a) => a.id === id) ?? authors[0]
const fmt = new Intl.DateTimeFormat("en-US", { month: "long", day: "numeric", year: "numeric", timeZone: "UTC" })
const fmtShort = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })
export const formatDate = (iso: string) => fmt.format(new Date(iso))
export const formatShort = (iso: string) => fmtShort.format(new Date(iso))// Ballmac UI: Publication template shell. https://ui.ballmac.com/templates/template-publication
"use client"
import * as React from "react"
import { Menu, Search, X } from "lucide-react"
import { pubBody, pubDisplay, pubSans } from "@/components/ballmac/templates/publication/publication-fonts"
import { sections } from "@/components/ballmac/templates/publication/publication-data"
import { cn } from "@/lib/utils"
import { Media, type MediaSource } from "@/components/ballmac/media"
type PublicationPage = "home" | "article" | "section" | "author" | "issues"
type PublicationHrefs = Record<PublicationPage, string>
const defaultHrefs: PublicationHrefs = { home: "/publication", article: "/publication/essays/the-unfinished-city", section: "/publication/essays", author: "/publication/authors/ines", issues: "/publication/issues" }
/** Publication's palette: newsprint and ink with a deep red. Dark mode is sepia night, for reading after dark. */
const publicationCss = `
.publication-theme,body:not(:has([data-gallery])):has(.publication-theme){--publication-on-accent:oklch(0.18 0.01 60);--background:oklch(0.975 0.014 85);--foreground:oklch(0.18 0.01 60);--card:oklch(0.988 0.01 85);--card-foreground:oklch(0.18 0.01 60);--popover:oklch(0.988 0.01 85);--popover-foreground:oklch(0.18 0.01 60);--primary:oklch(0.18 0.01 60);--primary-foreground:oklch(0.975 0.014 85);--secondary:oklch(0.945 0.02 85);--secondary-foreground:oklch(0.18 0.01 60);--muted:oklch(0.945 0.02 85);--muted-foreground:oklch(0.45 0.02 70);--accent:oklch(0.93 0.025 80);--accent-foreground:oklch(0.18 0.01 60);--border:oklch(0.18 0.01 60 / 18%);--input:oklch(0.18 0.01 60 / 28%);--ring:oklch(0.45 0.17 28);--surface:oklch(0.958 0.016 85);--destructive:oklch(0.5 0.2 27);--chart-1:oklch(0.45 0.17 28);--chart-2:oklch(0.4 0.07 240);--chart-3:oklch(0.78 0.12 85);--chart-4:oklch(0.46 0.1 150);--chart-5:oklch(0.62 0.11 50);--radius:0.125rem}
.dark .publication-theme,.dark body:not(:has([data-gallery])):has(.publication-theme){--publication-on-accent:oklch(0.18 0.01 60);--background:oklch(0.17 0.012 60);--foreground:oklch(0.94 0.02 85);--card:oklch(0.2 0.013 60);--card-foreground:oklch(0.94 0.02 85);--popover:oklch(0.22 0.014 60);--popover-foreground:oklch(0.94 0.02 85);--primary:oklch(0.94 0.02 85);--primary-foreground:oklch(0.18 0.01 60);--secondary:oklch(0.25 0.014 60);--secondary-foreground:oklch(0.94 0.02 85);--muted:oklch(0.24 0.014 60);--muted-foreground:oklch(0.72 0.025 80);--accent:oklch(0.28 0.016 60);--accent-foreground:oklch(0.94 0.02 85);--border:oklch(0.94 0.02 85 / 18%);--input:oklch(0.94 0.02 85 / 28%);--ring:oklch(0.74 0.14 30);--surface:oklch(0.19 0.012 60);--destructive:oklch(0.7 0.18 27);--chart-1:oklch(0.74 0.14 30);--chart-2:oklch(0.72 0.08 240);--chart-3:oklch(0.82 0.11 85);--chart-4:oklch(0.76 0.1 150);--chart-5:oklch(0.76 0.1 55)}
body:not(:has([data-gallery])):has(.publication-theme){font-family:var(--pub-sans),ui-sans-serif,system-ui,sans-serif}
`
const serif = "[font-family:var(--pub-display),ui-serif,Georgia,serif]"
const text = "[font-family:var(--pub-body),ui-serif,Georgia,serif]"
/** A duotone illustration in ink and red, standing in for photography. */
function MagArt({ variant, className, image, imageAlt }: { variant: number; className?: string; image?: MediaSource; imageAlt?: string }) {
const v = ((variant % 6) + 6) % 6
if (image) return <Media media={image} alt={imageAlt} fill className={cn("aspect-[4/3] w-full", className)} />
return (
<div aria-hidden="true" className={cn("bg-secondary relative isolate aspect-[4/3] w-full overflow-hidden", className)}>
{v === 0 && (<><div className="bg-chart-3 absolute inset-0" />{[0, 1, 2, 3].map((i) => <div key={i} className="bg-foreground absolute bottom-0" style={{ left: `${12 + i * 20}%`, width: "14%", height: `${28 + i * 10}%` }} />)}<div className="bg-chart-1 absolute top-[14%] end-[14%] w-[14%] aspect-square rounded-full" /></>)}
{v === 1 && (<><div className="bg-chart-2 absolute inset-0" /><div className="bg-card absolute top-[10%] start-[10%] w-[34%] aspect-square rounded-full" />{[0, 1, 2, 3, 4, 5].map((i) => <div key={i} className="bg-chart-1 absolute w-[6%] aspect-square rounded-full" style={{ left: `${44 + (i % 3) * 14}%`, top: `${42 + Math.floor(i / 3) * 18}%` }} />)}</>)}
{v === 2 && (<><div className="bg-card absolute inset-0" /><div className="absolute inset-[10%] grid grid-cols-6 gap-[2%]">{Array.from({ length: 24 }, (_, i) => <div key={i} className={cn("aspect-square", i % 7 === 0 ? "bg-chart-1" : i % 3 === 0 ? "bg-foreground" : "bg-secondary")} />)}</div></>)}
{v === 3 && (<><div className="bg-chart-5 absolute inset-0" /><div className="bg-card absolute top-1/2 left-1/2 w-[56%] aspect-square -translate-x-1/2 -translate-y-1/2 rounded-full" /><div className="bg-foreground absolute top-1/2 left-1/2 w-[34%] aspect-square -translate-x-1/2 -translate-y-1/2 rounded-full" /><div className="bg-chart-1 absolute top-1/2 left-1/2 w-[12%] aspect-square -translate-x-1/2 -translate-y-1/2 rounded-full" /></>)}
{v === 4 && (<><div className="bg-foreground absolute inset-0" />{[0, 1, 2, 3, 4, 5, 6].map((i) => <div key={i} className="bg-chart-3 absolute inset-x-[8%] h-[3px]" style={{ top: `${14 + i * 11}%`, opacity: 1 - i * 0.1 }} />)}<div className="bg-chart-1 absolute top-[22%] start-[22%] h-[40%] w-[34%] rotate-6" /></>)}
{v === 5 && (<><div className="bg-chart-4 absolute inset-0" /><div className="bg-card absolute -end-[10%] -bottom-[30%] w-[90%] aspect-square rounded-full" /><div className="bg-foreground absolute top-[16%] start-[12%] h-[8%] w-[50%]" /><div className="bg-foreground absolute top-[30%] start-[12%] h-[8%] w-[34%]" /></>)}
</div>
)
}
const dateline = new Intl.DateTimeFormat("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric", timeZone: "UTC" }).format(new Date("2026-10-01"))
type PublicationShellProps = React.ComponentProps<"div"> & {
/** The page being shown. */
page: PublicationPage
/** The section to mark current in the nav. */
section?: string
/** Override where pages live (used by previews). */
hrefs?: Partial<PublicationHrefs>
}
/** Publication's frame: a dateline, a centred masthead between double rules, a section nav and a newsletter-first footer. */
function PublicationShell({ page, section, hrefs: overrides, className, style, children, ...props }: PublicationShellProps) {
const hrefs = { ...defaultHrefs, ...overrides }
const [open, setOpen] = React.useState(false)
React.useEffect(() => {
const classes = [pubDisplay.variable, pubBody.variable, pubSans.variable].filter(Boolean)
document.body.classList.add(...classes)
return () => document.body.classList.remove(...classes)
}, [])
const current = section ?? (page === "article" ? "Essays" : undefined)
return (
<div
data-slot="publication"
className={cn("publication-theme bg-background text-foreground relative min-h-dvh overflow-x-clip", pubDisplay.variable, pubBody.variable, pubSans.variable, className)}
style={{ fontFamily: "var(--pub-sans), ui-sans-serif, system-ui, sans-serif", ...style }}
{...props}
>
<style>{publicationCss}</style>
<header className="bg-background">
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-2.5 text-xs sm:px-6">
<p className="text-muted-foreground hidden sm:block">{dateline}</p>
<p className="text-muted-foreground font-medium sm:hidden">Issue 48</p>
<p className="flex items-center gap-4"><a href={hrefs.issues} className="hover:text-chart-1 font-semibold underline-offset-4 hover:underline">Issue 48 · Autumn 2026</a><a href="#" className="hidden font-semibold underline-offset-4 hover:underline sm:inline">Sign in</a><a href={hrefs.issues} className="bg-chart-1 text-primary-foreground focus-visible:ring-ring/50 rounded-sm px-3 py-1.5 font-bold tracking-wide uppercase outline-none focus-visible:ring-[3px]">Subscribe</a></p>
</div>
<div className="mx-auto max-w-6xl px-4 sm:px-6">
<div className="border-foreground border-y-4 border-double py-4 text-center">
<a href={hrefs.home} className={cn("focus-visible:ring-ring/50 inline-block rounded text-[clamp(2.6rem,9vw,6.5rem)] leading-none tracking-[-0.02em] outline-none focus-visible:ring-[3px]", serif)}>Marginalia</a>
<p className="text-muted-foreground mt-1 text-xs tracking-[0.2em] uppercase">A magazine of ideas, written slowly</p>
</div>
<div className="flex items-center justify-between border-b py-1">
<nav aria-label="Sections" className="hidden md:block">
<ul className="flex gap-1">{sections.map((s) => <li key={s}><a href={hrefs.section} aria-current={current === s ? "page" : undefined} className="hover:text-chart-1 aria-[current=page]:text-chart-1 focus-visible:ring-ring/50 block rounded px-3.5 py-2.5 text-sm font-semibold tracking-wide uppercase outline-none transition-colors focus-visible:ring-[3px]">{s}</a></li>)}</ul>
</nav>
<button type="button" aria-label={open ? "Close sections" : "Open sections"} aria-expanded={open} aria-controls="pub-mobile-nav" onClick={() => setOpen((v) => !v)} className="hover:bg-accent focus-visible:ring-ring/50 inline-flex size-10 items-center justify-center rounded outline-none focus-visible:ring-[3px] md:hidden">{open ? <X className="size-5" aria-hidden="true" /> : <Menu className="size-5" aria-hidden="true" />}</button>
<button type="button" aria-label="Search" className="hover:bg-accent focus-visible:ring-ring/50 ms-auto inline-flex size-10 items-center justify-center rounded outline-none focus-visible:ring-[3px]"><Search className="size-5" aria-hidden="true" /></button>
</div>
{open && <nav id="pub-mobile-nav" aria-label="Sections, mobile" className="border-b py-2 md:hidden">{sections.map((s) => <a key={s} href={hrefs.section} className="hover:bg-accent block rounded px-2 py-2.5 text-sm font-semibold tracking-wide uppercase">{s}</a>)}</nav>}
</div>
</header>
{children}
<footer className="bg-foreground text-background mt-20">
<div className="mx-auto grid max-w-6xl gap-10 px-4 py-14 sm:px-6 md:grid-cols-[1.4fr_repeat(3,1fr)]">
<div><p className={cn("text-3xl", serif)}>Marginalia</p><p className={cn("mt-3 max-w-xs text-sm opacity-75", text)}>Independent since 2014. Funded by readers, not by clicks.</p></div>
{[["Read", ["Essays", "Culture", "Science", "Technology"]], ["Magazine", ["Issues", "Subscribe", "Gift a year", "Back issues"]], ["About", ["Our writers", "Submissions", "Contact", "Privacy"]]].map(([t, items]) => (
<div key={t as string}><h2 className="text-xs font-bold tracking-widest uppercase opacity-60">{t as string}</h2><ul className="mt-4 space-y-2.5">{(items as string[]).map((i) => <li key={i}><a href={i === "Issues" || i === "Subscribe" ? hrefs.issues : i === "Our writers" ? hrefs.author : hrefs.section} className="text-sm opacity-90 hover:underline">{i}</a></li>)}</ul></div>
))}
</div>
<p className="mx-auto max-w-6xl border-t border-current/20 px-4 py-5 text-xs opacity-60 sm:px-6">© 2026 Marginalia Press. All rights reserved.</p>
</footer>
</div>
)
}
export { MagArt, PublicationShell, defaultHrefs as publicationDefaultHrefs, serif as pubSerifClass, text as pubTextClass, type PublicationHrefs, type PublicationPage, type PublicationShellProps }// Ballmac UI: Publication home page. https://ui.ballmac.com/templates/template-publication
"use client"
import * as React from "react"
import { Check } from "lucide-react"
import { articles, formatShort, getAuthor, sections } from "@/components/ballmac/templates/publication/publication-data"
import { MagArt, PublicationShell, pubSerifClass, pubTextClass, type PublicationHrefs } from "@/components/ballmac/templates/publication/publication-theme"
import { cn } from "@/lib/utils"
const kicker = "text-chart-1 text-xs font-bold tracking-[0.14em] uppercase"
function Newsletter({ tone = "card" }: { tone?: "card" | "ink" }) {
const [email, setEmail] = React.useState("")
const [error, setError] = React.useState("")
const [done, setDone] = React.useState(false)
function submit(e: React.FormEvent) {
e.preventDefault()
if (!/^\S+@\S+\.\S+$/.test(email.trim())) return setError("Enter an email like you@example.com.")
setError("")
setDone(true)
}
return (
<section aria-labelledby={`nl-${tone}`} className={cn("border-foreground border-y-4 border-double px-2 py-10 text-center sm:py-14", tone === "ink" && "bg-secondary")}>
<p className={kicker}>The Sunday Margin</p>
<h2 id={`nl-${tone}`} className={cn("mx-auto mt-3 max-w-xl text-4xl text-balance sm:text-5xl", pubSerifClass)}>One good essay, every Sunday morning.</h2>
<p className={cn("text-muted-foreground mx-auto mt-3 max-w-md text-pretty", pubTextClass)}>Free, short and unhurried. Read by 41,000 people over breakfast.</p>
{done ? (
<p role="status" className="mt-6 inline-flex items-center gap-2 font-semibold"><Check className="text-chart-4 size-5" aria-hidden="true" />You’re on the list. See you Sunday.</p>
) : (
<form onSubmit={submit} noValidate className="mx-auto mt-6 flex max-w-md flex-col gap-2 sm:flex-row">
<label htmlFor={`nl-email-${tone}`} className="sr-only">Email address</label>
<input id={`nl-email-${tone}`} type="email" value={email} onChange={(e) => setEmail(e.target.value)} aria-invalid={!!error} aria-describedby={error ? `nl-err-${tone}` : undefined} placeholder="you@example.com" className="bg-background focus-visible:ring-ring/50 placeholder:text-muted-foreground aria-[invalid=true]:border-destructive h-12 flex-1 rounded-sm border px-4 outline-none focus-visible:ring-[3px]" />
<button type="submit" className="bg-foreground text-background focus-visible:ring-ring/50 h-12 rounded-sm px-6 text-sm font-bold tracking-wide uppercase outline-none transition-opacity hover:opacity-90 focus-visible:ring-[3px]">Sign me up</button>
</form>
)}
{error && <p id={`nl-err-${tone}`} role="alert" className="text-destructive mt-2 text-sm">{error}</p>}
</section>
)
}
type PublicationHomeProps = React.ComponentProps<"div"> & { hrefs?: Partial<PublicationHrefs> }
/** The Publication home page: a lead story with a dek, supporting stories, an opinion column, picks and a newsletter. */
function PublicationHome({ hrefs, ...props }: PublicationHomeProps) {
const [lead, ...rest] = articles
const side = rest.slice(0, 3)
const grid = rest.slice(3, 6)
const popular = [...articles].filter((a) => a.popular).sort((a, b) => (a.popular ?? 0) - (b.popular ?? 0))
const a = hrefs?.article ?? "/publication/essays/the-unfinished-city"
return (
<PublicationShell page="home" hrefs={hrefs} {...props}>
<main className="mx-auto max-w-6xl px-4 pt-8 sm:px-6">
<div className="grid gap-8 lg:grid-cols-[1fr_20rem] lg:gap-10">
<article>
<a href={a} className="group focus-visible:ring-ring/50 block rounded outline-none focus-visible:ring-[3px]">
<MagArt variant={lead.art} image={lead.image} imageAlt={lead.imageAlt} className="aspect-[16/10]" />
<p className={cn("mt-5", kicker)}>{lead.kicker}</p>
<h1 className={cn("mt-2 text-[clamp(2.4rem,6vw,4.5rem)] leading-[1.02] tracking-[-0.01em] text-balance group-hover:underline decoration-1 underline-offset-4", pubSerifClass)}>{lead.title}</h1>
<p className={cn("text-muted-foreground mt-4 max-w-2xl text-xl leading-relaxed text-pretty", pubTextClass)}>{lead.dek}</p>
<p className="text-muted-foreground mt-4 text-sm">By <span className="text-foreground font-semibold">{getAuthor(lead.author).name}</span> · {lead.read} min read</p>
</a>
</article>
<aside aria-label="More stories" className="lg:border-s lg:ps-10">
<ul className="divide-y">
{side.map((s) => (
<li key={s.id} className="py-5 first:pt-0 lg:last:pb-0">
<a href={a} className="group focus-visible:ring-ring/50 block rounded outline-none focus-visible:ring-[3px]">
<p className={kicker}>{s.section}</p>
<h2 className={cn("mt-1.5 text-2xl leading-tight text-balance group-hover:underline underline-offset-4", pubSerifClass)}>{s.title}</h2>
<p className="text-muted-foreground mt-2 text-sm">{getAuthor(s.author).name} · {s.read} min</p>
</a>
</li>
))}
</ul>
</aside>
</div>
<section aria-label="Latest" className="mt-12 border-t-2 border-current pt-8">
<ul className="grid gap-x-8 gap-y-10 sm:grid-cols-3">
{grid.map((g) => (
<li key={g.id}>
<a href={a} className="group focus-visible:ring-ring/50 block rounded outline-none focus-visible:ring-[3px]">
<MagArt variant={g.art} image={g.image} imageAlt={g.imageAlt} />
<p className={cn("mt-4", kicker)}>{g.section}</p>
<h3 className={cn("mt-1.5 text-2xl leading-tight text-balance group-hover:underline underline-offset-4", pubSerifClass)}>{g.title}</h3>
<p className={cn("text-muted-foreground mt-2 text-pretty", pubTextClass)}>{g.dek}</p>
<p className="text-muted-foreground mt-3 text-sm">{getAuthor(g.author).name} · {formatShort(g.date)}</p>
</a>
</li>
))}
</ul>
</section>
<div className="mt-16 grid gap-12 lg:grid-cols-[1fr_20rem]">
<section aria-labelledby="pub-opinion">
<h2 id="pub-opinion" className="border-foreground border-b-2 pb-2 text-sm font-bold tracking-[0.14em] uppercase">Opinion & essays</h2>
<ul className="divide-y">
{articles.filter((x) => x.section === "Essays" || x.section === "Interviews").slice(1, 5).map((o) => {
const au = getAuthor(o.author)
return (
<li key={o.id} className="grid grid-cols-[3.5rem_1fr] gap-4 py-6">
<span className={cn("flex size-14 items-center justify-center rounded-full text-lg", pubSerifClass, ["bg-chart-3 text-[var(--publication-on-accent)]", "bg-chart-5 text-[var(--publication-on-accent)]", "bg-chart-4 text-background", "bg-secondary text-secondary-foreground"][au.tone - 1])} aria-hidden="true">{au.name.split(" ").map((w) => w[0]).join("")}</span>
<a href={a} className="group focus-visible:ring-ring/50 rounded outline-none focus-visible:ring-[3px]">
<p className="text-muted-foreground text-xs font-semibold tracking-wide uppercase">{au.name}</p>
<h3 className={cn("mt-1 text-2xl leading-tight text-balance group-hover:underline underline-offset-4", pubSerifClass)}>{o.title}</h3>
<p className={cn("text-muted-foreground mt-1.5 text-pretty", pubTextClass)}>{o.dek}</p>
</a>
</li>
)
})}
</ul>
</section>
<aside aria-labelledby="pub-popular" className="bg-surface h-fit border p-6">
<h2 id="pub-popular" className="text-sm font-bold tracking-[0.14em] uppercase">Most read</h2>
<ol className="mt-4 space-y-5">
{popular.map((p, i) => (
<li key={p.id} className="grid grid-cols-[2rem_1fr] gap-2">
<span className={cn("text-chart-1 text-4xl leading-none", pubSerifClass)} aria-hidden="true">{i + 1}</span>
<a href={a} className={cn("hover:underline text-lg leading-snug text-balance underline-offset-4", pubSerifClass)}><span className="sr-only">{i + 1}. </span>{p.title}</a>
</li>
))}
</ol>
</aside>
</div>
<div className="mt-16"><Newsletter /></div>
<ul aria-label="Browse by section" className="mt-10 flex flex-wrap justify-center gap-2">{sections.map((s) => <li key={s}><a href={hrefs?.section ?? "/publication/essays"} className="hover:bg-accent focus-visible:ring-ring/50 inline-flex h-10 items-center rounded-full border px-5 text-sm font-semibold outline-none transition-colors focus-visible:ring-[3px]">{s}</a></li>)}</ul>
</main>
</PublicationShell>
)
}
export { Newsletter, PublicationHome, type PublicationHomeProps }// Ballmac UI: Publication article page. https://ui.ballmac.com/templates/template-publication
"use client"
import * as React from "react"
import { Link2 } from "lucide-react"
import { CopyButton } from "@/components/ballmac/copy-button"
import { ScrollProgress } from "@/components/ballmac/scroll-progress"
import { articles, formatDate, getAuthor } from "@/components/ballmac/templates/publication/publication-data"
import { Newsletter } from "@/components/ballmac/templates/publication/publication-home"
import { MagArt, PublicationShell, pubSerifClass, pubTextClass, type PublicationHrefs } from "@/components/ballmac/templates/publication/publication-theme"
import { cn } from "@/lib/utils"
/** A paragraph with an optional margin note: beside the text on wide screens, under it on small ones. */
function Para({ children, note, n }: { children: React.ReactNode; note?: string; n?: number }) {
return (
<div className="relative">
<p className={cn("text-[1.1875rem] leading-[1.8] text-pretty", pubTextClass)}>
{children}
{n && <sup className="ms-0.5"><a href={`#note-${n}`} id={`ref-${n}`} className="text-chart-1 focus-visible:ring-ring/50 rounded px-0.5 font-sans text-xs font-bold outline-none focus-visible:ring-[3px]" aria-label={`Note ${n}`}>{n}</a></sup>}
</p>
{note && n && (
<aside id={`note-${n}`} aria-label={`Note ${n}`} className="text-muted-foreground border-chart-1 mt-3 border-s-2 ps-4 text-sm leading-relaxed xl:absolute xl:top-0 xl:start-full xl:mt-0 xl:ms-10 xl:w-56 xl:border-s-0 xl:ps-0">
<span className="text-chart-1 me-1.5 font-bold">{n}</span>{note}
</aside>
)}
</div>
)
}
type PublicationArticleProps = React.ComponentProps<"div"> & { hrefs?: Partial<PublicationHrefs> }
/** The Publication article page: a reading-progress bar, drop cap, margin notes that sit beside the text on wide screens, a pull quote and an author card. */
function PublicationArticle({ hrefs, ...props }: PublicationArticleProps) {
const a = articles[0]
const au = getAuthor(a.author)
const related = articles.slice(1, 4)
return (
<PublicationShell page="article" section="Essays" hrefs={hrefs} {...props}>
<ScrollProgress position="top" className="[&>*]:bg-chart-1" />
<main>
<header className="mx-auto max-w-3xl px-4 pt-12 text-center sm:px-6">
<p className="text-chart-1 text-xs font-bold tracking-[0.14em] uppercase">{a.section} · {a.kicker}</p>
<h1 className={cn("mt-4 text-[clamp(2.6rem,6.4vw,4.8rem)] leading-[1.02] tracking-[-0.01em] text-balance", pubSerifClass)}>{a.title}</h1>
<p className={cn("text-muted-foreground mt-5 text-xl leading-relaxed text-pretty", pubTextClass)}>{a.dek}</p>
<div className="mt-7 flex items-center justify-center gap-3 text-sm">
<span className={cn("bg-chart-3 text-[var(--publication-on-accent)] flex size-11 items-center justify-center rounded-full text-base", pubSerifClass)} aria-hidden="true">IM</span>
<p className="text-start"><span className="block font-semibold">By {au.name}</span><span className="text-muted-foreground">{formatDate(a.date)} · {a.read} min read</span></p>
</div>
</header>
<div className="mx-auto mt-10 max-w-5xl px-4 sm:px-6"><MagArt variant={a.art} image={a.image} imageAlt={a.imageAlt} className="aspect-[16/8]" /><p className="text-muted-foreground mt-2 text-xs">Scaffolding on Rua da Madalena, Lisbon. Illustration.</p></div>
<article className="mx-auto mt-12 max-w-[40rem] space-y-7 px-4 sm:px-6">
<div className="relative">
<p className={cn("first-letter:float-start first-letter:me-3 first-letter:text-[5.2rem] first-letter:leading-[0.8] first-letter:font-bold first-letter:[font-family:var(--pub-display)] text-[1.1875rem] leading-[1.8] text-pretty", pubTextClass)}>
There is a street near my flat that has been covered in scaffolding for as long as I have lived here. The poles have weathered, a pigeon has opinions about the third floor, and the building behind it appears to be neither getting worse nor better. I used to find it depressing.<sup className="ms-0.5"><a href="#note-1" id="ref-1" className="text-chart-1 focus-visible:ring-ring/50 rounded px-0.5 font-sans text-xs font-bold outline-none focus-visible:ring-[3px]" aria-label="Note 1">1</a></sup>
</p>
<aside id="note-1" aria-label="Note 1" className="text-muted-foreground border-chart-1 mt-3 border-s-2 ps-4 text-sm leading-relaxed xl:absolute xl:top-0 xl:start-full xl:mt-0 xl:ms-10 xl:w-56 xl:border-s-0 xl:ps-0"><span className="text-chart-1 me-1.5 font-bold">1</span>The building is a 1920s tenement. The scaffold went up in 2019 after a cornice fell on a parked car.</aside>
</div>
<Para n={2} note="Jane Jacobs made this point in 1961. Cities, she wrote, need old buildings, and a great many of them, mixed in with the new.">Then I started to notice how many of the places I love are unfinished in exactly this way: the cathedral with a crane, the square half dug up, the market that is always about to be renovated. A city that is finished is a museum.</Para>
<h2 className={cn("pt-4 text-3xl leading-tight text-balance", pubSerifClass)}>What patience looks like in stone</h2>
<Para>Stonemasons talk about work they will never see completed. The medieval builders of a cathedral knew they would die before the spire went up, and they built the foundations for a roof they would never stand under. We have mostly lost that habit of mind.</Para>
<blockquote className="border-foreground my-10 border-y-4 border-double py-8 text-center"><p className={cn("text-[clamp(1.7rem,3.6vw,2.4rem)] leading-tight text-balance", pubSerifClass)}>“A city that is finished is a museum.”</p></blockquote>
<Para n={3} note="The scaffold on the Rua da Madalena has a name among neighbours: ‘o esqueleto’. The skeleton.">I asked the neighbours what they thought. Nobody wanted it gone. They had named it, mapped their bicycles to its poles and learned which floor had the best light. The scaffold had become part of the street’s grammar.</Para>
<Para>None of this is an argument for neglect. It is an argument for noticing that the stage between the old and the new is not a failure, and for learning to live there gracefully, perhaps even with affection.</Para>
</article>
<div className="mx-auto mt-14 flex max-w-[40rem] flex-wrap items-center justify-between gap-4 border-y px-4 py-4 sm:px-6">
<p className="text-muted-foreground text-sm">Filed under <a href={hrefs?.section ?? "/publication/essays"} className="text-foreground font-semibold underline-offset-4 hover:underline">Essays</a></p>
<div className="flex items-center gap-2"><span className="text-sm font-semibold">Share</span><CopyButton value="https://marginalia.example/essays/the-unfinished-city" ariaLabel="Copy link to this article" /></div>
</div>
<aside aria-labelledby="pub-author" className="mx-auto mt-10 flex max-w-[40rem] gap-5 px-4 sm:px-6">
<span className={cn("bg-chart-3 text-[var(--publication-on-accent)] flex size-16 shrink-0 items-center justify-center rounded-full text-2xl", pubSerifClass)} aria-hidden="true">IM</span>
<div><h2 id="pub-author" className="font-semibold">About {au.name}</h2><p className={cn("text-muted-foreground mt-1 text-pretty", pubTextClass)}>{au.bio}</p><a href={hrefs?.author ?? "/publication/authors/ines"} className="text-chart-1 mt-2 inline-flex items-center gap-1.5 text-sm font-semibold underline-offset-4 hover:underline"><Link2 className="size-4" aria-hidden="true" />More by {au.name.split(" ")[0]}</a></div>
</aside>
<section aria-labelledby="pub-related" className="mx-auto mt-20 max-w-6xl px-4 sm:px-6">
<h2 id="pub-related" className="border-foreground border-b-2 pb-2 text-sm font-bold tracking-[0.14em] uppercase">Keep reading</h2>
<ul className="mt-8 grid gap-8 sm:grid-cols-3">{related.map((r) => <li key={r.id}><a href={hrefs?.article ?? "#"} className="group focus-visible:ring-ring/50 block rounded outline-none focus-visible:ring-[3px]"><MagArt variant={r.art} image={r.image} imageAlt={r.imageAlt} /><h3 className={cn("mt-4 text-2xl leading-tight text-balance group-hover:underline underline-offset-4", pubSerifClass)}>{r.title}</h3><p className="text-muted-foreground mt-2 text-sm">{getAuthor(r.author).name} · {r.read} min</p></a></li>)}</ul>
</section>
<div className="mx-auto mt-16 max-w-6xl px-4 sm:px-6"><Newsletter tone="ink" /></div>
</main>
</PublicationShell>
)
}
export { PublicationArticle, type PublicationArticleProps }// Ballmac UI: Publication section page. https://ui.ballmac.com/templates/template-publication
"use client"
import * as React from "react"
import { articles, formatShort, getAuthor, sections, type Section } from "@/components/ballmac/templates/publication/publication-data"
import { MagArt, PublicationShell, pubSerifClass, pubTextClass, type PublicationHrefs } from "@/components/ballmac/templates/publication/publication-theme"
import { cn } from "@/lib/utils"
const descriptions: Record<string, string> = { Essays: "Long arguments, patiently made. New essays every week.", Culture: "Food, music, books and the small rituals of daily life.", Science: "Field work, deep time and the people who count things.", Technology: "Slow technology: tools, craft and the people who make them.", Interviews: "Unhurried conversations with writers and makers." }
type PublicationSectionProps = React.ComponentProps<"div"> & { hrefs?: Partial<PublicationHrefs>; initial?: Section | "All" }
/** A Publication section page: a topic switch, a sort, a story list with thumbnails and a "load more" that adds stories. */
function PublicationSection({ hrefs, initial = "Essays", ...props }: PublicationSectionProps) {
const [section, setSection] = React.useState<Section | "All">(initial)
const [sort, setSort] = React.useState<"new" | "long">("new")
const [count, setCount] = React.useState(4)
const list = articles.filter((a) => section === "All" || a.section === section).sort((a, b) => (sort === "new" ? b.date.localeCompare(a.date) : b.read - a.read))
const shown = list.slice(0, count)
const a = hrefs?.article ?? "/publication/essays/the-unfinished-city"
return (
<PublicationShell page="section" section={section === "All" ? undefined : section} hrefs={hrefs} {...props}>
<main className="mx-auto max-w-4xl px-4 pt-10 sm:px-6">
<p className="text-chart-1 text-xs font-bold tracking-[0.14em] uppercase">Section</p>
<h1 className={cn("mt-2 text-[clamp(3rem,8vw,5.5rem)] leading-none", pubSerifClass)}>{section === "All" ? "Everything" : section}</h1>
<p className={cn("text-muted-foreground mt-4 max-w-xl text-xl text-pretty", pubTextClass)}>{section === "All" ? "Every story, newest first." : descriptions[section]}</p>
<div className="mt-8 flex flex-wrap items-center gap-3 border-y py-3">
<div role="group" aria-label="Choose a section" className="flex flex-wrap gap-1.5">
{(["All", ...sections] as const).map((s) => <button key={s} type="button" aria-pressed={section === s} onClick={() => { setSection(s); setCount(4) }} className={cn("focus-visible:ring-ring/50 h-9 rounded-full border px-4 text-sm font-semibold outline-none transition-colors focus-visible:ring-[3px]", section === s ? "bg-foreground text-background border-transparent" : "hover:bg-accent")}>{s}</button>)}
</div>
<label className="text-muted-foreground ms-auto flex items-center gap-2 text-sm">Sort <select value={sort} onChange={(e) => setSort(e.target.value as "new" | "long")} className="bg-background focus-visible:ring-ring/50 text-foreground h-9 rounded border px-2 outline-none focus-visible:ring-[3px]"><option value="new">Newest</option><option value="long">Longest</option></select></label>
</div>
<p className="sr-only" role="status">Showing {shown.length} of {list.length} stories</p>
{shown.length === 0 && <p className="text-muted-foreground mt-12 border border-dashed p-12 text-center">Nothing in this section yet.</p>}
<ul className="divide-y">
{shown.map((s) => (
<li key={s.id}>
<a href={a} className="group focus-visible:ring-ring/50 grid gap-5 rounded py-8 outline-none focus-visible:ring-[3px] sm:grid-cols-[1fr_14rem]">
<div>
<p className="text-chart-1 text-xs font-bold tracking-[0.14em] uppercase">{s.section} · {s.kicker}</p>
<h2 className={cn("mt-2 text-3xl leading-tight text-balance group-hover:underline underline-offset-4 sm:text-4xl", pubSerifClass)}>{s.title}</h2>
<p className={cn("text-muted-foreground mt-3 text-pretty", pubTextClass)}>{s.dek}</p>
<p className="text-muted-foreground mt-4 text-sm">By <span className="text-foreground font-semibold">{getAuthor(s.author).name}</span> · {formatShort(s.date)} · {s.read} min</p>
</div>
<MagArt variant={s.art} image={s.image} imageAlt={s.imageAlt} className="order-first sm:order-none" />
</a>
</li>
))}
</ul>
{count < list.length && <div className="mt-6 text-center"><button type="button" onClick={() => setCount((c) => c + 4)} className="hover:bg-accent focus-visible:ring-ring/50 h-12 rounded-sm border-2 border-current px-8 text-sm font-bold tracking-wide uppercase outline-none transition-colors focus-visible:ring-[3px]">Load more stories</button></div>}
</main>
</PublicationShell>
)
}
export { PublicationSection, type PublicationSectionProps }// Ballmac UI: Publication author page. https://ui.ballmac.com/templates/template-publication
"use client"
import * as React from "react"
import { articles, authors, formatShort } from "@/components/ballmac/templates/publication/publication-data"
import { MagArt, PublicationShell, pubSerifClass, pubTextClass, type PublicationHrefs } from "@/components/ballmac/templates/publication/publication-theme"
import { cn } from "@/lib/utils"
type PublicationAuthorProps = React.ComponentProps<"div"> & { hrefs?: Partial<PublicationHrefs>; authorId?: string }
/** A Publication author page: portrait initials, a bio, the byline count and every story they wrote. */
function PublicationAuthor({ hrefs, authorId = "ines", ...props }: PublicationAuthorProps) {
const au = authors.find((x) => x.id === authorId) ?? authors[0]
const mine = articles.filter((a) => a.author === au.id)
const total = mine.reduce((n, a) => n + a.read, 0)
const a = hrefs?.article ?? "/publication/essays/the-unfinished-city"
return (
<PublicationShell page="author" hrefs={hrefs} {...props}>
<main className="mx-auto max-w-4xl px-4 pt-12 sm:px-6">
<header className="grid gap-8 border-b-4 border-double border-foreground pb-10 sm:grid-cols-[10rem_1fr] sm:items-center">
<span className={cn("flex size-36 items-center justify-center rounded-full text-6xl sm:size-40", pubSerifClass, ["bg-chart-3 text-[var(--publication-on-accent)]", "bg-chart-5 text-[var(--publication-on-accent)]", "bg-chart-4 text-background", "bg-secondary text-secondary-foreground"][au.tone - 1])} aria-hidden="true">{au.name.split(" ").map((w) => w[0]).join("")}</span>
<div>
<p className="text-chart-1 text-xs font-bold tracking-[0.14em] uppercase">{au.role}</p>
<h1 className={cn("mt-2 text-[clamp(2.8rem,7vw,4.8rem)] leading-none", pubSerifClass)}>{au.name}</h1>
<p className={cn("text-muted-foreground mt-4 max-w-xl text-xl leading-relaxed text-pretty", pubTextClass)}>{au.bio}</p>
<dl className="mt-5 flex gap-8 text-sm"><div><dd className="text-2xl font-bold tabular-nums">{mine.length}</dd><dt className="text-muted-foreground">stories</dt></div><div><dd className="text-2xl font-bold tabular-nums">{total}</dd><dt className="text-muted-foreground">minutes of reading</dt></div></dl>
</div>
</header>
<section aria-labelledby="pub-by" className="mt-10">
<h2 id="pub-by" className="text-sm font-bold tracking-[0.14em] uppercase">Stories by {au.name.split(" ")[0]}</h2>
<ul className="divide-y">
{mine.map((s) => (
<li key={s.id}><a href={a} className="group focus-visible:ring-ring/50 grid gap-5 rounded py-7 outline-none focus-visible:ring-[3px] sm:grid-cols-[1fr_12rem]"><div><p className="text-chart-1 text-xs font-bold tracking-[0.14em] uppercase">{s.section} · {formatShort(s.date)}</p><h3 className={cn("mt-2 text-3xl leading-tight text-balance group-hover:underline underline-offset-4", pubSerifClass)}>{s.title}</h3><p className={cn("text-muted-foreground mt-2 text-pretty", pubTextClass)}>{s.dek}</p></div><MagArt variant={s.art} image={s.image} imageAlt={s.imageAlt} className="order-first sm:order-none" /></a></li>
))}
</ul>
</section>
</main>
</PublicationShell>
)
}
export { PublicationAuthor, type PublicationAuthorProps }// Ballmac UI: Publication issues page. https://ui.ballmac.com/templates/template-publication
"use client"
import * as React from "react"
import { Check } from "lucide-react"
import { issues } from "@/components/ballmac/templates/publication/publication-data"
import { Newsletter } from "@/components/ballmac/templates/publication/publication-home"
import { MagArt, PublicationShell, pubSerifClass, pubTextClass, type PublicationHrefs } from "@/components/ballmac/templates/publication/publication-theme"
import { cn } from "@/lib/utils"
const plans = [
{ id: "digital", name: "Digital", price: 4, per: "a month", features: ["Every story and archive", "Four issues a year as PDF and ePub", "The Sunday Margin newsletter"] },
{ id: "print", name: "Print & digital", price: 9, per: "a month", features: ["Everything in Digital", "Four printed issues, posted free worldwide", "Back-issue discount of 30%"], featured: true },
]
type PublicationIssuesProps = React.ComponentProps<"div"> & { hrefs?: Partial<PublicationHrefs> }
/** The Publication issues page: a shelf of covers by season, two subscription plans with monthly or yearly billing, and the newsletter. */
function PublicationIssues({ hrefs, ...props }: PublicationIssuesProps) {
const [yearly, setYearly] = React.useState(true)
return (
<PublicationShell page="issues" hrefs={hrefs} {...props}>
<main className="mx-auto max-w-6xl px-4 pt-12 sm:px-6">
<p className="text-chart-1 text-xs font-bold tracking-[0.14em] uppercase">The magazine</p>
<h1 className={cn("mt-2 text-[clamp(3rem,8vw,6rem)] leading-none", pubSerifClass)}>Issues</h1>
<p className={cn("text-muted-foreground mt-4 max-w-xl text-xl text-pretty", pubTextClass)}>Four times a year, one theme, 160 pages. Printed on uncoated paper and meant to be kept.</p>
<section aria-label="Back issues" className="mt-12">
<ul className="grid grid-cols-2 gap-x-5 gap-y-10 sm:grid-cols-3 lg:grid-cols-6">
{issues.map((i) => (
<li key={i.n}>
<a href="#" className="group focus-visible:ring-ring/50 block rounded outline-none focus-visible:ring-[3px]">
<div className="relative shadow-[6px_6px_0_0_var(--border)] transition-transform duration-300 group-hover:-translate-y-1 motion-reduce:transition-none">
<MagArt variant={i.art} image={i.image} imageAlt={i.imageAlt} className="aspect-[3/4]" />
<div className="bg-card/95 absolute inset-x-0 bottom-0 border-t p-3"><p className={cn("text-xl leading-none", pubSerifClass)}>{i.theme}</p><p className="text-muted-foreground mt-1 text-[11px] tracking-wide uppercase">No. {i.n}</p></div>
</div>
<p className="text-muted-foreground mt-3 text-sm">{i.season}</p>
</a>
</li>
))}
</ul>
</section>
<section aria-labelledby="pub-sub" className="mt-24">
<div className="text-center">
<h2 id="pub-sub" className={cn("text-4xl sm:text-5xl", pubSerifClass)}>Read without the noise</h2>
<div role="group" aria-label="Billing" className="mt-6 inline-flex rounded-full border p-1">
{([[true, "Yearly · 2 months free"], [false, "Monthly"]] as const).map(([v, l]) => <button key={l} type="button" aria-pressed={yearly === v} onClick={() => setYearly(v)} className={cn("focus-visible:ring-ring/50 h-10 rounded-full px-5 text-sm font-semibold outline-none transition-colors focus-visible:ring-[3px]", yearly === v ? "bg-foreground text-background" : "text-muted-foreground")}>{l}</button>)}
</div>
</div>
<div className="mx-auto mt-10 grid max-w-3xl gap-5 md:grid-cols-2">
{plans.map((p) => {
const price = yearly ? Math.round(p.price * 10) : p.price
return (
<article key={p.id} className={cn("flex flex-col border-2 p-8", "featured" in p && p.featured ? "border-chart-1 bg-card" : "border-foreground")}>
<h3 className={cn("text-3xl", pubSerifClass)}>{p.name}</h3>
<p className="mt-4 flex items-baseline gap-2"><span className={cn("text-6xl tabular-nums", pubSerifClass)}>${price}</span><span className="text-muted-foreground text-sm">{yearly ? "a year" : p.per}</span></p>
<ul className="mt-6 flex-1 space-y-3">{p.features.map((f) => <li key={f} className={cn("flex items-start gap-3 text-pretty", pubTextClass)}><Check className="text-chart-4 mt-1.5 size-4 shrink-0" aria-hidden="true" />{f}</li>)}</ul>
<a href="#" className={cn("focus-visible:ring-ring/50 mt-8 inline-flex h-12 items-center justify-center rounded-sm text-sm font-bold tracking-wide uppercase outline-none transition-opacity hover:opacity-90 focus-visible:ring-[3px]", "featured" in p && p.featured ? "bg-chart-1 text-primary-foreground" : "bg-foreground text-background")}>Subscribe</a>
</article>
)
})}
</div>
</section>
<div className="mt-20"><Newsletter /></div>
</main>
</PublicationShell>
)
}
export { PublicationIssues, type PublicationIssuesProps }// Ballmac UI: Publication template route. https://ui.ballmac.com/templates/template-publication
import type { Metadata } from "next"
import { PublicationHome } from "@/components/ballmac/templates/publication/publication-home"
export const metadata: Metadata = {
title: "Marginalia: a magazine of ideas, written slowly",
description: "Essays, culture, science and technology, written slowly.",
}
export default function Page() {
return <PublicationHome />
}// Ballmac UI: Publication template route. https://ui.ballmac.com/templates/template-publication
import type { Metadata } from "next"
import { PublicationArticle } from "@/components/ballmac/templates/publication/publication-article"
export const metadata: Metadata = {
title: "The case for the unfinished city · Marginalia",
description: "What a scaffold-covered street teaches us about patience.",
}
// One layout for every article. Load the story by params.slug from your data when you add more.
export default function Page() {
return <PublicationArticle />
}// Ballmac UI: Publication template route. https://ui.ballmac.com/templates/template-publication
import type { Metadata } from "next"
import { PublicationSection } from "@/components/ballmac/templates/publication/publication-section"
export const metadata: Metadata = {
title: "Essays · Marginalia",
description: "Long arguments, patiently made.",
}
export default function Page() {
return <PublicationSection />
}// Ballmac UI: Publication template route. https://ui.ballmac.com/templates/template-publication
import type { Metadata } from "next"
import { PublicationAuthor } from "@/components/ballmac/templates/publication/publication-author"
export const metadata: Metadata = {
title: "Authors · Marginalia",
description: "Writers at Marginalia.",
}
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
return <PublicationAuthor authorId={id} />
}// Ballmac UI: Publication template route. https://ui.ballmac.com/templates/template-publication
import type { Metadata } from "next"
import { PublicationIssues } from "@/components/ballmac/templates/publication/publication-issues"
export const metadata: Metadata = {
title: "Issues · Marginalia",
description: "Four times a year, one theme, 160 pages.",
}
export default function Page() {
return <PublicationIssues />
}Installation
$ pnpm dlx shadcn@latest add @ballmac/template-publicationInstall the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/copy-button @ballmac/scroll-progress @ballmac/mediaCopy the source into your project.
components/ballmac/templates/publication/publication-fonts.ts// Ballmac UI: Publication template fonts. https://ui.ballmac.com/templates/template-publication import { DM_Serif_Display, Lora, Public_Sans } from "next/font/google" export const pubDisplay = DM_Serif_Display({ variable: "--pub-display", subsets: ["latin"], weight: "400", style: ["normal", "italic"], display: "swap", preload: false }) export const pubBody = Lora({ variable: "--pub-body", subsets: ["latin"], display: "swap", preload: false }) export const pubSans = Public_Sans({ variable: "--pub-sans", subsets: ["latin"], display: "swap", preload: false })components/ballmac/templates/publication/publication-data.tsimport type { MediaSource } from "@/components/ballmac/media" // Ballmac UI: Publication sample content. https://ui.ballmac.com/templates/template-publication export type Section = "Essays" | "Culture" | "Science" | "Technology" | "Interviews" export const sections: Section[] = ["Essays", "Culture", "Science", "Technology", "Interviews"] export type Author = { id: string; name: string; role: string; bio: string; tone: number } export const authors: Author[] = [ { id: "ines", name: "Inês Marques", role: "Senior editor", bio: "Writes about cities, memory and the objects we keep. Previously at a daily paper in Porto.", tone: 1 }, { id: "tomas", name: "Tomás Reyes", role: "Science correspondent", bio: "Reports on ecology and the people who count things for a living.", tone: 2 }, { id: "amara", name: "Amara Okafor", role: "Contributing writer", bio: "Essays on technology, work and the slow end of the internet.", tone: 3 }, { id: "elena", name: "Elena Rossi", role: "Culture editor", bio: "Edits the culture desk. Cooks, reads, rarely sleeps in October.", tone: 4 }, ] export type Article = { id: string; section: Section; kicker: string; title: string; dek: string; author: string; date: string; read: number; art: number; popular?: number; image?: MediaSource; imageAlt?: string } export const articles: Article[] = [ { id: "a1", section: "Essays", kicker: "The long view", title: "The case for the unfinished city", dek: "What a scaffold-covered street teaches us about patience, and why the cities we love are never done.", author: "ines", date: "2026-09-28", read: 14, art: 0, popular: 1 }, { id: "a2", section: "Science", kicker: "Field notes", title: "Counting every bird in a parish of three hundred", dek: "A volunteer census, a very cold spring, and the surprising comeback of the corn bunting.", author: "tomas", date: "2026-09-26", read: 11, art: 1, popular: 3 }, { id: "a3", section: "Technology", kicker: "Slow tech", title: "The internet we left in the drawer", dek: "On forums, feeds and the small websites that quietly still work.", author: "amara", date: "2026-09-25", read: 9, art: 2, popular: 2 }, { id: "a4", section: "Culture", kicker: "Table talk", title: "A defence of the weeknight dinner", dek: "Nobody photographs it. That is exactly the point.", author: "elena", date: "2026-09-24", read: 6, art: 3 }, { id: "a5", section: "Interviews", kicker: "In conversation", title: "“I only write in the margins”", dek: "The novelist Dalia Costa on notebooks, naps and finishing nothing for eleven years.", author: "ines", date: "2026-09-22", read: 18, art: 4, popular: 4 }, { id: "a6", section: "Essays", kicker: "On work", title: "Against the optimised life", dek: "A year without metrics, and what was left when I stopped counting.", author: "amara", date: "2026-09-20", read: 12, art: 5 }, { id: "a7", section: "Science", kicker: "Deep time", title: "The rock that remembers the sea", dek: "A geologist reads a cliff face like a diary.", author: "tomas", date: "2026-09-18", read: 8, art: 0 }, { id: "a8", section: "Culture", kicker: "Listening", title: "What the radio knew", dek: "Why the old weather forecast still feels like poetry.", author: "elena", date: "2026-09-15", read: 7, art: 2 }, { id: "a9", section: "Technology", kicker: "Craft", title: "Write the manual first", dek: "How good software gets made backwards.", author: "amara", date: "2026-09-12", read: 10, art: 1 }, { id: "a10", section: "Essays", kicker: "Small things", title: "In praise of the folding chair", dek: "The most democratic object ever designed.", author: "ines", date: "2026-09-09", read: 5, art: 3 }, ] export type Issue = { n: number; season: string; theme: string; art: number; image?: MediaSource; imageAlt?: string } export const issues: Issue[] = [ { n: 48, season: "Autumn 2026", theme: "Patience", art: 0 }, { n: 47, season: "Summer 2026", theme: "Heat", art: 1 }, { n: 46, season: "Spring 2026", theme: "Return", art: 2 }, { n: 45, season: "Winter 2025", theme: "Quiet", art: 3 }, { n: 44, season: "Autumn 2025", theme: "Maps", art: 4 }, { n: 43, season: "Summer 2025", theme: "Water", art: 5 }, ] export const getAuthor = (id: string) => authors.find((a) => a.id === id) ?? authors[0] const fmt = new Intl.DateTimeFormat("en-US", { month: "long", day: "numeric", year: "numeric", timeZone: "UTC" }) const fmtShort = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" }) export const formatDate = (iso: string) => fmt.format(new Date(iso)) export const formatShort = (iso: string) => fmtShort.format(new Date(iso))components/ballmac/templates/publication/publication-theme.tsx// Ballmac UI: Publication template shell. https://ui.ballmac.com/templates/template-publication "use client" import * as React from "react" import { Menu, Search, X } from "lucide-react" import { pubBody, pubDisplay, pubSans } from "@/components/ballmac/templates/publication/publication-fonts" import { sections } from "@/components/ballmac/templates/publication/publication-data" import { cn } from "@/lib/utils" import { Media, type MediaSource } from "@/components/ballmac/media" type PublicationPage = "home" | "article" | "section" | "author" | "issues" type PublicationHrefs = Record<PublicationPage, string> const defaultHrefs: PublicationHrefs = { home: "/publication", article: "/publication/essays/the-unfinished-city", section: "/publication/essays", author: "/publication/authors/ines", issues: "/publication/issues" } /** Publication's palette: newsprint and ink with a deep red. Dark mode is sepia night, for reading after dark. */ const publicationCss = ` .publication-theme,body:not(:has([data-gallery])):has(.publication-theme){--publication-on-accent:oklch(0.18 0.01 60);--background:oklch(0.975 0.014 85);--foreground:oklch(0.18 0.01 60);--card:oklch(0.988 0.01 85);--card-foreground:oklch(0.18 0.01 60);--popover:oklch(0.988 0.01 85);--popover-foreground:oklch(0.18 0.01 60);--primary:oklch(0.18 0.01 60);--primary-foreground:oklch(0.975 0.014 85);--secondary:oklch(0.945 0.02 85);--secondary-foreground:oklch(0.18 0.01 60);--muted:oklch(0.945 0.02 85);--muted-foreground:oklch(0.45 0.02 70);--accent:oklch(0.93 0.025 80);--accent-foreground:oklch(0.18 0.01 60);--border:oklch(0.18 0.01 60 / 18%);--input:oklch(0.18 0.01 60 / 28%);--ring:oklch(0.45 0.17 28);--surface:oklch(0.958 0.016 85);--destructive:oklch(0.5 0.2 27);--chart-1:oklch(0.45 0.17 28);--chart-2:oklch(0.4 0.07 240);--chart-3:oklch(0.78 0.12 85);--chart-4:oklch(0.46 0.1 150);--chart-5:oklch(0.62 0.11 50);--radius:0.125rem} .dark .publication-theme,.dark body:not(:has([data-gallery])):has(.publication-theme){--publication-on-accent:oklch(0.18 0.01 60);--background:oklch(0.17 0.012 60);--foreground:oklch(0.94 0.02 85);--card:oklch(0.2 0.013 60);--card-foreground:oklch(0.94 0.02 85);--popover:oklch(0.22 0.014 60);--popover-foreground:oklch(0.94 0.02 85);--primary:oklch(0.94 0.02 85);--primary-foreground:oklch(0.18 0.01 60);--secondary:oklch(0.25 0.014 60);--secondary-foreground:oklch(0.94 0.02 85);--muted:oklch(0.24 0.014 60);--muted-foreground:oklch(0.72 0.025 80);--accent:oklch(0.28 0.016 60);--accent-foreground:oklch(0.94 0.02 85);--border:oklch(0.94 0.02 85 / 18%);--input:oklch(0.94 0.02 85 / 28%);--ring:oklch(0.74 0.14 30);--surface:oklch(0.19 0.012 60);--destructive:oklch(0.7 0.18 27);--chart-1:oklch(0.74 0.14 30);--chart-2:oklch(0.72 0.08 240);--chart-3:oklch(0.82 0.11 85);--chart-4:oklch(0.76 0.1 150);--chart-5:oklch(0.76 0.1 55)} body:not(:has([data-gallery])):has(.publication-theme){font-family:var(--pub-sans),ui-sans-serif,system-ui,sans-serif} ` const serif = "[font-family:var(--pub-display),ui-serif,Georgia,serif]" const text = "[font-family:var(--pub-body),ui-serif,Georgia,serif]" /** A duotone illustration in ink and red, standing in for photography. */ function MagArt({ variant, className, image, imageAlt }: { variant: number; className?: string; image?: MediaSource; imageAlt?: string }) { const v = ((variant % 6) + 6) % 6 if (image) return <Media media={image} alt={imageAlt} fill className={cn("aspect-[4/3] w-full", className)} /> return ( <div aria-hidden="true" className={cn("bg-secondary relative isolate aspect-[4/3] w-full overflow-hidden", className)}> {v === 0 && (<><div className="bg-chart-3 absolute inset-0" />{[0, 1, 2, 3].map((i) => <div key={i} className="bg-foreground absolute bottom-0" style={{ left: `${12 + i * 20}%`, width: "14%", height: `${28 + i * 10}%` }} />)}<div className="bg-chart-1 absolute top-[14%] end-[14%] w-[14%] aspect-square rounded-full" /></>)} {v === 1 && (<><div className="bg-chart-2 absolute inset-0" /><div className="bg-card absolute top-[10%] start-[10%] w-[34%] aspect-square rounded-full" />{[0, 1, 2, 3, 4, 5].map((i) => <div key={i} className="bg-chart-1 absolute w-[6%] aspect-square rounded-full" style={{ left: `${44 + (i % 3) * 14}%`, top: `${42 + Math.floor(i / 3) * 18}%` }} />)}</>)} {v === 2 && (<><div className="bg-card absolute inset-0" /><div className="absolute inset-[10%] grid grid-cols-6 gap-[2%]">{Array.from({ length: 24 }, (_, i) => <div key={i} className={cn("aspect-square", i % 7 === 0 ? "bg-chart-1" : i % 3 === 0 ? "bg-foreground" : "bg-secondary")} />)}</div></>)} {v === 3 && (<><div className="bg-chart-5 absolute inset-0" /><div className="bg-card absolute top-1/2 left-1/2 w-[56%] aspect-square -translate-x-1/2 -translate-y-1/2 rounded-full" /><div className="bg-foreground absolute top-1/2 left-1/2 w-[34%] aspect-square -translate-x-1/2 -translate-y-1/2 rounded-full" /><div className="bg-chart-1 absolute top-1/2 left-1/2 w-[12%] aspect-square -translate-x-1/2 -translate-y-1/2 rounded-full" /></>)} {v === 4 && (<><div className="bg-foreground absolute inset-0" />{[0, 1, 2, 3, 4, 5, 6].map((i) => <div key={i} className="bg-chart-3 absolute inset-x-[8%] h-[3px]" style={{ top: `${14 + i * 11}%`, opacity: 1 - i * 0.1 }} />)}<div className="bg-chart-1 absolute top-[22%] start-[22%] h-[40%] w-[34%] rotate-6" /></>)} {v === 5 && (<><div className="bg-chart-4 absolute inset-0" /><div className="bg-card absolute -end-[10%] -bottom-[30%] w-[90%] aspect-square rounded-full" /><div className="bg-foreground absolute top-[16%] start-[12%] h-[8%] w-[50%]" /><div className="bg-foreground absolute top-[30%] start-[12%] h-[8%] w-[34%]" /></>)} </div> ) } const dateline = new Intl.DateTimeFormat("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric", timeZone: "UTC" }).format(new Date("2026-10-01")) type PublicationShellProps = React.ComponentProps<"div"> & { /** The page being shown. */ page: PublicationPage /** The section to mark current in the nav. */ section?: string /** Override where pages live (used by previews). */ hrefs?: Partial<PublicationHrefs> } /** Publication's frame: a dateline, a centred masthead between double rules, a section nav and a newsletter-first footer. */ function PublicationShell({ page, section, hrefs: overrides, className, style, children, ...props }: PublicationShellProps) { const hrefs = { ...defaultHrefs, ...overrides } const [open, setOpen] = React.useState(false) React.useEffect(() => { const classes = [pubDisplay.variable, pubBody.variable, pubSans.variable].filter(Boolean) document.body.classList.add(...classes) return () => document.body.classList.remove(...classes) }, []) const current = section ?? (page === "article" ? "Essays" : undefined) return ( <div data-slot="publication" className={cn("publication-theme bg-background text-foreground relative min-h-dvh overflow-x-clip", pubDisplay.variable, pubBody.variable, pubSans.variable, className)} style={{ fontFamily: "var(--pub-sans), ui-sans-serif, system-ui, sans-serif", ...style }} {...props} > <style>{publicationCss}</style> <header className="bg-background"> <div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-2.5 text-xs sm:px-6"> <p className="text-muted-foreground hidden sm:block">{dateline}</p> <p className="text-muted-foreground font-medium sm:hidden">Issue 48</p> <p className="flex items-center gap-4"><a href={hrefs.issues} className="hover:text-chart-1 font-semibold underline-offset-4 hover:underline">Issue 48 · Autumn 2026</a><a href="#" className="hidden font-semibold underline-offset-4 hover:underline sm:inline">Sign in</a><a href={hrefs.issues} className="bg-chart-1 text-primary-foreground focus-visible:ring-ring/50 rounded-sm px-3 py-1.5 font-bold tracking-wide uppercase outline-none focus-visible:ring-[3px]">Subscribe</a></p> </div> <div className="mx-auto max-w-6xl px-4 sm:px-6"> <div className="border-foreground border-y-4 border-double py-4 text-center"> <a href={hrefs.home} className={cn("focus-visible:ring-ring/50 inline-block rounded text-[clamp(2.6rem,9vw,6.5rem)] leading-none tracking-[-0.02em] outline-none focus-visible:ring-[3px]", serif)}>Marginalia</a> <p className="text-muted-foreground mt-1 text-xs tracking-[0.2em] uppercase">A magazine of ideas, written slowly</p> </div> <div className="flex items-center justify-between border-b py-1"> <nav aria-label="Sections" className="hidden md:block"> <ul className="flex gap-1">{sections.map((s) => <li key={s}><a href={hrefs.section} aria-current={current === s ? "page" : undefined} className="hover:text-chart-1 aria-[current=page]:text-chart-1 focus-visible:ring-ring/50 block rounded px-3.5 py-2.5 text-sm font-semibold tracking-wide uppercase outline-none transition-colors focus-visible:ring-[3px]">{s}</a></li>)}</ul> </nav> <button type="button" aria-label={open ? "Close sections" : "Open sections"} aria-expanded={open} aria-controls="pub-mobile-nav" onClick={() => setOpen((v) => !v)} className="hover:bg-accent focus-visible:ring-ring/50 inline-flex size-10 items-center justify-center rounded outline-none focus-visible:ring-[3px] md:hidden">{open ? <X className="size-5" aria-hidden="true" /> : <Menu className="size-5" aria-hidden="true" />}</button> <button type="button" aria-label="Search" className="hover:bg-accent focus-visible:ring-ring/50 ms-auto inline-flex size-10 items-center justify-center rounded outline-none focus-visible:ring-[3px]"><Search className="size-5" aria-hidden="true" /></button> </div> {open && <nav id="pub-mobile-nav" aria-label="Sections, mobile" className="border-b py-2 md:hidden">{sections.map((s) => <a key={s} href={hrefs.section} className="hover:bg-accent block rounded px-2 py-2.5 text-sm font-semibold tracking-wide uppercase">{s}</a>)}</nav>} </div> </header> {children} <footer className="bg-foreground text-background mt-20"> <div className="mx-auto grid max-w-6xl gap-10 px-4 py-14 sm:px-6 md:grid-cols-[1.4fr_repeat(3,1fr)]"> <div><p className={cn("text-3xl", serif)}>Marginalia</p><p className={cn("mt-3 max-w-xs text-sm opacity-75", text)}>Independent since 2014. Funded by readers, not by clicks.</p></div> {[["Read", ["Essays", "Culture", "Science", "Technology"]], ["Magazine", ["Issues", "Subscribe", "Gift a year", "Back issues"]], ["About", ["Our writers", "Submissions", "Contact", "Privacy"]]].map(([t, items]) => ( <div key={t as string}><h2 className="text-xs font-bold tracking-widest uppercase opacity-60">{t as string}</h2><ul className="mt-4 space-y-2.5">{(items as string[]).map((i) => <li key={i}><a href={i === "Issues" || i === "Subscribe" ? hrefs.issues : i === "Our writers" ? hrefs.author : hrefs.section} className="text-sm opacity-90 hover:underline">{i}</a></li>)}</ul></div> ))} </div> <p className="mx-auto max-w-6xl border-t border-current/20 px-4 py-5 text-xs opacity-60 sm:px-6">© 2026 Marginalia Press. All rights reserved.</p> </footer> </div> ) } export { MagArt, PublicationShell, defaultHrefs as publicationDefaultHrefs, serif as pubSerifClass, text as pubTextClass, type PublicationHrefs, type PublicationPage, type PublicationShellProps }components/ballmac/templates/publication/publication-home.tsx// Ballmac UI: Publication home page. https://ui.ballmac.com/templates/template-publication "use client" import * as React from "react" import { Check } from "lucide-react" import { articles, formatShort, getAuthor, sections } from "@/components/ballmac/templates/publication/publication-data" import { MagArt, PublicationShell, pubSerifClass, pubTextClass, type PublicationHrefs } from "@/components/ballmac/templates/publication/publication-theme" import { cn } from "@/lib/utils" const kicker = "text-chart-1 text-xs font-bold tracking-[0.14em] uppercase" function Newsletter({ tone = "card" }: { tone?: "card" | "ink" }) { const [email, setEmail] = React.useState("") const [error, setError] = React.useState("") const [done, setDone] = React.useState(false) function submit(e: React.FormEvent) { e.preventDefault() if (!/^\S+@\S+\.\S+$/.test(email.trim())) return setError("Enter an email like you@example.com.") setError("") setDone(true) } return ( <section aria-labelledby={`nl-${tone}`} className={cn("border-foreground border-y-4 border-double px-2 py-10 text-center sm:py-14", tone === "ink" && "bg-secondary")}> <p className={kicker}>The Sunday Margin</p> <h2 id={`nl-${tone}`} className={cn("mx-auto mt-3 max-w-xl text-4xl text-balance sm:text-5xl", pubSerifClass)}>One good essay, every Sunday morning.</h2> <p className={cn("text-muted-foreground mx-auto mt-3 max-w-md text-pretty", pubTextClass)}>Free, short and unhurried. Read by 41,000 people over breakfast.</p> {done ? ( <p role="status" className="mt-6 inline-flex items-center gap-2 font-semibold"><Check className="text-chart-4 size-5" aria-hidden="true" />You’re on the list. See you Sunday.</p> ) : ( <form onSubmit={submit} noValidate className="mx-auto mt-6 flex max-w-md flex-col gap-2 sm:flex-row"> <label htmlFor={`nl-email-${tone}`} className="sr-only">Email address</label> <input id={`nl-email-${tone}`} type="email" value={email} onChange={(e) => setEmail(e.target.value)} aria-invalid={!!error} aria-describedby={error ? `nl-err-${tone}` : undefined} placeholder="you@example.com" className="bg-background focus-visible:ring-ring/50 placeholder:text-muted-foreground aria-[invalid=true]:border-destructive h-12 flex-1 rounded-sm border px-4 outline-none focus-visible:ring-[3px]" /> <button type="submit" className="bg-foreground text-background focus-visible:ring-ring/50 h-12 rounded-sm px-6 text-sm font-bold tracking-wide uppercase outline-none transition-opacity hover:opacity-90 focus-visible:ring-[3px]">Sign me up</button> </form> )} {error && <p id={`nl-err-${tone}`} role="alert" className="text-destructive mt-2 text-sm">{error}</p>} </section> ) } type PublicationHomeProps = React.ComponentProps<"div"> & { hrefs?: Partial<PublicationHrefs> } /** The Publication home page: a lead story with a dek, supporting stories, an opinion column, picks and a newsletter. */ function PublicationHome({ hrefs, ...props }: PublicationHomeProps) { const [lead, ...rest] = articles const side = rest.slice(0, 3) const grid = rest.slice(3, 6) const popular = [...articles].filter((a) => a.popular).sort((a, b) => (a.popular ?? 0) - (b.popular ?? 0)) const a = hrefs?.article ?? "/publication/essays/the-unfinished-city" return ( <PublicationShell page="home" hrefs={hrefs} {...props}> <main className="mx-auto max-w-6xl px-4 pt-8 sm:px-6"> <div className="grid gap-8 lg:grid-cols-[1fr_20rem] lg:gap-10"> <article> <a href={a} className="group focus-visible:ring-ring/50 block rounded outline-none focus-visible:ring-[3px]"> <MagArt variant={lead.art} image={lead.image} imageAlt={lead.imageAlt} className="aspect-[16/10]" /> <p className={cn("mt-5", kicker)}>{lead.kicker}</p> <h1 className={cn("mt-2 text-[clamp(2.4rem,6vw,4.5rem)] leading-[1.02] tracking-[-0.01em] text-balance group-hover:underline decoration-1 underline-offset-4", pubSerifClass)}>{lead.title}</h1> <p className={cn("text-muted-foreground mt-4 max-w-2xl text-xl leading-relaxed text-pretty", pubTextClass)}>{lead.dek}</p> <p className="text-muted-foreground mt-4 text-sm">By <span className="text-foreground font-semibold">{getAuthor(lead.author).name}</span> · {lead.read} min read</p> </a> </article> <aside aria-label="More stories" className="lg:border-s lg:ps-10"> <ul className="divide-y"> {side.map((s) => ( <li key={s.id} className="py-5 first:pt-0 lg:last:pb-0"> <a href={a} className="group focus-visible:ring-ring/50 block rounded outline-none focus-visible:ring-[3px]"> <p className={kicker}>{s.section}</p> <h2 className={cn("mt-1.5 text-2xl leading-tight text-balance group-hover:underline underline-offset-4", pubSerifClass)}>{s.title}</h2> <p className="text-muted-foreground mt-2 text-sm">{getAuthor(s.author).name} · {s.read} min</p> </a> </li> ))} </ul> </aside> </div> <section aria-label="Latest" className="mt-12 border-t-2 border-current pt-8"> <ul className="grid gap-x-8 gap-y-10 sm:grid-cols-3"> {grid.map((g) => ( <li key={g.id}> <a href={a} className="group focus-visible:ring-ring/50 block rounded outline-none focus-visible:ring-[3px]"> <MagArt variant={g.art} image={g.image} imageAlt={g.imageAlt} /> <p className={cn("mt-4", kicker)}>{g.section}</p> <h3 className={cn("mt-1.5 text-2xl leading-tight text-balance group-hover:underline underline-offset-4", pubSerifClass)}>{g.title}</h3> <p className={cn("text-muted-foreground mt-2 text-pretty", pubTextClass)}>{g.dek}</p> <p className="text-muted-foreground mt-3 text-sm">{getAuthor(g.author).name} · {formatShort(g.date)}</p> </a> </li> ))} </ul> </section> <div className="mt-16 grid gap-12 lg:grid-cols-[1fr_20rem]"> <section aria-labelledby="pub-opinion"> <h2 id="pub-opinion" className="border-foreground border-b-2 pb-2 text-sm font-bold tracking-[0.14em] uppercase">Opinion & essays</h2> <ul className="divide-y"> {articles.filter((x) => x.section === "Essays" || x.section === "Interviews").slice(1, 5).map((o) => { const au = getAuthor(o.author) return ( <li key={o.id} className="grid grid-cols-[3.5rem_1fr] gap-4 py-6"> <span className={cn("flex size-14 items-center justify-center rounded-full text-lg", pubSerifClass, ["bg-chart-3 text-[var(--publication-on-accent)]", "bg-chart-5 text-[var(--publication-on-accent)]", "bg-chart-4 text-background", "bg-secondary text-secondary-foreground"][au.tone - 1])} aria-hidden="true">{au.name.split(" ").map((w) => w[0]).join("")}</span> <a href={a} className="group focus-visible:ring-ring/50 rounded outline-none focus-visible:ring-[3px]"> <p className="text-muted-foreground text-xs font-semibold tracking-wide uppercase">{au.name}</p> <h3 className={cn("mt-1 text-2xl leading-tight text-balance group-hover:underline underline-offset-4", pubSerifClass)}>{o.title}</h3> <p className={cn("text-muted-foreground mt-1.5 text-pretty", pubTextClass)}>{o.dek}</p> </a> </li> ) })} </ul> </section> <aside aria-labelledby="pub-popular" className="bg-surface h-fit border p-6"> <h2 id="pub-popular" className="text-sm font-bold tracking-[0.14em] uppercase">Most read</h2> <ol className="mt-4 space-y-5"> {popular.map((p, i) => ( <li key={p.id} className="grid grid-cols-[2rem_1fr] gap-2"> <span className={cn("text-chart-1 text-4xl leading-none", pubSerifClass)} aria-hidden="true">{i + 1}</span> <a href={a} className={cn("hover:underline text-lg leading-snug text-balance underline-offset-4", pubSerifClass)}><span className="sr-only">{i + 1}. </span>{p.title}</a> </li> ))} </ol> </aside> </div> <div className="mt-16"><Newsletter /></div> <ul aria-label="Browse by section" className="mt-10 flex flex-wrap justify-center gap-2">{sections.map((s) => <li key={s}><a href={hrefs?.section ?? "/publication/essays"} className="hover:bg-accent focus-visible:ring-ring/50 inline-flex h-10 items-center rounded-full border px-5 text-sm font-semibold outline-none transition-colors focus-visible:ring-[3px]">{s}</a></li>)}</ul> </main> </PublicationShell> ) } export { Newsletter, PublicationHome, type PublicationHomeProps }components/ballmac/templates/publication/publication-article.tsx// Ballmac UI: Publication article page. https://ui.ballmac.com/templates/template-publication "use client" import * as React from "react" import { Link2 } from "lucide-react" import { CopyButton } from "@/components/ballmac/copy-button" import { ScrollProgress } from "@/components/ballmac/scroll-progress" import { articles, formatDate, getAuthor } from "@/components/ballmac/templates/publication/publication-data" import { Newsletter } from "@/components/ballmac/templates/publication/publication-home" import { MagArt, PublicationShell, pubSerifClass, pubTextClass, type PublicationHrefs } from "@/components/ballmac/templates/publication/publication-theme" import { cn } from "@/lib/utils" /** A paragraph with an optional margin note: beside the text on wide screens, under it on small ones. */ function Para({ children, note, n }: { children: React.ReactNode; note?: string; n?: number }) { return ( <div className="relative"> <p className={cn("text-[1.1875rem] leading-[1.8] text-pretty", pubTextClass)}> {children} {n && <sup className="ms-0.5"><a href={`#note-${n}`} id={`ref-${n}`} className="text-chart-1 focus-visible:ring-ring/50 rounded px-0.5 font-sans text-xs font-bold outline-none focus-visible:ring-[3px]" aria-label={`Note ${n}`}>{n}</a></sup>} </p> {note && n && ( <aside id={`note-${n}`} aria-label={`Note ${n}`} className="text-muted-foreground border-chart-1 mt-3 border-s-2 ps-4 text-sm leading-relaxed xl:absolute xl:top-0 xl:start-full xl:mt-0 xl:ms-10 xl:w-56 xl:border-s-0 xl:ps-0"> <span className="text-chart-1 me-1.5 font-bold">{n}</span>{note} </aside> )} </div> ) } type PublicationArticleProps = React.ComponentProps<"div"> & { hrefs?: Partial<PublicationHrefs> } /** The Publication article page: a reading-progress bar, drop cap, margin notes that sit beside the text on wide screens, a pull quote and an author card. */ function PublicationArticle({ hrefs, ...props }: PublicationArticleProps) { const a = articles[0] const au = getAuthor(a.author) const related = articles.slice(1, 4) return ( <PublicationShell page="article" section="Essays" hrefs={hrefs} {...props}> <ScrollProgress position="top" className="[&>*]:bg-chart-1" /> <main> <header className="mx-auto max-w-3xl px-4 pt-12 text-center sm:px-6"> <p className="text-chart-1 text-xs font-bold tracking-[0.14em] uppercase">{a.section} · {a.kicker}</p> <h1 className={cn("mt-4 text-[clamp(2.6rem,6.4vw,4.8rem)] leading-[1.02] tracking-[-0.01em] text-balance", pubSerifClass)}>{a.title}</h1> <p className={cn("text-muted-foreground mt-5 text-xl leading-relaxed text-pretty", pubTextClass)}>{a.dek}</p> <div className="mt-7 flex items-center justify-center gap-3 text-sm"> <span className={cn("bg-chart-3 text-[var(--publication-on-accent)] flex size-11 items-center justify-center rounded-full text-base", pubSerifClass)} aria-hidden="true">IM</span> <p className="text-start"><span className="block font-semibold">By {au.name}</span><span className="text-muted-foreground">{formatDate(a.date)} · {a.read} min read</span></p> </div> </header> <div className="mx-auto mt-10 max-w-5xl px-4 sm:px-6"><MagArt variant={a.art} image={a.image} imageAlt={a.imageAlt} className="aspect-[16/8]" /><p className="text-muted-foreground mt-2 text-xs">Scaffolding on Rua da Madalena, Lisbon. Illustration.</p></div> <article className="mx-auto mt-12 max-w-[40rem] space-y-7 px-4 sm:px-6"> <div className="relative"> <p className={cn("first-letter:float-start first-letter:me-3 first-letter:text-[5.2rem] first-letter:leading-[0.8] first-letter:font-bold first-letter:[font-family:var(--pub-display)] text-[1.1875rem] leading-[1.8] text-pretty", pubTextClass)}> There is a street near my flat that has been covered in scaffolding for as long as I have lived here. The poles have weathered, a pigeon has opinions about the third floor, and the building behind it appears to be neither getting worse nor better. I used to find it depressing.<sup className="ms-0.5"><a href="#note-1" id="ref-1" className="text-chart-1 focus-visible:ring-ring/50 rounded px-0.5 font-sans text-xs font-bold outline-none focus-visible:ring-[3px]" aria-label="Note 1">1</a></sup> </p> <aside id="note-1" aria-label="Note 1" className="text-muted-foreground border-chart-1 mt-3 border-s-2 ps-4 text-sm leading-relaxed xl:absolute xl:top-0 xl:start-full xl:mt-0 xl:ms-10 xl:w-56 xl:border-s-0 xl:ps-0"><span className="text-chart-1 me-1.5 font-bold">1</span>The building is a 1920s tenement. The scaffold went up in 2019 after a cornice fell on a parked car.</aside> </div> <Para n={2} note="Jane Jacobs made this point in 1961. Cities, she wrote, need old buildings, and a great many of them, mixed in with the new.">Then I started to notice how many of the places I love are unfinished in exactly this way: the cathedral with a crane, the square half dug up, the market that is always about to be renovated. A city that is finished is a museum.</Para> <h2 className={cn("pt-4 text-3xl leading-tight text-balance", pubSerifClass)}>What patience looks like in stone</h2> <Para>Stonemasons talk about work they will never see completed. The medieval builders of a cathedral knew they would die before the spire went up, and they built the foundations for a roof they would never stand under. We have mostly lost that habit of mind.</Para> <blockquote className="border-foreground my-10 border-y-4 border-double py-8 text-center"><p className={cn("text-[clamp(1.7rem,3.6vw,2.4rem)] leading-tight text-balance", pubSerifClass)}>“A city that is finished is a museum.”</p></blockquote> <Para n={3} note="The scaffold on the Rua da Madalena has a name among neighbours: ‘o esqueleto’. The skeleton.">I asked the neighbours what they thought. Nobody wanted it gone. They had named it, mapped their bicycles to its poles and learned which floor had the best light. The scaffold had become part of the street’s grammar.</Para> <Para>None of this is an argument for neglect. It is an argument for noticing that the stage between the old and the new is not a failure, and for learning to live there gracefully, perhaps even with affection.</Para> </article> <div className="mx-auto mt-14 flex max-w-[40rem] flex-wrap items-center justify-between gap-4 border-y px-4 py-4 sm:px-6"> <p className="text-muted-foreground text-sm">Filed under <a href={hrefs?.section ?? "/publication/essays"} className="text-foreground font-semibold underline-offset-4 hover:underline">Essays</a></p> <div className="flex items-center gap-2"><span className="text-sm font-semibold">Share</span><CopyButton value="https://marginalia.example/essays/the-unfinished-city" ariaLabel="Copy link to this article" /></div> </div> <aside aria-labelledby="pub-author" className="mx-auto mt-10 flex max-w-[40rem] gap-5 px-4 sm:px-6"> <span className={cn("bg-chart-3 text-[var(--publication-on-accent)] flex size-16 shrink-0 items-center justify-center rounded-full text-2xl", pubSerifClass)} aria-hidden="true">IM</span> <div><h2 id="pub-author" className="font-semibold">About {au.name}</h2><p className={cn("text-muted-foreground mt-1 text-pretty", pubTextClass)}>{au.bio}</p><a href={hrefs?.author ?? "/publication/authors/ines"} className="text-chart-1 mt-2 inline-flex items-center gap-1.5 text-sm font-semibold underline-offset-4 hover:underline"><Link2 className="size-4" aria-hidden="true" />More by {au.name.split(" ")[0]}</a></div> </aside> <section aria-labelledby="pub-related" className="mx-auto mt-20 max-w-6xl px-4 sm:px-6"> <h2 id="pub-related" className="border-foreground border-b-2 pb-2 text-sm font-bold tracking-[0.14em] uppercase">Keep reading</h2> <ul className="mt-8 grid gap-8 sm:grid-cols-3">{related.map((r) => <li key={r.id}><a href={hrefs?.article ?? "#"} className="group focus-visible:ring-ring/50 block rounded outline-none focus-visible:ring-[3px]"><MagArt variant={r.art} image={r.image} imageAlt={r.imageAlt} /><h3 className={cn("mt-4 text-2xl leading-tight text-balance group-hover:underline underline-offset-4", pubSerifClass)}>{r.title}</h3><p className="text-muted-foreground mt-2 text-sm">{getAuthor(r.author).name} · {r.read} min</p></a></li>)}</ul> </section> <div className="mx-auto mt-16 max-w-6xl px-4 sm:px-6"><Newsletter tone="ink" /></div> </main> </PublicationShell> ) } export { PublicationArticle, type PublicationArticleProps }components/ballmac/templates/publication/publication-section.tsx// Ballmac UI: Publication section page. https://ui.ballmac.com/templates/template-publication "use client" import * as React from "react" import { articles, formatShort, getAuthor, sections, type Section } from "@/components/ballmac/templates/publication/publication-data" import { MagArt, PublicationShell, pubSerifClass, pubTextClass, type PublicationHrefs } from "@/components/ballmac/templates/publication/publication-theme" import { cn } from "@/lib/utils" const descriptions: Record<string, string> = { Essays: "Long arguments, patiently made. New essays every week.", Culture: "Food, music, books and the small rituals of daily life.", Science: "Field work, deep time and the people who count things.", Technology: "Slow technology: tools, craft and the people who make them.", Interviews: "Unhurried conversations with writers and makers." } type PublicationSectionProps = React.ComponentProps<"div"> & { hrefs?: Partial<PublicationHrefs>; initial?: Section | "All" } /** A Publication section page: a topic switch, a sort, a story list with thumbnails and a "load more" that adds stories. */ function PublicationSection({ hrefs, initial = "Essays", ...props }: PublicationSectionProps) { const [section, setSection] = React.useState<Section | "All">(initial) const [sort, setSort] = React.useState<"new" | "long">("new") const [count, setCount] = React.useState(4) const list = articles.filter((a) => section === "All" || a.section === section).sort((a, b) => (sort === "new" ? b.date.localeCompare(a.date) : b.read - a.read)) const shown = list.slice(0, count) const a = hrefs?.article ?? "/publication/essays/the-unfinished-city" return ( <PublicationShell page="section" section={section === "All" ? undefined : section} hrefs={hrefs} {...props}> <main className="mx-auto max-w-4xl px-4 pt-10 sm:px-6"> <p className="text-chart-1 text-xs font-bold tracking-[0.14em] uppercase">Section</p> <h1 className={cn("mt-2 text-[clamp(3rem,8vw,5.5rem)] leading-none", pubSerifClass)}>{section === "All" ? "Everything" : section}</h1> <p className={cn("text-muted-foreground mt-4 max-w-xl text-xl text-pretty", pubTextClass)}>{section === "All" ? "Every story, newest first." : descriptions[section]}</p> <div className="mt-8 flex flex-wrap items-center gap-3 border-y py-3"> <div role="group" aria-label="Choose a section" className="flex flex-wrap gap-1.5"> {(["All", ...sections] as const).map((s) => <button key={s} type="button" aria-pressed={section === s} onClick={() => { setSection(s); setCount(4) }} className={cn("focus-visible:ring-ring/50 h-9 rounded-full border px-4 text-sm font-semibold outline-none transition-colors focus-visible:ring-[3px]", section === s ? "bg-foreground text-background border-transparent" : "hover:bg-accent")}>{s}</button>)} </div> <label className="text-muted-foreground ms-auto flex items-center gap-2 text-sm">Sort <select value={sort} onChange={(e) => setSort(e.target.value as "new" | "long")} className="bg-background focus-visible:ring-ring/50 text-foreground h-9 rounded border px-2 outline-none focus-visible:ring-[3px]"><option value="new">Newest</option><option value="long">Longest</option></select></label> </div> <p className="sr-only" role="status">Showing {shown.length} of {list.length} stories</p> {shown.length === 0 && <p className="text-muted-foreground mt-12 border border-dashed p-12 text-center">Nothing in this section yet.</p>} <ul className="divide-y"> {shown.map((s) => ( <li key={s.id}> <a href={a} className="group focus-visible:ring-ring/50 grid gap-5 rounded py-8 outline-none focus-visible:ring-[3px] sm:grid-cols-[1fr_14rem]"> <div> <p className="text-chart-1 text-xs font-bold tracking-[0.14em] uppercase">{s.section} · {s.kicker}</p> <h2 className={cn("mt-2 text-3xl leading-tight text-balance group-hover:underline underline-offset-4 sm:text-4xl", pubSerifClass)}>{s.title}</h2> <p className={cn("text-muted-foreground mt-3 text-pretty", pubTextClass)}>{s.dek}</p> <p className="text-muted-foreground mt-4 text-sm">By <span className="text-foreground font-semibold">{getAuthor(s.author).name}</span> · {formatShort(s.date)} · {s.read} min</p> </div> <MagArt variant={s.art} image={s.image} imageAlt={s.imageAlt} className="order-first sm:order-none" /> </a> </li> ))} </ul> {count < list.length && <div className="mt-6 text-center"><button type="button" onClick={() => setCount((c) => c + 4)} className="hover:bg-accent focus-visible:ring-ring/50 h-12 rounded-sm border-2 border-current px-8 text-sm font-bold tracking-wide uppercase outline-none transition-colors focus-visible:ring-[3px]">Load more stories</button></div>} </main> </PublicationShell> ) } export { PublicationSection, type PublicationSectionProps }components/ballmac/templates/publication/publication-author.tsx// Ballmac UI: Publication author page. https://ui.ballmac.com/templates/template-publication "use client" import * as React from "react" import { articles, authors, formatShort } from "@/components/ballmac/templates/publication/publication-data" import { MagArt, PublicationShell, pubSerifClass, pubTextClass, type PublicationHrefs } from "@/components/ballmac/templates/publication/publication-theme" import { cn } from "@/lib/utils" type PublicationAuthorProps = React.ComponentProps<"div"> & { hrefs?: Partial<PublicationHrefs>; authorId?: string } /** A Publication author page: portrait initials, a bio, the byline count and every story they wrote. */ function PublicationAuthor({ hrefs, authorId = "ines", ...props }: PublicationAuthorProps) { const au = authors.find((x) => x.id === authorId) ?? authors[0] const mine = articles.filter((a) => a.author === au.id) const total = mine.reduce((n, a) => n + a.read, 0) const a = hrefs?.article ?? "/publication/essays/the-unfinished-city" return ( <PublicationShell page="author" hrefs={hrefs} {...props}> <main className="mx-auto max-w-4xl px-4 pt-12 sm:px-6"> <header className="grid gap-8 border-b-4 border-double border-foreground pb-10 sm:grid-cols-[10rem_1fr] sm:items-center"> <span className={cn("flex size-36 items-center justify-center rounded-full text-6xl sm:size-40", pubSerifClass, ["bg-chart-3 text-[var(--publication-on-accent)]", "bg-chart-5 text-[var(--publication-on-accent)]", "bg-chart-4 text-background", "bg-secondary text-secondary-foreground"][au.tone - 1])} aria-hidden="true">{au.name.split(" ").map((w) => w[0]).join("")}</span> <div> <p className="text-chart-1 text-xs font-bold tracking-[0.14em] uppercase">{au.role}</p> <h1 className={cn("mt-2 text-[clamp(2.8rem,7vw,4.8rem)] leading-none", pubSerifClass)}>{au.name}</h1> <p className={cn("text-muted-foreground mt-4 max-w-xl text-xl leading-relaxed text-pretty", pubTextClass)}>{au.bio}</p> <dl className="mt-5 flex gap-8 text-sm"><div><dd className="text-2xl font-bold tabular-nums">{mine.length}</dd><dt className="text-muted-foreground">stories</dt></div><div><dd className="text-2xl font-bold tabular-nums">{total}</dd><dt className="text-muted-foreground">minutes of reading</dt></div></dl> </div> </header> <section aria-labelledby="pub-by" className="mt-10"> <h2 id="pub-by" className="text-sm font-bold tracking-[0.14em] uppercase">Stories by {au.name.split(" ")[0]}</h2> <ul className="divide-y"> {mine.map((s) => ( <li key={s.id}><a href={a} className="group focus-visible:ring-ring/50 grid gap-5 rounded py-7 outline-none focus-visible:ring-[3px] sm:grid-cols-[1fr_12rem]"><div><p className="text-chart-1 text-xs font-bold tracking-[0.14em] uppercase">{s.section} · {formatShort(s.date)}</p><h3 className={cn("mt-2 text-3xl leading-tight text-balance group-hover:underline underline-offset-4", pubSerifClass)}>{s.title}</h3><p className={cn("text-muted-foreground mt-2 text-pretty", pubTextClass)}>{s.dek}</p></div><MagArt variant={s.art} image={s.image} imageAlt={s.imageAlt} className="order-first sm:order-none" /></a></li> ))} </ul> </section> </main> </PublicationShell> ) } export { PublicationAuthor, type PublicationAuthorProps }components/ballmac/templates/publication/publication-issues.tsx// Ballmac UI: Publication issues page. https://ui.ballmac.com/templates/template-publication "use client" import * as React from "react" import { Check } from "lucide-react" import { issues } from "@/components/ballmac/templates/publication/publication-data" import { Newsletter } from "@/components/ballmac/templates/publication/publication-home" import { MagArt, PublicationShell, pubSerifClass, pubTextClass, type PublicationHrefs } from "@/components/ballmac/templates/publication/publication-theme" import { cn } from "@/lib/utils" const plans = [ { id: "digital", name: "Digital", price: 4, per: "a month", features: ["Every story and archive", "Four issues a year as PDF and ePub", "The Sunday Margin newsletter"] }, { id: "print", name: "Print & digital", price: 9, per: "a month", features: ["Everything in Digital", "Four printed issues, posted free worldwide", "Back-issue discount of 30%"], featured: true }, ] type PublicationIssuesProps = React.ComponentProps<"div"> & { hrefs?: Partial<PublicationHrefs> } /** The Publication issues page: a shelf of covers by season, two subscription plans with monthly or yearly billing, and the newsletter. */ function PublicationIssues({ hrefs, ...props }: PublicationIssuesProps) { const [yearly, setYearly] = React.useState(true) return ( <PublicationShell page="issues" hrefs={hrefs} {...props}> <main className="mx-auto max-w-6xl px-4 pt-12 sm:px-6"> <p className="text-chart-1 text-xs font-bold tracking-[0.14em] uppercase">The magazine</p> <h1 className={cn("mt-2 text-[clamp(3rem,8vw,6rem)] leading-none", pubSerifClass)}>Issues</h1> <p className={cn("text-muted-foreground mt-4 max-w-xl text-xl text-pretty", pubTextClass)}>Four times a year, one theme, 160 pages. Printed on uncoated paper and meant to be kept.</p> <section aria-label="Back issues" className="mt-12"> <ul className="grid grid-cols-2 gap-x-5 gap-y-10 sm:grid-cols-3 lg:grid-cols-6"> {issues.map((i) => ( <li key={i.n}> <a href="#" className="group focus-visible:ring-ring/50 block rounded outline-none focus-visible:ring-[3px]"> <div className="relative shadow-[6px_6px_0_0_var(--border)] transition-transform duration-300 group-hover:-translate-y-1 motion-reduce:transition-none"> <MagArt variant={i.art} image={i.image} imageAlt={i.imageAlt} className="aspect-[3/4]" /> <div className="bg-card/95 absolute inset-x-0 bottom-0 border-t p-3"><p className={cn("text-xl leading-none", pubSerifClass)}>{i.theme}</p><p className="text-muted-foreground mt-1 text-[11px] tracking-wide uppercase">No. {i.n}</p></div> </div> <p className="text-muted-foreground mt-3 text-sm">{i.season}</p> </a> </li> ))} </ul> </section> <section aria-labelledby="pub-sub" className="mt-24"> <div className="text-center"> <h2 id="pub-sub" className={cn("text-4xl sm:text-5xl", pubSerifClass)}>Read without the noise</h2> <div role="group" aria-label="Billing" className="mt-6 inline-flex rounded-full border p-1"> {([[true, "Yearly · 2 months free"], [false, "Monthly"]] as const).map(([v, l]) => <button key={l} type="button" aria-pressed={yearly === v} onClick={() => setYearly(v)} className={cn("focus-visible:ring-ring/50 h-10 rounded-full px-5 text-sm font-semibold outline-none transition-colors focus-visible:ring-[3px]", yearly === v ? "bg-foreground text-background" : "text-muted-foreground")}>{l}</button>)} </div> </div> <div className="mx-auto mt-10 grid max-w-3xl gap-5 md:grid-cols-2"> {plans.map((p) => { const price = yearly ? Math.round(p.price * 10) : p.price return ( <article key={p.id} className={cn("flex flex-col border-2 p-8", "featured" in p && p.featured ? "border-chart-1 bg-card" : "border-foreground")}> <h3 className={cn("text-3xl", pubSerifClass)}>{p.name}</h3> <p className="mt-4 flex items-baseline gap-2"><span className={cn("text-6xl tabular-nums", pubSerifClass)}>${price}</span><span className="text-muted-foreground text-sm">{yearly ? "a year" : p.per}</span></p> <ul className="mt-6 flex-1 space-y-3">{p.features.map((f) => <li key={f} className={cn("flex items-start gap-3 text-pretty", pubTextClass)}><Check className="text-chart-4 mt-1.5 size-4 shrink-0" aria-hidden="true" />{f}</li>)}</ul> <a href="#" className={cn("focus-visible:ring-ring/50 mt-8 inline-flex h-12 items-center justify-center rounded-sm text-sm font-bold tracking-wide uppercase outline-none transition-opacity hover:opacity-90 focus-visible:ring-[3px]", "featured" in p && p.featured ? "bg-chart-1 text-primary-foreground" : "bg-foreground text-background")}>Subscribe</a> </article> ) })} </div> </section> <div className="mt-20"><Newsletter /></div> </main> </PublicationShell> ) } export { PublicationIssues, type PublicationIssuesProps }app/publication/page.tsx// Ballmac UI: Publication template route. https://ui.ballmac.com/templates/template-publication import type { Metadata } from "next" import { PublicationHome } from "@/components/ballmac/templates/publication/publication-home" export const metadata: Metadata = { title: "Marginalia: a magazine of ideas, written slowly", description: "Essays, culture, science and technology, written slowly.", } export default function Page() { return <PublicationHome /> }app/publication/essays/[slug]/page.tsx// Ballmac UI: Publication template route. https://ui.ballmac.com/templates/template-publication import type { Metadata } from "next" import { PublicationArticle } from "@/components/ballmac/templates/publication/publication-article" export const metadata: Metadata = { title: "The case for the unfinished city · Marginalia", description: "What a scaffold-covered street teaches us about patience.", } // One layout for every article. Load the story by params.slug from your data when you add more. export default function Page() { return <PublicationArticle /> }app/publication/essays/page.tsx// Ballmac UI: Publication template route. https://ui.ballmac.com/templates/template-publication import type { Metadata } from "next" import { PublicationSection } from "@/components/ballmac/templates/publication/publication-section" export const metadata: Metadata = { title: "Essays · Marginalia", description: "Long arguments, patiently made.", } export default function Page() { return <PublicationSection /> }app/publication/authors/[id]/page.tsx// Ballmac UI: Publication template route. https://ui.ballmac.com/templates/template-publication import type { Metadata } from "next" import { PublicationAuthor } from "@/components/ballmac/templates/publication/publication-author" export const metadata: Metadata = { title: "Authors · Marginalia", description: "Writers at Marginalia.", } export default async function Page({ params }: { params: Promise<{ id: string }> }) { const { id } = await params return <PublicationAuthor authorId={id} /> }app/publication/issues/page.tsx// Ballmac UI: Publication template route. https://ui.ballmac.com/templates/template-publication import type { Metadata } from "next" import { PublicationIssues } from "@/components/ballmac/templates/publication/publication-issues" export const metadata: Metadata = { title: "Issues · Marginalia", description: "Four times a year, one theme, 160 pages.", } export default function Page() { return <PublicationIssues /> }Update the import paths to match your project setup.
The page is added as a route in your app, with every block it uses.
What's inside
- Pages
- Home/publication
- Article/publication/essays/the-unfinished-city
- Section/publication/essays
- Author/publication/authors/ines
- Issues/publication/issues
- Fonts
- DM Serif Display, Lora, Public Sans (Google Fonts, loaded with next/font)
- Files
- 13 files · 3 Ballmac blocks and components
Built with
Dependencies
- npm
- lucide-react
Use with AI
Installs a five-page magazine. Change stories and authors in publication-data.ts, the palette in publicationCss, and the duotone MagArt illustrations (or swap them for photographs). With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Marginalia: magazine (@ballmac/template-publication) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Magazines, long-form blogs and newsletters with an archive
- Reading-first sites with margin notes and a strong masthead
Not for
- A product blog inside a SaaS marketing site (use blog-1 and blog-post-1)
Registry JSON: https://ui.ballmac.com/r/template-publication.json
Free to use in personal and commercial projects.