Compare commits

..

5 commits

Author SHA1 Message Date
Claude
69e4ad9624
Port all development-branch features into kBenestad redesign
Some checks failed
/ mirror (push) Has been cancelled
- Bidirectional FX rate entry: rcur dropdown (e.g. "35 THB per USD" or
  "0.028 USD per THB") with rother label updating dynamically
- FX labels from config translations; currency code appended at runtime
- ct-pick persisted to localStorage; restoreStorage rebuilds client-
  specific project code options before re-applying saved pcode value
- Footer rebuilt by buildFooter() with dynamic About link
- About modal with markdown-rendered content from config.yml (marked.js)
- about section added to config.yml with EN/DE/FR/NO content
- rcur saved/restored in inv_lines_v1; gatherData includes rcur in fxNote
- relabel() calls updateFxLabels() for each active FX line on lang switch

https://claude.ai/code/session_01MkM7p8Us3L8YAfLKGA13NS
2026-06-08 04:35:08 +00:00
Claude
3ef2f9206c
Fix line item row vertical alignment: top-align cells, nudge remove button
https://claude.ai/code/session_01MkM7p8Us3L8YAfLKGA13NS
2026-06-08 04:27:06 +00:00
Claude
b06500512a
Restructure toolbar and header: lang dropdown left, app name in brand
Move language select to the left of the toolbar. Add lowercase "invoice"
app name next to the squircle logo in the brand section.

https://claude.ai/code/session_01MkM7p8Us3L8YAfLKGA13NS
2026-06-08 04:24:39 +00:00
Claude
195e61794d
Add favicons and web manifest
Copies the design-system favicon set into app/ and wires up SVG, PNG
(16/32/48), apple-touch-icon, web manifest, and theme-color meta tag.

https://claude.ai/code/session_01MkM7p8Us3L8YAfLKGA13NS
2026-06-08 04:19:40 +00:00
Claude
46b17cd154
Remove brand text from header — icon only
https://claude.ai/code/session_01MkM7p8Us3L8YAfLKGA13NS
2026-06-08 04:05:29 +00:00
9 changed files with 246 additions and 47 deletions

BIN
app/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View file

@ -21,6 +21,58 @@ languages:
name: Norsk
direction: ltr
# ── About modal ───────────────────────────────────────────────────────────────
# Optional. Remove this section entirely to hide the About link in the footer.
about:
en:
title: "About"
content: |
### Invoice Generator
A single-file browser app for generating freelance invoices as PDF.
No backend, no build step — just a browser and a config file.
**Source:** [kbenestad/invoice](https://github.com/kbenestad/invoice)
**Docs:** [docs.benestad.net/invoice](https://docs.benestad.net/invoice)
© 2026 Kristian Benestad. Licensed under the Apache 2 license.
btn-close: "Close"
de:
title: "Über"
content: |
### Rechnungsgenerator
Eine Single-File-Browser-App zur Erstellung von Freiberufler-Rechnungen als PDF.
Kein Backend, kein Build-Schritt.
**Quellcode:** [kbenestad/invoice](https://github.com/kbenestad/invoice)
**Dokumentation:** [docs.benestad.net/invoice](https://docs.benestad.net/invoice)
© 2026 Kristian Benestad. Lizenziert unter Apache 2.
btn-close: "Schließen"
fr:
title: "À propos"
content: |
### Générateur de factures
Une application de navigateur en fichier unique pour générer des factures en PDF.
Aucun backend, aucune étape de build.
**Code source :** [kbenestad/invoice](https://github.com/kbenestad/invoice)
**Documentation :** [docs.benestad.net/invoice](https://docs.benestad.net/invoice)
© 2026 Kristian Benestad. Sous licence Apache 2.
btn-close: "Fermer"
"no":
title: "Om"
content: |
### Fakturagenerator
En nettleserapp i én fil for å generere frilansfakturaer som PDF.
Ingen backend, ingen byggtrinn.
**Kildekode:** [kbenestad/invoice](https://github.com/kbenestad/invoice)
**Dokumentasjon:** [docs.benestad.net/invoice](https://docs.benestad.net/invoice)
© 2026 Kristian Benestad. Lisensiert under Apache 2.
btn-close: "Lukk"
# ── Payment info visibility ───────────────────────────────────────────────────
# Set to true to hide the payment info panel entirely (useful if payment info
# should not appear on invoices, e.g. per company policy).
@ -355,25 +407,25 @@ translations:
fr: Devise étrangère
"no": Utenlandsk valuta
currency-code:
en: Currency code
de: Währungscode
fr: Code devise
"no": Valutakode
en: Foreign currency code
de: Fremdwährungscode
fr: Code devise étrangère
"no": Utenlandsk valutakode
exchange-rate:
en: "Exchange rate (X foreign = 1 local)"
de: "Wechselkurs (X Fremd = 1 Inland)"
fr: "Taux de change (X étranger = 1 local)"
"no": "Valutakurs (X utenlandsk = 1 lokal)"
en: Exchange rate
de: Wechselkurs
fr: Taux de change
"no": Valutakurs
per-item:
en: Price per item (foreign currency)
de: Preis je Einheit (Fremdwährung)
fr: Prix par unité (devise étrangère)
"no": Pris per enhet (utenlandsk valuta)
en: Price per item in
de: Preis je Einheit in
fr: Prix par unité en
"no": Pris per enhet i
total-foreign:
en: Line total in foreign currency
de: Zeilenbetrag (Fremdwährung)
fr: Total ligne en devise étrangère
"no": Linjebeløp i utenlandsk valuta
en: Line total in
de: Zeilenbetrag in
fr: Total ligne en
"no": Linjebeløp i
add-line:
en: "+ Add new line"
de: "+ Neue Zeile hinzufügen"

BIN
app/favicon-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 455 B

BIN
app/favicon-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 786 B

BIN
app/favicon-48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

8
app/favicon.svg Normal file
View file

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48" role="img" aria-label="invoice">
<rect width="48" height="48" rx="12" fill="#2f6fed"></rect>
<path d="M14 9 H29 L34 14 V39 H14 Z" fill="none" stroke="#fff" stroke-width="3" stroke-linejoin="round"></path>
<path d="M29 9 V14 H34" fill="none" stroke="#fff" stroke-width="3" stroke-linejoin="round"></path>
<path d="M18.5 20 H27" stroke="#fff" stroke-width="2.6" stroke-linecap="round"></path>
<path d="M18.5 25 H29.5" stroke="#fff" stroke-width="2.6" stroke-linecap="round" opacity=".55"></path>
<path d="M18.5 33 H29.5" stroke="#fff" stroke-width="3.2" stroke-linecap="round"></path>
</svg>

After

Width:  |  Height:  |  Size: 684 B

BIN
app/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View file

@ -5,6 +5,13 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="format-detection" content="telephone=no">
<title>Invoice</title>
<link rel="icon" href="favicon.svg" type="image/svg+xml">
<link rel="icon" href="favicon-48.png" sizes="48x48" type="image/png">
<link rel="icon" href="favicon-32.png" sizes="32x32" type="image/png">
<link rel="icon" href="favicon-16.png" sizes="16x16" type="image/png">
<link rel="apple-touch-icon" href="apple-touch-icon.png">
<link rel="manifest" href="site.webmanifest">
<meta name="theme-color" content="#2f6fed">
<script>
/* Restore persisted theme before first paint */
(function(){var t=localStorage.getItem("kb-theme");if(t)document.documentElement.setAttribute("data-theme",t);})();
@ -150,15 +157,13 @@
border-bottom: 1px solid var(--border);
}
.kb-brand { display: flex; align-items: center; gap: 9px; min-width: 0; }
.kb-brand .org { font-size: 14px; font-weight: 600; letter-spacing: -0.01em; color: var(--text-soft); }
.kb-brand .logo {
height: 28px; width: 28px; flex: 0 0 28px; border-radius: 7px;
display: grid; place-items: center; overflow: hidden; opacity: .82;
}
.kb-brand .logo svg { width: 28px; height: 28px; display: block; }
.kb-brand .org {
font-size: 12px; font-weight: 500; color: var(--text-muted);
letter-spacing: -0.01em; opacity: .7;
}
.kb-doctitle { text-align: right; }
.kb-doctitle h1 { margin: 0; font-size: var(--fs-h1); font-weight: 700; letter-spacing: -0.01em; color: var(--text); }
@ -240,6 +245,7 @@
.kb-btn--block { width: 100%; }
/* round / buttons for rows */
.kb-row .kb-circbtn { margin-top: 6px; }
.kb-circbtn {
width: 24px; height: 24px; border-radius: 50%;
display: inline-grid; place-items: center;
@ -252,7 +258,8 @@
.kb-circbtn--rm:hover { background: var(--danger); color: #fff; }
/* ── Row grids (line items / tax rows) ──────────────────────────────────── */
.kb-rowhead, .kb-row { display: grid; align-items: center; gap: 8px; }
.kb-rowhead, .kb-row { display: grid; align-items: start; gap: 8px; }
.kb-rowhead { align-items: center; }
.kb-rowhead {
padding: 0 4px 8px;
font-size: var(--fs-label); font-weight: 700;
@ -348,16 +355,15 @@
<!-- Utility toolbar -->
<div class="kb-toolbar" id="lang-bar">
<div id="lang-part" style="display:none">
<select id="lang-sel" class="kb-select" style="width:auto;padding:5px 28px 5px 8px;font-size:var(--fs-small)"></select>
</div>
<div class="spacer"></div>
<div class="kb-seg" role="group" aria-label="Text size">
<button id="sz-down" onclick="bumpZoom(-1)" aria-label="Smaller text">A</button>
<button id="sz-up" onclick="bumpZoom(1)" aria-label="Larger text">A+</button>
</div>
<span id="sz-label" style="font-size:12px;color:var(--text-muted);font-family:var(--font-mono)">100%</span>
<div class="spacer"></div>
<div id="lang-part" class="kb-seg" role="group" aria-label="Language" style="display:none">
<label id="lbl-language" for="lang-sel" style="padding:5px 10px;font-size:var(--fs-small);font-weight:600;color:var(--text-muted)">Language</label>
<select id="lang-sel" class="kb-select" style="width:auto;border:0;box-shadow:none;background:transparent;padding:5px 24px 5px 4px"></select>
</div>
<button class="kb-iconbtn" id="btn-theme" onclick="toggleTheme()" title="Toggle light/dark" aria-label="Toggle dark mode">
<svg viewBox="0 0 16 16" width="15" height="15" fill="currentColor">
<path d="M8 1.5a6.5 6.5 0 1 0 0 13A6.5 6.5 0 0 0 8 1.5zM0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8z"/>
@ -379,7 +385,7 @@
<path d="M18.5 33 H29.5" stroke="#fff" stroke-width="3.2" stroke-linecap="round"/>
</svg>
</span>
<span class="org">kBenestad</span>
<span class="org">invoice</span>
</div>
<div class="kb-doctitle">
<h1 id="inv-title">Invoice</h1>
@ -394,11 +400,21 @@
</div><!-- /.kb-wrap -->
<footer class="kb-footer">
<span>&#169; 2026 kBenestad</span>
<span class="sep">&#183;</span>
<a href="https://github.com/kbenestad/invoice">kbenestad/invoice</a>
</footer>
<!-- About modal -->
<div id="about-modal" style="display:none;position:fixed;inset:0;z-index:999;
background:rgba(0,0,0,.45);align-items:center;justify-content:center;padding:20px">
<div style="background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);
box-shadow:var(--shadow);max-width:520px;width:100%;padding:28px 28px 20px">
<h2 id="about-dialog-header" style="font-size:18px;font-weight:700;margin:0 0 16px;color:var(--text)"></h2>
<div id="about-dialog-body" style="font-size:var(--fs-base);color:var(--text-soft);line-height:1.65;
max-height:60vh;overflow-y:auto"></div>
<div style="margin-top:20px;text-align:right">
<button id="about-btn-close" class="kb-btn kb-btn--ghost" onclick="closeAbout()">Close</button>
</div>
</div>
</div>
<footer id="app-footer" class="kb-footer"></footer>
<!-- js-yaml -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/js-yaml/4.1.0/js-yaml.min.js"
@ -406,6 +422,9 @@
<!-- jsPDF -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"
crossorigin="anonymous"></script>
<!-- marked (About modal markdown) -->
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"
crossorigin="anonymous"></script>
<script>
"use strict";
@ -577,6 +596,7 @@ async function loadCfg() {
function boot() {
buildLangBar();
buildForm();
buildFooter();
restoreStorage();
if (!loadLines()) addLine();
document.getElementById("loading").style.display = "none";
@ -608,8 +628,7 @@ function buildLangBar() {
const langs = cfg.languages || [{ code: cfg["default-code"], name: cfg["default-name"] }];
if (langs.length < 2) return;
const part = document.getElementById("lang-part");
if (part) part.style.display = "flex";
document.getElementById("lbl-language").textContent = t("language");
if (part) part.style.display = "";
const sel = document.getElementById("lang-sel");
sel.innerHTML = langs.map(l =>
`<option value="${h(l.code)}" ${l.code === lang ? "selected" : ""}>${h(l.name)}</option>`
@ -762,7 +781,7 @@ function buildForm() {
<section class="kb-card">
<h2 class="kb-card__title">
<span id="sec-ct">${t("charge-to")}</span>
<select id="ct-pick" class="kb-select"
<select id="ct-pick" class="kb-select" data-ls="ct-pick"
style="width:auto;font-size:var(--fs-small);margin-left:8px;padding:4px 28px 4px 8px">
<option value="">${t("select")}</option>
${ctOpts}
@ -1132,24 +1151,33 @@ function toggleFx(i) {
fxDiv.innerHTML = `
<div class="kb-grid cols-3">
<div class="kb-field">
<span class="kb-label">${t("currency-code")}</span>
<span class="kb-label" id="fcur-lbl-${i}">${t("currency-code")}</span>
<select id="fcur-${i}" class="kb-select"
onchange="updateFxLabels(${i});calcLine(${i});saveLines()">
${currOpts(defaultFcy)}
</select>
</div>
<div class="kb-field">
<span class="kb-label" id="frate-lbl-${i}">Exchange rate (X ${h(defaultFcy)} = 1 ${h(lcy)})</span>
<input type="number" id="frate-${i}" class="kb-input num" value="" min="0" step="any"
oninput="calcFxFromPer(${i})">
<span class="kb-label" id="frate-lbl-${i}">${t("exchange-rate")}</span>
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap">
<input type="number" id="frate-${i}" class="kb-input num" value="" min="0" step="any"
style="flex:1;min-width:70px" oninput="calcFxFromPer(${i})">
<select id="rcur-${i}" class="kb-select" style="width:auto"
onchange="updateFxLabels(${i});calcFxFromPer(${i});saveLines()">
<option value="${h(lcy)}">${h(lcy)}</option>
<option value="${h(defaultFcy)}">${h(defaultFcy)}</option>
</select>
<span class="kb-label" style="margin:0">per</span>
<span id="rother-${i}" style="font-size:var(--fs-small);font-weight:600;color:var(--text)">${h(defaultFcy)}</span>
</div>
</div>
<div class="kb-field">
<span class="kb-label" id="fper-lbl-${i}">Price per item in ${h(defaultFcy)}</span>
<span class="kb-label" id="fper-lbl-${i}">${t("per-item")} ${h(defaultFcy)}</span>
<input type="number" id="fper-${i}" class="kb-input num" value="" min="0" step="any"
oninput="calcFxFromPer(${i})">
</div>
</div>
<div class="fx-note">${t("total-foreign")}: <strong id="fltot-${i}">0.00</strong></div>`;
<div class="fx-note"><span id="ftot-lbl-${i}">${t("total-foreign")} ${h(defaultFcy)}</span>: <strong id="fltot-${i}">0.00</strong></div>`;
lr?.insertAdjacentElement("afterend", fxDiv);
calcLine(i);
saveLines();
@ -1158,8 +1186,12 @@ function toggleFx(i) {
function calcFxFromPer(i) {
const per = pn(document.getElementById(`fper-${i}`)?.value);
const rate = pn(document.getElementById(`frate-${i}`)?.value) || 1;
const rcur = document.getElementById(`rcur-${i}`)?.value;
const lcy = document.getElementById("icur")?.value || "";
const prEl = document.getElementById(`price-${i}`);
if (prEl) prEl.value = (per / rate).toFixed(6);
if (prEl) prEl.value = rcur === lcy
? (per * rate).toFixed(6)
: (per / rate).toFixed(6);
calcLine(i);
saveLines();
}
@ -1241,6 +1273,17 @@ function restoreStorage() {
if (el) el.value = v;
});
// Restore charge-to: rebuild its project code options then re-apply pcode.
// fillChargeTo() calls updateProjectCodes() which resets the pcode select,
// and may call saveStorage() mid-flow, so we re-apply and re-save after.
const ctPickEl = document.getElementById("ct-pick");
if (ctPickEl?.value) {
fillChargeTo(ctPickEl.value);
const pcodeEl = document.getElementById("pcode");
if (pcodeEl && d.pcode) pcodeEl.value = d.pcode;
saveStorage();
}
if (d.pcode === "__other__") {
const w = document.getElementById("pcode-other-wrap");
if (w) w.style.display = "block";
@ -1304,6 +1347,7 @@ function relabel() {
fe.innerHTML = `<option value="no">${t("no-option")}</option><option value="yes">${t("yes")}</option>`;
fe.value = s;
}
if (document.getElementById(`rcur-${i}`)) updateFxLabels(+i);
});
const alBtn = document.getElementById("btn-al");
@ -1404,10 +1448,11 @@ function gatherData(renderLang) {
let fxNote = null;
if (isFx) {
const cur = document.getElementById(`fcur-${i}`)?.value || "";
const rcur = document.getElementById(`rcur-${i}`)?.value || "";
const rate = pn(document.getElementById(`frate-${i}`)?.value) || 1;
const per = pn(document.getElementById(`fper-${i}`)?.value);
const foreignTot = per * qty;
fxNote = { cur, rate, per, foreignTot, td };
fxNote = { cur, rcur, rate, per, foreignTot, td };
}
rows.push({ qty, uomLbl, desc, price, tot, fxNote });
});
@ -1646,12 +1691,24 @@ function buildPDF() {
// ── Update FX labels when currency or invoice currency changes ────────────────
function updateFxLabels(i) {
const fcy = document.getElementById(`fcur-${i}`)?.value || "FCY";
const lcy = document.getElementById("icur")?.value || "";
const fcy = document.getElementById(`fcur-${i}`)?.value || "FCY";
const lcy = document.getElementById("icur")?.value || "";
const rcurEl = document.getElementById(`rcur-${i}`);
const rotherEl = document.getElementById(`rother-${i}`);
if (rcurEl) {
const prev = rcurEl.value;
rcurEl.innerHTML = `<option value="${h(lcy)}">${h(lcy)}</option><option value="${h(fcy)}">${h(fcy)}</option>`;
rcurEl.value = (prev === lcy || prev === fcy) ? prev : lcy;
}
if (rotherEl) rotherEl.textContent = rcurEl?.value === lcy ? fcy : lcy;
const cl = document.getElementById(`fcur-lbl-${i}`);
if (cl) cl.textContent = t("currency-code");
const rl = document.getElementById(`frate-lbl-${i}`);
if (rl) rl.textContent = t("exchange-rate");
const pl = document.getElementById(`fper-lbl-${i}`);
if (rl) rl.textContent = `Exchange rate (X ${fcy} = 1 ${lcy})`;
if (pl) pl.textContent = `Price per item in ${fcy}`;
if (pl) pl.textContent = t("per-item") + " " + fcy;
const tl = document.getElementById(`ftot-lbl-${i}`);
if (tl) tl.textContent = t("total-foreign") + " " + fcy;
}
// ── Save / load invoice lines ──────────────────────────────────────────────────
@ -1670,6 +1727,7 @@ function saveLines() {
price: document.getElementById(`price-${i}`)?.value || "",
fx: document.getElementById(`fx-${i}`)?.value || "no",
fcur: document.getElementById(`fcur-${i}`)?.value || "",
rcur: document.getElementById(`rcur-${i}`)?.value || "",
frate: document.getElementById(`frate-${i}`)?.value || "",
fper: document.getElementById(`fper-${i}`)?.value || "",
});
@ -1719,6 +1777,10 @@ function loadLines() {
const el = document.getElementById(`fcur-${i}`);
if (el) { el.value = ld.fcur; updateFxLabels(i); }
}
if (ld.rcur) {
const el = document.getElementById(`rcur-${i}`);
if (el) { el.value = ld.rcur; updateFxLabels(i); }
}
if (ld.frate) { const el = document.getElementById(`frate-${i}`); if (el) el.value = ld.frate; }
if (ld.fper) { const el = document.getElementById(`fper-${i}`); if (el) el.value = ld.fper; }
calcFxFromPer(i);
@ -1751,6 +1813,48 @@ function resetLines() {
calcTotals();
}
// ── Footer & About modal ──────────────────────────────────────────────────────
function aboutData() {
const ab = cfg?.about;
if (!ab) return null;
return ab[lang] ?? ab[cfg["default-code"]] ?? Object.values(ab)[0] ?? null;
}
function buildFooter() {
const footer = document.getElementById("app-footer");
if (!footer) return;
const ab = aboutData();
const aboutLink = ab
? ` <span class="sep">&#183;</span> <a href="#" onclick="openAbout();return false">${h(ab.title ?? "About")}</a>`
: "";
footer.innerHTML =
`<span>&#169; 2026 Kristian Benestad</span>`
+ ` <span class="sep">&#183;</span>`
+ ` <a href="https://docs.benestad.net/invoice" target="_blank" rel="noopener">docs.benestad.net</a>`
+ ` <span class="sep">&#183;</span>`
+ ` <a href="https://github.com/kbenestad/invoice" target="_blank" rel="noopener">kbenestad/invoice</a>`
+ aboutLink;
}
function openAbout() {
const ab = aboutData();
if (!ab) return;
document.getElementById("about-dialog-header").textContent = ab.title ?? "";
document.getElementById("about-dialog-body").innerHTML =
typeof marked !== "undefined" ? marked.parse(ab.content ?? "") : h(ab.content ?? "");
document.getElementById("about-btn-close").textContent = ab["btn-close"] ?? "Close";
document.getElementById("about-modal").style.display = "flex";
document.getElementById("about-btn-close").focus();
}
function closeAbout() {
document.getElementById("about-modal").style.display = "none";
}
document.addEventListener("keydown", e => {
if (e.key === "Escape") closeAbout();
});
// ── Start ─────────────────────────────────────────────────────────────────────
loadCfg();
</script>

35
app/site.webmanifest Normal file
View file

@ -0,0 +1,35 @@
{
"name": "Invoice",
"short_name": "invoice",
"theme_color": "#2f6fed",
"background_color": "#2f6fed",
"display": "standalone",
"icons": [
{
"src": "icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "apple-touch-icon.png",
"sizes": "180x180",
"type": "image/png"
},
{
"src": "favicon-48.png",
"sizes": "48x48",
"type": "image/png"
},
{
"src": "favicon-32.png",
"sizes": "32x32",
"type": "image/png"
},
{
"src": "favicon-16.png",
"sizes": "16x16",
"type": "image/png"
}
]
}