<?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>Forem: Howard Shaw</title>
    <description>The latest articles on Forem by Howard Shaw (@howard_shaw_3c36a3a6cb900).</description>
    <link>https://forem.com/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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3521176%2Fd8816f40-1d31-4dfe-a4f3-48ac64ef4217.png</url>
      <title>Forem: Howard Shaw</title>
      <link>https://forem.com/howard_shaw_3c36a3a6cb900</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/howard_shaw_3c36a3a6cb900"/>
    <language>en</language>
    <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://forem.com/howard_shaw_3c36a3a6cb900/reducing-a-homepage-demo-video-from-116-mb-to-under-1-mb-30ip</link>
      <guid>https://forem.com/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://forem.com/howard_shaw_3c36a3a6cb900/how-i-built-white-label-document-sharing-for-client-facing-docs-1oi2</link>
      <guid>https://forem.com/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://forem.com/howard_shaw_3c36a3a6cb900/stop-over-engineering-your-waitlist-here-is-a-5-minute-no-db-solution-p3j</link>
      <guid>https://forem.com/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://forem.com/howard_shaw_3c36a3a6cb900/rate-limiting-access-codes-the-delicate-balance-between-security-and-ux-2d13</link>
      <guid>https://forem.com/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://forem.com/howard_shaw_3c36a3a6cb900/i-finally-added-dark-mode-and-it-forced-me-to-fix-more-than-colors-2n84</link>
      <guid>https://forem.com/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://forem.com/howard_shaw_3c36a3a6cb900/i-built-a-sankey-chart-to-map-document-reading-paths-95o</link>
      <guid>https://forem.com/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://forem.com/howard_shaw_3c36a3a6cb900/refactoring-the-analytics-core-in-docbeacon-the-hidden-cost-of-aggregation-46cf</link>
      <guid>https://forem.com/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://forem.com/howard_shaw_3c36a3a6cb900/training-a-model-to-predict-a-persuasion-score-for-documents-but-hitting-a-wall-1anm</link>
      <guid>https://forem.com/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://forem.com/howard_shaw_3c36a3a6cb900/the-seo-grind-no-one-talks-about-hoc</link>
      <guid>https://forem.com/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>
    <item>
      <title>Ever spent a full day rebuilding something your users will never notice? I just did. And it was absolutely worth it.</title>
      <dc:creator>Howard Shaw</dc:creator>
      <pubDate>Mon, 03 Nov 2025 13:18:53 +0000</pubDate>
      <link>https://forem.com/howard_shaw_3c36a3a6cb900/ever-spent-a-full-day-rebuilding-something-your-users-will-never-notice-i-just-did-and-it-was-2hm5</link>
      <guid>https://forem.com/howard_shaw_3c36a3a6cb900/ever-spent-a-full-day-rebuilding-something-your-users-will-never-notice-i-just-did-and-it-was-2hm5</guid>
      <description>&lt;p&gt;Over the past months, the product pages evolved fast — new features, layout changes, experiments. And somewhere along the way… the breadcrumb system turned into spaghetti.&lt;/p&gt;

&lt;p&gt;Some breadcrumbs were inside the layout.&lt;/p&gt;

&lt;p&gt;Some were hard-coded in individual pages.&lt;/p&gt;

&lt;p&gt;Some didn’t follow the same logic at all.&lt;/p&gt;

&lt;p&gt;Everything looked fine to the user. But inside the codebase? It was messy, redundant, and painful to maintain.&lt;/p&gt;

&lt;p&gt;So today, I paused “new features” and spent the entire day refactoring breadcrumbs — making them consistent, unified, and owned by one source of truth.&lt;/p&gt;

&lt;p&gt;No one outside will notice it.&lt;br&gt;
But the code is now cleaner, future-proof, and I can sleep better.&lt;/p&gt;

&lt;p&gt;Not all progress is visible. And sometimes, the most “invisible” work is what keeps a product from breaking later.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://docbeacon.io" rel="noopener noreferrer"&gt;DocBeacon - secure document sharing and tracking software&lt;/a&gt;&lt;/p&gt;

</description>
      <category>saas</category>
      <category>startup</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Coding Shipping</title>
      <dc:creator>Howard Shaw</dc:creator>
      <pubDate>Mon, 27 Oct 2025 13:39:27 +0000</pubDate>
      <link>https://forem.com/howard_shaw_3c36a3a6cb900/coding-shipping-2792</link>
      <guid>https://forem.com/howard_shaw_3c36a3a6cb900/coding-shipping-2792</guid>
      <description>&lt;p&gt;As developers, we’re trained to perfect things:&lt;br&gt;
clean architecture, elegant abstractions, scalability from day one…&lt;/p&gt;

&lt;p&gt;But real progress comes from shipping.&lt;/p&gt;

&lt;p&gt;Shipping forces you to learn:&lt;br&gt;
— What users actually care about&lt;br&gt;
— Which bugs really matter&lt;br&gt;
— How to prioritize under pressure&lt;br&gt;
— How to tell a story&lt;br&gt;
— How to market what you made&lt;/p&gt;

&lt;p&gt;Unreleased code solves nothing.&lt;/p&gt;

&lt;p&gt;AI will soon write great code for everyone.&lt;br&gt;
But it won’t make brave decisions for you.&lt;/p&gt;

&lt;p&gt;So ship the rough edges.&lt;br&gt;
Ship the ugly version.&lt;br&gt;
Ship the version that makes you nervous.&lt;/p&gt;

&lt;p&gt;You’ll learn 10× faster.&lt;/p&gt;

&lt;p&gt;What’s one project you want to ship in the next 7 days?&lt;/p&gt;

</description>
      <category>programming</category>
      <category>buildinpublic</category>
      <category>startup</category>
    </item>
    <item>
      <title>Monday check-in</title>
      <dc:creator>Howard Shaw</dc:creator>
      <pubDate>Mon, 20 Oct 2025 14:02:15 +0000</pubDate>
      <link>https://forem.com/howard_shaw_3c36a3a6cb900/monday-check-in-4kj2</link>
      <guid>https://forem.com/howard_shaw_3c36a3a6cb900/monday-check-in-4kj2</guid>
      <description>&lt;p&gt;Every week feels like a new wave in tech — new models, new tools, new APIs.&lt;/p&gt;

&lt;p&gt;It’s easy to get distracted chasing “the next thing.”&lt;/p&gt;

&lt;p&gt;But the truth is: showing up every day, even for small progress, still compounds faster than chasing every shiny update.&lt;/p&gt;

&lt;p&gt;Tools evolve. Builders stay.&lt;/p&gt;

</description>
      <category>programming</category>
      <category>startup</category>
      <category>buildinpublic</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
