PageSourceSearch

https://hanping.app/lists/cmn/radicals/

html hanping.app collected 2026-10-02 00:25:32 UTC 35,204 bytes, 427 lines download raw bytes

1<!DOCTYPE html><html lang="en"> <head><meta charset="UTF-8"><!-- Before ANYTHING else this file emits — the preconnect, the font preloads, the pre-paint
2		     scripts, and the stylesheets Astro appends after them. For the one thing that has to run
3		     before the page asks for a single byte it may not need: a page that decides the reader
4		     belongs at another URL and sends them there. A classic script does not run while a
5		     stylesheet is pending, so the same script anywhere lower waits on a CSS round trip — and
6		     that round trip is also what unblocks the first paint, so the document it was meant to
7		     replace paints first and sits there for the whole of the next navigation.
8		     Empty on every page but that one, which is the point: nothing else needs to be here. -->
8<script>(function(){const targets = {"simp":"/lists/cmn/radicals/simp/","trad":"/lists/cmn/radicals/trad/"};
9
10    (function () {
11      var hanzi = null;
12      try {
13        hanzi = localStorage.getItem('dictHanziType');
14      } catch (e) {}
15      // The same test Layout's head script applies for its pref-hanzi-trad class, so the two
16      // inline readers of this key agree on every stored value.
17      var script = hanzi === 'TRAD' || hanzi === 'TRAD_SIMP' ? 'trad' : 'simp';
18      location.replace(targets[script]);
19    })();
20  })();</script>
20<!-- Every byte of dictionary data — the search indexes, per-word JSON, stroke outlines, exam
21		     levels, audio — comes from this one cross-origin host, and none of it can be requested
22		     until the module that names it has downloaded and run. That leaves DNS, TCP and TLS to a
23		     third origin sitting between the search script landing and the first index byte arriving,
24		     on a page whose indexes are the largest thing it fetches. Warming the connection here
25		     moves that handshake into the time the browser is already spending on the HTML.
26		     Only this one origin: preconnect is a hint with a real cost (a held-open socket), so it
27		     is worth spending on the host that is certain to be used and expensive to reach late,
28		     and not on the ones that might be. --><link rel="preconnect" href="https://dict.hanping.app" crossorigin><!-- Resolve the theme before first paint. ThemeToggle.astro owns the BUTTON — its icon, its
29		     labels, its click — but it cannot own this: its script is a bundled module, and modules
30		     are deferred, so it only runs once the whole document is parsed. That is far too late to
31		     decide a background colour. A dark-theme visitor therefore got a full paint of the LIGHT
32		     theme first and a visible white flash on every hard load, which is exactly what a screen
33		     recording of a dict link opened from a chat app showed.
34		     is:inline and in <head> for the same reason the font block below is: bundled, it would
35		     land after the paint it is meant to precede. Nothing here writes localStorage — see
36		     ThemeToggle.astro for why an explicit choice and a resolved system preference must not
37		     be stored the same way. -->
37<script>
38			var applyTheme = function (root) {
39				var wantsDark;
40				try {
41					var storedTheme = localStorage.getItem('theme');
42					// Stored value wins; with nothing stored the system preference decides, which is
43					// the state ThemeToggle's own media-query listener keeps following.
44					wantsDark = storedTheme
45						? storedTheme === 'dark'
46						: window.matchMedia('(prefers-color-scheme: dark)').matches;
47				} catch (e) {
48					// localStorage can throw outright (Chrome with all cookies blocked). The light
49					// theme is the stylesheet's own default, so falling back to it changes nothing.
50					wantsDark = false;
51				}
52				(root || document.documentElement).classList.toggle('dark', wantsDark);
53			};
54			applyTheme();
55			// Whether this browser has a share sheet at all — false on Firefox everywhere, and on
56			// Chrome for Windows/Linux. The dict page's hero action row (dict-format.ts's
57			// heroActions) drops its share button when it doesn't, and that has to be settled
58			// BEFORE the first paint for the same reason the theme does: decided from a bundled
59			// module instead, the row would paint with three icons and then re-center itself around
60			// two. Rides in this script rather than one of its own only because it needs the same
61			// before-swap re-application below, for the same reason — swapRootAttributes would
62			// otherwise take this class off <html> along with .dark.
63			var applyShareSupport = function (root) {
64				(root || document.documentElement).classList.toggle('no-web-share', !navigator.share);
65			};
66			applyShareSupport();
67			// Whether there is a Hanping app this page could hand its word to — which is a question
68			// about the PLATFORM, not the browser: the button it gates (dict-format.ts's
69			// renderOpenInApp) points at an https deep link both mobile platforms resolve into the
70			// app, so every browser on either of them can follow it. A desktop has no app to open
71			// and would get a button that reloads the page, which is why this is a gate at all.
72			//
73			// iOS was originally left to Safari's own Smart App Banner (see the meta tag below,
74			// which is still there). That was the wrong bet on its own: the banner cannot appear in
75			// Chrome or Firefox for iOS, cannot appear at all in the home-screen PWA, since a
76			// standalone window has no Safari chrome to draw it in, and was not appearing in Safari
77			// either. The button is the offer that works in all of them.
78			//
79			// iPadOS 13+ is why the second test exists: it reports itself as a Macintosh, and the
80			// touch points are what give it away. The desktop Safari that UA otherwise describes
81			// reports 0.
82			//
83			// The UA string, not userAgentData: Chrome's reduced UA still carries "Android" (it is
84			// the platform, not the version detail the reduction hides), every other Android browser
85			// carries it too, and the modern API is unavailable on the browsers — Firefox, and every
86			// iOS one — that would otherwise be excluded by asking.
87			//
88			// Pre-paint for the same reason as the share check above, and it matters more here: this
89			// is a labelled button rather than a 44px icon, so deciding it late would add a block to
90			// the foot of the page a moment after it painted. Same before-swap re-application, same
91			// reason.
92			var applyPlatform = function (root) {
93				var ua = navigator.userAgent;
94				var el = root || document.documentElement;
95				el.classList.toggle('is-android', /Android/.test(ua));
96				el.classList.toggle(
97					'is-ios',
98					/iPad|iPhone|iPod/.test(ua) || (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1),
99				);
100			};
101			applyPlatform();
102			if (!window.__themeArmed) {
103				window.__themeArmed = true;
104				// Onto the INCOMING document at astro:before-swap, not back onto the live one at
105				// astro:after-swap. swapRootAttributes replaces <html>'s attributes wholesale with the
106				// new document's, so a class re-added afterwards has a mid-swap gap where <html> is
107				// missing it — and the swap's own scroll restoration forces a style recalc inside that
108				// gap, which is enough to start every transition keyed off the class (the header's
109				// sun/moon animated on each soft navigation even though nothing wrong ever painted).
110				// Classes carried by the incoming root arrive in the same setAttribute as the rest, so
111				// there is no gap at all. ThemeToggle's own after-swap handler then just reads back
112				// what this decided.
113				document.addEventListener('astro:before-swap', function (e) {
114					applyTheme(e.newDocument.documentElement);
115					applyShareSupport(e.newDocument.documentElement);
116					applyPlatform(e.newDocument.documentElement);
117				});
118			}
119		</script>
119<!-- Recover from a hashed asset that 404s, which strands the page with its scripts never
120		     run — most visibly the search page, whose search box keeps its markup but loses every listener
121		     initDictSearch would have attached, so it just sits there accepting text and returning
122		     nothing. Everything under /assets/ is content-hashed and immutable, so a miss never
123		     means "changed underneath us": it means this document and the deployment's asset
124		     manifest disagree, which is true for a few seconds while a new Pages deployment
125		     propagates across colos. functions/lib/static-asset-guard.ts is the other half of this
126		     — it stops that same miss being cached for a year — but a no-store 404 only helps the
127		     NEXT request, and nothing was making that next request happen.
128		     Reloading is the whole fix, and only because BOTH sides are refetchable: the guard's
129		     404 is no-store, and the document is never cached (max-age=0, must-revalidate, no ETag)
130		     — which the Function-rendered word pages only started honoring alongside this comment.
131		     They answered max-age=300 before, and a freshness lifetime is served from cache without
132		     asking, so a reload handed back the very same stale document and this script could only
133		     spin. See functions/lib/dict-render.ts: that was the larger half of the unstyled page,
134		     and it is what makes a reload worth issuing at all.
135		     is:inline so it ships in the HTML itself rather than as one more file in the very
136		     directory it is watching — bundled, it would be missing exactly when it is needed. -->
136<script>
137			// Guarded because the view-transitions router re-runs head scripts across a swap, and a
138			// second listener would mean a second reload for one failure.
139			if (!window.__assetReloadArmed) {
140				window.__assetReloadArmed = true;
141				var assetReloadKey = 'asset-reload-attempts';
142				var MAX_RELOADS = 3;
143				var assetLoadFailed = false;
144				// Resource-load errors don't bubble, so capture on window is the only way to see them.
145				window.addEventListener('error', function (event) {
146					var el = event.target;
147					// Scripts and stylesheets only: a missing image under /assets/ is cosmetic, and
148					// reloading the page over one would spend a reload without fixing anything.
149					if (!el || (el.tagName !== 'SCRIPT' && el.tagName !== 'LINK')) return;
150					var url = el.src || el.href;
151					if (typeof url !== 'string' || url.indexOf('/assets/') === -1) return;
152					// One page load is one attempt, however many resources it takes down with it. A
153					// document/manifest mismatch is never a single miss — this page carries a dozen
154					// /assets/ subresources (two font preloads, three stylesheets, seven module scripts)
155					// and they fail together — so counting error EVENTS spent the entire budget inside
156					// the FIRST load, before the reload it scheduled had even run. That reload then found
157					// the counter already at the cap and gave up, which is how a visitor ended up parked
158					// on the unstyled page: the recovery this block exists to provide had already
159					// exhausted itself against a single failure. Everything after the first error is that
160					// same failure reported again, so the flag has to gate the counter, not just `load`.
161					if (assetLoadFailed) return;
162					assetLoadFailed = true;
163					var attempts;
164					try {
165						attempts = Number(sessionStorage.getItem(assetReloadKey)) || 0;
166						// Enough consecutive failed loads means this isn't a deploy blip — a bad build,
167						// an offline visitor, a blocked request — and further reloads would only spin.
168						if (attempts >= MAX_RELOADS) return;
169						sessionStorage.setItem(assetReloadKey, String(attempts + 1));
170					} catch (e) {
171						// Storage disabled (private mode, blocked cookies): without somewhere to count
172						// attempts a reload can't be bounded, so don't start one.
173						return;
174					}
175					// Backing off, and never instant: a reload issued in the same moment has nothing new
176					// to find, because what it is waiting on (a deployment reaching this colo) does not
177					// finish in zero time. 0.5s, 2.5s, 4.5s — seven seconds of trying in total, which is
178					// the shape of the window this recovers from.
179					setTimeout(function () { location.reload(); }, 500 + attempts * 2000);
180				}, true);
181				// The counter tracks CONSECUTIVE failures, so a clean load clears it — otherwise the
182				// first blip would permanently spend this tab's retry budget. load fires even when a
183				// subresource 404s, hence the flag rather than trusting load on its own.
184				window.addEventListener('load', function () {
185					if (assetLoadFailed) return;
186					try { sessionStorage.removeItem(assetReloadKey); } catch (e) {}
187				});
188			}
189		</script>
189<!-- The block period for UKaiSubset/PhoneticSubset — the half of font loading CSS can't
190		     express. See the long writeup on @font-face UKaiSubset in src/styles/shared.css: those
191		     faces need a SHORT block period with an UNBOUNDED swap period, and no font-display
192		     value pairs those two (fallback, which does both but caps the swap at ~3s, left every
193		     hard refresh permanently on the device font, since a 4.3MB subset never lands inside
194		     that cap). So the descriptor keeps swap for its unbounded swap period, and the block
195		     period is this script plus shared.css's .webfonts-blocking rule.
196		     The block period ends on a STATE, not a clock — see fontBytesArrived below for why the
197		     800ms deadline this used to carry could not work at this file size.
198		     is:inline and placed high in <head> because it has to take effect before first paint —
199		     bundled, it would land after the text it is meant to hold back. -->
199<script>
200			// Guarded like the reload script above: the view-transitions router re-runs head scripts
201			// across a swap, and by then the fonts are long since loaded — a second block period
202			// would just blink the hanzi out on every soft navigation.
203			if (!window.__webfontBlockArmed) {
204				window.__webfontBlockArmed = true;
205				var fontRoot = document.documentElement;
206				fontRoot.classList.add('webfonts-blocking');
207				var revealFonts = function () { fontRoot.classList.remove('webfonts-blocking'); };
208				// What this gate has to tell apart is "the font bytes are already here and only the
209				// DECODE is left" from "this is a real download". The first is always worth waiting
210				// for; the second never is. A clock cannot tell them apart, which is exactly what the
211				// previous 800ms deadline got wrong: decoding the 4.3MB UKai subset costs ~165ms of
212				// main-thread work on a fast desktop (measured three times over the real file via
213				// new FontFace(arrayBuffer)), and several times that on a phone — so a fully cached
214				// load blew the deadline just like a cold one and painted the device font on EVERY
215				// visit, which is how this was reported (a cached font still flashing every time).
216				// The signal used instead is the UKai preload's Resource Timing entry: it appears at
217				// responseEnd, so its presence means the bytes are in hand — measured at ~3ms with
218				// transferSize 0 on a warm load, versus 4.3MB of transfer on a cold one. Deliberately
219				// NOT a transferSize check: "cached" was only ever a proxy for "the bytes arrive
220				// fast", and if they did arrive that fast off the network the right thing is still to
221				// wait for the decode. It also keeps working when the service worker serves the font
222				// from Cache API, where transferSize reads 0 no matter where the bytes came from.
223				var fontLink = document.getElementById('ukai-preload');
224				// How long to give those bytes before calling this a download. Slack for a phone
225				// reading 4.3MB off flash storage, not a budget for a fetch — a 4.3MB fetch cannot
226				// finish in this window, which is the whole point.
227				var fontBytesProbe = 250;
228				// Purely a safety net for a device slower than anything measured here, not policy:
229				// the decode is what's being waited on and it is bounded. It exists because the text
230				// must never end up stuck invisible — the same reason the class is only ever added by
231				// script, so JS off or this script throwing paints immediately instead.
232				var fontHardStop = 3000;
233				var fontStart = Date.now();
234				var fontBytesArrived = function () {
235					// No preload emitted means preloadHanziFonts is off — a page with no Chinese to
236					// hold back in the first place, so nothing here should keep waiting for it.
237					return !!fontLink && performance.getEntriesByName(fontLink.href).length > 0;
238				};
239				var fontsUsable = function () {
240					// check(), not load(): load() would force a fetch of both faces on every page,
241					// including the ~4.3MB UKai subset on pages with no Chinese text at all, where
242					// nothing currently downloads it. check() only reports on what the page's own
243					// content has already triggered.
244					// The sample characters are required — check()'s default sample is Latin, which
245					// neither subset covers, and a face that isn't needed to render the sample counts
246					// as ready, so the default would return true while the font was still loading.
247					return document.fonts.check('1em "UKaiSubset"', '字')
248						&& document.fonts.check('1em "PhoneticSubset"', 'ā');
249				};
250				var fontTick = function () {
251					var elapsed = Date.now() - fontStart;
252					var done;
253					try {
254						// Short-circuited, so the probe is only consulted once its window is up: before
255						// then the bytes simply haven't had time to arrive and their absence means
256						// nothing. After it, absence is the answer — this is a download, so reveal and
257						// let font-display: swap paint UKai whenever it lands.
258						done = fontsUsable() || (elapsed >= fontBytesProbe && !fontBytesArrived());
259					} catch (e) {
260						// No document.fonts/performance (or one threw): nothing here can observe the
261						// fonts, so don't hold the text back at all.
262						done = true;
263					}
264					if (done || elapsed >= fontHardStop) {
265						revealFonts();
266						return;
267					}
268					setTimeout(fontTick, 32);
269				};
270				fontTick();
271			}
272		</script>
272<!-- The other half of the same gate, over the same elements. The visitor's hanziType and
273		     phoneticType live in localStorage, which the SERVER cannot read, so a dict page is always
274		     SENT rendered in the defaults and reformatted client-side (applySavedPrefs in
275		     src/pages/dict-shell.astro). Without this hold the server's default pinyin paints and is
276		     then replaced by the visitor's Zhuyin — the same flash as the font one, in the same places,
277		     and it was reported alongside it.
278		     Armed per NAVIGATION, unlike the font block above which is armed once per document: the
279		     router fetches fresh server-rendered — i.e. default-prefs — HTML on every soft navigation
280		     and re-runs applySavedPrefs from astro:page-load each time, so an in-site dict link click
281		     hits this every time while the font is long since loaded. -->
281<script>
282			var prefsRoot = document.documentElement;
283			var prefsBackstop;
284			// The two toggles' own icons, resolved HERE rather than by their components' scripts, for
285			// exactly the reason the theme above is resolved here rather than by ThemeToggle's: those
286			// scripts are bundled modules, so they are deferred, and the header therefore painted "SC"
287			// and "PY" on every load before swapping to the visitor's "TC"/"ㄅㄆ" — the same flash the
288			// hold below removes from the content, in the one place the hold can't be used, since a
289			// blanked header would be worse than a briefly wrong one.
290			// The state lands on <html> because <html> is the only element that exists this early: the
291			// buttons are further down the body. HanziToggle.astro and PhoneticToggle.astro key their
292			// icon CSS off these classes and read them back on init, the way ThemeToggle.astro reads
293			// back .dark — one source of truth per preference, decided before the first paint.
294			var applyDictPrefs = function (root) {
295				root = root || prefsRoot;
296				var hanzi = null;
297				var phonetic = null;
298				try {
299					hanzi = localStorage.getItem('dictHanziType');
300					phonetic = localStorage.getItem('dictPhoneticType');
301				} catch (e) {
302					// Blocked storage: nothing can have been saved, so the defaults are already right.
303				}
304				// The same readings the two components parse. TRAD_SIMP is a legacy bracketed value and
305				// counts as Traditional (HanziToggle.astro); any phoneticType outside the cycle —
306				// PINYIN_NUMBERED, or nothing saved — falls back to tone-marked pinyin, which is the
307				// state with no class of its own (PhoneticToggle.astro's currentValue).
308				root.classList.toggle('pref-hanzi-trad', hanzi === 'TRAD' || hanzi === 'TRAD_SIMP');
309				root.classList.toggle('pref-phonetic-zhuyin', phonetic === 'ZHUYIN_MARKED');
310				root.classList.toggle('pref-phonetic-none', phonetic === 'NONE');
311			};
312			applyDictPrefs();
313			var releasePrefs = function () {
314				clearTimeout(prefsBackstop);
315				prefsRoot.classList.remove('prefs-blocking');
316			};
317			var holdPrefs = function () {
318				prefsRoot.classList.add('prefs-blocking');
319				// Re-armed with the hold rather than once per document, so a soft navigation minutes
320				// into the session is covered too. Fails open like the font block: if astro:page-load
321				// never fires at all — the ViewTransitions bundle 404ing on a mid-deploy load, the case
322				// the recovery script above exists for — the page is already broken, and
323				// broken-but-readable beats broken-and-invisible.
324				clearTimeout(prefsBackstop);
325				prefsBackstop = setTimeout(releasePrefs, 2000);
326			};
327			holdPrefs();
328			if (!window.__prefsHoldArmed) {
329				window.__prefsHoldArmed = true;
330				// The three pref classes ride the INCOMING document's root, exactly as .dark does above —
331				// see the theme script for why re-adding them at astro:after-swap left a mid-swap gap
332				// that started the toggles' icon transitions on every soft navigation.
333				document.addEventListener('astro:before-swap', function (e) {
334					applyDictPrefs(e.newDocument.documentElement);
335				});
336				// The hold stays at after-swap, not before-swap on the incoming root: it must be REMOVED
337				// again later (releasePrefs), so it wants to be where its backstop timer is armed, and a
338				// gap can't hurt it — visibility has no transition to trigger, and nothing paints between
339				// the swap and this because it all runs inside the swap's own task.
340				document.addEventListener('astro:after-swap', holdPrefs);
341				// setTimeout rather than releasing in the listener body. astro:page-load listeners run in
342				// registration order, and this one is registered from <head> — so it runs BEFORE
343				// dict-shell.astro's, which is the listener that actually applies the preference.
344				// Deferring to the next task lets every synchronous page-load listener finish first.
345				// Nothing can paint the wrong reading in that gap: applySavedPrefs is synchronous
346				// (localStorage, not a fetch), so by the time this runs the DOM is already correct.
347				document.addEventListener('astro:page-load', function () { setTimeout(releasePrefs, 0); });
348			}
349		</script>
349<script>
350					if (!window.__swRegisterArmed && 'serviceWorker' in navigator) {
351						window.__swRegisterArmed = true;
352						window.addEventListener('load', function () {
353							navigator.serviceWorker.register('/sw.js').then(function () {
354								return navigator.serviceWorker.ready;
355							}).then(function (reg) {
356								// Warm the cache with what THIS page just loaded. Runtime caching misses the
357								// first visit entirely (the worker starts controlling only after load, by which
358								// point the document and assets are already fetched), so without this a visitor
359								// who saves offline and immediately goes offline finds nothing cached. Hand the
360								// worker the document plus every resource the page pulled in; it caches the ones
361								// that are ours (see sw.js's warmOne) and ignores the rest.
362								var sw = reg.active;
363								if (!sw) return;
364								var urls = [location.href];
365								try {
366									performance.getEntriesByType('resource').forEach(function (e) { urls.push(e.name); });
367								} catch (e) {}
368								sw.postMessage({ type: 'warm', urls: urls });
369							}).catch(function () {
370								// Registration can fail (unsupported, private mode, blocked): the site works
371								// exactly as before online, it just won't be available offline.
372							});
373						});
374					}
375				</script>
375<meta name="description" content="All 214 Kangxi radicals and their variant forms, banded by stroke count, each writing itself stroke by stroke."><!-- interactive-widget=resizes-content (Android Chrome 108+; others ignore it): make the
376		     on-screen keyboard shrink the LAYOUT viewport, not just the visual one, so a page's
377		     layout reflows into the space actually left above the keyboard. Without it the layout
378		     viewport stays at full height and the visual viewport pans freely inside it, which
379		     leaves position:fixed/sticky elements sized and placed against a viewport the visitor
380		     can no longer entirely see — the search page's fixed search box (src/components/DictSearchBox.astro),
381		     typed into with the keyboard up, is the case on this site that most depends on this.
382		     Viewport-unit sizing (index.astro / dict-shell.astro use svh) tracks the keyboard under
383		     it too, since resizing the layout viewport re-resolves those units. --><meta name="viewport" content="width=device-width, interactive-widget=resizes-content"><!-- Chrome's language auto-detection can override the correct lang="en" above and offer
384		     to translate the page anyway, since dictionary pages are full of Chinese headwords —
385		     this opts out of that prompt regardless of how much non-English text is visible. --><meta name="google" content="notranslate"><meta name="robots" content="noindex"><link rel="icon" type="image/png" href="/images/favicon.png"><link rel="manifest" href="/manifest.webmanifest"><link rel="apple-touch-icon" href="/images/apple-touch-icon.png"><meta name="theme-color" content="#2E7D32"><meta name="generator" content="Astro v5.18.2"><title>Radicals</title><!-- Open Graph / Social Media --><!-- The name of the SITE, as distinct from og:title's name of the page — which is why it is a
386		     constant here and not a prop: every page in this repo belongs to the one site.
387		     It is also the second of the two things Google reads to decide the "site name" line above
388		     the blue link in a result (the first is the homepage's WebSite structured data — see
389		     src/pages/index.astro). With neither present it fell back to printing the bare domain,
390		     "hanping.app", over a result whose title already said Hanping Chinese Lookup. --><meta property="og:site_name" content="Hanping Chinese Lookup"><meta property="og:type" content="website"><meta property="og:url" content="https://hanping.app/lists/cmn/radicals/"><meta property="og:title" content="Radicals"><meta property="og:description" content="All 214 Kangxi radicals and their variant forms, banded by stroke count, each writing itself stroke by stroke."><meta property="og:image" content="https://cdn.hanpingchinese.com/cmn/poster/v1/all-radicals/simp-1200x630-header/all-radicals.png"><meta property="og:image:width" content="1200"><meta property="og:image:height" content="630"><meta property="og:image:alt" content="A grid of every Kangxi radical in Simplified Chinese, banded by stroke count, with each one's English name beneath it"><!-- Twitter --><meta name="twitter:card" content="summary_large_image"><meta name="twitter:url" content="https://hanping.app/lists/cmn/radicals/"><meta name="twitter:title" content="Radicals"><meta name="twitter:description" content="All 214 Kangxi radicals and their variant forms, banded by stroke count, each writing itself stroke by stroke."><meta name="twitter:image" content="https://cdn.hanpingchinese.com/cmn/poster/v1/all-radicals/simp-1200x630-header/all-radicals.png"><meta name="twitter:image:alt" content="A grid of every Kangxi radical in Simplified Chinese, banded by stroke count, with each one's English name beneath it"><meta name="astro-view-transitions-enabled" content="true"><meta name="astro-view-transitions-fallback" content="swap">
390<script type="module" src="/assets/ClientRouter.astro_astro_type_script_index_0_lang.CDGfc0hd.js"></script>
390<!-- Bootstrap CSS - Load asynchronously with high priority --><link rel="preload" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" as="style" fetchpriority="high" onload="this.onload=null;this.rel='stylesheet'"><noscript><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css"></noscript><!-- No analytics script here on purpose. This used to load GA4 (G-B7BJRHX9YX) unconditionally,
391		     with a manual page_view on astro:page-load because <ViewTransitions> makes most navigations
392		     client-side. It was removed because it set _ga cookies on every visitor before any consent
393		     — which under the ePrivacy Directive needs a banner — for a number nobody was reading.
394		     Cloudflare Web Analytics replaces it and needs NO code: it is a toggle on the Pages project,
395		     and Cloudflare injects the beacon itself. It is cookieless, so there is nothing to consent
396		     to and no banner to build, which is the whole point of the swap.
397		     What went with it: dict-search.ts's dict_lookup_settled event (docs/DICT_HANZI_TIER_PLAN.md
398		     Step 1). Cloudflare Web Analytics has no custom events, so if that measurement is wanted
399		     once the site has real traffic it needs its own cookieless sink — read the Step 1 section
400		     of that plan before rebuilding anything here. --><link rel="stylesheet" href="/assets/_path_.BG_m5kqJ.css">
401<style>.hanzi-list-page[data-astro-cid-mckmq6b2]{padding-top:calc(64px + 1.5rem);padding-bottom:3rem}@media(max-width:380px){.hanzi-list-page[data-astro-cid-mckmq6b2]{padding-top:calc(56px + 1rem)}}.hanzi-list-page[data-astro-cid-mckmq6b2] .container[data-astro-cid-mckmq6b2]{padding:0 2rem;max-width:800px;margin:0 auto}@media(max-width:576px){.hanzi-list-page[data-astro-cid-mckmq6b2] .container[data-astro-cid-mckmq6b2]{padding:0 1rem}}.hanzi-list-page[data-astro-cid-mckmq6b2] h1{font-size:1.5rem;margin:0 0 .25rem}.list-back[data-astro-cid-mckmq6b2]{float:left;margin:.25rem 1rem .75rem 0;font-size:.9rem}.hanzi-list-page[data-astro-cid-mckmq6b2] .container[data-astro-cid-mckmq6b2]>:not(.list-back):not(.list-animate-toggle){clear:both}.list-animate-toggle[data-astro-cid-mckmq6b2]{float:right;display:flex;margin:.1rem 0 .5rem 1rem;padding:.2rem;border:0;border-radius:var(--radius-md);background:none;cursor:pointer}.list-animate-toggle[data-astro-cid-mckmq6b2]:focus-visible{outline:2px solid var(--primary-color);outline-offset:2px}.hanzi-list-page[data-astro-cid-mckmq6b2] .list-lede{color:var(--text-muted);font-size:.9rem;margin:0 0 1.25rem}.script-redirect[data-astro-cid-q6qs3e76]{padding:calc(64px + 2rem) 1rem 2rem}
402</style>
403<link rel="stylesheet" href="/assets/_path_.Dr8DY1nd.css">
404<link rel="stylesheet" href="/assets/_path_.DtBdzlaw.css">
405<style>.radicals-poster[data-astro-cid-xq6de53h] .hanzi-grid{--hanzi-cell: 52px;--hanzi-caption-em: .24}.radicals-family[data-astro-cid-xq6de53h] .hanzi-grid{--hanzi-cell: 64px}.radicals-empty[data-astro-cid-xq6de53h]{color:var(--text-muted);margin:0}
406</style></head> <body> <!--
407			Navigation progress bar. <ViewTransitions> above turns every same-origin link on this
408			site into a CLIENT-SIDE navigation: the router fetches the next page and only then
409			swaps the DOM. Until that fetch lands, nothing on screen changes at all — and unlike a
410			full page load, there's no browser-native spinner, no tab throbber and no progress on
411			the address bar to fall back on, because as far as the browser is concerned the page
412			never went anywhere. On a fast connection that's the whole point. On a slow one it
413			reads as a dead site: tapping the header's search icon sits there for 5-10 seconds
414			looking like the tap simply missed.
415
416			transition:persist so the element itself survives the swap it's reporting on — without
417			it the router would replace this node with the incoming page's own copy mid-animation,
418			and the bar would vanish and restart rather than run to completion.
419
420			aria-hidden: the bar is decoration for a state the router already announces to
421			assistive tech on arrival; a live region here would just talk over that.
422		--> <div id="nav-progress" data-astro-transition-persist="nav-progress" aria-hidden="true"><div id="nav-progress-fill"></div></div>   <noscript> <main class="script-redirect" data-astro-cid-q6qs3e76> <p data-astro-cid-q6qs3e76>
422Radicals:</p> <ul data-astro-cid-q6qs3e76> <li data-astro-cid-q6qs3e76><a href="/lists/cmn/radicals/simp/" data-astro-cid-q6qs3e76>Simplified</a></li><li data-astro-cid-q6qs3e76><a href="/lists/cmn/radicals/trad/" data-astro-cid-q6qs3e76>Traditional</a></li> </ul> </main> </noscript>   <!-- No Bootstrap JS. Nothing here ever used it: there is not a single data-bs-* attribute in the
423	     codebase, and both dialogs use the native <dialog> showModal() rather than Bootstrap's Modal.
424	     It was 23KB and a third-party connection spent on a bundle that registered handlers for markup
425	     that doesn't exist. The CSS is a different story and is still loaded above — the sign-in UI
426	     and account footer use its utility classes, and DictHeader.astro leans on .navbar/.navbar-brand
427	     hard enough to have to override what they leak (see its own comments). --> 
427<script type="module">const e=document.getElementById("nav-progress"),i=document.getElementById("nav-progress-fill"),o=400;let t;function a(){clearTimeout(t),t=setTimeout(()=>{e?.classList.remove("is-active"),i?.offsetWidth,e?.classList.add("is-active")},o)}function s(){clearTimeout(t),e?.classList.remove("is-active")}document.addEventListener("astro:before-preparation",a);document.addEventListener("astro:page-load",s);window.addEventListener("pagehide",s);</script>
427 <!-- Cloudflare Pages Analytics -->
vendor: 99 bytes, line 427
427<script defer src='https://static.cloudflareinsights.com/beacon.min.js' data-cf-beacon='{"token": "
427f4cb193ec6c1431691f0a2d5d1d9f56c
vendor: 13 bytes, line 427
427"}'></script>
427<!-- Cloudflare Pages Analytics -->
427<script>(function(){function c(){var b=a.contentDocument||(a.contentWindow&&a.contentWindow.document);if(b){var d=b.createElement('script');d.innerHTML="window.__CF$cv$params={r:'a43f9203c924124c',t:'MTc5MDkwMDczMQ=='};var a=document.createElement('script');a.src='/cdn-cgi/challenge-platform/scripts/jsd/main.js';document.getElementsByTagName('head')[0].appendChild(a);";b.getElementsByTagName('head')[0].appendChild(d)}}if(document.body){var a=document.createElement('iframe');a.height=1;a.width=1;a.style.position='absolute';a.style.top=0;a.style.left=0;a.style.border='none';a.style.visibility='hidden';document.body.appendChild(a);if('loading'!==document.readyState)c();else if(window.addEventListener)document.addEventListener('DOMContentLoaded',c);else{var e=document.onreadystatechange||function(){};document.onreadystatechange=function(b){e(b);'loading'!==document.readyState&&(document.onreadystatechange=e,c())}}}})();</script>
427</body> </html>  

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.