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.