perf(rss): prerender the feed, keeping its content type via _headers - #33
Closed
dyascj wants to merge 262 commits into
Closed
perf(rss): prerender the feed, keeping its content type via _headers#33dyascj wants to merge 262 commits into
dyascj wants to merge 262 commits into
Conversation
fix: Resolve framer-motion variant type error on Home page
fix: Resolve remaining framer-motion type errors
New projects updates
Light mode
Project updates
- Update hero tagline to reflect design + IT leadership focus - Revise about section with new bio emphasizing product design and IT ops - Add real-world usage section to Pomodorii project with classroom photo - Streamline Pomodorii article by consolidating sound-first UI content
New article about rediscovering the joy of computing through Linux.
- Link "The Linux Command Line" book to linuxcommand.org - Add Wine and Proton links in gaming section - Rename blog post file for consistency - Update thumbnail from JPG to PNG
…ation - Add RSS feed at /rss.xml with auto-generated XML from blog posts - Add blog filtering by category (Technology, Design, Articles) - Add category badges with color-coded dots on blog cards - Add RSS subscribe button on blog page - Add RSS auto-discovery in blog page metadata - Update README with complete codebase documentation
- Install @vercel/analytics package - Add Analytics component to root layout
Add content:encoded and media:content elements so RSS readers can display full blog post content including inline images and thumbnails.
Delete the how-to-run-deepseek-r1-locally-on-macos blog post. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Remove DeepSeek R1 blog post
- Add reading progress bar component that shows scroll progress - Add sticky "On this page" table of contents sidebar for desktop - Add floating TOC button for mobile devices - Extract headings from markdown for TOC navigation - Add "Introduction" section when content exists before first heading - Unify blog and project page layouts with: - Gradient title styling - 4-column meta grid (author, date, category, client/read time) - Featured image matching content + TOC width - Consistent footer with author info and navigation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
feat: Add reading progress bar, table of contents, and unified page layout
The TableOfContents and MobileTableOfContents components were importing TOCItem from @/lib/blog, but the projects page passes project.toc which uses TOCItem from @/lib/projects. TypeScript treated these as different types even though they're structurally identical, causing pages to fail. Fixed by defining the TOCItem interface inline in each component. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
fix: Define TOCItem interface inline to fix type mismatch
Headings like "### 1. Frictionless Filtering" generate IDs starting with
numbers (e.g., 1-frictionless-filtering). CSS selectors cannot start with
a digit, so document.querySelector('#1-...') throws a DOMException.
getElementById works correctly with IDs that start with numbers.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Replace the IBM Plex Mono terminal theme with a light and dark system built on Inter: a grey ground with white surfaces floating on it, pill geometry, and generous spacing. Design system - app.css rewritten as tokens plus shared primitives for nav, buttons, cards, rows, prose, forms, and the content blocks used inside project markdown - Dark mode is a second depth model rather than an inversion. The ground is the darkest value and surfaces step up the tonal ladder, so elevation reads without borders - Drop the pixel icon dependency, Icon.svelte and ProjectIcon.svelte. Only stroked arrow affordances remain Layout - Landing page leads with visual work cards, then about, writing, experience and contact - Blog posts are a single centred column with the header above the text - Project case studies keep the wider layout, with the spec block split by content shape instead of an even grid that left large gaps - New /contact route owns the contact form Interaction - Nav is a dark notch that expands on hover or focus, with the same four destinations on every page - Back button in the corner on pages that sit below another - Section index is a magnifying tick rail with a preview card - Cross-fade page transitions via the View Transitions API, with the nav held static across route changes - Scroll reveals run off one deterministic pass, so a section skipped by a deep link or a fast scroll can never stay invisible Fixes - Article text overflowed on phones because grid columns could not shrink below their content width - 404 pages rendered the generic error message: +error.svelte read status from props, which SvelteKit does not pass to error pages - /contact was missing from the sitemap - The closed mobile index was aria-hidden while its links stayed focusable - Tertiary text contrast raised from 2.48:1, which was below the UI threshold - Author card bled to the screen edges on mobile - Row separators no longer cut across the hovered row's rounded corners Testing - 28 end-to-end tests, including a click-through suite that navigates the whole site by clicking rather than by URL - Remove the unused ibm-plex-mono dependency
Remove dead code and split the stylesheet so the repository reads clearly. - Drop the GitHub contributions module. The landing page stopped rendering the activity grid, but the server load still awaited a scrape of github.com on every render of the most visited page and nothing consumed the result - Delete LocalTime.svelte, orphaned when the terminal top bar was replaced - Remove computeDuration, and the social icon field and its type, all unused since the icon font was dropped - Split app.css into src/styles layers imported in cascade order. Verified behaviour-neutral by comparing 40 computed properties on 764 elements across 28 page states in both themes at two widths, with no differences - Delete the stale design docs describing the previous theme - Correct a stale comment about the nav and refresh the README structure
CI has been red since before the redesign: it runs a bare npm audit, which includes devDependencies, and six advisories sat in the eslint and tar dependency chains. The published site never depended on any of them, but the gate failed on every push. Pin brace-expansion and tar through overrides rather than taking the breaking eslint 10 upgrade npm audit fix wanted. Verified eslint, prettier, svelte-check, the unit tests, the browser suite and the production build all still pass, and npm audit now reports no vulnerabilities.
actions/checkout and actions/setup-node were on v4, which targets Node 20 and was being force-run on Node 24 with a deprecation warning on every job. Both are now on v7, which targets Node 24 natively. Neither major touches the inputs used here. checkout v7 restricts checking out fork pull requests, but only for pull_request_target and workflow_run, and this workflow triggers on pull_request and push.
It is no longer a solo three-month macOS project. Replace the outcome with what is verifiable from the repository and releases, and correct three errors: the license is AGPL-3.0 and not MIT, the desktop app ships for Windows and Linux as well as macOS, and the timeline is ongoing rather than ending in March. Add the contributor story. An outside contributor has shipped a streaming voice pipeline, chat history, task scheduling, context window configuration and OpenAI-compatible model discovery into the core, which reframes the project from a solo build into a maintained one.
Reworked the project write-ups so they answer what a hiring manager actually asks: what was the problem, what did you decide, what did it cost, and what happened. Adds a team field so the professional work stops reading as solo. Smart Directory, rewritten - It had no results section at all. It now closes on the real deployment: five cities, more than fifty double-sided kiosks, east coast to midwest - Corrected the role from Lead Product Designer to Product Designer, which is the title actually held at the time, and named the COO, design lead and engineering team - Replaced the feature tour with the decisions behind it. The central one was making customization a system with a hard floor: cities choose content, palette and apps, but never spacing, type scale, touch targets or contrast - Added trade-offs, which it was missing Rove iQ web and mobile - The stat row described the work rather than measuring it. It now leads with the 70+ B2B accounts running on the product - "a handful of customers" was the only proof of the core hypothesis; the hardware-free sales and the account total are now stated plainly - Moved the legal disclaimer off the top, where it was the first thing read - Named the team, and cut unattributed customer praise Mizu and loggrep - Mizu had no adoption evidence. Two teams ship production AI products on it, powering Utsuwa and an assistant platform in closed beta - loggrep is used by the whole ops team and saves each of them about two hours a week. Its role was listed as Developer, which undersold a UX problem - Both credited as solo design and build Ordering - The two professional case studies were fifth and seventh on the work index. Deployed infrastructure and business impact now lead - The landing page shows both professional pieces plus Utsuwa instead of leading with a component library Also removed the meta-commentary about what makes a good case study from three files, which described the writing instead of the work.
Swap the rounded 1,900+ for 1,980, dated July 2026. The count moved by six while writing this, so the date is what keeps an exact number honest rather than turning it into a claim that quietly goes stale.
Case studies now use the same centred single column as blog posts, with the header centred above the text, so both article types read the same way. The two-column hero and the article grid that carried it are gone. The section rail is fixed beside the column instead of occupying a grid column of its own. Its ticks align on the edge facing the text and grow outward into the margin, and the preview card opens outward with them, so the whole component reads as attached to the side of the page rather than crowding the body. Raised the rail's breakpoint from 1100px to 1280px. The card opens outward and needs roughly 630px of margin from centre; below that it clipped off-screen without ever widening the document, because its ancestor is fixed. Also ported the hero figure styles to the new markup. They were scoped to the removed wrapper, so the image lost its rounded frame and shadow and the caption lost the layout that separates the two labels.
Reverts the nav tuck. Hiding the chrome on scroll solved the wrong problem. - Header is left-aligned again. Centring a four-line dek and a row of facts read weakly, and a shared left edge ties the title, image, outcome and prose together - The block under the hero image was a wall: a two-column grid of label and value pairs, then tags, then an outcome heading. It is now one quiet line of facts in the header, then an unlabelled outcome summary, then the contribution tags. The outcome heading also duplicated the Outcome section every case study already has - Fixed the real spacing bug. The prose zeroes its first child's margin, so the opening section heading sat flush against the tags above it with a zero gap - The video figure was a card wrapping a video that carried its own frame and shadow: two sets of rounded corners that did not agree, with the caption trapped inside. It is now a single framed clip with the caption beneath, matching the hero figure - Widened the heading scale. h2 and h3 were 27px and 21px at the same weight and colour, which flattened the outline on the longer studies - Shrank the section rail and lowered its breakpoint from 1280px to 1152px, so it appears on more screens. Ticks and card are smaller, and the card still clears the viewport at the new minimum - Removed the dead two-column hero rules left behind by the centred layout
The contribution row repeated what the prose already covers, so it is gone from every case study, along with its derived value and the tag styles nothing renders any more. The facts read as a definition list now: bold field name in its own column, value beside it. Laid out inline they wrapped into ragged rows, because Team runs much longer than the others, and the dot delimiters could land at the end of a wrapped line. Note on the previous commit: it claimed those dot separators were already removed. They were not. The edit silently failed to match after the file had been reformatted, so the change never applied. Asserting on each replacement caught it this time.
The caption under the hero image repeated the category and client, both already in the overline directly above it. Removing it also let the frame move onto the image itself instead of a wrapper div. The outcome summary is labelled TL;DR. It reads as a summary up front and does not collide with the Outcome or Result section every case study already has further down, which is why an "Outcome" label there felt duplicated.
- Pomodorii and Automancer are drafts now, so they leave the work index, the sitemap and their own routes while the writing stays in the repo - Removed the category filter. Every live project is product design, so the control had nothing left to filter and the count beside it was noise - Work index order is explicit: Utsuwa, the web and mobile redesign, then the rest. The landing page leads with the same three - The section rail now runs on blog posts as well as case studies Two tests depended on the archived content. The unit test reached through Pomodorii for the only YouTube embed in the repo, so transformYouTube is exported and tested directly; archiving a document can no longer take its fixture with it. The filtering tests became an order assertion and an archive assertion that checks both routes 404 and neither slug lingers in the sitemap.
Rebuilt the downloadable resume from the resume-template.docx structure: centred name, contact rule, shaded and ruled section bands, a three-column competency grid, and company / role / bullets blocks. Set in Inter with the site's ink palette instead of Constantia and Word's navy. Fonts are static Inter instances cut from the variable font. Chromium embeds a variable-font instance as unnamed Type3 glyph procedures, which extracts badly; the static instances embed as named CIDFontType2 subsets instead. Content is grounded in the case studies: 50+ kiosks across five cities, 70+ B2B accounts, 79 Mizu components, 22 Utsuwa releases and 1,980 downloads, the pending kiosk mapping patent. Education is omitted.
Swaps adapter-vercel (pinned to nodejs22.x) for adapter-cloudflare and adds wrangler config. Adds the nodejs_compat flag, which SvelteKit's server runtime needs for node:async_hooks; without it the Worker throws at runtime rather than failing the build. The contact form rate limiter kept its counter in a module-level Map. That survives on a warm Node lambda but not on Workers, where each isolate would get its own empty counter and the limit would barely apply. checkRateLimit now prefers the Workers rate limiting binding and falls back to the in-memory limiter locally, in tests, and if the binding throws. The binding's period is restricted to 10 or 60 seconds, so this is a 5-per-60s burst guard rather than the previous 5-per-10-minutes window. Verified against the real workerd runtime with wrangler dev: every route including sitemap, rss, robots and llms.txt, and a contact form POST that exercised CSRF, the rate limit binding (5 through, 6th and 7th blocked) and the Resend call.
All content is bundled at build time through import.meta.glob, so nothing needed a per-request render. Prerendering the site turns 19 routes into static assets, which Cloudflare serves free and without invoking a Worker. Only two routes stay dynamic: /contact, because form actions need a live handler, and /rss.xml, because as a static file it would be typed application/xml by extension and lose the application/rss+xml header that some feed readers expect. The e2e suite caught that one. Dropped the s-maxage header on the homepage; it existed for Vercel's CDN and a prerendered page never renders per request. Also makes the Playwright base URL overridable via E2E_BASE_URL so the suite can run against the Workers build rather than the Vite dev server. Verified against workerd: 28 e2e, 13 unit, typecheck clean.
injectAnalytics posts to /_vercel/insights, which only exists on Vercel. Left in place it would have produced failing requests and console noise on every page view after the move, while collecting nothing. Cloudflare Web Analytics covers the same ground from the dashboard with no client code, if we want it back. Also updates the README, which still described adapter-vercel.
Move hosting to Cloudflare Workers
…29) Commits the custom domain routes and workers_dev fallback that the cutover added, so the deployed configuration lives in git rather than only on Cloudflare. Adds a second CI job that runs the e2e suite against workerd instead of the Vite dev server, and fails the build if wrangler reports a missing Node builtin. Both bugs that surfaced during the migration were invisible to the existing job: the nodejs_compat flag throws at runtime rather than at build, and a prerendered feed served the wrong content type. The old suite passed green through both. The nodejs_compat guard is verified by removing the flag and confirming the job fails, so it is not a check that can only pass. Co-authored-by: Charles Dyas <dyascj@gmail.com>
Turnstile's managed mode is free with no practical ceiling here: 20 widgets per account and a million siteverify calls a month. The widget only renders when a site key is present, and verification is skipped entirely when no secret is configured, so plain vite dev and the test suite are unaffected. The site key comes from a wrangler var rather than $env/static/public, because a static import is baked in at build time and the CI builder has no .env.local; it would have shipped empty. Two deliberate escape hatches. A missing token is rejected, but if siteverify itself is unreachable the submission is allowed through rather than letting a Cloudflare outage silently kill the contact form. The rate limiter still sits in front of it either way. The e2e stub that existed for Vercel Analytics now covers Turnstile instead, since Turnstile logs its own noise to console.error and will not issue a token to an automated browser, which is rather the point of it. Verified against workerd: the widget renders with the site key, a submission with no token is rejected, 18 unit tests, 28 e2e, typecheck and build clean. The human path is unverified by design, see the PR. Co-authored-by: Charles Dyas <dyascj@gmail.com>
Rejecting submissions with no Turnstile token took the form down for real people. The widget was not rendering: auto-render fires once when the script loads, before hydration has settled, and the node it renders into can be replaced out from under it. So no token was ever produced and every submission was refused. Renders explicitly after mount instead, against a node held by bind:this. Also stops treating a missing token as a rejection. A missing token means the widget did not render or something blocked it, not that the sender is a bot, and failing closed there breaks the form for anyone running a script blocker. It is logged instead. A token that is present and invalid is still rejected, and the rate limiter still applies either way. Turnstile is defence in depth here, not a gate the contact form depends on.
The permissive setting was a stopgap from when the widget was not rendering. It let anything without a token through, which waves past the crudest spam: a bot that never runs JavaScript produces no token at all, so Turnstile was only catching the ones that tried and failed. Now that the widget is confirmed working for real visitors, a missing token is refused again. The message points anyone whose blocker eats challenges.cloudflare.com at the other contact links rather than leaving them at a dead end. Verified as an unpromoted version on real infrastructure before taking production traffic, since a Turnstile token cannot be produced from an automated browser and this same change broke the form last time.
rss.xml was the last per-request Worker route besides /contact. As a static asset it would be typed application/xml by extension, so a root _headers rule overrides it to application/rss+xml (verified against the local Workers runtime). Feed polls now cost zero invocations.
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
cjdyas-design | 49b3bfe | Commit Preview URL Branch Preview URL |
Aug 08 2026, 01:03 PM |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
rss.xml was the last recurring Worker invocation besides /contact: feed readers poll it around the clock, and each poll ran the Worker. This prerenders it with the rest of the content and adds a root _headers rule so the static asset still serves as application/rss+xml rather than the extension-derived application/xml.
Verified locally against the Workers runtime (wrangler dev): /rss.xml returns 200 with Content-Type: application/rss+xml; charset=utf-8, and the autogenerated immutable-asset header block still merges after the custom rule.
After this, the only Worker invocations left are /contact (form actions need a live handler) and 404s from bot probes.