<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: yyj-dev</title>
    <description>The latest articles on DEV Community by yyj-dev (@yyj).</description>
    <link>https://dev.to/yyj</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3997002%2F54f4f34f-d2a3-4bb1-bbb5-a3977912901d.png</url>
      <title>DEV Community: yyj-dev</title>
      <link>https://dev.to/yyj</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/yyj"/>
    <language>en</language>
    <item>
      <title>filter: invert(1) is not dark mode, and a PDF page is not a picture</title>
      <dc:creator>yyj-dev</dc:creator>
      <pubDate>Wed, 02 Sep 2026 08:36:18 +0000</pubDate>
      <link>https://dev.to/yyj/filter-invert1-is-not-dark-mode-and-a-pdf-page-is-not-a-picture-2nk0</link>
      <guid>https://dev.to/yyj/filter-invert1-is-not-dark-mode-and-a-pdf-page-is-not-a-picture-2nk0</guid>
      <description>&lt;p&gt;Ask anyone how to dark-mode a PDF in the browser and you get the same two lines back: render the page to a canvas with pdf.js, put &lt;code&gt;filter: invert(1)&lt;/code&gt; on it, done. It is the answer in every thread on the subject, and it is wrong in three separate ways.&lt;/p&gt;

&lt;p&gt;Two of them show up in the first screenshot. Photos come out as film negatives. And the whole thing is a costume worn by the viewer: mail the file to a colleague, or open it on a tablet, and it is blinding white again, because you never touched the document.&lt;/p&gt;

&lt;p&gt;The third is the one that took me longest to accept. &lt;code&gt;255 - x&lt;/code&gt; is not "the dark version of this color." It is the &lt;strong&gt;complement&lt;/strong&gt;. A dark navy heading at &lt;code&gt;rgb(20, 40, 120)&lt;/code&gt; inverts to &lt;code&gt;rgb(235, 215, 135)&lt;/code&gt;, which is khaki. Every colored element in the document comes out wearing the wrong hue, and no amount of tuning fixes it, because the operation itself is answering a different question than the one you asked.&lt;/p&gt;

&lt;p&gt;I started where most people do, one step past the filter: rasterize the page, run a smarter per-pixel mapping over the bitmap, ship that. It works, and it is still the fallback path in my code. But a bitmap of a document is not a document, and every property you want back afterward has to be rebuilt by hand. Eventually I stopped treating the page as an image at all. Here is what is underneath.&lt;/p&gt;

&lt;h2&gt;
  
  
  A page is a little postfix program
&lt;/h2&gt;

&lt;p&gt;A PDF page's &lt;code&gt;/Contents&lt;/code&gt; is a &lt;strong&gt;content stream&lt;/strong&gt;: a byte string of operands followed by operators, evaluated like a stack language.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight postscript"&gt;&lt;code&gt;&lt;span class="mf"&gt;0&lt;/span&gt; &lt;span class="mf"&gt;0&lt;/span&gt; &lt;span class="mf"&gt;0&lt;/span&gt; &lt;span class="nf"&gt;rg&lt;/span&gt;              &lt;span class="c1"&gt;% set fill color to black (DeviceRGB)&lt;/span&gt;
&lt;span class="nf"&gt;BT&lt;/span&gt; &lt;span class="nv"&gt;/F1&lt;/span&gt; &lt;span class="mf"&gt;12&lt;/span&gt; &lt;span class="nf"&gt;Tf&lt;/span&gt; &lt;span class="mf"&gt;72&lt;/span&gt; &lt;span class="mf"&gt;700&lt;/span&gt; &lt;span class="nf"&gt;Td&lt;/span&gt; &lt;span class="s"&gt;(Hello)&lt;/span&gt; &lt;span class="nf"&gt;Tj&lt;/span&gt; &lt;span class="nf"&gt;ET&lt;/span&gt;   &lt;span class="c1"&gt;% draw some text&lt;/span&gt;
&lt;span class="mf"&gt;1&lt;/span&gt; &lt;span class="mf"&gt;0&lt;/span&gt; &lt;span class="mf"&gt;0&lt;/span&gt; &lt;span class="nf"&gt;RG&lt;/span&gt;              &lt;span class="c1"&gt;% set stroke color to red&lt;/span&gt;
&lt;span class="mf"&gt;72&lt;/span&gt; &lt;span class="mf"&gt;690&lt;/span&gt; &lt;span class="nf"&gt;m&lt;/span&gt; &lt;span class="mf"&gt;300&lt;/span&gt; &lt;span class="mf"&gt;690&lt;/span&gt; &lt;span class="nf"&gt;l&lt;/span&gt; &lt;span class="nf"&gt;S&lt;/span&gt;  &lt;span class="c1"&gt;% draw a line&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once you see this, dark mode stops being an image-processing problem and becomes a &lt;strong&gt;source rewriting&lt;/strong&gt; problem. You don't repaint the picture; you find the instructions that set colors, rewrite those, and pass every other byte through untouched. The text operators still run. The glyphs are still glyphs.&lt;/p&gt;

&lt;p&gt;That single change is what buys you the properties a canvas filter can never have: the output text is selectable and searchable, vector art stays razor sharp at 800% zoom, and the file size barely moves because you never rasterized anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The color operator zoo, and the one that ruins your day
&lt;/h2&gt;

&lt;p&gt;The absolute operators are easy, and you can tabulate them by operand count:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;COLOR_OPS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;g&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;G&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;// DeviceGray&lt;/span&gt;
  &lt;span class="na"&gt;rg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;RG&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// DeviceRGB&lt;/span&gt;
  &lt;span class="na"&gt;k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;K&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;// DeviceCMYK&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lowercase is fill, uppercase is stroke. Read the operands, map them through your color function, emit the replacement.&lt;/p&gt;

&lt;p&gt;Then there is &lt;code&gt;sc&lt;/code&gt; / &lt;code&gt;scn&lt;/code&gt;, which sets a color &lt;strong&gt;in whatever color space is currently selected&lt;/strong&gt;. The space was selected earlier by &lt;code&gt;cs&lt;/code&gt; / &lt;code&gt;CS&lt;/code&gt;, naming an entry in the page's &lt;code&gt;/ColorSpace&lt;/code&gt; resource dictionary. So &lt;code&gt;0.2 0.4 0.9 scn&lt;/code&gt; might be three RGB components, or it might be something else entirely, and you cannot know without resolving the resource. Worse, the selected space is part of the graphics state, so &lt;code&gt;q&lt;/code&gt; and &lt;code&gt;Q&lt;/code&gt; push and pop it. To know how many operands to even consume, you have to track what the original stream thought the current space was. You are not writing a regex over a byte string. You are writing an interpreter with a stack.&lt;/p&gt;

&lt;p&gt;There is no way around that on the way in. But there is a trick on the way out. &lt;strong&gt;Every rewritten color is emitted as plain &lt;code&gt;rg&lt;/code&gt; or &lt;code&gt;RG&lt;/code&gt;, regardless of what came in.&lt;/strong&gt; Those operators are absolute; they are always legal, and they reset the current color space to DeviceRGB as a side effect. Because you never re-emit a space-relative operator, you only ever have to &lt;em&gt;read&lt;/em&gt; the color space state machine, never write a consistent one back. A CMYK &lt;code&gt;0 0 0 1 k&lt;/code&gt; becomes &lt;code&gt;1 1 1 rg&lt;/code&gt;. An ICCBased &lt;code&gt;scn&lt;/code&gt; becomes &lt;code&gt;rg&lt;/code&gt;. The nastiest class of bug in this project (emitting operands that don't match the space the viewer thinks it's in) is designed out of existence rather than debugged.&lt;/p&gt;

&lt;h2&gt;
  
  
  Refuse loudly, and refuse the whole page
&lt;/h2&gt;

&lt;p&gt;The list of things a real-world PDF can contain is longer than the list of things you can confidently recolor. My rewriter throws a health-check failure and gives up on the entire page when it meets:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a color space it can't reduce to gray / RGB / CMYK: &lt;code&gt;Indexed&lt;/code&gt;, &lt;code&gt;Separation&lt;/code&gt;, &lt;code&gt;DeviceN&lt;/code&gt;, &lt;code&gt;Lab&lt;/code&gt;, &lt;code&gt;Pattern&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;scn&lt;/code&gt; / &lt;code&gt;SCN&lt;/code&gt; with a pattern name operand (&lt;code&gt;/P1 scn&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;an &lt;code&gt;sh&lt;/code&gt; shading operator, because a gradient would stay bright on a dark page&lt;/li&gt;
&lt;li&gt;an inline image (&lt;code&gt;BI ... ID &amp;lt;binary&amp;gt; EI&lt;/code&gt;), where re-slicing raw bytes out of the token stream is a good way to corrupt a file&lt;/li&gt;
&lt;li&gt;any stream that fails to decode or tokenize&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The design decision worth stealing is not the list, it is the &lt;strong&gt;granularity&lt;/strong&gt;. The tempting move is per-operator: skip the thing you don't understand, keep going. That gives you a page that is 90% dark with one glowing white gradient panel in the middle, which reads as broken software. Failing the whole page instead lets the caller fall back to the raster pipeline for that page only, so the failure mode degrades to "this page is a picture of a dark page" rather than "this page is wrong." A mixed document gets real vector text on the pages that qualify and a correct-looking fallback on the ones that don't.&lt;/p&gt;

&lt;p&gt;One trap on the way out: the PDF spec's default fill color is black. A stream that draws text without ever setting a color is relying on that default, and on a dark page black is now the wrong answer. So the rewritten stream gets a prologue that paints the background rectangle and then explicitly sets both fill and stroke to the mapped foreground, before a single original byte runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The accident that made scanned pages work
&lt;/h2&gt;

&lt;p&gt;Here is my favorite thing I learned, and I found it by accident.&lt;/p&gt;

&lt;p&gt;An &lt;strong&gt;image mask&lt;/strong&gt; (&lt;code&gt;/ImageMask true&lt;/code&gt;) is a one-bit stencil. It carries no color of its own; the viewer paints it using the &lt;em&gt;current fill color&lt;/em&gt;, exactly like a glyph. And it turns out that is precisely how scanners encode text: the JBIG2 layer of a scanned page is a stencil mask of the letterforms.&lt;/p&gt;

&lt;p&gt;Which means two things fell out of one decision.&lt;/p&gt;

&lt;p&gt;In the object-rewriting path, I never wrote a single line of code for scanned text. Changing the fill color operators is enough. The stencil follows the fill color, so scanned black-on-white text flips to light-on-dark on its own.&lt;/p&gt;

&lt;p&gt;In the raster path there is a scanner that walks pdf.js's operator list with a simulated graphics state (transform matrix plus clip box, pushed and popped on save/restore) to find the bounding boxes of images worth protecting from inversion. That scanner deliberately ignores &lt;code&gt;paintImageMaskXObject&lt;/code&gt;. Protecting those rectangles would have felt correct, and it would have carefully preserved the one thing the user came to change: the black-on-white scanned text.&lt;/p&gt;

&lt;p&gt;Two unrelated-looking features, one property of the format.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell someone starting this
&lt;/h2&gt;

&lt;p&gt;Resist the image-processing framing for as long as you can. The moment you rasterize, every property you care about afterward (selectable text, sharp vectors, sane file size, scanned pages) becomes a reconstruction job instead of a preservation job.&lt;/p&gt;

&lt;p&gt;And whatever your color mapping is, make it hue-preserving. Mine splits on saturation: near-grayscale colors get pulled toward the theme background by luminance (white to background, black to white), while chromatic colors keep their hue and get only their &lt;em&gt;lightness&lt;/em&gt; flipped, so that navy heading lands on light blue rather than khaki. The two results are cross-faded across a saturation band, because the anti-aliased edges of colored glyphs sit right in between and a hard switch leaves a visible seam around every letter. That scalar math lives in three places in my codebase, once readable and twice inlined per-pixel, purely because returning a fresh tuple for every pixel of a full-page bitmap is a GC problem you do not want.&lt;/p&gt;

&lt;p&gt;If you want to poke at the output rather than the theory, the running implementation is &lt;a href="https://pdfdark.org/invert-pdf-colors" rel="noopener noreferrer"&gt;a browser-only PDF color inverter&lt;/a&gt; built on exactly the above (File API in, Web Worker for the raster path, nothing leaves the tab), and the full two-pipeline flow is written up &lt;a href="https://pdfdark.org/blog/how-pdf-dark-mode-conversion-works" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Happy to go deeper on the tokenizer or the clip-box simulation in the comments if that's useful to anyone.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>pdf</category>
      <category>graphics</category>
    </item>
    <item>
      <title>The Pokédex Is 2.4MB. My Users Never Download It.</title>
      <dc:creator>yyj-dev</dc:creator>
      <pubDate>Sun, 16 Aug 2026 16:00:49 +0000</pubDate>
      <link>https://dev.to/yyj/the-pokedex-is-24mb-my-users-never-download-it-57g5</link>
      <guid>https://dev.to/yyj/the-pokedex-is-24mb-my-users-never-download-it-57g5</guid>
      <description>&lt;p&gt;A random Pokémon generator is a strange thing to optimize. The entire product is: press a button, get a Pokémon. But behind that one button sits the whole dataset of the franchise — 1,025 species across nine generations, an 18×18 type chart, hundreds of moves and abilities, and a learnset table for every single species. The naive versions of this app either hammer a public API on every click or quietly ship a multi-megabyte database to every visitor. I spent most of my architecture budget on &lt;a href="https://pickpokemon.xyz/" rel="noopener noreferrer"&gt;a random Pokémon generator I built&lt;/a&gt; making sure it does neither, and the pattern that came out of it — treat your build step as a data compiler — is the part worth writing down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why runtime API calls were never on the table
&lt;/h2&gt;

&lt;p&gt;PokéAPI is a lovely public resource, but it answers questions one at a time, and a generator doesn't ask questions one at a time. When someone toggles "Gen 3 only, no Legendaries, starters excluded" and hits the button, the app needs the entire filtered pool in memory &lt;em&gt;right now&lt;/em&gt;. Fetching per click means spinners, rate-limit anxiety, and being a bad citizen toward a free API.&lt;/p&gt;

&lt;p&gt;So at runtime, my site makes zero calls to PokéAPI. Instead, the excellent &lt;code&gt;@pkmn/dex&lt;/code&gt; npm package sits in &lt;code&gt;devDependencies&lt;/code&gt; — it never ships to anyone — and a build-time script, &lt;code&gt;scripts/generate-pokedex.mjs&lt;/code&gt;, runs it once and emits plain static JSON that the app imports like any other module:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;pokedex.json&lt;/code&gt; — 1,025 species, exactly 13 fields each&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;typechart.json&lt;/code&gt; — the full 18×18 effectiveness matrix&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;moves.json&lt;/code&gt; — 685 moves&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;abilities.json&lt;/code&gt; — 310 abilities&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;learnsets.json&lt;/code&gt; — the legal move list for each of the 1,025 species&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The motivation is a pair of numbers I refuse to inflict on visitors: the dex package weighs 2.4MB, and the raw learnsets data alone is a 3.1MB chunk. Neither ever appears in the client bundle. What ships instead is the trimmed JSON — 13 fields per species, nothing the UI doesn't render or filter on. The dex knows a thousand things about each Pokémon; my users' phones only need to know thirteen of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Editorial decisions belong in the generator script
&lt;/h2&gt;

&lt;p&gt;Here's the part I didn't expect: deciding &lt;em&gt;what counts as a Pokémon&lt;/em&gt; is genuinely messy, and I'm glad that mess lives in one script instead of being sprinkled across components.&lt;/p&gt;

&lt;p&gt;The raw dex data includes battle-only formes, cosmetic variants, fan-designed CAP species, and other entries that would feel like bugs if they popped out of a generator. My script hardcodes the policy in one place:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;skip anything with a non-empty &lt;code&gt;forme&lt;/code&gt;, and cosmetic formes generally&lt;/li&gt;
&lt;li&gt;skip entries with &lt;code&gt;num &amp;lt;= 0&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;skip anything whose &lt;code&gt;isNonstandard&lt;/code&gt; is &lt;code&gt;CAP&lt;/code&gt;, &lt;code&gt;LGPE&lt;/code&gt;, &lt;code&gt;Custom&lt;/code&gt;, &lt;code&gt;Unobtainable&lt;/code&gt;, or &lt;code&gt;Future&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Some classifications simply don't exist in the data, so I made them exist. There is no &lt;code&gt;isStarter&lt;/code&gt; flag anywhere — "starter" is a fan concept — so the script carries a hardcoded &lt;code&gt;Set&lt;/code&gt; of the 27 starter names and stamps a boolean onto each matching species. Legendary and mythical status comes from tags. Generation isn't stored either; it's derived from National Dex number ranges (number ≤ 151 → Gen 1, and so on up through Gen 9).&lt;/p&gt;

&lt;p&gt;The client never reasons about any of this. It reads pre-computed booleans and fields. If I ever change my mind about what belongs in the pool, that's a one-file diff and a rebuild, not a scavenger hunt through UI code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one file too big to import
&lt;/h2&gt;

&lt;p&gt;After trimming, &lt;code&gt;learnsets.json&lt;/code&gt; still weighs 870KB. That's small compared to the 3.1MB it came from, but it's still enormous next to everything else — and only one feature on the entire site needs it: the moveset generator.&lt;/p&gt;

&lt;p&gt;So it's the one file that never gets a static &lt;code&gt;import&lt;/code&gt;. It loads through &lt;code&gt;await import()&lt;/code&gt;, dynamically, the first time someone actually generates a moveset. The visitor who came to spin up random Gen 1 teams never downloads a byte of it. The visitor who wants movesets pays the cost exactly once, on first use, and the chunk is cached after that.&lt;/p&gt;

&lt;p&gt;The same isolation logic applies at a smaller scale: moves and abilities live in two separate lib files, not one shared "battle data" module. The moveset page doesn't bundle &lt;code&gt;abilities.json&lt;/code&gt;; the ability page doesn't bundle &lt;code&gt;moves.json&lt;/code&gt;. It's the kind of split that feels fussy until you look at a bundle analyzer and see each route carrying only its own weight.&lt;/p&gt;

&lt;h2&gt;
  
  
  Strings you can compute don't belong in JSON
&lt;/h2&gt;

&lt;p&gt;One more trim that surprised me with how much it mattered: sprite URLs aren't stored in the data at all. A thousand-entry JSON file where every entry carries even one URL string is a thousand copies of the same CDN prefix. Since sprite filenames are just the National Dex number, the URL is assembled at runtime from the number the app already has. The JSON stores an integer; the string exists only in memory, briefly, on its way into an &lt;code&gt;img&lt;/code&gt; tag.&lt;/p&gt;

&lt;h2&gt;
  
  
  The most boring line of code I've ever shipped
&lt;/h2&gt;

&lt;p&gt;After all of that — the build pipeline, the filtering policy, the chunk isolation — here is the actual "generator":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. That's the feature. No seeded PRNG, no weighting, no cleverness. The team generator adds exactly one wrinkle: when it draws a Pokémon for a six-slot team, it &lt;code&gt;splice&lt;/code&gt;s the pick out of a working copy of the pool, so the same species can't appear twice. Draw, remove, repeat six times.&lt;/p&gt;

&lt;p&gt;I find this genuinely funny. The line users think of as "the app" took thirty seconds to write. Everything around it — making sure &lt;code&gt;pool&lt;/code&gt; is small, correct, filtered to their settings, and arrived on their device without dragging megabytes of dex data along — took weeks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;If your side project is really a dataset wearing a UI, the highest-leverage code you'll write probably isn't in a component. It's a script that runs at build time, reads a heavyweight source of truth from &lt;code&gt;devDependencies&lt;/code&gt;, applies every editorial decision you'd otherwise scatter through the app, and emits the smallest possible artifacts for each route to import — with anything both large and rarely-used pushed behind a dynamic &lt;code&gt;import()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The browser gets answers, not the encyclopedia the answers came from.&lt;/p&gt;

&lt;p&gt;(Standard footnote: this is an independent fan project, not affiliated with Nintendo, Game Freak, or The Pokémon Company. The data pipeline is the part I can take credit for; the 1,025 reasons anyone visits are theirs.)&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>nextjs</category>
      <category>performance</category>
    </item>
    <item>
      <title>The Color Math Was Never the Slow Part</title>
      <dc:creator>yyj-dev</dc:creator>
      <pubDate>Fri, 14 Aug 2026 18:17:42 +0000</pubDate>
      <link>https://dev.to/yyj/the-color-math-was-never-the-slow-part-li2</link>
      <guid>https://dev.to/yyj/the-color-math-was-never-the-slow-part-li2</guid>
      <description>&lt;p&gt;The first version of my image-to-Minecraft-blocks converter locked up the browser tab for long enough that I assumed something had crashed. I dropped in a photo straight off my phone, hit convert, and the page stopped responding to clicks — no spinner animation, no scroll, nothing. I sat there for a while wondering whether I'd written an infinite loop.&lt;/p&gt;

&lt;p&gt;I hadn't. It was just doing exactly what I told it to, on the main thread, one pixel at a time.&lt;/p&gt;

&lt;p&gt;This post is about what I got wrong when I tried to fix it, because my first guess was confidently wrong and the actual bottleneck turned out to be somewhere I wasn't looking.&lt;/p&gt;

&lt;h2&gt;
  
  
  The naive shape of the problem
&lt;/h2&gt;

&lt;p&gt;The job is simple to state. You have a photo. You have a fixed list of Minecraft blocks, each with a known average color. For every cell in the output grid you find the block whose color is closest, and you write it down.&lt;/p&gt;

&lt;p&gt;The obvious implementation is two nested loops: outer over pixels, inner over the palette, keep the minimum. Textbook nearest-neighbor search, and for small inputs it's genuinely fine.&lt;/p&gt;

&lt;p&gt;Then you run the numbers on real inputs. A photo out of a modern phone camera is around 4000×3000 — twelve million pixels before you touch anything. You downscale before matching, sure, since the output grid is maybe 128 or 256 blocks wide. But the decode and resample still happen at full resolution, and then the match loop runs every cell of the output grid against every entry in a palette of several hundred blocks.&lt;/p&gt;

&lt;p&gt;And I was running all of it inside a click handler.&lt;/p&gt;

&lt;h2&gt;
  
  
  My wrong guess
&lt;/h2&gt;

&lt;p&gt;My first theory was the color space. Matching happens in OKLab rather than raw RGB, because RGB distance produces the classic "cheap filter" look — most visibly on skin tones and mid-range greens, where a numerically small gap can read as a very different color to a human eye. OKLab conversion involves a matrix multiply and cube roots, and cube roots felt expensive, so I assumed that was where the time was going.&lt;/p&gt;

&lt;p&gt;It wasn't. When I actually profiled it instead of guessing, the color conversion barely registered. Nearly everything was sitting in the palette scan — the inner loop — and in the plain fact that all of this was happening on the thread responsible for painting the page.&lt;/p&gt;

&lt;p&gt;Two separate problems wearing one costume:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The work was on the main thread, so the UI froze regardless of how fast the work was.&lt;/li&gt;
&lt;li&gt;The work was genuinely redundant, because photos repeat colors constantly and I was re-deriving the same answer thousands of times.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Moving it off the main thread, and the bug that came with it
&lt;/h2&gt;

&lt;p&gt;The first fix is the boring correct one: put the pipeline in a Web Worker. The UI thread hands over the pixels, the worker grinds, the UI stays alive and can render progress.&lt;/p&gt;

&lt;p&gt;What nobody warns you about is that the handoff itself has a cost. &lt;code&gt;postMessage&lt;/code&gt; structured-clones its payload by default, and structured-cloning a multi-megabyte pixel buffer means copying a multi-megabyte pixel buffer, twice per conversion — once in, once out. You can move the buffer instead of copying it by passing it in the transfer list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;postMessage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That hands ownership across the boundary with no copy. It also &lt;em&gt;neuters&lt;/em&gt; the original — after the transfer, &lt;code&gt;buf.byteLength&lt;/code&gt; on the sending side is &lt;code&gt;0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Which is how I spent an evening on a bug where the source thumbnail rendered as a black rectangle every time, but only after the first conversion. I was holding a reference to the same buffer for the preview, and the transfer had emptied it out from under me. The fix is unglamorous: keep a copy for anything the main thread still needs, or don't transfer that particular buffer. But it's the kind of bug that reads as "canvas is broken" for an hour before it reads as "you gave your array away."&lt;/p&gt;

&lt;h2&gt;
  
  
  The cache that made the inner loop mostly disappear
&lt;/h2&gt;

&lt;p&gt;The second fix is where the actual speedup lived, and it comes from a property of photographs rather than a property of JavaScript.&lt;/p&gt;

&lt;p&gt;Photos are enormously repetitive at the color level. A sky is thousands of pixels of nearly the same blue. A cheek is hundreds of pixels of nearly the same tone. If you're doing a full palette scan for every one of those pixels independently, you are computing the identical answer over and over.&lt;/p&gt;

&lt;p&gt;So quantize the lookup key. Take the source color down to 5 bits per channel and use that as a cache key. Five bits per channel means 32×32×32 = 32,768 possible keys — small enough to keep in a flat typed array, and a real photo touches only a fraction of them. First time you see a bucket, do the full palette scan. Every time after, it's an array index.&lt;/p&gt;

&lt;p&gt;The palette scan doesn't get faster. It just stops running for the overwhelming majority of pixels. That's the whole trick, and it's the difference between a frozen tab and a progress bar that finishes while you're still looking at it.&lt;/p&gt;

&lt;p&gt;One consequence worth flagging if you build something similar: &lt;strong&gt;the cache key has to include the palette identity&lt;/strong&gt;, not just the color. My block palette is versioned — Java releases from 1.13 up through 26.2, with Bedrock carrying its own separate block and texture set — because a palette frozen at whatever version the tool was written against will eventually disagree with what the game actually renders, and you get a preview that doesn't match your build. That's a correctness win, but it means "nearest block to this color" is not a stable function. Switch edition or version and every cached answer is potentially wrong. Ask me how I know. Version identity goes in the cache key, or the cache gets cleared on switch; either works, silently doing neither does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the worker sends back
&lt;/h2&gt;

&lt;p&gt;The last thing I changed was the return type, and it turned out to matter more than the performance work.&lt;/p&gt;

&lt;p&gt;Originally the worker returned an &lt;code&gt;ImageData&lt;/code&gt; — the rendered preview, RGBA. That's all you need to show someone what their build will look like. But it's the wrong artifact to hand a person who then has to actually place the blocks, because "a picture of the finished thing" throws away the one piece of information they need most: which block is at each position.&lt;/p&gt;

&lt;p&gt;So the worker now returns two things: the preview pixels &lt;em&gt;and&lt;/em&gt; a flat array of palette indices, one per cell. Once those indices are the source of truth, the things a builder actually needs stop being separate features and start being views over one array. A material list is a single tally pass with a counts array, which is where "you'll need 340 of this, 118 of that" comes from — the number you want before you go mining, not after. Slicing the same array into sections gives you a chunk-by-chunk build guide, so you can work one region at a time instead of squinting at a flat reference image and losing track of which row you're on. And &lt;code&gt;.schem&lt;/code&gt; and &lt;code&gt;.litematic&lt;/code&gt; export are just serializers over those indices, for anyone running WorldEdit or Litematica who'd rather skip manual placement altogether.&lt;/p&gt;

&lt;p&gt;None of that needed its own pipeline. Returning an RGBA preview and nothing else had been quietly throwing away the only representation any of it could be built from.&lt;/p&gt;

&lt;h2&gt;
  
  
  The side effect I didn't plan for
&lt;/h2&gt;

&lt;p&gt;There's an accidental property of this architecture that turned out to be one of my favorite things about it. The worker has no network code in it. There's no upload step, because there's nothing on a server to upload to — the whole conversion runs locally in the browser.&lt;/p&gt;

&lt;p&gt;That means the privacy claim isn't something you have to trust me on. Open DevTools, watch the Network panel, drop in a photo, convert. Nothing goes out. It's a verifiable property of the page rather than a promise in a policy document, and I only ended up there because moving the work into a worker was the fastest way to stop the tab from freezing.&lt;/p&gt;

&lt;p&gt;The whole thing lives at &lt;a href="https://minecraftpixelart.xyz" rel="noopener noreferrer"&gt;minecraftpixelart.xyz&lt;/a&gt; if you want to poke at it — it's a solo side project I maintain in my spare time, and the palette upkeep as new Minecraft versions ship is the part I expect to be doing forever.&lt;/p&gt;

&lt;p&gt;If you take one thing from this: profile before you optimize, because I would have happily spent a weekend hand-optimizing a cube root that accounted for almost none of the runtime.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>performance</category>
      <category>showdev</category>
    </item>
    <item>
      <title>One React Component, Three Display Technologies — Flip Cards, Pixel LEDs, and Seven-Segment Digits in Pure CSS</title>
      <dc:creator>yyj-dev</dc:creator>
      <pubDate>Fri, 03 Jul 2026 07:28:26 +0000</pubDate>
      <link>https://dev.to/yyj/one-react-component-three-display-technologies-flip-cards-pixel-leds-and-seven-segment-digits-3dbn</link>
      <guid>https://dev.to/yyj/one-react-component-three-display-technologies-flip-cards-pixel-leds-and-seven-segment-digits-3dbn</guid>
      <description>&lt;p&gt;I recently shipped a fullscreen clock app, &lt;a href="https://digitalclock.xyz/" rel="noopener noreferrer"&gt;digitalclock.xyz&lt;/a&gt;, that offers eight themes. Four of them look like a mechanical flip clock, three like a retro pixel LED display, and one like a seven-segment alarm clock. Three genuinely different display technologies — and I wanted all of them rendered by the same component tree, with zero canvas, zero SVG, and zero image assets.&lt;/p&gt;

&lt;p&gt;That constraint turned out to be the most interesting engineering decision in the project. Here's how the architecture actually works.&lt;/p&gt;

&lt;h2&gt;
  
  
  A theme is just data
&lt;/h2&gt;

&lt;p&gt;The first rule I set: a theme is not a component, not a CSS file, not a variant prop scattered across the tree. It's a plain object.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;ThemeStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;flip&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pixel&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;segment&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;Theme&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ThemeId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ThemeStyle&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;pageBg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;cardBg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;cardBgGradient&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;digitColor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;divider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;glow&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;fontClassVar&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;var(--font-flip)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;var(--font-flip-mono)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;var(--font-pixel)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;separatorColor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;subtleText&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything a theme knows is color tokens, an optional glow, a font variable — and one 3-value enum, &lt;code&gt;style&lt;/code&gt;, which decides which of three rendering branches the digit component takes. All eight themes fit in a single array in &lt;code&gt;themes.ts&lt;/code&gt;. "Flip Ocean" and "Pixel Amber" differ only in the values of these fields.&lt;/p&gt;

&lt;p&gt;The important consequence: adding a ninth theme is a data change, not a code change. Adding a fourth &lt;em&gt;display technology&lt;/em&gt; is a code change — one new branch — but nothing else in the tree has to know about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The only interface between JS and styling is custom properties
&lt;/h2&gt;

&lt;p&gt;React never writes real CSS declarations inline. The digit component's inline &lt;code&gt;style&lt;/code&gt; contains nothing but CSS custom properties:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;--seg-on&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;theme&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;digitColor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;--seg-off&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`color-mix(in srgb, &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;theme&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;digitColor&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; 10%, transparent)`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;--seg-glow&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;theme&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;glow&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CSSProperties&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Static CSS rules in &lt;code&gt;globals.css&lt;/code&gt; consume those variables. JS decides &lt;em&gt;what the values are&lt;/em&gt;; CSS decides &lt;em&gt;what they mean&lt;/em&gt;. That split is what keeps three unrelated display technologies from leaking into each other — the React side genuinely does not know how a segment gets its bevel or how a flip card folds.&lt;/p&gt;

&lt;p&gt;One detail I like a lot: the seven-segment theme only declares a single color, &lt;code&gt;digitColor&lt;/code&gt;. The dim "off" segments are derived in that snippet above with &lt;code&gt;color-mix(in srgb, &amp;lt;digitColor&amp;gt; 10%, transparent)&lt;/code&gt;, and the glow is another derivation of the same hue. One token in, an entire lit/unlit/halo palette out. When I added the theme picker, no theme ever shipped with mismatched on/off colors, because there's nothing to mismatch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Branch 1: pixel — the trivial case
&lt;/h2&gt;

&lt;p&gt;The pixel LED branch is almost embarrassingly simple, which is the point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;theme&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pixel&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"flip-pixel-digit"&lt;/span&gt;
      &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;theme&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;digitColor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;textShadow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;theme&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;glow&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A pixel font does the heavy lifting for the dot-matrix look, and a two-layer &lt;code&gt;text-shadow&lt;/code&gt; (an 18px halo plus a wider 36px falloff) makes it bloom like a backlit display. The glyph &lt;em&gt;is&lt;/em&gt; text, so the glow technique that fits is &lt;code&gt;text-shadow&lt;/code&gt; — it hugs the letterforms for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Branch 2: seven-segment — the font is in the stylesheet
&lt;/h2&gt;

&lt;p&gt;This is the branch people don't believe until they open devtools. A seven-segment digit renders as seven empty spans:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"seg-digit"&lt;/span&gt; &lt;span class="na"&gt;data-value&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;aria-label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"seg seg-h seg-a"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"seg seg-v seg-b"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"seg seg-v seg-c"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"seg seg-h seg-d"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"seg seg-v seg-e"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"seg seg-v seg-f"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"seg seg-h seg-g"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No text content at all. The "font" — which segments light up for which digit — lives entirely in CSS as ten attribute-selector rules. It's literally the 0–9 truth table you'd find in a 7-segment decoder datasheet, written in selectors:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.seg-digit&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;data-value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;"2"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="nd"&gt;:is&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;.seg-a&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;.seg-b&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;.seg-d&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;.seg-e&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;.seg-g&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
&lt;span class="nc"&gt;.seg-digit&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;data-value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;"4"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="nd"&gt;:is&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;.seg-b&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;.seg-c&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;.seg-f&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;.seg-g&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
&lt;span class="nc"&gt;.seg-digit&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;data-value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;"8"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="nd"&gt;:is&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;.seg-a&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;.seg-b&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;.seg-c&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;.seg-d&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;.seg-e&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;.seg-f&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;.seg-g&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--seg-on&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;React's entire job here is setting &lt;code&gt;data-value="4"&lt;/code&gt;. The cascade does the decoding.&lt;/p&gt;

&lt;p&gt;The geometry is absolutely positioned in &lt;code&gt;em&lt;/code&gt; units — &lt;code&gt;seg-a&lt;/code&gt; at &lt;code&gt;top: 0&lt;/code&gt;, &lt;code&gt;seg-g&lt;/code&gt; at &lt;code&gt;top: 0.455em&lt;/code&gt;, &lt;code&gt;seg-d&lt;/code&gt; at &lt;code&gt;top: 0.91em&lt;/code&gt;, verticals hung at the corners — so the whole digit scales with &lt;code&gt;font-size&lt;/code&gt; like real text does. The classic tapered segment ends are one &lt;code&gt;clip-path: polygon(...)&lt;/code&gt; per orientation, hexagons instead of rectangles.&lt;/p&gt;

&lt;p&gt;Two branch-specific details worth stealing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Glow:&lt;/strong&gt; &lt;code&gt;text-shadow&lt;/code&gt; is useless here because there's no text. The segment branch uses &lt;code&gt;filter: drop-shadow(var(--seg-glow))&lt;/code&gt; on the container, which follows the clipped hexagon shapes exactly — including the tapered tips.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Accessibility:&lt;/strong&gt; a pile of empty spans is meaningless to a screen reader, so the container carries &lt;code&gt;aria-label={value}&lt;/code&gt;. Cheap insurance for a fully decorative DOM structure.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Branch 3: flip — the only branch that needs memory
&lt;/h2&gt;

&lt;p&gt;Flip is the one technology that can't be stateless, because a flip has a &lt;em&gt;before&lt;/em&gt; and an &lt;em&gt;after&lt;/em&gt;. The component keeps the previous value in state and runs a two-phase animation — the top half folds down over 300ms, then the bottom half unfolds for another 300ms — before committing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;theme&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;flip&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setPrev&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// pixel/segment themes: no animation, just sync&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nf"&gt;setFlipping&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;timerRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setPrev&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;setFlipping&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;theme&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the second early return: when the active theme is pixel or segment, the same component silently degrades into "just track the value." The flip machinery — the extra state, the timeout, the transient fold elements rendered during &lt;code&gt;flipping&lt;/code&gt; — costs nothing on the branches that don't use it. That's what lets one &lt;code&gt;FlipDigit&lt;/code&gt; component serve all three technologies instead of three sibling components duplicating the value-diffing logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one SSR wrinkle
&lt;/h2&gt;

&lt;p&gt;A clock is the canonical hydration-mismatch generator: the server has no idea what time it is in the client's timezone. My &lt;code&gt;useNow&lt;/code&gt; hook returns &lt;code&gt;Date | null&lt;/code&gt;, starts as &lt;code&gt;null&lt;/code&gt;, and only picks up a real &lt;code&gt;Date&lt;/code&gt; inside &lt;code&gt;useEffect&lt;/code&gt; — so the server render and first client render agree on "no time yet," and the 250ms interval is purely a refresh rate after that.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this bought me
&lt;/h2&gt;

&lt;p&gt;The final tally for eight themes across three display technologies: one digit component with three branches, one theme array, and a few hundred lines of static CSS. No canvas contexts to manage, no SVG sprites to generate, nothing to preload. The DOM stays inspectable — you can watch digit &lt;code&gt;4&lt;/code&gt; light up &lt;code&gt;b, c, f, g&lt;/code&gt; in the elements panel, which also made every rendering bug a devtools problem instead of a bitmap-debugging problem.&lt;/p&gt;

&lt;p&gt;If you want to poke at the result (devtools open, ideally), it's live at &lt;a href="https://digitalclock.xyz/" rel="noopener noreferrer"&gt;digitalclock.xyz&lt;/a&gt;. And if you've pushed attribute selectors somewhere equally questionable, I'd honestly love to hear about it in the comments.&lt;/p&gt;

</description>
      <category>frontend</category>
      <category>webdev</category>
      <category>css</category>
      <category>react</category>
    </item>
    <item>
      <title>What Western Devs Need to Know Before Visiting China in 2026: Alipay, WeChat Pay &amp; the Mobile Web</title>
      <dc:creator>yyj-dev</dc:creator>
      <pubDate>Tue, 23 Jun 2026 15:42:03 +0000</pubDate>
      <link>https://dev.to/yyj/what-western-devs-need-to-know-before-visiting-china-in-2026-alipay-wechat-pay-the-mobile-web-1bi0</link>
      <guid>https://dev.to/yyj/what-western-devs-need-to-know-before-visiting-china-in-2026-alipay-wechat-pay-the-mobile-web-1bi0</guid>
      <description>&lt;p&gt;If you write software for a living and you're considering a trip to China in 2026, the friction you'll hit is not what you expect. The Great Firewall is the headline, but it's rarely what trips up a first-time visitor. What actually breaks your week is the small stuff: a QR code at a noodle shop, a metro turnstile that won't take your foreign card, a hotel Wi-Fi that quietly drops every request to Google.&lt;/p&gt;

&lt;p&gt;This is a brief survival guide written from a developer's mindset: what's actually changed in 2026, what you can fix before you leave, and what you should just accept.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Visa-free entry now covers most Western devs
&lt;/h2&gt;

&lt;p&gt;As of late 2025, China extended its 30-day visa-free transit policy to passport holders from 38 countries, including the US, UK, Germany, France, Australia, the Netherlands, and most of the EU. If you're flying in for a vacation, a conference, or even a short remote-work stretch, you may not need to apply for a visa at all — you just need an onward ticket within 30 days.&lt;/p&gt;

&lt;p&gt;The catch: the rules per nationality drift quarterly, and the official guidance is scattered across embassy pages. I keep a more current breakdown here: &lt;a href="https://firsttripchina.com/" rel="noopener noreferrer"&gt;FirstTripChina visa-free guide&lt;/a&gt; — worth checking the week you book your ticket.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The payment problem is the real "API" you need to integrate
&lt;/h2&gt;

&lt;p&gt;China runs on two payment rails: Alipay and WeChat Pay. Cash is technically legal but vendors below the level of a 4-star hotel will look at you like you handed them a stone tablet. Foreign credit cards work at airports and big chains; they do not work at the dumpling place you actually want to eat at.&lt;/p&gt;

&lt;p&gt;The fix that exists in 2026 — and that did not exist three years ago — is "Tour Card" inside Alipay and "International" mode inside WeChat Pay. Both let you link a Visa/Mastercard issued outside China and pay via the same QR system locals use. Setup steps (roughly):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Install Alipay (App Store / Play Store, US/EU regions both work).&lt;/li&gt;
&lt;li&gt;Verify with passport + selfie (KYC takes about 3 minutes).&lt;/li&gt;
&lt;li&gt;Tap &lt;strong&gt;Tour Card&lt;/strong&gt; → add your foreign card → top up an in-app balance, OR enable real-time charges.&lt;/li&gt;
&lt;li&gt;Repeat the equivalent flow in WeChat → Me → Services → Wallet → International.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Two tips that took me embarrassingly long to figure out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Top-ups under ¥200 (~$28) waive fees&lt;/strong&gt;; above that, you pay ~3%. Re-charge often instead of once.&lt;/li&gt;
&lt;li&gt;WeChat Pay's &lt;code&gt;International&lt;/code&gt; mode has a daily transaction cap that resets at midnight Beijing time, &lt;em&gt;not&lt;/em&gt; midnight local time. Plan accordingly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I wrote up the full setup with screenshots: &lt;a href="https://firsttripchina.com/" rel="noopener noreferrer"&gt;Alipay &amp;amp; WeChat Pay setup for foreigners&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Your phone is a router, not just a phone
&lt;/h2&gt;

&lt;p&gt;If you're traveling from a country with a "free roaming in China" plan (T-Mobile US, Three UK, some EU carriers), the bandwidth is throttled but the &lt;strong&gt;route is unfiltered&lt;/strong&gt; — your traffic exits in Hong Kong or Singapore. Google, GitHub, Slack, your work VPN: all just work.&lt;/p&gt;

&lt;p&gt;If you're not on one of those plans, you have three options:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Buy a physical or eSIM tourist plan&lt;/strong&gt; from China Unicom HK or 3HK — these route through Hong Kong, so still unfiltered. ~$25 for 20 GB / 30 days.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pre-install a paid VPN with a stealth protocol&lt;/strong&gt; (WireGuard over obfuscation works in mid-2026; pure OpenVPN is detected). Test it &lt;em&gt;before&lt;/em&gt; you land — VPN provider websites are blocked inside the wall, so you can't sign up after arrival.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do nothing&lt;/strong&gt; and accept that for one week, your Slack will live on Telegram, your Gmail on Outlook, and your Google Maps on Apple Maps. (Yes, Apple Maps works in China. It's surprisingly good. Baidu Maps is better.)&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  4. High-speed rail beats everything else for inter-city
&lt;/h2&gt;

&lt;p&gt;The high-speed rail (HSR) network in 2026 is the single best argument for traveling in mainland China. Beijing → Shanghai (1,300 km / 800 mi) is 4h 18m at 350 km/h, costs ¥553 (~$77) in second class, and runs every 12 minutes. There is no airport security line, no boarding pass, no luggage check — you tap your passport at the gate and walk on.&lt;/p&gt;

&lt;p&gt;Book through &lt;strong&gt;Trip.com&lt;/strong&gt; or &lt;strong&gt;12306 English&lt;/strong&gt; in your Apple/Play store. The native 12306 app is more reliable but the English UI is rough. Buy your ticket the day before; popular Beijing-Shanghai trains do sell out on weekends.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The three cities most worth your first week
&lt;/h2&gt;

&lt;p&gt;If this is your first trip and you have 7–10 days, the strongest itinerary I've seen:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Beijing (3 days)&lt;/strong&gt; — Forbidden City, Mutianyu Great Wall, 798 art district. The history is the point.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Xi'an (2 days)&lt;/strong&gt; — Terracotta Army, the Muslim Quarter night market. Worth the detour.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shanghai (3 days)&lt;/strong&gt; — Modern China. The Bund, Pudong skyline, plus the food scene most worth flying for.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;HSR connects all three in &amp;lt;6 hours each leg. Full city-by-city breakdown: &lt;a href="https://firsttripchina.com/" rel="noopener noreferrer"&gt;FirstTripChina city guides&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Check visa-free eligibility before you book — for most Western passports in 2026, you don't need a visa for ≤30 days.&lt;/li&gt;
&lt;li&gt;Install Alipay + WeChat Pay &lt;em&gt;before&lt;/em&gt; you fly. Their KYC requires a phone number you can receive SMS on; do it from home.&lt;/li&gt;
&lt;li&gt;Get an HK-routed eSIM if your home carrier doesn't include China roaming.&lt;/li&gt;
&lt;li&gt;Beijing → Xi'an → Shanghai by HSR is the optimal first-trip loop.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The country is friendlier to foreign travelers than its reputation suggests, but the friction is concentrated in the first 24 hours after you land. Front-load the prep, and the rest of the trip is a breeze.&lt;/p&gt;

</description>
      <category>travel</category>
      <category>lifestyle</category>
      <category>webdev</category>
      <category>productivity</category>
    </item>
    <item>
      <title>I built PDF Dark — convert any PDF to true dark mode, 100% in your browser</title>
      <dc:creator>yyj-dev</dc:creator>
      <pubDate>Mon, 22 Jun 2026 13:12:29 +0000</pubDate>
      <link>https://dev.to/yyj/i-built-pdf-dark-convert-any-pdf-to-true-dark-mode-100-in-your-browser-5985</link>
      <guid>https://dev.to/yyj/i-built-pdf-dark-convert-any-pdf-to-true-dark-mode-100-in-your-browser-5985</guid>
      <description>&lt;h2&gt;
  
  
  Why another PDF tool?
&lt;/h2&gt;

&lt;p&gt;I read a lot of PDFs at night — research papers, ebooks, technical manuals. Every PDF reader I tried had the same problem: their "dark mode" was just a viewer toggle. The moment I forwarded the PDF to my Kindle or iPad, the white background blasted me again.&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://pdfdark.org" rel="noopener noreferrer"&gt;&lt;strong&gt;PDF Dark&lt;/strong&gt;&lt;/a&gt; — a tool that converts any PDF into a &lt;strong&gt;real dark-mode PDF file&lt;/strong&gt; you can download, share, and reread anywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;100% browser-side conversion.&lt;/strong&gt; Your file never touches a server. Verifiable in DevTools.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real dark output PDF&lt;/strong&gt;, not a viewer trick. Works on Kindle, iPad, Acrobat, anywhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Saturation-aware algorithm&lt;/strong&gt; — photos and charts keep their original colors.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Free, MIT-licensed, no signup, no ads.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How it works under the hood
&lt;/h2&gt;

&lt;p&gt;The conversion runs entirely in the browser using &lt;strong&gt;PDF.js&lt;/strong&gt; to parse the original PDF page by page, then re-renders each page through a Canvas pipeline. The algorithm walks pixel data and inverts only low-saturation regions (text, line art, white background), while preserving high-saturation regions (photos, color charts, logos).&lt;/p&gt;

&lt;p&gt;The output is then re-encoded as a standard PDF using &lt;strong&gt;pdf-lib&lt;/strong&gt;. No server, no upload — your file never leaves your device.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Website: &lt;a href="https://pdfdark.org" rel="noopener noreferrer"&gt;https://pdfdark.org&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub (MIT): &lt;a href="https://github.com/1436941541/pdf-dark" rel="noopener noreferrer"&gt;https://github.com/1436941541/pdf-dark&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Feedback welcome
&lt;/h2&gt;

&lt;p&gt;This is an early version. PRs, bug reports, and feature requests are very welcome — especially edge cases (complex form PDFs, scanned PDFs with dense images, RTL languages). Drop a comment below or open an issue on GitHub.&lt;/p&gt;

&lt;p&gt;If you read PDFs at night, give it a try and let me know what breaks.&lt;/p&gt;

</description>
      <category>javascript</category>
    </item>
  </channel>
</rss>
