<?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>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>
    <item>
      <title>Building A Personal Metrics Dashboard With Vanilla JS And CSS Grid</title>
      <dc:creator>Richard Lemon</dc:creator>
      <pubDate>Fri, 31 Jul 2026 12:35:22 +0000</pubDate>
      <link>https://dev.to/richardlemon/building-a-personal-metrics-dashboard-with-vanilla-js-and-css-grid-33ok</link>
      <guid>https://dev.to/richardlemon/building-a-personal-metrics-dashboard-with-vanilla-js-and-css-grid-33ok</guid>
      <description>&lt;h2&gt;Why I Wanted A Stupidly Simple Dashboard&lt;/h2&gt;

&lt;p&gt;I like metrics. Steps, sleep, HRV, code sessions, pitches thrown at practice, deep work blocks. If I do not see them, I ignore them. If I need three apps to see them, I definitely ignore them.&lt;/p&gt;

&lt;p&gt;I kept bouncing between Notion dashboards, random SaaS analytics, and a graveyard of half-finished React side projects. All of them felt heavy. Too many moving parts. Too much ceremony for something I want to glance at over coffee.&lt;/p&gt;

&lt;p&gt;So I built a personal dashboard that runs on one &lt;code&gt;index.html&lt;/code&gt; file. No framework. No build step. Just vanilla JS, CSS Grid, and &lt;code&gt;localStorage&lt;/code&gt;. I open a browser tab and my day is there.&lt;/p&gt;

&lt;h2&gt;The Hardest Part Was Saying No To Frameworks&lt;/h2&gt;

&lt;p&gt;I write React for client work. I like it. I also think it is completely overkill for a single-user dashboard that never leaves my machine.&lt;/p&gt;

&lt;p&gt;The temptation is real though. You start thinking, what if I want charts, routing, theming, offline sync? That is how you turn a weekend project into a year-long migration plan.&lt;/p&gt;

&lt;p&gt;I forced myself into four constraints:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Single &lt;code&gt;index.html&lt;/code&gt; file, optional &lt;code&gt;style.css&lt;/code&gt;, &lt;code&gt;app.js&lt;/code&gt;
&lt;/li&gt;
  &lt;li&gt;No bundler, no transpiler, no Node install step&lt;/li&gt;
  &lt;li&gt;ECMAScript modules only if absolutely necessary&lt;/li&gt;
  &lt;li&gt;Everything must be readable in five years with no docs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If I could not solve something with native browser features, I removed the feature. This sounds harsh, but it kept the thing shippable.&lt;/p&gt;

&lt;h2&gt;Defining The Metrics That Actually Matter&lt;/h2&gt;

&lt;p&gt;Before touching code, I listed what I actually care about each day. Not what looks cool on a dashboard. What I am annoyed about if I do not see it.&lt;/p&gt;

&lt;p&gt;I ended up with five panels:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;
&lt;strong&gt;Sleep&lt;/strong&gt;: hours slept, subjective quality&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;Training&lt;/strong&gt;: baseball sessions, pitches thrown, lifting&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;Deep work&lt;/strong&gt;: focused blocks, start and end times&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;Input&lt;/strong&gt;: reading time, long-form articles, podcasts&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;Biomarkers&lt;/strong&gt;: HRV, resting HR, morning weight&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of these needed two things: a quick way to log a number, and a quick way to see a trend over the last 7 to 30 days. That is it. No filters. No export. Future me can build that if it actually hurts.&lt;/p&gt;

&lt;h2&gt;One HTML File, No Build Step&lt;/h2&gt;

&lt;p&gt;The structure lives in a stupidly simple &lt;code&gt;index.html&lt;/code&gt;. No templates. No JSX. Just semantic-ish HTML and a few attributes I can hook into.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;&amp;lt;!doctype html&amp;gt;
&amp;lt;html lang="en"&amp;gt;
&amp;lt;head&amp;gt;
  &amp;lt;meta charset="utf-8" /&amp;gt;
  &amp;lt;title&amp;gt;Daily Metrics Dashboard&amp;lt;/title&amp;gt;
  &amp;lt;meta name="viewport" content="width=device-width, initial-scale=1" /&amp;gt;
  &amp;lt;link rel="stylesheet" href="style.css" /&amp;gt;
&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
  &amp;lt;main class="dashboard"&amp;gt;
    &amp;lt;section class="card" data-panel="sleep"&amp;gt;
      &amp;lt;header&amp;gt;
        &amp;lt;h2&amp;gt;Sleep&amp;lt;/h2&amp;gt;
        &amp;lt;div class="card-meta"&amp;gt;
          &amp;lt;span data-sleep-average&amp;gt;0h avg&amp;lt;/span&amp;gt;
        &amp;lt;/div&amp;gt;
      &amp;lt;/header&amp;gt;
      &amp;lt;div class="card-body"&amp;gt;
        &amp;lt;label&amp;gt;
          Hours
          &amp;lt;input type="number" step="0.25" min="0" max="12" data-sleep-hours /&amp;gt;
        &amp;lt;/label&amp;gt;
        &amp;lt;label&amp;gt;
          Quality
          &amp;lt;select data-sleep-quality&amp;gt;
            &amp;lt;option value="1"&amp;gt;Awful&amp;lt;/option&amp;gt;
            &amp;lt;option value="2"&amp;gt;Bad&amp;lt;/option&amp;gt;
            &amp;lt;option value="3" selected&amp;gt;Ok&amp;lt;/option&amp;gt;
            &amp;lt;option value="4"&amp;gt;Good&amp;lt;/option&amp;gt;
            &amp;lt;option value="5"&amp;gt;Great&amp;lt;/option&amp;gt;
          &amp;lt;/select&amp;gt;
        &amp;lt;/label&amp;gt;
        &amp;lt;button data-sleep-save&amp;gt;Save today&amp;lt;/button&amp;gt;
        &amp;lt;div class="sparkline" data-sleep-sparkline&amp;gt;&amp;lt;/div&amp;gt;
      &amp;lt;/div&amp;gt;
    &amp;lt;/section&amp;gt;

    &amp;lt;!-- more cards: training, deep work, input, biomarkers --&amp;gt;
  &amp;lt;/main&amp;gt;

  &amp;lt;script src="app.js" type="module"&amp;gt;&amp;lt;/script&amp;gt;
&amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;I rely on &lt;code&gt;data-*&lt;/code&gt; attributes instead of IDs everywhere. It keeps things naturally namespaced per panel. Also it avoids global ID soup.&lt;/p&gt;

&lt;h2&gt;CSS Grid Makes Layout Boring In A Good Way&lt;/h2&gt;

&lt;p&gt;I wanted the layout to feel like a real dashboard, not a vertical stack of forms. CSS Grid is perfect for this. No framework. No utility classes. One layout definition.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;/* style.css */

:root {
  --bg: #050509;
  --card-bg: #111827;
  --accent: #22c55e;
  --text: #e5e7eb;
  --muted: #6b7280;
  --border-radius: 10px;
  --gap: 1.2rem;
  --font: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  min-height: 100vh;
  font-family: var(--font);
  background: radial-gradient(circle at top, #0f172a 0, #020617 55%, #000 100%);
  color: var(--text);
}

.dashboard {
  max-width: 1200px;
  margin: 2rem auto;
  padding: 0 1rem 2rem;
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  grid-auto-rows: minmax(180px, auto);
  gap: var(--gap);
}

.card {
  background: linear-gradient(145deg, #0b1120, #020617);
  border-radius: var(--border-radius);
  padding: 1rem 1.1rem 1.2rem;
  border: 1px solid rgba(148, 163, 184, 0.15);
  box-shadow: 0 18px 40px rgba(15, 23, 42, 0.8);
  display: flex;
  flex-direction: column;
}

.card header {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  margin-bottom: 0.75rem;
}

.card h2 {
  font-size: 0.95rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--muted);
}

.card-meta span {
  font-size: 0.8rem;
  color: var(--muted);
}

.card-body {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 0.75rem 1rem;
  align-items: flex-start;
}

.card label {
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
  font-size: 0.78rem;
  color: var(--muted);
}

input, select, button {
  font: inherit;
  border-radius: 6px;
  border: 1px solid rgba(148, 163, 184, 0.25);
  padding: 0.35rem 0.5rem;
  background: rgba(15, 23, 42, 0.9);
  color: var(--text);
}

button {
  cursor: pointer;
  border-color: rgba(34, 197, 94, 0.4);
  background: radial-gradient(circle at top left, #22c55e, #15803d);
  color: #ecfdf5;
  font-size: 0.8rem;
}

button:hover {
  filter: brightness(1.03);
}

.sparkline {
  grid-column: 1 / -1;
  margin-top: 0.4rem;
  height: 48px;
  display: grid;
  grid-template-columns: repeat(30, 1fr);
  align-items: end;
  gap: 2px;
}

.sparkline-bar {
  background: conic-gradient(from 160deg, #22c55e, #3b82f6);
  border-radius: 999px 999px 2px 2px;
  opacity: 0.35;
}

.sparkline-bar--today {
  opacity: 1;
}

@media (max-width: 900px) {
  .dashboard {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}

@media (max-width: 640px) {
  .dashboard {
    grid-template-columns: 1fr;
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The grid itself is boring. That is the point. CSS Grid lets me think about meaning instead of micro positioning. If I need a card to be wider, I can add one class and span more columns.&lt;/p&gt;

&lt;h2&gt;Tiny Data Model, Stored In localStorage&lt;/h2&gt;

&lt;p&gt;I do not want a backend for this. I just want the browser to remember my numbers. &lt;code&gt;localStorage&lt;/code&gt; is enough if you keep the data model predictable.&lt;/p&gt;

&lt;p&gt;The core idea: store everything indexed by an ISO date string. For example &lt;code&gt;"2024-03-21"&lt;/code&gt;. Each date holds an object with my metrics.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;// app.js

const STORAGE_KEY = "rl-dashboard-v1";

function loadState() {
  try {
    const raw = localStorage.getItem(STORAGE_KEY);
    if (!raw) return {};
    return JSON.parse(raw);
  } catch (e) {
    console.warn("Failed to load state", e);
    return {};
  }
}

function saveState(state) {
  localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}

function todayKey() {
  return new Date().toISOString().slice(0, 10); // YYYY-MM-DD
}

let state = loadState();
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;State shape looks like this in practice:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;{
  "2024-03-21": {
    sleep: { hours: 7.5, quality: 4 },
    training: { pitches: 60, gym: true },
    deepWork: { blocks: 3, minutes: 150 },
    input: { readingMinutes: 45 },
    biomarkers: { hrv: 78, weight: 81.4 }
  },
  "2024-03-22": {
    sleep: { hours: 6.25, quality: 3 }
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;I did not over-normalise it. This is a personal tool, not a multi-tenant SaaS. Flat JSON is fine.&lt;/p&gt;

&lt;h2&gt;Wiring Panels With Vanilla JS&lt;/h2&gt;

&lt;p&gt;With the HTML scaffold and a single source of truth, wiring up panels becomes repetitive in a nice way. I like boring JS.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;const panels = {
  sleep: {
    selector: "[data-panel='sleep']",
    getDefault() {
      return { hours: 0, quality: 3 };
    },
    readFromDOM(root) {
      return {
        hours: Number(root.querySelector("[data-sleep-hours]").value || 0),
        quality: Number(root.querySelector("[data-sleep-quality]").value || 3)
      };
    },
    writeToDOM(root, value) {
      root.querySelector("[data-sleep-hours]").value = value?.hours ?? "";
      root.querySelector("[data-sleep-quality]").value = value?.quality ?? 3;
    },
    extractSeriesForSparkline(state) {
      const last30 = getLastNDatesKeys(30);
      return last30.map((key) =&amp;gt; state[key]?.sleep?.hours ?? 0);
    },
    updateMeta(root, state) {
      const values = Object.values(state)
        .map((entry) =&amp;gt; entry.sleep?.hours)
        .filter((v) =&amp;gt; typeof v === "number" &amp;amp;&amp;amp; v &amp;gt; 0);

      const avg = values.length
        ? (values.reduce((a, b) =&amp;gt; a + b, 0) / values.length).toFixed(1)
        : "0";

      root.querySelector("[data-sleep-average]").textContent = `${avg}h avg`;
    }
  }
  // training, deepWork etc. follow the same pattern
};

function getLastNDatesKeys(n) {
  const arr = [];
  const d = new Date();
  for (let i = n - 1; i &amp;gt;= 0; i--) {
    const copy = new Date(d);
    copy.setDate(d.getDate() - i);
    arr.push(copy.toISOString().slice(0, 10));
  }
  return arr;
}

function init() {
  Object.entries(panels).forEach(([key, panel]) =&amp;gt; {
    const root = document.querySelector(panel.selector);
    if (!root) return;

    const today = todayKey();
    const todayValue = state[today]?.[key] ?? panel.getDefault();
    panel.writeToDOM(root, todayValue);

    const saveButton = root.querySelector(`[data-${key}-save]`);
    if (saveButton) {
      saveButton.addEventListener("click", () =&amp;gt; {
        const current = panel.readFromDOM(root);
        state = {
          ...state,
          [today]: {
            ...(state[today] || {}),
            [key]: current
          }
        };
        saveState(state);
        renderSparkline(root, panel.extractSeriesForSparkline(state));
        panel.updateMeta(root, state);
      });
    }

    renderSparkline(root, panel.extractSeriesForSparkline(state));
    panel.updateMeta(root, state);
  });
}

document.addEventListener("DOMContentLoaded", init);
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;There are no frameworks hiding here. Just objects, DOM queries, and event listeners. The code is not clever. That is intentional.&lt;/p&gt;

&lt;h2&gt;Fake Charts With CSS Grid Sparklines&lt;/h2&gt;

&lt;p&gt;I like charts yet I did not want to pull in a charting library. Also I did not want to touch canvas or SVG for something that can be implied visually instead of exactly measured.&lt;/p&gt;

&lt;p&gt;The small sparkline at the bottom of each card is just a CSS Grid with 30 skinny divs.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;function renderSparkline(root, series) {
  const container = root.querySelector(".sparkline");
  if (!container) return;

  const max = Math.max(...series, 1);
  container.innerHTML = "";

  series.forEach((value, index) =&amp;gt; {
    const bar = document.createElement("div");
    bar.className = "sparkline-bar";
    if (index === series.length - 1) {
      bar.classList.add("sparkline-bar--today");
    }
    const height = (value / max) * 100;
    bar.style.height = `${height}%`;
    container.appendChild(bar);
  });
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This gives me a quick gut feel for trends. Did sleep drop off this week. Did deep work flatline around launch. I do not need axes or tooltips for that.&lt;/p&gt;

&lt;h2&gt;Keyboard-First, Mouse-Optional&lt;/h2&gt;

&lt;p&gt;I use this thing twice a day. Morning and night. If it requires mouse gymnastics, I will stop entering data within a week. So I sketched a few constraints around interaction.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Tab order flows correctly across inputs and panels&lt;/li&gt;
  &lt;li&gt;Enter key on a focused input should not accidentally submit anything&lt;/li&gt;
  &lt;li&gt;Pressing Cmd+Shift+D focuses the first field of the first card&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The shortcut handling is trivial.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;document.addEventListener("keydown", (event) =&amp;gt; {
  const isMac = navigator.platform.toUpperCase().indexOf("MAC") &amp;gt;= 0;
  const mod = isMac ? event.metaKey : event.ctrlKey;

  if (mod &amp;amp;&amp;amp; event.shiftKey &amp;amp;&amp;amp; event.key.toLowerCase() === "d") {
    event.preventDefault();
    const first = document.querySelector(".card input, .card select");
    if (first) first.focus();
  }
});
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;I do not bother with a full command palette. This one shortcut gets me into logging mode quickly and that is enough.&lt;/p&gt;

&lt;h2&gt;Syncing External Data Without An API&lt;/h2&gt;

&lt;p&gt;Some metrics live elsewhere. Sleep from Oura. Steps from Apple Health. CRM stuff in another app. I did not bother with OAuth flows or background sync yet. I cheat.&lt;/p&gt;

&lt;p&gt;Once a week, I export CSV from the relevant app, copy the range I care about, then paste values into a textarea in a hidden admin card. The admin card parses the pasted block and merges entries into &lt;code&gt;state&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The code is ugly and specific to each export format, so I am not pasting it here, but the principle is simple. Manual copy paste beats half-baked API integration that breaks whenever a vendor feels like it.&lt;/p&gt;

&lt;h2&gt;Why I Still Avoided Any Build Tools&lt;/h2&gt;

&lt;p&gt;I write a lot of production code that goes through bundlers, minifiers, linters, and whatever new hot loader is trending. For this project I wanted the opposite feeling.&lt;/p&gt;

&lt;p&gt;Practical upside:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;I can open the file from disk in any browser and everything works&lt;/li&gt;
  &lt;li&gt;No dependency tree, no npm audit noise, no broken lockfiles&lt;/li&gt;
  &lt;li&gt;Page reload is the only refresh mechanism I need&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Debugging is also stupidly simple. Inspect element. Edit markup. Tweak CSS in the browser. Drag changes back into my editor. Old school, but it works.&lt;/p&gt;

&lt;h2&gt;Things I Intentionally Did Not Add&lt;/h2&gt;

&lt;p&gt;I get tempted by new features fast. So I kept a short list of things I would actively not build in version one.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;No user accounts, obviously. Only I use this.&lt;/li&gt;
  &lt;li&gt;No theming system. Hardcoded dark theme, take it or leave it.&lt;/li&gt;
  &lt;li&gt;No date picker. You can only edit today. Past corrections go through a JSON edit.&lt;/li&gt;
  &lt;li&gt;No notifications. I already have enough apps yelling at me.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This kind of intentional laziness matters. I would rather have a boring dashboard that I open daily than a sophisticated data product that I abandon.&lt;/p&gt;

&lt;h2&gt;What This Setup Is Good For&lt;/h2&gt;

&lt;p&gt;I would not build a client-facing product like this. But for personal tools, I think we underestimate what we can do with plain HTML, CSS Grid, and vanilla JS.&lt;/p&gt;

&lt;p&gt;This approach works well when:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;You are the only user&lt;/li&gt;
  &lt;li&gt;You do not need real-time collaboration or multi-device sync&lt;/li&gt;
  &lt;li&gt;You value reliability over features&lt;/li&gt;
  &lt;li&gt;You want to be able to fix it in five minutes, even half-asleep&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My dashboard loads instantly, never breaks on library updates, and is easy to tweak. Yesterday I added a tiny "coach" card that tracks how many kids I threw live BP to that week. It took ten minutes and zero npm commands.&lt;/p&gt;

&lt;p&gt;That is the upside of staying close to the metal. You trade fancy abstractions for control. For a personal metrics dashboard, I will take that deal every time.&lt;/p&gt;

</description>
      <category>css</category>
      <category>javascript</category>
      <category>sideprojects</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The client code I will not paste into an AI tool</title>
      <dc:creator>Richard Lemon</dc:creator>
      <pubDate>Fri, 31 Jul 2026 12:34:56 +0000</pubDate>
      <link>https://dev.to/richardlemon/the-client-code-i-will-not-paste-into-an-ai-tool-1ia9</link>
      <guid>https://dev.to/richardlemon/the-client-code-i-will-not-paste-into-an-ai-tool-1ia9</guid>
      <description>&lt;h2&gt;The line between "helpful" and "too much information"&lt;/h2&gt;

&lt;p&gt;AI coding tools make it very easy to cross a line you did not mean to cross. You are stuck on a bug, you copy a file, you paste it into a chat box, and only afterwards you notice what else was in there: naming conventions, business rules, launch plans.&lt;/p&gt;

&lt;p&gt;I am not trying to write a security policy. I am not a lawyer, and I am not threat-modelling nation states. What I do have is a practical boundary for my own work: code I will not paste into an AI tool, even with all the usual reassurances about privacy and retention.&lt;/p&gt;

&lt;p&gt;The pattern is simple: the more code says about &lt;em&gt;how a client operates&lt;/em&gt;, the less I want it leaving my editor.&lt;/p&gt;

&lt;h2&gt;Operationally proprietary beats legally proprietary&lt;/h2&gt;

&lt;p&gt;Some code is obviously sensitive: API keys, passwords, tokens. That is table stakes. The more interesting category is the code that is not legally proprietary, but is still proprietary in an operational sense.&lt;/p&gt;

&lt;p&gt;Anything that imports from our internal NAS-mounted asset pipeline stays out of AI tools. The paths, the naming conventions, and the fallback logic for missing InDesign packages all reveal how we structure client work at Ideebv. Even a single line like:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;import { processClientPackage } from '@ideebv/nas-utils'&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;tells a competitor more about our workflow than I am comfortable sharing. They do not get the implementation, but they do see how we think about packaging, which parts we automated, and how we name things internally.&lt;/p&gt;

&lt;p&gt;The code is not protected by some special statute. It is just nobody else’s business.&lt;/p&gt;

&lt;h2&gt;Business logic that exposes the business&lt;/h2&gt;

&lt;p&gt;Another category I keep away from AI tools is client-specific business logic that lives outside the main repository.&lt;/p&gt;

&lt;p&gt;One example: a pricing module for a client with a custom discount matrix tied into their ERP. The matrix itself is not cryptographically secret, but it is commercially sensitive. It encodes things like:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;How aggressively they discount by region&lt;/li&gt;
  &lt;li&gt;Which customer types get the best margins&lt;/li&gt;
  &lt;li&gt;Where they are willing to trade margin for volume&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pasting even a “sanitized” version into an AI assistant would expose the shape of their pricing tiers, their margin structure, and their regional discounting strategy. The model does not need any of that to help me refactor a generic &lt;code&gt;calculateTotal&lt;/code&gt; function.&lt;/p&gt;

&lt;p&gt;If I want help with the algorithm, I can strip it down to something like:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;function calculateTotal({ items, discounts }) {
  // ...pure math here
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;No client names, no product codes, no embedded business rules. The AI sees loops and conditionals, not a commercial strategy.&lt;/p&gt;

&lt;h2&gt;Auth code is not “just another bug”&lt;/h2&gt;

&lt;p&gt;Authentication and session handling are another trap. They look like regular plumbing until you read them with an attacker’s mindset.&lt;/p&gt;

&lt;p&gt;I was tempted to paste a Next.js middleware snippet into an AI tool to debug a redirect loop. Then I noticed the file also contained a hardcoded fallback cookie name, a session secret rotation pattern, and a comment about the legacy SSO provider we are migrating away from.&lt;/p&gt;

&lt;p&gt;That is not a coding problem, that is a security audit waiting to happen. Even if the snippet never leaks, I do not want a transcript somewhere that neatly lists how our sessions work, what we are deprecating, and where the weak points might be.&lt;/p&gt;

&lt;p&gt;My workaround is boring but effective: I keep a “safe sandbox” file with anonymized versions of the logic for AI debugging. Same patterns, different details. Cookie names become &lt;code&gt;SESSION_COOKIE&lt;/code&gt;, providers become &lt;code&gt;PRIMARY_SSO&lt;/code&gt;, secrets are removed entirely. I copy from the safe file into the AI tool, never from production code.&lt;/p&gt;

&lt;h2&gt;Roadmaps hidden in route files&lt;/h2&gt;

&lt;p&gt;Code around unannounced products is another hard stop.&lt;/p&gt;

&lt;p&gt;We are building a portal for a materials company that has not publicly announced their new composite line. The component names, route structure, and feature flags map directly to their product roadmap.&lt;/p&gt;

&lt;p&gt;Something as innocent as a &lt;code&gt;featureFlags&lt;/code&gt; object can give away:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Which features are in beta, alpha, or internal-only&lt;/li&gt;
  &lt;li&gt;Which markets or customer segments they care about first&lt;/li&gt;
  &lt;li&gt;How they plan to phase the rollout&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I caught myself almost pasting that object into an AI chat to ask about a type issue. The model would not misuse it, but the conversation would be stored, and my client’s launch timeline would be legible to anyone who read the generated code.&lt;/p&gt;

&lt;p&gt;Again, the model does not need to know any of this to help me. If I have a typing problem, I can replace real flags with &lt;code&gt;flagA&lt;/code&gt;, &lt;code&gt;flagB&lt;/code&gt;, &lt;code&gt;flagC&lt;/code&gt;. If I am debugging routing, I can reduce it to &lt;code&gt;/page-a&lt;/code&gt; and &lt;code&gt;/page-b&lt;/code&gt;. The logic stays, the roadmap goes.&lt;/p&gt;

&lt;h2&gt;A simple smell test instead of a policy document&lt;/h2&gt;

&lt;p&gt;All of this sounds like a policy, but I do not maintain a formal document. For me the boundary is a smell test.&lt;/p&gt;

&lt;p&gt;If I have to stop and ask “should I paste this?”, the answer is no.&lt;/p&gt;

&lt;p&gt;I use a simple rule to keep myself honest: generic algorithms are fair game, domain logic is not.&lt;/p&gt;

&lt;p&gt;In practice that means:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;I will happily paste a custom &lt;code&gt;useDebounce&lt;/code&gt; hook.&lt;/li&gt;
  &lt;li&gt;I will not paste the hook that debounces updates to a specific client’s inventory API.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The first is a tool. The second is a fingerprint. One can live in a blog post or a library. The other describes how a real business moves data around.&lt;/p&gt;

&lt;p&gt;This is also why I avoid pasting entire files. The more context you include, the easier it is to accidentally drag in something operationally proprietary: a path, a comment, a feature name that has not shipped yet.&lt;/p&gt;

&lt;h2&gt;Using AI without outsourcing judgment&lt;/h2&gt;

&lt;p&gt;AI tools are good at refactoring, explaining, and generating variations. They are not good at deciding what is safe to show them. That part is still on you.&lt;/p&gt;

&lt;p&gt;The boundary I use is intentionally low-tech. No diagrams, no frameworks, no traffic lights. Just a couple of questions:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Does this code reveal how a client structures their work, their pricing, their auth, or their roadmap?&lt;/li&gt;
  &lt;li&gt;Could a competitor learn something useful about our process or the client’s business from this snippet?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the answer is yes, I keep it out of the prompt and create a scrubbed, generic version instead. It takes a few extra minutes, but that is cheaper than explaining to a client why their internal logic is now sitting in someone else’s training data or logs.&lt;/p&gt;

&lt;p&gt;AI can help with the code. It does not need to see the business behind it.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>privacy</category>
      <category>security</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>The First Ten Minutes of Testing a Design on Mobile</title>
      <dc:creator>Richard Lemon</dc:creator>
      <pubDate>Fri, 31 Jul 2026 12:34:36 +0000</pubDate>
      <link>https://dev.to/richardlemon/the-first-ten-minutes-of-testing-a-design-on-mobile-24pn</link>
      <guid>https://dev.to/richardlemon/the-first-ten-minutes-of-testing-a-design-on-mobile-24pn</guid>
      <description>&lt;h2&gt;I start with the thumb, not the breakpoint&lt;/h2&gt;

&lt;p&gt;Before I open DevTools, I hold the phone in my right hand, stand up, and try to reach every interactive element with my thumb. That is the default user posture. Not a perfectly centered browser window, not a device emulator, just a slightly distracted human with one hand free.&lt;/p&gt;

&lt;p&gt;If a primary button, menu toggle, or form field lives in the top-left corner, it is already wrong ergonomically, no matter how pretty the layout looks. I do not care yet if the CSS grid collapsed correctly at 375px. If I have to stretch, change grip, or use my second hand for a core action, it fails the first test.&lt;/p&gt;

&lt;p&gt;Mobile testing is not about screen width; it is about human reach. The first few minutes are just me hunting for anything important that lives outside the natural thumb zone.&lt;/p&gt;

&lt;h2&gt;Hover states become dead zones&lt;/h2&gt;

&lt;p&gt;The next thing I look for is anything that only feels interactive on desktop because of hover. Cards that lift on &lt;code&gt;:hover&lt;/code&gt;, links that only change color when the cursor floats over them, ghost buttons that only show their border when you tease them with a mouse.&lt;/p&gt;

&lt;p&gt;On mobile, there is no hover. There is just the ambiguity of a tap.&lt;/p&gt;

&lt;p&gt;In the first minute, I drag my thumb slowly across the screen and see which elements ignore me until I commit to a full tap. If a link only changes color on hover and has no visible active state, people pause, tap twice, or miss it entirely.&lt;/p&gt;

&lt;p&gt;This is where a design that looked elegant on desktop suddenly feels evasive on mobile. The interface is technically there, but nothing is volunteering to be clicked. Before I open any inspector, I already have a list of components that need an obvious pressed state, focus state, or static affordance that says, clearly, “you can touch me.”&lt;/p&gt;

&lt;h2&gt;The 100vh trap shows up without tools&lt;/h2&gt;

&lt;p&gt;I scroll to any section that is meant to be “full viewport height” and just watch what happens. On a real phone, the browser chrome eats the bottom part of the layout. The nice, tidy &lt;code&gt;100vh&lt;/code&gt; hero often loses a noticeable slice of space to the address bar, bottom bar, or some other piece of UI the browser insists on showing.&lt;/p&gt;

&lt;p&gt;Then I rotate the phone. If the layout suddenly jumps, crops content, or leaves a weird empty band where the keyboard or chrome used to be, someone trusted &lt;code&gt;100vh&lt;/code&gt; a bit too much.&lt;/p&gt;

&lt;p&gt;These are not bugs you catch by resizing Chrome to a fixed mobile width on a laptop. They are environmental distortions caused by the actual browser UI. You feel them before you measure them. I note them mentally in those first minutes, because they are tactile problems, not computational ones.&lt;/p&gt;

&lt;h2&gt;Text that disappears in the sun&lt;/h2&gt;

&lt;p&gt;After that, I go looking for text that survives anything brighter than a dim office. Contrast ratios that technically pass WCAG on a calibrated monitor can fail brutally on a phone held outdoors at an angle.&lt;/p&gt;

&lt;p&gt;I walk to a window or step outside and check the body text, labels, and secondary information. If I have to squint, tilt the screen, or shade it with my hand to read a label, that design has already failed a real-world accessibility test. The browser does not care that the design system says “muted gray for secondary copy.” My eyes do.&lt;/p&gt;

&lt;p&gt;Mobile screens are not calibrated to your Figma canvas; they are calibrated to the weather. In the first two minutes, daylight is a more honest audit than any automated contrast checker.&lt;/p&gt;

&lt;h2&gt;Forms and keyboards: where layouts go to die&lt;/h2&gt;

&lt;p&gt;Forms are where I expect things to break, so I get to them quickly. I tap the first input field and watch the viewport instead of the label.&lt;/p&gt;

&lt;p&gt;I am checking a few things at once:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Does the page scroll so that the active field stays visible, or does it end up hidden under a header?&lt;/li&gt;
  &lt;li&gt;Does any fixed header or sticky element collapse gracefully, or does it sit on top of the input?&lt;/li&gt;
  &lt;li&gt;When the software keyboard appears, does the submit button stay reachable, or does it get buried with no obvious way to dismiss the keyboard?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are the interactions where mobile UX dies in the real world. A form that looks beautiful as a static mockup often becomes a navigation puzzle once the keyboard arrives.&lt;/p&gt;

&lt;p&gt;In the first thirty seconds of testing a form, I do not care about validation messages, error copy, or microcopy. I care about whether I can see what I am typing and reach the button that finishes the job.&lt;/p&gt;

&lt;h2&gt;Scrolling momentum as a performance test&lt;/h2&gt;

&lt;p&gt;Before I touch any performance panel, I flick the page and feel the scroll. That first flick tells me most of what I need to know about how heavy the page is.&lt;/p&gt;

&lt;p&gt;If the scroll stutters, catches on a sticky header, or feels like it is fighting my finger, I assume there is too much going on: too many repaints, too many sticky layers, or some JavaScript trying to hijack &lt;code&gt;touchmove&lt;/code&gt; events.&lt;/p&gt;

&lt;p&gt;Smooth scrolling is not a metric in this phase; it is a sensation. The body notices stutter before the profiler does.&lt;/p&gt;

&lt;p&gt;Only after that do I make a mental note to look for the usual suspects in DevTools later. The thumb sets the priority list.&lt;/p&gt;

&lt;h2&gt;The quiet before the metrics&lt;/h2&gt;

&lt;p&gt;There is a specific quiet in the first ten minutes where I try to be just a user, not a developer. No inspecting elements, no console, no screenshots of the DOM. I pick a simple task and try to complete it: sign up, buy something, read an article, or navigate to a specific page.&lt;/p&gt;

&lt;p&gt;As I do that, I pay attention to where my patience frays. The moment I feel “this site does not want me to finish,” I stop and write that down. That feeling is the real bug report.&lt;/p&gt;

&lt;p&gt;DevTools will later show me layout shifts, blocking scripts, and slow images. It will not show me the micro-frictions: the button I almost missed, the field I lost under the keyboard, the text that vanished in sunlight, the scroll that felt sticky, or the thumb stretch that made the primary action feel optional.&lt;/p&gt;

&lt;p&gt;Those first ten minutes, before any metrics, are where the most honest data comes from. The phone, the thumb, the light, and a bit of irritation are usually enough to tell me what is wrong long before the graphs do.&lt;/p&gt;

</description>
      <category>design</category>
      <category>mobile</category>
      <category>testing</category>
      <category>ux</category>
    </item>
    <item>
      <title>The Accessibility Audit That Slapped Me Awake</title>
      <dc:creator>Richard Lemon</dc:creator>
      <pubDate>Sat, 11 Jul 2026 07:16:17 +0000</pubDate>
      <link>https://dev.to/richardlemon/the-accessibility-audit-that-slapped-me-awake-2e1o</link>
      <guid>https://dev.to/richardlemon/the-accessibility-audit-that-slapped-me-awake-2e1o</guid>
      <description>&lt;h2&gt;The setup: I thought I was "pretty good" at a11y&lt;/h2&gt;

&lt;p&gt;I shipped a client site earlier this year that I was genuinely proud of. Fast, clean, nice motion, sensible structure. I had aria labels sprinkled in. Focus states visible. Color contrast checked with a browser extension.&lt;/p&gt;

&lt;p&gt;I walked around thinking: this is solid. Not perfect, but above average. Better than the usual marketing-page glitter I see launch on Product Hunt.&lt;/p&gt;

&lt;p&gt;The client then told me they had booked an external accessibility audit. Corporate policy. Third-party vendor. Full WCAG report.&lt;/p&gt;

&lt;p&gt;I said "nice, happy to collaborate". What I meant was "sure, let them rubber-stamp my genius".&lt;/p&gt;

&lt;p&gt;Yeah. That did not happen.&lt;/p&gt;

&lt;h2&gt;The report that wrecked my ego&lt;/h2&gt;

&lt;p&gt;The PDF landed in my inbox on a Thursday. 43 pages. That already hurt. The executive summary used the phrase "significant barriers" twice, which hurt more.&lt;/p&gt;

&lt;p&gt;I did what every developer does with long reports. Scanned for screenshots of my worst sins first. There they were. Highlight annotations. Red circles. Yellow boxes. My UI looked like a football coach diagram.&lt;/p&gt;

&lt;p&gt;Here is the uncomfortable bit. Nothing in that report was "clever". No edge-case academic stuff. It was all basic things I should have caught.&lt;/p&gt;

&lt;p&gt;I am going to walk through the worst ones and how they changed my process, because theory-level accessibility feels abstract. Getting slapped with real user failures is not.&lt;/p&gt;

&lt;h2&gt;1. My fancy focus trap was an actual trap&lt;/h2&gt;

&lt;p&gt;The site had a beautiful modal. Blurred background, springy animation, keyboard trap implemented with a tiny utility I had used before. I was proud of it.&lt;/p&gt;

&lt;p&gt;The auditor flagged it as a critical issue.&lt;/p&gt;

&lt;p&gt;During keyboard navigation tests the focus would enter the modal, cycle through the fields, but the close button was &lt;em&gt;not&lt;/em&gt; reachable with Tab. Why? Because I had added a little micro-interaction that hid the close label visually on small screens and replaced it with an icon-only button.&lt;/p&gt;

&lt;p&gt;The button itself was still there, but my focus ring was styled only for &lt;code&gt;button:focus-visible&lt;/code&gt;. On that element I had a weird outline offset. Combined with the layout, the visible outline ended up outside the viewport on some sizes.&lt;/p&gt;

&lt;p&gt;So technically the element got focus. Practically the user saw &lt;strong&gt;nothing&lt;/strong&gt;. It looked frozen.&lt;/p&gt;

&lt;p&gt;What I had never done before, and what the auditor did, was this:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Turn off the mouse completely.&lt;/li&gt;
  &lt;li&gt;Navigate the entire site with Tab and Shift+Tab at 1x speed. No cheating, no skipping.&lt;/li&gt;
  &lt;li&gt;Try to &lt;em&gt;escape&lt;/em&gt; every overlay, dialog, and menu with only the keyboard.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When I repeated that, slowly, I realised how brittle my focus handling really was. It worked in my happy path short test. It fell apart when you used it like a real person who is stuck inside a component.&lt;/p&gt;

&lt;p&gt;Change in my workflow: I now do a "keyboard only" pass on every feature. I literally put my trackpad out of reach and try to get stuck. If I can trap myself, I fix it before launch.&lt;/p&gt;

&lt;h2&gt;2. Screen reader order did not match the visual order&lt;/h2&gt;

&lt;p&gt;The homepage hero was a pretty typical pattern. Left column had a headline, paragraph, CTA buttons. Right column had a product mockup with some floating badges.&lt;/p&gt;

&lt;p&gt;I had built the layout using CSS grid and some reordering. For mobile I wanted the image first visually, so I used &lt;code&gt;order&lt;/code&gt; on flex items in one breakpoint. It looked great.&lt;/p&gt;

&lt;p&gt;The auditor ran this through NVDA and VoiceOver. Their note was blunt. The reading order was: image, decorative badge, secondary badge, then suddenly footer navigation, then only after that the main hero copy.&lt;/p&gt;

&lt;p&gt;I had wrapped the visual stuff in too many nested containers. The DOM structure was not in the logical content order. I relied on CSS to rearrange what the user saw, but the assistive tech still obeyed the DOM.&lt;/p&gt;

&lt;p&gt;This is the kind of bug you do not catch with Lighthouse or simple checklists. You have to actually turn on a screen reader and listen to your page. Like a podcast of your mistakes.&lt;/p&gt;

&lt;p&gt;Change in my workflow: I now treat DOM order as the source of truth. If I have to reorder visually, I pause and ask why. Can I instead structure the HTML in the same order as the content should be consumed, and use layout only for spacing and alignment?&lt;/p&gt;

&lt;p&gt;Most of the time, yes. My old approach was just lazy. Flexbox gave me a hammer and I reordered everything.&lt;/p&gt;

&lt;h2&gt;3. Low-contrast on active states, not static ones&lt;/h2&gt;

&lt;p&gt;I had checked the color palette with a standard 4.5:1 AA contrast checker. Base text, buttons, links. All fine.&lt;/p&gt;

&lt;p&gt;The auditor did not just check static UI. They checked states. That part annoyed me, because I knew they were right and I had never actually done it properly.&lt;/p&gt;

&lt;p&gt;On hover I darkened some buttons. On focus I lightened outlines. On form fields I added a subtle colored border and a glow when active. Subtle was the problem.&lt;/p&gt;

&lt;p&gt;On one primary button, the normal state contrast was 4.7:1. The hover state dropped to 3.2:1 because I loved how that slightly softer shade looked against the background image.&lt;/p&gt;

&lt;p&gt;Looks great to me. Useless if you have low vision and rely on the difference between states to know where you are.&lt;/p&gt;

&lt;p&gt;The same happened with inline error messages. Neutral grey text that turned red on error. That red had worse contrast against the light background than the original grey did, especially in the small labels.&lt;/p&gt;

&lt;p&gt;Change in my workflow: I started checking contrast &lt;strong&gt;per state&lt;/strong&gt;. Normal, hover, focus, disabled. If a designer gives me a Figma file with 9 different button variants, I run the text color vs background for each one.&lt;/p&gt;

&lt;p&gt;It is boring. It also exposed that some of the prettiest variants were the most hostile to real users. We adjusted the palette. The world kept turning. Nobody complained that the shade of red was not "brand authentic".&lt;/p&gt;

&lt;h2&gt;4. Interactive elements pretending to be divs&lt;/h2&gt;

&lt;p&gt;This one was straight up laziness. I thought I had grown out of custom clickable divs. Apparently not.&lt;/p&gt;

&lt;p&gt;We had a row of feature cards. Clickable, nice hover animation, whole card usable as a hit area. I built it with a &lt;code&gt;&amp;lt;div role="button"&amp;gt;&lt;/code&gt; wrapped around some content, added a &lt;code&gt;click&lt;/code&gt; handler, and made it submit a filter.&lt;/p&gt;

&lt;p&gt;The auditor flagged it as a keyboard trap because you could Tab into the card, press Enter, nothing happened. Space triggered click, Enter did not. Also there was no semantic connection between that "button" and the filter results that updated below. No ARIA live region, no announcement of the change.&lt;/p&gt;

&lt;p&gt;The simple fix: use a real &lt;code&gt;&amp;lt;button&amp;gt;&lt;/code&gt;. Or a &lt;code&gt;&amp;lt;a&amp;gt;&lt;/code&gt; link if it navigates. Let the browser give you the semantics, keyboard handling, and basic announcements for free.&lt;/p&gt;

&lt;p&gt;I know this. You know this. We all still ship rogue divs under time pressure.&lt;/p&gt;

&lt;p&gt;Change in my workflow: I now scan my own code for &lt;code&gt;role="button"&lt;/code&gt; during review. If I see it, I treat it as a smell. Ninety percent of the time, it is just a button that should have been a button.&lt;/p&gt;

&lt;h2&gt;5. Hidden labels behind clever UI&lt;/h2&gt;

&lt;p&gt;The search input in the header was the part of the design I felt most smug about. Minimal placeholder text, small icon, expands nicely on focus. Very tidy.&lt;/p&gt;

&lt;p&gt;The auditor noted that the input had no accessible name. I had hidden the visual label and not actually left a label element tied to the input with &lt;code&gt;for&lt;/code&gt; and &lt;code&gt;id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I had gone for the quick hack: placeholder as label. Screen readers treat that poorly, and placeholder text vanishes once you type. For someone using dictation or a screen reader, that label is how you know what the field is for when you come back to it.&lt;/p&gt;

&lt;p&gt;They showed me a screen reader log that literally read out: "Edit text, blank". No indication that it was a product search.&lt;/p&gt;

&lt;p&gt;Change in my workflow: now every input has a label. If the design wants it hidden, I use a visually hidden utility class, not an absent label. I also stopped using placeholder text as the only instruction. It is now, at best, secondary.&lt;/p&gt;

&lt;h2&gt;6. Motion that cannot be turned off&lt;/h2&gt;

&lt;p&gt;This one hurt because I love motion. Micro interactions are my candy. I spend time tuning easing curves and stagger timings.&lt;/p&gt;

&lt;p&gt;The site had a scroll-triggered animation sequence. Elements sliding in, fading, parallax on hero images. It all respected &lt;code&gt;prefers-reduced-motion&lt;/code&gt; in theory. Or so I thought.&lt;/p&gt;

&lt;p&gt;I had wrapped the main animations in a media query that checks for reduced motion. Cool. Except we later added a third-party library for one section that did not care about the user preference.&lt;/p&gt;

&lt;p&gt;The auditor tested with reduced motion set on the OS. The site still threw in a bunch of autoplaying motion as you scrolled through that section. Annoying for some people. Nausea-inducing for others.&lt;/p&gt;

&lt;p&gt;Change in my workflow: I now test with reduced motion enabled on my own machine at least once per feature. Not just trust my CSS. If I bring in a library, I check how to disable or tame its animations. If I cannot, I reconsider using it.&lt;/p&gt;

&lt;h2&gt;7. Error handling that assumes everyone can see red&lt;/h2&gt;

&lt;p&gt;Form errors were my silent shame. They worked visually. Red text below the field. Little icon. Subtle shake animation on submit.&lt;/p&gt;

&lt;p&gt;The auditor tested with color blindness simulation and with a screen reader. Two problems appeared immediately.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Color was the only indicator for error state. No icon or pattern or text like "Error:".&lt;/li&gt;
  &lt;li&gt;Focus did not move to the first invalid field when the form failed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Screen reader users hit submit, heard "Form submission failed", then nothing helpful. They had to hunt manually for what went wrong.&lt;/p&gt;

&lt;p&gt;The fix was mechanical. Wrap errors in &lt;code&gt;aria-live="polite"&lt;/code&gt; regions, move focus to the first error, add a clear text label like "Error: Email is required" instead of a vague "This field is required".&lt;/p&gt;

&lt;p&gt;Change in my workflow: I now test form failure states with a keyboard and with the dev tools color blindness filters. If I cannot find the error instantly without relying on red, it is not good enough.&lt;/p&gt;

&lt;h2&gt;What changed permanently in my process&lt;/h2&gt;

&lt;p&gt;The biggest lesson was not a single bug. It was the realisation that my informal "I know the basics" attitude created invisible cliffs for actual people.&lt;/p&gt;

&lt;p&gt;Here is what stuck and what I now do almost by default on client work.&lt;/p&gt;

&lt;h3&gt;1. One manual pass per feature&lt;/h3&gt;

&lt;p&gt;Every major feature now gets a dedicated accessibility pass before I call it done. Ten to fifteen minutes. Keyboard only. Reduced motion on. Screen reader on for the key flows.&lt;/p&gt;

&lt;p&gt;It is not a full audit. It is enough to catch the worst sins before an external auditor has to embarrass me again.&lt;/p&gt;

&lt;h3&gt;2. DOM order first, layout second&lt;/h3&gt;

&lt;p&gt;I stopped using &lt;code&gt;order&lt;/code&gt; and clever grid tricks to rearrange content just to hit a visual spec. I talk to designers earlier now.&lt;/p&gt;

&lt;p&gt;If the Figma layout forces weird DOM gymnastics, I push back. Not aggressively. Just with a clear explanation: "If we do it like this, screen readers will read the footer before the main content." That line works every time.&lt;/p&gt;

&lt;h3&gt;3. No more divs pretending to be controls&lt;/h3&gt;

&lt;p&gt;This became a hard rule for myself. If it is clickable and not a drag handle or some weird canvas thing, it must be a real button or a link.&lt;/p&gt;

&lt;p&gt;I am fine with extra wrappers for styling. I am not fine with rebuilding basic semantics from scratch because I am chasing a tiny CSS convenience.&lt;/p&gt;

&lt;h3&gt;4. States, not just components&lt;/h3&gt;

&lt;p&gt;Design systems love showing the perfect, static state of a component. Accessibility issues show up in the in-between states. Hover, active, loading, error.&lt;/p&gt;

&lt;p&gt;So I started building a little "state story" for important components. Button in all states, form in success and failure, dialog open and closing. Then I run through them with keyboard and screen reader.&lt;/p&gt;

&lt;h2&gt;The humility piece&lt;/h2&gt;

&lt;p&gt;Getting that audit back was not fun. It made me feel like a junior again. I had shipped a polished-looking site that still put real barriers in front of real users.&lt;/p&gt;

&lt;p&gt;On the other hand, that discomfort did something useful. It pushed accessibility out of the "nice to have" mental category and into "part of being a competent frontend dev" for me.&lt;/p&gt;

&lt;p&gt;I do not think you become good at this by reading long guidelines. You become good by having someone else try to use your work while you watch your assumptions crumble.&lt;/p&gt;

&lt;p&gt;If you have never had an external audit on something you are proud of, I recommend it. Not because it feels good. Because it will flatten the ego you have built around "I know the basics" and replace it with actual, practical habits.&lt;/p&gt;

&lt;p&gt;I know mine did.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Why I Still Test In Safari First (And The Bugs It Catches Early)</title>
      <dc:creator>Richard Lemon</dc:creator>
      <pubDate>Sat, 11 Jul 2026 07:14:58 +0000</pubDate>
      <link>https://dev.to/richardlemon/why-i-still-test-in-safari-first-and-the-bugs-it-catches-early-58oh</link>
      <guid>https://dev.to/richardlemon/why-i-still-test-in-safari-first-and-the-bugs-it-catches-early-58oh</guid>
      <description>&lt;h2&gt;Yes, I Still Open Safari First&lt;/h2&gt;

&lt;p&gt;I build and test new frontend work in Safari first. On purpose. Repeatedly. Sober.&lt;/p&gt;

&lt;p&gt;I know that sounds backwards. Everyone else ships for Chrome, patches for Firefox, then grudgingly opens Safari when QA files a bug with a YouTube link and zero details.&lt;/p&gt;

&lt;p&gt;I used to work like that too. Then I got tired of having my nicest builds break on the client’s actual devices. Which were almost always iPhones and iPads running whatever Safari Apple felt like shipping that month.&lt;/p&gt;

&lt;p&gt;So I flipped my workflow. Safari first, Chrome second, everything else after. It feels contrarian, but it has paid for itself in stress avoided and bugs caught before they became someone else’s problem.&lt;/p&gt;

&lt;h2&gt;Why Safari Is A Useful Enemy&lt;/h2&gt;

&lt;p&gt;Safari is stubborn. It sticks to specs when Chrome is “helpful”. It ships stuff slower. It has some… opinions.&lt;/p&gt;

&lt;p&gt;I treat that as a feature, not a flaw. Safari is a strict teacher. If something feels fragile in Safari, it usually &lt;em&gt;is&lt;/em&gt; fragile. Chrome just hides it with sugar and duct tape.&lt;/p&gt;

&lt;p&gt;Rough pattern I see:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Chrome: “Looks fine.”&lt;/li&gt;
  &lt;li&gt;Firefox: “That’s technically wrong but I will try.”&lt;/li&gt;
  &lt;li&gt;Safari: “You broke the rules. No.”&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That “No” is valuable. I want to hear it on day one, on my own machine, not in a client email that starts with “so we noticed on mobile…”&lt;/p&gt;

&lt;h2&gt;A Real Example: The Layout That Only Broke On iPad&lt;/h2&gt;

&lt;p&gt;A while back I shipped a fancy editorial layout. CSS Grid, variable fonts, a bit of scroll-linked animation. It looked beautiful in Chrome. Pixel perfect in Firefox. I was happy.&lt;/p&gt;

&lt;p&gt;Then I opened it in Safari on an iPad. The primary grid column collapsed to something like 40% width on orientation change. The sidebar became a weird floating island. Touch scroll felt sticky.&lt;/p&gt;

&lt;p&gt;The root cause was boring. I had a combination of:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;
&lt;code&gt;minmax()&lt;/code&gt; grid tracks with percentages&lt;/li&gt;
  &lt;li&gt;a flex wrapper around the grid for a layout experiment I had abandoned&lt;/li&gt;
  &lt;li&gt;some &lt;code&gt;height: 100vh&lt;/code&gt; elements that hated mobile Safari’s dynamic toolbar&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Chrome guessed my intent and kept the layout together. Safari followed the spec and smoked out all three problems in one go.&lt;/p&gt;

&lt;p&gt;If I had built in Chrome only, that bug would have surfaced &lt;em&gt;after&lt;/em&gt; going through content population, QA rounds, and probably some UAT demo. That kind of late-stage layout bug is brutal to fix quietly.&lt;/p&gt;

&lt;p&gt;By starting in Safari, I was forced to simplify the layout rules. I dropped the pointless flex wrapper, replaced the 100vh stuff with logical properties and calc, and tightened my min/max values. The layout got more robust on every browser as a side effect.&lt;/p&gt;

&lt;h2&gt;Safari Makes Me Choose My CSS More Carefully&lt;/h2&gt;

&lt;p&gt;When I start in Chrome, I reach for the shiny thing first. Subgrid, fancy masking, weird blend modes, random viewport units.&lt;/p&gt;

&lt;p&gt;When I start in Safari, I ask a different question:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What is the smallest, most boring CSS that still gets me the experience I want?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That question saves me from myself. A few patterns Safari-first testing has pushed into my workflow:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;I ship more layouts with plain Flexbox and classic Grid, less “spec tourism”.&lt;/li&gt;
  &lt;li&gt;I lean harder on intrinsic sizing and content flow instead of fixed heights and magic numbers.&lt;/li&gt;
  &lt;li&gt;I use fewer overlapping transform stacks and less hacky nesting that relies on “Chrome vibes” rather than predictable behavior.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The end result is a codebase that feels boring when you skim it, but keeps working when a browser team changes something in the rendering engine.&lt;/p&gt;

&lt;h2&gt;Forms And Inputs: Where Safari Refuses To Babysit You&lt;/h2&gt;

&lt;p&gt;Forms are where Safari-first really shines. Chrome is absurdly forgiving here. You can mess up types, attributes, focus states, and still get something “fine”.&lt;/p&gt;

&lt;p&gt;A few specific bugs that Safari surfaced early for me:&lt;/p&gt;

&lt;h3&gt;Focus Outlines And Custom Styles&lt;/h3&gt;

&lt;p&gt;I had a custom input style system. Tailored focus rings, accessible color contrast, nice transitions. Everything felt tight in Chrome.&lt;/p&gt;

&lt;p&gt;On Safari, tabbing through the form felt wrong. Focus outlines flickered, border-radius changed mid-focus, and some inputs lost their ring altogether on keyboard nav.&lt;/p&gt;

&lt;p&gt;The reason: I had hacked away default outlines inconsistently and relied on Chrome’s focus heuristics plus some internal timing. Safari did not play that game.&lt;/p&gt;

&lt;p&gt;Safari first forced me to implement a simple rule: I handle all focus styles myself, across the board, with predictable states. No mixing and matching browser defaults by accident.&lt;/p&gt;

&lt;h3&gt;Autofill, Dark Mode, And Input Backgrounds&lt;/h3&gt;

&lt;p&gt;Safari’s autofill and dark mode behavior is opinionated. Chrome quietly lets you live with slightly mismatched background and text colors. Safari goes straight to unreadable text if you are sloppy.&lt;/p&gt;

&lt;p&gt;By starting there, I fix color and background mismatches right away. The autofilled value has to be visible in the harshest condition on Safari first. Chrome ends up looking better as a side effect.&lt;/p&gt;

&lt;h2&gt;Mobile Safari Performance Bugs Chrome Hides&lt;/h2&gt;

&lt;p&gt;Desktop Chrome on a good machine is a lie. You can throw irresponsible amounts of JS, repaint-heavy transitions, and parallax nonsense at it and still get 60fps. Then you hand it to someone on an older iPhone and watch it chew their battery.&lt;/p&gt;

&lt;p&gt;Safari on iOS feels closer to reality. Less RAM. Tighter limits. Harsher scheduling. If something feels janky there with a cold cache, you built something heavy.&lt;/p&gt;

&lt;p&gt;Examples of what Safari-first performance checks have caught for me:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;
&lt;strong&gt;Scroll-linked animations&lt;/strong&gt; that hammered the main thread. Chrome smoothed them. Safari stuttered, especially with heavy images.&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;Oversized shadow and blur effects&lt;/strong&gt; on large elements that looked nice on desktop, but murdered Safari’s GPU on mobile.&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;Over-eager third-party scripts&lt;/strong&gt; loading in the critical path. Chrome absorbed the punch. Mobile Safari visibly froze for a moment.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My rule now: if it feels smooth in Safari on a mid-range iPhone, I am allowed to start tuning it for Chrome “delight”. Not the other way around.&lt;/p&gt;

&lt;h2&gt;The iOS Viewport Pain You Either Face Early Or Late&lt;/h2&gt;

&lt;p&gt;The viewport situation on iOS Safari is chaos. Dynamic toolbars. Keyboard pushing everything. Viewport units that tell the truth only half the time. Old news, but still painful.&lt;/p&gt;

&lt;p&gt;If you only ever test in desktop Chrome until late in the project, you will 100% get hit by this. The classic examples:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;“Full height” sections with &lt;code&gt;100vh&lt;/code&gt; that clip behind the address bar on iOS.&lt;/li&gt;
  &lt;li&gt;Sticky headers that un-stick while scrolling due to weird viewport changes.&lt;/li&gt;
  &lt;li&gt;Modals that are perfectly centered in Chrome and half off-screen when the iOS keyboard shows.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When I start in Safari, those are not “edge cases”. They are the baseline. I build the layout with the dynamic viewport in mind right away. That has pushed me to:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Favor content-driven vertical layouts over full-screen hero sections unless they truly earn it.&lt;/li&gt;
  &lt;li&gt;Use modern viewport units and fallbacks in a deliberate way instead of sprinkling &lt;code&gt;100vh&lt;/code&gt; everywhere.&lt;/li&gt;
  &lt;li&gt;Test keyboard + focus flows on iOS while the form markup is still easy to change.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It is annoying. But less annoying than redesigning a hero or modal flow after a client shows you an iPhone screenshot in a meeting.&lt;/p&gt;

&lt;h2&gt;Why Chrome-First Gives You A False Sense Of Security&lt;/h2&gt;

&lt;p&gt;Chrome is a confident liar. It tells you your layout is solid when it is secretly held together with heuristics and internal smoothing.&lt;/p&gt;

&lt;p&gt;The classic trap: you get a thumbs-up from DevTools, Lighthouse, and your own eyeballs in Chrome. You feel safe. Then reality hits:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;A design glitch report from a client demo on an iPad.&lt;/li&gt;
  &lt;li&gt;Strange tap targets on iOS because of unexpected relative positioning.&lt;/li&gt;
  &lt;li&gt;A weird gap or overlap that only happens at a specific Safari breakpoint with real content.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those bugs are expensive, not because they are technically hard, but because they arrive late. Late means:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Copy and imagery are already in.&lt;/li&gt;
  &lt;li&gt;Stakeholders are already attached to specific visuals.&lt;/li&gt;
  &lt;li&gt;Your own mental model of the layout is frozen, so deep changes feel dangerous.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Safari-first flips the script. I assume the strict parent is watching me from day one. It keeps the code simple and my ego in check.&lt;/p&gt;

&lt;h2&gt;My Actual Workflow, Step By Step&lt;/h2&gt;

&lt;p&gt;This is how I run things in practice on a new feature or layout.&lt;/p&gt;

&lt;h3&gt;1. Start In Safari Desktop&lt;/h3&gt;

&lt;p&gt;I open Safari on macOS, not Chrome, and build the layout there first. No feature flags, no prefixing, just raw HTML and CSS.&lt;/p&gt;

&lt;p&gt;I test:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Resizing the viewport aggressively. Small to huge, landscape to portrait.&lt;/li&gt;
  &lt;li&gt;Keyboard navigation, focus outlines, skip links.&lt;/li&gt;
  &lt;li&gt;Performance while throttling network and CPU in the dev tools.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If I catch myself thinking “ah, Chrome will probably handle this”, I stop and refactor the layout until Safari handles it cleanly.&lt;/p&gt;

&lt;h3&gt;2. Switch To iOS Safari Early&lt;/h3&gt;

&lt;p&gt;Before things look “done”, I airdrop the URL or use a local tunnel and open it on an iPhone and iPad. Real hardware, not just responsive mode.&lt;/p&gt;

&lt;p&gt;I check:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Orientation changes.&lt;/li&gt;
  &lt;li&gt;Scrolling, pull-to-refresh behavior, sticky stuff.&lt;/li&gt;
  &lt;li&gt;Forms with the keyboard open and closed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If something breaks here, I accept that as feedback on my architecture, not a “mobile quirk”. That mindset shift helps.&lt;/p&gt;

&lt;h3&gt;3. Then I Reward Myself With Chrome&lt;/h3&gt;

&lt;p&gt;Only when it feels solid in Safari do I open Chrome. That is when I let myself add the polish that Chrome does so well:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Subtle transforms.&lt;/li&gt;
  &lt;li&gt;Smoother transitions.&lt;/li&gt;
  &lt;li&gt;Optional enhancements that don’t break the core layout.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;By that point, the hard structural problems are solved. Chrome is just the candy coating.&lt;/p&gt;

&lt;h2&gt;But What About Feature Gaps?&lt;/h2&gt;

&lt;p&gt;Sometimes Safari simply does not have the shiny new CSS thing I want. Container queries used to be like that. Certain advanced mask and filter combos still are.&lt;/p&gt;

&lt;p&gt;My rule is simple. If the feature is non-essential, I treat it as progressive enhancement and move it behind a feature check or class. The core layout must work without it in Safari.&lt;/p&gt;

&lt;p&gt;If the feature is essential to the concept, I think hard about whether the concept is actually worth tying to a single browser engine. Usually it is not. When it is, it is a deliberate tradeoff, not a surprise late in the project.&lt;/p&gt;

&lt;h2&gt;Safari First, Not Safari Only&lt;/h2&gt;

&lt;p&gt;I am not a Safari fanboy. I keep multiple Chromes installed. I like Firefox a lot. I care about Edge for corporate contexts. This is not religion.&lt;/p&gt;

&lt;p&gt;Testing in Safari first is just a constraint that raises the floor. It forces me to write CSS and markup that can survive a stricter interpretation of the rules and the wonkiest mobile viewport in mainstream use.&lt;/p&gt;

&lt;p&gt;The upside is simple. I get fewer “urgent” bug tickets about layout issues. My builds age better when browser teams ship new stuff. Clients trust the work more because it behaves on the actual devices they hold in their hands.&lt;/p&gt;

&lt;p&gt;If you are tired of last-minute Safari bugs blowing up your timelines, try it for one project. Close Chrome. Open Safari. Build the next page there first.&lt;/p&gt;

&lt;p&gt;You will hate it for two days. Then the bugs it catches early will pay you back for months.&lt;/p&gt;

</description>
      <category>frontend</category>
      <category>ios</category>
      <category>testing</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Lazy Loading Images Almost Tanked My Core Web Vitals</title>
      <dc:creator>Richard Lemon</dc:creator>
      <pubDate>Sat, 11 Jul 2026 07:14:39 +0000</pubDate>
      <link>https://dev.to/richardlemon/lazy-loading-images-almost-tanked-my-core-web-vitals-4n4p</link>
      <guid>https://dev.to/richardlemon/lazy-loading-images-almost-tanked-my-core-web-vitals-4n4p</guid>
      <description>&lt;h2&gt;Lazy loading that made everything slower&lt;/h2&gt;

&lt;p&gt;I had a week where my Core Web Vitals looked like a slow-motion car crash.&lt;/p&gt;

&lt;p&gt;New layout. New images. New lazy loading strategy I felt pretty smart about. Lighthouse loved it locally. Then the real-world data arrived.&lt;/p&gt;

&lt;p&gt;Chrome UX Report came back with my LCP in the &lt;strong&gt;3.4s to 3.7s&lt;/strong&gt; range on mobile for some key pages. Before the redesign I was sitting around &lt;strong&gt;2.1s to 2.4s&lt;/strong&gt;. So I “optimized” and lost over a full second of LCP.&lt;/p&gt;

&lt;p&gt;The culprit was not big JavaScript. It was not fonts. It was me getting too aggressive with &lt;code&gt;loading="lazy"&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;The setup: what I changed that broke LCP&lt;/h2&gt;

&lt;p&gt;The site is simple.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Static content&lt;/li&gt;
  &lt;li&gt;Next.js, no heavy client-side routing&lt;/li&gt;
  &lt;li&gt;Mostly text, a few images per page&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The redesign added more visuals. Hero images, small illustrations, some inline screenshots. Classic trap: once you have more images, you feel obligated to lazy load everything because “performance”.&lt;/p&gt;

&lt;p&gt;So I did exactly that. I shipped a release where &lt;strong&gt;every image&lt;/strong&gt; had one of these attached:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;&amp;lt;img src="/images/post-hero.jpg" 
     alt="Post hero" 
     loading="lazy" /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;No exception for the hero. No exception for the image that always becomes LCP. Just blanket lazy loading, sprinkled like salt.&lt;/p&gt;

&lt;h2&gt;The real numbers: before and after the mess&lt;/h2&gt;

&lt;p&gt;This is the part I actually care about when reading posts like this, so here is mine.&lt;/p&gt;

&lt;h3&gt;Before the redesign&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;Homepage LCP (CrUX, mobile, 28-day median): &lt;strong&gt;2.2s&lt;/strong&gt;
&lt;/li&gt;
  &lt;li&gt;Blog post template LCP (mobile): &lt;strong&gt;2.3s&lt;/strong&gt;
&lt;/li&gt;
  &lt;li&gt;Good LCP share on mobile: around &lt;strong&gt;85% to 88%&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My LCP element was usually a heading or a small thumbnail. Nothing dramatic. Images were small and loaded early.&lt;/p&gt;

&lt;h3&gt;After the redesign + lazy everything&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;Homepage LCP (mobile): &lt;strong&gt;3.5s&lt;/strong&gt;
&lt;/li&gt;
  &lt;li&gt;Blog post template LCP (mobile): &lt;strong&gt;3.4s&lt;/strong&gt;
&lt;/li&gt;
  &lt;li&gt;Good LCP share on mobile dropped to around &lt;strong&gt;58% to 62%&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is the moment you stop feeling clever and start reading HAR files.&lt;/p&gt;

&lt;h3&gt;After the fix&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;Homepage LCP (mobile): &lt;strong&gt;2.3s to 2.5s&lt;/strong&gt;
&lt;/li&gt;
  &lt;li&gt;Blog post template LCP (mobile): &lt;strong&gt;2.4s to 2.6s&lt;/strong&gt;
&lt;/li&gt;
  &lt;li&gt;Good LCP share on mobile recovered to around &lt;strong&gt;86% to 90%&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I did not remove lazy loading. I just stopped being stupid with it.&lt;/p&gt;

&lt;h2&gt;How lazy loading actually interacts with LCP&lt;/h2&gt;

&lt;p&gt;Largest Contentful Paint is brutally simple. Chrome picks the largest visual element in the viewport and measures when it is fully rendered.&lt;/p&gt;

&lt;p&gt;On my pages that element was almost always the hero image. By putting &lt;code&gt;loading="lazy"&lt;/code&gt; on it, I told the browser: “Please wait until you think this is near the viewport before fetching it.”&lt;/p&gt;

&lt;p&gt;So the sequence looked like this, based on WebPageTest and performance panel traces:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;HTML downloaded&lt;/li&gt;
  &lt;li&gt;CSS downloaded and applied&lt;/li&gt;
  &lt;li&gt;Hero image discovered, but flagged as lazy&lt;/li&gt;
  &lt;li&gt;Intersection logic kicked in late on some devices&lt;/li&gt;
  &lt;li&gt;Actual image request started &lt;strong&gt;hundreds of milliseconds&lt;/strong&gt; later&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That extra gap was exactly the regression in my LCP. I had shifted the LCP asset from 
“first in line” to “whenever you get around to it”.&lt;/p&gt;

&lt;h2&gt;The three specific mistakes I made&lt;/h2&gt;

&lt;p&gt;This was not one bug. It was a cluster.&lt;/p&gt;

&lt;h3&gt;1. Lazy loading above-the-fold images&lt;/h3&gt;

&lt;p&gt;This is the obvious one. You read one article from 2019 that says “add lazy to all images” and you turn off your brain.&lt;/p&gt;

&lt;p&gt;My hero image was &lt;strong&gt;always&lt;/strong&gt; inside the initial viewport on mobile. That image should behave like a critical asset, not a nice-to-have below-the-fold picture of a cat.&lt;/p&gt;

&lt;p&gt;Once I removed &lt;code&gt;loading="lazy"&lt;/code&gt; from that hero, my lab tests showed LCP dropping from ~3.2s back to ~2.5s. Real users took a bit longer to reflect it, but the trend matched.&lt;/p&gt;

&lt;h3&gt;2. No explicit sizes, so layout shifted&lt;/h3&gt;

&lt;p&gt;On top of lazy loading, I also skipped proper &lt;code&gt;width&lt;/code&gt; and &lt;code&gt;height&lt;/code&gt;. Good combination, right.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;&amp;lt;img src="/images/post-hero.jpg" 
     alt="Post hero" 
     loading="lazy" /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The browser had no idea how much space to reserve. So the layout jumped once the hero image arrived. That made CLS worse, and it also gave the browser less confidence about when layout was stable enough to call LCP.&lt;/p&gt;

&lt;p&gt;Once I added sizing:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;&amp;lt;img src="/images/post-hero.jpg" 
     alt="Post hero" 
     width="1200" 
     height="630" /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;LCP stabilized. CLS went down from around &lt;strong&gt;0.13&lt;/strong&gt; to around &lt;strong&gt;0.02&lt;/strong&gt; on those pages. That matters for Core Web Vitals overall, not just for the LCP number.&lt;/p&gt;

&lt;h3&gt;3. Treating all images like they are equal&lt;/h3&gt;

&lt;p&gt;My first pass did not distinguish between hero, inline illustration, code screenshot, or footer logo. Same attribute on everything.&lt;/p&gt;

&lt;p&gt;Realistically I had three types of images:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;
&lt;strong&gt;Critical above-the-fold&lt;/strong&gt; (hero, key visual next to title)&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;Near-fold but not LCP&lt;/strong&gt; (first inline image, small icons)&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;Deep content&lt;/strong&gt; (later inline screenshots, bottom of article)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those three need different treatment. My markup did not reflect that at all.&lt;/p&gt;

&lt;h2&gt;The fixed strategy: simple rules that actually work&lt;/h2&gt;

&lt;p&gt;I am not interested in clever heuristics here. I want rules I can follow at 1 a.m. without thinking too hard.&lt;/p&gt;

&lt;h3&gt;Rule 1: Never lazy load the LCP candidate&lt;/h3&gt;

&lt;p&gt;On my pages, the likely LCP is almost always the hero image. So rule one is simple.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;&amp;lt;img src="/images/post-hero.jpg" 
     alt="Post hero" 
     width="1200" 
     height="630" 
     fetchpriority="high" /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;No lazy attribute at all. I also set &lt;code&gt;fetchpriority="high"&lt;/code&gt; to hint that this thing should start downloading early. That bumped the image fetch up in the waterfall by about 200 to 300 ms on throttled mobile tests.&lt;/p&gt;

&lt;p&gt;Result for real users:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Mobile LCP on blog posts moved from ~&lt;strong&gt;3.4s&lt;/strong&gt; to ~&lt;strong&gt;2.6s&lt;/strong&gt; in CrUX within a release cycle.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;Rule 2: Lazy load anything that starts fully below the fold&lt;/h3&gt;

&lt;p&gt;If an image is clearly below the fold on mobile, I do not feel bad about lazy loading it.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;&amp;lt;img src="/images/deep-screenshot.png" 
     alt="Deep in the article" 
     loading="lazy" 
     width="800" 
     height="450" /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;That includes later inline screenshots and section thumbnails further down.&lt;/p&gt;

&lt;p&gt;On my pages, this dropped total image bytes on initial load by 40 to 60 percent, depending on the article. More importantly, it did not touch LCP at all, because these images never become the largest element in the initial viewport.&lt;/p&gt;

&lt;h3&gt;Rule 3: Be careful with near-fold images&lt;/h3&gt;

&lt;p&gt;The tricky ones are images near the fold. On a tall screen they are above the fold, on a smaller device they are not.&lt;/p&gt;

&lt;p&gt;I experimented with laziness on these and saw some nasty LCP spikes for certain viewports. In the end I went conservative.&lt;/p&gt;

&lt;p&gt;If an image appears:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;in the first section after the hero, and&lt;/li&gt;
  &lt;li&gt;wide enough that it can realistically become LCP on smaller screens&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then I treat it like a critical image and &lt;strong&gt;do not&lt;/strong&gt; lazy load it. In HTML that looks like a normal image tag with proper sizing and &lt;code&gt;fetchpriority="auto"&lt;/code&gt; (or just omitted).&lt;/p&gt;

&lt;p&gt;That choice stabilized my LCP distribution. Worst-case LCP outliers on mobile dropped from over &lt;strong&gt;6s&lt;/strong&gt; down to around &lt;strong&gt;3.8s&lt;/strong&gt; for slower connections.&lt;/p&gt;

&lt;h2&gt;How I validated the fixes&lt;/h2&gt;

&lt;p&gt;I do not trust local Lighthouse scores since this mess. They are fine for rough direction, but my regressions did not show there until it was already in production.&lt;/p&gt;

&lt;p&gt;Here is what I actually used.&lt;/p&gt;

&lt;h3&gt;1. WebPageTest: see the waterfall&lt;/h3&gt;

&lt;p&gt;I set up tests for a couple of representative pages. Throttled mobile, 4G, real browser. I wanted to see:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;When the HTML finished&lt;/li&gt;
  &lt;li&gt;When CSS finished&lt;/li&gt;
  &lt;li&gt;When the hero image request started&lt;/li&gt;
  &lt;li&gt;When the hero image finished&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Before the fix, the hero request sat awkwardly late in the timeline. Sometimes it started more than a second after the initial HTML. After I removed lazy loading and added &lt;code&gt;fetchpriority="high"&lt;/code&gt;, that request started almost immediately after CSS.&lt;/p&gt;

&lt;h3&gt;2. Chrome DevTools performance panel: confirm LCP element&lt;/h3&gt;

&lt;p&gt;I recorded a couple of traces on mobile emulation and inspected the “Largest Contentful Paint” event.&lt;/p&gt;

&lt;p&gt;During the broken version, LCP was usually the hero image but with a timestamp around 3.1s to 3.4s. After the fix, the same element had timestamps around 1.9s to 2.3s under similar conditions.&lt;/p&gt;

&lt;p&gt;The key was seeing the correlation between the start of the image request and the LCP timestamp. You want those close together, not separated by half a second of nothing.&lt;/p&gt;

&lt;h3&gt;3. CrUX and Search Console: real user confirmation&lt;/h3&gt;

&lt;p&gt;The real test was Chrome UX Report and Search Console’s Core Web Vitals report. Those lag behind by a few days, so I had to wait.&lt;/p&gt;

&lt;p&gt;The pattern looked like this:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Week 0: shipped redesign with aggressive lazy loading&lt;/li&gt;
  &lt;li&gt;Week 1: “Good” LCP share on mobile dropped from ~88% to ~60%&lt;/li&gt;
  &lt;li&gt;Week 2: shipped fix (no lazy on hero, sizes, fetchpriority)&lt;/li&gt;
  &lt;li&gt;Week 3: “Good” LCP share on mobile climbed back to ~86%&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is the part that matters for SEO and for users actually feeling the speed.&lt;/p&gt;

&lt;h2&gt;The mental model I use now&lt;/h2&gt;

&lt;p&gt;I treat image loading less like a generic optimization and more like traffic control.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;
&lt;strong&gt;Fast lane&lt;/strong&gt;: hero and early large visuals. No lazy. Sometimes &lt;code&gt;fetchpriority="high"&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;Middle lane&lt;/strong&gt;: near-fold images that could become LCP on some devices. Usually not lazy, conservative approach.&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;Slow lane&lt;/strong&gt;: clearly below-the-fold images. &lt;code&gt;loading="lazy"&lt;/code&gt;, full width and height, no guilt.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That model kept my LCP honest while still saving bandwidth further down the page.&lt;/p&gt;

&lt;h2&gt;If you want a quick checklist&lt;/h2&gt;

&lt;p&gt;If you are skimming this for the parts that matter, here is what actually moved my metrics.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Remove &lt;code&gt;loading="lazy"&lt;/code&gt; from your hero or any likely LCP element.&lt;/li&gt;
  &lt;li&gt;Add explicit &lt;code&gt;width&lt;/code&gt; and &lt;code&gt;height&lt;/code&gt; on every image.&lt;/li&gt;
  &lt;li&gt;Use &lt;code&gt;fetchpriority="high"&lt;/code&gt; on your main hero image.&lt;/li&gt;
  &lt;li&gt;Lazy load only images that are safely below the fold on most devices.&lt;/li&gt;
  &lt;li&gt;Validate with WebPageTest and real CrUX data, not just local Lighthouse.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is not fancy. It is boring markup. But boring markup got me from a broken &lt;strong&gt;3.5s LCP&lt;/strong&gt; back into the comfortable &lt;strong&gt;2.4s-ish&lt;/strong&gt; zone on mobile.&lt;/p&gt;

&lt;p&gt;If your Core Web Vitals suddenly look worse right after you “optimize” images, check your lazy loading first. I learned that lesson the hard way, so you maybe do not have to.&lt;/p&gt;

</description>
      <category>frontend</category>
      <category>html</category>
      <category>performance</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
