# Build guide: small local tools for people without a development setup Complete text, all sections. Source: https://ai-build-guide.flomotlik.me/guide/ · Index: https://ai-build-guide.flomotlik.me/llms.txt The audience is someone with a browser and a chat window — no terminal, no editor, no package manager, no admin rights. Every rule below follows from that. --- ## 1. What to deliver Read when: Always. Read this before writing any code — it constrains every other section. ### Who you are building for Someone doing this alongside their actual job — an administrator, an organiser, a volunteer. No development environment, no terminal, no code editor, no package manager, no admin rights on the laptop. They have a browser and a chat window with you. Their time budget is one evening. Anything that requires them to install software, run a command, or start a local server has failed before it starts. This is not a preference — it is the constraint that decides whether the tool gets used. ### The deliverable **A folder the user saves. `index.html` is what they double-click.** How many files that folder holds is your choice. What matters is that it runs from `file://` without anything being installed or started. | Rule | Why | |---|---| | `index.html` runs by double-click | No server, no `npx`, no terminal | | No build step, no bundler, no npm | They cannot run any of it | | **No ` ``` Scripts run in document order, so a global defined in `daten.js` is available in `app.js`. Verified working from a double-clicked file, subfolders included. **Keep the folder flat when you split.** Nested directories work technically, but every extra path is another chance for the user to save a file in the wrong place. `index.html` + two or three siblings is the sweet spot. **Say how to save it.** When you hand over more than one file *in a chat*, name each file explicitly and say they all go in the same folder. That is the only part of multi-file delivery that is genuinely harder for the user — and it does not apply when you write the folder yourself. ### Data belongs in a .js file, not a .json file `fetch('daten.json')` fails from `file://` — Chrome reports *URL scheme "file" is not supported*. Do not use it, and do not work around it with a local server. Instead, write the data as JavaScript that assigns a global: ```js // daten.js window.DATEN = { gemeinden: [ { name: 'Oberndorf', einwohner: 5700 }, { name: 'Herzogenburg', einwohner: 8300 } ] }; ``` Same content, loads with a plain ` ``` The `body` rules are not optional: the design system deliberately styles no HTML tags, so without them the page renders as unstyled serif text and looks broken. See *Making it look right*. ### How to scope the first version Build the smallest thing that actually answers their question, then stop and let them use it. **Smallest is measured against the question, not against a screen count.** Test every control you are about to add: > Can they answer the question they asked me without this element? > If yes, leave it out. If no, it belongs in the first version. A year selector in a tool for comparing years is not feature creep — without it the tool does not answer the question. A settings panel nobody asked for is. The difference is whether the element follows from the request or from your own enthusiasm. - **No login, no onboarding, no accounts, no user management.** These never follow from the question. - **No second job.** A tool that reads budgets does not also send newsletters. One tool, one question. - **Real data from the start** — ask for an actual file or three real rows. - **If the task happens only once**, say so: a chat answer is the better tool and building anything is waste. Then ship it before adding anything else. What they still miss after using it three times is a real feature; what occurred to you while building usually is not. ### When this stops being enough Only when the user needs a public address, several linked pages, or other people contributing. That is a different shape of project — see *Going further*. --- ## 2. How you are delivering Read when: Always, right after 'What to deliver'. It decides how many files you produce and what you may leave behind. ### Two modes, different handover cost The runtime constraints never change: the user double-clicks `index.html`, has no build step, no server, no ES modules. What changes is how the files reach them. **Chat mode.** You emit text; the user copies or downloads it and saves it themselves. Every additional file is a manual step they can get wrong — a typo in a filename, a file saved to Downloads instead of the folder, a `.txt` extension added silently. **File-access mode.** You write into a folder directly. Tools of this kind — agentic coding assistants, workspace assistants, project workspaces with file output, and equivalents from other vendors — remove the handover entirely. You usually know which one you are in: if you have a tool that writes files, you are in file-access mode. If unsure, ask, or assume chat mode, which is the stricter of the two. | | Chat mode | File-access mode | |---|---|---| | Number of files | Prefer one; a few flat siblings at most | Split whenever it helps | | Naming | Say every filename and where it goes | Just write them | | Changing something | Re-emit the whole file | Edit in place | | Sample data | Paste it inline | Write a real `daten.js` | | Verification | The user reports back | Open it yourself and look | The preference for a single file in *What to deliver* is a **chat-mode** argument. It does not apply when you write the folder yourself. ### The trap in file-access mode You may have a terminal. **The user does not.** Nothing you can run is available to them afterwards. So, however capable your environment is: - **Do not add a build step.** No bundler, no transpiler, no CSS framework that must be compiled. A tool that needs `npm run build` before it can be changed is dead the moment you stop working on it. - **Do not add a dependency manifest** — no `package.json`, no `node_modules` — unless the user has asked for a real project and understands what it implies. See *Going further*. - **Do not leave generated output as the only source.** If a file is minified or compiled, the user cannot read or fix it. - **Do not rely on a local server** to make it work. It still has to open by double-click. The test: *if the user opened this folder in six months with nothing but a browser and a text editor, could they still use it and change one label?* If not, simplify. ### What file-access mode lets you do better - **Split for readability.** `index.html`, `app.js`, `daten.js`, `stil.css` — no handover penalty. Keep it flat anyway; the user still has to find things. - **Write real sample data.** A `daten.js` with twenty invented but realistic rows beats asking the user to paste their real list. See *Data protection*. - **Iterate in small steps.** Change one thing, check it, then the next. You do not have to re-emit a whole file to fix one line. - **Actually open the tool and check it.** You produced it; verify it does what it claims before saying it is finished. Load it, feed it the sample data, confirm the numbers. - **Leave a short `LIESMICH.txt` / `README.txt`** in the folder — see *Documenting the tool* for what goes in it. In chat mode it is an optional extra; here it costs nothing, so write it. - **Leave a rules file** (`CLAUDE.md` or `AGENTS.md`) if the tool will be worked on again, so the next session does not have to be re-briefed. Write it yourself — the user should never have to compose one. It is documentation for the next assistant, not for the user; the two are separate files. ### Reading files is not the same as the tool fetching them In file-access mode you can read the user's real CSV to understand its shape. The tool you build still cannot: `fetch()` on a local file is blocked at runtime. So use your access to *learn the structure* — column names, separator, encoding, a few real values — and then build the tool to take the file through a file picker or drag-and-drop, as described in *Reading files*. Do not bake the user's data into the tool unless they asked for exactly that. --- ## 3. Reading files Read when: The tool takes a document, spreadsheet, export or image from the user. Everything happens on the device. The file is never uploaded — there is no server that could receive it. Say this in the UI; for this audience it is the deciding feature, not a detail. That is true of the **finished tool**. It is not true of you while you are building it. Before you read a file the user points you at, run the precheck in *Data protection* — filename and header row first, flag the personal-data columns, and stop there if any turn up. Once a file is in the conversation it cannot be taken back out. ### Always offer both ways in A file picker alone is a usability bug: people drag files. A drop zone alone is worse: it is invisible on touch devices. ```html

Datei hierher ziehen

``` ```html ``` `fm-dropzone` and `is-dragover` come from the design system and carry the dashed border and the highlight state. ### Getting the content out ```js async function handleFiles(files) { for (const file of files) { if (/\.(csv|txt|json)$/i.test(file.name)) { const text = await file.text(); // ... } else { const bytes = await file.arrayBuffer(); // PDF, XLSX, images // ... } } } ``` Note: `async`/`await` and arrow functions are fine — they are language features, not modules. Only `import` is off limits. ### Per format **CSV.** Do not write your own splitter. Quoted fields, embedded semicolons and newlines inside cells will break it, and the user will not notice until the numbers are wrong. Load a parser from CDN as a global. Also: files from Austrian and German offices are usually semicolon-separated, Windows-1252 encoded, and use a comma as the decimal mark. Detect the separator, and offer an encoding switch if the first read shows mojibake. **XLSX.** Needs a library. If the user controls the file, the cheaper answer is "save it as CSV in Excel first" — say so instead of pulling in a heavy dependency. **PDF with a text layer.** Use **PDF.js 3.11.174** — the last release that ships a classic, non-module build. Verified working from a double-clicked file, with and without the CDN worker: ```html ``` ```js // Sets a global `pdfjsLib`. The worker is optional — without it PDF.js // parses on the main thread, which is fine for a few hundred pages. pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js'; const doc = await pdfjsLib.getDocument({ data: await file.arrayBuffer() }).promise; for (let n = 1; n <= doc.numPages; n++) { const items = (await (await doc.getPage(n)).getTextContent()).items; // items[i].str is the text, items[i].transform[4]/[5] are x and y } ``` Do **not** take PDF.js 4.x or 5.x: those ship only `.mjs` ES-module builds, which are blocked on `file://`. Pin 3.11.174. For tables, plain concatenated text is not enough — use the positions. `getTextContent()` gives each fragment an x and a y; group fragments with a similar y into a row, then split a row into cells on x-gaps. Two tolerances control this and they differ per document type, so make them easy to adjust and show the user the recognised rows before interpreting them. Expect this to be the most expensive part of the tool. Say so before committing to it. **Scanned PDF.** There is no text, only images. This needs OCR, and the error rate becomes its own problem. Say this rather than producing silently wrong output. **Images.** `URL.createObjectURL(file)` for display, or draw into a canvas for processing. Call `URL.revokeObjectURL()` afterwards. ### Show what was read before you interpret it First version: read the file and display what was recognised — row count, the first rows, detected columns. No analysis yet. This is not a debugging step you can skip. It is the only point where the user can catch "it read the wrong column" before that error is buried inside a result they will quote in a council meeting. Always state the row count and make the tool refuse loudly on a file it does not recognise, rather than producing an empty table. --- ## 4. Saving results Read when: The tool produces something the user keeps: a table, a report, an image, an edited file. ### Baseline: download (always implement this) Works in every browser, needs no permission, works from `file://`. ```js function download(content, filename, type) { const blob = new Blob([content], { type: type || 'text/csv;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); } ``` For CSV opened in Excel, prepend a BOM or the separator hint, otherwise umlauts break and columns collapse into one: ```js download('' + csvText, 'auswertung.csv'); ``` For most tools this is the whole story. Do not add complexity on top of it unless the user edits the same file repeatedly. ### Enhancement: write back into the same file Chrome and Edge can open a file, change it, and save it back to the same location — no growing pile of `auswertung (3).csv` in Downloads. Available from `file://` too, since a local file counts as a secure context. ```js let handle = null; async function openFile() { const picked = await window.showOpenFilePicker({ types: [{ description: 'CSV', accept: { 'text/csv': ['.csv'] } }], }); handle = picked[0]; const file = await handle.getFile(); return file.text(); } async function saveBack(text) { if (!handle) { handle = await window.showSaveFilePicker({ suggestedName: 'auswertung.csv' }); } const stream = await handle.createWritable(); await stream.write(text); await stream.close(); } ``` A whole folder works too, for "process every PDF in this directory": ```js const dir = await window.showDirectoryPicker({ mode: 'readwrite' }); for await (const [name, h] of dir.entries()) { if (h.kind === 'file' && name.endsWith('.pdf')) { const file = await h.getFile(); // ... } } ``` ### The rules that make it fail 1. **Chromium only.** Chrome and Edge yes; Firefox and Safari no. 2. **Must follow a user gesture.** Calling it from a timer, on page load, or after an `await` that loses the gesture throws. 3. **Permission does not survive a reload.** Re-request it, which needs another click. Storing the handle in IndexedDB lets you re-ask instead of re-pick: ```js if (await handle.queryPermission({ mode: 'readwrite' }) !== 'granted') { await handle.requestPermission({ mode: 'readwrite' }); // needs a click } ``` Therefore: **always feature-detect and always keep the download path.** ```js const canWriteBack = 'showSaveFilePicker' in window; async function exportResult(text, filename) { if (canWriteBack) { try { return await saveBack(text); } catch (e) { /* user cancelled or it failed — fall through */ } } download(text, filename); } ``` Never present write-back as the only way out. A tool that cannot export in Firefox is a broken tool, not a Chrome-optimised one. ### Other exits - **Clipboard** — often the best export for text that goes into an email or a document: `navigator.clipboard.writeText(text)`. Needs a user gesture. - **Print / PDF** — a `@media print` block plus `window.print()` beats generating a PDF in JavaScript for almost every case. - **Image** — see *Generating images*. --- ## 5. Keeping data Read when: Anything should survive closing the tab: settings, entered data, imported files. There is no server, so there is no central database — and that is usually the point. The only question is what survives closing the tab. | Need | Use | Limit | |---|---|---| | Settings, last selection, a draft | `localStorage` | strings only, a few MB | | Imported files, parsed records, long lists | IndexedDB | clumsy API | | Real queries across tables | SQLite compiled to WebAssembly | extra library, real effort | Pick the lowest rung that works. Most tools never leave `localStorage`. ### localStorage ```js var KEY = 'toolname.settings'; function saveSettings(obj) { try { localStorage.setItem(KEY, JSON.stringify(obj)); } catch (e) {} } function loadSettings() { try { return JSON.parse(localStorage.getItem(KEY)) || {}; } catch (e) { return {}; } } ``` Two habits that prevent later grief: **prefix every key** with the tool name, and **wrap every read** — the stored value may come from an older version, or the browser may refuse storage entirely (private window, blocked site data). A tool that throws on startup because of a stale setting is a tool nobody opens twice. ### IndexedDB For parsed data sets, imported files, anything beyond a few hundred kilobytes. The raw API is unpleasant enough that a small CDN wrapper is the one justified exception to "write it yourself". It works everywhere, including from a double-clicked file. It needs **no** OPFS and **no** cross-origin isolation (`COOP`/`COEP`). If you find yourself reaching for those headers, the approach is wrong for this context. ### SQLite in the browser Only for genuine analysis — grouping, joins, multi-year comparison. The pattern that holds up: open the database in memory, run the schema and the queries, then serialise the whole database to a byte array and store that in IndexedDB; restore it on the next start. No special headers required, so it still works on a static host. This is a multi-session project, not an evening. Only propose it when the analysis genuinely needs SQL. ### Asking the browser not to throw it away Browsers evict site data under disk pressure, and a tool that parked a parsed dataset in IndexedDB is a fat target. You can ask for it to be spared: ```js async function keepStorage() { if (!navigator.storage || !navigator.storage.persist) return false; try { return await navigator.storage.persist(); } catch (e) { return false; } } async function storageLeft() { if (!navigator.storage || !navigator.storage.estimate) return null; try { return await navigator.storage.estimate(); } // { usage, quota } catch (e) { return null; } } ``` Treat a `false` as the normal case, not an error. It is a request, not a setting: the browser decides, it may refuse without explanation, and from a double-clicked file it may not be granted at all. Feature-check both, wrap both, and never let either one block startup. So it does not change what you must tell the user below — it only improves the odds. **An export button is still the actual backup.** ### What the tool must tell the user Device-bound storage has consequences that belong in the UI, not in a footnote: - The data lives **only in this browser on this device**. Another laptop, another browser, a private window: empty. - **Clearing browser data deletes it**, without warning. - **There is no backup.** So: an export button is mandatory as soon as a tool keeps anything. Offer import of the same format, and the user has a backup and a way to move between devices. See *Saving results*. --- ## 6. Tables and numbers Read when: The tool shows rows of data or does arithmetic the user will quote. Most of these tools are, at heart, a table plus a number. Get both right and the tool is credible. ### Markup ```html
Ansatz20252026
Summe
``` `fm-table__num` right-aligns and switches on tabular figures so digits line up in columns. `fm-table-scroll` keeps a wide table scrolling inside itself instead of breaking the page on a phone. `fm-table--compact` and `--dense` exist for data-heavy views. ### Formatting numbers Never print a raw float at an Austrian or German audience. ```js var euro = new Intl.NumberFormat('de-AT', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }); var num = new Intl.NumberFormat('de-AT'); euro.format(1234567.8); // "1.234.568 €" num.format(0.385); // "0,385" ``` Reading them back is the mirror problem: `"1.234,50"` must become `1234.5`, not `NaN` and not `1.234`. Strip thousands separators, then swap the decimal comma. ### Arithmetic that survives scrutiny - **Round only for display.** Compute on full precision; a table whose rounded rows do not add up to the rounded total destroys trust instantly. - **For money, work in cents** (integers) when you sum many values, and divide at the end. - **Always show a total row** and, where the data allows, a check: the sum of the parts against an independently stated total. Display the discrepancy when it does not match instead of hiding it. - **Never silently drop rows.** If 12 of 340 rows could not be parsed, say "328 von 340 Zeilen gelesen, 12 übersprungen" and let the user see which. ### Sorting and filtering Plain JavaScript. No grid library — for a few thousand rows it is unnecessary weight, and it fights the design system. ```js rows.sort(function (a, b) { return b.betrag - a.betrag; }); ``` Use `localeCompare('de')` for text so umlauts sort correctly. Above roughly 5,000 rows, render only what is visible or paginate; below that, just redraw the ``. Make the active sort and filter visible in the UI. A filtered table that looks like a complete one is how wrong numbers end up in a council meeting. --- ## 7. Charts Read when: The tool visualises numbers. ### Choose the form from the claim Most useless charts exist because the shape was chosen before the statement. | The claim | The form | |---|---| | "More than last year" | grouped bars | | "This is how the total splits" | stacked bar — not a pie | | "This is the trend over the years" | line | | "One number decides it" | the number itself, `fm-metric-card` | | "Where it comes from, where it goes" | Sankey | A pie with seven slices says less than a sorted list. For a single figure, the figure beats every chart. ### ECharts, without modules ```html ``` This build sets a global `echarts` — it works from a classic `