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
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 ───────────────────────────────────────────────────────────────
# 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:
en:
title: "About"

View file

@ -153,19 +153,34 @@
/* ── Document header ────────────────────────────────────────────────────── */
.kb-header {
display: flex; justify-content: space-between; align-items: center;
gap: 16px; padding-bottom: 14px; margin-bottom: 20px;
display: flex; justify-content: space-between; align-items: flex-start;
gap: 24px; padding-bottom: 20px; margin-bottom: 20px;
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 {
display: inline-flex; align-items: center; gap: 8px;
font: 700 var(--fs-h1)/1 var(--font-sans);
color: var(--text); letter-spacing: -0.01em; user-select: none;
}
.app-wordmark svg { flex-shrink: 0; }
/* Right: app name + meta */
.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 ──────────────────────────────────────────────────────────────── */
.kb-card {
@ -365,28 +380,23 @@
</div>
<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">
<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"/>
<path d="M8 1.5V14.5A6.5 6.5 0 0 1 8 1.5z"/>
<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="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>
</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>
</button>
</div>
<!-- Document header -->
<!-- Document header (brand + title built by buildHeader() after config loads) -->
<header class="kb-header">
<div class="app-wordmark">
<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 id="hdr-brand"></div>
<div class="kb-doctitle">
<h1 id="inv-title">Invoice</h1>
<h1 id="inv-title"></h1>
<div class="meta" id="inv-meta"></div>
</div>
</header>
@ -593,13 +603,54 @@ async function loadCfg() {
// ── Boot ──────────────────────────────────────────────────────────────────────
function boot() {
buildLangBar();
buildHeader();
buildForm();
buildFooter();
restoreStorage();
updateInvMeta();
if (!loadLines()) addLine();
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 ────────────────────────────────────────────────────
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%"];
@ -623,6 +674,10 @@ function bumpZoom(dir) {
// ── Language bar ──────────────────────────────────────────────────────────────
function buildLangBar() {
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"] }];
if (langs.length < 2) return;
const part = document.getElementById("lang-part");
@ -905,6 +960,7 @@ function buildForm() {
document.getElementById("pcode").addEventListener("change", function () {
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("idate").addEventListener("change", calcPayBy);
document.getElementById("paid-inp").addEventListener("input", calcTotals);
@ -1305,7 +1361,7 @@ function bumpNum(s) {
// ── Relabel on language switch ────────────────────────────────────────────────
function relabel() {
const lm = {
"inv-title":"invoice","lbl-language":"language",
"lbl-language":"language",
"sec-sender":"sender-section","sec-invdet":"invoice-details-section",
"sec-ct":"charge-to","sec-lines":"invoice-lines",
"lbl-sn":"sender-name","lbl-sa1":"sender-address1","lbl-sa2":"sender-address2",