Dynamic header: org brand, app wordmark, invoice number meta, ⓘ button

Left brand:
- org-name set in config → kb-brand with 46px logo, org name, optional
  subheading (org-subheading)
- org-name absent/empty → plain app-wordmark (icon + "invoice")

Right doctitle:
- h1: 24px icon inline + lowercase "invoice" (built by buildHeader())
- .meta: invoice number, live-updates as user types in the ino field

Toolbar:
- Theme toggle updated to moon-phase icon (matches timesheet)
- ⓘ button added; shown only when cfg.about is configured

config.yml: org-name / org-subheading keys documented (commented out)
About modal comment updated to mention ⓘ button

https://claude.ai/code/session_01MkM7p8Us3L8YAfLKGA13NS
This commit is contained in:
Claude 2026-06-08 15:59:46 +00:00
parent 406ac77073
commit 206ed6184a
No known key found for this signature in database
2 changed files with 84 additions and 22 deletions

View file

@ -21,8 +21,14 @@ languages:
name: Norsk name: Norsk
direction: ltr direction: ltr
# ── Organisation brand ────────────────────────────────────────────────────────
# Optional. Set org-name to show a branded header (org logo + name + subheading).
# Leave org-name absent or empty to show the plain "invoice" wordmark instead.
# org-name: "Acme Corporation"
# org-subheading: "Freelance invoicing"
# ── About modal ─────────────────────────────────────────────────────────────── # ── About modal ───────────────────────────────────────────────────────────────
# Optional. Remove this section entirely to hide the About link in the footer. # Optional. Remove this section entirely to hide the ⓘ button and footer link.
about: about:
en: en:
title: "About" title: "About"

View file

@ -153,19 +153,34 @@
/* ── Document header ────────────────────────────────────────────────────── */ /* ── Document header ────────────────────────────────────────────────────── */
.kb-header { .kb-header {
display: flex; justify-content: space-between; align-items: center; display: flex; justify-content: space-between; align-items: flex-start;
gap: 16px; padding-bottom: 14px; margin-bottom: 20px; gap: 24px; padding-bottom: 20px; margin-bottom: 20px;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
/* Full brand (org name visible) */
.kb-brand { display: flex; align-items: center; gap: 14px; min-width: 0; }
.kb-brand .logo {
height: 46px; width: 46px; flex: 0 0 46px; border-radius: 10px;
display: grid; place-items: center; overflow: hidden;
}
.kb-brand .logo svg { width: 100%; height: 100%; display: block; }
.kb-brand .org { font-size: 17px; font-weight: 700; color: var(--text); letter-spacing: -0.01em; }
.kb-brand .org small { display: block; font-size: var(--fs-small); font-weight: 500; color: var(--text-muted); letter-spacing: 0; margin-top: 1px; }
/* Minimal wordmark (no org) */
.app-wordmark { .app-wordmark {
display: inline-flex; align-items: center; gap: 8px; display: inline-flex; align-items: center; gap: 8px;
font: 700 var(--fs-h1)/1 var(--font-sans); font: 700 var(--fs-h1)/1 var(--font-sans);
color: var(--text); letter-spacing: -0.01em; user-select: none; color: var(--text); letter-spacing: -0.01em; user-select: none;
} }
.app-wordmark svg { flex-shrink: 0; } .app-wordmark svg { flex-shrink: 0; }
/* Right: app name + meta */
.kb-doctitle { text-align: right; } .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); } .kb-doctitle h1 {
margin: 0; font-size: var(--fs-h1); font-weight: 700; letter-spacing: -0.01em;
color: var(--text); display: inline-flex; align-items: center; gap: 9px;
}
.kb-doctitle h1 svg { flex-shrink: 0; }
.kb-doctitle .meta { margin-top: 4px; font-size: var(--fs-small); color: var(--text-muted); font-family: var(--font-mono); }
/* ── Cards ──────────────────────────────────────────────────────────────── */ /* ── Cards ──────────────────────────────────────────────────────────────── */
.kb-card { .kb-card {
@ -365,28 +380,23 @@
</div> </div>
<span id="sz-label" style="font-size:12px;color:var(--text-muted);font-family:var(--font-mono)">100%</span> <span id="sz-label" style="font-size:12px;color:var(--text-muted);font-family:var(--font-mono)">100%</span>
<button class="kb-iconbtn" id="btn-theme" onclick="toggleTheme()" title="Toggle light/dark" aria-label="Toggle dark mode"> <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"> <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<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"/> <path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>
<path d="M8 1.5V14.5A6.5 6.5 0 0 1 8 1.5z"/> </svg>
</button>
<button class="kb-iconbtn" id="btn-about" onclick="openAbout()" aria-label="About" style="display:none">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/>
</svg> </svg>
</button> </button>
</div> </div>
<!-- Document header --> <!-- Document header (brand + title built by buildHeader() after config loads) -->
<header class="kb-header"> <header class="kb-header">
<div class="app-wordmark"> <div id="hdr-brand"></div>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="28" height="28" role="img" aria-label="Invoice">
<rect width="48" height="48" rx="12" fill="var(--accent)"/>
<path d="M14 9 H29 L34 14 V39 H14 Z" fill="none" stroke="#fff" stroke-width="3" stroke-linejoin="round"/>
<path d="M29 9 V14 H34" fill="none" stroke="#fff" stroke-width="3" stroke-linejoin="round"/>
<path d="M18.5 20 H27" stroke="#fff" stroke-width="2.6" stroke-linecap="round"/>
<path d="M18.5 25 H29.5" stroke="#fff" stroke-width="2.6" stroke-linecap="round" opacity=".55"/>
<path d="M18.5 33 H29.5" stroke="#fff" stroke-width="3.2" stroke-linecap="round"/>
</svg>
invoice
</div>
<div class="kb-doctitle"> <div class="kb-doctitle">
<h1 id="inv-title">Invoice</h1> <h1 id="inv-title"></h1>
<div class="meta" id="inv-meta"></div>
</div> </div>
</header> </header>
@ -593,13 +603,54 @@ async function loadCfg() {
// ── Boot ────────────────────────────────────────────────────────────────────── // ── Boot ──────────────────────────────────────────────────────────────────────
function boot() { function boot() {
buildLangBar(); buildLangBar();
buildHeader();
buildForm(); buildForm();
buildFooter(); buildFooter();
restoreStorage(); restoreStorage();
updateInvMeta();
if (!loadLines()) addLine(); if (!loadLines()) addLine();
document.getElementById("loading").style.display = "none"; document.getElementById("loading").style.display = "none";
} }
// ── Document header ───────────────────────────────────────────────────────────
const LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" aria-hidden="true">
<rect width="48" height="48" rx="12" fill="var(--accent)"/>
<path d="M14 9 H29 L34 14 V39 H14 Z" fill="none" stroke="#fff" stroke-width="3" stroke-linejoin="round"/>
<path d="M29 9 V14 H34" fill="none" stroke="#fff" stroke-width="3" stroke-linejoin="round"/>
<path d="M18.5 20 H27" stroke="#fff" stroke-width="2.6" stroke-linecap="round"/>
<path d="M18.5 25 H29.5" stroke="#fff" stroke-width="2.6" stroke-linecap="round" opacity=".55"/>
<path d="M18.5 33 H29.5" stroke="#fff" stroke-width="3.2" stroke-linecap="round"/>
</svg>`;
function buildHeader() {
const orgName = cfg["org-name"];
const orgSub = cfg["org-subheading"];
// Left brand
const brandEl = document.getElementById("hdr-brand");
if (orgName) {
brandEl.className = "kb-brand";
brandEl.innerHTML = `<span class="logo">${LOGO_SVG}</span>
<span class="org">${h(orgName)}${orgSub ? `<small>${h(orgSub)}</small>` : ""}</span>`;
} else {
brandEl.className = "app-wordmark";
brandEl.innerHTML = LOGO_SVG + " invoice";
}
// Right: app name h1 (icon + lowercase app name)
const titleEl = document.getElementById("inv-title");
titleEl.innerHTML = LOGO_SVG.replace("48 48", "24 24") + " invoice";
// Meta: invoice number (updated live)
updateInvMeta();
}
function updateInvMeta() {
const el = document.getElementById("inv-meta");
const ino = (document.getElementById("ino")?.value || "").trim();
if (el) el.textContent = ino || "";
}
// ── Font-size accessibility ──────────────────────────────────────────────────── // ── Font-size accessibility ────────────────────────────────────────────────────
const ZOOMS = [0.8, 0.85, 0.9, 0.95, 1.0, 1.1, 1.2, 1.3]; const ZOOMS = [0.8, 0.85, 0.9, 0.95, 1.0, 1.1, 1.2, 1.3];
const ZOOM_LABELS = ["80%", "85%", "90%", "95%", "100%", "110%", "120%", "130%"]; const ZOOM_LABELS = ["80%", "85%", "90%", "95%", "100%", "110%", "120%", "130%"];
@ -623,6 +674,10 @@ function bumpZoom(dir) {
// ── Language bar ────────────────────────────────────────────────────────────── // ── Language bar ──────────────────────────────────────────────────────────────
function buildLangBar() { function buildLangBar() {
applyZoom(); applyZoom();
if (cfg.about) {
const btn = document.getElementById("btn-about");
if (btn) btn.style.display = "";
}
const langs = cfg.languages || [{ code: cfg["default-code"], name: cfg["default-name"] }]; const langs = cfg.languages || [{ code: cfg["default-code"], name: cfg["default-name"] }];
if (langs.length < 2) return; if (langs.length < 2) return;
const part = document.getElementById("lang-part"); const part = document.getElementById("lang-part");
@ -905,6 +960,7 @@ function buildForm() {
document.getElementById("pcode").addEventListener("change", function () { document.getElementById("pcode").addEventListener("change", function () {
document.getElementById("pcode-other-wrap").style.display = this.value === "__other__" ? "block" : "none"; document.getElementById("pcode-other-wrap").style.display = this.value === "__other__" ? "block" : "none";
}); });
document.getElementById("ino").addEventListener("input", updateInvMeta);
document.getElementById("ct-pick").addEventListener("change", e => fillChargeTo(e.target.value)); document.getElementById("ct-pick").addEventListener("change", e => fillChargeTo(e.target.value));
document.getElementById("idate").addEventListener("change", calcPayBy); document.getElementById("idate").addEventListener("change", calcPayBy);
document.getElementById("paid-inp").addEventListener("input", calcTotals); document.getElementById("paid-inp").addEventListener("input", calcTotals);
@ -1305,7 +1361,7 @@ function bumpNum(s) {
// ── Relabel on language switch ──────────────────────────────────────────────── // ── Relabel on language switch ────────────────────────────────────────────────
function relabel() { function relabel() {
const lm = { const lm = {
"inv-title":"invoice","lbl-language":"language", "lbl-language":"language",
"sec-sender":"sender-section","sec-invdet":"invoice-details-section", "sec-sender":"sender-section","sec-invdet":"invoice-details-section",
"sec-ct":"charge-to","sec-lines":"invoice-lines", "sec-ct":"charge-to","sec-lines":"invoice-lines",
"lbl-sn":"sender-name","lbl-sa1":"sender-address1","lbl-sa2":"sender-address2", "lbl-sn":"sender-name","lbl-sa1":"sender-address1","lbl-sa2":"sender-address2",