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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

.spec-value {
  color: #344054;
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  .nav-toggle {
    display: none;
  }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

</description>
      <category>automation</category>
      <category>productivity</category>
      <category>tools</category>
    </item>
    <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>
  </channel>
</rss>
