Migration log
A day-by-day record of how this site was rebuilt — field notes that double as research for From Potatoes to Programs.
Migration Log — Carrd → Astro + Firebase
Append-only daily log. Each entry: date, what shipped, what broke, what was learned. This file is part of the deliverable — it feeds a chapter of From Potatoes to Programs. Write it for that audience.
2026-05-15 — Day 11: Project detail pages + shared-element morph
Picking the site back up after a few days. Two quick visual increments in one session: scroll-driven section reveals (v0.1.6, the previous entry omitted because it was a CSS-only ~30-line change) and a structural one — every showcase repo now gets its own internal detail page at /projects/<slug> with a View Transitions API morph from the card to the heading.
The motivating bug was subtle: Carrd had nowhere to go because everything was one page; the new Astro site has a /projects index, but each compact card linked straight to github.com. So the most-changing surface of the site immediately leaked visitors out to a third-party UI. Building an internal detail page (1) keeps visitors in the deck’s voice, type, and color, (2) gives Astro’s ClientRouter something to morph into, and (3) auto-extends — any future repo tagged oscar-neira-showcase automatically gets a detail page at build time, zero per-repo code.
Shipped
/projects/[slug].astroand/es/projects/[slug].astro. Both routes usegetStaticPaths()to prerender one HTML page per showcase repo. Detail page renders: large monospace H1 (the repo name, carryingview-transition-name: project-<slug>), description, meta row with language chip + ★ stars + last-updated time, and two external action buttons (View on GitHubalways,Live demoonly when GitHub’shomepagefield is non-empty).ProjectCardCompactrewired to wrap in<a href="/projects/<slug>" data-astro-prefetch>and carry the matchingview-transition-name: project-<slug>inline style on its<article>. The previous externalView on GitHub →link is removed — clicking anywhere on the card now morphs into the internal detail page; the external GitHub link lives one click deeper on the detail page itself.src/lib/slug.ts— one tiny helper. Lowercase, collapse non-alphanumerics to hyphens, strip edges,p-prefix on leading-digit. Same string is used for the URL segment and the morph name so debugging is trivial.- Reduced-motion fallback comes free from the View Transitions API spec — the browser suppresses the animation under
prefers-reduced-motion: reduceand just swaps content. Astro’s ClientRouter honors this. No extra code.
What broke
- Nothing yet —
pnpm devis clean on every route. The catch is that the GitHub fetch returns[]for OscarNeira right now (zero repos are topic-taggedoscar-neira-showcase), so there are no compact cards on/projectsto morph FROM. The detail-page system is correct and prerendered; it’s just visually invisible until at least one repo gets the topic. Once Oscar tagsmaverick-ops(and any others),pnpm buildemits one detail page per tagged repo and the morph plays.
What I learned
- Class-based CSS can’t drive
view-transition-nameper-instance. When you have N cards with N unique destinations, the only correct pattern isstyle={view-transition-name: project-${slug}}on each card. Inline style is the documented Astro/View Transitions API recipe. Novar()indirection — the property is special. - Slug = URL segment AND morph name. Keeping both derived from the same
slugify()call means there is exactly one way for them to drift apart (a bug in slugify), not two. Worth the obvious helper. - Wrapping a card in
<a>breaks:focus-withinon the inner article. The link is the parent, not a descendant, so the article’s:focus-withinno longer fires for keyboard users. Fix: add explicit.project-card-compact-link:focus-visible .project-card-compact { border-color: var(--color-accent); }so keyboard focus still shows the accent border.
Quote-worthy
“
view-transition-nameis the kind of CSS property that feels like it shouldn’t work — until you realize the browser is doing every frame of the morph for you. Two strings, one matched name, and the rect interpolates.”
2026-05-11 — Day 9: GitHub-curated /projects + social prominence
Third release of the day. Oscar’s feedback after v0.1.4 was direct: “/projects looks empty (we have things publicly on GitHub)” + “social media is really hard to find — put it under the picture and visible on all the pages.” Both shipped together because they’re both “make the site less friction-y” work.
Shipped
- Build-time GitHub fetch on
/projects. A newsrc/lib/github-fetch.tscallshttps://api.github.com/users/OscarNeira/repos?per_page=100&sort=pushedwith a 5-secondAbortControllertimeout, filters tofork === false && topics.includes('oscar-neira-showcase'), and returns a typedRepoCard[]. On ANY failure (network, non-2xx, JSON parse, timeout) the helper logsconsole.warnand returns[]— the build never fails because of GitHub. Surfaced repos render below the CANI featured card as compact cards (repo name + description + language chip + ★ stars +Intl.RelativeTimeFormattimestamp +View on GitHub →link). Until Oscar tags repos withoscar-neira-showcase, the section heading doesn’t render; the page degrades cleanly to CANI-only. - CANI card centered,
/projectsbecomes featured + grid. The CANI card stays as the hand-written hero of the page; the GitHub grid lives below it as a 1-col-mobile / 2-col-≥640px section labeled “Open source” / “Código abierto”. - Hero SocialRow restored under the portrait. The deck-grid (text + portrait) closes, then a sibling
<div class="hero-social">renders a centered<SocialRow align="center" size={26} />at cascade-delay 320ms (80ms after the deck buttons settle, ending the entrance choreography). Phase 3 step 1’s “Hero SHALL NOT contain a SocialRow” requirement was explicitly reversed — the spec delta documents the change. - Footer SocialRow promoted. Footer now leads with a
CONNECT/CONÉCTATEsection-label + a larger (size 22, up from 18) SocialRow, stacked above the copyright/last-updated/built-with/log meta line. The side-by-sidesm:flex-rowlayout is dropped — social gets full row width on every viewport. - i18n strings extended. New
projects.openSource.{heading, viewOnGitHub, updatedPrefix}and top-levelconnectkeys; symmetric EN/ES, enforced by the typed-strings discipline. - Tests. 45 new Playwright specs across
tests/e2e/social-prominence.spec.ts(hero + footer presence, CONNECT label, SVG sizes, DOM order, fade-in cascade) and extendedprojects.spec.ts(CANI centering, conditional open-source section, compact card link safety). Two pre-existing tests required explicit updates because they enforced the now-reversed Phase 3 step 1 contract (smoke.spec.ts“SocialRow is in the footer, not inside main” andsocial-row.spec.ts“4 links on /”).
Build numbers
pnpm build | clean, 14 pages, 0 errors / 0 warnings, ~5.2s |
pnpm typecheck | 0 errors / 0 warnings / 1 pre-existing hint |
| New runtime deps | 0 — native fetch at build time, no Octokit |
| GitHub repos surfaced today | 0 (none tagged yet — Oscar tags after deploy) |
| GitHub fallback verified | yes — page renders CANI-only when fetch returns [] |
| CSS added | ~2.5 KB (compact-card + open-source-grid + hero-social + footer-connect-block + section-label.text-center) |
| Playwright | 387 / 387 green (342 baseline + 45 new, 11 intentional mobile skips) |
Broke / adjusted
- Two pre-existing tests had to be updated because they encoded the Phase 3 step 1 design decision that v0.1.5 explicitly reverses.
smoke.spec.ts:141asserted “SocialRow is in the footer, not inside main” (now: hero + footer both);social-row.spec.ts:14asserted exactly 4 links on/(now: 8, one row per location). Both were rewritten to assert the new contract with a header comment explaining the reversal. Lesson confirmed: when a spec is reversed, the tests that enforced it must be updated atomically with the implementation. The OpenSpec delta workflow makes this explicit by requiring the modified-requirement copy in the change proposal. - No GitHub repos tagged yet on the live deploy. The graceful-fallback contract makes this a no-op visually; the open-source section heading is hidden when the array is empty. Oscar will tag whichever public repos he wants surfaced (candidates from the API survey:
cassandra-kubernetes-tut-demo← Tampere University,HomeCoffeeMakerUI← oscneira.coffee). Next build picks them up automatically.
Learned
- “Make it findable” beats “make it elegant” when the user reports a friction. Phase 3 step 1 was a deliberate design decision (hero is for the deck buttons; social belongs in the footer). It was the right call for the hero’s visual purity. But the LIVING test was “can a visitor find Oscar’s LinkedIn in 5 seconds” — and Oscar saw real visitors fail. The right move was to take the elegance hit and restore findability. The OpenSpec workflow lets you reverse a prior decision honestly — the modified-requirement copy carries the history, the migration log carries the rationale.
- Build-time fetch is the right shape for static portfolios. Astro’s frontmatter
awaitis the cleanest pattern: zero runtime cost, no CORS, no token-leak risk, deterministic per build. The graceful fallback decouples the deploy from GitHub uptime. Six lines oftry/catch+AbortControllergot us a feature that can’t break a deploy even if GitHub is down for 4 hours. - Topic-tag curation is the right friction balance. “Show everything personal” is too noisy (the API survey turned up 5-year-old experiments). A hand-coded allowlist in TypeScript is too sticky (every new project needs a code change). A topic on GitHub is one CLI command —
gh repo edit ... --add-topic oscar-neira-showcase— and the next build picks it up. The curation lives where the repos live.
Quote-worthy
“Phase 3 step 1 was the right elegance call. v0.1.5 is the right user-friction call. The repository remembers both — the OpenSpec delta is the receipt.”
Open / carried forward
- Tag some repos. Until Oscar runs
gh repo edit OscarNeira/<repo> --add-topic oscar-neira-showcaseon at least one repo, the open-source section is empty (correctly — the section heading is suppressed). Candidates from the API survey:cassandra-kubernetes-tut-demo,HomeCoffeeMakerUI,cass-dropwizard. - GitLab integration. CANI lives on Oscar’s personal GitLab, not GitHub. The current fetch is GitHub-only by design. A future
gitlab-projectscapability could mirror the same topic-tag pattern against GitLab’s REST API. - Pagination at >100 repos. Today Oscar’s GitHub returns 100 repos exactly in one call. If he ever crosses that threshold, the fetch needs
page=2handling. Defer until it bites.
2026-05-11 — Day 8: Vanta birds backdrop in CANI colors
Oscar shipped the /log route in the morning. By the afternoon he wanted the next layer of depth — an animated backdrop. He sent the Vanta.js BIRDS demo (https://www.vantajs.com/?effect=birds) and asked for CANI brand colors. v0.1.4 lands the same day as v0.1.3.
Shipped
- Vanta.js BIRDS effect rendered into a fixed-position
<div id="vanta-bg">atz-index: -2, behind everything. The existing noise overlay atz-index: -1composes on top viamix-blend-mode: multiply, so the moving birds get the paper-grain texture for free. - CANI-themed palette per theme. Light: cream
#fafaf7canvas + Tampere navy#1B2A4Abirds + CANI gold#E3C927highlights. Dark:#0e0f12canvas + CANI gold#E3C927birds + terracotta#c7613bhighlights. Both palettes have visual precedent on the site (CANI logo, Konami aurora gradient) so the birds don’t introduce a new color out of nowhere. - Four hard gates keep the cost off the wrong devices:
prefers-reduced-motion: reduce,(max-width: 767px),navigator.connection.saveData, and aurora-egg-active. Each short-circuits BEFORE the dynamicimport()of Three + Vanta. Mobile, reduced-motion, and save-data users fetch zero bytes of WebGL machinery. - View-Transitions-safe lifecycle.
astro:before-preparationdestroys the effect,astro:page-loadre-schedules a mount (idempotent). AMutationObserveron<html data-theme>+<html data-easter-egg>triggers destroy+remount on every theme flip or aurora toggle. The module-scopedeffectreference is the single source of truth; we never query the DOM to find existing effects. - CSS-only aurora coexistence.
[data-easter-egg='aurora'] #vanta-bg { display: none; }hides the div; the observer also callsunmount()so GPU resources are freed. Aurora dismiss → observer fires → scheduleMount → birds return. - Three.js pinned to 0.134.0. Modern
0.184.0producedTypeError: L is not a constructorbecause Vanta 0.5.24 still usesTHREE.Geometry, removed in r144 (2022). The diagnostic took 30 minutes of probing in headless chromium — Playwright initially failed silently because my code wrappedBIRDS({...})in try/catch with only a console.warn. A standalone probe (/tmp/vanta_probe.mjs) surfaced the real stack trace. tests/e2e/vanta-backdrop.spec.ts— 29 specs. Highlights: every route has the mount div (parameterized across all 10 routes); canvas renders on desktop+motion; canvas absent on mobile / reduced-motion / aurora-active; heavy chunks (three.module,vanta.birds.min) NEVER fetched when gated; aurora unlock destroys + hides; aurora dismiss re-mounts; theme toggle replaces the canvas node (DOM identity check viadata-stamp); SPA navigation re-mounts on the destination; stubbedHTMLCanvasElement.prototype.getContextfailure stays silent.
Build numbers
pnpm build | clean, 14 pages, 0 errors / 0 warnings, ~1.9s |
pnpm typecheck | 0 errors / 0 warnings / 1 pre-existing hint |
| JS added (desktop, lazy) | three.module.*.js ~716 KB + vanta.birds.min.*.js ~28 KB = ~744 KB behind dynamic import() |
| JS added (mobile / reduced-motion / save-data) | 0 bytes — gate short-circuits before import |
| JS always-loaded (lifecycle wrapper) | ~2.8 KB |
| CSS added | ~340 bytes (#vanta-bg + aurora hide rule + layered-backdrop comment block) |
| Playwright | 342 / 342 green (313 baseline + 29 new, 10 intentional mobile skips) |
Broke / adjusted
three@0.184broke vanta (see Shipped). Pinned tothree@0.134.0. The fix is inpackage.json; future bumps need to verify against Vanta birds explicitly.- First Playwright run failed silently. My try/catch wrapped Vanta init with only
console.warn. The test assertedcanvasCount === 1and saw0; no stack trace surfaced through the test output. Standalone probe vianode /tmp/vanta_probe.mjs(Playwright via the local@playwright/test/index.mjs) printed the actualTypeError: L is not a constructor. Lesson: when wrapping third-party init in try/catch for defensive purposes, log the FULL error to the page console (not just a warning summary) — the test harness will see it. Or, during development, deliberately re-throw inside the catch. - Test regex was too broad.
/vanta|three\.module/imatched the always-loaded lifecycle wrapper script (its compiled filename starts withVantaBirds.astro_astro_type_script_*). Tightened to/(three\.module|vanta\.birds\.min)\./iso only the heavy chunks count.
Learned
- Dynamic
import()is the bundle-cost firewall. Puttingimport('three')behind a runtime gate isn’t optimization theater — it’s a contract. The build’s module-graph analysis correctly emits Three.js as a separate chunk; mobile users never request the URL. Confirmed by capturingpage.on('request')and asserting the chunk URL never appears. The gate is the feature, not the animation. - WebGL is fragile by API contract. Three.js made breaking changes seven major versions ago; Vanta hasn’t been updated. Pinning the dep was the right move — the alternative (forking Vanta to use
BufferGeometry) is a multi-day commitment for an ambient animation. Sometimes the right engineering is “pin and document.” - Composition with existing layers paid off. The noise overlay + aurora egg both pre-existed. Slotting the birds at
z-index: -2with explicit destroy-on-aurora-attribute meant zero existing tests changed behavior. The 313 prior specs were all still green on the first try — that’s how you know the composition is right. - CANI brand reuse is its own kind of restraint. Using
#1B2A4A+#E3C927for the birds isn’t “I picked nice colors” — it’s “the same colors that appear on /projects and inside the Konami aurora gradient.” The site’s color system gets reinforced, not stretched.
Quote-worthy
“The gate is the feature, not the animation. Mobile users never pay for Three.js — that’s the contract. The birds are decoration on top of that contract.”
Open / carried forward
- Tablet portrait (768–1024px) — currently includes vanta. If Lighthouse perf there is bad, raise the gate to
>= 1024px. Defer until real-world data. - Lighthouse perf on desktop will drop. The CI Lighthouse gate (currently 95/100/100/100) may need a desktop-perf threshold relaxation. Watch the first post-deploy run.
- Vanta + future three.js upgrade path — if Vanta gets unmaintained for too long, the right next move is either (a) fork Vanta and replace
THREE.GeometrywithBufferGeometry, or (b) replace the effect with a hand-rolled CSS / Canvas2D bird flock (the boids algorithm is ~80 lines).
2026-05-11 — Day 7: The log adds itself to the site (/log route)
The migration log — this very file — became a public route today. /log and /es/log now render the day-by-day field notes as a vertical timeline, on the same canvas as the rest of the deck.
Shipped
- New route
/log— rendersdocs/migration-log.mdend-to-end as a single article. Uses Astro 5’s direct markdown import (import { Content, getHeadings } from '../../docs/migration-log.md') — no MDX, no content collection, no glob; the docs file stays where it belongs (book-research source) and the route imports across thesrc/boundary. /es/logmirror — Spanish chrome (page title, meta description, back button, header note), shared English body. The header note is a muted aside: “Notas de campo — estas entradas están en inglés.” The locale picker still works; visitors choose between reading log entries with Spanish framing and reading log entries with English framing. The entries themselves are English on both because the book chapter is English.- Vertical timeline rail on the article column. CSS-only:
border-left: 2px solid color-mix(in srgb, var(--color-accent) 35%, transparent)on.log-rail, plus an absolutely-positioned::beforedot on each Day H2 (background: var(--color-accent), ringed bybox-shadow: 0 0 0 4px var(--color-canvas)so it reads as floating on the canvas, not glued to the rail). Visible at ≥768px, collapses to a clean single column below. - Sticky day-TOC sidebar at ≥1024px (
position: sticky; top: 5rem; max-height: calc(100dvh - 6rem)). One entry per## YYYY-MM-DD — Day N: TitleH2, derived fromgetHeadings()and filtered todepth === 2. The H2 text is parsed with/^(\d{4}-\d{2}-\d{2}) — (Day [\d.]+): (.+)$/so the TOC rendersDay N — Titlewith the date on a secondary line. Regex falls back to raw H2 text on miss. - Prose styling for the rendered markdown. H3 subsection headings (“Shipped”, “Build numbers”, “Learned”, “Quote-worthy”, “Open / carried forward”) inherit the deck’s existing muted-uppercase section-label treatment. Blockquotes get pull-quote treatment: 3px sienna left border, 5% accent-tinted background, italic Raleway, larger font size. Tables get compact bordered cells. Inline + fenced code get a 6%-text-tinted background and the project’s mono stack.
- Footer cross-link to
/logon every existing route. Quiet--color-text-mutedtext with a dotted underline; flips to--color-accenton hover. ES routes link to/es/log. Discoverable from every page; doesn’t compete with the four deck buttons. - Smooth-scroll for in-page anchors, scoped to the log body class and wrapped in
@media (prefers-reduced-motion: no-preference)so reduced-motion users get instant jumps. - i18n strings — new
lognamespace with seven keys (label,pageTitle,pageDescription,intro,fieldNotesNote,tocAriaLabel,crossLinkLabel), symmetric across EN + ES, enforced by the existing typed-strings discipline. - Playwright suite: 253 → 313 specs (+60). New
tests/e2e/log.spec.tscovers: 200 + zero console errors, all 8 Day H2s rendered (count matches source), localized chrome (title, description, html-lang, canonical), TOC visible ≥1024px anddisplay: nonebelow, TOC anchor targets exist and accept click → scroll navigation, rail-dot ::before is non-transparent at ≥768px, blockquote pull-quote styling, table border styling, back-link href per locale, footer cross-link on every route, smooth-scroll respects reduced-motion.
Build numbers
pnpm build | clean, 14 pages (was 12), 0 errors / 0 warnings, ~1.4s |
pnpm typecheck | 0 errors / 0 warnings / 1 pre-existing hint (unused otherLocale in Layout.astro — not from this change) |
| HTML added | /log.html ~28 KB, /es/log.html ~28 KB (the markdown body is the same on both) |
| CSS added | ~3.4 KB minified (timeline rail, sticky TOC, prose styles, pull-quote, table, code, smooth-scroll, cross-link) |
| JS added | 0 bytes — rail is CSS, TOC is CSS-sticky, scroll is CSS-smooth |
| Playwright | 313 / 313 green (+60 new specs across desktop + mobile, 1 intentional mobile skip carried over) |
Broke / adjusted
page.locator('#2026-05-09-...')failed in Playwright with'#2026-...' is not a valid selector— auto-generated heading IDs start with digits and a bare#idselector is invalid CSS.CSS.escapeisn’t available in Playwright’s selector-string context either. Fix: switch to attribute selector[id="${id}"]. Two tests adjusted; suite green on retry.- No other regressions. No pnpm corruption today (the streak holds since Day 6). No font-loading or hreflang drift. The
bodyClassprop onLayout.astro(introduced forbody.log-body { scroll-behavior: smooth }) is opt-in — every other route renders with<body>exactly as before.
Learned
- “Direct .md import” is the right call for a one-file route. Content collections shine when you have N entries with a shared schema and want a query layer; here we have one file that doubles as a documentation deliverable. Importing it from
../../docs/migration-log.mdkeeps the source where it belongs (consumed by the book chapter, bydocs/, and now by the site — all from the same path). The collections scaffold undersrc/content/is reserved forbook/,now/,talks/percontent.config.ts— adding alog/collection would have created two homes for the same file. One source of truth beats two synchronized homes every time. - CSS-only is enough for a timeline. The rail is a
border-lefton a wrapper, the dots are::beforepseudo-elements on each H2, the dot ring isbox-shadownot an outline. No SVG, no per-day component wrapping, no JS. The<Content />output is flat HTML and stays flat; the rail wraps it. Total CSS added: ~3.4 KB. Total JS added for this feature: zero. The “fancy” parts (sticky TOC, smooth scroll) are also CSS —position: stickyandscroll-behavior: smooth. Modern CSS pays for itself. - Auto-generated heading slugs leak into test selectors. GitHub’s slugger produces IDs like
2026-05-09--day-6-...— perfectly fine for<a href="#...">round-trips (the browser does its own escaping), but they fail Playwright’s CSS-selector parser because they start with a digit. The fix is one line ([id="..."]instead of#...), but the lesson is: if you ever script-test against auto-slugged headings, write[id="${id}"]from the start. - The log being a public route is the point of having written it. Day 0 said “write this for the book chapter audience.” Day 1.5 said “build the replica first, evolve later.” Day 7 says “the evolution that makes the replica worth more than the original is publishing the build log itself.” The cheapest, highest-signal “this is a memoir-engineer portfolio” move was to take the file we’d been writing daily and route it. The portfolio explains itself because the file explained itself.
Quote-worthy
“The portfolio explains itself because the file explained itself. The cheapest, highest-signal ‘memoir-engineer site’ move was always to take what we’d been writing daily and just route it.”
Open / carried forward
- Transparent PNG/WebP for
about_me.jpgandwork.jpgstill pending — the dark-modefilter: brightness(0.92) saturate(0.92)band-aid stays in place until rembg’d versions land. - DNS swap to
oscar-neira.comstill pending — Bundle 1 in the backlog. - Mexico ↔ Finland live time strip in the footer — backlog top pick for the next “small but distinctive” iteration; pairs naturally with the new
/logcross-link there.
2026-05-09 — Day 6: UX polish pass 2 (ui-ux-pro-max + image band-aid)
Two-track day: install Oscar’s chosen UX skill, then apply its top-priority guidance to the two visible problems he flagged (boxes/alignment + portrait white-card in dark mode).
Shipped
ui-ux-pro-maxskill installed at.claude/skills/ui-ux-pro-max/vianpm i -g uipro-cli && uipro init --ai claude. 1129 lines of design intelligence: 67 styles, 96 palettes, 99 UX guidelines, 161 reasoning rules, 13 stacks. The skill auto-activates on UX work; we read its top-priority rules (accessibility, touch & interaction, performance, layout) into this proposal directly.- Tap-target floor 24×24 → 44×44 across all interactive chrome (
SocialRow,LangPicker,ThemeToggle,SiteHeader). The skill’stouch-target-sizerule is CRITICAL priority and recommends 44×44 (iOS HIG comfort) over WCAG 2.5.8’s 24×24 (legal floor). Visible glyph size unchanged; padding grew the hit area. - Dark-mode image band-aid for the three JPG illustrations:
- Hero portrait (
oscarWebCani.jpeg, white background) gets aradial-gradientmask-imagein dark mode that fades the corners to transparent. Where the JPG’s edges are pure white, the dark canvas now shows through. Center stays sharp. - About + Work cartoons (tan/cream backgrounds, edge-to-edge content) get
filter: brightness(0.92) saturate(0.92)in dark mode. Mask wouldn’t work — would crop the food cart, signs, whiteboard. Filter dims uniformly so the warm bg knocks back enough to feel like illustration, not blob. - Light mode: neither treatment applies; cartoons / portrait read naturally on cream canvas.
- This is explicitly a band-aid. The proper fix is transparent PNG/WebP sources documented in
inbox/README.md“Transparent illustrations” section.
- Hero portrait (
- Hero portrait reserved-slot (
aspect-ratio: 1 / 1.1; max-height: 70vh) so the layout doesn’t shift when the image hydrates. Honors the skill’scontent-jumpingrule. - Mobile deck-button layout: 4 buttons reflow to a 2×2 grid below 480px (
grid grid-cols-2) instead of the unpredictable flex-wrap behavior. - Footer responsive layout: explicit column-stack < 640px (social on top, text below for visual hierarchy), row ≥ 640px (text left, social right). Replaces
flex-wrapwhich produced unpredictable line-breaks at intermediate widths. - ProjectCard on
/projectsanchored to deck-grid left margin instead of centering — matches/aboutand/workalignment. - Section-label accent dot vertical-alignment tightened (
translateY(-0.05em)→translateY(-0.1em)) so the bullet sits at optical cap-center, not as a period. inbox/README.mdgained a 60-line “Transparent illustrations” section: format / dimensions / edge feathering / background / naming / tools / wire-in flow. Drops are now wire-in-and-go; no follow-up questions.
Build numbers
pnpm build | clean, 12 pages, 0 errors / 0 warnings, ~1s |
| CSS added | ~600 bytes (image treatment + tap-target rules + Hero slot + mobile grid) |
| JS added | 0 bytes |
| Playwright | 234 / 234 green in 96s |
Broke / adjusted (Day 6)
document.startViewTransitionwas already removed (Day 5) so no ripple from this proposal.- No pnpm corruption today — clean install state held through the full apply.
mask-imagebehavior verified via Playwright by readinggetComputedStyle(...).maskImage(and thewebkitMaskImagefallback for older Safari). Both populated in dark mode, both'none'in light. Tests sit beside the existing site-chrome suite.
Learned
- A skill can be tactical and not feel like ceremony. Installing
ui-ux-pro-maxand reading its top-priority rules took 5 minutes; the rules became concrete refactor targets (“44×44, not 24×24” — instant decision). The skill didn’t generate code; it set targets I then hit by hand. That ratio (read 5 min, apply 30 min) is the right shape for design tooling. - Mask + filter are perfect for the band-aid case but lose at edge-to-edge content. The Hero portrait’s tightly-centered subject made
mask-image: radial-gradienta clean fix. The cartoons’ edge-to-edge decorative content (signs, food cart) made the same mask a non-starter — would crop the storytelling. Different shapes need different treatments; a one-size strategy across all three would have failed. - The image-spec doc is itself a feature. Writing a 60-line
inbox/README.md“Transparent illustrations” section now means Oscar can drop the new files and I wire them in mechanically — no back-and-forth, no clarification rounds. Documentation as workflow infrastructure pays for itself within one cycle. - 44×44 vs. 24×24 is the kind of decision that gets made wrong by default. WCAG passes both. The skill’s CRITICAL priority on the comfort threshold (not the legal floor) is exactly the kind of opinionated nudge that improves real-world UX. Worth the few extra CSS pixels.
Quote-worthy
“Mask the white. Dim the tan. Document the real fix. Three lines of CSS bought us until the right transparent PNG arrives — and the doc that arrives with it tells Oscar exactly which PNG that is.”
Open / carried forward
- Transparent PNG/WebP sources for the three illustrations —
oscarWebCani-cutout.png,about_me-cutout.png,work-cutout.pngper theinbox/README.mdspec. Drop ininbox/; I wire in 5 min per image. - Regenerated About illustration with
TECH CARRER → TECH CAREERfix — bundle this into the transparent re-export if possible (one drop instead of two). - CANI bigger feature — Phase 3+ work whenever ready.
2026-05-09 — Day 5: Dark-mode toggle
The dormant [data-theme="dark"] tokens that have been sitting in global.css since Day 1 finally come online — auto-detection, persistence, reactive following.
Shipped
ThemeToggle.astro: small sun/moon icon button rendered top-right on every page, 110px to the left of the LangPicker. Click flips the theme.aria-pressedreflects current state;aria-labellocalized viat.theme.switchToLight/switchToDark.- First-visit detection: extends the existing inline locale-detection script in
Layout.astro<head>. Logic: readlocalStorage.theme; if set, honor it. Else readprefers-color-scheme: dark; apply if true. Runs before paint, no theme flash. - Persistence: clicking the toggle writes
localStorage.theme = 'light' | 'dark'. Subsequent visits honor that explicit choice. - Reactive
prefers-color-schemelistener: registered at end of<body>. When OS preference changes AND no explicit choice is set, the theme follows. After explicit toggle, the listener no-ops (your choice wins). - Body color transition:
transition: background-color 200ms ease-out, color 200ms ease-out;motion-safe. Defined outside@layer baseso layer ordering can’t override it. Reduced-motion users get the global 0.01ms guard (effectively instant). - i18n:
theme.switchToLight/switchToDarkkeys insrc/i18n/strings.tsfor both locales. EN: “Switch to light/dark theme”. ES: “Cambiar a tema claro/oscuro”.
Build numbers
pnpm build | clean, 12 pages, 0 errors / 0 warnings, ~1s |
| Inline JS added | ~600 bytes (toggle handler + first-visit theme block + reactive listener) |
| CSS added | ~150 bytes (toggle styles + body transition rule) |
| New fonts | none |
| Playwright | 230 / 230 green in 96s |
Broke / adjusted (Day 5)
startViewTransitionwrapper raced Playwright assertions. Useddocument.startViewTransition(() => applyTheme(next))for the visual crossfade — butstartViewTransitiondefers its callback by ~one animation frame. Click → assertion ran before the DOM mutation. Tests timed out at 30s. Removed the wrapper; the body’s CSS transition handles smoothness without the async hand-off.- LangPicker hit-area was wider than the 60px toggle offset accounted for. Picker is ~100 px wide (EN span + separator + ES anchor + flex gap + padding). Toggle at
right: calc(--spacing-4 + 60px)overlapped the picker’s pointer-event subtree → all click tests failed with “EN from - Reduced-motion test asserted
transitionDuration === '0s'but got1e-05s. The global@media (prefers-reduced-motion: reduce)guard force-setstransition-duration: 0.01mseverywhere (so any animation snaps cleanly). Computed value reads as1e-05s. Test now matches effectively zero (< 1 ms) rather than literal0s. document.currentScript.previousElementSiblingwas fragile. Initial click handler used that pattern to grab the toggle button. Astro’s compiled output may rearrange or scope-process elements, breaking the relationship. Switched todocument.querySelectorAll('button.theme-toggle')+ adataset.themeBoundguard, plus a rebind onastro:page-loadso the handler survives view transitions.
Learned
- Async UX wrappers are hostile to functional tests.
startViewTransitionis a beautiful API for visual smoothness, and dangerous for synchronous correctness. The fix wasn’t to slow the test down (sleep/wait); it was to apply the state mutation synchronously and let the visual layer catch up. Tests assert behavior; UX flair is additive. - Implicit overlap is the most expensive pixel cost in absolutely-positioned UI. Two top-right elements at different
rightoffsets seems safe — until the inner element’s pointer-event subtree extends beyond its visible bounds. The fix was a number bump; the lesson is “treat positioned components like rectangles with implicit gravity, not like points.” - The dormant-token pattern paid off again. Day 1’s “design for the Phase 2 swap from day one” decision meant Day 5 added a toggle and a detection script — zero token rewrites. Five days later, the architecture decision still holds.
Quote-worthy
“Dark mode is two weeks of design work that gets compressed into 30 minutes when the tokens were dual-mode from day one.”
Open / carried forward
- Image treatments for dark mode — Hero portrait (white bg) and the two cartoon illustrations (tan/cream bg) read as “floating photo cards” against the dark canvas. Acceptable for v1; could land a
mix-blend-mode: multiplyor per-mode source in a focused proposal. - Three-state toggle (light / dark / system) — 2-state works well; revisit if Oscar wants to “show me you’re following my system” affordance.
- Animated sun/moon morph — currently a hard swap. Cute morph would be polish-of-polish.
- Regenerated About illustration with
TECH CARRER → TECH CAREERfix — drop ininbox/about_me_v2.jpg. - CANI bigger feature — Phase 3+ work whenever you’re ready.
2026-05-09 — Day 4: Phase 3 visual polish
The site lifts from “competent replica” to “professional-looking engineer’s site” without a redesign. One single accent color introduced, restrained micro-interactions, tiny site chrome, social row relocated to where it belongs.
Shipped
- Single accent color
--color-accent: #a24e2c(burnt sienna). Used very sparingly: focus rings, link underlines, section-label leading dot, GhostButton hover fill, ProjectCard hover border, big 404 numerals, SocialRow icon hover. Body / headings / borders / backgrounds stay monochromatic. Dark-mode value#c7613bpreserved under[data-theme="dark"]for the futureevolve-design-tokensproposal. SiteHeader.astro: tiny “Oscar Neira” wordmark, top-left absolute, mirrors LangPicker’s top-right. Visible on every route, links to locale-appropriate home (/for EN,/esfor ES). Same restrained tracked-out small-caps treatment.- GhostButton refinement: hover fills with 12% accent via
color-mix(in srgb, var(--color-accent) 12%, transparent)+ accent border. Focus-visible: accent outline (replaces stark black ring). 150ms ease-out. - ProjectCard refinement: bigger
md:p-12padding, hover shifts border to accent, tagline gets accent leading dot. - Animated link underlines: outbound CANI links now have a left-to-right accent underline reveal on hover (220ms ease-out, motion-safe).
- Social row relocation: removed from Hero (
/,/es) and from the in-page/downloadcascade. Now lives in the Footer on every page, right-aligned, smaller (size=18). - Footer refinement: top hairline border (in the new soft-cream
--color-border-subtle: #d8d3c3), two-column flexbox layout (text left, social right). - Big decorative 404 numerals (
clamp(6rem, 18vw, 12rem), accent color atopacity: 0.18,aria-hidden). Stripe / Linear pattern. Adds character; the page no longer reads as a stub. - SocialRow hover: icons fade to accent color over 200ms (replaces the prior generic
opacity-60dim). - Section labels: small accent leading dot (
· P R O J E C T S). - Border-color split:
--color-border-subtleis now the soft cream hairline (footer divider, future card resting state); the strong near-black is--color-border-strong(ghost-button stroke, ProjectCard resting state). One-token expansion.
Build numbers
pnpm build | clean, 12 pages, 0 errors / 0 warnings, ~3s |
| CSS added | ~600 bytes (transitions, accent rules, error numerals, wordmark, animated underline) |
| JS added | 0 bytes |
| Fonts added | 0 bytes |
| Playwright | 180 / 180 green in 78s |
Broke / adjusted
color-mix()not supported in Safari < 16.2 (~2.5% global usage). Acceptable degradation — old browsers see border-only hover, no fill. Documented as a deliberate trade-off.- Border-color was overloaded — Phase 1 used
--color-border-subtle: #222for both the GhostButton stroke (strong) and any future hairline (subtle). They’re different concerns. Split into--color-border-strong(kept#222) and--color-border-subtle(new#d8d3c3cream hairline). Components updated. SocialRowalign=“center” prop is now dead code (no in-page row uses it post-relocation). Left in place — zero cost, supports future return to a centered focal-point usage.
Learned
- Accent restraint is the polish. Most “professional looking” upgrades fail by adding accent color everywhere — links, buttons, headings, hover, decoration. Restricting it to the smallest set of moments (focus, hover-fill, the section-label dot, the 404 character moment) is what reads as deliberate. The cream/black field still carries the page; the burnt sienna says “someone designed this.”
- Site chrome is a tax / signal trade-off. Adding the SiteHeader + LangPicker each costs a few pixels at the top corners. The signal: this is a site, not a stack of unrelated pages, and you can navigate by recognition not by recall (Nielsen #6). The deck pattern made orientation harder; chrome restores it.
- Removing the Hero social row was the biggest perceptual lift. The Hero went from “name + role + 3 paragraphs + 4 buttons + 4 social icons” to “name + role + 3 paragraphs + 4 buttons”. Less to scan, more confident. The social row in the footer is one scroll away — the same connection surface, just framed correctly.
color-mix()is the small-bytes/big-result CSS feature of 2025. A 12% accent-tint fill viacolor-mix(in srgb, var(--color-accent) 12%, transparent)ships zero new tokens, zero new utilities, zero JS. Old way required either a separate--color-accent-tinttoken or an opacity hack on a wrapping element. Worth pulling into more places.
Quote-worthy
“A polished site is mostly the same site with the volume turned down everywhere except where the eye should land.”
Open / carried forward
- LinkedIn URL — Oscar to send. One-line edit in
src/components/SocialRow.astroarray. - GitHub URL — same.
- Dark-mode toggle —
--color-accentdark value (#c7613b) is in place under[data-theme="dark"]. The toggle UI lands asevolve-design-tokens(Phase 3 step 2 if Oscar wants it). - Regenerated About illustration with
TECH CARRER → TECH CAREERfix — drop ininbox/about_me_v2.jpgwhen ready. - CANI bigger feature — Phase 3+ work (mentor profiles, sign-up flow, ES-default landing for Spanish-language CANI traffic).
- 404 illustration — a small custom illustration above the numerals would land well; defer to a focused proposal if Oscar wants it.
2026-05-09 — Day 3: Phase 2 step 1 (replica iteration + bilingual)
Two proposals shipped back-to-back. Both archived. Net: 4 EN + 6 ES routes, a real CANI section, a fade-cascade animation on /download, and 148 Playwright specs covering all of it.
Proposal #3 — iterate-social-cani-download (commit d3e6d2a)
Shipped:
- Social row trimmed from 7 → 4 icons — dropped X, Email, CANI; kept LinkedIn, GitHub, Instagram, Facebook. Phone (
tel:) was already absent. LinkedIn and GitHub still ship asURL pendingplaceholders. - Hero deck gained a 4th button:
ABOUT ME · WORK · PROJECTS · DOWNLOAD. Third button label changed fromContacttoDownloadto match the route name. - New
/projectsroute featuring CANI as the only card, via a newProjectCard.astrocomponent (N-ready: acceptstitle/tagline/description/links[]). CANI heading: “Building with students”. Description: project-led framing, one paragraph + one closing line, two outbound links (cani.lat + IG @cani.mx) withtarget="_blank" rel="noopener noreferrer". /downloadenhanced: 2-paragraph warm-engineer summary above the CTA (book name first mention, reader-direct close); CTA label changed from “Download the mini-book (PDF)” → “Download the full story here →”; fade-cascade reveal on heading → summary → CTA → social row → back button (100 ms stagger, 400 ms ease-out, pure CSS keyframes, ~200 bytes added). Cascade is silent underprefers-reduced-motion: reduce.
Proposal #4 — i18n-en-es (commit just landed)
Shipped:
- Astro 5 i18n config:
defaultLocale: 'en',locales: ['en', 'es'],prefixDefaultLocale: false. EN at/, ES at/es/*. Uses Astro’s built-in i18n routing; no manual middleware. - Centralized translatable strings in
src/i18n/strings.ts(Record<Locale, Strings>— TypeScript fails the build if a locale is missing a key). Six EN pages refactored to read from it; six ES mirror pages added. LangPicker.astro: top-right of every page,EN · EStext toggle. Active locale is non-link witharia-current="page". Inactive is the link to the corresponding mirror route. ≥24×24 px tap target, focus-visible, click-persistslocalStorage.lang.- First-visit auto-redirect: inline
<head>script runs before paint. IflocalStorage.langis set, honor it. Else ifnavigator.languagestarts withesAND not on/es, redirect to/esand persist. Else persist current locale. Wrapped intry/catchfor storage-blocked browsers. - Spanish translations drafted by Claude (warm voice matching cani.lat, not machine-translated). CANI section is original Spanish (since cani.lat is Spanish-first). Oscar reviewed before commit.
- Note on
/es/download:El libro está en inglés.rendered under the CTA. EN/downloadis silent (EN-default audience). <html lang>per locale +hreflang="en"/hreflang="es"/hreflang="x-default"alternates on every page.og:locale = en_USores_MX.- Footer is locale-aware:
Last updated/Built with Astroon EN;Última actualización/Hecho con Astroon ES.
Build numbers (after both proposals)
pnpm build | clean, 12 pages, 0 errors / 0 warnings, ~1s |
dist/index.html (EN Hero) | 16 KB |
dist/es.html (ES Hero) | 16 KB |
Total dist/*.html (12 pages) | 152 KB combined |
dist/_astro/*.css | 16 KB raw |
Fonts (public/fonts/) | 38 KB raw (3 woff2 files) |
| Image variants emitted | 30 |
| Playwright | 148 / 148 green in 60s across desktop + mobile Chromium |
Broke / adjusted (Day 3)
-
OpenSpec MODIFIED matches by exact title. I changed two requirement titles (“Four routes” → “Five routes”, “renders seven” → “renders four”) and the archive failed with “header not found”. Fix: keep the original titles, update the body, add a one-line explainer about why the title now diverges from the body. Trade-off: spec history reads cleaner; titles slightly outdated.
-
pnpmstore corruption surfaced for the third time after adding@fontsource/*andsharppackages. Same fix as Days 1.5 + 2:rm -rf node_modules && pnpm install. Documented but not yet root-caused. -
Astro
<Image>doesn’t takeformats={[]}plural — needed<Picture>instead. Same fix as Day 2 — confirmed the pattern carries through. -
Astro.url.pathnameincludes.htmlwhenformat: 'file'is set. Locale path-based logic (pathInOtherLocale) returned/es/about.htmlinstead of/es/about. Fix: normalize pathname inLayout.astrobefore passing to LangPicker:const rawPath = Astro.url.pathname; const normalized = rawPath.replace(/\.html$/, ''); const currentPath = normalized === '' ? '/' : normalized;Caught by Playwright on first run; would have shipped silently otherwise.
-
/esvs/es/—format: 'file'buildsdist/es.html(notdist/es/index.html), so/esis the canonical URL. UpdatedpathInOtherLocaleand all back-links insrc/pages/es/to use/es(no trailing slash) for consistency withtrailingSlash: 'never'.
Learned
- Spanish translation hygiene matters more than coverage. Hand-drafting “soy ingeniero, narrador y conferencista” reads as Mexican-Spanish-natural. Machine-translating “I am an engineer, storyteller, and speaker” produces “Soy un ingeniero, contador de historias y orador” — grammatically correct but immediately sounds translated. The 30 minutes spent hand-drafting was the difference between a site Oscar’s mentees would trust and one they’d notice.
format: 'file'+ i18n requires path normalization. Subtle interaction; ergonomically the framework should hide it. The Playwright tests caught it on first run because they ran againstpnpm preview(real production-build paths), not against the dev server. The rule from Day 1.5 — trust the browser, not the wire — held again, this time at the URL layer.- Centralizing strings before the second locale lands is cheap; centralizing after is expensive. The refactor of 6 EN pages to read from
src/i18n/strings.tswould have been ~3× the work if I’d waited until Phase 3 (when the page count grows). Doing it as part of i18n was the right time. LangPickeris the smallest non-trivial component on the site, but it carries the most behavior. Position (top-right absolute), state (active/inactive), URL computation (path-mirror), persistence (localStorage), accessibility (aria-current, focus, tap target). 60 lines of Astro. Worth re-reading any time UI ergonomics get questioned.
Quote-worthy
“A site that auto-redirects a Spanish browser to its Spanish mirror — without asking — is the smallest concrete way to say: I built this for you, too.”
Open / carried forward
- LinkedIn URL — Oscar to send. One-line edit in
src/components/SocialRow.astroarray, no rebuild needed. - GitHub URL — same.
- Spanish copy review — Oscar reviewed inline in chat; any remaining edits land as a one-file edit to
src/i18n/strings.ts. - Regenerated About illustration with
TECH CARRER → TECH CAREERfix. Drop ininbox/about_me_v2.jpgwhen ready. - Mini-book PDF location — still Google Drive. Self-hosting is one drop in
inbox/from-potatoes-to-programs.pdfaway. - Font budget overrun — 38 KB vs. 30 KB target. Acceptable for v1 (4× lighter than Carrd’s 161 KB). Phase 2’s
evolve-design-tokensproposal (or a dedicatedfont-budgetproposal) revisits. - Finnish (FI) localization — out of scope. Could land as a follow-up if Oscar’s FI audience grows.
- CANI bigger feature — Oscar wants a “big feature for the Kani-related work” later. CANI now has its own route (
/projects) as the entry point; expanding it (mentor profiles, sign-up flow, ES-default landing for Spanish browsers, Phase 3+ work) is the natural follow-up. - Phase 3 — Oscar mentioned UX specialization. The deck is now stable enough to start.
2026-05-09 — Day 2: Phase 1 replica
Shipped
- OpenSpec change
phase-1-replicadrafted, applied, validated strict-clean. Two capabilities modified (site-foundation,firebase-hosting), three new (multi-view-deck,replica-content,social-row). Day 1’sbootstrap-astro-firebasearchived first so the MOD deltas could resolve. - Token swap in
src/styles/global.css: dark + terracotta (Phase 2) → light replica palette perPHASE_1_REPLICA_SPEC.md§3.1.--color-canvas: #fafaf7,--color-text-primary: #0e0e12,--color-text-body: #222,--color-text-muted: #999,--color-border-subtle: #222, no accent color. Dark variant preserved under[data-theme="dark"], dormant — Phase 2 swaps the attribute and ships. - Self-hosted typography:
public/fonts/{raleway-300,raleway-700,source-sans-3-400}.woff2. Sourced via@fontsource/raleway+@fontsource/source-sans-3(the static packages, not@fontsource-variable/*— see “Broke / adjusted” below). Subsetted withpyftsubsetto Basic Latin + Latin-1 Supplement + selected punctuation. Total 44 KB (target was 30 KB — 14 KB over budget but ~4× lighter than Carrd’s render-blocking 161 KB Google Fonts payload). - Multi-view deck via
<ClientRouter />fromastro:transitions. Four real routes:/,/about,/work,/download— each crossfades, each is bookmarkable, each has its own<title>+ canonical, each sub-view has a← BACKlink. JS-off navigation still works (page-level fallbacks). Layout.astro+ 5 components:Footer.astro,GhostButton.astro,BackButton.astro,Illustration.astro,SocialRow.astro. Components sized for v1; Phase 2 can extend them in place.- Three illustrations moved from
manual-src/tosrc/assets/illustrations/, rendered through<Picture>fromastro:assetswith AVIF + WebP + JPEG fallback at responsive widths. 30 image variants emitted for the three sources (3 widths × 3 formats for the portrait, 4 widths × 3 formats for the cartoons, plus a few sizes for the lazy-loaded versions). - Hero portrait is the CANI profile photo (Oscar’s
oscarWebCani.jpeg) —loading="eager" fetchpriority="high", alt text “Oscar Neira — portrait used on the CANI mentoring platform.” Phase 2’scani-lat-featurebuilds on this prominence. /contact301 →/downloadwired intofirebase.jsonredirects. Inbound legacy links survive cutover. Structural test asserts the rule exists; emulator-backed test deferred todeploy-pipeline.- GA
G-207PJHTWMHloaded asynchronously fromLayout.astro<head>. CSP relaxed narrowly:script-srcaddsgoogletagmanager.com,connect-srcaddsgoogle-analytics.comand*.google-analytics.com. No blankethttps:allow-list. - Social row gained 3 first-class entries Carrd never let Oscar configure: LinkedIn, GitHub, CANI (cani.lat). LinkedIn + GitHub render with
href="#"andaria-label="… URL pending"placeholders until Oscar provides the URLs. Phone (tel:) icon dropped for privacy + space. Final order: X · LinkedIn · GitHub · CANI · Instagram · Facebook · Email — 7 icons. - Cayce Pollard stays gone. Footer reads
© Oscar Neira 2026 · Last updated 2026-05-09 · Built with Astroin--color-text-muted. inbox/drop folder at repo root with a tracked README;.gitignoreruleinbox/*+!inbox/README.md. Documented drop slots:about_me_v2.jpg(typo fix),from-potatoes-to-programs.pdf(self-host option),linkedin-portrait.jpg(Hero swap option), plus future-use slots for talks / now / book content.- Playwright suite expanded to 60 tests (× 2 viewports = 60 specs total):
smoke.spec.tsrewritten for light theme + Phase 1 H1; newroutes.spec.ts(4 routes return 200 + heading, sub-views have← BACK,/abouthas download CTA,/downloadhas zero forms, view-transition navigation produces no console errors,/contact → /downloadstructural assertion); newsocial-row.spec.ts(7 icons in order, accessible labels, LinkedIn/GitHubURL pending, notel:, focus visible, tap targets ≥24×24). All 60 green in 35.5s.
Build numbers
pnpm build | clean, 0 errors / 0 warnings, 5 pages |
dist/index.html (Hero) | 14.5 KB (inline GA + 7 SVG icons inflate vs. Day 1’s 4 KB) |
dist/about.html | 9.4 KB |
dist/work.html | 8.5 KB |
dist/download.html | 13.8 KB |
dist/404.html | 3.2 KB |
dist/_astro/*.css | 16 KB raw |
Fonts (public/fonts/) | 44 KB raw — 14 KB over the 30 KB target; documented overrun |
| Image variants emitted | 30 (AVIF + WebP + JPEG fallback at responsive widths) |
| JS shipped | <ClientRouter /> runtime — measured during commit; well under the 50 KB budget |
Broke / adjusted
@fontsource-variable/*packages, the natural choice, ship Latin variable woff2 at 48 KB (Raleway) + 29 KB (Source Sans 3) = 77 KB raw. Subsetting variable fonts with the full weight axis only got it to 56 KB. Switched to the static@fontsource/*packages, picked the three weights actually used (Raleway 300 + Raleway 700 + Source Sans 3 400), then subsetted again withpyftsubsetto Basic Latin. Final: 44 KB. Still over the 30 KB target but the closest a faithful replica gets without dropping the second face entirely.pnpm installproduced a corruptednode_modules/.pnpmagain after adding@fontsource-variable/*and@types/node— this timeyargs-parserwas missing, breakingastro check. Day 1.5’s lesson held:rm -rf node_modules && pnpm store prune && pnpm install. Sharp had to be re-added explicitly afterwards (pnpm add -D sharp); the reinstall didn’t pull it as a transitive dep the way the earlier install had.astro:assetsexports changed since I last touched them.ImageMetadatais exported fromastro(the package), notastro:assets(the virtual module). And<Image>acceptsformat(singular), notformats(plural) — to get AVIF + WebP, use<Picture>instead. Both fixed insrc/components/Illustration.astro.- GA’s CSP impact — the Day 1 CSP shipped
script-src 'self'only. To loadgtag.jsfromgoogletagmanager.comand let it callgoogle-analytics.com, bothscript-src(script load) andconnect-src(beacon POSTs) needed targeted hosts. I also added'unsafe-inline'toscript-srcbecause Astro’s<ClientRouter />injects an inline init script. That’s a CSP relaxation worth flagging — Phase 2 can switch to nonces when SSR endpoints are available. - The Carrd
tel:icon — assumed the recommended drop without explicit confirmation per “make one assumption and flag it” guidance. If Oscar wants it back, swap is a one-liner inSocialRow.astro.
Learned
- Variable fonts aren’t always smaller. Conventional wisdom: variable replaces N statics. Reality for typography this small: variable ships the full weight axis machinery whether you use it or not (~48 KB Latin Raleway). Three carefully-chosen statics +
pyftsubsetwon by 12+ KB. Phase 2 can revisit if more weights become useful. <Picture>≠<Image>in Astro 5. They have different prop shapes despite the API similarity. AVIF/WebP fallbacks need<Picture>withformats={['avif','webp']}and afallbackFormat. Single-format usage stays on<Image>.- GA on Firebase Hosting is purely a CSP problem, not a hosting problem. This was the spec’s claim and it held — adding two targeted directives in
firebase.jsonis the entire integration. The “no analytics on Firebase” tribal knowledge people sometimes cite doesn’t exist. - The
inbox/pattern is worth more than it costs. A 5-line.gitignorerule + a tracked README created a clean separation between “things Oscar drops” and “things Claude wires” — and removed the temptation to invent imagery from training data.
Quote-worthy
“The faithful replica is the boldest engineering choice. Every shortcut gets you closer to a redesign — and that’s not what this week is about.”
Open / carried forward
- LinkedIn URL — Oscar to send. One-line edit in
src/components/SocialRow.astro, replacehref="#"and updatearia-label. - GitHub URL — same.
- Mini-book PDF — kept the Google Drive link for v1. If self-hosting becomes preferred, drop
inbox/from-potatoes-to-programs.pdfand Claude moves it topublic/book/. - Regenerated About illustration — current
about_me.jpgships with the original CarrdTECH CARRERtypo. Drop the v2 ininbox/about_me_v2.jpgwhen ready; alt text already flags the typo for screen readers. - Font budget overrun — 44 KB vs. 30 KB target. Phase 2’s
evolve-design-tokensproposal should revisit, possibly using a single variable font + variable axis instead of two statics. - Lighthouse Mobile run — needs to happen on the deployed preview channel before the Tuesday cutover. Local
pnpm previewtest is meaningful but Firebase headers aren’t applied; defer todeploy-pipeline. - CANI profile — link is in the social row now; the bigger feature (“we will have a big feature for the Kani-related work”) lands as Phase 2’s
cani-lat-feature.
2026-05-08 — Day 1: Bootstrap (Astro 5 + Tailwind v4 + Firebase scaffold)
Shipped
- OpenSpec workspace initialized (
@fission-ai/openspec@1.3.1— note: bareopenspecon npm is a 2019 squat). Slash commands + skills installed under.claude/. openspec/project.mdwritten — single-page summary of the brief + four reference docs, used as durable AI context for every future proposal.- Change proposal #1 —
bootstrap-astro-firebasedrafted, validated strict-clean, applied. Two new capabilities specified:site-foundation(8 requirements) andfirebase-hosting(5 requirements). Proposal, design, specs, and tasks artifacts all green. - Build pipeline live:
package.json(Astro 5.18.1, Tailwind 4.2.4, TS 5.9.3,@astrojs/check0.9.9; pnpm 9.15.9 pinned viapackageManager)tsconfig.json(strict +noUncheckedIndexedAccess+~/*alias)astro.config.mjs(output: 'static',trailingSlash: 'never', Tailwind via@tailwindcss/vite).nvmrc(Node 20 LTS) ·.gitignore·README.md
- Design tokens scaffolded in
src/styles/global.cssperBEST_PRACTICES.md§1: dark canvas as default, light-mode parity for every color, terracotta + steel accents, 4-8-12-16-24-32-48-64-96 spacing, modular type scale 1.250, system font stack until proposal #2 adds the variable face. - Pages:
src/pages/index.astro(placeholder home with journey ribbon, status block, build-date footer, skip link) +src/pages/404.astro. - Content collections plumbing at
src/content.config.ts— three placeholder collections (talks,now,book) wired throughglobloaders soastro checkis honest from day one. - Firebase config:
firebase.jsonships the full security header set (HSTS preload, Referrer-Policy, X-Content-Type-Options, Permissions-Policy, CSP) plus immutable cache for assets and must-revalidate for HTML..firebasercbinds projectoscar-neira-fi(Oscar’s chosen ID). - Verified locally:
pnpm install --frozen-lockfile→ 3.9s on warm cachepnpm build→ 665ms, 2 pages, 0 TS errors / 0 warnings / 0 hintspnpm dev→ HTTP 200 served in 152ms coldpnpm preview→ HTTP 200 served in 4ms- Output:
index.html4 KB,404.html4 KB, CSS 16 KB raw / 3.8 KB gzipped, zero JS shipped. Critical-CSS budget: 30 KB. Comfortably inside.
- Git initialized on
master(per Oscar’s preference), local config bound to his identity, no remote (local-only by design until Oscar greenlights GitHub).
Broke / adjusted
- Homebrew Node ships without
corepackon this machine — the spec assumed corepack would auto-resolve pnpm frompackageManager. Adjustment:npm install -g pnpm@9instead. README quickstart updated to match. Doesn’t change the spec’s intent (pnpm v9+ available locally). PROJECT_BRIEF.md§5 saysnpx openspec@latest init. That package is a 2019 squat. Real package is@fission-ai/openspec. Recommend updating the brief — flagged as a TODO for Oscar to amend.PROJECT_BRIEF.md§8 liststailwind.config.ts. Tailwind v4 is CSS-first; that file would split the source of truth. Did not create it. Documented indesign.mdD2 +openspec/project.md.- Astro 5 deprecation. First build emitted “Auto-generating collections for folders in ‘src/content/’ that are not defined as collections. This is deprecated.” — moved config from
src/content/config.tstosrc/content.config.tsand declared the three placeholder collections explicitly viadefineCollection({ loader: glob(...) }). Second build clean. - WebFetch on
cani.latreturned 403 — Cloudflare WAF blocks the WebFetch UA. Existing audit data already covers it; re-scan can use a real browser when needed. - No global git identity on this machine. Set
user.name/user.emailat the local repo scope only (not global), bound to Oscar’s profile from~/.claude/CLAUDE.md. Override viagit config --localif the email should differ.
Learned
- Astro 5 + Tailwind v4 ships a static one-pager in 4 KB of HTML and 3.8 KB of gzipped CSS, with zero JavaScript. The current Carrd build is ~180 KB inlined just to render the same five paragraphs. The performance delta isn’t ambitious — it’s the default.
- OpenSpec 1.3.1 changed shape since the brief was written: no auto-generated
project.md, slash commands are now namespaced as/opsx:*(not/openspec:*), and the artifact build order is enforced byopenspec status --json. The propose-design-specs-tasks pipeline is cleaner than the docs the brief was based on. Useful — and means the brief’s §5 needs a small refresh. - The audit was right about the live site, and slightly under-counts how much voice copy there is to lift. WebFetch surfaced four phrases the audit didn’t mark verbatim, including “I believe in starting small, dreaming big, and always helping others along the way.” Worth pulling into the home/about narrative in proposal #3.
- The placeholder home is intentionally austere — but already on-brand. Dark matte canvas, terracotta accent, mono-style status block, journey ribbon at the top, build-date footer. Direction A is visible the moment you load
localhost. The tokens carry it; the design system in proposal #2 will only deepen it.
Quote-worthy
“Carrd ships 180 KB to render five paragraphs. We just shipped two pages, three sections, and a journey ribbon in 3.8 KB of gzipped CSS — and zero JavaScript. The migration is a thesis: the engineering itself is part of the story.”
Followup — same day, after Oscar opened it in a real browser
I declared “Day 1 done” before Oscar opened the page in his browser. He hit:
Can't resolve 'tailwindcss' in '/Users/oscneira/.../src/styles'
My curl health-checks had returned HTTP 200 and the body even contained the rendered Tailwind theme — but curl does not execute CSS imports. The browser does. A green curl is not a green site.
Two real bugs surfaced:
- Corrupted Astro install.
node_modules/.pnpm/astro@5.18.1_.../node_modules/astro/was missingpackage.jsonandastro.js— only thedist/subtree had landed.pnpm devfailed withCannot find module '.../astro/astro.js'at startup. Fixed withrm -rf node_modules && pnpm store prune && pnpm install. Root cause undetermined; possibly a corrupted store entry from a prior unrelated install. engines.nodetoo narrow. I pinned>=20 <23. Local machine is Node v25.9.0 — pnpm warned and may have shortcut some optional install steps. Loosened to>=20(no upper bound). The brief said “Node 20+”, which this honors.
The fix Oscar actually asked for: Playwright. Installed @playwright/test@1.59.1 + headless Chromium, plus @types/node (Astro check needs it for the playwright config). Test harness lives in tests/e2e/ (developer infrastructure, not site code). playwright.config.ts runs against pnpm preview — the same artifacts Firebase will serve, not the dev server — so resolution / bundling regressions can’t sneak through preview again.
Smoke suite (13 specs × 2 viewports = 26 tests, 16.1s, all green):
- HTTP 200 + zero console errors + zero failed network requests (the regression test for today’s bug — a curl-clean / browser-broken state can no longer pass).
- Single
<h1>per page (WCAG 1.3.1). - Verbatim hero subtitle present.
- Journey ribbon
Toluca · Tampere · todaypresent. - Build-date footer present.
data-theme="dark"applied.--color-canvastoken actually defined on:root— this is the test that would have caught the corrupted install.- Skip link is the first focusable element.
- Canonical, og:title, meta description, and no “Cayce Pollard”.
- Reflow at 320 px without horizontal scroll (WCAG 1.4.10).
- 404 page renders, links back home, has
noindex.
Tracked: playwright.config.ts, tests/e2e/*.spec.ts, tests/README.md. Gitignored: test-results/, playwright-report/, playwright/.cache/.
Followup — what I learned
- Trust the browser, not the wire. Headless Chromium is a 92 MB download; the failure mode it catches is unlimited. From here, every commit on this repo runs
pnpm test:e2ebefore I claim it’s done. - The “Day done” claim is itself a deliverable. Saying “Day 1 done” when the site won’t load is worse than saying “Day 1 ran into a wall.” Honest status > performative status.
- pnpm store can lie.
pnpm install --frozen-lockfilereported success while the Astro tarball was missing files. Considering pinningpnpm-storeintegrity check at the engine level for the next clean-room test.
Followup — quote-worthy
“A green curl is not a green site. The browser is the only honest test.”
Open / carried forward
- Update
PROJECT_BRIEF.md§5 to point at@fission-ai/openspecand use/opsx:*slash commands. - Update
PROJECT_BRIEF.md§8 folder structure to droptailwind.config.ts(CSS-first in v4). - Reserve Firebase project
oscar-neira-fi(firebase projects:create oscar-neira-fior via console) before proposal #8. - Decide LinkedIn presence on the new site — current Carrd has none, brief doesn’t mention it. Worth raising before proposal #6 (links/footer).
- Phone number
+358 40 550 0094was on the live site. Recommendation: drop from public site v1 unless Oscar wants it surfaced; not flagged in any of the existing docs.
2026-05-08 — Day 1.5: Course correction (Phase 1 = faithful replica)
What happened
Oscar reviewed the audit and bootstrap, then provided four screenshots of the actual current site (Hero, About Me, Work, Contact). The screenshots revealed two things the original audit missed entirely:
- The current site uses three custom illustrations (Hero portrait + two AI-generated cartoon scenes for About and Work) — not just text and a portrait.
- The site is a multi-view “deck”, not a long scroll. Hero has three buttons (
About Me,Work,Contact); each opens a sub-view with a← BACKbutton. Carrd handles this with view swaps.
The original brief said “redesign as a Principal Engineer would.” Oscar’s clarification: redesign later. Phase 1 = faithful replica, then cancel Carrd, then evolve.
Decisions ratified
- Phase 1 = pure migration, visually indistinguishable from the current site except: Contact form replaced with a
/downloadview (mini-book PDF download), Cayce Pollard footer killed,TECH CARRERtypo on the About illustration fixed. - Day 1’s dark/terracotta scaffold (Direction A) was right for Phase 2, wrong for Phase 1. The next change proposal will refactor the design tokens back to the current site’s light palette + Raleway/Source Sans Pro typography. The dark variant ships as Phase 2 opt-in.
- Google Analytics stays running through Phase 1 (
G-207PJHTWMH). Firebase Hosting can serve any static GA snippet — this was always a policy choice, not a technical one. Re-evaluate post-cutover. - Multi-view navigation implemented via Astro View Transitions (
<ClientRouter />) with four real routes (/,/about,/work,/download)./contact301s to/download. - Oscar will hand off the three illustration files (regenerated About illustration with the typo fixed). Do not invent imagery.
Shipped
docs/CURRENT_SITE_AUDIT.mdrewritten with screenshot-grounded visual reality, multi-view nav pattern, illustration inventory, and the typo flag.docs/PHASE_1_REPLICA_SPEC.mdcreated — authoritative spec for the v1 launch: success criteria, what NOT to do, visual spec, route map, component inventory, asset inventory, GA decision matrix, implementation checklist for the next proposal.docs/UI_DESIGN_DIRECTIONS.mdreframed as Phase 2+ targets, recommendation rewritten to layer Direction A as an opt-in dark mode after Phase 1 ships.PROJECT_BRIEF.md§14 updated with the phased plan and a pointer atPHASE_1_REPLICA_SPEC.mdas ”🔥 START HERE” for the next session.
Learned
- HTML audits are blind. Reading the DOM tells you about structure but not character. The illustrations on
oscar-neira.comare most of what makes it feel like Oscar’s site, and they’re invisible fromcurl. From here, every audit pairs HTML inspection with at least one rendered screenshot. - “Replica then evolve” is the right migration shape for a site with personality. Trying to redesign and migrate in the same step asks the user to evaluate two changes at once. Phasing them keeps the comparison honest.
- The Phase 1 codebase has to be designed for the Phase 2 swap. Tokens parameterized for both themes from day one, accent as a CSS variable, fonts loaded as variable axes — Phase 1 ships with one config, Phase 2 swaps the config.
Quote-worthy
“The map of a site is the HTML. The territory of a site is the screenshots. I had a perfectly good map and almost shipped a building on the wrong block.”
Open / carried forward
- Confirm with Oscar: keep the Google Drive PDF link or self-host the mini-book under
public/book/? - Confirm “Mobile” social icon behavior —
tel:link, or hide entirely? - Confirm GA decision (Option A recommended in the spec).
- Confirm phone number
+358 40 550 0094is still hidden in Phase 1. - Regenerate About illustration with corrected
TECH CAREERtext.
2026-05-08 — Day 0: Scoping & audit
Shipped
- Project folder created at
~/Documents/oscar-neira-firebase. PROJECT_BRIEF.mdwritten — full migration spec, including UX/A11y discipline (§13) and a deep audit of the liveoscar-neira.com(Appendix A).- Three reference docs in
docs/:CURRENT_SITE_AUDIT.md— live-site audit with verified findings (Cayce Pollard footer confirmed, Google Analytics live on the current site, render-blocking Google Fonts, hero image with empty alt,cani.latis the CANI mentoring platform not a Linktree).UI_DESIGN_DIRECTIONS.md— three directions grounded in the book draft and the Stories for V3 file. Recommendation: Direction A (“Toluca to Tampere”), architected so Direction C (quiet brutalist) is one config flag away.BEST_PRACTICES.md— stack baselines, perf budget, a11y floor, repo hygiene, Firebase config, deploy workflow.UX_CHECKLIST.md— runnable Nielsen + WCAG 2.2 AA + perf + SEO checklist.
Decisions still open
- Design direction (A / B / C) — Oscar to confirm.
- Tech stack confirmation (Astro 5 + Tailwind v4 + pnpm + Firebase) — Oscar to confirm.
- Whether to retire GA property
G-207PJHTWMHimmediately or keep it through cutover for traffic comparison. cani.latframing on the new site — featured project (recommended) vs. simple link reference.
Learned
- The live site has been untouched since 2025-10-10 (
last-modifiedheader). The migration urgency is real — the current site is degrading by absence, not by load. - Carrd ships ~180 KB of HTML for what is functionally a static one-pager. Astro will trivially 10× this on perf.
- The book draft and Stories for V3 are richer than the brief implied — the design has more raw material to honor than I expected.
Quote-worthy
“Carrd ships 180 KB of CSS to render five paragraphs. Astro will ship five paragraphs.”