<?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: Plopino</title>
    <description>The latest articles on DEV Community by Plopino (@plopino).</description>
    <link>https://dev.to/plopino</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%2F4126510%2Fe1dd58b1-8df4-41b1-9925-64a495c40432.png</url>
      <title>DEV Community: Plopino</title>
      <link>https://dev.to/plopino</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/plopino"/>
    <language>en</language>
    <item>
      <title>When AI-generated HTML needs a backend</title>
      <dc:creator>Plopino</dc:creator>
      <pubDate>Sat, 24 Oct 2026 03:20:45 +0000</pubDate>
      <link>https://dev.to/plopino/when-ai-generated-html-needs-a-backend-4l9d</link>
      <guid>https://dev.to/plopino/when-ai-generated-html-needs-a-backend-4l9d</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;AI disclosure: I used AI assistance to draft and edit this article. I reviewed the technical claims against the browser documentation linked below and checked the examples for consistency. The code is intentionally simplified.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;An AI assistant can produce a polished HTML demo in a minute. The page has buttons, calculations, a form, and maybe a chart. It looks like an application, so it is easy to assume that publishing the HTML also publishes everything behind it.&lt;/p&gt;

&lt;p&gt;That last step needs a closer look. A static page can render, calculate, filter, animate, and remember a preference in one browser. It cannot safely hold a private API key, turn every visitor's browser into your shared database, or make a service running only on your laptop available to everyone else.&lt;/p&gt;

&lt;p&gt;Here is the checklist I use to decide whether a generated page is ready for static hosting or needs a backend.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Ask where the data should go
&lt;/h2&gt;

&lt;p&gt;This code can make a feedback form look finished:&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;items&lt;/span&gt; &lt;span class="o"&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;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;feedback&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;createdAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;feedback&lt;/span&gt;&lt;span class="dl"&gt;'&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;items&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It preserves the current visitor's entries in that browser. It does not send them to the page owner, and another visitor will not see them. Browser storage is useful for a draft, a preference, or a local-only tool. It is not a shared submissions database.&lt;/p&gt;

&lt;p&gt;There is another preview trap: the required behavior of &lt;code&gt;localStorage&lt;/code&gt; for &lt;code&gt;file:&lt;/code&gt; URLs is undefined and can vary between browsers. A result you see after double-clicking an HTML file is not a reliable test of what storage will do after deployment.&lt;/p&gt;

&lt;p&gt;If you only need a visual demonstration, keep the data in memory or use a small fixture and label the action clearly: "Demo only — nothing is submitted." If someone must receive the submission, use an HTTPS endpoint and show what happened after the server responds.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Check whose computer &lt;code&gt;localhost&lt;/code&gt; means
&lt;/h2&gt;

&lt;p&gt;Generated code often contains a call 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;const&lt;/span&gt; &lt;span class="nx"&gt;response&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;http://localhost:3000/api/quote&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="nx"&gt;quote&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;response&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;While you develop, that address can reach a server on your machine. After you send the page to somebody else, the same code runs in their browser. &lt;code&gt;localhost&lt;/code&gt; then means their device, not yours.&lt;/p&gt;

&lt;p&gt;A relative URL does not magically preserve your development backend either:&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="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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a static host, this normally asks that host for &lt;code&gt;/api/quote&lt;/code&gt;. If no such endpoint exists, the page may receive a 404 or an HTML fallback instead of JSON.&lt;/p&gt;

&lt;p&gt;For a self-contained demo, replace the request with explicit sample data and keep the simulated behavior visible. For live data, deploy an endpoint your visitors can reach, then test its response and CORS policy from the public page.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Remove secrets from the delivered files
&lt;/h2&gt;

&lt;p&gt;A private credential does not become safe because it sits inside a JavaScript variable or an HTML comment:&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;// Simplified anti-example: never publish a real privileged credential here.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&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;https://api.example.com/private-data&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;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Bearer SERVER_ONLY_KEY&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;Every visitor can download the page and inspect its scripts. Obfuscation changes how the credential looks; it does not establish a secret boundary.&lt;/p&gt;

&lt;p&gt;Before publishing, inspect the actual export directory rather than only the project root. A bundler can copy a secret into its output even when &lt;code&gt;.env&lt;/code&gt; itself is excluded. Check inline scripts, source maps, generated configuration, fixtures, and comments too. Remove private customer data at the same time.&lt;/p&gt;

&lt;p&gt;If a privileged API call is required, keep that credential on a server. The browser calls your limited endpoint; your endpoint validates the request and calls the upstream service:&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;// Server-side route sketch. Add authentication, validation, and rate limits for a real service.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;GET&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="nx"&gt;upstream&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;https://api.example.com/private-data&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;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SERVICE_API_KEY&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&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;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;upstream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Do not pass an upstream secret or sensitive response through by default.&lt;/span&gt;
    &lt;span class="k"&gt;return&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;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Upstream request failed&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;502&lt;/span&gt; &lt;span class="p"&gt;});&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;Response&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;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;upstream&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;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Some client-side API identifiers are deliberately public and can be restricted by origin or scope. That is a provider-specific design, so follow the provider's documentation. Do not treat a server credential as public just because the variable name contains &lt;code&gt;KEY&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Test browser boundaries, not just the layout
&lt;/h2&gt;

&lt;p&gt;A local file, a development server, and a public HTTPS page are different environments.&lt;/p&gt;

&lt;p&gt;JavaScript modules loaded from &lt;code&gt;file:&lt;/code&gt; URLs can trigger CORS errors. Test modules through an HTTP server, and make sure the server returns JavaScript files with a JavaScript-compatible MIME type. An apparently missing module can be a delivery problem rather than an import mistake.&lt;/p&gt;

&lt;p&gt;For a public page that calls another origin, that server's CORS policy controls whether browser JavaScript may read the response. &lt;code&gt;mode: 'no-cors'&lt;/code&gt; is not a general way to unlock JSON for your application. If you control the API, configure the origins it should serve; if you do not, a server-side integration may be the right boundary.&lt;/p&gt;

&lt;p&gt;Also check for &lt;code&gt;http://&lt;/code&gt; dependencies in an HTTPS page. Browsers restrict mixed content, and &lt;code&gt;fetch()&lt;/code&gt; requests to insecure resources can fail. Camera, microphone, service worker, and modern clipboard features have additional secure-context and permission requirements. &lt;code&gt;http://localhost&lt;/code&gt; receives special treatment for development, which can hide a deployment problem.&lt;/p&gt;

&lt;p&gt;Feature detection is more useful than assuming the preview and the public page have the same capabilities:&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="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isSecureContext&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clipboard&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;writeText&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="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clipboard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&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="c1"&gt;// Show selectable text and a clear manual-copy instruction.&lt;/span&gt;
  &lt;span class="nf"&gt;showManualCopy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&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;h2&gt;
  
  
  5. Make the static/backend split explicit
&lt;/h2&gt;

&lt;p&gt;A static page is a good fit when the result depends on the current visitor's input and public assets: a calculator, a proposal preview, a chart from bundled data, or a small editing tool whose output can be downloaded.&lt;/p&gt;

&lt;p&gt;Add a backend when the product needs shared state or authority: submissions that another person must read, accounts and permissions, shared edits, private upstream credentials, privileged actions, or durable data across visitors and devices.&lt;/p&gt;

&lt;p&gt;Before sharing the page, run this short review:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open the public URL while logged out, ideally on a second device. Test one important interaction and a narrow screen.&lt;/li&gt;
&lt;li&gt;Inspect the Network panel during that interaction. Check the final URL, status, response type, CORS errors, and mixed-content errors.&lt;/li&gt;
&lt;li&gt;Search the delivered files for private data and credentials. Do not publish the whole source directory when only the built output is meant to be public.&lt;/li&gt;
&lt;li&gt;Submit the form and explain where the data went. Distinguish a local simulation from a server-confirmed submission.&lt;/li&gt;
&lt;li&gt;Check clipboard, camera, microphone, and offline behavior under the real deployment protocol. Provide a usable fallback when a capability is unavailable.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The goal is not to add a backend to every generated page. It is to make the page honest about where computation, data, and authority live. Once those boundaries are clear, static hosting can stay simple without pretending to provide server behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules" rel="noopener noreferrer"&gt;MDN: JavaScript modules&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage" rel="noopener noreferrer"&gt;MDN: Window.localStorage&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS" rel="noopener noreferrer"&gt;MDN: Cross-Origin Resource Sharing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/Security/Defenses/Mixed_content" rel="noopener noreferrer"&gt;MDN: Mixed content&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/Security/Defenses/Secure_Contexts/features_restricted_to_secure_contexts" rel="noopener noreferrer"&gt;MDN: Features restricted to secure contexts&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>html</category>
      <category>beginners</category>
    </item>
    <item>
      <title>What an anonymous feedback inbox should store — and what it should not</title>
      <dc:creator>Plopino</dc:creator>
      <pubDate>Fri, 23 Oct 2026 14:29:53 +0000</pubDate>
      <link>https://dev.to/plopino/what-an-anonymous-feedback-inbox-should-store-and-what-it-should-not-4kk0</link>
      <guid>https://dev.to/plopino/what-an-anonymous-feedback-inbox-should-store-and-what-it-should-not-4kk0</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;AI disclosure: I used AI assistance to draft and edit this article. I checked the code excerpts against the production codebase, ran the relevant tests locally, and stand behind the technical claims below.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Plopino is a small static host that accepts anonymous HTML uploads. That works for the sending side and leaves a hole on the receiving side: until this week, someone who hit a bug only had an abuse email address.&lt;/p&gt;

&lt;p&gt;On October 20 I shipped an in-site feedback inbox: a form that does not require login, and an admin inbox where I read and answer messages. This post covers the tradeoffs I chose, including the cases the happy path does not show.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why build it into the product
&lt;/h2&gt;

&lt;p&gt;An external form would have taken an afternoon, but it adds another data origin, a third-party script, and a privacy-policy paragraph. The product already accepts anonymous uploads, so the contact channel had to accept the same audience.&lt;/p&gt;

&lt;p&gt;I cut the rest: no notifications, no attachments, no categories, and no state beyond new, read, and archived.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check cheap signals before spending quota
&lt;/h2&gt;

&lt;p&gt;The endpoint is public and the rate limit is daily. The order matters: check cheap automation signals, then validate the input, then consume quota. A typo should not burn the allowance of someone acting in good faith.&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;// POST /api/feedback — trimmed, imports and the insert omitted&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;body&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;website&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nc"&gt;String&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;website&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="o"&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;return&lt;/span&gt; &lt;span class="nf"&gt;sendJson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&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;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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="nx"&gt;startedAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&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;startedAt&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="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isFinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;startedAt&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="nx"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;startedAt&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;elapsed&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;sendJson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&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;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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="nx"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;typeof&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;message&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&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;message&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="p"&gt;:&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;sendJson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;tErr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error.feedbackRequired&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;4000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;sendJson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;tErr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error.feedbackTooLong&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;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4000&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="nx"&gt;daily&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;consumeDailyQuota&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gwdb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`feedback:ip:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;clientIp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;feedbackDailyLimit&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;daily&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;sendJson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;tErr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error.feedbackRateLimited&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;&lt;code&gt;website&lt;/code&gt; is a hidden honeypot that a person will not fill. &lt;code&gt;startedAt&lt;/code&gt; is recorded when the page opens; less than two seconds to submit is a script signal. Both checks return &lt;code&gt;200&lt;/code&gt; with &lt;code&gt;ok: true&lt;/code&gt;, because an explicit error would tell automation which rule to avoid.&lt;/p&gt;

&lt;p&gt;The silence has a cost: a very fast human can lose a message without knowing. A clock that is ahead also becomes a real edge case. Future timestamps therefore follow the normal flow rather than being treated as suspicious.&lt;/p&gt;

&lt;p&gt;With no captcha, the limit is the main defense: ten messages per IP per day, stored in the same database as the upload quota and reset at the UTC day boundary. Signed-in users share the IP bucket, so a shared network splits those ten messages between everyone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Render nothing as HTML
&lt;/h2&gt;

&lt;p&gt;Messages and replies are stored as they arrive, without an HTML sanitizer. Feedback is not a page. The rendering rule is simpler than cleaning arbitrary HTML: text from the inbox always goes into the &lt;code&gt;textContent&lt;/code&gt; of a DOM-created element, never string-concatenated into &lt;code&gt;innerHTML&lt;/code&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;msg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;p&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;className&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fb-msg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The admin reply renders the same way. &lt;code&gt;pageUrl&lt;/code&gt; accepts only an internal path; absolute URLs, protocol-relative URLs, and backslashes become &lt;code&gt;NULL&lt;/code&gt;, with a 300-character cap. An admin UI test reads the HTML and fails if the message body lands in &lt;code&gt;innerHTML&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This choice moves the risk rather than removing it. Safety depends on every future renderer following the same rule. That is why the test checks the rendering path, not just the database layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three audiences, three permission levels
&lt;/h2&gt;

&lt;p&gt;Submission is public. "My feedback" requires a session, filters by &lt;code&gt;user_id&lt;/code&gt;, returns at most 20 rows, and omits email and the admin's name. Every admin endpoint requires the admin role. Status is a closed list; replies are capped at 4,000 characters; an empty reply clears the existing one.&lt;/p&gt;

&lt;p&gt;Those APIs return &lt;code&gt;Cache-Control: private, no-store&lt;/code&gt;, and the public form carries &lt;code&gt;noindex&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The known limit: an anonymous sender gets a reply only if they leave an email. A one-time lookup link was cut because it would add another state machine for the same problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  What one row keeps
&lt;/h2&gt;

&lt;p&gt;A row holds the message, an optional email, the internal page path, and, for a signed-in sender, a reference to the account plus the username at submission time.&lt;/p&gt;

&lt;p&gt;It does not store IP or User-Agent. Abuse control uses a daily per-IP counter and resets it the next day. The analytics event contains no message text and no email; it records only the path and whether the sender was signed in.&lt;/p&gt;

&lt;p&gt;Writing down what the inbox must not store was more useful than choosing a schema first. The omissions reduced what had to be protected, exported, or explained later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Translations are data with contracts
&lt;/h2&gt;

&lt;p&gt;The UI already supported 20 languages. The inbox added 32 keys, translated into the other 19 languages, and localized the server errors so a &lt;code&gt;429&lt;/code&gt; arrives as "too many messages today" instead of a bare code.&lt;/p&gt;

&lt;p&gt;The structural test compares every dictionary with English on three axes: the key set, key order, and placeholder names. It does not guarantee translation quality. The 19 versions are AI-assisted and have not all been reviewed by native speakers, but no raw key reaches the screen without a failing test.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the tests cover
&lt;/h2&gt;

&lt;p&gt;I ran the 13 feedback test cases: validation, the honeypot and minimum fill time, the shared per-IP limit, isolation of "my feedback", admin permissions, replies, deletion, and 404 responses. I also ran the 4 admin UI tests; one fails if the message body goes through &lt;code&gt;innerHTML&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Those tests check the code, not real traffic. There is no browser visual test yet, and I will understand shared-network behavior from the inbox itself.&lt;/p&gt;

&lt;p&gt;The design decision I would repeat: define the data the inbox must not keep before choosing the database or building the UI. The implementation then has less to forget later.&lt;/p&gt;




&lt;p&gt;This article is a canonicalized cross-post of the original in my engineering notes: &lt;a href="https://plopino.hashnode.dev/adding-feedback-to-an-account-free-service-what-the-inbox-does-not-store" rel="noopener noreferrer"&gt;Adding feedback to an account-free service: what the inbox does not store&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>privacy</category>
      <category>api</category>
    </item>
    <item>
      <title>Preço certo, prazo antigo: como conferir as transições de uma proposta em HTML</title>
      <dc:creator>Plopino</dc:creator>
      <pubDate>Fri, 02 Oct 2026 12:38:09 +0000</pubDate>
      <link>https://dev.to/plopino/preco-certo-prazo-antigo-como-conferir-as-transicoes-de-uma-proposta-em-html-3o2o</link>
      <guid>https://dev.to/plopino/preco-certo-prazo-antigo-como-conferir-as-transicoes-de-uma-proposta-em-html-3o2o</guid>
      <description>&lt;p&gt;Uma proposta interativa pode mostrar o preço certo e, ao mesmo tempo, manter o prazo da opção anterior. É fácil não perceber isso quando cada botão é conferido separadamente.&lt;/p&gt;

&lt;p&gt;Ao organizar um exemplo pequeno em HTML e JavaScript, o critério foi fazer preço, prazo e entregáveis saírem da mesma combinação de dados. São dois pacotes e duas revisões, com valores fictícios.&lt;/p&gt;

&lt;h2&gt;
  
  
  Primeiro, saber de onde vem cada resposta
&lt;/h2&gt;

&lt;p&gt;O estado guarda as escolhas, não o resultado:&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;revision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;first&lt;/span&gt;&lt;span class="dl"&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;plan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;essential&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;Uma tabela contém as quatro combinações. Este recorte usa números em USD apenas para ilustrar o modelo, sem conversão para reais:&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;proposals&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;first&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;essential&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4800&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;weeks&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="na"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;7200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;weeks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;revised&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;essential&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;weeks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;8100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;weeks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&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;O registro atual é &lt;code&gt;proposals[revision][plan]&lt;/code&gt;. Não existe uma variável extra para o preço, nem outra para o prazo. Assim, há menos valores que precisam ser mantidos em acordo.&lt;/p&gt;

&lt;h2&gt;
  
  
  O evento escolhe; a renderização atualiza
&lt;/h2&gt;

&lt;p&gt;Em vez de cada botão atualizar um pedaço da página, os eventos mudam uma escolha e chamam a mesma função &lt;code&gt;render()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;No exemplo completo, essa função atualiza o resumo, reconstrói a lista de entregáveis e ajusta &lt;code&gt;aria-pressed&lt;/code&gt; dos dois grupos de botões. Os textos entram com &lt;code&gt;textContent&lt;/code&gt;, sem serem interpretados como HTML.&lt;/p&gt;

&lt;p&gt;Isso também evita um comportamento estranho: trocar a revisão não volta o pacote para o básico. Quem está comparando o pacote completo continua nele. Só a revisão muda.&lt;/p&gt;

&lt;p&gt;Essa escolha deixa a comparação mais fácil de acompanhar. Se um pacote não estiver disponível em uma revisão futura, a interface precisará tratar e explicar esse caso.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conferir quatro resultados e depois percorrer o caminho
&lt;/h2&gt;

&lt;p&gt;A tabela esperada é pequena:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Revisão&lt;/th&gt;
&lt;th&gt;Pacote&lt;/th&gt;
&lt;th&gt;Valor fictício (USD)&lt;/th&gt;
&lt;th&gt;Prazo&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Inicial&lt;/td&gt;
&lt;td&gt;Básico&lt;/td&gt;
&lt;td&gt;4800&lt;/td&gt;
&lt;td&gt;3 semanas&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Inicial&lt;/td&gt;
&lt;td&gt;Completo&lt;/td&gt;
&lt;td&gt;7200&lt;/td&gt;
&lt;td&gt;4 semanas&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Revisada&lt;/td&gt;
&lt;td&gt;Básico&lt;/td&gt;
&lt;td&gt;5400&lt;/td&gt;
&lt;td&gt;4 semanas&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Revisada&lt;/td&gt;
&lt;td&gt;Completo&lt;/td&gt;
&lt;td&gt;8100&lt;/td&gt;
&lt;td&gt;5 semanas&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Conferir essas linhas ajuda a encontrar um dado errado. Mas a interface tem memória entre os cliques: algum elemento pode ficar com o conteúdo da seleção anterior.&lt;/p&gt;

&lt;p&gt;Por isso, vale percorrer &lt;strong&gt;Completo → Revisada → Básico → Inicial&lt;/strong&gt; sem recarregar a página.&lt;/p&gt;

&lt;p&gt;Em cada etapa, confira:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;se o preço e o prazo correspondem à mesma combinação;&lt;/li&gt;
&lt;li&gt;se a lista de entregáveis foi atualizada;&lt;/li&gt;
&lt;li&gt;se o pacote escolhido foi mantido ao trocar a revisão;&lt;/li&gt;
&lt;li&gt;se os dois grupos mostram o estado selecionado correto.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Não é preciso transformar esse exemplo em uma aplicação grande para conferir isso. Uma sequência curta já revela mais do que abrir cada combinação isoladamente.&lt;/p&gt;

&lt;h2&gt;
  
  
  O teclado também precisa entrar na conferência
&lt;/h2&gt;

&lt;p&gt;Os controles usam elementos &lt;code&gt;button&lt;/code&gt;. Tab muda o foco; Enter e Espaço ativam o botão. &lt;code&gt;aria-pressed&lt;/code&gt; expõe a seleção.&lt;/p&gt;

&lt;p&gt;O resumo usa &lt;code&gt;aria-live="polite"&lt;/code&gt; e &lt;code&gt;aria-atomic="true"&lt;/code&gt;, para disponibilizar a atualização completa às tecnologias assistivas. Ainda é necessário conferir o comportamento nos leitores de tela usados pelo público; esses atributos, sozinhos, não garantem que toda a experiência esteja boa.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prévia não é publicação
&lt;/h2&gt;

&lt;p&gt;As duas revisões já estão no arquivo. O botão alterna o que aparece, sem salvar nem atualizar a página hospedada.&lt;/p&gt;

&lt;p&gt;Por isso, “prévia da revisão” descreve melhor a ação. Para atualizar o conteúdo público, é necessário enviar o arquivo alterado e conferir o endereço que será compartilhado.&lt;/p&gt;

&lt;p&gt;O exemplo ficou pequeno porque tem uma tarefa limitada: comparar escopo sem separar preço, prazo e entregáveis. A parte interessante é conferir se essa promessa continua verdadeira depois de vários cliques.&lt;/p&gt;

&lt;p&gt;O &lt;a href="https://dev.to/plopino/a-tiny-html-proposal-with-two-switches-and-one-render-function-2gf5"&gt;artigo original em inglês&lt;/a&gt; traz o HTML e o JavaScript completos para reproduzir o exemplo. Aqui, o foco foi o raciocínio por trás da verificação das transições.&lt;/p&gt;

&lt;p&gt;Vocês costumam testar os estados isolados primeiro ou já começam pelos caminhos que a pessoa pode percorrer?&lt;/p&gt;

</description>
      <category>portuguese</category>
      <category>testing</category>
    </item>
    <item>
      <title>Cambiar la versión de una propuesta sin perder el paquete elegido</title>
      <dc:creator>Plopino</dc:creator>
      <pubDate>Fri, 02 Oct 2026 12:36:37 +0000</pubDate>
      <link>https://dev.to/plopino/cambiar-la-version-de-una-propuesta-sin-perder-el-paquete-elegido-170p</link>
      <guid>https://dev.to/plopino/cambiar-la-version-de-una-propuesta-sin-perder-el-paquete-elegido-170p</guid>
      <description>&lt;p&gt;Al comparar dos versiones de una propuesta, ya hay bastante que recordar. Si cambio de versión y la página también cambia el paquete que había elegido, termino comparando dos cosas a la vez.&lt;/p&gt;

&lt;p&gt;Ese detalle fue uno de los criterios al ordenar un pequeño ejemplo en HTML y JavaScript: dos paquetes, dos revisiones y un resumen con precio, plazo y entregables. Los importes son ficticios.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dos decisiones, no cuatro botones independientes
&lt;/h2&gt;

&lt;p&gt;La selección cabe en dos variables:&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;revision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;first&lt;/span&gt;&lt;span class="dl"&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;plan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;essential&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;Cambiar la revisión modifica únicamente &lt;code&gt;revision&lt;/code&gt;. Cambiar el paquete modifica únicamente &lt;code&gt;plan&lt;/code&gt;. No hay una razón para reiniciar la otra elección.&lt;/p&gt;

&lt;p&gt;Un ejemplo mínimo de los datos sería este. Los importes están expresados en USD, solo para ilustrar la estructura:&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;proposals&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;first&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;essential&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4800&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;weeks&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="na"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;7200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;weeks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;revised&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;essential&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;weeks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;8100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;weeks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;currentProposal&lt;/span&gt;&lt;span class="p"&gt;()&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;proposals&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;revision&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="nx"&gt;plan&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;El resultado se deriva de la selección. Guardar también el precio como estado crearía otra cosa que mantener sincronizada.&lt;/p&gt;

&lt;h2&gt;
  
  
  Un solo sitio para actualizar la interfaz
&lt;/h2&gt;

&lt;p&gt;La tentación es poner un poco de lógica en cada botón: este cambia el precio, aquel cambia el plazo, otro reconstruye la lista. Cuesta ver si todos terminan dejando la página en el mismo estado.&lt;/p&gt;

&lt;p&gt;La alternativa del ejemplo es que cada evento actualice una variable y llame a &lt;code&gt;render()&lt;/code&gt;. Esa función obtiene el registro de la combinación actual y actualiza el resumen, los entregables y &lt;code&gt;aria-pressed&lt;/code&gt; de los botones.&lt;/p&gt;

&lt;p&gt;Para los textos se usa &lt;code&gt;textContent&lt;/code&gt;. Un entregable es texto; no hace falta interpretarlo como HTML.&lt;/p&gt;

&lt;p&gt;Hay una consecuencia útil: si estoy viendo el paquete completo y paso a la revisión, sigo viendo el paquete completo. El precio puede cambiar porque cambió el alcance, pero mi selección permanece.&lt;/p&gt;

&lt;h2&gt;
  
  
  Una tabla correcta no garantiza una transición correcta
&lt;/h2&gt;

&lt;p&gt;Estos son los cuatro resultados esperados:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Revisión&lt;/th&gt;
&lt;th&gt;Paquete&lt;/th&gt;
&lt;th&gt;Precio ficticio (USD)&lt;/th&gt;
&lt;th&gt;Plazo&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Inicial&lt;/td&gt;
&lt;td&gt;Básico&lt;/td&gt;
&lt;td&gt;4800&lt;/td&gt;
&lt;td&gt;3 semanas&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Inicial&lt;/td&gt;
&lt;td&gt;Completo&lt;/td&gt;
&lt;td&gt;7200&lt;/td&gt;
&lt;td&gt;4 semanas&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Revisada&lt;/td&gt;
&lt;td&gt;Básico&lt;/td&gt;
&lt;td&gt;5400&lt;/td&gt;
&lt;td&gt;4 semanas&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Revisada&lt;/td&gt;
&lt;td&gt;Completo&lt;/td&gt;
&lt;td&gt;8100&lt;/td&gt;
&lt;td&gt;5 semanas&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Comprobar cada fila sirve para revisar los datos. Después conviene recorrer una secuencia:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Completo → Revisada → Básico → Inicial.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;En cada paso, hay que comparar el precio, el plazo, los entregables y el estado seleccionado de ambos grupos. Así también se busca contenido que se haya quedado de la selección anterior.&lt;/p&gt;

&lt;p&gt;Los controles son botones nativos. Se puede recorrer la interfaz con Tab y activarlos con Enter o Espacio. &lt;code&gt;aria-pressed&lt;/code&gt; comunica la selección y una región &lt;code&gt;aria-live="polite"&lt;/code&gt; anuncia el resumen. Eso no sustituye una prueba con lectores de pantalla.&lt;/p&gt;

&lt;h2&gt;
  
  
  El nombre del botón también forma parte del comportamiento
&lt;/h2&gt;

&lt;p&gt;Las dos revisiones ya están en el archivo. El selector las muestra: no guarda cambios ni publica una actualización.&lt;/p&gt;

&lt;p&gt;Llamarlo “vista previa de la revisión” ayuda a entender esa diferencia. Para modificar una página alojada, todavía hay que subir el archivo cambiado y comprobar el enlace público.&lt;/p&gt;

&lt;p&gt;Es un detalle pequeño, pero el control no debería prometer una acción que no ha ocurrido.&lt;/p&gt;

&lt;p&gt;La &lt;a href="https://dev.to/plopino/a-tiny-html-proposal-with-two-switches-and-one-render-function-2gf5"&gt;versión en inglés con el HTML y JavaScript completos&lt;/a&gt; incluye el ejemplo reproducible. Esta versión se centra en el criterio de conservar las elecciones durante la comparación.&lt;/p&gt;

&lt;p&gt;¿En qué casos sí tendría sentido reiniciar el paquete al cambiar de versión? Por ejemplo, si un paquete deja de existir, haría falta explicar ese cambio en lugar de aplicarlo en silencio.&lt;/p&gt;

</description>
      <category>spanish</category>
      <category>javascript</category>
    </item>
    <item>
      <title>A tiny HTML proposal with two switches and one render function</title>
      <dc:creator>Plopino</dc:creator>
      <pubDate>Fri, 02 Oct 2026 11:44:42 +0000</pubDate>
      <link>https://dev.to/plopino/a-tiny-html-proposal-with-two-switches-and-one-render-function-2gf5</link>
      <guid>https://dev.to/plopino/a-tiny-html-proposal-with-two-switches-and-one-render-function-2gf5</guid>
      <description>&lt;p&gt;A proposal can look perfectly reasonable until you change one option. The price updates, but the delivery time still belongs to the old plan. Now the page is giving two different answers.&lt;/p&gt;

&lt;p&gt;That is the small problem this example tackles: two packages, two revisions, and one consistent summary. It uses ordinary HTML and JavaScript. The company details and estimates are fictional.&lt;/p&gt;

&lt;p&gt;The useful part is the shape of the state. You can reuse it for a quote calculator, a package comparison, or a project scope preview.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the choices separate from the result
&lt;/h2&gt;

&lt;p&gt;There are only two choices:&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;revision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;first&lt;/span&gt;&lt;span class="dl"&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;plan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;essential&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 estimate is derived from those choices. It does not need another state variable.&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;proposals&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;first&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;essential&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;$4,800&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;weeks&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="na"&gt;items&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="s2"&gt;Five responsive pages&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;Contact form&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;complete&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;$7,200&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;weeks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;items&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="s2"&gt;Five responsive pages&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;Contact form&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;Project gallery&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="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;revised&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;essential&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;$5,400&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;weeks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;items&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="s2"&gt;Five responsive pages&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;Booking flow&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;Accessibility review&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;complete&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;$8,100&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;weeks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;items&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="s2"&gt;Five responsive pages&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;Booking flow&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;Accessibility review&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;Project gallery&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="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;Each combination has a complete record. That makes it possible to inspect the numbers and scope together before touching the interface.&lt;/p&gt;

&lt;p&gt;These are fixed example estimates. A real pricing tool would need its own rules for currency, tax, and calculations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give every change the same path through the DOM
&lt;/h2&gt;

&lt;p&gt;Here is the markup. Put it in the body of an HTML page, with the JavaScript below it in a &lt;code&gt;script&lt;/code&gt; element.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;h1&amp;gt;&lt;/span&gt;Website proposal&lt;span class="nt"&gt;&amp;lt;/h1&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;Fictional estimates for a scope comparison.&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;role=&lt;/span&gt;&lt;span class="s"&gt;"group"&lt;/span&gt; &lt;span class="na"&gt;aria-label=&lt;/span&gt;&lt;span class="s"&gt;"Proposal revision"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"button"&lt;/span&gt; &lt;span class="na"&gt;data-revision=&lt;/span&gt;&lt;span class="s"&gt;"first"&lt;/span&gt; &lt;span class="na"&gt;aria-pressed=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;First draft&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"button"&lt;/span&gt; &lt;span class="na"&gt;data-revision=&lt;/span&gt;&lt;span class="s"&gt;"revised"&lt;/span&gt; &lt;span class="na"&gt;aria-pressed=&lt;/span&gt;&lt;span class="s"&gt;"false"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Revised scope&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;role=&lt;/span&gt;&lt;span class="s"&gt;"group"&lt;/span&gt; &lt;span class="na"&gt;aria-label=&lt;/span&gt;&lt;span class="s"&gt;"Choose a package"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"button"&lt;/span&gt; &lt;span class="na"&gt;data-plan=&lt;/span&gt;&lt;span class="s"&gt;"essential"&lt;/span&gt; &lt;span class="na"&gt;aria-pressed=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Essential&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"button"&lt;/span&gt; &lt;span class="na"&gt;data-plan=&lt;/span&gt;&lt;span class="s"&gt;"complete"&lt;/span&gt; &lt;span class="na"&gt;aria-pressed=&lt;/span&gt;&lt;span class="s"&gt;"false"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Complete&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;p&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"summary"&lt;/span&gt; &lt;span class="na"&gt;aria-live=&lt;/span&gt;&lt;span class="s"&gt;"polite"&lt;/span&gt; &lt;span class="na"&gt;aria-atomic=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;ul&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"deliverables"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/ul&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both sets of buttons change a choice and call the same function:&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;render&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="nx"&gt;proposal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;proposals&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;revision&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="nx"&gt;plan&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;revisionLabel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;revision&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;first&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;First draft&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;Revised scope&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="nx"&gt;planLabel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;essential&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;Essential&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;Complete&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;summary&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;revisionLabel&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;planLabel&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;proposal&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="s2"&gt;; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;proposal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;weeks&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; weeks.`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;deliverables&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replaceChildren&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;proposal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&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;li&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;li&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;li&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;item&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;li&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="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;[data-revision]&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;button&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;button&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;aria-pressed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;button&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;revision&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;revision&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;[data-plan]&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;button&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;button&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;aria-pressed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;button&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;plan&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;plan&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="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;[data-revision]&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;button&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;button&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="s2"&gt;click&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;revision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;button&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;revision&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;render&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="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;[data-plan]&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;button&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;button&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="s2"&gt;click&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;plan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;button&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;render&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="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The click handlers are deliberately boring. They do not calculate prices, rewrite individual list items, or decide which buttons should look selected. Those changes happen together in &lt;code&gt;render()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Selecting Complete and then Revised scope keeps Complete selected. Switching the revision does not quietly reset the package. That behavior follows directly from keeping the choices separate.&lt;/p&gt;

&lt;p&gt;Using &lt;code&gt;textContent&lt;/code&gt; also means a deliverable is inserted as text, rather than interpreted as HTML. If this later reads external data, that is a useful boundary to preserve.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check the combinations, then the transitions
&lt;/h2&gt;

&lt;p&gt;There are four expected results:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Revision&lt;/th&gt;
&lt;th&gt;Package&lt;/th&gt;
&lt;th&gt;Price&lt;/th&gt;
&lt;th&gt;Delivery&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;First draft&lt;/td&gt;
&lt;td&gt;Essential&lt;/td&gt;
&lt;td&gt;$4,800&lt;/td&gt;
&lt;td&gt;3 weeks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;First draft&lt;/td&gt;
&lt;td&gt;Complete&lt;/td&gt;
&lt;td&gt;$7,200&lt;/td&gt;
&lt;td&gt;4 weeks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Revised scope&lt;/td&gt;
&lt;td&gt;Essential&lt;/td&gt;
&lt;td&gt;$5,400&lt;/td&gt;
&lt;td&gt;4 weeks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Revised scope&lt;/td&gt;
&lt;td&gt;Complete&lt;/td&gt;
&lt;td&gt;$8,100&lt;/td&gt;
&lt;td&gt;5 weeks&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Checking each row catches incorrect data. Checking a sequence catches stale UI. Try Complete → Revised scope → Essential → First draft, and compare the summary, deliverables, and pressed button states after each step.&lt;/p&gt;

&lt;p&gt;Then use only Tab, Enter, and Space. Native buttons provide the keyboard interaction; &lt;code&gt;aria-pressed&lt;/code&gt; exposes the selected state. The polite live region makes the combined price and delivery summary available to assistive technology. That is a starting point, and it still needs testing with the screen readers and browsers your audience uses.&lt;/p&gt;

&lt;h2&gt;
  
  
  A revision preview needs an honest label
&lt;/h2&gt;

&lt;p&gt;Both revisions in this page are already in the JavaScript object. The switch previews them. It does not save anything, publish a new version, or update a hosted page.&lt;/p&gt;

&lt;p&gt;That distinction matters when sharing a prototype. Label the control as a preview, keep the estimates fictional, and test the actual public URL after deployment. A working local file cannot tell you whether the recipient's link works.&lt;/p&gt;

&lt;p&gt;The page stays small because it has a small job: let someone compare scope without allowing the price, timeline, and deliverables to drift apart.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;AI disclosure: This article and the adapted code example were generated by an AI agent from an existing HTML example. The example was checked in a browser before publication.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>html</category>
      <category>webdev</category>
      <category>beginners</category>
    </item>
    <item>
      <title>MCP or CLI? Choose by Who Controls the Workflow</title>
      <dc:creator>Plopino</dc:creator>
      <pubDate>Thu, 24 Sep 2026 02:21:10 +0000</pubDate>
      <link>https://dev.to/plopino/mcp-or-cli-choose-by-who-controls-the-workflow-4ii8</link>
      <guid>https://dev.to/plopino/mcp-or-cli-choose-by-who-controls-the-workflow-4ii8</guid>
      <description>&lt;p&gt;Suppose a tool can publish a built site. Should it offer a command line interface, an MCP server, or both?&lt;/p&gt;

&lt;p&gt;The tempting answer is “MCP for AI, CLI for humans.” That is too simple: agents can run commands, and people can ask an MCP client to call tools. A better question is &lt;strong&gt;who decides the next step, and where does the data live?&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with the caller
&lt;/h2&gt;

&lt;p&gt;Choose a CLI when the caller already has a repeatable sequence: build, test, publish, inspect the result. A shell script or CI job can pass a path, use an exit code to detect failure, and parse JSON when it needs a URL or identifier. The command is also easy to run and debug locally.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Illustrative command, not a package name&lt;/span&gt;
publisher publish ./dist &lt;span class="nt"&gt;--json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Choose an MCP tool when an assistant needs to discover an operation and call it as part of a conversation. A tool such as &lt;code&gt;publish_page&lt;/code&gt; can declare that it accepts &lt;code&gt;content&lt;/code&gt; and &lt;code&gt;filename&lt;/code&gt;, then return a structured result. The assistant can use that result in its next step. MCP gives the client a standard way to list and call tools; it does not make a publication automatically safe or correct.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Starting point&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A fixed deployment job publishes the same build output&lt;/td&gt;
&lt;td&gt;CLI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A developer wants one command after a local build&lt;/td&gt;
&lt;td&gt;CLI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;An assistant drafts a small page and asks to publish it&lt;/td&gt;
&lt;td&gt;MCP tool accepting content&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;An assistant works with a local directory and its assets&lt;/td&gt;
&lt;td&gt;Local MCP tool accepting a path, or CLI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A remote assistant has no access to the developer's disk&lt;/td&gt;
&lt;td&gt;MCP tool accepting file contents or another upload mechanism&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These are starting points, not hard boundaries. If an agent already has a reliable terminal, invoking the CLI may be the simplest integration. If an application already speaks MCP, a tool can avoid teaching the assistant command syntax and output parsing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Location matters more than the interface name
&lt;/h2&gt;

&lt;p&gt;Consider a tool argument like &lt;code&gt;publish_path({ path: "./dist" })&lt;/code&gt;. With a local MCP server launched over stdio, the server process can usually read that directory. With a remote MCP server over HTTP, &lt;code&gt;./dist&lt;/code&gt; means a path on the &lt;strong&gt;server&lt;/strong&gt;, not the user's laptop. The same string has different consequences depending on the transport.&lt;/p&gt;

&lt;p&gt;For a remote client, send the contents explicitly, or upload them through an API that provides a file handle to the server. For a local directory with many related assets, a local tool or CLI is often a better fit. The &lt;a href="https://modelcontextprotocol.io/specification/2025-11-25/basic/transports" rel="noopener noreferrer"&gt;MCP transport documentation&lt;/a&gt; describes stdio and Streamable HTTP; the filesystem boundary follows from where each server runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design the failure path, too
&lt;/h2&gt;

&lt;p&gt;A useful CLI needs stable exit codes, script-friendly output, and a way to provide credentials without an interactive prompt. Keep human progress messages separate from machine-readable output. A useful MCP tool needs a precise input schema and a result that tells the client what succeeded or failed. For a stdio MCP server, stdout is the protocol channel, so ordinary logs belong elsewhere.&lt;/p&gt;

&lt;p&gt;Publishing also deserves the same controls through either interface: make the destination clear, inspect what files will leave the machine, keep secrets out of the published bundle, and avoid returning a private claim or edit link as the public viewing URL. If an operation replaces an existing page, make that intent explicit in the arguments or flags.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you offer both, share the operation
&lt;/h2&gt;

&lt;p&gt;The two interfaces can sit on top of one publishing function. The CLI handles flags, terminal output, and exit codes. The MCP adapter handles tool definitions and structured results. Authentication, upload rules, size limits, and update behavior should come from the shared operation, so the two entry points do not quietly disagree.&lt;/p&gt;

&lt;p&gt;My rule of thumb: &lt;strong&gt;use a CLI for a known sequence; use MCP when a tool-aware client is choosing actions; use a local interface for local paths.&lt;/strong&gt; Then add the second interface only when a real caller needs it.&lt;/p&gt;

&lt;p&gt;How do you draw that line in your own tools? Have you found cases where an agent calling a CLI works better than an MCP integration?&lt;/p&gt;




&lt;p&gt;&lt;em&gt;AI disclosure: An AI assistant drafted this article. The technical distinctions were checked against the MCP transport documentation and an implementation that exposes both CLI and MCP entry points. This post is intended as a discussion of interface design, not a product recommendation.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>cli</category>
      <category>architecture</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Things that were harder than expected building a drag-and-drop static host</title>
      <dc:creator>Plopino</dc:creator>
      <pubDate>Tue, 15 Sep 2026 15:01:44 +0000</pubDate>
      <link>https://dev.to/plopino/things-that-were-harder-than-expected-building-a-drag-and-drop-static-host-5cbh</link>
      <guid>https://dev.to/plopino/things-that-were-harder-than-expected-building-a-drag-and-drop-static-host-5cbh</guid>
      <description>&lt;p&gt;I built a small static host (&lt;a href="https://plopino.com" rel="noopener noreferrer"&gt;plopino.com&lt;/a&gt;) with a boring&lt;br&gt;
premise: drag a file onto a page, get a link. No account for the basic flow.&lt;/p&gt;

&lt;p&gt;The feature list is short. What took the time was a handful of specific&lt;br&gt;
problems. Here they are, in case they save you the same afternoon.&lt;/p&gt;
&lt;h2&gt;
  
  
  1. Path-based hosting means every relative link is your problem
&lt;/h2&gt;

&lt;p&gt;Most drop-style hosts give each project a subdomain:&lt;br&gt;
&lt;code&gt;my-project.host.com&lt;/code&gt;. I went the other way. Every board lives under a path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://plopino.com/b/&amp;lt;boardId&amp;gt;/...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One route serves it. One TLS certificate. No per-board DNS, no wildcard cert&lt;br&gt;
renewal, no waiting on propagation — the whole routing and TLS story stays&lt;br&gt;
simple.&lt;/p&gt;

&lt;p&gt;The bill comes due on relative links. A board is arbitrary user HTML, so a page&lt;br&gt;
at &lt;code&gt;/b/&amp;lt;id&amp;gt;/sub/page.html&lt;/code&gt; can contain anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"../../other.html"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"//cdn.example.com/x.png"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/absolute/path"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"javascript:void(0)"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I need to know which of those stay inside the board and which don't, because&lt;br&gt;
that decision drives both storage accounting and whether a board can reach&lt;br&gt;
outside its own tree. The rule I landed on: &lt;code&gt;data:&lt;/code&gt;, &lt;code&gt;javascript:&lt;/code&gt;, &lt;code&gt;mailto:&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;tel:&lt;/code&gt;, &lt;code&gt;blob:&lt;/code&gt;, protocol-relative (&lt;code&gt;//x&lt;/code&gt;) and root-relative (&lt;code&gt;/x&lt;/code&gt;) URLs are all&lt;br&gt;
"not board-internal". Everything else resolves against the board root.&lt;/p&gt;

&lt;p&gt;Root-relative is the one that trips people up. &lt;code&gt;/style.css&lt;/code&gt; inside a board looks&lt;br&gt;
like it should work, and it "works" in the sense that the browser requests&lt;br&gt;
&lt;code&gt;https://plopino.com/style.css&lt;/code&gt; — which is &lt;em&gt;my&lt;/em&gt; site's path space, not the&lt;br&gt;
board's. Treating it as external is the only safe reading.&lt;/p&gt;
&lt;h2&gt;
  
  
  2. Streaming the upload, and streaming the zip
&lt;/h2&gt;

&lt;p&gt;The upload path uses Busboy with a couple of settings that matter:&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="nc"&gt;Busboy&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;preservePath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;defParamCharset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;latin1&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;&lt;code&gt;preservePath&lt;/code&gt; is what makes folder drags work. When you drag a directory into a&lt;br&gt;
browser, the multipart parts carry relative paths (&lt;code&gt;site/css/main.css&lt;/code&gt;), and if&lt;br&gt;
you let the parser flatten them to basenames you have thrown away the structure&lt;br&gt;
before you ever see it.&lt;/p&gt;

&lt;p&gt;Each file part streams straight to a temp file. Nothing is buffered in memory —&lt;br&gt;
uploads are large enough that buffering a zip only to decompress it afterwards&lt;br&gt;
would double peak memory for no benefit.&lt;/p&gt;

&lt;p&gt;Zip extraction goes through yauzl in lazy mode:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;yauzl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;zipPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;lazyEntries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;decodeStrings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;lazyEntries&lt;/code&gt; keeps one entry in flight at a time instead of materialising the&lt;br&gt;
central directory as an array of objects, which matters when someone uploads a&lt;br&gt;
zip with a few thousand files. &lt;code&gt;decodeStrings: false&lt;/code&gt; is the interesting one —&lt;br&gt;
see below.&lt;/p&gt;
&lt;h2&gt;
  
  
  3. Filename encoding is still a mess in 2026
&lt;/h2&gt;

&lt;p&gt;This is the part I underestimated.&lt;/p&gt;

&lt;p&gt;Zip entries don't have a mandated encoding for filenames. The spec says CP437&lt;br&gt;
unless a UTF-8 flag is set, but in practice you receive whatever the producing&lt;br&gt;
tool felt like writing — and on Chinese Windows systems that frequently means&lt;br&gt;
GB18030.&lt;/p&gt;

&lt;p&gt;Meanwhile multipart &lt;code&gt;filename&lt;/code&gt; parameters are specified as Latin-1-ish, with&lt;br&gt;
RFC 5987's &lt;code&gt;filename*&lt;/code&gt; as the escape hatch for anything else. Browsers are&lt;br&gt;
inconsistent about which they send.&lt;/p&gt;

&lt;p&gt;So there are two decode paths that have to agree:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;filename*&lt;/code&gt; (RFC 5987) — Busboy already decodes this to Unicode correctly,
use it as-is.&lt;/li&gt;
&lt;li&gt;Plain &lt;code&gt;filename&lt;/code&gt; — Busboy preserves the raw bytes as Latin-1, so I take those
bytes and try UTF-8 first, falling back to GB18030 detection.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you skip this, a user with a Chinese-locale machine uploads a folder and&lt;br&gt;
every filename comes out as mojibake. From their side it looks like the site&lt;br&gt;
corrupted their files.&lt;/p&gt;
&lt;h2&gt;
  
  
  4. Persistent workers, not per-request processes
&lt;/h2&gt;

&lt;p&gt;Board thumbnails and Office previews both need a real renderer. The obvious&lt;br&gt;
implementation — spawn one per request — is also the one that makes previews&lt;br&gt;
feel slow and pins the CPU.&lt;/p&gt;

&lt;p&gt;Both run in long-lived pools instead:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Office (docx / xlsx / doc) previews render in a persistent worker pool and are
cached, so nothing blocks the main thread.&lt;/li&gt;
&lt;li&gt;Thumbnails use one lazily-started headless Chromium, one page per task, at a
fixed viewport, with a single-flight queue so a burst of new boards doesn't
start a browser per request.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The thumbnail step also needed a bit of judgement that isn't obvious from the&lt;br&gt;
outside: before screenshotting, it hides the &lt;em&gt;preview page's&lt;/em&gt; own chrome (the&lt;br&gt;
wordmark and download button) and collapses left-hand navigation. Otherwise&lt;br&gt;
every card thumbnail has a tiny "Download" button burned into the corner,&lt;br&gt;
and documentation-style boards show nothing but a sidebar.&lt;/p&gt;
&lt;h2&gt;
  
  
  5. A test that enforces translation key &lt;em&gt;order&lt;/em&gt;
&lt;/h2&gt;

&lt;p&gt;The UI ships in 20 languages including RTL Arabic and Persian.&lt;/p&gt;

&lt;p&gt;The test that keeps this honest compares every dictionary against the English&lt;br&gt;
one on three axes: the key &lt;strong&gt;set&lt;/strong&gt;, the key &lt;strong&gt;order&lt;/strong&gt;, and the placeholder names&lt;br&gt;
inside each string.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deepEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;EN_KEYS&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// set AND order&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;placeholders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="nf"&gt;placeholders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;EN&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key order is the unusual one. Nothing forces a JS object literal to keep the&lt;br&gt;
same ordering as another, and nothing breaks at runtime if it drifts — but a&lt;br&gt;
dictionary that has drifted is a dictionary someone edited by hand and then&lt;br&gt;
stopped maintaining. Asserting order means the file layout itself is part of the&lt;br&gt;
contract, so a missing translation fails the suite instead of silently rendering&lt;br&gt;
an English string into a Persian page.&lt;/p&gt;

&lt;p&gt;The test also validates BCP-47 tag shape, direction (&lt;code&gt;ltr&lt;/code&gt;/&lt;code&gt;rtl&lt;/code&gt;), and that the&lt;br&gt;
default language sorts first.&lt;/p&gt;

&lt;p&gt;One more thing that follows from having 20 languages: &lt;strong&gt;the server injects&lt;br&gt;
&lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt;, description, &lt;code&gt;og:*&lt;/code&gt; and hreflang per language&lt;/strong&gt;. Crawlers and social&lt;br&gt;
scrapers don't run JavaScript, so a client-side i18n layer is invisible to them.&lt;br&gt;
The page declares &lt;code&gt;data-i18n-doctitle&lt;/code&gt; / &lt;code&gt;data-i18n-docdesc&lt;/code&gt; on &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt;, and the&lt;br&gt;
server renders the real head from the dictionary.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Quotas, and moderation that runs on reports
&lt;/h2&gt;

&lt;p&gt;Anonymous uploads are rate-limited per IP per day, and the counters are&lt;br&gt;
persisted — a deploy does not hand everyone a fresh allowance. Signed-in users&lt;br&gt;
get a larger allowance.&lt;/p&gt;

&lt;p&gt;Moderation is report-driven: an abuse contact, a takedown path, and reports get&lt;br&gt;
acted on. I'm spelling that out because it is the most predictable question&lt;br&gt;
about a service that hosts arbitrary HTML from strangers.&lt;/p&gt;

&lt;p&gt;A related decision that took longer than it should have: are uploaded boards&lt;br&gt;
indexable? Blocking them entirely is the safe-feeling default, but it's wrong in&lt;br&gt;
a subtle way — &lt;code&gt;robots.txt&lt;/code&gt;-blocked URLs can't be crawled, so a private board's&lt;br&gt;
URL can linger in search results as a bare link with no context. The site now&lt;br&gt;
serves &lt;code&gt;X-Robots-Tag: noindex&lt;/code&gt; on &lt;code&gt;/b/&lt;/code&gt; by default and &lt;em&gt;removes&lt;/em&gt; it for public,&lt;br&gt;
undeleted boards. Public boards are linkable-and-crawlable when someone shares&lt;br&gt;
them; private, deleted and expired ones stay out. The sitemap lists zero boards&lt;br&gt;
either way — I'm not turning my sitemap into a directory of user content.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell someone starting this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Decide the URL shape on day one. Path vs subdomain changes the link-resolution
code, the TLS story and the SEO story, and retrofitting it is miserable.&lt;/li&gt;
&lt;li&gt;Treat filenames as hostile input from at least three encodings.&lt;/li&gt;
&lt;li&gt;If you render anything user-supplied, keep the renderer warm and put a queue
in front of it.&lt;/li&gt;
&lt;li&gt;Write the i18n test before the fourth language, not after the tenth.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The service is at &lt;a href="https://plopino.com" rel="noopener noreferrer"&gt;plopino.com&lt;/a&gt; if you want to try the&lt;br&gt;
actual thing. It's not open source right now — mostly because I don't want to&lt;br&gt;
hand out deployable copies while the abuse story is still unfinished.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>node</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
