<?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%2F00b3b9d4-975b-437d-a9d1-a32d8ee2f971.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>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>
