<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Copy Paste Paid]]></title><description><![CDATA[Copy Paste Paid]]></description><link>https://copypastepaid.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Thu, 29 Oct 2026 03:28:44 GMT</lastBuildDate><atom:link href="https://copypastepaid.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Testing one-page print layouts with headless Chrome]]></title><description><![CDATA[I make printable forms for home-service contractors: invoices, estimates, work orders, that kind of thing. Every form has one rule. It has to fit on one US…]]></description><link>https://copypastepaid.hashnode.dev/testing-one-page-print-layouts-with-headless-chrome</link><guid isPermaLink="true">https://copypastepaid.hashnode.dev/testing-one-page-print-layouts-with-headless-chrome</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[Python]]></category><category><![CDATA[CSS]]></category><category><![CDATA[pdf]]></category><dc:creator><![CDATA[Copy Paste Paid]]></dc:creator><pubDate>Wed, 28 Oct 2026 14:16:28 GMT</pubDate><content:encoded><![CDATA[<p>I make printable forms for home-service contractors: invoices, estimates, work orders, that kind of thing. Every form has one rule. It has to fit on one US Letter page. If the last line items or the signature line spill onto page 2, a plumber prints it on site and the customer gets a form that's cut off.</p>
<p>I used to check this by opening the PDF and scrolling through it. With alot of forms across several kits that stopped working, so now the build script checks every page and fails if anything doesn't fit. It's plain HTML and CSS, Chrome in headless mode, and about 40 lines of Python.</p>
<h2>1. One fixed-height div per page</h2>
<p>Each form is a <code>.page</code> div with a fixed height. US Letter is 11in tall, minus 0.5in margins top and bottom, so 10in:</p>
<pre><code class="language-css">@page { size: letter; margin: 0.5in; }
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body { font: 10pt/1.3 Arial, sans-serif; }
.page { height: 10in; overflow: hidden; break-after: page; }
.page:last-child { break-after: auto; }
</code></pre>
<p><code>overflow: hidden</code> keeps a long page from pushing everything after it onto the wrong sheet. That's good for the PDF and bad for testing, because now nothing looks broken. The content just gets cut off at the bottom. Counting pages in the PDF won't catch it either. I had a test form with 60 line items and the PDF still came out at exactly 2 pages, with half the table missing.</p>
<h2>2. Measure the overflow in the browser</h2>
<p>The fix is to ask the browser how much content is hiding. For an element with <code>overflow: hidden</code>, <code>scrollHeight - clientHeight</code> is the number of pixels that didn't fit. 0 means it fits.</p>
<p>So the build writes a second copy of the HTML just for measuring. It adds this script, which writes the numbers into the page itself:</p>
<pre><code class="language-html">&lt;script&gt;
window.addEventListener('load', () =&gt; {
  const r = [];
  document.querySelectorAll('.page').forEach(p =&gt; {
    r.push(p.dataset.key + ':' + (p.scrollHeight - p.clientHeight));
  });
  const pre = document.createElement('pre');
  pre.id = 'ov';
  pre.textContent = 'OVERFLOW ' + r.join(' ');
  document.body.appendChild(pre);
});
&lt;/script&gt;
</code></pre>
<p>Then Chrome's <code>--dump-dom</code> flag prints the DOM after scripts run, and Python reads the numbers back out. No Puppeteer, no Node, just the Chrome binary.</p>
<p>One catch. <code>--dump-dom</code> lays the page out on screen, not on paper, so the <code>.page</code> divs would be as wide as the default window. Text wraps differently at a different width and the heights come out wrong. The probe copy pins the width to the printable width (8.5in minus two 0.5in margins):</p>
<pre><code class="language-css">@media screen { .page { width: 7.5in } }
</code></pre>
<h2>3. The build script</h2>
<pre><code class="language-python">import os, re, subprocess, sys

CHROME = ["google-chrome", "--headless=new", "--no-sandbox", "--disable-gpu"]
CSS = open("page.css").read()
PROBE = open("probe.html").read()  # the &lt;script&gt; from step 2

def write(path, body, extra_css="", script=""):
    with open(path, "w") as f:
        f.write(f"&lt;!doctype html&gt;&lt;html&gt;&lt;head&gt;&lt;meta charset=utf-8&gt;"
                f"&lt;style&gt;{CSS}{extra_css}&lt;/style&gt;&lt;/head&gt;&lt;body&gt;{body}{script}&lt;/body&gt;&lt;/html&gt;")

def url(path):
    return "file://" + os.path.abspath(path)

def check_and_print(body, name):
    # 1. measure: same HTML, screen width pinned to the printable width
    write(f"{name}-probe.html", body, "@media screen { .page { width: 7.5in } }", PROBE)
    dom = subprocess.run(CHROME + ["--virtual-time-budget=3000", "--dump-dom", url(f"{name}-probe.html")],
                         capture_output=True, text=True).stdout
    m = re.search(r'&lt;pre id="ov"&gt;OVERFLOW ([^&lt;]*)', dom)
    res = dict(x.split(":") for x in m.group(1).split()) if m else {}
    over = {k: int(v) for k, v in res.items() if int(v) &gt; 0}

    # 2. print the real PDF
    write(f"{name}.html", body)
    subprocess.run(CHROME + ["--no-pdf-header-footer", f"--print-to-pdf={name}.pdf", url(f"{name}.html")],
                   check=True, capture_output=True)
    return res, over

body = open("invoice-body.html").read()  # &lt;div class="page" data-key="invoice"&gt;...&lt;/div&gt; etc.
res, over = check_and_print(body, "invoice")
print("overflow px per page:", res)
if over:
    print("OVERFLOW:", over)
    sys.exit(1)
</code></pre>
<p>With 10 line items it prints <code>{'invoice': '0', 'terms': '0'}</code> and exits 0. With 60 it prints <code>OVERFLOW: {'invoice': 391}</code> and exits 1, so the build stops before a broken PDF goes anywhere.</p>
<h2>Things that bit me</h2>
<ul>
<li><code>--virtual-time-budget</code> matters. Without it Chrome can dump the DOM before web fonts finish loading. Then you measure with the fallback font, and the real font is a little wider and pushes text down a line.</li>
<li><code>--no-pdf-header-footer</code> turns off the date, title and file path Chrome otherwise prints in the margins. Older Chrome versions called it <code>--print-to-pdf-no-header</code>.</li>
<li>Add <code>-webkit-print-color-adjust: exact; print-color-adjust: exact;</code> to <code>body</code> if you have colored header bars or table headers. Otherwise Chrome may drop the backgrounds when printing.</li>
<li>Use <code>break-inside: avoid</code> on blocks that shouldn't split, like a totals table. On a fixed-height page it mostly matters for multi-column checklists.</li>
<li>Check <code>m</code> before trusting the result. If the probe never ran (a script error, a bad path), the script above gets an empty dict and reports nothing. Failing when a page key is missing from <code>res</code> is an easy fix.</li>
</ul>
<p>The same one-page layout is behind my free <a href="https://copypastepaid.com/invoice-templates/">invoice templates</a> for contractors, if you want to see how it comes out printed. My site, no signup.</p>
]]></content:encoded></item><item><title><![CDATA[Building a Google review link and QR code generator with no server]]></title><description><![CDATA[I make free tools for plumbers, HVAC techs, cleaners and other home-service contractors at copypastepaid.com. One of them takes a Google Place ID and gives…]]></description><link>https://copypastepaid.hashnode.dev/building-a-google-review-link-and-qr-code-generator-with-no-server</link><guid isPermaLink="true">https://copypastepaid.hashnode.dev/building-a-google-review-link-and-qr-code-generator-with-no-server</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[webdev]]></category><category><![CDATA[SVG]]></category><category><![CDATA[Qrcode]]></category><category><![CDATA[smallbusiness]]></category><dc:creator><![CDATA[Copy Paste Paid]]></dc:creator><pubDate>Sat, 24 Oct 2026 14:06:16 GMT</pubDate><content:encoded><![CDATA[<p>I make free tools for plumbers, HVAC techs, cleaners and other home-service contractors at <a href="https://copypastepaid.com/free-tools/">copypastepaid.com</a>. One of them takes a Google Place ID and gives back a review link, a QR code, a printable counter card and a few review request texts with the link filled in. It all runs in the browser. No backend, no API key, no account.</p>
<p>Here's how the pieces work.</p>
<h2>1. The link itself</h2>
<p>Google has a URL that opens the "write a review" box for a business directly:</p>
<pre><code>https://search.google.com/local/writereview?placeid=&lt;PLACE_ID&gt;
</code></pre>
<p>That's the whole trick. The hard part is getting a clean Place ID out of whatever people paste. Contractors don't know what a Place ID is untill you show them, so they paste all sorts of things: the bare ID, a Maps URL with <code>query_place_id=</code> in it, the <code>g.page/r/.../review</code> link from their Business Profile, or the ID with "Place ID:" still stuck on the front.</p>
<p>So the parser accepts all of those:</p>
<pre><code class="language-js">var WR = 'https://search.google.com/local/writereview?placeid=';

function parse(raw) {
  var s = String(raw || '').trim().replace(/^[\s"'&lt;(]+|[\s"'&gt;)]+$/g, '');
  if (!s) return { ok: false, empty: true };
  var d = s; try { d = decodeURIComponent(s); } catch (e) {}

  // Business Profile short link: already a review link, use as-is
  var m = d.match(/^(?:https?:\/\/)?(?:www\.)?g\.page\/r\/([A-Za-z0-9_-]+)(?:\/review)?\/?(?:[?#].*)?$/i);
  if (m) return { ok: true, kind: 'gpage', link: 'https://g.page/r/' + m[1] + '/review' };

  var id;
  m = d.match(/(?:query_place_id|place_id|placeid)\s*[=:]\s*([A-Za-z0-9_-]+)/i);
  if (m) id = m[1];
  else if (/^(?:https?:\/\/|www\.)|^[a-z0-9-]+(?:\.[a-z0-9-]+)+\//i.test(d)) return { ok: false, msg: 'nolink' };
  else {
    d = d.replace(/^place\s*id\s*[:=]?\s*/i, '');
    if (/\s/.test(d)) {
      var t = d.match(/[A-Za-z0-9_-]{20,}/g);
      if (t &amp;&amp; t.length === 1) id = t[0]; else return { ok: false, msg: 'chars' };
    } else id = d;
  }
  if (!/^[A-Za-z0-9_-]+$/.test(id)) return { ok: false, msg: 'chars' };
  if (id.length &lt; 20) return { ok: false, msg: 'short' };
  return { ok: true, kind: 'id', id: id, odd: !/^ChIJ/.test(id), link: WR + id };
}
</code></pre>
<p>A few things I learned writing this:</p>
<ul>
<li>Run <code>decodeURIComponent</code> before matching. Links copied out of some apps come URL-encoded and the <code>=</code> turns into <code>%3D</code>.</li>
<li>If it's a URL with no Place ID in it, say so. Don't try to guess. A plain Maps share link doesn't carry the ID and guessing gives you a link to the wrong business.</li>
<li>Most business Place IDs start with <code>ChIJ</code>. I don't reject ones that don't, I just show a warning telling them to test the link first.</li>
<li>Every error message tells them what to paste instead. "Invalid input" helps nobody.</li>
</ul>
<h2>2. The QR code as an SVG path</h2>
<p>For the QR math I use Kazuhiko Arase's <a href="https://github.com/kazuhikoarase/qrcode-generator">qrcode-generator</a> (MIT, one file, no dependencies). I only use it to get the grid of dark and light modules, then draw it myself:</p>
<pre><code class="language-js">function qrMatrix(text) {
  var q = qrcode(0, 'M');      // 0 = pick the smallest version that fits, M = ~15% error correction
  q.addData(text, 'Byte');
  q.make();
  var n = q.getModuleCount(), rows = [];
  for (var r = 0; r &lt; n; r++) {
    var row = [];
    for (var c = 0; c &lt; n; c++) row.push(q.isDark(r, c));
    rows.push(row);
  }
  return rows;
}
</code></pre>
<p>The SVG is one <code>&lt;path&gt;</code> with a 1x1 square per dark module, plus a 4-module white border (the QR spec asks for that blank margin and some readers fail without it):</p>
<pre><code class="language-js">function qrSVG(text) {
  var M = qrMatrix(text), n = M.length, pad = 4, W = n + pad * 2, d = '';
  for (var r = 0; r &lt; n; r++)
    for (var c = 0; c &lt; n; c++)
      if (M[r][c]) d += 'M' + (c + pad) + ',' + (r + pad) + 'h1v1h-1z';
  return '&lt;svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + W + ' ' + W + '"' +
    ' shape-rendering="crispEdges"&gt;&lt;rect width="' + W + '" height="' + W + '" fill="#fff"/&gt;' +
    '&lt;path d="' + d + '" fill="#000"/&gt;&lt;/svg&gt;';
}
</code></pre>
<p>Two details matter here. The viewBox is in module units, so the code scales to any size with no math. And <code>shape-rendering="crispEdges"</code> stops the browser from anti-aliasing the edges between squares. Without it you can get faint lines between modules, which look bad on a print.</p>
<p>The SVG download is just that string in a Blob. SVG is the one to use for truck magnets and yard signs because it stays sharp at any size.</p>
<h2>3. PNG from a canvas</h2>
<p>Print shops and text messages want a PNG, so the same matrix gets drawn to a canvas at roughly 1200px wide, with whole-pixel modules so nothing gets blurry:</p>
<pre><code class="language-js">var s = Math.max(8, Math.floor(1200 / (n + pad * 2)));  // pixels per module
var W = (n + pad * 2) * s;
cv.width = W; cv.height = W;
x.fillStyle = '#fff'; x.fillRect(0, 0, W, W);
x.fillStyle = '#000';
for (var r = 0; r &lt; n; r++)
  for (var c = 0; c &lt; n; c++)
    if (M[r][c]) x.fillRect((c + pad) * s, (r + pad) * s, s, s);
cv.toBlob(function (b) { download(b, 'google-review-qr.png'); }, 'image/png');
</code></pre>
<p><code>download()</code> is the usual object URL plus a temporary <code>&lt;a download&gt;</code>. Revoke the URL a few seconds later, not right away, or some browsers cancel the download.</p>
<h2>4. Printable counter cards with print CSS</h2>
<p>The card is regular HTML (business name, the QR SVG, one line of text). When they hit Print, I copy 1 or 4 cards into a <code>#print-sheet</code> div and call <code>window.print()</code>. The print stylesheet hides everything else:</p>
<pre><code class="language-css">@media print {
  @page { margin: .4in }
  body &gt; *:not(#print-sheet) { display: none !important }
  #print-sheet { display: block !important }
  #print-sheet .rcard { break-inside: avoid; page-break-inside: avoid }
  #print-sheet.one .rc-qr svg { width: 3.1in }
  #print-sheet.four { display: grid !important; grid-template-columns: 1fr 1fr; gap: .25in }
  #print-sheet.four .rc-qr svg { width: 2.25in }
}
</code></pre>
<p>Sizing the QR in inches is the reason this works on paper. A 2.25in code scans fine from a counter. Anything under about 1in gets iffy on older phones.</p>
<h2>5. Remembering things with localStorage</h2>
<p>The Place ID, business name and card text get saved to localStorage on every input, so if they come back next week it's all still filled in. Nothing gets sent anywhere. Wrap the calls in try/catch because Safari private mode and some locked-down browsers throw on <code>setItem</code>:</p>
<pre><code class="language-js">function put(k, v) { try { localStorage.setItem(k, v); } catch (e) {} }
</code></pre>
<h2>What I'd tell anyone building something like this</h2>
<p>Test the generated link on a real phone before printing anything. The link format has worked the whole time I've used it, but Google owns that URL and could change it.</p>
<p>If you want to try it, the tool is free at <a href="https://copypastepaid.com/review-link-generator/">copypastepaid.com/review-link-generator</a>. No signup. View source works if you want the full thing.</p>
]]></content:encoded></item><item><title><![CDATA[Building a free invoice maker with no backend: integer cents, localStorage, and print CSS]]></title><description><![CDATA[I make free paperwork tools for plumbers, HVAC techs, electricians and other home-service contractors at copypastepaid.com. The invoice maker runs entirely…]]></description><link>https://copypastepaid.hashnode.dev/building-a-free-invoice-maker-with-no-backend-integer-cents-localstorage-and-print-css</link><guid isPermaLink="true">https://copypastepaid.hashnode.dev/building-a-free-invoice-maker-with-no-backend-integer-cents-localstorage-and-print-css</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[webdev]]></category><category><![CDATA[Beginner Developers]]></category><category><![CDATA[frontend]]></category><dc:creator><![CDATA[Copy Paste Paid]]></dc:creator><pubDate>Tue, 20 Oct 2026 14:11:34 GMT</pubDate><content:encoded><![CDATA[<p>I make free paperwork tools for plumbers, HVAC techs, electricians and other home-service contractors at <a href="https://copypastepaid.com/free-tools/">copypastepaid.com</a>. The invoice maker runs entirely in the browser. No server, no account, no database, no PDF library. It's one HTML file with inline CSS and about 34 KB of plain JavaScript. No framework.</p>
<p>Four decisions made it work. Here they are with the code.</p>
<h2>1. Do all money math in integer cents</h2>
<p>Say a contractor bills 1.5 hours of labor at $19.95. The right line total is $29.925, which rounds to $29.93. In JavaScript:</p>
<pre><code class="language-js">1.5 * 19.95 * 100              // 2992.4999999999995
Math.round(1.5 * 19.95 * 100)  // 2992  -&gt; $29.92, one cent short
</code></pre>
<p>One cent sounds like nothing. Then the customer adds up the lines on a calculator and gets a different total than your invoice. So every amount gets turned into an integer number of cents as soon as it's read, and it stays that way until it's printed.</p>
<p>The fix for the rounding error itself is to trim the float noise before rounding. <code>toPrecision(12)</code> keeps 12 significant digits, which drops the trailing <code>...4999999</code> and keeps any real fraction:</p>
<pre><code class="language-js">function rc(x) { return Math.round(Number(x.toPrecision(12))); }

rc(1.5 * 19.95 * 100)   // 2993 -&gt; $29.93
rc(0.5 * 64.35 * 100)   // 3218 (Math.round alone gives 3217)
</code></pre>
<p>Line totals, discounts, tax and deposits all go through <code>rc()</code>:</p>
<pre><code class="language-js">function lineCents(item) {
  const price = toNumber(item.price);
  if (price === null) return 0;
  const qty = toNumber(item.qty) ?? 1;   // blank qty = flat-price line, like a trip charge
  return rc(qty * price * 100);
}

function totals(items, taxRate, discountPct, paid) {
  const sub = items.reduce((s, it) =&gt; s + lineCents(it), 0);
  const disc = rc(sub * Math.min(discountPct, 100) / 100);
  const after = sub - disc;
  const tax = after &gt; 0 ? rc(after * taxRate / 100) : 0;
  const total = after + tax;
  const paidC = Math.min(rc(paid * 100), Math.max(total, 0));
  return { sub, disc, tax, total, balance: total - paidC };
}
</code></pre>
<p>Formatting only happens at the very end, with integer division, so a float never reaches the screen:</p>
<pre><code class="language-js">function money(c) {
  const neg = c &lt; 0; c = Math.abs(c);
  const dollars = Math.floor(c / 100), cents = c % 100;
  return (neg ? '-' : '') + '$' +
    String(dollars).replace(/\B(?=(\d{3})+(?!\d))/g, ',') +
    '.' + String(cents).padStart(2, '0');
}
</code></pre>
<p>Tax gets figured once on the discounted subtotal, never per line. If you round tax per line and add it up you can end up a cent or two off the rounded total, and customers notice that.</p>
<p>To test it I recompute every sample invoice's total from its line items, separately, and compare it to what the page shows.</p>
<h2>2. localStorage as the database, with keys shared across tools</h2>
<p>The site has a few tools: an estimate maker, an invoice maker, a change order maker and a payment reminder maker. A contractor should only have to type their business name, phone and license number once.</p>
<p>So there are two kinds of keys:</p>
<ul>
<li>Shared keys for the business: <code>cpp-business</code>, <code>cpp-phone</code>, <code>cpp-license</code>, and so on. Every tool reads them on load and writes them on change.</li>
<li>One key per document: <code>cpp-estimate-v1</code>, <code>cpp-invoice-v1</code>, <code>cpp-change-order-v1</code>. Each one holds that tool's whole state as JSON.</li>
</ul>
<p>Wrap storage access. Safari private mode and some locked-down browsers throw:</p>
<pre><code class="language-js">function get(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }
function put(k, v) { try { localStorage.setItem(k, v); return true; } catch (e) { return false; } }
</code></pre>
<p>Since every tool can read the other tools' keys, hand-offs are easy. The invoice maker has a "Start from my last estimate" button that reads <code>cpp-estimate-v1</code> and copies over the customer, line items, discount, tax and deposit. The payment reminder maker has "Start from my last invoice". No sync code and no API.</p>
<p>Invoice numbers work the same way. <code>cpp-inv-last</code> holds the last number printed, and the next invoice suggests that number plus one, keeping the prefix and zero padding:</p>
<pre><code class="language-js">function bump(s) {
  const m = /^(.*?)(\d+)(\D*)$/.exec(String(s || '').trim());
  if (!m) return '';
  let n = String(Number(m[2]) + 1);
  while (n.length &lt; m[2].length) n = '0' + n;
  return m[1] + n + m[3];
}
bump('INV-1001')  // 'INV-1002'
bump('2026-009')  // '2026-010'
</code></pre>
<p>Version the document keys (<code>-v1</code>). When the shape changes you can read the old key, migrate it and write a new one, and nobody loses a saved invoice.</p>
<p>One thing I got wrong at first. The trade pages (plumbing, HVAC, electrical and so on) open with a sample job filled in. If a sample saved its customer name and job title into the shared keys, every other tool would greet the visitor with a made-up customer. Now a page that's still showing its untouched sample skips the hand-off writes, and the first real edit clears the sample flag.</p>
<h2>3. Print CSS instead of a PDF library</h2>
<p>The "Save as PDF" button just calls <code>window.print()</code>. Every desktop and phone browser already has "Save as PDF" in the print dialog, so there's nothing to bundle.</p>
<p>The print stylesheet hides everything except the invoice and sets the page size:</p>
<pre><code class="language-css">@media print {
  @page { size: letter; margin: .5in; }
  body { background: #fff; }
  body &gt; *:not(main) { display: none !important; }
  main &gt; *:not(#inv-wrap) { display: none !important; }
  #inv-wrap &gt; *:not(.est-docwrap) { display: none !important; }
  main.wrap { max-width: none; padding: 0; margin: 0; }
  .est-docwrap { overflow: visible; }
}
</code></pre>
<p>Two things to check:</p>
<ul>
<li>Make sure a full invoice fits on one page. I print each sample to PDF in headless Chrome and count pages in the test suite.</li>
<li><code>overflow-x: auto</code> on a wrapper is handy on a phone screen but it clips the document when printing. Reset it to <code>visible</code> in print.</li>
</ul>
<p>There's also a "Download as HTML file" button. It builds a standalone HTML document with the invoice markup and its CSS inlined, so a contractor can email or archive the exact invoice without any app:</p>
<pre><code class="language-js">const doc = '&lt;!doctype html&gt;&lt;html lang="en"&gt;&lt;head&gt;&lt;meta charset="utf-8"&gt;' +
  '&lt;title&gt;' + esc(title) + '&lt;/title&gt;&lt;style&gt;' + DOC_CSS + '&lt;/style&gt;&lt;/head&gt;' +
  '&lt;body&gt;&lt;div id="inv-doc"&gt;' + invoiceHTML() + '&lt;/div&gt;&lt;/body&gt;&lt;/html&gt;';
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([doc], { type: 'text/html' }));
a.download = 'invoice-' + slug(number) + '.html';
a.click();
</code></pre>
<h2>4. Logos stay local too</h2>
<p>Contractors want their logo on the invoice. Uploading it would mean a server. So the file input reads the image, scales it down on a canvas (600 x 240 max) and stores the data URL in localStorage. Shrink it first. localStorage is usually capped around 5 MB per origin and a phone photo can be bigger than that.</p>
<h2>What this buys you</h2>
<ul>
<li>No hosting cost beyond static files. The whole site is static HTML on Netlify.</li>
<li>No privacy headaches about customer data, because none of it leaves the browser.</li>
<li>Once the page has loaded it works on a phone in a truck with a bad signal.</li>
</ul>
<p>The downside is the data lives in one browser. If someone clears site data their saved invoice is gone. The page says so, and the HTML download is the backup. Honestly that's a trade I'm realy fine with for a free tool.</p>
<p>If you want to see it working, the <a href="https://copypastepaid.com/invoice-maker/">invoice maker</a> is free with no signup, and there are versions with a sample job filled in for six trades at <a href="https://copypastepaid.com/invoice-templates/">/invoice-templates/</a>. View source is the documentation.</p>
<p>I'd like to hear how other people handle money rounding in front-end tools, especially sales tax rounding rules that differ by state.</p>
]]></content:encoded></item></channel></rss>