A personal blog in SvelteKit 2 / Svelte 5, built to the Ink Edition art direction
(maxubrq/project/THEME_MANIFESTO.md): white paper, one electric ink-blue, hairline grids,
oversized lowercase grotesque, mono fine print. Content is MDX in content/.
pnpm install # also runs `svelte-kit sync` via the prepare script
pnpm dev # http://localhost:5173
pnpm build # prerenders the whole site
pnpm preview
pnpm check # svelte-checkSvelteKit 2 · Svelte 5 (runes) · Vite 6 · mdsvex. vite-plugin-svelte has to move with Vite —
v4 peers only on Vite 5, so the two are upgraded as a pair.
The prepare script is load-bearing, not decoration: svelte-kit sync is what writes
.svelte-kit/tsconfig.json, which the root tsconfig.json extends. Without it a clean clone
(Vercel, CI) builds with Cannot find base config file "./.svelte-kit/tsconfig.json" — harmless,
but it means esbuild silently ignored the TS config for that pass, and it buries real warnings.
One file per post per language in content/posts/, extension .mdx. The slug is the filename,
so content/posts/001-float-memory-en.mdx is served at /en/writing/001-float-memory-en (the
locale prefix comes from the post’s own lang).
The frontmatter contract is deliberately identical to the production Next.js blog
(~/MyApps/maxblog), so posts move across without edits:
---
title: 'Floating point numbers'
subtitle: 'how is 0.1 held inside a computer?'
date: '2026-01-29'
topic: 'Tech' # Science · Tech · Philosophy · Art · Thinking · Notes
lang: en # en | vi
interactive: true
chapter: 'Chapter I'
series: 'OOP — From Syntax to Reflex'
description: 'Shown on index pages and in the RSS feed.'
weather: # the reading contract, hand-written
time: '25–30 min'
oneSitting: true
load: 4 # 1–5, drawn as squares; the number is never printed
needFirst: '…'
bestWhen: '…'
warn: '…' # only when there is something honest to warn about
rememberSentence: 'The author’s pick — one sentence to keep.'
rememberAttribution: 'from Floating point numbers'
neighborhood: # hand-picked next reads
- slug: gt-003-vi
title: '…'
min: 26
relation: 'Mở đường'
reason: 'why this one, next'
accent: 'numbers' # the word set in blue in the title (must occur in `title`)
draft: true # unlisted, but the page still builds
---reading (minutes) and words are computed at build time by
src/lib/content/reading-time.js; set them in frontmatter to pin them.
Two files that differ only in a trailing -en / -vi (or _en / _vi) are treated as
translations of one essay: each is published under its own locale, and both link to each other.
No imports needed — src/lib/content/inject-components.js splices the kit into every .mdx
file's script, and only for the components that file actually uses. The kit is
src/lib/mdx.ts:
Callout · CodeBlock · DiagramPlate · Figure · Fleuron · Footnote · LinkPreview ·
OneSentence · PullQuote · R · Sidenote · Term · Terminal · WeatherStrip · Float*
To add one: create the component, export it from src/lib/mdx.ts, and add its name to
KIT_COMPONENTS in inject-components.js.
Two mdsvex rules worth remembering:
- Markdown inside a component only parses when a blank line separates it from the tags.
- Markdown content arrives as a
<p>, so a component must not wrap children in a<p>.
An outbound link becomes a <LinkPreview> — a hover card with what is behind it. Production
swaps the a element through MDX's components map; mdsvex has no such map, so the swap happens
while the tree is still markdown, in src/lib/content/remark-links.js, the same way the glossary
and citation marks are placed. Only http: / https: links are wrapped: an internal link goes
somewhere the reader can already see the shape of, and a preview of your own page is a card telling
you what you are about to be told. Anchors, mailto: and relative paths pass through untouched.
That pass runs last, and the order is load-bearing. remarkGlossary and remarkResources both
skip link nodes on purpose — a term should not pick up a dotted underline inside a link, and a
citation numeral should not be planted in one. Rewrite the link into HTML tags first and its text is
no longer inside a link node, so both passes would happily mark it.
Math is $inline$ / $$display$$, rendered by KaTeX at build time — no KaTeX JS ships to
the reader, only its stylesheet. GFM tables come from mdsvex's own remark.
/[lang]/writing/[slug] is the reading surface. Top to bottom:
- the running head, and the fore-edge in the gutter (see The reading instruments)
- the title block — tag row · headline · deck
ReadingMemoryNudge, when you have started this essay beforeWeatherStrip— the reading contract, met on the way in- the prose
- the end mark
■ OneSentence— the author's pick, whenrememberSentenceis setGlossaryFootnote— the words the essay marked, when it marked any (see Glossary)Bibliography— the sources, when the post has any (see Resources)- the hand-picked
neighborhood, when the frontmatter lists one
The tag row, the deck and the weather strip carry .flow-hide; the headline and the nudge stay
whatever the mode — see Study and flow below.
There is no meta strip at the foot, and no meta rail beside the prose: topic, licence and word
count used to print at the foot, and author/date/reading/chapter used to stand in the left
margin. Both were removed and the prose took the column back. So MetaFoot is unused now, as are
the article.author / published / filedUnder / license / words catalogue keys and the
meta.words / meta.license frontmatter this edition still parses. (MetaRail itself is not
unused — /about still prints one.)
The article ends on 100px of empty page (80 on mobile), set on article rather than on the last
block, so the gap is there whichever block closes the piece.
ReaderMarks wraps the prose — and only the prose: a selection in the apparatus below it is not a
passage of the essay, and a mark drawn there would have nowhere to be redrawn.
It is built to maxubrq/project/pages/InkMarks.jsx, which replaces the reaction bar production
still ships (SelectionReact, ❤ ✦ ?). One primitive instead of four feelings — five gestures that
each write the same record, so what comes later reads one stream instead of five bespoke ones:
keep (worth carrying out of the essay) · dissent (the same gesture, sign reversed) ·
snag (an anchor to come back to, not a place to leave) · ask (a question pinned to the
passage) · note (for the author). The glyphs are proofreader's marks drawn as SVG strokes;
the design is explicit that they are never emoji.
Everything is private first, and that is what makes it cheap. Only note leaves the device, so
there is no consent step to design — the other four never travel. It also means /api/react and
the shared reactions table need no change at all: note is already a value the endpoint
accepts, and the four new words are never written to a table production also reads. dev and
draft are still refused (both editions write to one Postgres). doNotTrack is deliberately not
consulted, which is the opposite of ArticleTracker: there the site counts readers who never asked
to be counted, here the reader is writing a letter they chose to write.
The marks are drawn, not styled — rough.js strokes on an SVG layer above the text, seeded from
the mark's own number so a redraw is the same hand, and animated once with a stroke-dash so the pen
appears to draw it on. rough.js is import()ed with the first mark. Each gesture is a different
hand: a rule for keep, two rules and a strike for dissent, a wobble for snag, a rule plus an ellipse
around the numeral for ask, and for note a bracket in the margin — a note is addressed somewhere,
so the prose should not look edited by it.
The one thing that actually blocked this: Selection.toString() returns the text as the eye saw
it, one space between words, while the DOM's textContent still holds the newlines the markdown
source wrapped at (~80 columns). A quote taken from a selection therefore never matches the text
node it came from, and every mark failed to draw while recording perfectly — a silent, total
failure. flatten() collapses whitespace while keeping a map back to the original offsets, and the
search runs on that. Anything that matches prose against a selection in this repo needs the same
treatment.
The bar is the page's two colours swapped — an ink field with paper marks — and that has to be
said in tokens, not in white. The design canvas is a light-only mock, so it hardcodes white text
on a fixed dark bar; mapped straight across, the field follows --ink and turns light at night
while the text stays white, and the whole bar becomes unreadable in exactly one theme. --bar-field
/ --bar-mark are declared once on .bar and everything on it derives from that pair.
The same audit turned up six solid blue fields carrying white text — in the mark bar, the vault's
filters and fold, and the sky's layer toggle — that used --blue where app.css requires
--panel-blue. The token exists for this: at night --blue is brightened so it can be text on
paper, and white on that brightened blue does not carry, so a solid blue field has to drop to
--blue-deep. FilterBar had it right and was the model.
The anchor is the shape a W3C TextQuoteSelector uses — quote plus a slice of context each side — so a mark survives the author editing the paragraph around it. Production stores the bare quote and loses the mark the moment a character changes.
A passage crossing an inline element is recorded but not drawn: the remark passes put <Term> and
<R> inside sentences, so a selection over one has no single text node to wrap. Better to leave it
undrawn than to rebuild the author's markup around it.
Not built yet from that design: belief (an article-level gesture), dwell (passive, local), and
the register — the reader's own list of marks in the gutter.
Everything around the prose is one question — where am I, and what is left? — answered by one
measurement. src/lib/reading-progress.svelte.ts runs a single rAF-throttled scroll listener and
publishes progress · activeSection · sectionProgress; four instruments read it, so the
running head, the drawer and the bar can never disagree about where you are.
ForeEdge— the fore-edge of the book, drawn in the gutter. Not a progress bar: what you have read compresses into a thin band, what remains stays a thick block, and you feel the weight left the way your thumb feels it in a paperback. It is also the contents — each section owns a run of leaves whose length is its reading time; hover names it, click goes there. The fraction only ever sets thickness and is never printed as a number. Two homes, never both at once: a rail in the left gutter above 1180px, a 10px strip at the right edge of the viewport below it (that one is inert — a 10px tap target is not a control).TocDrawer+TableOfContents— the list, for readers who want a list. Roman numerals, per-section minutes, a hairline gauge of how far into each you have come. A sheet from the bottom on a phone, a card in the middle on a desktop.MobileReadingBar— the section you are in and what it costs to finish, on the bottom edge. No progress track: the strip at the right of the screen carries that.ReadingRuler— the reading cursor. The block nearest the reading line holds full contrast, the rest fall back to 34%, and the whole thing fades out when you stop moving. Never a rule laid across the text, which makes the eye read the rule instead of the sentence.
ArticleTracker records the three facts /api/track keeps: a session opened this essay, reached
a section, finished it. Counts, never identities — one row per session per post (and per section),
deduplicated by unique constraints in the schema, and nothing is read back into the page: the
reader is never shown a number about their own reading.
It owns no listeners. Production's version runs a second scroll handler and a second
IntersectionObserver; here it takes progress and activeSection from the page's own
measurement, which also settles what "reached a section" means — the reader is standing under it,
the same signal the running head and the contents use. One definition of where the reader is.
Three things switch it off entirely, before a session id is so much as created:
dev. Both editions write to the same Postgres, so apnpm devsession would otherwise land in the real numbers as a reader.- A draft. That is the author previewing, not readership.
navigator.doNotTrack. This edition's addition, not production's: the site counts in order to know whether an essay was finished, and that is a question a reader is allowed to decline.
The site's only third-party measurement, both injected in the root +layout.svelte so they also
see /, the 404 and /signals. Both are cookieless and keep no cross-site identity. What
Analytics adds over /api/track is traffic the essays cannot see — which routes are visited at
all, and where from; Speed Insights reports Web Vitals from real visits rather than a lab run.
They follow the same doNotTrack rule, and earlier: the check runs before either inject… call,
so a reader who has declined fetches neither script. In pnpm dev Analytics runs in development
mode (logs to the console, sends nothing) and Speed Insights in debug.
$lib/session.ts holds the id — a uuid in localStorage under production's mx_sid, so a reader
with both editions open is one reader. It is not an account and not a fingerprint; clearing site
data ends it.
The other half of the pair, and the distinction is the point: telemetry counts readers for the
author and leaves the device; reading memory keeps a place for the reader and never does.
So it has no dev guard and no doNotTrack check — there is nothing here to opt out of.
$lib/reading-memory.ts holds one entry per essay under production's reading_memory key: how
far in, which ##, how many minutes are left, and when. Four components touch it — one writes,
three read.
ReadingMemoryTrackerwrites it. LikeArticleTrackerit owns no scroll listener — the page measures itself once — and it writes on a 2s settle, so scrolling through an essay does not save fifty times. Two effects, not one: the settle effect re-runs on every frame and its teardown must not save (that would invert the debounce), while a second effect reads nothing reactive, so its teardown is the real departure.pagehidecovers the exits no teardown sees — a closed tab, and a page entering the back/forward cache.ReadingMemoryNudgeis the blue band at the head of an essay you have started. Dismissed per session, not for good: closing it deals with this visit, and a reader returning tomorrow still deserves to be told.ReadingMemoryGutteris the bookmark itself — a dot in the margin at the section, and a hard blue rule across its head. The heading comes from the post's markdown, so the component marks it with.memory-resume(styled inapp.css) and unmarks it on the way out, rather than writing inline styles into an element it does not own.
PickUpWhereYouLeftOff puts the list on the home page, above the masthead — it renders nothing at
all when there is nothing to resume, which is every first visit. Production's "see your sky ✦" link
is dropped: it goes to /constellation, which is not a URL here — the sky is a section of
/[lang]/reading-room.
An entry stores its essay's locale, and that is not decoration. A slug belongs to exactly one
locale, so a resume link built from the reader's locale 404s the moment the two differ — a
Vietnamese reader resuming an English essay. entryLang() prefers the recorded locale, falls back
to the slug's own -en / _vi suffix (which is what entries written by production have), and only
then to the reader's.
lastSeen() takes its labels from the i18n catalogue rather than hardcoding two languages the way
production must, having nowhere else to put them.
Study and flow are a CSS switch (data-reading-mode on <html>), not a different tree:
changing your mind mid-essay must not rebuild the article under you, or you lose your place.
Flow widens the column and hides everything marked .flow-hide — the tag row, the deck, the
weather strip. Flow is for a reader who already committed.
The mode, the cursor and the "N min left" estimate are three of the eleven reader settings — see Reading preferences below for the rest, and for how any of them reach the prose at all.
Deliberately unlike production. There, the site header is replaced on an article by a fixed running head carrying contents · flow · search · theme · print · folio. This edition already has a real header with search and the display settings, so a second fixed bar would only duplicate them. Instead the vertical folio at the right page edge became live — it names the section you are in and the folio numeral — and the way into the contents stands at the head of the fore-edge rail. Production's
TableOfContentsalso runs its ownIntersectionObserver, which only fires once a heading crosses its band; open the contents without scrolling first and nothing is marked. Here it takes the article'sactiveSection.
The contents are build-time data. src/lib/content/toc.js is a remark pass that writes
toc into the frontmatter bag next to words and reading: one entry per ##, each with its
own readMinutes at the same 220 wpm the post is counted at (it imports text() from
reading-time.js so the two counters cannot disagree). The length of a section is a property of
the text, not of the viewport.
The ids come from github-slugger fed every heading in document order — including the ###s
that never become entries — because that is exactly what rehype-slug does to the rendered page,
and its duplicate counter has to have seen the same headings in the same order. The search
corpus holds to the same rule. Nothing in the build fails when they drift; the anchors just
scroll nowhere. So:
pnpm build && node scripts/check-anchors.mjsThe pass runs after remarkMath and before the two mark passes: after, so a heading with $x$
slugs off the same string rehype-slug will see; before, so it never reads a heading that has
picked up a <Term> or an <R>.
Bản nháp lộ thiên — a piece left out in the open while it is still being written, with every
earlier save still reachable. Built to maxubrq/project/pages/InkDraft.jsx (SFIM, T4). It is not a
teaser and it is not draft: true: those two are opposites here, and independent. draft: true
means unlisted, but it still builds; an open draft is unfinished and listed, on purpose.
The whole feature exists to keep one promise — nothing is edited silently. Every change leaves a readable scar, and every past version can be pulled back, including the embarrassing ones.
Two halves, and the split is the point.
openDraft: in a post's frontmatter is the author writing, by hand, where the piece stands today:
the note pinned to this save, and the state of each section (settled · editing · notes). It
travels with the prose, in the same commit.
openDraft:
startedAt: '2026-08-16'
promise: 'chưa hứa'
note: 'quay câu cuối về phía người đọc. còn mục V.'
sections:
- label: 'I. Ba nhóm trẻ và hộp bút màu'
state: 'settled'
- label: 'V. Vậy thì làm gì với chuyện này'
state: 'notes'
notes:
- 'Cẩn thận: mục này rất dễ trượt thành một bài self-help.'The history is read out of git by scripts/drafts.mjs, which writes src/lib/drafts.data.js —
generated, and committed, exactly as scripts/halftone.mjs commits its plates:
node scripts/drafts.mjs # every post carrying openDraft:
node scripts/drafts.mjs 004-AI-empowerment-counting-by-SP-viReading git at build time is the obvious alternative and it is a trap: Vercel clones shallow, the history would come back empty, and the page would quietly claim the piece had never been touched. A draft that lies about its own edits is the one bug this feature cannot ship with.
Because each revision is read from the frontmatter of its own commit, a past note says what the
author thought that afternoon rather than what they would say about it now, and travelling back
rolls the section table back with the prose. Every commit is an edit; only a commit whose
frontmatter carries a note is a saved revision a reader can stand on — which is why the rail
draws twenty-three ticks and six stops rather than six of each.
Computed, never authored. scripts/drafts.mjs aligns two revisions paragraph by paragraph (LCS over
the paragraph text), pairs the changed ones by similarity, then diffs the pair word by word. Two
constants carry the legibility, and both were tuned against real revisions:
KEEP = 0.5— below this the two paragraphs are unrelated blocks, not one paragraph edited.REWRITE = 0.5— past this share of a paragraph changing, a word-level diff reads as confetti, so the paragraph is shown as what it honestly is: the old one struck out, the new one whole.
A run of changed paragraphs is paired in order with a forward-only pointer, so a revision that reworks four paragraphs in a row does not read as four deletions followed by four unrelated blocks — and a deletion that is stepped over is printed where it stood.
The head (OpenDraftHead) carries the flag, the state table, the rail, the note pinned to the save
you are standing at, and the scar layer's switch. Everything in it is read from that revision:
standing at an old save under today's verdict about it would be the dishonesty the feature exists to
avoid.
Where the reader is standing is not in the load function. The page stays prerendered static
HTML at the current text; ?r=r02 is applied and written client-side, which is all a shareable link
needs.
Past revisions are not compiled mdx. Six historical versions cannot be run through mdsvex, and
diffing compiled HTML is a worse problem than the one being solved, so a component survives as the
kind of block it was (aside, quote, fleuron) and its text is typeset to match approximately.
The current revision always renders as the real essay, components and all.
While the reader is travelling, everything that speaks about the live essay stands down: the
trackers, the reading memory, the nudge, the weather, the fore-edge and the whole apparatus
(one sentence · glossary · sources · the letter box). A nudge saying 3 minutes left over prose
that no longer exists is measuring the wrong text. The ■ is not printed at all — an open draft
has not earned it.
The rhythm is: write, pin a note, commit, amend. Nothing else is required, and
nothing has to be remembered — the hooks are wired by pnpm install (prepare
points core.hooksPath at .githooks/).
-
Edit the prose in
content/posts/<slug>.mdx. -
Say where it stands in the same file:
openDraft.notefor this save, and move any section whose state changed. Leavingnoteempty is a real choice — the commit still counts as an edit (a tick on the rail) but is not a save a reader can stand on. Use it for a typo pass. -
Commit. The post-commit hook regenerates
src/lib/drafts.data.jsand stages it, then asks for one command:git commit --amend --no-edit
The amend fires the hook again, the script produces identical bytes, and it goes quiet. That is why nothing generated keeps the commit's sha — a sha changes under an amend, and the file would never settle.
-
Push. The pre-commit hook refuses any other commit while the data module is behind git, so a skipped amend cannot ship. A commit that touches a post is exempt from that check, since its own revision cannot exist yet.
Doing it by hand is the same thing without the hooks: pnpm drafts after the
commit, pnpm drafts:check to ask whether the committed file is current.
What the page reads live, and what it reads from history. The head is keyed
off openDraft: in the frontmatter, not off the data module: a piece is an open
draft from its first line, and the flag, the state table and the unwritten
sections print before any save has been annotated. The rail waits — it appears
once there are two saved revisions, because a history of one save is not
somewhere else to stand. The newest save's note, section states and length come
from the frontmatter and the build-time word count too, never from the data
module. So an edit in progress is
already true on the page in pnpm dev, and a forgotten amend leaves the rail one
stop short rather than describing the prose under it wrongly. The data module is
only ever asked about the past, which is all it knows. The rail labels its last
stop now rather than by id for the same reason: the live prose can be an edit
or two past the last save that was annotated.
There is no done: true. Set every section to settled and the piece stops
being a draft on its own: isSettled lets it into the feed, and the head has
nothing left to report. Delete the openDraft block when you want the furniture
gone as well — the history stays in git either way.
- The archive lists it with
still being writtenwhere the reading minutes would be. - The feed leaves it out until every section is
settled(isSettledin$lib/drafts). A piece edited twenty-three times would otherwise arrive twenty-three times. Sections all settled is the only finish line this feature recognises — nodone: trueto forget, no date to miss. - Marks go fuzzy, not missing.
ReaderMarksrecords the revision a mark was made at, and a mark whose sentence is no longer in the prose is listed under the essay with the version it was made at, rather than silently failing to draw. The test is whether the quote occurs in the text at all — deliberately not whetherwrapsucceeded, sincewrapalso declines a quote that crosses an inline element, and that mark is present, merely undrawn.
Eleven settings, all device-local and never sent anywhere. /[lang]/reading is the room where
all of them live, with a sample that answers the moment you touch a control; the header's display
dropdown carries the six worth taking mid-sentence: theme · notes · framing · mode, and the two
in-article instruments. Both surfaces edit the same store, so neither can show a stale value.
The storage keys are production's — font-size, line-spacing, measure, typeface, theme,
theme-auto, reading-mode, reading-ruler, time-left, layout, framing — so a reader with
both editions open keeps one setting.
Everything lands on <html>, data-* for the switches and custom properties for the numbers,
which is why applyPrefs() is the only call needed to make a change visible everywhere at once:
| setting | lands as | read by |
|---|---|---|
| text size · line spacing | --reading-fs · --reading-lh |
.prose in PostBody.svelte |
| typeface | data-typeface → --reading-font |
.prose; the chrome never changes face |
| measure | data-measure → --measure → --article-w |
the article's column and the fore-edge rail, which is anchored to --article-w so it moves with the text |
| theme · theme by clock | data-theme |
app.css |
| mode | data-reading-mode |
.flow-hide, and the column |
| cursor | data-ruler |
.ruler-block |
| notes | data-layout |
Sidenote.svelte |
| framing | data-framing |
.interactive-plate on DiagramPlate |
Heading sizes in the prose are em of the reading size, not px: at 26px body copy a fixed 22px
h3 would be smaller than the paragraph under it and the hierarchy would invert.
Four things are easy to get wrong here, and all four were:
- Never read reactive state inside
hydrate(). It is called from an$effect; an effect that reads a piece of state and then writes it re-triggers itself, and Svelte answers witheffect_update_depth_exceededand stops updating the page. Every control still works, the values still save, and nothing on screen ever changes again. It is guarded by a plain (non-$state) flag for the same reason. - "No theme chosen" is a state, not a gap. With nothing stored, the blocking script stamps no
data-themeat all so the CSS can followprefers-color-scheme.applyPrefstherefore takes astampThemeflag: without it, changing the measure would stamplighton a reader whose system is dark and flip the page under them. The controls show the system's theme in that state, because alightchip lit over a dark page is the site lying about itself. --article-whas to follow the measure. Fixed at 940px, the widest step was capped at 852px of text and the control silently did half of what it said.- A component's scoped rule outranks a plain one from
app.css.DiagramPlatecarries no margin of its own precisely so.interactive-platecan own it; had it kept one,framingwould have done nothing.
No full-colour photography anywhere (§6): an image is a cyanotype plate — greyscale under a
blue multiply, done in CSS by DuoPhoto / .ink-duo, so almost any photo reads as deliberate.
The one exception is the cat on the 404 page (static/media/404-cat.webp), and it is an exception
because §6 is about photography: a drawing is already a deliberate object and has nothing to be
rescued from. It keeps the hard border every plate gets, its alt is empty because the headline
beside it says the same thing in the reader's own language (the sign in the picture only says it in
English), and it is shown for 404 only — a 500 is the site's fault, not a wrong turn, and a
cartoon apologising for it would be the wrong register.
Images are served from Cloudinary, the same account and the same contract as the production blog. A post names its cover in frontmatter as a plain delivery URL — no transformations in the URL an author writes:
coverImage: 'https://res.cloudinary.com/dmsb4anlx/image/upload/v1784555137/maxubrq.space/x.jpg'src/lib/images.ts adds the rest. Production does this through next/image's custom loader;
this edition has no image component to hook, so the same rewrite is explicit:
import { cloudinary, coverImageFor, srcsetFor } from '$lib/images';
const cover = coverImageFor(post.coverImage); // → the branded default when unset
cloudinary(cover, { width: 1080 }) // → …/upload/f_auto,q_auto,w_1080,c_limit/v1/x.jpg
srcsetFor(cover) // → the same at every width in IMAGE_WIDTHS
cloudinary(cover, { width: 1080, halftone: 'screen' }) // → … + /e_grayscale/e_ordered_dither:8/f_auto picks the format per browser, q_auto the quality, c_limit never upscales and keeps
the aspect ratio (object-fit: cover does the visual crop). Anything that is not a
Cloudinary delivery URL passes through both helpers untouched, and srcsetFor returns
undefined for it — a local plate has one size and must not claim others. DuoPhoto calls both
for you; pass it a sizes and, for a cover above the fold, priority.
Nothing is proxied and nothing is resized at build time: the browser fetches Cloudinary directly, which is why the whole site can stay prerendered static HTML.
The other treatment is a 1-bit halftone: dots instead of a duotone (§2). There are two ways to get one, and they are not interchangeable.
At the CDN — the default. Pass halftone and Cloudinary screens the image with its own
ordered-dither, after the resize, so the pitch is in final pixels:
<DuoPhoto src={cover} halftone="screen" /> <!-- or "coarse" · "fine" -->screen is 8×8 at 45°, the offset plate; coarse is the same angle with bigger dots; fine is
a 6×6 round dot, orthogonal, closest to newsprint.
A halftone plate uses .ink-screen instead of .ink-duo — no blue multiply, because the screen
is already the whole treatment. The colour is put on in CSS, not at the CDN: Cloudinary
returns one 1-bit black-on-white plate, and two blend modes ink it per theme, so switching theme
re-colours the plate without fetching anything.
| token | day / dusk | night |
|---|---|---|
--screen-ink |
--blue |
violet #a97ef0 — on ink paper the blue is already spent on text |
--screen-blend |
multiply |
screen |
--screen-ink-blend |
lighten |
darken |
The container's background is --paper, so the plate's field is the page's own paper — never a
white rectangle pasted onto warm or ink stock. The ::after floods the plate with the ink and
the second blend admits it on the dots only. Both blends need isolation: isolate, which
.ink-screen sets; without it the page behind joins the blend.
It costs nothing: the screened output is smaller than the photo it came from (≈24 KB vs 381 KB for the default cover at 700px), and it reaches images that only exist on Cloudinary.
Two things it cannot do, which is why the script still exists: the dot pitch is per-srcset-width,
so a 1× and a 2× screen do not match dot for dot, and the plate lives only in the browser — no
fixed artefact for an OG image, a feed, or print.
Offline — for a fixed plate. scripts/halftone.mjs screens a local PNG once, by hand, and
the result is committed. Same idea, exact and reproducible.
node scripts/halftone.mjs in.png static/media/out.png [--pitch 9] [--scale 3]
[--gamma 1] [--contrast 1]It turns a PNG into a 45°-rotated dot screen the way an offset plate does: one dot per grid cell, dot radius tracking the cell's darkness, no intermediate grey surviving. Output is a 1-bit greyscale PNG, and it prints the new dimensions, file size, and ink coverage.
| flag | default | what it does |
|---|---|---|
--pitch |
9 |
grid step in output pixels — larger is a coarser, more visible screen |
--scale |
3 |
output size relative to the source; the dots need pixels to stay round |
--gamma |
1 |
<1 opens the shadows, >1 deepens them, before screening |
--contrast |
1 |
pushes tones away from mid-grey, before screening |
Two things to hold on to:
- Run it once, by hand, when preparing the asset — never at build time. Commit the result to
static/media/, so Vercel needs no image library and the prerendered HTML ships the final PNG. A halftone is already 1-bit and tiny; it gains nothing from Cloudinary's pipeline. - It only depends on
node:zlib, and only reads 8-bit non-interlaced RGB/RGBA/greyscale PNG (no palette).sips -s format png in.jpg --out in.pngon macOS produces an acceptable input.
Two plates, one for a Mux stream and one for a YouTube video, both usable in any
post with no import: <MuxVideo playbackId="…" /> and <YouTubeVideo id="…" />.
They share VideoPlate, which is DiagramPlate's frame, so a video reads as a
figure in the essay rather than as an embed dropped into it — and the reader's
framing setting spaces them like every other plate.
Neither fetches anything until the reader asks. The Mux element carries
preload="none" and no src; the YouTube plate is not an iframe at all until it
is pressed. What a reader who scrolls past pays is one poster image, or nothing.
<MuxVideo playbackId="A3VXy02…" caption="The rig, running" title="A bench test" />
<YouTubeVideo id="dQw4w9WgXcQ" poster="https://res.cloudinary.com/…" start={42} />Both take label, hint, caption, ratio, poster and title (the spoken
description). MuxVideo adds posterTime and loop; YouTubeVideo adds
start.
hls.js is a dependency but not a cost: it is import()ed by the first press,
the same discipline ReaderMarks uses for rough.js. Safari and iOS never load it
— they play HLS themselves.
Two traps are worth knowing, because both fail silently and cost an afternoon:
canPlayTypemust be compared against'probably'. Chrome answers'maybe'forapplication/vnd.apple.mpegurland then cannot play a playlist at all. Trusting a truthy answer leaves the element at readyState 0 for ever, playing nothing, with no error to report.- A hidden tab will not open a MediaSource. If you are debugging playback
through an automated browser, check
document.visibilityStatebefore you suspect your own code: in a background tab hls.js parses the manifest, attaches the blob, and then waits for asourceopenthat never comes.
The controls are the site's own, because Mux's player brings rounded corners, a gradient and a brand hue into a printed object. The scrub rail is deliberately the draft's time rail again — a hairline, a blue run, a 9px square where you are standing. A fatal hls.js error swaps the stage for a hatch plate that says so, and the bar goes away with it.
The poster is image.mux.com/{playbackId}/thumbnail.jpg unless poster says
otherwise, and a poster that 404s falls back to the hatch rather than to a broken
image frame.
The plate you see before pressing play is ours: no iframe, no script, no image
from a Google host. The embed is built on the press, against
youtube-nocookie.com, and the foot line says which of those two states you are
in. There is also always a plain link out, for a reader who would rather not load
the embed at all.
The poster is a hatch plate, not YouTube's own thumbnail, and that is the
whole point: i.ytimg.com is a request to Google, which would break the promise
the facade exists to keep. Pass poster to put a real still there — your own
frame grab, from wherever the rest of the media is served.
Nothing about the player can be styled once the iframe is in. The plate around it is what keeps a video looking like part of the essay.
Every page lives under a locale prefix — /en/… and /vi/…, matched by src/params/lang.ts.
/ carries no content — it decides which edition the reader gets, in src/routes/+page.server.ts:
the locale they last read in (a lang cookie, written by the [lang] layout on every locale
page), then Accept-Language, then site.defaultLang. It is the one page that cannot be
prerendered, and it answers 307, never 308 — a permanent redirect is cached by the browser
forever, which would let the very first visit decide a reader's language for good. Each locale is its own
edition: it lists only the posts whose frontmatter lang matches, has its own feed
(/en/feed.xml, /vi/feed.xml), and stamps <html lang> via src/hooks.server.ts.
UI strings live in src/lib/i18n/messages.ts, two catalogs of the same shape (TypeScript enforces
it: vi is typed as typeof en). Most values are lifted verbatim from the production blog's
messages/{en,vi}.json; keys marked // ink are this edition's additions — the accent words the
hand-drawn marks lasso, and the footer triplet.
Read the active locale in any component, including ones used inside MDX:
import { useI18n } from '$lib/i18n';
const i18n = useI18n();
const t = $derived(i18n.t); // i18n.lang · i18n.otherBuild links with href(lang, '/writing') — paths are always authored locale-less, and
swapLocale() moves the current path to the other locale. The context holds getters, not
values, because the [lang] layout instance survives a client-side navigation from /en to
/vi.
Topic slugs and topic names stay canonical English (they key the frontmatter and the URLs);
only their blurbs are translated, in topicBlurbs.
Universal search over the whole locale, opened by the header button or ⌘K / Ctrl-K anywhere.
Three kinds of hit, the same three the production blog indexes: an essay (title, excerpt,
topic), a section (one ##/### heading), a passage (one paragraph, first six per post).
Arrows move, ↵ opens, esc closes; a section hit lands on that heading's anchor.
Nothing runs on a server here, so the corpus is a build artefact: src/lib/content/search.ts
globs every post's raw .mdx and /{lang}/search-index.json prerenders it, next to feed.xml.
Post metadata comes from the compiled modules (mdsvex already parsed the frontmatter); only the
body is re-read, to split it into sections and passages.
Two costs kept off the critical path: the JSON is fetched the first time search opens, and
SearchOverlay.svelte is itself dynamically imported by the layout — so MiniSearch reaches the
browser only for readers who actually search. The built index is cached per locale in module
scope, so reopening does not refetch.
Anchors. rehype-slug stamps every heading id at build time, and the corpus runs the same
github-slugger over the same headings in the same order — including headings that never become
results, because the slugger's duplicate counter has to see what rehype-slug saw. If they drift, a
section hit scrolls nowhere. Worth re-checking after changing either side:
# every section anchor in the index must exist in the rendered page
node -e "const j=require('./.svelte-kit/output/prerendered/pages/en/search-index.json'),fs=require('fs');
console.log(j.sections.filter(s=>!fs.readFileSync('.svelte-kit/output/prerendered/pages/en/writing/'+s.slug+'.html','utf8').includes('id=\"'+s.anchor+'\"')).length + ' broken')"Matching is MiniSearch, not the substring scan
production uses: prefix matching while you type, fuzzy: 0.2 for a typo, and field boosts so a
title outranks a paragraph. Two settings are there for Vietnamese specifically — processTerm
folds diacritics on both sides so cong ty finds Công ty, and combineWith: 'AND' keeps that
from matching half the corpus (folded Vietnamese syllables are short, and an OR query on ty
matches nearly everything).
content/posts/ the posts (.mdx)
static/media/ committed plates — the halftones, and the 404 cat
scripts/halftone.mjs offline halftone screener; run by hand, never at build
scripts/check-anchors.mjs every TOC anchor must exist in the rendered page; run after a build
src/app.css the tokens — palette, type, layout constants, dark mirror
src/lib/images.ts Cloudinary delivery — the cover default + the URL/srcset rewrite
src/lib/site.ts name, volume, topics, links, default language
src/lib/i18n/ the en/vi catalogs + the locale context
src/lib/content/search.ts the search corpus builder (build-time only)
src/lib/glossary.ts the site dictionary (data in glossary.data.js)
src/lib/resources.ts the bibliography (data in resources.data.js)
src/lib/content/remark-*.js the passes that mark terms, citations and outbound links
src/lib/server/db/ drizzle schema + the lazy Postgres client (never client-side)
src/lib/server/api.ts shared endpoint guards: 503 / 400 / opaque 500
src/routes/api/ the six prerender-exempt endpoints
src/lib/search.svelte.ts whether the search overlay is open, + the ⌘K binding
src/lib/reading.svelte.ts the reader's settings, live — the store both surfaces edit
src/lib/reading-prefs.ts every device-local reader setting + the <html> stamping
src/lib/reading-progress.svelte.ts one scroll listener: progress · active section · per-section
src/lib/session.ts the reader's session id (a uuid, for deduplicating counts)
src/lib/reading-memory.ts where you stopped, per essay — device-local, never sent anywhere
src/lib/content/toc.js the contents, with per-section reading minutes (build-time only)
src/lib/components/chrome/ SearchButton, SearchOverlay, DisplaySettings, Placeholder
src/params/lang.ts the /en · /vi route matcher
src/lib/format.ts the printed-document date/number voice
src/lib/content/ post loading, frontmatter contract, mdsvex layout + plugins
src/lib/components/ink/ the theme kit: Tag, Scribble, Underline, ArrowMark, DuoPhoto,
ResourceCover, RunningHead, MetaRail, MetaFoot, Headline,
IndexRow, FilterBar
src/lib/components/article/ PullQuote, Callout, Sidenote, Footnote, Fleuron, OneSentence,
WeatherStrip · Term and R (the marks) ·
Bibliography and GlossaryFootnote (the apparatus) ·
the reading instruments: ForeEdge, TableOfContents,
TocDrawer, MobileReadingBar, ReadingRuler · ArticleTracker ·
ReadingMemoryTracker / Nudge / Gutter
src/lib/components/home/ PickUpWhereYouLeftOff
src/lib/components/tech/ CodeBlock, Terminal, DiagramPlate
src/routes/[lang=lang]/ /{en,vi} · /writing · /writing/[slug] · /topics · /topics/[topic] ·
/glossary · /resources · /reading · /about · /feed.xml ·
/search-index.json · placeholder rooms
(/ negotiates the locale — the only non-prerendered page)
Design tokens live only in src/app.css as CSS custom properties, so dark mode is a token swap
and components never hardcode a colour. Two laws from the manifesto that are easy to break:
the fields are softened (paper #fafaf7, ink #24242c) but rules stay pure black, and
everything is a rectangle — border-radius: 0 is set globally.
A topic is a room with an editor in it, not a filter over the archive. src/lib/topics.ts
holds all six — name, tagline, an editor's note, three hand-picked ways in, and a scratchpad of
what is being thought about — copied from the production blog so both editions say the same thing
about the same room. /[lang]/topics is the hub of doorways; /[lang]/topics/[topic] is the room:
siblings strip · masthead · editor's note · starters · scratchpad · everything else filed here,
newest first.
id is not name. The id keys the URL and the frontmatter match and is never translated; the
name is printed and always is. That separation is what lets /vi/topics/science be titled Khoa
học without a Vietnamese essay having to write topic: 'Khoa học', and it is the one place this
edition changed its mind — the names used to stay English on the grounds that they key the URLs,
which conflated the two jobs. The archive's filter bar carries ids for the same reason: switching
language mid-filter keeps the filter.
A post files itself by writing topic in its frontmatter, and frontmatterTopics is the list of
spellings a room answers to, matched case-insensitively. This edition's tech is production's
software and accepts both words, so an essay crosses between the editions without an edit.
The editor's note is set by the room's own tone (clinical-warm · plainspoken · literary ·
sparse), which is the only body size on the site that is a property of the subject rather
than of the reader's settings. One measure governs the paragraphs and the signature both: a "— m."
set to the edge of a wider box than the prose it signs belongs to the page, not to the note.
site.ts used to carry a topics list with its blurbs off in the i18n catalogs; both are gone,
and topicBlurbs with them. Nothing about a topic belongs in the chrome's own config.
On a wide screen the whole page was a left-aligned column on a 1568px canvas — the masthead's
title stops around 345px, the note's measure around 500px — so the right of the page read as
unfinished rather than as margin. plate in $lib/topics fills it: a cell of the page's grid,
divided by the same 1.5px hairline the hub's doorways are divided by, not a picture floated into
a gap. That is why the masthead and the note are wrapped in one .room grid — a plate beside the
note alone has only the note's height to work with, and no honest ratio fills a page that wide from
a 380px column. Spanning both gives it the vertical run a department page actually needs.
The picture fills its cell rather than setting it: aspect-ratio is dropped and object-fit: cover does the crop, which is what makes the plate's rule and the note's rule land on the same
line (the note is flex: 1 in its column for the same reason). Any aspect can be dropped in
without the layout caring. Below 1080px the plate stands down and the note takes the width back —
it is the one element on the page that is atmosphere rather than reading, and a magazine drops its
department art on a narrow page too.
No alt, on purpose — the same reasoning as the 404 cat: the note beside it already says what
the room is, in the reader's own language. That also settles what would otherwise be awkward, since
an image is not translated but a description of one would have to be.
All six rooms currently declare a plate with no src, so each draws DuoPhoto's labelled
placeholder naming the picture it is waiting for — the slot is visibly reserved, the way the
unported live figures are. To fill one, add a plain Cloudinary delivery URL (no transformations;
$lib/images adds those) and optionally halftone: 'screen' | 'coarse' | 'fine' for newsprint
instead of the cyanotype. Delete a room's plate entirely to take the column back.
Every room's arrays are [], and that is a decision, not an unfinished port — they go back in once
the archive is thick enough to choose from. Both sections claim something four posts cannot support
honestly: if you read three implies there were more than three to pick from, and a scratchpad of
six drafts beside four published essays advertises a backlog rather than a body of work. Each
section renders nothing while its array is empty, so a room today is masthead · note · what is
filed here, and reads as finished rather than as scaffolding. The tail's heading follows the same
rule — also filed under X only when the starters stand above it, plain filed under X otherwise.
Production's text is what to start from when they return: ~/MyApps/maxblog/src/lib/topics.ts,
three starters and about five scratchpad items per room, each with a vi twin. Nothing else has to
change — the types, the components, and the tail's de-duplication (a starter's essay is not
repeated below it) are all still wired.
Deliberately unlike production, and this is the one page where the two editions disagree about
what the room is. There it is a hub: six doors with counts printed on them, each leading to its
own page. Here everything is shown, one section after another, on a single page — a door that only
says how many things are behind it makes the reader click to find out what they are, and the room
is the reader's own, so there is nothing to tease. /shelf is therefore not a URL in this edition;
the shelf is a section of /[lang]/reading-room. None of production's door/blurb/count strings
apply, so only the masthead and the colophon were lifted.
The room is empty on the server. The loader hands over the catalogue — every post in this locale — so a spine can be lettered with a live title; which of them you finished is read out of your own device after mount. Nothing about a reader is ever rendered on a server, which is what lets the page stay prerendered static HTML like every other.
spines is null until the device has been asked, and that is not the same as empty: rendering
"your shelf is still empty" on the first frame would tell a reader with a full shelf they had read
nothing.
One closing line for the whole room, not one per section. Production gives the shelf its own
colophon; here the section's deck and its finished only · opening earns nothing tag already say
how a spine is earned, so repeating it below the plank was the same sentence twice. The room's line
— how any of this got here, and where it lives — runs the full width of the page rather than to
a measure, because it closes the page instead of belonging to the section above it. Each new
section adds its own deck, not its own colophon.
Opening a piece earns nothing. A spine appears the moment you reach the last line, never at a
percentage — $lib/shelf reads reading_memory and keeps only finished entries, which makes it
the exact opposite of PickUpWhereYouLeftOff: that lists what you have started and drops an
essay the moment it is finished. Between them they account for every entry in the store.
A spine's dimensions are seeded from a hash of its slug, not from its position in the list. The design did the latter, which would reshuffle the whole shelf every time you finished something new — a real shelf does not resize its books.
Two details fall out of the join rather than needing a rule. The catalogue is one locale's posts,
so an essay finished in the other locale simply does not appear rather than appearing with a link
that 404s. And in dark mode the palette inverts the light flag by itself: --paper2 spines go
dark and --ink spines go light, so the row still breaks up, just the other way round.
TONES carries both Tech and Software, because the store is shared with production and a spine
may have been finished on either edition.
It prints: the row wraps into stacked shelves instead of being guillotined at the margin, and the blue plank edge goes black — a colour cartridge should not be spent saying what the rule already says.
Every essay you have opened drawn as a star, joined by a thread in the order you read them, with an
essay's hand-picked neighborhood as the citation edges. The layout is a force simulation
rather than an authored diagram, and it has to be: the set of essays is different for every reader,
so there is no arrangement to draw in advance. Domains ride a stable ellipse and each star is
pulled toward its own, so the sky opens into regions instead of one blob that slowly separates.
d3-force positions the stars and d3-shape draws the lines between them; both are import()ed
inside their effects — the same discipline SearchOverlay uses for MiniSearch — so a reader who
never opens the reading room pays for neither. They land as two chunks, 15.3 KB and 6.2 KB.
That second number is the reason d3-shape is destructured rather than taken as a namespace:
import('d3-shape').then((d3) => …) reaches its bindings by property access, which defeats
tree-shaking and shipped the whole module — every curve, arc, symbol and stack — at 32.2 KB.
Naming the two imports brought it to 6.2 KB. d3-force keeps the namespace form on purpose: seven
of its nine exports are used, so there is nothing to shake off.
A library that did layout and edges was considered and rejected. Cytoscape.js, vis-network and Sigma.js all do both, and all render to canvas or WebGL — which cannot read the CSS custom properties this site's theming is built on, prints as a bitmap, weighs 10–300× the two d3 modules, and arrives with rounded, arrow-headed, shadowed defaults that argue with the manifesto. The deciding point is narrower than any of that: the line that looked wrong is the reading-order thread, which is not a graph edge at all — no graph library models a path through nodes in the order a person visited them, only relations between them. The one thing that needed fixing is the one thing they do not own.
The thread is a centripetal Catmull-Rom spline, not a polyline. Reading order is not a spatial
relation, so straight hops between stars read as a route drawn on a map; one continuous curve reads
as a line drawn between stars. alpha(0.5) is the variant that cannot form a cusp or a loop when
two stars land close together. Its ends are trimmed back by a star's radius so it joins the stars
rather than skewering them, and a citation is a shallow arc rather than a chord — two essays that
cite each other and sit next to each other would otherwise hide their edge under the thread.
Every position is read through one derived Map, and that is not tidiness. nodes is $state,
so its members are proxies, while the simulation mutates the raw objects underneath. An earlier
version also kept a Map of the raw ones: the stars rendered from the proxies, the thread was
computed from the raw array, and the two drifted apart — the thread hung several pixels off its own
stars and stayed there once the simulation settled.
An essay you have read pulls its neighbours into the sky even when you have not opened them: the chart is where you have been and what stands next to it. That is what the open circles are.
The concepts layer is not here, and this is a data judgement rather than an unfinished port.
Production floats a concept at the centroid of the essays that invoke it and fills it as you finish
them, hiding any concept with fewer than two members present. Checked against this edition's
corpus, all four of production's concepts have at most one member here — so the layer would
render nothing at all, while still costing a data file, a toggle, a legend row and a tally.
Production's own concepts.ts header calls its membership "a STARTER SET… treat this as a first
pass to correct, not as a finding", so porting it would also be publishing someone's uncorrected
guesses as the author's concept map. layers is an object rather than a boolean so putting it back
is a key, not a rewrite.
Two things read as sparse rather than broken today, and both are corpus, not code: with no post
carrying neighborhood in its frontmatter there are no citation edges yet, and with two posts per
locale a full sky is two stars and a thread.
Production's plate carries its own title because it was the whole page; here the section above it does the naming, so the plate keeps only its chart and its legend — and the domains toggle moved into the legend, since both answer "how do I read this chart".
Headline's scribble is an ellipse of fixed proportions, so it only wraps an accent of about the
width it was tuned for. On a wide accent it lassoes the air above the word and strikes through the
letters. markTop / markLeft are now props (defaulting to the old hardcoded values, so no
existing headline moved) — but this room's accent is room. in one locale and đọc. in the other,
and one geometry strikes through one of them whichever way it is set, so it uses mark="underline"
instead. An underline hangs off the baseline: a width that is a little wrong over- or under-runs
the word rather than crossing it.
/[lang]/about was already close to production; what it was missing is the door.
Its content column is production's proportions — a 270px rail and the rest, at 90% of the page —
and deliberately not tied to --measure. That token is the reader's article width
(700/820/940 by preference), so letting it govern here made a colophon obey a setting meant for
prose: the page got narrower because someone chose a narrow measure for reading essays. Two things
that follow from the same cascade are worth knowing, because both were bugs: .measure p sets
18px, so the sign-off has to be qualified as .measure p.thanks or it prints at body size (the
!important that used to sit on its margin was the tell), and the page ends on 120px of air rather
than on its last rule.
The vault is
deliberately not in the nav — the only way in is the panel at the foot of /about, and a
private cabinet with a menu entry is not private, it is a section. The page also carries
robots: noindex, follow: reachable, not advertised.
/[lang]/vault is a timeline grouped by the year a thing entered the collection, not by kind,
and that ordering is the whole argument. Sorted by kind it is a list of favourites, which every
site has; sorted by arrival, a record and a paper from the same year stand next to each other and
the shelf becomes a chronology of what changed the author's mind, and when. added is when it
reached me, made is when the work was made — never the same axis, and the page is built on the
first.
The newest two years stay open and the rest fold away; toggled records only the years the reader
has clicked, so changing the medium filter re-derives sensible open/closed state rather than
stranding a year open because of a click on a different list.
The spine takes one change from production. A dense year flows into two columns, but a second
column of entries needs a second spine — otherwise its nodes hang in the air marking a line that
was never drawn, which is what production ships. column-rule draws it, and the 11px column-gap
is not arbitrary: the rule sits at the centre of the gap, so a gap of exactly one node's width puts
it under the second column's nodes the way the border does under the first. The visible gutter is
that 11px plus the 30px each entry already holds clear for its own spine. The node is the one
circle on the site — a timeline node is not a rectangle.
Covers go through ResourceCover like the bibliography's, so the vault's mixed shelf (jackets,
album art, film posters) lines up on the same fixed 2:3 crop. One plate is a jacket and gets the
full width; two or more are a glance at a place or a course and shrink to fit the row, capped at
four — past that the strip stops being a glance and starts being a gallery.
One data fix. In production, On Writing Well carries the overjustification paper's id, a
copy-paste slip two entries apart. React only warns about a duplicate key; Svelte's keyed {#each}
throws, so it is on_writing_well here — worth mending upstream, since the two editions are
otherwise the same shelf. Two of the seven media (Objects, Experience) have no entries yet;
their filter buttons print the empty state, which is honest rather than hidden.
No entry carries a vi block, and that is not an oversight waiting to be fixed: the notes are
already written in whichever language the author thought the thought in, and translating a private
note would be writing a new one. getVaultLocale is there for the day one genuinely needs both.
A series is an argument built across an arc, not several posts sharing a tag. src/lib/series.ts
is the data layer the rest of the site was missing, and it exists for one reason: a series has to
describe chapters that are not written yet. A post can say "I belong to a series"; it cannot say
how long the arc is, what order it runs in, or what a reader should carry from one part into the
next. So the arc is authored in the module and the posts are looked up from it — not the other
way round. The frontmatter series / chapter fields are for the essay's own tag row and are not
the source of the arc.
resolveChapters is the whole idea in one function: every chapter is a declaration, and its
fallbackTitle / fallbackDate / fallbackMin carry the promise until a post exists to replace
them. An unwritten chapter still prints, still holds its place in the arc, and is simply not a link.
Posts are passed in rather than imported — loading them is async here (mdsvex behind
import.meta.glob), and reaching for them inside the module would make every caller async for
nothing.
Four surfaces, all wired:
/[lang]/series— the shelf. A row keeps two facts apart: what the author promised (the arc, its length, its state) and what is actually written. "2 of 3 written" is the author being honest about a contract still being fulfilled, not a progress bar for the reader./[lang]/series/[id]— the arc, followingmaxubrq/project/pages/InkSeries.jsx: the contract for the whole (a sum the individual essays cannot state), the arc with the reader's place marked, the bridge, and the threads that recur.SeriesRibbonat the head of an essay — the smallest honest form of "there is something before this and something after". The shape of the arc belongs on the series page, not stacked on the prose.
The bridge is the point of the page. It is a handoff, not a "next" button: on one side the
rememberSentence of the part just finished, on the other the author's own line on what to carry
in, written per chapter in the arc. It only draws once there is something to hand over — the
reader has to have finished a part for there to be a bank to cross from.
Only "where you are" is the reader's, and it comes out of reading memory after mount, so the page
is the same static HTML for everyone. seriesProgress takes the finished set as an argument and
touches no localStorage, so it stays usable on the server. The current chapter is the first one
not finished: a series is read forwards, and the place to stand is the first door still shut.
A chapter only counts in the locale it publishes under. viSlug is optional, so a chapter
without one falls back to the English slug — and posts holds every locale, so the lookup would
find the English post, mark the chapter as existing, and link to /vi/writing/<en-slug>, which
404s: an essay lives at exactly one locale. resolveChapters therefore checks the post's own lang
before accepting it. An untranslated chapter is simply one that does not exist here yet, and prints
as the promise it still is. Without that check the build fails rather than the page, because
handleHttpError: 'fail' catches the dead link while prerendering — which is how it was found.
Nothing is finished yet, so there is no arc to describe — the same call as the topics' starters and
scratchpad. Everything above is written and wired; adding one object to SERIES lights up the
shelf, the arc page and the ribbon together, and /series/[id] starts prerendering (with the list
empty it generates no pages at all, which is correct). Until then each surface draws its empty
state. This was verified with a temporary three-chapter fixture — read / current / unwritten, with
a bridge — and the fixture removed afterwards.
That empty list costs one line of build config. A prerenderable route that produces no pages is
normally a bug (an entries export that quietly returned nothing), so SvelteKit fails the build —
and /series/[id] generates nothing while SERIES is empty, with no link to it to crawl either.
handleUnseenRoutes in svelte.config.js names that one route and still throws for any other, so
the check keeps its value everywhere else. It cannot go stale: add a series and the route is
prerendered, so it stops being reported and the branch is never taken again.
src/lib/glossary.ts is the site dictionary: one entry per marked term, with a short gloss, a
long one, a topic, and the essays it appears in — each with the heading it was first used under.
Entries carry a vi block; getGlossaryLocale(entry, lang) returns one locale with the English
text as the fallback for every field.
The marks are not written by hand. src/lib/content/remark-glossary.js runs while mdsvex
compiles a post and wraps the first mention of each dictionary term in a <Term> — later
mentions are left plain, so a page is not a field of dotted underlines. An author writes prose.
<Term id="flow" /> <!-- explicit, when you want it somewhere precise -->
<Term term="widget" def="a small thing" /> <!-- ad-hoc, not in the dictionary -->The pass records what it marked, in reading order, onto the post's terms metadata — the same
file.data.fm handoff remark-resources uses for citations. GlossaryFootnote gathers them at
the foot of the essay: the same short glosses the reader dismissed inline, together, at the point
where there is room to look. Production's GlossaryFootnote wraps the whole article and hands a
collect(id) down a React context that every Term calls on mount, so the list is state that
fills in as the tree commits; here the pass knew before a byte reached the browser, so the
component holds no state, wraps nothing, and server-renders complete.
It prints A–Z, like production and like /glossary itself — not the reading order terms
arrives in. The bibliography directly beneath it is in reading order, and the difference is not
an inconsistency: a citation carries a numeral, so its position means something and the prose can
point at it. A word carries no numeral, six unnumbered rows in the order they happened to come up
read as no order at all, and a list of words has an order everyone already knows. The metadata
keeps reading order regardless, because that is what the pass actually observed.
It is the glossary's twin of Bibliography, and built the opposite way round. A source belongs to
a post by declaration — appearsIn names it — so a bibliography prints even for an essay whose
prose carries no marks. A term belongs by use: it is in the list because the pass found the word.
An essay that marks nothing shows nothing. Each row links to /glossary#gl-<letter>, which is the
only anchor that page has — it is sectioned by letter, not by entry.
The pass also reads hand-placed <Term id="…"> marks before it starts, and seeds its seen set
with them: a word the author placed deliberately must not pick up a second mark somewhere else in
the same essay. Those ids count as first mentions, in the position the author put them.
/glossary prints the same entries as an A–Z index. Its loader passes the slugs that actually
publish in that locale, and the page narrows each entry's appearances to those before deciding
what to show: an entry is listed if it still has a live citation, or if it never claimed one (a
word may be defined before it is used, but a word whose every use is a draft has nothing to show).
This is stricter than production, which keeps citations to unpublished posts.
The two-column seam, the folded long gloss and the mark demo are .ink-gloss-* in app.css
rather than component styles — /resources uses the same grid.
src/lib/resources.ts is the bibliography — books, papers, standards, talks — each with an
author, a type, a topic, an optional cover, and a note on why it earned a place. Only the note
has a vi translation: a book keeps its published title and an author keeps their name, because
that is how you find the thing.
Citations are auto-placed too, by src/lib/content/remark-resources.js: it marks the first
mention of a source's title, its main title before a colon, or the first author's surname. What
makes a bare surname safe here is the candidate set — only sources that already name this post
in appearsIn are considered, so "Sennett" is unambiguous inside one essay even though it would
not be across the library. Organisation authors ("…Accountability Office") contribute no surname.
A hand-placed <R id="…" /> always wins: the author knows which sentence carries the claim.
<R id="ieee-754" /> <!-- explicit, when the auto-match would land elsewhere -->The numeral is the source's position in this post's bibliography, so two citations of one work
in an essay share a number and the order follows the curated order in RESOURCES. Production
threads the post slug through a React context; here the route already knows it, so <R> reads
page.params.slug — the article lives at /[lang]/writing/[slug]. A source that does not list
the current post shows [·] rather than a number that means nothing.
Bibliography.svelte puts the sources at the foot of every essay that has any. It needs no
authoring — appearsIn already maps sources to posts, so a post's bibliography is a lookup.
Numbering follows reading order, and this is the one place the port deliberately diverges
from production. There the numeral is a source's position in the curated list, so an essay can
open at [02]; here the remark pass records the ids of the marks it placed, in document order,
onto the post's citations metadata, and both <R> and the end-of-essay list order themselves
with bibliographyFor(slug, citations). Sources cited in appearsIn but never named in the prose
follow, in curated order. One function orders both, so a numeral cannot disagree with its list.
/resources prints the same entries as an index. It is /glossary one level down and shares its
.ink-gloss-* grid, folded detail and availability rule; what differs is how a reader looks
things up, so this one groups by topic and has no A–Z. Search matches the note in the
current locale, not the English original, and citation links are narrowed to the essays that
publish in that locale — a resource citing both …-en and …-vi shows only the relevant half.
Covers go through the Cloudinary pipeline like every other image; ResourceCover renders nothing
when there is no coverImage, which is the normal case — a wrong cover is worse than none.
Both passes run inside svelte.config.js, which SvelteKit loads with plain Node — no TypeScript
step. So the entries themselves sit in glossary.data.js / resources.data.js, JSDoc-typed
against the interfaces next door, and the .ts modules re-export them with the types. Same reason
reading-time.js is not .ts.
One consequence worth knowing: inject-components.js decides a post's imports before mdsvex
runs, so it cannot see marks that do not exist yet. It asks the two passes in advance —
mentionsTerm(content) and citesAnything(filename) — and imports Term / R for them.
Every page is prerendered, but that is a per-route default, not a property of the build: the
adapter is adapter-vercel, and a route that sets export const prerender = false becomes a real
serverless function while the rest of the site stays static HTML. The six endpoints below are the
only ones that do — plus one page, /signals, the author's dashboard.
Same Postgres, same schema, same driver as the production blog — src/lib/server/db/schema.ts is
a copy of ~/MyApps/maxblog/src/db/schema.ts, so both editions read and write the same four
tables: reactions, page_views, section_reach, poll_votes.
cp .env.example .env.local # DATABASE_URL, DIRECT_URL, SIGNAL_PASSWORD
pnpm drizzle-kit generate # after editing schema.ts
pnpm drizzle-kit migrate # or `push` to diff against the live databaseDATABASE_URL is the pooled connection (pgbouncer, transaction mode — hence prepare: false);
DIRECT_URL is the unpooled one, because DDL cannot run over the pool.
Three rules this port holds to:
- The client is lazy.
db()opens no connection until an endpoint asks, sopnpm buildworks with no database reachable at all — prerendering must never depend on one. WithDATABASE_URLunset the endpoints answer503, they do not crash. $lib/server/is enforced, not a convention. Importing the db module into anything that reaches the browser is a build error (Cannot import $lib/server/… into code that runs in the browser), so the connection string cannot leak into a bundle by accident.- Errors are opaque. A Postgres message names tables, columns and the host; endpoints log the
real error and return a bare
Internal error.
Hovering an outbound link in an essay opens a card with the page's host, title and description —
enough to decide whether to spend a page-load on it. That has to be fetched on the server because a
browser cannot read another origin's <head>, which is the one thing on this list that is not
about the reader: it fetches a public page on their behalf and returns three strings from it.
The link is never blocked on the fetch. It is a real anchor from the first paint, and a slow
endpoint, an unreachable site or a page with no metadata all fall back to the bare host — still
true, still useful. Results are cached in module scope, so a URL mentioned twice on a page costs one
request, and the response carries a day's Cache-Control so the CDN carries the rest.
The SSRF guard is the substantive difference from production. That version validates the
protocol and nothing else, which leaves the server willing to fetch any address the caller names —
including ones only the server can reach. On a cloud host http://169.254.169.254/… is the instance
metadata service. This one blocks loopback, private, link-local, unique-local and .internal hosts,
re-checks every redirect hop (following redirects automatically would let a public URL bounce
the server onto a private one), refuses non-HTML content types, caps the read at 512 KB and times
out at 5s. Verified: the metadata IP, localhost:5432, 10.0.0.1 and file:// all answer 400,
where production would have fetched all four.
Its honest limit is in the code: the check is on the literal hostname, so a name that resolves to
a private address still passes, as does rebinding between the check and the fetch. Closing those
needs DNS resolution plus a pinned-IP connection, which the platform's fetch does not expose.
Between them they return every letter a reader has written, with session ids. Auth lives in
src/hooks.server.ts, guarding both prefixes — the dashboard page and the feed it reads: no
credentials → 401, wrong credentials → 404 (a wrong password should not confirm the route
exists), and no SIGNAL_PASSWORD configured → 404, so a deploy that forgets the variable
stays shut rather than falling open. Page and feed share one realm, so the browser asks once and
then sends the same credentials with the dashboard's own fetch.
Worth knowing when comparing the two editions: production guards the page
/[locale]/signalsinmiddleware.ts, but that middleware's matcher excludesapi, so/api/signalsthere is reachable without credentials — GET returns all letters, PATCH mutates. Guarding both prefixes in hooks, as here, is what closes that gap.
drizzle/0000_* came from production and describes only three tables: poll_votes was added
there with drizzle-kit push, which writes no migration. drizzle/0001_* (generated here)
reconciles that, so a fresh database can be built from history alone. It is CREATE TABLE IF NOT EXISTS, because against the existing database the table is already there and the migration must
be a no-op rather than a failure.
@sveltejs/adapter-vercel, with prerender = true on the root layout: every page is static
HTML at build time, and the only serverless functions are the six endpoints above (which opt out
per route). Import the repo on Vercel — the
framework preset and pnpm are detected, no vercel.json needed. The adapter's runtime is
pinned to nodejs22.x so local builds don't depend on the machine's Node version.
- The live figures.
FloatBuilder,FloatExplorer,FloatVsFixed,FloatSpacingrender a labelled placeholder plate; the real sims are ~1,500 lines of React in~/MyApps/maxblog/src/components/interactive/. - Every room in the nav is built.
/seriesis real but its shelf is empty by design — see Series. - Two of the four tables still have endpoints but no UI — see Database & endpoints. In production
these are
ReflectionPrompt(polls) andWitnessInviteCard/FairWitnessDrawer(the public record).ArticleTrackeris done, andreactionsis now written by the mark — see The article page. - Series, the rest of it —
SeriesNavDrawer(the arc as a drawer inside a chapter) andSeriesNext(the bridge out, at the foot of a chapter). The data layer andSeriesRibbonare done — see Series. Dialogue— theformat: conversationposts, where the topic/date row becomes a cast list.- The rest of the reading room: the commonplace book, the misreading book, appointments and the reading profile. In production each is its own page behind a door; here each becomes another section of the one room. The concepts layer of the constellation is also outstanding — see Your constellation for why it is not in yet.
- The print edition is not here at all yet.
Researched, not decided. The findings are written down so the next person does not have to re-read someone's source to get back to this point.
Lenis for smooth scrolling — lenis.dev
Feasible, and cheaper to integrate than it looks. Lenis (1.3.26, 5.4 KB gzip + 513 B CSS)
does not translate the page: grep -c transform over its bundle is 0. It intercepts
wheel/touch, interpolates a number, and calls the browser's real scroll every frame —
setScroll(scroll) { this.options.wrapper.scrollTo({ top: scroll, behavior: 'instant' }) }— so window.scrollY, getBoundingClientRect(), the native scroll event and
IntersectionObserver all stay truthful, and every reading instrument keeps working untouched.
respectReducedMotion is on by default. There is no official Svelte package (react/vue/framer
only), but the vanilla API in an $effect on the root layout is about fifteen lines.
Four things in this repo would have to change:
ForeEdge'sgoTo()useswindow.scrollTo({ behavior: 'smooth' })— two interpolators writing one number. It becomeslenis.scrollTo(el, { offset: -110 }).TocDrawerandSearchOverlaylock the page withbody.style.overflow = 'hidden', and Lenis cannot see that: itscheckOverflow()readsgetComputedStyle(this.rootElement), which is<html>, not<body>. Scroll accumulates behind the open sheet and lands in one jump when it closes. They would calllenis.stop()/lenis.start()instead.- Client-side navigation:
onNativeScrollonly re-syncs while Lenis is idle, so a link clicked mid-glide fights SvelteKit's scroll reset. NeedsstopInertiaOnNavigate: trueand alenis.resize()inafterNavigate, since the page height changes. anchors: { offset: -110 }, so a#idlink from the contents or from search glides the same way the fore-edge does instead of jumping.
Two reasons it is on the shelf and not in the build.
The page is full of position: fixed — the fore-edge rail, the running head, the reading bar,
the header, the sidenotes — and Lenis documents fixed elements lagging on pre-M1 macOS Safari.
For decoration a dropped frame is invisible; the fore-edge is a measuring instrument standing
next to the text it measures, and one frame out of step with that text reads as a bug rather
than as an effect. The risk points straight at the newest work.
And it argues with the art direction. This edition is a printed object: everything a rectangle,
border-radius: 0 set globally, rules pure black, one blue. The fore-edge exists so a reader
feels the weight left the way a paperback gives it to the hand. Paper has no inertia. (A
smaller version of the same seam: ReadingRuler fades the cursor after 1.4s of stillness, and
Lenis's tail runs ~1.2s, so the band would linger past the moment the reader actually stopped.)
If it is ever tried, the cheap way is a smoothScroll key in reading-prefs.ts and a row in
the display dropdown beside the reading cursor, with import('lenis') fired only when a reader
turns it on — nobody pays the 5.4 KB by default, and backing it out is one line.
Not bugs in the code, but they make the site look emptier than it is:
- The bibliography's GAO report is never auto-cited: its author is an organisation (no surname to match) and its English title does not appear in either language's prose. It still lists in the essay's sources, just without a numeral pointing at it.
content/posts/holds 4 files against production's 432 (both languages, 14 of them still[xxx]-prefixed drafts). The frontmatter contract is identical, so the rest copy across unchanged.
The bibliography is copied whole from production and is this sparse there too — four sources. The
dictionary is not a copy: production's single entry (flow) was dropped, because the essay it
claimed a use in never says the word — in either edition — and a dictionary that describes prose
that does not exist is worse than a shorter one. The six that replace it were each checked against
essay 001 in both languages before being written down, which is why that essay's foot carries a
GlossaryFootnote and 002's does not. Neither page is waiting on a port; they are waiting on
someone to write entries.
