HB
HoschDB
live

← writing

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

2026-05-14 · 5 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.

What’s next

The site is at v1. Dark mode tokens are already defined in tokens.css under [data-theme="dark"] — the toggle is just a JavaScript attribute setter, deferred to v2. The design canvas also has a v2 console layout and a v3 schema diagram view that I want to explore as alternate skins for different content types.

For now: write more posts, add more projects, let the content catch up to the infrastructure.