HB
HoschDB
live
← writing

Building hosch.us — Astro 6, a Design System, and an iOS Safari Bug

2026-05-14 · 9 min read
astrocssweb

This site didn’t start with a blank Figma file. It started with a working React prototype built in Claude’s design canvas — a component library called HoschDB with tokens, an editorial layout, and live interactive mockups. The challenge was porting it into a real deployed site without losing the design fidelity. Here’s how that went.

The starting point

The design system came pre-built: tokens.css, hoschdb.css, DM Mono and DM Sans in WOFF2, an ink/chalk/moss palette, and a React prototype (v1-editorial.jsx) showing the full portfolio at desktop, tablet, and phone viewports. All the visual decisions were already made.

What didn’t exist: an Astro project, any content schema, dynamic routes, RSS, or deployment.

The prototype also had some things I deliberately didn’t want — Google Fonts CDN references, hardcoded hex values scattered through component styles, and no consideration for Astro’s static output model. So the port wasn’t a straight copy.

Six phases, no skipping

The build ran in six sequential phases:

Phase 1 — Foundation. Astro scaffold, design system CSS ported verbatim, WOFF2 fonts in public/fonts/, BaseLayout.astro, SEOHead.astro with full OG + structured data, sitemap, robots.txt. The critical constraint here: site: 'https://hosch.us' in astro.config.mjs has to be set from day one. RSS canonical URLs, the sitemap, and Astro.site all depend on it. Setting it later causes silent failures.

Phase 2 — Content Collections. Blog and project schemas with Zod. Astro 6’s Content Layer API has a specific requirement that bit me: slug cannot appear in a Zod schema because Astro 6 removes entry.slug entirely — you use entry.id for routing. Also: z.coerce.date() not z.date() for date fields, or build-time validation fails on string dates in frontmatter.

Phase 3 — Static Pages. Home, about, blog list, projects list. The home page hero has an 88px display headline and a 4-column stats grid. Nothing about this phase was complicated — it was mainly making the React prototype’s JSX readable as Astro template syntax.

Phase 4 — Layouts + Dynamic Routes. BlogPostLayout.astro, [slug].astro for both blog and projects, reading time via a custom remark plugin. Draft filtering here is non-negotiable: !data.draft in every getCollection() call, including inside getStaticPaths(). A draft post that reaches getStaticPaths generates a real HTML file regardless of whether the list page shows it.

Phase 5 — Discovery. Tag pages, RSS feed, the subscribe prompt on the writing page. The RSS endpoint is an Astro API route (feed.xml.ts) and can’t access Astro.site — you have to use context.site! from the GET function parameters. Small thing, annoying to debug.

Phase 6 — Deploy + DNS. Vercel import, custom domain, Search Console. One constraint worth documenting: if your domain has MX records you care about (mine handles benjamin@hosch.us), don’t switch your Squarespace nameservers to Vercel’s — use Vercel’s A and CNAME records at the registrar level instead. Switching nameservers would have wiped email routing.

Mobile optimization via container queries

After launch, I revisited the design system’s mobile mockups — the design canvas had iPhone 15 Pro and Pixel 8 frames with a working interactive prototype. The design used CSS container queries (not media queries) on .hdb.v1, with three breakpoints:

  • 820px — tablet: reduce padding, single-column “Now” section, featured project rows drop their metric and year columns
  • 560px — phone: desktop nav hidden, hamburger + animated drawer, hero headline scales down to 44px, stats go 2×2
  • 380px — tiny: headline 38px, stats go 1-column

The approach: add container-type: inline-size; container-name: v1 to body.hdb.v1, then target named CSS classes (v1-display, v1-stats, v1-row-featured, etc.) inside @container v1 (max-width: ...) blocks. All the responsive rules live in tokens.css — no media queries scattered across component files.

The hamburger uses a <button> with all: unset plus explicit sizing, and the drawer is a <div> that CSS shows at narrow widths (display: flex !important) while JavaScript controls the max-height for the open/close animation.

The iOS Safari bug

After deploying the mobile layout, the writing page was broken on iOS — only read time and tag badges were showing, not post titles. The projects page worked fine.

The difference: the projects list hides its trailing columns (3rd and 4th children — kind and metric), leaving number, title, and year. The writing list was hiding its first column (the date), then reflowing the remaining items into a new grid template.

iOS Safari has a rendering bug where hiding grid > :nth-child(1) with display: none and simultaneously changing grid-template-columns causes the shifted content (what was the second child, now occupying the first column) to not render correctly. The items are present in the DOM — badges inside the same div were visible — but the title text was gone.

The fix was to reorder the writing row HTML so the date goes last (1fr auto 100px → title, read time, date), then hide :nth-child(3) at the tablet breakpoint. Same visual result on desktop, no iOS rendering issue.

This is the same pattern the projects list uses — hide trailing children, never the leading one. Worth keeping in mind for any grid that needs to collapse columns responsively on Safari.

Updated 2026-08-22 — the rest of this post covers what shipped after launch.

Visual polish and a CMS I didn’t plan to build

The site sat mostly untouched for a few months after launch — real gaps between sessions, not continuous work. When I came back to it, the goal was narrow: fix the favicon (it rendered wrong across Chrome, Firefox, and Safari desktop, and wasn’t picking up on iOS home screens), get DM Mono’s light weight actually loading, and unify the writing pages onto the same 4-column grid the projects pages already used. All of that shipped cleanly.

What wasn’t planned was a CMS. It got added mid-milestone via a roadmap revision, because hand-editing MDX in git was real friction against actually publishing. The solution: a @astrojs/vercel adapter with exactly two on-demand routes (/api/auth and /api/auth-callback) to run a GitHub OAuth handshake, and Sveltia CMS vendored same-origin under /admin as a static file mount — invisible to the sitemap and getStaticPaths() since it’s never an Astro route. Every other route on the site stayed fully static.

The CMS’s live-preview pane needed the real site’s styles, not Sveltia’s defaults, so the build pipeline now concatenates tokens.css + hoschdb.css + a tracked shim into public/admin/preview.css on every build and registers it into the preview iframe. That’s how the CMS editor shows syntax-highlighted code blocks and styled figcaptions that actually match production.

The best bug from this phase: Sveltia’s richtext widget takes a modes: config with values rich_text and raw — underscored. Both research docs I’d generated cited the hyphenated versions (rich-text/plain-text), which are Sveltia’s internal display labels, not valid config input. Neither value matched a real enum member, so Sveltia silently fell back to raw mode with the entire formatting toolbar disabled — no error, nothing in the console, just a toolbar that wasn’t there. It shipped that way for a full plan cycle and was only caught during a live end-to-end UAT pass by reading Sveltia’s actual bundled source instead of trusting the docs.

Making everything editable, then proving it’s accessible

The CMS covered blog and project posts, but everything else on the site — the hero headline, the bio, work history, skills, contact info, the stats row — was still hardcoded in src/data/site.ts. Closing that gap turned out to need a pattern Astro’s Content Collections don’t have natively: a single object with several independently-shaped keyed sections. The fix was a file() loader over one site.yaml, validated by a z.union([...]) of seven per-section Zod schemas, accessed through a typed getSiteSection(id) helper that turns an opaque invalid_union build error into one that names the actual offending section. src/data/site.ts is gone now — everything routes through site.yaml and is editable at /admin.

Getting the CMS config right here had its own trap: Sveltia distinguishes plural fields: (a list of objects, each with sub-fields) from singular field: (a list of scalars), and picking the wrong one produces a YAML shape the Zod schema silently rejects. There was no prior precedent for this in the codebase — it only got proven correct by an actual round trip through the live /admin UI, first on the stats widget, then reused for work history.

With content done, the last piece was accessibility. I built a repeatable local harness — npm run a11y, axe-core driving Playwright across 8 route instances at 2 viewports — with a strict mode that additionally gates heading-hierarchy and landmark rules axe normally leaves as advisory. First run found real contrast failures: the --stone token was below the 4.5:1 threshold, and fixing it required updating the value in two files (tokens.css and hoschdb.css), because hoschdb.css re-implements badge and stat styling at higher specificity and would otherwise keep shipping the broken shade regardless of what the token file said.

The harness also caught a bug in itself. A scrollable code block on mobile was failing scrollable-region-focusable in a way three earlier plans had each independently — and incorrectly — written off as “pre-existing, out of scope.” It turned out to be a real timing race: Expressive Code’s debounced ResizeObserver hadn’t finished settling the block’s tabindex before axe scanned the page. Fixing the harness’s own wait condition, rather than adding a blind sleep, unmasked a second, previously-hidden landmark-unique violation underneath it — closed by giving each scrollable region its own aria-label.

What’s next

Three things are true right now that weren’t at launch: everything on the site is CMS-editable, not just posts and projects; the whole site passes an automated WCAG AA gate; and dark mode is still exactly where it was in May — tokens defined, toggle not built.

The one gap that’s bugged me the whole way: a real cross-browser QA pass never actually happened. The checklist got written — 21 URLs across four browsers, 16 regression rows — but the human four-browser pass it exists to gate got waived to close the milestone instead of run. It’s the top item on the list for whatever comes next, ahead of the dark mode toggle, site search, and everything else still sitting in the backlog.