<?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: Howard Shaw</title>
    <description>The latest articles on DEV Community by Howard Shaw (@howard_shaw_3c36a3a6cb900).</description>
    <link>https://dev.to/howard_shaw_3c36a3a6cb900</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%2F3521176%2Fd8816f40-1d31-4dfe-a4f3-48ac64ef4217.png</url>
      <title>DEV Community: Howard Shaw</title>
      <link>https://dev.to/howard_shaw_3c36a3a6cb900</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/howard_shaw_3c36a3a6cb900"/>
    <language>en</language>
    <item>
      <title>Why Single-Model "Cartoonify" Prompts Fail in Production (And How I Fixed It with a 2-Stage Pipeline)</title>
      <dc:creator>Howard Shaw</dc:creator>
      <pubDate>Tue, 20 Oct 2026 04:06:32 +0000</pubDate>
      <link>https://dev.to/howard_shaw_3c36a3a6cb900/why-single-model-cartoonify-prompts-fail-in-production-and-how-i-fixed-it-with-a-2-stage-2ano</link>
      <guid>https://dev.to/howard_shaw_3c36a3a6cb900/why-single-model-cartoonify-prompts-fail-in-production-and-how-i-fixed-it-with-a-2-stage-2ano</guid>
      <description>&lt;p&gt;If you have ever built an image-to-image (I2I) consumer feature, you already know the dirty secret of prompt engineering: &lt;strong&gt;what works in a Cherry-Picked Twitter thread rarely survives contact with real user uploads.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A few weeks ago, I started building an AI Cartoonify effect. The goal seemed straightforward—let users upload a selfie or pet photo and transform it into a stylized 3D/2D cartoon while keeping the person recognizable.&lt;/p&gt;

&lt;p&gt;At first, I tried throwing the reference image and a carefully crafted style prompt into a single generation pass. It was a disaster.&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%2Fm82g5barqiepg9z5bq4h.jpg" 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%2Fm82g5barqiepg9z5bq4h.jpg" alt=" " width="800" height="1073"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&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%2Fg1ky2ci22trhc31wzi5k.png" alt=" " width="800" height="1073"&gt;
&lt;/h2&gt;

&lt;h2&gt;
  
  
  The "Identity vs. Stylization" Trap
&lt;/h2&gt;

&lt;p&gt;Take a typical torture-test upload: &lt;strong&gt;a guy with a thick beard, wearing thin wire-rimmed glasses, standing under harsh side-lighting, holding up a peace sign.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When I ran this through a standard single-model I2I workflow, I kept hitting a brick wall:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Failure Mode A (High Style Strength):&lt;/strong&gt; The output looked like a gorgeous Pixar frame, except the wire-rimmed glasses melted into his eyebrows, his peace sign grew a sixth finger, and he looked like a generic stock character instead of the person in the photo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Failure Mode B (High Reference Conditioning):&lt;/strong&gt; His face and glasses stayed intact, but the model refused to actually &lt;em&gt;cartoonify&lt;/em&gt; the geometry. It just slapped a plasticky, airbrushed skin shader over a photorealistic face—straight into the depths of the uncanny valley.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The root cause? A single pass forces the model to figure out &lt;em&gt;what is in the image&lt;/em&gt; and &lt;em&gt;how to deform it artistically&lt;/em&gt; at the exact same time. &lt;/p&gt;

&lt;p&gt;To ship this to production, I had to &lt;strong&gt;decouple semantic understanding from spatial rendering&lt;/strong&gt;. Here is how I rebuilt the pipeline by pairing &lt;strong&gt;ChatGPT Image 2.5&lt;/strong&gt; with &lt;strong&gt;Nano Banana 2.1&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Solution: A Two-Stage "Lock &amp;amp; Render" Pipeline
&lt;/h2&gt;

&lt;p&gt;Instead of asking a single model to guess which pixels matter, I split the workload between two engines based on what they actually do best:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Stage 1: The Semantic Lock (ChatGPT Image 2.5)&lt;/strong&gt; — Used strictly as a visual parser and dynamic prompt compiler. Its job is to spot fragile details (like those wire-rimmed glasses or specific hand gestures) and convert them into hard text tokens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stage 2: The Spatial Renderer (Nano Banana 2.1)&lt;/strong&gt; — Used as the fast conditioning engine. It takes the original image as a structural blueprint alongside the dynamic tokens generated in Stage 1 to execute the actual style transfer.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  How the Data Flows in Code
&lt;/h3&gt;

&lt;p&gt;Rather than relying on static prompt templates, the backend builds a custom conditioning payload for every single upload. Here is a simplified look at the TypeScript structure driving the orchestration:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;interface CartoonifyJob {
  sourceImageUrl: string;
  targetAesthetic: '3d-animation' | 'cel-shaded' | 'flat-illustration';
}

interface ConditioningPayload {
  spatialReference: {
    imageUrl: string;
    edgeFidelity: 'strict' | 'balanced' | 'loose';
    compositionLock: number; // 0.0 to 1.0
  };
  semanticTokens: {
    identityLocks: string[];   // Extracted via ChatGPT Image 2.5
    lightingCues: string[];    // Preserves mood without keeping raw photo noise
    styleModifiers: string[];  // Drives the Nano Banana 2.1 shader
    guardrails: string[];      // Anti-uncanny-valley negative tokens
  };
}

async function buildCartoonPipeline(job: CartoonifyJob): Promise&amp;lt;ConditioningPayload&amp;gt; {
  // Step 1: Extract fragile micro-features using ChatGPT Image 2.5
  const analysis = await extractVisualAnchors(job.sourceImageUrl, {
    model: 'chatgpt-image-2.5',
    focusAreas: ['eyewear', 'facial_hair', 'hand_pose', 'garment_necklines']
  });

  // Step 2: Assemble the hybrid payload for Nano Banana 2.1
  return {
    spatialReference: {
      imageUrl: job.sourceImageUrl,
      edgeFidelity: 'balanced',
      compositionLock: 0.70 // Enough room to exaggerate eyes/proportions
    },
    semanticTokens: {
      identityLocks: analysis.detectedFeatures, 
      // e.g., ["thin gold wire-rimmed glasses", "trimmed full beard", "two-finger peace sign"]
      lightingCues: [analysis.primaryLightingDirection, "soft rim light"],
      styleModifiers: [
        "stylized 3D character render",
        "expressive stylized proportions",
        "clean subsurface scattering"
      ],
      guardrails: [
        "photorealistic pores",
        "hyper-detailed skin wrinkles",
        "melted eyeglass frames",
        "asymmetrical pupils"
      ]
    }
  };
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;




&lt;h2&gt;
  
  
  3 Lessons Learned While Tuning the Models
&lt;/h2&gt;

&lt;p&gt;Moving to a dual-model setup solved the macro architecture, but getting consistent outputs across thousands of edge cases took some serious trial and error.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Let the Reference Image Handle Macro, Let Keywords Handle Micro
&lt;/h3&gt;

&lt;p&gt;Nano Banana 2.1 is fantastic at keeping macro composition intact—head tilt, shoulder angle, and background silhouettes stay rock solid when using a reference image. However, when you relax the reference lock enough to allow cartoon exaggeration (like slightly larger eyes or smoother jawlines), micro-details vanish. By having ChatGPT Image 2.5 explicitly inject tokens like &lt;code&gt;"thin gold wire-rimmed glasses"&lt;/code&gt; into the prompt stream, Nano Banana 2.1 is forced to redraw those micro-details cleanly in the new cartoon style rather than blurring them away.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Strip Out Real-World Lighting Noise
&lt;/h3&gt;

&lt;p&gt;User photos usually have terrible lighting—fluorescent office bulbs, harsh bathroom shadows, or blown-out window backlighting. If you rely purely on image conditioning, the cartoon output inherits those muddy color casts. By extracting only the &lt;em&gt;directional intent&lt;/em&gt; of the lighting (e.g., &lt;code&gt;"warm side-lighting"&lt;/code&gt;) via keywords and letting Nano Banana 2.1 re-render the actual color palette from scratch, the output looks like it came out of an animation studio rather than a dark bedroom.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Kill Skin Texture Early
&lt;/h3&gt;

&lt;p&gt;The #1 reason AI cartoons look creepy is leftover high-frequency skin texture. When a model puts stylized cartoon eyes on a face that still has realistic pores and nasolabial folds, the human brain immediately rejects it. Hard-coding anti-realism guardrails (&lt;code&gt;photorealistic pores&lt;/code&gt;, &lt;code&gt;raw skin texture&lt;/code&gt;) into the Nano Banana 2.1 conditioning step was the single biggest quality unlock.&lt;/p&gt;




&lt;h2&gt;
  
  
  Test It Out in the Wild
&lt;/h2&gt;

&lt;p&gt;I have shipped this exact two-stage pipeline (&lt;strong&gt;ChatGPT Image 2.5 + Nano Banana 2.1&lt;/strong&gt;) into production. &lt;/p&gt;

&lt;p&gt;If you want to see how it handles tricky portraits, pets, or weird lighting—or if you just want to try breaking the semantic lock with your own photos—you can play with the live implementation here:&lt;/p&gt;

&lt;p&gt;👉 &lt;strong&gt;Try the Live Tool:&lt;/strong&gt; &lt;a href="https://www.vioevo.com/effects/ai-cartoonify" rel="noopener noreferrer"&gt;AI Cartoonify on VioEvo&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;As image models get faster and more specialized, chaining them together is becoming much more practical than waiting for one "god model" to do everything perfectly in a single shot. Using a heavy semantic model to write dynamic conditioning instructions for a fast spatial renderer has completely changed how I approach I2I features.&lt;/p&gt;

&lt;p&gt;How are you all handling identity retention vs. style strength in your own generative pipelines? Drop a comment below if you've found other workarounds!&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>showdev</category>
    </item>
    <item>
      <title>I Integrated 20+ AI Models. The Hard Part Wasn't the AI.</title>
      <dc:creator>Howard Shaw</dc:creator>
      <pubDate>Sun, 04 Oct 2026 07:33:49 +0000</pubDate>
      <link>https://dev.to/howard_shaw_3c36a3a6cb900/i-integrated-20-ai-models-the-hard-part-wasnt-the-ai-1e13</link>
      <guid>https://dev.to/howard_shaw_3c36a3a6cb900/i-integrated-20-ai-models-the-hard-part-wasnt-the-ai-1e13</guid>
      <description>&lt;p&gt;When I integrated my first AI model, the whole thing felt almost ridiculously simple.&lt;/p&gt;

&lt;p&gt;There was an API, a prompt, a request, and eventually a result. I wrapped the API call in my application, handled the response, stored the output, and moved on to the next feature. It was one of those tasks where you could spend more time reading the documentation than actually writing the code.&lt;/p&gt;

&lt;p&gt;That impression did not last very long.&lt;/p&gt;

&lt;p&gt;Over the following months, I ended up integrating more than 20 image and video models into a single product. At first, I thought this would mostly be a matter of writing a few provider adapters and keeping a common interface. In reality, the adapters were probably the easy part. The difficult work was figuring out what "the same feature" even meant when every model had slightly different assumptions, capabilities, input requirements, behavior, pricing, and failure modes.&lt;/p&gt;

&lt;p&gt;This was one of those projects where the deeper I got into it, the less I believed the simple version of the problem.&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%2Foq0gdyjru9za0glp6oiy.jpeg" 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%2Foq0gdyjru9za0glp6oiy.jpeg" alt=" " width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  My original plan was much cleaner
&lt;/h2&gt;

&lt;p&gt;I started with a fairly conventional idea.&lt;/p&gt;

&lt;p&gt;The application would have a common generation interface, and each provider would sit behind an adapter. Something roughly like this:&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;type&lt;/span&gt; &lt;span class="nx"&gt;GenerateInput&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;imageUrl&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;videoUrl&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;
  &lt;span class="nx"&gt;aspectRatio&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;GenerateResult&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;ModelAdapter&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;GenerateInput&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;GenerateResult&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nf"&gt;getStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;JobStatus&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The idea was to keep the rest of the application ignorant of provider-specific details. If I wanted to add another model later, I would just implement another adapter.&lt;/p&gt;

&lt;p&gt;This worked pretty well with the first couple of models. By the third one, I started adding exceptions. Then more exceptions appeared.&lt;/p&gt;

&lt;p&gt;One provider wanted a publicly accessible image URL. Another worked better with uploaded assets. Another had a separate asset creation step before a generation request could even be made. Some models supported reference images, but the meaning of a reference image was not necessarily the same from one model to another. One provider offered a webhook while another expected the application to poll for status. Some exposed durations as arbitrary values, while others only allowed a short list of predefined options.&lt;/p&gt;

&lt;p&gt;None of these differences were particularly difficult on their own. The problem was that they accumulated.&lt;/p&gt;

&lt;p&gt;Eventually, the common interface started filling up with optional fields and special cases. At that point, I had to admit that I had built an abstraction that was trying too hard to make different things look identical.&lt;/p&gt;

&lt;h2&gt;
  
  
  A universal API sounds better than it actually is
&lt;/h2&gt;

&lt;p&gt;This is probably the part of the project where I learned the most.&lt;/p&gt;

&lt;p&gt;When you're designing an abstraction, there is a natural temptation to define the ideal interface first and then force every implementation to fit it. That can work very well when the implementations are variations of the same underlying system. AI models are often not.&lt;/p&gt;

&lt;p&gt;Take something that sounds simple, like "image to video."&lt;/p&gt;

&lt;p&gt;You might assume that the application sends an image and a prompt, and the model creates a video. Technically, that's often true. In practice, the differences are substantial. Some models treat the image primarily as the starting frame. Others use it more as visual guidance. Some preserve the subject and composition extremely well but are less reliable when the requested motion becomes complicated. Others are much better at camera movement. Some handle faces and people reasonably well, while another may behave very differently with the exact same source image.&lt;/p&gt;

&lt;p&gt;From the outside, all of these can be presented as "image to video."&lt;/p&gt;

&lt;p&gt;From an engineering and product perspective, they are not the same capability.&lt;/p&gt;

&lt;p&gt;That distinction matters once users start selecting models based on what they are actually trying to create. If the application hides all of those differences behind one generic form, you can end up with a UI that looks beautifully consistent but gives users very little information about what will actually happen.&lt;/p&gt;

&lt;p&gt;So I gradually stopped trying to make every model behave identically.&lt;/p&gt;

&lt;p&gt;The goal became much more modest: provide a common foundation for the things that really are common, while allowing each provider to expose the capabilities that make it different.&lt;/p&gt;

&lt;p&gt;That turned out to be a much healthier abstraction.&lt;/p&gt;

&lt;h2&gt;
  
  
  The model itself is only one part of the job
&lt;/h2&gt;

&lt;p&gt;This was the other thing I seriously underestimated.&lt;/p&gt;

&lt;p&gt;When I first started working with image and video generation, I naturally focused on the model call. That's the interesting part, after all. You send the input, something impressive comes back, and that's the part people usually see in demos.&lt;/p&gt;

&lt;p&gt;In a real product, the model call is only a small section of the flow.&lt;/p&gt;

&lt;p&gt;A user uploads an image. The application has to validate the file, store it somewhere, and make sure the provider can access it. The request has to be converted into the format that particular provider expects. A generation job needs to be created and associated with the user. The application needs to know whether the job is queued, processing, completed, or failed. If the provider reports an error, the application needs to decide whether that error is recoverable. When the generation finishes, the output needs to be stored and made available to the user.&lt;/p&gt;

&lt;p&gt;And then there are all the things that don't happen on the happy path.&lt;/p&gt;

&lt;p&gt;The user closes the browser while the generation is still running. A provider takes much longer than usual to respond. The request succeeds, but the output never becomes available. A retry creates a second generation when you didn't intend to create one. Two jobs finish at different times and arrive out of order.&lt;/p&gt;

&lt;p&gt;None of this is particularly glamorous. It also has almost nothing to do with the intelligence of the model.&lt;/p&gt;

&lt;p&gt;But these are the things that determine whether a user feels that the product works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Eventually I started treating generation as a state machine
&lt;/h2&gt;

&lt;p&gt;I spent quite a bit of time trying to make the generation lifecycle elegant. In the end, the simplest mental model turned out to be the most useful.&lt;/p&gt;

&lt;p&gt;A generation is a job with a state.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;queued
  ↓
processing
  ↓
completed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;queued
  ↓
processing
  ↓
failed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The internal details vary by provider, but the application usually doesn't need to know every provider-specific status. The adapter can deal with polling, webhooks, provider response formats, retries, and all the other unpleasant details. The rest of the application just needs a reliable representation of the job.&lt;/p&gt;

&lt;p&gt;For example:&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;type&lt;/span&gt; &lt;span class="nx"&gt;JobStatus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;queued&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;processing&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;completed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That sounds almost too basic, but it made a big difference.&lt;/p&gt;

&lt;p&gt;Once I treated the generation itself as a first-class job rather than just an API request, several other design decisions became easier. It became much clearer where retries belonged, what the frontend should display, and how to handle a job that finishes after the user has left the page.&lt;/p&gt;

&lt;p&gt;The nice thing about this approach is that it isn't really an "AI architecture." It's just good asynchronous application design. The AI part happens to be what creates the jobs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Costs turned out to be part of the architecture
&lt;/h2&gt;

&lt;p&gt;There is another complication you don't notice when testing models one at a time.&lt;/p&gt;

&lt;p&gt;Different models don't just produce different results. They can have very different costs and generation times.&lt;/p&gt;

&lt;p&gt;When you offer several models in one product, the user isn't really choosing only between Model A and Model B. They are also choosing between a faster and slower generation, a cheaper and more expensive generation, and sometimes a more predictable and less predictable one.&lt;/p&gt;

&lt;p&gt;That has consequences for product design.&lt;/p&gt;

&lt;p&gt;For example, the model with the best benchmark or the most impressive demo is not necessarily the model I want as the default for every task. If it costs several times as much and takes substantially longer, asking every user to start there may not actually produce a better experience.&lt;/p&gt;

&lt;p&gt;This is one reason why I now think about model selection as a product problem, not just an engineering problem.&lt;/p&gt;

&lt;p&gt;The cheapest model is not always right. The most expensive model is not always right either. What matters is whether the model is a good fit for the task the user is trying to accomplish.&lt;/p&gt;

&lt;p&gt;That sounds obvious when written down. It was much less obvious when I was in the middle of integrating them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Most of the bugs live in the edge cases
&lt;/h2&gt;

&lt;p&gt;The happy path is still easy.&lt;/p&gt;

&lt;p&gt;You upload something, send a request, wait, and get a result.&lt;/p&gt;

&lt;p&gt;The real application starts when that sequence breaks.&lt;/p&gt;

&lt;p&gt;I have had plenty of cases where a model worked perfectly with the test inputs I used during integration, and then behaved differently once I started trying a wider variety of real-world inputs. Resolution, aspect ratio, image composition, faces, source quality, motion requirements, and other details can all change the outcome.&lt;/p&gt;

&lt;p&gt;The tricky part is that a failure isn't always an explicit failure.&lt;/p&gt;

&lt;p&gt;Sometimes the API returns a normal success response and the result is simply not very good.&lt;/p&gt;

&lt;p&gt;That makes AI integration different from many traditional API integrations. With a payment API, you usually know whether the operation succeeded. With a generation model, "success" can mean that a video was technically produced even though it doesn't satisfy what the user actually asked for.&lt;/p&gt;

&lt;p&gt;So there are really two types of reliability to think about.&lt;/p&gt;

&lt;p&gt;There is infrastructure reliability, where the job completes and the system correctly handles the result. Then there is model reliability, where the output is actually useful for the requested task.&lt;/p&gt;

&lt;p&gt;You need both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adding the 20th model was easier than adding the second
&lt;/h2&gt;

&lt;p&gt;This was one of the more surprising things I learned.&lt;/p&gt;

&lt;p&gt;The first model is difficult because everything is new. The second is often harder than expected because it exposes the differences you didn't account for. By the time you've integrated several providers and rewritten the abstraction a couple of times, you start to understand where the real boundaries should be.&lt;/p&gt;

&lt;p&gt;Once that foundation is reasonably stable, adding another model is often much more mechanical.&lt;/p&gt;

&lt;p&gt;That became a useful test for me. If integrating one more model requires changes all over the application, something is probably wrong with the architecture. The same is true if the adapter has become so complicated that nobody really understands what it is doing.&lt;/p&gt;

&lt;p&gt;A good abstraction does not eliminate differences between systems. It gives those differences a sensible place to live.&lt;/p&gt;

&lt;p&gt;That may be one of the most useful lessons I took from this entire project.&lt;/p&gt;

&lt;h2&gt;
  
  
  I stopped chasing feature parity
&lt;/h2&gt;

&lt;p&gt;At some point, I realized I was asking the wrong question.&lt;/p&gt;

&lt;p&gt;I had been thinking, "How can I make every model support the same set of options?"&lt;/p&gt;

&lt;p&gt;The better question was, "What are the capabilities that actually make sense to expose across these models?"&lt;/p&gt;

&lt;p&gt;Those are not the same thing.&lt;/p&gt;

&lt;p&gt;Some models have capabilities that others simply don't have. Trying to hide that fact usually makes the product more confusing, not less.&lt;/p&gt;

&lt;p&gt;So instead of forcing everything into one giant shared set of controls, I started thinking in terms of common capabilities and model-specific capabilities. The common parts should feel consistent. The differences should be visible when they matter.&lt;/p&gt;

&lt;p&gt;That also changed how I think about model selection.&lt;/p&gt;

&lt;p&gt;I don't care as much about saying that a product supports 20 or 30 models. The number sounds good in a headline, but it doesn't tell the user very much.&lt;/p&gt;

&lt;p&gt;What is much more useful is knowing why you might choose one model over another.&lt;/p&gt;

&lt;p&gt;One may be better for preserving a character. Another may be better for motion. Another may be faster. Another may be more affordable. Another may support a type of input that the others don't.&lt;/p&gt;

&lt;p&gt;The differences are the useful information.&lt;/p&gt;

&lt;h2&gt;
  
  
  Documentation is only the beginning
&lt;/h2&gt;

&lt;p&gt;I've also become much less willing to consider a model "integrated" just because the API call works according to the documentation.&lt;/p&gt;

&lt;p&gt;Documentation tells you how a provider expects the API to behave. It does not always tell you what it feels like to use that API as part of a product.&lt;/p&gt;

&lt;p&gt;You discover the rest through testing.&lt;/p&gt;

&lt;p&gt;A particular aspect ratio may produce unexpectedly poor results. A reference image may work very well in one scenario and poorly in another. A capability may exist on paper but have practical limitations that aren't obvious until you put it into a real workflow.&lt;/p&gt;

&lt;p&gt;So my integration process has changed.&lt;/p&gt;

&lt;p&gt;I still read the documentation first. But I don't consider the job finished when the request returns a successful response. I want to see the model behave with the kinds of inputs and workflows that the actual product supports.&lt;/p&gt;

&lt;p&gt;That takes more time up front, but it saves a lot of confusion later.&lt;/p&gt;

&lt;h2&gt;
  
  
  This changed how I look at new models
&lt;/h2&gt;

&lt;p&gt;When another impressive model gets announced now, my first reaction is no longer just, "How good is it?"&lt;/p&gt;

&lt;p&gt;I'm more interested in what it does differently.&lt;/p&gt;

&lt;p&gt;Does it solve a problem that the existing models don't solve well? Is it noticeably better for a particular type of input? Is it fast enough to be practical? Is the API reliable enough to build around? Is the output quality consistent enough that users won't feel like they are rolling the dice?&lt;/p&gt;

&lt;p&gt;Those questions are less exciting than benchmark screenshots, but they are much more useful when you're actually running a product.&lt;/p&gt;

&lt;p&gt;At some point, adding another model just because it exists stops being meaningful.&lt;/p&gt;

&lt;p&gt;Twenty models that behave roughly the same way are not necessarily better than five models with clearly different strengths.&lt;/p&gt;

&lt;p&gt;That was not how I thought about multi-model products when I started.&lt;/p&gt;

&lt;h2&gt;
  
  
  I'm still figuring it out
&lt;/h2&gt;

&lt;p&gt;I don't want to turn this into one of those "here are the lessons I learned and now everything makes sense" posts.&lt;/p&gt;

&lt;p&gt;It doesn't.&lt;/p&gt;

&lt;p&gt;I'm still integrating models. I'm still finding strange edge cases. I'm still changing parts of the architecture that seemed perfectly reasonable a few months earlier. That's probably not going to stop anytime soon, because the models themselves keep changing.&lt;/p&gt;

&lt;p&gt;But after working through enough integrations, I do have a much clearer idea of where the real engineering work is.&lt;/p&gt;

&lt;p&gt;It isn't the API call.&lt;/p&gt;

&lt;p&gt;The interesting part is everything around it: normalizing inputs without pretending providers are identical, managing asynchronous jobs, handling failures, dealing with cost and latency, understanding what each model is actually good at, and turning all of that into something a normal user can interact with without needing to know any of it.&lt;/p&gt;

&lt;p&gt;That is the part I didn't really understand when I integrated my first model.&lt;/p&gt;

&lt;p&gt;And that's probably the biggest difference between adding an AI API to a web application and actually building a product around AI.&lt;/p&gt;

&lt;h3&gt;
  
  
  A small side project that turned into a much bigger lesson
&lt;/h3&gt;

&lt;p&gt;The project that pushed me into all of this is &lt;a href="https://www.vioevo.com/" rel="noopener noreferrer"&gt;VioEvo AI&lt;/a&gt;, an independent AI image and video creation platform where I've been experimenting with multiple generation models in the same product.&lt;/p&gt;

&lt;p&gt;I originally thought the interesting part would be the models themselves.&lt;/p&gt;

&lt;p&gt;After 20+ integrations, I'm much more interested in the engineering problems that appear when you try to make all of those very different systems feel like one product.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>architecture</category>
      <category>programming</category>
    </item>
    <item>
      <title>I Added Open Graph Preview Images to DocBeacon Share Links</title>
      <dc:creator>Howard Shaw</dc:creator>
      <pubDate>Tue, 07 Apr 2026 09:29:55 +0000</pubDate>
      <link>https://dev.to/howard_shaw_3c36a3a6cb900/i-added-open-graph-preview-images-to-docbeacon-share-links-37ne</link>
      <guid>https://dev.to/howard_shaw_3c36a3a6cb900/i-added-open-graph-preview-images-to-docbeacon-share-links-37ne</guid>
      <description>&lt;p&gt;Recently, I added Open Graph preview images to DocBeacon share links.&lt;/p&gt;

&lt;p&gt;The problem was simple: when a DocBeacon link was shared in Slack or an email client, it often looked like a plain URL. For proposals, pitch decks, contracts, and other important documents, that is not a great first impression.&lt;/p&gt;

&lt;p&gt;So I used OG images to make shared links more informative before the document is even opened.&lt;/p&gt;

&lt;p&gt;Now, when a DocBeacon share link is posted in supported environments, the preview can include basic document context instead of showing only the raw link. That makes the link easier to recognize and helps it feel more legitimate at a glance.&lt;/p&gt;

&lt;p&gt;While building this, one thing became clearer to me: a lot of document-sharing friction happens before the click.&lt;/p&gt;

&lt;p&gt;A link appears in an inbox or a Slack thread. It looks unfamiliar. It provides little context. That alone can create hesitation.&lt;/p&gt;

&lt;p&gt;This update is meant to reduce that.&lt;/p&gt;

&lt;p&gt;It also works nicely with Shared Document Branding. When branding is enabled, the preview image can reflect the user's own branding and logo, which makes the experience feel more polished and consistent from the first touchpoint.&lt;/p&gt;

&lt;p&gt;From an implementation standpoint, this is a fairly small feature.&lt;/p&gt;

&lt;p&gt;From a product and trust standpoint, it feels much bigger.&lt;/p&gt;

&lt;p&gt;Sometimes people do not judge the document first. They judge the preview first.&lt;/p&gt;

&lt;p&gt;I would be curious to hear how other builders think about this. Have small details like OG metadata, preview images, or branding had a noticeable impact on trust or click-through in your product?&lt;/p&gt;

</description>
      <category>buildinpublic</category>
      <category>product</category>
    </item>
    <item>
      <title>Reducing a Homepage Demo Video from 11.6 MB to Under 1 MB</title>
      <dc:creator>Howard Shaw</dc:creator>
      <pubDate>Mon, 30 Mar 2026 07:27:07 +0000</pubDate>
      <link>https://dev.to/howard_shaw_3c36a3a6cb900/reducing-a-homepage-demo-video-from-116-mb-to-under-1-mb-30ip</link>
      <guid>https://dev.to/howard_shaw_3c36a3a6cb900/reducing-a-homepage-demo-video-from-116-mb-to-under-1-mb-30ip</guid>
      <description>&lt;p&gt;Yesterda I worked on a small homepage improvement that turned out to be more technical than expected.&lt;/p&gt;

&lt;p&gt;I wanted to add an autoplay product video to the "how it works" section of my SaaS homepage. The goal was simple: make the product flow easier to understand without adding too much friction or weight to the page.&lt;/p&gt;

&lt;p&gt;The problem was file size.&lt;/p&gt;

&lt;p&gt;My screen recording tool had already compressed the video, but the result was still too large for the web. One clip was only about 13 seconds long at 1280x720, H.264, and still came out to 11.6 MB. That felt far too heavy for a short homepage demo.&lt;/p&gt;

&lt;p&gt;At first I thought the issue was just resolution, but it turned out the bigger problem was delivery format. Even after the recording software compressed the file, it was still not really optimized for web use.&lt;/p&gt;

&lt;p&gt;So instead of relying on the recording tool's export alone, I treated that file as an intermediate version and ran it through ffmpeg.&lt;/p&gt;

&lt;p&gt;This is the command that gave me a very good result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ffmpeg &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"upload and configure.mp4"&lt;/span&gt; &lt;span class="nt"&gt;-vf&lt;/span&gt; &lt;span class="s2"&gt;"scale=-2:720"&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; 24 &lt;span class="nt"&gt;-c&lt;/span&gt;:v libx264 &lt;span class="nt"&gt;-crf&lt;/span&gt; 27 &lt;span class="nt"&gt;-preset&lt;/span&gt; medium &lt;span class="nt"&gt;-pix_fmt&lt;/span&gt; yuv420p &lt;span class="nt"&gt;-an&lt;/span&gt; &lt;span class="nt"&gt;-movflags&lt;/span&gt; +faststart &lt;span class="s2"&gt;"output-720p1.mp4"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few settings mattered most here:&lt;/p&gt;

&lt;p&gt;-r 24 reduced frame rate to something more reasonable for a UI demo&lt;br&gt;
-crf 27 gave me a good balance between size and clarity&lt;br&gt;
-an removed audio I did not need&lt;br&gt;
-movflags +faststart made the file better suited for web playback&lt;/p&gt;

&lt;p&gt;The result was much better than I expected: the final file dropped to around 800 KB, while still looking good enough in the actual page section.&lt;/p&gt;

&lt;p&gt;One useful takeaway from this: even if your screen recording software already outputs a compressed video, that does not mean it is truly web-optimized. For homepage demos, especially UI-heavy ones, a separate ffmpeg pass can make a huge difference.&lt;/p&gt;

&lt;p&gt;My current workflow is simple:&lt;/p&gt;

&lt;p&gt;export from the recording tool&lt;br&gt;
compress with ffmpeg for web delivery&lt;br&gt;
adjust resolution depending on the role of the video&lt;/p&gt;

&lt;p&gt;For one larger file, I also went down to 540p, and that was fine too.&lt;/p&gt;

&lt;p&gt;Small change, but definitely worth it.&lt;/p&gt;

&lt;p&gt;Also launching on Product Hunt today:&lt;br&gt;
&lt;a href="https://www.producthunt.com/products/docbeacon" rel="noopener noreferrer"&gt;https://www.producthunt.com/products/docbeacon&lt;/a&gt;&lt;br&gt;
If you check it out, would love to hear what you think.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>How I Built White-Label Document Sharing for Client-Facing Docs</title>
      <dc:creator>Howard Shaw</dc:creator>
      <pubDate>Thu, 26 Mar 2026 12:39:11 +0000</pubDate>
      <link>https://dev.to/howard_shaw_3c36a3a6cb900/how-i-built-white-label-document-sharing-for-client-facing-docs-1oi2</link>
      <guid>https://dev.to/howard_shaw_3c36a3a6cb900/how-i-built-white-label-document-sharing-for-client-facing-docs-1oi2</guid>
      <description>&lt;p&gt;When people send proposals, pitch decks, NDAs, and other client-facing documents, they usually want the recipient focused on their brand, not the software powering the experience.&lt;/p&gt;

&lt;p&gt;That was the motivation behind a feature I recently shipped in DocBeacon: white-label document sharing for Pro users.&lt;/p&gt;

&lt;p&gt;The idea sounds simple:&lt;/p&gt;

&lt;p&gt;Let users attach a custom brand name, logo, and website URL to shared documents.&lt;/p&gt;

&lt;p&gt;But once I got into implementation, it turned into an interesting mix of product design, access control, storage, validation, and fallback behavior.&lt;/p&gt;

&lt;p&gt;This post is a short breakdown of how I approached it.&lt;/p&gt;

&lt;p&gt;The problem&lt;/p&gt;

&lt;p&gt;Before this feature, a shared document was still visually branded as DocBeacon throughout the viewing flow.&lt;/p&gt;

&lt;p&gt;That was fine for some use cases, but not ideal for teams sending:&lt;/p&gt;

&lt;p&gt;consulting proposals&lt;br&gt;
investor decks&lt;br&gt;
sales documents&lt;br&gt;
sensitive client materials&lt;/p&gt;

&lt;p&gt;In those workflows, brand consistency matters. If someone is sharing a high-value proposal, they usually want the experience to reinforce their credibility, not mine.&lt;/p&gt;

&lt;p&gt;So the requirement became:&lt;/p&gt;

&lt;p&gt;Let eligible users show their own branding across the document viewing experience, while keeping the system safe and predictable.&lt;/p&gt;

&lt;p&gt;What the feature needed to support&lt;/p&gt;

&lt;p&gt;At a high level, I wanted users to be able to configure:&lt;/p&gt;

&lt;p&gt;a display name&lt;br&gt;
a logo&lt;br&gt;
a website URL&lt;br&gt;
an on/off toggle for branding&lt;/p&gt;

&lt;p&gt;And once enabled, that branding should appear in places like:&lt;/p&gt;

&lt;p&gt;the document viewer header&lt;br&gt;
attribution text&lt;br&gt;
shared document metadata&lt;br&gt;
other client-facing viewer touchpoints&lt;/p&gt;

&lt;p&gt;I also wanted a clean fallback path. If branding data was incomplete, invalid, or the user’s plan didn’t allow the feature, the system should quietly fall back to the default DocBeacon brand.&lt;/p&gt;

&lt;p&gt;The core design decision: resolve branding once&lt;/p&gt;

&lt;p&gt;One of the first decisions I made was to avoid scattering branding logic across multiple rendering layers.&lt;/p&gt;

&lt;p&gt;Instead of asking every page or component to figure out branding on its own, I used a single resolution step:&lt;/p&gt;

&lt;p&gt;Check whether the user is allowed to use the feature&lt;br&gt;
Check whether branding is enabled&lt;br&gt;
Check whether enough valid branding data exists&lt;br&gt;
Return either:&lt;br&gt;
the user’s brand config, or&lt;br&gt;
the default platform brand&lt;/p&gt;

&lt;p&gt;In pseudocode:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;resolveBranding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nx"&gt;canBrand&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;planAllowsBranding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;hasIdentity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;displayName&lt;/span&gt; &lt;span class="nx"&gt;OR&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;logoPath&lt;/span&gt;
    &lt;span class="nx"&gt;enabled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;brandingEnabled&lt;/span&gt; &lt;span class="nx"&gt;AND&lt;/span&gt; &lt;span class="nx"&gt;canBrand&lt;/span&gt; &lt;span class="nx"&gt;AND&lt;/span&gt; &lt;span class="nx"&gt;hasIdentity&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nx"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;displayName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;logoUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;buildAssetUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;logoPath&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="na"&gt;websiteUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;normalizeUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;websiteUrl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;defaultBrand&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This helped keep the rest of the system simple. Viewer pages didn’t need to know all the rules. They just consumed a resolved branding object.&lt;/p&gt;

&lt;p&gt;Access control&lt;/p&gt;

&lt;p&gt;This feature is only available on Pro+ plans, so access control had to be built into the feature itself, not just the UI.&lt;/p&gt;

&lt;p&gt;That means even if someone somehow submits branding settings through an API call or stale client state, the backend still needs to enforce eligibility.&lt;/p&gt;

&lt;p&gt;The check is conceptually simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;canUseBranding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;plan&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pro&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;business&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;enterprise&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I treated this as a server-side rule first, and a UI rule second.&lt;/p&gt;

&lt;p&gt;That’s important because frontend gating alone is never enough for plan-restricted features.&lt;/p&gt;

&lt;p&gt;Asset storage for logos&lt;/p&gt;

&lt;p&gt;For logo uploads, I wanted a storage layer that was simple, cheap, and fast to serve publicly.&lt;/p&gt;

&lt;p&gt;I used Cloudflare R2 for uploaded branding assets.&lt;/p&gt;

&lt;p&gt;The flow looks roughly like this:&lt;/p&gt;

&lt;p&gt;User uploads a PNG or JPG&lt;br&gt;
Backend validates file type and size&lt;br&gt;
File is stored under a user-scoped path&lt;br&gt;
The app stores only the storage path in the user record&lt;br&gt;
A helper builds the public asset URL when needed&lt;/p&gt;

&lt;p&gt;In pseudocode:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;uploadLogo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;image/png&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;image/jpeg&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;MAX_LOGO_SIZE&lt;/span&gt;

    &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;branding/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;generateFileName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nf"&gt;saveUserBranding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;logoPath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And later:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;buildAssetUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;CDN_BASE_URL&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I preferred storing the path rather than a fully expanded URL in the database, because it keeps storage concerns separate from delivery concerns.&lt;/p&gt;

&lt;p&gt;URL validation was the trickiest part&lt;/p&gt;

&lt;p&gt;The hardest part of this feature was not rendering a logo.&lt;/p&gt;

&lt;p&gt;It was letting users attach a website URL safely.&lt;/p&gt;

&lt;p&gt;On the surface, “enter your website” sounds trivial. In practice, it needs guardrails.&lt;/p&gt;

&lt;p&gt;I did not want to allow:&lt;/p&gt;

&lt;p&gt;localhost&lt;br&gt;
private network IPs&lt;br&gt;
credential-based URLs like user:&lt;a href="mailto:pass@example.com"&gt;pass@example.com&lt;/a&gt;&lt;br&gt;
unsafe schemes like javascript: or file:&lt;/p&gt;

&lt;p&gt;So I treated URL handling as a normalization + validation pipeline.&lt;/p&gt;

&lt;p&gt;The job of that pipeline is:&lt;/p&gt;

&lt;p&gt;parse the input&lt;br&gt;
normalize format&lt;br&gt;
require http or https&lt;br&gt;
reject unsafe hosts&lt;br&gt;
reject credentials&lt;br&gt;
return either a safe URL or null&lt;/p&gt;

&lt;p&gt;In pseudocode:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;normalizeUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;input&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;input&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;null&lt;/span&gt;

    &lt;span class="n"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;scheme&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;null&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hasCredentials&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;null&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isLocalhost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="nf"&gt;isPrivateIp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;null&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;normalizedHref&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This kind of defensive validation matters because branding fields are user-controlled input, and user-controlled input always deserves scrutiny.&lt;/p&gt;

&lt;p&gt;Fallback behavior matters more than people think&lt;/p&gt;

&lt;p&gt;A lot of polish in a feature like this comes from handling invalid states well.&lt;/p&gt;

&lt;p&gt;For example:&lt;/p&gt;

&lt;p&gt;branding is toggled on, but no logo is uploaded yet&lt;br&gt;
the user entered an invalid URL&lt;br&gt;
the user downgraded from Pro to Free&lt;br&gt;
an asset was deleted or unavailable&lt;br&gt;
branding data is partially filled out&lt;/p&gt;

&lt;p&gt;In all of those cases, I wanted the document experience to remain stable.&lt;/p&gt;

&lt;p&gt;So instead of letting the UI break or showing half-configured branding, I default to the platform brand whenever the custom brand can’t be resolved safely.&lt;/p&gt;

&lt;p&gt;That gives the system a predictable contract:&lt;/p&gt;

&lt;p&gt;Either show a fully valid custom brand, or show the default brand.&lt;/p&gt;

&lt;p&gt;No broken middle state.&lt;/p&gt;

&lt;p&gt;Why I like this implementation&lt;/p&gt;

&lt;p&gt;What I like about this feature is that it makes the product less visible in the right places.&lt;/p&gt;

&lt;p&gt;For this use case, that’s the goal.&lt;/p&gt;

&lt;p&gt;The product should support the sender’s identity, not compete with it.&lt;/p&gt;

&lt;p&gt;From an engineering point of view, I also like that the system is built around a few clear principles:&lt;/p&gt;

&lt;p&gt;central branding resolution&lt;br&gt;
server-side plan enforcement&lt;br&gt;
explicit URL normalization&lt;br&gt;
safe asset handling&lt;br&gt;
reliable fallback behavior&lt;/p&gt;

&lt;p&gt;The final result feels simple to the user, which usually means the internal rules are doing their job.&lt;/p&gt;

&lt;p&gt;What I’d improve next&lt;/p&gt;

&lt;p&gt;A few things I’d likely add next:&lt;/p&gt;

&lt;p&gt;custom domains for shared links&lt;br&gt;
better branding previews in settings&lt;br&gt;
image processing for uploaded logos&lt;br&gt;
audit logging for branding changes&lt;br&gt;
more granular per-document branding controls&lt;/p&gt;

&lt;p&gt;That would take it from “account-level branding” to a more flexible identity layer.&lt;/p&gt;

&lt;p&gt;Final thought&lt;/p&gt;

&lt;p&gt;This started as a branding feature, but it ended up being a good reminder that “simple UX” often sits on top of a lot of backend discipline.&lt;/p&gt;

&lt;p&gt;If you’re building anything user-configurable and client-facing, the hard part usually isn’t just rendering the settings.&lt;/p&gt;

&lt;p&gt;It’s defining the rules for what’s allowed, what’s safe, and what happens when the input is incomplete.&lt;/p&gt;

&lt;p&gt;If you’re curious, the product is DocBeacon: &lt;a href="https://docbeacon.io" rel="noopener noreferrer"&gt;https://docbeacon.io&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>buildinpublic</category>
      <category>startup</category>
    </item>
    <item>
      <title>Stop over-engineering your Waitlist. Here is a 5-minute "No-DB" solution.</title>
      <dc:creator>Howard Shaw</dc:creator>
      <pubDate>Sat, 07 Mar 2026 09:26:11 +0000</pubDate>
      <link>https://dev.to/howard_shaw_3c36a3a6cb900/stop-over-engineering-your-waitlist-here-is-a-5-minute-no-db-solution-p3j</link>
      <guid>https://dev.to/howard_shaw_3c36a3a6cb900/stop-over-engineering-your-waitlist-here-is-a-5-minute-no-db-solution-p3j</guid>
      <description>&lt;p&gt;As developers, we have a bad habit of building a full-blown CRUD app just to collect early-access emails.&lt;/p&gt;

&lt;p&gt;I’m currently building my new SaaS, and I wanted a waitlist that:&lt;/p&gt;

&lt;p&gt;Costs zero dollars.&lt;/p&gt;

&lt;p&gt;Gives me 100% control over the UI (no "Powered by" watermarks).&lt;/p&gt;

&lt;p&gt;Requires no database maintenance.&lt;/p&gt;

&lt;p&gt;Here is the "stupidly simple" stack I used: Vanilla JS + Google Apps Script + Google Sheets.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The "Backend" (Google Apps Script)
Create a Google Sheet, go to Extensions &amp;gt; Apps Script, and paste this. It acts as a serverless function that appends data to your sheet.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function doPost(e) {
  try {
    const data = JSON.parse(e.postData.contents);
    const ss = SpreadsheetApp.openById("YOUR_SHEET_ID_HERE");
    const sheet = ss.getSheets()[0];

    // Custom fields for my SaaS waitlist
    sheet.appendRow([
      new Date(), 
      data.email, 
      data.name, 
      data.company, 
      data.pressure, // My specific niche field
      data.city, 
      data.country
    ]);

    return ContentService.createTextOutput(JSON.stringify({ result: "success" }))
      .setMimeType(ContentService.MimeType.JSON);
  } catch (err) {
    return ContentService.createTextOutput(JSON.stringify({ result: "error", error: err.toString() }))
      .setMimeType(ContentService.MimeType.JSON);
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;The Frontend (Native Fetch)
No heavy libraries. Just a clean async function to talk to your new API.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const submitWaitlist = async (formData) =&amp;gt; {
  const SCRIPT_URL = 'https://script.google.com/macros/s/XXXXX/exec';

  try {
    // We use 'no-cors' because GAS redirects can be tricky with standard CORS
    await fetch(SCRIPT_URL, {
      method: 'POST',
      mode: 'no-cors', 
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(formData)
    });
    console.log('Early access secured!');
  } catch (error) {
    console.error('Submission failed', error);
  }
};
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why do it this way?&lt;br&gt;
Zero Technical Debt: When the project scales, I can migrate to a real DB in 10 minutes.&lt;/p&gt;

&lt;p&gt;Privacy: My data isn't sitting in a third-party startup's database. It's in my own Google Drive.&lt;/p&gt;

&lt;p&gt;Speed: I spent 5 minutes on the backend and 2 hours perfecting the CSS (priorities, right?).&lt;/p&gt;

&lt;p&gt;If you’re in the validation stage, don't let the "perfect stack" slow you down. Just get the emails and get back to building the actual product.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Rate Limiting Access Codes: The Delicate Balance Between Security and UX</title>
      <dc:creator>Howard Shaw</dc:creator>
      <pubDate>Tue, 20 Jan 2026 10:24:54 +0000</pubDate>
      <link>https://dev.to/howard_shaw_3c36a3a6cb900/rate-limiting-access-codes-the-delicate-balance-between-security-and-ux-2d13</link>
      <guid>https://dev.to/howard_shaw_3c36a3a6cb900/rate-limiting-access-codes-the-delicate-balance-between-security-and-ux-2d13</guid>
      <description>&lt;h1&gt;
  
  
  Rate Limiting Access Codes: The Delicate Balance Between Security and UX
&lt;/h1&gt;

&lt;p&gt;When building &lt;a href="https://docbeacon.io" rel="noopener noreferrer"&gt;DocBeacon&lt;/a&gt;, we faced a common but tricky engineering challenge: how to protect resources secured by short access codes without frustrating legitimate users. &lt;/p&gt;

&lt;h2&gt;
  
  
  The Strategic Choice: When to Increment?
&lt;/h2&gt;

&lt;p&gt;The core of rate limiting lies in deciding which events trigger the counter. &lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Strict Enforcement (Count Every Request):&lt;/strong&gt; Every attempt, valid or not, consumes the limit. While secure, this often leads to "false positives"—locking out users due to flaky connections, page refreshes, or shared IP environments.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adaptive Enforcement (Count Only Failures):&lt;/strong&gt; We opted for this approach. By only incrementing the counter on incorrect guesses, we ensure that a user with the correct code is never blocked by the security layer.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The Security Math
&lt;/h2&gt;

&lt;p&gt;Is "counting only failures" risky? Let's look at the entropy. For a code of length &lt;strong&gt;L&lt;/strong&gt; using a character set of size &lt;strong&gt;S&lt;/strong&gt;, the total combinations &lt;strong&gt;C&lt;/strong&gt; is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;C = S^L&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If we allow &lt;strong&gt;n&lt;/strong&gt; attempts per window, the probability &lt;strong&gt;P&lt;/strong&gt; of a successful brute-force attack is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;P = n / C&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;By choosing an appropriate &lt;strong&gt;L&lt;/strong&gt; and &lt;strong&gt;S&lt;/strong&gt;, even with a generous &lt;strong&gt;n&lt;/strong&gt;, the risk remains statistically negligible (often &amp;lt; 0.001%), making the UX gain far outweigh the marginal security risk.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation Pattern: The Verification Flow
&lt;/h2&gt;

&lt;p&gt;Instead of sharing our production code, here is the high-level logic pattern. Note how the rate limit is checked and incremented only after a failed validation:&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;// A generalized pattern for secure verification&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;verifyAccessWithLimit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;inputCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;context&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;shareId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;clientIp&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;context&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;identifier&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`limit:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;shareId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;clientIp&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// 1. Check if the user is already blocked&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isBlocked&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;rateLimiter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isLimitReached&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;identifier&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;isBlocked&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Too many attempts. Please try again later.&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="c1"&gt;// 2. Validate the code&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isValid&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;validateCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;inputCode&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;isValid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;success&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;// Valid users are never penalized&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// 3. Increment counter ONLY on failure&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;rateLimiter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;recordFailure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;identifier&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Invalid code&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Key Design: Composite Identifiers
&lt;/h2&gt;

&lt;p&gt;To prevent one attacker from affecting the entire system, we use &lt;strong&gt;composite keys&lt;/strong&gt;. This ensures that rate limits are isolated:&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;// Abstracting the key generation to ensure isolation&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generateRateLimitKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resourceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userIp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Combining resource and IP ensures that an attack on &lt;/span&gt;
  &lt;span class="c1"&gt;// Resource A doesn't block legitimate access to Resource B.&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`ratelimit:res_&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;resourceId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:ip_&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;userIp&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;True security is about &lt;strong&gt;proportional response&lt;/strong&gt;. By focusing our defenses on failure patterns rather than total traffic, we created a system for &lt;strong&gt;DocBeacon&lt;/strong&gt; that feels invisible to the user but remains an impenetrable wall for brute-force scripts.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>security</category>
      <category>startup</category>
    </item>
    <item>
      <title>I Finally Added Dark Mode, and It Forced Me to Fix More Than Colors</title>
      <dc:creator>Howard Shaw</dc:creator>
      <pubDate>Mon, 15 Dec 2025 13:16:04 +0000</pubDate>
      <link>https://dev.to/howard_shaw_3c36a3a6cb900/i-finally-added-dark-mode-and-it-forced-me-to-fix-more-than-colors-2n84</link>
      <guid>https://dev.to/howard_shaw_3c36a3a6cb900/i-finally-added-dark-mode-and-it-forced-me-to-fix-more-than-colors-2n84</guid>
      <description>&lt;p&gt;Dark mode is one of those features that sounds like "just swap a palette" until you actually ship it. I recently added dark mode to &lt;a href="https://docbeacon.io" rel="noopener noreferrer"&gt;DocBeacon (a document sharing and analytics SaaS)&lt;/a&gt;, and the implementation turned into an unexpected UI quality audit: charts, heatmaps, skeleton loaders, and even my spacing decisions were suddenly on trial.&lt;/p&gt;

&lt;p&gt;This post is a technical walkthrough of what broke, what I changed, and the patterns I'd reuse next time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why dark mode is not a theme toggle
&lt;/h2&gt;

&lt;p&gt;Users do not ask for dark mode because they love dark gray. They ask for it because they spend time in your product: reading analytics, reviewing dashboards, working late, switching between tabs. Dark mode is a comfort feature, but it is also a trust signal. If the UI looks inconsistent or unreadable in dark mode, the product feels less mature.&lt;/p&gt;

&lt;p&gt;That is why I treated this as a system change, not a CSS tweak.&lt;/p&gt;

&lt;h2&gt;
  
  
  My implementation approach: token first, components second
&lt;/h2&gt;

&lt;p&gt;The fastest path is usually to add a &lt;code&gt;.dark&lt;/code&gt; class and override colors. That works until it does not.&lt;/p&gt;

&lt;p&gt;What scaled for me was to define a small set of design tokens and refactor the UI to use them consistently.&lt;/p&gt;

&lt;p&gt;I used a token approach roughly like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Background layers: &lt;code&gt;bg.canvas&lt;/code&gt;, &lt;code&gt;bg.surface&lt;/code&gt;, &lt;code&gt;bg.elevated&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Text: &lt;code&gt;text.primary&lt;/code&gt;, &lt;code&gt;text.secondary&lt;/code&gt;, &lt;code&gt;text.muted&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Borders: &lt;code&gt;border.subtle&lt;/code&gt;, &lt;code&gt;border.strong&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Accents: &lt;code&gt;accent&lt;/code&gt;, &lt;code&gt;accent.contrast&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Semantic states: &lt;code&gt;success&lt;/code&gt;, &lt;code&gt;warning&lt;/code&gt;, &lt;code&gt;danger&lt;/code&gt;, &lt;code&gt;info&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then I mapped tokens to actual colors for light and dark themes.&lt;/p&gt;

&lt;p&gt;If you already use Tailwind, this maps nicely to CSS variables:&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;--bg-canvas&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--bg-surface&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;248&lt;/span&gt; &lt;span class="m"&gt;250&lt;/span&gt; &lt;span class="m"&gt;252&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--text-primary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;15&lt;/span&gt; &lt;span class="m"&gt;23&lt;/span&gt; &lt;span class="m"&gt;42&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--text-secondary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;51&lt;/span&gt; &lt;span class="m"&gt;65&lt;/span&gt; &lt;span class="m"&gt;85&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--border-subtle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;226&lt;/span&gt; &lt;span class="m"&gt;232&lt;/span&gt; &lt;span class="m"&gt;240&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--accent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;37&lt;/span&gt; &lt;span class="m"&gt;99&lt;/span&gt; &lt;span class="m"&gt;235&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.dark&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--bg-canvas&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt; &lt;span class="m"&gt;6&lt;/span&gt; &lt;span class="m"&gt;23&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--bg-surface&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;15&lt;/span&gt; &lt;span class="m"&gt;23&lt;/span&gt; &lt;span class="m"&gt;42&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--text-primary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;226&lt;/span&gt; &lt;span class="m"&gt;232&lt;/span&gt; &lt;span class="m"&gt;240&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--text-secondary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;148&lt;/span&gt; &lt;span class="m"&gt;163&lt;/span&gt; &lt;span class="m"&gt;184&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--border-subtle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;51&lt;/span&gt; &lt;span class="m"&gt;65&lt;/span&gt; &lt;span class="m"&gt;85&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--accent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;96&lt;/span&gt; &lt;span class="m"&gt;165&lt;/span&gt; &lt;span class="m"&gt;250&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;And in Tailwind you reference them as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.bg-canvas&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;background-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;rgb&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;--bg-canvas&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.text-primary&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;rgb&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;--text-primary&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.border-subtle&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;border-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;rgb&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;--border-subtle&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;The point is not the exact colors. The point is that every component uses tokens, not hard-coded colors.&lt;/p&gt;

&lt;h2&gt;
  
  
  What broke first: visual hierarchy
&lt;/h2&gt;

&lt;p&gt;In light mode, a lot of UIs get away with weak hierarchy because white backgrounds create contrast for free. In dark mode, everything compresses.&lt;/p&gt;

&lt;p&gt;These were my main fixes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Reduce the number of background layers. Too many surfaces make the UI look muddy.&lt;/li&gt;
&lt;li&gt;Use stronger typographic hierarchy. Dark mode needs clearer separation through font weight and size, not just color.&lt;/li&gt;
&lt;li&gt;Avoid pure black backgrounds. Near-black works better and keeps shadows, borders, and elevations readable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Practical rule I adopted: if a component needs a border to be visible, the surface layers are too similar.&lt;/p&gt;

&lt;h2&gt;
  
  
  Skeleton loaders and empty states: "invisible UI"
&lt;/h2&gt;

&lt;p&gt;This surprised me. Many of my skeleton loaders were built with subtle grays that were fine in light mode, but in dark mode they basically disappeared.&lt;/p&gt;

&lt;p&gt;Fixes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use opacity-based tokens, not fixed grays.&lt;/li&gt;
&lt;li&gt;Ensure skeletons have enough contrast to indicate shape, not just shimmer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A better skeleton pattern is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use background based on &lt;code&gt;bg.surface&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Use a slightly lighter overlay for shimmer&lt;/li&gt;
&lt;li&gt;Keep corners consistent with the real component&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Charts: your default palette will betray you
&lt;/h2&gt;

&lt;p&gt;I have charts in DocBeacon, and they were the first place dark mode exposed problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Axis labels became low-contrast&lt;/li&gt;
&lt;li&gt;Grid lines either vanished or became too prominent&lt;/li&gt;
&lt;li&gt;Tooltips looked like random floating boxes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What I changed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Set chart text and grid colors from the same tokens as the rest of the UI.&lt;/li&gt;
&lt;li&gt;Make tooltips use the same surface tokens as dropdown menus.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you use a chart library, do not accept defaults. Route all chart styling through your theme tokens, otherwise charts look like a separate product embedded inside your product.&lt;/p&gt;

&lt;h2&gt;
  
  
  Heatmaps and highlights: loud colors become painful
&lt;/h2&gt;

&lt;p&gt;Heatmaps were the hardest part. In light mode you can use aggressive colors and still keep things readable. In dark mode, the same colors become harsh and distracting.&lt;/p&gt;

&lt;p&gt;I ended up doing two things:&lt;/p&gt;

&lt;p&gt;1) Rebalanced the heatmap scale in dark mode&lt;br&gt;&lt;br&gt;
Instead of using the same color intensity, I reduced intensity and relied more on opacity.&lt;/p&gt;

&lt;p&gt;2) Separated background heat from selection highlight&lt;br&gt;&lt;br&gt;
A common issue: your heat layer and your highlight layer compete. In dark mode, that competition is amplified.&lt;/p&gt;

&lt;p&gt;Rule that helped: heat uses opacity, highlight uses hue. Do not let both fight at the same time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Icons, badges, and "fine until it was not"
&lt;/h2&gt;

&lt;p&gt;Dark mode is where sloppy UI decisions get exposed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Icons that were PNGs instead of SVGs looked wrong&lt;/li&gt;
&lt;li&gt;Badges had hard-coded backgrounds that clashed with everything&lt;/li&gt;
&lt;li&gt;Focus rings were invisible&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Fixes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Make icons inherit &lt;code&gt;currentColor&lt;/code&gt; where possible&lt;/li&gt;
&lt;li&gt;Tokenize badge backgrounds and text&lt;/li&gt;
&lt;li&gt;Tokenize focus rings (do not rely on browser defaults)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Testing strategy: how I avoided shipping a broken theme
&lt;/h2&gt;

&lt;p&gt;If you only toggle the theme and click around, you will miss issues.&lt;/p&gt;

&lt;p&gt;I did a boring but effective checklist:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Go through every major page and state: loading, empty, error, success&lt;/li&gt;
&lt;li&gt;Check forms: focus, hover, disabled, validation errors&lt;/li&gt;
&lt;li&gt;Check charts with real data (not demo data)&lt;/li&gt;
&lt;li&gt;Check at least one mobile viewport and one desktop viewport&lt;/li&gt;
&lt;li&gt;Screenshot key pages in both themes and compare side by side&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If I were doing this again, I would also add visual regression tests (Playwright screenshots), because dark mode is a perfect candidate for snapshot diffs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;p&gt;Dark mode is not colors. It is a forcing function that reveals:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Inconsistent component styling&lt;/li&gt;
&lt;li&gt;Weak hierarchy&lt;/li&gt;
&lt;li&gt;Over-reliance on subtle borders&lt;/li&gt;
&lt;li&gt;Chart defaults that do not match your UI system&lt;/li&gt;
&lt;li&gt;Heatmap and highlight conflicts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your product has analytics, dashboards, or any "stare at this for 10 minutes" workflow, dark mode is worth doing. Not because it is trendy, but because it raises the quality bar across the UI.&lt;/p&gt;

&lt;p&gt;If you shipped dark mode, what was the most unexpected thing that broke in your UI?&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ui</category>
      <category>frontend</category>
      <category>saas</category>
    </item>
    <item>
      <title>I built a Sankey chart to map document reading paths</title>
      <dc:creator>Howard Shaw</dc:creator>
      <pubDate>Mon, 08 Dec 2025 12:17:23 +0000</pubDate>
      <link>https://dev.to/howard_shaw_3c36a3a6cb900/i-built-a-sankey-chart-to-map-document-reading-paths-95o</link>
      <guid>https://dev.to/howard_shaw_3c36a3a6cb900/i-built-a-sankey-chart-to-map-document-reading-paths-95o</guid>
      <description>&lt;p&gt;Last week I shipped one of the biggest upgrades to my analytics engine since launch.&lt;/p&gt;

&lt;p&gt;I was chasing a simple question. How do people actually move through a document&lt;br&gt;
Not just page views, but start points, re-reads, and drop-off points.&lt;/p&gt;

&lt;p&gt;Most tools stop at views or reading time. Useful, but they miss attention flow.&lt;/p&gt;

&lt;p&gt;So I added a Sankey chart in &lt;a href="https://docbeacon.io" rel="noopener noreferrer"&gt;DocBeacon&lt;/a&gt; to map session-level page transitions.&lt;/p&gt;

&lt;p&gt;What I can see now&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;users starting on page 3 instead of page 1&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;loops where a section gets re-read&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;exits right after pricing&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Under the hood, this required bigger changes than the UI suggests&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;session stitching&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;transition graph building&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;path weighting&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;sanity checks for noisy sessions&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result feels closer to intent analytics than file tracking.&lt;/p&gt;

&lt;p&gt;Next step is turning these paths into actionable signals&lt;br&gt;
content ordering suggestions, confusion hotspots, and lightweight benchmarks for proposals and pitch decks.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>buildinpublic</category>
      <category>startup</category>
    </item>
    <item>
      <title>Refactoring the Analytics Core in DocBeacon: The Hidden Cost of Aggregation</title>
      <dc:creator>Howard Shaw</dc:creator>
      <pubDate>Mon, 01 Dec 2025 12:48:52 +0000</pubDate>
      <link>https://dev.to/howard_shaw_3c36a3a6cb900/refactoring-the-analytics-core-in-docbeacon-the-hidden-cost-of-aggregation-46cf</link>
      <guid>https://dev.to/howard_shaw_3c36a3a6cb900/refactoring-the-analytics-core-in-docbeacon-the-hidden-cost-of-aggregation-46cf</guid>
      <description>&lt;p&gt;I’ve been deep inside one of the most complex parts of DocBeacon, the tracking and analytics engine.&lt;/p&gt;

&lt;p&gt;We aggregate user behavior data on three levels:&lt;/p&gt;

&lt;p&gt;Share-level (each document share link)&lt;/p&gt;

&lt;p&gt;Document-level (per uploaded file)&lt;/p&gt;

&lt;p&gt;User-level (overall engagement footprint)&lt;/p&gt;

&lt;p&gt;The challenge:&lt;br&gt;
Every time a new event is logged (a view, scroll, or dwell), DocBeacon performs a hierarchical aggregation to update summary stats across all three levels. For users with large historical data, this can become very expensive, both computationally and in terms of API overhead.&lt;/p&gt;

&lt;p&gt;The logical solution seems simple: reduce the frequency of aggregation.&lt;br&gt;
But that’s where things got tricky.&lt;/p&gt;

&lt;p&gt;Months ago, I noticed rare cases where the aggregation would silently fail to trigger. That meant the top-level summaries occasionally drifted out of sync, sometimes off by just a few views, other times more dramatically. The bug was elusive: hard to reproduce, impossible to ignore.&lt;/p&gt;

&lt;p&gt;During this refactor, I’ve been dissecting the entire chain of event handling, from event queue → aggregation trigger → rollup storage. The logic is being restructured to make trigger conditions more deterministic and fault-tolerant.&lt;/p&gt;

&lt;p&gt;So far, progress has been solid, but reproducing the original edge case remains challenging. Debugging aggregation bugs is like chasing ghosts, you only see their footprints.&lt;/p&gt;

&lt;p&gt;Once this refactor ships, the analytics engine will be leaner, more predictable, and less resource-hungry. It’s the kind of work no one sees on the surface, but it’s what makes real-time analytics trustworthy.&lt;/p&gt;

&lt;p&gt;Still testing. Hoping to roll out next week.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>saas</category>
      <category>programming</category>
    </item>
    <item>
      <title>Training a model to predict a persuasion score for documents, but hitting a wall</title>
      <dc:creator>Howard Shaw</dc:creator>
      <pubDate>Mon, 24 Nov 2025 13:29:03 +0000</pubDate>
      <link>https://dev.to/howard_shaw_3c36a3a6cb900/training-a-model-to-predict-a-persuasion-score-for-documents-but-hitting-a-wall-1anm</link>
      <guid>https://dev.to/howard_shaw_3c36a3a6cb900/training-a-model-to-predict-a-persuasion-score-for-documents-but-hitting-a-wall-1anm</guid>
      <description>&lt;p&gt;I’m building &lt;a href="https://docbeacon.io" rel="noopener noreferrer"&gt;DocBeacon: secure document sharing and tracking platform&lt;/a&gt;. It shows exactly how readers interact with each page: scroll depth, dwell time, replays, and even heatmaps of attention.&lt;/p&gt;

&lt;p&gt;Recently I’ve been thinking about going a step further. Instead of just showing behavior metrics, what if DocBeacon could estimate how emotionally or cognitively engaged a reader is with a document? Something like a persuasion score that reflects how much a sales proposal actually moved them, did they seem convinced, neutral, or totally uninterested?&lt;/p&gt;

&lt;p&gt;The basic idea sounds simple: use past reading behavior data and train a model to predict the likelihood of acceptance. But here’s the problem: I have plenty of behavioral data (how people read), yet no solid labels on what happened after they read. Without knowing whether the reader ended up accepting the proposal, replying, or ghosting, the model can’t really learn meaningful correlations.&lt;/p&gt;

&lt;p&gt;Has anyone here tackled a similar cold-start problem?&lt;br&gt;
Would it make sense to infer pseudo labels using proxy signals such as follow-up activity or revisit patterns? Or maybe combine user feedback loops to build a semi-supervised system?&lt;/p&gt;

&lt;p&gt;Curious to hear how others would approach building a persuasion predictor when you only have half the story.&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>development</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>The SEO grind no one talks about</title>
      <dc:creator>Howard Shaw</dc:creator>
      <pubDate>Mon, 10 Nov 2025 12:41:01 +0000</pubDate>
      <link>https://dev.to/howard_shaw_3c36a3a6cb900/the-seo-grind-no-one-talks-about-hoc</link>
      <guid>https://dev.to/howard_shaw_3c36a3a6cb900/the-seo-grind-no-one-talks-about-hoc</guid>
      <description>&lt;p&gt;For the past couple of weeks, I’ve been deep in SEO work for DocBeacon&lt;br&gt;
, my product is a &lt;a href="https://docbeacon.io" rel="noopener noreferrer"&gt;secure document sharing and document tracking software&lt;/a&gt;. I wanted the marketing pages to sound more natural for readers while still keeping Google happy.&lt;/p&gt;

&lt;p&gt;Turns out that balance is way harder than I thought.&lt;/p&gt;

&lt;p&gt;While doing keyword research, I realized both core terms, “secure document sharing” and “document tracking software,” are insanely competitive. The keyword difficulty is sky high, and CPCs start at over $10. Now I understand why big SaaS companies spend so much on ads in this space.&lt;/p&gt;

&lt;p&gt;I’ve probably spent more than a month rewriting, restructuring, and trying to improve page quality. And honestly, progress feels slow. You stare at analytics every day, hoping for movement, but SEO has its own rhythm. It moves quietly.&lt;/p&gt;

&lt;p&gt;I’ve also been reading about programmatic SEO, generating thousands of pages through code and AI. It sounds great on paper, but I’m a bit skeptical. I don’t want to get flagged for thin or repetitive content. So for now, I’m doing most of it manually, using a bit of AI to speed up research and formatting.&lt;/p&gt;

&lt;p&gt;This whole process made me realize how much patience SEO really takes. It’s not just about keywords or backlinks. It’s about good writing, structure, and showing up consistently.&lt;/p&gt;

&lt;p&gt;If you’re working on SEO for your SaaS too, how are you finding the balance between quality and speed? I’d love to hear what’s been working for you.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>startup</category>
      <category>buildinpublic</category>
    </item>
  </channel>
</rss>
