<?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: hb lai</title>
    <description>The latest articles on DEV Community by hb lai (@hblai_filmlook).</description>
    <link>https://dev.to/hblai_filmlook</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%2F3971544%2F07148009-54bc-435d-91ec-c30a2d094054.png</url>
      <title>DEV Community: hb lai</title>
      <link>https://dev.to/hblai_filmlook</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hblai_filmlook"/>
    <language>en</language>
    <item>
      <title>Building a 100-question Rice Purity Test with Next.js: persona theming + a canvas share card</title>
      <dc:creator>hb lai</dc:creator>
      <pubDate>Tue, 23 Jun 2026 16:48:48 +0000</pubDate>
      <link>https://dev.to/hblai_filmlook/building-a-100-question-rice-purity-test-with-nextjs-persona-theming-a-canvas-share-card-1d7e</link>
      <guid>https://dev.to/hblai_filmlook/building-a-100-question-rice-purity-test-with-nextjs-persona-theming-a-canvas-share-card-1d7e</guid>
      <description>&lt;p&gt;I shipped a small side project last week, a Rice Purity Test, and a couple of the build details turned out more interesting than the quiz itself. Two in particular: making the result screen recolor itself based on your score, and exporting a share card to PNG entirely in the browser. Notes below in case they save someone an hour.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;It's a Next.js 16 app on the App Router, deployed to Cloudflare Workers via OpenNext. The quiz is one client component (an "island") sitting inside otherwise static, server-rendered pages, so the SEO content stays fast and only the interactive part hydrates. 100 checkbox questions; your score is 100 minus whatever you tick.&lt;/p&gt;

&lt;h2&gt;
  
  
  Persona-adaptive theming
&lt;/h2&gt;

&lt;p&gt;This was the fun part. Instead of one result screen, the page picks a colour identity from your score band and drives everything off a single CSS custom property.&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="nd"&gt;:root&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;--persona&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;--sage&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;      &lt;span class="c"&gt;/* high score, reserved */&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;data-band&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;"wild"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;--persona&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;--crimson&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;  &lt;span class="c"&gt;/* low score */&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result component sets a &lt;code&gt;data-band&lt;/code&gt; attribute plus the accent colour, and the card background, the borders, even the shadow tint all follow from &lt;code&gt;--persona&lt;/code&gt;. No conditional class soup. Ten bands, one variable.&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;band&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;bandForScore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// 0..9&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;section&lt;/span&gt; &lt;span class="na"&gt;data-band&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;band&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="si"&gt;}&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;--persona&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;band&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;color&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="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;section&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;Setting a CSS var inline in React like that is underused. You compute a theme value in JS and hand it straight to plain CSS, no styled-components, no re-render storm.&lt;/p&gt;

&lt;h2&gt;
  
  
  Exporting the share card as a PNG
&lt;/h2&gt;

&lt;p&gt;People want to post their score, so the card has to become an image. I used &lt;code&gt;html-to-image&lt;/code&gt; and dynamic-imported it so it never touches the initial bundle:&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;savePng&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fonts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ready&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                 &lt;span class="c1"&gt;// gotcha #1&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;toPng&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;html-to-image&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;toPng&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;pixelRatio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;cacheBust&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="c1"&gt;// gotcha #2&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;a&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;download&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rice-purity-score.png&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;click&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;Two things ate that hour. Web fonts must be fully loaded before you rasterize or the card renders in a fallback font, hence &lt;code&gt;await document.fonts.ready&lt;/code&gt;. And the default export looks soft on retina, so &lt;code&gt;pixelRatio: 2&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  i18n without moving indexed URLs
&lt;/h2&gt;

&lt;p&gt;I added five languages after launch. English stays at the root (unprefixed) so nothing already indexed moves; other locales get a prefix (&lt;code&gt;/es&lt;/code&gt;, &lt;code&gt;/pt&lt;/code&gt;, and so on). One dictionary file per locale, all matching the same TypeScript &lt;code&gt;Dict&lt;/code&gt; shape, and the client quiz reads the dict as plain serializable data. hreflang and canonical both generate from a single &lt;code&gt;LOCALES&lt;/code&gt; array, so adding a language is basically: write the dict, append the code, deploy.&lt;/p&gt;

&lt;p&gt;If you want to poke at the finished thing, it's live at &lt;a href="https://ricepuritytest.art/" rel="noopener noreferrer"&gt;ricepuritytest.art&lt;/a&gt;, and the &lt;a href="https://ricepuritytest.art/score-meaning" rel="noopener noreferrer"&gt;score-meaning breakdown&lt;/a&gt; is where those band colours come from. Happy to get into any of the build details in the comments.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>webdev</category>
      <category>css</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Building a paint-to-hide browser minigame with Next.js and SVG</title>
      <dc:creator>hb lai</dc:creator>
      <pubDate>Tue, 16 Jun 2026 03:58:42 +0000</pubDate>
      <link>https://dev.to/hblai_filmlook/building-a-paint-to-hide-browser-minigame-with-nextjs-and-svg-18fn</link>
      <guid>https://dev.to/hblai_filmlook/building-a-paint-to-hide-browser-minigame-with-nextjs-and-svg-18fn</guid>
      <description>&lt;p&gt;There's a PC party game called Meccha Chameleon where hiders paint their body to match a wall or a crate and freeze, while seekers try to spot them. I got a bit obsessed with the camouflage idea and built two small things around it.&lt;/p&gt;

&lt;p&gt;The first is a camo "lab": pick a surface and it hands you the exact colours, a pattern, and a pose. Under the hood it's one inline SVG chameleon whose fills are driven by a per-surface palette. Recolouring an inline SVG with a few variables turned out far simpler than generating images, and each surface is a tiny data object (three hex colours, a pattern, a blend score), so adding one is a one-line change.&lt;/p&gt;

&lt;p&gt;The second is an actual playable minigame: a grid of tiles, a few of them hiding a chameleon painted to match its tile, and you tap to find them before the timer runs out. It's all React state, no canvas and no engine, which kept it tiny and instant to load.&lt;/p&gt;

&lt;p&gt;It runs as a Next.js app on Cloudflare Workers via OpenNext, a nice combo for a mostly-static site with a couple of client tools: static pages cache at the edge while the interactive bits hydrate on the client.&lt;/p&gt;

&lt;p&gt;If you want to poke at it, the &lt;a href="https://mecchachameleon.online/" rel="noopener noreferrer"&gt;Camo Lab is here&lt;/a&gt; and the &lt;a href="https://mecchachameleon.online/play" rel="noopener noreferrer"&gt;browser game is here&lt;/a&gt;. Happy to answer anything about the SVG recolour or the OpenNext setup.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>gamedev</category>
    </item>
    <item>
      <title>I built a free toolbox for Threads (no login, no watermark)</title>
      <dc:creator>hb lai</dc:creator>
      <pubDate>Mon, 08 Jun 2026 03:04:41 +0000</pubDate>
      <link>https://dev.to/hblai_filmlook/i-built-a-free-toolbox-for-threads-no-login-no-watermark-1ign</link>
      <guid>https://dev.to/hblai_filmlook/i-built-a-free-toolbox-for-threads-no-login-no-watermark-1ign</guid>
      <description>&lt;p&gt;I post on Threads a fair bit, and I kept hitting the same small walls: no way to save a video, no clean screenshot, and reposting from X meant retyping everything. The sites that promised to help were buried in ads or wanted a login for a five-second job.&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://threadstools.app" rel="noopener noreferrer"&gt;ThreadsTools&lt;/a&gt;, a set of small, single-purpose tools for Threads. Free, no login, no watermark.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's in it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Download the video, photos, GIF, or audio from any public Threads post&lt;/li&gt;
&lt;li&gt;A &lt;a href="https://threadstools.app/tools/threads-audio-downloader" rel="noopener noreferrer"&gt;Threads to MP3&lt;/a&gt; converter that pulls just the audio&lt;/li&gt;
&lt;li&gt;A screenshot maker, plus quote-card and mockup generators&lt;/li&gt;
&lt;li&gt;Converters that turn an X tweet or a LinkedIn post into a Threads draft&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A couple of build notes
&lt;/h2&gt;

&lt;p&gt;The media downloads stream straight from the CDN to the browser, because Meta's CDN blocks server-side fetches anyway. The MP4-to-GIF and MP4-to-MP3 conversions run client-side with ffmpeg.wasm, so nothing gets uploaded. The whole thing runs on Cloudflare Workers, mostly because there are no egress fees, which is what makes a free downloader that streams video affordable to run.&lt;/p&gt;

&lt;p&gt;Code and notes: &lt;a href="https://github.com/Nora4844/threadstools" rel="noopener noreferrer"&gt;https://github.com/Nora4844/threadstools&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Full set of tools: &lt;a href="https://threadstools.app/tools" rel="noopener noreferrer"&gt;threadstools.app/tools&lt;/a&gt;&lt;/p&gt;

</description>
      <category>showdev</category>
    </item>
    <item>
      <title>The 'bad photo' look is back — here's the engineering behind it</title>
      <dc:creator>hb lai</dc:creator>
      <pubDate>Sat, 06 Jun 2026 16:46:56 +0000</pubDate>
      <link>https://dev.to/hblai_filmlook/the-bad-photo-look-is-back-heres-the-engineering-behind-it-1h59</link>
      <guid>https://dev.to/hblai_filmlook/the-bad-photo-look-is-back-heres-the-engineering-behind-it-1h59</guid>
      <description>&lt;p&gt;Every few years a visual style comes back, but this one is funny: it's literally old engineering bugs becoming features. The "2000s digicam" / CCD / disposable-camera look that's everywhere right now is just a stack of old hardware limitations — and you can recreate every one of them in code.&lt;/p&gt;

&lt;p&gt;Here's what actually made those photos look "wrong," and how each maps to something you can fake today.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Tiny sensors → noise
&lt;/h2&gt;

&lt;p&gt;Early compacts and phone cameras had minuscule sensors. Less light per pixel means more noise — that fine speckled grain in the shadows. Engineers spent ~15 years killing it with bigger sensors and computational denoising. To fake it: add per-pixel luminance noise, weighted toward the shadows.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Aggressive compression → blocky softness
&lt;/h2&gt;

&lt;p&gt;Storage was tiny and expensive, so cameras compressed hard. Low-quality JPEG smears fine detail and leaves 8×8 block artifacts. Re-encoding at low quality (or simulating DCT block quantization) gives you that soft, slightly-mushy texture.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. On-board flash → the flat night look
&lt;/h2&gt;

&lt;p&gt;A bright bulb right next to the lens gives flat, head-on lighting: bright faces, hard shadows, a background that falls to black. Approximate it with a radial highlight near the subject and a steep falloff.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Narrow dynamic range → clipped highlights
&lt;/h2&gt;

&lt;p&gt;Old sensors couldn't hold bright and dark at once, so skies blew to white and shadows crushed to black. A tone curve that clips both ends reproduces it.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. CCD color science → the cool cast
&lt;/h2&gt;

&lt;p&gt;CCD sensors leaned cool, sometimes slightly green. A small channel-mixer / white-balance shift toward cyan-green nails the "CCD" feeling people are nostalgic for.&lt;/p&gt;

&lt;p&gt;The fun part: do all five at once and you don't get "a filter" — you get a photo that looks like it &lt;em&gt;remembers&lt;/em&gt; something. It reads as a real moment instead of a product shot.&lt;/p&gt;

&lt;p&gt;I've been poking at a free in-browser version that does these as one-click presets (iPhone 4, CCD, disposable, Y2K). Everything runs locally on a canvas, nothing uploads: &lt;a href="https://digicamfilter.online/" rel="noopener noreferrer"&gt;digicamfilter.online&lt;/a&gt;. Useful for seeing each effect in isolation before wiring up your own.&lt;/p&gt;

&lt;p&gt;Anyway — half of "retro" aesthetics are just old constraints we worked hard to remove, switched back on by choice. Kind of poetic.&lt;/p&gt;

</description>
      <category>photography</category>
      <category>webdev</category>
      <category>design</category>
    </item>
  </channel>
</rss>
