<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>To Ansonated</title><description>A commonplace book for things I build, read, and wonder about</description><link>https://blog.ansonate.ink/</link><language>en</language><item><title>A Rented Theme, a Site of My Own: Moving My Little Home from Jekyll to Astro</title><link>https://blog.ansonate.ink/posts/jekyll-to-astro-en/</link><guid isPermaLink="true">https://blog.ansonate.ink/posts/jekyll-to-astro-en/</guid><description>Why I left minima and why I landed on Astro: a landing page, multilingual content, and what happens when you split one site into a monorepo, two projects, and three domains.</description><pubDate>Fri, 23 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;If you open ansonate.ink right now, what you see is no longer a bare index of blog posts but a landing page: the five most recent posts, a gallery of apps, and a badge that says I&apos;m looking for a co-op. The blog itself has moved to blog.ansonate.ink, and the dozen or so SPA demos I&apos;ve accumulated over the years hang off dev.ansonate.ink. Behind those three domains are two Astro projects in one monorepo, plus a GitHub Pages site that exists only to host old things.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
    R[&quot;One monorepo&quot;]
    R --&amp;gt; L[&quot;landing/&amp;lt;br/&amp;gt;Astro, zero client JS&quot;]
    R --&amp;gt; B[&quot;blog/&amp;lt;br/&amp;gt;Astro (Fuwari)&quot;]
    L --&amp;gt; A[&quot;ansonate.ink&quot;]
    B --&amp;gt; D[&quot;blog.ansonate.ink&quot;]
    O[&quot;Old demo repos&quot;] --&amp;gt; E[&quot;dev.ansonate.ink&amp;lt;br/&amp;gt;GitHub Pages&quot;]
    A -.-&amp;gt; D
    A -.-&amp;gt; E
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This post is the full account of the migration: why I left, why I landed on Astro, why the hardest part turned out to be deployment rather than the framework, and everything I did to keep the old links alive. There are two prequels: &lt;a href=&quot;/posts/new-blog-site/&quot;&gt;in 2024 I wrote about why I chose Jekyll&lt;/a&gt;, and &lt;a href=&quot;/posts/htmx-tailwind-en/&quot;&gt;the htmx piece from a few months ago&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;Three Requirements, One Moment&lt;/h2&gt;
&lt;p&gt;There were three motives for this migration: one push and two pulls.&lt;/p&gt;
&lt;p&gt;The push: I didn&apos;t want to keep patching minima. The next section unpacks the bill, so here&apos;s just the conclusion. Over two years, every customization to the blog was pasted into the body as an include, and each layer of patches made the next one more expensive. The expected cost of &quot;adding something to the blog&quot; had overtaken the cost of &quot;rewriting it.&quot;&lt;/p&gt;
&lt;p&gt;Pull one: I needed a landing page. The blog index can&apos;t do that job. It only lists posts in reverse chronological order, and what I wanted was an entrance I could arrange freely: what I&apos;ve written lately, what I&apos;ve built, what I&apos;m doing now. Implementing that in minima would mean forking the theme&apos;s home layout first, then pasting five more layers of patches on top.&lt;/p&gt;
&lt;p&gt;Pull two was multilingual content. Content sites are static by default, but my blog is bilingual, and &quot;bilingual&quot; has a very specific meaning for me: when a recruiter clicks through from my résumé while I&apos;m looking for a co-op, they cannot land on a screenful of Chinese write-up. That requirement splits in half. One half is that the content needs Chinese and English versions. Jekyll has plugins that can do this, but Jekyll hosted on GitHub Pages only runs whitelisted plugins, and the multilingual ones aren&apos;t on the list. Using them means building locally and pushing the output, which is moving CI&apos;s work back onto my own machine and back to the same old toolchain bill. The other half is filtering and switching content by the visitor&apos;s language. That&apos;s a runtime decision, and purely static hosting was never born with that organ; the only way to add it is another &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; pasted into the body, the same disease of patch debt. How it landed is in section 3.&lt;/p&gt;
&lt;p&gt;There&apos;s also a cost that isn&apos;t a motive but has been accruing interest the whole time: Ruby isn&apos;t part of my toolchain. Every time I came back to this project, the opening act was &lt;code&gt;bundle install&lt;/code&gt;, followed by some gem version problem, some strange PATH problem — and I was debugging in a language I don&apos;t write, with a package manager I don&apos;t know. Jekyll itself isn&apos;t slow. What&apos;s slow is the ticket price every time I walk in.&lt;/p&gt;
&lt;p&gt;The direction was settled long before. When I wrote the htmx post, I spent a whole section on islands architecture and said Astro makes it a first-class citizen: zero JS by default, and only the components that need a runtime get one. A blog that is &quot;content first, interaction on demand,&quot; a landing page that is &quot;fully free-form, cut to fit,&quot; and a multilingual mechanism that is &quot;static at the base, dynamic in patches&quot; — three requirements pointing at the same framework. Any one of them alone would have produced a different answer. For the landing page alone, I could spin up a new repo and throw anything together. To escape minima alone, I could switch to another theme and keep going with Jekyll. For multilingual content alone, I could grit my teeth and build locally before pushing the output. But all three colliding at once made &quot;one monorepo, one Astro setup, two kinds of output&quot; the only reasonable shape.&lt;/p&gt;
&lt;h2&gt;What I Hacked into minima&lt;/h2&gt;
&lt;h3&gt;The Two Lines at the Top of Every Post&lt;/h3&gt;
&lt;p&gt;The previous version of this site had 12 content files: seven posts, three drafts, the home page, and the about page. Eleven of them began their body with the same two lines of Liquid:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{% include override-styles.html %}
{% include load-barlow-font.html %}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Posts with math added &lt;code&gt;math-support.html&lt;/code&gt; (five files); posts with diagrams added &lt;code&gt;mermaid-support.html&lt;/code&gt; (three files). The busiest page collected all four, at the top of the impedance post:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{% include override-styles.html %}
{% include load-barlow-font.html %}
{% include math-support.html %}
{% include mermaid-support.html %}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Fonts, code-block colors, math rendering, diagram rendering — these site-wide behaviors weren&apos;t configured in &lt;code&gt;_config.yml&lt;/code&gt;, and they weren&apos;t written into any layout template either. They were &lt;strong&gt;opted into page by page&lt;/strong&gt;: each page declared &quot;I want these.&quot; Every time I started a new post, the first move before writing wasn&apos;t thinking of a title; it was reciting an incantation. Does this one have math? Does it have diagrams? Which lines do I paste?&lt;/p&gt;
&lt;p&gt;A page that forgot to paste them got no warning. Without &lt;code&gt;math-support&lt;/code&gt;, the reader sees a screenful of raw LaTeX; without &lt;code&gt;mermaid-support&lt;/code&gt;, a diagram is a silent code block. Local preview passed, the build passed, the GitHub Pages deploy passed. Nowhere along that chain did any step consider &quot;a few chunks of HTML in the body&quot; to be something worth checking.&lt;/p&gt;
&lt;p&gt;There was another layer of waste hiding in the output. &lt;code&gt;override-styles.html&lt;/code&gt; had exactly one copy of its content, but Jekyll inlined it into every page&apos;s HTML: each page carried an identical &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; block, and the browser never had a chance to cache a shared stylesheet, because that stylesheet didn&apos;t exist. What existed was 11 inline copies.&lt;/p&gt;
&lt;h3&gt;The Shapes a Patch Takes&lt;/h3&gt;
&lt;p&gt;Five includes, all different in content, all the same in shape: go around the theme, and stuff code that belonged somewhere else into the body.&lt;/p&gt;
&lt;p&gt;The font line was the shortest: &lt;code&gt;&amp;lt;link href=&quot;/assets/fonts/barlow.css&quot;&amp;gt;&lt;/code&gt;. That &lt;code&gt;barlow.css&lt;/code&gt; was hand-written by me, 18 &lt;code&gt;@font-face&lt;/code&gt; rules for the 18 &lt;code&gt;.ttf&lt;/code&gt; files I downloaded from Google Fonts into &lt;code&gt;assets/fonts/&lt;/code&gt;. No woff2, no &lt;code&gt;unicode-range&lt;/code&gt;, no subsetting; every weight was an uncompressed original font file. That need has a standard answer today, and it&apos;s called &lt;code&gt;@fontsource&lt;/code&gt;: one npm dependency, font files go into the build chain, the stylesheet is generated automatically, and upgrading is &lt;code&gt;pnpm update&lt;/code&gt;. Instead, I was maintaining a hand-transcribed font directory and inviting it in with a Liquid tag in every page body.&lt;/p&gt;
&lt;p&gt;The style block existed to push minima&apos;s defaults back down:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;*:not(code, .katex *) { font-family: &apos;Segoe UI&apos;, Tahoma, Geneva, Verdana, sans-serif; }
code, span:is(.highlight *) { /* … */ background-color: #f0f0f0 !important; }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;!important&lt;/code&gt; was there because it needed to override Jekyll&apos;s styles. Once the cascade war reaches that point, the selectors have lost their meaning. &lt;code&gt;*:not(code, .katex *)&lt;/code&gt; means &quot;change the font everywhere except these two special cases&quot;; it doesn&apos;t modify a component, it modifies the entire document. &lt;code&gt;p { overflow: auto }&lt;/code&gt; is the same: rules like that are almost always the relic of one specific overflow incident, and I had long forgotten which incident. Maybe at the time I just couldn&apos;t find the offending element, so every &lt;code&gt;&amp;lt;p&amp;gt;&lt;/code&gt; in the world got dragged down too.&lt;/p&gt;
&lt;p&gt;Math was three CDN tags: KaTeX 0.18.6, each tag carrying a sha384 hash, plus a config object written in an &lt;code&gt;onload&lt;/code&gt; attribute, copied from the official tutorial at the time:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;script defer src=&quot;https://cdn.jsdelivr.net/npm/katex@0.18.6/dist/katex.min.js&quot;
    integrity=&quot;sha384-7jGyG5zFwmEamqNWdCbpsPn+GTWEis3lnV7X/jXHyhFpJG7ExABLyMapabg8F4+p&quot;
    crossorigin=&quot;anonymous&quot;&amp;gt;&amp;lt;/script&amp;gt;
&amp;lt;script defer src=&quot;https://cdn.jsdelivr.net/npm/katex@0.18.6/dist/contrib/auto-render.min.js&quot; ...
    onload=&quot;renderMathInElement(document.body, { delimiters: [/* … */], throwOnError: false });&quot;&amp;gt;&amp;lt;/script&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Those three lines hit every flaw of this kind of setup at once. The version number is locked into the URL, so upgrading means re-copying three lines of base64. Rendering happens in the reader&apos;s browser, its timing is &lt;code&gt;onload&lt;/code&gt;, and its scope is &lt;code&gt;document.body&lt;/code&gt;: KaTeX&apos;s CSS only takes effect after the body has downloaded and the script has loaded, so formulas flash as raw text first and then the whole page reflows. &lt;code&gt;throwOnError: false&lt;/code&gt; guarantees that a mistyped formula won&apos;t bother anyone, including me.&lt;/p&gt;
&lt;p&gt;The diagram include took the same shape: pull an ESM build of mermaid from jsdelivr, find &lt;code&gt;code.language-mermaid&lt;/code&gt; in the browser (that&apos;s the output convention of the Rouge highlighter), and render on the spot. Two details say a lot. First, the URL says &lt;code&gt;mermaid@11&lt;/code&gt;, which isn&apos;t version pinning but &quot;give me the latest 11.x&quot;: the page&apos;s behavior depends on whatever 11.x was on jsdelivr the day the reader visited. That sat in the same repo, right next to KaTeX&apos;s pin-to-a-hash approach, so the hygiene standard was clearly not uniform. Second:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;mermaid.initialize({ startOnLoad: true, theme: &apos;neutral&apos; });
mermaid.run({ querySelector: &apos;code.language-mermaid&apos; });
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;startOnLoad: true&lt;/code&gt; makes mermaid look for elements with the &lt;code&gt;.mermaid&lt;/code&gt; class, of which this site had exactly zero. The line actually doing the work is the manual &lt;code&gt;run()&lt;/code&gt; below it. One of those two lines is dead configuration that stayed for two years because it was harmless — and harmless dead configuration lives the longest. The hardcoded &lt;code&gt;&apos;neutral&apos;&lt;/code&gt; theme is also worth noting: there was no dark mode back then, and by the time there was, changing it meant hunting down three strings across three files.&lt;/p&gt;
&lt;p&gt;The figures were &lt;code&gt;center-figure.html&lt;/code&gt;, which appeared 18 times:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{% include center-figure.html src=&quot;/assets/2025-08-07-a-brief-introduction-on-impedance/Low_pass_filter.png&quot;
    alt=&quot;LC Circuit&quot; maxw=&quot;400px&quot; maxh=&quot;400px&quot; %}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For every image, I first decided on a display size, calculated the pixels by hand, filled in two parameters, and copied the alt text one more time. The output was an inline-styled &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt; wrapping an &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; with no &lt;code&gt;width&lt;/code&gt;/&lt;code&gt;height&lt;/code&gt; attributes: no lazy loading, no &lt;code&gt;srcset&lt;/code&gt;, and layout shift thrown in free with every image. The image path was absolute too, so moving a directory once broke every link on the site.&lt;/p&gt;
&lt;h3&gt;The Compound Interest of Patches&lt;/h3&gt;
&lt;p&gt;To be fair: Jekyll didn&apos;t lock anyone into the patching path. The theme&apos;s templates live in a gem, and you can copy &lt;code&gt;_layouts/post.html&lt;/code&gt; into your repo, change it, and from that day on it&apos;s your own template. That&apos;s the door the official side leaves open. I never walked through it, because pasting was always cheaper in the moment: an include takes effect immediately, whereas forking the theme means every upstream fix in minima becomes something I have to follow by hand, and I&apos;d have to maintain a directory structure I never designed.&lt;/p&gt;
&lt;p&gt;So the problem isn&apos;t the first patch; the first patch was even reasonable. The cost starts showing from the third patch, or the fifth, onward: new changes land in the old patches&apos; coordinate system, and every added layer requires remembering why the previous layers exist, which files they live in, and whether they depend on each other&apos;s order. But technical debt always comes due. &lt;strong&gt;Patch interest compounds&lt;/strong&gt;, and the monthly payment two years later is that when I want to add something halfway decent, my first reaction is &quot;forget it.&quot;&lt;/p&gt;
&lt;p&gt;Three examples are enough to describe the situation of this codebase. Inside &lt;code&gt;override-styles.html&lt;/code&gt; there&apos;s a line &lt;code&gt;box-shadow: inset 0 1px 1px rgb(255, 255, 2555, 0.3)&lt;/code&gt;: 2555 is a typo, and the browser clamps it to 255 under CSS Color 4, so it has been quietly wrong for two years. KaTeX&apos;s &lt;code&gt;throwOnError: false&lt;/code&gt; is the same species: errors are configured to be invisible. mermaid&apos;s &lt;code&gt;@11&lt;/code&gt; is a third: the library floats, the page&apos;s behavior drifts, and nobody knows which day the drift happened. What they share is this: no type checking, no lint, no artifact that any CI would verify. This code doesn&apos;t live on a layer that can be checked. It lives in the markdown body.&lt;/p&gt;
&lt;p&gt;By this point, &quot;minima isn&apos;t enough&quot; no longer explains much. Styles, fonts, math, diagrams, figures — every patch to those five things points at a structure that should have existed: fonts should be a dependency, math should happen at build time, figures should be one line of markdown, mermaid should be a component with a name, styles should belong to a system where I can write tokens. In &quot;Jekyll plus somebody else&apos;s theme,&quot; those slots either don&apos;t exist, or you only get them by forking the theme first. And my actual situation for two years can be summed up in one sentence: &lt;strong&gt;I had been maintaining a fork of a theme — just without the ownership of the fork.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Where those things live now is worth a note here; I&apos;ll expand later. Fonts became &lt;code&gt;@fontsource/barlow&lt;/code&gt;, a dependency sitting in &lt;code&gt;package.json&lt;/code&gt;. Math became &lt;code&gt;remark-math&lt;/code&gt; plus &lt;code&gt;rehype-katex&lt;/code&gt;, rendered to HTML at build time, with its version and upgrade path managed by the lockfile. Figures went back to ordinary markdown image syntax, with relative paths resolved and hashed by the build. mermaid is the only thing left at runtime, and even its library still comes from jsdelivr, but the code driving it has moved: initialization, re-rendering, theme switching, and serialization across page changes are all handled by one explicit piece of code in the layout, so when something breaks I can trace it in the source. The runtime didn&apos;t disappear; it just moved into the layer where it belongs.&lt;/p&gt;
&lt;h2&gt;Why Astro and Fuwari&lt;/h2&gt;
&lt;p&gt;There were three criteria for the migration, ordered by importance.&lt;/p&gt;
&lt;p&gt;First, one tool had to answer all three requirements at once. The blog needs a content pipeline: content collections turn markdown into data with a schema, a remark/rehype plugin chain handles rendering, and RSS, sitemap, and reading time all have ready-made shapes. The landing page needs a free frontend: routing, components, and styles all under my control. Multilingual needs a third thing: pages generated statically, with the dynamic bits shrinking into a few isolated components. Astro happens to give all three.&lt;/p&gt;
&lt;p&gt;Second, JS granularity has to reach the component level. The vast majority of the blog&apos;s content is static, but a few components always need to be alive — the two language switchers are the live ones; the landing page has zero lines of client JS, and that&apos;s all it needs. Astro&apos;s default is exactly &quot;no JS unless you ask for it.&quot; The full version of that argument is in &lt;a href=&quot;/posts/htmx-tailwind-en/&quot;&gt;section 7 of the htmx post&lt;/a&gt;, so I won&apos;t repeat it here.&lt;/p&gt;
&lt;p&gt;Third, the toolchain has to be mine. npm, TypeScript, Vite — that&apos;s the stack I debug in daily, and when the build fails I can read every line of the error. That sounds obvious, but on the Jekyll side it was a wound that kept bleeding for two years.&lt;/p&gt;
&lt;p&gt;The rejected options, briefly. Hugo is another patient with the same disease: a theme model, templates living elsewhere, customization by overriding, multilingual still assembled from plugins — only the template language changes from Liquid to Go templates. Next is heavy in the opposite direction: bringing a runtime and deployment assumptions to a mostly static site, in exchange for capabilities I would never use.&lt;/p&gt;
&lt;p&gt;But this blog wasn&apos;t written from scratch. It started from &lt;a href=&quot;https://github.com/saicaca/fuwari&quot;&gt;Fuwari&lt;/a&gt;, an Astro blog theme — or more precisely, material. I should be clear about the difference between &quot;forking a theme&quot; here and the &quot;theme model&quot; I spent a couple thousand words trashing in section 2.&lt;/p&gt;
&lt;p&gt;The difference is where the code lives. minima&apos;s templates sit in a gem, and all I can do is override and inject. Fuwari&apos;s entire &lt;code&gt;src/&lt;/code&gt; sits in my repo, including the theme mechanism itself: the remark plugin that handles mermaid, the plugin that computes excerpts and reading time, the rehype components that render cards — all named, diffable, editable files under &lt;code&gt;src/plugins/&lt;/code&gt;. The day I forked it, the theme&apos;s maintainer became me. That&apos;s exactly the &quot;ownership of the fork&quot; from section 2 — except this time I took it.&lt;/p&gt;
&lt;p&gt;What I&apos;ve touched since taking over is roughly a list of plugins: fenced code blocks for mermaid become &lt;code&gt;&amp;lt;pre class=&quot;mermaid-src&quot;&amp;gt;&lt;/code&gt; at build time and are handed to the frontend; &lt;code&gt;remark-excerpt&lt;/code&gt; and &lt;code&gt;remark-reading-time&lt;/code&gt; generate excerpts and durations for list pages; GitHub cards, link cards, and admonitions go through &lt;code&gt;rehype-components&lt;/code&gt;; code blocks were switched to expressive-code, with fonts and colors adjusted. None of this is &quot;configuring a theme&quot;; all of it is writing my own build pipeline.&lt;/p&gt;
&lt;h3&gt;A Table of Contents, Two Years Late&lt;/h3&gt;
&lt;p&gt;Having no table of contents on long posts was an old grievance from the Jekyll era. I always wanted one, but every route available there led to yet another layer of patches, so I never did anything about it.&lt;/p&gt;
&lt;p&gt;Fuwari upstream has a TOC component: a &lt;code&gt;&amp;lt;table-of-contents&amp;gt;&lt;/code&gt; custom element with scroll tracking and an indicator bar, sitting in a floating column to the right of the post body and appearing only at 2xl and above. I moved it into the sidebar and wrapped it in a card:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;div id=&quot;toc-card&quot; class=&quot;hidden lg:block sticky top-4 mt-4&quot;&amp;gt;
    &amp;lt;WidgetLayout id=&quot;toc-widget&quot; ...&amp;gt;
        &amp;lt;div id=&quot;toc-inner-wrapper&quot; class=&quot;max-h-[calc(100vh-12rem)] overflow-y-auto ...&quot;&amp;gt;
            &amp;lt;TOC headings={headings} /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;sticky&lt;/code&gt; moved from &lt;code&gt;#sidebar-sticky&lt;/code&gt; (the categories-and-tags blob) to this card; the card&apos;s height is capped at one screen minus 12rem, so a long TOC scrolls inside the panel. In &lt;code&gt;MainGridLayout&lt;/code&gt; I deleted the whole floating right-hand block and left a comment for the next person reading the code: &lt;code&gt;#toc&lt;/code&gt; now belongs to the sidebar, and swup needs this id to stick around. While I was there, I swapped &lt;code&gt;overflow-hidden&lt;/code&gt; on &lt;code&gt;main&lt;/code&gt; for &lt;code&gt;min-w-0&lt;/code&gt;, otherwise the card&apos;s shadow would get clipped.&lt;/p&gt;
&lt;p&gt;The component itself changed too. Upstream&apos;s scroll tracking uses IntersectionObserver to watch each section, but the sections produced by &lt;code&gt;remark-sectionize&lt;/code&gt; are nested: a parent section&apos;s box contains all of its child sections, so when a child heading scrolls into view, the parent heading is also judged to be &quot;being read,&quot; even though it scrolled off the top long ago. The fix is to ignore the boxes and compute by &quot;between this heading and the next one,&quot; paired with rAF batching so it recomputes at most once per frame.&lt;/p&gt;
&lt;p&gt;The amount of work here wasn&apos;t huge, but none of it was &quot;configuration&quot;: which column to move it to, who owns &lt;code&gt;sticky&lt;/code&gt;, how tracking is computed — the answers all live in my own layout and component files. Two years ago it stopped at &quot;I want one&quot;; now it&apos;s a card. And when upstream ships new features later, which ones I chase and which ones I don&apos;t is my call too.&lt;/p&gt;
&lt;h3&gt;Multilingual: The Dynamic Part of a Content Site&lt;/h3&gt;
&lt;p&gt;The landing page is English-only; the blog is bilingual. In section 1 multilingual was a reason to migrate; here it&apos;s a concrete mechanism, in three layers.&lt;/p&gt;
&lt;p&gt;The content layer. Each post&apos;s frontmatter carries &lt;code&gt;lang&lt;/code&gt; and &lt;code&gt;translationKey&lt;/code&gt;, and the two language versions of the same post recognize each other by key. When a post page detects its twin, it shows a cross-link, and the label follows the interface language: a Chinese interface says &quot;English version,&quot; an English interface says &quot;Chinese version.&quot;&lt;/p&gt;
&lt;p&gt;The list layer. There&apos;s a language switch in the nav bar; once set, the home page and archive show only versions in that language. The filtering logic is collected in one shared function: group by &lt;code&gt;translationKey&lt;/code&gt;, prefer the version in the chosen language, and fall back to whatever version exists. Better to show a post in the other language than to make it vanish.&lt;/p&gt;
&lt;p&gt;The interface layer. Fuwari&apos;s built-in i18n is build-time: the site language is baked into the HTML at build. I moved it to runtime: server-rendered strings carry &lt;code&gt;data-i18n&lt;/code&gt; markers, and on a switch one function scans the document and rewrites in place; Svelte components subscribe to the same CustomEvent, and after swup changes pages it replays once more. Interface language and content language are two independent switches and two localStorage keys, because &quot;reading an English article in a Chinese interface&quot; is a perfectly valid combination.&lt;/p&gt;
&lt;p&gt;The defaults were weighed deliberately: both switches default to English. A first-time visitor — the recruiter, say — sees an English site; a Chinese reader clicks the switch once, the choice is remembered, and every return visit after that is in Chinese. I didn&apos;t implement Accept-Language negotiation, because &quot;English default, one click, remembered long-term&quot; already covers the scenarios I care about, and the extra correctness of negotiation isn&apos;t worth the extra surprise it brings.&lt;/p&gt;
&lt;p&gt;The whole thing is islands: pages are static at build time, and the dynamic part is two switches, two localStorage keys, and a few CustomEvents. Section 1 said multilingual needs &quot;a runtime decision.&quot; In practice it turned out to need two small islands, not a server.&lt;/p&gt;
&lt;h3&gt;The Work at the Seams&lt;/h3&gt;
&lt;p&gt;The remaining problems all grow in the seams. Two representative ones.&lt;/p&gt;
&lt;p&gt;Fuwari uses swup for page transitions: clicking a link doesn&apos;t reload the whole page, it replaces the main container and re-executes the scripts inside the page. That mechanism collided with mermaid and produced a race condition. On a page change, swup re-executes the scripts in the body, so mermaid&apos;s initialization code gets evaluated again; if state hangs off module scope, every navigation registers one more &lt;code&gt;page:view&lt;/code&gt; hook, and after a few jumps two concurrent &lt;code&gt;mermaid.run()&lt;/code&gt; calls rewrite the same &lt;code&gt;&amp;lt;pre&amp;gt;&lt;/code&gt; that&apos;s still rendering. The symptom is a failed diagram render with only the code left behind.&lt;/p&gt;
&lt;p&gt;The fix is to hang one-time state on &lt;code&gt;window&lt;/code&gt; and serialize rendering:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const mermaidRuntime = (window as unknown as { __mermaidRuntime?: MermaidRuntime })
	.__mermaidRuntime ??= { mounted: false, queue: Promise.resolve() };

function scheduleMermaidRender(): void {
	mermaidRuntime.queue = mermaidRuntime.queue.then(renderMermaid);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;mounted&lt;/code&gt; guarantees the hook is registered only once, and &lt;code&gt;queue&lt;/code&gt; guarantees at most one &lt;code&gt;run()&lt;/code&gt; in flight at any moment. Two other things got fixed along the way: clear the &lt;code&gt;data-processed&lt;/code&gt; flag mermaid leaves behind before rendering, otherwise restoring the source on a dark-mode switch won&apos;t trigger a re-render; and the theme is no longer hardcoded, but follows the &lt;code&gt;dark&lt;/code&gt; class on &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt;, watched with a MutationObserver. The bill from section 2 — &quot;changing the theme means hunting through three files&quot; — gets settled here.&lt;/p&gt;
&lt;p&gt;There&apos;s a smaller pit of the same kind: swup treats attachments under &lt;code&gt;/assets/&lt;/code&gt; (the zip attached to the activation-function post, for example) as pages and tries to fetch them, fails to find a swap container, aborts, and leaves the page stuck in its transition class with all styles gone. The fix is to give swup an ignore list in its config, telling it which paths aren&apos;t pages.&lt;/p&gt;
&lt;p&gt;Of course, neither of these bugs has anything to do with &quot;a blog.&quot; Both are seams between &quot;SPA-style navigation&quot; and &quot;third-party renderers and real downloads.&quot; The theme solves what a page looks like; the seams are always yours. That&apos;s what treating a theme as material actually means.&lt;/p&gt;
&lt;h2&gt;One Repo, Two Projects, Three Domains&lt;/h2&gt;
&lt;p&gt;Choosing the framework was the easiest part of this migration. The hard part was deployment. Why it was hard starts with one small feature on the landing page.&lt;/p&gt;
&lt;p&gt;The landing page has a &quot;recent posts&quot; list. Its simplest implementation is build-time generation: a script reads every post&apos;s frontmatter in &lt;code&gt;posts&lt;/code&gt;, drops drafts, filters by tag, picks the English version of each Chinese/English pair, takes the five most recent by publish date, and writes out JSON. It&apos;s wired into the landing page&apos;s dev and build commands, re-syncing on every build:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&quot;dev&quot;: &quot;node scripts/sync-posts.mjs &amp;amp;&amp;amp; astro dev&quot;,
&quot;build&quot;: &quot;node scripts/sync-posts.mjs &amp;amp;&amp;amp; astro build&quot;,
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Build-time coupling is good coupling: the output is purely static, with no runtime fetch, no CORS, no loading state, and the list updates on the same day as the blog. But it comes with one hard constraint: &lt;strong&gt;the landing page&apos;s build must be able to read the blog&apos;s source files&lt;/strong&gt;. Two sites, one repo, no other option. That&apos;s where the monorepo came from.&lt;/p&gt;
&lt;p&gt;And that knocked GitHub Pages out. Its publishing model is one repository per site: one publish source, one custom domain. The requirement for two projects, each with its own domain, has no counterpart in that model.&lt;/p&gt;
&lt;p&gt;I considered every alternative I could think of:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Two repos, with the landing page checking out the blog at build time. Cross-repo checkout needs a token, the build order becomes a coordination problem between two repos, and pushing a post to the blog won&apos;t trigger a rebuild of the landing page, so the list stays stuck at the old state.&lt;/li&gt;
&lt;li&gt;The landing page fetching the blog&apos;s RSS or JSON at runtime. That adds a loading state above the fold, ties the landing page&apos;s availability to the blog&apos;s availability forever, and contradicts the whole premise of &quot;static output, JS on demand.&quot;&lt;/li&gt;
&lt;li&gt;One site, with the blog under a path prefix. One site carrying two mental models, with the &lt;code&gt;base&lt;/code&gt; path and CNAME to fiddle with, and the landing page and blog&apos;s lifecycles bound together from then on.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Cloudflare Pages&apos; model happens to be shaped exactly like the requirement: one repo can host multiple projects, and each project specifies its own root directory, build command, and custom domain. So the topology now is:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart LR
    P[&quot;push to main&quot;]
    P --&amp;gt; W[&quot;Pages build trigger&amp;lt;br/&amp;gt;configured in the dashboard&quot;]
    W --&amp;gt; L[&quot;project: landing&amp;lt;br/&amp;gt;root landing/&quot;]
    W --&amp;gt; B[&quot;project: blog&amp;lt;br/&amp;gt;root blog/&quot;]
    L --&amp;gt; A[&quot;ansonate.ink&quot;]
    B --&amp;gt; D[&quot;blog.ansonate.ink&quot;]
    G[&quot;GitHub Pages&amp;lt;br/&amp;gt;source docs/&quot;] --&amp;gt; E[&quot;dev.ansonate.ink&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The deployment chain got a new lead too. GitHub Actions in the repo only validates: &lt;code&gt;astro check&lt;/code&gt;, then a build on Node 22 and another on Node 24, with no deploy step. The actual go-live happens on Cloudflare&apos;s side: the Pages build trigger is configured in the dashboard rather than in the repo, and a push to main starts the build and deploy of the corresponding project. CI owns &quot;is the code right&quot;; CF owns &quot;is the site alive.&quot; The division of labor is clear, and the price is in section 6.&lt;/p&gt;
&lt;p&gt;The division of domains:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Domain&lt;/th&gt;
&lt;th&gt;Points to&lt;/th&gt;
&lt;th&gt;Hosting&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ansonate.ink&lt;/td&gt;
&lt;td&gt;landing&lt;/td&gt;
&lt;td&gt;Cloudflare Pages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;blog.ansonate.ink&lt;/td&gt;
&lt;td&gt;blog&lt;/td&gt;
&lt;td&gt;Cloudflare Pages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;dev.ansonate.ink&lt;/td&gt;
&lt;td&gt;old demo repos&lt;/td&gt;
&lt;td&gt;GitHub Pages&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The third domain deserves its own paragraph. Those dozen or so SPA demos are each their own repo, each with GitHub Pages enabled. GitHub has a handy mechanism: once a user site has a custom domain configured (with DNS pointed at GitHub Pages first), project sites under the same account hang off subpaths of that domain by default. So the user-site repo claims dev.ansonate.ink; every demo can live on as &lt;code&gt;dev.ansonate.ink/&amp;lt;repo&amp;gt;/&lt;/code&gt;, and old links from the main site get 301&apos;d there.&lt;/p&gt;
&lt;p&gt;This Pages site publishes from &lt;code&gt;docs/&lt;/code&gt;, which contains only a single redirect page with &lt;code&gt;noindex&lt;/code&gt;. Strictly speaking it could hold nothing at all — the demos&apos; survival doesn&apos;t depend on what&apos;s at the root path — but the root path itself needs somewhere to land, otherwise anyone visiting dev.ansonate.ink directly hits a 404. Now that redirect page sends this small crowd to the gallery at &lt;code&gt;ansonate.ink/apps&lt;/code&gt;. Nothing old died; it just moved house.&lt;/p&gt;
&lt;h2&gt;Wait! Old Links Must Not Die&lt;/h2&gt;
&lt;p&gt;The completion criterion for a migration is that every old URL has somewhere to land, &amp;lt;s&amp;gt;because I put the old links on my résumé&amp;lt;/s&amp;gt;. The old site was small, but it had plenty of URL types: eight posts, a feed, an about page, and a dozen demo paths.&lt;/p&gt;
&lt;p&gt;Posts first. Jekyll&apos;s default permalink is &lt;code&gt;/:categories/:year/:month/:day/:title.html&lt;/code&gt;, so old post URLs look like this, and they&apos;re long:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/mathematics/deep-learning/2026/10/16/desmos-and-calu.html
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The categories were whatever I felt like, and some contain spaces (&quot;Web Development&quot; on the htmx post). The browser actually requests the &lt;code&gt;%20&lt;/code&gt;-encoded form. Cloudflare Pages&apos; &lt;code&gt;_redirects&lt;/code&gt; supports placeholders, so one rule matches both the decoded and the encoded form:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/:category/programming/2026/10/07/htmx-tailwind.html  https://blog.ansonate.ink/posts/htmx-tailwind/  301
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There are two copies of &lt;code&gt;_redirects&lt;/code&gt;, because rules apply per project: the apex project can&apos;t see the blog&apos;s rules, and vice versa. The apex copy (&lt;code&gt;landing/public/_redirects&lt;/code&gt;) uses absolute URLs: old posts and the feed 301 across domains to the blogs subdomain, and demo paths 301 to the dev subdomain. The blog copy (&lt;code&gt;blog/public/_redirects&lt;/code&gt;) uses site-relative paths, catching old links that hit the blog domain directly. The feed is a separate line: jekyll-feed produced &lt;code&gt;/feed.xml&lt;/code&gt;, and on the Astro side it&apos;s &lt;code&gt;/rss.xml&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The demo paths are thirteen exact redirects plus two lowercase aliases — early repo names weren&apos;t consistently cased, and both spellings have to be caught:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/resume-tailor/*  https://dev.ansonate.ink/resume-tailor/:splat  301
/credicheck/*     https://dev.ansonate.ink/CrediCheck/:splat     301
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Think carefully before you use a 301: browsers and CDNs cache it long-term, so once you get one wrong, the time it takes the fix to take effect is out of your hands. The safe order is to start with 302s for a few days, confirm every destination is right, and only then switch to 301.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;_redirects&lt;/code&gt; also has places it can&apos;t reach: it only handles paths, not domains. A domain-level redirect like apps.ansonate.ink to &lt;code&gt;ansonate.ink/apps&lt;/code&gt; has to be a Redirect Rule written in the Cloudflare dashboard. That&apos;s recorded in the landing page&apos;s README now, because I&apos;ll almost certainly look in the wrong place next time.&lt;/p&gt;
&lt;h2&gt;Free Is Not Free&lt;/h2&gt;
&lt;p&gt;This topology isn&apos;t free. Here&apos;s the bill.&lt;/p&gt;
&lt;p&gt;Two build chains. GitHub Actions validates once, and Cloudflare builds again when deploying. The direct consequence is that &lt;strong&gt;green CI doesn&apos;t mean it&apos;s live&lt;/strong&gt;: a build failure on the CF side only appears in a different dashboard, and nothing turns red on GitHub. Versions have to be aligned in three places too: &lt;code&gt;engines&lt;/code&gt; in &lt;code&gt;landing/package.json&lt;/code&gt;, the pnpm version hardcoded in the workflow, and the one in the CF project settings. Any drift between them means &quot;works locally, passes CI, explodes in production.&quot;&lt;/p&gt;
&lt;p&gt;Two lockfiles, two sets of dependabot configs, DNS for the domains. &lt;code&gt;_redirects&lt;/code&gt; is a table that needs maintenance, and site-level paths (the feed, the about page) have to be remembered and updated whenever the site is restructured. One thing is hard to place: the Pages project settings live in the Cloudflare dashboard, and that config is the only part of the setup that isn&apos;t under version control. There isn&apos;t much of it, but every time I want to say &quot;all config is code,&quot; I have to add a caveat.&lt;/p&gt;
&lt;p&gt;When does this become over-engineering? If all you need is a blog, GitHub Pages with Jekyll or Hugo is still the least fuss, and the story ends there — right up until you start reshaping the publishing pipeline so another page can read the content.&lt;/p&gt;
&lt;h2&gt;Closing&lt;/h2&gt;
&lt;p&gt;In that announcement post from 2024, I wrote that Jekyll fit my preferences perfectly. That was true at the time, and it isn&apos;t exactly wrong now; the context just changed. Over two years my usage grew into the shape of a theme: a landing page, a set of reusable components, a bilingual convention — while the tool stayed where it was. Jekyll didn&apos;t get worse. My demands on it changed.&lt;/p&gt;
&lt;p&gt;The substance of this migration is that I stopped renting the presentation layer. From markdown to DNS, every layer now has a name, a version, and an error message I can read. The price is doing my own plumbing: two build chains, three domains, a redirect table, and a bit of Pages config hiding in a dashboard.&lt;/p&gt;
&lt;p&gt;Publishing a post now goes like this: write it, push, both projects build, the landing page&apos;s &quot;recent posts&quot; updates itself, and the old links stay where they are. Not a single step in that flow requires me to remember anything, which is probably what the person pasting includes into post bodies two years ago was after.&lt;/p&gt;
&lt;h2&gt;Appendix: Migration Checklist&lt;/h2&gt;
&lt;p&gt;For anyone about to do the same thing, in order:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Inventory every URL on the old site first: posts, feed, about, attachments, demos. Put them in a table. Migration is done when that table is all green.&lt;/li&gt;
&lt;li&gt;Order the criteria for choosing a framework: is the toolchain yours, JS granularity, ecosystem. Performance benchmarks come last.&lt;/li&gt;
&lt;li&gt;Fork the theme; don&apos;t hack the theme, unless you know there won&apos;t be many new things later. There&apos;s only one test: are the theme&apos;s template files in your repo?&lt;/li&gt;
&lt;li&gt;Content before styling: settle the frontmatter schema, where content collections live, and the multilingual conventions before you talk about how it looks.&lt;/li&gt;
&lt;li&gt;Share content between sites with a build-time script — explicit and diffable. Don&apos;t use runtime fetch.&lt;/li&gt;
&lt;li&gt;Confirm your host supports &quot;multiple projects per repo, built by directory.&quot; That&apos;s the prerequisite for the monorepo.&lt;/li&gt;
&lt;li&gt;Separate validation from deployment: CI only validates, deployment is triggered by the host on push, but accept that &quot;green light doesn&apos;t mean live.&quot;&lt;/li&gt;
&lt;li&gt;Redirects: 302 first, 301 later. Use placeholders for old paths with spaces, and test both encoded forms.&lt;/li&gt;
&lt;li&gt;Learn &lt;code&gt;_redirects&lt;/code&gt;&apos; limits: it doesn&apos;t handle domains, there&apos;s a cap on rule count, and domain-level redirects go in the dashboard.&lt;/li&gt;
&lt;/ol&gt;
</content:encoded></item><item><title>租来的主题，自己的站：把小窝从 Jekyll 搬到 Astro</title><link>https://blog.ansonate.ink/posts/jekyll-to-astro/</link><guid isPermaLink="true">https://blog.ansonate.ink/posts/jekyll-to-astro/</guid><description>为什么离开 minima，为什么落在 Astro：landing、多语言，以及把一个站点拆成 monorepo、两个 project、三个域名时发生的事。</description><pubDate>Fri, 23 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;如果你现在打开 ansonate.ink，看到的不再是简陋的博客文章索引，而是一个 landing：最近更新的五篇文章、一个 apps 画廊、一个写着我找 coop 的 bage。博客本体搬到了 blog.ansonate.ink，这些年攒下的十几个 SPA demo 挂在 dev.ansonate.ink。三个域名背后，是一个 monorepo 里的两个 Astro 项目，外加一个只用来托管旧物的 GitHub Pages。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
    R[&quot;一个 monorepo&quot;]
    R --&amp;gt; L[&quot;landing/&amp;lt;br/&amp;gt;Astro，零客户端 JS&quot;]
    R --&amp;gt; B[&quot;blog/&amp;lt;br/&amp;gt;Astro（Fuwari）&quot;]
    L --&amp;gt; A[&quot;ansonate.ink&quot;]
    B --&amp;gt; D[&quot;blog.ansonate.ink&quot;]
    O[&quot;旧 demo 项目群&quot;] --&amp;gt; E[&quot;dev.ansonate.ink&amp;lt;br/&amp;gt;GitHub Pages&quot;]
    A -.-&amp;gt; D
    A -.-&amp;gt; E
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这篇记录的是迁移的完整经过：为什么走，为什么落在 Astro，最难的部分为什么不在框架而在部署，以及为了让老链接活下来都做了什么。有两篇前情：&lt;a href=&quot;/posts/new-blog-site/&quot;&gt;2024 年我写过为什么选 Jekyll&lt;/a&gt;，&lt;a href=&quot;/posts/htmx-tailwind/&quot;&gt;几个月前那篇 htmx&lt;/a&gt; 。&lt;/p&gt;
&lt;h2&gt;三个需求，一个时间点&lt;/h2&gt;
&lt;p&gt;这次迁移有三个动机：一个推力，两个拉力。&lt;/p&gt;
&lt;p&gt;推力：我不想再给 minima 打补丁了。下一节展开这笔账，这里只说结论：两年下来，博客的定制全部以 include 的形式贴在正文里，每一层补丁都让下一层更贵，&quot;给博客加点东西&quot;的预期成本已经超过了&quot;重写&quot;。&lt;/p&gt;
&lt;p&gt;拉力一：我需要一个 landing page。博客索引页干不了这件事，它只会按时间倒序列文章，但是我要的是一个能自由排布的入口：最近写什么、做过什么、现在什么状态。在 minima 里实现它，需要先 fork 主题的 home layout，再往上贴五层补丁。&lt;/p&gt;
&lt;p&gt;拉力二是多语言。内容网站默认是静态的，我的博客却是双语的，而且&quot;双语&quot;在我这里有一个很具体的含义：找 coop 的时候，recruiter 顺着简历点进来，看到的不能是一屏中文 write-up。这个需求拆开是两半。一半是内容要有中英两个版本，这半 Jekyll 有插件能做，但 GitHub Pages 托管的 Jekyll 只跑白名单插件，多语言插件不在名单上，想用就得本地构建再推产物，等于把 CI 的活搬回自己电脑，回到工具链那笔旧账。另一半是按访客的语言筛选和切换内容，这是个运行时的决定，纯静态托管天生没长这个器官，要补只能再往正文里贴一层 &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt;，补丁债务的同一个病。落地放在第 3 节说。&lt;/p&gt;
&lt;p&gt;还有一笔不算动机、但一直在计息的成本：Ruby 不在我的工具链里。每次回到这个项目，开场都是 &lt;code&gt;bundle install&lt;/code&gt;，然后是某个 gem 的版本问题，某个奇怪的 PATH 问题，而我 debug 用的是一门我不写的语言和一个我不熟的包管理器。Jekyll 本身不慢，慢的是我每次进场的门票。&lt;/p&gt;
&lt;p&gt;方向其实早就定了。写 htmx 那篇的时候，我用一整节讲岛屿架构，说 Astro 把它做成了一等公民：默认零 JS，哪个组件需要运行时哪个才有。一个&quot;内容为主、交互按需&quot;的博客，一个&quot;高度自由、自行裁切&quot;的 landing，一个&quot;静态为底、动态按块&quot;的多语言机制，三个需求指向同一个框架。只有其中任何一个，答案都不一样：只为了 landing，开个新仓库随便搭；只为了逃离 minima，换个主题继续 Jekyll 也能混；只为了多语言，硬着头皮本地构建再推产物也行。三个撞在一起，&quot;一个 monorepo、一套 Astro、两种产物&quot;才成了唯一合理的形状。&lt;/p&gt;
&lt;h2&gt;我在 minima 里 hack 了什么&lt;/h2&gt;
&lt;h3&gt;正文开头的两行&lt;/h3&gt;
&lt;p&gt;这个站点的上一版有 12 个内容文件：七篇博文、三篇草稿、首页和关于页。其中 11 个的正文开头躺着同样的两行 Liquid：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{% include override-styles.html %}
{% include load-barlow-font.html %}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;有数学的文章再追加 &lt;code&gt;math-support.html&lt;/code&gt;（5 个文件），有图的文章再追加 &lt;code&gt;mermaid-support.html&lt;/code&gt;（3 个文件）。最满的页面凑齐四行，排在阻抗那篇的开头：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{% include override-styles.html %}
{% include load-barlow-font.html %}
{% include math-support.html %}
{% include mermaid-support.html %}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;一个站点的字体、代码块配色、数学渲染、图表渲染，这些&quot;全站行为&quot;没有配置在 &lt;code&gt;_config.yml&lt;/code&gt; 里，也没有写进某个布局模板，而是&lt;strong&gt;逐页 opt-in&lt;/strong&gt;：每一页自己声明&quot;我要这些&quot;。每开一篇新文章，动笔之前的第一个动作不是想标题，是背咒语：这篇有数学吗，有图吗，该贴哪几行。&lt;/p&gt;
&lt;p&gt;忘了贴的那一页不会得到任何警告。少了 &lt;code&gt;math-support&lt;/code&gt;，读者看到的是一整屏原始 LaTeX；少了 &lt;code&gt;mermaid-support&lt;/code&gt;，图表就是一个沉默的代码块。本地预览能过，构建能过，GitHub Pages 部署也能过。整条链路上没有一个环节认为&quot;正文里的几段 HTML&quot;是值得检查的东西。&lt;/p&gt;
&lt;p&gt;还有一层浪费藏在输出里。&lt;code&gt;override-styles.html&lt;/code&gt; 的内容只有一份，但 Jekyll 会把它内联进每一个页面的 HTML：每个页面自带一段一模一样的 &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt;，浏览器没有机会缓存一份共享样式表，因为那份样式表不存在，存在的只是 11 份 inline 拷贝。&lt;/p&gt;
&lt;h3&gt;补丁的几种形状&lt;/h3&gt;
&lt;p&gt;五个 include，内容各不相同，形状是同一种：绕过主题，把本该属于别处的代码塞进正文。&lt;/p&gt;
&lt;p&gt;字体那一行最短：&lt;code&gt;&amp;lt;link href=&quot;/assets/fonts/barlow.css&quot;&amp;gt;&lt;/code&gt;。那份 &lt;code&gt;barlow.css&lt;/code&gt; 是我手写的，18 个 &lt;code&gt;@font-face&lt;/code&gt;，对应 &lt;code&gt;assets/fonts/&lt;/code&gt; 目录下 18 份我从 Google Fonts 上下载的文件 &lt;code&gt;.ttf&lt;/code&gt;。没有 woff2，没有 &lt;code&gt;unicode-range&lt;/code&gt;，没有子集化，每个字重都是一份未压缩的原始字体文件。这个需求在今天有标准答案，叫 &lt;code&gt;@fontsource&lt;/code&gt;：一个 npm 依赖，字体文件进构建链，样式表自动生成，升级就是 &lt;code&gt;pnpm update&lt;/code&gt;。而当时的我在维护一个手抄的字体目录，并且要在每个页面的正文里用 Liquid 标签把它请进来。&lt;/p&gt;
&lt;p&gt;样式那段 &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; 负责把 minima 的默认值压回去：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;*:not(code, .katex *) { font-family: &apos;Segoe UI&apos;, Tahoma, Geneva, Verdana, sans-serif; }
code, span:is(.highlight *) { /* … */ background-color: #f0f0f0 !important; }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;用 &lt;code&gt;!important&lt;/code&gt; 时因为需要覆盖 Jekyll 的样式。层叠战争打到这个地步，选择器已经失去语义了。&lt;code&gt;*:not(code, .katex *)&lt;/code&gt; 的意思是&quot;除了两种特殊情况，全站都换字体&quot;，它修饰的不是某个组件，是整个文档。&lt;code&gt;p { overflow: auto }&lt;/code&gt; 也一样，这类全局规则几乎总是某次具体溢出事故的遗物，但我早已遗忘了是哪次事故。当时也许只是当初没找到肇事元素，于是让全世界的 &lt;code&gt;&amp;lt;p&amp;gt;&lt;/code&gt; 陪绑。&lt;/p&gt;
&lt;p&gt;数学是三行 CDN 标签：KaTeX 0.18.6，每个标签带一段 sha384 哈希，外加一个写在 &lt;code&gt;onload&lt;/code&gt; 属性里的配置对象，当时从官方教程里复制下来的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;script defer src=&quot;https://cdn.jsdelivr.net/npm/katex@0.18.6/dist/katex.min.js&quot;
    integrity=&quot;sha384-7jGyG5zFwmEamqNWdCbpsPn+GTWEis3lnV7X/jXHyhFpJG7ExABLyMapabg8F4+p&quot;
    crossorigin=&quot;anonymous&quot;&amp;gt;&amp;lt;/script&amp;gt;
&amp;lt;script defer src=&quot;https://cdn.jsdelivr.net/npm/katex@0.18.6/dist/contrib/auto-render.min.js&quot; ...
    onload=&quot;renderMathInElement(document.body, { delimiters: [/* … */], throwOnError: false });&quot;&amp;gt;&amp;lt;/script&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这三行把这一类写法的毛病占齐了。版本号锁在 URL 里，升级意味着重抄三行 base64。渲染发生在读者的浏览器里，时机是 &lt;code&gt;onload&lt;/code&gt;，范围是 &lt;code&gt;document.body&lt;/code&gt;：KaTeX 的 CSS 要等正文下载完、脚本加载完才开始生效，公式先以原始文本闪一下，然后整页重排。&lt;code&gt;throwOnError: false&lt;/code&gt; 保证写错的公式不会打扰任何人，包括我。&lt;/p&gt;
&lt;p&gt;图表是同构的：从 jsdelivr 拉一份 mermaid 的 ESM，在浏览器里找 &lt;code&gt;code.language-mermaid&lt;/code&gt;（那是 Rouge 高亮器的输出约定），现场渲染。两处细节很能说明问题。其一，URL 里写的是 &lt;code&gt;mermaid@11&lt;/code&gt;，这不是版本锁定，是&quot;给我 11.x 的最新版&quot;：页面的行为取决于读者访问那天 jsdelivr 上的 11.x 是什么。它跟 KaTeX 那种锁到哈希的做法并排躺在同一个仓库里，卫生标准显然不统一。其二：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;mermaid.initialize({ startOnLoad: true, theme: &apos;neutral&apos; });
mermaid.run({ querySelector: &apos;code.language-mermaid&apos; });
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;startOnLoad: true&lt;/code&gt; 会让 mermaid 去找 &lt;code&gt;.mermaid&lt;/code&gt; 类的元素，这个站点里一个都不存在；真正干活的是下面那行手动 &lt;code&gt;run()&lt;/code&gt;。两行里有一行是死配置，写了两年没人删，因为它无害，而无害的死配置活得最久。主题硬编码成 &lt;code&gt;&apos;neutral&apos;&lt;/code&gt; 也顺手记下来：那时还没有暗色模式，等到有的时候，改它意味着翻遍三个文件里的三处字符串。&lt;/p&gt;
&lt;p&gt;插图是 &lt;code&gt;center-figure.html&lt;/code&gt;，出场 18 次：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{% include center-figure.html src=&quot;/assets/2025-08-07-a-brief-introduction-on-impedance/Low_pass_filter.png&quot;
    alt=&quot;LC Circuit&quot; maxw=&quot;400px&quot; maxh=&quot;400px&quot; %}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;每插一张图，先想好显示尺寸，手算像素，填进两个参数，把 alt 再抄一遍。产出是一个内联样式的 &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt;，包着一个没有 &lt;code&gt;width&lt;/code&gt;/&lt;code&gt;height&lt;/code&gt; 属性的 &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt;：没有延迟加载，没有 &lt;code&gt;srcset&lt;/code&gt;，布局偏移随图附赠。图片路径还是绝对路径，挪一次目录，全站死链。&lt;/p&gt;
&lt;h3&gt;补丁的复利&lt;/h3&gt;
&lt;p&gt;要公平地说一句：Jekyll 并没有把人锁死在补丁这条路上。主题的模板躺在 gem 里，把 &lt;code&gt;_layouts/post.html&lt;/code&gt; 复制进仓库，改掉，从那天起它就是我自己的模板，这是官方留的门。我每次都没走那扇门，因为贴的当下永远更便宜：include 立刻生效，fork 主题则意味着 minima 上游的每个修复从此要我手动跟进，我要维护一份我没有设计过的目录结构。&lt;/p&gt;
&lt;p&gt;所以问题不在第一条补丁，第一条补丁甚至是合理的。代价从第三条、第五条开始显形：新的改动落在旧补丁的坐标系里，每加一层，都要先记得前几层为什么存在、贴在哪些文件里、互相有没有顺序依赖。但技术债在某一天总是要还的。&lt;strong&gt;补丁的利息是复利&lt;/strong&gt;，两年后的月供是：想加一个稍微像样的东西，第一反应都是&quot;算了&quot;。&lt;/p&gt;
&lt;p&gt;这套代码的处境，三个例子就够了。&lt;code&gt;override-styles.html&lt;/code&gt; 里有一行 &lt;code&gt;box-shadow: inset 0 1px 1px rgb(255, 255, 2555, 0.3)&lt;/code&gt;：2555 是笔误，浏览器按 CSS Color 4 把它 clamp 成 255，于是它安静地错了两年。KaTeX 的 &lt;code&gt;throwOnError: false&lt;/code&gt; 同理，错误被配置成不可见。mermaid 的 &lt;code&gt;@11&lt;/code&gt; 是第三种：库在浮动，页面行为在漂移，没有人知道漂移发生在哪一天。它们的共同特征是：没有类型检查，没有 lint，不进任何会被 CI 验证的产物。这些代码不住在能被检查的那一层，它们住在 markdown 正文里。&lt;/p&gt;
&lt;p&gt;到这里，&quot;minima 不够用&quot;已经解释不了什么了。样式、字体、数学、图表、插图，五件事的每个补丁都指向一个本该存在的结构：字体该是个依赖，数学该发生在构建期，插图该是一行 markdown，mermaid 该是个有名字的组件，样式该属于一个我能写 token 的系统。这些位置在&quot;Jekyll + 别人的主题&quot;里要么不存在，要么得先把主题 fork 出来才能拥有。而两年里我的实际处境是一句话：&lt;strong&gt;我一直在维护一个主题的分叉，只是没有分叉的所有权。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这几件事现在的去处，先记一笔，后面还会展开。字体成了 &lt;code&gt;@fontsource/barlow&lt;/code&gt;，一个躺在 &lt;code&gt;package.json&lt;/code&gt; 里的依赖；数学换成 &lt;code&gt;remark-math&lt;/code&gt; 加 &lt;code&gt;rehype-katex&lt;/code&gt;，构建期就渲染成 HTML，版本号和升级路径由 lockfile 管；插图退回一行普通的 markdown 图片语法，相对路径交给构建解析和哈希。mermaid 是唯一留在运行时的，连库都仍然来自 jsdelivr，但驱动它的代码换了位置：初始化、重渲染、主题切换、换页时的串行，都归布局里一段明确的代码管，出问题能在源码里查到出处。运行时没有消失，只是住进了该住的层。&lt;/p&gt;
&lt;h2&gt;为什么落在 Astro 和 Fuwari&lt;/h2&gt;
&lt;p&gt;迁移选型的判据有三个，按重要程度排。&lt;/p&gt;
&lt;p&gt;第一，一个工具要能同时回答三个需求。博客要内容管线：content collections 把 markdown 收编成有 schema 的数据，remark/rehype 插件链管渲染，RSS、sitemap、阅读时长都有现成的形状。landing 要自由前端：路由、组件、样式全听我的。多语言要第三样：页面静态生成，动态收缩成几个孤立的组件。Astro 恰好三样都给。&lt;/p&gt;
&lt;p&gt;第二，JS 的粒度要落到组件级。博客绝大部分内容是静态的，但总有几个组件要活，多语言的两个切换器就是活的那几个；landing 干脆一行客户端 JS 都没有，需求就到这。Astro 的默认值正好是&quot;没有 JS，除非你要&quot;。这个判断的完整版本在 &lt;a href=&quot;/posts/htmx-tailwind/&quot;&gt;htmx 那篇的第 7 节&lt;/a&gt;，这里不重复。&lt;/p&gt;
&lt;p&gt;第三，工具链得是我自己的。npm、TypeScript、Vite，这是我日常 debug 的栈，构建出错时我读得懂每一行报错。这条听起来理所当然，但在 Jekyll 那边，它是两年里反复出血的伤口。&lt;/p&gt;
&lt;p&gt;被否掉的简单说。Hugo 是同一个病的另一个患者：主题模型、模板在别处、定制靠覆盖，多语言一样要靠插件拼装，只是模板语言从 Liquid 换成 Go template。Next 是另一个方向的重：为一个以静态为主的站点引入运行时和部署假设，换来的能力我一样都用不上。&lt;/p&gt;
&lt;p&gt;但是这个博客不是从零写的，起点是 &lt;a href=&quot;https://github.com/saicaca/fuwari&quot;&gt;Fuwari&lt;/a&gt;，一个 Astro 博客主题，或者更准确地说，材料。这里要说清楚，&quot;fork 一个主题&quot;和第 2 节骂了两千字的&quot;主题模型&quot;有什么区别。&lt;/p&gt;
&lt;p&gt;区别在代码住在哪。minima 的模板躺在 gem 里，我能做的只有覆盖和注入；Fuwari 的整个 &lt;code&gt;src/&lt;/code&gt; 躺在我的仓库里，主题机制本身，处理 mermaid 的 remark 插件、算摘要和阅读时间的插件、渲染卡片的 rehype 组件，全是 &lt;code&gt;src/plugins/&lt;/code&gt; 下有名字、能 diff、能改的文件。fork 它的那天，主题的维护者就变成了我。这正是第 2 节说的&quot;分叉的所有权&quot;，只不过这次我拿走了所有权。&lt;/p&gt;
&lt;p&gt;接管之后动过的地方，大致是一份插件清单：mermaid 的围栏代码块在构建期转成 &lt;code&gt;&amp;lt;pre class=&quot;mermaid-src&quot;&amp;gt;&lt;/code&gt;，交给前端渲染；&lt;code&gt;remark-excerpt&lt;/code&gt; 和 &lt;code&gt;remark-reading-time&lt;/code&gt; 生成列表页的摘要和时长；GitHub 卡片、链接卡片、admonition 走 &lt;code&gt;rehype-components&lt;/code&gt;；代码块换成 expressive-code，调了字体和配色。这些没有一样是&quot;配置主题&quot;，全是在写自己的构建管线。&lt;/p&gt;
&lt;h3&gt;目录：一个要了两年的功能&lt;/h3&gt;
&lt;p&gt;长文没有目录是 Jekyll 时代的旧怨。一直想要，但那边能做的路数都通向又一层补丁，于是一直没动。&lt;/p&gt;
&lt;p&gt;Fuwari 上游有目录组件：一个 &lt;code&gt;&amp;lt;table-of-contents&amp;gt;&lt;/code&gt; 自定义元素，带着滚动追踪和指示条，摆在正文右侧一列浮动的长条里，2xl 以上才出现。我把它搬进 sidebar，套成一张卡片：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;div id=&quot;toc-card&quot; class=&quot;hidden lg:block sticky top-4 mt-4&quot;&amp;gt;
    &amp;lt;WidgetLayout id=&quot;toc-widget&quot; ...&amp;gt;
        &amp;lt;div id=&quot;toc-inner-wrapper&quot; class=&quot;max-h-[calc(100vh-12rem)] overflow-y-auto ...&quot;&amp;gt;
            &amp;lt;TOC headings={headings} /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;sticky 从 &lt;code&gt;#sidebar-sticky&lt;/code&gt;（分类和标签那一坨）挪到了这张卡片上，卡片高度封顶一屏减 12rem，目录长了就在面板内部滚。&lt;code&gt;MainGridLayout&lt;/code&gt; 里删掉右侧浮动的那一整块，留一行注释给下一个读代码的人：&lt;code&gt;#toc&lt;/code&gt; 现在归 sidebar 所有，而 swup 需要这个 id 一直在。顺手把 &lt;code&gt;main&lt;/code&gt; 上的 &lt;code&gt;overflow-hidden&lt;/code&gt; 换成 &lt;code&gt;min-w-0&lt;/code&gt;，不然卡片阴影会被裁掉。&lt;/p&gt;
&lt;p&gt;组件本身也动过。上游的滚动追踪用 IntersectionObserver 观察每个 section，而 &lt;code&gt;remark-sectionize&lt;/code&gt; 产出的 section 是嵌套的：父 section 的盒子包含所有子 section，于是子标题滚进视口时，父标题也被判成&quot;正在读&quot;，哪怕它早在屏幕上方。改法是不看盒子，按&quot;本标题到下一个标题之间&quot;的区间算，再配一个 rAF 合批，一帧最多算一次。&lt;/p&gt;
&lt;p&gt;这件事的工作量不算太大，但没有一件是&quot;配置&quot;：搬到哪一栏、sticky 归谁、追踪怎么算，答案都在我自己的布局文件和组件文件里。两年前它停在&quot;想要&quot;，现在它是一张卡片。后面追上游的 feature，什么追什么不追，也由我自己决定。&lt;/p&gt;
&lt;h3&gt;多语言：一个内容网站的动态部分&lt;/h3&gt;
&lt;p&gt;landing 是纯英文的，博客是双语的。第 1 节里多语言是个迁移理由，这里它是一套具体机制，分三层。&lt;/p&gt;
&lt;p&gt;内容层。每篇文章的 frontmatter 带 &lt;code&gt;lang&lt;/code&gt; 和 &lt;code&gt;translationKey&lt;/code&gt;，同一篇文章的两个语言版本靠 key 认亲。文章页检测到孪生时放一条互链，标签文案还跟着界面语言走：中文界面看到&quot;英文版&quot;，英文界面看到 &quot;Chinese version&quot;。&lt;/p&gt;
&lt;p&gt;列表层。导航栏上有个语言开关，选定之后，首页和归档只显示该语言的版本。筛选逻辑收在一个共享函数里：按 &lt;code&gt;translationKey&lt;/code&gt; 分组，有偏好语言的版本就给它，没有就退回任何一版，宁可显示另一语言的文章，也不让文章凭空消失。&lt;/p&gt;
&lt;p&gt;界面层。Fuwari 自带的 i18n 是构建期的，站点语言在构建时烤进 HTML。我把它挪到了运行时：服务器渲染的字符串带 &lt;code&gt;data-i18n&lt;/code&gt; 标记，切换时一个函数扫一遍文档就地改写，Svelte 组件订阅同一个 CustomEvent，swup 换页之后再重放一遍。界面语言和内容语言是两个独立的开关、两个 localStorage 键，因为&quot;用中文界面读英文文章&quot;是个完全成立的组合。&lt;/p&gt;
&lt;p&gt;默认值是掂量过的：两个开关默认都是英文。第一次到访的人，比如那个 recruiter，看到的是一个英文站；中文读者点一下开关，选择被记住，之后每次回来都是中文。没做 Accept-Language 自动协商，因为&quot;默认英文 + 一次点击 + 长期记住&quot;已经覆盖了我在意的场景，自动协商多出来的那点正确性，抵不上它多出来的惊讶。&lt;/p&gt;
&lt;p&gt;整套东西全是岛屿：页面构建期静态，动态的部分是两个开关、两个 localStorage 键、几个 CustomEvent。第 1 节说多语言需要一个&quot;运行时的决定&quot;，落地时发现它只需要两座小岛，不需要服务器。&lt;/p&gt;
&lt;h3&gt;边界上的工作量&lt;/h3&gt;
&lt;p&gt;剩下的问题都长在接缝上，讲两个有代表性的。&lt;/p&gt;
&lt;p&gt;Fuwari 用 swup 做换页：点链接不整页刷新，只替换主容器，然后重新执行页面里的脚本。这套机制跟 mermaid 撞出了一个竞态。换页时 swup 会把 body 里的脚本再执行一遍，mermaid 的初始化代码随之被再求值一次；如果状态挂在模块作用域里，每导航一次就多注册一个 &lt;code&gt;page:view&lt;/code&gt; 钩子，跳转几次之后，两个并发的 &lt;code&gt;mermaid.run()&lt;/code&gt; 会同时改写同一个还在渲染中的 &lt;code&gt;&amp;lt;pre&amp;gt;&lt;/code&gt;。症状是图表渲染失败，只剩下代码。&lt;/p&gt;
&lt;p&gt;修法是把一次性状态挂到 &lt;code&gt;window&lt;/code&gt; 上，再把渲染串行化：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const mermaidRuntime = (window as unknown as { __mermaidRuntime?: MermaidRuntime })
	.__mermaidRuntime ??= { mounted: false, queue: Promise.resolve() };

function scheduleMermaidRender(): void {
	mermaidRuntime.queue = mermaidRuntime.queue.then(renderMermaid);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;mounted&lt;/code&gt; 保证钩子只注册一次，&lt;code&gt;queue&lt;/code&gt; 保证任何时刻最多一个 &lt;code&gt;run()&lt;/code&gt; 在飞。顺带修掉的还有两件事：渲染前清掉 mermaid 留下的 &lt;code&gt;data-processed&lt;/code&gt; 标志，否则暗色模式切换时恢复源码不会触发重渲染；主题也不再硬编码，跟着 &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt; 的 &lt;code&gt;dark&lt;/code&gt; class 走，用 MutationObserver 盯着。第 2 节那个&quot;改主题要翻三个文件&quot;的账，在这里结了。&lt;/p&gt;
&lt;p&gt;同一类的还有一个小坑：swup 会把 &lt;code&gt;/assets/&lt;/code&gt; 下的附件（比如激活函数那篇配的 zip）当页面去 fetch，找不到换页容器，abort 之后页面卡在过渡 class 里，样式全丢。修法是在配置里给 swup 一张 ignore 清单，告诉它哪些路径不是页面。&lt;/p&gt;
&lt;p&gt;当然，这两个 bug 没有一个跟&quot;博客&quot;有关，全是&quot;SPA 化导航&quot;和&quot;第三方渲染器、真实下载&quot;之间的接缝。主题解决页面长什么样，边界上的事永远归自己。这就是把主题当材料的实际含义。&lt;/p&gt;
&lt;h2&gt;一个仓库，两个 project，三个域名&lt;/h2&gt;
&lt;p&gt;框架选型是这次迁移里最容易的部分，难的在部署。难的原因要从 landing 的一个小功能说起。&lt;/p&gt;
&lt;p&gt;landing 首页有一个&quot;最近文章&quot;列表。它最简单的实现是构建时生成：一个脚本读 &lt;code&gt;posts&lt;/code&gt; 里每篇文章的 frontmatter，丢掉草稿，按标签过滤，中英文孪生取英文版，按发布日期取前五篇，写成一个 JSON。它被接进 landing 的 dev 和 build 命令里，每次构建都重新同步：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&quot;dev&quot;: &quot;node scripts/sync-posts.mjs &amp;amp;&amp;amp; astro dev&quot;,
&quot;build&quot;: &quot;node scripts/sync-posts.mjs &amp;amp;&amp;amp; astro build&quot;,
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;构建期耦合是好耦合：产物是纯静态的，没有运行时 fetch，没有 CORS，没有加载态，列表和博客同一天更新。但它有一个硬约束：&lt;strong&gt;landing 的构建必须读得到博客的源文件&lt;/strong&gt;。两个站点，一个仓库，没有别的选项。这就是 monorepo 的由来。&lt;/p&gt;
&lt;p&gt;然后 GitHub Pages 就出局了。它的发布模型是一个仓库对应一个站点：一个发布源，一个自定义域名。两个 project、两个域名的需求，在这个模型里没有对应物。&lt;/p&gt;
&lt;p&gt;能想到的替代方案我都想过：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;两个仓库，landing 构建时把博客 checkout 进来。跨仓 checkout 要带 token，构建顺序变成两个仓库之间的协调，而且博客推一篇文章不会触发 landing 重建，列表会一直停在旧的样子。&lt;/li&gt;
&lt;li&gt;landing 运行时去 fetch 博客的 RSS 或 JSON。首屏多一个加载态，landing 的可用性从此挂在博客的可用性上，和&quot;静态产物、按需 JS&quot;的整个前提相反。&lt;/li&gt;
&lt;li&gt;一个站点，博客加路径前缀。一个站点装两种心智，&lt;code&gt;base&lt;/code&gt; 路径和 CNAME 要跟着折腾，landing 和博客的生命周期从此绑死。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Cloudflare Pages 的模型正好是这个需求的形状：一个仓库可以挂多个 project，每个 project 指定自己的根目录、构建命令和自定义域名。于是现在的拓扑是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart LR
    P[&quot;push 到 main&quot;]
    P --&amp;gt; W[&quot;Pages 构建触发&amp;lt;br/&amp;gt;dashboard 里配置&quot;]
    W --&amp;gt; L[&quot;project: landing&amp;lt;br/&amp;gt;根目录 landing/&quot;]
    W --&amp;gt; B[&quot;project: blog&amp;lt;br/&amp;gt;根目录 blog/&quot;]
    L --&amp;gt; A[&quot;ansonate.ink&quot;]
    B --&amp;gt; D[&quot;blog.ansonate.ink&quot;]
    G[&quot;GitHub Pages&amp;lt;br/&amp;gt;发布源 docs/&quot;] --&amp;gt; E[&quot;dev.ansonate.ink&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;部署链也换了主角。仓库里的 GitHub Actions 只做验证：&lt;code&gt;astro check&lt;/code&gt;，再在 Node 22 和 24 上各构建一遍，没有 deploy 步骤。真正上线的那一下在 Cloudflare 那边：构建触发配在 dashboard 里而不是仓库里，push 到 main 就触发对应 project 的构建和部署。CI 管&quot;代码对不对&quot;，CF 管&quot;站点活没活&quot;，分工是清楚的，代价留在第 6 节说。&lt;/p&gt;
&lt;p&gt;域名的分工：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;域名&lt;/th&gt;
&lt;th&gt;指向&lt;/th&gt;
&lt;th&gt;托管&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ansonate.ink&lt;/td&gt;
&lt;td&gt;landing&lt;/td&gt;
&lt;td&gt;Cloudflare Pages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;blog.ansonate.ink&lt;/td&gt;
&lt;td&gt;blog&lt;/td&gt;
&lt;td&gt;Cloudflare Pages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;dev.ansonate.ink&lt;/td&gt;
&lt;td&gt;旧 demo 项目群&lt;/td&gt;
&lt;td&gt;GitHub Pages&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;第三个域名值得单独说。那十几个 SPA demo 各自是独立仓库，各自开着 GitHub Pages。GitHub 有个顺手的机制：用户站配了自定义域名（先配好 DNS 解析到 GitHub Pages）之后，同账号的项目站默认挂在这个域名的子路径下，所以这个仓库的 Pages 占住 dev.ansonate.ink，所有 demo 就都能以 &lt;code&gt;dev.ansonate.ink/&amp;lt;repo&amp;gt;/&lt;/code&gt; 的形式活着，主站的旧链接用 301 指过去。&lt;/p&gt;
&lt;p&gt;这个 Pages 的发布源是 &lt;code&gt;docs/&lt;/code&gt;，里面只有一个带 &lt;code&gt;noindex&lt;/code&gt; 的跳转页。严格说它不放东西也行，demo 的存活不依赖根路径上有什么；但根路径本身总得有个着落，不然直接访问 dev.ansonate.ink 的人会撞上一个 404。现在这个跳转页把这一小撮人送去 &lt;code&gt;ansonate.ink/apps&lt;/code&gt; 的 gallery。老东西一个没死，只是搬了家。&lt;/p&gt;
&lt;h2&gt;等等！老链接不能死&lt;/h2&gt;
&lt;p&gt;迁移的完成标准是每一个旧 URL 都有着落，&amp;lt;s&amp;gt;,因为我简历里填了老链接&amp;lt;/s&amp;gt;。旧站虽然小，但 URL 的种类不少：八篇文章、一个 feed、一个 about、十几个 demo 路径。&lt;/p&gt;
&lt;p&gt;先处理文章。Jekyll 的默认 permalink 是 &lt;code&gt;/:categories/:year/:month/:day/:title.html&lt;/code&gt;，所以旧文章地址长这样，够长的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/mathematics/deep-learning/2026/10/16/desmos-and-calu.html
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;category 是我自由发挥的，其中有带空格的（htmx 那篇的 &quot;Web Development&quot;），浏览器实际请求的是 &lt;code&gt;%20&lt;/code&gt; 编码后的形态。Cloudflare Pages 的 &lt;code&gt;_redirects&lt;/code&gt; 支持占位符，一条规则同时匹配解码和编码两种形态：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/:category/programming/2026/10/07/htmx-tailwind.html  https://blog.ansonate.ink/posts/htmx-tailwind/  301
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;_redirects&lt;/code&gt; 有两份，因为规则按 project 生效，apex 的 project 看不到 blogs 的规则，反过来也一样。apex 那份（&lt;code&gt;landing/public/_redirects&lt;/code&gt;）用绝对地址干活：旧文章和 feed 跨域 301 到 blogs 子域，demo 路径 301 到 dev 子域。blogs 那份（&lt;code&gt;blog/public/_redirects&lt;/code&gt;）用站内相对路径，接住直接撞在博客域名上的旧链接。feed 单独一行：jekyll-feed 的产物是 &lt;code&gt;/feed.xml&lt;/code&gt;，Astro 这边是 &lt;code&gt;/rss.xml&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;demo 路径是十三行精确跳转加两行小写别名，早年的仓库名大小写不规范，两种写法都得接住：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/resume-tailor/*  https://dev.ansonate.ink/resume-tailor/:splat  301
/credicheck/*     https://dev.ansonate.ink/CrediCheck/:splat     301
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;301 这个状态码要想清楚再用：浏览器和 CDN 会长期缓存它，写错一条，修复的生效时间就不归你管了。稳妥的顺序是先挂 302 跑几天，确认每个目的地都对，再改成 301。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;_redirects&lt;/code&gt; 也有够不着的地方：它只管路径，不管域名。apps.ansonate.ink 到 &lt;code&gt;ansonate.ink/apps&lt;/code&gt; 这种域名级跳转，得去 Cloudflare dashboard 写 Redirect Rule。这条现在记在 landing 的 README 里，因为后面大概率会找错地方。&lt;/p&gt;
&lt;h2&gt;Free Is Not Free&lt;/h2&gt;
&lt;p&gt;这套拓扑不是免费的，账单如下。&lt;/p&gt;
&lt;p&gt;两条构建链。GitHub Actions 验证一遍，Cloudflare 部署时再构建一遍。直接后果是 &lt;strong&gt;CI 绿了不等于上线了&lt;/strong&gt;：CF 侧的构建失败只出现在另一个 dashboard 里，GitHub 上不会有任何红灯。版本也要在三处对齐：&lt;code&gt;landing/package.json&lt;/code&gt; 的 &lt;code&gt;engines&lt;/code&gt;、workflow 里写死的 pnpm 版本、CF 项目设置里的那一份。任何一处漂移，就是&quot;本地能过、CI 能过、线上炸了&quot;。&lt;/p&gt;
&lt;p&gt;两份 lockfile，两组 dependabot，域名的 DNS。&lt;code&gt;_redirects&lt;/code&gt; 是一张要维护的表，站点级路径（feed、about 这类）改版时要记得回来更新。还有一处不好安放的：Cloudflare 的 project 设置配在 dashboard 里，是整套东西里唯一不在版本控制里的一部分。它不多，但它让我每次想说&quot;全部配置即代码&quot;的时候都要打个折扣。&lt;/p&gt;
&lt;p&gt;这套东西什么时候是过度设计：如果你只需要一个博客的话，GitHub Pages 加 Jekyll 或 Hugo 仍然是最省事的组合，故事到此为止，但直到当你开始为了&quot;让另一个页面读到这些内容&quot;而改造发布管道的时候。&lt;/p&gt;
&lt;h2&gt;结语&lt;/h2&gt;
&lt;p&gt;2024 年那篇公告里，我写 Jekyll 完美适合我的偏好。那句话当时是真的，现在也不算错，只是语境变了：两年里我的用法长出了主题的形状，一个 landing、一组复用组件、一套双语约定，而工具停在原地。Jekyll 没有变差，是我对它提的要求变了。&lt;/p&gt;
&lt;p&gt;这次迁移的实质，是我停止租用展示层。从 markdown 到 DNS，每一层现在都有名字、有版本、有我读得懂的报错。代价是自己修水管：两条构建链、三个域名、一份 redirect 表，外加一点藏在 dashboard 里的配置。&lt;/p&gt;
&lt;p&gt;现在发一篇文章的动作是这样的：写完，push，两个 project 各自构建，landing 的&quot;最近文章&quot;自动换一版，老链接原地不动。整套流程里没有一步需要我记着什么，这大概就是两年前那个在正文里贴 include 的人想要的东西。&lt;/p&gt;
&lt;h2&gt;附：迁移清单&lt;/h2&gt;
&lt;p&gt;给要做同样事情的人，按顺序：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;先盘点旧站的所有 URL：文章、feed、about、附件、demo，列成表。迁移完成的标准就是这张表全绿。&lt;/li&gt;
&lt;li&gt;选框架的判据排序：工具链是不是你的，JS 粒度，生态。性能 benchmark 排最后。&lt;/li&gt;
&lt;li&gt;fork 主题，不要 hack 主题，除非你知道后面新东西不多。判断标准只有一个：主题的模板文件在不在你的仓库里。&lt;/li&gt;
&lt;li&gt;内容先于样式：先定 frontmatter schema、content collections 的位置、多语言约定，再谈好不好看。&lt;/li&gt;
&lt;li&gt;站点间共享内容用构建期脚本，显式、可 diff，不要用运行时 fetch。&lt;/li&gt;
&lt;li&gt;确认托管方支持&quot;一个仓库多个 project、按目录构建&quot;，这是 monorepo 的前提。&lt;/li&gt;
&lt;li&gt;验证和部署分开：CI 只验证，部署交给托管方的 push 触发，但要接受&quot;绿灯不等于上线&quot;。&lt;/li&gt;
&lt;li&gt;redirects 先 302 后 301；带空格的旧路径用占位符，两种编码形态都要测。&lt;/li&gt;
&lt;li&gt;查清 &lt;code&gt;_redirects&lt;/code&gt; 的能力边界：不管域名、有规则条数上限，域名级跳转移交 dashboard。&lt;/li&gt;
&lt;/ol&gt;
</content:encoded></item><item><title>From Desmos to CaLU: My Activation Function Tinkering Notes (I)</title><link>https://blog.ansonate.ink/posts/desmos-and-calu-en/</link><guid isPermaLink="true">https://blog.ansonate.ink/posts/desmos-and-calu-en/</guid><description>From a Desmos curve to the CaLU activation function: 45 controlled runs on MNIST.</description><pubDate>Fri, 16 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;em&gt;A note on division of labor: the inspiration and conjectures are mine; the code and experiments were written by AI — I was only responsible for talking, thinking, and training.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;A few days ago, tired from reviewing trigonometry, I was dragging curves around in Desmos for fun, plotting arctan and tanh on the same axes. The more I looked, the more alike they seemed — both S-shaped, both saturating on both sides. The only difference is that $\tanh$ has range $(-1, 1)$ while $\arctan$ has range $(-\frac{\pi}{2}, \frac{\pi}{2})$ — a horizontal and vertical rescaling makes them roughly coincide.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../assets/posts/desmos-and-calu/01_tanh_arctan.svg&quot; alt=&quot;tanh vs rescaled arctan: both have slope 1 at the origin; one tail converges exponentially, the other drags polynomially&quot; /&gt;&lt;/p&gt;
&lt;p&gt;At that moment a thought popped up: &lt;strong&gt;could a rescaled arctan work directly as an activation function?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;But the thought lived for about three seconds. Because the answer is obvious: definitely not good. Just as tanh and sigmoid died in deep networks, arctan would die the same way — saturating on both sides, with derivative $\frac{1}{1+x^2}$ sliding away to nothing on either flank, so gradients vanish as soon as the network gets deep. No experiment needed; one look at the supremum of the derivative tells you. So arctan fundamentalism was abandoned on the spot.&lt;/p&gt;
&lt;p&gt;But Desmos was still open, so I stared a bit longer. I casually plotted sigmoid too.&lt;/p&gt;
&lt;h2&gt;The infamous &quot;dip&quot; in Swish&lt;/h2&gt;
&lt;p&gt;My eyes moved to Swish: $f(x) = x\cdot\sigma(x)$. The left half of this curve has a famous little dip — it bottoms out at about $-0.28$ near $x\approx -1.28$ before climbing back. Everyone says this non-monotonic dip is part of why Swish works well, but I was being stubborn at the time: &lt;strong&gt;what if I insisted on straightening it out?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../assets/posts/desmos-and-calu/02_swish_dip.svg&quot; alt=&quot;Filling the dip: SiLU&apos;s dip, gradually flattened by soft leaky relu with different eta (eta=0.2 is already strictly monotonic)&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The most handy fix is to add an x term and make it leaky:&lt;/p&gt;
&lt;p&gt;$$f(x) = (1-\beta)\cdot x\cdot\sigma(x) + \beta\cdot x$$&lt;/p&gt;
&lt;p&gt;As $x\to +\infty$ it approaches $x$, and as $x\to -\infty$ it approaches $\beta\cdot x$ — isn&apos;t that just a smoothed-out Leaky ReLU? I named it &lt;strong&gt;soft leaky relu&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Then I checked monotonicity directly in Desmos. With $\beta = 0.2$ it is solidly monotonic; with $\beta = 0.01$ the dip survives. Doing the math: $f&apos;(x) = (1-\beta)\cdot[\text{derivative of SiLU}] + \beta$, and the SiLU derivative has a minimum of $-0.0998$ at $x\approx -2.40$. Forcing the minimum derivative above zero gives &lt;strong&gt;strict monotonicity when $\beta &amp;gt; 0.0908$&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;That $0.09$ threshold would turn out to be interesting later — hold that thought.&lt;/p&gt;
&lt;h2&gt;sigmoid — tanh — arctan: why not hack the gate?&lt;/h2&gt;
&lt;p&gt;After writing down soft leaky relu, I stared at it for a long time. Its structure is $x\cdot\mathrm{gate}(x) + \mathrm{leak}$. The gate is currently sigmoid, but my chain of association went like this:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;sigmoid and tanh are relatives&lt;/strong&gt; ($\tanh(x) = 2\sigma(2x) - 1$, second-grade knowledge), &lt;strong&gt;and tanh and arctan look alike&lt;/strong&gt; (Desmos told me) — so why not hack arctan into a gate and plug it into the same template?&lt;/p&gt;
&lt;p&gt;The original form I wrote down looked like this:&lt;/p&gt;
&lt;p&gt;$$
\begin{align}
T(x) &amp;amp;= \frac{\arctan(x)}{\pi/2} \
t(x) &amp;amp;= \frac{T(x)+1}{2} \
a(x) &amp;amp;= (1-\beta)\cdot x\cdot t(x) + \beta\cdot x
\end{align}
$$&lt;/p&gt;
&lt;p&gt;$T$ squeezes $\arctan$ into $(-1, 1)$, $t$ shifts it into $(0, 1)$ — a perfect gate. After writing it I tossed the formula to the AI, which recognized it at a glance: $\frac{1}{2}\left(\frac{2\arctan(x)}{\pi}+1\right)$ — isn&apos;t that just… the &lt;strong&gt;CDF of the standard Cauchy distribution&lt;/strong&gt;? $C(x) = \frac{1}{2} + \frac{1}{\pi}\arctan(x)$, exactly the same.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../assets/posts/desmos-and-calu/03_gates.svg&quot; alt=&quot;Three gates side by side: sigmoid, Cauchy CDF, and RoCLU&apos;s log gate, all with range (0,1)&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Following that hint, Swish and GELU all clicked together:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Logistic CDF → SiLU/Swish&lt;/li&gt;
&lt;li&gt;Gaussian CDF → GELU&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cauchy CDF → ??? empty slot&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The missing piece of the $x\cdot\mathrm{CDF}(x)$ trinity looks like this. The name was coined by the coding AI: &lt;strong&gt;CaLU (Cauchy Linear Unit)&lt;/strong&gt;, the full version called &lt;strong&gt;Leaky CaLU&lt;/strong&gt; — one family with SiLU and GELU, and I approved it. The gate slopes at zero line up neatly too: Gaussian $0.399$ &amp;gt; Cauchy $0.318$ &amp;gt; Logistic $0.25$, with Cauchy sitting exactly in the middle — heaviest tails, middling turn-on, beautifully symmetric.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../assets/posts/desmos-and-calu/04_family.svg&quot; alt=&quot;xdotathrm{gate}(x) family portrait: SiLU, CaLU, Leaky CaLU, RoCLU&quot; /&gt;&lt;/p&gt;
&lt;p&gt;After naming it I went to search the literature, and my heart sank halfway and then warmed back up: a paper from September 3, 2026 by Küçükali et al., &lt;strong&gt;RoCLU&lt;/strong&gt; (Robust Cauchy Linear Unit), with formula $1.444\cdot x\cdot \ln\left(1.5 + \frac{\arctan(x)}{\pi}\right)$ — the same $x\cdot g(\arctan(x))$ family, with a log-transformed gate.&lt;/p&gt;
&lt;p&gt;On second thought, that&apos;s good news: first, it means I&apos;m not the only one walking the Cauchy-gate path, so the direction is probably right; second, their gate and mine differ (log gate vs. exact CDF gate), so our stories don&apos;t collide; third, &lt;strong&gt;a ready-made literature baseline&lt;/strong&gt;, free for the taking. I promptly added it to the comparison runs.&lt;/p&gt;
&lt;h2&gt;Experiments: 45 runs, just the results&lt;/h2&gt;
&lt;p&gt;The experimental design is simple, with one core idea in a single sentence: &lt;strong&gt;make the model skinny and the differences reveal themselves&lt;/strong&gt;. Classic LeNet on MNIST hits 99% with eyes closed — every activation looks the same — so I deliberately crippled the network down to $5.4\text{k}$ parameters (about $\frac{1}{10}$ of the original), with no BN, no Dropout, and only $10$ training epochs. The model looks like this ($28\times28$ input):&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Config&lt;/th&gt;
&lt;th&gt;Output size&lt;/th&gt;
&lt;th&gt;Params&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Conv1 + Act&lt;/td&gt;
&lt;td&gt;$1{\to}4$ channels, $5{\times}5$&lt;/td&gt;
&lt;td&gt;$4\times24\times24$&lt;/td&gt;
&lt;td&gt;104&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;AvgPool&lt;/td&gt;
&lt;td&gt;$2{\times}2$&lt;/td&gt;
&lt;td&gt;$4\times12\times12$&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Conv2 + Act&lt;/td&gt;
&lt;td&gt;$4{\to}8$ channels, $5{\times}5$&lt;/td&gt;
&lt;td&gt;$8\times8\times8$&lt;/td&gt;
&lt;td&gt;808&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;AvgPool&lt;/td&gt;
&lt;td&gt;$2{\times}2$&lt;/td&gt;
&lt;td&gt;$8\times4\times4$&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Flatten&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;$128$&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;FC1 + Act&lt;/td&gt;
&lt;td&gt;$128{\to}32$&lt;/td&gt;
&lt;td&gt;$32$&lt;/td&gt;
&lt;td&gt;4128&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;FC2 (no activation)&lt;/td&gt;
&lt;td&gt;$32{\to}10$&lt;/td&gt;
&lt;td&gt;$10$ (logits)&lt;/td&gt;
&lt;td&gt;330&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5370&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;11 activation functions; everything except what&apos;s inside Act is &lt;strong&gt;strictly identical&lt;/strong&gt;: the same Xavier initialization, the same optimizer (Adam, $\text{lr}=10^{-3}$, $\text{weight decay}=0$, no scheduler), the same batch size ($512$), the same device (mps), 3 seeds per activation. The code was done by AI — I just supplied the hyperparameters and the experiment-ID mapping table, and then did the training.&lt;/p&gt;
&lt;h3&gt;Main results (MNIST test set, 3-seed mean $\pm$ std, in %)&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Activation&lt;/th&gt;
&lt;th&gt;seed0&lt;/th&gt;
&lt;th&gt;seed1&lt;/th&gt;
&lt;th&gt;seed2&lt;/th&gt;
&lt;th&gt;Mean&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Soft Leaky ReLU&lt;/td&gt;
&lt;td&gt;97.78&lt;/td&gt;
&lt;td&gt;97.39&lt;/td&gt;
&lt;td&gt;97.82&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$97.66 \pm 0.24$&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SiLU&lt;/td&gt;
&lt;td&gt;97.67&lt;/td&gt;
&lt;td&gt;97.38&lt;/td&gt;
&lt;td&gt;97.83&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$97.63 \pm 0.23$&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GELU&lt;/td&gt;
&lt;td&gt;97.58&lt;/td&gt;
&lt;td&gt;97.54&lt;/td&gt;
&lt;td&gt;97.71&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$97.61 \pm 0.09$&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RoCLU&lt;/td&gt;
&lt;td&gt;97.90&lt;/td&gt;
&lt;td&gt;97.04&lt;/td&gt;
&lt;td&gt;97.69&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$97.54 \pm 0.45$&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tanh&lt;/td&gt;
&lt;td&gt;97.53&lt;/td&gt;
&lt;td&gt;97.67&lt;/td&gt;
&lt;td&gt;97.37&lt;/td&gt;
&lt;td&gt;$97.52 \pm 0.15$&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Leaky CaLU&lt;/td&gt;
&lt;td&gt;97.75&lt;/td&gt;
&lt;td&gt;97.17&lt;/td&gt;
&lt;td&gt;97.60&lt;/td&gt;
&lt;td&gt;$97.51 \pm 0.30$&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CaLU&lt;/td&gt;
&lt;td&gt;97.76&lt;/td&gt;
&lt;td&gt;97.12&lt;/td&gt;
&lt;td&gt;97.60&lt;/td&gt;
&lt;td&gt;$97.49 \pm 0.33$&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Arctan&lt;/td&gt;
&lt;td&gt;97.40&lt;/td&gt;
&lt;td&gt;97.70&lt;/td&gt;
&lt;td&gt;97.21&lt;/td&gt;
&lt;td&gt;$97.44 \pm 0.25$&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Leaky ReLU&lt;/td&gt;
&lt;td&gt;97.54&lt;/td&gt;
&lt;td&gt;97.55&lt;/td&gt;
&lt;td&gt;97.15&lt;/td&gt;
&lt;td&gt;$97.41 \pm 0.23$&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ReLU&lt;/td&gt;
&lt;td&gt;97.52&lt;/td&gt;
&lt;td&gt;97.53&lt;/td&gt;
&lt;td&gt;97.15&lt;/td&gt;
&lt;td&gt;$97.40 \pm 0.22$&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sigmoid&lt;/td&gt;
&lt;td&gt;90.66&lt;/td&gt;
&lt;td&gt;94.33&lt;/td&gt;
&lt;td&gt;90.30&lt;/td&gt;
&lt;td&gt;$91.76 \pm 2.23$&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;SiLU ($97.63$) &amp;gt; GELU ($97.61$) &amp;gt; CaLU ($97.49$). GELU has the smallest variance in the field ($\pm 0.09$) and the fastest epoch-1 startup ($90.17$) — the Gaussian gate, battle-tested by industry, is scarily stable. RoCLU ranks fourth and took the single best run of the field ($97.90$), but its variance is also the largest apart from sigmoid. Sigmoid finishing last is no surprise: only $43.7%$ after the first epoch, a textbook vanishing-gradient scene.&lt;/p&gt;
&lt;h3&gt;Gate $\times$ leak&lt;/h3&gt;
&lt;p&gt;Pulling out the four &quot;in-group&quot; members, gate (sigmoid gate vs. Cauchy gate) $\times$ leak ($\beta=0$ vs. $\beta=0.01$) forms a clean controlled comparison:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mean&lt;/th&gt;
&lt;th&gt;$\beta=0$&lt;/th&gt;
&lt;th&gt;$\beta=0.01$&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;sigmoid gate&lt;/td&gt;
&lt;td&gt;SiLU 97.63&lt;/td&gt;
&lt;td&gt;Soft 97.66&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cauchy gate&lt;/td&gt;
&lt;td&gt;CaLU 97.49&lt;/td&gt;
&lt;td&gt;LeakyCaLU 97.51&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The leak effect is $+0.02\sim0.03$ in both rows, and the gate effect is sigmoid gate $+0.14\sim0.15$ in both columns — fully consistent directions, though unfortunately both within the noise band. RoCLU (log gate) lands in between at $97.54$, matching its gate shape sitting in the middle too ($g&apos;(0)\approx 0.306$, with a deeper negative-side asymptote than Cauchy: $-0.46$ vs. $-0.32$). And inside the trinity, SiLU &amp;gt; GELU &amp;gt; CaLU is exactly the &lt;strong&gt;reverse order&lt;/strong&gt; of gate turn-on sharpness. But honestly, apart from sigmoid everyone is within noise of each other — not enough significance.&lt;/p&gt;
&lt;h3&gt;The most interesting part: β ablation vs. the monotonicity threshold&lt;/h3&gt;
&lt;p&gt;As computed in Desmos earlier, the derivative of soft leaky relu is&lt;/p&gt;
&lt;p&gt;$$f&apos;(x) = (1-\beta),[\sigma(x) + x\sigma(x)(1-\sigma(x))] + \beta$$&lt;/p&gt;
&lt;p&gt;The bracket is the SiLU derivative, with minimum about $-0.0998$ (at $x\approx -2.40$). Setting it above zero:&lt;/p&gt;
&lt;p&gt;$$(1-\beta)(-0.0998) + \beta &amp;gt; 0 \quad\Longrightarrow\quad \beta &amp;gt; \beta^* \approx 0.0908$$&lt;/p&gt;
&lt;p&gt;So $\beta=0.2$ is solidly monotonic, $\beta=0.1$ is borderline monotonic (minimum derivative $0.01$), and $\beta=0.01$ is non-monotonic (keeps the dip). I ran four settings, scaling $\beta$ from $0.01$ up to $0.3$:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;$\beta$&lt;/th&gt;
&lt;th&gt;Final accuracy (mean$\pm$std)&lt;/th&gt;
&lt;th&gt;epoch-1 mean&lt;/th&gt;
&lt;th&gt;Monotonicity (min derivative)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0.01&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$97.66\pm0.24$&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;89.92&lt;/td&gt;
&lt;td&gt;non-monotonic ($-0.089$, keeps dip)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.1&lt;/td&gt;
&lt;td&gt;$97.47\pm0.44$&lt;/td&gt;
&lt;td&gt;88.99&lt;/td&gt;
&lt;td&gt;borderline monotonic ($+0.010$)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.2&lt;/td&gt;
&lt;td&gt;$97.36\pm0.46$&lt;/td&gt;
&lt;td&gt;88.02&lt;/td&gt;
&lt;td&gt;solidly monotonic ($+0.120$)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.3&lt;/td&gt;
&lt;td&gt;$97.00\pm0.41$&lt;/td&gt;
&lt;td&gt;87.30&lt;/td&gt;
&lt;td&gt;solidly monotonic ($+0.230$)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Mean and startup speed both decline strictly monotonically, while variance grows overall ($\pm0.24\to\pm0.41$, peaking at $\pm0.46$ for $\beta=0.2$). In other words, &lt;strong&gt;the monotonic versions are actually worse&lt;/strong&gt;. That dip is at least not a bad thing on shallow-network MNIST. My original obsession with &quot;straightening it out&quot; was refuted by my own experiment. That&apos;s science: conjectures get to be beautiful, data gets to be merciless.&lt;/p&gt;
&lt;h2&gt;Next: VGG&lt;/h2&gt;
&lt;p&gt;My intuition: a heavy-tailed, polynomial-dragging design like the Cauchy gate can&apos;t show its strength in a slimmed-down LeNet with only 3 nonlinear layers, because gate differences like these only compound after being multiplied 8–10 times. The figure below shows why: $1-g(x)$ of the sigmoid gate decays exponentially (a straight line on semi-log axes), saturating to the bottom in no time; while the Cauchy gate and RoCLU gate drag polynomial tails that stay soft for a long while. As depth grows, the former &quot;decides&quot; early while the latter still keeps gradients alive (a BN-free deep net like VGG is its home turf). The RoCLU paper likewise tested deep (up to 700 layers).&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../assets/posts/desmos-and-calu/05_tails.svg&quot; alt=&quot;Gate tail comparison (semi-log axes): exponential vs. polynomial tails, two orders of magnitude apart by just xpprox8&quot; /&gt;&lt;/p&gt;
&lt;p&gt;So next up: VGG + CIFAR-10. Which of the three gate families (sigmoid / Cauchy-CDF / log) fights best in deep water — stay tuned for part II.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;The full experiment report and &lt;strong&gt;raw results&lt;/strong&gt; of all 45 training runs are in the archive (&lt;a href=&quot;/assets/2026-10-16-desmos-and-calu/calu-archive.zip&quot;&gt;calu-archive.zip&lt;/a&gt;, 42 experiment directories in total: 11 activations × 3 seeds = 33 main runs, plus 9 new β-ablation runs; the β=0.01 setting reuses the main runs. Unzip and run &lt;code&gt;collect_results.py&lt;/code&gt; / &lt;code&gt;beta_ablation.py&lt;/code&gt; to reproduce all figures). Critiques welcome. And please tell me if the CaLU name collides with anyone&apos;s — as of writing, RoCLU is the only relative I found.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;References&lt;/h2&gt;
&lt;p&gt;[1] Küçükali Ö, Bozkurt MH, Ulutaş E, Bozkurt I. A Novel Activation Function for Enhancing Deep Neural Network Stability and Performance. &lt;em&gt;Concurrency and Computation: Practice and Experience&lt;/em&gt;. 2026;38:e70944. DOI: &lt;a href=&quot;https://doi.org/10.1002/cpe.70944&quot;&gt;10.1002/cpe.70944&lt;/a&gt;.&lt;/p&gt;
</content:encoded></item><item><title>从 Desmos 到 CaLU：我的激活函数折腾手记 (I)</title><link>https://blog.ansonate.ink/posts/desmos-and-calu/</link><guid isPermaLink="true">https://blog.ansonate.ink/posts/desmos-and-calu/</guid><description>从 Desmos 上的一条曲线到 CaLU 激活函数：在 MNIST 上的 45 次对照实验手记。</description><pubDate>Fri, 16 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;em&gt;说明一下分工：灵感、猜想都是我的；代码和实验是 AI 写的，我只负责动嘴和动念头和训练。&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;前几天三角函数复习累了，在 Desmos 上拖曲线玩，把 arctan 和 tanh 画到一张图里，越看越觉得它俩长得像——都是 S 形，都是两边饱和。区别无非是 $\tanh$ 值域是 $(-1, 1)$，$\arctan$ 是 $(-\frac{\pi}{2}, \frac{\pi}{2})$，横竖缩放一下就能重合。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../assets/posts/desmos-and-calu/01_tanh_arctan.svg&quot; alt=&quot;tanh vs 缩放后的 arctan：原点处斜率同为 1，尾部一个指数收敛、一个多项式拖尾&quot; /&gt;&lt;/p&gt;
&lt;p&gt;那一刻冒出个念头：&lt;strong&gt;arctan 缩一缩能不能直接当激活函数？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;但这个念头只活了三秒。因为答案是显而易见的：肯定不好。tanh 和 sigmoid 当年在深网里怎么死的，arctan 就得怎么死——两边饱和，导数 $\frac{1}{1+x^2}$ 往两边一滑就没了，层数一深梯度直接消失。这都不用跑实验，算一下导数上确界就知道。所以 arctan 原教旨主义当场放弃。&lt;/p&gt;
&lt;p&gt;但 Desmos 还开着，我就多看了两眼。顺手把 sigmoid 画了一下&lt;/p&gt;
&lt;h2&gt;关于 Swish 的那个&quot;坑&quot;&lt;/h2&gt;
&lt;p&gt;视线移到 Swish 身上：$f(x) = x\cdot\sigma(x)$。这条曲线的左半边有个很有名的小坑——$x\approx -1.28$ 处 dip 到 $-0.28$ 再爬回来。大家都说这个非单调的坑是 Swish 好用的原因之一，但我当时犯轴：&lt;strong&gt;如果我偏要把它掰直呢？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../assets/posts/desmos-and-calu/02_swish_dip.svg&quot; alt=&quot;填坑：SiLU 的 dip，以及 soft leaky relu 用不同 eta 把它逐渐填平（eta=0.2 已严格单调）&quot; /&gt;&lt;/p&gt;
&lt;p&gt;最顺手的办法就是加一个 x term 进去，搞成漏的（leaky）：&lt;/p&gt;
&lt;p&gt;$$f(x) = (1-\beta)\cdot x\cdot\sigma(x) + \beta\cdot x$$&lt;/p&gt;
&lt;p&gt;$x\to +\infty$ 时趋近于 $x$，$x\to -\infty$ 时趋近于 $\beta\cdot x$——这不就是 Leaky ReLU 被磨光滑了吗？我给它起名叫 &lt;strong&gt;soft leaky relu&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;然后直接在 Desmos上看一下单调性。当 $\beta$ 取 $0.2$ 是稳单调的，$\beta$ 取 $0.01$ 则还留着那个坑。算了一下单调性。$f&apos;(x) = (1-\beta)\cdot[\text{SiLU 的导数}] + \beta$，而 SiLU 导数在 $x\approx -2.40$ 处有个最小值 $-0.0998$。令最小导数大于零，解出来 &lt;strong&gt;$\beta &amp;gt; 0.0908$ 时函数严格单调&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;这个 $0.09$ 的阈值后来变得很有意思——先按下不表。&lt;/p&gt;
&lt;h2&gt;sigmoid—tanh—arctan：为什么不魔改门控？&lt;/h2&gt;
&lt;p&gt;soft leaky relu 写出来之后，我盯着它看了很久，结构是 $x\cdot\mathrm{gate}(x) + \mathrm{leak}$。门控现在是 sigmoid，但我的联想链是这样的：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;sigmoid 和 tanh 是亲戚&lt;/strong&gt;（$\tanh(x) = 2\sigma(2x) - 1$，小学二年级知识），&lt;strong&gt;tanh 和 arctan 长得像&lt;/strong&gt;（Desmos 告诉我的）——那为什么不把 arctan 魔改成门控，塞进同一个模板里试试？&lt;/p&gt;
&lt;p&gt;当时写下的原式是这样的：&lt;/p&gt;
&lt;p&gt;$$
\begin{align}
T(x) &amp;amp;= \frac{\arctan(x)}{\pi/2} \
t(x) &amp;amp;= \frac{T(x)+1}{2} \
a(x) &amp;amp;= (1-\beta)\cdot x\cdot t(x) + \beta\cdot x
\end{align}
$$&lt;/p&gt;
&lt;p&gt;$T$ 把 $\arctan$ 压到 $(-1, 1)$，$t$ 再搬到 $(0, 1)$，正好是个门控。写完之后我随手把式子丢给 AI 看，它一眼就认了出来：$\frac{1}{2}\left(\frac{2\arctan(x)}{\pi}+1\right)$，这不就是……&lt;strong&gt;标准 Cauchy 分布的 CDF&lt;/strong&gt; 吗？$C(x) = \frac{1}{2} + \frac{1}{\pi}\arctan(x)$，一模一样。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../assets/posts/desmos-and-calu/03_gates.svg&quot; alt=&quot;三家门控同框：sigmoid、Cauchy CDF、RoCLU 的 log 门，值域都是 (0,1)&quot; /&gt;&lt;/p&gt;
&lt;p&gt;顺着这个提示，swish、gelu 全连起来了：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Logistic CDF → SiLU/Swish&lt;/li&gt;
&lt;li&gt;Gaussian CDF → GELU&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cauchy CDF → ？？？空着呢&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;$x\cdot\mathrm{CDF}(x)$ 三位一体，缺的那块拼图长这样。名字是写代码的 AI 起的：&lt;strong&gt;CaLU（Cauchy Linear Unit）&lt;/strong&gt;，完整版叫 &lt;strong&gt;Leaky CaLU&lt;/strong&gt;——跟 SiLU、GELU 正好一家人，我拍板用了。门控零点斜率也排得整整齐齐：Gaussian $0.399$ &amp;gt; Cauchy $0.318$ &amp;gt; Logistic $0.25$，Cauchy 恰好卡在中间——尾部最重、开启居中，对称得很漂亮。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../assets/posts/desmos-and-calu/04_family.svg&quot; alt=&quot;xdotathrm{gate}(x) 全家福：SiLU、CaLU、Leaky CaLU、RoCLU&quot; /&gt;&lt;/p&gt;
&lt;p&gt;名字起完去搜文献，心凉了半截又热了起来：一篇 2026 年 9 月 3 号的论文，Küçükali 等人的 &lt;strong&gt;RoCLU&lt;/strong&gt;（Robust Cauchy Linear Unit），公式是 $1.444\cdot x\cdot \ln\left(1.5 + \frac{\arctan(x)}{\pi}\right)$——同一个 $x\cdot g(\arctan(x))$ 家族，门控是个 log 变换。&lt;/p&gt;
&lt;p&gt;转念一想，这是好事：第一，说明 Cauchy 门这条路不是我一个人在走，方向大概率没错；第二，人家的门和我的门不一样（log 门 vs 精确 CDF 门），叙事不打架；第三，&lt;strong&gt;现成的文献基线&lt;/strong&gt;，白捡的。果断把它加进对比里一起跑。&lt;/p&gt;
&lt;h2&gt;实验：跑了 45 次，只看结果&lt;/h2&gt;
&lt;p&gt;实验设计很简单，核心思想就一句话：&lt;strong&gt;把模型变瘦，差距自然现形&lt;/strong&gt;。经典 LeNet 在 MNIST 上闭眼 99%，什么激活函数都一个样——所以故意把网络阉割到 $5.4\text{k}$ 参数（约为原版的 $\frac{1}{10}$），无 BN、无 Dropout、只训 $10$ 个 epoch。模型长这样（$28\times28$ 输入）：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;层&lt;/th&gt;
&lt;th&gt;配置&lt;/th&gt;
&lt;th&gt;输出尺寸&lt;/th&gt;
&lt;th&gt;参数量&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Conv1 + Act&lt;/td&gt;
&lt;td&gt;$1{\to}4$ 通道，$5{\times}5$&lt;/td&gt;
&lt;td&gt;$4\times24\times24$&lt;/td&gt;
&lt;td&gt;104&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;AvgPool&lt;/td&gt;
&lt;td&gt;$2{\times}2$&lt;/td&gt;
&lt;td&gt;$4\times12\times12$&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Conv2 + Act&lt;/td&gt;
&lt;td&gt;$4{\to}8$ 通道，$5{\times}5$&lt;/td&gt;
&lt;td&gt;$8\times8\times8$&lt;/td&gt;
&lt;td&gt;808&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;AvgPool&lt;/td&gt;
&lt;td&gt;$2{\times}2$&lt;/td&gt;
&lt;td&gt;$8\times4\times4$&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Flatten&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;$128$&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;FC1 + Act&lt;/td&gt;
&lt;td&gt;$128{\to}32$&lt;/td&gt;
&lt;td&gt;$32$&lt;/td&gt;
&lt;td&gt;4128&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;FC2（无激活）&lt;/td&gt;
&lt;td&gt;$32{\to}10$&lt;/td&gt;
&lt;td&gt;$10$（logits）&lt;/td&gt;
&lt;td&gt;330&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;合计&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5370&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;11 个激活函数，除了 Act 里装的东西不一样，其他&lt;strong&gt;严格同构&lt;/strong&gt;：同一套 Xavier 初始化、同一个优化器（Adam，$\text{lr}=10^{-3}$，$\text{weight decay}=0$，无 scheduler）、同一个 batch size（$512$）、同一个 device（mps），每个激活跑 3 个 seed。代码是 AI 搞的，我就给了超参数和 exp 编号对照表，然后训练。&lt;/p&gt;
&lt;h3&gt;主结果（MNIST 测试集，3-seed 均值 $\pm$ std，单位 %）&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;激活&lt;/th&gt;
&lt;th&gt;seed0&lt;/th&gt;
&lt;th&gt;seed1&lt;/th&gt;
&lt;th&gt;seed2&lt;/th&gt;
&lt;th&gt;均值&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Soft Leaky ReLU&lt;/td&gt;
&lt;td&gt;97.78&lt;/td&gt;
&lt;td&gt;97.39&lt;/td&gt;
&lt;td&gt;97.82&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$97.66 \pm 0.24$&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SiLU&lt;/td&gt;
&lt;td&gt;97.67&lt;/td&gt;
&lt;td&gt;97.38&lt;/td&gt;
&lt;td&gt;97.83&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$97.63 \pm 0.23$&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GELU&lt;/td&gt;
&lt;td&gt;97.58&lt;/td&gt;
&lt;td&gt;97.54&lt;/td&gt;
&lt;td&gt;97.71&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$97.61 \pm 0.09$&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RoCLU&lt;/td&gt;
&lt;td&gt;97.90&lt;/td&gt;
&lt;td&gt;97.04&lt;/td&gt;
&lt;td&gt;97.69&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$97.54 \pm 0.45$&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tanh&lt;/td&gt;
&lt;td&gt;97.53&lt;/td&gt;
&lt;td&gt;97.67&lt;/td&gt;
&lt;td&gt;97.37&lt;/td&gt;
&lt;td&gt;$97.52 \pm 0.15$&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Leaky CaLU&lt;/td&gt;
&lt;td&gt;97.75&lt;/td&gt;
&lt;td&gt;97.17&lt;/td&gt;
&lt;td&gt;97.60&lt;/td&gt;
&lt;td&gt;$97.51 \pm 0.30$&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CaLU&lt;/td&gt;
&lt;td&gt;97.76&lt;/td&gt;
&lt;td&gt;97.12&lt;/td&gt;
&lt;td&gt;97.60&lt;/td&gt;
&lt;td&gt;$97.49 \pm 0.33$&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Arctan&lt;/td&gt;
&lt;td&gt;97.40&lt;/td&gt;
&lt;td&gt;97.70&lt;/td&gt;
&lt;td&gt;97.21&lt;/td&gt;
&lt;td&gt;$97.44 \pm 0.25$&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Leaky ReLU&lt;/td&gt;
&lt;td&gt;97.54&lt;/td&gt;
&lt;td&gt;97.55&lt;/td&gt;
&lt;td&gt;97.15&lt;/td&gt;
&lt;td&gt;$97.41 \pm 0.23$&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ReLU&lt;/td&gt;
&lt;td&gt;97.52&lt;/td&gt;
&lt;td&gt;97.53&lt;/td&gt;
&lt;td&gt;97.15&lt;/td&gt;
&lt;td&gt;$97.40 \pm 0.22$&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sigmoid&lt;/td&gt;
&lt;td&gt;90.66&lt;/td&gt;
&lt;td&gt;94.33&lt;/td&gt;
&lt;td&gt;90.30&lt;/td&gt;
&lt;td&gt;$91.76 \pm 2.23$&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;SiLU（$97.63$）&amp;gt; GELU（$97.61$）&amp;gt; CaLU（$97.49$）GELU 方差全场最小（$\pm 0.09$）、首 epoch 启动最快（$90.17$），高斯门不愧是工业界千锤百炼的选择，稳得可怕。RoCLU 排第四，还拿了全场最高单次 $97.90$，但方差也是除 sigmoid 外最大的。sigmoid 垫底不意外：首 epoch 只有 $43.7%$，梯度消失的教科书现场。&lt;/p&gt;
&lt;h3&gt;门控 $\times$ leak&lt;/h3&gt;
&lt;p&gt;把四个&quot;自己人&quot;单独拎出来，门控（sigmoid 门 vs Cauchy 门）$\times$ leak（$\beta=0$ vs $\beta=0.01$）正好组成对照：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;均值&lt;/th&gt;
&lt;th&gt;$\beta=0$&lt;/th&gt;
&lt;th&gt;$\beta=0.01$&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;sigmoid 门&lt;/td&gt;
&lt;td&gt;SiLU 97.63&lt;/td&gt;
&lt;td&gt;Soft 97.66&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cauchy 门&lt;/td&gt;
&lt;td&gt;CaLU 97.49&lt;/td&gt;
&lt;td&gt;LeakyCaLU 97.51&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;leak 效应两行都是 $+0.02\sim0.03$，门控效应两列都是 sigmoid 门 $+0.14\sim0.15$——方向完全一致，可惜幅度都在噪声带里。RoCLU（log 门）以 $97.54$ 落在两者之间，恰好对应它的门控形状也居中（$g&apos;(0)\approx 0.306$，负半轴渐近值比 Cauchy 更深：$-0.46$ vs $-0.32$）。而三位一体内部 SiLU &amp;gt; GELU &amp;gt; CaLU，恰好是门控开启锐度的&lt;strong&gt;反序&lt;/strong&gt;。但说实话，除 sigmoid 外大家差距都在噪声带里，显著性不够。&lt;/p&gt;
&lt;h3&gt;最有意思的：β 消融 vs 单调阈值&lt;/h3&gt;
&lt;p&gt;前面在 Desmos 上算过，soft leaky relu 的导数是&lt;/p&gt;
&lt;p&gt;$$f&apos;(x) = (1-\beta),[\sigma(x) + x\sigma(x)(1-\sigma(x))] + \beta$$&lt;/p&gt;
&lt;p&gt;方括号里是 SiLU 的导数，最小值约 $-0.0998$（$x\approx -2.40$ 处）。令它大于零：&lt;/p&gt;
&lt;p&gt;$$(1-\beta)(-0.0998) + \beta &amp;gt; 0 \quad\Longrightarrow\quad \beta &amp;gt; \beta^* \approx 0.0908$$&lt;/p&gt;
&lt;p&gt;所以 $\beta=0.2$ 稳单调，$\beta=0.1$ 擦边单调（最小导数 $0.01$），$\beta=0.01$ 不单调（还留着坑）。而我做了四档实验，把 $\beta$ 从 $0.01$ 放大到 $0.3$：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;$\beta$&lt;/th&gt;
&lt;th&gt;最终精度（均值$\pm$std）&lt;/th&gt;
&lt;th&gt;epoch1 均值&lt;/th&gt;
&lt;th&gt;单调性（最小导数）&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0.01&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$97.66\pm0.24$&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;89.92&lt;/td&gt;
&lt;td&gt;不单调（$-0.089$，留坑）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.1&lt;/td&gt;
&lt;td&gt;$97.47\pm0.44$&lt;/td&gt;
&lt;td&gt;88.99&lt;/td&gt;
&lt;td&gt;擦边单调（$+0.010$）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.2&lt;/td&gt;
&lt;td&gt;$97.36\pm0.46$&lt;/td&gt;
&lt;td&gt;88.02&lt;/td&gt;
&lt;td&gt;稳单调（$+0.120$）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.3&lt;/td&gt;
&lt;td&gt;$97.00\pm0.41$&lt;/td&gt;
&lt;td&gt;87.30&lt;/td&gt;
&lt;td&gt;稳单调（$+0.230$）&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;均值和启动速度两个维度严格单调下降，方差整体放大（$\pm0.24\to\pm0.41$，中间 $\beta=0.2$ 档最高 $\pm0.46$）。也就是说，&lt;strong&gt;单调的版本反而更差&lt;/strong&gt;。那个坑，在浅网 MNIST 上至少不是坏事。我当初&quot;把它掰直&quot;的执念，被自己的实验否了。科学就是这样，猜想负责貌美如花，数据负责铁面无私。&lt;/p&gt;
&lt;h2&gt;下一步：VGG&lt;/h2&gt;
&lt;p&gt;我的直觉是：Cauchy 门这种重尾、多项式拖尾的设计，在只有 3 个非线性层的简化 LeNet 里根本施展不开，因为门控的这种差异要连乘 8~10 次才会复利放大。下图是原因：sigmoid 门的 $1-g(x)$ 是指数衰减（半对数坐标下呈直线），三下五除二就饱和到底；而 Cauchy 门和 RoCLU 门是多项式拖尾，软得很久。层数一深，前者早早&quot;决断&quot;，后者还留着梯度（VGG 这种无 BN 的深网才是它的主场）。RoCLU 那篇论文也是往深了测（最深到 700 层）。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../assets/posts/desmos-and-calu/05_tails.svg&quot; alt=&quot;门控尾部对比（半对数坐标）：指数尾 vs 多项式尾，差两个数量级只需要 xpprox8&quot; /&gt;&lt;/p&gt;
&lt;p&gt;所以后面：VGG + CIFAR-10。门控三家（sigmoid / Cauchy-CDF / log）到底谁在深水区更能打，敬请期待下篇。&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;完整实验报告、45 次训练的&lt;strong&gt;原始实验结果&lt;/strong&gt;见存档（&lt;a href=&quot;/assets/2026-10-16-desmos-and-calu/calu-archive.zip&quot;&gt;calu-archive.zip&lt;/a&gt;，共 42 个实验目录：11 个激活，3 seed，33 个主实验，另 9 个为 β 消融新增；β=0.01 一档复用主实验。解包后运行 &lt;code&gt;collect_results.py&lt;/code&gt; / &lt;code&gt;beta_ablation.py&lt;/code&gt; 即可复现全部图表），欢迎拍砖。CaLU 的名字要是跟谁撞了也请告诉我——截至动笔那天，我只查到 RoCLU 这一个亲戚。&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;参考文献&lt;/h2&gt;
&lt;p&gt;[1] Küçükali Ö, Bozkurt MH, Ulutaş E, Bozkurt I. A Novel Activation Function for Enhancing Deep Neural Network Stability and Performance. &lt;em&gt;Concurrency and Computation: Practice and Experience&lt;/em&gt;. 2026;38:e70944. DOI: &lt;a href=&quot;https://doi.org/10.1002/cpe.70944&quot;&gt;10.1002/cpe.70944&lt;/a&gt;.&lt;/p&gt;
</content:encoded></item><item><title>Who Produces Consensus? Oreskes, Marx, and the Conditions of Scientific Trust</title><link>https://blog.ansonate.ink/posts/who-produces-consensus/</link><guid isPermaLink="true">https://blog.ansonate.ink/posts/who-produces-consensus/</guid><description>An ENGL 192 essay on Naomi Oreskes&apos; Why Trust Science and the social conditions behind scientific consensus.</description><pubDate>Fri, 09 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;english homework&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1&gt;Introduction&lt;/h1&gt;
&lt;p&gt;In Naomi Oreskes&apos; &lt;em&gt;Why Trust Science&lt;/em&gt; (Oreskes, 2019), she argues that scientific knowledge is not the product of a single scientific method, but the collective consensus of a science community. It is trustworthy as it is produced by such a diverse community that each individual&apos;s bias and values can be identified and recognised, so that the knowledge in the community can be more objective.&lt;/p&gt;
&lt;p&gt;In Chapter 5 of the book, Ottmar Edenhofer and Martin Kowarsch (Edenhofer &amp;amp; Kowarsch, 2019) extend Oreskes&apos; view by adding that policy consensus does not have to be aligned with the scientific consensus. In the case of climate change, this translates to disagreements over the design of climate policies, as policy decisions require extraordinary effort to consider the policy&apos;s co-benefits and unintended side effects. So, in evaluating policies, they proposed that diversity in values and pathways is essential for effective and efficient collaboration and inclusive learning process that facilitates the evaluation.&lt;/p&gt;
&lt;p&gt;They rightly distinguish scientific consensus from policy consensus, but they do not ask who shapes the conditions where policy pluralism can operate. So the underlying question is, ‘How does this diverse consensus come from? When will the consensus degrade into a single voice or premature consensus? Who is producing this diverse consensus?’&lt;/p&gt;
&lt;p&gt;In the example of Limited Energy Theory mentioned in the book, Clarke conceptualises the bias against women&apos;s education through one of the fundamental laws of thermodynamics — the total energy is constant in a closed system. However, the human body is not a closed system, as people can eat and drink, and there was no evidence that the energy consumption involved in the process of education would lead to a decrease in the energy in the uterus. Oreskes shows that Jacobi’s critique was ignored, but she does not fully analyse the institutional conditions that allowed it to be ignored&lt;/p&gt;
&lt;p&gt;Apparently, a scientific community is not diverse because of its own characteristics or internal guidelines, but because external conditions shape it to be diverse. That is to say, diversity is not a natural property of science, nor a virtue that scientists can simply achieve through declarations of openness, but is shaped by social and political factors.&lt;/p&gt;
&lt;h1&gt;Beneath Consensus&lt;/h1&gt;
&lt;p&gt;As Karl Marx argues in &lt;em&gt;The German Ideology&lt;/em&gt; (Marx &amp;amp; Engels, 1975), the ruling ideas are always the ideas of the ruling class. The reason is simple: the one who controls the material production of society, including funds, education, publishing, and media, is also the one who controls the production of ideas, or mental production, in Marx&apos;s words, since they can decide what kind of ideas the public can perceive, which will be shaped by the ideas of the ruling entity in that field.&lt;/p&gt;
&lt;p&gt;Marx’s concept is too broad there, but it does illuminates how control over material and institutional resources shapes which ideas can become visible, credible, and consensus-forming.&lt;/p&gt;
&lt;h2&gt;Captured by Capital&lt;/h2&gt;
&lt;p&gt;Consider the example of climate change. Large corporations and institutions have the ability and motivation to promote skepticism on the topic of climate change, not only because they fund research that harms their interests, but also because they control the means of mental production.&lt;/p&gt;
&lt;p&gt;They control funding and investment, which allows them to select which questions are asked and studied by shifting resources toward preferred topics. They influence publishing and media, allowing them to support think tanks that manufacture doubt, buy media attention to amplify skepticism, lobby governments to block policy, and pressure publishers and journals to present ‘both sides’ of the ambitious climate policy even if the science were unsettled. None of this requires fabricating data but only shaping the ground where consensus is produced.&lt;/p&gt;
&lt;p&gt;Whoever rules topics generates consensus that the public can perceive. Therefore we cannot rely solely on discussion, as Edenhofer and Kowarsch propose. We must carefully examine whose ideas are recognised in that discussion — in other words, whether the backgrounds of the scientists, or ‘cartographers’ mentioned in that chapter, are diverse not only in their fields of study but also in the industries they depend on for funding and career advancement. In this case, scientists are the minority because the system fails to support the scientific community in a manner independent of the corporations and institutes that profit from the fossil fuel industry.&lt;/p&gt;
&lt;p&gt;In the case of tobacco, the same mechanism applies. Of course, the scientific consensus that tobacco harms health was eventually established, but the point is that it delayed the consensus for decades, and the delay itself caused enormous harm to public health. This is not an issue inside the scientific community. A single sponsor or political entity that science serves in a particular field can monopolise the means of mental production, which converges science into a premature consensus.&lt;/p&gt;
&lt;h2&gt;On Political And Social Factors&lt;/h2&gt;
&lt;p&gt;Besides capital, sometimes scientific consensus can be corrupted by politics. In the example of many American scientists opposing continental drift theory, they accuse the theory of being a flawed hypothetico-deductive European theory reflecting authoritativeness instead of a theory built upon inductive methods mirroring American pluralism and democracy. However, this accusation reveals the institutional power of the accusers more than the issues in Wegener&apos;s theory of continental drift. As the leading figures in geology, they controlled the means of mental production in geological research by defining preferred methods, important topics, and standards of reliable conclusions. By equating induction with democracy and deduction with authoritarianism, they turned a political preference into a methodological criterion. In this way, Wegener&apos;s theory was excluded from discussion simply because it did not meet that criterion. In this case, it is a science community itself that monopolised the authority to decide what counts as science.&lt;/p&gt;
&lt;p&gt;In the example of eugenics, many believed that human beings can be improved by socially controlling reproduction. That is, to encourage ‘fit’ families that commonly are wealthy and white, and to discourage the reproduction of ‘unfit’ families that commonly associate with illness and poverty. This theory was convincing because it was framed and justified as a logical deduction from scientific theories, including Darwin&apos;s theory of evolution. Diversity seemed to exist as people and institutions supported eugenics, including writers, presidents, the Supreme Court and great figures in Eugenics Record Office (ERO). But really? These people and institutions are tied together to marginalise the dissenting voices. Diversity only existed at the organisational level, but in terms of ideology and interests except science, these entities are highly homogeneous and deeply aligned. Like what happened in the case of climate change, this alliance monopolised the means of mental production, such as funds and authority to decide what the problem is, like why eugenics is reasonable and beneficial, instead of questioning its validity and ethical compliance.&lt;/p&gt;
&lt;p&gt;The public&apos;s view is equally important. Eugenics was also embraced by many ordinary American people because Eugenics resonated with racial anxiety and dissatisfaction with immigration in their minds. The same fears that immigrants brought crime and disease exacerbated immigration restrictions and eventually led to the Eugenics policy. What makes it worse is that many members of the public lacked specialised training to distinguish between genuine genetic science and racist theories ‘framed as applications of biological science’. The alliance of capital, state, and scientific authority took advantage of it and thus controlled not only the means of mental production but also the emotional and ideological ground where consensus was built.&lt;/p&gt;
&lt;p&gt;Together, climate change illustrates a top-down dynamic where capital controls funding, publishing, media, and think tanks, and thereby rules research agendas and shapes consensus. In contrast, eugenics includes a bottom-up dynamic where political movements leverage racial anxieties, fears regarding immigration, and the limitations of public understanding of science to foster public acceptance of eugenics.&lt;/p&gt;
&lt;p&gt;To conclude, the diversity of science is built upon the plurality in the parties that control the means of mental production. That is, no single party decides where to direct funds, what the problem is and what the appropriate method is.&lt;/p&gt;
&lt;h1&gt;Why trust science?&lt;/h1&gt;
&lt;p&gt;Oreskes is right that science is trustworthy when it is the critical consensus of a diverse community. But her prescription, including diversity, openness, and criticality, presupposes the condition that it aims to produce. Instead of a norm that scientists can will into existence, it is an institutional achievement that depends on the plural control of the means of mental production.&lt;/p&gt;
&lt;p&gt;Trusting science should not be a faith for the public, otherwise the tragedy of eugenics would repeat itself on other topics in the future. Trust should be based on a prerequisite that the parties generating consensus must be plural, because they control the means of mental production that shape the diversity that is essential to reliable knowledge.&lt;/p&gt;
&lt;p&gt;With this condition met, we trust science because of its self-correcting ability, an ability provided by and dependent on its political and social environment. But we cannot wait for certainty and correction before acting. This is Oreskes&apos; Pascal&apos;s Wager, ‘if we act on scientific knowledge and it turns out to be wrong, we may have created a better world for nothing; if we fail to act and it turns out to be right, people will suffer, and the world will be diminished’.&lt;/p&gt;
&lt;p&gt;Therefore, in the darkness before the dawn of correction, we trust science not because it is reliable but because it is fallible and self-correctable. And most importantly, it is the best available way to reduce the harm we do to others and to ourselves. In this way, this trust is conditional, institutional, and fragile since it depends on the plural control of the means of mental production, and most importantly, on our willingness to maintain it.&lt;/p&gt;
&lt;h1&gt;References&lt;/h1&gt;
&lt;p&gt;Edenhofer, O., &amp;amp; Kowarsch, M. (2019). Pascal&apos;s Wager Reframed: Toward Trustworthy Climate Policy Assessments for Risk Societies. In N. Oreskes (Ed.), &lt;em&gt;Why Trust Science?&lt;/em&gt; (pp. 191–201). Princeton University Press.&lt;/p&gt;
&lt;p&gt;Marx, K., &amp;amp; Engels, F. (1975). &lt;em&gt;The German Ideology&lt;/em&gt;. International Publishers. (Written Fall 1845 to mid-1846. First published in full 1932.)&lt;/p&gt;
&lt;p&gt;Oreskes, N. (2019). &lt;em&gt;Why Trust Science?&lt;/em&gt; Princeton University Press.&lt;/p&gt;
</content:encoded></item><item><title>Back to the Future: Why I Build Modern Web Apps with htmx, Template Engines, and Tailwind</title><link>https://blog.ansonate.ink/posts/htmx-tailwind-en/</link><guid isPermaLink="true">https://blog.ansonate.ink/posts/htmx-tailwind-en/</guid><description>Why I build modern web apps with htmx, server templates, and Tailwind: forms, error handling, SSE, and islands.</description><pubDate>Wed, 07 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;A story about why &quot;retro tools&quot; still hold up today.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;A Feeling of Déjà Vu&lt;/h2&gt;
&lt;p&gt;Recently I took on a real-time data dashboard. It needs user login, tiered permissions (admins, volunteers, and clients each seeing their own views), a live card grid refreshing every few seconds, and a pile of dense form operations. Sounds like a typical 2026 project.&lt;/p&gt;
&lt;p&gt;My first reaction was the typical 2026 reaction too: React plus React Router, React Query for server state, Zustand for client state, some form library, some CSS-in-JS solution, plus a WebSocket client. I got as far as the &lt;code&gt;npm create&lt;/code&gt; step — and stopped.&lt;/p&gt;
&lt;p&gt;Because a solution from 20 years ago suddenly flashed through my mind. Back then, pages came from the server, forms were real &lt;code&gt;&amp;lt;form&amp;gt;&lt;/code&gt;s, and errors were real HTTP status codes. The user submits, the page navigates — that simple. Of course, that era had its own hell — ViewState, postbacks, layers of unpredictably nested server controls — so I&apos;m not nostalgic for it. What I miss is that &lt;strong&gt;mental model&lt;/strong&gt;: the server understands the page, there is exactly one state, and HTML is a projection of that state.&lt;/p&gt;
&lt;p&gt;Then I realized: over the past decade-plus we didn&apos;t &quot;abandon&quot; that model — we were &lt;strong&gt;forced&lt;/strong&gt; away from it by what browsers couldn&apos;t do yet. Browsers back then only had crude full-page navigation — XHR existed, but mature, declarative partial updates didn&apos;t — so we invented client-side routing, client-side state, and client-side rendering, stuffing an entire application runtime into the browser just to avoid the primitiveness of &quot;reloading the whole page on every interaction.&quot;&lt;/p&gt;
&lt;p&gt;And today, htmx has patched that hole.&lt;/p&gt;
&lt;p&gt;htmx isn&apos;t retro. htmx fulfills the promise HTML never delivered back then: letting &lt;strong&gt;any element&lt;/strong&gt; fire a request, letting response HTML &lt;strong&gt;replace a target fragment&lt;/strong&gt;, letting the server push &lt;strong&gt;partial updates&lt;/strong&gt; instead of full-page refreshes. You don&apos;t need a heavyweight framework for it — you just add a few attributes to ordinary HTML.&lt;/p&gt;
&lt;p&gt;What this post wants to explain is a design pattern: &lt;strong&gt;why not build an SPA, why the htmx + template engine + Tailwind combo works, and how login/logout, error handling, live updates, and interactive components each land in practice&lt;/strong&gt;. It&apos;s language-agnostic — I used Rust and axum, but swap in Django, Rails, Phoenix, or ASP.NET and the argument still holds.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Why Not Build an SPA&lt;/h2&gt;
&lt;p&gt;I don&apos;t want to argue &quot;SPAs are bad&quot; — they&apos;re not; in many scenarios they&apos;re the right answer. My point is: &lt;strong&gt;in this application, I pay twice for every layer of abstraction an SPA brings.&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;The Twins of State&lt;/h3&gt;
&lt;p&gt;This is the most fundamental one. In an SPA, you always maintain two copies of state: the true state on the server, and a mirror state in the browser. A user has 100 points — those 100 points live in the database, and also in some React component tree&apos;s state. To keep the two consistent, you need cache invalidation, query keys, optimistic updates, revalidation — a whole set of dedicated libraries and mental overhead to solve a synchronization problem &lt;strong&gt;you created yourself&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;In a server-rendered app, there is only one state. The page is a projection of the state, nothing more. The server says &quot;this card&apos;s state is now X,&quot; and the browser displays X. There is no second copy of state to synchronize, because there never was a second copy.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
    subgraph SPA[&quot;SPA — two copies of state&quot;]
        direction TD
        A[&quot;Server state&quot;] &amp;lt;--&amp;gt;|&quot;Sync: cache invalidation / query keys / optimistic updates&quot;| B[&quot;Client mirror state&quot;]
    end
    subgraph HTMX[&quot;htmx — one state&quot;]
        direction TD
        C[&quot;Server state&quot;] --&amp;gt;|&quot;Render HTML&quot;| D[&quot;Page&quot;]
    end
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;The Twins of Routing&lt;/h3&gt;
&lt;p&gt;An SPA has two routing systems: client-side routing (the user clicks a link, the address bar changes, React Router decides which component to render) and server-side API routing (the endpoints the client fetches data from). Both routing tables need their own permission checks, their own error shapes, their own loading states. For an &quot;admins only&quot; page, you guard on the frontend (don&apos;t render) &lt;em&gt;and&lt;/em&gt; on the backend (don&apos;t return the data) — because the frontend guard can always be bypassed.&lt;/p&gt;
&lt;p&gt;A server-rendered app has exactly one routing table. Permissions are checked in one place, pages are assembled in one place, links are real links, and navigation is real HTTP requests.&lt;/p&gt;
&lt;h3&gt;Build Cost&lt;/h3&gt;
&lt;p&gt;An SPA frontend is an independent production line: bundling pipeline, tree shaking, code splitting, type synchronization (frontend TypeScript types aligned with backend DTOs), API version negotiation. The essence of all this work is: &lt;strong&gt;moving HTML away from the server, then moving data back.&lt;/strong&gt; If your goal was always &quot;the server owns state, the browser displays state,&quot; what are you moving things around for?&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;An SPA treats the browser as an application runtime; htmx treats the browser as an HTML renderer. The former needs a framework; the latter just needs a web framework.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;To be fair, I should be clear about when an SPA is still the right call: offline-first apps, complex client-side state machines (e.g. an editor with dozens of drag interactions), rich-text/collaborative editing, and products where &quot;the page itself is a local application.&quot; In those cases the client really is a runtime, not a renderer. But my dashboard isn&apos;t one of them.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;htmx + Template Engine + Tailwind: Why the Trio Works&lt;/h2&gt;
&lt;p&gt;Looked at alone, none of these three tools is remarkable. Their power comes from the combination.&lt;/p&gt;
&lt;h3&gt;Template Engines: Components on the Server&lt;/h3&gt;
&lt;p&gt;A template engine (Askama, Jinja, ERB, Tera — pick your favorite) gives you compile-time type checking and automatic HTML escaping, and more importantly: it lets you &lt;strong&gt;assemble fragments on the server&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;There&apos;s a key insight here: in an SPA you write a &lt;code&gt;&amp;lt;Component&amp;gt;&lt;/code&gt; that returns JSX; in htmx you write a template function that returns a chunk of HTML. &lt;strong&gt;Same granularity, but the platform is now HTTP.&lt;/strong&gt; A &quot;card component&quot; is just a template; a handler calls it, fills in the data, and returns HTML.&lt;/p&gt;
&lt;h3&gt;htmx: Declarative Requests and Replacement&lt;/h3&gt;
&lt;p&gt;htmx turns &quot;fire a request, swap the content&quot; into a few HTML attributes. Here&apos;s the most typical example:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;form hx-post=&quot;/sessions/42/assignments&quot;
      hx-target=&quot;#card-17&quot;
      hx-swap=&quot;outerHTML&quot;&amp;gt;
    &amp;lt;input name=&quot;exercise&quot; placeholder=&quot;exercise&quot; required&amp;gt;
    &amp;lt;button&amp;gt;Assign&amp;lt;/button&amp;gt;
&amp;lt;/form&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The user clicks the button, htmx intercepts the submit and POSTs to the server. The server returns a &lt;strong&gt;new card HTML&lt;/strong&gt; — the same card as before, but with updated content. htmx swaps that HTML into the &lt;code&gt;#card-17&lt;/code&gt; slot. No JSON, no client store, no re-render scheduling, no diffing algorithm. The entire &quot;update one card&quot; interaction is three attributes plus one server-side template function.&lt;/p&gt;
&lt;h3&gt;Tailwind: The Template Is the Component&lt;/h3&gt;
&lt;p&gt;Because styles live in the template, &lt;strong&gt;the template becomes the component&lt;/strong&gt;. Every fragment carries its own styling; you no longer jump between CSS files, JS files, and component files. To restyle a card, you edit the template that renders that card.&lt;/p&gt;
&lt;p&gt;And utility classes pay off infinitely here — they let you barely write CSS at all. No semantic class names, no BEM naming schemes, no selector hierarchies; &lt;code&gt;flex&lt;/code&gt;, &lt;code&gt;gap-3&lt;/code&gt;, &lt;code&gt;text-sm&lt;/code&gt;, &lt;code&gt;font-mono&lt;/code&gt; go straight on the element. Elsewhere that&apos;s just &quot;fewer keystrokes&quot;; but in the template world, your &quot;component&quot; is a server-side fragment, and the styling lives inside the fragment, rendered and swapped along with it. A card arrives from the server with its styles already complete; restyling a card means editing that card&apos;s template. &lt;strong&gt;A fragment is self-contained&lt;/strong&gt; — structure, behavior, and styling in the same place. You no longer maintain a mental map of &quot;where this class is defined and at which layer it gets overridden,&quot; which is far less mental overhead.&lt;/p&gt;
&lt;p&gt;Even better is Tailwind&apos;s build model. It scans your template files at build time and compiles a static CSS file. The browser receives &lt;strong&gt;one&lt;/strong&gt; fingerprinted, cacheable &lt;code&gt;app.css&lt;/code&gt;. No runtime CSS-in-JS, no cost of injecting &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; at runtime, no hydration. Styling is fully resolved at build time; the runtime stays clean.&lt;/p&gt;
&lt;h3&gt;Modern SSR: Tearing Down the House to Repaint a Wall&lt;/h3&gt;
&lt;p&gt;Someone might object: don&apos;t Next.js and Remix do server rendering too?&lt;/p&gt;
&lt;p&gt;Of course they do. But their approach is &lt;strong&gt;wanting both the framework and SSR, then stitching the two together&lt;/strong&gt;: you still write React/Vue components, maintain client state, and run a full bundling pipeline, and then the framework &quot;hydrates&quot; all of that onto the server-rendered HTML. Hydration itself is a cost; so Server Components, island partitioning, and similar mechanisms appear to &lt;strong&gt;reduce&lt;/strong&gt; hydration — but the layers stack up, with more and more boundaries and rules, all to reconcile the natural tension between &quot;client framework&quot; and &quot;server rendering.&quot;&lt;/p&gt;
&lt;p&gt;That&apos;s what I mean by &quot;tearing down the house to repaint a wall.&quot; You want a faster first paint and better SEO, and end up importing an entire hydration mechanism to rescue a client runtime &lt;strong&gt;that could have not existed at all&lt;/strong&gt;. htmx takes the other road: &lt;strong&gt;no client framework at all&lt;/strong&gt;, so &quot;hydration&quot; simply never comes up — HTML arrives from the server in its final form, with no second initialization and no handoff between two copies of state.&lt;/p&gt;
&lt;p&gt;(Astro is the most clear-headed of that family: zero JS by default, islands mounted on demand. But it still asks you to embrace component frameworks and build chains; htmx&apos;s answer is more thorough — not even the component framework.)&lt;/p&gt;
&lt;h3&gt;What&apos;s Retro Is the Mental Model, Not the Tools&lt;/h3&gt;
&lt;p&gt;This stack does feel like aspx and php at times. Because the ASPX and PHP generation wasn&apos;t dumb — they were right: &lt;strong&gt;the server owns state, HTML owns expression.&lt;/strong&gt; They were just dragged down by their tools: no type-safe templates, no declarative partial updates, no genuinely usable CSS system, so they hand-wrote &lt;code&gt;Response.Write&lt;/code&gt; and &lt;code&gt;echo&lt;/code&gt; and struggled in the mud of full-page postbacks.&lt;/p&gt;
&lt;p&gt;Today we use the same mental model, but with the tools 2026 deserves: type-safe templates, attribute-driven partial updates, build-time-compiled CSS. &lt;strong&gt;What&apos;s retro isn&apos;t the tools — it&apos;s the idea that &quot;the server understands the page.&quot; And it was never wrong.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;These ideas aren&apos;t mine — Carson Gross&apos;s &lt;em&gt;Hypermedia Systems&lt;/em&gt; and the essays section of the htmx site explained &quot;hypermedia as the engine of application state&quot; (HATEOAS) far more thoroughly than I can. What I do here is just land those principles in a real project and honestly record where things went smoothly and where I had to supplement with JavaScript. Further reading is linked at the end.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Login and Logout: When a Form Becomes a Real Form Again&lt;/h2&gt;
&lt;p&gt;Login is where &quot;traditional Web&quot; and SPA diverge most — and the best illustration of &quot;when to use htmx and when not to.&quot;&lt;/p&gt;
&lt;h3&gt;A Counterintuitive Decision: Deliberately Not Using htmx for the Login Form&lt;/h3&gt;
&lt;p&gt;My login form is a plain &lt;code&gt;&amp;lt;form method=&quot;post&quot;&amp;gt;&lt;/code&gt; with no &lt;code&gt;hx-*&lt;/code&gt; attributes.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;form method=&quot;post&quot; action=&quot;/login&quot; id=&quot;login-form&quot;&amp;gt;
    &amp;lt;label&amp;gt;Username
        &amp;lt;input name=&quot;username&quot; autocomplete=&quot;username&quot; required&amp;gt;
    &amp;lt;/label&amp;gt;
    &amp;lt;label&amp;gt;Password
        &amp;lt;input name=&quot;password&quot; type=&quot;password&quot; autocomplete=&quot;current-password&quot; required&amp;gt;
    &amp;lt;/label&amp;gt;
    &amp;lt;button type=&quot;submit&quot;&amp;gt;Log in&amp;lt;/button&amp;gt;
&amp;lt;/form&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Why? Because what happens after a successful login — writing the session, redirecting to the dashboard — &lt;strong&gt;is naturally a full page navigation&lt;/strong&gt;, not a partial fragment swap. Login isn&apos;t a &quot;swap this HTML for that HTML&quot; operation; it&apos;s an identity transition from &quot;anonymous&quot; to &quot;logged in.&quot; Once the transition completes, the whole page should re-render, not swap one small fragment.&lt;/p&gt;
&lt;p&gt;The more practical reasons come from browser behavior. A real form submission lets password managers correctly handle &lt;code&gt;autocomplete=&quot;current-password&quot;&lt;/code&gt; autofill, and lets the browser recognize &quot;this is a login form.&quot; And — crucial for login — &lt;strong&gt;an ordinary &lt;code&gt;&amp;lt;form&amp;gt;&lt;/code&gt; still works with no JavaScript, or before htmx has finished loading&lt;/strong&gt;. Login is the first door into your system; you don&apos;t want that door to depend on a just-loaded JS library to open.&lt;/p&gt;
&lt;p&gt;So the login form uses &lt;strong&gt;real navigation&lt;/strong&gt;. &lt;strong&gt;htmx is not &quot;htmx everywhere&quot; — it&apos;s &quot;htmx where it belongs.&quot;&lt;/strong&gt; For login, the traditional form is the righter tool.&lt;/p&gt;
&lt;h3&gt;One Endpoint, Two Response Shapes&lt;/h3&gt;
&lt;p&gt;But here&apos;s the catch: the same &lt;code&gt;/login&lt;/code&gt; endpoint may be hit by browser navigation or by htmx (if you triggered it via htmx elsewhere). How does the server tell them apart?&lt;/p&gt;
&lt;p&gt;The answer is the &lt;code&gt;HX-Request&lt;/code&gt; header. htmx includes &lt;code&gt;HX-Request: true&lt;/code&gt; on every request it sends, and the server branches on it:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def login_redirect(headers, dest, cookie):
    if is_htmx(headers):
        # htmx call: 200 + HX-Redirect, let the browser do a real navigation
        return Response(200, {&quot;HX-Redirect&quot;: dest, &quot;Set-Cookie&quot;: cookie})
    else:
        # Ordinary browser navigation: classic 303
        return Response(303, {&quot;Location&quot;: dest, &quot;Set-Cookie&quot;: cookie})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Both sides end in the same real navigation; only the trigger differs. This branching pattern — &lt;strong&gt;deciding the response shape from &lt;code&gt;HX-Request&lt;/code&gt;&lt;/strong&gt; — runs through the entire error-handling chapter and is one of the cornerstones of this architecture.&lt;/p&gt;
&lt;h3&gt;Tiered Permissions: Extractors, Not Middleware&lt;/h3&gt;
&lt;p&gt;After login comes authorization. This app has three roles seeing completely different pages. My approach is to make &quot;who this is and what role they have&quot; a request extractor, with each route declaring the identity it needs:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class AdminPageAuth:
    # Resolve &quot;admin identity&quot; from the request, for full-page routes

    def from_request(state, request):
        match AdminAuth.from_request(state, request):
            case Ok(auth):
                return Ok(auth)
            case Err(Unauthorized(_)):
                # Unauthenticated page visit: 303 to /login, not a JSON 401
                return Err(Response(303, {&quot;Location&quot;: &quot;/login&quot;}))
            case Err(e):
                return Err(e.into_response())
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;The same &quot;unauthorized&quot; has two response shapes&lt;/strong&gt;: when a browser visits a page, it needs to be navigated to the login form; when an API client calls, it needs a &lt;code&gt;401&lt;/code&gt; plus JSON. The reason is simple — the browser needs a page, the client needs structured data. The extractor does the right thing here: unauthorized on full-page routes becomes a 303, unauthorized on API routes becomes a 401 JSON, both derived from the same &lt;code&gt;Unauthorized&lt;/code&gt; error.&lt;/p&gt;
&lt;p&gt;Wrong role is a different matter. A volunteer visiting an admin page deserves not &quot;please log in&quot; but &lt;code&gt;403&lt;/code&gt; — you&apos;re already logged in, you just lack permission. That distinction (401 unauthenticated vs. 403 unauthorized) gets blurred together in many systems; here it&apos;s kept explicit.&lt;/p&gt;
&lt;h3&gt;Logout: That&apos;s All There Is&lt;/h3&gt;
&lt;p&gt;Logout is a POST: the server revokes the token, clears the cookie, and 303s back to the login page.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def logout(state, request):
    if session_cookie(request):
        revoke_token(session_cookie(request))
    return Response(303, {&quot;Location&quot;: &quot;/login&quot;, &quot;Set-Cookie&quot;: clear_session_cookie()})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That&apos;s it. No &lt;code&gt;localStorage.removeItem(&apos;jwt&apos;)&lt;/code&gt;, no Redux reset, no &quot;clear all client-side caches&quot; ceremony. Because state lived on the server all along, logout is deleting that session record on the server and clearing the cookie in the browser.&lt;/p&gt;
&lt;h3&gt;Security Details That Emerge Naturally&lt;/h3&gt;
&lt;p&gt;These details are utterly natural in the &quot;traditional form&quot; world, yet become awkward in the SPA + JWT world:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;HttpOnly cookies&lt;/strong&gt;: the session token sits in an HttpOnly cookie that JavaScript can&apos;t read at all — XSS can&apos;t steal it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Password hashing on a background thread&lt;/strong&gt;: argon2-style hashing takes ~half a second and must never block the event loop — throw it into a background task.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Rate limiting by (username, IP)&lt;/strong&gt;: against brute force, with the limiter gating before the hash runs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Explicitly rendered CAPTCHA&lt;/strong&gt;: because login is a pure form (real navigation), the CAPTCHA widget is freshly rendered each time with single-use tokens — which fits exactly with the &quot;re-render the login page on every failure&quot; flow.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of these is an htmx-exclusive feature, but they all become &lt;strong&gt;natural again once you&apos;re back in the real-form model&lt;/strong&gt;.&lt;/p&gt;
&lt;h3&gt;CSRF: An Old Threat with an Old Fix&lt;/h3&gt;
&lt;p&gt;Authenticating with session cookies means facing an old friend again: CSRF. An attacker lures the user&apos;s browser into firing a state-changing request at your site without the user knowing — because you use cookies, the browser attaches them automatically.&lt;/p&gt;
&lt;p&gt;In the SPA + JWT world, this problem is mostly sidestepped: the token lives in localStorage and JavaScript manually stuffs it into headers, which the browser never sends automatically. But note this only moves the risk — XSS can now steal that token outright. On the cookie + HttpOnly path, the token is unreadable to JavaScript, so XSS can&apos;t steal it — though XSS can still operate on the victim&apos;s logged-in session as a proxy, just without carrying the credential away. The price is handling CSRF head-on.&lt;/p&gt;
&lt;p&gt;The good news: the CSRF fix is old, mature, and lands especially naturally because forms are server-rendered:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;SameSite&lt;/code&gt; attribute&lt;/strong&gt;: mark the cookie &lt;code&gt;SameSite=Lax&lt;/code&gt; (the modern browser default) or &lt;code&gt;Strict&lt;/code&gt;, and browsers won&apos;t attach it on cross-site requests — already blocking the vast majority of CSRF.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CSRF tokens&lt;/strong&gt;: a per-session secret the server writes into a hidden field when rendering the form, verified on submit. Because rendering is server-side, stuffing it into the field is one template variable — no client logic needed.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So this is another place where &quot;back in the traditional-form world, things get easier&quot;: the threat is old, the fix is old, and both have been proven over decades.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Error Handling: htmx&apos;s Deepest Pit, and Its Most Valuable Contract&lt;/h2&gt;
&lt;p&gt;If you take one thing from this post, make it this section. Because htmx error handling hides a deeply counterintuitive pit — one that makes your app &lt;strong&gt;silently fail&lt;/strong&gt; at certain moments, with the user seeing only a button that seemingly does nothing. Once you understand the problem, you also understand the most elegant part of this architecture: errors stop being chores scattered across the frontend and become a contract that lives in one function, shared by every endpoint.&lt;/p&gt;
&lt;h3&gt;The Pit: htmx Only Swaps Successful Responses&lt;/h3&gt;
&lt;p&gt;Picture the most ordinary scenario. There&apos;s a card on the page with a form; the user clicks &quot;Confirm&quot; and expects the card to update. In an SPA, your &lt;code&gt;fetch&lt;/code&gt; returns a Promise — success or failure, you can do something in &lt;code&gt;.catch&lt;/code&gt;. In htmx, the flow is:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;htmx intercepts the form submit and sends an XHR request to the server.&lt;/li&gt;
&lt;li&gt;The server returns a response.&lt;/li&gt;
&lt;li&gt;htmx replaces your target element with the response body.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Step three is the crux. htmx&apos;s default behavior: &lt;strong&gt;only 2xx responses get swapped into the page&lt;/strong&gt;. If your endpoint returns a 400 or 500, htmx fires an &lt;code&gt;htmx:responseError&lt;/code&gt; event and then… does nothing. The original DOM stays untouched; your error message sits in the response body, visible only in the browser devtools network panel.&lt;/p&gt;
&lt;p&gt;For a &quot;user clicked a button and stares at the screen waiting for feedback&quot; form, that&apos;s catastrophic. No red hint, no toast, not even a console error. The user assumes the button is broken and clicks again, and again — the server correctly rejects the request every time, but every rejection vanishes into thin air.&lt;/p&gt;
&lt;p&gt;The root cause: &lt;strong&gt;htmx treats &quot;errors&quot; as something that shouldn&apos;t be shown to the user by default&lt;/strong&gt;. That default suits &quot;background poll failed, retry quietly&quot; scenarios, but completely fails &quot;the user mistyped a field&quot; scenarios.&lt;/p&gt;
&lt;h3&gt;The Fix: Dual-Shape Responses&lt;/h3&gt;
&lt;p&gt;So I set myself one rule covering all htmx fragment endpoints:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;For htmx callers, always return 200 and put the outcome — success or failure — in the body; for everyone else, return the real status code.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Unpacked, the rule has two branches.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;On success&lt;/strong&gt;, return 200 with the fresh replacement HTML in the body, plus an &lt;code&gt;HX-Trigger&lt;/code&gt; header telling the browser to pop a success toast. The user sees the card change, with a &quot;saved&quot; flash in the corner.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;On failure&lt;/strong&gt;, still return 200, but with an &quot;something went wrong&quot; hint in the body and an &lt;code&gt;HX-Reswap: none&lt;/code&gt; header telling htmx &lt;strong&gt;not to touch the target element&lt;/strong&gt;. The card isn&apos;t wiped, the user&apos;s input stays, and the error is written separately into the page&apos;s toast container via an out-of-band (OOB) patch.&lt;/p&gt;
&lt;p&gt;And &lt;strong&gt;non-htmx callers&lt;/strong&gt; — browsers without JavaScript, direct API clients, test code — should never know toasts exist. They want an honest HTTP status code and a structured JSON error body.&lt;/p&gt;
&lt;p&gt;The core of the whole mechanism is converging the &quot;how to present errors&quot; decision into one function:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def form_err(headers, error):
    if is_htmx(headers):
        # htmx fragment request: always 200, keep the original DOM, errors go via toast
        return Response(
            status=200,
            headers={
                &quot;HX-Trigger&quot;: toast_trigger(error.message, &quot;error&quot;),
                &quot;HX-Reswap&quot;: &quot;none&quot;,
            },
            body=toast_oob(error.message, &quot;error&quot;),   # an OOB #toast HTML chunk
        )
    else:
        # Ordinary callers: real status code + JSON error body
        return error.into_response()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The 200 on the failure branch is &lt;strong&gt;deliberate&lt;/strong&gt;, not lazy. Precisely because htmx only swaps 2xx, we must disguise &quot;failure&quot; as a &quot;successfully fetched response&quot; so htmx is willing to hand it to the page. The real failure information travels via the &lt;code&gt;HX-Trigger&lt;/code&gt; header, not the status code.&lt;/p&gt;
&lt;h3&gt;A Possible Objection: Does 200 Violate HTTP Semantics?&lt;/h3&gt;
&lt;p&gt;Seeing the &quot;always return 200&quot; rule, someone will frown: isn&apos;t that lying? An error is an error — why wrap it in 200?&lt;/p&gt;
&lt;p&gt;That conflates two different things. The key question: &lt;strong&gt;what is the &quot;resource&quot; this request asks for?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;For an htmx fragment request, what the client wants isn&apos;t the fact of &quot;whether this operation succeeded,&quot; but a chunk of &lt;strong&gt;HTML that fits into that div&lt;/strong&gt;. The server did successfully produce that HTML — it just happens to render an error message. From that angle, 200 is fully honest: the request succeeded, and the body is &quot;rendered error DOM.&quot; The real business error (&quot;a field was filled in wrong&quot;) travels in-band via &lt;code&gt;HX-Trigger&lt;/code&gt; (the toast message).&lt;/p&gt;
&lt;p&gt;And where status-code semantics truly matter — API clients, no-JS browsers, tests, monitoring — we &lt;strong&gt;didn&apos;t&lt;/strong&gt; break them: those callers take the other branch and get real 400/401/422 codes plus JSON. HTTP semantics are fully preserved where they should apply, relaxed only in the one case of &quot;the client explicitly wants an HTML fragment.&quot;&lt;/p&gt;
&lt;p&gt;To be precise: this is &lt;strong&gt;not&lt;/strong&gt; claiming HTTP semantics are void — 404, 409, 422 and friends exist for business outcomes and are still used as usual in the error enum. We&apos;re just making one pragmatic concession for &quot;the client wants an HTML fragment&quot;: htmx only swaps 2xx (unless you tweak &lt;code&gt;htmx.config.responseHandling&lt;/code&gt;), so &quot;the business operation failed&quot; goes in the body rather than the status code. An equally reasonable alternative is listening for &lt;code&gt;htmx:responseError&lt;/code&gt; and swapping real 4xx bodies in; we just picked the plainer road.&lt;/p&gt;
&lt;h3&gt;An Unexpected Detail: Latin-1 Headers&lt;/h3&gt;
&lt;p&gt;The &lt;code&gt;HX-Trigger&lt;/code&gt; header looks simple but has a bite waiting — one that only shows on non-English content.&lt;/p&gt;
&lt;p&gt;Browsers send requests via XHR underneath, and response headers XHR receives get decoded as &lt;strong&gt;Latin-1 (ISO-8859-1)&lt;/strong&gt;. So if you stuff UTF-8 text into &lt;code&gt;HX-Trigger&lt;/code&gt; — a Chinese hint, say, or an en-dash — it arrives at the browser as mojibake. A &lt;code&gt;Zone 60–120 bpm&lt;/code&gt; becomes &lt;code&gt;Zone 60â120 bpm&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;I had no idea at first, until a toast containing non-ASCII characters rendered garbled in the browser. After a good while debugging I located it: my server encoding wasn&apos;t wrong — the response-header channel itself can only safely carry ASCII.&lt;/p&gt;
&lt;p&gt;The fix is direct: after serializing the &lt;code&gt;HX-Trigger&lt;/code&gt; JSON, escape every non-ASCII character into &lt;code&gt;\uXXXX&lt;/code&gt; form. The structural JSON characters (quotes, colons, braces) are ASCII anyway, so the conversion is safe; and the client restores the true characters automatically with &lt;code&gt;JSON.parse&lt;/code&gt;. Mojibake gone; what the server sends and what the client parses match exactly.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Server side: escape every non-ASCII character as \uXXXX
def toast_trigger(message, kind):
    payload = json.dumps({&quot;toast&quot;: {&quot;msg&quot;: message, &quot;kind&quot;: kind}})
    return ascii_safe(payload)   # &quot;Zone 60\u2013120 bpm \u00b7 done&quot;

# Client side: JSON.parse restores automatically
# &quot;Zone 60–120 bpm · done&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This detail really represents a whole category of experience in this kind of architecture: &lt;strong&gt;you no longer wrestle with a framework&apos;s abstractions — you wrestle with HTTP&apos;s real constraints.&lt;/strong&gt; Those constraints are real, stable, and testable; and once you understand them, that class of bug never returns.&lt;/p&gt;
&lt;h3&gt;A Single Source of Truth: The Error Enum&lt;/h3&gt;
&lt;p&gt;Once error handling converges on the single place &lt;code&gt;form_err&lt;/code&gt;, the next need arises naturally: give the concept of &quot;error&quot; itself a single source of truth.&lt;/p&gt;
&lt;p&gt;In SPAs, errors are usually loose parts — frontend error constants here, backend exception classes there, thinly joined by the HTTP-status-code layer in between. In a server-rendered architecture, one enum (or exception hierarchy) can decide three things at once:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Error code&lt;/strong&gt; (&lt;code&gt;bad_request&lt;/code&gt;, &lt;code&gt;unauthorized&lt;/code&gt;, &lt;code&gt;not_found&lt;/code&gt;, &lt;code&gt;conflict&lt;/code&gt;, …)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;HTTP status code&lt;/strong&gt; (400, 401, 404, 409, …)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Human-readable message for the user&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The simplified enum below is a portrait of that thinking:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;enum AppError:
    case BadRequest(message)
    case Unauthorized(message)
    case Forbidden(message)
    case NotFound(message)
    case RateLimited(message)
    case Internal(message)

    def code(self):
        # &quot;bad_request&quot;, &quot;unauthorized&quot;, ...
    def status(self):
        # 400, 401, 403, 404, 429, 500
    def message(self):
        # Human-readable message
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then the whole chain connects: any handler anywhere &lt;code&gt;raise NotFound(&quot;no such session&quot;)&lt;/code&gt;, and it automatically becomes — a red toast for htmx, a &lt;code&gt;404&lt;/code&gt; plus &lt;code&gt;{&quot;type&quot;: &quot;error&quot;, &quot;code&quot;: &quot;not_found&quot;, &quot;message&quot;: &quot;no such session&quot;}&lt;/code&gt; for APIs, a re-rendered 404 page for no-JS browsers. &lt;strong&gt;Three exits, one definition.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Internal vs. user errors must be treated differently. &lt;code&gt;Internal&lt;/code&gt; errors may hide database details, stack traces, internal paths — things that must &lt;strong&gt;never&lt;/strong&gt; appear in response bodies. The right move is intercepting them at the response boundary: log them (let &lt;code&gt;Internal&lt;/code&gt; details into the logs), and hand the client only a generic &lt;code&gt;&quot;internal error&quot;&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def into_response(self):
    status = self.status()
    if self is Internal(detail):
        log.error(detail)              # details go to logs only
        message = &quot;internal error&quot;     # the client only ever sees this
    else:
        message = self.message()
    return Response(status, json({&quot;type&quot;: &quot;error&quot;, &quot;code&quot;: self.code(), &quot;message&quot;: message}))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Putting that &lt;code&gt;log.error(detail)&lt;/code&gt; at the response boundary has a bonus benefit: even if some handler forgot to log on error, as long as it returned &lt;code&gt;Internal&lt;/code&gt;, the logs are guaranteed a record. You&apos;ve turned &quot;internal errors must be logged&quot; from a discipline every developer must remember into something the architecture guarantees automatically.&lt;/p&gt;
&lt;h3&gt;Why This Setup Holds Together Here&lt;/h3&gt;
&lt;p&gt;Looking back you might ask: couldn&apos;t SPAs do all this too? Pop a toast in &lt;code&gt;fetch&lt;/code&gt;&apos;s &lt;code&gt;.catch&lt;/code&gt; and be done?&lt;/p&gt;
&lt;p&gt;Of course they could. The difference isn&apos;t whether you can, but &lt;strong&gt;how many times you do it&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;In an SPA, error handling belongs to each call site. You have a global interceptor, but every component still decides its own failure look — loading state, error state, retry button, boundary components. Errors are the easiest part of a frontend app to quietly drop.&lt;/p&gt;
&lt;p&gt;In the htmx architecture, error presentation is pushed back to the server. The server &lt;strong&gt;already knows&lt;/strong&gt; what went wrong, how badly, and what the user should see. And htmx&apos;s response contract lets you encode that knowledge &lt;strong&gt;once&lt;/strong&gt; — in &lt;code&gt;form_err&lt;/code&gt; and &lt;code&gt;AppError&lt;/code&gt; — after which every endpoint, whether editing a card, bulk-assigning, or deleting a record, inherits the same behavior automatically.&lt;/p&gt;
&lt;p&gt;This touches the deepest philosophy of the architecture: &lt;strong&gt;keep decisions about &quot;state&quot; and &quot;errors&quot; on the server, and let HTML only express the outcome.&lt;/strong&gt; Error handling matters because it&apos;s where that philosophy first becomes concrete and tangible.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Live Updates: SSE Instead of WebSocket&lt;/h2&gt;
&lt;p&gt;The hardest part of the dashboard is liveness. Cards must refresh every few seconds to reflect data uploaded by on-site devices. The SPA standard answer is WebSocket plus a client rendering layer. Here, the answer is suspiciously simple.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;body hx-ext=&quot;sse&quot;&amp;gt;
    &amp;lt;div id=&quot;grid&quot; sse-connect=&quot;/stream&quot; sse-swap=&quot;snapshot&quot;&amp;gt;
        {{ cards|safe }}
    &amp;lt;/div&amp;gt;
&amp;lt;/body&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;htmx&apos;s SSE extension maintains a server-push connection to &lt;code&gt;/stream&lt;/code&gt;. &lt;code&gt;sse-swap=&quot;snapshot&quot;&lt;/code&gt; declares: the server will push an event named &lt;code&gt;snapshot&lt;/code&gt; whose data payload is the HTML to swap into &lt;code&gt;#grid&lt;/code&gt;. So what the server pushes is &lt;strong&gt;not JSON data, but already-rendered card HTML&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;event: snapshot
data: &amp;lt;div id=&quot;grid&quot;&amp;gt;...complete card HTML...&amp;lt;/div&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The client swaps it in on arrival. When live data is just &quot;a projection of server state,&quot; rendering should happen on the side that owns the state — the server — not redone in a browser that must first rebuild the state. So you &lt;strong&gt;drop an entire client rendering layer&lt;/strong&gt;: no more &quot;receive JSON → update store → trigger re-render&quot; chain, only &quot;receive HTML → replace DOM.&quot;&lt;/p&gt;
&lt;p&gt;The price sits on the server side: you must manage that long-lived connection&apos;s lifecycle — who&apos;s listening, whether to clean up on disconnect, how to broadcast to all listeners when data changes. That sounds scary, but it&apos;s essentially a &quot;subscriber list + broadcast loop,&quot; an old trick every backend framework knows, in a few dozen lines (in production you also handle slow consumers, reconnect stampedes, and such edges — but that&apos;s another topic). And it&apos;s &lt;strong&gt;one-way only&lt;/strong&gt; — data flows from server to browser; any client-initiated command (editing a card, say) still goes over ordinary HTTP POST. The live channel carries no &quot;instructions back.&quot;&lt;/p&gt;
&lt;h3&gt;Why SSE Instead of WebSocket&lt;/h3&gt;
&lt;p&gt;Because live updates in this app are &lt;strong&gt;one-way&lt;/strong&gt; — data flows from server to browser, and the browser barely sends anything back. SSE is designed for exactly that shape: a long-lived connection over HTTP, with &lt;code&gt;EventSource&lt;/code&gt; reconnecting automatically, and the connection naturally carrying same-origin cookies, so it seamlessly reuses your existing HTTP auth — an already-logged-in user&apos;s session cookie rides along with the SSE connection, no extra auth step needed.&lt;/p&gt;
&lt;p&gt;WebSocket&apos;s cost is stuffing in &quot;two-way&quot; — a capability you don&apos;t even use — as a price you must pay: no auto-reconnect on drop, so you implement retries and heartbeats yourself; while it also upgrades from an HTTP handshake (so it can carry cookies too), it&apos;s more like an independent bidirectional pipe, living a separate life from the HTTP &quot;request-response-push&quot; lifecycle. When all you need is a one-way push stream, WebSocket is overkill.&lt;/p&gt;
&lt;h3&gt;Being Honest: The DOM Is State Too&lt;/h3&gt;
&lt;p&gt;In section 2 I said &quot;there is only one state.&quot; That was idealized.&lt;/p&gt;
&lt;p&gt;Because the DOM itself is a second state. The user is filling in a form inside a card, has a dropdown expanded, focus sitting in some input — none of which the server knows, yet all of which is real state the user is holding. When the server pushes a &lt;code&gt;snapshot&lt;/code&gt; that replaces the whole &lt;code&gt;#grid&lt;/code&gt;, the input, the open menu, the focus — all gone. Far worse than a &quot;stale card.&quot;&lt;/p&gt;
&lt;p&gt;So the &quot;implicit dual-state&quot; problems — concurrent writes, multiple tabs, the back button — don&apos;t disappear in server-rendered architectures; they just change shape. The honest version: state synchronization goes from &quot;the framework manages it&quot; to &quot;you manage it with HTTP primitives.&quot; SSE events, &lt;code&gt;hx-swap&lt;/code&gt;, OOB patches are your primitives; and &quot;when pushes should yield to what the user is doing&quot; is your responsibility — no framework judges that for you.&lt;/p&gt;
&lt;p&gt;Here&apos;s how we manage it: &lt;strong&gt;when swapping, skip cards the user is editing.&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# A card counts as &quot;being edited&quot; if either holds:
def card_is_editing(card):
    focus = document.active_element
    if focus is inside card and focus is an input (input / textarea / select):
        return true
    if card contains an open edit form:
        return true
    return false
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So when each &lt;code&gt;card&lt;/code&gt; event arrives, first check whether the target card is being edited — if so, &lt;code&gt;preventDefault()&lt;/code&gt;, skip this swap, and let that second&apos;s update lapse. And the whole-grid &lt;code&gt;snapshot&lt;/code&gt; is more cautious: if &lt;strong&gt;any&lt;/strong&gt; card is being edited, stash this snapshot and apply it once editing ends — at the price of the whole grid staying stale during the edit. Broadcasts tick once a second, so an edited card never stays stale forever: the moment editing ends, the next tick brings a fresh one.&lt;/p&gt;
&lt;p&gt;You do have to write a small piece of JavaScript arbitrating between &quot;live pushes&quot; and &quot;what the user is doing.&quot; It&apos;s inelegant, but honest — it admits the DOM is state, then explicitly decides which state yields to which, and when. That&apos;s what this architecture really looks like: no magic, just HTTP primitives laid out one by one.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Islands: When a Chunk of Page Truly Needs to Be &quot;Alive&quot;&lt;/h2&gt;
&lt;p&gt;By now a reader will ask: all well and good, but what about a genuinely interactive component — a chart with tab switching and live refresh, where clicking a tab swaps datasets and hovering shows tooltips? htmx&apos;s partial replacement handles forms and lists, but not &quot;a widget in the browser managing its own state.&quot;&lt;/p&gt;
&lt;p&gt;The answer is admitting it, then carving out a small self-contained &quot;island.&quot; That&apos;s not a betrayal of the argument so far — it&apos;s drawing the boundary clearly.&lt;/p&gt;
&lt;h3&gt;Most Pages Are Server-Rendered; a Few Are Not&lt;/h3&gt;
&lt;p&gt;In my dashboard, the vast majority of pages follow one rule: the server renders HTML, htmx handles partial updates. With one exception — a participant history detail page with a chart. It has &quot;this session / across sessions&quot; tabs, a live-ticking frame count, a summary row, and a Chart.js line chart.&lt;/p&gt;
&lt;p&gt;That widget needs something server rendering struggles to provide: &lt;strong&gt;client state&lt;/strong&gt;. The user clicks a tab, the chart switches data sources, but the server doesn&apos;t need to know &quot;which tab is showing&quot; — that&apos;s purely presentational state, irrelevant to the server. Forcing it through htmx means a server round-trip per tab switch, interrupting the experience for a piece of local UI state. Not worth it.&lt;/p&gt;
&lt;p&gt;So the chart is an island: a self-contained chunk on the page with its own little runtime, surrounded on all sides by server-rendered everything.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
    Page[&quot;Detail page (server-rendered)&quot;]
    Page --&amp;gt; Header[&quot;Header and static fields&quot;]
    Page --&amp;gt; Panel[&quot;#detail-panel form area&amp;lt;br/&amp;gt;(htmx partial swaps)&quot;]
    Page --&amp;gt; Island[&quot;History-chart island&amp;lt;br/&amp;gt;(petite-vue + Chart.js)&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;div
    v-scope=&quot;HistoryChart({ pid: 42, cutoff: 0.6 })&quot;
    @vue:mounted=&quot;start&quot;
    @vue:unmounted=&quot;stop&quot;
&amp;gt;
    &amp;lt;button @click=&quot;setTab(&apos;session&apos;)&quot; :disabled=&quot;tab === &apos;session&apos;&quot;&amp;gt;This session&amp;lt;/button&amp;gt;
    &amp;lt;button @click=&quot;setTab(&apos;all&apos;)&quot; :disabled=&quot;tab === &apos;all&apos;&quot;&amp;gt;Across sessions&amp;lt;/button&amp;gt;
    &amp;lt;span v-if=&quot;headerCode&quot;&amp;gt;{{ headerCode }} · {{ frameCount }} frames&amp;lt;/span&amp;gt;
    &amp;lt;canvas&amp;gt;&amp;lt;/canvas&amp;gt;
&amp;lt;/div&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I used petite-vue here — a 6KB, no-build-step mini-Vue built for &quot;adding reactivity to a small chunk of HTML.&quot; It&apos;s not a second full framework, just a small tool letting the island respond to user input internally.&lt;/p&gt;
&lt;h3&gt;Islands Still Respect the Core Principle&lt;/h3&gt;
&lt;p&gt;Islands may hold client state, but &lt;strong&gt;data still comes from the server&lt;/strong&gt;. The chart bootstraps with a same-origin fetch for history, carrying the existing session cookie — zero extra auth complexity. And its liveness rides the same EventSource (SSE) from the previous section — one stream carrying multiple named events: the grid gets &lt;code&gt;snapshot&lt;/code&gt; and swaps HTML, the island gets &lt;code&gt;card&lt;/code&gt; and refetches its own data. There is still exactly one source of data; the island is just a lively view of it.&lt;/p&gt;
&lt;p&gt;That&apos;s a dividing line: islands hold &lt;strong&gt;ephemeral, presentational state&lt;/strong&gt; — which tab is selected, which frame the chart is drawn up to. The server&apos;s state — the records themselves, quality thresholds, which frames are valid — remains the single source of truth, still owned by the server. You&apos;ve introduced a small runtime, but no second truth to synchronize.&lt;/p&gt;
&lt;h3&gt;Islands Must Live Outside the Swap&lt;/h3&gt;
&lt;p&gt;Islands have one easy-to-violate discipline: &lt;strong&gt;they can&apos;t sit inside htmx&apos;s swap targets.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The reason is mount timing. petite-vue&apos;s &lt;code&gt;createApp().mount()&lt;/code&gt; runs once at page load, compiling the &lt;code&gt;[v-scope]&lt;/code&gt; elements present in the DOM at that moment. htmx swaps new HTML into the page afterward — if that swapped-in HTML contains a &lt;code&gt;v-scope&lt;/code&gt;, petite-vue knows nothing about it; it&apos;s just dead tags with no reactivity. To revive it you&apos;d manually remount after every swap, while handling the previous mount&apos;s teardown, event listeners, and EventSource cleanup yourself. Once you walk that road, you&apos;ve stitched two runtimes together, and complexity snaps back with a vengeance.&lt;/p&gt;
&lt;p&gt;So in practice, swap targets and islands are &lt;strong&gt;sibling neighbors in the DOM, never nested parent and child&lt;/strong&gt;. Back on that detail page: above sits a &lt;code&gt;#detail-panel&lt;/code&gt; swap target with pure forms inside — setting ranges, assigning actions, all via htmx partial swaps; below sits a chart island that doesn&apos;t swap via htmx but refreshes itself via its own same-origin fetch plus EventSource. Two update channels, each minding its own, never nested.&lt;/p&gt;
&lt;p&gt;That discipline is two faces of the same thing as the previous section: the island&apos;s data doesn&apos;t come from htmx response bodies, but from its own line. Precisely because of that, it can rest safely outside any swap, never uprooted by a partial replacement.&lt;/p&gt;
&lt;h3&gt;A Familiar Price&lt;/h3&gt;
&lt;p&gt;Islands aren&apos;t free. You&apos;ve reintroduced client state, a hand-written JS file, and a second runtime (petite-vue). &quot;The cost of SPA&quot; is back — just strictly confined to a small box of a few dozen lines, instead of suffusing the whole app.&lt;/p&gt;
&lt;p&gt;And there are only-step-on-it-to-know pitfalls. petite-vue deep-wraps every property in its scope in a Proxy for reactivity — usually great; but if you put a Chart.js instance or an EventSource inside the reactive scope, their internal slots break under the Proxy and method calls throw outright. The fix is keeping those instances in closure-local variables, invisible to the reactivity system. Same category of experience as that Latin-1 header earlier: what you wrestle changes from &quot;a framework&apos;s abstractions&quot; to &quot;some specific mechanism&apos;s boundary&quot; — and once understood, never wrong again.&lt;/p&gt;
&lt;h3&gt;Island Architecture, Not &quot;All or Nothing&quot;&lt;/h3&gt;
&lt;p&gt;If you know Astro, you&apos;ll recognize the &quot;islands architecture&quot; pattern in this. The only difference: Astro made it a first-class framework citizen, while here it&apos;s hand-rolled and smaller. The judgment behind both is the same — &lt;strong&gt;deciding what needs a client runtime is done component by component, not for the whole app at once.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The most important judgment is really this: htmx&apos;s value was never &quot;refusing JavaScript,&quot; but &quot;deferring JavaScript until it&apos;s truly needed.&quot; The vast majority of interactions — a form, a list refresh, an error hint — need only HTML and HTTP; only when you meet a widget that genuinely manages its own state do you carve out an island. Server-render by default, introduce runtimes on demand — not the other way around.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;When This Pattern Is the Right One&lt;/h2&gt;
&lt;p&gt;I don&apos;t want to write this as an htmx hit piece or puff piece. It&apos;s a tool; tools belong in the right settings. This checklist is my judgment:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;This pattern fits you when:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The app is essentially &quot;multiple views over server state&quot; — dashboards, admin backends, content display.&lt;/li&gt;
&lt;li&gt;It&apos;s form-dense and CRUD-driven.&lt;/li&gt;
&lt;li&gt;Liveness is mostly pushes, without complex client-side interaction.&lt;/li&gt;
&lt;li&gt;The team is stronger on backend than frontend, or you just don&apos;t want to maintain two codebases and two build chains.&lt;/li&gt;
&lt;li&gt;You want &quot;logout really logs out&quot; and &quot;there really is only one state.&quot;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;This pattern doesn&apos;t fit you when:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;You need offline-first.&lt;/li&gt;
&lt;li&gt;The client has a complex local state machine (drag-and-drop, rich-text editing, collaborative editing).&lt;/li&gt;
&lt;li&gt;The page itself is a local application rather than a projection of server state.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In those settings the browser really is a runtime, and SPA is the right answer.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Coda: The Mental Model Was Right All Along&lt;/h2&gt;
&lt;p&gt;The ASPX and PHP generation wasn&apos;t dumb — the right tools just hadn&apos;t grown yet. Their instinct — &lt;strong&gt;the server owns state, HTML owns expression&lt;/strong&gt; — was never wrong; what was wrong were the tools in their hands: no type-safe templates, no declarative partial updates, no genuinely usable CSS system, no clean live-push channel.&lt;/p&gt;
&lt;p&gt;htmx filled in partial updates, template engines filled in type safety, Tailwind filled in styling, SSE filled in liveness. When the tools arrived, that old mental model suddenly became modern again — not because the world regressed to 2005, but because the idea that &quot;the server understands the page&quot; had been waiting there to be redeemed all along.&lt;/p&gt;
&lt;p&gt;If you&apos;ve ever felt exhausted deep inside &lt;code&gt;node_modules&lt;/code&gt;, between two routing tables, in the mire of cache invalidation — maybe look back. You&apos;ll find you&apos;re not stepping backward, but recovering a road that was right all along.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Further Reading&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;em&gt;Hypermedia Systems&lt;/em&gt; — by Carson Gross, Adam Stepinski, and Denis Pashevsky, free to read online: hypermedia.systems. The systematic treatment of every idea above, far more complete than this post.&lt;/li&gt;
&lt;li&gt;The essays section of the htmx site (htmx.org/essays) — &quot;hypermedia as the engine of application state&quot; (HATEOAS), when to use hypermedia and when to use SPA, discussed more rigorously there.&lt;/li&gt;
&lt;li&gt;The htmx official docs (htmx.org/docs) — the authoritative reference for every attribute mentioned here: &lt;code&gt;hx-swap&lt;/code&gt;, &lt;code&gt;HX-Trigger&lt;/code&gt;, the SSE extension, and the rest.&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>回到未来：我为什么用 htmx、模板引擎和 Tailwind 构建现代 Web 应用</title><link>https://blog.ansonate.ink/posts/htmx-tailwind/</link><guid isPermaLink="true">https://blog.ansonate.ink/posts/htmx-tailwind/</guid><description>用 htmx、服务端模板和 Tailwind 构建现代 Web 应用：表单、错误处理、SSE 与岛屿架构的实践总结。</description><pubDate>Wed, 07 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;一个关于“复古工具”为何在今天依然成立的故事。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;一种似曾相识的感觉&lt;/h2&gt;
&lt;p&gt;最近我接了一个实时数据仪表盘。它需要用户登录、权限分级（管理员、志愿者、客户端各看各的）、一个几秒刷新一次的实时卡片网格、还有一堆密集的表单操作。听起来像是一个典型的 2026 年项目。&lt;/p&gt;
&lt;p&gt;我的第一反应也是典型的 2026 年反应：React 加 React Router，React Query 管服务端状态，Zustand 管客户端状态，某个表单库，某个 CSS-in-JS 方案，再配一套 WebSocket 客户端。写到 &lt;code&gt;npm create&lt;/code&gt; 的那一步，我停住了。&lt;/p&gt;
&lt;p&gt;因为我的脑海里突然闪过 20 年前的解法。那时候页面是服务器给的，表单是真的 &lt;code&gt;&amp;lt;form&amp;gt;&lt;/code&gt;，出错是真的 HTTP 状态码。用户提交，页面导航，就这么简单。当然，那个时代有它自己的地狱——ViewState、回传、服务器控件层层嵌套的不可预测性——所以我并不是在怀念它。我怀念的是那个&lt;strong&gt;心智模型&lt;/strong&gt;：服务器懂页面，状态只有一个，HTML 是状态的投影。&lt;/p&gt;
&lt;p&gt;然后我意识到，这十几年里我们不是&quot;放弃了&quot;那个模型，而是被浏览器当时的能力不足&lt;strong&gt;逼&lt;/strong&gt;着离开了它。当时的浏览器只有粗笨的全页导航——XHR 虽然早就存在，但真正成熟、声明式的局部更新当时还不存在——所以我们才发明了客户端路由、客户端状态、客户端渲染，把一个完整的应用运行时塞进浏览器，只为了回避&quot;每次交互都重新加载整个页面&quot;的原始。&lt;/p&gt;
&lt;p&gt;而今天，htmx 把这个漏洞补上了。&lt;/p&gt;
&lt;p&gt;htmx 不是复古。htmx 补完了 HTML 当年没有兑现的承诺：让&lt;strong&gt;任何元素&lt;/strong&gt;都能发起请求，让响应的 HTML &lt;strong&gt;替换目标片段&lt;/strong&gt;，让服务器推送&lt;strong&gt;局部更新&lt;/strong&gt;，而不是整页刷新。你不需要为此引入一个笨重的框架，你只需要给普通的 HTML 加上几个属性。&lt;/p&gt;
&lt;p&gt;这篇文章想讲清楚的，是一套设计模式：&lt;strong&gt;为什么不做一个 SPA，为什么 htmx + 模板引擎 + Tailwind 这套组合能打，登录登出、错误处理、实时更新和交互式组件分别怎么落地&lt;/strong&gt;。它跟具体语言无关——我用的是 Rust 和 axum，但你换成 Django、Rails、Phoenix、ASP.NET，论证依然成立。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;为什么不做一个 SPA&lt;/h2&gt;
&lt;p&gt;我不想用&quot;SPA 是坏的&quot;来立论——它不是，它在很多场景下是正解。我想说的是：&lt;strong&gt;在这个应用里，SPA 的每一层抽象，我都要付两次钱。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;状态的双胞胎&lt;/h3&gt;
&lt;p&gt;这是最根本的一条。在一个 SPA 里，你永远在维护两份状态：服务器上的真实状态，和浏览器里的镜像状态。用户的积分是 100 分，这 100 分存在于数据库里，也存在于某个 React 组件树的 state 里。为了让这两份保持一致，你需要 cache invalidation、query keys、乐观更新、revalidation——一整套专门的库和心智负担，去解决一个&lt;strong&gt;你自己制造出来的&lt;/strong&gt;同步问题。&lt;/p&gt;
&lt;p&gt;而在服务端渲染的应用里，状态只有一个。页面就是状态的投影，仅此而已。服务器说&quot;这个卡片现在的状态是 X&quot;，浏览器就把 X 显示出来。没有第二份状态需要同步，因为从来没有第二份状态。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
    subgraph SPA[&quot;SPA — 两份状态&quot;]
        direction TD
        A[&quot;服务器状态&quot;] &amp;lt;--&amp;gt;|&quot;同步：缓存失效 / query keys / 乐观更新&quot;| B[&quot;客户端镜像 state&quot;]
    end
    subgraph HTMX[&quot;htmx — 一份状态&quot;]
        direction TD
        C[&quot;服务器状态&quot;] --&amp;gt;|&quot;渲染 HTML&quot;| D[&quot;页面&quot;]
    end
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;路由的双胞胎&lt;/h3&gt;
&lt;p&gt;SPA 有两条路由系统：客户端路由（用户点链接，浏览器地址栏变化，React Router 决定渲染哪个组件），和服务端 API 路由（客户端去取数据的那套端点）。这两套路由要各自维护权限、各自的错误形态、各自的加载态。一个&quot;只有管理员能看&quot;的页面，你既要在前端守卫（不渲染），又要在后端守卫（不返回数据）——因为前端守卫永远可以被绕过。&lt;/p&gt;
&lt;p&gt;服务端渲染的应用只有一套路由。权限在一个地方检查，页面在一个地方组装，链接就是真的链接，导航就是真的 HTTP 请求。&lt;/p&gt;
&lt;h3&gt;构建成本&lt;/h3&gt;
&lt;p&gt;SPA 的前端是一条独立的生产线：打包链、Tree Shaking、代码分割、类型同步（前端 TypeScript 类型要和后端 DTO 对齐）、API 版本协商。这些工作的本质是：&lt;strong&gt;把 HTML 从服务器搬走，再把数据搬回来。&lt;/strong&gt; 如果你本来的目标就是&quot;服务器有状态，浏览器显示状态&quot;，那你搬来搬去图什么呢？&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;SPA 把浏览器当成一个应用运行时；htmx 把浏览器当成一个 HTML 渲染器。前者你需要一个框架，后者你只需要一个 Web 框架。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;为了公平，我得说清楚什么时候 SPA 仍然是对的：离线优先的应用、复杂的客户端状态机（比如一个有几十种拖拽交互的编辑器）、富文本/协作编辑、以及那些&quot;页面本身就是一个本地应用&quot;的产品。这些场景里，客户端确实是一个运行时，而不是一个渲染器。但我这个仪表盘不是。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;htmx + 模板引擎 + Tailwind：三件套为什么能搭&lt;/h2&gt;
&lt;p&gt;单独看这三个工具，每个都不起眼。它们的威力来自组合。&lt;/p&gt;
&lt;h3&gt;模板引擎：服务端的组件&lt;/h3&gt;
&lt;p&gt;模板引擎（Askama、Jinja、ERB、Tera——随你喜欢哪个）给了你编译期的类型检查和自动 HTML 转义，更重要的是：它让你在&lt;strong&gt;服务端组装片段&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;这里有个关键洞察：在 SPA 里你写一个 &lt;code&gt;&amp;lt;Component&amp;gt;&lt;/code&gt; 返回 JSX；在 htmx 里你写一个模板函数，返回一段 HTML。&lt;strong&gt;粒度完全一样，但平台换成了 HTTP。&lt;/strong&gt; 一个&quot;卡片组件&quot;就是一段模板，一个 handler 调它、填好数据、返回 HTML。&lt;/p&gt;
&lt;h3&gt;htmx：请求与替换的声明化&lt;/h3&gt;
&lt;p&gt;htmx 把&quot;发请求、换内容&quot;这件事声明成了几个 HTML 属性。看一个最典型的例子：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;form hx-post=&quot;/sessions/42/assignments&quot;
      hx-target=&quot;#card-17&quot;
      hx-swap=&quot;outerHTML&quot;&amp;gt;
    &amp;lt;input name=&quot;exercise&quot; placeholder=&quot;exercise&quot; required&amp;gt;
    &amp;lt;button&amp;gt;Assign&amp;lt;/button&amp;gt;
&amp;lt;/form&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;用户点下按钮，htmx 拦截提交，POST 到服务器。服务器返回一段&lt;strong&gt;新的卡片 HTML&lt;/strong&gt;——就是原来那个卡片，但内容更新了。htmx 把这段 HTML 替换到 &lt;code&gt;#card-17&lt;/code&gt; 的位置。没有 JSON，没有 client store，没有重渲染调度，没有 diff 算法。整个&quot;更新一个卡片&quot;的交互，就这三行属性加一个服务端模板函数。&lt;/p&gt;
&lt;h3&gt;Tailwind：模板即组件&lt;/h3&gt;
&lt;p&gt;因为样式写在模板里，&lt;strong&gt;模板就变成了组件&lt;/strong&gt;。每个片段自带它的样式，你不再在 CSS 文件、JS 文件、组件文件之间跳来跳去。改一个卡片的样式，你改的是渲染那张卡片的模板。&lt;/p&gt;
&lt;p&gt;而 utility class 的好处在这里会被无限放大——它让你几乎不用写 CSS。没有语义化类名、没有 BEM 那套命名、没有选择器层级，&lt;code&gt;flex&lt;/code&gt;、&lt;code&gt;gap-3&lt;/code&gt;、&lt;code&gt;text-sm&lt;/code&gt;、&lt;code&gt;font-mono&lt;/code&gt; 直接写在元素上。这个好处在别处只是&quot;少敲几个字&quot;；但到了模板的世界里，你的&quot;组件&quot;就是一段服务端片段，样式住在片段里、跟着片段一起被渲染和替换。一张卡片从服务器回来，样式已经齐了；改一张卡片的样式，改的就是那张卡片的模板。&lt;strong&gt;一个片段是自包含的&lt;/strong&gt;—。结构、行为、样式都在同一个地方，你不需要在大脑里维护一张&quot;这个 class 定义在哪、又在哪一层被覆盖&quot;的地图，心智负担小得多。&lt;/p&gt;
&lt;p&gt;更妙的是 Tailwind 的构建模型。它在构建时扫描你的模板文件，编译出一份静态 CSS。浏览器拿到的是&lt;strong&gt;一份&lt;/strong&gt;带指纹缓存的 &lt;code&gt;app.css&lt;/code&gt;。没有 runtime 的 CSS-in-JS，没有运行时注入 &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; 的成本，没有水合。样式的问题在构建期就全部解决完了，运行时干干净净。&lt;/p&gt;
&lt;h3&gt;现代 SSR：为了刷墙，把房子拆了&lt;/h3&gt;
&lt;p&gt;有人可能会说：Next.js、Remix 不也做服务器渲染吗？&lt;/p&gt;
&lt;p&gt;它们当然做。但它们的路子是&lt;strong&gt;既想要框架，又想要 SSR，于是把两者缝在一起&lt;/strong&gt;：你照样写 React/Vue 组件、维护客户端状态、跑一整套打包链，然后框架再把这些东西&quot;水合&quot;（hydrate）到服务器渲染出来的 HTML 上。水合本身就是一笔开销；于是又冒出 Server Components、island 划分这些机制来&lt;strong&gt;减少&lt;/strong&gt;水合——但这套东西一层套一层，边界和规则越来越多，全是为了调和&quot;客户端框架&quot;和&quot;服务器渲染&quot;这两股天然的张力。&lt;/p&gt;
&lt;p&gt;这就是我说的&quot;为了刷一面墙，把房子拆了&quot;。你想让首屏快一点、SEO 好一点，结果引入了一整个 hydration 机制，去救那个&lt;strong&gt;本可以不存在&lt;/strong&gt;的客户端运行时。htmx 走的是另一条路：&lt;strong&gt;根本不要客户端框架&lt;/strong&gt;，于是&quot;水合&quot;这件事压根不存在——HTML 从服务器来，就是它最终的样子，没有第二次初始化，没有两份状态的交接。&lt;/p&gt;
&lt;p&gt;（Astro 是这一族里最清醒的：默认零 JS、按需挂载 island。但它仍然要求你拥抱组件框架和构建链；而 htmx 的答案更彻底——连那个组件框架都不要。）&lt;/p&gt;
&lt;h3&gt;复古的不是工具，是心智模型&lt;/h3&gt;
&lt;p&gt;这套东西写起来确实有 aspx 和 php 的既视感。因为当年 ASPX 和 PHP 那一代人不是笨，他们是对的：&lt;strong&gt;服务器负责状态，HTML 负责表达。&lt;/strong&gt; 他们只是被工具拖累了：没有类型安全的模板，没有声明式的局部更新，没有一个真正可用的 CSS 系统，所以只好手写 &lt;code&gt;Response.Write&lt;/code&gt; 和 &lt;code&gt;echo&lt;/code&gt;，然后在全页回传的泥潭里挣扎。&lt;/p&gt;
&lt;p&gt;今天我们用同样的心智模型，但配上了 2026 年该有的工具：类型安全的模板、属性驱动的局部更新、构建期编译的 CSS。&lt;strong&gt;复古的不是工具，是&quot;服务器懂页面&quot;这个想法——而它从来没错过。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这些想法不是我发明的——Carson Gross 的《Hypermedia Systems》和 htmx 官网的 essays 专栏，早就把&quot;超媒体作为应用状态的引擎&quot;（HATEOAS）这件事讲得比我透彻得多。我在这里做的，只是把这些原则落进一个真实项目，然后诚实记录哪些地方顺利、哪些地方得补 JavaScript。文末附了深入阅读的链接。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;登录与登出：当表单变回真的表单&lt;/h2&gt;
&lt;p&gt;登录是&quot;传统 Web&quot;和 SPA 分歧最大的地方，也是最能说明&quot;什么时候用 htmx、什么时候不用&quot;的地方。&lt;/p&gt;
&lt;h3&gt;一个反直觉的决定：登录表单故意不用 htmx&lt;/h3&gt;
&lt;p&gt;我的登录表单就是一个朴素的 &lt;code&gt;&amp;lt;form method=&quot;post&quot;&amp;gt;&lt;/code&gt;，没有 &lt;code&gt;hx-*&lt;/code&gt; 属性。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;form method=&quot;post&quot; action=&quot;/login&quot; id=&quot;login-form&quot;&amp;gt;
    &amp;lt;label&amp;gt;Username
        &amp;lt;input name=&quot;username&quot; autocomplete=&quot;username&quot; required&amp;gt;
    &amp;lt;/label&amp;gt;
    &amp;lt;label&amp;gt;Password
        &amp;lt;input name=&quot;password&quot; type=&quot;password&quot; autocomplete=&quot;current-password&quot; required&amp;gt;
    &amp;lt;/label&amp;gt;
    &amp;lt;button type=&quot;submit&quot;&amp;gt;Log in&amp;lt;/button&amp;gt;
&amp;lt;/form&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;为什么？因为登录成功之后的动作——写入会话、跳转到仪表盘——&lt;strong&gt;天然就是一次完整的页面导航&lt;/strong&gt;，而不是一次局部片段替换。登录不是&quot;把这段 HTML 换成那段 HTML&quot;的操作，它是&quot;把我从匿名状态切换成已登录状态&quot;的身份转换；转换完成之后，整页都该重新渲染，而不是 swap 一个小片段。&lt;/p&gt;
&lt;p&gt;更实际的理由来自浏览器的行为。一次真正的表单提交会让密码管理器正确地对 &lt;code&gt;autocomplete=&quot;current-password&quot;&lt;/code&gt; 自动填充，会让浏览器认得&quot;这是一个登录表单&quot;。而且——这一点对登录至关重要——&lt;strong&gt;一个普通的 &lt;code&gt;&amp;lt;form&amp;gt;&lt;/code&gt; 在没有 JavaScript、或者 htmx 还没加载完成时，依然能工作&lt;/strong&gt;。登录是用户进入你系统的第一道门，你不希望这道门依赖一个刚加载的 JS 库才能打开。&lt;/p&gt;
&lt;p&gt;所以登录表单走&lt;strong&gt;真导航&lt;/strong&gt;。&lt;strong&gt;htmx 不是&quot;所有地方都要 htmx&quot;，而是&quot;在该用的地方用&quot;。&lt;/strong&gt; 登录这个场景，传统表单是更对的工具。&lt;/p&gt;
&lt;h3&gt;同一个端点，两种响应形态&lt;/h3&gt;
&lt;p&gt;但问题来了：同一个 &lt;code&gt;/login&lt;/code&gt; 端点，可能被浏览器导航调用，也可能被 htmx 调用（如果你在别处用 htmx 触发了它）。服务器怎么区分？&lt;/p&gt;
&lt;p&gt;答案是 &lt;code&gt;HX-Request&lt;/code&gt; 这个请求头。htmx 会在它发出的每个请求里带上 &lt;code&gt;HX-Request: true&lt;/code&gt;，服务器据此分支：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def login_redirect(headers, dest, cookie):
    if is_htmx(headers):
        # htmx 调用：200 + HX-Redirect，让浏览器做真导航
        return Response(200, {&quot;HX-Redirect&quot;: dest, &quot;Set-Cookie&quot;: cookie})
    else:
        # 普通浏览器导航：经典 303
        return Response(303, {&quot;Location&quot;: dest, &quot;Set-Cookie&quot;: cookie})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;两边最终都导向同一次真导航，区别只是触发方式不同。这个分支模式——&lt;strong&gt;根据 &lt;code&gt;HX-Request&lt;/code&gt; 决定响应形态&lt;/strong&gt;——会贯穿整个错误处理章节，是这套架构的基石之一。&lt;/p&gt;
&lt;h3&gt;权限分级：用提取器，而不是中间件&lt;/h3&gt;
&lt;p&gt;登录成功之后，就是权限。这个应用有三种角色，看到完全不同的页面。我的做法是把&quot;当前是谁、什么角色&quot;做成一个请求提取器（extractor），路由声明自己需要哪种身份：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class AdminPageAuth:
    # 从请求里解析出&quot;管理员身份&quot;，用于完整页面路由

    def from_request(state, request):
        match AdminAuth.from_request(state, request):
            case Ok(auth):
                return Ok(auth)
            case Err(Unauthorized(_)):
                # 未登录访问页面：303 到 /login，而不是一个 JSON 401
                return Err(Response(303, {&quot;Location&quot;: &quot;/login&quot;}))
            case Err(e):
                return Err(e.into_response())
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;同一个&quot;未授权&quot;，有两种响应形态&lt;/strong&gt;：浏览器访问页面时，它需要被导航到登录表单；而 API 客户端调用时，它需要的是一个 &lt;code&gt;401&lt;/code&gt; 加 JSON。原因很简单——浏览器需要的是页面，客户端需要的是结构化数据。提取器在这里做了正确的事：完整页面路由的未授权变成 303，API 路由的未授权变成 401 JSON，两者都源自同一个 &lt;code&gt;Unauthorized&lt;/code&gt; 错误。&lt;/p&gt;
&lt;p&gt;角色不对又是另一回事。一个志愿者访问管理员页面，正确的响应不是&quot;请登录&quot;，而是 &lt;code&gt;403&lt;/code&gt;——你已经登录了，只是没权限。这个区分（401 未登录 vs 403 无权限）在很多系统里被糊在一起，在这里被明确地分开了。&lt;/p&gt;
&lt;h3&gt;登出：就这么多&lt;/h3&gt;
&lt;p&gt;登出是一个 POST，服务器端吊销 token、清除 cookie，然后 303 回登录页。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def logout(state, request):
    if session_cookie(request):
        revoke_token(session_cookie(request))
    return Response(303, {&quot;Location&quot;: &quot;/login&quot;, &quot;Set-Cookie&quot;: clear_session_cookie()})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;就这么多。没有 &lt;code&gt;localStorage.removeItem(&apos;jwt&apos;)&lt;/code&gt;，没有 Redux reset，没有&quot;清除所有 client-side 缓存&quot;的仪式。因为状态本来就在服务器上，登出就是删掉服务器上的那条会话记录，然后清掉浏览器里的那个 cookie。&lt;/p&gt;
&lt;h3&gt;几个自然浮现的安全细节&lt;/h3&gt;
&lt;p&gt;这些细节是&quot;传统表单世界&quot;里非常自然的做法，却在 SPA + JWT 的世界里反而会变得别扭：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;HttpOnly cookie&lt;/strong&gt;：会话 token 放在 HttpOnly cookie 里，JavaScript 根本读不到，XSS 也偷不走。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;密码哈希在后台线程做&lt;/strong&gt;：argon2 这种哈希要跑约半秒，绝不能阻塞事件循环，丢进一个后台任务里。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;速率限制按 (用户名, IP) 维度&lt;/strong&gt;：防暴力破解，限制器在哈希之前先拦一道。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;人机验证用显式渲染&lt;/strong&gt;：因为登录是纯表单（真导航），所以 CAPTCHA 组件要每次 fresh render、token 单次消费——这正好配合&quot;每次失败都重新渲染登录页&quot;的流程。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这些东西没有任何一个是 htmx 的专属功能，但它们都&lt;strong&gt;因为回到了真表单的模型而变得顺理成章&lt;/strong&gt;。&lt;/p&gt;
&lt;h3&gt;CSRF：旧威胁，旧解法&lt;/h3&gt;
&lt;p&gt;用 session cookie 做鉴权，就必须重新面对一个老朋友：CSRF。攻击者诱导用户的浏览器，在不知情的情况下向你的站点发出一个携带 session cookie 的状态变更请求——因为你用的是 cookie，浏览器会自动把它带上。&lt;/p&gt;
&lt;p&gt;SPA + JWT 的世界里，这个问题基本被绕开了：token 放在 localStorage 里，由 JavaScript 手动塞进请求头，浏览器不会自动发送。但请注意，这只是把风险挪了地方——XSS 现在能直接偷走那个 token。而 cookie + HttpOnly 的路线里，token 是 JavaScript 读不到的，XSS 偷不走它——当然，XSS 仍能借受害者的已登录会话代为操作，只是拿不走凭证本身。代价是你得正面处理 CSRF。&lt;/p&gt;
&lt;p&gt;好消息是，CSRF 的解法又老又成熟，而且因为表单是服务器渲染的，落地起来格外自然：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;SameSite 属性&lt;/strong&gt;：给 cookie 标上 &lt;code&gt;SameSite=Lax&lt;/code&gt;（现代浏览器的默认值）或 &lt;code&gt;Strict&lt;/code&gt;，浏览器就不会在跨站请求里带上它——这已经挡住了绝大多数 CSRF。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CSRF token&lt;/strong&gt;：一个随会话生成的密钥，服务器渲染表单时把它写进隐藏字段，提交时校验。因为是服务器渲染，塞进这个字段就是一行模板变量的事，不需要任何客户端逻辑。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以这又是一个&quot;回到传统表单世界反而更省事&quot;的地方：威胁是旧的，解法也是旧的，而且两者都已经被验证了几十年。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;错误处理：htmx 最深的坑，也是最有价值的契约&lt;/h2&gt;
&lt;p&gt;如果你只想从这篇文章里带走一件事，那就是这一节。因为 htmx 的错误处理藏着一个非常反直觉的坑——它会让你的应用在某些时刻&lt;strong&gt;静默地失败&lt;/strong&gt;，而用户看到的只是一个什么都没发生的按钮。你一旦理解了这个问题，也就同时理解了这套架构里最优雅的部分：错误不再是一个散落在前端各处的杂务，而是一份可以放在一个函数里、被所有端点共享的契约。&lt;/p&gt;
&lt;h3&gt;那个坑：htmx 只 swap 成功响应&lt;/h3&gt;
&lt;p&gt;先想象一个最普通的场景。页面上有一张卡片，上面是一个表单，用户点下&quot;确认&quot;按钮，期待卡片被更新。在 SPA 里，你的 &lt;code&gt;fetch&lt;/code&gt; 会拿到一个 Promise，不管是成功还是失败，你都能在 &lt;code&gt;.catch&lt;/code&gt; 里做点什么。但在 htmx 里，流程是这样的：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;htmx 拦截表单提交，发一个 XHR 请求到服务器。&lt;/li&gt;
&lt;li&gt;服务器返回响应。&lt;/li&gt;
&lt;li&gt;htmx 拿响应的 body 去替换你指定的目标元素。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;关键在第三步。htmx 的默认行为是：&lt;strong&gt;只有 2xx 的响应才会被 swap 进页面&lt;/strong&gt;。如果你的端点返回了一个 400 或者 500，htmx 会触发一个 &lt;code&gt;htmx:responseError&lt;/code&gt; 事件，然后……什么都不做。原来的 DOM 原封不动，你的错误信息躺在响应的 body 里，只有在浏览器的 devtools 网络面板里才能看到。&lt;/p&gt;
&lt;p&gt;对于一个&quot;用户点了按钮、然后盯着屏幕等反馈&quot;的表单，这是灾难性的。没有红色提示，没有 toast，甚至没有控制台报错。用户会以为按钮坏了，再点一次，又点一次——每次服务器都在正确地拒绝这个请求，但每次拒绝都消失在空气里。&lt;/p&gt;
&lt;p&gt;问题的根源在于：&lt;strong&gt;htmx 把&quot;错误&quot;默认当成了一件不该呈现给用户的事&lt;/strong&gt;。这个默认值适合那种&quot;后台轮询失败了就安静地重试&quot;的场景，却完全不适合&quot;用户在表单里填错了一个字段&quot;的场景。&lt;/p&gt;
&lt;h3&gt;那个解法：双形态响应&lt;/h3&gt;
&lt;p&gt;所以我给自己定了一条规则，覆盖所有的 htmx 片段端点：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;对 htmx 调用方，永远返回 200，把结果——无论成功还是失败——放进响应体里；对其他人，返回真实的状态码。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;拆开来看，这条规则有两个分支。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;成功的时候&lt;/strong&gt;，返回 200，响应体就是要替换的那段新 HTML，再附带一个 &lt;code&gt;HX-Trigger&lt;/code&gt; 头，通知浏览器弹一个成功的 toast。用户看到卡片变了，右上角还闪过一句&quot;已保存&quot;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;失败的时候&lt;/strong&gt;，仍然返回 200，但响应体里带一段&quot;出错了&quot;的提示，同时用一个 &lt;code&gt;HX-Reswap: none&lt;/code&gt; 头告诉 htmx &lt;strong&gt;不要动目标元素&lt;/strong&gt;。这样一来，卡片不会被清空，用户填的内容还在，而错误信息通过一个 out-of-band（OOB）的补丁单独写到页面的 toast 容器里。&lt;/p&gt;
&lt;p&gt;而&lt;strong&gt;非 htmx 的调用方&lt;/strong&gt;——没有 JavaScript 的浏览器、直接调 API 的客户端、跑测试的代码——它们根本不该知道 toast 这种东西存在。它们要的就是一个正经的 HTTP 状态码和一个结构化的 JSON 错误体。&lt;/p&gt;
&lt;p&gt;整个机制的核心，是把&quot;错误怎么呈现&quot;这个决策收敛到一个函数里：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def form_err(headers, error):
    if is_htmx(headers):
        # htmx 片段请求：永远 200，保留原 DOM，错误走 toast
        return Response(
            status=200,
            headers={
                &quot;HX-Trigger&quot;: toast_trigger(error.message, &quot;error&quot;),
                &quot;HX-Reswap&quot;: &quot;none&quot;,
            },
            body=toast_oob(error.message, &quot;error&quot;),   # 一段 OOB 的 #toast HTML
        )
    else:
        # 普通调用方：真实状态码 + JSON 错误体
        return error.into_response()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;失败分支返回的 200 是&lt;strong&gt;故意&lt;/strong&gt;的，而不是偷懒。正是因为 htmx 只 swap 2xx，我们才需要把&quot;失败&quot;伪装成一个&quot;成功拿到的响应&quot;，让 htmx 愿意把它交给页面去处理。真正的失败信息通过 &lt;code&gt;HX-Trigger&lt;/code&gt; 这个头，而不是状态码来传递。&lt;/p&gt;
&lt;h3&gt;一个可能的反驳：200 违反 HTTP 语义吗？&lt;/h3&gt;
&lt;p&gt;看到&quot;永远返回 200&quot;这条规则，一定会有人皱眉：这不是在说谎吗？错误就是错误，凭什么用 200 包装？&lt;/p&gt;
&lt;p&gt;这其实是把两件不同的事混在了一起。关键要分清：&lt;strong&gt;这个请求要的&quot;资源&quot;是什么。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;对一个 htmx 片段请求来说，客户端要的不是&quot;这次操作成功与否&quot;这个事实，而是一段&lt;strong&gt;能放进那个 div 里的 HTML&lt;/strong&gt;。服务器确实成功地生产出了这段 HTML——只不过这段 HTML 渲染出来的是一句错误提示。从这个角度看，200 是完全诚实的：请求成功了，响应体就是&quot;渲染好的错误 DOM&quot;。真正的业务错误（&quot;字段填错了&quot;这类）则通过 &lt;code&gt;HX-Trigger&lt;/code&gt;（toast 消息）在带内传递。&lt;/p&gt;
&lt;p&gt;而真正需要状态码语义的地方——API 客户端、没有 JS 的浏览器、测试、监控——我们&lt;strong&gt;没有&lt;/strong&gt;破坏它们：这些调用方走的是另一条分支，拿到的是真实的 400/401/422 加 JSON。所以 HTTP 语义在它该生效的地方一分不少，只在&quot;客户端明确要 HTML 片段&quot;这一种情况里做了让步。&lt;/p&gt;
&lt;p&gt;我得把话说准确：这&lt;strong&gt;不是&lt;/strong&gt;在声称 HTTP 语义作废——404、409、422 这些状态码本来就是为业务结果准备的，它们也仍在错误枚举里照常用着。我们只是对&quot;客户端要一段 HTML 片段&quot;这一种情况做了一次务实的让步：htmx 只 swap 2xx（除非你调整 &lt;code&gt;htmx.config.responseHandling&lt;/code&gt;），所以把&quot;业务失败了&quot;放进响应体、而不是状态码。另一条同样合理的路是监听 &lt;code&gt;htmx:responseError&lt;/code&gt;、把真实的 4xx 响应体也 swap 进去；我们只是挑了更直白的这一条。&lt;/p&gt;
&lt;h3&gt;一个意想不到的细节：Latin-1 头&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;HX-Trigger&lt;/code&gt; 这个头看着简单，实际用起来有一个坑会咬你一口——而且它只在非英文内容上才会暴露。&lt;/p&gt;
&lt;p&gt;浏览器底层用 XHR 来发请求，而 XHR 拿到的响应头会被按 &lt;strong&gt;Latin-1（ISO-8859-1）&lt;/strong&gt; 解码。这意味着如果你在 &lt;code&gt;HX-Trigger&lt;/code&gt; 里塞了一段 UTF-8 文本——比如一句中文提示，或者一个 en-dash 字符——它到达浏览器的时候会变成乱码。一个 &lt;code&gt;Zone 60–120 bpm&lt;/code&gt; 会变成 &lt;code&gt;Zone 60â120 bpm&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;我一开始完全没意识到这件事，直到某条包含非 ASCII 字符的 toast 在浏览器里显示成乱码。调试了好一会儿才定位到：不是我的服务器编码错了，是响应头这条通道本身就只能安全地承载 ASCII。&lt;/p&gt;
&lt;p&gt;解法很直接：把 &lt;code&gt;HX-Trigger&lt;/code&gt; 里的 JSON 序列化之后，把所有非 ASCII 字符转义成 &lt;code&gt;\uXXXX&lt;/code&gt; 形式。结构化的 JSON 字符（引号、冒号、花括号）本来就是 ASCII，所以这个转换是安全的；而客户端用 &lt;code&gt;JSON.parse&lt;/code&gt; 解析的时候，会自动把 &lt;code&gt;\uXXXX&lt;/code&gt; 还原成真正的字符。乱码消失了，服务器发出去的东西和客户端解析出来的东西分毫不差。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 服务器端：任何非 ASCII 字符都转成 \uXXXX
def toast_trigger(message, kind):
    payload = json.dumps({&quot;toast&quot;: {&quot;msg&quot;: message, &quot;kind&quot;: kind}})
    return ascii_safe(payload)   # &quot;Zone 60\u2013120 bpm \u00b7 done&quot;

# 客户端：JSON.parse 自动还原
# &quot;Zone 60–120 bpm · done&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个细节代表的，其实是这类架构里一整个类别的经验：&lt;strong&gt;你不再和框架的抽象层搏斗，而是和 HTTP 本身的真实约束搏斗&lt;/strong&gt;。这些约束是真实的、稳定的、可测试的，而一旦你理解了它们，你的代码就再也不会有这一类 bug。&lt;/p&gt;
&lt;h3&gt;一份单一的来源：错误枚举&lt;/h3&gt;
&lt;p&gt;当错误处理被收敛到 &lt;code&gt;form_err&lt;/code&gt; 一个地方之后，你自然会产生下一个需求：让&quot;错误&quot;这个概念本身也有一个单一的来源。&lt;/p&gt;
&lt;p&gt;在 SPA 里，错误往往是散装的——前端有前端的错误常量，后端有后端的异常类，中间靠 HTTP 状态码这一层薄薄地对接。而在服务端渲染的架构里，你可以让一个枚举（或者一个异常层级）同时决定三件事：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;错误码&lt;/strong&gt;（&lt;code&gt;bad_request&lt;/code&gt;、&lt;code&gt;unauthorized&lt;/code&gt;、&lt;code&gt;not_found&lt;/code&gt;、&lt;code&gt;conflict&lt;/code&gt;……）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;HTTP 状态码&lt;/strong&gt;（400、401、404、409……）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;给用户看的人类可读信息&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;下面这个简化的枚举就是这种思路的写照：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;enum AppError:
    case BadRequest(message)
    case Unauthorized(message)
    case Forbidden(message)
    case NotFound(message)
    case RateLimited(message)
    case Internal(message)

    def code(self):
        # &quot;bad_request&quot;, &quot;unauthorized&quot;, ...
    def status(self):
        # 400, 401, 403, 404, 429, 500
    def message(self):
        # 人类可读的信息
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;于是整条链路就通了：一个 handler 里任何地方 &lt;code&gt;raise NotFound(&quot;no such session&quot;)&lt;/code&gt;，它就会自动变成——对 htmx 一个红色 toast、对 API 一个 &lt;code&gt;404&lt;/code&gt; 加 &lt;code&gt;{&quot;type&quot;: &quot;error&quot;, &quot;code&quot;: &quot;not_found&quot;, &quot;message&quot;: &quot;no such session&quot;}&lt;/code&gt;、对无 JS 的浏览器一个重渲染的 404 页面。&lt;strong&gt;三种出口，一个定义。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;内部错误和用户错误必须被区别对待。&lt;code&gt;Internal&lt;/code&gt; 这类错误里可能藏着数据库的细节、栈信息、内部路径——这些东西&lt;strong&gt;永远不该&lt;/strong&gt;出现在响应体里。正确的做法是在响应边界处把它们拦住：记录日志（让 &lt;code&gt;Internal&lt;/code&gt; 的细节进日志），然后只给客户端一句笼统的 &lt;code&gt;&quot;internal error&quot;&lt;/code&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def into_response(self):
    status = self.status()
    if self is Internal(detail):
        log.error(detail)              # 细节只进日志
        message = &quot;internal error&quot;     # 客户端只看得到这句
    else:
        message = self.message()
    return Response(status, json({&quot;type&quot;: &quot;error&quot;, &quot;code&quot;: self.code(), &quot;message&quot;: message}))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这一行 &lt;code&gt;log.error(detail)&lt;/code&gt; 放在响应边界，还有一个附带的好处：即使某个 handler 忘了在出错时打日志，只要它返回了 &lt;code&gt;Internal&lt;/code&gt;，日志里就一定会有一条记录。你把&quot;内部错误一定要被记录&quot;这件纪律性的事，从&quot;每个开发者都要记得&quot;变成了&quot;架构自动保证&quot;。&lt;/p&gt;
&lt;h3&gt;为什么这套东西在这里成立&lt;/h3&gt;
&lt;p&gt;回头看你可能会问：这些东西 SPA 里不是也能做吗？&lt;code&gt;fetch&lt;/code&gt; 的 &lt;code&gt;.catch&lt;/code&gt; 里弹个 toast 不就行了？&lt;/p&gt;
&lt;p&gt;当然能。区别不在于能不能做，而在于&lt;strong&gt;做多少次&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;在 SPA 里，错误处理是每个调用点各自的事。你有一个全局的拦截器，但每个组件仍然要决定自己失败时的样子——loading 态、error 态、重试按钮、边界组件。错误是前端应用里最容易悄悄漏掉的一块。&lt;/p&gt;
&lt;p&gt;而在 htmx 架构里，错误的呈现被推回了服务器。服务器&lt;strong&gt;本来就知道&lt;/strong&gt;发生了什么错、错到什么程度、该给用户看什么。而 htmx 的响应契约让你能把这个知识&lt;strong&gt;一次性地&lt;/strong&gt;编码进 &lt;code&gt;form_err&lt;/code&gt; 和 &lt;code&gt;AppError&lt;/code&gt; 两个地方，然后每一个端点——不管是编辑卡片、批量分配、还是删除记录——都自动继承同一套行为。&lt;/p&gt;
&lt;p&gt;这其实触及了这套架构最根本的哲学：&lt;strong&gt;把关于&quot;状态&quot;和&quot;错误&quot;的决策留在服务器端，让 HTML 只负责表达结果。&lt;/strong&gt; 错误处理之所以重要，是因为它是这个哲学第一次变得具体、变得可触摸的地方。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;实时更新：SSE 而不是 WebSocket&lt;/h2&gt;
&lt;p&gt;仪表盘里最难的一环是实时性。卡片要几秒刷新一次，反映现场设备上传的数据。SPA 的标准答案是 WebSocket + 一个客户端渲染层。而在这里，答案简单得让人怀疑自己是不是漏了什么。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;body hx-ext=&quot;sse&quot;&amp;gt;
    &amp;lt;div id=&quot;grid&quot; sse-connect=&quot;/stream&quot; sse-swap=&quot;snapshot&quot;&amp;gt;
        {{ cards|safe }}
    &amp;lt;/div&amp;gt;
&amp;lt;/body&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;htmx 的 SSE 扩展维护一条到 &lt;code&gt;/stream&lt;/code&gt; 的服务端推送连接。&lt;code&gt;sse-swap=&quot;snapshot&quot;&lt;/code&gt; 声明的是：服务器会推送一个名为 &lt;code&gt;snapshot&lt;/code&gt; 的事件，事件的数据体就是要替换进 &lt;code&gt;#grid&lt;/code&gt; 的 HTML。所以服务器这一侧推送的&lt;strong&gt;不是 JSON 数据，而是已经渲染好的卡片 HTML&lt;/strong&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;event: snapshot
data: &amp;lt;div id=&quot;grid&quot;&amp;gt;...完整的卡片 HTML...&amp;lt;/div&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;客户端拿到就 swap。当实时数据只是&quot;服务器状态的投影&quot;时，渲染这件事应该发生在拥有状态的那一侧——服务器——而不是在一个需要先重建状态的浏览器里再做一遍。这样你就&lt;strong&gt;少了一层客户端渲染&lt;/strong&gt;：不再有&quot;收到 JSON → 更新 store → 触发重渲染&quot;这条链路，只有&quot;收到 HTML → 替换 DOM&quot;。&lt;/p&gt;
&lt;p&gt;代价在服务器这一侧：你得管理这条长连接的生命周期——谁在听、连接断了要不要清掉、数据一变怎么广播给所有监听的客户端。这听起来吓人，但本质就是一个&quot;订阅者列表 + 一个广播循环&quot;，是每个后端框架都能做的老把戏，几十行就够（生产上当然还要处理慢消费者、断线重连的雪崩这些边角，但那是另一个话题）。而且它只做&lt;strong&gt;单向&lt;/strong&gt;——数据从服务器流向浏览器，任何客户端主动发起的命令（比如编辑一张卡片）仍然走普通的 HTTP POST，实时通道不承担&quot;往回传指令&quot;的职责。&lt;/p&gt;
&lt;h3&gt;为什么是 SSE 而不是 WebSocket&lt;/h3&gt;
&lt;p&gt;因为实时更新在这个应用里是&lt;strong&gt;单向&lt;/strong&gt;的——数据从服务器流向浏览器，浏览器几乎不往回发指令。SSE 就是为这个形状设计的：它是 HTTP 之上的一条长连接，&lt;code&gt;EventSource&lt;/code&gt; 会自动重连，连接天然携带同源 cookie，所以它能无缝地复用你现有的 HTTP 鉴权——一个已经登录的用户，它的会话 cookie 会自动跟着 SSE 连接走，不需要额外的认证步骤。&lt;/p&gt;
&lt;p&gt;WebSocket 的代价在于，它把&quot;双向&quot;这个你根本用不上的能力，作为你必须支付的成本塞了回来：断了不会自动重连，你要自己实现重试和心跳；它虽然同样是从 HTTP 握手升级而来（所以也能带 cookie），但它更像一条独立的双向管道，和&quot;请求-响应-推送&quot;这套 HTTP 生命周期是两张皮。当你只需要一条单向的推送流时，WebSocket 是杀鸡用牛刀。&lt;/p&gt;
&lt;h3&gt;诚实一点：DOM 也是状态&lt;/h3&gt;
&lt;p&gt;我在第二节说&quot;状态只有一个&quot;。这句话被理想化了。&lt;/p&gt;
&lt;p&gt;因为 DOM 本身就是第二份状态。用户正在一张卡片里填表单、展开了一个下拉框、焦点停在某个输入框里——这些都不是服务器知道的事，但它们是真真切切、用户正在持有的状态。这时候服务器推来一个 &lt;code&gt;snapshot&lt;/code&gt;，把整个 &lt;code&gt;#grid&lt;/code&gt; 换掉，用户的输入、展开的菜单、焦点，全都没了。比一张&quot;过期的卡片&quot;糟糕得多。&lt;/p&gt;
&lt;p&gt;所以并发写入、多标签页、后退按钮这些&quot;隐性的双状态&quot;问题，并没有在服务端渲染架构里消失——它们只是换了形态。诚实一点的说法是：状态同步这件事，从&quot;框架帮你管&quot;变成了&quot;你自己用 HTTP 原语管&quot;。SSE 事件、&lt;code&gt;hx-swap&lt;/code&gt;、OOB 补丁，就是你的原语；而&quot;什么时候该让推送让步于用户正在做的事&quot;，是你的责任，框架不会替你判断。&lt;/p&gt;
&lt;p&gt;我们是这样管的：&lt;strong&gt;swap 的时候，跳过用户正在编辑的卡片。&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 一张卡片&quot;正在被编辑&quot;，如果满足任一条件：
def card_is_editing(card):
    focus = document.active_element
    if focus 落在 card 内，且 focus 是输入框（input / textarea / select）:
        return true
    if card 内有一个打开的编辑表单:
        return true
    return false
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;于是每条 &lt;code&gt;card&lt;/code&gt; 事件到达时，先检查目标卡片是否在编辑——是，就 &lt;code&gt;preventDefault()&lt;/code&gt;，跳过这次 swap，让那一秒的更新作废。而整网的 &lt;code&gt;snapshot&lt;/code&gt; 则更谨慎：只要有&lt;strong&gt;任何一张&lt;/strong&gt;卡片在编辑，就把这次 snapshot 暂存起来，等编辑结束再应用——代价是编辑期间整张网格都跟着停在旧状态。广播是一秒一拍，所以在编辑的卡片不会永远停在旧状态：编辑一结束，下一拍就带来一张新鲜的。&lt;/p&gt;
&lt;p&gt;你确实要为此写一小段 JavaScript，专门在&quot;实时推送&quot;和&quot;用户在做的交互&quot;之间做仲裁。它不优雅，但它诚实——它承认了 DOM 是状态，然后显式地决定这两份状态谁在什么时候让谁。这正是这套架构真正的样子：没有魔法，只有把 HTTP 原语一件一件摆清楚。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;岛屿：当一块页面真的需要&quot;活着&quot;&lt;/h2&gt;
&lt;p&gt;写到这里，一个读者会问：这一切都很好，但我要一个真正的交互式组件怎么办——带标签切换、能实时刷新的图表，那种点一下 tab 就换一组数据、鼠标划过能看 tooltip 的东西？htmx 的局部替换能处理表单和列表，但处理不了&quot;浏览器里有一个需要自己管理状态的 widget&quot;。&lt;/p&gt;
&lt;p&gt;答案是承认它，然后给它划出一块小小的、自包含的&quot;岛屿&quot;（island）。这不是对前面论证的背叛，而是把那条界线画清楚。&lt;/p&gt;
&lt;h3&gt;大多数页面是服务器渲染的，少数不是&lt;/h3&gt;
&lt;p&gt;在我的仪表盘里，绝大多数页面遵循同一套规则：服务器渲染 HTML，htmx 负责局部更新。但有一处例外——参与者的历史详情页，里面有一张图表。它有&quot;本次会话 / 跨会话&quot;两个 tab、一个实时跳动的帧计数、一条汇总行，还有一张 Chart.js 画的折线图。&lt;/p&gt;
&lt;p&gt;这个 widget 需要一种服务器渲染很难提供的东西：&lt;strong&gt;客户端状态&lt;/strong&gt;。用户点 tab，图表切换数据源，但服务器并不需要知道&quot;当前显示哪个 tab&quot;——这是纯呈现层的状态，和服务器无关。强行用 htmx 去做，每切一次 tab 都要服务器往返一次，为了一个本地 UI 状态去打断用户体验，得不偿失。&lt;/p&gt;
&lt;p&gt;所以这张图表是一座岛屿：页面上一个自包含、带自己运行时的小块，周围的一切仍然是服务器渲染的。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
    Page[&quot;详情页（服务器渲染）&quot;]
    Page --&amp;gt; Header[&quot;头部与静态字段&quot;]
    Page --&amp;gt; Panel[&quot;#detail-panel 表单区&amp;lt;br/&amp;gt;（htmx 局部替换）&quot;]
    Page --&amp;gt; Island[&quot;历史图表 island&amp;lt;br/&amp;gt;（petite-vue + Chart.js）&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;div
    v-scope=&quot;HistoryChart({ pid: 42, cutoff: 0.6 })&quot;
    @vue:mounted=&quot;start&quot;
    @vue:unmounted=&quot;stop&quot;
&amp;gt;
    &amp;lt;button @click=&quot;setTab(&apos;session&apos;)&quot; :disabled=&quot;tab === &apos;session&apos;&quot;&amp;gt;This session&amp;lt;/button&amp;gt;
    &amp;lt;button @click=&quot;setTab(&apos;all&apos;)&quot; :disabled=&quot;tab === &apos;all&apos;&quot;&amp;gt;Across sessions&amp;lt;/button&amp;gt;
    &amp;lt;span v-if=&quot;headerCode&quot;&amp;gt;{{ headerCode }} · {{ frameCount }} frames&amp;lt;/span&amp;gt;
    &amp;lt;canvas&amp;gt;&amp;lt;/canvas&amp;gt;
&amp;lt;/div&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里我用的是 petite-vue——一个 6KB、无构建步骤的迷你 Vue，专为&quot;给一小块 HTML 加上反应性&quot;而生。它不是第二套完整框架，只是一个让岛屿内部能响应用户输入的小工具。&lt;/p&gt;
&lt;h3&gt;岛屿仍然尊重核心原则&lt;/h3&gt;
&lt;p&gt;岛屿里可以有客户端状态，但&lt;strong&gt;数据仍然来自服务器&lt;/strong&gt;。这张图表初始化时用同源 fetch 拉历史记录，带的是现有的会话 cookie——鉴权没有任何额外复杂度。而它的实时性走的是上一节讲的那条 EventSource（SSE）——同一条流上承载着多个命名事件：网格收到 &lt;code&gt;snapshot&lt;/code&gt; 去换 HTML，岛屿收到 &lt;code&gt;card&lt;/code&gt; 就重新拉取自己的数据。数据仍然只有一个来源，岛屿只是那个来源的一个活跃视图。&lt;/p&gt;
&lt;p&gt;这是一条分界线：岛屿持有的是&lt;strong&gt;短暂的、呈现层的状态&lt;/strong&gt;——哪个 tab 被选中、图表当前画到哪一帧。服务器的状态——记录本身、质量阈值、哪些帧有效——仍然是唯一的事实来源，仍然由服务器拥有。你引进了一个小小的运行时，但没有引进第二份需要同步的真相。&lt;/p&gt;
&lt;h3&gt;岛屿必须住在 swap 之外&lt;/h3&gt;
&lt;p&gt;岛屿还有一个容易踩的纪律：&lt;strong&gt;它不能待在 htmx 的 swap 目标里。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;原因在挂载时机。petite-vue 的 &lt;code&gt;createApp().mount()&lt;/code&gt; 只在页面加载时执行一次，它编译的是那一刻 DOM 里已经存在的 &lt;code&gt;[v-scope]&lt;/code&gt; 元素。而 htmx 是之后才把新 HTML swap 进页面的——如果这段 swap 进来的 HTML 里含有一个 &lt;code&gt;v-scope&lt;/code&gt;，petite-vue 根本不知道它的存在，它只是一堆不会被响应式化的死标签。想让它活过来，你就得在每个 swap 之后手动重新挂载，同时还得自己处理上一次挂载的销毁、事件监听和 EventSource 的释放。这条路一旦走进去，你就把两套运行时缝合在了一起，复杂度会报复性地涨回来。&lt;/p&gt;
&lt;p&gt;所以实际操作里，swap 目标和岛屿在 DOM 里是&lt;strong&gt;并列的兄弟，而不是嵌套的父子&lt;/strong&gt;。回到那张详情页：上面是一个 &lt;code&gt;#detail-panel&lt;/code&gt; 的 swap 目标，里面是纯表单——设置区间、分配动作，这些走 htmx 局部替换；下面是一张图表的 island，它不靠 htmx 换，而是靠自己的同源 fetch 加 EventSource 刷新自己。两条更新通道各管各的，互不嵌套。&lt;/p&gt;
&lt;p&gt;这条纪律和上一节是同一件事的两个面：岛屿的数据不来自 htmx 的响应体，而来自它自己拉的那条线。正因为这样，它才能安心地待在 swap 之外，不需要被任何局部替换连根拔起。&lt;/p&gt;
&lt;h3&gt;一个熟悉的代价&lt;/h3&gt;
&lt;p&gt;岛屿不是免费的。你重新引入了客户端状态、一个手写的 JS 文件、以及第二个运行时（petite-vue）。&quot;SPA 的成本&quot;回来了，只是被严格限制在了一个几十行的小方块里，而不是弥漫到整个应用。&lt;/p&gt;
&lt;p&gt;还有那种只有踩过才会知道的坑。petite-vue 会把作用域里的每个属性深度包装成 Proxy 来做反应性，这通常很好；但如果你把 Chart.js 的实例或 EventSource 放进响应式作用域，它们的内部槽位会被 Proxy 破坏，方法调用直接报错。解法是把这些实例放在闭包局部变量里，让它们对反应性系统不可见。这和前面那个 Latin-1 响应头是同一类经验：你搏斗的对象从&quot;框架的抽象&quot;变成了&quot;某个具体机制的边界&quot;，而后者一旦理解，就再也不会出错。&lt;/p&gt;
&lt;h3&gt;岛屿架构，而不是&quot;全有或全无&quot;&lt;/h3&gt;
&lt;p&gt;如果你熟悉 Astro，你会在&quot;岛屿架构&quot;这个词里认出这个模式。区别只是：Astro 把它做成了框架的一等公民，而这里是手搓的、更小的一撮。两者背后的判断是同一个——&lt;strong&gt;决定哪些东西需要客户端运行时，是一个一个组件做的，而不是整个应用一起做的。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;最重要的判断，其实是这个：htmx 的价值从来不在&quot;拒绝 JavaScript&quot;，而在于&quot;把 JavaScript 的使用推迟到真正需要它的地方&quot;。绝大多数交互——一个表单、一次列表刷新、一个错误提示——用 HTML 和 HTTP 就够；只有当你遇到一个真正需要自己管理状态的 widget 时，才划出一座岛屿。默认服务器渲染，按需引入运行时，而不是反过来。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;什么时候这个模式是对的&lt;/h2&gt;
&lt;p&gt;我不想把它写成一封 htmx 的劝退信或安利信。它是工具，工具要放在对的场景里。这张清单是我的判断：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;这个模式适合你，当：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;应用本质上是&quot;服务器状态的多个视图&quot;——仪表盘、后台管理、内容展示。&lt;/li&gt;
&lt;li&gt;表单密集、CRUD 主导。&lt;/li&gt;
&lt;li&gt;实时性以推送为主，不在客户端做复杂交互。&lt;/li&gt;
&lt;li&gt;团队后端强于前端，或者你根本不想维护两套代码和两条构建链。&lt;/li&gt;
&lt;li&gt;你希望&quot;登出就真的登出了&quot;，&quot;状态就真的只有一份&quot;。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;这个模式不适合你，当：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;需要离线优先。&lt;/li&gt;
&lt;li&gt;客户端有复杂的本地状态机（拖拽、富文本编辑、协作编辑）。&lt;/li&gt;
&lt;li&gt;页面本身就是一个本地应用，而不是服务器状态的投影。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;在这些场景里，浏览器确实是一个运行时，SPA 就是正解。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;结语：那个心智模型一直是对的&lt;/h2&gt;
&lt;p&gt;ASPX 和 PHP 那一代人不是笨，只是合适的工具还没有发展出来。他们的直觉——&lt;strong&gt;服务器负责状态，HTML 负责表达&lt;/strong&gt;——从头到尾都没错，错的是他们手里的工具：没有类型安全的模板，没有声明式的局部更新，没有真正可用的 CSS 系统，没有一条干净的实时推送通道。&lt;/p&gt;
&lt;p&gt;htmx 补上了局部更新，模板引擎补上了类型安全，Tailwind 补上了样式，SSE 补上了实时。当这些工具到齐了，那个旧的心智模型突然又变得现代了——不是因为世界倒退回了 2005 年，而是因为&quot;服务器懂页面&quot;这个想法，一直在那里等着被重新兑现。&lt;/p&gt;
&lt;p&gt;如果你也曾经在 &lt;code&gt;node_modules&lt;/code&gt; 的深处、在两条路由表之间、在缓存失效的泥潭里感到过疲惫，也许值得回头看一眼。你会发现，你并不是在往后退，而是在找回一条本来就对的路。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;进一步阅读&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;《Hypermedia Systems》——Carson Gross、Adam Stepinski、Denis Pashevsky 著，免费在线阅读：hypermedia.systems。这本书是上面所有想法的系统论述，比这篇文章完整得多。&lt;/li&gt;
&lt;li&gt;htmx 官网的 essays 专栏（htmx.org/essays）——&quot;超媒体作为应用状态的引擎&quot;（HATEOAS）、什么时候该用超媒体、什么时候该用 SPA，那里有更严谨的讨论。&lt;/li&gt;
&lt;li&gt;htmx 官方文档（htmx.org/docs）——本文提到的 &lt;code&gt;hx-swap&lt;/code&gt;、&lt;code&gt;HX-Trigger&lt;/code&gt;、SSE 扩展等所有属性的权威参考。&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>准确性让位于理解：为什么我重新准备了一份深度学习教程</title><link>https://blog.ansonate.ink/posts/accuracy-yields-to-understanding/</link><guid isPermaLink="true">https://blog.ansonate.ink/posts/accuracy-yields-to-understanding/</guid><description>为什么不用现成教材、为什么先讲黑话后讲定义：一份给初学者的教程，和它背后的教学观。</description><pubDate>Thu, 11 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;::card{url=&quot;https://github.com/ulink-deep-learning-club/ulink-deeplearningclub-handouts&quot; site=&quot;GitHub&quot; title=&quot;ulink-deeplearningclub-handouts&quot; desc=&quot;深度学习社教程的源码：Sphinx + Markdown，含全部章节与这篇后记。&quot;}&lt;/p&gt;
&lt;p&gt;&lt;em&gt;这篇原本是那份教程的「写在后面」。教程的线上版本在&lt;a href=&quot;https://ulink-deep-learning-club.github.io/ulink-deeplearningclub-handouts/&quot;&gt;这里&lt;/a&gt;。&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;致来者：&lt;/p&gt;
&lt;p&gt;常有人问我，为什么要为社团活动重新准备这些资料，而不是直接使用现成的教材——这看起来像是在“重新发明轮子”。说实话，我自己也没有一个非常确定的答案。这些文档在我看来，更像是一页页的 PPT，而非信息完备的教程。&lt;/p&gt;
&lt;p&gt;理论上，我当然可以直接选用几本深度学习领域的经典著作，比如斋藤康毅老师的“鱼书”。但如果只是像学校上课那样照本宣科，对于纯粹由兴趣驱动的社团来说，恐怕不太合适。这样做很可能导致参与人数随时间“指数级衰减”。我个人对这种单向灌输的模式并不认同——它更像是在把整理与消化知识的负担，从备课者转移给听讲者，效率甚至不如鼓励大家自学。&lt;/p&gt;
&lt;p&gt;但避免糟糕的单向灌输算不上什么真正的动机，这更像是一种妥协，就像做 presentation 必须准备 slides 一样。直到 Bob 老师告诉我“搞完这一年社团，总得留下点什么”，创作的动机才真正落地。回忆大约两年前我研究 YOLOv5 时，论文和教程中出现的各种黑话总是令人困惑。他们既没有解释这些是什么，也没有解释为什么——这些黑话被以一种常人看不懂的模式放在一起，在逻辑和现实中却是行得通的。我浏览了几乎所有我找得到的文章，仍然是一头雾水，因为字里行间似乎透露着理所应当，或者作者默认了读者能够理解。为什么复杂的 PAN/FPN 有用？为什么这卷积堆起来卷来卷去就能提取特征信息？为什么 bottleneck 要先变小再变大？为啥要残差？训练为什么退火？训练多少个 epoch 比较好？什么时候结束训练？诚然，对于论文的作者和其他的学者来说，这些所谓的黑话有另一个名字：术语（terminology）——一种高效沟通的协议。但对初学者而言，这些术语背后牵扯的问题与答案太多、太复杂，似乎非得啃下厚厚的数学教材才能理解。&lt;/p&gt;
&lt;p&gt;但是果真如此吗？一个合理的符合现实的逻辑或现象必定可以使用一个简洁易懂的自然语言描述出来，哪怕这种描述是不严谨的。在知识体系尚未建立的阶段，准确性必须让位于理解本身，因为只有能理解那些论文在讨论什么，那些文章在分析什么，我们才有资格讨论这理解本身是否准确。而过早地陷入不必要的准确的细节，正是我最开始尝试理解时犯下的错误。&lt;/p&gt;
&lt;p&gt;问题来了，如何才能找到相对准确客观的自然语言描述呢？答案之一来自一种常见的文学手段：类比。那问题又来了，如何找到类比的对象？答案在于寻找底层逻辑的共通性——只要事物发展的核心驱动力（矛盾）类似，其表现出的现象就有共通之处。因此，选取日常生活中的常识作为类比，是合理的。对定义的理解来源于相似性，而非定义本身。&lt;/p&gt;
&lt;p&gt;另一种方法是追寻发展的脉络。罗马不是一天建成的，因此，研究庞杂的系统，可以从它与其前代版本的演进角度入手，并尝试回答：改进/增加某个模块是为了解决什么；设计哲学的改变是为了规避什么。这或许可以一路追寻到这个复杂系统的第一个原始版本。那时候，这东西还没有变得复杂起来。&lt;/p&gt;
&lt;p&gt;还有一种是寻找不严格的数学上的类比：将高级的复杂的操作不严谨地概括为一个已知操作的推广（比如这里 CNN 本质是在求 kernel 和特征图的滑动窗口内的部分的相似程度，通过我们都学过的点积）。&lt;/p&gt;
&lt;p&gt;因此，为了规避过早陷入细节的问题，为了其他人不重蹈覆辙，将知识点重新梳理，用上述方式呈现出来，对初学者来说就显得尤为必要。这些资料的主要目的，并非成为面面俱到的百科全书，而是一份循序渐进的、带有解释的“知识清单”。&lt;/p&gt;
&lt;p&gt;但是这足够吗？未必。就像那哲学三问（我是谁，从哪来，到哪去），构建知识体系也可以有精确对应的三问：这是什么，为什么会有/成立，so what。这份清单大体能回答前两问，有时也不行，但真正的理解大致产生于回答第三个问题时。因为理解本身更像是一种直觉，难以完全被语言捕捉。语言本身只能像盲人摸象一样勾勒出轮廓。&lt;/p&gt;
&lt;p&gt;最后，希望几年后，这些文字还能帮到那些初来乍到的新朋友。也希望你能在这个社团里，找到探索世界的好奇与同行者。&lt;/p&gt;
&lt;p&gt;2026年6月 于上海 &amp;lt;br/&amp;gt;
Anson2251&lt;/p&gt;
</content:encoded></item><item><title>物理笔记：一份 (略微冗长的 划掉） 简短的阻抗介绍</title><link>https://blog.ansonate.ink/posts/a-brief-introduction-on-impedance/</link><guid isPermaLink="true">https://blog.ansonate.ink/posts/a-brief-introduction-on-impedance/</guid><description>一份（略微冗长的）阻抗介绍：从复数表示到滤波与谐振电路。</description><pubDate>Thu, 07 Aug 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;最近在学习电子设计，发现阻抗一词（impedance）出现得非常频繁，但之前在高中物理中却几乎没有涉及，因此决定写一篇笔记来整理一下阻抗相关的内容。&lt;/p&gt;
&lt;h2&gt;1. 写在前面：回顾电阻 (Resistance $R$)&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;电阻是导体对直流电的阻碍作用，其大小与导体的电阻率、长度和横截面积有关。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;电阻概念的历史起源&lt;/h3&gt;
&lt;p&gt;电阻的概念可以追溯到19世纪初电学研究的早期。在1827年，德国物理学家格奥尔格·欧姆（Georg Ohm）通过一系列精巧的实验，发现了电压、电流和电阻之间的关系，这便是我们今天所熟知的欧姆定律。&lt;/p&gt;
&lt;p&gt;欧姆的实验装置相对简单：他使用伏打电堆作为电源，通过不同长度的导线，并使用扭秤测量电流的磁效应（当时尚无直接的电流测量仪器）。通过系统地改变导线长度和材料，欧姆发现电流与电压成正比，与导线长度成反比，这一发现奠定了电路理论的基础。&lt;/p&gt;
&lt;h3&gt;电阻是什么&lt;/h3&gt;
&lt;p&gt;从微观角度看，电阻的本质源于&lt;strong&gt;电子在导体中运动时与原子晶格（Lattice）的碰撞&lt;/strong&gt;。当电场施加在导体两端时，自由电子受到电场力的作用而定向移动，形成电流。然而，这种碰撞阻碍了电子的自由运动，从而产生了电阻效应。&lt;/p&gt;
&lt;p&gt;电阻的单位是欧姆 (Ohm, $\Omega$)，其与电压和电流的关系为欧姆定律：&lt;/p&gt;
&lt;p&gt;$$
V = IR \tag{1.1}
$$&lt;/p&gt;
&lt;p&gt;其中 $V$ 是电压，$I$ 是电流，$R$ 是电阻。&lt;/p&gt;
&lt;h3&gt;电阻的影响因素&lt;/h3&gt;
&lt;p&gt;电阻值的大小取决于以下几个因素：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;材料特性&lt;/strong&gt;：不同材料的电阻率不同，如银、铜、铝等良导体的电阻率较低，而橡胶、玻璃等绝缘体的电阻率极高。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;几何形状&lt;/strong&gt;：电阻与导体的长度成正比，与横截面积成反比，这一关系可表示为：&lt;/p&gt;
&lt;p&gt;$$
R = \rho \frac{L}{A} \tag{1.2}
$$&lt;/p&gt;
&lt;p&gt;其中 $\rho$ 是电阻率，$L$ 是长度，$A$ 是横截面积。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;温度&lt;/strong&gt;：对于大多数金属导体，电阻随温度升高而增大（由于晶格振动加剧导致电子碰撞变得更加频繁）；而对于半导体和电解质，电阻可能随温度升高而减小。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;在理想情况下，电阻值不随电压或电流的变化而变化，这种电阻称为线性电阻。然而，在实际应用中，许多元件的电阻会随工作条件的变化而改变。&lt;/p&gt;
&lt;p&gt;电阻的概念为我们理解电路中的能量耗散提供了基础，但它只描述了电路行为的一个方面。当我们从直流电转向交流电时，电路的行为变得更加复杂，我们需要引入新的概念来全面描述电路的特性。&lt;/p&gt;
&lt;h2&gt;2. 从直流到交流：电路行为的演变&lt;/h2&gt;
&lt;h3&gt;直流电 (DC) 的特性&lt;/h3&gt;
&lt;p&gt;直流电（Direct Current, DC）是我们最先接触的电学概念。在直流电路中，电流的大小和方向保持恒定，不会随时间变化。这种稳定性使得直流电路的分析相对简单，欧姆定律足以描述大多数情况下的电路行为。&lt;/p&gt;
&lt;p&gt;直流电的特点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;电流方向恒定&lt;/li&gt;
&lt;li&gt;电流大小恒定&lt;/li&gt;
&lt;li&gt;只需考虑电阻对电流的阻碍作用&lt;/li&gt;
&lt;li&gt;能量主要以热能形式消耗在电阻上&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;交流电 (AC) 的复杂性&lt;/h3&gt;
&lt;p&gt;交流电（Alternating Current, AC）则完全不同，它的大小和方向随时间周期性变化。最常见的交流电是正弦波交流电，其数学表达式为：&lt;/p&gt;
&lt;p&gt;$$
I(t) = I_0 \sin(\omega t + \phi) \tag{2.1}
$$&lt;/p&gt;
&lt;p&gt;其中 $I_0$ 是电流幅值，$\omega$ 是角频率，$t$ 是时间，$\phi$ 是初相位。&lt;/p&gt;
&lt;p&gt;交流电的引入使电路分析变得复杂，因为：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;时变性&lt;/strong&gt;：电压和电流随时间变化，需要考虑瞬时值和有效值&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;方向性&lt;/strong&gt;：电流方向周期性反转，改变了电磁场的分布&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;频率依赖性&lt;/strong&gt;：电路元件的行为随频率变化而不同&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;相位关系&lt;/strong&gt;：电压和电流之间可能出现相位差&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;交流电中的特殊现象&lt;/h3&gt;
&lt;p&gt;在交流电路中，我们观察到了一些直流电路中不存在的现象：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;电磁感应&lt;/strong&gt;：变化的电流产生变化的磁场，进而产生感应电动势&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;电容效应&lt;/strong&gt;：变化的电压导致电容器极板上的电荷积累和释放&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;趋肤效应&lt;/strong&gt;：高频电流倾向于在导体表面流动，有效横截面积减小&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;辐射效应&lt;/strong&gt;：高频交流电会以电磁波形式辐射能量&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这些现象的出现，使得简单的欧姆定律不再足以描述交流电路中的全部行为。我们需要引入更复杂的概念——阻抗，来全面描述交流电路中的电压-电流关系。&lt;/p&gt;
&lt;p&gt;然而，在深入探讨阻抗概念之前，我们需要先了解交流电路中的两个关键元件：电感和电容。正是这两个元件的存在，使得交流电路表现出与直流电路完全不同的特性，也使得阻抗概念变得必要。&lt;/p&gt;
&lt;h2&gt;3. 电感与电容：交流电路中的特殊元件&lt;/h2&gt;
&lt;h3&gt;电感的物理原理&lt;/h3&gt;
&lt;p&gt;电感（Inductance）是电路中一种重要的储能元件，它的存在源于电磁感应现象。1831年，英国科学家迈克尔·法拉第（Michael Faraday）发现了电磁感应定律，为电感现象奠定了理论基础。&lt;/p&gt;
&lt;h4&gt;电感的物理本质&lt;/h4&gt;
&lt;blockquote&gt;
&lt;p&gt;自感现象：当电流通过导体时，会在导体周围产生磁场。如果电流发生变化，磁场也会随之变化，这种变化的磁场会在导体中产生感应电动势，这个感应电动势的方向总是阻碍电流的变化。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;电感的数学表达式为：&lt;/p&gt;
&lt;p&gt;$$
V_L = L \frac{\mathrm dI}{\mathrm dt} \tag{3.1}
$$&lt;/p&gt;
&lt;p&gt;其中 $v_L$ 是电感两端的电压，$L$ 是电感量，$\frac{\mathrm dI}{\mathrm dt}$ 是电流变化率。&lt;/p&gt;
&lt;p&gt;这个公式告诉我们，由于自感现象，电感两端的电压与电流的变化率成正比，而不是与电流本身成正比。这正是电感与电阻的根本区别：&lt;strong&gt;电阻阻碍电流的流动，而电感阻碍电流的变化&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;如果电阻对应物理世界中的摩擦力，那么电感则对应于物理世界中带有惯性（inertia）的物体。电感元件在电路中储存和释放能量，其能量形式为磁场能。&lt;/p&gt;
&lt;h4&gt;电感在交流电路中的行为&lt;/h4&gt;
&lt;p&gt;在交流电路中，电流随时间正弦变化：&lt;/p&gt;
&lt;p&gt;$$
I(t) = I_0 \sin(\omega t) \tag{3.2}
$$&lt;/p&gt;
&lt;p&gt;根据电感的电压公式 $(3.1)$，我们可以得到：&lt;/p&gt;
&lt;p&gt;$$
V_L(t) = L \cdot \frac{\mathrm d}{\mathrm dt}[I_0 \sin(\omega t)] = L \cdot \omega I_0 \cos(\omega t) = \omega L I_0 \sin\left(\omega t + \frac{\pi}{2}\right) \tag{3.3}
$$&lt;/p&gt;
&lt;p&gt;这表明电感两端的电压超前电流 $\dfrac{\pi}{2}$。这种相位差是交流电路中电感的特征行为。&lt;/p&gt;
&lt;p&gt;电感对交流电的阻碍作用称为感抗（Inductive Reactance），其大小为：&lt;/p&gt;
&lt;p&gt;$$
X_L = \omega L = 2\pi fL \tag{3.4}
$$&lt;/p&gt;
&lt;p&gt;感抗随频率的增加而增加，因此电感对高频信号的阻碍作用更强&lt;/p&gt;
&lt;h3&gt;电容的物理原理&lt;/h3&gt;
&lt;p&gt;电容（Capacitance）是另一种重要的储能元件，它的历史可以追溯到18世纪中叶，当时荷兰科学家彼得·范·穆森布鲁克（Pieter van Musschenbroek）发明了莱顿瓶，这是最早的电容器。&lt;/p&gt;
&lt;h4&gt;电容的物理本质&lt;/h4&gt;
&lt;p&gt;电容是由两个相互绝缘的导体（极板）组成的系统。当在两极板间施加电压时，正电荷会聚集在一个极板上，负电荷会聚集在另一个极板上，形成电场。&lt;/p&gt;
&lt;p&gt;电容的定义为：&lt;/p&gt;
&lt;p&gt;$$
C = \frac{Q}{V} \tag{3.5}
$$&lt;/p&gt;
&lt;p&gt;其中 $C$ 是电容量，$Q$ 是极板上的电荷量，$V$ 是两极板间的电压&lt;/p&gt;
&lt;p&gt;电容的 $I-V$ 关系为：&lt;/p&gt;
&lt;p&gt;$$
I_C = C \frac{\mathrm dV}{\mathrm dt} \tag{3.6}
$$&lt;/p&gt;
&lt;p&gt;这表明电容中的电流与电压的变化率成正比，即充放电过程，而不是与电压本身成正比。这是电容与电阻的根本区别。&lt;/p&gt;
&lt;p&gt;如果将电阻比作物理世界中的摩擦力，那么电容则对应于物理世界中的弹性（elasticity）。电容元件在电路中储存和释放能量，其能量形式为电场能&lt;/p&gt;
&lt;h4&gt;电容在交流电路中的行为&lt;/h4&gt;
&lt;p&gt;在交流电路中，电压随时间正弦变化：&lt;/p&gt;
&lt;p&gt;$$
V(t) = V_0 \sin(\omega t) \tag{3.7}
$$&lt;/p&gt;
&lt;p&gt;根据电容的电流公式，我们可以得到：&lt;/p&gt;
&lt;p&gt;$$
I_C(t) = C \frac{\mathrm d}{\mathrm dt}[V_0 \sin(\omega t)] = C \cdot \omega  V_0 \cos(\omega t) = \omega C V_0 \sin\left(\omega t + \frac{\pi}{2}\right) \tag{3.8}
$$&lt;/p&gt;
&lt;p&gt;这表明电容中的电流超前电压 $\dfrac{\pi}{2}$，与电感的情况正好相反。&lt;/p&gt;
&lt;p&gt;电容对交流电的阻碍作用称为容抗（Capacitive Reactance），其大小为：&lt;/p&gt;
&lt;p&gt;$$
X_C = \frac{1}{\omega C} = \frac{1}{2\pi fC} \tag{3.9}
$$&lt;/p&gt;
&lt;p&gt;容抗随频率的增加而减小，因此&lt;strong&gt;电容对高频信号的阻碍作用较弱，对低频信号的阻碍作用较强&lt;/strong&gt;。&lt;/p&gt;
&lt;h3&gt;电感与电容的对比&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;特性&lt;/th&gt;
&lt;th&gt;电感&lt;/th&gt;
&lt;th&gt;电容&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;储能形式&lt;/td&gt;
&lt;td&gt;磁场能&lt;/td&gt;
&lt;td&gt;电场能&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;电压-电流关系&lt;/td&gt;
&lt;td&gt;$V_L = L \dfrac{\mathrm dI}{\mathrm dt}$&lt;/td&gt;
&lt;td&gt;$I_C = C \dfrac{\mathrm dV}{\mathrm dt}$&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;相位关系&lt;/td&gt;
&lt;td&gt;电压超前电流 $\dfrac{\pi}{2}$&lt;/td&gt;
&lt;td&gt;电流超前电压 $\dfrac{\pi}{2}$&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;频率特性&lt;/td&gt;
&lt;td&gt;感抗随频率增加而增加&lt;/td&gt;
&lt;td&gt;容抗随频率增加而减小&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;直流特性&lt;/td&gt;
&lt;td&gt;对直流相当于短路&lt;/td&gt;
&lt;td&gt;对直流相当于开路&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;电感和电容的这些特性，使它们在交流电路中表现出与电阻完全不同的行为，也为我们理解阻抗概念奠定了基础。&lt;/p&gt;
&lt;p&gt;现在我们已经了解了电阻、电感和电容的基本特性，是时候将它们统一在一个框架下了。阻抗概念正是为了描述这些元件在交流电路中的综合行为而引入的，它不仅包含了能量耗散，也包含了能量存储和相位关系。&lt;/p&gt;
&lt;h2&gt;4. 关于阻抗：电阻，但不只是电阻&lt;/h2&gt;
&lt;h3&gt;什么是阻抗 (Impedance $Z$) ？&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;阻抗（Electrical Impedance）又称电阻抗，是电路中电阻、电感、电容对交流电的阻碍作用的统称&lt;/p&gt;
&lt;p&gt;- From &lt;a href=&quot;https://zh.wikipedia.org/zh/%E9%98%BB%E6%8A%97&quot;&gt;wikipedia&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;阻抗由以下部份组成：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
    Z[阻抗 Z] --&amp;gt; R[电阻 R：阻碍直流]
    Z --&amp;gt; X[电抗 X：阻碍交流]
    X --&amp;gt; XL[感抗 Xₗ：线圈反抗电流变化]
    X --&amp;gt; XC[容抗 Xc：电容反抗电压变化]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;$$
Z = R + jX
$$&lt;/p&gt;
&lt;p&gt;$$
X_l = 2\pi fL
$$&lt;/p&gt;
&lt;p&gt;$$
X_c = \frac{1}{2\pi fC}
$$&lt;/p&gt;
&lt;h3&gt;阻抗的数学推导与复数表示&lt;/h3&gt;
&lt;h4&gt;时域与频域：两种不同的视角&lt;/h4&gt;
&lt;p&gt;在深入探讨阻抗的数学表示之前，我们需要理解两个重要的概念：时域和频域。这两个概念为我们提供了分析电路的两种不同视角，每种视角都有其独特的优势。&lt;/p&gt;
&lt;h5&gt;什么是时域？&lt;/h5&gt;
&lt;p&gt;时域是我们最熟悉的视角，它描述信号如何随时间变化。在时域中，我们观察的是电压、电流等物理量在每一时刻的值。就像我们用秒表记录运动物体的位置一样，时域分析关注的是&quot;在时间t时刻，信号值是多少&quot;。&lt;/p&gt;
&lt;p&gt;在之前的章节中，我们已经接触了时域的表示方法。例如，交流电的电流可以表示为：&lt;/p&gt;
&lt;p&gt;$$
I(t) = I_0 \sin(\omega t + \phi)
$$&lt;/p&gt;
&lt;p&gt;这个公式告诉我们，在任何时刻 $t$，电流的值是多少。时域分析的优点是直观、易于理解，因为我们日常经验就是基于时间的。&lt;/p&gt;
&lt;p&gt;然而，时域分析也有其局限性。当电路中包含电感和电容时，我们需要处理微分方程，这使得计算变得复杂。此外，时域分析难以直接看出电路对不同频率信号的响应特性。&lt;/p&gt;
&lt;h5&gt;什么是频域？&lt;/h5&gt;
&lt;p&gt;频域是另一种视角，它描述信号包含哪些频率成分，以及每个频率成分的强度和相位。如果说时域关注的是&quot;信号在时间上如何变化&quot;，那么频域关注的是&quot;信号由哪些频率组成&quot;。&lt;/p&gt;
&lt;p&gt;频域分析就像是用棱镜将白光分解成彩虹中的各种颜色。白光本身看起来是单一的，但实际上它包含了多种不同频率（颜色）的光。同样，一个复杂的时域信号也可以被分解为多个不同频率的正弦波的叠加。&lt;/p&gt;
&lt;p&gt;频域分析的数学基础是傅里叶变换，它可以将时域信号转换为频域表示。对于我们常见的正弦波交流电，其频域表示非常简单：只在特定频率f处有一个幅值。&lt;/p&gt;
&lt;h5&gt;为什么需要频域分析？&lt;/h5&gt;
&lt;p&gt;频域分析在交流电路分析中特别有用，原因如下：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;简化计算&lt;/strong&gt;：在频域中，微分运算变成了简单的乘法运算。例如，电感的电压-电流关系 $V_L = L \dfrac{\mathrm dI}{\mathrm dt}$ 在频域中变成了 $V = j\omega L I$，这是一个简单的代数方程。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;直观理解频率响应&lt;/strong&gt;：频域分析使我们能够直接看出电路对不同频率信号的响应。例如，我们可以一眼看出一个电路是低通滤波器、高通滤波器还是带通滤波器。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;统一分析框架&lt;/strong&gt;：频域分析为电阻、电感和电容提供了一个统一的分析框架，使我们能够用相同的方法处理这些不同的元件。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;简化复杂信号分析&lt;/strong&gt;：对于复杂的非正弦信号（如方波、三角波等），频域分析可以将它们分解为多个正弦波的叠加，然后分别分析每个频率成分通过电路的情况。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h5&gt;时域与频域的对应关系&lt;/h5&gt;
&lt;p&gt;时域和频域是描述同一信号的两种不同方式，它们之间有着严格的数学对应关系。以下是几个常见信号的时域和频域对应关系：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;信号类型&lt;/th&gt;
&lt;th&gt;时域表示&lt;/th&gt;
&lt;th&gt;频域表示&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;直流信号&lt;/td&gt;
&lt;td&gt;常数&lt;/td&gt;
&lt;td&gt;只在频率0处有值&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;正弦波&lt;/td&gt;
&lt;td&gt;$A\sin(2\pi ft + \phi)$&lt;/td&gt;
&lt;td&gt;只在频率f处有值，幅值为A，相位为$\phi$&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;方波&lt;/td&gt;
&lt;td&gt;周期性方波&lt;/td&gt;
&lt;td&gt;基频和奇次谐波&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;冲激信号&lt;/td&gt;
&lt;td&gt;极短时间内的极大值&lt;/td&gt;
&lt;td&gt;所有频率都有相同的幅值&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h5&gt;从时域到频域的转变&lt;/h5&gt;
&lt;p&gt;在交流电路分析中，我们经常需要在时域和频域之间转换。这种转换不仅是一种数学技巧，更是一种思维方式的转变。&lt;/p&gt;
&lt;p&gt;例如，当我们分析一个RC低通滤波器时：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;在时域中，我们需要解微分方程来找出输出电压随时间的变化&lt;/li&gt;
&lt;li&gt;在频域中，我们只需要计算传递函数 $H(f) = \dfrac{1}{1 + j2\pi fRC}$，就能直接看出电路对不同频率信号的响应&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这种从时域到频域的转变，使我们能够更深入地理解电路的行为特性，特别是对频率的选择性。&lt;/p&gt;
&lt;h5&gt;频域分析的实际应用&lt;/h5&gt;
&lt;p&gt;频域分析在许多领域都有广泛应用：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;音频处理&lt;/strong&gt;：均衡器通过增强或衰减特定频率的信号来调整音质&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;无线通信&lt;/strong&gt;：不同电台使用不同频率的信号进行广播&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;图像处理&lt;/strong&gt;：图像可以通过傅里叶变换转换到频域进行处理&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;振动分析&lt;/strong&gt;：机械系统的振动可以分解为不同频率的成分&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;通过理解时域和频域的概念，我们为学习阻抗的复数表示打下了坚实的基础。阻抗本质上是一个频域概念，它描述了电路对不同频率信号的响应特性。在接下来的内容中，我们将看到如何利用频域分析来简化交流电路的计算，并获得更深刻的物理洞察。&lt;/p&gt;
&lt;h4&gt;为什么需要复数？一个简单的引入&lt;/h4&gt;
&lt;p&gt;在之前的章节中，我们已经了解到交流电路中的电感和电容会导致电压和电流之间产生相位差。这种相位差使得交流电路的分析比直流电路复杂得多。为了简化这种复杂性，数学家们发现了复数这样一个非常强大的工具&lt;/p&gt;
&lt;h4&gt;复数基础回顾&lt;/h4&gt;
&lt;p&gt;在深入阻抗的复数表示之前，让我们先简单回顾一下复数的基本概念：&lt;/p&gt;
&lt;p&gt;复数可以表示为 $a + jb$，其中：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;$a$ 是实部&lt;/li&gt;
&lt;li&gt;$b$ 是虚部&lt;/li&gt;
&lt;li&gt;$j$ 是虚数单位，满足 $j^2 = -1$&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;由于 $i$ 有时表示电流，电子学中常用 $j$ 表示虚数单位，即 $j^2 = -1$&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;复数可以在平面上表示，横轴是实轴，纵轴是虚轴。这种表示方法帮助我们直观地理解复数的运算。&lt;/p&gt;
&lt;h4&gt;欧拉公式&lt;/h4&gt;
&lt;p&gt;在交流电路分析中，我们面临一个挑战：如何简化微分方程的求解？18世纪瑞士数学家莱昂哈德·欧拉（Leonhard Euler）发现了一个被誉为&quot;数学中最美公式&quot;的欧拉公式：&lt;/p&gt;
&lt;p&gt;$$
e^{j\theta} = \cos\theta + j\sin\theta \tag{4.1}
$$&lt;/p&gt;
&lt;p&gt;这个公式实际上建立了一个非常重要的桥梁：它将指数函数与三角函数联系了起来，这意味着我们可以用指数函数来表示 $\sin$ 和 $\cos$&lt;/p&gt;
&lt;p&gt;$$
\sin(\omega t) = \text{Im}[e^{j\omega t}] \tag{4.2}
$$&lt;/p&gt;
&lt;p&gt;$$
\cos(\omega t) = \text{Re}[e^{j\omega t}] \tag{4.3}
$$&lt;/p&gt;
&lt;p&gt;使用欧拉公式将复数变换至形式，微分运算将会变得非常简单：&lt;/p&gt;
&lt;p&gt;$$
\frac{\mathrm d}{\mathrm dt}e^{j\omega t} = j\omega e^{j\omega t} \tag{4.4}
$$&lt;/p&gt;
&lt;h4&gt;复数表示法与相量：简化交流电路分析&lt;/h4&gt;
&lt;h5&gt;什么是相量？&lt;/h5&gt;
&lt;p&gt;在交流电路分析中，我们引入了相量（Phasor）的概念。相量可以理解为&quot;旋转的箭头&quot;，它是一个复数，表示正弦量的两个关键信息：幅值（大小）和相位（位置）。&lt;/p&gt;
&lt;p&gt;相量的数学表示为：&lt;/p&gt;
&lt;p&gt;$$
V = V_0 e^{j\phi_v} \tag{4.5}
$$&lt;/p&gt;
&lt;p&gt;$$
I = I_0 e^{j\phi_i} \tag{4.5}
$$&lt;/p&gt;
&lt;p&gt;其中 $V_0$ 和 $I_0$ 是幅值（箭头的长度），$\phi_v$ 和 $\phi_i$ 是初相位（箭头的初始角度）。&lt;/p&gt;
&lt;h5&gt;相量如何简化电路分析？&lt;/h5&gt;
&lt;p&gt;使用相量表示法，我们可以将复杂的微分方程转换为简单的代数方程。让我们通过电感和电容的例子，一步一步地详细说明这个过程。&lt;/p&gt;
&lt;h6&gt;电感的例子：从微分方程到代数方程&lt;/h6&gt;
&lt;p&gt;在时域中，电感的电压-电流关系是一个微分方程：&lt;/p&gt;
&lt;p&gt;$$
V_L(t) = L \frac{\mathrm dI_L(t)}{\mathrm dt} \tag{4.6}
$$&lt;/p&gt;
&lt;p&gt;这个方程告诉我们，电感两端的电压与电流的变化率成正比。如果我们知道电流的具体形式，可以通过求导得到电压。&lt;/p&gt;
&lt;p&gt;让我们假设通过电感的电流是一个正弦波：&lt;/p&gt;
&lt;p&gt;$$
I_L(t) = I_0 \sin(\omega t + \phi_i) \tag{4.7}
$$&lt;/p&gt;
&lt;p&gt;其中：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;$I_0$ 是电流的幅值（最大值）&lt;/li&gt;
&lt;li&gt;$\omega$ 是角频率（$\omega = 2\pi f$，$f$ 是频率）&lt;/li&gt;
&lt;li&gt;$t$ 是时间&lt;/li&gt;
&lt;li&gt;$\phi_i$ 是初相位（决定电流在 $t=0$ 时的值）&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果我们直接在时域中求解，需要将方程 $(4.7)$ 代入方程 $(4.6)$：&lt;/p&gt;
&lt;p&gt;$$
V_L(t) = L \frac{\mathrm d}{\mathrm dt}[I_0 \sin(\omega t + \phi_i)]
$$&lt;/p&gt;
&lt;p&gt;求导得到：&lt;/p&gt;
&lt;p&gt;$$
V_L(t) = L \cdot I_0 \omega \cos(\omega t + \phi_i) \tag{4.8}
$$&lt;/p&gt;
&lt;p&gt;利用三角恒等式 $\cos\theta = \sin(\theta + \dfrac{\pi}{2})$，我们可以将方程 $(4.8)$ 改写为：&lt;/p&gt;
&lt;p&gt;$$
V_L(t) = L \cdot I_0 \omega \sin(\omega t + \phi_i + \frac{\pi}{2}) \tag{4.9}
$$&lt;/p&gt;
&lt;p&gt;这个结果表明，电感两端的电压也是一个正弦波，但相位超前电流 $\dfrac{\pi}{2}$&lt;/p&gt;
&lt;p&gt;现在，让我们看看如何使用相量表示法来简化这个过程。首先，我们需要将正弦函数表示为复指数函数的虚部。&lt;/p&gt;
&lt;p&gt;根据欧拉公式：$e^{j\theta} = \cos\theta + j\sin\theta$，我们有：&lt;/p&gt;
&lt;p&gt;$$
\sin\theta = \text{Im}[e^{j\theta}]
$$&lt;/p&gt;
&lt;p&gt;因此，方程 $(4.7)$ 可以表示为：&lt;/p&gt;
&lt;p&gt;$$
I_L(t) = I_0 \sin(\omega t + \phi_i) = \text{Im}[I_0 e^{j(\omega t + \phi_i)}] = \text{Im}[I_0 e^{j\phi_i} e^{j\omega t}] \tag{4.10}
$$&lt;/p&gt;
&lt;p&gt;在相量表示法中，我们定义电流相量 $I$ 为：&lt;/p&gt;
&lt;p&gt;$$
I = I_0 e^{j\phi_i} \tag{4.11}
$$&lt;/p&gt;
&lt;p&gt;因此，方程 $(4.10)$ 可以写为：&lt;/p&gt;
&lt;p&gt;$$
I_L(t) = \text{Im}[I e^{j\omega t}] \tag{4.12}
$$&lt;/p&gt;
&lt;p&gt;现在，让我们对方程 $(4.12)$ 两边求导：&lt;/p&gt;
&lt;p&gt;$$
\frac{\mathrm dI_L(t)}{\mathrm dt} = \frac{\mathrm d}{\mathrm dt}\text{Im}[I e^{j\omega t}] = \text{Im}\left[\frac{\mathrm d}{\mathrm dt}(I e^{j\omega t})\right]
$$&lt;/p&gt;
&lt;p&gt;因为 $I$ 是一个常数（不随时间变化），所以：&lt;/p&gt;
&lt;p&gt;$$
\frac{\mathrm dI_L(t)}{\mathrm dt} = \text{Im}[I \cdot \frac{\mathrm d}{\mathrm dt}e^{j\omega t}] = \text{Im}[I \cdot j\omega e^{j\omega t}] = \text{Im}[j\omega I e^{j\omega t}] \tag{4.13}
$$&lt;/p&gt;
&lt;p&gt;将方程 $(4.13)$ 代入电感的电压方程 $(4.6)$：&lt;/p&gt;
&lt;p&gt;$$
V_L(t) = L \cdot \text{Im}[j\omega I e^{j\omega t}] = \text{Im}[j\omega L I e^{j\omega t}] \tag{4.14}
$$&lt;/p&gt;
&lt;p&gt;这表明电压的相量表示为：&lt;/p&gt;
&lt;p&gt;$$
V = j\omega L I \tag{4.15}
$$&lt;/p&gt;
&lt;p&gt;从方程 $(4.15)$，我们可以定义电感的阻抗为：&lt;/p&gt;
&lt;p&gt;$$
Z_L = \frac{V}{I} = j\omega L \tag{4.16}
$$&lt;/p&gt;
&lt;p&gt;这个结果告诉我们，在相量表示法中，电感的阻抗是一个纯虚数 $j\omega L$。现在让我们验证一下这个结果是否与直接求解的结果一致。从方程(10)和(6)，我们有：&lt;/p&gt;
&lt;p&gt;$$
V = j\omega L I = j\omega L I_0 e^{j\phi_i} = \omega L I_0 e^{j(\phi_i + \frac{\pi}{2})} \tag{4.17}
$$&lt;/p&gt;
&lt;p&gt;因为 $j = e^{j\frac{\pi}{2}}$，所以 $j\omega L I_0 e^{j\phi_i} = \omega L I_0 e^{j(\phi_i + \frac{\pi}{2})}$。&lt;/p&gt;
&lt;p&gt;将方程 $(4.17)$ 转换回时域：&lt;/p&gt;
&lt;p&gt;$$
V_L(t) = \text{Im}\left[V e^{j\omega t}\right] = \text{Im}\left[\omega L I_0 e^{j(\phi_i + \frac{\pi}{2})} e^{j\omega t}\right] = \omega L I_0 \sin\left(\omega t + \phi_i + \frac{\pi}{2}\right)
$$&lt;/p&gt;
&lt;p&gt;这与我们直接求解得到的方程 $(4.9)$ 完全一致，验证了相量表示法的正确性。&lt;/p&gt;
&lt;h5&gt;电容的例子&lt;/h5&gt;
&lt;p&gt;在时域中，电容的电流-电压关系是：&lt;/p&gt;
&lt;p&gt;$$
I_C(t) = C \frac{\mathrm dV_C(t)}{\mathrm dt} \tag{4.18}
$$&lt;/p&gt;
&lt;p&gt;假设电容两端的电压是一个正弦波：&lt;/p&gt;
&lt;p&gt;$$
V_C(t) = V_0 \sin(\omega t + \phi_v) \tag{4.19}
$$&lt;/p&gt;
&lt;p&gt;将方程 $(4.19)$ 代入方程 $(4.18)$：&lt;/p&gt;
&lt;p&gt;$$
I_C(t) = C \frac{\mathrm d}{\mathrm dt}[V_0 \sin(\omega t + \phi_v)] = C \cdot V_0 \omega \cos(\omega t + \phi_v)
$$&lt;/p&gt;
&lt;p&gt;利用三角恒等式，可以改写为：&lt;/p&gt;
&lt;p&gt;$$
I_C(t) = C \cdot V_0 \omega \sin\left(\omega t + \phi_v + \frac{\pi}{2}\right) \tag{4.20}
$$&lt;/p&gt;
&lt;p&gt;这表明，电容中的电流超前电压 $\dfrac{\pi}{2}$。&lt;/p&gt;
&lt;p&gt;将方程 $(4.19)$ 表示为复指数函数的虚部：&lt;/p&gt;
&lt;p&gt;$$
V_C(t) = V_0 \sin(\omega t + \phi_v) = \text{Im}[V_0 e^{j(\omega t + \phi_v)}] = \text{Im}[V_0 e^{j\phi_v} e^{j\omega t}] \tag{4.21}
$$&lt;/p&gt;
&lt;p&gt;定义电压相量 $V$ 为：&lt;/p&gt;
&lt;p&gt;$$
V = V_0 e^{j\phi_v} \tag{4.22}
$$&lt;/p&gt;
&lt;p&gt;因此，方程 $(4.20)$ 可以写为：&lt;/p&gt;
&lt;p&gt;$$
V_C(t) = \text{Im}[V e^{j\omega t}] \tag{4.23}
$$&lt;/p&gt;
&lt;p&gt;对方程 $(4.22)$ 两边求导：&lt;/p&gt;
&lt;p&gt;$$
\frac{\mathrm dV_C(t)}{\mathrm dt} = \frac{\mathrm d}{\mathrm dt}\text{Im}\left[V e^{j\omega t}\right] = \text{Im}\left[\frac{\mathrm d}{\mathrm dt}(V e^{j\omega t})\right] = \text{Im}\left[V \cdot j\omega e^{j\omega t}\right] = \text{Im}[j\omega V e^{j\omega t}] \tag{4.24}
$$&lt;/p&gt;
&lt;p&gt;将方程 $(4.24)$ 代入电容的电流方程 $(4.18)$：&lt;/p&gt;
&lt;p&gt;$$
I_C(t) = C \cdot \text{Im}[j\omega V e^{j\omega t}] = \text{Im}[j\omega C V e^{j\omega t}] \tag{4.25}
$$&lt;/p&gt;
&lt;p&gt;这表明电流的相量表示为：&lt;/p&gt;
&lt;p&gt;$$
I = j\omega C V \tag{4.26}
$$&lt;/p&gt;
&lt;p&gt;从方程 $(4.26)$，我们可以解出 $V$：&lt;/p&gt;
&lt;p&gt;$$
V = \frac{1}{j\omega C} I \tag{4.27}
$$&lt;/p&gt;
&lt;p&gt;因此，电容的阻抗为：&lt;/p&gt;
&lt;p&gt;$$
Z_C = \frac{V}{I} = \frac{1}{j\omega C} \tag{4.28}
$$&lt;/p&gt;
&lt;p&gt;我们可以进一步简化方程 $(4.28)$。首先，注意到：&lt;/p&gt;
&lt;p&gt;$$
\frac{1}{j} = \frac{j}{j \cdot j} = \frac{j}{-1} = -j
$$&lt;/p&gt;
&lt;p&gt;因此，电容的阻抗可以写为：&lt;/p&gt;
&lt;p&gt;$$
Z_C = -j\frac{1}{\omega C} \tag{4.29}
$$&lt;/p&gt;
&lt;p&gt;让我们验证一下这个结果是否与直接求解的结果一致。从方程 $(4.27)$ 和 $(4.22)$，我们有：&lt;/p&gt;
&lt;p&gt;$$
I = j\omega C V = j\omega C V_0 e^{j\phi_v} = \omega C V_0 e^{j(\phi_v + \frac{\pi}{2})} \tag{4.30}
$$&lt;/p&gt;
&lt;p&gt;将方程 $(4.20)$ 转换回时域：&lt;/p&gt;
&lt;p&gt;$$
I_C(t) = \text{Im}[I e^{j\omega t}] = \text{Im}[\omega C V_0 e^{j(\phi_v + \frac{\pi}{2})} e^{j\omega t}] = \omega C V_0 \sin(\omega t + \phi_v + \frac{\pi}{2})
$$&lt;/p&gt;
&lt;p&gt;这与我们直接求解得到的方程 $(4.20)$ 完全一致，验证了相量表示法的正确性。&lt;/p&gt;
&lt;h5&gt;相量表示法的优势&lt;/h5&gt;
&lt;p&gt;通过相量表示法，我们将微分方程转换为代数方程，大大简化了计算过程，同时相量的旋转特性帮助我们直观地理解交流电的相位关系，这为电阻、电感和电容提供了一个统一的分析框架，也使他们可以在复平面上绘制电压和电流的关系&lt;/p&gt;
&lt;h3&gt;阻抗的复数表示：统一的电路描述&lt;/h3&gt;
&lt;h4&gt;阻抗的复数形式&lt;/h4&gt;
&lt;p&gt;现在，我们可以将前面学到的知识整合起来，给出阻抗的复数表示：&lt;/p&gt;
&lt;p&gt;$$
Z = R + jX \tag{4.31}
$$&lt;/p&gt;
&lt;p&gt;其中：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;$R$ 是电阻（实部），代表能量耗散&lt;/li&gt;
&lt;li&gt;$X$ 是电抗（虚部），代表能量存储&lt;/li&gt;
&lt;li&gt;$j$ 是虚数单位&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这个复数形式完美地统一了电阻、电感和电容的特性：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;对于纯电阻：$Z = R$（只有实部）&lt;/li&gt;
&lt;li&gt;对于纯电感：$Z = j\omega L$（只有虚部，为正）&lt;/li&gt;
&lt;li&gt;对于纯电容：$Z = -j\dfrac{1}{\omega C}$（只有虚部，为负）&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;阻抗的几何解释&lt;/h4&gt;
&lt;p&gt;阻抗也可以在复平面上表示，这为我们提供了直观的几何理解：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;graph LR
    A[复平面] --&amp;gt; B[&quot;实轴：电阻R&quot;]
    A --&amp;gt; C[&quot;虚轴：电抗X&quot;]
    D[阻抗Z] --&amp;gt; E[&quot;从原点到点 R,jX 的向量&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;阻抗的极坐标形式为：&lt;/p&gt;
&lt;p&gt;$$
Z = |Z| e^{j\theta} \tag{4.32}
$$&lt;/p&gt;
&lt;p&gt;其中：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;$|Z| = \sqrt{R^2 + X^2}$ 是阻抗的模，表示电路对交流电的总阻碍作用&lt;/li&gt;
&lt;li&gt;$\theta = \arctan\left(\dfrac{X}{R}\right)$ 是阻抗的相位角，表示电压与电流之间的相位差&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;物理意义&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;阻抗的模 $|Z|$ 类似于电阻的大小，但它不仅考虑了能量耗散，还考虑了能量存储效应。&lt;/li&gt;
&lt;li&gt;相位角 $\theta$ 告诉我们电压超前电流多少角度：
&lt;ul&gt;
&lt;li&gt;如果 $\theta &amp;gt; 0$，电压超前电流（电路呈感性）&lt;/li&gt;
&lt;li&gt;如果 $\theta &amp;lt; 0$，电压滞后电流（电路呈容性）&lt;/li&gt;
&lt;li&gt;如果 $\theta = 0$，电压与电流同相（电路呈纯阻性）&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;从复数阻抗到欧姆定律&lt;/h4&gt;
&lt;p&gt;有了复数阻抗的概念，我们可以将欧姆定律推广到交流电路：&lt;/p&gt;
&lt;p&gt;$$
V = Z \cdot I \tag{4.33}
$$&lt;/p&gt;
&lt;p&gt;这个形式与直流电路中的欧姆定律 $V = R \cdot I$ 非常相似，但这里的 $V$ 和 $I$ 是相量，$Z$ 是复数阻抗。这种统一的形式大大简化了交流电路的分析！&lt;/p&gt;
&lt;h3&gt;阻抗的串联与并联：电路分析的实用工具&lt;/h3&gt;
&lt;h4&gt;阻抗的串联&lt;/h4&gt;
&lt;p&gt;当多个阻抗串联时，总阻抗等于各阻抗之和。这与电阻的串联规则完全相同：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;串联&lt;/strong&gt;：总阻抗等于各阻抗之和&lt;/p&gt;
&lt;p&gt;$$
Z_{total} = Z_1 + Z_2 + \cdots + Z_n \tag{4.34}
$$&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;例子&lt;/strong&gt;：一个电阻 $R$ 和一个电感 $L$ 串联&lt;/p&gt;
&lt;p&gt;$$
Z_{total} = R + j\omega L
$$&lt;/p&gt;
&lt;p&gt;这个结果的物理意义是：电路中既有能量耗散（电阻部分），又有能量存储（电感部分），且电压超前电流。&lt;/p&gt;
&lt;h4&gt;阻抗的并联&lt;/h4&gt;
&lt;p&gt;当多个阻抗并联时，总阻抗的倒数等于各阻抗倒数之和。这也与电阻的并联规则相同：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;并联&lt;/strong&gt;：总阻抗的倒数等于各阻抗倒数之和&lt;/p&gt;
&lt;p&gt;$$
\frac{1}{Z_{total}} = \frac{1}{Z_1} + \frac{1}{Z_2} + \cdots + \frac{1}{Z_n} \tag{4.35}
$$&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;例子&lt;/strong&gt;：一个电阻 $R$ 和一个电容 $C$ 并联&lt;/p&gt;
&lt;p&gt;$$
\frac{1}{Z_{total}} = \frac{1}{R} + \frac{1}{-j\frac{1}{\omega C}} = \frac{1}{R} + j\omega C
$$&lt;/p&gt;
&lt;p&gt;因此，&lt;/p&gt;
&lt;p&gt;$$
Z_{total} = \frac{1}{\frac{1}{R} + j\omega C} = \frac{R}{1 + j\omega RC}
$$&lt;/p&gt;
&lt;p&gt;这个结果表明，并联RC电路的阻抗是一个复数，其实部和虚部都与频率有关。&lt;/p&gt;
&lt;h4&gt;实际应用：为什么这些规则很重要？&lt;/h4&gt;
&lt;p&gt;阻抗的串联和并联规则在实际电路设计中非常重要，因为：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;电路分析&lt;/strong&gt;：这些规则允许我们分析复杂的交流电路，将多个元件简化为一个等效阻抗。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;滤波器设计&lt;/strong&gt;：通过串联和并联不同的元件，可以设计出具有特定频率响应的滤波器。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;阻抗匹配&lt;/strong&gt;：在信号传输中，需要匹配源阻抗和负载阻抗，以最大化功率传输或最小化反射。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;谐振电路&lt;/strong&gt;：通过串联或并联电感和电容，可以创建在特定频率下具有特殊行为的谐振电路。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这些规则与直流电路中的电阻串联和并联规则形式相同，这使得我们可以将直流电路的分析方法直接推广到交流电路，大大简化了学习过程！&lt;/p&gt;
&lt;h3&gt;阻抗与导纳：两种互补的视角&lt;/h3&gt;
&lt;h4&gt;什么是导纳？&lt;/h4&gt;
&lt;p&gt;导纳（Admittance）是阻抗的倒数，用 $Y$ 表示：&lt;/p&gt;
&lt;p&gt;$$
Y = \frac{1}{Z} = G + jB \tag{4.36}
$$&lt;/p&gt;
&lt;p&gt;其中：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;$G$ 是电导（实部），是电阻的倒数&lt;/li&gt;
&lt;li&gt;$B$ 是电纳（虚部），是电抗的倒数&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;为什么需要导纳？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;导纳的概念为我们提供了分析电路的另一种视角。如果说阻抗描述了电路对电流的&quot;阻碍&quot;程度，那么导纳则描述了电路对电流的&quot;导通&quot;程度。&lt;/p&gt;
&lt;p&gt;在某些情况下，特别是分析并联电路时，使用导纳比使用阻抗更加方便。因为并联导纳可以直接相加，就像并联电阻的电导可以直接相加一样。&lt;/p&gt;
&lt;h4&gt;阻抗与导纳的转换&lt;/h4&gt;
&lt;p&gt;让我们看看如何将阻抗转换为导纳：&lt;/p&gt;
&lt;p&gt;给定阻抗 $Z = R + jX$，其导纳为：&lt;/p&gt;
&lt;p&gt;$$
Y = \frac{1}{Z} = \frac{1}{R + jX}
$$&lt;/p&gt;
&lt;p&gt;为了将分母中的虚数消除，我们可以将分子和分母同时乘以 $R - jX$：&lt;/p&gt;
&lt;p&gt;$$
Y = \frac{R - jX}{(R + jX)(R - jX)} = \frac{R - jX}{R^2 + X^2} = \frac{R}{R^2 + X^2} - j\frac{X}{R^2 + X^2} \tag{4.37}
$$&lt;/p&gt;
&lt;p&gt;因此，&lt;/p&gt;
&lt;p&gt;$$
G = \frac{R}{R^2 + X^2} \tag{4.38}
$$
$$
B = -\frac{X}{R^2 + X^2} \tag{4.39}
$$&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;例子&lt;/strong&gt;：电感的导纳&lt;/p&gt;
&lt;p&gt;电感的阻抗为 $Z_L = j\omega L$，其导纳为：&lt;/p&gt;
&lt;p&gt;$$
Y_L = \frac{1}{j\omega L} = -j\frac{1}{\omega L}
$$&lt;/p&gt;
&lt;p&gt;这表明电感的导纳是一个纯虚数，且为负值。&lt;/p&gt;
&lt;h4&gt;导纳的实际应用&lt;/h4&gt;
&lt;p&gt;导纳的概念在以下几种情况下特别有用：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;并联电路分析&lt;/strong&gt;：当多个元件并联时，总导纳等于各导纳之和：
$$
Y_{total} = Y_1 + Y_2 + \cdots + Y_n \tag{4.40}
$$
这比计算并联阻抗要简单得多。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;节点分析法&lt;/strong&gt;：在电路理论中，节点分析法使用导纳而不是阻抗，可以简化方程的建立。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;传输线理论&lt;/strong&gt;：在分析高频信号传输时，导纳是一个基本概念。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;网络分析&lt;/strong&gt;：在分析复杂网络时，导纳矩阵比阻抗矩阵更容易处理。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h4&gt;阻抗与导纳的对比&lt;/h4&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;特性&lt;/th&gt;
&lt;th&gt;阻抗 $Z$&lt;/th&gt;
&lt;th&gt;导纳 $Y$&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;定义&lt;/td&gt;
&lt;td&gt;电压与电流之比&lt;/td&gt;
&lt;td&gt;电流与电压之比&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;单位&lt;/td&gt;
&lt;td&gt;欧姆 ($\Omega$)&lt;/td&gt;
&lt;td&gt;西门子 (S)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;串联&lt;/td&gt;
&lt;td&gt;直接相加&lt;/td&gt;
&lt;td&gt;需要转换&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;并联&lt;/td&gt;
&lt;td&gt;需要转换&lt;/td&gt;
&lt;td&gt;直接相加&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;物理意义&lt;/td&gt;
&lt;td&gt;对电流的阻碍&lt;/td&gt;
&lt;td&gt;对电流的导通&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;阻抗和导纳是描述电路特性的两种互补方法。在实际问题中，我们可以根据具体情况选择使用阻抗还是导纳，以简化计算和分析过程。&lt;/p&gt;
&lt;h3&gt;阻抗的物理意义：从数学到现实&lt;/h3&gt;
&lt;p&gt;阻抗的复数表示不仅仅是一个抽象的数学工具，它有着深刻的物理意义。让我们从不同角度来理解阻抗的物理含义。&lt;/p&gt;
&lt;h4&gt;能量视角：耗散与存储&lt;/h4&gt;
&lt;p&gt;从能量的角度看，阻抗的复数形式完美地描述了电路中能量的两种不同命运：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;实部（电阻）&lt;/strong&gt;：代表能量耗散&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;当电流通过电阻时，电能被不可逆地转化为热能&lt;/li&gt;
&lt;li&gt;这种能量转化是不可逆的，符合热力学第二定律&lt;/li&gt;
&lt;li&gt;电阻越大，能量耗散越快&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;虚部（电抗）&lt;/strong&gt;：代表能量存储&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;在电感中，能量以磁场形式存储&lt;/li&gt;
&lt;li&gt;在电容中，能量以电场形式存储&lt;/li&gt;
&lt;li&gt;这种能量存储是可逆的，可以被完全释放回电路&lt;/li&gt;
&lt;li&gt;电抗的大小决定了能量存储的效率&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h4&gt;时间视角：即时与延迟&lt;/h4&gt;
&lt;p&gt;从时间的角度看，阻抗描述了电压和电流之间的时间关系：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;纯电阻电路&lt;/strong&gt;：电压和电流同步变化，没有时间延迟&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;电感电路&lt;/strong&gt;：电压变化超前于电流变化，电感&quot;抵抗&quot;电流的变化&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;电容电路&lt;/strong&gt;：电流变化超前于电压变化，电容&quot;抵抗&quot;电压的变化&lt;/li&gt;
&lt;/ol&gt;
&lt;h4&gt;频率视角：通与阻&lt;/h4&gt;
&lt;p&gt;从频率的角度看，阻抗描述了电路对不同频率信号的不同响应：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;电阻&lt;/strong&gt;：对所有频率的信号响应相同&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;电感&lt;/strong&gt;：对高频信号阻碍大，对低频信号阻碍小&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;电容&lt;/strong&gt;：对低频信号阻碍大，对高频信号阻碍小&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这种频率选择性是许多电子电路（如滤波器、谐振电路）的基础。&lt;/p&gt;
&lt;h4&gt;几何视角：向量与旋转&lt;/h4&gt;
&lt;p&gt;从几何的角度看，阻抗可以在复平面上表示为一个向量：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;模&lt;/strong&gt;：向量的长度，表示电路对交流电的总阻碍作用&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;相位角&lt;/strong&gt;：向量与实轴的夹角，表示电压与电流之间的相位关系&lt;/li&gt;
&lt;/ol&gt;
&lt;h4&gt;实用视角：简化与统一&lt;/h4&gt;
&lt;p&gt;从实用的角度看，阻抗的复数表示为我们提供了一个统一的分析框架：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;统一描述&lt;/strong&gt;：将电阻、电感和电容统一在一个数学框架下&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;简化计算&lt;/strong&gt;：将微分方程转换为代数方程，大大简化了计算过程&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;直观理解&lt;/strong&gt;：通过复数平面上的几何表示，提供了直观的理解方式&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;广泛应用&lt;/strong&gt;：适用于从简单电路到复杂网络的各种情况&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;5. 阻抗的实际应用&lt;/h2&gt;
&lt;h3&gt;滤波电路：频率选择性&lt;/h3&gt;
&lt;p&gt;阻抗的频率依赖性是许多电子电路的基础。滤波电路利用不同频率下阻抗的变化，实现对特定频率信号的选择性通过或阻断。&lt;/p&gt;
&lt;h4&gt;低通滤波器（Low-pass Filter）&lt;/h4&gt;
&lt;p&gt;一个简单的RC低通滤波器由一个电阻和一个电容串联组成：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../assets/posts/a-brief-introduction-on-impedance/Low_pass_filter.png&quot; alt=&quot;LC Circuit&quot; /&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Schematic of a simple parallel LC circuit, &lt;a href=&quot;https://commons.wikimedia.org/wiki/File:Low_pass_filter.png&quot;&gt;from wikipedia&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;该电路的传递函数为：&lt;/p&gt;
&lt;p&gt;$$
H(f) = \frac{V_{out}}{V_{in}} = \frac{1}{1 + j2\pi fRC} \tag{5.1}
$$&lt;/p&gt;
&lt;p&gt;当频率 $f$ 很低时，电容的容抗 $X_C = \dfrac{1}{2\pi fC}$ 很大，相当于开路，信号几乎无衰减地通过。当频率 $f$ 很高时，容抗很小，相当于短路，信号被大幅衰减。&lt;/p&gt;
&lt;h4&gt;高通滤波器（High-pass Filter）&lt;/h4&gt;
&lt;p&gt;类似地，一个简单的高通滤波器可以通过交换电阻和电容的位置来实现：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../assets/posts/a-brief-introduction-on-impedance/CR_high_pass_filter.svg.png&quot; alt=&quot;High Pass Filter&quot; /&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;A high-pass filter, &lt;a href=&quot;https://commons.wikimedia.org/wiki/File:CR_high_pass_filter.svg&quot;&gt;from wikipedia&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;该电路的传递函数为：&lt;/p&gt;
&lt;p&gt;$$
H(f) = \frac{V_{out}}{V_{in}} = \frac{j2\pi fRC}{1 + j2\pi fRC} \tag{5.2}
$$&lt;/p&gt;
&lt;p&gt;这种滤波器允许高频信号通过，而阻断低频信号。&lt;/p&gt;
&lt;h3&gt;谐振电路（Resonant/LC Circuit）：能量交换的和谐&lt;/h3&gt;
&lt;p&gt;当电感和电容组合在一起时，会发生谐振现象。在谐振频率下，感抗和容抗相互抵消，电路呈现纯电阻特性。&lt;/p&gt;
&lt;h4&gt;串联谐振电路&lt;/h4&gt;
&lt;p&gt;&lt;img src=&quot;../../assets/posts/a-brief-introduction-on-impedance/LC_parallel_simple.svg.png&quot; alt=&quot;LC Circuit&quot; /&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Schematic of a simple parallel LC circuit, &lt;a href=&quot;https://commons.wikimedia.org/wiki/File:LC_parallel_simple.svg&quot;&gt;from wikipedia&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;串联谐振电路的总阻抗为：&lt;/p&gt;
&lt;p&gt;$$
Z = R + j\left(\omega L - \frac{1}{\omega C}\right) \tag{5.3}
$$&lt;/p&gt;
&lt;p&gt;谐振频率 $\omega_0$ 满足 $\omega_0 L = \frac{1}{\omega_0 C}$，即：&lt;/p&gt;
&lt;p&gt;$$
f_0 = \frac{1}{2\pi\sqrt{LC}} \tag{5.4}
$$&lt;/p&gt;
&lt;p&gt;在谐振频率下，阻抗达到最小值 $Z = R$，电流达到最大值。&lt;/p&gt;
&lt;h3&gt;阻抗匹配：最大功率传输&lt;/h3&gt;
&lt;p&gt;在电子系统中，阻抗匹配是一个重要概念。根据最大功率传输定理，当负载阻抗等于源阻抗的共轭复数时，负载可以获得最大功率。&lt;/p&gt;
&lt;p&gt;对于纯电阻电路，这意味着负载电阻应等于源电阻。对于含有电抗的电路，负载阻抗应为源阻抗的共轭复数：&lt;/p&gt;
&lt;p&gt;$$
Z_L = Z_S^*
$$&lt;/p&gt;
&lt;p&gt;这一原理在音频系统、射频电路和天线设计中都有重要应用。&lt;/p&gt;
&lt;h3&gt;实际应用&lt;/h3&gt;
&lt;h4&gt;音频系统中的分频器&lt;/h4&gt;
&lt;p&gt;在音响系统中，分频器利用阻抗的频率特性，将音频信号分配到不同的扬声器：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;低音扬声器：通过低通滤波器，接收低频信号&lt;/li&gt;
&lt;li&gt;高音扬声器：通过高通滤波器，接收高频信号&lt;/li&gt;
&lt;li&gt;中音扬声器：通过带通滤波器，接收中频信号&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;无线通信中的匹配网络&lt;/h4&gt;
&lt;p&gt;在无线通信系统中，天线阻抗通常为50Ω或75Ω，而放大器输出阻抗可能不同。为了实现最大功率传输，需要设计匹配网络，使天线阻抗与放大器输出阻抗匹配。&lt;/p&gt;
&lt;h4&gt;电力系统中的功率因数校正&lt;/h4&gt;
&lt;p&gt;在电力系统中，感性负载（如电动机）会导致电流滞后于电压，降低功率因数。通过并联适当的电容，可以补偿相位差，提高功率因数，减少能源浪费。&lt;/p&gt;
&lt;p&gt;通过这些实际应用，我们可以看到阻抗概念不仅是一个抽象的物理概念，也是现代电子技术和电力系统的基础。&lt;/p&gt;
&lt;h2&gt;总结&lt;/h2&gt;
&lt;h3&gt;笔记回顾&lt;/h3&gt;
&lt;p&gt;在这篇笔记中，我们经历了一个从简单到复杂、从具体到抽象的学习过程：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;从电阻开始&lt;/strong&gt;：我们首先回顾了电阻的概念，了解了它的历史起源、物理本质和影响因素。电阻作为电路中最基本的元件，为我们理解能量耗散提供了基础。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;进入交流世界&lt;/strong&gt;：接着，我们探讨了交流电与直流电的区别，认识到交流电路中出现的特殊现象，如电磁感应、电容效应等，这些现象使得简单的欧姆定律不再适用。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;认识特殊元件&lt;/strong&gt;：我们深入研究了电感和电容的物理原理，了解了它们在交流电路中的特殊行为，特别是它们与电阻的根本区别——电感和电容阻碍的是电流和电压的变化，而不是电流和电压本身。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;引入阻抗概念&lt;/strong&gt;：通过将电阻、感抗和容抗统一在一个复数框架下，我们引入了阻抗的概念。阻抗不仅包含了电路对交流电的阻碍作用，还包含了电压与电流之间的相位关系。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;数学工具的应用&lt;/strong&gt;：我们学习了如何使用复数和相量来简化交流电路的分析，将微分方程转换为代数方程，大大简化了计算过程。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;实践验证理论&lt;/strong&gt;：最后，我们通过实际应用案例和实验演示，验证了阻抗理论，并了解了它在现代电子技术中的广泛应用。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;阻抗概念的深层意义&lt;/h3&gt;
&lt;p&gt;阻抗概念的重要性不仅在于它的实用性，还在于它体现了物理学中的一种重要思想：&lt;strong&gt;统一与简化&lt;/strong&gt;。通过引入复数表示，我们能够将原本复杂的交流电路问题简化为类似于直流电路的问题，这种思想在物理学的发展史上反复出现。&lt;/p&gt;
&lt;p&gt;阻抗概念也展示了物理学中&lt;strong&gt;模型构建&lt;/strong&gt;的过程。面对复杂的自然现象，我们不是直接陷入细节，而是构建适当的数学模型，抓住主要矛盾，忽略次要因素，从而获得对问题的深入理解。&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt; &lt;/p&gt;
&lt;p&gt;由我和 GLM-4.5 合作整理于2025年8月7日&lt;/p&gt;
</content:encoded></item><item><title>Why Simple Harmonic Motion Can Be Modelled by a Reference Circle</title><link>https://blog.ansonate.ink/posts/why-simple-harmonic-motion-can-be-modelled-by-a-reference-circle/</link><guid isPermaLink="true">https://blog.ansonate.ink/posts/why-simple-harmonic-motion-can-be-modelled-by-a-reference-circle/</guid><description>Why uniform circular motion projects into simple harmonic motion: the reference-circle picture.</description><pubDate>Sat, 18 Jan 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;Introduction&lt;/h2&gt;
&lt;p&gt;During a physics lesson I was introduced to the concept of Simple Harmonic Motion (SHM), which can be modelled using a reference circle. At first I was puzzled: why can SHM be represented by a circle? Is it an assumption or a coincidence? To understand this in a more &quot;sensible&quot; way, I decided to derive the relationship between SHM and the reference circle mathematically.&lt;/p&gt;
&lt;h2&gt;1. What is SHM (Simple Harmonic Motion)&lt;/h2&gt;
&lt;p&gt;SHM is the motion of an object about a fixed point such that its acceleration $$a$$ is proportional to its displacement $$x$$ from the fixed point, and is directed towards the point. In other words, SHM follows the following equation:&lt;/p&gt;
&lt;p&gt;$$a = -C x$$&lt;/p&gt;
&lt;p&gt;where $$C$$ is a positive constant.&lt;/p&gt;
&lt;p&gt;How to model this motion? A reference circle can be used.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../assets/posts/why-simple-harmonic-motion-can-be-modelled-by-a-reference-circle/Unit_circle_angles_color.svg&quot; alt=&quot;A reference circle&quot; /&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;A reference circle. By &lt;a href=&quot;//commons.wikimedia.org/w/index.php?title=User:Jim.belk&amp;amp;action=edit&amp;amp;redlink=1&quot;&gt;Jim.belk&lt;/a&gt; - &lt;em&gt;Own work&lt;/em&gt;, Public Domain, &lt;a href=&quot;https://commons.wikimedia.org/w/index.php?curid=12062595&quot;&gt;Link&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;By projecting the motion of a particle moving in a circle onto a diameter, we have:&lt;/p&gt;
&lt;p&gt;$$
\begin{align}
x &amp;amp;= x_0\sin(\omega t+\theta) \
v &amp;amp;= \dfrac{\mathrm{d}{x}}{\mathrm{d}{t}}=x_0\omega \cos(\omega t+\theta) \
a &amp;amp;= \dfrac{\mathrm{d}{v}}{\mathrm{d}{t}}=-x_0\omega^2 \sin(\omega t+\theta) = -\omega^2 x
\end{align}
$$&lt;/p&gt;
&lt;p&gt;Where $$\omega$$ is the angular speed of the reference circle, and $$\theta$$ is the initial phase angle. The radius of the circle corresponds to the amplitude of the SHM.&lt;/p&gt;
&lt;p&gt;But wait, why can we model SHM with a reference circle? In order to answer this question, we need to dive into the mathematics behind it.&lt;/p&gt;
&lt;h2&gt;2. Some Mathematical Derivation&lt;/h2&gt;
&lt;p&gt;In the following equation, we are going to ignore frictions and other external forces.&lt;/p&gt;
&lt;p&gt;Since we were introduced to simple harmonic motion from the motion of a spring. Let&apos;s start from the Hooke&apos;s Law, which states the force $$F$$ exerted by a spring is proportional to the displacement $$x$$ from its equilibrium position:&lt;/p&gt;
&lt;p&gt;$$
F=-kx
$$&lt;/p&gt;
&lt;p&gt;According to Newton&apos;s Second Law, we have:&lt;/p&gt;
&lt;p&gt;$$
F=ma
$$&lt;/p&gt;
&lt;p&gt;Additionally, due to the definition of acceleration, we have:&lt;/p&gt;
&lt;p&gt;$$
a=\dfrac{\mathrm{d}^2}{\mathrm{d}t^2}x
$$&lt;/p&gt;
&lt;p&gt;Let&apos;s combine these equations:&lt;/p&gt;
&lt;p&gt;$$
\begin{align}
ma &amp;amp;= -kx \
m\dfrac{\mathrm{d}^2x}{\mathrm{d}t^2} &amp;amp;= -kx \
\dfrac{\mathrm{d}^2x}{\mathrm{d}t^2} &amp;amp;= -\dfrac{k}{m}x \
\dfrac{\mathrm{d}^2x}{\mathrm{d}t^2} + \dfrac{k}{m}x &amp;amp;= 0
\end{align}
$$&lt;/p&gt;
&lt;p&gt;Well, we have obtained a second-order differential equation. To solve it, we can assume a solution of the form:&lt;/p&gt;
&lt;p&gt;$$x(t) = e^{rt}$$&lt;/p&gt;
&lt;p&gt;Why we use this form? Because exponential functions $$e^x$$ have the property that their derivatives are proportional to themselves, which makes them suitable for solving differential equations. Let&apos;s substitute this into our differential equation:&lt;/p&gt;
&lt;p&gt;Now we substitue $$x(t) = e^{rt}$$ into the differential equation:&lt;/p&gt;
&lt;p&gt;$$
\begin{align}
r^2 e^{rt} + \frac{k}{m} e^{rt} &amp;amp;= 0
\end{align}
$$&lt;/p&gt;
&lt;p&gt;By factoring out $$e^{rt}$$, we can obtain the characteristic equation, since $$e^{rt} \neq 0$$:&lt;/p&gt;
&lt;p&gt;$$
\begin{align}
e^{rt}\left(r^2 + \frac{k}{m}\right) &amp;amp;= 0 \
r^2 + \frac{k}{m} &amp;amp;= 0
\end{align}
$$&lt;/p&gt;
&lt;p&gt;Now, we can solve the characteristic equation:&lt;/p&gt;
&lt;p&gt;$$
\begin{align}
r^2 &amp;amp;= -\frac{k}{m} \
r &amp;amp;= \pm i \sqrt{\frac{k}{m}}
\end{align}
$$&lt;/p&gt;
&lt;p&gt;Finally, since the roots of the characteristic equation are complex, the general solution to the differential equation is:&lt;/p&gt;
&lt;p&gt;$$
\begin{align}
x(t) &amp;amp;= C_1 e^{i \sqrt{\frac{k}{m}} t} + C_2 e^{-i \sqrt{\frac{k}{m}} t}
\end{align}
$$&lt;/p&gt;
&lt;p&gt;Where $$C_1$$ and $$C_2$$ are arbitrary constants determined by initial conditions.&lt;/p&gt;
&lt;p&gt;Then, according to Euler&apos;s formula: $$e^{i\theta} = \cos \theta + i \sin \theta$$, we can rewrite the general solution as:&lt;/p&gt;
&lt;p&gt;$$
\begin{align}
x(t) &amp;amp;= C_1 \left(\cos\left(\sqrt{\frac{k}{m}} t\right) + i\sin\left(\sqrt{\frac{k}{m}} t\right)\right) \&amp;amp; \quad + C_2 \left(\cos\left(\sqrt{\frac{k}{m}} t\right) - i\sin\left(\sqrt{\frac{k}{m}} t\right)\right) \notag\
x(t) &amp;amp;= (C_1 + C_2)\cos\left(\sqrt{\frac{k}{m}} t\right) + i(C_1 - C_2)\sin\left(\sqrt{\frac{k}{m}} t\right)
\end{align}
$$&lt;/p&gt;
&lt;p&gt;Let $$A = C_1 + C_2$$ and $$B = i(C_1 - C_2)$$, so:&lt;/p&gt;
&lt;p&gt;$$
x(t) = A\cos\left(\sqrt{\frac{k}{m}} t\right) + B\sin\left(\sqrt{\frac{k}{m}} t\right)
$$&lt;/p&gt;
&lt;p&gt;By using the trigonometric identity (compound angle formula) we used in Pure Mathmatics 3:
&amp;lt;!-- add more details --&amp;gt;&lt;/p&gt;
&lt;p&gt;$$
\begin{align}
A\cos\left(\sqrt{\frac{k}{m}} t\right) + B\sin\left(\sqrt{\frac{k}{m}} t\right) = R\sin\left(\sqrt{\frac{k}{m}} t + \theta \right)
\end{align}
$$&lt;/p&gt;
&lt;p&gt;where:&lt;/p&gt;
&lt;p&gt;$$
R = \sqrt{A^2 + B^2} \quad \text{and} \quad \theta = \tan^{-1}\left(\frac{A}{B}\right).
$$&lt;/p&gt;
&lt;p&gt;We are very close to the final solution. Let&apos;s define $$\omega = \sqrt{\frac{k}{m}}$$ and $$x_0 = R$$, so:&lt;/p&gt;
&lt;p&gt;$$
x(t) = x_0\sin(\omega t + \theta)
$$&lt;/p&gt;
&lt;p&gt;It is obvious that this equation is the same as the equation of the projection of the reference circle.&lt;/p&gt;
&lt;p&gt;By getting the second derivative of $$x(t)$$, we can get the expression of $$a(t)$$:&lt;/p&gt;
&lt;p&gt;$$
\begin{align}
a(t) &amp;amp;= \frac{\mathrm{d^2} v}{\mathrm{d}t^2} = -x_0\omega^2\sin(\omega t + \theta)
\end{align}
$$&lt;/p&gt;
&lt;p&gt;Now, we can combine two equations to get the relationship bewteen $$a(t)$$ and $$x(t)$$:&lt;/p&gt;
&lt;p&gt;$$
\begin{align}
a(t) &amp;amp;= -\omega^2 (x_0\sin(\omega t + \theta)) \
a(t) &amp;amp;= -\omega^2 x(t)
\end{align}
$$&lt;/p&gt;
&lt;p&gt;In this way, we have proved that the acceleration of the mass is proportional to its displacement from the equilibrium position and is always directed towards the equilibrium position. This is the defining characteristic of simple harmonic motion.&lt;/p&gt;
&lt;p&gt;$$
\text{Q.E.D.}
$$&lt;/p&gt;
</content:encoded></item><item><title>Protecting Secrets: Credential Handling for Open-Source Projects</title><link>https://blog.ansonate.ink/posts/development-credential-handling-solution/</link><guid isPermaLink="true">https://blog.ansonate.ink/posts/development-credential-handling-solution/</guid><description>Keeping secrets out of open-source repos: a credential-handling workflow for public projects.</description><pubDate>Sat, 27 Jul 2024 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;Introduction&lt;/h2&gt;
&lt;p&gt;This blog aims to document an effective method I recently developed for managing credential API keys in the open-source project &lt;a href=&quot;https://github.com/Anson2251/trackmaker&quot;&gt;Trackmaker&lt;/a&gt; which I have been developing for a long time, and the journey I came up with this method. The method uses a private credential configuration file to store the credentials or passes the credentials via environment variables. The first approach simplifies debugging, while the second approach enables GitHub Actions to automatically build GitHub Pages without the need to upload a private credential configuration file.&lt;/p&gt;
&lt;h2&gt;How I Came Up with This Idea and Made a Framework&lt;/h2&gt;
&lt;p&gt;The consideration for protecting credentials dates back to when I signed up for the &lt;a href=&quot;https://www.bingmapsportal.com/&quot;&gt;Bing Maps Dev Centre&lt;/a&gt; and obtained a basic key. According to the terms of use, this credential key must not be exposed to the public. This posed a challenging task: ensuring the program can read the key without including any key-related information in the publicly accessible code on GitHub.&lt;/p&gt;
&lt;p&gt;To address this problem, I explored various solutions over several months. To evaluate the quality of these solutions, I considered the following factors:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;[ ] &lt;strong&gt;Can avoid the credentials from being exposed via the source code.&lt;/strong&gt;
&lt;ul&gt;
&lt;li&gt;The source code can be accessed by the public.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Can avoid the credentials from being exposed via the built product.&lt;/strong&gt;
&lt;ul&gt;
&lt;li&gt;The credentials may be obtained from reverse engineering.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Can support the source code to be built without uploading the credentials.&lt;/strong&gt;
&lt;ul&gt;
&lt;li&gt;The project should pass compilation even if the credentials are not included, making it fully open to the public.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Can cooperate with GitHub Pages &amp;amp; Actions to build the page automatically.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;New credentials can be added easily.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;.gitignore&lt;/h3&gt;
&lt;p&gt;The preliminary method I came up with was straightforward: separate the storage of the credential keys and add the file containing the keys to &lt;code&gt;.gitignore&lt;/code&gt;.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;[x] Can avoid the credentials from being exposed via the source code.&lt;/li&gt;
&lt;li&gt;[ ] Can avoid the credentials from being exposed via the built product.&lt;/li&gt;
&lt;li&gt;[ ] Can support the source code to be built without uploading the credentials.&lt;/li&gt;
&lt;li&gt;[ ] Can cooperate with GitHub Pages &amp;amp; Actions to build the page automatically.&lt;/li&gt;
&lt;li&gt;[ ] New credentials can be added easily.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Encryption of Credentials&lt;/h3&gt;
&lt;p&gt;To prevent credentials from being obtained through reverse engineering, I applied a private encryption method. I designed an algorithm to achieve this.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;[x] Can avoid the credentials from being exposed via the source code.&lt;/li&gt;
&lt;li&gt;[x] Can avoid the credentials from being exposed via the built product.&lt;/li&gt;
&lt;li&gt;[ ] Can support the source code to be built without uploading the credentials.&lt;/li&gt;
&lt;li&gt;[ ] Can cooperate with GitHub Pages &amp;amp; Actions to build the page automatically.&lt;/li&gt;
&lt;li&gt;[ ] New credentials can be added easily.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Configuration File, Environment Variables, &amp;amp; Vite&lt;/h3&gt;
&lt;p&gt;As I started configuring GitHub Pages to preview the built page, the need to build with the public source code emerged. Credentials can be passed using &lt;code&gt;secrets&lt;/code&gt;, where the credentials can be stored and accessed when building the page. I also found a useful configuration in Vite, a bundler I used, called &lt;a href=&quot;https://vitejs.dev/config/shared-options.html#define&quot;&gt;&lt;code&gt;define&lt;/code&gt;&lt;/a&gt;, which defines global constant replacements. With this option, I can replace constants with credentials during the build. To facilitate debugging, I modified the &lt;code&gt;vite.config.ts&lt;/code&gt; to read credentials from a file and environment variables for GitHub Pages.&lt;/p&gt;
&lt;p&gt;Additionally, using &lt;code&gt;JavaScript Obfuscator&lt;/code&gt; can encrypt the credentials, ensuring they do not exist in plaintext form in the production build.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;[x] Can avoid the credentials from being exposed via the source code.&lt;/li&gt;
&lt;li&gt;[x] Can avoid the credentials from being exposed via the built product.&lt;/li&gt;
&lt;li&gt;[x] Can support the source code to be built without uploading the credentials.&lt;/li&gt;
&lt;li&gt;[x] Can cooperate with GitHub Pages &amp;amp; Actions to build the page automatically.&lt;/li&gt;
&lt;li&gt;[ ] New credentials can be added easily.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Generalised Framework&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;There is a node package called &lt;code&gt;dotenv&lt;/code&gt;, which does exactly what the following framework does.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;To simplify adding new credentials, I packaged the key replacement into a more generalised framework.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;[x] Can avoid the credentials from being exposed via the source code.&lt;/li&gt;
&lt;li&gt;[x] Can avoid the credentials from being exposed via the built product.&lt;/li&gt;
&lt;li&gt;[x] Can support the source code to be built without uploading the credentials.&lt;/li&gt;
&lt;li&gt;[x] Can cooperate with GitHub Pages &amp;amp; Actions to build the page automatically.&lt;/li&gt;
&lt;li&gt;[x] New credentials can be added easily.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Final Code for the framework&lt;/h2&gt;
&lt;p&gt;The modified &lt;code&gt;vite.config.ts&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import { promises as fs } from &quot;fs&quot;; // handling file operations in node
import { defineConfig } from &quot;vite&quot;;

// Default path for the credential configuration file
const credentialFileDefaultPath = &quot;./credentials-config.json&quot;;

// Type definition for credential items
type CredentialItemType = {
  name: string;
  type: &quot;string&quot; | &quot;number&quot;;
};

// List of credential items to be managed
const credentialItems: CredentialItemType[] = [
  {
    name: &quot;EXAMPLE_KEY&quot;,
    type: &quot;string&quot;,
  },
];

/**
 * Check if a file exists at the given file path.
 * @param filePath - Path to the file.
 * @returns Promise&amp;lt;boolean&amp;gt; - True if the file exists, false otherwise.
 */
async function checkFileExist(filePath: string): Promise&amp;lt;boolean&amp;gt; {
  try {
    await fs.access(filePath);
    return Promise.resolve(true);
  } catch (error) {
    return Promise.resolve(false);
  }
}

/**
 * Read the content of a file.
 * @param filePath - Path to the file.
 * @returns Promise&amp;lt;string&amp;gt; - Content of the file.
 */
async function readFile(filePath: string): Promise&amp;lt;string&amp;gt; {
  try {
    const config = await fs.readFile(filePath, {
      encoding: &quot;utf-8&quot;,
    });
    return Promise.resolve(config);
  } catch (err) {
    return Promise.reject(`Cannot read from the file &quot;${filePath}&quot;`);
  }
}

/**
 * Load credentials from a specified file path or environment variables.
 * @param credentialFilePath - Path to the credential configuration file.
 * @returns Promise&amp;lt;Record&amp;lt;string, string&amp;gt;&amp;gt; - An object containing the final credentials.
 */
async function getCredentials(credentialFilePath: string) {
  // Log the use of a custom credential configuration file
  if (credentialFilePath !== credentialFileDefaultPath) {
    console.log(`Using credential configuration file: ${credentialFilePath}`);
  }

  // Check if the credential file exists
  const credentialFileExist = await checkFileExist(credentialFilePath);
  // Load credential file content if it exists, otherwise use an empty object
  const credentialFileContent: Record&amp;lt;string, string | number&amp;gt; =
    credentialFileExist ? JSON.parse(await readFile(credentialFilePath)) : {};
  // Object to hold the final credentials
  const finalCredential: Record&amp;lt;string, string&amp;gt; = {};

  // Iterate over each credential item
  credentialItems.forEach((item) =&amp;gt; {
    // Try to get the value from environment variables or credential file content
    let value: string | number | undefined =
      process.env[item.name] || credentialFileContent[item.name] || undefined;

    // If value is undefined, issue a warning and set it to an empty string
    if (typeof value === &quot;undefined&quot;) {
      console.warn(
        &quot;\x1b[33m%s\x1b[0m&quot;,
        `Credential item &quot;${item.name}&quot; cannot be found in the environment or the &quot;${credentialFilePath}&quot;`
      );
      value = &quot;&quot;;
    }

    // Convert the value to the appropriate type
    if (item.type === &quot;string&quot;) value = String(value);
    else if (item.type === &quot;number&quot;) value = Number(value);

    // Add the formatted credential to the finalCredential object
    finalCredential[`__${item.name}__`] = JSON.stringify(value);
  });

  return finalCredential;
}

// Export the Vite configuration
export default defineConfig(async () =&amp;gt; {
  // Get the path for the credential configuration file, defaulting to the specified default path
  const credentialsConfigPath =
    process.env.CREDENTIALS_CONFIG_PATH || credentialFileDefaultPath;

  return {
    // Define global constants with the loaded credentials
    define: await getCredentials(credentialsConfigPath),
  };
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;This framework simplifies secure credential management for open-source projects by using private configuration files and environment variables. It ensures credentials are protected while supporting automated builds with GitHub Actions. This approach helps maintain security without complicating the development process.&lt;/p&gt;
&lt;h2&gt;References&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/Anson2251/trackmaker/blob/main/README.md&quot;&gt;Trackmaker Project README.md&lt;/a&gt;, accessed July 27, 2024.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://vitejs.dev/config/shared-options.html#define&quot;&gt;Vite Configuration &lt;code&gt;define&lt;/code&gt;&lt;/a&gt;, accessed July 27, 2024.&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>A brief note on CSS overflow behaviour</title><link>https://blog.ansonate.ink/posts/css-overflow-behaviour/</link><guid isPermaLink="true">https://blog.ansonate.ink/posts/css-overflow-behaviour/</guid><description>How CSS overflow works in grid and flex layouts, why scrollbars sometimes never appear, and where min-width: 0 and min-height: 0 belong.</description><pubDate>Sat, 27 Jul 2024 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;0x0: Introduction&lt;/h2&gt;
&lt;p&gt;When I was working on the Trackmaker project, the overflow behaviour of &lt;code&gt;grid&lt;/code&gt; kept biting me. I had a side panel that was supposed to scroll, and instead it kept stretching the whole page. I added &lt;code&gt;overflow-y: auto&lt;/code&gt; in a few places, nothing happened, and I started guessing.&lt;/p&gt;
&lt;p&gt;The fix turned out to be one line on a wrapper, not on the panel. This note is what I wish I had at the time.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../assets/posts/css-overflow-behaviour/css-is-awesome.png&quot; alt=&quot;CSS is awesome&quot; /&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;CSS is awesome! &lt;a href=&quot;https://www.etsy.com/jp/listing/891486110/css-is-awesome-mug&quot;&gt;link&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The short version: &lt;code&gt;overflow&lt;/code&gt; needs a box with a bounded size. Grid and flex items have a content-based minimum that can stop that from happening. Setting &lt;code&gt;overflow&lt;/code&gt; on the item itself removes that minimum, so &lt;code&gt;min-width: 0&lt;/code&gt; and &lt;code&gt;min-height: 0&lt;/code&gt; are usually for the non-scrolling wrappers around the scroller, not for the scroller itself.&lt;/p&gt;
&lt;h2&gt;0x1: The box model and what overflow means&lt;/h2&gt;
&lt;p&gt;An element is made up of content, padding, border, and margin.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../assets/posts/css-overflow-behaviour/box-model.png&quot; alt=&quot;The box model&quot; /&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;From &quot;The box model&quot;, mdn web docs. &lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Learn_web_development/Core/Styling_basics/Box_model&quot;&gt;link&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;code&gt;width&lt;/code&gt; and &lt;code&gt;height&lt;/code&gt; set the size of the content box by default. With &lt;code&gt;box-sizing: border-box&lt;/code&gt; they set the size up to the border instead, so padding eats into the room left for content. Either way, once that size is fixed, content can still be larger.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;overflow&lt;/code&gt; does not change whether content is too big. It only changes what the browser does with the extra part:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;visible&lt;/code&gt; paints it outside the box.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;hidden&lt;/code&gt; clips it. It also makes the box a scroll container, so you can still scroll it with script, focus, or &lt;code&gt;scrollIntoView&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;scroll&lt;/code&gt; clips it and always shows a scrollbar. On platforms with overlay scrollbars, the scrollbar may only appear while you scroll.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;auto&lt;/code&gt; clips it and shows a scrollbar only when needed.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;clip&lt;/code&gt; clips it without making the box scrollable. That difference matters when grid or flex sizing is involved.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;0x2: How the browser decides something has overflowed&lt;/h2&gt;
&lt;p&gt;It helps to think of layout as two passes.&lt;/p&gt;
&lt;p&gt;First the browser works out how big each box is. For a plain block this comes from &lt;code&gt;width&lt;/code&gt;, &lt;code&gt;height&lt;/code&gt;, and the parent. For flex and grid it comes from flex sizing or track sizing, plus &lt;code&gt;min-width&lt;/code&gt;, &lt;code&gt;max-width&lt;/code&gt;, &lt;code&gt;min-height&lt;/code&gt;, &lt;code&gt;max-height&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Then it lays out the children and builds what the spec calls the scrollable overflow area. Roughly, that is the union of the box&apos;s own content and anything sticking out of it: long words, wide images, absolutely positioned descendants whose containing block is the box or a descendant of it, text with &lt;code&gt;white-space: nowrap&lt;/code&gt;, and so on. Each child contributes its border box plus some extra cases like text shadows and floats.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../assets/posts/css-overflow-behaviour/overflow-area.svg&quot; alt=&quot;A padding box with content overflowing past its right edge, surrounded by a larger dashed scrollable overflow area&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;The scrollable overflow area can be larger than the padding box even when &lt;code&gt;overflow&lt;/code&gt; is visible.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;If that overflow area is larger than the padding box, the box overflows. That is true even with &lt;code&gt;overflow: visible&lt;/code&gt;. The property kicks in after the fact. It picks between painting the extra part, clipping it, or turning the box into a scroll container.&lt;/p&gt;
&lt;p&gt;This is why &lt;code&gt;overflow: auto&lt;/code&gt; sometimes looks broken. You added the scrollbar rule, but the box grew to fit the content, so from the browser&apos;s point of view nothing overflowed. If nothing overflows, no scrollbar appears.&lt;/p&gt;
&lt;p&gt;A few things that tend to make the content side larger than people expect:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;white-space: nowrap&lt;/code&gt; stops text from wrapping, so the min content size becomes one very long line.&lt;/li&gt;
&lt;li&gt;Replaced elements like &lt;code&gt;img&lt;/code&gt; or &lt;code&gt;video&lt;/code&gt; keep an intrinsic width unless you constrain them with &lt;code&gt;max-width: 100%&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A block with &lt;code&gt;width: auto&lt;/code&gt; fills the space it is given, but when it is a grid or flex item, its minimum size comes from its contents. A long unbreakable string or a wide nested grid can push that minimum up.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;.card {
  width: 200px;
  height: 100px;
  overflow: hidden;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;.card {
  width: auto;
  height: auto;
  overflow: visible;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In the first block the box stays 200 by 100 and extra content gets clipped. In the second the box grows with content, so nothing counts as overflow.&lt;/p&gt;
&lt;h2&gt;0x3: The automatic minimum in grid and flex&lt;/h2&gt;
&lt;p&gt;Flex items and grid items default to &lt;code&gt;min-width: auto&lt;/code&gt; and &lt;code&gt;min-height: auto&lt;/code&gt;. In practice, &lt;code&gt;auto&lt;/code&gt; here means &quot;no smaller than my content.&quot; The browser takes the min-content size of what is inside (the longest word, the image width, the nested track) and uses it as a floor.&lt;/p&gt;
&lt;p&gt;There is an exception that the usual explanations skip: a scroll container&apos;s automatic minimum is &lt;code&gt;0&lt;/code&gt;. In flexbox this applies to the main axis; in grid it applies on both axes. So an item with &lt;code&gt;overflow: hidden&lt;/code&gt;, &lt;code&gt;auto&lt;/code&gt;, or &lt;code&gt;scroll&lt;/code&gt; on itself already has no content floor. It can shrink, and the overflow rule then clips or scrolls what does not fit.&lt;/p&gt;
&lt;p&gt;That is why the common advice sounds contradictory. &lt;code&gt;min-width: 0&lt;/code&gt; is not needed on the element that has &lt;code&gt;overflow: auto&lt;/code&gt;. It is needed on a non-scrolling wrapper whose &lt;code&gt;overflow&lt;/code&gt; is still &lt;code&gt;visible&lt;/code&gt;, so that a scroller inside it gets a bounded box.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;overflow: clip&lt;/code&gt; is the odd one out. It clips, but it does not create a scroll container, so it does not remove the content floor. A grid item with &lt;code&gt;overflow: clip&lt;/code&gt; can still grow its track. If you want clipping and shrinking without a scroll container, pair &lt;code&gt;clip&lt;/code&gt; with &lt;code&gt;min-width: 0&lt;/code&gt; or &lt;code&gt;min-height: 0&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../assets/posts/css-overflow-behaviour/automatic-minimum.svg&quot; alt=&quot;A flowchart: flex or grid items check whether they are scroll containers; scroll containers get an automatic minimum of zero, everything else gets a content-based minimum&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;When the automatic minimum is content-based, the item can grow its track or container.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Grid adds one more twist. &lt;code&gt;1fr&lt;/code&gt; is shorthand for &lt;code&gt;minmax(auto, 1fr)&lt;/code&gt;. That leading &lt;code&gt;auto&lt;/code&gt; is the same automatic minimum. So a track written as &lt;code&gt;1fr&lt;/code&gt; will not go narrower than the content of the items placed in it, unless you tell it otherwise or the item is a scroll container.&lt;/p&gt;
&lt;p&gt;Say you have this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.grid-container {
  display: grid;
  grid-template-columns: 200px 1fr;
}

.grid-item {
  /* overflow: visible by default */
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If the second column holds a long URL or a wide table, the item reports a large min-content size. The &lt;code&gt;1fr&lt;/code&gt; track stretches to honor it. The page gets a horizontal scrollbar instead of the column getting one.&lt;/p&gt;
&lt;p&gt;If the item itself should scroll, &lt;code&gt;overflow&lt;/code&gt; is enough:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.grid-item {
  overflow-x: auto;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The item becomes a scroll container, its automatic minimum becomes &lt;code&gt;0&lt;/code&gt;, and the track stays at its share of the width. No &lt;code&gt;min-width: 0&lt;/code&gt; needed.&lt;/p&gt;
&lt;p&gt;If the scroller is a child of the item, the item keeps its content floor and needs the override:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.grid-item {
  min-width: 0;
}

.grid-item .scroller {
  overflow-x: auto;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Same story vertically:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.app {
  display: grid;
  grid-template-rows: auto 1fr;
  height: 100dvh;
}

.panel {
  /* overflow: visible by default */
}

.list {
  overflow-y: auto;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If the row is &lt;code&gt;auto&lt;/code&gt; sized, it grows with the content by design. If it is a &lt;code&gt;1fr&lt;/code&gt; row but the panel has its automatic minimum intact, the panel grows the row instead of letting the list scroll. Put the zero minimum on the panel:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.panel {
  min-height: 0;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Or let the panel itself be the scroller:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.panel {
  overflow-y: auto;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then &lt;code&gt;min-height: 0&lt;/code&gt; is redundant.&lt;/p&gt;
&lt;p&gt;Flex behaves the same way, with one precision: the automatic minimum is special on the main axis. In a row, &lt;code&gt;min-width: auto&lt;/code&gt; is the content floor; &lt;code&gt;min-height: auto&lt;/code&gt; is not. A flex item with &lt;code&gt;flex: 1&lt;/code&gt; and an inner scroller needs &lt;code&gt;min-width: 0&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.flex-container {
  display: flex;
  width: 300px;
}

.flex-item {
  flex: 1;
  min-width: 0;
}

.flex-item .scroller {
  overflow-x: auto;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If &lt;code&gt;.flex-item&lt;/code&gt; itself has &lt;code&gt;overflow-x: auto&lt;/code&gt;, it is a scroll container and the &lt;code&gt;min-width: 0&lt;/code&gt; is unnecessary.&lt;/p&gt;
&lt;p&gt;Other sizing knobs still apply. &lt;code&gt;flex-basis&lt;/code&gt; sets the starting size before growing and shrinking. &lt;code&gt;min-width&lt;/code&gt; and &lt;code&gt;max-width&lt;/code&gt; win over it. Content-based tracks like &lt;code&gt;auto&lt;/code&gt;, &lt;code&gt;min-content&lt;/code&gt;, and &lt;code&gt;max-content&lt;/code&gt; grow with content by design, so expect overflow there to paint outward unless you clip it:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.grid-container {
  display: grid;
  grid-template-columns: max-content;
}

.grid-item {
  overflow: visible; /* content paints outside the grid area */
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Nested layouts stack the problem. A grid inside a flex item, or a flex column inside a grid row, carries its own automatic minimum at each level:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.app {
  display: grid;
  grid-template-rows: auto 1fr;
  height: 100dvh;
}

.middle {
  /* overflow: visible, and no min-height: 0 */
}

.inner-grid {
  display: grid;
  grid-template-rows: auto 1fr;
  height: 100%;
}

.list {
  overflow-y: auto;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Without &lt;code&gt;min-height: 0&lt;/code&gt; on &lt;code&gt;.middle&lt;/code&gt;, the middle box grows to its content height. The inner grid never gets a bounded height, so the list never scrolls. If any non-scrolling ancestor in the chain refuses to shrink, the inner scroller never sees a bounded box.&lt;/p&gt;
&lt;h2&gt;0x4: Where &lt;code&gt;min-width: 0&lt;/code&gt; and &lt;code&gt;min-height: 0&lt;/code&gt; actually go&lt;/h2&gt;
&lt;p&gt;Setting &lt;code&gt;min-width: 0&lt;/code&gt; or &lt;code&gt;min-height: 0&lt;/code&gt; removes the content-based floor on a box that is not already a scroll container. The box may now be narrower or shorter than its contents. On its own, that only bounds the box; the content still paints outside unless the box also has &lt;code&gt;overflow&lt;/code&gt; set to clip or scroll.&lt;/p&gt;
&lt;p&gt;It helps to separate the two jobs:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;min-width: 0&lt;/code&gt; or &lt;code&gt;min-height: 0&lt;/code&gt; removes the floor.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;overflow: auto&lt;/code&gt;, &lt;code&gt;hidden&lt;/code&gt;, &lt;code&gt;scroll&lt;/code&gt;, or &lt;code&gt;clip&lt;/code&gt; decides what happens to the content that no longer fits.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The scroller does both jobs with &lt;code&gt;overflow&lt;/code&gt; alone. A wrapper only does the first job.&lt;/p&gt;
&lt;p&gt;Here is the horizontal case with the scroller inside the item:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.grid-container {
  display: grid;
  grid-template-columns: 1fr 1fr;
  width: 200px;
}

.grid-item {
  min-width: 0;
}

.grid-item .scroller {
  overflow: hidden;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Without &lt;code&gt;min-width: 0&lt;/code&gt;, a long word forces the track wider than 200px. With it, the item stays in its cell and the inner scroller clips the extra text.&lt;/p&gt;
&lt;p&gt;If the item itself is the scroller, the same markup needs one rule, not two:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.grid-item {
  overflow: hidden;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;overflow: hidden&lt;/code&gt; makes the item a scroll container, so its automatic minimum is already &lt;code&gt;0&lt;/code&gt;, and it clips the content. Adding &lt;code&gt;min-width: 0&lt;/code&gt; here is harmless but redundant. That is the part I had backwards for a while.&lt;/p&gt;
&lt;p&gt;The vertical case is where I got stuck in Trackmaker. I had a full height app shell, a header plus a &lt;code&gt;1fr&lt;/code&gt; row, and a list inside that row that should scroll:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.app {
  display: grid;
  grid-template-rows: auto 1fr;
  height: 100dvh;
}

.panel {
  /* overflow: visible */
}

.list {
  overflow-y: auto;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;height: 100dvh&lt;/code&gt; bounds the grid. &lt;code&gt;1fr&lt;/code&gt; gives the second row the leftover space. But the panel reports its full content height as its minimum, the row grows past the viewport, and the page scrolls. With &lt;code&gt;min-height: 0&lt;/code&gt; on the panel, it accepts the row height, the list gets a bounded box, and &lt;code&gt;overflow-y: auto&lt;/code&gt; scrolls inside:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.panel {
  min-height: 0;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If the panel itself has &lt;code&gt;overflow-y: auto&lt;/code&gt;, the &lt;code&gt;min-height: 0&lt;/code&gt; is unnecessary. The panel is a scroll container, so its minimum is already zero. The version that actually bit me had the overflow on the list, with the panel still &lt;code&gt;overflow: visible&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../assets/posts/css-overflow-behaviour/zero-minimum-chain.svg&quot; alt=&quot;Nested boxes for a bounded ancestor, a 1fr grid row, a wrapper with min-height zero, and a scroller with overflow-y auto&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;The zero minimum belongs on the non-scrolling wrapper, not on the scroller.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Two details I keep forgetting:&lt;/p&gt;
&lt;p&gt;First, every non-scrolling wrapper between the bounded height and the scroller needs the override. If &lt;code&gt;.panel&lt;/code&gt; contains another grid and the list is one more level down, that middle wrapper also needs &lt;code&gt;min-height: 0&lt;/code&gt;. One stubborn ancestor is enough to stretch the whole chain.&lt;/p&gt;
&lt;p&gt;Second, &lt;code&gt;minmax(0, 1fr)&lt;/code&gt; on the track does the same job from the other side. &lt;code&gt;grid-template-columns: minmax(0, 1fr) minmax(0, 1fr)&lt;/code&gt; replaces the implicit &lt;code&gt;auto&lt;/code&gt; minimum with &lt;code&gt;0&lt;/code&gt;, so items in those tracks may shrink even if they still say &lt;code&gt;min-width: auto&lt;/code&gt;. I put &lt;code&gt;min-w-0&lt;/code&gt; on each wrapper that contains the scroller, and use &lt;code&gt;minmax(0, 1fr)&lt;/code&gt; when the whole track should be allowed to shrink.&lt;/p&gt;
&lt;p&gt;In flex the fix reads the same:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.container {
  display: flex;
  width: 200px;
}

.item {
  flex: 1;
  min-width: 0;
}

.item .scroller {
  overflow: hidden;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Leave out &lt;code&gt;min-width: 0&lt;/code&gt; and the item pushes past 200px to fit its content. Put it back and the item holds at 200px and the inner scroller clips. If the item itself has &lt;code&gt;overflow: hidden&lt;/code&gt;, the item is already a scroll container and the &lt;code&gt;min-width: 0&lt;/code&gt; is redundant.&lt;/p&gt;
&lt;p&gt;So the rule I use: &lt;code&gt;overflow&lt;/code&gt; needs a box with a bounded size to work against. In grid and flex, &lt;code&gt;min-width: 0&lt;/code&gt; or &lt;code&gt;min-height: 0&lt;/code&gt; keeps that bound intact on the boxes that are not scrolling. If the box that scrolls is the same box, &lt;code&gt;overflow&lt;/code&gt; already does it.&lt;/p&gt;
&lt;h2&gt;0x5: The pattern I use now&lt;/h2&gt;
&lt;p&gt;For a scrollable pane inside a grid cell, I write three things:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;A bounded ancestor: a fixed height, &lt;code&gt;h-screen&lt;/code&gt;, &lt;code&gt;h-dvh&lt;/code&gt;, or a &lt;code&gt;1fr&lt;/code&gt; row inside one.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;overflow-y: auto&lt;/code&gt; or &lt;code&gt;overflow-x: auto&lt;/code&gt; on the element that should scroll.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;min-height: 0&lt;/code&gt; or &lt;code&gt;min-width: 0&lt;/code&gt; on every non-scrolling wrapper between the two.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;In Tailwind that is usually &lt;code&gt;min-h-0&lt;/code&gt; on the wrappers and &lt;code&gt;overflow-y-auto&lt;/code&gt; on the scroller. If the scroller is the direct child of the grid, &lt;code&gt;overflow-y-auto&lt;/code&gt; alone is enough; &lt;code&gt;min-h-0&lt;/code&gt; on the same element is redundant.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Where the overflow is&lt;/th&gt;
&lt;th&gt;What removes the floor&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;On the item itself&lt;/td&gt;
&lt;td&gt;&lt;code&gt;overflow&lt;/code&gt; already removes it; &lt;code&gt;min-*: 0&lt;/code&gt; is redundant&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;On a child of the item&lt;/td&gt;
&lt;td&gt;&lt;code&gt;min-*: 0&lt;/code&gt; on every non-scrolling wrapper between the bounded ancestor and the child&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;On the track&lt;/td&gt;
&lt;td&gt;&lt;code&gt;minmax(0, 1fr)&lt;/code&gt; instead of &lt;code&gt;1fr&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Tailwind&apos;s &lt;code&gt;grid-cols-*&lt;/code&gt; utilities already generate &lt;code&gt;repeat(n, minmax(0, 1fr))&lt;/code&gt;, so grid children rarely blow out in Tailwind. Flex children still need &lt;code&gt;min-w-0&lt;/code&gt; when the scroller is nested, because &lt;code&gt;flex: 1&lt;/code&gt; keeps the content-based minimum on the main axis.&lt;/p&gt;
&lt;p&gt;If it still does not scroll, I walk up the tree and look for the ancestor that grew. It is almost always a wrapper with &lt;code&gt;overflow: visible&lt;/code&gt; and no &lt;code&gt;min-h-0&lt;/code&gt; one level up.&lt;/p&gt;
&lt;h2&gt;0x6: Related behaviours worth knowing&lt;/h2&gt;
&lt;h3&gt;&lt;code&gt;hidden&lt;/code&gt; is not the same as &lt;code&gt;clip&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;overflow: hidden&lt;/code&gt; clips, but it still makes the box a scroll container. You can scroll it with &lt;code&gt;element.scrollTop&lt;/code&gt;, &lt;code&gt;scrollIntoView()&lt;/code&gt;, keyboard focus, and find-in-page. That can be surprising when a hidden panel jumps because something inside received focus. &lt;code&gt;overflow: clip&lt;/code&gt; clips and does not create a scroll container, so none of those can scroll it. &lt;code&gt;overflow-clip-margin&lt;/code&gt; lets the content paint a little past the clip edge before it is cut.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;hidden&lt;/code&gt; being a scroll container is also why it removes the automatic minimum in grid and flex. &lt;code&gt;clip&lt;/code&gt; does not, which is why it needs &lt;code&gt;min-width: 0&lt;/code&gt; if you also want the box to shrink.&lt;/p&gt;
&lt;h3&gt;One axis visible, one axis clipped&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;overflow-x: hidden; overflow-y: visible&lt;/code&gt; does not do what it looks like. When one axis is &lt;code&gt;hidden&lt;/code&gt;, &lt;code&gt;scroll&lt;/code&gt;, or &lt;code&gt;auto&lt;/code&gt;, the other axis cannot stay &lt;code&gt;visible&lt;/code&gt;; it computes to &lt;code&gt;auto&lt;/code&gt;. If you need horizontal clipping with vertical overflow visible, use &lt;code&gt;overflow-x: clip; overflow-y: visible&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;Clipping does not catch fixed or outside-positioned elements&lt;/h3&gt;
&lt;p&gt;An element with &lt;code&gt;position: fixed&lt;/code&gt; is positioned relative to the viewport, so a scroll container&apos;s &lt;code&gt;overflow&lt;/code&gt; does not clip it. That is why a tooltip or dropdown can escape a panel that is supposed to contain it. The exception is an ancestor with &lt;code&gt;transform&lt;/code&gt;, &lt;code&gt;filter&lt;/code&gt;, &lt;code&gt;perspective&lt;/code&gt;, or &lt;code&gt;contain&lt;/code&gt;, which makes that ancestor the containing block.&lt;/p&gt;
&lt;p&gt;Absolutely positioned elements are clipped only when the scroll container is in their containing block chain. If their containing block is outside the scroller, they can paint outside the clip too.&lt;/p&gt;
&lt;p&gt;The modern fix for menus and tooltips is the top layer: &lt;code&gt;popover&lt;/code&gt; or &lt;code&gt;&amp;lt;dialog&amp;gt;&lt;/code&gt;. Elements in the top layer are not clipped by ancestor overflow.&lt;/p&gt;
&lt;h3&gt;Sticky headers and overflow&lt;/h3&gt;
&lt;p&gt;A &lt;code&gt;position: sticky&lt;/code&gt; element sticks within its nearest scrollport. An ancestor with &lt;code&gt;overflow: hidden&lt;/code&gt;, &lt;code&gt;auto&lt;/code&gt;, or &lt;code&gt;scroll&lt;/code&gt; becomes that scrollport. If the ancestor does not actually scroll, the sticky element has nothing to stick to, which is why adding &lt;code&gt;overflow: hidden&lt;/code&gt; somewhere up the tree can break a sticky header. &lt;code&gt;overflow: clip&lt;/code&gt; does not create a scroll container, so it does not have this effect.&lt;/p&gt;
&lt;h3&gt;Nested scrolling&lt;/h3&gt;
&lt;p&gt;When a pane scrolls inside a page, reaching the end of the pane can start scrolling the page. &lt;code&gt;overscroll-behavior: contain&lt;/code&gt; stops that. &lt;code&gt;scrollbar-gutter: stable&lt;/code&gt; reserves space for the scrollbar so the layout does not shift when &lt;code&gt;auto&lt;/code&gt; decides to show one.&lt;/p&gt;
&lt;h3&gt;Viewport height&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;height: 100vh&lt;/code&gt; is taller than the visible viewport on mobile browsers with a collapsible address bar. Use &lt;code&gt;100dvh&lt;/code&gt; for an app shell, or &lt;code&gt;100svh&lt;/code&gt; if you want the small viewport size. Also, &lt;code&gt;height: 100%&lt;/code&gt; needs a parent with a definite height; &lt;code&gt;min-height&lt;/code&gt; does not give the parent one, so a percentage height often collapses to &lt;code&gt;auto&lt;/code&gt; even when the parent looks tall.&lt;/p&gt;
&lt;h3&gt;Debugging&lt;/h3&gt;
&lt;p&gt;Two checks answer most of these questions:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const el = document.querySelector(&quot;.panel&quot;);
console.log(el.scrollHeight &amp;gt; el.clientHeight); // is it overflowing?
console.log(getComputedStyle(el).minHeight); // what floor is in play?
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;DevTools marks scrollable elements in the Elements panel, and you can toggle &lt;code&gt;min-width: 0&lt;/code&gt; or &lt;code&gt;min-height: 0&lt;/code&gt; live to see which box starts growing. If the scrollbar appears on the page instead of the pane, the pane is not the box with the bounded size.&lt;/p&gt;
&lt;h2&gt;0x7: Symptoms and fixes&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Likely cause&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;The whole page scrolls horizontally&lt;/td&gt;
&lt;td&gt;A grid or flex item is growing its container because its automatic minimum is content-based&lt;/td&gt;
&lt;td&gt;&lt;code&gt;min-width: 0&lt;/code&gt; on the non-scrolling item, or &lt;code&gt;minmax(0, 1fr)&lt;/code&gt; on the track&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The scrollbar never appears&lt;/td&gt;
&lt;td&gt;The box grew to fit its content, so nothing overflowed&lt;/td&gt;
&lt;td&gt;Bound the box, then put &lt;code&gt;overflow&lt;/code&gt; on the scroller&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The scrollbar appears on the wrong element&lt;/td&gt;
&lt;td&gt;&lt;code&gt;overflow&lt;/code&gt; is on an ancestor of the intended scroller&lt;/td&gt;
&lt;td&gt;Move &lt;code&gt;overflow&lt;/code&gt; to the element that should scroll&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Content is clipped instead of scrolling&lt;/td&gt;
&lt;td&gt;&lt;code&gt;hidden&lt;/code&gt; instead of &lt;code&gt;auto&lt;/code&gt; or &lt;code&gt;scroll&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Use &lt;code&gt;overflow-y: auto&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Content is clipped closer than you want&lt;/td&gt;
&lt;td&gt;&lt;code&gt;hidden&lt;/code&gt; or &lt;code&gt;clip&lt;/code&gt; on an ancestor&lt;/td&gt;
&lt;td&gt;Remove the overflow, or use &lt;code&gt;overflow: clip&lt;/code&gt; with &lt;code&gt;overflow-clip-margin&lt;/code&gt; to allow some paint-out&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A sticky header stopped working&lt;/td&gt;
&lt;td&gt;An ancestor has &lt;code&gt;overflow: hidden&lt;/code&gt;, &lt;code&gt;auto&lt;/code&gt;, or &lt;code&gt;scroll&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Move the scroll container, or use &lt;code&gt;overflow: clip&lt;/code&gt; if you only need clipping&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A tooltip or dropdown is cut off&lt;/td&gt;
&lt;td&gt;It is inside a scroll container&lt;/td&gt;
&lt;td&gt;Use &lt;code&gt;popover&lt;/code&gt; or &lt;code&gt;&amp;lt;dialog&amp;gt;&lt;/code&gt;, or move it outside the clip&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The pane scrolls, then the page scrolls at the end&lt;/td&gt;
&lt;td&gt;Scroll chaining&lt;/td&gt;
&lt;td&gt;&lt;code&gt;overscroll-behavior: contain&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The layout shifts when the scrollbar appears&lt;/td&gt;
&lt;td&gt;&lt;code&gt;auto&lt;/code&gt; adds a scrollbar only when needed&lt;/td&gt;
&lt;td&gt;&lt;code&gt;scrollbar-gutter: stable&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;If the pane still does not scroll, the box that grew is the one to fix. It is usually one level above the scroller, with &lt;code&gt;overflow: visible&lt;/code&gt; and no &lt;code&gt;min-height: 0&lt;/code&gt;.&lt;/p&gt;
</content:encoded></item><item><title>A TypeScript Based Agent-based Disease Model</title><link>https://blog.ansonate.ink/posts/agent-based-disease-model/</link><guid isPermaLink="true">https://blog.ansonate.ink/posts/agent-based-disease-model/</guid><description>An agent-based model implemented in TypeScript that simulates the spread of disease, compared with the classical SIR model.</description><pubDate>Mon, 01 Jul 2024 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;This post is transcribed from my paper &lt;em&gt;A TypeScript Based Agent-based Disease Model&lt;/em&gt;. The code of the model is available on GitHub: &lt;a href=&quot;https://github.com/Anson2251/camp-agent-base-model&quot;&gt;Anson2251/camp-agent-base-model&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;Introduction&lt;/h2&gt;
&lt;p&gt;An agent-based model is a computer simulation of the interaction of agents (people) that is used to predict and demonstrate the spread of diseases (&lt;a href=&quot;https://www.publichealth.columbia.edu/research/population-health-methods/agent-based-modeling&quot;&gt;Columbia University Irving Medical Center&lt;/a&gt;). This post presents a TypeScript-based agent-based model that can partially simulate the spread of disease.&lt;/p&gt;
&lt;h3&gt;Mathematics Theory of Spread of Disease&lt;/h3&gt;
&lt;p&gt;In the field of mathematics, there also exists a model known as the SIR model, which is a set of differential equations that describe the number of people who are susceptible ($S$, not infected), infected ($I$), and recovered ($R$, have recovered from the illness and are no longer susceptible to the same disease). The equations of the SIR model are as follows:&lt;/p&gt;
&lt;p&gt;$$
\begin{aligned}
\frac{\mathrm{d} S}{\mathrm{d} t} &amp;amp;= -\beta S I \
\frac{\mathrm{d} I}{\mathrm{d} t} &amp;amp;= \beta S I - \gamma I \
\frac{\mathrm{d} R}{\mathrm{d} t} &amp;amp;= \gamma I
\end{aligned}
$$&lt;/p&gt;
&lt;p&gt;The flow chart of the SIR model (after &lt;a href=&quot;https://medium.com/@ln8378/simulation-102-agent-based-disease-modeling-17307f2de2f3&quot;&gt;Le Nguyen&lt;/a&gt;) is:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;flowchart TD
    S[Susceptible] -- &quot;β&quot; --&amp;gt; I[Infectious]
    I -- &quot;γ&quot; --&amp;gt; R[Recovered]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;../../assets/posts/agent-based-disease-model/sir-graph.png&quot; alt=&quot;The diagram of the SIR model (β = 1 and γ = 0.1, after Joaquin de Castro)&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;The diagram of the SIR model ($\beta=1$ and $\gamma=0.1$), after &lt;a href=&quot;https://pub.towardsai.net/graphing-the-sir-model-with-python-e3cd6edb20de&quot;&gt;Joaquin de Castro&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;Methodology&lt;/h2&gt;
&lt;h3&gt;Agent-based Model&lt;/h3&gt;
&lt;p&gt;In order to construct an agent-based model, it is necessary to define the agents, their behaviours, the interactions between them, and the environment in which the agents operate.
The agent-based model was implemented in TypeScript due to the author&apos;s familiarity with this programming language.
In general, agents are collections of properties, such as name, age, gender, health, and so on. They are used to represent the individuals, playing an important role in the simulation.
The behaviours of the agents in this model represent their daily routines and the locations of the agents at a specific time.&lt;/p&gt;
&lt;h3&gt;Environment&lt;/h3&gt;
&lt;p&gt;The term &quot;environment&quot; is defined as the space in which the agents reside. The dynamics of the agents can be understood as the movement of the agents within this space, which is influenced by their routines.&lt;/p&gt;
&lt;h3&gt;Dynamics&lt;/h3&gt;
&lt;h4&gt;Infection Possibility&lt;/h4&gt;
&lt;p&gt;In order to simulate the spread of the disease, it is necessary to consider how healthy agents interact with infected agents and become infected. If infected agents move in close proximity to healthy agents, the risk of infection will be increased. A simple formula that can be used to roughly represent this is as follows.&lt;/p&gt;
&lt;p&gt;$$
\mathbb{P}(\text{Infection}) =
\begin{cases}
\dfrac{1}{d^2} &amp;amp; \text{for } d &amp;gt; 1 \
1 &amp;amp; \text{for } d \leq 1
\end{cases}
\quad \text{($d$ is the distance between the agents)}
$$&lt;/p&gt;
&lt;p&gt;The graph of the above formula is shown in the figure below.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;../../assets/posts/agent-based-disease-model/infection-possibility-graph.png&quot; alt=&quot;The graph of possibility of infection&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Where the $x$ is the distance between the agents, calculated by the formula.&lt;/p&gt;
&lt;p&gt;$$
d = \sqrt{(x_1 - x_2)^2 + (y_1 - y_2)^2}
$$&lt;/p&gt;
&lt;p&gt;Every agent will randomly get infected with a certain probability.&lt;/p&gt;
&lt;h4&gt;Restoration&lt;/h4&gt;
&lt;p&gt;Upon infection of the agents, a timer will commence a countdown. Upon reaching zero, the timer will initiate the restoration of the agents. It can be assumed that the restored agents will not become infected by the same disease.&lt;/p&gt;
&lt;h3&gt;Implementation&lt;/h3&gt;
&lt;p&gt;This model is implemented in TypeScript for the purpose of type safety, which enables the model to be well structured in a logical and comprehensible manner. However, the graphing of the model is implemented in R due to the availability of the powerful plotting package &lt;code&gt;ggplot2&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The model&apos;s graphical representation (see Appendix: model structure), which illustrates the design and the manner in which the modules interact with one another, can be found in the appendix.
The flow chart of the model (see Appendix: flow chart) is also included in the appendix.&lt;/p&gt;
&lt;h2&gt;Results&lt;/h2&gt;
&lt;p&gt;The report generated by the model is appended in the Appendix. The graphic part of the report and the parameters are shown below.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Simulation Time: 60&lt;/li&gt;
&lt;li&gt;Agent Number: 40&lt;/li&gt;
&lt;li&gt;Room Size: 40&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The disease in this simulation model:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Disease Name: Cold&lt;/li&gt;
&lt;li&gt;Disease Severity: 100&lt;/li&gt;
&lt;li&gt;Disease Time to Restore: 10&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;../../assets/posts/agent-based-disease-model/simulation-diagram.png&quot; alt=&quot;The diagram of the created agent-based model&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;The diagram of the created agent-based model.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;It is evident that this model is capable of partially simulating the spread of the disease with given parameters in relation to the agents and diseases, due to the similarity between the SIR diagram above and this figure, which depict outbreaks and extinctions.&lt;/p&gt;
&lt;h2&gt;Discussion&lt;/h2&gt;
&lt;p&gt;This model is an effective tool for simulating the simple spread of the disease. It has been developed in TypeScript, a language chosen for its maintainability, but it lacks the ability to simulate the multiple diseases with complex dynamics. Some features that could facilitate the migration and control of parameters such as $\beta$ and $\gamma$ are missing and should be added in the future. And this model uses recover time to simulate, not the possibility to recover for the agents. The author has chosen to write in TypeScript, a language that is not commonly used for data analysis, due to its familiarity. This model also lacks simulation for some complex mechanisms between the infection possibility and the age, health of the agents, the severity of the disease, etc.&lt;/p&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;The model described above is capable of accurately simulating the spread of the disease, including the occurrence of outbreaks and extinctions.&lt;/p&gt;
&lt;h2&gt;References&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Columbia University Irving Medical Center, &lt;em&gt;Agent-Based Modeling&lt;/em&gt;, &lt;a href=&quot;https://www.publichealth.columbia.edu/research/population-health-methods/agent-based-modeling&quot;&gt;link&lt;/a&gt; (Accessed: 2024-07-01).&lt;/li&gt;
&lt;li&gt;Le Nguyen, &lt;em&gt;Simulation 102: Agent-Based Disease Modeling&lt;/em&gt; (2023), &lt;a href=&quot;https://medium.com/@ln8378/simulation-102-agent-based-disease-modeling-17307f2de2f3&quot;&gt;link&lt;/a&gt; (Accessed: 2024-07-01).&lt;/li&gt;
&lt;li&gt;Joaquin de Castro, &lt;em&gt;Graphing The SIR Model With Python&lt;/em&gt; (2020), &lt;a href=&quot;https://pub.towardsai.net/graphing-the-sir-model-with-python-e3cd6edb20de&quot;&gt;link&lt;/a&gt; (Accessed: 2024-07-01).&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Appendix&lt;/h2&gt;
&lt;h3&gt;The Test Run Report for the Model&lt;/h3&gt;
&lt;p&gt;The content of the report can be found in the following URL (&lt;code&gt;JSON&lt;/code&gt; format): &lt;a href=&quot;https://pastecode.io/s/twvh8go9&quot;&gt;https://pastecode.io/s/twvh8go9&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;The Structure of the Agent-based Model&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;../../assets/posts/agent-based-disease-model/model-structure.png&quot; alt=&quot;The structure of the created agent-based model&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;The Flow Chart of the Agent-based Model&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;../../assets/posts/agent-based-disease-model/model-flow-chart.png&quot; alt=&quot;The flow chart of the created agent-based model&quot; /&gt;&lt;/p&gt;
</content:encoded></item><item><title>New Blog Site!</title><link>https://blog.ansonate.ink/posts/new-blog-site/</link><guid isPermaLink="true">https://blog.ansonate.ink/posts/new-blog-site/</guid><description>Announcing the new blog and the plan to turn old project pages into posts.</description><pubDate>Mon, 24 Jun 2024 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;Introduction&lt;/h2&gt;
&lt;p&gt;I have updated my personal site to replace the previous plain Markdown-based site. The new site is built on Jekyll, allowing for better presentation and easier organization of my content.&lt;/p&gt;
&lt;h2&gt;Migration of Content&lt;/h2&gt;
&lt;p&gt;I will be transferring the content from the &lt;code&gt;Useful Links&lt;/code&gt; section of the old site into individual blog posts that explain how the projects work. This process will take some time, but I will complete it gradually.&lt;/p&gt;
&lt;h2&gt;Useful Links from the Previous Page&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;The repository for this site: &lt;a href=&quot;https://github.com/Anson2251/Anson2251.github.io&quot;&gt;这个主页的储存库&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Sequence Calculator: &lt;a href=&quot;https://anson2251.github.io/sequence/&quot;&gt;数列计算器&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Binomial Expansion Calculator: &lt;a href=&quot;https://anson2251.github.io/binomial-expansion-calculator/&quot;&gt;展开 (a+b)^n&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Solve2048 Demo: &lt;a href=&quot;https://anson2251.github.io/solve2048/&quot;&gt;solve2048 demo&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Why Jekyll?&lt;/h2&gt;
&lt;p&gt;Jekyll is a convenient tool for transforming plain text, written in Markdown, into static websites and blogs, which suits my preferences perfectly. GitHub also supports Jekyll with workflows for building and hosting the site online, allowing for a smooth transition.&lt;/p&gt;
&lt;h2&gt;Future Posts&lt;/h2&gt;
&lt;p&gt;In upcoming posts, I will explain the design ideas behind the projects listed in the &lt;code&gt;Useful Links&lt;/code&gt; section. I will also refurbish the projects and update the README files for each project.&lt;/p&gt;
</content:encoded></item></channel></rss>