<?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: KNALLHART.DEV</title>
    <description>The latest articles on DEV Community by KNALLHART.DEV (@knallhartdev).</description>
    <link>https://dev.to/knallhartdev</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%2F3979912%2F1f8f3633-d828-4bf6-a89b-c08fa56c26ae.jpg</url>
      <title>DEV Community: KNALLHART.DEV</title>
      <link>https://dev.to/knallhartdev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/knallhartdev"/>
    <language>en</language>
    <item>
      <title>Getting an LLM to Actually Follow Your Output Format (Without Fighting It Every Request)</title>
      <dc:creator>KNALLHART.DEV</dc:creator>
      <pubDate>Fri, 26 Jun 2026 18:10:39 +0000</pubDate>
      <link>https://dev.to/knallhartdev/getting-an-llm-to-actually-follow-your-output-format-without-fighting-it-every-request-1kn1</link>
      <guid>https://dev.to/knallhartdev/getting-an-llm-to-actually-follow-your-output-format-without-fighting-it-every-request-1kn1</guid>
      <description>&lt;p&gt;If you've ever asked an LLM to return output in a strict format — &lt;br&gt;
valid JSON, a specific HTML structure, exactly N items — you've &lt;br&gt;
probably noticed it drifts. Not constantly, but often enough that &lt;br&gt;
"mostly works" isn't good enough for production code parsing the result.&lt;/p&gt;

&lt;p&gt;I ran into this building a tool that sends a website screenshot to &lt;br&gt;
Gemini and expects back a strict HTML structure: an ordered list, &lt;br&gt;
each item with a specific tag layout, nothing extra.&lt;/p&gt;
&lt;h2&gt;
  
  
  What kept breaking
&lt;/h2&gt;

&lt;p&gt;Early versions of my prompt just described the desired format in &lt;br&gt;
prose: "format the response as an HTML list with this structure." &lt;br&gt;
That worked maybe 80% of the time. The other 20%:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Extra commentary before or after the list ("Here's my analysis:")&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;&amp;lt;em&amp;gt;&lt;/code&gt; tag meant only for one specific line showing up elsewhere 
in the output, sometimes even written out as literal visible text&lt;/li&gt;
&lt;li&gt;Two distinct issues merged into a single list item&lt;/li&gt;
&lt;li&gt;Occasionally a missing &lt;code&gt;&amp;lt;li&amp;gt;&lt;/code&gt; entirely&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are "the model is bad." They're the model treating a &lt;br&gt;
descriptive request as a soft suggestion rather than a hard constraint.&lt;/p&gt;
&lt;h2&gt;
  
  
  What actually fixed it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Validate the output programmatically, don't trust it.&lt;/strong&gt;&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;const&lt;/span&gt; &lt;span class="nx"&gt;roastHtml&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;roastHtml&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;li&amp;gt;&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="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Unexpected format — triggering retry&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;This alone changes the failure mode from "silently broken downstream" &lt;br&gt;
to "automatically retried." A simple structural check (does the &lt;br&gt;
expected tag exist) catches most drift without needing to validate &lt;br&gt;
every detail.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Be explicit about what NOT to do, not just what to do.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Positive instructions ("format it like this") leave room for &lt;br&gt;
interpretation. Adding explicit negative constraints closes the gaps:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FORMAT RULES — these are strict:
- The &amp;lt;em&amp;gt; tag is used ONLY for the Fix line at the end of each 
  point — never in the description
- Do not write tag names as visible text anywhere
- Do not add any introduction, conclusion, or commentary outside 
  the list
- Do not add extra HTML attributes, classes, or styles
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The difference between "use em tags for fixes" and "never use em tags &lt;br&gt;
anywhere else, and never write them as visible text" is the difference &lt;br&gt;
between a suggestion and a constraint, even though both describe the &lt;br&gt;
same intended behavior.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Retry on validation failure, with backoff.&lt;/strong&gt;&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;withRetry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;maxAttempts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;maxAttempts&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;maxAttempts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&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;Format drift is usually not consistent — the same prompt against the &lt;br&gt;
same input often succeeds on a second attempt. Treating a format &lt;br&gt;
mismatch as a retryable error, the same way you'd treat a network &lt;br&gt;
timeout, costs almost nothing and fixes most of the remaining cases.&lt;/p&gt;

&lt;h2&gt;
  
  
  The underlying pattern
&lt;/h2&gt;

&lt;p&gt;Soft, descriptive instructions get treated as suggestions, not &lt;br&gt;
requirements — even when they're logically necessary for the output &lt;br&gt;
to be usable. The model needs the constraint stated as a constraint, &lt;br&gt;
and your code needs to verify compliance rather than assume it. &lt;br&gt;
Neither piece alone was enough; the prompt changes reduced drift, &lt;br&gt;
but the validation + retry is what makes the pipeline actually reliable &lt;br&gt;
end to end.&lt;/p&gt;

&lt;p&gt;If you're building anything that parses LLM output downstream: &lt;br&gt;
validate structurally, state constraints as negatives as well as &lt;br&gt;
positives, and treat format mismatches as retryable failures rather &lt;br&gt;
than edge cases to patch around later.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The Stripe Webhook Gotcha Nobody Warns You About: 100% Off Coupons and Missing PaymentIntents</title>
      <dc:creator>KNALLHART.DEV</dc:creator>
      <pubDate>Thu, 25 Jun 2026 13:56:46 +0000</pubDate>
      <link>https://dev.to/knallhartdev/the-stripe-webhook-gotcha-nobody-warns-you-about-100-off-coupons-and-missing-paymentintents-2kg1</link>
      <guid>https://dev.to/knallhartdev/the-stripe-webhook-gotcha-nobody-warns-you-about-100-off-coupons-and-missing-paymentintents-2kg1</guid>
      <description>&lt;p&gt;If you've ever added a 100%-off coupon to a Stripe Checkout flow &lt;br&gt;
and then watched your webhook handler silently do nothing, you've &lt;br&gt;
run into a gotcha that isn't well documented anywhere obvious.&lt;/p&gt;
&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;I was building a small test program: give a few people free access &lt;br&gt;
to a paid product via a Stripe Promotion Code, so they could go &lt;br&gt;
through the real checkout flow (price, decision, everything) instead &lt;br&gt;
of skipping straight to the result.&lt;/p&gt;

&lt;p&gt;The webhook listens for &lt;code&gt;checkout.session.completed&lt;/code&gt;, like every &lt;br&gt;
Stripe tutorial tells you to. Worked perfectly for paid orders. &lt;br&gt;
Free orders went through Checkout fine on the frontend... and then &lt;br&gt;
nothing happened on the backend.&lt;/p&gt;
&lt;h2&gt;
  
  
  The actual cause
&lt;/h2&gt;

&lt;p&gt;Two details that aren't obvious until you hit them:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;checkout.session.completed&lt;/code&gt; still fires for €0 sessions&lt;/strong&gt; — 
that part is fine, no surprises there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;But the session looks different.&lt;/strong&gt; A free Checkout Session has 
no associated &lt;code&gt;PaymentIntent&lt;/code&gt; (there's nothing to charge, so Stripe 
never creates one), and &lt;code&gt;session.payment_status&lt;/code&gt; is &lt;code&gt;"no_payment_required"&lt;/code&gt; 
instead of &lt;code&gt;"paid"&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If your webhook handler has any logic like:&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payment_status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;paid&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// fulfill order&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;...free orders get silently ignored. No error, no log, nothing — &lt;br&gt;
the condition is just false and the code below it never runs.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Don't gate fulfillment on &lt;code&gt;payment_status === "paid"&lt;/code&gt;. Treat &lt;br&gt;
&lt;code&gt;"paid"&lt;/code&gt; and &lt;code&gt;"no_payment_required"&lt;/code&gt; as equally valid completion &lt;br&gt;
states:&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;const&lt;/span&gt; &lt;span class="nx"&gt;validStatuses&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;paid&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="s2"&gt;no_payment_required&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;validStatuses&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payment_status&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// fulfill order&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And if you need the PaymentIntent ID for anything downstream &lt;br&gt;
(refund logic, in my case), just make sure your code tolerates &lt;br&gt;
it being &lt;code&gt;null&lt;/code&gt; or &lt;code&gt;undefined&lt;/code&gt; rather than assuming it always exists:&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;const&lt;/span&gt; &lt;span class="nx"&gt;paymentIntentId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payment_intent&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// later, somewhere that might issue a refund:&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;paymentIntentId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;refunds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;payment_intent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;paymentIntentId&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// if it's null, there's nothing to refund anyway — skip silently&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why this matters beyond coupons
&lt;/h2&gt;

&lt;p&gt;This isn't just a coupon edge case. Anything that can legitimately &lt;br&gt;
bring a Checkout Session total to zero — full discounts, free trial &lt;br&gt;
codes, internal test accounts — hits this same gap. If your &lt;br&gt;
fulfillment logic only recognizes &lt;code&gt;"paid"&lt;/code&gt;, every one of those &lt;br&gt;
paths fails silently, and you won't notice until someone reports &lt;br&gt;
"I redeemed the code but never got anything."&lt;/p&gt;

&lt;p&gt;Costly to debug after the fact, free to avoid if you know about &lt;br&gt;
it upfront.&lt;/p&gt;

&lt;p&gt;If you're testing your own coupon flow: don't trust that webhook &lt;br&gt;
silence means "nothing happened." Check &lt;code&gt;payment_status&lt;/code&gt; directly &lt;br&gt;
on a real free-session payload before assuming your handler covers it.&lt;/p&gt;

</description>
      <category>stripe</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>payments</category>
    </item>
    <item>
      <title>The scrollHeight Lie: How I Finally Got Full-Page Screenshots Right with Playwright</title>
      <dc:creator>KNALLHART.DEV</dc:creator>
      <pubDate>Sun, 21 Jun 2026 16:59:14 +0000</pubDate>
      <link>https://dev.to/knallhartdev/the-scrollheight-lie-how-i-finally-got-full-page-screenshots-right-with-playwright-4emn</link>
      <guid>https://dev.to/knallhartdev/the-scrollheight-lie-how-i-finally-got-full-page-screenshots-right-with-playwright-4emn</guid>
      <description>&lt;p&gt;If you've ever tried to take a full-page screenshot of a modern website &lt;br&gt;
programmatically, you've probably run into the same wall I did: &lt;br&gt;
&lt;code&gt;document.body.scrollHeight&lt;/code&gt; lies. Constantly.&lt;/p&gt;
&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;Lazy-loaded images, infinite scroll sections, sticky headers, scroll-triggered &lt;br&gt;
animations — all of these mess with the page's reported height in ways that &lt;br&gt;
make &lt;code&gt;scrollHeight&lt;/code&gt; an unreliable signal for "have I reached the bottom of &lt;br&gt;
this page yet?"&lt;/p&gt;

&lt;p&gt;I was building &lt;a href="https://knallhart.dev" rel="noopener noreferrer"&gt;knallhart.dev&lt;/a&gt;, a tool that takes a &lt;br&gt;
full-page screenshot of any website and sends an AI-generated critique via &lt;br&gt;
email. The screenshot step turned out to be the hardest part of the entire &lt;br&gt;
build — harder than the AI integration, harder than the payment flow.&lt;/p&gt;
&lt;h2&gt;
  
  
  What didn't work
&lt;/h2&gt;

&lt;p&gt;My first approach was the obvious one: read &lt;code&gt;scrollHeight&lt;/code&gt;, calculate how &lt;br&gt;
many scroll steps are needed, scroll that many times, done.&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;const&lt;/span&gt; &lt;span class="nx"&gt;pageHeight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollHeight&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pageHeight&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scrollBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&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 worked on simple static pages and broke immediately on anything modern. &lt;br&gt;
Pages with lazy-loaded content report a &lt;em&gt;short&lt;/em&gt; initial &lt;code&gt;scrollHeight&lt;/code&gt;, then &lt;br&gt;
grow as you scroll — so the calculated step count is wrong before you've &lt;br&gt;
even started. Pages with infinite scroll never stop growing at all.&lt;/p&gt;
&lt;h2&gt;
  
  
  What actually works
&lt;/h2&gt;

&lt;p&gt;Instead of trying to calculate the destination upfront, I scroll step by &lt;br&gt;
step and watch whether &lt;code&gt;window.scrollY&lt;/code&gt; is still changing. If it stops &lt;br&gt;
changing for a few consecutive checks, I've actually hit the bottom — &lt;br&gt;
regardless of what &lt;code&gt;scrollHeight&lt;/code&gt; claims.&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;let&lt;/span&gt; &lt;span class="nx"&gt;lastScrollY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;noChangeCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;maxAttempts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// safety cap, ~8 seconds&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;maxAttempts&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scrollBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;currentScrollY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currentScrollY&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;lastScrollY&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;noChangeCount&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;noChangeCount&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// confirmed: no more progress&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;noChangeCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;lastScrollY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;currentScrollY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scrollTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&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 more robust because it doesn't trust any single height value — it &lt;br&gt;
trusts &lt;em&gt;observed behavior over time&lt;/em&gt;. A page that's still loading content &lt;br&gt;
will keep moving &lt;code&gt;scrollY&lt;/code&gt;; a page that's truly done won't, no matter how &lt;br&gt;
confusing its &lt;code&gt;scrollHeight&lt;/code&gt; is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bonus problem: images
&lt;/h2&gt;

&lt;p&gt;Triggering the scroll isn't enough on its own — lazy-loaded images often &lt;br&gt;
need a moment to actually finish loading after they enter the viewport. &lt;br&gt;
I added an explicit wait for image load events before taking the final &lt;br&gt;
screenshot:&lt;/p&gt;

&lt;p&gt;\\javascript&lt;br&gt;
await page.evaluate(async () =&amp;gt; {&lt;br&gt;
  const images = Array.from(document.querySelectorAll("img"));&lt;br&gt;
  await Promise.all(&lt;br&gt;
    images.map((img) =&amp;gt; {&lt;br&gt;
      if (img.complete) return Promise.resolve();&lt;br&gt;
      return new Promise((resolve) =&amp;gt; {&lt;br&gt;
        img.addEventListener("load", resolve);&lt;br&gt;
        img.addEventListener("error", resolve);&lt;br&gt;
        setTimeout(resolve, 5000); // don't hang forever on one broken image&lt;br&gt;
      });&lt;br&gt;
    })&lt;br&gt;
  );&lt;br&gt;
});&lt;br&gt;
\\&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell past me
&lt;/h2&gt;

&lt;p&gt;Don't trust any single DOM measurement as an endpoint signal on a page you &lt;br&gt;
don't control. Modern websites are dynamic enough that almost any static &lt;br&gt;
value can lie to you at some point. Behavior over time — does this keep &lt;br&gt;
changing or not — is a much sturdier signal than asking the DOM "are we &lt;br&gt;
there yet?"&lt;/p&gt;

&lt;p&gt;If you're curious what this screenshot pipeline turned into: &lt;br&gt;
&lt;a href="https://knallhart.dev" rel="noopener noreferrer"&gt;knallhart.dev&lt;/a&gt; — it roasts your website with AI &lt;br&gt;
and emails you three things that are actually wrong with it.&lt;/p&gt;

&lt;p&gt;Happy to go deeper on any part of this if useful.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>I built a €10 AI service that brutally roasts your website</title>
      <dc:creator>KNALLHART.DEV</dc:creator>
      <pubDate>Thu, 11 Jun 2026 16:54:40 +0000</pubDate>
      <link>https://dev.to/knallhartdev/i-built-a-eu10-ai-service-that-brutally-roasts-your-website-2oob</link>
      <guid>https://dev.to/knallhartdev/i-built-a-eu10-ai-service-that-brutally-roasts-your-website-2oob</guid>
      <description>&lt;p&gt;I've been building websites for clients for years. And every time &lt;br&gt;
I looked at their sites, I saw the same problems — unclear CTAs, &lt;br&gt;
hero sections that try to say everything at once, cookie-cutter &lt;br&gt;
layouts that put the booking form three scrolls deep.&lt;/p&gt;

&lt;p&gt;The feedback I'd give them privately was always more honest than &lt;br&gt;
what anyone would write in a formal audit. So I built a tool that &lt;br&gt;
does exactly that.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://knallhart.dev" rel="noopener noreferrer"&gt;knallhart.dev&lt;/a&gt; does one thing:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You submit a URL and your email&lt;/li&gt;
&lt;li&gt;Pay €10 via Stripe&lt;/li&gt;
&lt;li&gt;Our service takes a full-page Playwright screenshot&lt;/li&gt;
&lt;li&gt;Sends it to Gemini 2.5 Flash with a brutally honest prompt&lt;/li&gt;
&lt;li&gt;You get an email with 3 specific critiques + actionable fixes&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No dashboards, no subscriptions, no 47-point audit template. &lt;br&gt;
Just three things that are actually wrong and how to fix them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tech stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next.js&lt;/strong&gt; on Vercel (frontend + webhook handling)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Playwright&lt;/strong&gt; for full-page screenshots (headless Chromium)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gemini 2.5 Flash&lt;/strong&gt; for the AI analysis&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stripe&lt;/strong&gt; for payments&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resend&lt;/strong&gt; for email delivery&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The hardest part was getting Playwright to reliably handle &lt;br&gt;
lazy loading on modern sites. The trick: scroll until &lt;br&gt;
&lt;code&gt;window.scrollY&lt;/code&gt; stops changing rather than calculating &lt;br&gt;
&lt;code&gt;scrollHeight&lt;/code&gt; upfront — that value is unreliable on &lt;br&gt;
too many sites.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real example output
&lt;/h2&gt;

&lt;p&gt;Here's what it said about basecamp.com:&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Welcome to Visual Overload&lt;/strong&gt; — Seriously, did a screenshot &lt;br&gt;
gallery throw up on your hero section? "Refreshingly &lt;br&gt;
straightforward" indeed, if "straightforward" means immediately &lt;br&gt;
overwhelming me with a static, unreadable dump of your entire &lt;br&gt;
application UI before I even know what Basecamp &lt;em&gt;is&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Fix: Replace the dense UI screenshots with a clean, &lt;br&gt;
aspirational visual that highlights benefits, not features.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The Great Wall of Founder's Text&lt;/strong&gt; — A full-page novel &lt;br&gt;
from the co-founder right after being visually assaulted. &lt;br&gt;
This isn't a fireside chat; it's a product page.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Fix: Break it into scannable sections. Move the full letter &lt;br&gt;
to an About page.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Testimonial Tsunami&lt;/strong&gt; — Nine testimonials in a 3x3 grid &lt;br&gt;
and then a button promising a thousand more. This isn't &lt;br&gt;
social proof; it's a marathon reading challenge.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Fix: 2-3 high-impact quotes max, placed near relevant features.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Gemini 2.5 Flash is surprisingly good at visual critique&lt;/strong&gt; &lt;br&gt;
when you give it proper context. The prompt matters a lot — &lt;br&gt;
telling it this is a full-page desktop screenshot, that fixed &lt;br&gt;
elements may be mispositioned, and to focus on the first 900px &lt;br&gt;
as the critical first impression made a huge difference in &lt;br&gt;
output quality.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bot protection is a real problem.&lt;/strong&gt; Large e-commerce sites &lt;br&gt;
(Otto, Zalando) detect headless browsers immediately and serve &lt;br&gt;
a gray placeholder. I handle this by checking screenshot file &lt;br&gt;
size before sending to Gemini — anything suspiciously small &lt;br&gt;
triggers an automatic Stripe refund.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Serverless + async don't mix the way you think.&lt;/strong&gt; &lt;br&gt;
On Vercel, fire-and-forget async operations get killed the &lt;br&gt;
moment you return a response. You have to await the service &lt;br&gt;
call even if the service itself handles things asynchronously.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;If you want your site roasted: &lt;a href="https://knallhart.dev" rel="noopener noreferrer"&gt;knallhart.dev&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you have questions about the build, drop them in the comments.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>sideprojects</category>
      <category>buildinpublic</category>
    </item>
  </channel>
</rss>
