<?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: Andy Yeung</title>
    <description>The latest articles on DEV Community by Andy Yeung (@amsyeung).</description>
    <link>https://dev.to/amsyeung</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%2F2640233%2F5d61bf06-0631-4ce6-aff9-5c02feae3f2e.jpeg</url>
      <title>DEV Community: Andy Yeung</title>
      <link>https://dev.to/amsyeung</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/amsyeung"/>
    <language>en</language>
    <item>
      <title>Why Windows wouldn't let me rename a folder I wasn't using</title>
      <dc:creator>Andy Yeung</dc:creator>
      <pubDate>Wed, 28 Oct 2026 07:06:46 +0000</pubDate>
      <link>https://dev.to/amsyeung/why-windows-wouldnt-let-me-rename-a-folder-i-wasnt-using-48m</link>
      <guid>https://dev.to/amsyeung/why-windows-wouldnt-let-me-rename-a-folder-i-wasnt-using-48m</guid>
      <description>&lt;h2&gt;
  
  
  Background
&lt;/h2&gt;

&lt;p&gt;I wanted to give a Next.js project folder a new name. It should have been a two-second job, but every attempt ended with Access is denied, even though I had closed every file inside it.&lt;/p&gt;

&lt;p&gt;The usual answer is to reboot. I wanted to know what was actually holding it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I tried
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stopped the dev server&lt;/strong&gt;: still couldn't rename it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Closed every editor window&lt;/strong&gt;: a quick check showed more than ten editor processes still running in the background.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Force-quit those editor processes&lt;/strong&gt;: that also stopped another project's dev server running in its terminal, and the rename still failed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Checked the files with Windows' own lock check (Restart Manager)&lt;/strong&gt;: it said nothing was locked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Installed PowerToys&lt;/strong&gt;: its install log said the machine was waiting for a reboot.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In the end I kept the old name, but I finally understood what was going on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Windows locks things
&lt;/h2&gt;

&lt;p&gt;Windows makes a different trade-off from macOS and Linux:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Windows&lt;/strong&gt;: when a program opens a file, other programs can't delete or rename it unless the first program explicitly allows it. That way nothing swaps out a file a running program depends on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;macOS and Linux&lt;/strong&gt;: you can rename or delete a file while it's open, and the program that opened it keeps reading the old contents.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What gets locked is often the folder itself rather than any one file. In a development project the usual suspects are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Working directory&lt;/strong&gt;: once a terminal has cd'd into a folder, the folder can't be renamed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;File watchers&lt;/strong&gt;: editors and dev servers watch the whole project, and they can keep running after the window is gone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Antivirus scans&lt;/strong&gt;: Defender scans newly written files, and node_modules holds a lot of them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;System services&lt;/strong&gt;: the search indexer and Explorer thumbnails and previews.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The tool that answers the question: File Locksmith
&lt;/h2&gt;

&lt;p&gt;Access is denied tells you something is using the folder, but never what. File Locksmith, one of the small utilities in Microsoft's free PowerToys, answers exactly that.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Turn it on&lt;/strong&gt;: open PowerToys and switch on File Locksmith.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask about a file or folder&lt;/strong&gt;: right-click it in File Explorer, choose Show more options, then Unlock with File Locksmith. You can select several items at once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It looks inside folders&lt;/strong&gt;: for a folder it also checks every file and subfolder in it, which is what my case needed. A check that only looks at single files misses this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the list&lt;/strong&gt;: it shows every process using your selection, and each one expands for more detail.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Release only what's needed&lt;/strong&gt;: End task stops that one process. The list updates by itself when a process goes away, and Reload scans again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;See everything&lt;/strong&gt;: by default it only sees your own programs. If the list is empty but the folder is still locked, choose Restart as administrator to include system services and other users' processes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's also a command-line version that can wait until a file is released, which is handy in a build script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;PowerToys.FileLocksmith.CLI.exe &lt;span class="nt"&gt;--wait&lt;/span&gt; &lt;span class="nt"&gt;--timeout&lt;/span&gt; 30000 &lt;span class="s2"&gt;"C:&lt;/span&gt;&lt;span class="se"&gt;\p&lt;/span&gt;&lt;span class="s2"&gt;ath&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="s2"&gt;o&lt;/span&gt;&lt;span class="se"&gt;\m&lt;/span&gt;&lt;span class="s2"&gt;y-project"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  If you can't install PowerToys
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Resource Monitor&lt;/strong&gt; is built in: open it, go to the CPU tab and search for the folder name under Associated Handles.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sysinternals handle&lt;/strong&gt; does the same from the command line:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;handle.exe &lt;span class="nt"&gt;-nobanner&lt;/span&gt; my-project
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both list the handles each process holds, folders included. Restart Manager only checks files, which is why it found nothing when the folder itself was locked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistakes I made
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Closing the windows isn't closing the program&lt;/strong&gt;: with every editor window gone, more than ten processes were still running.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Force-quitting has side effects&lt;/strong&gt;: a dev server running in the editor's terminal stops with it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Using the wrong tool&lt;/strong&gt;: a file-only check can't see folder locks, so my "nothing is locked" conclusion was wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A pending reboot&lt;/strong&gt;: unfinished system updates make every kind of lock harder to track down.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What improved
&lt;/h2&gt;

&lt;p&gt;Now when a folder won't rename, I right-click it and File Locksmith tells me which program is holding it, instead of me closing apps one by one and guessing. I end just that one process instead of rebooting. And before renaming a project, I close the editor, the dev server and any terminal that's sitting inside it.&lt;/p&gt;

&lt;p&gt;Two more ways to avoid it altogether: keep projects on a Windows 11 Dev Drive (or add them to Defender's exclusions), or run Node.js projects inside WSL.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Cover: &lt;a href="https://www.rawpixel.com/image/463170/free-photo-image-padlock-door-lock-metal" rel="noopener noreferrer"&gt;Closeup padlock wooden door&lt;/a&gt; by Markus Spiske, &lt;a href="https://creativecommons.org/publicdomain/zero/1.0/" rel="noopener noreferrer"&gt;CC0 1.0&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>windows</category>
      <category>devtools</category>
      <category>powertoys</category>
      <category>node</category>
    </item>
    <item>
      <title>My first day with ComfyUI: from a selfie to a 20-second aquarium shot</title>
      <dc:creator>Andy Yeung</dc:creator>
      <pubDate>Tue, 27 Oct 2026 07:06:32 +0000</pubDate>
      <link>https://dev.to/amsyeung/my-first-day-with-comfyui-from-a-selfie-to-a-20-second-aquarium-shot-5af8</link>
      <guid>https://dev.to/amsyeung/my-first-day-with-comfyui-from-a-selfie-to-a-20-second-aquarium-shot-5af8</guid>
      <description>&lt;h2&gt;
  
  
  Background
&lt;/h2&gt;

&lt;p&gt;After installing ComfyUI for a friend I couldn't resist trying it myself. I don't work in VFX and knew nothing about nodes or models, so I started from ComfyUI's built-in templates: LTX-2.5 for image to video and FLUX.2 Klein for image editing. My graphics card has 16 GB of VRAM, and a 5-second clip took about 100 seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I tried
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A selfie with the template's own prompt&lt;/strong&gt;: the clip started with me at a restaurant door and then cut to a cyberpunk rooftop. Image to video means "make the first frame move", so when the prompt describes a different place, the AI does both.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Edit the photo first, then animate it&lt;/strong&gt;: an image-edit prompt saying "turn him into a cyberpunk character" gave me a movie hero who looked nothing like me. Rewriting it as a list ("keep his face, change only these things") got much closer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A full-body shot with new clothes and a new location&lt;/strong&gt;: no likeness at all. In a full-body frame the face is only a few dozen pixels, far too little for the AI to keep the details that make it you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Changing tack: a photo without a face&lt;/strong&gt;: me from behind at an aquarium. With no face to keep, the AI could put all its effort into the fish and the camera. This worked best.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How I did it
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. The first frame decides everything
&lt;/h3&gt;

&lt;p&gt;Location, distance and aspect ratio all come from the first frame. My first aquarium clip showed only my head and shoulders: the aspect ratio was still 16:9, so a very tall portrait photo was cut into a horizontal strip from the middle. I cropped the photo to 9:16 (trimming only the top of the tank and keeping my feet) and set the aspect ratio to 9:16.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Describe what's already in the frame first
&lt;/h3&gt;

&lt;p&gt;First the person, clothes and setting, then one or two motions and one camera move, and finally "Same location throughout, no scene change, no cuts". LTX-2.5 also generates sound, so the prompt can end with what you want to hear.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Control the camera with words
&lt;/h3&gt;

&lt;p&gt;For a wide shot, write "wide shot, the camera keeps the same distance". To stop it pushing in too far, write "stays at a medium distance, always showing the whole sardine ball", because fish seen too close fall apart.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. For 20 seconds, move the camera instead of cutting
&lt;/h3&gt;

&lt;p&gt;The AI tends to warp at a hard cut. I wrote four beats with "first... then... finally...": a slow push in on the wide view, then tilt up to follow the manta ray, then drift back down to the sardine ball as a shark swims through it, then pull back to finish. About five seconds each, so it never feels rushed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;One continuous take, smooth camera movement, no hard cuts.
At the start: &amp;lt;wide description of the first frame&amp;gt;. The camera drifts slowly toward &amp;lt;subject&amp;gt; but stays at a medium distance.
Then &amp;lt;second subject&amp;gt; passes; the camera gently tilts and pans to follow it.
Then the camera drifts back to &amp;lt;main subject&amp;gt;.
At the end, the camera slowly pulls back to the wide view.
The person does not move. Same location throughout, no scene change, no text.
&amp;lt;sound description&amp;gt;.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Things that bit me
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keeping a real face is the hardest part&lt;/strong&gt;: asking the AI to "become a character" makes it invent a new person. "Keep the face, change only...", a waist-up framing and a higher resolution all help; for full-body likeness you need a dedicated face-swap tool or a LoRA trained on your face. That's also why VFX artists pair AI with Nuke.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Settings I forgot to change&lt;/strong&gt;: I thought I had set 20 seconds and portrait, but the result was 5 seconds and landscape. Check duration and aspect ratio before every run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Be specific about the sardines&lt;/strong&gt;: a vague "fish" gave me a crowd of big fish. "A massive swirling ball of thousands of small silvery sardines with dark spots along their sides" gave me the ones I wanted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Game screenshots as references&lt;/strong&gt;: fine for playing around, but don't copy existing characters or locations for commercial work. And tick "Add AI label" when you post.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What improved
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;At first&lt;/th&gt;
&lt;th&gt;Later&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Location&lt;/td&gt;
&lt;td&gt;Restaurant door, then a sudden cyberpunk rooftop&lt;/td&gt;
&lt;td&gt;The same place from start to end&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Framing&lt;/td&gt;
&lt;td&gt;Cut down to head and shoulders&lt;/td&gt;
&lt;td&gt;Whole body in frame&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fish&lt;/td&gt;
&lt;td&gt;A crowd of big fish&lt;/td&gt;
&lt;td&gt;A sardine ball, a manta ray and a shark&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Length and camera&lt;/td&gt;
&lt;td&gt;5 seconds, pushing in the whole time&lt;/td&gt;
&lt;td&gt;20 seconds, four natural camera beats&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Let AI apply it for you
&lt;/h2&gt;

&lt;p&gt;If you want to turn a photo into an AI video, paste this into an AI chat along with your photo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Help me write an image-to-video prompt for ComfyUI (LTX-style models) from the attached photo:

1. Describe exactly what is already in the photo: subject, clothes, setting, lighting.
2. Add only one or two motions and one camera move. For clips over 10 seconds, use "first... then...
   finally..." beats with continuous camera moves instead of cuts.
3. Control distance with words: "wide shot, the camera keeps the same distance" or "stays at a medium
   distance"; avoid "close-up" or "push in" unless I ask.
4. End with: "Same location throughout, no scene change, no cuts, no text." and a short sound description.
5. Tell me which aspect ratio and duration to set so the photo is not cropped.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Cover: &lt;a href="https://wordpress.org/photos/photo/6106961b3a/" rel="noopener noreferrer"&gt;A large school of silver fish with dark markings swimming through deep blue water in a spacious aquarium.&lt;/a&gt; by &lt;a href="https://faisalahammad.com" rel="noopener noreferrer"&gt;Faisal Ahammad&lt;/a&gt;, &lt;a href="https://creativecommons.org/publicdomain/zero/1.0/" rel="noopener noreferrer"&gt;CC0 1.0&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>comfyui</category>
      <category>aivideo</category>
      <category>generativeai</category>
      <category>vfx</category>
    </item>
    <item>
      <title>A one-click ComfyUI + Nuke installer for a friend in VFX</title>
      <dc:creator>Andy Yeung</dc:creator>
      <pubDate>Mon, 26 Oct 2026 03:31:25 +0000</pubDate>
      <link>https://dev.to/amsyeung/a-one-click-comfyui-nuke-installer-for-a-friend-in-vfx-21cn</link>
      <guid>https://dev.to/amsyeung/a-one-click-comfyui-nuke-installer-for-a-friend-in-vfx-21cn</guid>
      <description>&lt;h2&gt;
  
  
  Background
&lt;/h2&gt;

&lt;p&gt;My friend does compositing and wanted to find out whether AI image and video tools could fit into a Nuke workflow. An open-source plugin, ComfyUI-for-Nuke, makes that possible, but it needs a lot: ComfyUI itself, a PyTorch build that matches the graphics card, two ComfyUI nodes, the Nuke plugin, Python packages for Nuke's own Python and an edit to Nuke's &lt;code&gt;menu.py&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;My friend isn't technical, and their biggest fear was an update that stops old projects from opening. So the rules were clear: no typing commands, no changes to anything already on their computer, and updates only when they choose them, with a way back.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I tried
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A written list of install steps&lt;/strong&gt;: nine steps, a few of them in an administrator terminal. For someone who isn't technical, one wrong step and they are stuck.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One bash script for Windows and Mac&lt;/strong&gt;: it sounds neat, but a fresh Windows PC has no Git, so no Git Bash, so the very first step (install Git) can't run. In the end Windows uses the built-in PowerShell, Mac uses the built-in bash, and both read the same version list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Using the Python and Git already on the computer&lt;/strong&gt;: it saves space, but any upgrade could break their other tools. Everything moved to private copies instead.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Everything in one folder
&lt;/h3&gt;

&lt;p&gt;The script downloads private copies of Git, uv (a Python package manager) and Python into the install folder. The only thing written outside it is a tiny settings file that remembers where it was installed. No environment variables, no PATH changes.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Versions pinned in one list
&lt;/h3&gt;

&lt;p&gt;ComfyUI, the nodes and the plugin are pinned to tested commits. PyTorch is picked for the graphics card: a newer driver gets the newer CUDA build, older cards get an older build, and without NVIDIA it falls back to CPU.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"repos"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"comfyui"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"ref"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"v0.x.y"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"commit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;sha&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"risk"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Medium"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"torch"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cu130"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"minDriver"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;580&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"minComputeCap"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;7.5&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
             &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cpu"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. Updates one by one, with a restore point first
&lt;/h3&gt;

&lt;p&gt;"Check for updates" lists each item with its old and new version and a risk note (the Nuke plugin is marked "High: ComfyUI nodes in older .nk scripts may need to be refreshed"), and only what they pick gets updated. Before every install, update or rollback the script records each repo's commit, the Python package list and &lt;code&gt;menu.py&lt;/code&gt;, so any earlier state can come back.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Only one marked block in &lt;code&gt;menu.py&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Nuke's settings file gets a single block with clear start and end markers, backed up before every change, and uninstall removes only that block.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Logs, a change list and "Doctor"
&lt;/h3&gt;

&lt;p&gt;Every run writes a log and ends with a list of what was installed, updated or backed up. When something goes wrong, "Doctor" puts a zip of logs and settings (no images or projects) on the desktop that they can send me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things that bit me
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;uv tries to be helpful&lt;/strong&gt;: when it installs Python it adds a shortcut to the PATH and registers it in the Windows registry. It takes &lt;code&gt;--no-bin&lt;/code&gt; and &lt;code&gt;--no-registry&lt;/code&gt; to really leave the system alone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The user's PYTHONPATH&lt;/strong&gt;: if the computer has PYTHONPATH or PYTHONHOME set, even a separate environment picks up old packages or fails to start. The script and the launcher clear those variables for their own process only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PowerShell swallows output&lt;/strong&gt;: running a program directly inside a function turns its output into the function's return value, so prompts and progress bars vanish. &lt;code&gt;Start-Process -NoNewWindow -Wait&lt;/code&gt; fixed it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Mac's bash is old&lt;/strong&gt;: macOS ships bash 3.2, with no associative arrays, so the script reads JSON with the built-in plutil instead of extra tools.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What improved
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Install&lt;/td&gt;
&lt;td&gt;Nine manual steps, admin rights&lt;/td&gt;
&lt;td&gt;Double-click, choose 1, about fifteen minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Effect on the computer&lt;/td&gt;
&lt;td&gt;Changes system Python and PATH&lt;/td&gt;
&lt;td&gt;Everything in one folder&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Updates&lt;/td&gt;
&lt;td&gt;All at once, no way back&lt;/td&gt;
&lt;td&gt;Picked one by one with a risk note, can roll back&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;When something breaks&lt;/td&gt;
&lt;td&gt;Just "it won't open"&lt;/td&gt;
&lt;td&gt;Logs, a change list and a Doctor zip&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Still to do: the Mac version hasn't run on a real Mac yet. That's next.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let AI apply it for you
&lt;/h2&gt;

&lt;p&gt;If you want to package an open-source tool for a friend who isn't technical, paste this into an AI coding assistant:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Write a one-click installer for &amp;lt;tool&amp;gt; for a non-technical user:

1. Self-contained: download private copies of every runtime (Git, Python via uv) into one install
   folder. Never touch the system PATH, registry, environment variables or existing installs, and
   ignore PYTHONPATH/PYTHONHOME for our own processes only.
2. Pinned: keep every version (repos by commit, packages, runtimes) in one manifest of tested versions.
   Pick the GPU build from the detected driver.
3. Updates: list each item with old/new version and a plain-English risk, let the user pick by number,
   and update tested versions together as a set.
4. Reversible: before every install/update/rollback, snapshot repo commits, the package list and any
   config file we edit; offer rollback to any snapshot. Only edit config inside a marked block, after a backup.
5. Traceable: a log per run, a summary of what changed, and a "Doctor" option that zips logs and settings.

Windows uses the built-in PowerShell 5.1, macOS the built-in bash 3.2. Ask before anything destructive.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Cover: &lt;a href="https://www.rawpixel.com/image/5917049/photo-image-public-domain-free" rel="noopener noreferrer"&gt;Free close socket toolbox image&lt;/a&gt; by Unknown, &lt;a href="https://creativecommons.org/publicdomain/zero/1.0/" rel="noopener noreferrer"&gt;CC0 1.0&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>comfyui</category>
      <category>vfx</category>
      <category>automation</category>
      <category>ai</category>
    </item>
    <item>
      <title>Free AI for my Chrome extension's users, with no API key and no surprise bill</title>
      <dc:creator>Andy Yeung</dc:creator>
      <pubDate>Mon, 19 Oct 2026 07:07:55 +0000</pubDate>
      <link>https://dev.to/amsyeung/free-ai-for-my-chrome-extensions-users-with-no-api-key-and-no-surprise-bill-a32</link>
      <guid>https://dev.to/amsyeung/free-ai-for-my-chrome-extensions-users-with-no-api-key-and-no-surprise-bill-a32</guid>
      <description>&lt;h2&gt;
  
  
  The problem: I wanted the extension to see what I'm working on
&lt;/h2&gt;

&lt;p&gt;Groupify is my Chrome tab organizer. When I start a new project I usually have Pinterest, the Next.js docs, Cloudflare and Vercel open at once, and grouping by website just splits them apart. What I wanted was one click to put those tabs in one group, with a sensible name such as “Website deploy”.&lt;/p&gt;

&lt;p&gt;That needs AI. The question was which AI.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I tried
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Chrome's built-in AI (Gemini Nano)&lt;/strong&gt;: it runs on the user's computer, free and private, so it's the ideal choice. But it needs a capable machine and a lot of free disk space. My own computer doesn't have it, and most users won't either.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Asking users for their own API key&lt;/strong&gt;: the simplest technically, but my users aren't developers. Nobody signs up for a key to rename tabs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;My own API on OpenAI or Azure&lt;/strong&gt;: possible, but neither has a real free tier for this. A budget alert only emails me; it doesn't stop anything, so a bit of abuse still turns into a bill.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I wanted a free allocation that stops when it's used up, like the Azure speech free tier I use in another extension.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Cloudflare Workers AI
&lt;/h2&gt;

&lt;p&gt;Workers AI gives every account a free daily allocation. On the Workers Free plan, requests past it fail instead of being charged, so the most it can ever cost me is nothing. I only needed a tiny Worker in the middle that does three things: recognise my extension, limit how much each person can use, and turn the tabs into a prompt.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiwebdbzaq6fierqkljbc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiwebdbzaq6fierqkljbc.png" alt="How a tab group gets its name"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How I applied it
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. The Worker does two things
&lt;/h3&gt;

&lt;p&gt;One endpoint names a set of tabs; the other sorts a pile of tabs into groups by task. The Worker stores nothing and logs nothing, so it never remembers what anyone sent.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Send only what's needed
&lt;/h3&gt;

&lt;p&gt;The extension sends each tab's title and site name, never the full address, because addresses often carry tokens or private parameters. At most 40 tabs go in one request, and long titles are cut short.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Only my extension gets an answer
&lt;/h3&gt;

&lt;p&gt;The Worker answers only my extension's origins (the store build and my development build) through CORS. That means the extension needs no new permission, so users see no new warning when it updates. An origin can be faked, so there's also a per-IP limit per minute on top of the free allocation's hard cap. The worst case is a day without AI.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. A model that's good at Chinese and cheap
&lt;/h3&gt;

&lt;p&gt;Most of my users read Traditional Chinese, so I picked a cheap open model that handles Chinese well and told it to skip its thinking output to save allocation. Naming a group uses very little, and at my current number of users the free allocation goes a long way.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Off by default, and clear about what's sent
&lt;/h3&gt;

&lt;p&gt;Cloud AI is a switch in Settings, off by default, with a line saying tab titles and site names go to my server and nothing is kept. The extension always tries on-device AI first, then the cloud, then names by date, so whichever step fails, users never see an error.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things that tripped me up
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Half a Chinese character&lt;/strong&gt;: now and then the model put a broken character in the middle of a name. The Worker asks again once when that happens, and both sides strip any broken characters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JSON isn't guaranteed&lt;/strong&gt;: not every model supports JSON mode, so I take the first JSON object in the answer and check every field, for example that tab numbers aren't repeated or out of range.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The rate limit is approximate&lt;/strong&gt;: Cloudflare counts per location, and in my test it only started refusing around the ninth request. Good enough against abuse, but not an exact counter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A development build has a different ID&lt;/strong&gt;: an unpacked extension's ID comes from its folder path, so the allow list needs that ID too.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  One more small trick: telling your changes from the extension's
&lt;/h2&gt;

&lt;p&gt;Once Groupify could sort tabs automatically, I hit a problem: automatic sorting must not pull apart groups I made by hand. If I put my own blog's tabs in a group called “Me”, sorting by purpose shouldn't move them into “Reading”.&lt;/p&gt;

&lt;p&gt;But Chrome doesn't record who changed a tab or a group, only how things are now. So the extension has to tell for itself. Before it changes any group, it writes down “the next few seconds of changes are mine”. A background worker listens to Chrome's tab and group events, and treats anything outside that window as the user's decision, including dragging tabs and renaming groups in Chrome's own tab bar.&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="c1"&gt;// The extension's own changes are wrapped, so they never count as the user's&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;asExtension&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;change&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&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;await&lt;/span&gt; &lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ownChangesUntil&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;change&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Chrome's events can arrive a little late, so keep the window open for a second&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ownChangesUntil&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;_000&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isOwnChange&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &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;await&lt;/span&gt; &lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ownChangesUntil&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;// Background worker: every other change is the user's decision&lt;/span&gt;
&lt;span class="nx"&gt;tabs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onUpdated&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tabId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;change&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tab&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;change&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;groupId&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;change&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;groupId&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;NO_GROUP&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;isOwnChange&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;group&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tabGroups&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;change&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;groupId&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;group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;rememberSite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;siteOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tab&lt;/span&gt;&lt;span class="p"&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;group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// e.g. "amsyn.org" -&amp;gt; "Me"&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="nx"&gt;tabGroups&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onUpdated&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;group&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;before&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;lastKnownTitle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// from a snapshot the worker keeps&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;before&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;isOwnChange&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;
  &lt;span class="nf"&gt;renameRulesAndLock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;before&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// rules and locks follow the new name&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="c1"&gt;// Reclassify: what the user decided goes first, automatic sorting gets the rest&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;reclassify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tabs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Tab&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SiteRule&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Mode&lt;/span&gt;&lt;span class="p"&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;movable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tabs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pinned&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;isLocked&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;group&lt;/span&gt;&lt;span class="p"&gt;))&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;claimed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rest&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;splitByRules&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;movable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// sites with a rule&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nf"&gt;groupsFromRules&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;claimed&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rest&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 few details:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Names, not IDs&lt;/strong&gt;: a rule is “site → group name”. Chrome gives groups new IDs when it restarts, but names stay.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Side panel actions are recorded directly&lt;/strong&gt;: dragging a tab, naming a new group or renaming one is already the user acting, so there's nothing to guess.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The latest decision wins&lt;/strong&gt;: drag a site's tab into another group and the rule updates; Settings lists every rule so you can forget one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your groups lock themselves&lt;/strong&gt;: a group you changed by hand gets a lock, so Reclassify leaves it alone.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What improved
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;Now&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Users without on-device AI&lt;/td&gt;
&lt;td&gt;Groups named after the date&lt;/td&gt;
&lt;td&gt;One switch for AI names and sorting&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API key&lt;/td&gt;
&lt;td&gt;Users would need their own&lt;/td&gt;
&lt;td&gt;None at all&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost risk&lt;/td&gt;
&lt;td&gt;A budget alert only notifies&lt;/td&gt;
&lt;td&gt;Stops when the free allocation runs out&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Privacy&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Off by default, titles and site names only, nothing kept&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If usage grows or someone abuses it, the worst that happens is a day without AI, and the extension goes back to naming by date. For a side project with a handful of users, that's the setup I'm most comfortable with.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let an AI set it up for you
&lt;/h2&gt;

&lt;p&gt;If you want to add free AI that can't run up a bill to your own extension or small tool, paste this into your AI coding assistant:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Add an optional shared AI feature to my browser extension, using this design:

1. Server: a Cloudflare Worker using the Workers AI binding (free daily allocation, so on the
   Workers Free plan it fails instead of billing). Pick a cheap model that handles my users'
   languages. Expose only the endpoints the feature needs, keep no logs or state, and validate
   every request body (cap the number of items and the length of each string).
2. Access: answer CORS only for my extension's origins (store ID plus my unpacked dev ID, read
   from a variable), so the extension needs no new permission. Add a per-IP rate limit with the
   Workers rate-limiting binding.
3. Data: send only what the prompt needs (for tabs: title and hostname, never full URLs).
4. Answers: ask for JSON, but parse leniently (find the first JSON object, ignore any thinking
   block) and validate every field. Retry once if the answer contains U+FFFD.
5. Extension: an opt-in setting, off by default, that says exactly what is sent and where. Try
   any on-device AI first, then the Worker, then a non-AI fallback, so no error ever reaches the
   user. Treat every HTTP error or timeout as "no AI".
6. Tests for request parsing, answer parsing and the fallback order, plus a README for the
   Worker with the deploy steps.

Don't ask me for any secret. Stop after writing the code so I can review and deploy it.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Cover: &lt;a href="https://www.rawpixel.com/image/7006634/photo-image-vintage-public-domain-tables" rel="noopener noreferrer"&gt;Mail sorting room, U.S. Post&lt;/a&gt; by libraryofcongress, &lt;a href="https://creativecommons.org/publicdomain/zero/1.0/" rel="noopener noreferrer"&gt;CC0 1.0&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>chromeextension</category>
      <category>cloudflare</category>
      <category>ai</category>
      <category>privacy</category>
    </item>
    <item>
      <title>Getting my iPhone music app to keep playing on the lock screen</title>
      <dc:creator>Andy Yeung</dc:creator>
      <pubDate>Sun, 18 Oct 2026 07:06:39 +0000</pubDate>
      <link>https://dev.to/amsyeung/getting-my-iphone-music-app-to-keep-playing-on-the-lock-screen-91f</link>
      <guid>https://dev.to/amsyeung/getting-my-iphone-music-app-to-keep-playing-on-the-lock-screen-91f</guid>
      <description>&lt;p&gt;I mostly use my private music app on an iPhone, added to the home screen, with the phone locked in my pocket. For a music app, two things are the bare minimum:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;When a track ends on the lock screen, the next one starts.&lt;/li&gt;
&lt;li&gt;The lock screen and Dynamic Island show previous and next buttons.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both sound basic. In a home-screen web app on iOS, both took me far longer than I expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I tried first
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A short silent clip between tracks.&lt;/strong&gt; Before each switch I played a tiny silent sound, hoping iOS would think audio never stopped. The clip was so short it fired its own "ended" event, which sometimes skipped several tracks at once. Timers in the background were delayed too, so it worked only some of the time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Endless silent audio in the background.&lt;/strong&gt; Keeping a silent loop playing all the time did make the lock screen move on to the next track. But I didn't know why it worked, and it broke again whenever I changed something else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Registering the buttons again after play started.&lt;/strong&gt; This brought back previous and next on the Dynamic Island, but again I didn't know why.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When I rewrote the player, both problems came back. This time I wanted to understand the real cause instead of trying things until something stuck.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the next track never started
&lt;/h2&gt;

&lt;p&gt;From WebKit bug reports and other developers' digging into WebKit's source, the picture is this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;On recent iOS versions, when an audio element &lt;strong&gt;plays to its natural end&lt;/strong&gt; and nothing else is playing, WebKit closes the app's audio session.&lt;/li&gt;
&lt;li&gt;In the foreground, the next &lt;code&gt;play()&lt;/code&gt; simply opens it again.&lt;/li&gt;
&lt;li&gt;In the background, iOS won't let the app reopen it. The next track shows up on the lock screen, but paused, until you open the app.&lt;/li&gt;
&lt;li&gt;Pausing or seeking doesn't close the session. Only a natural end does.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That explains why the endless silent audio helped: something was always playing, so the session never closed. But there's a simpler way: make sure the music itself never "ends".&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: loop, and catch the jump back to the start
&lt;/h2&gt;

&lt;p&gt;The audio element always has &lt;code&gt;loop&lt;/code&gt; turned on. When a track reaches its end, the browser doesn't fire "ended"; it jumps back to 0:00. The player notices that jump, pauses, switches the source and plays the next track.&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;audio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Audio&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;loop&lt;/span&gt; &lt;span class="o"&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;// never reaches a natural end&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isWrap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;playedPastStart&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;recentManualSeek&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
  &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;1.5&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;seeking&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="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="nf"&gt;isWrap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lastTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentTime&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nf"&gt;playNext&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 few things made it reliable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Seeks I make myself are marked&lt;/strong&gt;, so dragging the progress bar back to the start isn't mistaken for the end of a track.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pause first, then change the source.&lt;/strong&gt; Pausing doesn't close the session.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Switch within the same event&lt;/strong&gt;, not in a timer, because background timers get delayed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Download the next track early&lt;/strong&gt;, while the current one plays, so the switch is instant with no gap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tell iOS this is a music app&lt;/strong&gt; by setting the audio session type to playback, where the browser supports it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why the buttons skipped 10 seconds
&lt;/h2&gt;

&lt;p&gt;The second problem: I had registered previous and next, but the Dynamic Island showed buttons that skip 10 seconds back and forward instead.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4eyg97l8esdwj7ao1da7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4eyg97l8esdwj7ao1da7.png" alt="Before the fix, the Dynamic Island shows skip-10-seconds buttons; after it, previous and next track" width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;iOS ignores Media Session handlers that are registered &lt;strong&gt;before&lt;/strong&gt; the audio element becomes "Now Playing".&lt;/li&gt;
&lt;li&gt;Without valid previous and next handlers, it falls back to the 10-second skip buttons.&lt;/li&gt;
&lt;li&gt;My rewrite registered everything once when the app opened, before any sound. That's why the old "register again after play" trick had worked: the timing was right by accident.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fix is to register the handlers again on every &lt;code&gt;playing&lt;/code&gt; event, which costs almost nothing, and to clear the seek buttons explicitly:&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="nx"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;playing&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="o"&gt;=&amp;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;ms&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mediaSession&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setActionHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;previoustrack&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setActionHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nexttrack&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// otherwise iOS adds ±10s and hides previous/next&lt;/span&gt;
  &lt;span class="nx"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setActionHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;seekbackward&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setActionHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;seekforward&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&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;h2&gt;
  
  
  What changed
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;On a locked iPhone, one track ends and the next one starts, every time.&lt;/li&gt;
&lt;li&gt;The lock screen and Dynamic Island show previous and next.&lt;/li&gt;
&lt;li&gt;I know why it works, so changing the player no longer breaks it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A small testing tip: you don't have to wait for a whole song. Lock the phone and drag the progress bar to the last few seconds. Still, listen through at least once at the end, since that's closest to real use.&lt;/p&gt;

&lt;p&gt;One caveat: what I described about WebKit comes from bug reports and other developers' analysis, not from Apple's documentation, so a future iOS version could change it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://bugs.webkit.org/show_bug.cgi?id=261858" rel="noopener noreferrer"&gt;WebKit bug 261858: a standalone PWA stops after one track&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://bugs.webkit.org/show_bug.cgi?id=173332" rel="noopener noreferrer"&gt;WebKit bug 173332: background JavaScript and the ended event&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/Jeffreyyvdb/MusicHoarder/pull/526" rel="noopener noreferrer"&gt;MusicHoarder #526: a natural end closes the audio session; loop as the fix&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/Jeffreyyvdb/MusicHoarder/pull/507" rel="noopener noreferrer"&gt;MusicHoarder #507: playback audio session and switching tracks in the same event&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/weitzman/splitopen/pull/15" rel="noopener noreferrer"&gt;splitopen #15: handlers registered before Now Playing are ignored&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/esang-MAO/Miziki/pull/42" rel="noopener noreferrer"&gt;Miziki #42: clearing seekforward and seekbackward&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Related: &lt;a href="https://amsyn.org/articles/my-music-app-no-longer-needs-my-computer-on" rel="noopener noreferrer"&gt;My music app no longer needs my computer on&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Cover: &lt;a href="https://www.rawpixel.com/image/3295309/free-photo-image-blur-background-bokeh-camera" rel="noopener noreferrer"&gt;Headphones connected white smartphone black&lt;/a&gt; by Unknown, &lt;a href="https://creativecommons.org/publicdomain/zero/1.0/" rel="noopener noreferrer"&gt;CC0 1.0&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>pwa</category>
      <category>ios</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Shipping my Chrome extension on merge, with no stored secrets</title>
      <dc:creator>Andy Yeung</dc:creator>
      <pubDate>Sat, 17 Oct 2026 11:43:51 +0000</pubDate>
      <link>https://dev.to/amsyeung/shipping-my-chrome-extension-on-merge-with-no-stored-secrets-24o3</link>
      <guid>https://dev.to/amsyeung/shipping-my-chrome-extension-on-merge-with-no-stored-secrets-24o3</guid>
      <description>&lt;p&gt;I maintain a small Chrome extension that translates text with Chrome's built-in AI. It doesn't have many users, but I update it now and then, and every update meant the same routine: bump the version, write the changelog, build, zip, sign in to the store dashboard, upload and submit. None of it was hard. It was just easy to get wrong, like the time I forgot the version number and the store refused the upload.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I didn't want
&lt;/h2&gt;

&lt;p&gt;Most guides for automating this put a long-lived credential in your CI secrets, either a service account key or an OAuth refresh token. That works, but it means a key that never expires sits next to your code. If it leaks, anyone can publish as you.&lt;/p&gt;

&lt;p&gt;I wanted the automation without a key to lose.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works now
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0k9unvyw46hjnjhzt0sm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0k9unvyw46hjnjhzt0sm.png" alt="How a release reaches the Chrome Web Store" width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step by step
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Let the commits decide the version.&lt;/strong&gt; I write commit messages in the Conventional Commits style. A new feature bumps the minor version, a fix bumps the patch and docs or chores don't release at all. A release tool reads them, updates the manifest and changelog, and tags the release.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Always keep a zip.&lt;/strong&gt; Every release builds the extension and attaches the zip to a GitHub release, so there's always something to upload by hand if the store step ever fails.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Create an identity that can't do much.&lt;/strong&gt; In Google Cloud I created a service account with no roles at all. Its only power comes from being added as a member of my publisher account in the Chrome Web Store dashboard.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Trust GitHub instead of a key.&lt;/strong&gt; Workload Identity Federation lets a GitHub Actions run show Google a short-lived proof of which repository it comes from. Google checks that it's my repository and, only then, gives it a token for the service account that lasts 15 minutes. I restricted it to one repository, so forks and other projects can't use it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Connect the store.&lt;/strong&gt; In the dashboard's Settings, I added the service account and copied my publisher ID. (Older guides send you to an Account page that no longer exists.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Keep a human in the loop.&lt;/strong&gt; The upload runs in a protected GitHub environment with me as the required reviewer. Every store submission stops and waits until I press Approve. The store job starts like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
  &lt;span class="na"&gt;id-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt; &lt;span class="c1"&gt;# lets the run ask GitHub who it is&lt;/span&gt;
&lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;chrome-web-store&lt;/span&gt; &lt;span class="c1"&gt;# waits for my approval&lt;/span&gt;
&lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;google-github-actions/auth@v3&lt;/span&gt;
    &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;workload_identity_provider&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }}&lt;/span&gt;
      &lt;span class="na"&gt;service_account&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ vars.GCP_SERVICE_ACCOUNT }}&lt;/span&gt;
      &lt;span class="na"&gt;token_format&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;access_token&lt;/span&gt;
      &lt;span class="na"&gt;access_token_scopes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://www.googleapis.com/auth/chromewebstore&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;7. Test without changing anything.&lt;/strong&gt; The workflow has a read-only status mode that just asks the store what's published. When it answered with my current version, I knew the whole chain worked.&lt;/p&gt;

&lt;h2&gt;
  
  
  A few things that tripped me up
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Two Google APIs need to be on besides the Chrome Web Store API: the one that issues service account tokens and the token exchange service.&lt;/li&gt;
&lt;li&gt;The workflow needs permission to request GitHub's identity token. Without it, there's nothing to exchange.&lt;/li&gt;
&lt;li&gt;I fill in all the settings at once. My workflow treats the extension ID as the sign that the store is set up, so setting it alone would make the next release try to upload and fail.&lt;/li&gt;
&lt;li&gt;The store only accepts a higher version than the one it has, so re-running an old release fails on purpose.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What changed
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Releasing is one click.&lt;/strong&gt; I merge, wait for the notification and approve.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Versions and changelogs write themselves&lt;/strong&gt; from the commit messages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;There's no key to leak.&lt;/strong&gt; Each run gets a token that expires in 15 minutes, and only my repository can get one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I still decide.&lt;/strong&gt; Nothing reaches the store without my approval, and removing the service account from the dashboard cuts it off instantly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Google's review still takes its time, and the listing text and screenshots still live in the dashboard. But shipping a fix no longer needs a free evening.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use it with your own extension
&lt;/h2&gt;

&lt;p&gt;If you'd like the same setup, paste this into an AI coding assistant inside your extension's repository:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Set up automatic publishing for my Chrome extension in this repository, using this design:

1. Versioning: use Conventional Commits with release-it (or the closest tool for this stack).
   feat bumps minor, fix bumps patch, docs/chore don't release. On every push to main it
   bumps manifest.json, updates CHANGELOG.md, commits, tags and creates a GitHub Release
   with the zipped build attached.
2. Store upload: a reusable GitHub Actions workflow that calls the Chrome Web Store API v2
   (upload, wait for processing, then publish) with a small script. Support three modes:
   status (read-only), upload (draft only) and publish. Refuse to upload a version that
   isn't higher than the one in the store.
3. Credentials: no long-lived secrets. Use Google Workload Identity Federation with
   google-github-actions/auth, permissions id-token: write, token_format access_token and
   the chromewebstore scope. The service account gets no IAM roles; restrict the WIF
   provider with an attribute condition to this repository only.
4. Safety: run the store job in a GitHub Environment named chrome-web-store with me as a
   required reviewer. Read all IDs from repository variables (not secrets):
   GCP_WORKLOAD_IDENTITY_PROVIDER, GCP_SERVICE_ACCOUNT, CWS_PUBLISHER_ID, CWS_EXTENSION_ID.
   Skip the store job (don't fail) until all four are set.
5. Docs: write docs/PUBLISHING.md with the one-time setup: the gcloud commands for Cloud
   Shell (enable iamcredentials, sts and chromewebstore APIs, create the pool and provider,
   bind roles/iam.workloadIdentityUser), adding the service account under Settings in the
   Chrome Web Store Developer Dashboard, where to find the Publisher ID, and how to test
   with the status mode.

Don't ask me for any secret. Tell me which steps need me in a browser, and stop after
writing the code and docs so I can review them.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Cover: &lt;a href="https://www.rawpixel.com/image/5924919/photo-image-public-domain-free-box" rel="noopener noreferrer"&gt;Free carton boxes factory photo&lt;/a&gt; by Unknown, &lt;a href="https://creativecommons.org/publicdomain/zero/1.0/" rel="noopener noreferrer"&gt;CC0 1.0&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>githubactions</category>
      <category>chromeextension</category>
      <category>devops</category>
      <category>security</category>
    </item>
    <item>
      <title>My music app no longer needs my computer on</title>
      <dc:creator>Andy Yeung</dc:creator>
      <pubDate>Fri, 16 Oct 2026 07:35:20 +0000</pubDate>
      <link>https://dev.to/amsyeung/my-music-app-no-longer-needs-my-computer-on-4d7b</link>
      <guid>https://dev.to/amsyeung/my-music-app-no-longer-needs-my-computer-on-4d7b</guid>
      <description>&lt;p&gt;I have a small music app that I built for myself. It's a PWA that plays the tracks I've collected, with the audio stored in the cloud and a simple library on top. Playing music was never the hard part. Adding a track was. Each new track needs a slow job: fetch it, convert it, upload it, then add it to the library.&lt;/p&gt;

&lt;h2&gt;
  
  
  The old setup: the app called my computer
&lt;/h2&gt;

&lt;p&gt;That slow job ran on my home computer. A small API server sat on it, exposed to the internet through a tunnel, and the app called it directly whenever I added a track.&lt;/p&gt;

&lt;p&gt;It worked, but it had some real problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The computer had to be on.&lt;/strong&gt; If it was off, adding a track just failed, and I had to try again once I was home.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;There was a server open to the internet.&lt;/strong&gt; It was a server on my home network that I had to keep locked down.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The progress bar was fake.&lt;/strong&gt; The app only knew the request had been sent. The rest was an animation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Errors disappeared.&lt;/strong&gt; When an upload credential expired, uploads failed quietly for a while. The only sign was a line in a console nobody was watching.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The new setup: the database is the queue
&lt;/h2&gt;

&lt;p&gt;The fix came down to one change: &lt;strong&gt;the app no longer calls the worker. It writes a job into a table, and the worker comes to collect it.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The worker only makes outgoing connections, to the database and to storage. No tunnel, and nothing open to the internet.&lt;/li&gt;
&lt;li&gt;If the computer is off, jobs simply wait in the table. When it's back on, the worker picks them up one by one.&lt;/li&gt;
&lt;li&gt;As it works, the worker writes its progress back into the job, and the app shows it live. The progress bar is real now.&lt;/li&gt;
&lt;li&gt;The worker also checks in regularly, so the app can show whether the worker is online.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Postgres already has what you need to hand out jobs safely: a worker can claim the next waiting job in a way that no other worker can claim it at the same time. If a worker dies halfway through, the job is released after a while and tried again, a few times at most, before it's marked as failed with a Retry button in the app. No Redis or separate queue service, and the free tier of my database covers it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A queue is something people can write to
&lt;/h2&gt;

&lt;p&gt;Moving the work into a table brings up a new question: who is allowed to write to it? Whatever lands in the table, the worker will do. So:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Only approved accounts can add jobs, and only for themselves.&lt;/li&gt;
&lt;li&gt;Every job is validated twice: by the database when it's inserted, and again by the worker before it runs. Only the kind of link the app expects is accepted. Without that, a job could ask the worker to fetch an address on my home network.&lt;/li&gt;
&lt;li&gt;A job can only touch the person's own library, even though the worker itself has broader permissions.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What changed
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;My computer can be off.&lt;/strong&gt; It used to mean "the app is broken". Now it just means "new tracks show up a bit later".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nothing is open to the internet.&lt;/strong&gt; The worker only reaches out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Progress and errors are real.&lt;/strong&gt; If something fails, the reason shows up in the app, with a Retry button.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No new services.&lt;/strong&gt; The database I already had does the job.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Using a database as a queue isn't a new idea. For a small personal project that you only use now and then, though, it's just enough: it turned "my computer must always be on" from a hard requirement into something I don't have to think about.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Cover: &lt;a href="https://www.rawpixel.com/image/3304143/free-photo-image-music-png-archives-filing-room" rel="noopener noreferrer"&gt;Old vinyl records lined shelf&lt;/a&gt; by Unknown, &lt;a href="https://creativecommons.org/publicdomain/zero/1.0/" rel="noopener noreferrer"&gt;CC0 1.0&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>architecture</category>
      <category>sideprojects</category>
      <category>pwa</category>
    </item>
    <item>
      <title>I kept losing my Claude artifacts, so I made a mod</title>
      <dc:creator>Andy Yeung</dc:creator>
      <pubDate>Thu, 15 Oct 2026 03:40:35 +0000</pubDate>
      <link>https://dev.to/amsyeung/i-kept-losing-my-claude-artifacts-so-i-made-a-mod-3bph</link>
      <guid>https://dev.to/amsyeung/i-kept-losing-my-claude-artifacts-so-i-made-a-mod-3bph</guid>
      <description>&lt;p&gt;A lot of what I make with Claude now ends up as an artifact: a page or a doc that lives on &lt;a href="https://claude.ai" rel="noopener noreferrer"&gt;claude.ai&lt;/a&gt; instead of in the chat. That includes quotes for &lt;a href="https://aoricreative.com" rel="noopener noreferrer"&gt;Aori Creative&lt;/a&gt; clients, a pricing sheet, a security overview of my CMS, a script for a YouTube video I'm planning and notes on a side project's recommendation algorithm. They're useful, and I come back to them.&lt;/p&gt;

&lt;p&gt;Or I try to. Finding them again was the hard part.&lt;/p&gt;

&lt;h2&gt;
  
  
  A gallery that only grows
&lt;/h2&gt;

&lt;p&gt;Artifacts land in one long list, newest first. That's fine for the first few. For me, after a couple of weeks, a client quote sat next to a video script, which sat next to an internal security note, and half the titles only made sense on the day I made them.&lt;/p&gt;

&lt;p&gt;The chat a page came from was usually closed by then, so there was no easy way back to it. I'd scroll, open the wrong one, scroll again, and sometimes just ask Claude to make the thing again.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, some order
&lt;/h2&gt;

&lt;p&gt;The quick fix was to tidy what I already had:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Categories in the title.&lt;/strong&gt; Every artifact got a short prefix: company, clients, YouTube or side projects. The gallery is still one list, but now I can scan it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A catalogue in Notion.&lt;/strong&gt; One database with the title, category, a one-line summary and a link. That's where I go when I want to search or sort.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An index page.&lt;/strong&gt; One more artifact, pinned to the sidebar, that lists everything by category with a search box. Its entries live in the artifact's own small database, so adding an entry doesn't mean republishing the page.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That cleaned up the past. But it only stays clean if every new artifact gets filed too, and I knew I'd forget.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then, a mod that asks at the right moment
&lt;/h2&gt;

&lt;p&gt;Claude Code lets you write mods: small modules that hook into what Claude does and can draw a little UI next to the prompt. That was the right place for this. The moment to file an artifact is right after it's made, while I still remember what it's for.&lt;/p&gt;

&lt;p&gt;So now, when Claude publishes a new artifact, a row appears above the prompt once Claude is done:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;New artifact "Q3 pricing review" — add it to your catalogue? &lt;strong&gt;[Add]&lt;/strong&gt; &lt;strong&gt;[Skip]&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Press &lt;strong&gt;Add&lt;/strong&gt;, and Claude picks a category from my list, writes a one-line summary, adds it to the Notion database and the index page, and renames it with the category prefix. Press &lt;strong&gt;Skip&lt;/strong&gt;, and the row goes away.&lt;/p&gt;

&lt;p&gt;A few details mattered more than I expected:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It only asks about new artifacts.&lt;/strong&gt; Updating a page or fixing a typo doesn't count, and the index page never asks to file itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It waits.&lt;/strong&gt; The row only shows up when Claude isn't busy, so it never interrupts a task halfway through.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It speaks my language.&lt;/strong&gt; My copy asks in Traditional Chinese, and it also comes in English, Simplified Chinese, Japanese and Korean.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Sharing it
&lt;/h2&gt;

&lt;p&gt;Once it worked for me, it seemed silly to keep it to myself. So I made it generic: Notion is optional, the categories are yours, and there's an index page template you can publish as is. It's open source on &lt;a href="https://github.com/amsyeung/artifact-catalog" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;, and installing it is one line at the Claude Code prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/plugin install artifact-catalog --marketplace amsyeung/artifact-catalog
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Leave everything empty, and the first time you press &lt;strong&gt;Add&lt;/strong&gt;, Claude will ask where you'd like things filed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;I find things again.&lt;/strong&gt; Pinned index, by category, with search. No more scrolling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nothing piles up.&lt;/strong&gt; Filing takes one press, at the moment I still remember what the artifact is for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The titles make sense.&lt;/strong&gt; Even in the plain gallery list.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's a small tool for a small annoyance. But I use Claude every day, and this was the kind of friction that adds up. Small fixes like this are often the ones I end up using the most.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Cover: &lt;a href="https://commons.wikimedia.org/w/index.php?curid=68147692" rel="noopener noreferrer"&gt;Wooden Card Catalog Furniture&lt;/a&gt; by &lt;a href="https://commons.wikimedia.org/wiki/User:MarkBuckawicki" rel="noopener noreferrer"&gt;MarkBuckawicki&lt;/a&gt;, &lt;a href="https://creativecommons.org/publicdomain/zero/1.0/deed.en/" rel="noopener noreferrer"&gt;CC0 1.0&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claude</category>
      <category>ai</category>
      <category>productivity</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Why I moved my portfolio off Notion and Contentful</title>
      <dc:creator>Andy Yeung</dc:creator>
      <pubDate>Tue, 13 Oct 2026 16:58:26 +0000</pubDate>
      <link>https://dev.to/amsyeung/why-i-moved-my-portfolio-off-notion-and-contentful-3911</link>
      <guid>https://dev.to/amsyeung/why-i-moved-my-portfolio-off-notion-and-contentful-3911</guid>
      <description>&lt;p&gt;My portfolio, &lt;a href="https://amsyn.org" rel="noopener noreferrer"&gt;amsyn.org&lt;/a&gt;, is two things: a blog and a photo gallery. Until recently, I wrote the blog in Notion and kept the gallery in Contentful. Both made sense when I set them up, and both still worked. But over time the site got slower, messier and harder to change, so this month I took them both out.&lt;/p&gt;

&lt;h2&gt;
  
  
  My blog lived somewhere else
&lt;/h2&gt;

&lt;p&gt;I like writing in Notion, and the editor is great. To get posts onto the site, I forked an old &lt;code&gt;notion-parse&lt;/code&gt; project, kept it working with the current Notion API, and added a Next.js MDX mode. It's on npm as &lt;a href="https://www.npmjs.com/package/@amsyn/notion-parse" rel="noopener noreferrer"&gt;@amsyn/notion-parse&lt;/a&gt;, and I'll keep maintaining it.&lt;/p&gt;

&lt;p&gt;Every time I edited a post, a webhook kicked off a full rebuild that pulled everything from Notion and converted it to MDX. It worked, but it was fragile. My articles only existed inside that build step, a typo fix meant rebuilding the whole site, and when the sync failed quietly, the site could ship without its articles.&lt;/p&gt;

&lt;h2&gt;
  
  
  A CMS I was only using as a database
&lt;/h2&gt;

&lt;p&gt;The gallery's photos were always in my own storage. Contentful only held the details: title, camera, lens, tags and albums. I had also built my own upload screen, so I never used Contentful's editor, which is the main reason you'd pick a headless CMS in the first place.&lt;/p&gt;

&lt;p&gt;That left me fighting its limits without getting its benefits. Every photo page asked Contentful for data while the visitor waited, and the images were resized on the fly from full-size camera files. That's why the gallery always felt a little slow to load.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI tags I couldn't manage
&lt;/h2&gt;

&lt;p&gt;When I first imported my photos, I let a vision model write the tags and albums. It felt clever. A few hundred photos later, I had &lt;code&gt;Cityscape&lt;/code&gt;, &lt;code&gt;Cityscapes&lt;/code&gt; and &lt;code&gt;CityScapes&lt;/code&gt; as three different albums, and so many tags that the filters had stopped filtering anything. The model also had a habit of guessing locations: plenty of my European photos were apparently taken in Mong Kok.&lt;/p&gt;

&lt;p&gt;The model wasn't the problem. Letting it invent its own labels was.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fixes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A short, fixed list of tags.&lt;/strong&gt; The site now has one list of allowed tags: subjects like &lt;em&gt;street&lt;/em&gt;, &lt;em&gt;mountains&lt;/em&gt; or &lt;em&gt;food&lt;/em&gt;, and conditions like &lt;em&gt;golden hour&lt;/em&gt; or &lt;em&gt;snow&lt;/em&gt;. Every photo was re-tagged against that list. Any AI tagging from now on can only pick from it, never add to it. Albums are now trips I name myself, and a location only appears when I actually know it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The gallery became plain data.&lt;/strong&gt; All the photo details now sit in one file in the site's code and are read when the site is built. No CMS and no live queries. Every gallery page is generated ahead of time, and each photo is resized once into a few web-friendly sizes and served from a CDN. Location data is stripped from the images, which I should have been doing all along.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The blog moved to my own CMS.&lt;/strong&gt; Rather than swap Notion for yet another service, I moved the blog into a small CMS I built myself. It didn't have a field for long-form writing, so I added a Markdown one, and this post is the first one written in it. When I hit publish, only the blog refreshes, not the whole site.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Write once, publish in a few places.&lt;/strong&gt; The site has an RSS feed with full articles, and new posts are cross-posted automatically with a link back to the original here. If you're reading this somewhere else, that's how it got there.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The gallery is faster.&lt;/strong&gt; Pages are ready before anyone asks for them, and images are already the right size.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The filters are useful again.&lt;/strong&gt; A handful of tags you can actually browse by, instead of hundreds of near-duplicates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Writing is simpler.&lt;/strong&gt; I write in my own CMS, preview the draft on the real site, publish, and that's it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fewer moving parts.&lt;/strong&gt; Two services gone, and less for me to keep alive.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The lesson I keep relearning: pick tools for how you actually use them, not for what they can do. Next up is a full redesign of the site, and I'll write about that when it's done.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>cms</category>
      <category>webdev</category>
      <category>photography</category>
    </item>
  </channel>
</rss>
