<?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: Dominik</title>
    <description>The latest articles on DEV Community by Dominik (@gronskideveloper).</description>
    <link>https://dev.to/gronskideveloper</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%2F4063459%2F22e7ac79-5161-44b2-b933-ff19ebcb5783.png</url>
      <title>DEV Community: Dominik</title>
      <link>https://dev.to/gronskideveloper</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/gronskideveloper"/>
    <language>en</language>
    <item>
      <title>Why your three.js scene gets a bad PageSpeed score (and it is not JavaScript)</title>
      <dc:creator>Dominik</dc:creator>
      <pubDate>Mon, 28 Sep 2026 07:51:18 +0000</pubDate>
      <link>https://dev.to/gronskideveloper/why-your-threejs-scene-gets-a-bad-pagespeed-score-and-it-is-not-javascript-gpj</link>
      <guid>https://dev.to/gronskideveloper/why-your-threejs-scene-gets-a-bad-pagespeed-score-and-it-is-not-javascript-gpj</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://grodev.pl/blog/three-js-scena-niski-wynik-pagespeed" rel="noopener noreferrer"&gt;grodev.pl&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Your 3D scene runs smoothly on your machine. Sixty frames, no jank. You drop the page into PageSpeed Insights and get a 48. Total Blocking Time is red, and the main-thread breakdown shows one long task blamed on &lt;code&gt;gsap.min.js&lt;/code&gt;. The first instinct is "too much JavaScript" or "too many particles". Both are almost always wrong, and chasing them can eat a whole day.&lt;/p&gt;

&lt;p&gt;This post is the diagnosis, not the recipe. I will show you why a heavy three.js scene scores badly in PageSpeed and where the cost really sits.&lt;/p&gt;

&lt;h2&gt;
  
  
  PageSpeed has no GPU
&lt;/h2&gt;

&lt;p&gt;This is the key that changes everything else, so start here. Google does not measure your page on a graphics card. Lighthouse and PageSpeed run headless Chrome where WebGL goes through software rendering on the CPU (SwiftShader). Your scene, which takes a fraction of a millisecond on your GPU, runs on the CPU on Google's machine.&lt;/p&gt;

&lt;p&gt;You can confirm it with one read of the renderer name. If &lt;code&gt;WEBGL_debug_renderer_info&lt;/code&gt; returns &lt;code&gt;swiftshader&lt;/code&gt;, &lt;code&gt;llvmpipe&lt;/code&gt;, &lt;code&gt;softpipe&lt;/code&gt; or &lt;code&gt;software&lt;/code&gt;, you are in that mode. Real users on virtual machines and weak hardware get the same. As long as you measure on your own card, you are not seeing what Google sees.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it is not JavaScript
&lt;/h2&gt;

&lt;p&gt;In the main-thread breakdown you look at categories: "Script Evaluation", "Style &amp;amp; Layout", "Other". With a heavy WebGL scene, &lt;strong&gt;"Other" can be bigger than "Script Evaluation"&lt;/strong&gt;. That is not a bucket to ignore. Other is usually the GPU work and pixel scans, which a script profile does not show at all.&lt;/p&gt;

&lt;p&gt;Then there is the misleading label. A long task tagged &lt;code&gt;gsap.min.js&lt;/code&gt; or &lt;code&gt;requestAnimationFrame&lt;/code&gt; is usually not GSAP. In one frame, every &lt;code&gt;requestAnimationFrame&lt;/code&gt; call lands in a single task that takes the label of whoever started it. The GSAP ticker registers first, so it takes the blame for someone else's work. Optimizing GSAP here is aiming at the wrong target.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first frame is the most expensive frame
&lt;/h2&gt;

&lt;p&gt;A scene usually draws one frame on start and then waits for the user to move. Google's measurement does not move the mouse, so it sees exactly that one frame. And that one frame compiles every shader program at once.&lt;/p&gt;

&lt;p&gt;On one of my production builds the scene had fourteen programs: particles, arcs, several bloom layers, composite, output. Each compiled in the first frame, and after every compile the library asked the card for the result. Every such query waits until the card has worked through its whole command queue. The result: &lt;strong&gt;one 656 ms task on Google's machine and a score of 73&lt;/strong&gt; on desktop. Total Blocking Time sat around 700 ms. None of it was JavaScript in the sense most guides mean.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cutting particles does not help, and it ruins mobile
&lt;/h2&gt;

&lt;p&gt;The most common reflex is to lower the particle count "for performance". That misreads the problem on two levels.&lt;/p&gt;

&lt;p&gt;First, particles are cheap. A point is one vertex. What is expensive is fill: bloom, transparency, overdraw, not the number of points. Cut the particles and you cut the cheap resource while leaving the expensive one untouched.&lt;/p&gt;

&lt;p&gt;Second, an image made of particles has a resolution equal to their count. Text built from ten thousand particles looks different from the same text built from seventy thousand. When you give the phone ten times fewer particles "to make it lighter", the text goes blurry or sparse, and you still have not touched the real cost. On one build I raised the phone particle count from seven thousand to seventy-two thousand and the text finally looked sharp, while the score did not drop, because the problem was never the point count.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to measure without chasing noise
&lt;/h2&gt;

&lt;p&gt;Before you fix anything, you need a measurement that does not lie. Here are the three traps almost everyone falls into.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you do&lt;/th&gt;
&lt;th&gt;Why it lies&lt;/th&gt;
&lt;th&gt;What to do instead&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Profile in DevTools on your own GPU&lt;/td&gt;
&lt;td&gt;The second load has the programs cached on disk, the compilation disappears&lt;/td&gt;
&lt;td&gt;A fresh profile with no cache, software rendering (&lt;code&gt;--disable-gpu&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A single PageSpeed run&lt;/td&gt;
&lt;td&gt;Noise of 7 to 10 points, the same file gives 80 and 95&lt;/td&gt;
&lt;td&gt;Median of three, a 7-point significance threshold&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Looking at categories instead of tasks&lt;/td&gt;
&lt;td&gt;"Other" tells you nothing about the cause&lt;/td&gt;
&lt;td&gt;Read the list of long tasks with their start times&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rule is simple. Measure in Google's conditions, not on your card. Read specific tasks, not categories. Treat a change smaller than seven points as chance, not success. Only with a measurement like that does it make sense to touch the scene.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do about it
&lt;/h2&gt;

&lt;p&gt;The fix exists and it is repeatable. In short: the first frame must not compile programs, so you compile them ahead of time and warm them up in the background, in the right render conditions. Pixel loops and scene building cannot sit in one task at &lt;code&gt;load&lt;/code&gt;. The phone gets the same particle count, only the shots without text are lighter. On the same build where I started at 73, I ended at &lt;strong&gt;97 on desktop, with Total Blocking Time cut from 700 to 140 ms and the first frame from 656 ms to a few milliseconds&lt;/strong&gt;, with no change to how the scene looks.&lt;/p&gt;

&lt;p&gt;I packaged the full method, with copy-ready code, a measurement protocol and a PageSpeed script, as a &lt;a href="https://grodev.pl/skills" rel="noopener noreferrer"&gt;ready-made skill for developers and coding agents&lt;/a&gt;. If you would rather hand the work to me, &lt;a href="https://grodev.pl/skills" rel="noopener noreferrer"&gt;send the scene URL&lt;/a&gt; and I will tell you plainly where the cost sits and how much I will take off.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Does a bad PageSpeed score on a WebGL scene hurt my Google ranking?&lt;/strong&gt;&lt;br&gt;
Google ranks mostly on field data from real users (Core Web Vitals: LCP, CLS, INP), not on the lab score. A &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt; is not an LCP candidate, so a WebGL scene does not hurt LCP. It does hurt Total Blocking Time, a lab metric, and real INP if the scene blocks the thread during interaction. Worth fixing for the user and the score, without panic that one red lab number sinks the page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why is the long task labelled GSAP when I barely use GSAP?&lt;/strong&gt;&lt;br&gt;
Because a long task label points to the script that started it, not the one that did the work. All &lt;code&gt;requestAnimationFrame&lt;/code&gt; calls in a frame go in one task and take the name of the first. The GSAP ticker registers early, so it takes the blame. The real weight is usually shader compilation in the first frame. Do not optimize GSAP on that basis.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I just move the scene to a Web Worker or OffscreenCanvas?&lt;/strong&gt;&lt;br&gt;
Usually not, and usually not as the first move. A Web Worker is a day of work, while the problem is most often solved by task ordering and compiling programs ahead of time, a change of a few hours. Reach for a Worker when you genuinely have heavy CPU work in the render loop, not to hide shader compilation from the measurement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can it be fixed without changing how the scene looks?&lt;/strong&gt;&lt;br&gt;
Yes, and that is the whole point. The method keeps the desktop image untouched to the percent, and the phone just as sharp, only smaller. It is about order and the way things are drawn, when programs compile and what sits in the first frame, not about cutting graphics.&lt;/p&gt;

</description>
      <category>threejs</category>
      <category>webgl</category>
      <category>performance</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Server-side pricing in Three.js configurators - why client-side gets DevTools-hacked</title>
      <dc:creator>Dominik</dc:creator>
      <pubDate>Fri, 04 Sep 2026 17:52:51 +0000</pubDate>
      <link>https://dev.to/gronskideveloper/server-side-pricing-in-threejs-configurators-why-client-side-gets-devtools-hacked-3jjh</link>
      <guid>https://dev.to/gronskideveloper/server-side-pricing-in-threejs-configurators-why-client-side-gets-devtools-hacked-3jjh</guid>
      <description>&lt;h2&gt;
  
  
  The setup that looks fine
&lt;/h2&gt;

&lt;p&gt;You build a 3D product configurator in Three.js. User picks material, size, options - and next to the model, a live price updates as they configure. The frontend calculates it because that is fast, feels responsive, and does not need a network trip.&lt;/p&gt;

&lt;p&gt;The code looks like this:&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;calculatePrice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&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;price&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;BASE_PRICE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;MATERIAL_PRICING&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;material&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;PER_SQ_METER&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Live update on any config change&lt;/span&gt;
&lt;span class="nx"&gt;someSlider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;input&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readConfig&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;calculatePrice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;updatePriceDisplay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// On order submission, send the whole state&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;submitOrder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/orders&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="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&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;This is what most tutorials show. It is also what I shipped in my first configurator.&lt;/p&gt;

&lt;h2&gt;
  
  
  The obvious problem I missed
&lt;/h2&gt;

&lt;p&gt;A user opens DevTools. &lt;code&gt;window.state.price = 1&lt;/code&gt;. Clicks 'Buy'. Order goes to my backend with price = 1. Backend has no independent pricing logic - it trusts what the frontend sends.&lt;/p&gt;

&lt;p&gt;This is not paranoia. This is a 5-second exploit that anyone with basic browser knowledge can do. I caught it because a friend I asked to test the configurator did exactly this within 20 seconds of opening the page.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix that is actually secure
&lt;/h2&gt;

&lt;p&gt;Separate two responsibilities:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Frontend&lt;/strong&gt;: preview the price. This is UX, not truth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backend&lt;/strong&gt;: calculate the final price from raw config, independently. This is truth.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Frontend - preview only&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;previewPrice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Same math as before, but purely for display&lt;/span&gt;
  &lt;span class="c1"&gt;// Explicitly labeled 'estimate' in UI&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;submitOrder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/orders&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="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="c1"&gt;// No price sent&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Backend - authoritative&lt;/span&gt;
&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;calculateFinalPrice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$config&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nv"&gt;$price&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;BASE_PRICE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nv"&gt;$price&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;MATERIAL_PRICING&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$config&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'material'&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&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;\InvalidArgumentException&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nv"&gt;$price&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nv"&gt;$config&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'width'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nv"&gt;$config&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'height'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;PER_SQ_METER&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$price&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;Frontend never sends price. Backend never trusts frontend price. If someone hacks the DevTools price display, they only change what they see - the order still calculates correctly server-side.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 'but performance' objection
&lt;/h2&gt;

&lt;p&gt;One network trip per configuration change would kill UX. So do not do that. Frontend preview stays instant (client-side math), but is labeled 'preview' or 'estimate'. Backend calculation happens ONCE, at order submission. User never waits during configuration.&lt;/p&gt;

&lt;p&gt;Add a 'Confirm order' step where the real price is fetched from backend and displayed for confirmation - so there is no surprise between what they saw and what they pay. This is the pattern Bagisto, Shopify custom apps and Zakeke all use.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gotcha with variant validation
&lt;/h2&gt;

&lt;p&gt;Same principle applies to configuration validity. Frontend might allow user to set impossible combinations (glass thickness 20mm with hinge type 'lightweight'). Backend must validate independently and reject.&lt;/p&gt;

&lt;p&gt;I keep validation logic in a shared JSON schema (JSON Schema draft-07) that both frontend and backend read. Frontend uses it for real-time feedback. Backend uses it as the final gate.&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;"if"&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;"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;"thickness"&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;"minimum"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;15&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="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"then"&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;"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;"hinge"&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;"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;"heavy-duty"&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="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;One source of truth. Both sides read it. Backend is authoritative on submission.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reference implementation
&lt;/h2&gt;

&lt;p&gt;My starter &lt;a href="https://github.com/GronskiDeveloper/threejs-product-configurator-starter" rel="noopener noreferrer"&gt;threejs-product-configurator-starter&lt;/a&gt; has the frontend preview pattern wired in. Backend calculator is not in the starter (it is deliberately frontend-only for education), but the client-side code is structured so that adding server-side pricing later is a one-file change.&lt;/p&gt;

&lt;p&gt;The pricing question is one of five I hit building configurators. The full list is in &lt;a href="https://4programmers.net/Forum/JavaScript/378090-konfigurator_3d_od_zera_w_threejs_5_pulapek_ktore_kosztowaly_mnie_tydzien" rel="noopener noreferrer"&gt;my 4programmers.net Polish-language thread&lt;/a&gt; - WebGL context leaks, material vs new material for color changes, pixel ratio caps for 4K, OrbitControls vs TrackballControls for touch. Different post, same theme: things Three.js tutorials skip.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I am Dominik Groński / &lt;a href="https://grodev.pl" rel="noopener noreferrer"&gt;GroDev&lt;/a&gt; - a new studio in Poznań, Poland (JDG since May 2026), building custom 3D configurators and Laravel panels for manufacturers. Available for first paid deployments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>threejs</category>
      <category>javascript</category>
      <category>security</category>
      <category>ecommerce</category>
    </item>
    <item>
      <title>How I built @grodev/claude-chat-react — architecture notes</title>
      <dc:creator>Dominik</dc:creator>
      <pubDate>Tue, 18 Aug 2026 12:59:38 +0000</pubDate>
      <link>https://dev.to/gronskideveloper/how-i-built-grodevclaude-chat-react-architecture-notes-5e61</link>
      <guid>https://dev.to/gronskideveloper/how-i-built-grodevclaude-chat-react-architecture-notes-5e61</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;A drop-in React chat widget on the Claude API — &lt;a href="https://www.npmjs.com/package/@grodev/claude-chat-react" rel="noopener noreferrer"&gt;@grodev/claude-chat-react&lt;/a&gt; — with a headless hook (&lt;code&gt;useClaudeStream&lt;/code&gt;) if you want to bring your own UI, or a &lt;code&gt;&amp;lt;ClaudeChat/&amp;gt;&lt;/code&gt; component if you don't. 8 KB gzipped, zero runtime deps beyond React. Companion project to my &lt;a href="https://github.com/GronskiDeveloper/claude-chat-widget" rel="noopener noreferrer"&gt;vanilla-JS widget&lt;/a&gt; — same wire contract, same PHP proxy, different frontend stack.&lt;/p&gt;

&lt;p&gt;This is a notes-from-the-build post: three decisions that shaped the API, and one thing I audited line-by-line because I don't trust the model with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision 1: Two ways in, not one
&lt;/h2&gt;

&lt;p&gt;Every React library eventually gets asked &lt;em&gt;"can I use just the state, not the UI?"&lt;/em&gt; — so I built both from day one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Drop-in — 5 lines&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ClaudeChat&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@grodev/claude-chat-react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ClaudeChat&lt;/span&gt; &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"/api/chat"&lt;/span&gt; &lt;span class="na"&gt;accentColor&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"#1D9E75"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="c1"&gt;// Headless — bring your own UI&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useClaudeStream&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@grodev/claude-chat-react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sendMessage&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useClaudeStream&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/chat&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;The hook does the streaming, state, and abort control; the component adds layout, dark mode, and a11y on top. Consumers who need a different look pay zero cost — they import only the hook and ~2 KB drops out of the bundle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision 2: The API key never touches the browser (and I mean &lt;em&gt;never&lt;/em&gt;)
&lt;/h2&gt;

&lt;p&gt;Central design choice — the widget calls &lt;strong&gt;your&lt;/strong&gt; server-side proxy, never &lt;code&gt;api.anthropic.com&lt;/code&gt; directly. If you look at the source, there is no place where you can accidentally pass an API key as a prop. It's not in the type definitions. It's not a hidden config. &lt;strong&gt;The escape hatch does not exist.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is the boring kind of security: not a validator that checks for keys, but an API shape where the wrong pattern isn't representable. Companion repo &lt;a href="https://github.com/GronskiDeveloper/claude-chat-widget" rel="noopener noreferrer"&gt;claude-chat-widget&lt;/a&gt; ships a working PHP proxy with the exact wire contract this library expects — copy it, deploy anywhere PHP runs, done.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision 3: Streaming with a real abort
&lt;/h2&gt;

&lt;p&gt;The streaming loop looks like a hundred other SSE readers on GitHub. Three things I got specifically right:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;AbortController&lt;/code&gt; lives in &lt;code&gt;useRef&lt;/code&gt;, not &lt;code&gt;useState&lt;/code&gt;. State would re-render every &lt;code&gt;send&lt;/code&gt;, and downstream components would blink.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;setMessages((prev) =&amp;gt; ...)&lt;/code&gt; — never closure-based. Tokens arriving during a re-render need current state, not stale state, or you'll see torn text.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;if (payload.error) throw&lt;/code&gt; — routes upstream errors through the same catch, so &lt;code&gt;onError&lt;/code&gt; fires once with a real &lt;code&gt;Error&lt;/code&gt;, not on each malformed frame.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The a11y I refuse to leave to Claude
&lt;/h2&gt;

&lt;p&gt;When I let the model draft the JSX, it produced a working panel. It also produced a working panel without any of these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;role="dialog"&lt;/code&gt; on the panel&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aria-live="polite"&lt;/code&gt; on the message log&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aria-expanded&lt;/code&gt; on the launcher&lt;/li&gt;
&lt;li&gt;Focus management (input focus on panel open, escape close)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Enter&lt;/code&gt; sends, &lt;code&gt;Shift+Enter&lt;/code&gt; inserts a newline&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of these is in the final code. Every one was added by me after the draft, not before. LLMs don't skip a11y out of malice — they skip it because most tutorial code they trained on skipped it. If you don't audit specifically for the boring accessibility bits, you don't get them.&lt;/p&gt;

&lt;p&gt;That pattern generalizes: audit the AI's work for what the training data underrepresents, not for what the model gets "wrong."&lt;/p&gt;

&lt;h2&gt;
  
  
  What's not in the package
&lt;/h2&gt;

&lt;p&gt;Deliberately out of scope, documented in the README:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Rate limiting → add on your proxy&lt;/li&gt;
&lt;li&gt;Retrieval-augmented context → your job; feed the system prompt from your data&lt;/li&gt;
&lt;li&gt;Session persistence → the hook exposes &lt;code&gt;messages&lt;/code&gt;, you decide where to store&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of those turns a demo into a product, and each one is where your particular use case matters more than a shared abstraction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Full write-up
&lt;/h2&gt;

&lt;p&gt;Project decisions, human-vs-AI split, and the security checklist I run before every proxy change are in &lt;a href="https://github.com/GronskiDeveloper/claude-chat-react/blob/main/CLAUDE.md" rel="noopener noreferrer"&gt;&lt;code&gt;CLAUDE.md&lt;/code&gt;&lt;/a&gt; in the repo. Or the &lt;a href="https://dev.to/gronskideveloper/how-i-document-my-ai-first-workflow-in-every-public-repo-4l0h"&gt;broader post&lt;/a&gt; about how I document AI-first workflow across all six of my repos.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm &lt;a href="https://grodev.pl" rel="noopener noreferrer"&gt;Dominik Groński / GroDev&lt;/a&gt; — a new studio in Poznań, Poland (JDG since May 2026), available for first paid deployments. This library is MIT.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>react</category>
      <category>claude</category>
      <category>typescript</category>
      <category>opensource</category>
    </item>
    <item>
      <title>How I document my AI-first workflow in every public repo</title>
      <dc:creator>Dominik</dc:creator>
      <pubDate>Wed, 12 Aug 2026 06:10:46 +0000</pubDate>
      <link>https://dev.to/gronskideveloper/how-i-document-my-ai-first-workflow-in-every-public-repo-4l0h</link>
      <guid>https://dev.to/gronskideveloper/how-i-document-my-ai-first-workflow-in-every-public-repo-4l0h</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;In every one of my public repos I keep three artifacts that make my AI-first workflow visible from the file tree, not just claimed in a README:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;CLAUDE.md&lt;/code&gt;&lt;/strong&gt; — a literal table of "who did what" (human vs AI) per layer of the project, plus a list of things I &lt;strong&gt;rejected&lt;/strong&gt; from the AI's draft, plus "known gotchas for the next AI pass."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;.claude/commands/&lt;/code&gt;&lt;/strong&gt; — one slash command per repo, tuned to the most expensive failure mode (security review, math invariants, WebGL disposal, hook chain audit, React contract).&lt;/li&gt;
&lt;li&gt;For one representative repo — a full &lt;strong&gt;&lt;code&gt;CASE_STUDY.md&lt;/code&gt;&lt;/strong&gt;: 90-minute build retrospective, step by step, threat model → SDK lookup → AI draft → line-by-line audit → hardening → in-browser verification → docs.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Decision authority (architecture, security posture, API contract, cost engineering, what to &lt;em&gt;reject&lt;/em&gt;) stays with the human. AI gets boilerplate and first drafts. This post shows the how, with links to five working repos as reference.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why publish this at all
&lt;/h2&gt;

&lt;p&gt;Because AI-augmented work isn't worth hiding. Developers who cover it up today will be the ones saying "I don't do computers" in two years. Companies hiring devs already write "AI-first (Claude, Copilot, Cursor)" straight into job posts — and they want to see &lt;strong&gt;evidence&lt;/strong&gt;, not statements.&lt;/p&gt;

&lt;p&gt;But "evidence of AI-first work" isn't "I have ChatGPT open in a tab." It's &lt;strong&gt;artifacts in the file tree&lt;/strong&gt;. This post shows three concrete ones I've deployed across every public repo I own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Artifact 1: &lt;code&gt;CLAUDE.md&lt;/code&gt; with a human/AI split
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;CLAUDE.md&lt;/code&gt; is the file Claude Code reads as project context (analogous to &lt;code&gt;.cursorrules&lt;/code&gt; or &lt;code&gt;AGENTS.md&lt;/code&gt;). But I use it for &lt;strong&gt;two audiences at once&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;For the next AI pass&lt;/strong&gt; — so the model knows what's sacred and what's flexible in this repo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For humans&lt;/strong&gt; — so anyone browsing the file tree (a client, a recruiter, a teammate) sees, in black and white: &lt;strong&gt;who did what&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The table shape I use in every repo:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Who did it&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Threat model &amp;amp; architecture (proxy, not client-side call)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Human&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The whole reason this repo exists. Delegating "should the API key touch the browser?" to an AI is how you ship a leaked-key incident. Not negotiable.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server-side proxy skeleton&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;AI-drafted, human-reviewed line by line&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Claude wrote the first pass; I audited every line for four things: (1) key never in response headers or logs, (2) &lt;code&gt;role&lt;/code&gt; validated, not trusted, (3) input length capped, (4) SSE framing correct.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Widget UI (vanilla JS, ~6 KB)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;AI-drafted, human-styled&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Dependency-free was a hard constraint from me. &lt;code&gt;textContent&lt;/code&gt; (not &lt;code&gt;innerHTML&lt;/code&gt;) on user text is mine — XSS prevention isn't a call to leave to the model's discretion.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prompt caching decision&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Human&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Cost engineering — the difference between $10/mo and $1/mo of API spend.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three things make this useful and not fluff:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The "Why" column&lt;/strong&gt; is the part most people skip. It's also the only place you can tell the author understood &lt;em&gt;why&lt;/em&gt; they delegated (or didn't).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A "What I rejected from the AI's draft" section&lt;/strong&gt; — I write out, in plain text, what Claude proposed, what I refused to merge, and why. This is what separates "I use AI" from "I use AI well." An example from one of my repos:&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;Rejected two things Claude proposed initially:&lt;br&gt;
(1) logging the request body — would log user PII to a plain file. Removed.&lt;br&gt;
(2) falling back to &lt;code&gt;$_ENV['ANTHROPIC_API_KEY']&lt;/code&gt; if &lt;code&gt;getenv()&lt;/code&gt; returned false — unnecessary, and on some hosting stacks it could pick up stale values. Removed.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A "Known gotchas for the next AI pass" section&lt;/strong&gt; — spots where the next iteration of AI would do something dumb. This protects &lt;em&gt;both&lt;/em&gt; me and the model — because Claude reads this file as context, so the next round it won't re-propose an idea that's already been rejected.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Working examples:&lt;br&gt;
&lt;a href="https://github.com/GronskiDeveloper/claude-chat-widget/blob/main/CLAUDE.md" rel="noopener noreferrer"&gt;&lt;code&gt;CLAUDE.md&lt;/code&gt; in claude-chat-widget&lt;/a&gt; ·&lt;br&gt;
&lt;a href="https://github.com/GronskiDeveloper/claude-chat-react/blob/main/CLAUDE.md" rel="noopener noreferrer"&gt;claude-chat-react&lt;/a&gt; ·&lt;br&gt;
&lt;a href="https://github.com/GronskiDeveloper/booking-slots-php/blob/main/CLAUDE.md" rel="noopener noreferrer"&gt;booking-slots-php&lt;/a&gt; ·&lt;br&gt;
&lt;a href="https://github.com/GronskiDeveloper/woocommerce-custom-product-data/blob/main/CLAUDE.md" rel="noopener noreferrer"&gt;woocommerce-custom-product-data&lt;/a&gt; ·&lt;br&gt;
&lt;a href="https://github.com/GronskiDeveloper/threejs-product-configurator-starter/blob/main/CLAUDE.md" rel="noopener noreferrer"&gt;threejs-product-configurator-starter&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Artifact 2: A case study — one build, step by step
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;CLAUDE.md&lt;/code&gt; tells you &lt;strong&gt;what&lt;/strong&gt; someone did. A case study tells you &lt;strong&gt;how&lt;/strong&gt; and &lt;strong&gt;in what order&lt;/strong&gt;. Two different documents, both needed.&lt;/p&gt;

&lt;p&gt;The case study I wrote for &lt;code&gt;claude-chat-widget&lt;/code&gt; has eight sections, each one concrete step of a 90-minute build:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Threat model and architecture&lt;/strong&gt; (human, before writing a single line of code)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Looking up the exact SDK shape&lt;/strong&gt; (human, from the &lt;code&gt;claude-api&lt;/code&gt; skill) — because the model's training data may be stale for SDK APIs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI drafts the proxy skeleton&lt;/strong&gt; (Claude, ~10 min)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Line-by-line audit + hardening&lt;/strong&gt; (human, ~20 min) — the most important section&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI drafts the widget&lt;/strong&gt; (Claude, ~10 min)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Widget audit + hardening&lt;/strong&gt; (human, ~15 min) — XSS, dark mode, mobile, UX&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verification in a real browser&lt;/strong&gt; (human, ~10 min)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Documentation&lt;/strong&gt; (human, ~15 min) — because LLMs write generic READMEs&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This format has one unexpected side effect: &lt;strong&gt;it forces honesty&lt;/strong&gt;. When you write "AI wrote 60% of the lines, human made every load-bearing decision" — you have to say &lt;em&gt;which&lt;/em&gt; decisions those were. Suddenly it's visible where you actually did the work, versus where you just signed off on a generated file.&lt;/p&gt;

&lt;p&gt;Full case study: &lt;a href="https://github.com/GronskiDeveloper/claude-chat-widget/blob/main/CASE_STUDY.md" rel="noopener noreferrer"&gt;&lt;code&gt;CASE_STUDY.md&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Artifact 3: &lt;code&gt;.claude/commands/&lt;/code&gt; — automating the review
&lt;/h2&gt;

&lt;p&gt;Claude Code (and most agents) support &lt;strong&gt;slash commands&lt;/strong&gt; — Markdown files under &lt;code&gt;.claude/commands/&lt;/code&gt; invoked in a session as &lt;code&gt;/name&lt;/code&gt;. They load as part of the prompt, so I can save a checklist and pull it up with one word.&lt;/p&gt;

&lt;p&gt;Every repo I own has &lt;strong&gt;one&lt;/strong&gt; such file, tuned to the class of failure I most want to catch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/GronskiDeveloper/claude-chat-widget/blob/main/.claude/commands/security-review.md" rel="noopener noreferrer"&gt;&lt;code&gt;security-review.md&lt;/code&gt;&lt;/a&gt;&lt;/strong&gt; (&lt;code&gt;claude-chat-widget&lt;/code&gt;) — 6-invariant audit of the proxy's security posture (key only in &lt;code&gt;Anthropic\Client(apiKey:)&lt;/code&gt;, &lt;code&gt;role&lt;/code&gt; validated, length capped, &lt;code&gt;X-Accel-Buffering: no&lt;/code&gt;, etc.). Runs before every merge to &lt;code&gt;server/chat.php&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/GronskiDeveloper/claude-chat-react/blob/main/.claude/commands/react-security-review.md" rel="noopener noreferrer"&gt;&lt;code&gt;react-security-review.md&lt;/code&gt;&lt;/a&gt;&lt;/strong&gt; (&lt;code&gt;claude-chat-react&lt;/code&gt;) — 8-invariant audit for the React version (no &lt;code&gt;@anthropic-ai/sdk&lt;/code&gt; import in &lt;code&gt;src/&lt;/code&gt;, no &lt;code&gt;dangerouslySetInnerHTML&lt;/code&gt;, &lt;code&gt;setMessages(prev =&amp;gt; ...)&lt;/code&gt; never closure-based, &lt;code&gt;AbortController&lt;/code&gt; in a ref not state, &lt;code&gt;react&lt;/code&gt; in peerDependencies, a11y attributes preserved).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/GronskiDeveloper/booking-slots-php/blob/main/.claude/commands/verify-slot-math.md" rel="noopener noreferrer"&gt;&lt;code&gt;verify-slot-math.md&lt;/code&gt;&lt;/a&gt;&lt;/strong&gt; (&lt;code&gt;booking-slots-php&lt;/code&gt;) — audit of the overlap math (half-open intervals &lt;code&gt;[start, end)&lt;/code&gt;, &lt;code&gt;&amp;lt;&lt;/code&gt; not &lt;code&gt;&amp;lt;=&lt;/code&gt;, symmetric buffer). Because booking bugs come from off-by-one errors right there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/GronskiDeveloper/woocommerce-custom-product-data/blob/main/.claude/commands/hook-audit.md" rel="noopener noreferrer"&gt;&lt;code&gt;hook-audit.md&lt;/code&gt;&lt;/a&gt;&lt;/strong&gt; (&lt;code&gt;woocommerce-custom-product-data&lt;/code&gt;) — audit of the three-hook WooCommerce chain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/GronskiDeveloper/threejs-product-configurator-starter/blob/main/.claude/commands/webgl-review.md" rel="noopener noreferrer"&gt;&lt;code&gt;webgl-review.md&lt;/code&gt;&lt;/a&gt;&lt;/strong&gt; (&lt;code&gt;threejs-product-configurator-starter&lt;/code&gt;) — geometry disposal (so WebGL doesn't leak) and "no build step" invariant (so nobody sneaks Vite in).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The effect: &lt;strong&gt;repeatable code review that doesn't depend on my discipline on a given day&lt;/strong&gt;. Instead of remembering all six points every time I touch the proxy, I type &lt;code&gt;/security-review&lt;/code&gt; and get the checklist.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why these three artifacts
&lt;/h2&gt;

&lt;p&gt;Because they cover the three levels of question anyone evaluating your AI-first work actually asks:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;The artifact that answers it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;"What specifically did you do vs the AI?"&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;CLAUDE.md&lt;/code&gt; — one-screen table&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"What does your process look like — order of decisions, what you reject from AI drafts?"&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;CASE_STUDY.md&lt;/code&gt; — one build retrospective&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Can you configure agents, or do you just type in a chat?"&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.claude/commands/&lt;/code&gt; — config tuned to a specific failure class&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A recruiter reviewing 40 GitHub profiles a week isn't going to read your code. They &lt;em&gt;are&lt;/em&gt; going to scan your file tree. If they see &lt;code&gt;CLAUDE.md&lt;/code&gt;, &lt;code&gt;CASE_STUDY.md&lt;/code&gt;, and &lt;code&gt;.claude/commands/&lt;/code&gt; there — they immediately know they're looking at someone who &lt;strong&gt;actually practices&lt;/strong&gt; the workflow, not someone who put it in their CV.&lt;/p&gt;

&lt;h2&gt;
  
  
  The philosophy I'm trying to make routine
&lt;/h2&gt;

&lt;p&gt;AI is a &lt;strong&gt;fast typist and a reasonable reviewer&lt;/strong&gt;. Decision authority — architecture, threat model, validation strategy, dependency selection, what to &lt;strong&gt;reject&lt;/strong&gt; — stays with the human. When I hand decision authority to a model, I get a repo that lints clean and blows up in production.&lt;/p&gt;

&lt;p&gt;This philosophy works well enough that I shipped 5 working public repos (a WordPress plugin, a PHP booking-slots engine, a Three.js configurator starter, a vanilla-JS chat widget, and a React chat widget on Claude API) — each with a full &lt;code&gt;CLAUDE.md&lt;/code&gt;, docs, badges, and a pinned &lt;code&gt;v1.0.0&lt;/code&gt; release. Every load-bearing decision documented, every AI draft audited before merge.&lt;/p&gt;

&lt;p&gt;Full profile: &lt;a href="https://github.com/GronskiDeveloper" rel="noopener noreferrer"&gt;github.com/GronskiDeveloper&lt;/a&gt;. Fork any repo and check what &lt;code&gt;CLAUDE.md&lt;/code&gt; looks like in practice.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build websites, e-commerce, custom 3D configurators and AI assistants — see &lt;a href="https://grodev.pl" rel="noopener noreferrer"&gt;grodev.pl&lt;/a&gt; or &lt;a href="https://grodev.pl/ai" rel="noopener noreferrer"&gt;grodev.pl/ai&lt;/a&gt;. Poznań, Poland, remote across PL and EU.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aifirst</category>
      <category>claude</category>
      <category>workflow</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Building a 3D Product Configurator in Three.js — Lessons From 9 Client Deployments</title>
      <dc:creator>Dominik</dc:creator>
      <pubDate>Wed, 05 Aug 2026 06:47:15 +0000</pubDate>
      <link>https://dev.to/gronskideveloper/building-a-3d-product-configurator-in-threejs-lessons-from-9-client-deployments-3c90</link>
      <guid>https://dev.to/gronskideveloper/building-a-3d-product-configurator-in-threejs-lessons-from-9-client-deployments-3c90</guid>
      <description>&lt;p&gt;Over the last year I shipped &lt;strong&gt;9 production 3D configurators&lt;/strong&gt; for polish manufacturers — pools, garage doors, saunas, pergolas, greenhouses, packaging, decorative lamps, terrace roofs, and light-boxes. Each one runs live on its own subdomain of my studio at &lt;a href="https://grodev.pl" rel="noopener noreferrer"&gt;grodev.pl&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Some of the lessons were obvious in hindsight. Some cost me a weekend of debugging. Sharing the non-obvious ones here.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Draco compression is not optional for CAD-heavy models
&lt;/h2&gt;

&lt;p&gt;Manufacturers send you &lt;strong&gt;STEP or SolidWorks files exported to glTF&lt;/strong&gt;. Raw output is 40–120 MB per variant. On 4G mobile that's a 20-second load with an empty white canvas.&lt;/p&gt;

&lt;p&gt;Draco compression brings that to 2–5 MB with &lt;strong&gt;no visible quality loss&lt;/strong&gt; on product shots:&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;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;GLTFLoader&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;three/examples/jsm/loaders/GLTFLoader.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;DRACOLoader&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;three/examples/jsm/loaders/DRACOLoader.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dracoLoader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DRACOLoader&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nx"&gt;dracoLoader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setDecoderPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/draco/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// self-hosted, don't use CDN&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;loader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;GLTFLoader&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nx"&gt;loader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setDRACOLoader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dracoLoader&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nx"&gt;loader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/models/pool-3.5m.glb&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="nx"&gt;gltf&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;scene&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="nx"&gt;gltf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scene&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;Self-host the decoder — Google's CDN version added ~600 ms to first paint in my measurements. Copy &lt;code&gt;node_modules/three/examples/jsm/libs/draco/&lt;/code&gt; to your &lt;code&gt;public/&lt;/code&gt; folder.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tooling:&lt;/strong&gt; &lt;code&gt;gltf-pipeline -i model.glb -o model.draco.glb --draco.compressionLevel 10&lt;/code&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Instancing beats individual meshes past ~200 objects
&lt;/h2&gt;

&lt;p&gt;A pergola with 40 louvres × 3 tilt positions × user color picker = 120 meshes updating on every frame. Naive approach tanks FPS to 12 on mid-range phones.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;InstancedMesh&lt;/code&gt; batches identical geometry into one draw call:&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;geo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;THREE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;BoxGeometry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&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;mat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;THREE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;MeshStandardMaterial&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;louvres&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;THREE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;InstancedMesh&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;geo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;40&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;dummy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;THREE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Object3D&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="mi"&gt;40&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="nx"&gt;dummy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;position&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&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;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;dummy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rotation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;userTilt&lt;/span&gt;  &lt;span class="c1"&gt;// update per frame is fine&lt;/span&gt;
  &lt;span class="nx"&gt;dummy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updateMatrix&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="nx"&gt;louvres&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setMatrixAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dummy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;matrix&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;louvres&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;instanceMatrix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;needsUpdate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="nx"&gt;scene&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="nx"&gt;louvres&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same for basen tiles, brama slats, sauna wall boards. &lt;strong&gt;One material change updates all instances.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Ship the price calculator to the server, not the client
&lt;/h2&gt;

&lt;p&gt;Every configurator eventually needs a "Show price" button. Tempting to compute client-side — you already have all the state.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't.&lt;/strong&gt; Manufacturers change prices monthly. Every hardcoded PHP-in-JS multiplication is a redeploy. Also: users open DevTools.&lt;/p&gt;

&lt;p&gt;Instead:&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="c1"&gt;// Only what changed goes over the wire&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pool-3.5m&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;finish&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;granite-grey&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;extras&lt;/span&gt;&lt;span class="p"&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;lighting&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;cover&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;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;deliveryWeeks&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/quote&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="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Server (Laravel in my case) hits a &lt;code&gt;pricing_rules&lt;/code&gt; table with monthly-updated coefficients. Client only knows what a valid config looks like, never the pricing logic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bonus:&lt;/strong&gt; the same endpoint powers the "email me a PDF quote" flow. Zero duplication.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Mobile is your target device, not a "nice to have"
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;70% of my configurator traffic is mobile.&lt;/strong&gt; Sauna buyers browse on the couch, pool buyers browse at the site. Any deployment that assumes desktop-first will feel broken.&lt;/p&gt;

&lt;p&gt;Concrete mobile survival kit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))&lt;/code&gt; — clamping to 2 saves ~40% GPU on retina phones&lt;/li&gt;
&lt;li&gt;Detect &lt;code&gt;WEBGL_lose_context&lt;/code&gt; and reload cleanly when the browser tab is backgrounded on iOS&lt;/li&gt;
&lt;li&gt;Preload one hero material, lazy-load the rest — first paint at 400 ms feels instant even if full swatch library is still coming&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  5. The integration is 60% of the project
&lt;/h2&gt;

&lt;p&gt;Building the WebGL viewer takes 2–3 weeks. Making it actually feed the manufacturer's WooCommerce / CRM / ERP takes &lt;strong&gt;another 4–6 weeks&lt;/strong&gt; — and the client only sees the shiny part.&lt;/p&gt;

&lt;p&gt;Budget accordingly. I now quote &lt;strong&gt;12–55k PLN&lt;/strong&gt; depending on catalog depth and integration surface, not per screen.&lt;/p&gt;




&lt;p&gt;Live examples if you want to see any of these techniques in production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://basen3d.grodev.pl" rel="noopener noreferrer"&gt;basen3d.grodev.pl&lt;/a&gt;&lt;/strong&gt; — pool configurator (Draco + PWA)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://pergola3d.grodev.pl" rel="noopener noreferrer"&gt;pergola3d.grodev.pl&lt;/a&gt;&lt;/strong&gt; — bioclimatic pergola (InstancedMesh louvres)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://lampy3d.grodev.pl" rel="noopener noreferrer"&gt;lampy3d.grodev.pl&lt;/a&gt;&lt;/strong&gt; — decorative lamp (white-label)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Full studio at &lt;strong&gt;&lt;a href="https://grodev.pl" rel="noopener noreferrer"&gt;grodev.pl&lt;/a&gt;&lt;/strong&gt; — happy to chat if you're integrating similar for a manufacturer.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What tripped you up on your first Three.js production project? Reply — always curious about other people's disaster stories.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>threejs</category>
      <category>webgl</category>
      <category>javascript</category>
      <category>ecommerce</category>
    </item>
  </channel>
</rss>
