[CHORE] Rebuild on bun, TypeScript, Ark UI and an in-house router #1

Merged
dunemask merged 20 commits from ep/Aug3-2026/ArkUiMigration into master 2026-08-03 23:43:36 +00:00
Owner

Also update for 2026

Also update for 2026
Snapshot of all in-flight work before the packages/TypeScript/bun
restructure, so before-and-after output can be compared against a
known-good commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
packages/site and packages/resume under bun workspaces. scripts/ is gone:
prerender.mjs is now a Vite plugin so `vite build` alone produces the
prerendered dist, and sync-resume.mjs is the resume package's publish step.
Toolchain moves from npm+node to bun; dev 5173, preview 4173.

dist/ is byte-identical to the pre-split build, hashed asset filenames
included. Three things threatened that and are handled: @emotion/cache was
an undeclared dependency resolving only through npm hoisting; the SSR bundle
inherits NODE_ENV from its runner, so the plugin unsets it around the render
to keep emotion class names stable; and dependency float without the npm
lockfile is pinned by root overrides plus a hoisted bun linker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every source file in both packages is now TypeScript under strict mode with
noUncheckedIndexedAccess and allowJs off. Shared domain types live in
data/types.ts; the data literals are `as const satisfies` so slugs and unions
stay narrow and indexed access needs no guards.

Structure: pages/ holds the two real pages, sections/home/ the eleven sections
that were miscategorised as pages, and ProjectCard/PhotoHover move to
components/. theme splits into tokens and surfaces, which is also where the
two style objects that one section imported from a sibling section now live.
ReferenceCarousel goes from 458 lines to a 152-line shell plus six pieces, and
its accessibility pass takes a slide count instead of closing over site data.

entry-server hand-copied the app frame from Nile under a comment saying it
must mirror it exactly; both now render a shared AppFrame, so it cannot drift
into a hydration failure.

Rendered output is unchanged, verified rather than assumed: the prerendered
HTML and the CSS bundle are byte-identical to the pre-conversion build, and a
CDP fingerprint of every element (geometry, computed styles, hrefs, text) at
four viewports on / and three on /resume/, plus an interaction log covering
every clickable control, diffs clean. Console is empty on all seven. Only the
client JS hash moves, from the AppFrame indirection and a for-of rewrite that
lets TypeScript narrow a variable assigned inside a callback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
index.html named scripts/prerender.mjs and site.ts named components/Draft.jsx;
both moved. site.ts also claimed DRAFT_* slots that no longer exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two bugs that predate the restructure.

Deep links to a catalog card outside the current page did nothing. All nine
cards stay mounted and off-page ones are hidden with display:none, so
scrollIntoView found the node on its first attempt and no-oped, and the retry
loop never fired. MoreProjects now moves both the pager page and the mobile
count when the hash names a catalog slug, and scrollToHash retries while an
element has no client rects. Both state values move regardless of viewport,
since the render path carries no viewport JS and useMediaQuery is false during
renderToString. Initial state is still not derived from the hash, which would
mismatch the server render.

Every SPA-fallback route threw React #418/#418/#423 on load. try_files serves
the / prerender for any unprerendered path, and rendering <Navigate> against
home markup mismatches, so React discarded the prerendered tree and re-rendered
the whole page client-side. Those routes now render Home and redirect from an
effect, which is the only first client render that matches the body actually
served. One HomeRoute covers /, every highlight slug, and *, so a redirect
reconciles rather than remounts and carousel and pager state survive it.

All 12 project deep links land at top 96 at both viewports, hash present at
load or arriving after first paint. 34 page loads across both viewports produce
zero console entries of any level. The element fingerprint of every page at
rest is unchanged, and the CSS bundle hash is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Performance. Mobile Lighthouse on / goes 77 to 90, LCP 5852ms to 3451ms,
transfer 1107kB to 685kB; /resume mobile 94 to 97 with CLS 0.093 to 0.
The two certificate images were 421kB loading eagerly at medium priority
ahead of the portrait that is the LCP element, so they are lazy now and the
portrait is fetchpriority high. resume-preview.png had no reserved ratio and
dropped the download button a full page when it decoded, which was all of
that CLS. Both stylesheets inline into the prerendered HTML, replacing each
link in place so the cascade is unchanged, worth about 300ms of FCP on every
route and profile. The prerender plugin now sets NODE_ENV rather than
deleting it, so emotion stops emitting dev labels into production class
names and both pages shrink about 8%.

Nothing was removable from the 495kB bundle: MUI imports are already deep,
react-icons already tree-shakes to 1.6kB gzip. The remaining fat is Tooltip
pulling popperjs and TextField pulling Select/Menu/Popover, both of which
need markup changes, so they are queued rather than taken.

Private repos. oasis cannot be published as it stands, so its card no longer
links to a repo that 404s for every logged-out visitor. A privateRepo field
on the project record turns the card into a dialog that offers to prefill
the contact form with a request for a copy; publishing a repo means deleting
the field, and dnd-toolkit and ambrosia can take the same treatment by
adding it. No component names a slug.

Two regressions were caught by the fingerprint and fixed rather than shipped:
lazy certificates collapsed their grid row from 86px to 81px because the
thumbnail's intrinsic size was setting row height, and a button is border-box
and misses the p,a font rule, which resized the repo mark and its icon.

Fingerprint versus the pre-restructure baseline is now 3 head elements per
page, from label removal and stylesheet inlining, plus 3 elements inside the
oasis card. Zero geometry diffs, zero computed-style diffs, zero console
entries at any viewport.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Formatter and linter in one tool. Config reproduces the previous prettier
settings exactly, and the swap reformatted nothing: 0 files, 0 lines across
all 64 eligible files, with prettier reporting the same tree clean. Forced to
width 40 on real files the two tools disagree in exactly one place, a CSS
multi-value wrap that cannot fire at width 120.

Config is .jsonc because biome 2.5 rejects comments in biome.json, and the
disabled rules need their reasons next to them. index.html and svg are
excluded: biome's HTML formatter is experimental, prerender.ts string-matches
that markup, and a prettier reflow of it once shipped the noscript line on
every page. Import sorting is off since CSS side-effect import order is
observable here.

Lint runs but fixes nothing yet. Four recommended rules are disabled with
their rationale inline rather than obeyed, because each wants to break
something deliberate: exhaustive-deps would re-run the effect whose 250ms
post-paint gate fixed React #418/#422, noImportantStyles would strip the
!important that is the entire reduced-motion mechanism, noArrayIndexKey fires
on Array.from placeholders where the index is the identity, and the two
non-null assertions are guaranteed by build-time assertions in the prerender
plugin.

CI now runs lint and typecheck before the build. vite build only transpiles,
so type errors reached master unchallenged until now.

Element fingerprint and interaction log are identical to the previous commit
on all seven route and viewport combinations. Resume measurements unchanged
at 119px slack, 0 misalign, 0 of 15 bullets wrapped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Groundwork for moving off MUI and emotion. Nothing is ported yet: MUI,
emotion, Tailwind and Ark all install side by side and the site renders
unchanged.

Preflight is disabled rather than scoped, via Tailwind's granular imports.
MUI's normalisation is global, so a scoped reset would make the two disagree
per subtree instead of globally; preflight returns with the commit that
removes MUI. Automatic source detection is off too: it matched bare words
inside sx objects, reading display:"flex" as a .flex utility, and emitted
5.2kB of CSS no element uses. The first phase that uses a utility class adds
an explicit @source.

Design tokens are mirrored into @theme and checked by a script that imports
tokens.ts, parses the @theme block, and asserts every export has a
counterpart: 14 tokens and 5 breakpoints agree. MUI's breakpoints are read
out of its source rather than recalled. tokens.ts still exports everything,
so nothing has to switch yet.

The built JS is byte-identical, unused Ark ships nothing, and both prerendered
pages differ by 104 bytes: Tailwind's unconditional legal comment, which has
no selectors, declarations or custom properties. Removing it needs
legalComments:"none", which vite also applies to the JS minifier and would
strip license blocks out of vendored code.

Landmark geometry and computed styles are unchanged across all seven route
and viewport pairs, and the console stays empty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
First five components off MUI: Footer, LogoBackground, ContentWrapper,
ExperienceDisplay, Draft. Their hand-written stylesheets are untouched; only
the MUI and emotion parts are gone.

Two structural traps found here that apply to every remaining section.
@layer utilities loses to any unlayered rule at any specificity, and
public/css/fonts.css sets font-family, size, line-height and weight on bare
p and a — emotion's classes were unlayered so MUI won for free, while a
Tailwind utility does not. Every p and a carrying MUI type needs !. And with
preflight off, border-solid and border-dashed set a style on all four edges
whose widths then default to medium, so borders must be written as one
declaration.

MUI's default font stack is now --font-sans in @theme rather than repeated as
an arbitrary value in every ported file. MUI's Typography variant metrics,
Button and SvgIcon defaults are recorded in docs/conventions.md so the
remaining sections do not rediscover them.

LogoBackground's marginTop and marginLeft were dead — margin:auto overwrote
them — and translating them literally would have introduced them, since
Tailwind orders m-* before mt-*. Only m-auto is emitted.

The landmark probe now normalises React useId values out of its keys. They
encode tree position, so removing one wrapper component renamed 48 form-field
landmarks without moving a pixel.

Gate: 0 changed, 0 missing, 0 added across all 212 home and 37 resume
landmarks at every viewport. Console empty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six slices, run in parallel and gated individually: chrome and wrappers,
the static content sections, project cards with both their layouts, the
navbar, the contact form, the references carousel and resume page, then the
four files the first slicing missed.

Interaction moves to Ark UI where it is real behaviour — certificate
lightbox, private-repo dialog, reference dialog, resume-references dialog,
and the mobile menu, which uses Collapsible rather than Popover or Dialog
because the bar grows in flow rather than floating, and modal would hide the
close control from the accessibility tree. The AI badge went the other way:
Ark's tooltip gated clean but cost twelve state machines at hydration for a
mark that takes no focus, so it is two pseudo-elements now.

Everything else is styling translated value for value out of the recorded
baseline, not redesigned. MUI's invisible defaults had to be reproduced by
hand: Typography variant metrics, Button's uppercase and min-width, SvgIcon's
root, and the fact that unlayered bare-element rules in fonts.css beat any
Tailwind utility, which is why type utilities on p, a, h1-h3, label, span,
pre and blockquote carry !. Chips have always rendered in fonts.css's 13px
Helvetica rather than the chip's nominal 12px Roboto, and that is preserved.

MUI's TextField was the biggest single win: nothing imports Select, Menu or
Popover any more. No file imports MUI at all now; four comments cite it as
the source of inlined icon paths.

Also fixes a resume bug found on the way. The sheet measured itself only from
the iframe's load event, which React attaches at commit, so on a warm cache
the event fired before hydration and the height stuck at its fallback: a
returning visitor saw a skeleton where the resume should be. It measures on
mount when the document is already complete, through a one-shot guard,
because a second reading re-adds the document's padding.

Landmark geometry and all 33 recorded computed properties are unchanged
across every section at four viewports on / and three on /resume/, against a
baseline rebuilt from pristine MUI carrying only the sheet fix. Console is
empty. JS is 498.69kB raw down to 347.60kB, 110.47kB gzipped, before MUI is
even uninstalled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mui/material, @mui/icons-material and all of emotion are uninstalled, and
popper, react-transition-group, @babel/runtime, regenerator-runtime and
emotion's cosmiconfig chain fall out with them. 22 overrides entries go too.

JS drops 498.69kB to 347.71kB raw, 160.24kB to 110.04kB gzipped: a third of
the bundle, with nothing on the page changing. CSS grows, because the
utilities are the styles now.

The emotion critical-CSS extraction lived in entry-server, not the prerender
plugin, and it is gone along with the NODE_ENV line that existed only to keep
emotion's generated class names stable. Every prerender assertion survives.
The whole shipped-HTML diff is one line per page: the empty style element
emotion used to leave behind.

Preflight stays OFF, and the reason recorded in the config was wrong. It was
never fighting MUI's normalisation — this site has no reset at all. MUI styled
only what it rendered and everything else has always been the browser's own
stylesheet, which is what every component was ported against. Enabling
preflight moves 845 landmarks: * { margin: 0 } alone shifts the page 8px
because body's margin dies, html's font-family and line-height replace the
inherited defaults, a { color: inherit } drops the UA blue on 91 card-art
anchors, and form controls jump from 13.33px to 16px. Compensating it back
rule by rule still left 252 changed, and a preflight reverted rule by rule is
just the UA stylesheet reimplemented in the author origin.

Also bounds the navbar's landscape min-height to max-sm. Tailwind emits
breakpoint variants before orientation ones, so the unbounded form wins at
every width; it does not bind today only because the logo link already
measures 64px. The same shape had already cost the toolbar spacer 16px at two
viewports.

Gate: 0 changed, 0 missing, 0 added over seven runs, all 212 home landmarks at
four viewports and 37 resume landmarks at three, against pristine MUI. Console
empty. Every interactive surface driven and observed: menu, pager, load more,
all 12 deep links at top 96, carousel, dialogs, lightbox, the Oasis request
prefill, and the mailto with no network call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
common.css underlines every anchor on hover, which reads as a link affordance
on a button. The four button-shaped anchors opt out.

Suppression is unconditional rather than scoped to hover. Tailwind wraps every
hover: variant in @media (hover: hover), so hover:no-underline! carried
!important, sat on the right elements, and applied on neither touch nor
headless. These are buttons; they should not underline in any input mode.

The opacity dip stays, since that is the site-wide hover affordance. Landmarks
at rest are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A four-lens review of the branch found two behavioural defects that neither
the landmark gate nor typecheck can see, because both need interaction.

One Escape dismissed two layers. Navbar listened on window unconditionally
and Ark does not stop propagation, so with the mobile menu open, dismissing
any dialog also closed the navigation under it. The listener moved to the
capture phase and defers to an open dialog, which reads the layer stack as it
was when the key was pressed rather than depending on listener order. That
also removes a race where the navbar and Zag both wrote activeElement in one
tick.

Autoplay stole keyboard focus off the quote being read, every ~38s: focus was
a 30s timed park while opening a quote was a stop, so the interval fired, the
slide went aria-hidden, and the a11y pass moved focus to the next referee.
Focus now holds autoplay with no clock running and releases on blur. Not a
stop, because onFocusCapture also fires when a mouse user clicks an arrow,
which would have ended autoplay for pointer users.

Also: certificate dialogs name their own certificate rather than both
announcing "Certificate"; window.open carries noopener; and the focus ring is
visible on the three amber controls, where outline: currentColor drew white
on white and left the contact form's submit with no indicator. The
[outline:0] fragments that were meant to suppress it never did — layered
utilities lose to the unlayered rule.

Dead code: theme/tokens.ts drops to the one export with consumers, the
preview config and the MemoryRouter branch it stranded are gone, and
publish.ts loses a fallback for a migration that finished. The Stack taxonomy
and AI prose move into data/, where this repo's own convention says facts
live. cdp.ts narrows its parsed JSON instead of annotating an any.

Docs: 16 false claims corrected against the code, and the porting section
rewritten from an append-only log where each agent bolted a correction onto
the end, so the first account a reader met was the wrong one.

Gate: 0 changed, 0 missing, 0 added. Resume 119/119, 0 misalign, 0 of 15
wrapped. Console empty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It pointed at "/" like About, so clicking it went to the top of the page
rather than to the references. The comment explaining why said arriving from
/resume used to drop the reader mid-page, having scrolled past the hero —
that is now handled by the hash scroll, which retries until the target has a
box and lands it clear of the fixed bar.

Verified from / and from /resume, at 1400 and 390: lands with the section 96px
from the top against a 64px bar. About keeps "/" — its section is the top of
the page.

The only landmark that moves is that link's own href, on all seven captures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every dependency to latest. React 18.3.1 to 19.2.8, react-router-dom 6 to 7,
vite 5.4 to 8.2, @vitejs/plugin-react 4 to 6, typescript 5.5 to 7.0, types to
match, ark 5.38, react-icons 5.7. react-responsive-carousel is already at its
last release and declares no peer range, so React 19 does not disturb it.

Four source edits, all forced by a major: StaticRouter moved to the package
root in router 7, fetchpriority became fetchPriority, the ref type widened for
React 19's useRef(null), and react-attrs.d.ts is gone with the @types/react
pin that made it necessary.

overrides drops 43 entries to 1. The babel, rollup and postcss chains have no
consumer left now that vite bundles with rolldown and transforms with oxc, and
the type packages resolve current on their own. The one kept lifts classnames
past the version the carousel pins. bunfig.toml is deleted: the hoisted linker
was pinning rollup module ids against esbuild identifier names, and neither is
in the build any more. Verified by installing with the default linker and
diffing the output.

Vite 8 is a net win, measured against the same source on both toolchains:
5,652 raw bytes smaller, 3,447 gzip, CSS smaller too, and the build runs in
260ms instead of 2s. The JS grows 17.3kB gzip regardless, and it is library
code, not minification: react-dom accounts for 51kB raw of it, react-router 7
for 16.7kB. React 18 ships pre-minified by Closure and React 19 does not, so
that was checked directly by minifying both standalone.

Landmark geometry and computed styles are unchanged across all seven route and
viewport pairs, console is empty, resume measures 119/119 with 0 misalign, and
every interactive surface was driven against both builds side by side.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The app matched four static paths and paid a routing framework for it. Of
react-router 7's bytes, ~10kB was Form, useSubmit, NavLink and prefetch code
never imported here, welded to Link through usePrefetchBehavior so it could
not be shaken out. Removing the dependency takes 36.4kB raw and 12.3kB gzip
with it — more than the library's own weight, since Routes, Route,
matchRoutes, resolveTo and the runtime go too.

src/router is five files: a provider holding location and a navigation
counter, useLocation, useNavigate, and a Link that renders a real anchor and
pushes state only on unmodified primary clicks, so middle-click and
ctrl-click still open a tab and the links work without JS. Route matching is
a table of four exact shapes, not a pattern engine.

location.key keeps react-router's semantics exactly: a counter incremented on
every navigation including one to the URL already showing. Keyed on
pathname+hash instead, clicking Additional projects a second time would do
nothing, and MoreProjects would not re-reveal a paged-out card.

Hydration is unchanged: App reads window.location before hydrateRoot and
passes it down, so nothing reads a browser global during render; the server
takes the url as a prop. HomeRoute still renders Home and redirects from an
effect, because nginx serves the prerendered home for any unprerendered path.

Not reimplemented, and listed in architecture.md so re-adding any of it is
deliberate: NavLink, Form and actions, loaders and data routers, Outlet and
nested routes, params and pattern matching, useSearchParams, location.state
and search, basename, scroll restoration, prefetch, blockers, MemoryRouter.

Landmark geometry and computed styles unchanged across all seven route and
viewport pairs, run twice. Console empty on /, /resume/, all three slug
redirects and a bogus path, at both viewports. All 12 deep links land at top
96 cold and via a hash after first paint; back and forward, the double-click
case, middle-click and ctrl-click all verified against both builds.

The prerendered HTML is byte-identical apart from 18 data-discover attributes
react-router used to emit on its own anchors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The markup reads as a stray anchor because the element is invisible unless it
holds focus. Note in AppFrame says what it is and why it comes first; the CSS
records the two constraints that look like quirks: it is off-screen rather
than display:none because either of those would take it out of the tab order
and kill the pattern, and its z-index clears the navbar's 1100 so it is not
drawn under the fixed bar at the one moment it is meant to be seen.

Comments only. JS bundle hash unchanged; landmarks unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
og.png was a screenshot of a much older site. It read "React Fullstack
Software Engineer", said he was seeking an entry-level part time position or
internship, and carried a typo — on every share, against a page claiming 2.5
years owning production infrastructure. The words in the head were right the
whole time; the picture was not.

The replacement is a designed card rather than a screenshot: wordmark, name,
headline, thesis, the phoenix masked in brand amber, and three measured
figures. Copy is read out of src/data/site.ts by the renderer, so the card
cannot disagree with the page it links to, and `bun run og` rebuilds it. That
is the actual fix — the old one went stale because regenerating it was a
manual act nobody remembered.

The mark is masked, not filtered. The asset ships grey, and a filter over grey
can only approximate the amber.

Head gains what was missing: og:image:type, :width, :height, :alt for screen
readers announcing the card, og:locale, and the three profile:* tags og:type
profile admits.

Landmarks unchanged; the card is not part of any page's layout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
src/build held a single file while tools/ held the card renderer, so the same
kind of code lived in two places. prerender.ts moves in with og-card.ts: src/
is application code Vite compiles, tools/ is what runs in Node at build time.
That is also the honest answer to why entry-server.tsx stays in src/ — it is
compiled into the app graph, the plugin only drives the result.

tools/ deliberately stays out of the tsconfig include. prerender.ts is already
checked through vite.config.ts's import; adding the directory pulls in
og-card.ts, which needs @types/bun, whose globals collide with lib: DOM across
every file in src/.

Every colour literal in the site is now a token in @theme, named by role
rather than appearance: --color-ink and its nine alphas, the surfaces, the
pager dots, the scrims, the skeleton tones, the three brand marks, and the
four MUI shadows the port had inlined. Comments that cited a hex name the
token instead and keep the reasoning — the contrast ratios and the owner's
decisions are the part worth reading.

Eleven tokens sit in a second `@theme static` block: Tailwind tree-shakes
variables it cannot see used, and it does not see hand-written sheets or
inline style values, so those would have resolved to nothing.

Two colours must stay literal and say so: meta and link attribute values
cannot take a var. The brand intro's amber rule is a var and costs a
colourless 1px rule for the first frames of `bun run dev`, where no stylesheet
is inlined yet — measured, dev-only, and cheaper than a second copy of the
brand amber.

CSS grows 2kB raw, 0.4kB gzip, for the indirection. Landmark colours,
geometry and computed styles unchanged; console empty; the card re-renders
byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
[FIX] Point the dnd-toolkit card at the repo that exists
Some checks failed
PR Build / pr-build (pull_request) Failing after 11s
058e9a59b7
The card linked to dunemask/dnd-toolkit; the repo lives under elysium and is
now public. Anonymous checks of all twelve project links: eleven return 200,
and the one that does not is oasis, which is gated deliberately and opens the
request dialog rather than a link.

The only landmarks that move are that card's two anchors, on the four home
captures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
dunemask merged commit eb4feea068 into master 2026-08-03 23:43:36 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
elysium/nile!1
No description provided.