<?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: Tarek Elzoghby | طارق الزغبي</title>
    <description>The latest articles on DEV Community by Tarek Elzoghby | طارق الزغبي (@tarek_elzoghby).</description>
    <link>https://dev.to/tarek_elzoghby</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%2F4083487%2F0adbe41a-0092-41a4-8292-e6818065d079.png</url>
      <title>DEV Community: Tarek Elzoghby | طارق الزغبي</title>
      <link>https://dev.to/tarek_elzoghby</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tarek_elzoghby"/>
    <language>en</language>
    <item>
      <title>The aria-live Message That Didn't Announce Itself</title>
      <dc:creator>Tarek Elzoghby | طارق الزغبي</dc:creator>
      <pubDate>Wed, 04 Nov 2026 08:09:25 +0000</pubDate>
      <link>https://dev.to/tarek_elzoghby/the-aria-live-message-that-didnt-announce-itself-4dh4</link>
      <guid>https://dev.to/tarek_elzoghby/the-aria-live-message-that-didnt-announce-itself-4dh4</guid>
      <description>&lt;p&gt;This is the contact form on my own portfolio site, the first project where I used JavaScript. I built it with an AI design partner, which reviewed my code, explained concepts, and helped me debug. The code is in &lt;a href="https://github.com/Tarek-Elzoghby/frontend-portfolio" rel="noopener noreferrer"&gt;the repo&lt;/a&gt; and the site is &lt;a href="https://tarek-elzoghby.github.io/frontend-portfolio/" rel="noopener noreferrer"&gt;live&lt;/a&gt;. This post is about one part of the form: the message that appears after you press "Send message", and why it took two attempts to get a screen reader to announce it.&lt;/p&gt;

&lt;p&gt;The form validates in JavaScript, then sends the data to Formspree with &lt;code&gt;fetch()&lt;/code&gt;. After that, the page tells the visitor one of two things: the message went through, or something went wrong. A sighted visitor sees the text appear. A screen reader user only hears it if the page announces it, which is what &lt;code&gt;aria-live&lt;/code&gt; is for.&lt;/p&gt;

&lt;h2&gt;
  
  
  First attempt: aria-live on the paragraph I was already creating
&lt;/h2&gt;

&lt;p&gt;At that point my code built the success and failure messages as new paragraphs. Create the element, set its text, add &lt;code&gt;aria-live="polite"&lt;/code&gt;, append it to the page. I chose polite over assertive because a form result isn't urgent enough to interrupt whatever the screen reader is in the middle of reading.&lt;/p&gt;

&lt;h2&gt;
  
  
  What VoiceOver did
&lt;/h2&gt;

&lt;p&gt;I tested it with VoiceOver. The message appeared on screen, but VoiceOver didn't read it when I submitted. It read it only when I moved onto it myself, by hovering or navigating. The attribute was in the code and the text was right, and the result was still silence at the moment it mattered.&lt;/p&gt;

&lt;h2&gt;
  
  
  The explanation we settled on
&lt;/h2&gt;

&lt;p&gt;Working through it with my design partner, the explanation came out like this. A live region announces changes to content inside a region that already exists in the accessibility tree. A paragraph added with &lt;code&gt;appendChild&lt;/code&gt; arrives as a new element with its content already inside it, and screen readers don't consistently treat that as a change in a live region, even when the attribute is set. So the attribute was correct and the setup around it was wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: a region that exists before its content does
&lt;/h2&gt;

&lt;p&gt;One permanent, empty paragraph in the HTML, present from page load:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;p&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"form-status"&lt;/span&gt; &lt;span class="na"&gt;aria-live=&lt;/span&gt;&lt;span class="s"&gt;"polite"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I stopped creating and removing elements entirely. The two result functions now only change the text and class of that one element:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;onFormSuccess&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;submitStateMessage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Thanks for reaching out! We have received your message and will get back to you&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;submitStateMessage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error-status-message&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;submitStateMessage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;success-message&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;onFormFailure&lt;/span&gt; &lt;span class="p"&gt;(){&lt;/span&gt;
  &lt;span class="nx"&gt;submitStateMessage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Something went wrong on our end. Try again, or reach out directly using the links below.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;submitStateMessage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;success-message&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;submitStateMessage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error-status-message&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The region itself never moves. Only what's inside it changes. I ran it in VoiceOver again, and on a successful submission the message was announced immediately, without me navigating to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug I introduced while wiring it up
&lt;/h2&gt;

&lt;p&gt;Those two functions each remove the other state's class before adding their own, and they only do that now because the first version didn't. It added classes and never removed any, so a failure right after a success left both &lt;code&gt;success-message&lt;/code&gt; and &lt;code&gt;error-status-message&lt;/code&gt; on the element at once instead of swapping. The validation-failure branch had the same hole. It cleared the text but left whichever class the last attempt had set. Now it clears both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;submitStateMessage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt; &lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;submitStateMessage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;success-message&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error-status-message&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the same kind of bug I'd already fixed earlier in the same form, where an invalid-email message stayed on screen after the field was cleared. Both were leftover state from a previous attempt. I tested this fix by triggering a failure straight after a success and checking that only the failure state was showing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this doesn't show
&lt;/h2&gt;

&lt;p&gt;I tested with VoiceOver only. I haven't tried NVDA, JAWS, or TalkBack, so I can't say whether they handle an appended live region differently.&lt;/p&gt;

&lt;p&gt;The three field error spans also have &lt;code&gt;aria-live="polite"&lt;/code&gt;. They already existed in the HTML, and they announced correctly when I tested them in VoiceOver. They're hidden with &lt;code&gt;display: none&lt;/code&gt; and revealed by JavaScript, and I haven't worked out why they announced correctly when the appended paragraph didn't.&lt;/p&gt;

&lt;p&gt;I haven't run an automated checker against this, and I don't know of one that would have caught it. A tool can tell you the attribute exists. It can't tell you whether anything was announced. I ran into the same limit with Lighthouse in &lt;a href="https://tarek-elzoghby.hashnode.dev/what-a-100-accessibility-score-didn-t-check" rel="noopener noreferrer"&gt;an earlier post&lt;/a&gt;, and here too I only found the problem by listening.&lt;/p&gt;

</description>
      <category>a11y</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>beginners</category>
    </item>
    <item>
      <title>I Solved the Same Gmail Problem Two Ways Without Noticing</title>
      <dc:creator>Tarek Elzoghby | طارق الزغبي</dc:creator>
      <pubDate>Wed, 28 Oct 2026 08:17:06 +0000</pubDate>
      <link>https://dev.to/tarek_elzoghby/i-solved-the-same-gmail-problem-two-ways-without-noticing-1d4i</link>
      <guid>https://dev.to/tarek_elzoghby/i-solved-the-same-gmail-problem-two-ways-without-noticing-1d4i</guid>
      <description>&lt;p&gt;This is the third post from my n8n intake classifier, a practice project for a fictional automation agency that I built with an AI collaborator. The first was about &lt;a href="https://tarek-elzoghby.hashnode.dev/each-fix-to-my-classification-prompt-exposed-the-next-problem" rel="noopener noreferrer"&gt;the classification prompt&lt;/a&gt;. The second was about &lt;a href="https://tarek-elzoghby.hashnode.dev/ambiguous-invalid-or-system-error" rel="noopener noreferrer"&gt;ambiguous, invalid and system_error&lt;/a&gt;. This one is about the wiring after classification: where logging goes, what the Gmail node does to your data, and a bug I only found by opening real inboxes. The workflow is in the &lt;a href="https://github.com/Tarek-Elzoghby/client-intake-classifier" rel="noopener noreferrer"&gt;GitHub repository&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where logging was supposed to go
&lt;/h2&gt;

&lt;p&gt;After classification, a Switch node sends each submission down one of five paths, and each path sends emails. Every submission also gets logged to Google Sheets. For this phase I planned to work the way I had in the earlier ones: Sheets first, then emails, one branch at a time.&lt;/p&gt;

&lt;p&gt;Before building anything, I caught a conflict. Logging is the same logic on all five paths, and only the data changes. Putting it first inside each branch meant five copies of one node, or converging after Sheets and then branching again to send category-specific emails. Neither made sense. So I flipped the order: build the emails first, since those differ per category, and let all five paths converge on one shared Sheets node at the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gmail replaces your data
&lt;/h2&gt;

&lt;p&gt;The first branch I built showed a second problem. When the Gmail node sends an email, the item it passes on is Gmail's own send confirmation, not the submission. Name, Email and description were gone by the time anything downstream needed them. The LLM Chain node had done the same thing to me earlier.&lt;/p&gt;

&lt;p&gt;In the ambiguous/system_error branch, I added a Set node right after the internal notification to rebuild one clean object. The IF node and both client emails read from that, instead of each reaching back to whichever earlier node had the data. In simple_automation, I added a Set node after the client email to rebuild the object before it reached the shared Sheets node.&lt;/p&gt;

&lt;h2&gt;
  
  
  Noticing I'd solved it twice
&lt;/h2&gt;

&lt;p&gt;While wiring the final Sheets node, I looked at the ambiguous/system_error branch and saw it fed Sheets from two points off the same rebuild node, one path through the client emails and one straight to Sheets. That wasn't a bug. The email path never reconnected to Sheets, so nothing was logged twice. But it showed me I had solved the Gmail problem two different ways. Everywhere else I rebuilt the data after the email. In this branch I branched before the email, because Sheets never needed anything the email step produced.&lt;/p&gt;

&lt;h2&gt;
  
  
  Moving logging upstream
&lt;/h2&gt;

&lt;p&gt;That comparison pointed at something bigger. Every branch converged on one Sheets node, so there was no reason logging should depend on any email succeeding. A Gmail send can fail for reasons that have nothing to do with the submission.&lt;/p&gt;

&lt;p&gt;I moved the Sheets connection to the two points right after classification: one for a successful classification and one for the failure handler. Both now feed the Switch and Sheets in parallel. Two side effects followed. &lt;code&gt;has_partial_intent&lt;/code&gt; only had to be added in two places instead of four, and I could delete the end-of-branch Set nodes that existed only to feed the old Sheets wiring.&lt;/p&gt;

&lt;p&gt;Before deleting any of them, I checked each one. Some still fed a client email's own data, and removing one of those would have broken a client-facing email without touching Sheets. I kept those.&lt;/p&gt;

&lt;p&gt;The final build still contains both patterns. The ambiguous/system_error branch has its rebuild node, and the other client emails reach back to earlier nodes by name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;={{ $('Route: By Classification Category').item.json.Email }}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I haven't compared whether one pattern is more robust than the other. What changed is that logging no longer depends on either.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking real inboxes
&lt;/h2&gt;

&lt;p&gt;After the rewiring, I retested every branch individually and checked real inboxes, not just node output. That caught a bug in the ai_workflow branch: the internal notification and the client email had their destinations swapped. The internal-only message went to the client, and the client's confirmation went to the team.&lt;/p&gt;

&lt;p&gt;Both emails sent without errors, so nothing had failed that would make me suspect a problem. Only who received what showed it. I then confirmed the simple_automation branch had no swap, since I'd used the same inbox check there.&lt;/p&gt;

&lt;p&gt;My notes don't record how the swap got there, or whether the rewiring caused it, so I won't guess. The current build sends each internal email to the team address and each client email to the submitter.&lt;/p&gt;

&lt;h2&gt;
  
  
  A test case I wrote wrong
&lt;/h2&gt;

&lt;p&gt;While testing &lt;code&gt;ambiguous&lt;/code&gt;, my first case was a deliberately vague description. It came back as &lt;code&gt;invalid&lt;/code&gt;, and I took that as a bug. Reading the reasoning showed the classifier was right. Vague wording alone doesn't give any category a specific signal to rest on, which is a different situation from two categories each being concretely supported. I wrote a sharper case with explicit signals for both &lt;code&gt;ai_workflow&lt;/code&gt; and &lt;code&gt;custom_integration&lt;/code&gt;, and that one returned &lt;code&gt;ambiguous&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this doesn't show
&lt;/h2&gt;

&lt;p&gt;Every test used descriptions I wrote. A forced classification failure does produce a row in the sheet, with the &lt;code&gt;has_partial_intent&lt;/code&gt; cell empty, because the model never produced a value on that path. I did not test what happens if the Sheets step itself fails.&lt;/p&gt;

&lt;p&gt;What I take from it is narrow: logging shouldn't wait on anything that can fail for unrelated reasons, and a branch that sends emails isn't tested until I've read the emails.&lt;/p&gt;

</description>
      <category>automation</category>
      <category>debugging</category>
      <category>testing</category>
      <category>programming</category>
    </item>
    <item>
      <title>Ambiguous, Invalid, or System Error?</title>
      <dc:creator>Tarek Elzoghby | طارق الزغبي</dc:creator>
      <pubDate>Tue, 20 Oct 2026 09:54:40 +0000</pubDate>
      <link>https://dev.to/tarek_elzoghby/ambiguous-invalid-or-system-error-41gb</link>
      <guid>https://dev.to/tarek_elzoghby/ambiguous-invalid-or-system-error-41gb</guid>
      <description>&lt;p&gt;In my &lt;a href="https://tarek-elzoghby.hashnode.dev/each-fix-to-my-classification-prompt-exposed-the-next-problem" rel="noopener noreferrer"&gt;previous post&lt;/a&gt;, I wrote about the problems I found while defining the categories for an AI intake classifier. I left &lt;code&gt;ambiguous&lt;/code&gt; and &lt;code&gt;invalid&lt;/code&gt; for another post.&lt;/p&gt;

&lt;p&gt;Those two states turned out to be only part of the problem. While wiring the workflow, I also needed to distinguish a request the model could not classify from a request the model never managed to evaluate.&lt;/p&gt;

&lt;p&gt;This is the same practice project for a fictional automation agency, built in n8n with an AI collaborator. It is not a production client system. The &lt;a href="https://github.com/Tarek-Elzoghby/client-intake-classifier" rel="noopener noreferrer"&gt;workflow and project notes are in the GitHub repository&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three different reasons for not choosing a project category
&lt;/h2&gt;

&lt;p&gt;The classifier had three normal project categories:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;simple_automation&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;custom_integration&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ai_workflow&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But not every submission should be forced into one of them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;ambiguous&lt;/code&gt; means the request is usable, but the text supports more than one category.&lt;/strong&gt; The workflow should not pretend that a guess is a confident classification. In my sharper overlap test, I deliberately included concrete signals for both &lt;code&gt;ai_workflow&lt;/code&gt; and &lt;code&gt;custom_integration&lt;/code&gt;. The classifier returned &lt;code&gt;ambiguous&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;invalid&lt;/code&gt; means the input does not provide enough usable project intent to assign a normal category.&lt;/strong&gt; This does not necessarily mean the person has no genuine request. A vague but relevant message can still contain partial intent. On the other hand, an out-of-scope message may contain nothing useful to route.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;system_error&lt;/code&gt; means classification did not complete.&lt;/strong&gt; The workflow did not reach a decision about the request, so treating it as invalid would report something the system did not know.&lt;/p&gt;

&lt;p&gt;The difference matters because each state calls for a different response. A real request with overlapping categories may need a person to decide. An out-of-scope submission may not need a client email. A system failure needs troubleshooting or a retry, not a judgment about the request itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;invalid&lt;/code&gt; needed one more field
&lt;/h2&gt;

&lt;p&gt;My first definition of &lt;code&gt;invalid&lt;/code&gt; treated missing information as the main signal. That overlapped with &lt;code&gt;ambiguous&lt;/code&gt;, because both could describe a submission that could not be assigned cleanly. I changed the distinction: &lt;code&gt;ambiguous&lt;/code&gt; is for multiple categories supported by the text; &lt;code&gt;invalid&lt;/code&gt; is for input that does not support a responsible category assignment.&lt;/p&gt;

&lt;p&gt;Then I noticed that one &lt;code&gt;invalid&lt;/code&gt; label was still hiding two different situations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The person has a real project in mind, but the request is too vague to classify yet.&lt;/li&gt;
&lt;li&gt;The submission is unrelated or out of scope, with no useful project intent to follow up on.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I could have tried routing by keywords in the model's &lt;code&gt;reasoning&lt;/code&gt; text, but that would make the workflow depend on the exact wording of an explanation. I also considered a conditional schema. Because the result passed through n8n's Structured Output Parser, I did not want to assume that conditional-schema behavior would be preserved through its translation layer.&lt;/p&gt;

&lt;p&gt;I chose a simpler, always-present boolean: &lt;code&gt;has_partial_intent&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"invalid"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"has_partial_intent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That keeps &lt;code&gt;invalid&lt;/code&gt; as the category and makes the next decision explicit. It is easier for a downstream node to route on a boolean than to interpret free-text reasoning.&lt;/p&gt;

&lt;p&gt;I tested it with three small examples:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;help automating my workflow&lt;/code&gt; returned &lt;code&gt;has_partial_intent: true&lt;/code&gt;. The request was vague, but it did point to a real automation need.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;fix my car&lt;/code&gt; returned &lt;code&gt;false&lt;/code&gt;. It was a request, but not one this intake workflow was built to handle.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;I love football&lt;/code&gt; also returned &lt;code&gt;false&lt;/code&gt; as unrelated input.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These examples helped check the distinction I had defined. They are not a representative dataset, and they do not tell me how often the classifier would get this right with real submissions.&lt;/p&gt;

&lt;h2&gt;
  
  
  A model failure is not an invalid submission
&lt;/h2&gt;

&lt;p&gt;I also needed &lt;code&gt;system_error&lt;/code&gt; for cases where classification itself failed. A network interruption or a broken model credential should not make the workflow label a valid request as invalid. The system has not made a decision about the content at all.&lt;/p&gt;

&lt;p&gt;I tested the failure path by temporarily breaking the Gemini credentials. The workflow retried classification three times at five-second intervals, then reached the fallback route for human review. I also checked that the original submission data remained available on that path.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;ambiguous&lt;/code&gt; and &lt;code&gt;system_error&lt;/code&gt; routes shared some downstream handling, but I kept the category values separate. The reason for sending something to human review is important: in one case the model made a decision that needs human judgment; in the other, it did not complete the decision.&lt;/p&gt;

&lt;p&gt;That distinction affected the client-facing message too. For a system failure, it would have been misleading to send the same confirmation as if a normal classification had succeeded. The workflow needed to acknowledge the issue without claiming the request was invalid.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I used explicit routing
&lt;/h2&gt;

&lt;p&gt;I had considered using an AI Agent to choose how these outcomes should be handled. I did not need another model-driven decision, though. The possible states were already known, and the workflow's job was to route each one consistently.&lt;/p&gt;

&lt;p&gt;I used explicit Switch and IF branches instead. That made the expected paths visible in the workflow and easier to test. For the &lt;code&gt;invalid&lt;/code&gt; case with &lt;code&gt;has_partial_intent: false&lt;/code&gt;, I confirmed that no email was sent while the submission was still logged to Google Sheets. Logging did not depend on sending a client message.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I can claim from these tests
&lt;/h2&gt;

&lt;p&gt;I exercised a deliberately ambiguous request, vague and out-of-scope inputs, and a forced model failure. I also checked the fallback path and verified that the invalid/no-partial-intent case was logged without triggering emails.&lt;/p&gt;

&lt;p&gt;That is evidence that these particular paths were exercised in this build. It is not proof of general classifier accuracy or production reliability. The examples were written by me, and a larger, independently labelled set of realistic submissions would be needed to measure that properly.&lt;/p&gt;

&lt;p&gt;The useful lesson for me was that a fallback is not just a place to send everything that does not match a normal category. The workflow needs to preserve &lt;em&gt;why&lt;/em&gt; it did not choose a category: competing interpretations, insufficient or irrelevant intent, or a failure to classify at all.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: I used AI assistance while editing this article. The workflow design, tests, and conclusions describe my own build and observations.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>testing</category>
      <category>automation</category>
      <category>programming</category>
    </item>
    <item>
      <title>Each Fix to My Classification Prompt Exposed the Next Problem</title>
      <dc:creator>Tarek Elzoghby | طارق الزغبي</dc:creator>
      <pubDate>Tue, 13 Oct 2026 11:58:21 +0000</pubDate>
      <link>https://dev.to/tarek_elzoghby/each-fix-to-my-classification-prompt-exposed-the-next-problem-49p6</link>
      <guid>https://dev.to/tarek_elzoghby/each-fix-to-my-classification-prompt-exposed-the-next-problem-49p6</guid>
      <description>&lt;p&gt;This comes from my first n8n project with an LLM in it: an intake classifier for an automation agency. It's a practice brief, so the agency and the client are fictional, and I built it with an AI collaborator. The workflow and my full process notes are in the &lt;a href="https://github.com/Tarek-Elzoghby/client-intake-classifier" rel="noopener noreferrer"&gt;GitHub repo&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;A client fills in a form, Gemini reads the project description through a Basic LLM Chain with a structured output parser, and the submission gets sorted into a category that decides which emails go out and what gets logged to Google Sheets. This post is only about the classification prompt, and about a handful of test descriptions that kept sending me back into it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it started
&lt;/h2&gt;

&lt;p&gt;Three of the five categories are real project types: &lt;code&gt;simple_automation&lt;/code&gt;, &lt;code&gt;ai_workflow&lt;/code&gt; and &lt;code&gt;custom_integration&lt;/code&gt;. The other two, &lt;code&gt;ambiguous&lt;/code&gt; and &lt;code&gt;invalid&lt;/code&gt;, are for submissions that don't sort cleanly. I'm leaving those for another post.&lt;/p&gt;

&lt;p&gt;My first definition of &lt;code&gt;simple_automation&lt;/code&gt; allowed exactly one action off a trigger. That seemed reasonable until I tested it with a description I'd picked as the easiest case:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;when a client submits the form on my website, notify me and log it to a sheet&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It came back &lt;code&gt;ambiguous&lt;/code&gt;. Notifying me and logging to a sheet is two actions, and my definition only allowed one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 1: I'd defined the category by counting
&lt;/h2&gt;

&lt;p&gt;Raising the limit to two actions would have been a patch. The real problem was that counting actions doesn't separate the three categories at all. What separates them is whether there's conditional logic, whether data is actually transformed, and whether AI reasoning is involved. I rewrote all three definitions around those, and the &lt;code&gt;simple_automation&lt;/code&gt; one now says so directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;simple_automation: One or more actions fire in a fixed, uniform way off a
single trigger. [...] No conditional branching (no "if X then Y, else Z"),
no computed or derived values, and no AI/LLM reasoning involved. The number
of destinations touched does not matter [...]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Problem 2: the model had to commit before it could compare
&lt;/h2&gt;

&lt;p&gt;The next test was "route incoming leads smartly based on their details". I read the reasoning field on that one, and it said it saw two valid readings and picked one anyway.&lt;/p&gt;

&lt;p&gt;Working out why, I realized it was my prompt's doing. It evaluated the categories in sequence, so the model had to commit to one before it ever checked whether another also fit. Genuine overlaps were being settled by the model's best guess instead of reaching &lt;code&gt;ambiguous&lt;/code&gt;. I rebuilt the evaluation so each category is assessed independently and the model counts how many plausibly fit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Do not decide sequentially. Instead:
[...]
3. Count how many categories plausibly fit:
   - Exactly one fits -&amp;gt; assign that category.
   - Two or more fit, and the description does not specify enough to pick
     between them -&amp;gt; assign 'ambiguous'.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I also added a line to the prompt saying that "more common in practice" is a guess about unstated intent, which is exactly what &lt;code&gt;ambiguous&lt;/code&gt; exists to catch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 3: the schema asked for the answer before the reasoning
&lt;/h2&gt;

&lt;p&gt;My schema also listed &lt;code&gt;category&lt;/code&gt; before &lt;code&gt;reasoning&lt;/code&gt;, so the model was structurally being asked for its answer before it had written the explanation that was supposed to inform it. I swapped them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"properties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"reasoning"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"enum"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"simple_automation"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ai_workflow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"custom_integration"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ambiguous"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"invalid"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"required"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"reasoning"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Trimmed to the two fields that matter here.) I changed the order and kept testing. I have no record of testing that change by itself, so I can't say how much of the later improvement comes from it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 4: the fix overcorrected
&lt;/h2&gt;

&lt;p&gt;With the new wording, the model started treating "automatically" as a possible AI signal. That word just means "not manual", and it appears in nearly every automation request, so it would push ordinary requests toward &lt;code&gt;ambiguous&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I tightened the definitions so each category only counts as plausible if the text contains a specific word or phrase that grounds it, and I named "automatically" and similar words as things that don't count. I also added a rule aimed at the habit I'd just watched:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;If you find yourself justifying a category with "could imply" or "might
involve" without pointing to a specific word or phrase that drove that
reading, do not count it
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Cleaning up
&lt;/h2&gt;

&lt;p&gt;The old &lt;code&gt;[TIE-BREAKING]&lt;/code&gt; section was still in the prompt. After the evaluation rewrite it repeated the same logic less precisely, so I removed it and reran both the original failing case and a genuinely ambiguous one to confirm each landed where it should.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking the boundaries
&lt;/h2&gt;

&lt;p&gt;I wrote two more tests aimed at the seams between categories instead of one category at a time.&lt;/p&gt;

&lt;p&gt;The first stacked signals on purpose: conditional branching ("if it's not feedback, stop"), several systems (Gmail, a data table, Slack), and clear AI work (sentiment extraction, summarizing, drafting a reply). The reasoning field weighed the branching signals against the AI ones and landed on &lt;code&gt;ai_workflow&lt;/code&gt;, using my rule that &lt;code&gt;custom_integration&lt;/code&gt; applies when the logic is driven by fixed rules, not AI reasoning. That rule is my call. A description with both could reasonably be argued the other way, so the result shows the prompt follows my rule, not that the rule is correct.&lt;/p&gt;

&lt;p&gt;The second was the opposite check: budget-based lead routing across HubSpot, Slack and Mailchimp, with no AI language at all. I wanted to see whether multi-system complexity alone would get pulled into &lt;code&gt;ai_workflow&lt;/code&gt;. It came back &lt;code&gt;custom_integration&lt;/code&gt;, with the reasoning ruling out both other categories using the exclusion wording.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this doesn't show
&lt;/h2&gt;

&lt;p&gt;Every test above is a single description I wrote myself. That was enough to find four problems in the prompt, and not enough to say the prompt is reliable. A real set of varied inputs is the next thing it needs.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>promptengineering</category>
      <category>automation</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Three Ways My Scroll Arrow Went Wrong</title>
      <dc:creator>Tarek Elzoghby | طارق الزغبي</dc:creator>
      <pubDate>Tue, 06 Oct 2026 12:35:15 +0000</pubDate>
      <link>https://dev.to/tarek_elzoghby/three-ways-my-scroll-arrow-went-wrong-51bg</link>
      <guid>https://dev.to/tarek_elzoghby/three-ways-my-scroll-arrow-went-wrong-51bg</guid>
      <description>&lt;p&gt;This is a practice brief, and the client is fictional: Marwa Adel, a potter in Cairo who wants a one-page site instead of taking commissions through Instagram DMs. I built it with an AI design partner, who worked through the design decisions with me and reviewed the HTML I brought back. The code is in the &lt;a href="https://github.com/Tarek-Elzoghby/client-brief-01-ceramics-studio" rel="noopener noreferrer"&gt;GitHub repo&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Early on I decided against a nav bar. One page, one path, and a nav bar reads as "website" in a way that fights the feel of a small studio. In its place I planned a quiet scroll indicator at the bottom of the hero, a small arrow saying there's more below. I wanted it to be a real anchor link so it stays accessible instead of just decorative.&lt;/p&gt;

&lt;p&gt;That's a small feature. It still went wrong three separate ways, after one decision I had to reason through first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where does it live?
&lt;/h2&gt;

&lt;p&gt;The scroll indicator was the only thing that would have lived in &lt;code&gt;&amp;lt;header&amp;gt;&lt;/code&gt;. Sitting there almost empty felt wrong. It didn't belong loose in &lt;code&gt;&amp;lt;main&amp;gt;&lt;/code&gt; either, since it isn't tied to any section.&lt;/p&gt;

&lt;p&gt;I worked through it with my design partner. A &lt;code&gt;&amp;lt;header&amp;gt;&lt;/code&gt; is for introductory content, and a scroll cue doesn't introduce anything. It points at what comes next. So the indicator isn't its own structural unit. It's part of the hero's job, and it goes inside the hero, right after the tagline.&lt;/p&gt;

&lt;h2&gt;
  
  
  First wrong turn: the wrong element
&lt;/h2&gt;

&lt;p&gt;When I brought my HTML back for review, I had built a &lt;code&gt;&amp;lt;progress&amp;gt;&lt;/code&gt; element instead of the anchor link we'd planned. My design partner caught it. I didn't.&lt;/p&gt;

&lt;p&gt;A progress bar and a scroll cue are different features. A progress bar tracks how far down the page you are, needs JavaScript, and usually sits pinned full-width at the top. A scroll cue says there's more below and gives you something to tap. I went back to the plan: an &lt;code&gt;&amp;lt;a href="#about"&amp;gt;&lt;/code&gt; inside the hero.&lt;/p&gt;

&lt;h2&gt;
  
  
  Second wrong turn: an arrow that never appeared
&lt;/h2&gt;

&lt;p&gt;Later, after I'd built the About section, I hit two bugs where nothing looked broken and nothing happened either.&lt;/p&gt;

&lt;p&gt;The first was the arrow. I had written &lt;code&gt;content: '↓'&lt;/code&gt; and nothing appeared. Used the way I'd used it, a text string in &lt;code&gt;content&lt;/code&gt; only takes effect on &lt;code&gt;::before&lt;/code&gt; and &lt;code&gt;::after&lt;/code&gt;. On a regular element it does nothing, and it doesn't complain about it. I put the arrow directly in the HTML instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Third wrong turn: a link that went nowhere
&lt;/h2&gt;

&lt;p&gt;Right after that, the link itself wasn't scrolling anywhere. I had made About a class instead of an id. A fragment link like &lt;code&gt;#about&lt;/code&gt; looks for an element with that id, so a class gives it nothing to land on. The link rendered fine. It was just pointing at nothing, so clicking it did nothing. The fix was changing the class to an id.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the last two had in common
&lt;/h2&gt;

&lt;p&gt;Neither bug threw an error or left anything visibly broken. The arrow simply wasn't there, and the link simply didn't scroll. I noted at the time that this was the sneakiest kind.&lt;/p&gt;

&lt;p&gt;Where it ended up: the anchor sits inside the hero after the tagline, the arrow is plain text in the HTML, and &lt;code&gt;#about&lt;/code&gt; points at an element that actually has that id.&lt;/p&gt;

</description>
      <category>html</category>
      <category>css</category>
      <category>debugging</category>
      <category>beginners</category>
    </item>
    <item>
      <title>What a 100 Accessibility Score Didn't Check</title>
      <dc:creator>Tarek Elzoghby | طارق الزغبي</dc:creator>
      <pubDate>Tue, 29 Sep 2026 11:49:09 +0000</pubDate>
      <link>https://dev.to/tarek_elzoghby/-what-a-100-accessibility-score-didnt-check-2pf1</link>
      <guid>https://dev.to/tarek_elzoghby/-what-a-100-accessibility-score-didnt-check-2pf1</guid>
      <description>&lt;p&gt;I built a one-page site for a made-up potter as a practice brief: hero, about, gallery, contact. The client and the name are fictional. I worked with an AI as a design partner throughout, mostly to review my HTML and talk through decisions before I made them. The code is &lt;a href="https://github.com/Tarek-Elzoghby/client-brief-01-ceramics-studio" rel="noopener noreferrer"&gt;on GitHub&lt;/a&gt;. This post is only about the last stretch, after everything was built, when I went back through it for accessibility.&lt;/p&gt;

&lt;h2&gt;
  
  
  The baseline
&lt;/h2&gt;

&lt;p&gt;Lighthouse gave me 99 Performance, 95 Accessibility, 100 Best Practices, and 91 SEO. Accessibility had exactly one complaint: the three contact links had low color contrast.&lt;/p&gt;

&lt;p&gt;The links were set in terracotta (&lt;code&gt;#B5603F&lt;/code&gt;) on a cream background, which comes to about 3.9:1. Normal-size text needs 4.5:1 to pass AA. Terracotta was the single accent color in the whole design, reserved for real calls to action, so I didn't want to drop it. The palette already had &lt;code&gt;--color-primary-dark&lt;/code&gt;, a darker shade I'd originally made for hover states. I pointed the links at that instead. Accessibility went to 100 from that one change.&lt;/p&gt;

&lt;p&gt;The link color had looked fine to me by eye, and it failed anyway. That's why I ran the audit at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the 100 covers
&lt;/h2&gt;

&lt;p&gt;A Lighthouse score covers the checks Lighthouse runs. It says nothing about the ones it doesn't. So I went through the site by hand, looking for gaps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decorative icons
&lt;/h2&gt;

&lt;p&gt;The contact section has three inline SVG icons (email, phone, Instagram), each next to a text label that says the same thing. Screen readers handle inline SVGs inconsistently. Some announce "graphic," some read out raw markup, some skip them. Nothing breaks, but the experience is unpredictable, and the icons carry no information the label doesn't already carry.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;svg&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"contact-icon"&lt;/span&gt; &lt;span class="na"&gt;aria-hidden=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt; &lt;span class="na"&gt;focusable=&lt;/span&gt;&lt;span class="s"&gt;"false"&lt;/span&gt; &lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;aria-hidden="true"&lt;/code&gt; removes the icon from the accessibility tree. &lt;code&gt;focusable="false"&lt;/code&gt; is a safety net for older IE and Edge, where SVGs could be focusable by default and pick up a stray tab stop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Landmarks
&lt;/h2&gt;

&lt;p&gt;Lighthouse checks that basic page structure is present. It doesn't check whether landmarks are useful. &lt;code&gt;&amp;lt;main&amp;gt;&lt;/code&gt; gave me one landmark for free. The four blocks inside it (hero, about, gallery, contact) weren't landmarks at all. A plain &lt;code&gt;&amp;lt;section&amp;gt;&lt;/code&gt; only becomes a region landmark once it has an accessible name, and &lt;code&gt;&amp;lt;address&amp;gt;&lt;/code&gt; has no landmark role by default.&lt;/p&gt;

&lt;p&gt;So the question I worked out before touching any markup was which sections are places someone would want to jump to. Having a heading wasn't the test.&lt;/p&gt;

&lt;p&gt;The hero is where a screen reader user already lands, so making it a landmark would only add a "you are here" entry to the jump list. About, Gallery, and Contact are content someone might skip straight to. Those three got landmark status.&lt;/p&gt;

&lt;p&gt;For About and Gallery, I gave the &lt;code&gt;&amp;lt;h2&amp;gt;&lt;/code&gt; inside each an id and pointed the parent section at it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;section&lt;/span&gt; &lt;span class="na"&gt;aria-labelledby=&lt;/span&gt;&lt;span class="s"&gt;"about-heading"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;h2&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"about-heading"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;About me&lt;span class="nt"&gt;&amp;lt;/h2&amp;gt;&lt;/span&gt;
  ...
&lt;span class="nt"&gt;&amp;lt;/section&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Contact was different. Its wrapper was an &lt;code&gt;&amp;lt;address&amp;gt;&lt;/code&gt;, and &lt;code&gt;aria-labelledby&lt;/code&gt; on an element with no landmark role names something that was never a landmark. I added &lt;code&gt;role="region"&lt;/code&gt; alongside it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;address&lt;/span&gt; &lt;span class="na"&gt;role=&lt;/span&gt;&lt;span class="s"&gt;"region"&lt;/span&gt; &lt;span class="na"&gt;aria-labelledby=&lt;/span&gt;&lt;span class="s"&gt;"contact-heading"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;h2&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"contact-heading"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Contact&lt;span class="nt"&gt;&amp;lt;/h2&amp;gt;&lt;/span&gt;
  ...
&lt;span class="nt"&gt;&amp;lt;/address&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Contact is as much a jump-to destination as the other two, and its tag choice shouldn't make it the odd one out. That got it into the landmark list, and it was still wrong. &lt;code&gt;&amp;lt;address&amp;gt;&lt;/code&gt; is for contact details, and the HTML spec doesn't allow heading elements inside it, so the &lt;code&gt;&amp;lt;h2&amp;gt;&lt;/code&gt; there was invalid markup. I found this later, when the AI reviewed the repo again before I wrote this post. Nothing I had run flagged it, and NVDA listed the region as if nothing were off.&lt;/p&gt;

&lt;p&gt;The fix was smaller than I expected. I changed the wrapper from &lt;code&gt;&amp;lt;address&amp;gt;&lt;/code&gt; to &lt;code&gt;&amp;lt;section&amp;gt;&lt;/code&gt; and removed &lt;code&gt;role="region"&lt;/code&gt;. Everything else stayed the same:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;section&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"contact"&lt;/span&gt; &lt;span class="na"&gt;aria-labelledby=&lt;/span&gt;&lt;span class="s"&gt;"contact-heading"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"address-content"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;h2&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"contact-heading"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Contact&lt;span class="nt"&gt;&amp;lt;/h2&amp;gt;&lt;/span&gt;
    ...
  &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/section&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;&amp;lt;section&amp;gt;&lt;/code&gt; with an accessible name is already a region, so once the tag was right the role had nothing left to do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking it
&lt;/h2&gt;

&lt;p&gt;Reading markup only tells me what I intended. I opened NVDA's Elements List (NVDA+F7) and filtered to Landmarks. It showed the implicit &lt;code&gt;main&lt;/code&gt; plus three named regions: About me, Recent Work, and Contact. The hero was correctly absent and nothing was duplicated.&lt;/p&gt;

&lt;p&gt;That check passed on the version with the invalid &lt;code&gt;&amp;lt;address&amp;gt;&lt;/code&gt;. It tells me what a screen reader is given, not whether the markup is valid. Validity is a separate check, and the W3C validator is the one that covers it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's still open
&lt;/h2&gt;

&lt;p&gt;Accessibility is at 100. I left SEO at 91 on purpose. What remains there depends mostly on the meta description and real content, which is better written once real copy and photos exist than guessed at now.&lt;/p&gt;

</description>
      <category>a11y</category>
      <category>html</category>
      <category>beginners</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Tracing a URL to a Rendered Page, and the Two Stories I Almost Told at Once</title>
      <dc:creator>Tarek Elzoghby | طارق الزغبي</dc:creator>
      <pubDate>Tue, 22 Sep 2026 10:53:13 +0000</pubDate>
      <link>https://dev.to/tarek_elzoghby/-tracing-a-url-to-a-rendered-page-and-the-two-stories-i-almost-told-at-once-2n3d</link>
      <guid>https://dev.to/tarek_elzoghby/-tracing-a-url-to-a-rendered-page-and-the-two-stories-i-almost-told-at-once-2n3d</guid>
      <description>&lt;p&gt;The assignment was simple to state and harder to actually do: explain, step by step, everything that happens between typing a URL into a browser and seeing a rendered page appear on screen. Label the client and the server. Break the URL into its parts. Say what DNS does, in one sentence. Explain frontend versus backend, in one or two sentences. Then be able to say the whole thing out loud, from memory, without the page in front of me.&lt;/p&gt;

&lt;p&gt;I used a fake URL throughout so nothing here depends on a real server actually responding: &lt;a href="https://www.TeSidrah.com/projects?sort=latest" rel="noopener noreferrer"&gt;https://www.TeSidrah.com/projects?sort=latest&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Client and server&lt;/p&gt;

&lt;p&gt;Two roles show up the moment the URL is typed in and Enter is hit. The browser on my machine is the client, it sends requests. TeSidrah's remote machine is the server, it listens for requests and sends back responses.&lt;/p&gt;

&lt;p&gt;The frontend is everything that runs on the client's own machine, using the client's own resources. The backend is everything that runs on the server's machine, using the server's resources.&lt;/p&gt;

&lt;p&gt;That second sentence took longer to get right than it looks. My first attempt at defining backend split it into three separate ideas: where it runs, what its logic does, and where its database lives. It read like three facts about backend instead of one definition of what backend actually is. The database isn't what makes something backend, it's just a common piece of what backend systems tend to have. Once I dropped it and matched the shape of the frontend sentence exactly, both definitions said the same kind of thing about two different machines.&lt;/p&gt;

&lt;p&gt;Breaking down the URL&lt;/p&gt;

&lt;p&gt;The browser takes the URL apart into four pieces: the protocol (https://, the rules for communication, here adding an encryption layer), the domain (&lt;a href="http://www.TeSidrah.com" rel="noopener noreferrer"&gt;www.TeSidrah.com&lt;/a&gt;, the name of the server), the path (/projects, which resource on that server), and the query parameters (?sort=latest, extra instructions attached to the request).&lt;/p&gt;

&lt;p&gt;DNS: the lookup before the real request&lt;/p&gt;

&lt;p&gt;The browser can't send anything to a name like &lt;a href="http://www.TeSidrah.com" rel="noopener noreferrer"&gt;www.TeSidrah.com&lt;/a&gt;, it needs a numeric address. So it sends the domain to a DNS server first.&lt;/p&gt;

&lt;p&gt;This is where something shifted while I was writing it. DNS isn't a separate mechanism sitting off to the side of the real request. It's the same request/response pattern, just happening first and at a smaller scale: the browser sends something (a domain name) and gets something back (an IP address). Once that clicked, the rest of the flow stopped looking like a list of separate steps and started looking like one idea repeating at different sizes: ask, wait, get an answer back.&lt;/p&gt;

&lt;p&gt;Getting that into one sentence, the way the assignment asked, took a few tries: DNS translates a human-friendly domain name into the numeric IP address a computer actually needs to route the request. Writing a paragraph about DNS was easy. Getting it down to one correct sentence took several passes.&lt;/p&gt;

&lt;p&gt;The real request&lt;/p&gt;

&lt;p&gt;Now holding the IP address, the browser sends the actual request: the method (GET, please retrieve this for me), the path (/projects), the query string (?sort=latest), and metadata about the browser itself.&lt;/p&gt;

&lt;p&gt;The server responds&lt;/p&gt;

&lt;p&gt;This is the step where my first draft quietly told two different stories at once, without me noticing at first. I'd written the server's response as a 404, page not found, and then in the very next section described what happens if there was a page: the DOM gets built, CSS and JS and images get fetched, layout gets calculated, pixels get painted. Two different outcomes, stitched together as if they were one continuous thing.&lt;/p&gt;

&lt;p&gt;I considered a few ways to fix it. Keeping both outcomes as two branches would have split the walkthrough in half, and the whole point was to explain one continuous flow I could say out loud without qualifying it. Stopping at the 404 was tempting, it's a real and common outcome, but it would have cut the explanation short right before the most substantial part: everything the browser does once it actually has a page to work with. So I committed to the success path all the way through, and kept the 404 as a one-line aside instead of its own branch.&lt;/p&gt;

&lt;p&gt;TeSidrah's server reads the method, path, and query, runs its backend logic (queries the database, sorts by "latest," builds the page), and responds with a status code, 200 OK, and a body: the raw HTML.&lt;/p&gt;

&lt;p&gt;What the browser does with it&lt;/p&gt;

&lt;p&gt;The browser parses the HTML into the DOM, an internal tree of the page. As it parses, every reference to a stylesheet, script, or media file fires off a new request, each one its own small request/response cycle. Once everything's arrived, the browser combines the HTML with the CSS to work out layout, then paints the final pixels.&lt;/p&gt;

&lt;p&gt;One breath&lt;/p&gt;

&lt;p&gt;User types URL, browser labels client and server. URL splits into protocol, domain, path, query. DNS resolves the domain to an IP, a request/response in miniature. The browser sends the real request. The server runs its logic and replies with 200 and HTML. The browser parses, fetches what it's missing, lays out, paints. The page appears.&lt;/p&gt;

&lt;p&gt;Writing all of this down turned out to be the easier half. Being able to say the whole cycle out loud, from memory, without the page open in front of me, was a different and harder bar than being able to write it. Something about not being able to lean on the sentence I'd already typed made it obvious which parts I actually understood and which parts I'd just successfully described once.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>networking</category>
      <category>beginners</category>
      <category>learning</category>
    </item>
    <item>
      <title>The Breakpoint That Was Hiding the Real Bug</title>
      <dc:creator>Tarek Elzoghby | طارق الزغبي</dc:creator>
      <pubDate>Tue, 15 Sep 2026 11:30:35 +0000</pubDate>
      <link>https://dev.to/tarek_elzoghby/-the-breakpoint-that-was-hiding-the-real-bug-8j0</link>
      <guid>https://dev.to/tarek_elzoghby/-the-breakpoint-that-was-hiding-the-real-bug-8j0</guid>
      <description>&lt;p&gt;This was my first Frontend Mentor junior challenge, and the first time I used CSS Grid for anything real. I'd already built the mobile layout with Flexbox, a single stacked column, before I sat down to work through the desktop layout. That's where the actual learning happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two grids instead of one
&lt;/h2&gt;

&lt;p&gt;My first instinct was to wrap four of the five cards in a &lt;code&gt;.group&lt;/code&gt; div, turn that into its own 3-column grid, and place the fifth card (Kira) in a separate outer grid next to it.&lt;/p&gt;

&lt;p&gt;It didn't work. When I tried to explain my reasoning, I understood why: Kira needed to visually span two rows next to the other four cards, but she wasn't even inside the same grid as them. I didn't know, until this project, that a single grid item can span multiple rows in one column. Once I understood that was possible, the whole &lt;code&gt;.group&lt;/code&gt; wrapper stopped making sense. It wasn't solving a real problem. It was a workaround for something Grid could already do on its own.&lt;/p&gt;

&lt;p&gt;The fix was to delete &lt;code&gt;.group&lt;/code&gt; entirely and flatten the HTML so all five &lt;code&gt;&amp;lt;article&amp;gt;&lt;/code&gt; elements are direct children of one grid container.&lt;/p&gt;

&lt;h2&gt;
  
  
  grid-template-areas
&lt;/h2&gt;

&lt;p&gt;Instead of placing every card with column and row numbers, I used &lt;code&gt;grid-template-areas&lt;/code&gt;, writing the layout as a literal map:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;grid-template-areas&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="s1"&gt;"daniel  daniel   jonathan kira"&lt;/span&gt;
    &lt;span class="s1"&gt;"jeanette patrick patrick  kira"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This clicked immediately. The CSS looks like the layout. Each article gets one line, &lt;code&gt;grid-area: daniel;&lt;/code&gt;, and Grid figures out the rest.&lt;/p&gt;

&lt;p&gt;I'd guessed correctly that Kira would stretch to fill both rows, but I wasn't sure the two rows would end up equal in height. They don't, by default. With &lt;code&gt;grid-template-rows: auto auto&lt;/code&gt;, each row sizes independently based on its own content, they aren't forced to match. What does happen automatically: every grid item has &lt;code&gt;align-self: stretch&lt;/code&gt; by default, so an item spanning multiple rows fills the combined height of every row and gap it spans, no manual syncing required.&lt;/p&gt;

&lt;h2&gt;
  
  
  Chasing a stretch bug into a fake fix
&lt;/h2&gt;

&lt;p&gt;Once the grid was structurally working, the cards looked stretched and awkward, especially Jonathan's and Jeanette's, which had noticeable empty space below their text.&lt;/p&gt;

&lt;p&gt;The cause: &lt;code&gt;align-items: stretch&lt;/code&gt; was correctly making shorter cards match the height of taller ones in the same row. That part was expected Grid behavior. But &lt;code&gt;justify-content: center&lt;/code&gt; on my &lt;code&gt;article&lt;/code&gt; rule was then centering the shorter content vertically inside that taller box, creating dead space above and below instead of a clean top-aligned look. The fix was &lt;code&gt;justify-content: flex-start&lt;/code&gt; on &lt;code&gt;article&lt;/code&gt;, so content anchors to the top regardless of how tall the box gets.&lt;/p&gt;

&lt;p&gt;Before I found that, I tried something else first: I set &lt;code&gt;min-width&lt;/code&gt; on &lt;code&gt;.card&lt;/code&gt;, which happened to hide the problem by force-widening the container so text wrapped less. It looked fixed. It wasn't. &lt;code&gt;min-width&lt;/code&gt; sets a floor, and below that value the grid became wider than the viewport, causing horizontal scrollbars.&lt;/p&gt;

&lt;p&gt;Switching to &lt;code&gt;max-width&lt;/code&gt;, the actual correct property, let the grid shrink properly again, which immediately re-exposed the real stretch problem. At 768px, four columns didn't have enough room for comfortable text, so everything wrapped heavily and rows got tall and uneven.&lt;/p&gt;

&lt;p&gt;That's when I found the real root cause myself: the grid layout simply didn't have enough room to exist at 768px. The style guide specified mobile at 375px and desktop at 1440px, nothing about a tablet-specific layout, so I moved my breakpoint up to &lt;code&gt;80rem&lt;/code&gt; (1280px), well clear of the cramped zone, and kept Flexbox stacking everywhere below that. I also retuned the typography specifically for the desktop grid, since a layout at 1280px and up has different space needs than the same content at 375px.&lt;/p&gt;

&lt;p&gt;After that, I resized the browser slowly across every width to check it. No scrollbars, no stretch issues.&lt;/p&gt;

&lt;h2&gt;
  
  
  Positioning the quotation mark
&lt;/h2&gt;

&lt;p&gt;I'd hidden the decorative quotation mark image with &lt;code&gt;display: none&lt;/code&gt; early on and forgot about it until it got pointed out near the end. Placing it behind Daniel's card content taught me three things I hadn't really understood before:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;position: relative&lt;/code&gt; on the parent turns it into the anchor that absolutely positioned children measure their offsets against. &lt;code&gt;position: absolute&lt;/code&gt; on the quotation image takes it out of normal document flow so it can overlap content without pushing anything around. And &lt;code&gt;z-index&lt;/code&gt; only works on positioned elements, an element left at the default &lt;code&gt;position: static&lt;/code&gt; ignores it completely, no matter the value.&lt;/p&gt;

&lt;p&gt;What surprised me more: positioned elements always paint above static ones regardless of &lt;code&gt;z-index&lt;/code&gt;, that comparison only happens between positioned elements. So my fix, adding &lt;code&gt;position: relative&lt;/code&gt; and &lt;code&gt;z-index: 1&lt;/code&gt; to the text containers, was really about moving them into the positioned category so &lt;code&gt;z-index&lt;/code&gt; could apply at all. The number itself mattered less than getting into the right category to begin with. DOM order alone might have already put the text on top in my case, so the fix mostly added intentional control rather than correcting an active failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I almost missed
&lt;/h2&gt;

&lt;p&gt;Working through structural problems like Grid and positioning, it's easy to lose track of visual details entirely. I nearly missed the quotation mark graphic, the box-shadow on the cards, and a leftover wrapper div around Jonathan's avatar from earlier experimentation. I'm trying to build a habit around this now: a quick detail scan of the design before styling each section, shadows, radius, spacing, hover states, as its own pass separate from the structural work.&lt;/p&gt;

&lt;p&gt;For the box-shadow itself, I landed on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;box-shadow&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="err"&gt;3&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="err"&gt;5&lt;/span&gt;&lt;span class="nt"&gt;rem&lt;/span&gt; &lt;span class="err"&gt;3&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="err"&gt;5&lt;/span&gt;&lt;span class="nt"&gt;rem&lt;/span&gt; &lt;span class="err"&gt;5&lt;/span&gt;&lt;span class="nt"&gt;rem&lt;/span&gt; &lt;span class="nt"&gt;-2rem&lt;/span&gt; &lt;span class="nt"&gt;hsl&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="err"&gt;224&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="err"&gt;10&lt;/span&gt;&lt;span class="o"&gt;%,&lt;/span&gt; &lt;span class="err"&gt;45&lt;/span&gt;&lt;span class="o"&gt;%);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Offset-x and offset-y shift the shadow horizontally and vertically. Blur-radius controls how soft the edge is, zero being a hard silhouette. Spread-radius grows or shrinks the shadow shape before blur, and a negative value here pulls it inward instead of framing the element evenly. Small thing, but tuning it by feel taught me more about how the property actually works than reading the syntax did.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I take away from this
&lt;/h2&gt;

&lt;p&gt;The real lesson wasn't Grid syntax. It was learning to trace a visual symptom back to its actual structural cause instead of patching it with another override. The &lt;code&gt;min-width&lt;/code&gt; detour looked like a fix. It wasn't one, it just moved the problem somewhere I couldn't see it yet. A working fix and a correct fix aren't the same thing if the first one is just hiding what's actually wrong underneath.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Working title:&lt;/strong&gt; The Breakpoint That Was Hiding the Real Bug&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Platform description/summary:&lt;/strong&gt; Building my first CSS Grid layout, and chasing a stretch bug through a fake fix before finding the actual root cause: the wrong breakpoint entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Suggested tags, Hashnode:&lt;/strong&gt; css, grid, frontend, debugging, beginners&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Suggested tags, dev.to (max 4):&lt;/strong&gt; css, grid, debugging, beginners&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cover image:&lt;/strong&gt; Worth having one. Something simple showing the grid-template-areas map itself (the literal ASCII-art layout string) would work well as a visual, since that's the moment the post pivots from confusion to clarity. Doesn't need to be more elaborate than that.&lt;/p&gt;

</description>
      <category>css</category>
      <category>grid</category>
      <category>debugging</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Building for the Person Who Can't Debug It</title>
      <dc:creator>Tarek Elzoghby | طارق الزغبي</dc:creator>
      <pubDate>Tue, 08 Sep 2026 13:47:25 +0000</pubDate>
      <link>https://dev.to/tarek_elzoghby/-building-for-the-person-who-cant-debug-it-3hgd</link>
      <guid>https://dev.to/tarek_elzoghby/-building-for-the-person-who-cant-debug-it-3hgd</guid>
      <description>&lt;p&gt;The first post about this workflow covered the Webhook payload break — the moment the Form Trigger got swapped out and every downstream reference silently pointed at data that no longer existed. That part's done. This is the rest of the build: three smaller decisions that don't share a bug, but do share a reason.&lt;/p&gt;

&lt;p&gt;Marisol — the client this workflow was built for — isn't technical. She has no developer on call. If this breaks while she's using it, she's the one looking at it first. That fact didn't come up once and get filed away; it kept showing up, in places that had nothing to do with each other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Flipping the IF Node
&lt;/h2&gt;

&lt;p&gt;The junk filter was supposed to do one thing: stop a submission if both phone and email were empty, since a lead with neither is useless. The logic in my head was straightforward — "if phone empty AND email empty, stop."&lt;/p&gt;

&lt;p&gt;But when I actually built the IF node, I flipped it: "if phone OR email is not empty, continue." Same condition, inverted. What changed was which branch reads as the main path. Written this way, the true branch is the branch I actually follow when I trace the workflow — the valid data moving forward. The false branch is just "nothing happens," which is exactly what it should look like: the door that quietly doesn't open.&lt;/p&gt;

&lt;p&gt;It's a small thing. The workflow does the same job either way. But if Marisol — or anyone else — ever opens this node to understand why a lead didn't show up in Sheets, the condition should read like the thing that's actually happening, not like a double negative she has to mentally invert first.&lt;/p&gt;

&lt;p&gt;Tested both directions: a submission with both phone and email empty correctly stopped, and a submission with just one filled correctly passed through.&lt;/p&gt;

&lt;h2&gt;
  
  
  Redesigning the Set Node
&lt;/h2&gt;

&lt;p&gt;Originally, the Set node had exactly one job — default the message field to "No message provided" if it came in empty. That was true right up until the trigger swap.&lt;/p&gt;

&lt;p&gt;Once the Webhook replaced the Form Trigger, the payload picked up a lot of extra weight — headers, params, webhookUrl, executionMode, all the raw HTTP metadata that comes bundled with a webhook request. None of it was useful to Sheets or Slack. All of it was now sitting in the same object as the five fields that actually mattered.&lt;/p&gt;

&lt;p&gt;I could have left it. The extra keys wouldn't have broken anything downstream — Sheets and Slack only read what I told them to read. But that's exactly the kind of thing that's invisible until someone else has to look at it. So I rebuilt the Set node to do more: explicit mappings for all five clean fields — Name, Phone, Email, Service address, Message — stripping the noise and defaulting the message in the same place.&lt;/p&gt;

&lt;p&gt;I chose explicit field mappings over n8n's "keep only set fields" toggle on purpose. The toggle does the same job with less typing, but it hides what's happening — you'd have to open the node and go find the setting to know why the extra fields disappeared. Written out explicitly, the five fields the workflow actually cares about are right there, visible, nothing to go dig for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Writing the Doc for Marisol
&lt;/h2&gt;

&lt;p&gt;The last piece was the plain-English explanation — the thing Marisol actually reads if something goes wrong. First time writing a doc like this, so I built it around what she'd need, not around what I found interesting to explain: what the workflow does, a step-by-step walkthrough using the real node names (Filter Out Junk Leads, Clean and Format Lead Data — the names she'd see if she ever opened it herself), and a troubleshooting section.&lt;/p&gt;

&lt;p&gt;For troubleshooting, I didn't invent hypothetical failure cases — I used one that had actually happened. During testing, the Google Sheets node failed once because the credential needed reconnecting; disconnecting and reconnecting it fixed it. Small thing, but real, and more likely to recur than something exotic I could've made up instead.&lt;/p&gt;

&lt;p&gt;The doc and the exported workflow JSON stayed as two separate files, deliberately. The JSON is the actual automation, importable straight into her n8n. The doc is a reference for a human. They don't get merged into each other — that split is what the brief asked for, and forcing them together would've made one file try to do two jobs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Same Constraint, Three Places
&lt;/h2&gt;

&lt;p&gt;None of these three decisions are related on the surface — an inverted condition, a redesigned node, a document. What ties them together is that each one got shaped by a person who wasn't going to be in the room the next time this workflow needed explaining, and who can't reason through n8n's internals if it breaks. The workflow doesn't get simpler because of that. It gets more legible.&lt;/p&gt;

</description>
      <category>automation</category>
      <category>webdev</category>
      <category>beginners</category>
      <category>nocode</category>
    </item>
    <item>
      <title>The Trigger Swap That Broke Every Downstream Reference</title>
      <dc:creator>Tarek Elzoghby | طارق الزغبي</dc:creator>
      <pubDate>Tue, 01 Sep 2026 11:30:24 +0000</pubDate>
      <link>https://dev.to/tarek_elzoghby/the-trigger-swap-that-broke-every-downstream-reference-cle</link>
      <guid>https://dev.to/tarek_elzoghby/the-trigger-swap-that-broke-every-downstream-reference-cle</guid>
      <description>&lt;p&gt;I built this workflow — a lead intake automation for a made-up client, Ferrer Window Co. — with a Form Trigger first, even though I knew from the start it needed to end up on a Webhook. That wasn't the plan drifting. It was deliberate: I'd used webhooks before and Form Trigger less, so building with Form Trigger first let me see real data coming through without also holding webhook payload structure in my head at the same time. Get the logic right, then swap the trigger once everything else was solid.&lt;/p&gt;

&lt;p&gt;And the logic did get solid. By the time I was ready to swap, I had a full chain working: an IF node filtering out junk leads (both phone and email empty), a Set node defaulting empty message fields to "No message provided," and Sheets and Slack both branching off that same cleaned data independently. Tested both edge cases — junk lead correctly stopped, empty message correctly defaulted. Everything passed.&lt;/p&gt;

&lt;p&gt;Then I deleted the Form Trigger, added a Webhook node, and sent a test payload through Postman.&lt;/p&gt;

&lt;p&gt;It broke immediately.&lt;/p&gt;

&lt;p&gt;Not the logic — the logic was fine. What broke was every single reference to the data. With Form Trigger, the payload had come through flat:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Tarek"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Phone"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"02-011-41104-582"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tarekalaaelzoghby1@gmail.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Service address"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Primary school st"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"I want to request an order"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"submittedAt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-08-24T03:24:50.089-04:00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"formMode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"test"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With Webhook, everything I actually wanted was nested one level down, sitting alongside a pile of raw HTTP metadata I didn't need:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"headers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"body"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Tarek"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Phone"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"02-011-41104-582"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"webhookUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"executionMode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"test"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every reference I'd written — the IF node's condition, the Set node's field mappings — pointed at &lt;code&gt;$json.Name&lt;/code&gt;, &lt;code&gt;$json.Phone&lt;/code&gt;, and so on. None of it was wrong, exactly. It was just pointed at a shape of data that no longer existed. The whole workflow's reasoning was intact and now unreachable.&lt;/p&gt;

&lt;p&gt;So I went back through it node by node — the IF node's conditions, the Set node's mappings — and repointed every one from &lt;code&gt;$json.X&lt;/code&gt; to &lt;code&gt;$json.body.X&lt;/code&gt;. Nothing about &lt;em&gt;what&lt;/em&gt; the workflow decided changed. Only &lt;em&gt;where it looked&lt;/em&gt; for the thing it was deciding about.&lt;/p&gt;

&lt;p&gt;Once that was done, I didn't just assume it worked because the references compiled. I re-ran both edge cases through the webhook path — both phone and email empty, and an empty message field — to confirm the filter still stopped junk leads and the default still kicked in, now that the data was arriving through a different shape entirely.&lt;/p&gt;

&lt;p&gt;The part I'd flag for anyone doing this same swap later: it's not a dramatic failure. Nothing throws a loud error that points you at the fix. The workflow just quietly stops seeing the data it expects, because the trigger node changed the shape of what "the data" even means. If you built your logic against one trigger's payload structure, swapping the trigger isn't a drop-in replacement — it's a second pass through everything downstream, checking what each node thinks it's looking at.&lt;/p&gt;

</description>
      <category>debugging</category>
      <category>automation</category>
      <category>json</category>
      <category>webhook</category>
    </item>
    <item>
      <title>Two Things I Got Wrong Building a Blog Preview Card</title>
      <dc:creator>Tarek Elzoghby | طارق الزغبي</dc:creator>
      <pubDate>Tue, 25 Aug 2026 13:58:57 +0000</pubDate>
      <link>https://dev.to/tarek_elzoghby/two-things-i-got-wrong-building-a-blog-preview-card-367k</link>
      <guid>https://dev.to/tarek_elzoghby/two-things-i-got-wrong-building-a-blog-preview-card-367k</guid>
      <description>&lt;p&gt;This one's a small build — a blog preview card, a Frontend Mentor challenge. Nothing about it is complicated on the surface: an image, a tag, a date, a title, a description, an author. But two small pieces of it didn't work the way I expected, and both times the reason why turned out to matter more than the fix itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The span that wouldn't stay small
&lt;/h2&gt;

&lt;p&gt;The card has a tag on it — "Learning" — styled to look like a little yellow pill sitting above the title. I reached for a &lt;code&gt;&amp;lt;span&amp;gt;&lt;/code&gt; for it, because a span is inline by default. Inline elements only take up as much width as their content needs. That's the whole reason to use one here: I wanted a tag-shaped box, not a bar running across the card.&lt;/p&gt;

&lt;p&gt;Except when I built it, it didn't behave like that. The tag stretched the full width of the card.&lt;/p&gt;

&lt;p&gt;The card is a flex container with &lt;code&gt;flex-direction: column&lt;/code&gt;. I knew that. What I hadn't connected was that flex changes the rules for its children — inside a flex container, children stretch to fill the cross axis by default, regardless of what they'd normally do outside of one. &lt;code&gt;display: inline&lt;/code&gt; doesn't mean anything once an element becomes a flex item. The span wasn't behaving like a span anymore; it was behaving like a flex child, and flex children stretch unless told otherwise.&lt;/p&gt;

&lt;p&gt;The fix is one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.card__tag&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;align-self&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;flex-start&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;align-self&lt;/code&gt; overrides the container's default alignment for just that one element — shrink to fit your content, ignore what everyone else is doing. Once I added it, the tag went back to looking like a tag.&lt;/p&gt;

&lt;p&gt;The part worth remembering isn't the fix. It's that "I know what this element normally does" stopped being true the moment I put it inside a flex container. Context changes behavior, even for something as basic as inline vs block.&lt;/p&gt;

&lt;h2&gt;
  
  
  The heading that couldn't take a hover
&lt;/h2&gt;

&lt;p&gt;The title needed a hover effect — the text should change color when you mouse over it, since it's a link to the full article. I built it the straightforward way first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;h1&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"card__title"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;HTML &lt;span class="err"&gt;&amp;amp;&lt;/span&gt; CSS foundations&lt;span class="nt"&gt;&amp;lt;/h1&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An &lt;code&gt;&amp;lt;h1&amp;gt;&lt;/code&gt; because it's the piece's main heading. That part wasn't in question. But once I tried to add the hover behavior to it, something felt off, and it took some reasoning to figure out why.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&amp;lt;h1&amp;gt;&lt;/code&gt; elements aren't interactive. They don't receive keyboard focus. A mouse user would see the color change on hover just fine — but a keyboard user tabbing through the page would get nothing, no visual signal that this thing does anything at all. A screen reader wouldn't announce it as a link either, because it isn't one. It's heading text that happens to look clickable to someone using a mouse, and that's it. Anyone not using a mouse would have no way to know.&lt;/p&gt;

&lt;p&gt;If something responds to hover, it should be something that can actually be interacted with — which meant an &lt;code&gt;&amp;lt;a&amp;gt;&lt;/code&gt; tag, not styling applied to a heading. But I didn't want to just wrap the whole &lt;code&gt;&amp;lt;h1&amp;gt;&lt;/code&gt; in a link and call it done, because that changes where the hover boundary sits: hover the empty space around the text, not just the text, and it still triggers. I wanted the interactive zone to match the text itself.&lt;/p&gt;

&lt;p&gt;So the anchor goes inside the heading, not around it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;h1&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"card__title"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"#"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;HTML &lt;span class="err"&gt;&amp;amp;&lt;/span&gt; CSS foundations&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&amp;lt;/h1&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.card__title&lt;/span&gt; &lt;span class="nt"&gt;a&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;inherit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;text-decoration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.card__title&lt;/span&gt; &lt;span class="nt"&gt;a&lt;/span&gt;&lt;span class="nd"&gt;:hover&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--color-bg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;color: inherit&lt;/code&gt; on the anchor matters here too — browsers apply their own default link styling (blue, underlined), which would override whatever color the heading was supposed to have. Setting it back to &lt;code&gt;inherit&lt;/code&gt; cancels that out, so the link reads as the same color as the rest of the heading until you hover it.&lt;/p&gt;

&lt;p&gt;What changed my approach wasn't "add an anchor somewhere." It was realizing the hover target and the semantic structure needed to line up exactly — the thing that looks clickable, is clickable, exactly where it looks clickable, and nowhere else.&lt;/p&gt;

&lt;h2&gt;
  
  
  What both of these actually have in common
&lt;/h2&gt;

&lt;p&gt;Neither one was a hard bug. Nothing crashed, nothing broke visibly in a way that demanded urgent fixing. Both were cases where something looked fine and worked &lt;em&gt;almost&lt;/em&gt; right, and the "almost" was doing a lot of quiet work — an element not shrinking the way it should, an interaction that only half the people looking at the page would ever know existed. Small card, small build, and still two places where the obvious approach wasn't the correct one until I asked why it wasn't behaving the way I expected.&lt;/p&gt;

</description>
      <category>html</category>
      <category>css</category>
      <category>beginners</category>
      <category>a11y</category>
    </item>
    <item>
      <title>The Card That Leaned</title>
      <dc:creator>Tarek Elzoghby | طارق الزغبي</dc:creator>
      <pubDate>Tue, 18 Aug 2026 15:06:59 +0000</pubDate>
      <link>https://dev.to/tarek_elzoghby/the-card-that-leaned-169g</link>
      <guid>https://dev.to/tarek_elzoghby/the-card-that-leaned-169g</guid>
      <description>&lt;p&gt;This was my first project — a small QR code card, built from a Frontend Mentor challenge. Nothing complicated: a white card centered on a colored background, with an image and two lines of text stacked inside it. I thought I was done.&lt;/p&gt;

&lt;p&gt;Then I resized the browser to a smaller width, and the card started leaning to one side.&lt;/p&gt;

&lt;p&gt;That was confusing on its own, because nothing in my CSS should have cared about screen width in a way that would push the card sideways. I hadn't written anything asymmetric. So the first question wasn't "how do I fix this" — it was "what is even causing this."&lt;/p&gt;

&lt;p&gt;I reached for the debugging trick I'd read about: outline everything.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;outline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="no"&gt;red&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This draws a visible border around every element on the page, whether or not it normally has one. The idea is simple — if you can't tell where an element's boundaries are, make the boundaries visible.&lt;/p&gt;

&lt;p&gt;What I saw didn't make sense at first. The background color was filling the entire screen, the way it should. But the outlined box sitting on top of it was small — and it wasn't even centered. It had extra space on one side and almost none on the other.&lt;/p&gt;

&lt;p&gt;That was the part that stopped me. How can the background fill the whole screen, but the &lt;em&gt;body&lt;/em&gt; only take up a small, off-center chunk of it? The background color was set on &lt;code&gt;body&lt;/code&gt;. If the body were really that small, the color shouldn't be filling the screen at all.&lt;/p&gt;

&lt;p&gt;So my assumption was wrong. I was looking at the wrong element.&lt;/p&gt;

&lt;p&gt;It took a second to place it, because the actual culprit had no visual presence anywhere in my styles — I hadn't given it a color, a border, anything. It was &lt;code&gt;&amp;lt;main&amp;gt;&lt;/code&gt;. I'd added it early on for semantic structure and then more or less forgotten it existed, because it never &lt;em&gt;looked&lt;/em&gt; like anything. But it was still there in the HTML, still taking up space in the layout, and the browser's default styling for it was enough to throw the whole thing off-center.&lt;/p&gt;

&lt;p&gt;That's the part I hadn't internalized yet: every element in HTML takes up layout space, even the ones you can't see. Invisibility isn't the same as absence.&lt;/p&gt;

&lt;p&gt;The fix was one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;main&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;contents&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;display: contents&lt;/code&gt; removes an element from the layout entirely — visually, it's as if &lt;code&gt;&amp;lt;main&amp;gt;&lt;/code&gt;'s children became direct children of &lt;code&gt;&amp;lt;body&amp;gt;&lt;/code&gt;. But the element itself stays in the HTML, which matters, because screen readers and search engines still read &lt;code&gt;&amp;lt;main&amp;gt;&lt;/code&gt; as a landmark for page structure. So I got to keep the semantic meaning without it interfering with the layout.&lt;/p&gt;

&lt;p&gt;The card centered correctly after that, at every width.&lt;/p&gt;

&lt;p&gt;What I actually took from this wasn't the fix itself — &lt;code&gt;display: contents&lt;/code&gt; is a one-line answer I could have just been told. It was the order of operations that got me there: when something about spacing or alignment looks wrong and the CSS doesn't explain it, the problem is often not in the CSS at all. It's in an element you forgot was in the HTML.&lt;/p&gt;

</description>
      <category>css</category>
      <category>debugging</category>
      <category>webdev</category>
      <category>beginners</category>
    </item>
  </channel>
</rss>
