Oscar Neira

Un registro día por día de cómo se reconstruyó este sitio — notas de campo que también sirven de investigación para De Papas a Programas.

Notas de campo — estas entradas están en inglés.

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].astro and /es/projects/[slug].astro. Both routes use getStaticPaths() to prerender one HTML page per showcase repo. Detail page renders: large monospace H1 (the repo name, carrying view-transition-name: project-<slug>), description, meta row with language chip + ★ stars + last-updated time, and two external action buttons (View on GitHub always, Live demo only when GitHub’s homepage field is non-empty).
  • ProjectCardCompact rewired to wrap in <a href="/projects/<slug>" data-astro-prefetch> and carry the matching view-transition-name: project-<slug> inline style on its <article>. The previous external View 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: reduce and just swaps content. Astro’s ClientRouter honors this. No extra code.

What broke

  • Nothing yet — pnpm dev is clean on every route. The catch is that the GitHub fetch returns [] for OscarNeira right now (zero repos are topic-tagged oscar-neira-showcase), so there are no compact cards on /projects to 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 tags maverick-ops (and any others), pnpm build emits one detail page per tagged repo and the morph plays.

What I learned

  • Class-based CSS can’t drive view-transition-name per-instance. When you have N cards with N unique destinations, the only correct pattern is style={view-transition-name: project-${slug}} on each card. Inline style is the documented Astro/View Transitions API recipe. No var() 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-within on the inner article. The link is the parent, not a descendant, so the article’s :focus-within no 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-name is 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 new src/lib/github-fetch.ts calls https://api.github.com/users/OscarNeira/repos?per_page=100&sort=pushed with a 5-second AbortController timeout, filters to fork === false && topics.includes('oscar-neira-showcase'), and returns a typed RepoCard[]. On ANY failure (network, non-2xx, JSON parse, timeout) the helper logs console.warn and 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.RelativeTimeFormat timestamp + View on GitHub → link). Until Oscar tags repos with oscar-neira-showcase, the section heading doesn’t render; the page degrades cleanly to CANI-only.
  • CANI card centered, /projects becomes 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ÉCTATE section-label + a larger (size 22, up from 18) SocialRow, stacked above the copyright/last-updated/built-with/log meta line. The side-by-side sm:flex-row layout is dropped — social gets full row width on every viewport.
  • i18n strings extended. New projects.openSource.{heading, viewOnGitHub, updatedPrefix} and top-level connect keys; 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 extended projects.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” and social-row.spec.ts “4 links on /”).

Build numbers

pnpm buildclean, 14 pages, 0 errors / 0 warnings, ~5.2s
pnpm typecheck0 errors / 0 warnings / 1 pre-existing hint
New runtime deps0 — native fetch at build time, no Octokit
GitHub repos surfaced today0 (none tagged yet — Oscar tags after deploy)
GitHub fallback verifiedyes — 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)
Playwright387 / 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:141 asserted “SocialRow is in the footer, not inside main” (now: hero + footer both); social-row.spec.ts:14 asserted 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 await is 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 of try/catch + AbortController got 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-showcase on 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-projects capability 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=2 handling. 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"> at z-index: -2, behind everything. The existing noise overlay at z-index: -1 composes on top via mix-blend-mode: multiply, so the moving birds get the paper-grain texture for free.
  • CANI-themed palette per theme. Light: cream #fafaf7 canvas + Tampere navy #1B2A4A birds + CANI gold #E3C927 highlights. Dark: #0e0f12 canvas + CANI gold #E3C927 birds + terracotta #c7613b highlights. 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 dynamic import() of Three + Vanta. Mobile, reduced-motion, and save-data users fetch zero bytes of WebGL machinery.
  • View-Transitions-safe lifecycle. astro:before-preparation destroys the effect, astro:page-load re-schedules a mount (idempotent). A MutationObserver on <html data-theme> + <html data-easter-egg> triggers destroy+remount on every theme flip or aurora toggle. The module-scoped effect reference 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 calls unmount() so GPU resources are freed. Aurora dismiss → observer fires → scheduleMount → birds return.
  • Three.js pinned to 0.134.0. Modern 0.184.0 produced TypeError: L is not a constructor because Vanta 0.5.24 still uses THREE.Geometry, removed in r144 (2022). The diagnostic took 30 minutes of probing in headless chromium — Playwright initially failed silently because my code wrapped BIRDS({...}) 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 via data-stamp); SPA navigation re-mounts on the destination; stubbed HTMLCanvasElement.prototype.getContext failure stays silent.

Build numbers

pnpm buildclean, 14 pages, 0 errors / 0 warnings, ~1.9s
pnpm typecheck0 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)
Playwright342 / 342 green (313 baseline + 29 new, 10 intentional mobile skips)

Broke / adjusted

  • three@0.184 broke vanta (see Shipped). Pinned to three@0.134.0. The fix is in package.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 asserted canvasCount === 1 and saw 0; no stack trace surfaced through the test output. Standalone probe via node /tmp/vanta_probe.mjs (Playwright via the local @playwright/test/index.mjs) printed the actual TypeError: 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/i matched the always-loaded lifecycle wrapper script (its compiled filename starts with VantaBirds.astro_astro_type_script_*). Tightened to /(three\.module|vanta\.birds\.min)\./i so only the heavy chunks count.

Learned

  • Dynamic import() is the bundle-cost firewall. Putting import('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 capturing page.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: -2 with 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 + #E3C927 for 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.Geometry with BufferGeometry, 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 — renders docs/migration-log.md end-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 the src/ boundary.
  • /es/log mirror — 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 ::before dot on each Day H2 (background: var(--color-accent), ringed by box-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: Title H2, derived from getHeadings() and filtered to depth === 2. The H2 text is parsed with /^(\d{4}-\d{2}-\d{2}) — (Day [\d.]+): (.+)$/ so the TOC renders Day N — Title with 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 /log on every existing route. Quiet --color-text-muted text with a dotted underline; flips to --color-accent on 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 log namespace 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.ts covers: 200 + zero console errors, all 8 Day H2s rendered (count matches source), localized chrome (title, description, html-lang, canonical), TOC visible ≥1024px and display: none below, 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 buildclean, 14 pages (was 12), 0 errors / 0 warnings, ~1.4s
pnpm typecheck0 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 added0 bytes — rail is CSS, TOC is CSS-sticky, scroll is CSS-smooth
Playwright313 / 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 #id selector is invalid CSS. CSS.escape isn’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 bodyClass prop on Layout.astro (introduced for body.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.md keeps the source where it belongs (consumed by the book chapter, by docs/, and now by the site — all from the same path). The collections scaffold under src/content/ is reserved for book/, now/, talks/ per content.config.ts — adding a log/ 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-left on a wrapper, the dots are ::before pseudo-elements on each H2, the dot ring is box-shadow not 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: sticky and scroll-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.jpg and work.jpg still pending — the dark-mode filter: brightness(0.92) saturate(0.92) band-aid stays in place until rembg’d versions land.
  • DNS swap to oscar-neira.com still 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 /log cross-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-max skill installed at .claude/skills/ui-ux-pro-max/ via npm 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’s touch-target-size rule 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 a radial-gradient mask-image in 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 reserved-slot (aspect-ratio: 1 / 1.1; max-height: 70vh) so the layout doesn’t shift when the image hydrates. Honors the skill’s content-jumping rule.
  • 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-wrap which produced unpredictable line-breaks at intermediate widths.
  • ProjectCard on /projects anchored to deck-grid left margin instead of centering — matches /about and /work alignment.
  • 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.md gained 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 buildclean, 12 pages, 0 errors / 0 warnings, ~1s
CSS added~600 bytes (image treatment + tap-target rules + Hero slot + mobile grid)
JS added0 bytes
Playwright234 / 234 green in 96s

Broke / adjusted (Day 6)

  • document.startViewTransition was already removed (Day 5) so no ripple from this proposal.
  • No pnpm corruption today — clean install state held through the full apply.
  • mask-image behavior verified via Playwright by reading getComputedStyle(...).maskImage (and the webkitMaskImage fallback 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-max and 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-gradient a 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 illustrationsoscarWebCani-cutout.png, about_me-cutout.png, work-cutout.png per the inbox/README.md spec. Drop in inbox/; I wire in 5 min per image.
  • Regenerated About illustration with TECH CARRER → TECH CAREER fix — 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-pressed reflects current state; aria-label localized via t.theme.switchToLight / switchToDark.
  • First-visit detection: extends the existing inline locale-detection script in Layout.astro <head>. Logic: read localStorage.theme; if set, honor it. Else read prefers-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-scheme listener: 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 base so layer ordering can’t override it. Reduced-motion users get the global 0.01ms guard (effectively instant).
  • i18n: theme.switchToLight / switchToDark keys in src/i18n/strings.ts for both locales. EN: “Switch to light/dark theme”. ES: “Cambiar a tema claro/oscuro”.

Build numbers

pnpm buildclean, 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 fontsnone
Playwright230 / 230 green in 96s

Broke / adjusted (Day 5)

  • startViewTransition wrapper raced Playwright assertions. Used document.startViewTransition(() => applyTheme(next)) for the visual crossfade — but startViewTransition defers 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 got 1e-05s. The global @media (prefers-reduced-motion: reduce) guard force-sets transition-duration: 0.01ms everywhere (so any animation snaps cleanly). Computed value reads as 1e-05s. Test now matches effectively zero (< 1 ms) rather than literal 0s.
  • document.currentScript.previousElementSibling was 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 to document.querySelectorAll('button.theme-toggle') + a dataset.themeBound guard, plus a rebind on astro:page-load so the handler survives view transitions.

Learned

  • Async UX wrappers are hostile to functional tests. startViewTransition is 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 right offsets 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: multiply or 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 CAREER fix — drop in inbox/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 #c7613b preserved under [data-theme="dark"] for the future evolve-design-tokens proposal.
  • 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, /es for 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-12 padding, 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 /download cascade. 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 at opacity: 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-60 dim).
  • Section labels: small accent leading dot (· P R O J E C T S).
  • Border-color split: --color-border-subtle is 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 buildclean, 12 pages, 0 errors / 0 warnings, ~3s
CSS added~600 bytes (transitions, accent rules, error numerals, wordmark, animated underline)
JS added0 bytes
Fonts added0 bytes
Playwright180 / 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: #222 for 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 #d8d3c3 cream hairline). Components updated.
  • SocialRow align=“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 via color-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-tint token 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.astro array.
  • GitHub URL — same.
  • Dark-mode toggle--color-accent dark value (#c7613b) is in place under [data-theme="dark"]. The toggle UI lands as evolve-design-tokens (Phase 3 step 2 if Oscar wants it).
  • Regenerated About illustration with TECH CARRER → TECH CAREER fix — drop in inbox/about_me_v2.jpg when 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 as URL pending placeholders.
  • Hero deck gained a 4th button: ABOUT ME · WORK · PROJECTS · DOWNLOAD. Third button label changed from Contact to Download to match the route name.
  • New /projects route featuring CANI as the only card, via a new ProjectCard.astro component (N-ready: accepts title / 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) with target="_blank" rel="noopener noreferrer".
  • /download enhanced: 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 under prefers-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 · ES text toggle. Active locale is non-link with aria-current="page". Inactive is the link to the corresponding mirror route. ≥24×24 px tap target, focus-visible, click-persists localStorage.lang.
  • First-visit auto-redirect: inline <head> script runs before paint. If localStorage.lang is set, honor it. Else if navigator.language starts with es AND not on /es, redirect to /es and persist. Else persist current locale. Wrapped in try/catch for 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 /download is silent (EN-default audience).
  • <html lang> per locale + hreflang="en" / hreflang="es" / hreflang="x-default" alternates on every page. og:locale = en_US or es_MX.
  • Footer is locale-aware: Last updated / Built with Astro on EN; Última actualización / Hecho con Astro on ES.

Build numbers (after both proposals)

pnpm buildclean, 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/*.css16 KB raw
Fonts (public/fonts/)38 KB raw (3 woff2 files)
Image variants emitted30
Playwright148 / 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.

  • pnpm store corruption surfaced for the third time after adding @fontsource/* and sharp packages. Same fix as Days 1.5 + 2: rm -rf node_modules && pnpm install. Documented but not yet root-caused.

  • Astro <Image> doesn’t take formats={[]} plural — needed <Picture> instead. Same fix as Day 2 — confirmed the pattern carries through.

  • Astro.url.pathname includes .html when format: 'file' is set. Locale path-based logic (pathInOtherLocale) returned /es/about.html instead of /es/about. Fix: normalize pathname in Layout.astro before 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.

  • /es vs /es/format: 'file' builds dist/es.html (not dist/es/index.html), so /es is the canonical URL. Updated pathInOtherLocale and all back-links in src/pages/es/ to use /es (no trailing slash) for consistency with trailingSlash: '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 against pnpm 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.ts would 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.
  • LangPicker is 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.astro array, 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 CAREER fix. Drop in inbox/about_me_v2.jpg when ready.
  • Mini-book PDF location — still Google Drive. Self-hosting is one drop in inbox/from-potatoes-to-programs.pdf away.
  • Font budget overrun — 38 KB vs. 30 KB target. Acceptable for v1 (4× lighter than Carrd’s 161 KB). Phase 2’s evolve-design-tokens proposal (or a dedicated font-budget proposal) 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-replica drafted, applied, validated strict-clean. Two capabilities modified (site-foundation, firebase-hosting), three new (multi-view-deck, replica-content, social-row). Day 1’s bootstrap-astro-firebase archived first so the MOD deltas could resolve.
  • Token swap in src/styles/global.css: dark + terracotta (Phase 2) → light replica palette per PHASE_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 with pyftsubset to 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 /> from astro:transitions. Four real routes: /, /about, /work, /download — each crossfades, each is bookmarkable, each has its own <title> + canonical, each sub-view has a ← BACK link. 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/ to src/assets/illustrations/, rendered through <Picture> from astro:assets with 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’s cani-lat-feature builds on this prominence.
  • /contact 301 → /download wired into firebase.json redirects. Inbound legacy links survive cutover. Structural test asserts the rule exists; emulator-backed test deferred to deploy-pipeline.
  • GA G-207PJHTWMH loaded asynchronously from Layout.astro <head>. CSP relaxed narrowly: script-src adds googletagmanager.com, connect-src adds google-analytics.com and *.google-analytics.com. No blanket https: allow-list.
  • Social row gained 3 first-class entries Carrd never let Oscar configure: LinkedIn, GitHub, CANI (cani.lat). LinkedIn + GitHub render with href="#" and aria-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 Astro in --color-text-muted.
  • inbox/ drop folder at repo root with a tracked README; .gitignore rule inbox/* + !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.ts rewritten for light theme + Phase 1 H1; new routes.spec.ts (4 routes return 200 + heading, sub-views have ← BACK, /about has download CTA, /download has zero forms, view-transition navigation produces no console errors, /contact → /download structural assertion); new social-row.spec.ts (7 icons in order, accessible labels, LinkedIn/GitHub URL pending, no tel:, focus visible, tap targets ≥24×24). All 60 green in 35.5s.

Build numbers

pnpm buildclean, 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.html9.4 KB
dist/work.html8.5 KB
dist/download.html13.8 KB
dist/404.html3.2 KB
dist/_astro/*.css16 KB raw
Fonts (public/fonts/)44 KB raw — 14 KB over the 30 KB target; documented overrun
Image variants emitted30 (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 with pyftsubset to 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 install produced a corrupted node_modules/.pnpm again after adding @fontsource-variable/* and @types/node — this time yargs-parser was missing, breaking astro 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:assets exports changed since I last touched them. ImageMetadata is exported from astro (the package), not astro:assets (the virtual module). And <Image> accepts format (singular), not formats (plural) — to get AVIF + WebP, use <Picture> instead. Both fixed in src/components/Illustration.astro.
  • GA’s CSP impact — the Day 1 CSP shipped script-src 'self' only. To load gtag.js from googletagmanager.com and let it call google-analytics.com, both script-src (script load) and connect-src (beacon POSTs) needed targeted hosts. I also added 'unsafe-inline' to script-src because 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 in SocialRow.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 + pyftsubset won 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> with formats={['avif','webp']} and a fallbackFormat. 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.json is 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 .gitignore rule + 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, replace href="#" and update aria-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.pdf and Claude moves it to public/book/.
  • Regenerated About illustration — current about_me.jpg ships with the original Carrd TECH CARRER typo. Drop the v2 in inbox/about_me_v2.jpg when ready; alt text already flags the typo for screen readers.
  • Font budget overrun — 44 KB vs. 30 KB target. Phase 2’s evolve-design-tokens proposal 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 preview test is meaningful but Firebase headers aren’t applied; defer to deploy-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: bare openspec on npm is a 2019 squat). Slash commands + skills installed under .claude/.
  • openspec/project.md written — single-page summary of the brief + four reference docs, used as durable AI context for every future proposal.
  • Change proposal #1 — bootstrap-astro-firebase drafted, validated strict-clean, applied. Two new capabilities specified: site-foundation (8 requirements) and firebase-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/check 0.9.9; pnpm 9.15.9 pinned via packageManager)
    • 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.css per BEST_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 through glob loaders so astro check is honest from day one.
  • Firebase config: firebase.json ships 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. .firebaserc binds project oscar-neira-fi (Oscar’s chosen ID).
  • Verified locally:
    • pnpm install --frozen-lockfile → 3.9s on warm cache
    • pnpm build → 665ms, 2 pages, 0 TS errors / 0 warnings / 0 hints
    • pnpm dev → HTTP 200 served in 152ms cold
    • pnpm preview → HTTP 200 served in 4ms
    • Output: index.html 4 KB, 404.html 4 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 corepack on this machine — the spec assumed corepack would auto-resolve pnpm from packageManager. Adjustment: npm install -g pnpm@9 instead. README quickstart updated to match. Doesn’t change the spec’s intent (pnpm v9+ available locally).
  • PROJECT_BRIEF.md §5 says npx 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 lists tailwind.config.ts. Tailwind v4 is CSS-first; that file would split the source of truth. Did not create it. Documented in design.md D2 + 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.ts to src/content.config.ts and declared the three placeholder collections explicitly via defineCollection({ loader: glob(...) }). Second build clean.
  • WebFetch on cani.lat returned 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.email at the local repo scope only (not global), bound to Oscar’s profile from ~/.claude/CLAUDE.md. Override via git config --local if 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 by openspec 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:

  1. Corrupted Astro install. node_modules/.pnpm/astro@5.18.1_.../node_modules/astro/ was missing package.json and astro.js — only the dist/ subtree had landed. pnpm dev failed with Cannot find module '.../astro/astro.js' at startup. Fixed with rm -rf node_modules && pnpm store prune && pnpm install. Root cause undetermined; possibly a corrupted store entry from a prior unrelated install.
  2. engines.node too 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 · today present.
  • Build-date footer present.
  • data-theme="dark" applied.
  • --color-canvas token 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:e2e before 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-lockfile reported success while the Astro tarball was missing files. Considering pinning pnpm-store integrity 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/openspec and use /opsx:* slash commands.
  • Update PROJECT_BRIEF.md §8 folder structure to drop tailwind.config.ts (CSS-first in v4).
  • Reserve Firebase project oscar-neira-fi (firebase projects:create oscar-neira-fi or 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 0094 was 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:

  1. The current site uses three custom illustrations (Hero portrait + two AI-generated cartoon scenes for About and Work) — not just text and a portrait.
  2. 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 ← BACK button. 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 /download view (mini-book PDF download), Cayce Pollard footer killed, TECH CARRER typo 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). /contact 301s 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.md rewritten with screenshot-grounded visual reality, multi-view nav pattern, illustration inventory, and the typo flag.
  • docs/PHASE_1_REPLICA_SPEC.md created — 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.md reframed 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 at PHASE_1_REPLICA_SPEC.md as ”🔥 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.com are most of what makes it feel like Oscar’s site, and they’re invisible from curl. 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 0094 is still hidden in Phase 1.
  • Regenerate About illustration with corrected TECH CAREER text.

2026-05-08 — Day 0: Scoping & audit

Shipped

  • Project folder created at ~/Documents/oscar-neira-firebase.
  • PROJECT_BRIEF.md written — full migration spec, including UX/A11y discipline (§13) and a deep audit of the live oscar-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.lat is 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-207PJHTWMH immediately or keep it through cutover for traffic comparison.
  • cani.lat framing on the new site — featured project (recommended) vs. simple link reference.

Learned

  • The live site has been untouched since 2025-10-10 (last-modified header). 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.”