Commit 12387c68 by PLN (Algolia)

blog: article page anatomy — TOC rail, margin notes, code bars, lightbox, pager

parent 4fa6d331
'use client';
import { useEffect, useRef } from 'react';
/**
* Progressive-enhancement island for the article body. The page reads
* completely without it — every job here decorates DOM the static HTML
* already shipped, and degrades to plain content when JS is off:
*
* 1. Code blocks: a language label + copy button above every shiki <pre>.
* No-JS readers get the plain pre; the button simply never exists.
* 2. Figures: click-to-zoom lightbox on a native <dialog> (Esc and the
* focus trap come free; click anywhere closes). No-JS readers see the
* inline image.
* 3. Language memory: stores the last switcher choice and flags the
* reader's usual language on later posts — a nudge, never an
* auto-redirect: the reader always gets the page they asked for.
*
* Rendered as an invisible anchor inside <article>; it scopes all its DOM
* work to the closest article so unrelated pages stay untouched.
*/
export default function ArticleEnhance({ lang, languages }) {
const anchorRef = useRef(null);
useEffect(() => {
const article = anchorRef.current?.closest('article');
if (!article) return undefined;
const cleanups = [];
// --- code blocks: label + copy -----------------------------------
const copyLabel = lang === 'en' ? 'Copy' : 'Copier';
const copiedLabel = lang === 'en' ? 'Copied ✓' : 'Copié ✓';
article.querySelectorAll('pre[data-language]').forEach((pre) => {
if (pre.dataset.codebar) return;
pre.dataset.codebar = 'true';
const holder = pre.closest('[data-rehype-pretty-code-figure]') ?? pre.parentElement;
if (!holder) return;
const bar = document.createElement('div');
bar.className = 'prose-codebar';
const label = document.createElement('span');
label.className = 'prose-codelang';
label.textContent = pre.dataset.language || '';
const button = document.createElement('button');
button.type = 'button';
button.className = 'prose-copy';
button.textContent = copyLabel;
button.setAttribute('aria-label', `${copyLabel} (${pre.dataset.language})`);
button.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(pre.innerText);
button.textContent = copiedLabel;
} catch {
return; // clipboard refused — leave the button as it was
}
setTimeout(() => {
button.textContent = copyLabel;
}, 1600);
});
bar.append(label, button);
holder.prepend(bar);
cleanups.push(() => bar.remove());
});
// --- figures: lightbox --------------------------------------------
const dialog = document.createElement('dialog');
dialog.className = 'prose-lightbox';
dialog.addEventListener('click', () => dialog.close());
article.append(dialog);
cleanups.push(() => dialog.remove());
article.querySelectorAll('.prose-figure img').forEach((img) => {
const open = () => {
dialog.replaceChildren(img.cloneNode(true));
dialog.showModal();
};
img.addEventListener('click', open);
cleanups.push(() => img.removeEventListener('click', open));
});
// --- language switch memory ---------------------------------------
let stored = null;
try {
stored = window.localStorage.getItem('pln:blog-lang');
} catch {
// storage refused (private mode) — the switcher still works, unaided
}
article.querySelectorAll('[data-lang-switch] a[data-lang]').forEach((link) => {
const target = link.dataset.lang;
const remember = () => {
try {
window.localStorage.setItem('pln:blog-lang', target);
} catch {
// as above — best effort
}
};
link.addEventListener('click', remember);
cleanups.push(() => link.removeEventListener('click', remember));
if (stored && target === stored && languages?.includes(stored)) {
link.classList.add('langPreferred');
link.title = lang === 'en' ? 'Your usual language' : 'Votre langue habituelle';
}
});
return () => cleanups.forEach((fn) => fn());
}, [lang, languages]);
return <div ref={anchorRef} hidden aria-hidden="true" />;
}
import Link from "next/link"; import Link from "next/link";
import Layout from "@/components/layout"; import Layout from "@/components/layout";
import Date from "@/components/date"; import Date from "@/components/date";
import utilStyles from "@/styles/utils.module.css";
import styles from "./blog.module.css"; import styles from "./blog.module.css";
import { syne, serifBody } from "@/app/blog/article-fonts";
import { sansBody } from "@/app/blog/dev-fonts";
import { getBlogNeighbors } from "@/lib/blog";
import ArticleEnhance from "./ArticleEnhance";
import FontSwitcher from "./FontSwitcher";
/** /**
* The article markup shared by /blog/[slug], /blog/[slug]/[lang] and the * The article markup shared by /blog/[slug], /blog/[slug]/[lang] and the
...@@ -11,27 +15,81 @@ import styles from "./blog.module.css"; ...@@ -11,27 +15,81 @@ import styles from "./blog.module.css";
* *
* `switcher` / `banner` come from buildLanguageNav (language-nav.js) on the * `switcher` / `banner` come from buildLanguageNav (language-nav.js) on the
* public routes; the draft route passes neither (it ships exactly as before). * public routes; the draft route passes neither (it ships exactly as before).
* `post.toc` is minted at build time by the markdown chain; the pager is
* computed from the visible listing (drafts follow the prod/dev rule).
*
* Anatomy notes:
* - Layout is a grid of [TOC rail][core][margin band] at ≥80rem; below
* that it collapses to a single centered column (phone-first).
* - The TOC only ships for posts with ≥3 h2/h3; otherwise nothing.
* - Footnote margin clones come from the markdown chain; CSS decides
* whether they are margin notes, popovers or nothing (see blog.module.css).
* - Zero client JS is required to read: ArticleEnhance decorates code
* blocks, figures and the language switch; FontSwitcher is dev-only.
*/ */
const LABELS = {
fr: { toc: "Sommaire", pager: "Articles", older: "Plus ancien", newer: "Plus récent" },
en: { toc: "Contents", pager: "Posts", older: "Older", newer: "Newer" },
};
function TocList({ toc }) {
return (
<ol className={styles.tocList}>
{toc.map((entry) => (
<li key={entry.id} data-depth={entry.depth}>
<a href={`#${entry.id}`}>{entry.text}</a>
</li>
))}
</ol>
);
}
export default function PostArticle({ post, switcher, banner }) { export default function PostArticle({ post, switcher, banner }) {
const toc = post.toc ?? [];
const hasToc = toc.length >= 3;
const labels = LABELS[post.lang] ?? LABELS.fr;
const { older, newer } = getBlogNeighbors(post.slug, post.lang);
return ( return (
<Layout> <Layout>
<article className={styles.article}> <div className={styles.progress} aria-hidden="true" />
{/* next/font CSS variables: brand display face + default body serif.
The dev-only sans variable joins them when dev-fonts.js is real
(it is stubbed to null in production builds). */}
<article
className={[styles.article, syne.variable, serifBody.variable, sansBody?.variable]
.filter(Boolean)
.join(" ")}
>
<div className={styles.shell}>
{hasToc && (
<nav className={styles.tocRail} aria-label={labels.toc}>
<div className={styles.tocSticky}>
<p className={styles.tocTitle}>{labels.toc}</p>
<TocList toc={toc} />
</div>
</nav>
)}
<div className={styles.core}>
{switcher && switcher.length > 1 && ( {switcher && switcher.length > 1 && (
<nav className={styles.langSwitch} aria-label="Language"> <nav className={styles.langSwitch} aria-label="Langue / Language" data-lang-switch>
{switcher.map((item, index) => ( {switcher.map((item, index) => (
<span key={item.lang}> <span key={item.lang}>
{index > 0 && " / "} {index > 0 && " / "}
{item.lang === post.lang ? ( {item.lang === post.lang ? (
<b>{item.lang.toUpperCase()}</b> <b data-lang={item.lang}>{item.lang.toUpperCase()}</b>
) : ( ) : (
<Link href={item.href}>{item.lang.toUpperCase()}</Link> <Link href={item.href} data-lang={item.lang}>
{item.lang.toUpperCase()}
</Link>
)} )}
</span> </span>
))} ))}
</nav> </nav>
)} )}
<h1 className={utilStyles.headingXl}>{post.title}</h1> <h1 className={styles.title}>{post.title}</h1>
<div className={`${styles.meta} ${utilStyles.lightText}`}> <div className={styles.meta}>
<Date dateString={post.date} /> <Date dateString={post.date} />
{post.tags && post.tags.length > 0 && ( {post.tags && post.tags.length > 0 && (
<ul className={styles.tags}> <ul className={styles.tags}>
...@@ -48,7 +106,42 @@ export default function PostArticle({ post, switcher, banner }) { ...@@ -48,7 +106,42 @@ export default function PostArticle({ post, switcher, banner }) {
</p> </p>
)} )}
{post.description && <p className={styles.description}>{post.description}</p>} {post.description && <p className={styles.description}>{post.description}</p>}
{hasToc && (
<details className={styles.tocDetails}>
<summary>{labels.toc}</summary>
<TocList toc={toc} />
</details>
)}
<div className={styles.prose} dangerouslySetInnerHTML={{ __html: post.contentHtml }} /> <div className={styles.prose} dangerouslySetInnerHTML={{ __html: post.contentHtml }} />
{/* RELATED SEAM: post↔project threads slot in beside the pager
once /projects exists (DESIGN.md — the git-graph surface).
The pager keeps the date spine; related posts will hang off
the same nav. */}
{(older || newer) && (
<nav className={styles.pager} aria-label={labels.pager}>
{older ? (
<Link href={older.href} rel="prev" className={styles.pagerOlder}>
<span className={styles.pagerDir}>← {labels.older}</span>
{older.title}
</Link>
) : (
<span />
)}
{newer ? (
<Link href={newer.href} rel="next" className={styles.pagerNewer}>
<span className={styles.pagerDir}>{labels.newer} →</span>
{newer.title}
</Link>
) : (
<span />
)}
</nav>
)}
</div>
</div>
<ArticleEnhance lang={post.lang} languages={post.languages} />
<FontSwitcher />
</article> </article>
</Layout> </Layout>
); );
......
...@@ -20,6 +20,7 @@ export const syne = Syne({ ...@@ -20,6 +20,7 @@ export const syne = Syne({
subsets: ['latin'], subsets: ['latin'],
weight: ['700', '800'], weight: ['700', '800'],
display: 'swap', display: 'swap',
variable: '--font-syne',
}); });
export const serifBody = Source_Serif_4({ export const serifBody = Source_Serif_4({
...@@ -27,4 +28,5 @@ export const serifBody = Source_Serif_4({ ...@@ -27,4 +28,5 @@ export const serifBody = Source_Serif_4({
weight: ['400', '600', '700'], weight: ['400', '600', '700'],
style: ['normal', 'italic'], style: ['normal', 'italic'],
display: 'swap', display: 'swap',
variable: '--font-article-serif',
}); });
/* /blog styling. Two layers: /* /blog styling. Two layers:
* - hashed classes (.article, .prose, …) for structure the pages control; * - hashed classes (.article, .prose, …) for structure the pages control;
* - :global(.prose-*) / :global(.directive-note) for classes the markdown * - :global(.prose-*) / :global(.directive-note) / :global(.prose-fn-*) for
* pipeline emits (lib/markdown.ts) — plain, stable names so the lib stays * classes the markdown pipeline emits (lib/markdown.ts) — plain, stable
* decoupled from any CSS module. Element selectors inside .prose style the * names so the lib stays decoupled from any CSS module. Element selectors
* ordinary markdown output (tables, lists, code) the same way. * under .article style the ordinary markdown output (tables, lists, code).
*
* The article page is phone-first: one column, inline `<details>` TOC, plain
* endnote footnotes. Footnote popovers arrive at hover-capable mid widths;
* true Tufte margin notes + a sticky TOC rail arrive at ≥80rem, when the
* viewport has margins to spend.
*/ */
/* ------------------------------------------------------------------ *
* Shell — article grid: [toc rail] [core] [margin-note band]
* ------------------------------------------------------------------ */
.article { .article {
max-width: 42rem; position: relative;
margin: 0 auto; /* Default body face: the editorial serif (next/font var set on this
padding-bottom: 4rem; element). In prod the alternative sans pair never loads, so the
word-wrap: break-word; [data-article-body] branch below can only ever fire in dev. */
font-family: var(--font-article-serif), 'Source Serif 4', Georgia, 'Times New Roman', serif;
}
:global([data-article-body='sans']) .article {
font-family: var(--font-article-sans), system-ui, -apple-system, 'Segoe UI', sans-serif;
}
.shell {
display: block;
}
.core {
max-width: 68ch;
margin-inline: auto;
padding-inline: 1.25rem;
}
@media (min-width: 80rem) {
.shell {
display: grid;
grid-template-columns: 1fr min(68ch, 100%) 1fr;
column-gap: 2rem;
}
.core {
grid-column: 2;
min-width: 0;
padding-inline: 0;
}
}
/* Reading progress — pure CSS scroll-driven animation; unsupported browsers
(and reduced-motion users) simply never see the bar. */
.progress {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 2px;
background: var(--link);
transform: scaleX(0);
transform-origin: 0 50%;
z-index: 50;
pointer-events: none;
}
@supports (animation-timeline: scroll()) {
@media (prefers-reduced-motion: no-preference) {
.progress {
animation: article-progress linear both;
animation-timeline: scroll(root);
}
}
}
@keyframes article-progress {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
/* ------------------------------------------------------------------ *
* Header: language switch, title, meta, lede, untranslated banner
* ------------------------------------------------------------------ */
.langSwitch {
font-size: 0.8rem;
letter-spacing: 0.06em;
color: var(--text-muted);
}
.langSwitch a {
color: var(--link);
text-decoration: none;
}
.langSwitch a:hover {
text-decoration: underline;
}
.langSwitch b {
font-weight: 700;
color: var(--text-primary);
}
/* The remembered language (ArticleEnhance reads localStorage) */
.langSwitch :global(.langPreferred) {
text-decoration: underline dotted;
text-underline-offset: 0.25em;
}
.title {
margin: 0.75rem 0 0;
font-family: var(--font-syne), system-ui, sans-serif;
font-size: clamp(1.9rem, 1.55rem + 1.2vw, 2.35rem);
font-weight: 800;
line-height: 1.12;
letter-spacing: -0.02rem;
text-wrap: balance;
} }
.meta { .meta {
margin: 0.25rem 0 0; margin: 0.6rem 0 0;
display: flex; display: flex;
align-items: baseline; align-items: baseline;
flex-wrap: wrap;
gap: 0.75rem; gap: 0.75rem;
color: var(--text-muted);
font-size: 0.9rem;
} }
.tags { .tags {
list-style: none; list-style: none;
display: flex; display: flex;
flex-wrap: wrap;
gap: 0.5rem; gap: 0.5rem;
margin: 0; margin: 0;
padding: 0; padding: 0;
...@@ -34,45 +149,258 @@ ...@@ -34,45 +149,258 @@
} }
.description { .description {
margin: 1rem 0 0; margin: 1.25rem 0 0;
font-size: 1.1rem; font-size: 1.16rem;
line-height: 1.55;
color: var(--text-muted);
max-width: 56ch;
text-wrap: pretty;
}
.untranslated {
margin: 1.25rem 0 0;
padding: 0.7rem 1rem;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface-raised);
color: var(--text-muted);
font-size: 0.92rem;
}
.untranslated a {
color: var(--link);
}
/* ------------------------------------------------------------------ *
* TOC — sticky rail (wide) / inline collapsible (narrow)
* ------------------------------------------------------------------ */
.tocDetails {
margin: 1.75rem 0 0;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface-raised);
font-size: 0.92rem;
}
.tocDetails summary {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 1rem;
cursor: pointer;
font-weight: 600;
list-style: none;
color: var(--text-primary);
}
.tocDetails summary::-webkit-details-marker {
display: none;
}
/* custom caret */
.tocDetails summary::after {
content: '';
margin-left: auto;
width: 0.45rem;
height: 0.45rem;
border-right: 1.5px solid var(--text-muted);
border-bottom: 1.5px solid var(--text-muted);
transform: rotate(45deg);
transition: transform 0.2s ease;
}
.tocDetails[open] summary::after {
transform: rotate(225deg);
}
.tocDetails[open] summary {
border-bottom: 1px solid var(--border);
}
.tocList {
list-style: none;
margin: 0;
padding: 0.6rem 0 0.75rem;
line-height: 1.5;
}
.tocList li[data-depth='3'] {
padding-left: 1.1rem;
font-size: 0.9em;
}
.tocList a {
color: var(--text-primary);
text-decoration: none;
}
.tocList a:hover,
.tocList a:focus-visible {
color: var(--link);
}
.tocDetails .tocList {
padding-inline: 1rem;
}
@media (min-width: 80rem) {
.tocDetails {
display: none;
}
.tocRail {
grid-column: 1;
justify-self: end;
width: 13rem;
}
.tocSticky {
position: sticky;
top: 5.5rem;
max-height: calc(100vh - 7.5rem);
overflow-y: auto;
font-size: 0.85rem;
}
.tocTitle {
margin: 0 0 0.5rem;
font-weight: 600;
color: var(--text-muted); color: var(--text-muted);
font-size: 0.78rem;
letter-spacing: 0.04em;
}
}
@media (max-width: 79.99rem) {
.tocRail {
display: none;
}
} }
/* ------------------------------------------------------------------ * /* ------------------------------------------------------------------ *
* Prose body — figures, tables, code, directive notes * Prose body — the reading ramp
* ------------------------------------------------------------------ */ * ------------------------------------------------------------------ */
.prose { .prose {
margin-top: 2rem; margin-top: 2.25rem;
line-height: 1.7; font-size: 1.08rem;
font-size: 1.05rem; line-height: 1.65;
overflow-wrap: break-word;
} }
.prose h2, /* main.css's `.site-shell main h2` competes at (0,1,2) — the .article
.prose h3 { prefix keeps the article's own ramp in charge. */
margin: 2rem 0 0.75rem; .article .prose h2 {
letter-spacing: -0.02rem; margin: 2.75rem 0 0.75rem;
font-size: clamp(1.5rem, 1.32rem + 0.6vw, 1.7rem);
font-weight: 700;
line-height: 1.2;
letter-spacing: -0.01em;
text-wrap: balance;
}
.article .prose h3 {
margin: 2.25rem 0 0.6rem;
font-size: 1.35rem;
font-weight: 600;
line-height: 1.25;
text-wrap: balance;
} }
.prose a { .article .prose h4 {
margin: 1.75rem 0 0.5rem;
font-size: 1.13rem;
font-weight: 600;
line-height: 1.3;
}
.article .prose p {
margin: 0 0 1.1rem;
text-wrap: pretty;
}
.article .prose a {
color: var(--link); color: var(--link);
text-decoration: underline;
text-decoration-color: color-mix(in srgb, var(--link) 35%, transparent);
text-underline-offset: 0.18em;
}
.article .prose a:hover {
text-decoration-color: var(--link);
} }
.prose blockquote { .article .prose ul,
.article .prose ol {
margin: 0 0 1.1rem;
padding-left: 1.6rem;
}
.article .prose li {
margin: 0.3rem 0;
}
/* Quiet quotation — indent and voice, no side-stripe furniture. */
.article .prose blockquote {
margin: 1.5rem 0; margin: 1.5rem 0;
padding: 0.25rem 1.25rem; padding: 0.1rem 0 0.1rem 1.4rem;
border-left: 3px solid var(--border);
color: var(--text-muted); color: var(--text-muted);
font-size: 0.97em;
}
.article .prose hr {
border: 0;
border-top: 1px solid var(--border);
margin: 2.5rem 0;
}
/* GFM tables — block+scroll so a wide table never pushes the measure at
phone widths */
.article .prose table {
display: block;
overflow-x: auto;
border-collapse: collapse;
width: 100%;
margin: 1.5rem 0;
font-size: 0.92rem;
font-variant-numeric: tabular-nums;
}
.article .prose th,
.article .prose td {
border: 1px solid var(--border);
padding: 0.5rem 0.75rem;
text-align: left;
} }
/* Lone images wrapped by rehype-figure */ .article .prose th {
.prose :global(.prose-figure) { background: var(--surface-raised);
}
/* ::note[...] / ::note directives (lib/markdown.ts directiveComponents) */
.article .prose :global(.directive-note) {
margin: 1.5rem 0;
padding: 0.8rem 1rem;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface-raised);
}
.article .prose :global(.directive-note p:last-child) {
margin-bottom: 0;
}
/* ------------------------------------------------------------------ *
* Figures + lightbox
* ------------------------------------------------------------------ */
.article .prose :global(.prose-figure) {
margin: 2rem 0; margin: 2rem 0;
text-align: center; text-align: center;
} }
.prose :global(.prose-figure img) { .article .prose :global(.prose-figure img) {
display: block; display: block;
max-width: 100%; max-width: 100%;
height: auto; height: auto;
...@@ -80,69 +408,315 @@ ...@@ -80,69 +408,315 @@
border-radius: 8px; border-radius: 8px;
} }
.prose :global(.prose-figcaption) { .article .prose :global(.prose-figcaption) {
margin-top: 0.6rem; margin-top: 0.6rem;
font-size: 0.85rem; font-size: 0.85rem;
color: var(--text-muted); color: var(--text-muted);
text-wrap: balance;
} }
/* GFM tables */ /* Click-to-zoom arrives with JS (ArticleEnhance); the affordance travels
.prose table { with the cursor so no-JS readers are never promised a zoom. */
border-collapse: collapse; @media (hover: hover) {
width: 100%; .article .prose :global(.prose-figure img) {
margin: 1.5rem 0; cursor: zoom-in;
font-size: 0.95rem; }
} }
.prose th, .article :global(dialog.prose-lightbox) {
.prose td { border: none;
border: 1px solid var(--border); padding: 0;
padding: 0.5rem 0.75rem; background: transparent;
text-align: left; max-width: none;
max-height: none;
overscroll-behavior: contain;
} }
.prose th { .article :global(dialog.prose-lightbox)::backdrop {
background: var(--surface-raised); background: rgb(10 10 10 / 0.82);
}
.article :global(dialog.prose-lightbox img) {
display: block;
width: auto;
height: auto;
max-width: 92vw;
max-height: 88vh;
margin: auto;
border-radius: 6px;
cursor: zoom-out;
} }
/* Code — shiki tokens are inline-styled by rehype-pretty-code; this supplies @media (prefers-reduced-motion: reduce) {
the dark pre. Vesper's own background is #101010. */ .article :global(dialog.prose-lightbox),
.prose pre { .article :global(dialog.prose-lightbox)::backdrop {
transition: none;
animation: none;
}
}
/* ------------------------------------------------------------------ *
* Code — shiki tokens are inline-styled by rehype-pretty-code; this
* supplies the dark pre (Vesper's own background is #101010). The label +
* copy bar is attached by ArticleEnhance; no JS, no bar.
* ------------------------------------------------------------------ */
.article .prose pre {
background: #101010; background: #101010;
color: #d5d5d5; color: #d5d5d5;
margin: 1.5rem 0;
padding: 1rem 1.25rem; padding: 1rem 1.25rem;
border-radius: 8px; border-radius: 8px;
overflow-x: auto; overflow-x: auto;
line-height: 1.6; line-height: 1.6;
font-size: 0.9rem; font-size: 0.88rem;
} }
.prose pre code { .article .prose pre code {
display: block; display: block;
padding: 0; padding: 0;
background: none; background: none;
font-variant-ligatures: none;
} }
.prose :not(pre) > code { .article .prose :not(pre) > code {
background: var(--surface-raised); background: var(--surface-raised);
padding: 0.15em 0.4em; padding: 0.15em 0.4em;
border-radius: 4px; border-radius: 4px;
font-size: 0.88em; font-size: 0.88em;
font-variant-ligatures: none;
} }
/* ::note[...] / ::note directives (lib/markdown.ts directiveComponents) */ .article .prose :global([data-rehype-pretty-code-figure]) {
.prose :global(.directive-note) {
margin: 1.5rem 0; margin: 1.5rem 0;
padding: 0.75rem 1rem;
border-left: 3px solid var(--link);
border-radius: 0 8px 8px 0;
background: var(--surface-raised);
} }
.prose :global(.directive-note p:last-child) { .article :global(.prose-codebar) {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin: 1.5rem 0 0;
padding: 0.35rem 1rem;
background: #191919;
color: #9a9a9a;
border-radius: 8px 8px 0 0;
font-size: 0.75rem;
letter-spacing: 0.06em;
}
/* the bar sits flush on its pre… */
.article .prose :global(.prose-codebar) + pre {
margin-top: 0;
border-radius: 0 0 8px 8px;
}
/* …and never doubles up inside the pretty-code figure wrapper */
.article .prose :global([data-rehype-pretty-code-figure]) .article :global(.prose-codebar) {
margin-top: 0;
}
.article :global(.prose-codelang) {
text-transform: uppercase;
}
.article :global(.prose-copy) {
font: inherit;
font-size: 0.7rem;
letter-spacing: 0.05em;
padding: 0.15rem 0.55rem;
background: transparent;
border: 1px solid #3a3a3a;
border-radius: 5px;
color: #b5b5b5;
cursor: pointer;
}
.article :global(.prose-copy:hover) {
color: #ffffff;
border-color: #666666;
}
/* ------------------------------------------------------------------ *
* Footnotes — margin notes on wide screens, popovers in between,
* endnotes everywhere (the no-JS, mobile and screen-reader source).
* Markup from lib/markdown.ts rehypeMarginNotes:
* <span class="prose-fn"><sup>…ref…</sup><span class="prose-fn-body"
* aria-hidden="true">…clone…</span></span>
* ------------------------------------------------------------------ */
.article .prose sup a {
color: var(--link);
text-decoration: none;
font-weight: 600;
padding: 0 0.1em;
}
.article .prose :global(.prose-fn) {
position: relative;
}
.article .prose :global(.prose-fn-body) {
display: none;
}
/* inline-safe block shapes inside clones (popovers and margin notes) */
.article .prose :global(.prose-fn-block) {
display: block;
margin: 0 0 0.5rem;
}
.article .prose :global(.prose-fn-block:last-child) {
margin-bottom: 0; margin-bottom: 0;
} }
.article .prose :global(.prose-fn-item) {
display: block;
margin: 0 0 0.3rem;
}
.article .prose :global(.prose-fn-code) {
display: block;
font-size: 0.9em;
white-space: pre-wrap;
}
/* mid widths: hover/focus popover above the reference */
@media (min-width: 48rem) and (max-width: 79.99rem) and (hover: hover) {
.article .prose :global(.prose-fn):hover :global(.prose-fn-body),
.article .prose :global(.prose-fn):focus-within :global(.prose-fn-body) {
display: block;
position: absolute;
bottom: 1.5em;
left: 50%;
transform: translateX(-50%);
width: 21rem;
max-width: 80vw;
padding: 0.8rem 1rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: 0 8px 24px rgb(0 0 0 / 0.12);
color: var(--text-primary);
font-size: 0.88rem;
line-height: 1.55;
z-index: 30;
cursor: default;
}
/* hover bridge: keeps the popover open while the pointer crosses the gap */
.article .prose :global(.prose-fn-body)::before {
content: '';
position: absolute;
left: 0;
right: 0;
top: -0.9em;
height: 0.9em;
}
}
/* wide screens: true Tufte margin notes — the clone floats into the right
band, clearing earlier notes, at the reference's line height */
@media (min-width: 80rem) {
.article .prose :global(.prose-fn-body) {
display: block;
float: right;
clear: right;
width: 13rem;
margin: 0.1rem -15rem 0.75rem 1.2rem;
color: var(--text-muted);
font-size: 0.86rem;
line-height: 1.55;
}
}
/* endnotes — always present, styled down from body text */
.article .prose :global([data-footnotes]) {
margin-top: 3rem;
padding-top: 1.25rem;
border-top: 1px solid var(--border);
font-size: 0.9rem;
line-height: 1.6;
color: var(--text-muted);
}
.article .prose :global([data-footnotes]) ol {
margin: 0;
padding-left: 1.4rem;
}
.article .prose :global([data-footnotes]) li {
margin: 0.45rem 0;
}
.article .prose :global([data-footnotes]) a {
color: var(--link);
}
/* ------------------------------------------------------------------ *
* Pager (older / newer) + dev font badge
* ------------------------------------------------------------------ */
.pager {
display: flex;
justify-content: space-between;
align-items: stretch;
gap: 1.5rem;
margin-top: 3.5rem;
padding-top: 1.25rem;
border-top: 1px solid var(--border);
font-size: 0.92rem;
}
.pager a {
color: var(--text-primary);
text-decoration: none;
max-width: 24ch;
}
.pager a:hover {
color: var(--link);
}
.pagerDir {
display: block;
margin-bottom: 0.2rem;
color: var(--text-muted);
font-size: 0.75rem;
letter-spacing: 0.05em;
}
.pagerNewer {
margin-left: auto;
text-align: right;
}
.fontBadge {
position: fixed;
bottom: 1rem;
left: 1rem;
z-index: 60;
padding: 0.25rem 0.7rem;
border: 1px dashed var(--border);
border-radius: 999px;
background: var(--surface-raised);
color: var(--text-muted);
font-size: 0.7rem;
letter-spacing: 0.04em;
}
/* ------------------------------------------------------------------ *
* Focus + draft/lang badges (index page)
* ------------------------------------------------------------------ */
.article :global(a:focus-visible),
.article :global(button:focus-visible),
.tocDetails summary:focus-visible {
outline: 2px solid var(--link);
outline-offset: 2px;
border-radius: 2px;
}
/* Draft badge (index, dev only) */ /* Draft badge (index, dev only) */
.draftBadge { .draftBadge {
margin-left: 0.5rem; margin-left: 0.5rem;
...@@ -166,33 +740,3 @@ ...@@ -166,33 +740,3 @@
letter-spacing: 0.08em; letter-spacing: 0.08em;
vertical-align: middle; vertical-align: middle;
} }
/* Language switch on a post page (both languages available) */
.langSwitch {
font-size: 0.8rem;
letter-spacing: 0.06em;
margin-bottom: 0.75rem;
}
.langSwitch a {
color: var(--link);
}
.langSwitch b {
font-weight: 600;
}
/* Untranslated notice: the requested language has no content */
.untranslated {
margin: 1rem 0 0;
padding: 0.6rem 0.9rem;
border-left: 3px solid var(--border);
border-radius: 0 8px 8px 0;
background: var(--surface-raised);
color: var(--text-muted);
font-size: 0.9rem;
}
.untranslated a {
color: var(--link);
}
...@@ -20,4 +20,5 @@ export const sansBody = Atkinson_Hyperlegible({ ...@@ -20,4 +20,5 @@ export const sansBody = Atkinson_Hyperlegible({
weight: ['400', '700'], weight: ['400', '700'],
style: ['normal', 'italic'], style: ['normal', 'italic'],
display: 'swap', display: 'swap',
variable: '--font-article-sans',
}); });
const path = require('path'); const path = require('path');
const webpack = require('webpack');
module.exports = { module.exports = {
// Static export — the site ships as flat files (out/), served by self-hosted // Static export — the site ships as flat files (out/), served by self-hosted
...@@ -29,11 +30,14 @@ module.exports = { ...@@ -29,11 +30,14 @@ module.exports = {
config.resolve.alias['@'] = path.resolve(__dirname); config.resolve.alias['@'] = path.resolve(__dirname);
// The article body-face switcher (?fonts=) is a dev-only mockup surface. // The article body-face switcher (?fonts=) is a dev-only mockup surface.
// In production builds the alternative font module is swapped for a stub // In production builds the alternative font module is swapped for a stub
// so no second body pair is ever emitted into the shipped bundle. // (before alias resolution — an exact alias loses to the '@' prefix) so
// no second body pair is ever emitted into the shipped bundle.
if (!dev) { if (!dev) {
config.resolve.alias['@/app/blog/dev-fonts$'] = path.resolve( config.plugins.push(
__dirname, new webpack.NormalModuleReplacementPlugin(
'app/blog/dev-fonts.stub.js', /^@\/app\/blog\/dev-fonts$/,
'@/app/blog/dev-fonts.stub.js',
),
); );
} }
return config; return config;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment