<?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: David</title>
    <description>The latest articles on DEV Community by David (@lilvoda).</description>
    <link>https://dev.to/lilvoda</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%2F4218709%2F263829cb-bd12-46bf-a2a2-4c8b4b74b453.png</url>
      <title>DEV Community: David</title>
      <link>https://dev.to/lilvoda</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/lilvoda"/>
    <language>en</language>
    <item>
      <title>Zooming a web editor without losing your place</title>
      <dc:creator>David</dc:creator>
      <pubDate>Thu, 05 Nov 2026 11:22:29 +0000</pubDate>
      <link>https://dev.to/lilvoda/zooming-a-web-editor-without-losing-your-place-2h06</link>
      <guid>https://dev.to/lilvoda/zooming-a-web-editor-without-losing-your-place-2h06</guid>
      <description>&lt;p&gt;Put your cursor over a small piece of text in an editor and zoom in. You should be able to keep looking at the same text.&lt;/p&gt;

&lt;p&gt;It's a small test, but it catches a surprising amount of unfinished zoom behavior. The percentage changes, the document gets bigger, and the detail you wanted to inspect slides somewhere else.&lt;/p&gt;

&lt;p&gt;I build &lt;a href="https://www.repceipt.com/" rel="noopener noreferrer"&gt;Repceipt&lt;/a&gt;, a receipt editor that runs in the browser. Receipts have plenty of small text packed into a narrow space. Zooming in on a price or a line of text needs to keep that detail within reach.&lt;/p&gt;

&lt;p&gt;The implementation uses DOM elements inside a scaled wrapper. The interesting part is how the scale interacts with coordinates and scrolling.&lt;/p&gt;

&lt;p&gt;There are two sets of coordinates to keep straight: positions within the document, and positions on screen. Here, screen coordinates mean CSS pixels relative to the browser viewport.&lt;/p&gt;

&lt;p&gt;A block at document position &lt;code&gt;x = 300&lt;/code&gt; stays at &lt;code&gt;x = 300&lt;/code&gt; when you zoom. At 50%, it appears 150 CSS pixels from the document's left edge. At 200%, it appears 600 pixels from that edge.&lt;/p&gt;

&lt;p&gt;Keeping those stored coordinates stable means zoom can change the view without rewriting every block.&lt;/p&gt;

&lt;p&gt;Our wrapper scales from its top-left corner. Around it, an untransformed frame gets an explicit width of &lt;code&gt;documentWidth * zoom&lt;/code&gt; and height of &lt;code&gt;documentHeight * zoom&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That outer frame matters. A CSS transform changes rendering while leaving normal flow layout intact. Transforms can affect overflow, but applying &lt;code&gt;scale()&lt;/code&gt; doesn't rewrite the element's layout dimensions. The &lt;a href="https://www.w3.org/TR/css-transforms-1/#transform-rendering" rel="noopener noreferrer"&gt;CSS transforms specification&lt;/a&gt; describes that distinction.&lt;/p&gt;

&lt;p&gt;The explicit frame gives centering and scrolling a box whose layout dimensions match the displayed document size.&lt;/p&gt;

&lt;p&gt;Once that's in place, zooming around the cursor has three steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Find the document point currently beneath the cursor.&lt;/li&gt;
&lt;li&gt;Apply the new zoom.&lt;/li&gt;
&lt;li&gt;Adjust scrolling to put that point back beneath the cursor.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For a document scaled uniformly from the top-left corner, the first calculation is simple:&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;documentX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cursorX&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;frameLeft&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;currentZoom&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Suppose the frame starts at screen position 100, the cursor is at 250, and zoom is 50%. The cursor is over document coordinate 300:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(250 - 100) / 0.5 = 300
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zoom to 100%, keeping the same frame origin, and that point would appear at screen position 400. Scrolling right by 150 brings it back to 250.&lt;/p&gt;

&lt;p&gt;In the real editor, the frame origin can change too. A centered document gets wider as you zoom, and its left edge moves. So we measure the frame again after the update.&lt;/p&gt;

&lt;p&gt;Here's the geometry from the wheel handler, with the event setup and zoom-limit calculation left out:&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;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;flushSync&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react-dom&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Inside the handler, after choosing nextZoom:&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;before&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getBoundingClientRect&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;documentX&lt;/span&gt; &lt;span class="o"&gt;=&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="nx"&gt;clientX&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;before&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="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;currentZoom&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;documentY&lt;/span&gt; &lt;span class="o"&gt;=&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="nx"&gt;clientY&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;before&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="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;currentZoom&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;flushSync&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setZoom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nextZoom&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;after&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getBoundingClientRect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;viewport&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollLeft&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt;
  &lt;span class="nx"&gt;after&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;documentX&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;nextZoom&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;event&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="nx"&gt;viewport&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollTop&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt;
  &lt;span class="nx"&gt;after&lt;/span&gt;&lt;span class="p"&gt;.&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;documentY&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;nextZoom&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;event&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The final two lines calculate where the original document point has landed, compare it with the cursor, and scroll by the difference.&lt;/p&gt;

&lt;p&gt;Both measurements use &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect" rel="noopener noreferrer"&gt;&lt;code&gt;getBoundingClientRect()&lt;/code&gt;&lt;/a&gt;, which gives viewport-relative positions and accounts for scrolling. That keeps them in the same coordinate system as &lt;code&gt;clientX&lt;/code&gt; and &lt;code&gt;clientY&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The order matters. Measuring immediately after requesting a React update can give you the old layout. We use &lt;a href="https://react.dev/reference/react-dom/flushSync" rel="noopener noreferrer"&gt;&lt;code&gt;flushSync&lt;/code&gt;&lt;/a&gt; at this boundary so the second measurement sees the updated DOM. It forces synchronous work and can affect performance, so it's a deliberate choice in this handler.&lt;/p&gt;

&lt;p&gt;A toolbar button needs a different anchor. The mouse is over the toolbar when you click it; anchoring there wouldn't help you inspect the document.&lt;/p&gt;

&lt;p&gt;Our zoom buttons use the center of the current selection when that center is visible. Otherwise, they use the center of the editing viewport. That lets you select a small label, press the plus button a few times, and keep the label in roughly the same place.&lt;/p&gt;

&lt;p&gt;The same coordinate calculation works for each input. What changes is the anchor:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Input&lt;/th&gt;
&lt;th&gt;Anchor&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Wheel zoom&lt;/td&gt;
&lt;td&gt;Cursor position&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Two-finger pinch&lt;/td&gt;
&lt;td&gt;Point initially beneath the fingers' midpoint&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Toolbar zoom&lt;/td&gt;
&lt;td&gt;Visible selection center, otherwise viewport center&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For a pinch, we save the initial distance between the fingers and the starting zoom. The new zoom is the starting zoom multiplied by the ratio of the current distance to the initial distance.&lt;/p&gt;

&lt;p&gt;We also track the midpoint as the fingers move. The document point saved at the start follows that moving midpoint. This lets a pinch zoom and pan together, which feels much closer to moving a piece of paper under your fingers.&lt;/p&gt;

&lt;p&gt;There are limits to how precisely an anchor can stay fixed. Scroll containers have boundaries. Zoom out until the document fits inside the viewport and the browser may have no remaining scroll range to compensate. At that point, the layout's centering rules take over.&lt;/p&gt;

&lt;p&gt;It's also worth checking that every edge remains reachable when zoomed in. Centering an oversized document inside a fixed-width flex row can leave content overflowing on the left where you can't scroll to it. Our row grows with the document, and auto margins center the frame when there's spare space.&lt;/p&gt;

&lt;p&gt;Zoom affects interaction distances too.&lt;/p&gt;

&lt;p&gt;Our editor snaps blocks to alignment guides within an eight-pixel screen distance. If that were eight document pixels, the visible snapping distance would shrink to two pixels at 25% zoom and grow to sixteen at 200%.&lt;/p&gt;

&lt;p&gt;To keep the visible distance consistent, the threshold passed to the document-coordinate calculations is:&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;snapThreshold&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;zoom&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At 25%, that's 32 document pixels. At 200%, it's four. Both cover eight pixels on screen.&lt;/p&gt;

&lt;p&gt;The same question comes up with drag handles, tap tolerance, and selection outlines: should this measurement grow with the document, or stay comfortable to use on screen?&lt;/p&gt;

&lt;p&gt;One of our browser tests checks zoom by recording the document point beneath the fingers, expressed as a fraction of the document's width and height. After pinching, those fractions must remain within one percentage point of their starting values.&lt;/p&gt;

&lt;p&gt;That checks something useful about the interaction. A zoom readout changing from 50% to 100% would pass even if the text disappeared off screen.&lt;/p&gt;

&lt;p&gt;For a quick manual check, put the cursor over the last digit of a price and zoom in a few times. Then select the price and try the toolbar buttons. You should be able to keep looking at the digit you started with, without needing another gesture to find it.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>react</category>
      <category>ux</category>
    </item>
    <item>
      <title>The hard part of undo is deciding what counts as a change</title>
      <dc:creator>David</dc:creator>
      <pubDate>Thu, 05 Nov 2026 09:47:52 +0000</pubDate>
      <link>https://dev.to/lilvoda/the-hard-part-of-undo-is-deciding-what-counts-as-a-change-52ch</link>
      <guid>https://dev.to/lilvoda/the-hard-part-of-undo-is-deciding-what-counts-as-a-change-52ch</guid>
      <description>&lt;p&gt;If one drag takes twelve presses of Undo to reverse, the history code probably recorded exactly what it was told to record.&lt;/p&gt;

&lt;p&gt;Twelve changes.&lt;/p&gt;

&lt;p&gt;The person using the editor remembers moving a block once.&lt;/p&gt;

&lt;p&gt;I build &lt;a href="https://www.repceipt.com/" rel="noopener noreferrer"&gt;Repceipt&lt;/a&gt;, a receipt editor that runs in the browser. You can move text around, resize elements, change fonts, and select several blocks at once. Undo has to make sense across all of those interactions.&lt;/p&gt;

&lt;p&gt;The stack operations are straightforward. Deciding when to put something on the stack takes more thought.&lt;/p&gt;

&lt;p&gt;A snapshot in the editor contains the document and its selection:&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="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Snapshot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Template&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;selectedId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BlockId&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="nl"&gt;selectedIds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BlockId&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;There are arrays for the past and future. An edit saves the current snapshot to the past and clears the future. Undo restores the last past snapshot and puts the current one at the front of the future. Redo moves it back.&lt;/p&gt;

&lt;p&gt;This is the familiar approach described in the &lt;a href="https://redux.js.org/usage/implementing-undo-history" rel="noopener noreferrer"&gt;Redux undo history guide&lt;/a&gt;. Our store uses Zustand, but the same structure works there.&lt;/p&gt;

&lt;p&gt;Notice what's missing from the snapshot: zoom, the active tool, and whether a panel is open.&lt;/p&gt;

&lt;p&gt;Suppose you change some text, zoom in to inspect it, then press Undo. You probably want the old text back while staying zoomed in. Recording every store update would make you undo the zoom first.&lt;/p&gt;

&lt;p&gt;Selection is a useful exception. Clicking a block doesn't create a history entry, but selection travels with document snapshots. Delete a selected block, then undo the deletion, and the block comes back selected. You can carry on working without finding it again.&lt;/p&gt;

&lt;p&gt;Dragging needs a similar distinction between what appears on screen and what becomes an edit.&lt;/p&gt;

&lt;p&gt;In Repceipt, the block follows the pointer through temporary transforms during the gesture. The saved document still contains the starting coordinates. When the drag ends, the temporary styles are cleared and the final movement is committed to the store.&lt;/p&gt;

&lt;p&gt;That produces one history entry, however many movement events arrived along the way. A group drag commits the selected blocks together, so Undo moves them all back together too.&lt;/p&gt;

&lt;p&gt;You could also update a temporary document throughout the gesture and record its starting state once. The important part is having a clear boundary around the action. You shouldn't have to guess whether a slow drag contains three edits or thirty.&lt;/p&gt;

&lt;p&gt;Cancellation needs a boundary as well. On a phone, a second finger can turn a block drag into a pinch to zoom the canvas. Our editor cancels the block drag, restores its temporary transforms, and leaves history alone.&lt;/p&gt;

&lt;p&gt;For an editor using pointer events directly, remember that a gesture can end with &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Element/pointercancel_event" rel="noopener noreferrer"&gt;&lt;code&gt;pointercancel&lt;/code&gt;&lt;/a&gt;, including when the browser takes over for scrolling or zooming. Decide how to restore the preview on that path, too.&lt;/p&gt;

&lt;p&gt;Typing and keyboard nudges are a different case. The document should update as you type, but undoing a label one character at a time gets tedious.&lt;/p&gt;

&lt;p&gt;The store groups consecutive updates when they have the same key and arrive less than 400 milliseconds apart. Here's the decision, stripped down:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;coalesce&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
  &lt;span class="nx"&gt;lastCommit&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="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
  &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;lastCommit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first update saves the starting snapshot. Matching updates keep changing the document without pushing more snapshots. Each update refreshes the timestamp, so the window measures the gap between updates, rather than the total length of the typing session.&lt;/p&gt;

&lt;p&gt;The key identifies what is changing. Editing a block's text has a different key from changing its size. Repeated arrow-key nudges use a key based on the selected block IDs.&lt;/p&gt;

&lt;p&gt;The number alone wouldn't be enough. Two unrelated edits can easily happen within 400 milliseconds. They still need separate undo steps.&lt;/p&gt;

&lt;p&gt;And that number is a product choice. Pause long enough while typing and you'll get another step. A text editor might want word boundaries or explicit editing sessions instead. For these short labels and repeated nudges, a small window keeps the implementation manageable. Drags already have start and end events, so they don't need the timer.&lt;/p&gt;

&lt;p&gt;Undo and Redo also reset the grouping marker. Otherwise, a quick edit after Undo could accidentally join the group you just left.&lt;/p&gt;

&lt;p&gt;There's another small detail that becomes obvious once you try this sequence:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Move a block.&lt;/li&gt;
&lt;li&gt;Undo the move.&lt;/li&gt;
&lt;li&gt;Click the block without moving it.&lt;/li&gt;
&lt;li&gt;Press Redo.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The move should still be available.&lt;/p&gt;

&lt;p&gt;A zero-distance drag exits before committing. Selecting the block doesn't commit either. Both decisions matter because a commit clears the future array. If a click records an unchanged document, it can silently throw away the redo history.&lt;/p&gt;

&lt;p&gt;The movement code also checks the final delta after rounding and clamping. A pointer can move while the block ends up in the same place. That's still no change to the document.&lt;/p&gt;

&lt;p&gt;Some real document updates should stay out of history, too.&lt;/p&gt;

&lt;p&gt;When fonts finish loading, the editor measures text so selection outlines fit the glyphs the browser actually drew. That can update stored geometry. It shouldn't make the receipt appear modified, and Undo shouldn't restore an inaccurate selection outline.&lt;/p&gt;

&lt;p&gt;That measurement has its own update path. It leaves history and the modified flag alone. There's a regression test for exactly that behavior: opening a template and measuring its text must leave the undo stack empty.&lt;/p&gt;

&lt;p&gt;Snapshots also depend on keeping old objects intact. Our edits create new document and block objects, leaving previous versions available to history. Mutating a shared block would change what an older snapshot points to. React's &lt;a href="https://react.dev/learn/updating-objects-in-state" rel="noopener noreferrer"&gt;guide to updating objects in state&lt;/a&gt; explains why treating those objects as read-only matters.&lt;/p&gt;

&lt;p&gt;We cap history at 80 snapshots. That bounds the number of retained versions; it isn't a guarantee about memory usage, especially if a document contains large assets.&lt;/p&gt;

&lt;p&gt;When checking undo behavior, these are the sequences I'd start with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Drag several blocks, then undo once. Every block should return.&lt;/li&gt;
&lt;li&gt;Undo, change selection or zoom, then redo. The edit should still be there.&lt;/li&gt;
&lt;li&gt;Undo, make a new document edit, then try Redo. The abandoned edit should be gone.&lt;/li&gt;
&lt;li&gt;Start dragging on a phone, then add a second finger to pinch. The block should stay at its original position.&lt;/li&gt;
&lt;li&gt;Open a document and wait for its fonts. Undo should remain unavailable until you edit something.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those sequences say more about how the editor feels than a test that only checks whether an array got shorter.&lt;/p&gt;

&lt;p&gt;For each new control, I now want an answer to one question before wiring up its change handler: what should one press of Undo reverse here?&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>ux</category>
    </item>
    <item>
      <title>I counted the fonts on 632 receipts. The most popular one is a barcode.</title>
      <dc:creator>David</dc:creator>
      <pubDate>Sat, 31 Oct 2026 09:51:40 +0000</pubDate>
      <link>https://dev.to/lilvoda/i-counted-the-fonts-on-632-receipts-the-most-popular-one-is-a-barcode-3lph</link>
      <guid>https://dev.to/lilvoda/i-counted-the-fonts-on-632-receipts-the-most-popular-one-is-a-barcode-3lph</guid>
      <description>&lt;p&gt;I build &lt;a href="https://www.repceipt.com/" rel="noopener noreferrer"&gt;Repceipt&lt;/a&gt;, a receipt editor that runs in the browser. Its template library is made by redrawing real receipts, one element at a time, and each redraw records which typefaces it uses and at what sizes. By August we had 632 of them, which meant we were sitting on a dataset I'd never seen anyone collect: what fonts receipts actually use.&lt;/p&gt;

&lt;p&gt;I went in assuming the answer was "one". Some bitmap face burned into every thermal printer on earth, end of story.&lt;/p&gt;

&lt;p&gt;It's 24.&lt;/p&gt;

&lt;p&gt;This post is the numbers, and then the parts that matter if you ever have to draw a receipt yourself: why they're monospaced, how to fake that in CSS when they aren't, what to do about the barcode, and the four silent bugs between "looks right in the browser" and "looks right in the exported PNG".&lt;/p&gt;

&lt;h2&gt;
  
  
  First, what this data is and isn't
&lt;/h2&gt;

&lt;p&gt;These are &lt;strong&gt;redrawn&lt;/strong&gt; receipts. Each one rebuilds a real document as closely as possible, so the fonts are the typefaces chosen to &lt;em&gt;reproduce&lt;/em&gt; receipts. Where the original came off a printer's built-in bitmap font, the redraw uses the nearest web font. So this tells you what receipt typography looks like and how much it varies. It is not a survey of printer firmware.&lt;/p&gt;

&lt;p&gt;We also only measured structure: typeface names, sizes, element types, canvas dimensions. No text contents. Real receipts carry real card digits and addresses, and none of that belongs in a blog post.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers
&lt;/h2&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;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Receipts measured&lt;/td&gt;
&lt;td&gt;632&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Distinct typeface families&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Distinct family combinations&lt;/td&gt;
&lt;td&gt;176&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Distinct type-size ranges (smallest to largest on one receipt)&lt;/td&gt;
&lt;td&gt;545&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And the most common families, by share of receipts they appear on:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Typeface&lt;/th&gt;
&lt;th&gt;Share of receipts&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Libre Barcode 39&lt;/td&gt;
&lt;td&gt;35.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Thermal Receipt1&lt;/td&gt;
&lt;td&gt;31.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Arvo&lt;/td&gt;
&lt;td&gt;23.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Thermal Receipt2&lt;/td&gt;
&lt;td&gt;16.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lekton&lt;/td&gt;
&lt;td&gt;14.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Open Sans&lt;/td&gt;
&lt;td&gt;11.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Roboto&lt;/td&gt;
&lt;td&gt;9.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B612 Mono&lt;/td&gt;
&lt;td&gt;7.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Number one is a font nobody has ever read a word of. Libre Barcode 39's glyphs are barcode bars, and in the source data barcodes are stored as text set in it, so a barcode wins a font ranking.&lt;/p&gt;

&lt;p&gt;Third place surprised me more. Arvo is a slab serif, and it's on nearly a quarter of all receipts. My best guess is that its chunky strokes are a decent stand-in for the slightly smeared output of a worn thermal print head.&lt;/p&gt;

&lt;p&gt;176 combinations across 632 receipts works out to a new pairing roughly every three and a half receipts. And there are almost as many distinct size ranges as there are receipts. They all look the same from a metre away and differ a lot up close.&lt;/p&gt;

&lt;p&gt;One confession: our own data has a typo in it. "Thermal Receipt2" and "ThermalReceipt2" (4.7%, just below the cut) are the same face spelled two ways, so 24 is the count of family &lt;em&gt;strings&lt;/em&gt;, and the real number of typefaces is slightly lower. I'd rather show you that than quietly merge it.&lt;/p&gt;

&lt;p&gt;Bonus fact while we were measuring: the median receipt is exactly three times as tall as it is wide.&lt;/p&gt;

&lt;h2&gt;
  
  
  Monospace is the layout engine
&lt;/h2&gt;

&lt;p&gt;A receipt is a two-column table. Item on the left, price on the right. The printer doesn't measure text, and with a fixed-pitch font it doesn't need to: every character is one column, so if every line is the same number of characters, the prices line up on their own.&lt;/p&gt;

&lt;p&gt;If you're generating text for an actual receipt printer, that's all your layout code is. Pad strings:&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;line&lt;/span&gt; &lt;span class="o"&gt;=&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="nx"&gt;right&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cols&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;32&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;left&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;cols&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;right&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;padEnd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cols&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;right&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="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;right&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;line&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;FLAT WHITE&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;3.80&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// "FLAT WHITE                  3.80"&lt;/span&gt;
&lt;span class="nf"&gt;line&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SOURDOUGH LOAF, SLICED, EXTRA LONG NAME&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;6.50&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// "SOURDOUGH LOAF, SLICED, EXT 6.50"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;slice&lt;/code&gt; matters. Without it, a long item name pushes its price onto the next line and the whole column falls apart, which is exactly what a real receipt does when a POS system gets a product name longer than anyone planned for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Doing it in CSS when the font isn't monospaced
&lt;/h2&gt;

&lt;p&gt;Here's the bit the data made clear: nearly half the receipts mix in a proportional face. Open Sans, Roboto and Raleway are all in the top dozen. Look at a real receipt and they're in the store name, the header and the "thanks for shopping" footer. The fixed-pitch constraint only applies to the money in the middle.&lt;/p&gt;

&lt;p&gt;On a screen you don't have to pad anything. Something like this gets you most of the way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"receipt"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;h1&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"store"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Corner Bakery&lt;span class="nt"&gt;&amp;lt;/h1&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"row"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;span&amp;gt;&lt;/span&gt;Flat white&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"amt"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;3.80&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"row"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;span&amp;gt;&lt;/span&gt;Croissant&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"amt"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;2.90&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"row"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;span&amp;gt;&lt;/span&gt;Sourdough loaf&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"amt"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;6.50&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"row total"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;span&amp;gt;&lt;/span&gt;Total&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"amt"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;13.20&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.receipt&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;32ch&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c"&gt;/* ch = the width of "0", so in a monospace font: 32 columns */&lt;/span&gt;
  &lt;span class="nl"&gt;font&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;14px&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.3&lt;/span&gt; &lt;span class="s2"&gt;'Roboto Mono'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;monospace&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.store&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;1em&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;font&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;700&lt;/span&gt; &lt;span class="m"&gt;20px&lt;/span&gt; &lt;span class="s2"&gt;'Arvo'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;serif&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;text-align&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;center&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.row&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;justify-content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;space-between&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;gap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1ch&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.amt&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;font-variant-numeric&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;tabular-nums&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;white-space&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;nowrap&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.total&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;border-top&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;dashed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;16px&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 things worth knowing. &lt;code&gt;tabular-nums&lt;/code&gt; only does anything if the font ships tabular figures (the &lt;code&gt;tnum&lt;/code&gt; OpenType feature), so check before you set your prices in some display face and expect the decimals to line up. And notice there are three sizes: store name, body, total. Receipts don't really have a type scale, they have those three tiers, and every design sets them a little differently (hence 545 distinct size ranges).&lt;/p&gt;

&lt;h2&gt;
  
  
  The barcode: font or SVG?
&lt;/h2&gt;

&lt;p&gt;Since a third of our receipts use a barcode font, it's tempting to just do the same:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"font: 48px 'Libre Barcode 39'"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;*A-1234*&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works, with two catches. Code 39 needs the asterisks: they're the start and stop characters, and the &lt;a href="https://graphicore.github.io/librebarcode/documentation/code39" rel="noopener noreferrer"&gt;Libre Barcode docs&lt;/a&gt; tell you to wrap your text in them. Leave them off and you get bars that look fine and won't scan. And plain Code 39 is uppercase letters, digits, space and &lt;code&gt;- . $ / + %&lt;/code&gt;. Anything else needs the Extended variant or a different symbology.&lt;/p&gt;

&lt;p&gt;In the editor we draw barcodes with &lt;a href="https://github.com/lindell/JsBarcode" rel="noopener noreferrer"&gt;JsBarcode&lt;/a&gt; into an SVG instead, which also gives you Code 128, EAN-13, UPC and friends:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;JsBarcode&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;jsbarcode&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nc"&gt;JsBarcode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;svgElement&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-1234&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;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CODE39&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;displayValue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;margin&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Four silent bugs between the browser and the PNG
&lt;/h2&gt;

&lt;p&gt;Exports are rendered on the server with &lt;code&gt;@napi-rs/canvas&lt;/code&gt;, and they have to match what the browser showed pixel for pixel. None of these four throw an error. Your PNG just quietly looks wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The name inside the font file isn't the name you use.&lt;/strong&gt; The two thermal fonts are referenced as "Thermal Receipt1" and "Thermal Receipt2". Ask the files what they're called:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;fc-scan &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s2"&gt;"%{family}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; ThermalReceipt1.ttf
DotLatin_D31
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Register the file without an alias, ask canvas for "Thermal Receipt1", and it falls back to a default font without a word. Pass the name you actually use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;GlobalFonts&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@napi-rs/canvas&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;GlobalFonts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;registerFromPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fonts/ThermalReceipt1.ttf&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;Thermal Receipt1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Some fonts name every weight as its own family.&lt;/strong&gt; This one's my favourite. The regular-weight Source Code Pro file from Fontsource:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;fc-scan &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s2"&gt;"%{family} / %{fullname}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; source-code-pro-latin-400-normal.woff2
Source Code Pro ExtraLight / Source Code Pro ExtraLight Regular
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;em&gt;regular&lt;/em&gt; file says its family is "Source Code Pro ExtraLight". Ask for "Source Code Pro" and nothing matches. Aliasing fixes this too, and the weights survive because each file still carries its weight metadata.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. &lt;code&gt;letter-spacing&lt;/code&gt; adds a gap after the last character as well.&lt;/strong&gt; In Chromium, &lt;code&gt;abcde&lt;/code&gt; in a 20px monospace font measures 60px. Add &lt;code&gt;letter-spacing: 10px&lt;/code&gt; and it measures 110px: five gaps, not four. If you measure text yourself, for a canvas renderer or a selection box, and count &lt;code&gt;length - 1&lt;/code&gt; gaps, every spaced line comes out one gap short and right-aligned text drifts. Count &lt;code&gt;length&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. An SVG and &lt;code&gt;drawImage&lt;/code&gt; disagree about stretching.&lt;/strong&gt; Give an &lt;code&gt;&amp;lt;svg&amp;gt;&lt;/code&gt; with a &lt;code&gt;viewBox&lt;/code&gt; a box of a different shape and the browser scales it to fit, centred, keeping its aspect ratio (&lt;code&gt;preserveAspectRatio&lt;/code&gt; defaults to &lt;code&gt;xMidYMid meet&lt;/code&gt;). &lt;code&gt;drawImage&lt;/code&gt; into the same box stretches it to fill. Draw it naively and the exported barcode comes out stretched to fill the box. The server has to do the browser's maths:&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;scale&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="nx"&gt;boxW&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;img&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="nx"&gt;boxH&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;img&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;img&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;scale&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;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;img&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="nx"&gt;boxW&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;)&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;y&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;boxH&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;)&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;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What I'd tell myself before starting
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Use monospace for the columns with money in them, and anything you like for the header and footer.&lt;/li&gt;
&lt;li&gt;For a printer, pad strings. For a screen, flexbox and &lt;code&gt;tabular-nums&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Check a font's real family name with &lt;code&gt;fc-scan&lt;/code&gt; before you register it on a server.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;letter-spacing&lt;/code&gt; gaps = number of characters.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The full tables (all twelve top typefaces, element counts and the height-to-width bands) are in the &lt;a href="https://www.repceipt.com/blog/receipt-typography-measured" rel="noopener noreferrer"&gt;write-up of our 632-receipt typography measurement&lt;/a&gt;, and the aggregate figures are free to reuse.&lt;/p&gt;

</description>
      <category>css</category>
      <category>typography</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
