<?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: Richard Lemon</title>
    <description>The latest articles on DEV Community by Richard Lemon (@richardlemon).</description>
    <link>https://dev.to/richardlemon</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%2F3798270%2F7fc64f22-b7f0-471f-9ac1-e15050494121.jpeg</url>
      <title>DEV Community: Richard Lemon</title>
      <link>https://dev.to/richardlemon</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/richardlemon"/>
    <language>en</language>
    <item>
      <title>Schema.org markup and entity recognition in 2026</title>
      <dc:creator>Richard Lemon</dc:creator>
      <pubDate>Fri, 06 Nov 2026 10:55:26 +0000</pubDate>
      <link>https://dev.to/richardlemon/schemaorg-markup-and-entity-recognition-in-2026-ibf</link>
      <guid>https://dev.to/richardlemon/schemaorg-markup-and-entity-recognition-in-2026-ibf</guid>
      <description>&lt;h2&gt;Treating the site as data, not just pages&lt;/h2&gt;

&lt;p&gt;In 2026, the SEO work that mattered most for Aftertype was treating the site as data instead of just a collection of pages. Concretely, that meant site-wide &lt;code&gt;schema.org&lt;/code&gt; markup and a companion &lt;code&gt;llms.txt&lt;/code&gt; file, so both classic search engines and AI systems could understand what the site is, not just what it says.&lt;/p&gt;

&lt;h2&gt;From impressions to entity recognition&lt;/h2&gt;

&lt;p&gt;Before structured data, Search Console made Aftertype look like a pile of pages and keywords. Impressions went up and down, clicks were fine, and the query mix was a foggy blend of terms that roughly matched the content.&lt;/p&gt;

&lt;p&gt;After rolling out structured data across the site, the interesting change was how the site appeared inside Google’s own model of the world. Queries started clustering around Aftertype as an entity, not just as text on a page. Search Console made it clearer that the site was being treated as a thing in the graph, not just a set of strings.&lt;/p&gt;

&lt;h2&gt;What actually went into the markup&lt;/h2&gt;

&lt;p&gt;The work was not magic. It was mostly tedious, careful markup across three main types: &lt;code&gt;Organization&lt;/code&gt;, &lt;code&gt;Article&lt;/code&gt;, and &lt;code&gt;FAQPage&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;Organization schema&lt;/h3&gt;

&lt;p&gt;First step: describe Aftertype as an &lt;code&gt;Organization&lt;/code&gt;. That meant being explicit about:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Official name&lt;/li&gt;
  &lt;li&gt;URL and logo&lt;/li&gt;
  &lt;li&gt;Contact details&lt;/li&gt;
  &lt;li&gt;
&lt;code&gt;sameAs&lt;/code&gt; links to profiles elsewhere&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the base layer. Without it, every other piece of content floats around without an owner. With it, articles and FAQs can all point back to a single, stable entity.&lt;/p&gt;

&lt;h3&gt;Article schema across the site&lt;/h3&gt;

&lt;p&gt;Next step: treat each significant piece of content as an &lt;code&gt;Article&lt;/code&gt; instead of a generic page. That meant marking up:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;
&lt;code&gt;headline&lt;/code&gt; and &lt;code&gt;description&lt;/code&gt;
&lt;/li&gt;
  &lt;li&gt;
&lt;code&gt;author&lt;/code&gt;, &lt;code&gt;publisher&lt;/code&gt;, and &lt;code&gt;datePublished&lt;/code&gt;
&lt;/li&gt;
  &lt;li&gt;
&lt;code&gt;mainEntityOfPage&lt;/code&gt; to connect the article to the canonical URL&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is boring, mechanical work, but it gives search engines a clean graph: this organization publishes these articles, on these topics, at these times.&lt;/p&gt;

&lt;h3&gt;FAQPage schema where it actually fit&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;FAQPage&lt;/code&gt; schema only went on content that was genuinely question and answer shaped. Each question and answer pair was marked up as &lt;code&gt;Question&lt;/code&gt; and &lt;code&gt;Answer&lt;/code&gt; inside a &lt;code&gt;FAQPage&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The goal was simple: give both search engines and AI systems a clean, structured set of canonical answers for recurring questions, instead of scattering them across loosely formatted paragraphs.&lt;/p&gt;

&lt;h2&gt;llms.txt: a companion for AI search&lt;/h2&gt;

&lt;p&gt;Alongside the Schema.org work, Aftertype added a companion &lt;code&gt;llms.txt&lt;/code&gt; file. Classic search engines read &lt;code&gt;robots.txt&lt;/code&gt; to understand what they can crawl. AI-focused crawlers and LLM-backed search tools need a similar pointer, but with extra hints about what is actually useful for training or retrieval.&lt;/p&gt;

&lt;p&gt;Where Schema.org describes the structure of each page, &lt;code&gt;llms.txt&lt;/code&gt; describes which parts of the site are high signal, canonical, or stable enough to be worth ingesting. Together, they act as two halves of the same idea: one for traditional SEO, one for AI search.&lt;/p&gt;

&lt;h2&gt;GEO vs SEO in 2026&lt;/h2&gt;

&lt;p&gt;By 2026, the line between SEO and what you might call GEO (graph or entity optimization) is very visible.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;SEO is still about ranking pages for queries.&lt;/li&gt;
  &lt;li&gt;GEO is about making sure you, your company, and your content exist as clear entities in the graph that search engines and LLMs use.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Schema.org and &lt;code&gt;llms.txt&lt;/code&gt; work for Aftertype sat squarely in the GEO bucket. Instead of chasing another round of on-page tweaks, the focus was on telling machines:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;“This is the organization behind this site.”&lt;/li&gt;
  &lt;li&gt;“These are the articles and what they are about.”&lt;/li&gt;
  &lt;li&gt;“These are the canonical answers to recurring questions.”&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once that graph was in place, traditional SEO metrics started to look different. Branded and related queries clustered more logically, and Search Console began to look less like noise and more like a map of how the entity was understood.&lt;/p&gt;

&lt;h2&gt;What changed in Search Console&lt;/h2&gt;

&lt;p&gt;After deploying structured data site-wide on Aftertype, Search Console results shifted in a few ways that matched the GEO focus:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Queries tied more clearly to the organization name and core topics, instead of random long-tail matches.&lt;/li&gt;
  &lt;li&gt;Branded and entity-like queries became easier to track as a coherent group.&lt;/li&gt;
  &lt;li&gt;Rich results and enhanced snippets aligned more cleanly with marked-up FAQs and articles.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This was not about a viral spike or overnight doubling of traffic. It was a change in how the site was categorized and presented, and in how new content slotted into an existing entity graph instead of starting from zero each time.&lt;/p&gt;

&lt;h2&gt;Why this beat other SEO habits in 2026&lt;/h2&gt;

&lt;p&gt;Most SEO activity in 2026 feels like tuning a radio station that keeps changing frequency. Algorithms shift, SERP layouts change, and AI overviews eat more of the page. The Schema.org and &lt;code&gt;llms.txt&lt;/code&gt; work for Aftertype targeted a slower-moving layer: how the site is modeled as data.&lt;/p&gt;

&lt;p&gt;Instead of guessing which micro-optimizations might please the latest search experiment, the work focused on:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Making the organization unambiguous as an entity&lt;/li&gt;
  &lt;li&gt;Making each article unambiguous as a piece of content&lt;/li&gt;
  &lt;li&gt;Making FAQs unambiguous as canonical answers&lt;/li&gt;
  &lt;li&gt;Making AI crawlers unambiguous about what to read and reuse&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That combination turned a loose collection of pages into something closer to an API for both search engines and LLMs. The payoff showed up less as raw volume and more as recognition.&lt;/p&gt;

&lt;h2&gt;If you only do one thing&lt;/h2&gt;

&lt;p&gt;If you are deciding where to spend limited SEO time in 2026, a simple order of operations looks like this:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;Implement solid &lt;code&gt;Organization&lt;/code&gt; schema site-wide.&lt;/li&gt;
  &lt;li&gt;Mark every significant content piece as an &lt;code&gt;Article&lt;/code&gt; with proper metadata.&lt;/li&gt;
  &lt;li&gt;Use &lt;code&gt;FAQPage&lt;/code&gt; schema only where the content is truly question and answer shaped.&lt;/li&gt;
  &lt;li&gt;Add an &lt;code&gt;llms.txt&lt;/code&gt; file that points AI crawlers at your best, most stable content.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Everything else can wait. Entity recognition is the part that actually sticks.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>seo</category>
    </item>
    <item>
      <title>Deriving a Ghost theme palette from one accent color</title>
      <dc:creator>Richard Lemon</dc:creator>
      <pubDate>Wed, 21 Oct 2026 18:11:04 +0000</pubDate>
      <link>https://dev.to/richardlemon/deriving-a-ghost-theme-palette-from-one-accent-color-1bbf</link>
      <guid>https://dev.to/richardlemon/deriving-a-ghost-theme-palette-from-one-accent-color-1bbf</guid>
      <description>&lt;h2&gt;The constraint: Ghost gives you one color&lt;/h2&gt;

&lt;p&gt;Ghost themes get exactly one brand color from the admin: &lt;code&gt;--ghost-accent-color&lt;/code&gt;. That value often needs to carry most of the UI: links, buttons, hovers, subtle backgrounds, borders.&lt;/p&gt;

&lt;p&gt;You can add more theme settings, but it is possible to treat that single accent as the source of truth and derive everything else in CSS.&lt;/p&gt;

&lt;p&gt;With one custom property in &lt;code&gt;package.json&lt;/code&gt;, the theme can generate tints, hover states, borders and soft backgrounds using &lt;code&gt;color-mix()&lt;/code&gt; and, where helpful, &lt;code&gt;oklch()&lt;/code&gt;. No extra admin fields are required.&lt;/p&gt;

&lt;h2&gt;Starting point: normalize the accent color&lt;/h2&gt;

&lt;p&gt;Ghost exposes the accent color as &lt;code&gt;--ghost-accent-color&lt;/code&gt; on &lt;code&gt;:root&lt;/code&gt;. A simple first step is to mirror it into a small set of semantic tokens so the rest of the CSS never touches the raw accent directly.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;:root {
  /* from Ghost admin */
  --ghost-accent-color: #ff1a75;

  /* base semantic tokens */
  --accent: var(--ghost-accent-color);
  --accent-text-on-solid: #fff;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;code&gt;--accent&lt;/code&gt; is the only color that is really “brand”. Everything else hangs off it with &lt;code&gt;color-mix()&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;Using color-mix() for hover and focus states&lt;/h2&gt;

&lt;p&gt;A straightforward use is hover states. For buttons and links you can create a slightly stronger accent on hover without guessing a separate color.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;:root {
  --accent-strong: color-mix(in srgb, var(--accent) 85%, black);
}

.button-primary {
  background: var(--accent);
  color: var(--accent-text-on-solid);
}

.button-primary:hover,
.button-primary:focus-visible {
  background: var(--accent-strong);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This works well for mid-range colors, but it can break down at the extremes. A very dark accent mixed toward black barely changes. A very light accent mixed toward black can jump too hard and fail contrast against white text.&lt;/p&gt;

&lt;h2&gt;Very light and very dark accents&lt;/h2&gt;

&lt;p&gt;Two cases tend to cause trouble:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Almost-white accents, like &lt;code&gt;#fbe9ff&lt;/code&gt; or very light pastels.&lt;/li&gt;
  &lt;li&gt;Almost-black accents, like &lt;code&gt;#050505&lt;/code&gt; or deep navy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With almost-white accents, Ghost often pairs them with dark text, not white. If you always use &lt;code&gt;--accent-text-on-solid: #fff&lt;/code&gt; and darken the accent on hover, the background can still end up with too little contrast against white text.&lt;/p&gt;

&lt;p&gt;With almost-black accents, mixing toward black on hover does very little visually. The button looks unchanged.&lt;/p&gt;

&lt;p&gt;Instead of assuming the accent is a normal mid-tone, it helps to reason about it in &lt;code&gt;oklch()&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;Using oklch to reason about lightness&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;oklch()&lt;/code&gt; exposes a lightness component that behaves more linearly than &lt;code&gt;srgb&lt;/code&gt;. That makes it easier to decide two things:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Should text on the accent be light or dark?&lt;/li&gt;
  &lt;li&gt;Should hover move the accent lighter or darker?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;First, convert the accent to &lt;code&gt;oklch()&lt;/code&gt; in a custom property. Browsers can do this conversion implicitly:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;:root {
  --accent-oklch: color-mix(in oklch, var(--accent) 100%, black 0%);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Then derive lighter and darker variants by nudging the lightness channel via mixes toward black or white.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;:root {
  /* Slightly darker for hover, slightly lighter for subtle backgrounds */
  --accent-strong-oklch: color-mix(in oklch, var(--accent) 90%, black 10%);
  --accent-soft-oklch:   color-mix(in oklch, var(--accent) 90%, white 10%);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;These are still &lt;code&gt;oklch&lt;/code&gt;-space colors, but the browser returns them as usable CSS colors. For many accents this gives smoother steps than mixing in &lt;code&gt;srgb&lt;/code&gt;, especially for saturated colors.&lt;/p&gt;

&lt;h2&gt;Choosing text color based on accent lightness&lt;/h2&gt;

&lt;p&gt;Ghost itself decides whether to use light or dark text on the accent in some places, but inside a theme it is useful to have a consistent rule. Accents above a certain lightness can be treated as “light backgrounds” and use dark text on them.&lt;/p&gt;

&lt;p&gt;There is no native &lt;code&gt;if()&lt;/code&gt; in CSS yet, so a practical approach is to define two modes in the theme, then switch modes based on a class that is set server-side or through a small script that inspects the computed accent color.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;:root {
  --accent-text-on-solid: #fff;
}

.theme--accent-light {
  --accent-text-on-solid: #111;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;A script can read &lt;code&gt;getComputedStyle(document.documentElement).getPropertyValue('--accent')&lt;/code&gt;, convert it to &lt;code&gt;oklch()&lt;/code&gt; using a helper, and toggle &lt;code&gt;.theme--accent-light&lt;/code&gt; if the lightness is above a threshold. Extremely light accents then flip to dark text, and the hover and active colors derived from &lt;code&gt;oklch&lt;/code&gt; stay on the right side of the contrast line.&lt;/p&gt;

&lt;h2&gt;Subtle backgrounds and borders from the same accent&lt;/h2&gt;

&lt;p&gt;For cards, badges and inline highlights you can use the accent to create a soft background and a slightly stronger border.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;:root {
  --accent-bg-soft: color-mix(in oklch, var(--accent) 12%, white);
  --accent-border-soft: color-mix(in oklch, var(--accent) 45%, white);
}

.badge {
  background: var(--accent-bg-soft);
  border: 1px solid var(--accent-border-soft);
  color: var(--accent);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;For very dark accents, mixing toward white like this keeps the badge readable without turning it into a loud block. For very light accents, the badge is barely tinted; the accent still shows up in the border and text.&lt;/p&gt;

&lt;p&gt;The same pattern works for focus outlines:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;:root {
  --accent-outline: color-mix(in oklch, var(--accent) 65%, white);
}

:focus-visible {
  outline: 2px solid var(--accent-outline);
  outline-offset: 3px;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;h2&gt;Checking contrast without designing per brand&lt;/h2&gt;

&lt;p&gt;Because the accent is user-controlled, you cannot guarantee perfect contrast for every possible color. You can only make it harder to get into trouble.&lt;/p&gt;

&lt;p&gt;A practical approach is to test a small grid of accent colors locally:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Very light pastel&lt;/li&gt;
  &lt;li&gt;Mid-tone brand color&lt;/li&gt;
  &lt;li&gt;Deep saturated color&lt;/li&gt;
  &lt;li&gt;Near-black&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For each, check a few key pairs in a contrast checker:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;
&lt;code&gt;--accent&lt;/code&gt; vs &lt;code&gt;--accent-text-on-solid&lt;/code&gt;
&lt;/li&gt;
  &lt;li&gt;
&lt;code&gt;--accent-bg-soft&lt;/code&gt; vs body text&lt;/li&gt;
  &lt;li&gt;Focus outline vs page background&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;oklch&lt;/code&gt;-based mixes help keep these pairs within acceptable ranges for normal accents. Extremely low-contrast choices from the admin, such as an almost white accent on a white site background, will still be low contrast, but the theme does not make that worse by using the accent for long-form body text.&lt;/p&gt;

&lt;h2&gt;Fallbacks for browsers without color-mix()&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;color-mix()&lt;/code&gt; is not universal yet, so the theme should keep a basic experience for older browsers.&lt;/p&gt;

&lt;p&gt;One pattern is:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;:root {
  /* basic fallback */
  --accent-strong: var(--accent);
  --accent-bg-soft: rgba(0, 0, 0, 0.03);
}

@supports (color-mix(in srgb, black, white)) {
  :root {
    --accent-strong: color-mix(in oklch, var(--accent) 85%, black);
    --accent-bg-soft: color-mix(in oklch, var(--accent) 12%, white);
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;In browsers without &lt;code&gt;@supports (color-mix)&lt;/code&gt;, buttons still use the accent, they just do not get a more intense hover color. Soft backgrounds fall back to a neutral tint instead of an accent-tinted one.&lt;/p&gt;

&lt;h2&gt;Keeping everything wired to one variable&lt;/h2&gt;

&lt;p&gt;The main benefit of this approach is that the entire palette stays wired to &lt;code&gt;--ghost-accent-color&lt;/code&gt;. When someone changes the accent in Ghost admin, links, buttons, cards, focus states and badges all follow without touching the theme settings.&lt;/p&gt;

&lt;p&gt;The trade-off is that you have to be conservative: avoid using the accent for long-form text, keep strong backgrounds limited, and lean on &lt;code&gt;oklch&lt;/code&gt; and &lt;code&gt;color-mix()&lt;/code&gt; to keep hover and subtle states in a safe range.&lt;/p&gt;

</description>
      <category>css</category>
      <category>frontend</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Who actually reads my llms.txt?</title>
      <dc:creator>Richard Lemon</dc:creator>
      <pubDate>Sat, 17 Oct 2026 10:42:14 +0000</pubDate>
      <link>https://dev.to/richardlemon/who-actually-reads-my-llmstxt-ef0</link>
      <guid>https://dev.to/richardlemon/who-actually-reads-my-llmstxt-ef0</guid>
      <description>&lt;h2&gt;Checking who touches &lt;code&gt;llms.txt&lt;/code&gt; in practice&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;/llms.txt&lt;/code&gt; and &lt;code&gt;/llms-full.txt&lt;/code&gt; have been live on richardlemon.com for a while. The obvious next question: who, if anyone, is actually requesting them?&lt;/p&gt;

&lt;p&gt;One way to approach that is to look at the web server logs directly. No dashboards, no vibes. Just the Nginx access log on the droplet.&lt;/p&gt;

&lt;h2&gt;Where the evidence lives: Nginx access logs&lt;/h2&gt;

&lt;p&gt;On a typical Nginx setup on a small VPS, access logs will usually live somewhere like:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;/var/log/nginx/access.log
/var/log/nginx/access.log.1
/var/log/nginx/access.log.2.gz
...&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The exact path depends on the distro and how Nginx was installed, but the pattern is similar: a current &lt;code&gt;access.log&lt;/code&gt; plus rotated and sometimes gzipped older logs.&lt;/p&gt;

&lt;p&gt;For anything related to &lt;code&gt;llms.txt&lt;/code&gt;, the relevant fields are the request path and the user agent. That tells you which client asked for which file, and how it chose to introduce itself.&lt;/p&gt;

&lt;h2&gt;Filtering for &lt;code&gt;llms.txt&lt;/code&gt; and &lt;code&gt;llms-full.txt&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;First step is to pull out only the lines that touch those two files. You can do that with a simple &lt;code&gt;grep&lt;/code&gt; across your logs. For example, from the directory where your logs live:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;cd /var/log/nginx

grep -hE "llms\.txt|llms-full\.txt" access.log* &amp;gt; llms-requests.log&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;A few details here:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;
&lt;code&gt;-h&lt;/code&gt; hides the filename in the output, which keeps the next steps cleaner.&lt;/li&gt;
  &lt;li&gt;
&lt;code&gt;-E&lt;/code&gt; lets you use an extended regular expression so you can match both filenames in one go.&lt;/li&gt;
  &lt;li&gt;
&lt;code&gt;access.log*&lt;/code&gt; will hit the current log and any rotated ones that are not gzipped. If you have &lt;code&gt;.gz&lt;/code&gt; files, you can run &lt;code&gt;zgrep&lt;/code&gt; similarly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After this you have a smaller file, &lt;code&gt;llms-requests.log&lt;/code&gt;, that only contains requests for your LLM-related files.&lt;/p&gt;

&lt;h2&gt;Grouping by user agent&lt;/h2&gt;

&lt;p&gt;The next step is to see who those requests belong to. For that you need the user agent field from each log line.&lt;/p&gt;

&lt;p&gt;This depends on the Nginx log format, but on a fairly standard combined log format the user agent is the last quoted string in the line. You can extract and group it with &lt;code&gt;awk&lt;/code&gt; and &lt;code&gt;sort&lt;/code&gt;:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;awk -F '"' '{print $6}' llms-requests.log \
  | sort \
  | uniq -c \
  | sort -nr &amp;gt; llms-user-agents.txt&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;What this does:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;
&lt;code&gt;-F '"'&lt;/code&gt; tells &lt;code&gt;awk&lt;/code&gt; to split on double quotes. In a combined log, the user agent ends up as field 6.&lt;/li&gt;
  &lt;li&gt;
&lt;code&gt;sort | uniq -c&lt;/code&gt; groups identical user agents and counts them.&lt;/li&gt;
  &lt;li&gt;The final &lt;code&gt;sort -nr&lt;/code&gt; orders them by frequency, highest first.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At this point you have a list of all user agents that have requested &lt;code&gt;llms.txt&lt;/code&gt; or &lt;code&gt;llms-full.txt&lt;/code&gt;, plus how often they did it.&lt;/p&gt;

&lt;h2&gt;Bucketing into GPTBot, ClaudeBot, Perplexity, Googlebot, other&lt;/h2&gt;

&lt;p&gt;Raw user agent strings are messy. If you want something legible, it helps to bucket them into a small set of known crawlers and an “everything else” bucket.&lt;/p&gt;

&lt;p&gt;For LLM-related traffic, the usual suspects are:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;
&lt;strong&gt;GPTBot&lt;/strong&gt; (OpenAI)&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;ClaudeBot&lt;/strong&gt; (Anthropic)&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;PerplexityBot&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;Googlebot&lt;/strong&gt; (useful for comparison, even though it is not an LLM crawler in the same sense)&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;Other&lt;/strong&gt; (browsers, random scrapers, SEO tools, misconfigured scripts)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can do a rough classification with another &lt;code&gt;awk&lt;/code&gt; pass that looks for substrings in the user agent string and assigns a label.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;awk -F '"' '{
  ua = $6
  bucket = "Other"

  if (ua ~ /GPTBot/i) {
    bucket = "GPTBot"
  } else if (ua ~ /ClaudeBot/i) {
    bucket = "ClaudeBot"
  } else if (ua ~ /PerplexityBot/i) {
    bucket = "PerplexityBot"
  } else if (ua ~ /Googlebot/i) {
    bucket = "Googlebot"
  }

  print bucket
}' llms-requests.log \
  | sort \
  | uniq -c \
  | sort -nr&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This does a few things:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Looks at each log line, extracts the user agent, and sets a default bucket of &lt;code&gt;Other&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;Checks for known bot identifiers with case-insensitive regexes.&lt;/li&gt;
  &lt;li&gt;Prints the bucket name for each request, then groups and counts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result is a small table of how many requests came from each crawler family. You can run the same script periodically to see if the mix changes over time.&lt;/p&gt;

&lt;h2&gt;What you might expect to see&lt;/h2&gt;

&lt;p&gt;Given that &lt;code&gt;llms.txt&lt;/code&gt; and &lt;code&gt;llms-full.txt&lt;/code&gt; have been live for months, it is reasonable to expect at least some of the known LLM crawlers to touch them occasionally if they are crawling the domain at all.&lt;/p&gt;

&lt;p&gt;That could look like:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Sporadic hits from GPTBot, ClaudeBot, or PerplexityBot if they respect and look for &lt;code&gt;llms.txt&lt;/code&gt;-style files.&lt;/li&gt;
  &lt;li&gt;Regular background noise from Googlebot and other search crawlers that might fetch anything that looks like a text file.&lt;/li&gt;
  &lt;li&gt;Manual requests from browsers or command-line tools when humans are testing or debugging.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the logs show zero requests from a specific crawler family over a long period, that suggests either the crawler is not visiting the domain at all or it does not care about these particular files. If you see requests from a bot with a name that sounds like a major provider, that still needs a bit of skepticism.&lt;/p&gt;

&lt;h2&gt;Why a request is not the same as being in the training set&lt;/h2&gt;

&lt;p&gt;It is tempting to look at a log entry from something called &lt;code&gt;GPTBot&lt;/code&gt; and immediately conclude that your content is now in some training corpus. That leap is not supported by the data.&lt;/p&gt;

&lt;p&gt;The logs only prove one thing: at a certain time, from a certain IP, a client that claimed a particular user agent fetched a particular URL and got some HTTP status code back. Everything beyond that is inference.&lt;/p&gt;

&lt;p&gt;Some specific limits:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;
&lt;strong&gt;Identity is self-reported.&lt;/strong&gt; User agents can be faked. Without reverse DNS and IP range checks, you do not know if a request really came from OpenAI, Anthropic, or anyone else.&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;Fetching is not storing.&lt;/strong&gt; A crawler can request &lt;code&gt;llms.txt&lt;/code&gt;, read the policy, and then decide not to crawl or not to retain content. The file might be used purely as a routing or filtering hint.&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;Storing is not training.&lt;/strong&gt; Even if content is cached, that does not prove it ends up in a training dataset. Providers often separate crawling, indexing, and training pipelines.&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;Training is not citation.&lt;/strong&gt; Even if something is in a training set, there is no guarantee it will ever be surfaced as a citation or explicit reference in generated output.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So “I see GPTBot in my logs” is a weaker claim than “GPTBot trained on my content,” and much weaker than “GPTBot will cite my content.” The first is observable. The others are policy and implementation details on the provider side.&lt;/p&gt;

&lt;h2&gt;What this does and does not say about GEO&lt;/h2&gt;

&lt;p&gt;There is a lot of talk about geographic opt-out files and LLM-specific robots instructions as a way to control how models use content. Files like &lt;code&gt;llms.txt&lt;/code&gt; are one piece of that conversation, but access logs do not tell you much about how GEO policies are actually enforced.&lt;/p&gt;

&lt;p&gt;From the logs you can reasonably say:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;“This crawler family appears to be aware of &lt;code&gt;llms.txt&lt;/code&gt; and fetches it.”&lt;/li&gt;
  &lt;li&gt;“This crawler seems to visit from these IP ranges and with this frequency.”&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;From the logs you cannot safely say:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;“This provider is fully complying with my GEO or usage instructions.”&lt;/li&gt;
  &lt;li&gt;“My content is excluded from all future training runs in all regions.”&lt;/li&gt;
  &lt;li&gt;“If I change &lt;code&gt;llms.txt&lt;/code&gt; today, the effect will propagate everywhere by a specific date.”&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At best, you can correlate: you publish or change &lt;code&gt;llms.txt&lt;/code&gt;, you see a certain bot fetch it, and you assume that whatever policy they have that mentions the file is now in play. The real enforcement logic and the timelines sit behind someone else’s API and release notes.&lt;/p&gt;

&lt;h2&gt;Why still bother with &lt;code&gt;llms.txt&lt;/code&gt; and logs&lt;/h2&gt;

&lt;p&gt;Even with all those caveats, &lt;code&gt;llms.txt&lt;/code&gt; and &lt;code&gt;llms-full.txt&lt;/code&gt; are still useful knobs to have:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;They give you a clear, machine-readable place to state your preferences.&lt;/li&gt;
  &lt;li&gt;Access logs let you verify at least that some crawlers are paying attention.&lt;/li&gt;
  &lt;li&gt;When policies or providers change, you have a concrete file to update instead of guessing which meta tags or headers matter this month.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The flip side is that they are not magic. If you care about how your content is used in LLMs, logs are just the first, low-tech feedback loop. They tell you who knocked on the door. They do not tell you what happened to the data after it left your server.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Why I finally deleted NotePlan</title>
      <dc:creator>Richard Lemon</dc:creator>
      <pubDate>Thu, 27 Aug 2026 23:42:15 +0000</pubDate>
      <link>https://dev.to/richardlemon/why-i-finally-deleted-noteplan-2jnf</link>
      <guid>https://dev.to/richardlemon/why-i-finally-deleted-noteplan-2jnf</guid>
      <description>&lt;h2&gt;The feature list looked perfect. The app did not.&lt;/h2&gt;

&lt;p&gt;On paper, NotePlan inside Setapp can look like a great home for tasks. Daily notes. Calendar integration. Linked references. A whole personal system in one place.&lt;/p&gt;

&lt;p&gt;In practice, it never stuck for me. I would install it, poke around, half-commit to moving things in, then slide back to Todoist and other tools.&lt;/p&gt;

&lt;p&gt;The problem was not the feature list. It was the feeling of using it.&lt;/p&gt;

&lt;h2&gt;NotePlan vs Todoist: the difference you actually feel&lt;/h2&gt;

&lt;p&gt;Todoist has one trick that keeps pulling me back: natural language input that feels like talking to the app instead of configuring it.&lt;/p&gt;

&lt;p&gt;Typing something like:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;Call Sam tomorrow at 3pm #work p1&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;and watching it turn into a properly dated, tagged, prioritised task is hard to beat. The interface mostly gets out of the way. You think about the task, not the tool.&lt;/p&gt;

&lt;p&gt;NotePlan, by comparison, always felt like work before the work. The interface is visually dense: panes, calendars, notes, metadata. The power sits in front of you instead of behind you. Every time I opened it, I had a small moment of friction: &lt;em&gt;where does this thing go&lt;/em&gt; and &lt;em&gt;what is this screen trying to show me&lt;/em&gt;?&lt;/p&gt;

&lt;p&gt;That friction is subtle. It is not a crash, a bug, or a missing feature. It is a half-second of hesitation every time you capture or review. Multiply that by a few dozen interactions a day and you end up with a tool that looks great in theory and quietly loses in practice.&lt;/p&gt;

&lt;h2&gt;Visual friction is adoption friction&lt;/h2&gt;

&lt;p&gt;Visual friction is what happens when the interface makes your brain do admin before it can do the actual task.&lt;/p&gt;

&lt;p&gt;With NotePlan, the combination of calendar, notes, and tasks in one view sounds efficient, but it also asks for constant micro-decisions:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Is this a note, a task, or an event?&lt;/li&gt;
  &lt;li&gt;Should it live in a daily note or a project note?&lt;/li&gt;
  &lt;li&gt;Am I looking at the right pane for the thing I care about?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Todoist, in contrast, is visually boring in a good way. It is a list with some filters. The natural language input means the main interaction is: type what you mean, hit enter, move on. The visual layer confirms what you did instead of asking more questions.&lt;/p&gt;

&lt;p&gt;That difference is one reason a “richer” app like NotePlan can lose to a “simpler” one like Todoist. The richer app has more states to understand. The simpler one lets you stay slightly under-loaded mentally, which is exactly where you want to be when you are trying to actually do things.&lt;/p&gt;

&lt;h2&gt;The moment I finally deleted NotePlan&lt;/h2&gt;

&lt;p&gt;There was no dramatic break-up. I was using Todoist every day, and NotePlan had become an ambitious side system that never quite got fully adopted.&lt;/p&gt;

&lt;p&gt;At some point you have to admit that if you keep re-opening the same app every few months and it never sticks, the problem is not that you have not found the right configuration. The problem is that the tool does not match how your brain wants to see work.&lt;/p&gt;

&lt;p&gt;So I removed NotePlan from the rotation and stopped pretending it was “almost” my all-in-one solution. The result was not a loss of features. It was a gain in clarity.&lt;/p&gt;

&lt;h2&gt;The 4-app stack that replaced the all-in-one dream&lt;/h2&gt;

&lt;p&gt;Instead of forcing everything into one environment, I ended up with a small stack where each app has exactly one job. That constraint turned out to be more useful than any integrated feature set.&lt;/p&gt;

&lt;p&gt;The current daily drivers look like this:&lt;/p&gt;

&lt;h3&gt;1. Todoist: tasks only&lt;/h3&gt;

&lt;p&gt;Todoist is where every actionable item lives. If it has a verb and a clear “done,” it goes here. Nothing else.&lt;/p&gt;

&lt;p&gt;No notes, no long-form thinking, no project briefs. Just tasks, with natural language input doing most of the heavy lifting. This keeps the interface clean and the mental model simple: open Todoist when you want to decide what to do next or capture something you must not forget.&lt;/p&gt;

&lt;h3&gt;2. Calendar: time, not tasks&lt;/h3&gt;

&lt;p&gt;The calendar is for events and time blocks. That is it.&lt;/p&gt;

&lt;p&gt;Meetings, training sessions, and any block of time that is actually reserved goes here. Tasks do not live on the calendar by default. If something from Todoist needs protected time, I block it in the calendar, but the task itself still belongs to Todoist.&lt;/p&gt;

&lt;p&gt;This separation avoids the usual mess where tasks and events fight for the same space and you cannot tell what is optional and what is fixed.&lt;/p&gt;

&lt;h3&gt;3. Notes app: thinking, not tracking&lt;/h3&gt;

&lt;p&gt;A notes app handles all the text that does not have a clear “do this” attached.&lt;/p&gt;

&lt;p&gt;Project outlines, ideas, meeting notes, and reference material live here. Tasks that emerge from notes get promoted into Todoist, but the note itself stays as context, not as a to-do list.&lt;/p&gt;

&lt;p&gt;The key is resisting the temptation to turn the notes app into another task manager. That is how you end up with ghost tasks scattered through documents that never get reviewed.&lt;/p&gt;

&lt;h3&gt;4. Whiteboard or sketch tool: structure, not storage&lt;/h3&gt;

&lt;p&gt;For planning and untangling bigger pieces of work, a whiteboard or sketch tool fills the gap between messy thinking and structured tasks.&lt;/p&gt;

&lt;p&gt;This is where you map flows, group ideas, and see relationships. Once the structure makes sense, the actual actions go into Todoist. The board is allowed to stay messy. It is not a database.&lt;/p&gt;

&lt;h2&gt;Why “one job each” beats “one app for everything”&lt;/h2&gt;

&lt;p&gt;Feature lists push you toward consolidation. If an app can technically do tasks, notes, calendar, and planning, it feels rational to put everything there. In reality, this often creates a second problem: you now have to remember &lt;em&gt;how&lt;/em&gt; you decided to use that flexibility.&lt;/p&gt;

&lt;p&gt;Giving each tool exactly one job does a few useful things:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;You always know where something belongs.&lt;/li&gt;
  &lt;li&gt;You reduce context switching inside each app, because you are not jumping between modes.&lt;/li&gt;
  &lt;li&gt;You are free to swap out one tool without rethinking your entire system.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;NotePlan tried to be the place for everything. The 4-app stack is a set of single-purpose rooms. That small architectural change removed a lot of silent friction.&lt;/p&gt;

&lt;h2&gt;Visual appeal and intuition beat feature checklists&lt;/h2&gt;

&lt;p&gt;None of this shows up in a comparison table. Natural language input vs markdown syntax. Calendar integration vs external calendar. Daily notes vs separate notes app. On a spreadsheet, NotePlan can look like the smarter choice.&lt;/p&gt;

&lt;p&gt;The problem is that you do not live in a spreadsheet. You live in the interface.&lt;/p&gt;

&lt;p&gt;If an app feels visually heavy, if it asks micro-questions before it lets you act, if you keep bouncing off it even though you &lt;em&gt;like&lt;/em&gt; the idea of it, that is data. Ignoring that and chasing features is how you end up maintaining a system instead of doing the work the system was supposed to support.&lt;/p&gt;

&lt;p&gt;Deleting NotePlan was basically admitting that intuition and visual comfort matter more than theoretical power. Once the tool stopped fighting my habits, everything else got simpler.&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>tools</category>
    </item>
    <item>
      <title>Gamifying Cardiac Rehab with Habitica</title>
      <dc:creator>Richard Lemon</dc:creator>
      <pubDate>Thu, 27 Aug 2026 23:41:55 +0000</pubDate>
      <link>https://dev.to/richardlemon/gamifying-cardiac-rehab-with-habitica-5dad</link>
      <guid>https://dev.to/richardlemon/gamifying-cardiac-rehab-with-habitica-5dad</guid>
      <description>&lt;h2&gt;Why I turned recovery into a game&lt;/h2&gt;

&lt;p&gt;Cardiac rehab is a long, repetitive grind. The problem is that it feels like a grind. The exercises matter, the sleep and nutrition matter, but the feedback loop is slow and fuzzy.&lt;/p&gt;

&lt;p&gt;Habitica gives me an immediate feedback loop. I turn recovery into an RPG: quests, parties, XP, streaks. The medical side still lives with doctors and devices. The day-to-day compliance lives in a game.&lt;/p&gt;

&lt;h2&gt;The Habitica party: making recovery a team sport&lt;/h2&gt;

&lt;p&gt;Habitica is built around parties: small groups of players who share quests and damage. A screenshot of my party setup would show a few key things:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Everyone has their own class, gear, and cosmetics, so it looks like a proper RPG group.&lt;/li&gt;
  &lt;li&gt;We share quests that trigger when people complete specific tasks.&lt;/li&gt;
  &lt;li&gt;Missed Dailies deal damage to the whole party, not just you.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last part is the hook. If I skip a rehab task, it is not just my avatar taking a hit. The whole party pays for it. That bit of social pressure does more than any abstract “you should really exercise” notification.&lt;/p&gt;

&lt;h2&gt;Dailies vs Habits: wiring rehab into the game&lt;/h2&gt;

&lt;p&gt;Habitica has two core building blocks for behavior:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;
&lt;strong&gt;Dailies&lt;/strong&gt; are recurring tasks that are either done or not done on a given day.&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;Habits&lt;/strong&gt; are behaviors you can do multiple times, or avoid, and they can give positive or negative rewards.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For recovery, I use Dailies for the non-negotiables. If a cardiologist would frown when you skip it, it is probably a Daily. Things like specific rehab sessions, medications, and sleep windows fit here. They show up every day (or on selected days), and if I do not check them off, my character and my party take damage at midnight.&lt;/p&gt;

&lt;p&gt;Habits are better for sliders than switches. They track intensity, quality, and “more vs less” decisions. A Habit might reward extra walking, stretching after sitting too long, or choosing a sensible meal. Negative Habits can punish the things I am trying to avoid.&lt;/p&gt;

&lt;p&gt;The distinction matters because it changes how the task feels. A missed Daily is a clean failure. A missed Habit is just a missed opportunity. Rehab needs both: hard commitments and soft nudges.&lt;/p&gt;

&lt;h2&gt;The 6:40 circuit checklist as a Daily&lt;/h2&gt;

&lt;p&gt;The backbone of my setup is a specific rehab block: a 6:40 circuit. Habitica turns it into a simple Daily with a checklist. The Daily itself is “Complete 6:40 circuit” and inside it lives the step-by-step sequence.&lt;/p&gt;

&lt;p&gt;The checklist looks like:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;0:40 warmup&lt;/li&gt;
  &lt;li&gt;1:00 cardio block&lt;/li&gt;
  &lt;li&gt;1:00 strength block&lt;/li&gt;
  &lt;li&gt;1:00 cardio block&lt;/li&gt;
  &lt;li&gt;1:00 strength block&lt;/li&gt;
  &lt;li&gt;1:00 cooldown / breathing&lt;/li&gt;
  &lt;li&gt;0:20 notes / log&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Daily makes sure the circuit happens. The checklist turns one vague “do rehab” task into a series of small wins. Every tick gives a little hit of progress before the final completion reward.&lt;/p&gt;

&lt;h2&gt;Using WHOOP recovery as a daily XP multiplier&lt;/h2&gt;

&lt;p&gt;WHOOP gives a daily recovery score. It is a compact summary of how ready your body supposedly is for strain: sleep, HRV, resting heart rate, and so on rolled into one number.&lt;/p&gt;

&lt;p&gt;In my setup, that recovery score becomes an XP multiplier for the day. The logic is simple:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;High WHOOP recovery = high multiplier. Rehab work that day should be a little heavier and more rewarding in-game.&lt;/li&gt;
  &lt;li&gt;Low WHOOP recovery = low multiplier. Focus on the minimum viable Dailies and do not chase streaks with hero workouts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Practically, I look at the WHOOP score in the morning and mentally tag the day as “green,” “yellow,” or “red.” Then I adjust how aggressively I chase Habitica rewards.&lt;/p&gt;

&lt;p&gt;On a green day, I try to clear all the Dailies, complete the full 6:40 circuit, and hit a few bonus Habits. It feels like a double-XP weekend: the game and the body are aligned.&lt;/p&gt;

&lt;p&gt;On a red day, the win condition is narrower. The goal is to protect streaks on the core Dailies, keep the party from taking damage, and then stop. The game does not get to overrule the recovery score.&lt;/p&gt;

&lt;h2&gt;The Todoist shutdown ritual as a quest&lt;/h2&gt;

&lt;p&gt;Recovery is not just workouts and heart rate graphs. A big part of it is not carrying work stress into the evening. For that, I use a shutdown ritual in Todoist and mirror it in Habitica as a quest.&lt;/p&gt;

&lt;p&gt;The Todoist side holds the actual work tasks: review the inbox, clear or reschedule remaining tasks, capture loose ideas, and pick the top priorities for tomorrow. It is a straightforward checklist that closes the workday.&lt;/p&gt;

&lt;p&gt;In Habitica, this becomes a quest-like Daily. The checklist looks like:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Review today’s completed tasks in Todoist&lt;/li&gt;
  &lt;li&gt;Reschedule or drop any remaining tasks&lt;/li&gt;
  &lt;li&gt;Write down tomorrow’s 1–3 priorities&lt;/li&gt;
  &lt;li&gt;Close work apps and mute notifications&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Completing it earns XP and gold, like a battle. Skipping it means walking into the evening with open loops, which is exactly what I do not want when recovery is the priority.&lt;/p&gt;

</description>
      <category>motivation</category>
      <category>productivity</category>
    </item>
    <item>
      <title>A CSS Hover-Reveal Pattern for Technical Specs</title>
      <dc:creator>Richard Lemon</dc:creator>
      <pubDate>Fri, 21 Aug 2026 06:46:13 +0000</pubDate>
      <link>https://dev.to/richardlemon/a-css-hover-reveal-pattern-for-technical-specs-413h</link>
      <guid>https://dev.to/richardlemon/a-css-hover-reveal-pattern-for-technical-specs-413h</guid>
      <description>&lt;h2&gt;The problem on the Gate Seal page&lt;/h2&gt;

&lt;p&gt;The Gate Seal product page for a maritime client needed to present detailed specifications without turning the layout into a wall of text or a table that looked like an export from Excel. The technical detail buyers cared about was present, but visually buried.&lt;/p&gt;

&lt;p&gt;The requirement was to surface those details in a compact way, keep the implementation CSS-only, and make sure it still worked with keyboard navigation.&lt;/p&gt;

&lt;h2&gt;The hover-reveal pattern&lt;/h2&gt;

&lt;p&gt;The pattern below uses a hover-reveal on key specification rows. On desktop, moving the cursor over a spec row reveals additional context. With a keyboard, focusing the same row does the same thing. No JavaScript is required for the basic interaction.&lt;/p&gt;

&lt;p&gt;Structurally, each spec item is a container with two layers of content:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Always-visible summary (label and primary value)&lt;/li&gt;
  &lt;li&gt;Hidden detail that appears on hover or focus&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here is a simplified version of the markup:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;&amp;lt;div class="spec-list"&amp;gt;
  &amp;lt;button class="spec-item"&amp;gt;
    &amp;lt;div class="spec-main"&amp;gt;
      &amp;lt;span class="spec-label"&amp;gt;Gate size&amp;lt;/span&amp;gt;
      &amp;lt;span class="spec-value"&amp;gt;Up to 6 m&amp;lt;/span&amp;gt;
    &amp;lt;/div&amp;gt;
    &amp;lt;div class="spec-detail"&amp;gt;
      Custom diameters available for retrofit situations.
    &amp;lt;/div&amp;gt;
  &amp;lt;/button&amp;gt;

  &amp;lt;button class="spec-item"&amp;gt;
    &amp;lt;div class="spec-main"&amp;gt;
      &amp;lt;span class="spec-label"&amp;gt;Seal material&amp;lt;/span&amp;gt;
      &amp;lt;span class="spec-value"&amp;gt;EPDM / NBR&amp;lt;/span&amp;gt;
    &amp;lt;/div&amp;gt;
    &amp;lt;div class="spec-detail"&amp;gt;
      Oil-resistant compounds for lock gates in heavy traffic.&amp;lt;/div&amp;gt;
  &amp;lt;/button&amp;gt;
&amp;lt;/div&amp;gt;&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The choice of &lt;code&gt;&amp;lt;button&amp;gt;&lt;/code&gt; here is deliberate: it is naturally focusable, works with keyboard navigation, and is announced as an interactive element by assistive technology. In a production implementation, the button semantics can be adapted depending on whether you need a true button or a different element with &lt;code&gt;role="button"&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;The CSS-only interaction&lt;/h3&gt;

&lt;p&gt;The interaction is controlled through &lt;code&gt;:hover&lt;/code&gt; and &lt;code&gt;:focus-visible&lt;/code&gt;, with a basic transition for a smoother reveal.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;.spec-list {
  display: grid;
  gap: 0.75rem;
}

.spec-item {
  width: 100%;
  text-align: left;
  border: 1px solid #d0d5dd;
  border-radius: 4px;
  padding: 0.75rem 1rem;
  background: #ffffff;
  cursor: pointer;
  position: relative;
}

.spec-item:focus-visible {
  outline: 2px solid #004b8d;
  outline-offset: 2px;
}

.spec-main {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 1rem;
}

.spec-label {
  font-weight: 500;
}

.spec-value {
  color: #344054;
}

.spec-detail {
  margin-top: 0.4rem;
  font-size: 0.875rem;
  color: #475467;

  max-height: 0;
  opacity: 0;
  overflow: hidden;
  transition: max-height 0.18s ease-out, opacity 0.18s ease-out;
}

.spec-item:hover .spec-detail,
.spec-item:focus-visible .spec-detail {
  max-height: 6rem; /* large enough for typical copy */
  opacity: 1;
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The detail block is always in the DOM and available to assistive technology, but visually collapsed until the user shows intent to inspect that spec with the mouse or the keyboard.&lt;/p&gt;

&lt;h2&gt;Keyboard and accessibility fallback&lt;/h2&gt;

&lt;p&gt;A pure hover interaction would be a problem for keyboard users and some assistive technologies. Mirroring the behavior on &lt;code&gt;:focus-visible&lt;/code&gt; avoids that.&lt;/p&gt;

&lt;p&gt;With the structure above:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Each &lt;code&gt;.spec-item&lt;/code&gt; is focusable and reachable via Tab.&lt;/li&gt;
  &lt;li&gt;The same detail that appears on hover also appears on focus-visible.&lt;/li&gt;
  &lt;li&gt;The content is not injected; it is in the DOM and readable by screen readers regardless of the visual state.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If stricter control of what screen readers announce is required, &lt;code&gt;aria-expanded&lt;/code&gt; attributes can be added and toggled with a small amount of JavaScript, but the base pattern does not depend on that. The important part is that there is no information that is only available on hover.&lt;/p&gt;

&lt;p&gt;On touch devices, there is no hover, so the interaction degrades to a tap-to-focus behavior. The spec item receives focus when tapped, the detail appears, and tapping elsewhere moves focus away again. If that feels too subtle on mobile, details can be kept always visible below a certain breakpoint:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;@media (max-width: 768px) {
  .spec-detail {
    max-height: none;
    opacity: 1;
  }
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This keeps specs compact and interactive on larger screens where hover exists, and straightforward on phones.&lt;/p&gt;

&lt;h2&gt;Using this pattern on the Gate Seal page&lt;/h2&gt;

&lt;p&gt;On the Gate Seal page, this pattern can be applied to key specifications such as material options, mounting configurations, and maintenance notes. Core numbers stay visible, and the hover-reveal carries clarifications and edge-case notes.&lt;/p&gt;

&lt;p&gt;The Gate Seal product page exists to support real purchasing decisions, so the interaction should be reserved for supplementary context rather than critical safety, pricing, or legal information. Those details should remain visible by default.&lt;/p&gt;

</description>
      <category>a11y</category>
      <category>css</category>
      <category>frontend</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The Schema.org Markup Every Client Site Should Ship With</title>
      <dc:creator>Richard Lemon</dc:creator>
      <pubDate>Sun, 16 Aug 2026 12:13:46 +0000</pubDate>
      <link>https://dev.to/richardlemon/the-schemaorg-markup-every-client-site-should-ship-with-4fl3</link>
      <guid>https://dev.to/richardlemon/the-schemaorg-markup-every-client-site-should-ship-with-4fl3</guid>
      <description>&lt;h2&gt;My default structured data bundle&lt;/h2&gt;

&lt;p&gt;I got tired of treating Schema.org like a research project.&lt;/p&gt;

&lt;p&gt;You know the drill. New client. New niche. Too many schema types. Then you add nothing because it feels endless.&lt;/p&gt;

&lt;p&gt;So I stopped doing that. I built a small default bundle instead. A set of JSON-LD blocks I ship on almost every marketing or SaaS site, plus a couple of variants for blogs and local businesses.&lt;/p&gt;

&lt;p&gt;This is the technical walkthrough of that bundle. No theory. Just the markup I actually use, and where it lives.&lt;/p&gt;

&lt;h2&gt;Core rules I follow&lt;/h2&gt;

&lt;p&gt;Before the snippets, a few constraints I stick to.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;
&lt;strong&gt;JSON-LD only.&lt;/strong&gt; No microdata, no RDFa. It lives in &lt;code&gt;&amp;lt;script type="application/ld+json"&amp;gt;&lt;/code&gt; blocks.&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;One primary entity per page.&lt;/strong&gt; Google can handle more, but I keep it simple unless I really need composites.&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;Generated, not hand-written.&lt;/strong&gt; I wire this into the build step or CMS fields. No manual copy paste for every page.&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;Data must exist visually.&lt;/strong&gt; If the user cannot see it, I do not put it in schema. That keeps me out of spam territory.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;Where I inject JSON-LD&lt;/h2&gt;

&lt;p&gt;I put JSON-LD in the HTML head wherever possible.&lt;/p&gt;

&lt;p&gt;React / Next.js: I attach it to a &lt;code&gt;&amp;lt;Head&amp;gt;&lt;/code&gt; component. Astro / Svelte / plain HTML: I write it straight into &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If I need dynamic data, I render the JSON on the server, then drop it in as stringified content.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;&amp;lt;script type="application/ld+json"&amp;gt;
{ ...jsonHere }
&amp;lt;/script&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;No CDNs. No external scripts. Search engines want the JSON inline.&lt;/p&gt;

&lt;h2&gt;1. Site-wide: WebSite + SearchAction&lt;/h2&gt;

&lt;p&gt;Every multi-page site I ship gets a base &lt;code&gt;WebSite&lt;/code&gt; schema on the homepage. It is tiny, and Google uses it for sitelinks search boxes.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;{
  "@context": "https://schema.org",
  "@type": "WebSite",
  "name": "Acme Analytics",
  "url": "https://acmeanalytics.com/",
  "potentialAction": {
    "@type": "SearchAction",
    "target": "https://acmeanalytics.com/search?q={search_term_string}",
    "query-input": "required name=search_term_string"
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;A few rules I stick to:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;
&lt;strong&gt;Only on the root URL.&lt;/strong&gt; I keep this script on &lt;code&gt;/&lt;/code&gt;, not on every page.&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;Search endpoint must exist.&lt;/strong&gt; If there is no search, I drop &lt;code&gt;potentialAction&lt;/code&gt;. No fake endpoints.&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;Exact canonical URL.&lt;/strong&gt; I match the &lt;code&gt;url&lt;/code&gt; value to the canonical link tag, every time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Implementation detail. I have a small &lt;code&gt;buildWebsiteSchema(config)&lt;/code&gt; helper where I feed &lt;code&gt;siteName&lt;/code&gt;, &lt;code&gt;url&lt;/code&gt;, and optional &lt;code&gt;searchUrl&lt;/code&gt;. The page component just calls it and stringifies the JSON.&lt;/p&gt;

&lt;h2&gt;2. The main entity: Organization or LocalBusiness&lt;/h2&gt;

&lt;p&gt;Almost every client site represents a company of some sort. I start with a generic &lt;code&gt;Organization&lt;/code&gt;. For brick-and-mortar clients I switch to a more specific &lt;code&gt;LocalBusiness&lt;/code&gt; subtype.&lt;/p&gt;

&lt;h3&gt;Global company: Organization schema&lt;/h3&gt;

&lt;p&gt;This is my baseline for SaaS, agencies without public shops, and any online-only service.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;{
  "@context": "https://schema.org",
  "@type": "Organization",
  "name": "Acme Analytics",
  "url": "https://acmeanalytics.com/",
  "logo": "https://acmeanalytics.com/assets/logo.svg",
  "sameAs": [
    "https://twitter.com/acmeanalytics",
    "https://www.linkedin.com/company/acme-analytics/"
  ],
  "contactPoint": [
    {
      "@type": "ContactPoint",
      "telephone": "+31-20-123-4567",
      "contactType": "sales",
      "areaServed": "NL",
      "availableLanguage": ["en", "nl"]
    }
  ]
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Some details I do not skip:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;
&lt;strong&gt;Logo URL.&lt;/strong&gt; I use the same logo as in the header, absolute URL, and make sure it is crawlable.&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;sameAs.&lt;/strong&gt; Only real social profiles, nothing else. I do not put random directories in there.&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;contactPoint.&lt;/strong&gt; If we show a phone number on the site, I reflect it here. Otherwise I remove the block.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This usually lives on the homepage next to the &lt;code&gt;WebSite&lt;/code&gt; schema. Two separate scripts.&lt;/p&gt;

&lt;h3&gt;Local business: LocalBusiness schema&lt;/h3&gt;

&lt;p&gt;For gyms, clinics, restaurants, barber shops, I use the matching &lt;code&gt;LocalBusiness&lt;/code&gt; subtype instead: &lt;code&gt;Restaurant&lt;/code&gt;, &lt;code&gt;Physiotherapy&lt;/code&gt;, &lt;code&gt;HealthClub&lt;/code&gt;, and so on.&lt;/p&gt;

&lt;p&gt;Example for a gym:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;{
  "@context": "https://schema.org",
  "@type": "HealthClub",
  "name": "Lemon Performance Lab",
  "image": "https://lemonperformance.nl/og-image.jpg",
  "@id": "https://lemonperformance.nl/#business",
  "url": "https://lemonperformance.nl/",
  "telephone": "+31-6-1234-5678",
  "address": {
    "@type": "PostalAddress",
    "streetAddress": "Brouwersgracht 100",
    "addressLocality": "Amsterdam",
    "postalCode": "1013 GP",
    "addressCountry": "NL"
  },
  "geo": {
    "@type": "GeoCoordinates",
    "latitude": 52.381,
    "longitude": 4.887
  },
  "openingHoursSpecification": [
    {
      "@type": "OpeningHoursSpecification",
      "dayOfWeek": ["Monday", "Wednesday", "Friday"],
      "opens": "07:00",
      "closes": "18:00"
    }
  ],
  "sameAs": [
    "https://www.instagram.com/lemonperformance/"
  ]
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Things that matter here:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;I add an &lt;code&gt;@id&lt;/code&gt; with a hash fragment. It gives the entity a stable identifier I can reference later if needed.&lt;/li&gt;
  &lt;li&gt;I do not invent coordinates. I pull them from Google Maps or the client.&lt;/li&gt;
  &lt;li&gt;
&lt;code&gt;openingHoursSpecification&lt;/code&gt; must match the actual hours displayed on the page.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On local sites this block is non-negotiable. Rankings and knowledge panel consistency improve a lot once it is in place.&lt;/p&gt;

&lt;h2&gt;3. Every page: WebPage schema&lt;/h2&gt;

&lt;p&gt;Most devs skip &lt;code&gt;WebPage&lt;/code&gt;. I like it because it gives me a predictable way to describe the actual page entity, tie it to the main organization, and reuse metadata I already have.&lt;/p&gt;

&lt;p&gt;I generate this on &lt;em&gt;every&lt;/em&gt; indexable page with a simple helper.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;{
  "@context": "https://schema.org",
  "@type": "WebPage",
  "name": "Customer data without the headaches",
  "url": "https://acmeanalytics.com/customer-data-platform",
  "description": "Acme Analytics gives you a privacy-first CDP your team can actually maintain.",
  "inLanguage": "en",
  "isPartOf": {
    "@type": "WebSite",
    "url": "https://acmeanalytics.com/"
  },
  "about": {
    "@id": "https://acmeanalytics.com/#organization"
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;A couple of notes:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;
&lt;strong&gt;name&lt;/strong&gt; is almost always the page title.&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;description&lt;/strong&gt; mirrors the meta description or a trimmed hero copy.&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;about&lt;/strong&gt; points to the &lt;code&gt;@id&lt;/code&gt; of the organization on the homepage.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On static site generators this fits nicely into a layout template. I already have &lt;code&gt;title&lt;/code&gt;, &lt;code&gt;description&lt;/code&gt;, &lt;code&gt;url&lt;/code&gt; variables, so I just feed them into &lt;code&gt;buildWebPageSchema()&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;4. Blog posts: Article schema&lt;/h2&gt;

&lt;p&gt;Any site that has a blog or resources section gets &lt;code&gt;Article&lt;/code&gt; markup on each post. I do not go crazy with subtypes unless I have a good reason. &lt;code&gt;BlogPosting&lt;/code&gt; is enough.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;{
  "@context": "https://schema.org",
  "@type": "BlogPosting",
  "headline": "How I shipped our analytics migration in 3 weeks",
  "description": "The exact process I used to move 12 properties from UA to GA4 without losing our minds.",
  "image": [
    "https://acmeanalytics.com/blog/ga4-migration/cover.jpg"
  ],
  "author": {
    "@type": "Person",
    "name": "Richard Lemon",
    "url": "https://richardlemon.com/"
  },
  "publisher": {
    "@type": "Organization",
    "name": "Acme Analytics",
    "logo": {
      "@type": "ImageObject",
      "url": "https://acmeanalytics.com/assets/logo-512.png"
    }
  },
  "datePublished": "2024-03-18T09:00:00+01:00",
  "dateModified": "2024-03-20T10:30:00+01:00",
  "mainEntityOfPage": {
    "@type": "WebPage",
    "@id": "https://acmeanalytics.com/blog/ga4-migration"
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This is where text data from the CMS pays off. I map fields directly:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Title field to &lt;code&gt;headline&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;Excerpt field to &lt;code&gt;description&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;Featured image to &lt;code&gt;image&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;Author model to &lt;code&gt;author&lt;/code&gt; object.&lt;/li&gt;
  &lt;li&gt;Published / updated timestamps to &lt;code&gt;datePublished&lt;/code&gt; and &lt;code&gt;dateModified&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the blog supports multiple authors, I let &lt;code&gt;author&lt;/code&gt; be an array. Same structure, just wrapped.&lt;/p&gt;

&lt;h2&gt;5. Product or pricing pages: Product + Offer&lt;/h2&gt;

&lt;p&gt;I am careful with &lt;code&gt;Product&lt;/code&gt; schema. I only use it if there is an actual product with pricing and a way to buy. That could be an ecommerce product, a SaaS plan, or a course.&lt;/p&gt;

&lt;p&gt;Here is a simple SaaS plan example on a pricing page.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;{
  "@context": "https://schema.org",
  "@type": "Product",
  "name": "Acme Analytics Pro",
  "description": "Event-based analytics for product teams that need real-time dashboards.",
  "image": "https://acmeanalytics.com/assets/pro-plan.png",
  "brand": {
    "@type": "Organization",
    "name": "Acme Analytics"
  },
  "offers": {
    "@type": "Offer",
    "url": "https://acmeanalytics.com/pricing",
    "priceCurrency": "EUR",
    "price": "79",
    "priceValidUntil": "2025-12-31",
    "availability": "https://schema.org/InStock"
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Key constraints I follow:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;
&lt;strong&gt;Exact price.&lt;/strong&gt; It has to match the visible price on the page. If we show “starting at 79”, I use that number.&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;No fake discounts.&lt;/strong&gt; If the site does not show a discount, I do not use &lt;code&gt;priceSpecification&lt;/code&gt; with &lt;code&gt;price&lt;/code&gt; vs &lt;code&gt;priceBeforeDiscount&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;Limit the scope.&lt;/strong&gt; I only put this schema on the pricing page or the dedicated product detail page, not globally.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For shops with reviews, I also wire in &lt;code&gt;aggregateRating&lt;/code&gt; and &lt;code&gt;review&lt;/code&gt; only when the rating count and values are rendered in the UI.&lt;/p&gt;

&lt;h2&gt;6. BreadcrumbList for content depth&lt;/h2&gt;

&lt;p&gt;If the site has a clear content hierarchy, I add &lt;code&gt;BreadcrumbList&lt;/code&gt;. This can produce breadcrumb rich results, but I mostly like it because it encodes structure explicitly.&lt;/p&gt;

&lt;p&gt;Example for a blog post nested one level deep.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;{
  "@context": "https://schema.org",
  "@type": "BreadcrumbList",
  "itemListElement": [
    {
      "@type": "ListItem",
      "position": 1,
      "name": "Blog",
      "item": "https://acmeanalytics.com/blog"
    },
    {
      "@type": "ListItem",
      "position": 2,
      "name": "GA4 Migration",
      "item": "https://acmeanalytics.com/blog/ga4-migration"
    }
  ]
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;I let the router or CMS build this automatically from the URL structure. If there is no real breadcrumb UI, I skip this. I like my structured data to mirror the layout.&lt;/p&gt;

&lt;h2&gt;7. How I actually wire this up&lt;/h2&gt;

&lt;p&gt;All of this is useless if it lives in a Notion doc. The power comes from making it boring, repeatable, and hard to break.&lt;/p&gt;

&lt;p&gt;This is roughly how I integrate it on client projects.&lt;/p&gt;

&lt;h3&gt;Step 1: Central schema helpers&lt;/h3&gt;

&lt;p&gt;I keep a &lt;code&gt;schema/&lt;/code&gt; or &lt;code&gt;seo/&lt;/code&gt; folder with tiny pure functions that return POJOs for each type.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;// schema/website.ts
export function buildWebsiteSchema({ name, url, searchUrl }) {
  const base: any = {
    "@context": "https://schema.org",
    "@type": "WebSite",
    name,
    url
  };

  if (searchUrl) {
    base.potentialAction = {
      "@type": "SearchAction",
      target: `${searchUrl}?q={search_term_string}`,
      "query-input": "required name=search_term_string"
    };
  }

  return base;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Each helper hides the annoying details. Pages just pass real data.&lt;/p&gt;

&lt;h3&gt;Step 2: Shared head component&lt;/h3&gt;

&lt;p&gt;I do not scatter &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tags everywhere. Instead I use a shared SEO or head component that accepts structured data as an array.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;function JsonLd({ data }) {
  return (
    &amp;lt;script
      type="application/ld+json"
      dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
    /&amp;gt;
  );
}

function SeoHead({ title, description, schemas = [] }) {
  return (
    &amp;lt;Head&amp;gt;
      &amp;lt;title&amp;gt;{title}&amp;lt;/title&amp;gt;
      &amp;lt;meta name="description" content={description} /&amp;gt;
      {schemas.map((schema, i) =&amp;gt; (
        &amp;lt;JsonLd key={i} data={schema} /&amp;gt;
      ))}
    &amp;lt;/Head&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Now any page can plug in the relevant schema variants without repeating the script wrapper logic.&lt;/p&gt;

&lt;h3&gt;Step 3: Validate in CI at least once&lt;/h3&gt;

&lt;p&gt;I do two passes.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;During development I use the Schema.org or Google Rich Results test against my local or a staging URL.&lt;/li&gt;
  &lt;li&gt;Before launch I run a quick automated check that hits a couple of canonical pages and asserts the &lt;code&gt;&amp;lt;script type="application/ld+json"&amp;gt;&lt;/code&gt; blocks exist.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I am not chasing zero warnings. I mostly want to avoid accidentally shipping broken JSON or wildly inconsistent data.&lt;/p&gt;

&lt;h2&gt;The minimal bundle I recommend&lt;/h2&gt;

&lt;p&gt;If you build sites for clients and want a &lt;em&gt;minimum&lt;/em&gt; structured data set that covers most use cases, I would ship this on every project:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;
&lt;strong&gt;Homepage:&lt;/strong&gt; &lt;code&gt;WebSite&lt;/code&gt; + &lt;code&gt;Organization&lt;/code&gt; or &lt;code&gt;LocalBusiness&lt;/code&gt; + &lt;code&gt;WebPage&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;Standard pages:&lt;/strong&gt; &lt;code&gt;WebPage&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;Blog index:&lt;/strong&gt; &lt;code&gt;WebPage&lt;/code&gt; (optionally &lt;code&gt;CollectionPage&lt;/code&gt; if you want, but I do not bother).&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;Blog posts:&lt;/strong&gt; &lt;code&gt;WebPage&lt;/code&gt; + &lt;code&gt;BlogPosting&lt;/code&gt; + &lt;code&gt;BreadcrumbList&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;Pricing / product pages:&lt;/strong&gt; &lt;code&gt;WebPage&lt;/code&gt; + &lt;code&gt;Product&lt;/code&gt; (with &lt;code&gt;Offer&lt;/code&gt;) when there is a real product.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This keeps the implementation small enough that you can wire it up properly, automate it, and forget about it. Which is the point. Structured data should be boring infra, not a one-off SEO stunt.&lt;/p&gt;

&lt;p&gt;Ship it once, wire it into your layouts, and your future client projects get it for free.&lt;/p&gt;

</description>
      <category>html</category>
      <category>seo</category>
      <category>software</category>
      <category>webdev</category>
    </item>
    <item>
      <title>CSS Container Queries In Production: Where They Shine And Where Media Queries Still Win</title>
      <dc:creator>Richard Lemon</dc:creator>
      <pubDate>Sun, 16 Aug 2026 12:12:27 +0000</pubDate>
      <link>https://dev.to/richardlemon/css-container-queries-in-production-where-they-shine-and-where-media-queries-still-win-448d</link>
      <guid>https://dev.to/richardlemon/css-container-queries-in-production-where-they-shine-and-where-media-queries-still-win-448d</guid>
      <description>&lt;h2&gt;Refactoring a real project to container queries&lt;/h2&gt;

&lt;p&gt;
I stopped reading hot takes about container queries and actually refactored a real client project.
A mid-sized marketing site with a component library, a CMS, and the usual pile of legacy CSS.
&lt;/p&gt;

&lt;p&gt;
The goal was simple.
Use container queries wherever component-level decisions made more sense than viewport-level ones.
Keep media queries where they still pull their weight.
No heroic rewrites.
No greenfield fantasies.
&lt;/p&gt;

&lt;p&gt;
This is not a spec tour.
This is what I actually changed, what hurt, what felt great, and why I did not burn my media queries to the ground.
&lt;/p&gt;

&lt;h2&gt;The old setup: media-query soup&lt;/h2&gt;

&lt;p&gt;
The project started life the way a lot of responsive sites did.
Global breakpoints like this:
&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;:root {
  --bp-xs: 480px;
  --bp-sm: 640px;
  --bp-md: 768px;
  --bp-lg: 1024px;
  --bp-xl: 1280px;
}

@media (min-width: 768px) { /* ... */ }
@media (min-width: 1024px) { /* ... */ }
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;
Most components were basically:
"If viewport is at least &lt;code&gt;md&lt;/code&gt;, do layout X, otherwise stack Y".
Classic.
&lt;/p&gt;

&lt;p&gt;
The problem showed up when the same component lived in completely different layouts.
Hero section inside a full-width page.
Same hero in a sidebar-heavy layout.
Same hero inside a CMS block inside a card.
&lt;/p&gt;

&lt;p&gt;
Viewport-based breakpoints started to feel wrong.
The component did not care about the viewport.
It cared about its box.
&lt;/p&gt;

&lt;h2&gt;Before touching CSS: I had to fix the HTML&lt;/h2&gt;

&lt;p&gt;
Container queries need containers.
Obvious, but it changes how you structure your HTML.
&lt;/p&gt;

&lt;p&gt;
I did a quick audit.
The rule I used was:
&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;If a component needs to respond to its own width, it gets a container.&lt;/li&gt;
  &lt;li&gt;If a part inside a component needs to respond to the component, the parent becomes the container.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;
That turned into a few standard patterns.
&lt;/p&gt;

&lt;h3&gt;Pattern 1: Layout shells as containers&lt;/h3&gt;

&lt;p&gt;
Grid sections, sidebars, cards.
Anything that wrapped other components and controlled width.
Those became container roots.
&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;&amp;lt;section class="page-section" data-layout="two-column"&amp;gt;
  &amp;lt;div class="page-section__content"&amp;gt;
    &amp;lt;article class="feature-card"&amp;gt;...&amp;lt;/article&amp;gt;
    &amp;lt;article class="feature-card"&amp;gt;...&amp;lt;/article&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/section&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;
And the CSS:
&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;.page-section__content {
  display: grid;
  grid-template-columns: 1fr;
  gap: 1.5rem;
  container-type: inline-size;
  container-name: page-section;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;
Now the children could ask: "How wide is my section wrapper?" instead of "How wide is the viewport?".
&lt;/p&gt;

&lt;h3&gt;Pattern 2: Cards and blocks as containers&lt;/h3&gt;

&lt;p&gt;
Any reusable block in the design system got its own container.
Things like &lt;code&gt;.feature-card&lt;/code&gt;, &lt;code&gt;.stat-block&lt;/code&gt;, &lt;code&gt;.media-object&lt;/code&gt;.
&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;.feature-card {
  container-type: inline-size;
  container-name: feature-card;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;
I did not add containers everywhere.
Only where I had previously written a media query for "small card layout" vs "large card layout".
&lt;/p&gt;

&lt;h2&gt;The first win: components stopped arguing with the viewport&lt;/h2&gt;

&lt;p&gt;
The hero card was the first thing I switched.
Previously it looked like this:
&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;.hero-card {
  display: grid;
  gap: 1.5rem;
}

@media (min-width: 768px) {
  .hero-card {
    grid-template-columns: 3fr 2fr;
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;
The problem.
When the hero card lived in a skinny sidebar at 1440px viewport width, it still tried to go two-column.
It just looked broken.
&lt;/p&gt;

&lt;p&gt;
With container queries, the hero only cares about its own box.
&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;.hero-card {
  display: grid;
  gap: 1.5rem;
  container-type: inline-size;
  container-name: hero-card;
}

@container hero-card (min-width: 560px) {
  .hero-card {
    grid-template-columns: 3fr 2fr;
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;
Now the hero goes two-column only if the card itself is at least 560px wide.
Not if the viewport happens to be big.
That felt like how it should always have worked.
&lt;/p&gt;

&lt;h2&gt;Where container queries really shine&lt;/h2&gt;

&lt;p&gt;
After a week of refactoring, a few patterns stood out as massive improvements.
&lt;/p&gt;

&lt;h3&gt;1. Components reused in unpredictable layouts&lt;/h3&gt;

&lt;p&gt;
The design system had "content blocks" that editors could drop anywhere in the CMS.
You can imagine the chaos.
&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Inside full-width sections&lt;/li&gt;
  &lt;li&gt;Inside narrow sidebars&lt;/li&gt;
  &lt;li&gt;Inside cards that lived in carousels&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;
In the old version I cheated.
I limited where editors could place certain components.
I basically told the CMS what the layout was allowed to be, just to protect my media queries.
&lt;/p&gt;

&lt;p&gt;
With container queries that restriction went away.
Each block simply responded to the space it actually received.
&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;.content-block {
  container-type: inline-size;
  container-name: content-block;
}

@container content-block (min-width: 700px) {
  .content-block--image-right {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 2rem;
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;
The same content block behaved nicely in a 900px region and inside a 420px sidebar.
No new breakpoint.
No layout-specific modifier.
Just local logic.
&lt;/p&gt;

&lt;h3&gt;2. Nested complexity without breakpoint explosions&lt;/h3&gt;

&lt;p&gt;
One section had a nasty nesting problem.
Three cards across on desktop.
Cards had stats, icons, buttons, and sometimes a badge.
Each card also changed layout internally at &lt;code&gt;md&lt;/code&gt;.
&lt;/p&gt;

&lt;p&gt;
With media queries I had two sets of breakpoints.
One set for the section grid.
One set for the cards.
They needed to stay in sync.
You know how this ends.
&lt;/p&gt;

&lt;p&gt;
Container queries let the section and cards negotiate separately.
&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;.stats-section {
  display: grid;
  gap: 1.5rem;
  container-type: inline-size;
  container-name: stats-section;
}

@container stats-section (min-width: 900px) {
  .stats-section {
    grid-template-columns: repeat(3, minmax(0, 1fr));
  }
}

.stat-card {
  container-type: inline-size;
  container-name: stat-card;
}

@container stat-card (min-width: 340px) {
  .stat-card__body {
    display: grid;
    grid-template-columns: auto 1fr;
    gap: 1rem;
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;
The section manages its columns.
The card manages its internal layout.
No cross-dependency.
No mental math about "what does 340px inside a 900px grid mean at 1024px viewport".
&lt;/p&gt;

&lt;h3&gt;3. Dark corners of the design system became predictable&lt;/h3&gt;

&lt;p&gt;
Every design system has a few awkward components.
For me it was a "media object" pattern that designers used everywhere.
Image + content + actions.
Sometimes horizontal, sometimes vertical, sometimes tiny.
&lt;/p&gt;

&lt;p&gt;
Previously I had a mess of modifiers:
&lt;code&gt;.media--compact&lt;/code&gt;, &lt;code&gt;.media--horizontal-lg-only&lt;/code&gt;, all wired to global breakpoints.
Maintaining them sucked.
&lt;/p&gt;

&lt;p&gt;
Switching to container queries let me cut that down to "respond when too small or big".
&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;.media-object {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
  container-type: inline-size;
  container-name: media-object;
}

@container media-object (min-width: 520px) {
  .media-object {
    flex-direction: row;
    align-items: flex-start;
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;
Editors could now throw this pattern wherever they wanted.
I did not need to ship ten variants.
That felt like an actual productivity gain, not a theoretical nice-to-have.
&lt;/p&gt;

&lt;h2&gt;Where I intentionally kept media queries&lt;/h2&gt;

&lt;p&gt;
I am not treating container queries like a new religion.
Viewport media queries still win in a few places and I kept them.
&lt;/p&gt;

&lt;h3&gt;1. Global layout shifts&lt;/h3&gt;

&lt;p&gt;
Some layout changes are tied to the viewport, not a container.
Header navigation is a good example.
&lt;/p&gt;

&lt;p&gt;
The site went from mobile nav to horizontal nav at around 900px viewport width.
This is not a per-component decision.
This is a global "how do we use the top 80px of the screen" decision.
&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;@media (min-width: 900px) {
  .site-header {
    grid-template-columns: auto 1fr auto;
  }

  .nav-toggle {
    display: none;
  }

  .primary-nav {
    display: flex;
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;
I could have wrapped the whole page in a container and used container queries there.
I did not bother.
Viewport width is the right source of truth for this.
&lt;/p&gt;

&lt;h3&gt;2. Typography scale and rhythm&lt;/h3&gt;

&lt;p&gt;
Global type scale based on viewport still makes sense to me.
Things like base font size, vertical rhythm, and heading scales.
&lt;/p&gt;

&lt;p&gt;
Container queries inside components are great for "if my card is cramped, reduce the heading size a bit".
But the general "phone vs desktop reading experience" feels like a viewport choice.
&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;html {
  font-size: 15px;
}

@media (min-width: 768px) {
  html {
    font-size: 16px;
  }
}

@media (min-width: 1200px) {
  html {
    font-size: 17px;
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;
I experimented with wrapping the main content area in a container and tying typography to that.
It felt fussy and added complexity without real benefit.
Viewport queries stayed.
&lt;/p&gt;

&lt;h3&gt;3. "Hard" layout breakpoints in marketing pages&lt;/h3&gt;

&lt;p&gt;
There were a few full-bleed marketing sections that changed layout aggressively.
Big storytelling panels.
&lt;/p&gt;

&lt;p&gt;
Those were designed around fixed viewport breakpoints in Figma.
Trying to force them into container logic gave me weird edge cases.
Things broke in exactly the places the designer cared about.
&lt;/p&gt;

&lt;p&gt;
So I did not fight it.
I kept simple viewport media queries:
&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;@media (min-width: 1024px) {
  .story-section {
    grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;
Sometimes the boring answer is the right one.
&lt;/p&gt;

&lt;h2&gt;What actually changed in my workflow&lt;/h2&gt;

&lt;p&gt;
Once the refactor settled, a few habits changed for good.
&lt;/p&gt;

&lt;h3&gt;1. I think in "component width" instead of "screen size"&lt;/h3&gt;

&lt;p&gt;
My first question used to be:
"What happens at 768px and 1024px?".
Now it is:
"At what width does this component start to look stupid?".
&lt;/p&gt;

&lt;p&gt;
That leads to smaller, more honest breakpoints.
&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;@container card (min-width: 420px) { ... }
@container card (min-width: 640px) { ... }
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;
I stopped pretending all components share the same breakpoints.
They do not.
And that is fine.
&lt;/p&gt;

&lt;h3&gt;2. My CSS files are more local and less global&lt;/h3&gt;

&lt;p&gt;
The old codebase had big global breakpoint sections.
All components shared them.
That sounds organized.
In practice it meant every change risked side effects.
&lt;/p&gt;

&lt;p&gt;
With container queries, most responsive logic lives next to the component styles.
&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;.feature-card { ... }

@container feature-card (min-width: 480px) { ... }
@container feature-card (min-width: 720px) { ... }
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;
I like this a lot better.
When I open a component file I see all of its behaviour, including responsiveness, in one place.
&lt;/p&gt;

&lt;h3&gt;3. Fewer "support grid X only" rules in the CMS&lt;/h3&gt;

&lt;p&gt;
The CMS previously had hidden layout rules.
We told editors:
"You cannot put block Y inside layout X because it breaks at tablet."
&lt;/p&gt;

&lt;p&gt;
That sort of rule is a smell.
It tells you the CSS is too coupled to a specific layout.
Container queries finally gave me a way to fix that instead of just documenting around it.
&lt;/p&gt;

&lt;p&gt;
After the refactor, we removed several "you may not combine these" notes.
Editors have more freedom.
I get fewer layout bug tickets.
That is an easy win.
&lt;/p&gt;

&lt;h2&gt;Gotchas that actually hurt&lt;/h2&gt;

&lt;p&gt;
It was not all magic.
A few things were annoying enough that I would warn future-me before doing this again.
&lt;/p&gt;

&lt;h3&gt;1. You must be deliberate about container boundaries&lt;/h3&gt;

&lt;p&gt;
Early on I just sprinkled &lt;code&gt;container-type: inline-size;&lt;/code&gt; everywhere.
That was a mistake.
&lt;/p&gt;

&lt;p&gt;
Too many nested containers make it hard to reason about which container is actually being queried.
&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;.card {
  container-type: inline-size;
  container-name: card;
}

.card__content {
  container-type: inline-size;
  container-name: card-content;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;
Then inside CSS I wrote &lt;code&gt;@container (min-width: 500px)&lt;/code&gt; without a name.
Suddenly some rules were responding to &lt;code&gt;card&lt;/code&gt;, others to &lt;code&gt;card__content&lt;/code&gt;.
&lt;/p&gt;

&lt;p&gt;
I fixed it by:
&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Using named containers for anything complex.&lt;/li&gt;
  &lt;li&gt;Limiting containers to a few well-known layers: page, section, component.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;2. DevTools muscle memory is wrong&lt;/h3&gt;

&lt;p&gt;
Checking responsive behaviour with container queries feels different.
You no longer just drag the viewport.
You need to also think about how the container width changes when the layout changes.
&lt;/p&gt;

&lt;p&gt;
Modern DevTools help, but they are not perfect yet.
Chrome and Firefox both have container query overlays, which helps a lot.
But I still occasionally misdiagnose a bug as "container query not firing" when it is actually "different container is active".
&lt;/p&gt;

&lt;h3&gt;3. Performance paranoia&lt;/h3&gt;

&lt;p&gt;
Specs and browser teams have done a good job making container queries efficient.
But old instincts kick in when you sprinkle dozens of them into a complex page.
&lt;/p&gt;

&lt;p&gt;
I stress tested a few templates.
So far, normal marketing-site scale has been fine.
But I would not blindly apply container queries to every tiny utility component in a mega-dashboard without measuring.
&lt;/p&gt;

&lt;h2&gt;How I would approach the next project&lt;/h2&gt;

&lt;p&gt;
If I started a fresh project tomorrow, I would not go "container queries only".
I would set some rules upfront.
&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Use viewport media queries for global layout, navigation, and base typography.&lt;/li&gt;
  &lt;li&gt;Use container queries for reusable components and CMS blocks that live in variable layouts.&lt;/li&gt;
  &lt;li&gt;Define a small set of standard container layers: &lt;code&gt;page-shell&lt;/code&gt;, &lt;code&gt;section&lt;/code&gt;, &lt;code&gt;component&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;Keep container names explicit in CSS, avoid unnamed queries except for trivial cases.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;
The main mental shift is this.
&lt;/p&gt;

&lt;p&gt;
Media queries answer: "What does the world look like?".
Container queries answer: "What does my box look like?".
&lt;/p&gt;

&lt;p&gt;
You need both.
Trying to force everything into one or the other is dogma, not engineering.
&lt;/p&gt;

&lt;h2&gt;So, was the refactor worth it?&lt;/h2&gt;

&lt;p&gt;
For this project, yes.
Strong yes.
&lt;/p&gt;

&lt;p&gt;
The biggest win was not fewer lines of CSS.
It was fewer layout-specific hacks in the CMS and fewer weird "component behaves badly in this one layout" bugs.
&lt;/p&gt;

&lt;p&gt;
Container queries made the system more honest.
Components now respond to the thing they actually depend on: their own size.
Media queries still run the global show.
&lt;/p&gt;

&lt;p&gt;
If you have a design system that shows up in many layouts, or a CMS where editors can shuffle blocks freely, then I think you are leaving real value on the table by not using container queries.
&lt;/p&gt;

&lt;p&gt;
If your app is mostly a couple of fixed layouts with hand-tuned breakpoints, then viewport media queries are still perfectly fine.
I would not refactor just for the buzzword.
&lt;/p&gt;

&lt;p&gt;
Use container queries where the component cares about its own box.
Use media queries where the design cares about the screen.
Once you draw that line, the rest becomes straightforward.
&lt;/p&gt;

</description>
      <category>css</category>
      <category>frontend</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Why Littlebird Sits At The Center Of My AI Stack</title>
      <dc:creator>Richard Lemon</dc:creator>
      <pubDate>Sun, 16 Aug 2026 11:53:10 +0000</pubDate>
      <link>https://dev.to/richardlemon/why-littlebird-sits-at-the-center-of-my-ai-stack-3naa</link>
      <guid>https://dev.to/richardlemon/why-littlebird-sits-at-the-center-of-my-ai-stack-3naa</guid>
      <description>&lt;h2&gt;The AI subscriptions I cancelled&lt;/h2&gt;

&lt;p&gt;I used to pay for several AI tools every month. Over time that turned into more overlap than value, so I shut most of them down and kept one thing at the center of the stack: Littlebird.&lt;/p&gt;

&lt;p&gt;Here is what I cancelled:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;ChatGPT Pro&lt;/li&gt;
  &lt;li&gt;Claude Pro&lt;/li&gt;
  &lt;li&gt;Perplexity Pro&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is three separate subscriptions gone. The monthly savings are the combined cost of those plans that I am no longer paying. Instead of spreading budget across multiple assistants, I pay for Littlebird and use it to orchestrate the rest of the stack.&lt;/p&gt;

&lt;h2&gt;Why Littlebird is the one I still pay for&lt;/h2&gt;

&lt;p&gt;Littlebird is not just another AI assistant alongside the usual suspects. It is the layer that sits in front of them and coordinates how they are used.&lt;/p&gt;

&lt;p&gt;Instead of logging into different sites, copying prompts around, and trying to remember where a specific conversation lives, Littlebird becomes the front door. I talk to it, and it decides which underlying model or tool to call, with what context, and how to feed the results back into my ongoing work.&lt;/p&gt;

&lt;p&gt;The result is that I do not need separate paid accounts for every model to feel like I have a strong stack. I need one orchestrator that can talk to them intelligently.&lt;/p&gt;

&lt;h2&gt;The Mac Studio as a sovereign AI hub&lt;/h2&gt;

&lt;p&gt;All of this is organized around a Mac Studio that acts as a kind of sovereign AI hub. Instead of treating AI as a collection of cloud websites I visit, I treat the Mac Studio as the place where my data, agents, and workflows live.&lt;/p&gt;

&lt;p&gt;That hub does a few important things:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;
&lt;strong&gt;Central storage:&lt;/strong&gt; files, notes, code, and local tools live on a machine I control.&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;Local context:&lt;/strong&gt; when an AI tool needs context, it is pulled from the hub, not from random uploads scattered across different services.&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;Single integration point:&lt;/strong&gt; automations, scripts, and agents plug into the Mac Studio instead of every service trying to talk to every other service.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Littlebird sits on top of that. It treats the hub as the source of truth, and it treats external models as stateless workers that come and go. The stability lives on the Mac Studio; the interchangeable part is which model is used for a specific task.&lt;/p&gt;

&lt;h2&gt;How the context loop actually works&lt;/h2&gt;

&lt;p&gt;The useful part is the loop between Littlebird, the Mac Studio hub, and the external AI tools.&lt;/p&gt;

&lt;p&gt;At a high level, the loop looks like this:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;
&lt;strong&gt;Start in Littlebird:&lt;/strong&gt; I give Littlebird a task in natural language: a coding question, a research prompt, or something related to a project on the Mac Studio.&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;Attach context from the hub:&lt;/strong&gt; Littlebird can pull in relevant context from the Mac Studio: local files, previous notes, project structure, or past conversations.&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;Route to the right model:&lt;/strong&gt; Based on the task, Littlebird calls out to one of the external AI tools or models. The external tool does not see everything; it just gets the slice of context Littlebird decides to share.&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;Pull results back into the hub:&lt;/strong&gt; When the external model returns an answer, Littlebird writes the useful parts back into the Mac Studio environment: updated files, new notes, or structured data.&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;Keep the conversation going:&lt;/strong&gt; The next time I ask a related question, Littlebird can see the updated state on the hub and continue from there without me manually pasting anything.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The key is that the context is not owned by any single vendor. It lives on the Mac Studio. Littlebird is the thing that keeps that context in sync with whatever external model it is using at the moment.&lt;/p&gt;

&lt;h2&gt;From many tabs to one orchestrated stack&lt;/h2&gt;

&lt;p&gt;Using multiple AI tools usually means multiple browser tabs and separate histories. A coding thread might live in one place, a research thread in another, and none of them know about the files on my machine unless I upload them manually each time.&lt;/p&gt;

&lt;p&gt;With Littlebird in front and the Mac Studio underneath, the stack becomes one surface instead of three or four:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;I talk to Littlebird, not to individual vendors.&lt;/li&gt;
  &lt;li&gt;The Mac Studio holds the long-term memory.&lt;/li&gt;
  &lt;li&gt;External AI tools are workers that come and go.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is why paying for Littlebird makes more sense than keeping separate subscriptions alive. The value is in the coordination and the context loop, not in having another slightly different chat UI.&lt;/p&gt;

&lt;h2&gt;Why the premium plan matters&lt;/h2&gt;

&lt;p&gt;Littlebird has an upcoming premium plan. That matters less as yet another subscription and more as a way to keep the orchestrator sharp while the underlying models keep changing.&lt;/p&gt;

&lt;p&gt;The external tools will continue to compete on speed, quality, and features. Some will be better for code, some for research, some for structured data. I expect that mix to keep shifting. The premium plan is a way for Littlebird to keep adding better routing, deeper integrations with the Mac Studio hub, and smarter context handling without requiring a full rebuild of the stack every few months.&lt;/p&gt;

&lt;p&gt;The important part is that the premium plan sits at the coordination layer, not the model layer. The point is not to pay for one more model. The point is to pay for a better conductor for the models I already have access to.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>tools</category>
    </item>
    <item>
      <title>ABM landing pages for maritime cybersecurity</title>
      <dc:creator>Richard Lemon</dc:creator>
      <pubDate>Sun, 16 Aug 2026 11:52:41 +0000</pubDate>
      <link>https://dev.to/richardlemon/abm-landing-pages-for-maritime-cybersecurity-3a3e</link>
      <guid>https://dev.to/richardlemon/abm-landing-pages-for-maritime-cybersecurity-3a3e</guid>
      <description>&lt;h2&gt;Industrial B2B meets product-page UX&lt;/h2&gt;

&lt;p&gt;Account-based marketing can look tidy in a deck and messy in a browser. The tension is sharp when you mix an industrial niche like maritime cybersecurity with expectations shaped by modern product pages.&lt;/p&gt;

&lt;p&gt;The Blackhole Networks case sits in that gap. On one side are complex, high-stakes problems around ships, ports, OT networks, and regulatory pressure. On the other are buyers who expect clarity, proof, and a clear next step.&lt;/p&gt;

&lt;p&gt;A useful way to frame the work is a simple question: what needs to change between a generic homepage and an ABM landing page for a specific maritime account?&lt;/p&gt;

&lt;h2&gt;Why a generic homepage is the wrong tool&lt;/h2&gt;

&lt;p&gt;A generic homepage has to do too many jobs at once. It has to explain the company, route different audiences, and show some credibility. That usually leads to safe, abstract copy and a lot of navigation.&lt;/p&gt;

&lt;p&gt;For a visitor from a named maritime account, that is almost the opposite of what you want. They already know roughly who you are. They arrive from targeted outreach or a campaign built for their segment. Sending them to the homepage is like inviting someone to a meeting and then handing them your company brochure.&lt;/p&gt;

&lt;p&gt;Three things tend to be missing when you treat ABM traffic like generic traffic:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;There is no focused way to capture the lead.&lt;/li&gt;
  &lt;li&gt;There is not enough visible social proof.&lt;/li&gt;
  &lt;li&gt;The copy does not speak in the language of the sector.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Blackhole Networks case is a way to look at those gaps in a maritime context.&lt;/p&gt;

&lt;h2&gt;1. Lead capture: from “contact us” to a specific next step&lt;/h2&gt;

&lt;p&gt;Many industrial cybersecurity sites rely on a “Contact” link in the header and a form in the footer. That is acceptable for general inbound, but weak for ABM. If there has already been investment in targeting and outreach, the page should behave more like a product page: clear offer, clear action.&lt;/p&gt;

&lt;p&gt;On the ABM side, that usually means a dedicated landing page with:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;A single primary call to action, not several competing buttons.&lt;/li&gt;
  &lt;li&gt;A form that matches the value on offer, instead of a generic “get in touch”.&lt;/li&gt;
  &lt;li&gt;Copy around the form that reminds the visitor why this is worth their time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For maritime cybersecurity, the question is not “contact sales” but “what is a low-friction, high-relevance step for a port IT lead or fleet security officer?” The exact answer will differ per company, but the UX pattern is consistent: treat the ABM page like a focused product page, not a mini homepage.&lt;/p&gt;

&lt;h2&gt;2. Social proof that speaks to risk, not just logos&lt;/h2&gt;

&lt;p&gt;In industrial B2B, it is common to show a strip of client logos and call that social proof. It is better than nothing, but it does not help a maritime buyer connect an offer to their specific risk profile.&lt;/p&gt;

&lt;p&gt;On an ABM landing page, social proof can be much more specific:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Short, concrete statements about outcomes relevant to maritime operations.&lt;/li&gt;
  &lt;li&gt;References to similar environments: ports, vessels, OT networks, or regulatory regimes.&lt;/li&gt;
  &lt;li&gt;Comments or summaries of feedback from roles that match the target account’s team.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pattern comes from product pages: you do not just show that people use the product, you show that people like the target account use the product for a clear reason. In a maritime cybersecurity context, that might mean emphasizing uptime, incident response, or audit readiness rather than generic “security posture” language.&lt;/p&gt;

&lt;h2&gt;3. Sector-specific copy instead of generic cybersecurity language&lt;/h2&gt;

&lt;p&gt;Generic homepages tend to talk about “organizations” and “businesses” and “digital transformation”. That is safe, but it does not sound like a port or a shipping company. It sounds like a template.&lt;/p&gt;

&lt;p&gt;ABM landing pages have the room to be more direct:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Use the domain language of the sector: vessels, terminals, OT, shoreside networks, fleet operations.&lt;/li&gt;
  &lt;li&gt;Refer to the kinds of incidents and constraints that matter in that world.&lt;/li&gt;
  &lt;li&gt;Frame benefits in terms of day-to-day reality: delays, downtime, inspections, and crew impact.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is where a product-page mindset helps. A good product page does not describe a category. It describes a specific use case in specific terms. Applied to maritime cybersecurity, that means writing as if you are already inside their environment, not pitching from the sidewalk.&lt;/p&gt;

&lt;h2&gt;Mixing industrial B2B with product-page UX&lt;/h2&gt;

&lt;p&gt;Industrial buyers use consumer apps and SaaS tools all day. They are used to clarity and low-friction flows. The useful move is to borrow the right patterns without pretending maritime cybersecurity is a simple subscription.&lt;/p&gt;

&lt;p&gt;Some of the product-page patterns that translate well:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;
&lt;strong&gt;Hero section with a sharp promise&lt;/strong&gt;: One line that connects capability to a maritime-specific outcome, not a long mission statement.&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;Problem / solution layout&lt;/strong&gt;: Short blocks that describe concrete operational problems and how the offer addresses them.&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;Minimal navigation&lt;/strong&gt;: Enough to orient, not enough to distract from the main call to action.&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;Scannable structure&lt;/strong&gt;: Clear headings, short paragraphs, and visual hierarchy that respect short attention windows.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What does not translate is a casual tone. Maritime cybersecurity is attached to physical risk and regulatory pressure. The page can be clear and modern without being flippant.&lt;/p&gt;

&lt;h2&gt;From homepage to ABM: a phased rollout&lt;/h2&gt;

&lt;p&gt;Shifting from a single generic homepage to a set of ABM landing pages is usually easier in phases than as a big relaunch, and the Blackhole Networks work followed that logic.&lt;/p&gt;

&lt;p&gt;The first phase is structural: define the core layout, decide how lead capture will work, and identify the places where social proof and sector-specific copy need to live. This is the point where product-page patterns are selected and adapted.&lt;/p&gt;

&lt;p&gt;The second phase is about content. For maritime cybersecurity, that means gathering the right language from sales conversations, incident reports, and existing clients, then shaping it into copy that can live on a page. The same structure can support other sectors later, but the first version should be unapologetically specific.&lt;/p&gt;

&lt;p&gt;The third phase is account-specific refinement. Once the base maritime page exists, it can be cloned and adapted for individual named accounts: adjusting examples, emphasizing certain outcomes, or aligning with their internal terminology. The UX and structure stay stable while the message tightens around each target.&lt;/p&gt;

&lt;p&gt;This phased rollout keeps the risk manageable. A single maritime ABM page can sit alongside the existing homepage, a small number of accounts can be routed to it, and the results can inform the next iteration.&lt;/p&gt;

&lt;h2&gt;What changes when you take ABM seriously&lt;/h2&gt;

&lt;p&gt;The interesting part of mixing industrial B2B with product-page UX is not the visual layer. It is the shift in what the page is for.&lt;/p&gt;

&lt;p&gt;A generic homepage tries to tell the whole story. An ABM landing page for maritime cybersecurity has a narrower job: help one type of buyer at one type of company decide whether to take one specific next step.&lt;/p&gt;

&lt;p&gt;Once you design for that, the missing elements become obvious: you need lead capture that fits the offer, social proof that speaks to their risk, and copy that sounds like their world. The rest is implementation detail.&lt;/p&gt;

</description>
      <category>cybersecurity</category>
      <category>marketing</category>
      <category>product</category>
      <category>ux</category>
    </item>
    <item>
      <title>Letting AI refactor one legacy stylesheet</title>
      <dc:creator>Richard Lemon</dc:creator>
      <pubDate>Thu, 13 Aug 2026 13:06:26 +0000</pubDate>
      <link>https://dev.to/richardlemon/letting-ai-refactor-one-legacy-stylesheet-oig</link>
      <guid>https://dev.to/richardlemon/letting-ai-refactor-one-legacy-stylesheet-oig</guid>
      <description>&lt;h2&gt;The patient: one legacy Sass file&lt;/h2&gt;

&lt;p&gt;The target was a legacy client CSS file: about 1,200 lines of hand-written Sass from before Tailwind was standard at Ideebv. It defined 47 component classes, 12 utility classes that duplicated Tailwind defaults, and a section of commented-out gradients from a 2021 rebrand.&lt;/p&gt;

&lt;p&gt;I fed the whole thing to Claude with a single prompt:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“Refactor this to Tailwind v3 utility classes where possible, preserve the custom properties, and flag anything that looks dead.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;What came back was a neatly formatted diff that looked reasonable at first glance. The real work started after that, going line by line and deciding what to accept, what to change, and what to reject.&lt;/p&gt;

&lt;p&gt;The audit took 45 minutes: about 20 minutes to get a usable diff, and 25 minutes to verify the context and consequences. The file ended up cleaner. The interesting part is how that happened.&lt;/p&gt;

&lt;h2&gt;What I accepted: mechanical spacing refactors&lt;/h2&gt;

&lt;p&gt;The safest chunk was spacing. The original Sass was full of declarations like:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;.card {
  margin-top: 24px;
  padding: 0 16px;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Claude systematically mapped these to Tailwind utilities:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;
&lt;code&gt;margin-top: 24px;&lt;/code&gt; became &lt;code&gt;mt-6&lt;/code&gt;
&lt;/li&gt;
  &lt;li&gt;
&lt;code&gt;padding: 0 16px;&lt;/code&gt; became &lt;code&gt;px-4&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It also correctly identified that the client’s spacing scale was already on a 4&amp;nbsp;px base, so 4&amp;nbsp;px increments lined up with Tailwind’s default spacing scale. In total it caught 89 instances that were exact, lossless translations from raw values to Tailwind classes.&lt;/p&gt;

&lt;p&gt;I accepted these without modification. They were pure syntax transformations: no layout changes, no semantic changes, no new assumptions about the design system. Just shorter, more consistent code.&lt;/p&gt;

&lt;h2&gt;What I changed: color mappings without project context&lt;/h2&gt;

&lt;p&gt;Colors were where “looks right” and “is right” diverged.&lt;/p&gt;

&lt;p&gt;The original Sass used custom properties like:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;.btn-primary {
  color: var(--brand-primary);
  background-color: var(--brand-primary);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Claude suggested converting these to Tailwind’s default color utilities:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;
&lt;code&gt;color: var(--brand-primary);&lt;/code&gt; → &lt;code&gt;text-blue-600&lt;/code&gt;
&lt;/li&gt;
  &lt;li&gt;
&lt;code&gt;background-color: var(--brand-primary);&lt;/code&gt; → &lt;code&gt;bg-blue-600&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pattern was sane: replace custom color declarations with semantic Tailwind classes. The problem was the data behind it. This project has a custom &lt;code&gt;tailwind.config.js&lt;/code&gt; that maps a &lt;code&gt;brand&lt;/code&gt; color token to a specific hex value that is not Tailwind’s default blue.&lt;/p&gt;

&lt;p&gt;The AI had no access to that config. It guessed. The guess was wrong.&lt;/p&gt;

&lt;p&gt;I kept the structural idea but changed the actual classes to:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;
&lt;code&gt;text-brand&lt;/code&gt; instead of &lt;code&gt;text-blue-600&lt;/code&gt;
&lt;/li&gt;
  &lt;li&gt;
&lt;code&gt;bg-brand&lt;/code&gt; instead of &lt;code&gt;bg-blue-600&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the refactor went in two steps:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;Let AI propose a mapping pattern from custom properties to Tailwind utilities.&lt;/li&gt;
  &lt;li&gt;Replace the guessed tokens with the project’s real design tokens.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;On paper that looks like a small edit. In practice this is the line between “AI refactored my CSS” and “AI suggested a template, and I refactored my CSS using it.” The syntax was automated. The semantics still came from the project’s existing design system.&lt;/p&gt;

&lt;h2&gt;What I rejected: grid architecture changes&lt;/h2&gt;

&lt;p&gt;One component used a CSS Grid setup with &lt;code&gt;subgrid&lt;/code&gt; for alignment. The idea was that nested cards should align their internal rows across columns, so when content varied in length, the visual grid still lined up. The key line was:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;grid-template-rows: subgrid;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Tailwind does not yet support &lt;code&gt;subgrid&lt;/code&gt; natively, and Claude did not recognise why it was there. It proposed replacing the whole grid with a simpler layout:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;
&lt;code&gt;grid-cols-4&lt;/code&gt; for the outer grid&lt;/li&gt;
  &lt;li&gt;A flex-based workaround for the inner layout, with fixed heights to “stabilise” rows&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On real content, that would have broken as soon as one card had longer text than the others. The entire point of the original &lt;code&gt;subgrid&lt;/code&gt; setup was to avoid exactly that failure mode.&lt;/p&gt;

&lt;p&gt;I rejected the suggestion and kept the original CSS grid declaration. I also added a comment to make the constraint explicit:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;/* Using subgrid to align card rows across columns.
   Tailwind does not support this yet; do not replace with grid-cols-* or flex.
*/
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This was the first place where the cost of verification showed up clearly. The AI could rewrite the layout syntax, but it could not see the layout behaviour or the design intent. It saw an opportunity to use Tailwind utilities; it did not see the trade-off it was making.&lt;/p&gt;

&lt;h2&gt;What I rejected: “unnecessary” pseudo-elements&lt;/h2&gt;

&lt;p&gt;The file also contained &lt;code&gt;::before&lt;/code&gt; pseudo-elements used for decorative-looking borders. Claude flagged them as “likely unnecessary visual noise” and suggested removing them to simplify the CSS.&lt;/p&gt;

&lt;p&gt;What was not in the file was the reason they existed. Those pseudo-elements were part of a specific accessibility requirement for the client’s WCAG 2.2 compliance audit. They provided a visible focus indicator for keyboard navigation. No pseudo-element, no visible focus ring. No visible focus ring, no compliance.&lt;/p&gt;

&lt;p&gt;The AI saw CSS. It did not see the legal and accessibility constraints behind it.&lt;/p&gt;

&lt;p&gt;I rejected the removal and added another explicit comment:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;/* ::before used for WCAG 2.2 focus indicator.
   Required for accessibility audit. Do not remove.
*/
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This is where a “helpful cleanup” could have turned into an expensive regression. Not because the model was malicious, but because it had no way to know which parts of the file were bound to requirements that lived in tickets, audits and contracts, not in code.&lt;/p&gt;

&lt;h2&gt;The time balance&lt;/h2&gt;

&lt;p&gt;On paper, the numbers looked like this:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;45 minutes total for the audit&lt;/li&gt;
  &lt;li&gt;20 minutes to generate and massage the diff into something usable&lt;/li&gt;
  &lt;li&gt;25 minutes to verify context and decide on three architectural calls&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The AI saved me the manual typing of 89 Tailwind spacing replacements. It also tried to “help” with three structural changes: color tokens, grid layout, and focus indicators. All three needed correction or rejection because they depended on project context the model could not see.&lt;/p&gt;

&lt;p&gt;The net result was a cleaner file, fewer one-off spacing declarations, and a bit more documentation around the tricky bits. That was not AI magically refactoring a legacy stylesheet.&lt;/p&gt;

&lt;p&gt;AI refactored CSS syntax. A human still had to refactor CSS meaning.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>css</category>
      <category>frontend</category>
      <category>refactoring</category>
    </item>
    <item>
      <title>Replacing Three Client Dashboards With One Local n8n</title>
      <dc:creator>Richard Lemon</dc:creator>
      <pubDate>Thu, 13 Aug 2026 13:05:43 +0000</pubDate>
      <link>https://dev.to/richardlemon/replacing-three-client-dashboards-with-one-local-n8n-oe5</link>
      <guid>https://dev.to/richardlemon/replacing-three-client-dashboards-with-one-local-n8n-oe5</guid>
      <description>&lt;h2&gt;The client problem: three logins for one simple question&lt;/h2&gt;

&lt;p&gt;A client needed recurring reports from three separate tools. Nothing fancy: pull the latest numbers, clean them up, and send them in a format they could actually use.&lt;/p&gt;

&lt;p&gt;The pain wasn’t the data. The pain was access. They had:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Three different dashboards&lt;/li&gt;
  &lt;li&gt;Three different URLs and login flows&lt;/li&gt;
  &lt;li&gt;Three different ideas of what “last month” means&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every time someone new joined their team, they had to be onboarded into this little zoo of dashboards. Every time someone left, they had to be offboarded from the same zoo. Nobody was sure which dashboard was the source of truth, so people ended up screenshotting charts into slides anyway.&lt;/p&gt;

&lt;p&gt;The question behind all of this was simple: “Can we just get the reports in one place?”&lt;/p&gt;

&lt;h2&gt;One n8n instance instead of three dashboards&lt;/h2&gt;

&lt;p&gt;One way to answer that question is to run a single n8n instance locally. Instead of sending the client into three web apps, the automations run in the background and hand them the result in one consistent format.&lt;/p&gt;

&lt;p&gt;On the n8n canvas, a setup for this kind of reporting might look like:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Three data source nodes (one for each tool)&lt;/li&gt;
  &lt;li&gt;Transformation nodes to normalize fields and dates&lt;/li&gt;
  &lt;li&gt;A formatting step to build a clean report&lt;/li&gt;
  &lt;li&gt;An output step that delivers the report on a schedule&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The client never has to see n8n. They just see a single link or a recurring email. No extra login, no jumping between products to answer one question about their numbers.&lt;/p&gt;

&lt;h2&gt;Why local automation beats another SaaS subscription&lt;/h2&gt;

&lt;p&gt;Plenty of SaaS automation tools can connect APIs and schedule workflows. The difference is where the workflows run and where the data flows.&lt;/p&gt;

&lt;p&gt;With a local n8n instance:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The workflows run on a machine you control.&lt;/li&gt;
  &lt;li&gt;API keys and secrets stay in your environment.&lt;/li&gt;
  &lt;li&gt;Client data doesn’t pass through another company’s servers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For recurring client reports, that matters. You are often touching:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Revenue and conversion data&lt;/li&gt;
  &lt;li&gt;User behavior and identifiers&lt;/li&gt;
  &lt;li&gt;Internal performance metrics&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every extra SaaS tool in the chain is another privacy policy, another DPA, and another potential leak path. Keeping the automation local keeps the blast radius smaller.&lt;/p&gt;

&lt;h2&gt;Control beats feature lists&lt;/h2&gt;

&lt;p&gt;Cloud automation platforms tend to sell you on connectors and templates. Local automation is about control.&lt;/p&gt;

&lt;p&gt;With a local n8n instance you decide:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;When to update&lt;/li&gt;
  &lt;li&gt;How to back up&lt;/li&gt;
  &lt;li&gt;Which ports are open&lt;/li&gt;
  &lt;li&gt;How logging is handled&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For client work, that control maps directly to trust. When a client asks where their data goes, you can give a concrete answer instead of pointing at a long list of sub-processors.&lt;/p&gt;

&lt;h2&gt;Cost difference vs Make.com&lt;/h2&gt;

&lt;p&gt;There is also the recurring cost angle. A typical Make.com setup that replaces three client dashboards usually means:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;A paid scenario with enough operations to handle scheduled reports&lt;/li&gt;
  &lt;li&gt;Extra headroom for spikes and retries&lt;/li&gt;
  &lt;li&gt;Potential overage if a client wants more frequent reporting&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With n8n running locally, the cost is mainly:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The server or machine you already have&lt;/li&gt;
  &lt;li&gt;Your time to set up and maintain workflows&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is no per-scenario or per-operation billing. Once an instance is running, adding a new client report is mostly a matter of cloning and adjusting an existing workflow instead of upgrading a SaaS plan.&lt;/p&gt;

&lt;p&gt;Over a year of recurring reports, the difference between a fixed local setup and a growing Make.com bill can be significant, especially if you are handling multiple clients.&lt;/p&gt;

&lt;h2&gt;Client experience: fewer doors, clearer answers&lt;/h2&gt;

&lt;p&gt;From the client’s perspective, the win is simple: they go to fewer places to get the data they care about.&lt;/p&gt;

&lt;p&gt;Instead of:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Logging into three dashboards&lt;/li&gt;
  &lt;li&gt;Exporting CSVs&lt;/li&gt;
  &lt;li&gt;Copy-pasting numbers into a slide&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They get:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;One report, delivered consistently&lt;/li&gt;
  &lt;li&gt;One definition of each metric&lt;/li&gt;
  &lt;li&gt;No extra logins to remember&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They do not need to learn new tools just to check their own performance. The automation takes that complexity away.&lt;/p&gt;

&lt;h2&gt;When local n8n is a better fit than cloud automation&lt;/h2&gt;

&lt;p&gt;Local n8n starts to look better than a SaaS automation platform when:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;You are handling recurring reports across multiple tools&lt;/li&gt;
  &lt;li&gt;Clients care about where their data lives&lt;/li&gt;
  &lt;li&gt;You want predictable costs instead of usage-based billing&lt;/li&gt;
  &lt;li&gt;You prefer to keep infrastructure under your control&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There are cases where a SaaS tool still makes sense, especially for quick one-off integrations or when a team wants a non-technical interface. But for recurring client reporting with sensitive data, a single local n8n instance can quietly replace a surprising number of dashboards and subscriptions.&lt;/p&gt;

</description>
      <category>automation</category>
      <category>productivity</category>
      <category>tools</category>
    </item>
  </channel>
</rss>
