<?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: Christian • ancer</title>
    <description>The latest articles on DEV Community by Christian • ancer (@ancer).</description>
    <link>https://dev.to/ancer</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%2F3965198%2F9b8a4f0e-f78d-4238-89e1-0b08c446259e.png</url>
      <title>DEV Community: Christian • ancer</title>
      <link>https://dev.to/ancer</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ancer"/>
    <language>en</language>
    <item>
      <title>Testing a Batch Image Pipeline Before It Touches Your Files</title>
      <dc:creator>Christian • ancer</dc:creator>
      <pubDate>Wed, 28 Oct 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/ancer/testing-a-batch-image-pipeline-before-it-touches-your-files-3pdi</link>
      <guid>https://dev.to/ancer/testing-a-batch-image-pipeline-before-it-touches-your-files-3pdi</guid>
      <description>&lt;p&gt;Making crop work on one image is the easy part. Making it safe to run on two hundred of somebody else's files is the actual job, and what decided when it shipped was a test list.&lt;/p&gt;

&lt;p&gt;The first two tests on that list failed. Not on the crop, which was the point of the list, but on the version of Convertim that had been out since July.&lt;/p&gt;

&lt;p&gt;Crop shipped on Monday, in Convertim 2.1. This is how the pipeline is tested, what the crop had to pass before it shipped, and what those two failures taught me about the two hundred tests that had been passing all along.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was already there
&lt;/h2&gt;

&lt;p&gt;Convertim is a portable image converter for Windows: a desktop app and a CLI, both on top of one conversion engine built on sharp. The desktop app reaches the engine through Electron's main process; the CLI calls it directly. Same functions, same options.&lt;/p&gt;

&lt;p&gt;That shared engine is where most of the tests point. There are a little over two hundred, on Node's built-in test runner, in a few layers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Engine and pipeline.&lt;/strong&gt; Formats in and out, quality, lossless, both resize modes, rotation and flips, colour adjustments, text and image watermarks in each position, and the guards that refuse to vectorise a photo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The CLI as a real process.&lt;/strong&gt; Flags, JSON output and exit codes. A batch with one corrupt file and one valid file has to convert the valid one and exit with 2, partial success.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The packaged executable.&lt;/strong&gt; A smoke test that runs the real binary and checks that what comes out decodes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The bridge.&lt;/strong&gt; Every channel the preload script calls has a handler on the other side, and the renderer never gets &lt;code&gt;require&lt;/code&gt; or &lt;code&gt;process&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The presets live in one file, &lt;code&gt;src/shared/presets.js&lt;/code&gt;, which the interface imports and the tests import too. A test of the &lt;em&gt;Web&lt;/em&gt; preset reads the real preset, not a copy of its numbers, so changing a value there changes what gets tested.&lt;/p&gt;

&lt;p&gt;Version 2.1 adds the command line to that list. The social presets behind its new &lt;code&gt;--social&lt;/code&gt; flag come from the same file, and a saved profile goes through the same code in the app and in the CLI, with a test that checks both give the same image.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixtures built to make mistakes visible
&lt;/h2&gt;

&lt;p&gt;None of the test images are photos. They're generated by code before the tests run, and each one exists to make one kind of mistake impossible to miss:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Fixture&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;th&gt;What it catches&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;asym-h&lt;/code&gt;, &lt;code&gt;asym-v&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Half red, half blue&lt;/td&gt;
&lt;td&gt;A flip that doesn't swap the halves&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;quadrants&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A different colour in each quarter&lt;/td&gt;
&lt;td&gt;Where a watermark, or now a crop, actually landed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gray120&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Flat grey, 120 on every channel&lt;/td&gt;
&lt;td&gt;Brightness, contrast and saturation as one measurable number&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;alpha&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Semi-transparent green&lt;/td&gt;
&lt;td&gt;Transparency lost where it should survive&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;corrupt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A sentence of text saved as &lt;code&gt;.jpg&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;A broken file that takes the batch down with it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;noise&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Random pixels from a fixed seed&lt;/td&gt;
&lt;td&gt;A "photo" that is identical on every run&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;high-freq&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;One-pixel black and white stripes&lt;/td&gt;
&lt;td&gt;Only two colours, maximum detail: the case that got past the colour-only SVG guard&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;quadrants&lt;/code&gt; is the workhorse. Simplified, it's 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="c1"&gt;// 80×60, a unique colour in each quarter: a test can tell exactly which&lt;/span&gt;
&lt;span class="c1"&gt;// corner or area an operation touched.&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="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;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;top&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;left&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
      &lt;span class="nx"&gt;top&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;left&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;// NW red&lt;/span&gt;
      &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;top&lt;/span&gt;       &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;// NE green&lt;/span&gt;
      &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;left&lt;/span&gt;      &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;// SW blue&lt;/span&gt;
      &lt;span class="p"&gt;:&lt;/span&gt;             &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="c1"&gt;// SE yellow&lt;/span&gt;
    &lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;b&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="nf"&gt;sharp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;channels&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="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;png&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;quadrantsPath&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Checking pixels without golden images
&lt;/h2&gt;

&lt;p&gt;The tempting way to test an image pipeline is golden files: save a known-good output, then compare every new output against it byte for byte. It breaks every time the encoder changes how it rounds something, and when it breaks it tells you nothing about what is wrong.&lt;/p&gt;

&lt;p&gt;So the suite asks narrower questions, each one with an answer that can't be in doubt:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dimensions and ratio,&lt;/strong&gt; exactly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A pixel whose colour is certain.&lt;/strong&gt; Flip &lt;code&gt;asym-h&lt;/code&gt; horizontally and the pixel in the top-left corner must now be blue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A region that should, or shouldn't, change.&lt;/strong&gt; A watermark in the south-east has to change that corner of &lt;code&gt;quadrants&lt;/code&gt; and leave the opposite one alone, measured as a difference over the region against a threshold, not as an exact match.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exact equality only where it's promised.&lt;/strong&gt; A lossless round trip has to give back identical pixels, because that is the entire promise of lossless.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Order instead of value,&lt;/strong&gt; where exact numbers would be brittle. A higher quality has to land closer to the original than a lower one, and the &lt;em&gt;Min size&lt;/em&gt; preset has to spend fewer bytes per pixel than &lt;em&gt;High quality&lt;/em&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most of it comes down to a helper this small:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;readPixel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filePath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;info&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sharp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filePath&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;ensureAlpha&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toBuffer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;resolveWithObject&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&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;cy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&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;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cy&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;cx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;3&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;For crop, &lt;code&gt;quadrants&lt;/code&gt; gets one more job. Crop it from 80×60 to a 60×60 square and the border between red and green tells you where the crop happened without anyone looking at the result. Anchored in the centre, the border lands at x = 30. Anchored left, at x = 40. Anchored right, at x = 20. Three pixel reads per anchor, and no image ever opened.&lt;/p&gt;

&lt;h2&gt;
  
  
  The crop test list
&lt;/h2&gt;

&lt;p&gt;Before the crop was anywhere near done, I wrote down what it has to survive:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A phone photo stored sideways is upright before anything gets cropped.&lt;/li&gt;
&lt;li&gt;The original survives. Always.&lt;/li&gt;
&lt;li&gt;Every preset gives its exact ratio, for landscape, portrait and square inputs, and for an input that already matches.&lt;/li&gt;
&lt;li&gt;The anchor lands where it says, read off &lt;code&gt;quadrants&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Padding saved as JPG is the colour that was asked for, not black.&lt;/li&gt;
&lt;li&gt;Transparency survives wherever the format supports it.&lt;/li&gt;
&lt;li&gt;Rotation happens before the crop and the watermark after it, so a corner watermark is still there.&lt;/li&gt;
&lt;li&gt;Small inputs are enlarged only when a final size asks for it, and the preview says so when they are.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The first two were supposed to be formalities. They're the two that failed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure one: phone photos come out sideways
&lt;/h2&gt;

&lt;p&gt;Many phone photos aren't stored the way you see them. The camera writes the pixels in one orientation and adds an EXIF tag saying how to turn them. To test that, I needed a photo like that, and none of the fixtures was one: every fixture is generated from raw pixels, and code doesn't hold the phone sideways.&lt;/p&gt;

&lt;p&gt;So I made one. Stored as 200×100, red on the left and blue on the right, with orientation 6, which means "rotate 90° clockwise to display". Seen properly, it's a 100×200 portrait with red on top.&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;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;a photo stored sideways comes out upright&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;red&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;sharp&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;channels&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;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#ff0000&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="nf"&gt;png&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toBuffer&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sharp&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;width&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="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;channels&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;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#0000ff&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="nf"&gt;composite&lt;/span&gt;&lt;span class="p"&gt;([{&lt;/span&gt; &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;red&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;left&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;top&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;}])&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withMetadata&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;orientation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;jpeg&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;phonePhoto&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;result&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;convertToFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;phonePhoto&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;outputFormat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;webp&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;meta&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;sharp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;outputPath&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;()&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="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// fails: 200&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="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&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="c1"&gt;// fails: 100&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It failed with no crop involved at all. Convertim 2.0 writes it out at 200×100, without the tag. The engine never turns the image upright, and since it doesn't keep the metadata either, the tag that would have told a viewer how to turn it is gone too. A plain conversion, a resize: a photo stored sideways comes out sideways.&lt;/p&gt;

&lt;p&gt;Crop only turns that from annoying into destructive. A sideways photo can be rotated back. A sideways photo cropped to 4:5 has already lost the wrong part of itself.&lt;/p&gt;

&lt;p&gt;The fix turned out to be small. sharp 0.34 has &lt;code&gt;autoOrient()&lt;/code&gt;, and in 2.1 it goes first in every path that reads an image (conversion, optimisation, vectorisation and the previews), always before the user's own rotation. The part that needed care was everything that measured the image: in 0.34, &lt;code&gt;metadata()&lt;/code&gt; still reports the stored size, and the oriented size comes separately, in &lt;code&gt;metadata().autoOrient&lt;/code&gt;. Every resize, every crop and every watermark is now computed from the second:&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;getVisibleSize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;transforms&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;oriented&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;autoOrient&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;metadata&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;quarterTurn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;transforms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rotation&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;transforms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rotation&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;270&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;quarterTurn&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;oriented&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;oriented&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&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;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;oriented&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;oriented&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test above is in the suite now, along with one for every other orientation value a camera can write.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure two: "never overwrite" had a gap
&lt;/h2&gt;

&lt;p&gt;Convertim has one rule I considered unbreakable: it never writes over a file. If &lt;code&gt;photo.webp&lt;/code&gt; already exists, the output becomes &lt;code&gt;photo_1.webp&lt;/code&gt;. Crop makes that rule matter more, because a cropped image is missing pixels for good, and the original is the only copy of them.&lt;/p&gt;

&lt;p&gt;The test for it had one thing none of the old tests had: two files with the same name, converted at the same time.&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;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;two inputs with the same name never share an output&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="nf"&gt;convertToFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;photo.jpg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;outputFormat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;webp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="nf"&gt;convertToFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;photo.png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;outputFormat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;webp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;])&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;notEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;outputPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;outputPath&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// fails: both are photo.webp&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both conversions chose &lt;code&gt;photo.webp&lt;/code&gt;, and the second one wrote over the first. Every single time I ran it.&lt;/p&gt;

&lt;p&gt;The name check and the write are far apart. The engine looks for a free name when a conversion starts, and the file only appears on disk when the encode finishes. In between, another conversion has time to look, see the same free name and take it too. The CLI converts one file at a time, so it never hits this. The desktop app runs several in parallel (half your CPU cores, by default), and its default output folder is the source folder, so &lt;code&gt;photo.jpg&lt;/code&gt; and &lt;code&gt;photo.png&lt;/code&gt; side by side are enough.&lt;/p&gt;

&lt;p&gt;On its own, that costs one output. With &lt;em&gt;Delete originals&lt;/em&gt; turned on, both originals are deleted after two "successful" conversions, and one of the two images is gone.&lt;/p&gt;

&lt;p&gt;If you are still on 2.0, update. Until you do, set the concurrency to 1, or don't use &lt;em&gt;Delete originals&lt;/em&gt; on a batch where names repeat.&lt;/p&gt;

&lt;p&gt;The fix is in 2.1. The name is reserved in the same step that checks it: the output file is created empty and exclusively, with Node's &lt;code&gt;'wx'&lt;/code&gt; flag, which fails if the file already exists. If it does, the conversion moves on to the next suffix.&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;reserveOutputPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filePath&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;dir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dirname&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filePath&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;ext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extname&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filePath&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;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;basename&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filePath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ext&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;counter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;counter&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;candidate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;counter&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;filePath&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&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;base&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;counter&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;ext&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="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;closeSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;openSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;wx&lt;/span&gt;&lt;span class="dl"&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;candidate&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&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;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;EEXIST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;e&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;The encoder then writes over its own reservation, and removes it if the encode fails. Behind that there's a second safety net: an original is only deleted if its output is on disk with exactly the size that conversion wrote.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why two hundred tests never saw either
&lt;/h2&gt;

&lt;p&gt;Neither bug was subtle once a test went looking for it. They survived because of what the fixtures were.&lt;/p&gt;

&lt;p&gt;Every fixture is generated from raw pixels, so none of them carries metadata. Every test writes into its own fresh folder and converts one thing at a time. That makes the suite fast, reproducible and independent, which is exactly what a suite should be. It also means it lived in a world with no cameras and no two files with the same name.&lt;/p&gt;

&lt;p&gt;Generated fixtures test the code you wrote. Real files bring the world with them: the tag a phone added, the folder where someone exported the same picture twice. Both bugs lived in the gap between the two.&lt;/p&gt;

&lt;p&gt;So the list has a third kind of input now, next to the generated fixtures and the real files: generated ones that imitate what real files &lt;em&gt;do&lt;/em&gt;, not only what their pixels look like. Orientation tags, repeated names, conversions that overlap.&lt;/p&gt;

&lt;h2&gt;
  
  
  What isn't automated
&lt;/h2&gt;

&lt;p&gt;Some things are still checked by eye, on purpose. Before every release there's a manual checklist, around five hundred lines long, run against a folder of real files the generated fixtures can't imitate: a JPG over 5 MB, an animated GIF, a JPG truncated with a hex editor, a filename with spaces, an &lt;em&gt;ñ&lt;/em&gt; and an emoji, and folders nested three deep.&lt;/p&gt;

&lt;p&gt;For crop, that folder grows: real portraits, real landscapes, screenshots and logos on transparent backgrounds, run through every preset and looked at as a grid of thumbnails. It's the only test that can answer the one question the automated ones can't: does the default look right?&lt;/p&gt;

&lt;h2&gt;
  
  
  Done means it fails predictably
&lt;/h2&gt;

&lt;p&gt;A batch feature isn't done when it works on one image, or even on two hundred clean ones. It's done when it fails predictably: when the two hundredth image behaves like the first, when a broken file fails on its own and the rest of the batch carries on, when two files with the same name can never become one, and when the worst a bad default can do is something you notice in a grid of thumbnails, not something you discover after posting.&lt;/p&gt;

&lt;p&gt;Both fixes shipped in 2.1 on Monday, and both are tests now, so neither can come back unnoticed. They went out with the rest of the list, once running it had become boring. That was the whole release plan.&lt;/p&gt;

</description>
      <category>programming</category>
      <category>testing</category>
      <category>javascript</category>
      <category>electron</category>
    </item>
    <item>
      <title>Crop or Pad? Designing Fixed Aspect Ratios for Batch Image Conversion</title>
      <dc:creator>Christian • ancer</dc:creator>
      <pubDate>Wed, 21 Oct 2026 08:00:00 +0000</pubDate>
      <link>https://dev.to/ancer/crop-or-pad-designing-fixed-aspect-ratios-for-batch-image-conversion-1mfn</link>
      <guid>https://dev.to/ancer/crop-or-pad-designing-fixed-aspect-ratios-for-batch-image-conversion-1mfn</guid>
      <description>&lt;p&gt;Convertim, the image converter I build, can already turn a 4:3 photo into a 1080×1350 image. It does it by stretching the photo until it fits.&lt;/p&gt;

&lt;p&gt;That's its &lt;em&gt;Force dimensions&lt;/em&gt; mode: exact width, exact height, aspect ratio ignored. Under the hood it's sharp's &lt;code&gt;fit: 'fill'&lt;/code&gt;. It does exactly what it says, and for a photo it's the worst of the three possible answers. The other two are what I'm working on now: cropping to fixed ratios, and padding to them.&lt;/p&gt;

&lt;p&gt;Convertim is a portable Windows image converter: a desktop app and a CLI on top of the same engine, built on sharp (libvips), running entirely on your machine. Its whole point is batches. Drop two hundred files, pick a format, go. That single fact changes how crop has to be designed, and almost none of the hard decisions turned out to be technical.&lt;/p&gt;

&lt;p&gt;I'm writing this while building it. Most of it is decided now, and I'll say why. What isn't settled yet is whether those decisions survive a few hundred real images.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixed ratios, not a crop handle
&lt;/h2&gt;

&lt;p&gt;The first decision was what not to build: a freeform crop, with a rectangle you drag over each image.&lt;/p&gt;

&lt;p&gt;Freeform crop is one decision per image. That's perfect for one photo, and it's what a photo editor is for. Convertim is built for two hundred at a time, and two hundred drag handles is not a feature, it's an afternoon.&lt;/p&gt;

&lt;p&gt;So crop in Convertim means fixed aspect ratios, chosen once for the whole batch: eleven common ones, from 1:1, 4:5 and 9:16 to 16:9 and 3:1, plus a custom one anywhere between 1:10 and 10:1. One rule, applied to everything. Presets by destination come on top: pick where the image is going (an Instagram portrait, a story, a YouTube thumbnail, a LinkedIn banner) and get the exact size and ratio. They're built on the same rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every ratio change costs the same
&lt;/h2&gt;

&lt;p&gt;When an image doesn't match the target ratio, there are only two honest answers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Crop&lt;/strong&gt; what sticks out. The frame is full, and some pixels are gone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pad&lt;/strong&gt; what's missing. Every pixel survives, and part of the frame is filler.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What I didn't expect is that they cost exactly the same. Turning a 4000×3000 photo (4:3) into 4:5, at its own resolution:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Crop&lt;/th&gt;
&lt;th&gt;Pad&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Result&lt;/td&gt;
&lt;td&gt;2400×3000&lt;/td&gt;
&lt;td&gt;4000×5000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost&lt;/td&gt;
&lt;td&gt;40% of the pixels, thrown away&lt;/td&gt;
&lt;td&gt;40% of the canvas, filler&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;It isn't a coincidence. The fraction you lose by cropping is always the fraction you fill by padding: one minus the smaller aspect ratio divided by the larger. A 16:9 video frame turned into a 9:16 story costs 68%, whichever way you do it.&lt;/p&gt;

&lt;p&gt;So the question is never which one is cheaper. It's whether you would rather pay in lost pixels or in filler, and that depends on the image, which is exactly what a batch doesn't let you look at.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to anchor the crop
&lt;/h2&gt;

&lt;p&gt;Crop has a second question hiding inside it: which part to keep. sharp's &lt;code&gt;cover&lt;/code&gt; takes a &lt;code&gt;position&lt;/code&gt;, and the answers come in three families:&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;// Same image, same 4:5 box, three different results.&lt;/span&gt;
&lt;span class="nf"&gt;sharp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;resize&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1080&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1350&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;fit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cover&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;centre&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="nf"&gt;sharp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;resize&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1080&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1350&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;fit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cover&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;north&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="nf"&gt;sharp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;resize&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1080&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1350&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;fit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cover&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sharp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;strategy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;attention&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Centre&lt;/strong&gt; is the predictable one. It's also the one that cuts the top of someone's head when a portrait is cropped to a wider ratio, because in most portraits the face sits in the upper half of the frame.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Top&lt;/strong&gt; (&lt;code&gt;north&lt;/code&gt;) protects the heads. It only does anything when the crop removes height; when it removes width, it behaves exactly like centre. And it fails on anything whose subject sits low in the frame.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Content-aware&lt;/strong&gt; strategies look at the pixels. &lt;code&gt;entropy&lt;/code&gt; keeps the busiest region; &lt;code&gt;attention&lt;/code&gt; looks for luminance frequency, colour saturation and skin tones. On average they often do better than either of the others.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That "on average" is the problem. In a batch, the anchor doesn't need to be clever, it needs to be predictable. With centre or top, someone can picture what will happen to the whole batch before pressing the button. With &lt;code&gt;attention&lt;/code&gt;, the only way to know where image 137 was cropped is to open image 137, and saving you from opening every image is the reason a batch tool exists.&lt;/p&gt;

&lt;p&gt;So the default is centre, and the anchor is a 3×3 grid: the centre, the four edges and the four corners. Content-aware crop is there too, as &lt;em&gt;Smart crop&lt;/em&gt;, but it's a checkbox that starts unticked, with a short note right next to it. It works well with one clear subject on a simple background, with a subject that has more colour or contrast than its surroundings, and with portraits, because it weighs skin tones. It can fail with busy backgrounds like foliage or crowds, with several subjects spread across the image, and with a dull subject on a louder background. It doesn't recognise faces or objects: it follows colour, contrast and skin tones. Ticking it is a choice to trade predictability for a better average, and the note is there so that it's an informed one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to pad with
&lt;/h2&gt;

&lt;p&gt;Padding has its own question: what goes in the gap?&lt;/p&gt;

&lt;p&gt;For formats with an alpha channel (PNG, WebP, AVIF), transparency looks like the obvious answer. The image stays exactly what it was, and the frame around it is nothing. It's one of the options, but not the default: the default is a solid colour, white, for every format, so the same batch gets the same frame whether it comes out as PNG or as JPG.&lt;/p&gt;

&lt;p&gt;JPG has no alpha channel, and this is where sharp surprised me. Pad with a transparent background, save as JPG, and the padding comes out black. Not the colour you asked for: pad with transparent &lt;em&gt;white&lt;/em&gt; and you still get black, because the colour of a fully transparent pixel doesn't survive the trip.&lt;/p&gt;

&lt;p&gt;The obvious fix doesn't work 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="c1"&gt;// Looks right, and isn't: the padding is still black in the JPG.&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sharp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resize&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1080&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1350&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;fit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;contain&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;r&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;g&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flatten&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#ffffff&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="nf"&gt;jpeg&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;sharp flattens the alpha before it resizes, whatever order the calls are written in, so &lt;code&gt;flatten&lt;/code&gt; never sees the padding that &lt;code&gt;contain&lt;/code&gt; adds afterwards. It does flatten the transparency that was already in the input; it just can't reach the gap. What works is deciding the colour before the padding exists:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Without an alpha channel, the gap needs a solid colour from the start.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;background&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;supportsAlpha&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;format&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;r&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;g&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#ffffff&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sharp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resize&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1080&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1350&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;fit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;contain&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;background&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;format&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With that in place, transparent padding saved as JPG comes out white, not black, and a colour pad is the background of the whole frame: the image's own transparency is flattened onto that colour too, not only the bands around it.&lt;/p&gt;

&lt;p&gt;And it isn't only a padding question: a transparent PNG converted to JPG faces the same choice, crop or no crop. In Convertim 2.0 it came out on black. In the update it's flattened onto white.&lt;/p&gt;

&lt;p&gt;Besides a colour and transparency, there's a third option: blur. The gap is filled with the image itself, scaled up to cover the whole frame and heavily blurred, with the full image on top. It's the usual way to turn a landscape photo into a 9:16 story without colour bars. The blur runs on a copy at an eighth of the final size, which is then scaled back up: it looks like a huge blur at full size and costs a fraction of one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The order of operations
&lt;/h2&gt;

&lt;p&gt;Then there's where crop goes in the pipeline. In Convertim 2.0 the pipeline was written in this order: the user's rotation and flips, colour adjustments, resize, watermark, encode. Crop changes what has to come before what:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Orientation.&lt;/strong&gt; A phone photo stored sideways, with an EXIF tag saying which way is up, has to be turned upright before anything measures it. Skip that, and the app measures a portrait as a landscape and crops the wrong side. (Testing this turned up something I'll write about next week.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The user's rotation.&lt;/strong&gt; Crop to 4:5 and then rotate 90°, and you've made a 5:4.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Crop or pad, and resize.&lt;/strong&gt; In sharp they're one operation: &lt;code&gt;cover&lt;/code&gt; scales until the box is full and cuts the rest, &lt;code&gt;contain&lt;/code&gt; scales until the image fits and fills the rest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watermark.&lt;/strong&gt; After the crop, never before. A watermark in a corner is exactly what a crop removes first, and its size is computed from the final dimensions, so it has to wait for them anyway.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encode,&lt;/strong&gt; with the padding colour already decided.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's the order it runs in now, in a single pipeline shared by conversion, optimisation and the preview, so the preview shows exactly what will be written.&lt;/p&gt;

&lt;p&gt;The trap is that a sharp chain isn't a script. Some operations run in a fixed internal order, whatever order you call them in. In the version Convertim uses (0.34), a 90° &lt;code&gt;rotate()&lt;/code&gt; runs before the resize even if you chain it after, and so does &lt;code&gt;flatten()&lt;/code&gt;, which is the JPG trap above. &lt;code&gt;extract()&lt;/code&gt;, sharp's manual crop, is the exception: it runs where you put it.&lt;/p&gt;

&lt;p&gt;So the order of operations isn't something I trust from reading the code. It's something to test, by checking the output dimensions and where the pixels ended up. More on that next week too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Small images
&lt;/h2&gt;

&lt;p&gt;One more decision that looks technical and isn't: what happens when the input is smaller than the destination? A 640×480 photo going into a 1080×1350 preset.&lt;/p&gt;

&lt;p&gt;sharp enlarges by default, so &lt;code&gt;cover&lt;/code&gt; scales it up and crops, and you get a soft 1080×1350. Convertim's existing quality presets already behave like that: the &lt;em&gt;Web&lt;/em&gt; preset takes a 400-pixel-wide image up to 1200, and there's a test checking that it does.&lt;/p&gt;

&lt;p&gt;The alternative, &lt;code&gt;withoutEnlargement&lt;/code&gt;, has a surprise when combined with &lt;code&gt;cover&lt;/code&gt;: a small image comes back untouched. Not cropped, not padded, just its original size in its original ratio. So "never upscale" and "always 4:5" can't both win. If a preset promises a ratio, either the image gets enlarged, or the crop happens at the image's own resolution and the preset only ever shrinks.&lt;/p&gt;

&lt;p&gt;Convertim does both, depending on what you ask for. A ratio without a final size scales nothing: crop keeps the largest box of that ratio inside the image, and pad builds the smallest canvas that holds it (up to 16,383 pixels a side, WebP's limit). A final size, or a social preset, which always has one, is a promise of exact pixels, so the image is scaled to it, up if it has to be, and the preview says when an image is being enlarged.&lt;/p&gt;

&lt;h2&gt;
  
  
  The default is the product
&lt;/h2&gt;

&lt;p&gt;On a single image, a bad crop costs two seconds: you see it, you undo it, you move on. In a batch, nobody sees it. Two hundred images go in, two hundred come out, and the first time anyone looks closely at number 137 might be after it's been posted.&lt;/p&gt;

&lt;p&gt;That's why the default deserves more time than the crop itself. The crop is a handful of sharp options. The default is a decision applied two hundred times without anyone looking, and it's what most people will get, because most people never change a default.&lt;/p&gt;

&lt;p&gt;Here's where things stand:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Decided:&lt;/strong&gt; fixed ratios, no freeform crop, one rule per batch. Orientation and rotation before the crop, the watermark after it. A solid padding colour whenever the format has no alpha channel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The defaults:&lt;/strong&gt; crop, anchored in the centre, with smart crop as an opt-in. White padding for every format, with transparency and blur as options. No scaling without a final size; with one, the image is enlarged if it has to be, and the preview says so.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Still to prove:&lt;/strong&gt; that those defaults hold up on a few hundred real images.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of those defaults went through the same three questions. Which failure is easier to spot in a grid of two hundred thumbnails? Which failure leaves the whole image in the output, and which one doesn't? And can someone predict the result for the entire batch before pressing the button? Centre and white are the answers anyone can predict before pressing it. But a question answered in the abstract isn't answered yet. These are the defaults going into testing, not a promise: if a few hundred real images say otherwise, they change.&lt;/p&gt;

&lt;p&gt;In a batch tool, the default isn't a detail of the feature. It is the feature, for everyone who drops the files, presses the button and never opens the options. That's why this part gets more time than the crop itself, and why crop doesn't ship until the defaults have been argued with real images. No date yet.&lt;/p&gt;

&lt;p&gt;Next week: the test images I use to break this before anyone else does.&lt;/p&gt;

</description>
      <category>programming</category>
      <category>javascript</category>
      <category>electron</category>
      <category>ux</category>
    </item>
    <item>
      <title>Shipping Desktop Software to People Who Will Never Read the Docs</title>
      <dc:creator>Christian • ancer</dc:creator>
      <pubDate>Wed, 14 Oct 2026 08:00:00 +0000</pubDate>
      <link>https://dev.to/ancer/shipping-desktop-software-to-people-who-will-never-read-the-docs-5c16</link>
      <guid>https://dev.to/ancer/shipping-desktop-software-to-people-who-will-never-read-the-docs-5c16</guid>
      <description>&lt;p&gt;Rueda de Actos has a README. It's long, half technical documentation and half user manual, and it's accurate: the data model, every feature, where the backups live, what to do after a bad close.&lt;/p&gt;

&lt;p&gt;Nobody who uses the app will ever read it.&lt;/p&gt;

&lt;p&gt;That isn't a complaint about users. It's the most important design constraint the app has, and it took me two versions to treat it as one.&lt;/p&gt;

&lt;p&gt;Rueda de Actos is the Windows desktop app I built for a festival group in Alcoy, Spain: five events, eleven spots each, and a rotating turn that has to stay fair year after year. The person who runs it is a volunteer on the group's board. They aren't technical, they open the app once a year, and on the night that matters there's no store to reinstall from, no support line and nobody to call. For most of the year I'm not in the room.&lt;/p&gt;

&lt;p&gt;So every instruction that matters has to be somewhere they'll actually look, at the moment they need it. This is the last article in the series about this app, and it's about the unglamorous part: getting software into the hands of people who will never read the docs, and keeping it working there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before the app exists: the installer
&lt;/h2&gt;

&lt;p&gt;The first moment is the only one where the app can't speak for itself, because it isn't running yet.&lt;/p&gt;

&lt;p&gt;The installer is a standard Windows setup wizard, in Spanish, the language of the people who use it. And it isn't code-signed, so the first time it runs, Windows shows a blue warning: &lt;em&gt;Windows protected your PC&lt;/em&gt;. The button that runs it anyway is hidden behind a &lt;em&gt;More info&lt;/em&gt; link.&lt;/p&gt;

&lt;p&gt;I went through the economics of a signing certificate in &lt;a href="https://dev.to/ancer/building-offline-first-desktop-software-in-2026-1cfp"&gt;an earlier article&lt;/a&gt;. What matters here is what that screen does to someone who isn't technical. It reads as "this is a virus", and the software has failed before it has opened.&lt;/p&gt;

&lt;p&gt;Nothing inside the app can fix that. So the explanation travels with the file: before anyone double-clicks, the person who will install it is told that a blue warning is coming and exactly which two things to press, in the words Windows will show them, &lt;em&gt;Más información&lt;/em&gt; and then &lt;em&gt;Ejecutar de todas formas&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The difference is all in the timing. A warning you didn't mention looks like a problem. A warning you predicted looks like proof that the instructions were right, and that buys trust for everything that comes after.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first screen: an empty state that says what to do
&lt;/h2&gt;

&lt;p&gt;The database is created empty. There's no demo data and no sample list. An earlier build seeded the table from a bundled file, and it turned out to fire whenever the table was empty, not only on the first run, so it went.&lt;/p&gt;

&lt;p&gt;An empty table is a dead end, though. A grid of headers with nothing under it doesn't tell you what to do next. So when there's nobody in the list, the table isn't shown at all. In its place, translated from Spanish:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;No members yet&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Load the full list with &lt;strong&gt;Import&lt;/strong&gt;, in the header, or add them one by one with &lt;strong&gt;Add member&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The event buttons stay disabled until there's someone to choose from. In an earlier build they didn't, and starting an event on an empty list produced empty backups and blocked the rota.&lt;/p&gt;

&lt;p&gt;An empty state is the first page of the manual, and it's the only page that's guaranteed to be read.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting the data in: their spreadsheet, and errors that name the row
&lt;/h2&gt;

&lt;p&gt;Every association already has a spreadsheet. The alternative to importing it is typing everyone in by hand, which is how a tool dies before its first use.&lt;/p&gt;

&lt;p&gt;So &lt;em&gt;Import&lt;/em&gt; takes the spreadsheet the group already had, Excel or JSON, with a single condition: the first row holds the headers, and one of them is &lt;em&gt;Nombre&lt;/em&gt;. Headers are matched with or without accents, by their visible or internal name, abbreviations are accepted, and columns the app doesn't know are ignored. The details of that leniency are in &lt;a href="https://dev.to/ancer/what-a-year-of-real-use-changed-about-my-app-1634"&gt;the September article on real use&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;What matters for people who won't read the docs is what happens when the file is wrong. If any record fails validation, the whole import is rejected, the data already in the app isn't touched, and the message says how many records failed and which rows, with the reason for each.&lt;/p&gt;

&lt;p&gt;All or nothing is deliberate. A failed import is obvious. A half-done import looks exactly like a list, and nobody counts the rows to check that they all arrived.&lt;/p&gt;

&lt;p&gt;And then the step that would be in bold in any manual: after importing, you have to press &lt;em&gt;Save&lt;/em&gt;. Which brings me to the one rule in this app that has to be impossible to miss.&lt;/p&gt;

&lt;h2&gt;
  
  
  One button that makes it real
&lt;/h2&gt;

&lt;p&gt;The data model is strict on purpose. The app always starts from the last saved state, and &lt;em&gt;Save&lt;/em&gt; is the only thing that commits anything. Closing without saving is a clean undo of the whole evening, which is exactly what you want after a session that went sideways.&lt;/p&gt;

&lt;p&gt;A rule like that is invisible unless the interface shows it, so it shows it all the time. The status bar along the bottom always says one of two things, &lt;em&gt;Saved&lt;/em&gt; or &lt;em&gt;Unsaved changes&lt;/em&gt;, and hovering the second one tells you to press &lt;em&gt;Save&lt;/em&gt;. The comment above that element is the whole argument:&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="c"&gt;&amp;lt;!-- What isn't saved is lost on close, so the state has to be on screen at
     all times, not just in the exit warning. --&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exit warning exists too. Closing with something pending asks first, with three buttons: &lt;em&gt;Save and exit&lt;/em&gt;, &lt;em&gt;Exit without saving&lt;/em&gt;, &lt;em&gt;Cancel&lt;/em&gt;. Clicking outside doesn't dismiss it. And if saving fails, the app stays open:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// If Save fails, we don't carry on: that would be exactly the case where it hurts most.&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;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isConfirmed&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;guardarDatos&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;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isDenied&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the close that happens anyway (the wrong button, a flat battery, someone tidying up the cables), there's an escape hatch: &lt;em&gt;Settings → Data management → Load temporary version (session)&lt;/em&gt; brings back the last thing that was on screen, saved or not, with the date of each version next to it.&lt;/p&gt;

&lt;p&gt;None of that is documentation in the usual sense. But the status bar, the three buttons and the recovery option explain the data model better than any paragraph could, and they do it at the only moments the user is thinking about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Backups that live on the same disk
&lt;/h2&gt;

&lt;p&gt;The app backs itself up: a full copy of the database every thirty minutes, keeping the last ten. It's a real safety net, for one specific failure: &lt;em&gt;I've just broken the data&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;It does nothing for the other one, &lt;em&gt;the laptop died&lt;/em&gt;, because those copies sit on the same disk as the original. I said that in &lt;a href="https://dev.to/ancer/building-offline-first-desktop-software-in-2026-1cfp"&gt;the offline-first article&lt;/a&gt;, and it bears repeating, because automatic backups are exactly the kind of feature that makes people stop worrying, and this is the one case where they should keep worrying.&lt;/p&gt;

&lt;p&gt;What actually protects the group's history is an export saved somewhere else: a USB stick, an external drive, an email. JSON and Excel both re-import without losing anything, so with that file a brand-new installation gets everything back through the same &lt;em&gt;Import&lt;/em&gt; as on the first day.&lt;/p&gt;

&lt;p&gt;The trouble is that nobody thinks about this until it's too late, and nobody reads the paragraph telling them to. So the export sits where the data is worth the most. The final summary, the screen that appears when the fifth event closes, has the export buttons on it, and the highlighted one is the JSON export. At that moment the evening's work is done, someone is still at the keyboard, and a copy is one click away.&lt;/p&gt;

&lt;h2&gt;
  
  
  Updating without losing a year
&lt;/h2&gt;

&lt;p&gt;The database doesn't live next to the program. It's in the user's Windows profile, so installing a new version over the old one never touches it. What does change is its shape, and the database a new version finds is, by design, a year old.&lt;/p&gt;

&lt;p&gt;So the schema is versioned. Every change to it ships as a migration that runs on first launch, each one inside a transaction, so a failure stops the chain instead of leaving the database half converted. The migrations that move existing data have their own tests, because the database they will meet on that machine is the real one, with years of history and no copy on any server. I wrote about how those tests work in &lt;a href="https://dev.to/ancer/testing-when-you-only-get-one-shot-a-year-3605"&gt;the testing article&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;From the user's side, all of that has to collapse into a sentence they already believe without being told: the new version keeps my data.&lt;/p&gt;

&lt;h2&gt;
  
  
  A year later: the guide
&lt;/h2&gt;

&lt;p&gt;The hardest user to design for is the same person, twelve months later. They used the app once, last year, and have forgotten all of it: where &lt;em&gt;Import&lt;/em&gt; is, what the amber card means, why some cells are striped.&lt;/p&gt;

&lt;p&gt;For that person, the app carries its own guide, one click away inside the app: nineteen short numbered sections, from managing members and choosing people for an event to verifying or skipping, reopening, the gender rules, squad leaders, search, saving and recovering, export and import, the final summary and how windows and notices behave.&lt;/p&gt;

&lt;p&gt;A few rules make it usable rather than merely complete:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It uses the screen's words.&lt;/strong&gt; Every button is named exactly as it appears, in bold, and every path is the real one: &lt;em&gt;Settings → Data management → Load temporary version (session)&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Each section is a handful of short sentences.&lt;/strong&gt; Nobody reads a guide from the top. People scan it for the one thing they're stuck on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It changes with the app.&lt;/strong&gt; When version 2 added search, reopening and the new event states, the guide grew in the same release. A feature isn't finished until its section is written.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A guide inside the app has one advantage over every other kind of documentation: it's the only one guaranteed to still be there, on the right machine, a year from now.&lt;/p&gt;

&lt;h2&gt;
  
  
  The interface is the documentation
&lt;/h2&gt;

&lt;p&gt;Look at where the instructions in this app actually live: a heads-up that travels with the installer, an empty state, an error that names the rows, a status bar, a dialog with three buttons, the export buttons on the last screen of the night, and a guide written in the words of the screen.&lt;/p&gt;

&lt;p&gt;That's the documentation. For someone who isn't technical, the documentation that exists is the interface itself. Everything else, the README, the FAQ, this article, gets read by people who no longer need it.&lt;/p&gt;

&lt;p&gt;This is the last article I'll write about Rueda de Actos for a while. The case study, with screenshots made from test data, is on my site: &lt;a href="https://ancer.es/en/project/rueda-ligeros" rel="noopener noreferrer"&gt;ancer.es/en/project/rueda-ligeros&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>programming</category>
      <category>electron</category>
      <category>windows</category>
      <category>ux</category>
    </item>
    <item>
      <title>What Happened When My App Finally Met the Room</title>
      <dc:creator>Christian • ancer</dc:creator>
      <pubDate>Wed, 07 Oct 2026 08:00:00 +0000</pubDate>
      <link>https://dev.to/ancer/what-happened-when-my-app-finally-met-the-room-2cen</link>
      <guid>https://dev.to/ancer/what-happened-when-my-app-finally-met-the-room-2cen</guid>
      <description>&lt;p&gt;Last Friday, the app I have spent most of this year on did the one job it exists for.&lt;/p&gt;

&lt;p&gt;It is called &lt;strong&gt;Rueda de Actos&lt;/strong&gt;, a Windows desktop app for the Filà Ligeros, one of the groups that take part in the Moros y Cristianos festival in Alcoy, Spain. Once a year it hands out participation in five festival events, eleven spots each, following a rotating turn that has to stay fair from one year to the next. It does it in a single session, in a room, projected on a wall, in front of the people whose names are in the table.&lt;/p&gt;

&lt;p&gt;Version 1 had that session last year. It took about three hours, and I came home with a notebook full of things to fix. In September I wrote about &lt;a href="https://dev.to/ancer/what-a-year-of-real-use-changed-about-my-app-1634"&gt;what that notebook turned into&lt;/a&gt;, about &lt;a href="https://dev.to/ancer/designing-a-ui-that-gets-projected-in-front-of-a-room-4kdc"&gt;designing for a projected screen&lt;/a&gt; and about &lt;a href="https://dev.to/ancer/testing-when-you-only-get-one-shot-a-year-3605"&gt;testing for one shot a year&lt;/a&gt;. This is the other half: what happened when version 2 met the same room.&lt;/p&gt;

&lt;p&gt;The short version: it worked from start to finish, the session took under an hour, and the notebook has two lines in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Same chair, same rule
&lt;/h2&gt;

&lt;p&gt;I did exactly what I did last year. I sat in the room, I kept my hands off the keyboard, and I wrote down every hesitation: every pause before a click, every time the person driving went back on something they had just done, every question from the room that the screen could not answer.&lt;/p&gt;

&lt;p&gt;I don't ask for feedback afterwards. Asking gets you opinions about the app in the abstract. Watching gets you the gap between what someone expected to happen and what happened, and that gap is the only thing worth writing down.&lt;/p&gt;

&lt;p&gt;Last year the notebook ended up with fifteen items. Most of them became version 2.&lt;/p&gt;

&lt;h2&gt;
  
  
  Under an hour
&lt;/h2&gt;

&lt;p&gt;Last year's session took about three hours. This one took under an hour.&lt;/p&gt;

&lt;p&gt;I am not going to claim all of that for the software. Part of it is a room that had already done this once with an app on the wall, and knew what was coming. But part of it is visible in what actually got used, and the most interesting thing about that list is where it came from.&lt;/p&gt;

&lt;h2&gt;
  
  
  What got used
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Verification at eleven.&lt;/strong&gt; Every time an event reached eleven people, the app stopped and opened the verification window: the eleven names, a checkbox for squad leaders, and a way to take someone out before closing. That window is the moment the room checks the app, not the other way around.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The search box.&lt;/strong&gt; Last year, the single most repeated moment of the evening was somebody scrolling a table of nearly a hundred rows looking for a name while everyone waited. This year, when someone needed a name, it got typed instead of hunted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An event that didn't get its eleven.&lt;/strong&gt; Not every event fills. In version 1, an event that closed short looked exactly like a full one: five identical green cards. This year the card went amber and said what had happened, and that is what next year's turn will read.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A verified event, corrected.&lt;/strong&gt; At some point, an event that had already been verified needed different people in it. Last year, the only tool for that would have erased every event played afterwards, so the correction went onto paper, and from that moment the paper was the record, not the app. This year the event was reopened, corrected and verified again, and the app stayed the record of what happened.&lt;/p&gt;

&lt;p&gt;Three of those four (the search box, the amber card and the reopen) exist because of last year's notebook. None of them was in the rules I was given when I built version 1. That is still the strongest argument I know for shipping something incomplete and then watching it being used.&lt;/p&gt;

&lt;h2&gt;
  
  
  What nobody noticed
&lt;/h2&gt;

&lt;p&gt;Most of what I wrote about in September never came up.&lt;/p&gt;

&lt;p&gt;Cells blocked by unpaid dues or penalties don't say why, so the room sees that a rule was applied without learning anything private about anyone. Dialogs can be dragged out of the way. Notices that need no answer slide into a corner and leave on their own, and only the ones that change the table under everyone's eyes take the whole screen.&lt;/p&gt;

&lt;p&gt;There is not a single line in the notebook about any of it. For that kind of design, that is the result you want. Privacy that works is invisible. A notice that doesn't interrupt is one nobody remembers. If any of those decisions had been wrong, they would have been wrong in public, in front of exactly the people they affect, and they would have been the first thing I wrote down.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two lines
&lt;/h2&gt;

&lt;p&gt;This is the whole notebook for this year:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Moving between cells, and marking or unmarking them, from the keyboard instead of double-clicking.&lt;/li&gt;
&lt;li&gt;A scroll that adjusts itself.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Compare that with last year. Version 1's notes were about things the app could not do: correct a mistake without losing work, tell a full event from an incomplete one, get the group's data in without me. Those were missing capabilities. This year's two lines are about something else entirely: the hands of the person driving.&lt;/p&gt;

&lt;p&gt;That is what a second round looks like, I think. You only notice how many times someone reaches for the mouse once nobody is waiting for you to fix something bigger. Double-clicking works. It is also the slowest way to walk down a list, aim at a cell on a projected table and do it again, with a room watching each move.&lt;/p&gt;

&lt;h2&gt;
  
  
  What goes into the next version
&lt;/h2&gt;

&lt;p&gt;Both lines are already built, and they go out with the next version.&lt;/p&gt;

&lt;p&gt;The table is now a single Tab stop. Inside it, the arrow keys move between rows, &lt;em&gt;Home&lt;/em&gt; and &lt;em&gt;End&lt;/em&gt; jump to the first and the last, and the space bar marks or unmarks the person in the event in progress. The part I cared about most is that the keyboard doesn't get its own logic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The row the keyboard is on. It goes by position, like the rows' `key`: when an&lt;/span&gt;
&lt;span class="c1"&gt;// event closes and the rota moves people to the back of the queue, focus stays&lt;/span&gt;
&lt;span class="c1"&gt;// where it was on the table instead of chasing the person to the bottom.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;filaActiva&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;onTeclaFila&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;KeyboardEvent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&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;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;altKey&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ctrlKey&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;metaKey&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;hayModalAbierto&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;TECLAS_NAVEGACION&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&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="p"&gt;{&lt;/span&gt;   &lt;span class="c1"&gt;// arrows, Home, End&lt;/span&gt;
    &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;enfocarFila&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;destinoDe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&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="nx"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Space on the row's edit button belongs to the button.&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;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&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="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentTarget&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;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="c1"&gt;// Holding it down can't keep marking and unmarking the same person.&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;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;repeat&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;columnaActual&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;
  &lt;span class="c1"&gt;// Exactly what a double-click on the current event's cell does, blocks included.&lt;/span&gt;
  &lt;span class="nf"&gt;onCeldaDblClick&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;columnaActual&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The space bar calls the same function as the double-click. Every rule that blocks a cell (dues, penalties, the gender of the squad, not repeating an event) applies to both, because there is only one path. A second path would have been a second place for those rules to drift apart, and on this app a rule that drifts doesn't throw an error. It produces a plausible, unfair result.&lt;/p&gt;

&lt;p&gt;Focus going by position is the other detail that only makes sense in this room. Closing an event rotates the queue: everyone who took part moves to the back. If focus followed the person, it would jump to the bottom of the table every time an event closed, and the operator would lose their place in front of everyone.&lt;/p&gt;

&lt;p&gt;The scroll is a single line once the row has focus:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;fila&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;focus&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;preventScroll&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="c1"&gt;// Centred in the visible area: once the row passes the middle of the screen, the&lt;/span&gt;
&lt;span class="c1"&gt;// table scrolls to keep it there. At the very top and bottom there's nothing to&lt;/span&gt;
&lt;span class="c1"&gt;// centre against, and the scroll stops at the edge, which is what you want.&lt;/span&gt;
&lt;span class="nx"&gt;fila&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scrollIntoView&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;block&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;center&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;And one guard that only makes sense in front of a room:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// With a dialog open, the table ignores the keyboard. The dialog doesn't trap&lt;/span&gt;
&lt;span class="c1"&gt;// focus, so Tab can end up on a row behind it, and the space bar would change&lt;/span&gt;
&lt;span class="c1"&gt;// the selection without anyone seeing it.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;hayModalAbierto&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;modal-open&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;A change nobody can see is the worst kind of change in front of a room. The table simply stops listening while something is covering it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What stays out
&lt;/h2&gt;

&lt;p&gt;Nothing in the notebook argues for the things I said no to last month. Not a second person editing at the same time, not cloud sync, not a configurable number of events and spots, not a general undo. One person drove, in one room, and the outcome counted because everyone watched it happen. None of those features would have made that hour better, and most of them would have made it different.&lt;/p&gt;

&lt;p&gt;The temptation after a good night is to read it as permission to add things. I am reading it the other way: the notebook is the spec, and the spec got shorter.&lt;/p&gt;

&lt;h2&gt;
  
  
  The only metric
&lt;/h2&gt;

&lt;p&gt;Software that runs once a year has no analytics worth the name. No retention curve, no error tracking, no release every week. The night is the whole feedback loop, and whatever you learn in that room is all the feedback you get until the next one.&lt;/p&gt;

&lt;p&gt;So the only measure I really trust is the length of the notebook. Last year: fifteen items, pages of them. This year: two lines. Next year I will sit in the same chair, with the same notebook, and keep my hands off the same keyboard. A blank page is the goal. I don't expect to get it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The full case study, with screenshots made from test data, is on my site: &lt;a href="https://ancer.es/en/project/rueda-ligeros" rel="noopener noreferrer"&gt;https://ancer.es/en/project/rueda-ligeros&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>programming</category>
      <category>ux</category>
      <category>buildinpublic</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Testing When You Only Get One Shot a Year</title>
      <dc:creator>Christian • ancer</dc:creator>
      <pubDate>Wed, 30 Sep 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/ancer/testing-when-you-only-get-one-shot-a-year-3605</link>
      <guid>https://dev.to/ancer/testing-when-you-only-get-one-shot-a-year-3605</guid>
      <description>&lt;p&gt;Most software gets a second chance. A bad deploy on Tuesday is fixed by Wednesday morning, and by Friday nobody remembers it happened. That safety net is so ordinary that it disappears from the way we talk about quality: we argue about coverage percentages because, underneath, we assume anything we miss can be corrected later.&lt;/p&gt;

&lt;p&gt;I spent the last months on an app where that assumption is false, and it changed how I decide what to test.&lt;/p&gt;

&lt;h2&gt;
  
  
  The deployment model is the requirement
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Rueda de Actos&lt;/strong&gt; is a Windows desktop app I built for a &lt;em&gt;filà&lt;/em&gt;, one of the associations that take part in the Moros y Cristianos festivities in Alcoy, Spain. Once a year the board sits down and hands out the spots for five acts, eleven places each, following a rotation that decides who takes part and who waits another year.&lt;/p&gt;

&lt;p&gt;That session is the entire product. It happens once. It happens live, with the screen projected in front of the room. And it happens on a machine that is not connected to anything.&lt;/p&gt;

&lt;p&gt;Three properties fall out of that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No network.&lt;/strong&gt; The app never opens an outbound connection. Everything lives in a local SQLite file inside the user's Windows profile.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No auto-update.&lt;/strong&gt; There is no update channel to check, because there is no network. A new version means someone runs a new installer over the old one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One live run per year.&lt;/strong&gt; Between two real sessions there are twelve months, and I am not in the room for eleven and a half of them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Together they remove the thing every web developer leans on without noticing: I cannot ship a fix before it matters. The build installed on that machine is the build that runs. By the time anyone notices a problem, the decision it affected has already been made, out loud, in front of the people it affects.&lt;/p&gt;

&lt;h2&gt;
  
  
  Coverage percentage is the wrong question
&lt;/h2&gt;

&lt;p&gt;The question I actually needed to answer was not &lt;em&gt;how much of this is covered&lt;/em&gt;. It was:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If this path is wrong, when do we find out, and what does it cost to fix it then?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That splits the codebase into three groups.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Wrong and obvious immediately.&lt;/strong&gt; The user sees it, retries, moves on. Cheap. A test here buys very little.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wrong and obvious later, with the data still intact.&lt;/strong&gt; Annoying, recoverable, worth some tests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wrong and &lt;em&gt;plausible&lt;/em&gt;.&lt;/strong&gt; No error, no crash, nothing red anywhere. The app produces a result that looks exactly like a correct result and isn't. Or data quietly stops existing.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Roughly 400 tests, and almost all of them live in the third group.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the tests actually are
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. The rotation
&lt;/h3&gt;

&lt;p&gt;This is the critical one, and it is critical precisely because it never fails loudly.&lt;/p&gt;

&lt;p&gt;If the wheel logic is wrong, nothing throws. The screen shows eleven names per act, correctly formatted, in a table that looks right. What has actually happened is that somebody was skipped, or got a second turn before someone else got a first. Nobody sees a stack trace. Someone notices months later that they have been left out two years running, and at that point the damage is social, not technical. In a volunteer association, fairness is the entire reason this software exists.&lt;/p&gt;

&lt;p&gt;So the rotation is not tested as a function with a known return value. It is tested as a set of invariants over simulated years:&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&lt;/span&gt;
&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;never grants a second turn before everyone has had a first&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;wheel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;newWheel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;makeMembers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;year&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;year&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;year&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;assigned&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;runYear&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;wheel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;// 5 acts, 11 places each&lt;/span&gt;
    &lt;span class="nx"&gt;wheel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;advance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;wheel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;assigned&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;maxTurns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;wheel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;minTurns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;wheel&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBeLessThanOrEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The valuable tests here run the wheel across several years and then assert a &lt;em&gt;property&lt;/em&gt; of the outcome, never an exact list of names. Exact-list assertions break the moment a rule changes, and they teach you nothing when they do. Invariants survive rule changes, which is the whole point: they encode what fairness means, not what the current implementation happens to produce.&lt;/p&gt;

&lt;p&gt;The same style covers the awkward cases that real use produced. Skipping an act must not advance anyone's turn. Closing an act half full must not pretend it was complete. Reopening a closed act to correct a mistake must leave the queue exactly as it would have been had the correction been made the first time.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The rules that block a spot
&lt;/h3&gt;

&lt;p&gt;Gender rules for squads, payment status, sanctions, licence validity, and the rule against repeating an act you already did in a previous year. On top of that, three configuration switches that each turn one of those rules off, because different associations work differently.&lt;/p&gt;

&lt;p&gt;They interact, so they are tested as a matrix rather than one by one. A member can be eligible under the gender rule and blocked by the licence, or blocked by both, or unblocked by a switch that only lifts one of the two.&lt;/p&gt;

&lt;p&gt;One of these tests is not about logic at all. Payment and sanction status block a spot but must never be rendered in the projected table, because the room is watching. So there is a test asserting that the sensitive field does not appear in the table markup for a blocked member. It is a privacy requirement, and writing it as a test is what stops it being quietly undone by a future refactor of that component.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Schema migrations
&lt;/h3&gt;

&lt;p&gt;The tests nobody applauds, and the ones I would keep if I could keep only one group.&lt;/p&gt;

&lt;p&gt;Every year, the installed version is a year old. Upgrading means running the new installer over the previous one, and the database sitting in the user's profile is the real one, with the real history of who took part in what. There is no seed data to fall back on and no server-side copy anywhere.&lt;/p&gt;

&lt;p&gt;So every schema version ships with a migration, and every migration ships with a test that starts from a fixture database at the &lt;em&gt;previous&lt;/em&gt; version, migrates it, and asserts everything survived: members, years, acts, flags, corrections. Not "the migration completed without throwing", but the row counts and the row contents, plus whatever the new version adds.&lt;/p&gt;

&lt;p&gt;Two more tests guard the edges. Migrations must be ordered and idempotent, and the app must refuse to open a database written by a newer version rather than trying its best. That last one sounds theoretical until you remember how these installers travel. Reinstalling last year's build from an old pendrive is a completely realistic accident.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Import and export, in both directions
&lt;/h3&gt;

&lt;p&gt;The app starts empty. Real data gets in through an import from the spreadsheet the association already had, which is to say: a file a human maintained by hand, for years, with no schema in mind.&lt;/p&gt;

&lt;p&gt;That means accents, trailing spaces, inconsistent capitalisation, blank rows in the middle, a header row that is not the first row, and a name column that might be called &lt;code&gt;Nombre&lt;/code&gt;, &lt;code&gt;NOMBRE&lt;/code&gt; or &lt;code&gt;Nombre y apellidos&lt;/code&gt;. The alternative to importing that file is typing everyone in by hand, which kills a tool before it is ever used. The messy path is the main path.&lt;/p&gt;

&lt;p&gt;Excel and JSON both work in both directions, and the tests treat that as a round trip: export the full wheel, re-import it into an empty database, assert the resulting state is identical. An export you cannot re-import is not a backup, it is a report.&lt;/p&gt;

&lt;p&gt;The fixtures are deliberately dirty. The clean file passes on day one and never catches anything again.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. The build itself
&lt;/h3&gt;

&lt;p&gt;The suite runs on every build of the installer. The installer is not produced by hand from whatever happens to be in my working tree: an automated process builds it from source with the same pinned dependency versions the tests ran against. If the suite fails, no installer comes out.&lt;/p&gt;

&lt;p&gt;That matters more here than in a service I can redeploy. The artefact is physically handed over. The file that reaches that machine is the last point at which anything can still be corrected, so it should not be possible for it to exist in an untested state.&lt;/p&gt;

&lt;p&gt;It is not perfect. The installer is still unsigned, so Windows SmartScreen warns the first time it runs, and no amount of testing fixes that particular rough edge. It is a signing certificate, not a bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I deliberately don't test
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Visual layout.&lt;/strong&gt; Checked by using the app, not by asserting on pixels.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Electron and SQLite themselves.&lt;/strong&gt; They have their own test suites, written by people who know them better than I do.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Components with no logic in them.&lt;/strong&gt; A unit test there mostly proves I typed the props twice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Performance.&lt;/strong&gt; Dozens of people and a handful of years of history. Nothing here will ever be slow, and a benchmark would only give me something to maintain.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of those are unrecoverable if they are wrong. That was the only criterion.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule I ended up with
&lt;/h2&gt;

&lt;p&gt;Test budget is finite, whatever anyone says about coverage. The way I spend it now is by asking, for every path, when a mistake would surface and what fixing it would cost at that point.&lt;/p&gt;

&lt;p&gt;Loud failures are cheap, because the user becomes the test. Silent, plausible failures are expensive, because nothing tells you they happened, and by the time something does, the moment has passed. The rotation is the purest example I have worked on: no exception, no error state, a result that looks completely reasonable, and a consequence that arrives months later and lands on a person rather than on a log.&lt;/p&gt;

&lt;p&gt;In a few weeks the app runs for real again, offline, on one machine, in front of a room. Feature work has already stopped. From here it is tests, polish and leaving things alone.&lt;/p&gt;

&lt;p&gt;I will write about how it goes.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>electron</category>
      <category>softwareengineering</category>
      <category>programming</category>
    </item>
    <item>
      <title>What a Year of Real Use Changed About My App</title>
      <dc:creator>Christian • ancer</dc:creator>
      <pubDate>Wed, 23 Sep 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/ancer/what-a-year-of-real-use-changed-about-my-app-1634</link>
      <guid>https://dev.to/ancer/what-a-year-of-real-use-changed-about-my-app-1634</guid>
      <description>&lt;p&gt;Version 1 of this app was used for real exactly once. One evening, last year, for about three hours.&lt;/p&gt;

&lt;p&gt;I was in the room. I didn't touch the keyboard, and I only answered questions when the session would otherwise have stopped. What I did instead was write things down: every hesitation, every time the person driving it went back on something they'd just done, every question from the room that the screen couldn't answer.&lt;/p&gt;

&lt;p&gt;That notebook is, almost line for line, the changelog of version 2.&lt;/p&gt;

&lt;p&gt;The app is &lt;strong&gt;Rueda de Actos&lt;/strong&gt;, a Windows desktop app for the Filà Ligeros — one of the groups that take part in the Moros y Cristianos festival in Alcoy, Spain. Once a year it hands out participation in five festival events, eleven slots each, following a rotating turn that has to stay fair from one year to the next. The session happens in a meeting room, projected on a wall, with the board watching.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I deliberately didn't do was ask
&lt;/h2&gt;

&lt;p&gt;I could have sent a list of questions the next morning. It would have been faster and it would have produced a worse list.&lt;/p&gt;

&lt;p&gt;If I'd asked &lt;em&gt;what's missing&lt;/em&gt;, I'd have got three or four reasonable answers, because that's what the question invites: a considered opinion about the app in the abstract. The list I actually came home with had fifteen items on it, and the ones that mattered most were things nobody would have volunteered — in the moment they didn't register as missing features. They registered as &lt;em&gt;this is just how it is&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The notebook had two columns. What the person did, and what they seemed to expect would happen. Only the gap between those two things was worth anything. A pause before a click is a gap. Going back to fix something a different way than the app offered is a gap. Somebody in the room asking a question that the screen was already answering, badly, is a gap.&lt;/p&gt;

&lt;p&gt;Asking gets you opinions. Watching gets you requirements.&lt;/p&gt;

&lt;p&gt;Here's what came out of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. A closed event has to be re-openable
&lt;/h2&gt;

&lt;p&gt;About ninety minutes in, on the fourth of the five events, somebody noticed that a name in event two shouldn't have been there.&lt;/p&gt;

&lt;p&gt;Version 1 had exactly one answer for this: &lt;em&gt;Restore by Event&lt;/em&gt;, which reloads the snapshot taken just before that event was verified. It works perfectly. It also erases events three and four, which had taken forty minutes and a fair amount of discussion to settle.&lt;/p&gt;

&lt;p&gt;So it didn't get used. The correction was written on paper and applied later. Which means that from that moment on, the app was no longer the record of what had happened — the paper was.&lt;/p&gt;

&lt;p&gt;Reopening a single event is easy to describe and slightly more interesting to implement than it looks, because closing an event doesn't only fill cells. It rotates people. Everyone who took part moves to the back of the queue, and that rotation is the entire fairness guarantee the app exists to provide.&lt;/p&gt;

&lt;p&gt;The obvious fix — restore each person's previous queue number — is wrong. Their old neighbours have moved on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Someone pulled out of a reopened event does NOT get their old num_rueda&lt;/span&gt;
&lt;span class="c1"&gt;// back: the people who did go out are no longer ahead of them, and everyone&lt;/span&gt;
&lt;span class="c1"&gt;// has moved up a place. What they get is their relative position — behind the&lt;/span&gt;
&lt;span class="c1"&gt;// first of their former predecessors who still hasn't gone out. Number 7, with&lt;/span&gt;
&lt;span class="c1"&gt;// number 3 already rotated away, comes back as 6, which is what they're owed.&lt;/span&gt;
&lt;span class="nx"&gt;lista&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;splice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;idxActual&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;destino&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;idxPrevio&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&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="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;anterior&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;lista&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nombre&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ordenPrevioReabierto&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;haSalidoEsteAño&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&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;anterior&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;destino&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;lista&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;anterior&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;break&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;lista&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;splice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;destino&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;persona&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;reasignarNumerosRueda&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;ordenPrevioReabierto&lt;/code&gt; is just the list of names as it stood in the snapshot taken before that event closed. If the snapshot is gone, the person goes last among those who haven't been out yet — a worse placement, but the reopen still happens. Refusing to correct an error because a backup file is missing would be solving my problem, not theirs.&lt;/p&gt;

&lt;p&gt;A year later, this is the feature I'd point at if somebody asked what watching bought me. It wasn't a missing button. It was a case where the app's only recovery mechanism was so expensive that the user quietly stopped using the app instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. An event that wasn't full was showing as full
&lt;/h2&gt;

&lt;p&gt;Skipping an event already existed in version 1. That wasn't the problem.&lt;/p&gt;

&lt;p&gt;The problem was afterwards. A skipped event and one that closed with eight of eleven slots looked exactly like a full one — five identical green cards across the top, all reading as &lt;em&gt;done&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;That's not sloppiness in the view layer, it's information being destroyed at close time. The per-event counter gets clamped to the limit when an event is verified, because that's the flag for "played":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Slots actually covered when each event closed; null = not closed yet.&lt;/span&gt;
&lt;span class="c1"&gt;// This needs its own record because `contadores` can't tell you: on close it&lt;/span&gt;
&lt;span class="c1"&gt;// gets pinned to the limit to mark the event as played, so a skipped event or&lt;/span&gt;
&lt;span class="c1"&gt;// one verified half-empty is indistinguishable from a full one.&lt;/span&gt;
&lt;span class="nx"&gt;plazasCubiertas&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;escuadra1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;escuadra2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;diana1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;diana2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;diana3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With that in place the card can tell the truth:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Played, but without filling all 11 slots: skipped, or verified with fewer&lt;/span&gt;
&lt;span class="c1"&gt;// people. The rota carries on either way, so the card is the only place this&lt;/span&gt;
&lt;span class="c1"&gt;// is recorded.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;actoIncompleto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;acto&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ActoName&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&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;cubiertas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;appState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;plazasCubiertas&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;acto&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;actoFinalizado&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;acto&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;cubiertas&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="nx"&gt;cubiertas&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;appState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;limitePorColumna&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;acto&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;It now goes amber and says &lt;em&gt;Skipped&lt;/em&gt; or &lt;em&gt;Incomplete&lt;/em&gt;, with the real count — &lt;code&gt;8/11&lt;/code&gt; — and the status bar warns at the end of the rota how many closed short.&lt;/p&gt;

&lt;p&gt;This matters more than a colour, because that history is the input to next year's turn. An event closed with eight people is not the same fact as one closed with eleven, and version 1 was quietly writing down the second when the first had happened. Reality doesn't fill every slot. Software that assumes it will ends up storing a slightly false year, then reasoning from it.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The spreadsheet they already had
&lt;/h2&gt;

&lt;p&gt;Version 1 started from a JSON file. I know that file was fine, because I built it myself, out of their spreadsheet, on my machine.&lt;/p&gt;

&lt;p&gt;Which is the whole problem. An app whose first screen needs a data migration performed by its developer is not a tool that group owns. It's a tool I own and let them use.&lt;/p&gt;

&lt;p&gt;They had a spreadsheet. Every association has a spreadsheet.&lt;/p&gt;

&lt;p&gt;So version 2 imports and exports both Excel and JSON, in both directions — and, the part that actually took the work, accepts a sheet somebody typed by hand. The only hard requirement is a column called &lt;em&gt;Nombre&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Headers are matched after normalisation, and both the visible header and the internal field name are accepted:&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;// No accents, no case, whitespace collapsed: the header the app writes has&lt;/span&gt;
&lt;span class="c1"&gt;// accents and the one the user types may not, and "Género" and "genero" have&lt;/span&gt;
&lt;span class="c1"&gt;// to be the same column.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;normalizar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;texto&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="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;texto&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;texto&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;NFD&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="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\p&lt;/span&gt;&lt;span class="sr"&gt;{Diacritic}/gu&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&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="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="nf"&gt;toLowerCase&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;Values are read the same way — &lt;code&gt;H&lt;/code&gt;/&lt;code&gt;M&lt;/code&gt; for gender, &lt;code&gt;X&lt;/code&gt; or &lt;code&gt;Sí&lt;/code&gt; for a checkbox, &lt;code&gt;Cad.&lt;/code&gt; for an expired license, because that's what people actually put in cells. But there's one decision in there I'd defend harder than the rest:&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;SI&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;si&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sí&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;s&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;verdadero&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1&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;NO&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;no&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;false&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;falso&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="c1"&gt;// Unrecognised values come back untouched instead of becoming "No": the main&lt;/span&gt;
&lt;span class="c1"&gt;// process validates afterwards, and it's better for the user to see what they&lt;/span&gt;
&lt;span class="c1"&gt;// typed wrong than for the app to decide for them and silently change a fact.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;aSiNo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;valor&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;bruto&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;texto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;valor&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;bruto&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="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;clave&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;normalizar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bruto&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;SI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;clave&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Sí&lt;/span&gt;&lt;span class="dl"&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;NO&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;clave&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;No&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;bruto&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Being lenient about input and being lenient about &lt;em&gt;meaning&lt;/em&gt; are different things. Reading &lt;code&gt;x&lt;/code&gt; as yes costs nothing. Reading an unrecognised value as "No" would let a lenient importer write a false fact about a person, and nobody would ever find out.&lt;/p&gt;

&lt;p&gt;The reciprocal fix was less glamorous: in version 1 the Excel the app exported could not be imported back. It generated a file it couldn't read. Nobody had complained, because nobody had tried, but a format that only goes one way isn't an export — it's a printout.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Nobody types accents in front of a room
&lt;/h2&gt;

&lt;p&gt;The single most repeated moment of the evening was somebody looking for a name in a table of ninety-six rows, scrolling, while the room waited.&lt;/p&gt;

&lt;p&gt;Version 2 has a search box. The whole feature is about fifteen lines, and the only part that matters is that it doesn't ask for precision:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The names come in from the imported file in caps and with accents, so&lt;/span&gt;
&lt;span class="c1"&gt;// without normalising, searching "jose" wouldn't find "JOSÉ".&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;normalizar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;texto&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&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;texto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;NFD&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\p&lt;/span&gt;&lt;span class="sr"&gt;{Diacritic}/gu&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="nf"&gt;toLowerCase&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;Two smaller decisions came from the same three seconds of watching. Results are ordered by where the match starts, so people whose name &lt;em&gt;begins&lt;/em&gt; with what you typed come first — when you type a first name, that's who you mean, even if the same string shows up inside somebody else's surname. And the highlight is sliced out of the original string, not the normalised one, so the accents and capitals survive on screen:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;hallados&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;MAX_RESULTADOS&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;indice&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;persona&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pos&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nombre&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;persona&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nombre&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;indice&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;persona&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;antes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;nombre&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;coincide&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;nombre&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pos&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;termino&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;despues&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;nombre&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pos&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;termino&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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;None of this is clever. It's the kind of thing that gets skipped because it isn't a feature, it's a detail — and then it turns out to be the difference between finding a person in two seconds and scrolling a table in front of an audience.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Closing the app shouldn't cost you the evening
&lt;/h2&gt;

&lt;p&gt;This one didn't come from a hesitation. It came from the app being closed with unsaved work in it.&lt;/p&gt;

&lt;p&gt;The data model here is deliberately strict: the app always boots from the saved database, never from the working session. &lt;em&gt;Save&lt;/em&gt; is the only point that consolidates anything. That's a real feature — it means closing without saving is a clean undo of the entire evening, which is exactly what you want after a session that went sideways.&lt;/p&gt;

&lt;p&gt;It also means an accidental close is catastrophic, and an accidental close is not a hypothetical. It happened.&lt;/p&gt;

&lt;p&gt;You can't fix that by relaxing the rule, because the rule is the feature. So every change to the working session also writes it somewhere separate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Single point every change to the working session passes through, so it's&lt;/span&gt;
&lt;span class="c1"&gt;// also where "there's something pending Save" gets marked.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;guardarEnSession&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;ipc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;saveSessionPersonas&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;listaPersonas&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cambiosSinGuardar&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That lands in its own SQLite table, next to the consolidated one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;session_data&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nb"&gt;timestamp&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;em&gt;Settings → Load temporary version (session)&lt;/em&gt; brings it back, with both timestamps shown side by side so you can see what you're choosing between. Closing the app now also asks, offering &lt;em&gt;Save and exit&lt;/em&gt; / &lt;em&gt;Exit without saving&lt;/em&gt; / &lt;em&gt;Cancel&lt;/em&gt; — and if the save fails, it doesn't exit.&lt;/p&gt;

&lt;p&gt;The general shape of this: &lt;strong&gt;if you make a strict rule about when data is committed, you owe the user a way back from the one case where that rule hurts.&lt;/strong&gt; Not a softer rule. An escape hatch, clearly labelled as one, sitting outside the normal flow.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I didn't build, and why
&lt;/h2&gt;

&lt;p&gt;Fifteen items in the notebook. Not fifteen features. Some of the best decisions of version 2 were the ones I talked myself out of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Two people editing at once.&lt;/strong&gt; This never came up as a request; it came up as a &lt;em&gt;could it&lt;/em&gt;. The session has exactly one person driving, in a room, on purpose. That's what makes the outcome authoritative. Multi-user doesn't improve that evening, it dissolves it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloud sync or cloud backup.&lt;/strong&gt; The app backs up its database locally every thirty minutes, which protects against nothing if the machine dies. The honest answer to "what if the laptop breaks" is &lt;em&gt;export the file and put it somewhere else&lt;/em&gt;, and I ship that instead. Adding a server would also undo the entire offline-first argument the app is built on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A configurable number of events and slots.&lt;/strong&gt; Genuinely the most tempting one, and it came from me rather than from the room — the generalisation itch. Five events, eleven slots, in that fixed order, is what this group does. Making it configurable buys a hypothetical second customer and charges every screen in the app a layer of indirection to get there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A general undo stack.&lt;/strong&gt; Reopening an event covers the case that actually occurred. Ctrl+Z over a rotating queue is a much larger idea wearing a small idea's clothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auto-update.&lt;/strong&gt; It checks nothing and connects nowhere. Updating means running the new installer over the old one, and that is the whole story.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of those makes the app more capable in the abstract and worse at the single evening it exists for. Saying no to them is not restraint or minimalism, it's the same design work as saying yes to the other five — done in the direction nobody gives you credit for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Version 1's job was to produce the list
&lt;/h2&gt;

&lt;p&gt;The uncomfortable part is how cheap every one of these fixes was. A field that stores what the counter destroys. A normalise call. A second table. A placement loop that took an afternoon.&lt;/p&gt;

&lt;p&gt;I could not have specified any of them up front, and I don't think a longer analysis phase would have got me there. I wrote version 1 from the rules of the rota — slots, rotation, squads, veterans, licenses, penalties — and every one of those rules was implemented correctly in version 1. It was a correct application. The list above isn't in the rules. It lives in the gap between the rules and one evening in a room in Alcoy.&lt;/p&gt;

&lt;p&gt;So "a year of real use" is doing some work in that title, and I should be precise about it: one session run live, and twelve months of that session's result being the thing people actually consulted. Both halves produced findings. The evening produced the hesitations; the year afterwards produced the ones you only notice when you go back to the data and it doesn't say what happened.&lt;/p&gt;

&lt;p&gt;Between them they produced a better requirements document than any process I could have run beforehand, and the cost was sitting still and writing things down.&lt;/p&gt;

&lt;p&gt;If there's something to take from it, it isn't &lt;em&gt;watch your users&lt;/em&gt; — everybody says that and nobody schedules it. It's narrower: &lt;strong&gt;version 1 doesn't have to be complete.&lt;/strong&gt; It has to be good enough to be used for real, once, in the actual conditions. Then you have to be in the room, and you have to keep your hands off the keyboard.&lt;/p&gt;

</description>
      <category>buildinpublic</category>
      <category>ux</category>
      <category>softwareengineering</category>
      <category>programming</category>
    </item>
    <item>
      <title>Designing a UI That Gets Projected in Front of a Room</title>
      <dc:creator>Christian • ancer</dc:creator>
      <pubDate>Wed, 16 Sep 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/ancer/designing-a-ui-that-gets-projected-in-front-of-a-room-4kdc</link>
      <guid>https://dev.to/ancer/designing-a-ui-that-gets-projected-in-front-of-a-room-4kdc</guid>
      <description>&lt;p&gt;Almost every interface decision I make assumes a private screen. One person, their own monitor, their own data in front of them. It's such a safe assumption that it never gets written down anywhere.&lt;/p&gt;

&lt;p&gt;For the last few months I've been building a Windows desktop app called &lt;strong&gt;Rueda de Actos&lt;/strong&gt;, for the Filà Ligeros — one of the groups that take part in the Moros y Cristianos festival in Alcoy, Spain. Its job is to hand out participation in five festival events, eleven slots each, following a rotating turn that has to stay fair from one year to the next.&lt;/p&gt;

&lt;p&gt;It breaks that assumption completely.&lt;/p&gt;

&lt;p&gt;The app runs once a year, in a meeting room, projected on a wall, with the whole board watching. One person drives it. There are ninety-six names in the table on screen, and a good number of them belong to people sitting in that room.&lt;/p&gt;

&lt;p&gt;Every interesting design decision in this app came out of that one sentence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The room is the spec
&lt;/h2&gt;

&lt;p&gt;Put the scenario next to the usual mental model and the assumptions fall over one by one.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;The usual assumption&lt;/th&gt;
&lt;th&gt;The actual session&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;One user looking at the screen&lt;/td&gt;
&lt;td&gt;One user driving, a room reading over their shoulder&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The data is about other people&lt;/td&gt;
&lt;td&gt;The data is about the audience&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A mistake costs a few seconds&lt;/td&gt;
&lt;td&gt;A mistake happens in front of everyone it affects&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;You can use the app again tomorrow&lt;/td&gt;
&lt;td&gt;The session happens once a year&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;None of that is exotic. It's a projector and a meeting, which describes a fair amount of internal software. The difference here is that the rows &lt;em&gt;are&lt;/em&gt; the audience — this is a group of neighbours and friends deciding who marches in which squad, and the tool is standing in the middle of it.&lt;/p&gt;

&lt;p&gt;So the brief stopped being "make this efficient to operate" and became something closer to "make this safe to project".&lt;/p&gt;

&lt;h2&gt;
  
  
  The block is public. The reason is private.
&lt;/h2&gt;

&lt;p&gt;Two things can make a person ineligible: unpaid dues and a standing penalty. Both are recorded per person. Neither one appears anywhere in the table.&lt;/p&gt;

&lt;p&gt;They live in the individual record, behind the pencil icon on the row, as two toggles next to the license status. That record opens for one person at a time, and in practice it gets opened when nobody has a reason to look.&lt;/p&gt;

&lt;p&gt;What the table shows instead is that the cell can't be clicked. That's it.&lt;/p&gt;

&lt;p&gt;The reason this works is that a blocked cell is deliberately ambiguous. Three different rules produce it, and they all render identically:&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;// Behind on dues, or carrying a penalty.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blockedByStatus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;person&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unpaidDues&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;person&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;penalty&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;activeColumn&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;assignedThisYear&lt;/span&gt;

&lt;span class="c1"&gt;// Already taking part in another event this year.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blockedByAnotherEvent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;allowMultipleEvents&lt;/span&gt;
  &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;hasAnyEventThisYear&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;person&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;assignedThisYear&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;person&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;

&lt;span class="c1"&gt;// Already did this same event in an earlier edition.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blockedByRepeat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;allowRepeatEvent&lt;/span&gt;
  &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;activeColumn&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;assignedThisYear&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;person&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;

&lt;span class="c1"&gt;// One class. Three reasons. The cell never says which.&lt;/span&gt;
&lt;span class="nx"&gt;classes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cell-blocked&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="nx"&gt;blockedByStatus&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;blockedByAnotherEvent&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;blockedByRepeat&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And that class is a single flat pattern, with nothing attached to it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;td&lt;/span&gt;&lt;span class="nc"&gt;.cell-blocked&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;background-image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;repeating-linear-gradient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="m"&gt;-45deg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;transparent&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;5px&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;116&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;139&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;.13&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="m"&gt;5px&lt;/span&gt; &lt;span class="m"&gt;6px&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;not-allowed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;pointer-events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The room sees that the rules were applied. The room does not learn who owes money.&lt;/p&gt;

&lt;p&gt;The temptation I had to talk myself out of was the tooltip. On any other desktop app I'd have added &lt;code&gt;title="Blocked: unpaid dues"&lt;/code&gt; without thinking — it's helpful, it's cheap, it's what a good interface does. Projected, it's the worst feature in the product: the same private fact, on a half-second delay, rendered wherever the mouse happens to be resting, at wall size.&lt;/p&gt;

&lt;p&gt;Worth being precise about where the line sits, because it isn't "hide every status". The avantcarga license — the paperwork some members need for certain events — is right there in the open, as a small coloured card next to each name and as a running count in the header: 32 valid, 13 expired, 51 none. Nobody minds. It's an administrative fact about a document.&lt;/p&gt;

&lt;p&gt;Money and penalties are a fact about a person, in a room full of people who know them. That's the line, and it isn't a technical one. It came from asking what would be uncomfortable to have on a wall, not from a data classification exercise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Everyone has to stay oriented, not just the operator
&lt;/h2&gt;

&lt;p&gt;In a normal app, only the user needs to know where they are. Here, thirty people are following along without touching anything, and every question they can't answer for themselves becomes an interruption: &lt;em&gt;wait, which event are we on? how many are left? is that one full?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So the state of the session is permanently on screen, not tucked behind the current interaction:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Five cards across the top&lt;/strong&gt;, one per event, each showing &lt;code&gt;8/11&lt;/code&gt; and a men/women split (&lt;code&gt;H 8  M 0&lt;/code&gt;), with a fill bar. The event in progress is the blue one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The active column is tinted down the entire height of the table&lt;/strong&gt;, so a row halfway down the list still reads as belonging to the current event.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A status line along the bottom&lt;/strong&gt;: how many people are in the rota, which event is in progress, and whether the squad has locked to a gender.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One small detail I like more than it probably deserves. When an event closes, its per-gender counters are reset internally, so leaving them on the card would show a confident &lt;code&gt;H 0  M 0&lt;/code&gt; for an event that had just filled eleven slots. The card swaps them for a state instead — &lt;em&gt;Completed&lt;/em&gt;, &lt;em&gt;Skipped&lt;/em&gt;, &lt;em&gt;Incomplete&lt;/em&gt;, &lt;em&gt;Pending verification&lt;/em&gt; — and only shows numbers while they still mean something.&lt;/p&gt;

&lt;p&gt;Nobody would catch that in a code review. Projected, a wrong number on a wall gets a question from the third row, and the session stops while somebody explains that the zero doesn't mean what it says.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you cover something, let them move it
&lt;/h2&gt;

&lt;p&gt;When the eleven slots fill, a verification modal opens listing the eleven people, with a checkbox for squad leaders and an × to remove anyone who shouldn't be there. It's the moment the room is paying most attention.&lt;/p&gt;

&lt;p&gt;It also covers about eleven rows of the table underneath — which is exactly when somebody asks whether so-and-so already went out last year.&lt;/p&gt;

&lt;p&gt;On a private screen the answer is trivial: close the dialog, look, open it again. In front of a room, that means throwing away a selection you just made while eleven people wait for you to rebuild it.&lt;/p&gt;

&lt;p&gt;So every modal in the app can be dragged out of the way:&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;INTERACTIVE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;button, a, input, select, textarea, label, [contenteditable]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;onPointerDown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&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;e&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;!==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&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;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;closest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;INTERACTIVE&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;

  &lt;span class="c1"&gt;// The handle is the header. Small dialogs have no header, so anything&lt;/span&gt;
  &lt;span class="c1"&gt;// non-interactive works there.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;header&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;contentEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.modal-header&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;header&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;header&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&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;measureBase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="nx"&gt;pointerStart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientY&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;offsetStart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;dragging&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;contentEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;setPointerCapture&lt;/span&gt;&lt;span class="p"&gt;?.(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pointerId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;// without this, dragging selects the dialog's text&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things in there exist only because of the room.&lt;/p&gt;

&lt;p&gt;The first is that check against &lt;code&gt;INTERACTIVE&lt;/code&gt;. Dragging from a button would make the checkboxes in that verification list less than perfectly reliable to click, and a mis-click during verification is the one mistake you cannot make quietly.&lt;/p&gt;

&lt;p&gt;The second doesn't appear in that snippet: every offset is clamped against &lt;code&gt;window.innerWidth&lt;/code&gt; and &lt;code&gt;innerHeight&lt;/code&gt; before it's applied, so the dialog can't be dragged past the edge of the screen. This is a kiosk-shaped desktop app — no browser chrome, no page scroll, nothing to rescue a window that left the viewport. Get that wrong and the operator is closing a modal they can no longer see, blind, in front of everybody.&lt;/p&gt;

&lt;p&gt;The position also resets to centre on every open, rather than remembering where the last dialog was left. Where you dragged the previous dialog says nothing about what the next one is covering.&lt;/p&gt;

&lt;h2&gt;
  
  
  Interrupt for changes, not for confirmations
&lt;/h2&gt;

&lt;p&gt;The app has one dialog system, and the rule that decides how a message appears turned out to be a single line:&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;// A notice is a dialog that closes itself and expects no answer.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isNotice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;computed&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;dialog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;timer&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;dialog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;showConfirmButton&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;dialog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;showCancelButton&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If there's nothing to answer, it isn't allowed to take the screen. &lt;em&gt;Saved&lt;/em&gt;, &lt;em&gt;12 records imported&lt;/em&gt;, &lt;em&gt;Exported to XLSX&lt;/em&gt;, &lt;em&gt;Event reopened&lt;/em&gt; — corner of the screen, no backdrop, a thin bar draining to show how long it stays, gone in two seconds. The app underneath keeps working the whole time.&lt;/p&gt;

&lt;p&gt;Dimming an entire projected wall to announce "Saved" is absurd once you've watched it happen. It was also covering the modal that was in the middle of closing behind it.&lt;/p&gt;

&lt;p&gt;The interesting case is the one that still blocks.&lt;/p&gt;

&lt;p&gt;The squad rules say an event locks to a gender once six of the eleven go the same way. When that happens the app doesn't just note it — it un-assigns everyone of the other gender from the event, which rewrites rows the room is actively reading.&lt;/p&gt;

&lt;p&gt;That started life as a corner notice. It was wrong. The toast slid away after two seconds, the table simply changed, and people were left wondering what the operator had done.&lt;/p&gt;

&lt;p&gt;It's now the one message in the app allowed to take the whole screen: centred, no backdrop click, no Escape, and a single &lt;em&gt;Understood&lt;/em&gt; button somebody has to press before the session moves on. It names the rule that fired — &lt;em&gt;Men's event&lt;/em&gt; — and says plainly that the women have been un-assigned.&lt;/p&gt;

&lt;p&gt;The rule I ended up with: &lt;strong&gt;interrupt when the screen changed underneath the room, never to confirm something the operator did on purpose.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  At three metres, colour is the interface
&lt;/h2&gt;

&lt;p&gt;Nobody in the fourth row is reading a table cell. They're reading colour and position, and only leaning in when something looks off.&lt;/p&gt;

&lt;p&gt;So colour carries real information, and it's the only part of the visual design with a strict budget:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Green&lt;/strong&gt; — assigned in the current edition.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hatched grey&lt;/strong&gt; — unavailable, reason unstated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One tint per past year&lt;/strong&gt; — &lt;code&gt;2023&lt;/code&gt; amber, &lt;code&gt;2024&lt;/code&gt; yellow, &lt;code&gt;2025&lt;/code&gt; blue, and so on. You can see how long ago someone last did an event without reading a single number.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bold year&lt;/strong&gt; — that person was a squad leader that year.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A small coloured card beside the name&lt;/strong&gt; — license valid, expired, or missing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything else is grey. There is no colour in this UI that doesn't mean something, which is less a matter of taste than of bandwidth: the moment you use a tint for decoration, the room starts trying to decode it.&lt;/p&gt;

&lt;p&gt;The honest caveat: those license cards are colour-only, three images that differ in nothing but hue, with a &lt;code&gt;title&lt;/code&gt; carrying the exact status. That would not pass an accessibility review, and it's on the list. The rest of the palette is redundant — every tinted cell also contains the year as text, and blocked cells carry a hatch pattern as well as a fill — but the cards genuinely aren't, and writing this up is what made me notice.&lt;/p&gt;

&lt;h2&gt;
  
  
  None of this was in the requirements
&lt;/h2&gt;

&lt;p&gt;Here's the part that bothers me a little.&lt;/p&gt;

&lt;p&gt;Every decision above is small and cheap. Not painting two fields. One CSS class covering three cases. A &lt;code&gt;pointerdown&lt;/code&gt; handler with an early return. A boolean choosing corner over centre.&lt;/p&gt;

&lt;p&gt;And not one of them was in anything I was given. The brief was the rules of the rota — slots, rotation, squads, veterans, licenses, penalties — and every one of those rules is in the app, correctly. You could implement the entire specification, pass every test, and still ship something that quietly puts a member's unpaid dues on a wall in front of them.&lt;/p&gt;

&lt;p&gt;They came from watching the previous version being used. Not from a demo and not from a call: somebody had been running this session for years, and the shape of that evening — the projector, the questions from the room, the window that had to be closed to check something — was the real requirements document. It just wasn't written like one.&lt;/p&gt;

&lt;p&gt;I don't have a repeatable process for extracting that, and I'm suspicious of anyone who claims one. The closest thing I have is a question I now ask early, before any of the interesting technical questions: &lt;strong&gt;where will this be running, and who else can see the screen?&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Context of use is a functional requirement
&lt;/h2&gt;

&lt;p&gt;We're fairly disciplined about non-functional requirements when they have familiar names. Performance, accessibility, security — those get budgeted for.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Where the screen is&lt;/em&gt; doesn't have a familiar name, so it gets skipped. And when it's skipped, what you ship is an interface that is entirely correct and still fails on the one night it's used, because you built for a desk and it ended up standing in front of a room.&lt;/p&gt;

&lt;p&gt;The rota is fair either way. The maths doesn't care where it runs. But the whole point of building this was that the group could watch the reasoning happen and stop having the argument — and that only works if the screen shows exactly what the room is entitled to see, and not one field more.&lt;/p&gt;

</description>
      <category>ux</category>
      <category>design</category>
      <category>electron</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Building Offline-First Desktop Software in 2026</title>
      <dc:creator>Christian • ancer</dc:creator>
      <pubDate>Wed, 09 Sep 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/ancer/building-offline-first-desktop-software-in-2026-1cfp</link>
      <guid>https://dev.to/ancer/building-offline-first-desktop-software-in-2026-1cfp</guid>
      <description>&lt;p&gt;Every new app is drawn with a server in it.&lt;/p&gt;

&lt;p&gt;Accounts, an API, sync, a subscription tier. It gets decided before anyone asks the question that actually matters: where will this run, and who is standing in front of it?&lt;/p&gt;

&lt;p&gt;For the last few months I've been building a Windows desktop app called &lt;strong&gt;Rueda de Actos&lt;/strong&gt;. It's a custom tool for the Filà Ligeros, one of the groups that take part in the Moros y Cristianos festival in Alcoy, Spain. Its job is to hand out participation in five festival events, eleven spots each, following a rotating turn that has to stay fair year after year.&lt;/p&gt;

&lt;p&gt;It makes zero network calls. Not one. No accounts, no sync, no telemetry, no update check.&lt;/p&gt;

&lt;p&gt;That isn't nostalgia and it wasn't a shortcut. It's the answer the context gave.&lt;/p&gt;

&lt;h2&gt;
  
  
  The room decides the architecture
&lt;/h2&gt;

&lt;p&gt;Software gets used somewhere, and that somewhere usually has more to say about your stack than any benchmark does.&lt;/p&gt;

&lt;p&gt;Here is the somewhere: a room, a projector, the whole board sitting around a table, and one person driving the app while everyone watches the screen. It happens once a year. The wifi in that building is whatever it happens to be that night.&lt;/p&gt;

&lt;p&gt;Run a cloud app against that scenario and count the failure modes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The connection drops on the one night of the year the tool is needed.&lt;/li&gt;
&lt;li&gt;Members' personal data leaves the building to sit on somebody else's server.&lt;/li&gt;
&lt;li&gt;The tool stops working the day a subscription lapses, or the day I stop maintaining it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of those are exotic. They are the ordinary price of having a server, and normally you pay it gladly, because you get sync, multi-user access, remote access and painless updates in return. In this room you get none of that back. There is one user, one machine, and one session a year.&lt;/p&gt;

&lt;p&gt;So the server came out. What's left is a database file on the computer of the person running the meeting.&lt;/p&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Choice&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Shell&lt;/td&gt;
&lt;td&gt;Electron 31&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UI&lt;/td&gt;
&lt;td&gt;Vue 3 (Composition API) + Bootstrap 5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data&lt;/td&gt;
&lt;td&gt;SQLite via &lt;code&gt;better-sqlite3&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build&lt;/td&gt;
&lt;td&gt;Vite&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tests&lt;/td&gt;
&lt;td&gt;Vitest&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Packaging&lt;/td&gt;
&lt;td&gt;electron-builder → NSIS installer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Logging&lt;/td&gt;
&lt;td&gt;electron-log, to a local file&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Nothing exotic. The interesting part is what the renderer is &lt;em&gt;allowed&lt;/em&gt; to do. It runs with &lt;code&gt;nodeIntegration&lt;/code&gt; off and &lt;code&gt;contextIsolation&lt;/code&gt; on, so its entire access to the machine is whatever the preload script decides to hand it:&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;// electron/preload.js&lt;/span&gt;
&lt;span class="nx"&gt;contextBridge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exposeInMainWorld&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&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;getPersonas&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="nx"&gt;ipcRenderer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;db:getPersonas&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;savePersonas&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;ipcRenderer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;db:savePersonas&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;importXLSX&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;ipcRenderer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;import:xlsx&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;exportJSON&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;ipcRenderer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;export:json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="c1"&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 UI is a web app that cannot touch the filesystem or the database. It can call the handful of functions the preload hands it, and nothing else. Every one of them is validated on the other side before it reaches SQLite. Removing the server does not mean removing the boundary — it means the boundary moved inside the process.&lt;/p&gt;

&lt;h2&gt;
  
  
  What disappears when you delete the server
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Accounts and authentication.&lt;/strong&gt; Physical access to the laptop is the authentication.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sync and conflict resolution.&lt;/strong&gt; There is one writer. There is never a merge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recurring cost.&lt;/strong&gt; Nothing to pay, nothing to renew, nothing to migrate when a provider changes its pricing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Attack surface.&lt;/strong&gt; No listening port, no endpoint, no token to leak, no dependency on my uptime.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The privacy conversation.&lt;/strong&gt; Not because it was answered well, but because it stopped applying.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one was the part the client actually felt. "Where do the names end up?" — "On your computer. They never leave it."&lt;/p&gt;

&lt;h2&gt;
  
  
  Three problems move in instead
&lt;/h2&gt;

&lt;p&gt;Deleting the server doesn't simplify the project. It relocates the difficulty.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Backups are your job now
&lt;/h3&gt;

&lt;p&gt;There is no nightly dump on somebody else's infrastructure. So the app makes its own: a full SQLite copy every 30 minutes, keeping the last ten.&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;performBackup&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;timestamp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;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="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;:.&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&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="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;19&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;backupToFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;backupDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`rueda-ligeros-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.db`&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="nf"&gt;rotate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;backupDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;MAX_BACKUPS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// newest ten stay, the rest are unlinked&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;performBackup&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nf"&gt;setInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;performBackup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&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;better-sqlite3&lt;/code&gt; exposes SQLite's own online backup API, so this is a consistent snapshot rather than a file copy taken mid-write.&lt;/p&gt;

&lt;p&gt;Worth being honest about what this protects against: those copies live on the same disk as the original. They save you from "I just broke the data", not from "the laptop died". The second one has a different answer, further down.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Migrations run on a machine you will never see
&lt;/h3&gt;

&lt;p&gt;There is no maintenance window, no staging environment, no rollback button, and nobody to call. The user double-clicks the new version and the schema has to be correct by the time the window appears.&lt;/p&gt;

&lt;p&gt;So the schema is versioned, and the app migrates itself on startup:&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;runMigrations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;log&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;currentVersion&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getSchemaVersion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// from a metadata table&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;migrations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;currentVersion&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;migration&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transaction&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;migration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;up&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="nf"&gt;setSchemaVersion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;migration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;version&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;})()&lt;/span&gt;
    &lt;span class="nx"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Migration v&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;migration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;version&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; applied: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;migration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each migration is a &lt;code&gt;{ version, description, up }&lt;/code&gt; object in an ordered array, each one runs inside a transaction, and a throw stops the chain instead of leaving the database half-converted.&lt;/p&gt;

&lt;p&gt;That structure earned its keep on migration 3, which normalised the event assignments out of the &lt;code&gt;personas&lt;/code&gt; table into a &lt;code&gt;persona_actos&lt;/code&gt; child table. SQLite's answer to dropping columns is create-copy-drop-rename, which is well documented and looks harmless:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create the new child table.&lt;/li&gt;
&lt;li&gt;Read the old assignments.&lt;/li&gt;
&lt;li&gt;Rebuild the parent table without the old columns.&lt;/li&gt;
&lt;li&gt;Insert the assignments into the child table.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Get steps 3 and 4 the wrong way round and you lose data silently. With &lt;code&gt;foreign_keys = ON&lt;/code&gt;, the &lt;code&gt;DROP TABLE personas&lt;/code&gt; in step 3 fires an implicit &lt;code&gt;DELETE&lt;/code&gt;, which cascades through &lt;code&gt;ON DELETE CASCADE&lt;/code&gt; and empties the child table you just carefully filled. No error. No warning. An empty table, and a version number claiming the migration succeeded.&lt;/p&gt;

&lt;p&gt;The comment explaining that ordering is now four lines long and sits directly above the code. When a migration runs on a stranger's machine, in a building you're not in, the day before the tool is needed, there is no second attempt.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Distribution is a person carrying a file
&lt;/h3&gt;

&lt;p&gt;No auto-update. Not even a version check — a version check is a network call, and the whole premise was that there aren't any. The electron-builder config has no &lt;code&gt;publish&lt;/code&gt; block and never will: there is nowhere to publish to, and nothing to check against.&lt;/p&gt;

&lt;p&gt;New version means a new installer, run over the old one, data preserved. That is the entire update story.&lt;/p&gt;

&lt;p&gt;And then there's SmartScreen. The installer isn't code-signed, so Windows greets a non-technical user with a blue full-screen panel that says &lt;em&gt;"Windows protected your PC"&lt;/em&gt; and hides the button that runs it anyway behind a &lt;em&gt;More info&lt;/em&gt; link. To a developer that's an inconvenience. To the person installing it, that dialog reads as "this is a virus", and the software has failed before it ever opened.&lt;/p&gt;

&lt;p&gt;There are two options: buy a certificate, or explain. A code-signing certificate is a recurring cost, and even with one, SmartScreen reputation takes downloads to accumulate — which an app with a single-digit user count will never get. So the build script is written to sign automatically the day a certificate exists, and until then the answer is documentation: one line in the install guide, and a heads-up to the person who will click the button, before they click it.&lt;/p&gt;

&lt;p&gt;It's an unsatisfying answer. It's also the honest one, and pretending the dialog doesn't exist would have been worse.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data has to be able to leave
&lt;/h2&gt;

&lt;p&gt;With no server, there is no "your data is safe with us" to offer. What you can offer instead is that nothing is trapped.&lt;/p&gt;

&lt;p&gt;Everything imports and exports in both directions, as XLSX and as JSON. Import accepts the spreadsheet the group was already keeping — a hand-made one is fine, as long as there's a name column. Export writes a file that goes on a USB stick, into an email, or onto another machine.&lt;/p&gt;

&lt;p&gt;That's also the real backup story. The half-hourly copies protect the session; the export is the one that survives the hard drive. And it's the answer to the question every custom-software client should ask and rarely does: &lt;em&gt;what happens to us if you disappear?&lt;/em&gt; The data is a spreadsheet. Somebody else can pick it up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing replaces the operations you don't have
&lt;/h2&gt;

&lt;p&gt;No error tracking, no logs streaming back to me, no ability to hotfix anything. Whatever ships is what runs until somebody tells me otherwise.&lt;/p&gt;

&lt;p&gt;What's left is testing before the fact: close to 400 tests covering the turn rotation, the eligibility rules, validation, the migrations and the import/export round trip, running on every build of the installer. Plus electron-log writing to a local file, so that when something does go wrong there's something concrete to ask the user for.&lt;/p&gt;

&lt;p&gt;That trade is easy to underestimate. Server-side, you can be wrong for twenty minutes. Here, being wrong means being wrong for a year.&lt;/p&gt;

&lt;h2&gt;
  
  
  Offline-first isn't a step back
&lt;/h2&gt;

&lt;p&gt;The reflex is to read a local desktop app as what we used to do before we knew better. Sometimes that's exactly what it is. Here it isn't.&lt;/p&gt;

&lt;p&gt;Everything the cloud would have added to this project — sync, accounts, remote access, seamless updates — is worth nothing in a room with one machine and one session a year. Everything it would have introduced — a connection that can fail, a bill that can lapse, personal data on somebody else's disk — lands squarely on the night it matters most.&lt;/p&gt;

&lt;p&gt;Picking an architecture is not picking the most current option. It's picking the one that still works under the real conditions of use.&lt;/p&gt;

&lt;p&gt;The wifi in that room can do whatever it likes. The app opens.&lt;/p&gt;

</description>
      <category>electron</category>
      <category>sqlite</category>
      <category>architecture</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The First Version Is Just The Beginning</title>
      <dc:creator>Christian • ancer</dc:creator>
      <pubDate>Wed, 29 Jul 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/ancer/the-first-version-is-just-the-beginning-4mch</link>
      <guid>https://dev.to/ancer/the-first-version-is-just-the-beginning-4mch</guid>
      <description>&lt;p&gt;Releasing a piece of software feels like reaching the finish line. After weeks or months of planning, writing code, fixing bugs and polishing details, clicking the "Publish" button is a satisfying moment. But the more products I build, the more I realize that a release isn't the end of the journey. It's simply the point where the product stops being shaped only by the developer and starts being shaped by the people who actually use it.&lt;/p&gt;

&lt;p&gt;Before a launch, every design decision is based on assumptions. You can test your application, ask for opinions and imagine different workflows, but there's a limit to how much you can learn in isolation. Real users always find unexpected ways to use a product. They notice friction you had stopped seeing, ask questions you never considered and often reveal problems that only appear outside your own development environment.&lt;/p&gt;

&lt;p&gt;One thing I've learned is that software rarely improves because of one huge update. More often, progress comes from dozens of small refinements. A shortcut that saves a few seconds. A button that's easier to find. A clearer error message. A better default setting. None of those changes deserves a headline on its own, but together they have a significant impact on how the product feels after weeks or months of use.&lt;/p&gt;

&lt;p&gt;Feedback plays an important role in that process, although not always in the way people expect. Feature requests are useful, but they are only one type of feedback. Sometimes the most valuable comments aren't asking for something new—they're describing a moment of confusion or explaining why a workflow didn't behave as expected. Those observations often highlight opportunities to simplify the product rather than expand it.&lt;/p&gt;

&lt;p&gt;That's the mindset I've tried to keep while working on Convertim. Since its release, the objective hasn't been to add features as quickly as possible. Instead, I've focused on understanding how people use the application, improving existing workflows and being selective about what deserves to become part of the product. Every new feature increases the cost of maintenance, documentation and testing, so saying "not yet" is often as important as saying "yes."&lt;/p&gt;

&lt;p&gt;This approach isn't the fastest way to build software, but I believe it leads to better products in the long run. A tool that stays focused is usually easier to understand, easier to maintain and more enjoyable to use than one that tries to solve every possible problem. Growth is important, but clarity is what keeps a product useful over time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;Version 1.0 isn't the destination. It's the first opportunity to learn from real users and real workflows. The goal isn't to ship as many updates as possible, but to make each one meaningful. Looking back, I think that's what building software is really about: continuous improvement, thoughtful decisions and accepting that the first version is only the beginning.&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>programming</category>
      <category>productivity</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Every Dependency Has a Cost</title>
      <dc:creator>Christian • ancer</dc:creator>
      <pubDate>Sat, 25 Jul 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/ancer/every-dependency-has-a-cost-19cm</link>
      <guid>https://dev.to/ancer/every-dependency-has-a-cost-19cm</guid>
      <description>&lt;p&gt;One of the best things about modern software development is that we don't have to build everything ourselves. Open-source libraries and third-party packages let us solve complex problems in minutes instead of days, allowing us to focus on what actually makes our applications unique.&lt;/p&gt;

&lt;p&gt;That's why adding a dependency often feels like an obvious choice.&lt;/p&gt;

&lt;p&gt;The problem is that every dependency comes with baggage.&lt;/p&gt;

&lt;p&gt;You're not only importing code—you are also importing someone else's release cycle, security updates, breaking changes, licensing decisions, and maintenance priorities. Even the most popular packages can become abandoned or introduce changes that force you to adapt your own project.&lt;/p&gt;

&lt;p&gt;That doesn't mean dependencies are bad. Many of them save hundreds of hours of work and have become industry standards for good reason. The key is being intentional instead of installing a package for every small problem.&lt;/p&gt;

&lt;p&gt;Before adding a new dependency, I usually ask myself a few simple questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Does it solve a significant problem?&lt;/li&gt;
&lt;li&gt;Is the project actively maintained?&lt;/li&gt;
&lt;li&gt;Could I reasonably implement this myself?&lt;/li&gt;
&lt;li&gt;If I needed to remove it later, how difficult would that be?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those questions don't always lead to the same answer, but they help avoid unnecessary complexity.&lt;/p&gt;

&lt;p&gt;While building Convertim, I've found that saying &lt;strong&gt;"no"&lt;/strong&gt; to an extra dependency is sometimes the better engineering decision. A few more lines of code today can mean fewer updates, fewer compatibility issues, and less maintenance in the future.&lt;/p&gt;

&lt;p&gt;Architecture isn't only about choosing the right technologies.&lt;/p&gt;

&lt;p&gt;Sometimes it's also about choosing &lt;strong&gt;what not to add.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>programming</category>
      <category>softwareengineering</category>
      <category>architecture</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The Features We Chose Not To Build</title>
      <dc:creator>Christian • ancer</dc:creator>
      <pubDate>Wed, 22 Jul 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/ancer/the-features-we-chose-not-to-build-2lde</link>
      <guid>https://dev.to/ancer/the-features-we-chose-not-to-build-2lde</guid>
      <description>&lt;p&gt;When people talk about software development, the conversation usually revolves around what gets added. New features generate excitement, screenshots, release notes and marketing material. They give users something tangible to explore and give developers something new to build. It's easy to believe that the value of a product grows in direct proportion to the number of capabilities it offers.&lt;/p&gt;

&lt;p&gt;In reality, some of the most important product decisions never appear in a changelog.&lt;/p&gt;

&lt;p&gt;Every feature starts as a good idea. Someone encounters a problem, imagines a solution and suggests adding another option, another setting or another workflow. Looking at those ideas one by one, it's surprisingly difficult to reject them because each proposal often makes perfect sense on its own. The challenge only becomes visible when you look at the product as a whole instead of evaluating individual requests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every Feature Is a Long-Term Commitment
&lt;/h2&gt;

&lt;p&gt;Adding functionality isn't just about writing code. Every new feature becomes part of the application for as long as the product exists. It needs documentation, testing, maintenance and compatibility with everything that comes afterwards. Future updates have to take it into account, bug fixes become more complicated and every new release has one more moving part that can potentially fail.&lt;/p&gt;

&lt;p&gt;Those costs are rarely obvious when the feature is first implemented. At that moment, all you see is the problem it solves. The maintenance cost only becomes apparent months or even years later, when the product has evolved and dozens of independent decisions begin interacting with one another.&lt;/p&gt;

&lt;p&gt;That's why product development isn't simply a process of adding value. It's also a process of deciding which responsibilities you're willing to carry in the future.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Best Question Isn't "Can We Build It?"
&lt;/h2&gt;

&lt;p&gt;As developers, we naturally enjoy solving technical problems. When someone suggests a useful feature, the first instinct is often to think about how it could be implemented. Modern frameworks, libraries and development tools make building software faster than ever, which means the technical challenge is often smaller than it used to be.&lt;/p&gt;

&lt;p&gt;The more interesting question isn't whether something can be built. It's whether it deserves to become part of the product.&lt;/p&gt;

&lt;p&gt;Some ideas solve edge cases that affect very few people. Others introduce configuration screens that make the interface harder to understand. Some require new dependencies, increase testing effort or create maintenance work that continues long after the original problem has been forgotten.&lt;/p&gt;

&lt;p&gt;A feature can be technically successful while still making the product worse.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building Convertim Meant Rejecting Good Ideas
&lt;/h2&gt;

&lt;p&gt;While building Convertim, there were plenty of ideas that looked genuinely useful. Some would have added flexibility, others would have supported additional workflows, and a few would probably have looked impressive on a feature list.&lt;/p&gt;

&lt;p&gt;We still decided not to build them.&lt;/p&gt;

&lt;p&gt;Not because they were impossible, but because they moved the application away from its main purpose. Convertim was designed to be a simple, local-first image conversion tool. Every feature that doesn't reinforce that goal has to justify the complexity it introduces, and many perfectly reasonable ideas simply couldn't pass that test.&lt;/p&gt;

&lt;p&gt;Saying "no" isn't about limiting the product. It's about protecting it from gradually becoming something it was never intended to be.&lt;/p&gt;

&lt;h2&gt;
  
  
  Simplicity Doesn't Happen By Accident
&lt;/h2&gt;

&lt;p&gt;Software naturally becomes more complex over time. Every new option, every additional workflow and every extra dependency increases the number of decisions both developers and users have to make. Left unchecked, complexity accumulates almost automatically.&lt;/p&gt;

&lt;p&gt;Simplicity works differently.&lt;/p&gt;

&lt;p&gt;A simple product is usually the result of dozens of conscious decisions to leave things out. Those decisions often happen long before users ever download the application, and they're rarely visible from the outside. Users don't know which features were rejected during development, but they do notice when a tool feels focused, predictable and easy to understand.&lt;/p&gt;

&lt;p&gt;That's why simplicity should never be confused with a lack of effort. In many cases, it's the result of much more discipline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Think Beyond The Next Release
&lt;/h2&gt;

&lt;p&gt;It's tempting to evaluate features based on how exciting they are today. They make a roadmap look more impressive and provide something new to announce. The problem is that software doesn't stop evolving after the next release. Every decision becomes part of the foundation that future versions have to build upon.&lt;/p&gt;

&lt;p&gt;One mindset that has helped me is to imagine maintaining today's decisions a year from now. If a feature still feels worthwhile after considering future updates, bug fixes and ongoing maintenance, it's probably a good investment. If its long-term value is difficult to justify, adding it simply because it's possible is rarely the right answer.&lt;/p&gt;

&lt;p&gt;Thinking that way doesn't slow development down. It keeps the product moving in the right direction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;A product isn't defined only by what it includes. It's also defined by everything its creators intentionally leave out.&lt;/p&gt;

&lt;p&gt;Users rarely ask whether a feature was difficult to build, but they immediately notice when an application feels focused, coherent and easy to use. Those qualities don't appear by chance. They're usually the result of hundreds of small decisions, many of which involve saying "no" to ideas that seemed perfectly reasonable at the time.&lt;/p&gt;

&lt;p&gt;Sometimes the best feature isn't the next one you add.&lt;/p&gt;

&lt;p&gt;It's the one you decide not to build.&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>programming</category>
      <category>productivity</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Why Software Friction Matters</title>
      <dc:creator>Christian • ancer</dc:creator>
      <pubDate>Sat, 18 Jul 2026 16:00:00 +0000</pubDate>
      <link>https://dev.to/ancer/why-software-friction-matters-3iki</link>
      <guid>https://dev.to/ancer/why-software-friction-matters-3iki</guid>
      <description>&lt;p&gt;When we talk about improving software, the first idea is usually to add something new: another feature, another option, another way to customize the experience.&lt;/p&gt;

&lt;p&gt;But many times, the biggest improvements come from the opposite direction. Removing unnecessary steps, reducing decisions users should not have to make, and making common actions feel simpler.&lt;/p&gt;

&lt;p&gt;That is where software friction appears.&lt;/p&gt;

&lt;p&gt;Friction is everything that creates distance between the user and the result they want to achieve. It can be an extra step repeated hundreds of times, a confusing setting, a slow process, or a workflow that forces people to adapt to the tool instead of helping them complete their task.&lt;/p&gt;

&lt;p&gt;Individually, these problems usually look small. One extra click does not seem important. Waiting a few more seconds feels acceptable. Manually repeating an action once is not a big issue.&lt;/p&gt;

&lt;p&gt;But software is built around repetition, and small moments of friction become much more noticeable over time.&lt;/p&gt;

&lt;p&gt;Reducing friction is not only a design problem. It is also an engineering decision.&lt;/p&gt;

&lt;p&gt;A simple experience often depends on things users never directly see: how the application is structured, how workflows are designed, which defaults are selected, and which features are intentionally kept simple.&lt;/p&gt;

&lt;p&gt;The goal is not always to add more possibilities. Sometimes the better decision is making the existing workflow clearer, faster, and easier to understand.&lt;/p&gt;

&lt;p&gt;Good software does not just solve a problem. It removes everything unnecessary between the user and that solution.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;What is a small improvement in a tool that completely changed your experience using it?&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>software</category>
      <category>productivity</category>
      <category>softwareengineering</category>
      <category>ux</category>
    </item>
  </channel>
</rss>
