<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{{ app_display }}{% endblock title %}</title>
<link rel="stylesheet" id="lily-theme-css" href="/static/css/themes/light.css">
<script src="/static/js/htmx.min.js" defer></script>
<script src="/static/js/alpine.min.js" defer></script>
<script>
(function () {
'use strict';
var rtl = { ar: 1, fa: 1, ur: 1 };
try {
var t = localStorage.getItem('lily-theme') || 'light';
if (/^[a-z0-9-]+$/.test(t)) {
var link = document.getElementById('lily-theme-css');
if (link) {
link.href = '/static/css/themes/' + t + '.css';
}
document.documentElement.setAttribute('data-theme', t);
}
var loc = localStorage.getItem('lily-locale');
if (loc && /^[a-z]{2}$/.test(loc)) {
document.documentElement.setAttribute('lang', loc);
document.documentElement.setAttribute('dir', rtl[loc] ? 'rtl' : 'ltr');
}
var acc = localStorage.getItem('lily-accent');
if (acc && /^#[0-9a-fA-F]{6}$/.test(acc)) {
document.documentElement.style.setProperty('--nhs-blue', acc);
}
} catch (e) { }
})();
</script>
</head>
<body>
<a class="skip-link" aria-label="Skip to main content" href="#main-content">Skip to main content</a>
<div class="super-banner"
role="alert"
aria-live="assertive"
aria-label="System announcement"
data-type="warning"
x-data='{
id: "maintenance-2026-05-29",
dismissed: false,
init() {
try {
var key = "lily-super-banner-dismissed-" + this.id;
this.dismissed = localStorage.getItem(key) === "1";
} catch (e) { /* ignore */ }
},
dismiss() {
this.dismissed = true;
try {
localStorage.setItem("lily-super-banner-dismissed-" + this.id, "1");
} catch (e) { /* ignore */ }
if (window.lily && window.lily.toast) {
window.lily.toast("Banner dismissed", "info");
}
}
}'
x-init="init()"
x-show="!dismissed">
<div class="banner-box">
<span>
<strong>Scheduled maintenance</strong> on 2026-05-29 02:00–04:00 UTC.
Read-only access during the window. See
<a href="/health" aria-label="Open system health page for status">system status</a>.
</span>
<button type="button"
aria-label="Dismiss announcement"
@click="dismiss()">Dismiss</button>
</div>
</div>
<header class="header" aria-label="Site header">
<h1>{{ app_display }}</h1>
<div class="dropdown-menu"
aria-label="Primary navigation menu"
x-data='{
open: false,
activeIdx: 0,
toggle() {
this.open = !this.open;
this.activeIdx = 0;
if (this.open) this.$nextTick(() => this.focusItem(0));
},
close() { this.open = false; },
focusItem(idx) {
var items = this.$root.querySelectorAll("[role=menuitem]");
if (items.length === 0) return;
this.activeIdx = (idx + items.length) % items.length;
items[this.activeIdx].focus();
},
onKey(evt) {
if (evt.key === "ArrowDown") { evt.preventDefault(); this.focusItem(this.activeIdx + 1); }
else if (evt.key === "ArrowUp") { evt.preventDefault(); this.focusItem(this.activeIdx - 1); }
else if (evt.key === "Home") { evt.preventDefault(); this.focusItem(0); }
else if (evt.key === "End") {
evt.preventDefault();
var items = this.$root.querySelectorAll("[role=menuitem]");
this.focusItem(items.length - 1);
}
else if (evt.key === "Escape") { this.close(); this.$refs.menuTrigger.focus(); }
}
}'
@click.outside="close()">
<button class="hamburger-menu"
type="button"
aria-label="Open primary navigation menu"
aria-haspopup="menu"
aria-controls="primary-nav-dropdown"
x-ref="menuTrigger"
x-bind:aria-expanded="open ? 'true' : 'false'"
@click="toggle()"
@keydown.arrow-down.prevent="if (!open) { toggle(); } else { focusItem(0); }">
<span aria-hidden="true">☰</span>
<span>Menu</span>
</button>
<nav class="navigation-menu"
aria-label="Primary navigation"
x-show="open"
x-transition
x-bind:aria-hidden="open ? 'false' : 'true'">
<ol class="dropdown-menu-list"
id="primary-nav-dropdown"
role="menu"
aria-label="Primary navigation links"
@keydown="onKey($event)">
<li class="dropdown-menu-list-item" role="none">
<a class="dropdown-menu-item" role="menuitem" tabindex="-1" href="/" @click="close()">Home</a>
</li>
<li class="dropdown-menu-list-item" role="none">
<a class="dropdown-menu-item" role="menuitem" tabindex="-1" href="/{{ entity_plural }}" @click="close()">{{ entity_plural | capitalize }}</a>
</li>
<li class="dropdown-menu-list-item" role="none">
<a class="dropdown-menu-item" role="menuitem" tabindex="-1" href="/{{ entity_plural }}/search" @click="close()">Search</a>
</li>
<li class="dropdown-menu-list-item" role="none">
<a class="dropdown-menu-item" role="menuitem" tabindex="-1" href="/{{ entity_plural }}/review-queue" @click="close()">Review queue</a>
</li>
<li class="dropdown-menu-list-item" role="none">
<a class="dropdown-menu-item" role="menuitem" tabindex="-1" href="/audit" @click="close()">Audit</a>
</li>
<li class="dropdown-menu-list-item" role="none">
<a class="dropdown-menu-item" role="menuitem" tabindex="-1" href="/notifications" aria-description="Notification center" @click="close()">Notifications</a>
</li>
<li class="dropdown-menu-list-item" role="none">
<a class="dropdown-menu-item" role="menuitem" tabindex="-1" href="/health" @click="close()">Health</a>
</li>
<li class="dropdown-menu-list-item" role="none">
<a class="dropdown-menu-item" role="menuitem" tabindex="-1" href="/metrics" @click="close()">Metrics</a>
</li>
<li class="dropdown-menu-list-item" role="none">
<a class="dropdown-menu-item" role="menuitem" tabindex="-1" href="/tour" @click="close()">Tour</a>
</li>
<li class="dropdown-menu-list-item" role="none">
<a class="dropdown-menu-item" role="menuitem" tabindex="-1" href="/docs" @click="close()">API docs</a>
</li>
<li class="dropdown-menu-list-item" role="none">
<a class="dropdown-menu-item" role="menuitem" tabindex="-1" href="/settings" @click="close()">Settings</a>
</li>
</ol>
</nav>
</div>
<div class="theme-picker" aria-label="Theme picker">
<label class="label" for="theme-select">Theme</label>
<select id="theme-select"
class="theme-select"
aria-label="Choose a visual theme">
{% for theme in themes %}<option class="theme-select-option" value="{{ theme.slug }}"{% if theme.slug == "light" %} selected{% endif %}>{{ theme.label }}{% if theme.slug == "light" %} (default){% endif %}</option>
{% endfor %}
</select>
</div>
<div class="locale-picker" aria-label="Locale picker">
<label class="label" for="locale-select">Language</label>
<select id="locale-select"
class="select"
aria-label="Choose a display language">
<option value="">English (default)</option>
<option value="ar">العربية · Arabic</option>
<option value="bg">Български · Bulgarian</option>
<option value="bh">भोजपुरी · Bhojpuri</option>
<option value="bn">বাংলা · Bengali</option>
<option value="cs">Čeština · Czech</option>
<option value="cy">Cymraeg · Welsh</option>
<option value="da">Dansk · Danish</option>
<option value="de">Deutsch · German</option>
<option value="el">Ελληνικά · Greek</option>
<option value="en">English</option>
<option value="es">Español · Spanish</option>
<option value="et">Eesti · Estonian</option>
<option value="eu">Euskara · Basque</option>
<option value="fa">فارسی · Persian</option>
<option value="fi">Suomi · Finnish</option>
<option value="fr">Français · French</option>
<option value="ga">Gaeilge · Irish</option>
<option value="gu">ગુજરાતી · Gujarati</option>
<option value="ha">Harshen Hausa · Hausa</option>
<option value="hi">हिन्दी · Hindi</option>
<option value="hr">Hrvatski · Croatian</option>
<option value="hu">Magyar · Hungarian</option>
<option value="id">Bahasa Indonesia · Indonesian</option>
<option value="it">Italiano · Italian</option>
<option value="ja">日本語 · Japanese</option>
<option value="jv">Basa Jawa · Javanese</option>
<option value="ko">한국어 · Korean</option>
<option value="lt">Lietuvių · Lithuanian</option>
<option value="lv">Latviešu · Latvian</option>
<option value="mr">मराठी · Marathi</option>
<option value="mt">Malti · Maltese</option>
<option value="nl">Nederlands · Dutch</option>
<option value="pa">ਪੰਜਾਬੀ · Punjabi</option>
<option value="pl">Polski · Polish</option>
<option value="pt">Português · Portuguese</option>
<option value="ro">Română · Romanian</option>
<option value="ru">Русский · Russian</option>
<option value="sk">Slovenčina · Slovak</option>
<option value="sl">Slovenščina · Slovenian</option>
<option value="sv">Svenska · Swedish</option>
<option value="ta">தமிழ் · Tamil</option>
<option value="te">తెలుగు · Telugu</option>
<option value="tr">Türkçe · Turkish</option>
<option value="ur">اردو · Urdu</option>
<option value="vi">Tiếng Việt · Vietnamese</option>
<option value="zh">普通话 · Mandarin</option>
</select>
</div>
</header>
<main id="main-content" class="page-wrapper">
{% block content %}{% endblock content %}
</main>
<footer class="footer" aria-label="Site footer">
<p>© 2026 {{ app_display }} — styled with the
<a href="https://lilydesignsystem.github.io">Lily Design System</a>,
powered by <a href="https://loco.rs">Loco</a>,
<a href="https://keats.github.io/tera/">Tera</a>,
<a href="https://htmx.org">HTMX</a>, and
<a href="https://alpinejs.dev">Alpine</a>.</p>
</footer>
<dialog id="command-palette"
class="dialog"
role="dialog"
aria-modal="true"
aria-label="Command palette"
x-data='{
query: "",
activeIndex: 0,
commands: [
{ label: "Go to Home", hint: "Top-level landing page", kb: ["g","h"], href: "/" },
{ label: "Go to {{ entity_plural | capitalize }}", hint: "{{ entity_plural | capitalize }} index", kb: ["g","i"], href: "/{{ entity_plural }}" },
{ label: "Search", hint: "Full-text + filters", kb: ["g","s"], href: "/{{ entity_plural }}/search" },
{ label: "Review queue", hint: "Pending duplicates", kb: ["g","r"], href: "/{{ entity_plural }}/review-queue" },
{ label: "Compare two records", hint: "Side-by-side match", href: "/{{ entity_plural }}/compare?a=aaaa-1&b=aaaa-2" },
{ label: "Calendar", hint: "Records by creation date", href: "/{{ entity_plural }}/calendar" },
{ label: "Map", hint: "Geo pins", href: "/{{ entity_plural }}/map" },
{ label: "Import CSV", hint: "Bulk-import wizard", href: "/{{ entity_plural }}/import" },
{ label: "System audit", hint: "Recent activity", kb: ["g","a"], href: "/audit" },
{ label: "System health", hint: "RAG + subsystem status", kb: ["g","e"], href: "/health" },
{ label: "Performance metrics", hint: "Sparklines per endpoint", href: "/metrics" },
{ label: "Guided tour", hint: "8-step walkthrough", href: "/tour" },
{ label: "API documentation", hint: "REST + FHIR endpoint reference", href: "/docs" },
{ label: "Color palette", hint: "Full NHS token customizer", href: "/palette" },
{ label: "Settings", hint: "Per-browser preferences", href: "/settings" },
{ label: "Show keyboard shortcuts", hint: "Open the shortcuts dialog", kb: ["?"], action: "shortcuts" },
{% for theme in themes %} {{ "{ " }}label: "Switch theme: {{ theme.label }}{% if theme.slug == "light" %} (default){% endif %}", hint: "Theme: {{ theme.label }}", action: "theme:{{ theme.slug }}" {{ "}" }}{% if not loop.last %},{% endif %}
{% endfor %} { label: "Reset palette", hint: "Clear all token overrides", action: "palette:reset" }
],
filtered() {
var q = this.query.trim().toLowerCase();
if (!q) return this.commands;
return this.commands.filter(function (c) {
return c.label.toLowerCase().indexOf(q) !== -1
|| (c.hint || "").toLowerCase().indexOf(q) !== -1;
});
},
invoke(c) {
if (c.href) {
window.location.href = c.href;
} else if (c.action === "shortcuts") {
document.getElementById("command-palette").close();
document.getElementById("shortcuts-dialog").showModal();
} else if (c.action && c.action.indexOf("theme:") === 0) {
var t = c.action.slice(6);
if (/^[a-z0-9-]+$/.test(t)) {
var link = document.getElementById("lily-theme-css");
if (link) {
link.href = "/static/css/themes/" + t + ".css";
}
document.documentElement.setAttribute("data-theme", t);
try { localStorage.setItem("lily-theme", t); } catch (e) {}
var picker = document.getElementById("theme-select");
if (picker) picker.value = t;
}
if (window.lily && window.lily.toast) {
window.lily.toast("Theme switched", "info");
}
document.getElementById("command-palette").close();
} else if (c.action === "palette:reset") {
try {
var stored = JSON.parse(localStorage.getItem("lily-palette") || "{}");
Object.keys(stored).forEach(function (k) {
document.documentElement.style.removeProperty("--" + k);
});
localStorage.removeItem("lily-palette");
} catch (e) {}
if (window.lily && window.lily.toast) {
window.lily.toast("Palette reset", "warning");
}
document.getElementById("command-palette").close();
}
},
step(delta) {
var n = this.filtered().length;
if (n === 0) return;
this.activeIndex = (this.activeIndex + delta + n) % n;
},
choose() {
var f = this.filtered();
var pick = f[this.activeIndex] || f[0];
if (pick) this.invoke(pick);
}
}'>
<h2>Command palette</h2>
<input class="text-input-with-search"
type="search"
role="searchbox"
aria-label="Filter commands"
placeholder="Type to filter commands…"
autocomplete="off"
autofocus
x-model="query"
@input="activeIndex = 0"
@keydown.arrow-down.prevent="step(1)"
@keydown.arrow-up.prevent="step(-1)"
@keydown.enter.prevent="choose()">
<ol class="command" aria-label="Matching commands" role="listbox">
<template x-for="(c, i) in filtered()" :key="c.label">
<li role="option"
x-bind:aria-selected="i === activeIndex ? 'true' : 'false'"
x-bind:data-active="i === activeIndex ? 'true' : 'false'"
tabindex="-1"
@click="activeIndex = i; choose()"
@mouseover="activeIndex = i">
<strong x-text="c.label"></strong>
<template x-if="c.kb">
<span>
<template x-for="k in c.kb" :key="k">
<kbd class="kbd" x-text="k"></kbd>
</template>
</span>
</template>
<br>
<span class="hint" x-text="c.hint"></span>
</li>
</template>
<li role="option" x-show="filtered().length === 0" aria-disabled="true">
<span class="hint" aria-label="Empty filter">No commands match.</span>
</li>
</ol>
<form method="dialog" class="form" aria-label="Close command palette">
<button class="button" type="submit" aria-label="Close">Close</button>
</form>
</dialog>
<dialog id="shortcuts-dialog"
class="dialog"
role="dialog"
aria-modal="true"
aria-labelledby="shortcuts-title">
<h2 id="shortcuts-title">Keyboard shortcuts</h2>
<ol class="summary-list" aria-label="Available shortcuts">
<li class="summary-list-item">
<dl>
<dt><kbd class="kbd">?</kbd></dt>
<dd>Show / hide this dialog</dd>
</dl>
</li>
<li class="summary-list-item">
<dl>
<dt><kbd class="kbd">Ctrl</kbd> <kbd class="kbd">K</kbd> · <kbd class="kbd">⌘</kbd> <kbd class="kbd">K</kbd></dt>
<dd>Open the command palette (works inside inputs too)</dd>
</dl>
</li>
<li class="summary-list-item">
<dl>
<dt><kbd class="kbd">/</kbd></dt>
<dd>Focus the search field on this page</dd>
</dl>
</li>
<li class="summary-list-item">
<dl>
<dt><kbd class="kbd">g</kbd> <kbd class="kbd">h</kbd></dt>
<dd>Go to Home</dd>
</dl>
</li>
<li class="summary-list-item">
<dl>
<dt><kbd class="kbd">g</kbd> <kbd class="kbd">i</kbd></dt>
<dd>Go to the {{ entity_plural }} index</dd>
</dl>
</li>
<li class="summary-list-item">
<dl>
<dt><kbd class="kbd">g</kbd> <kbd class="kbd">s</kbd></dt>
<dd>Go to Search</dd>
</dl>
</li>
<li class="summary-list-item">
<dl>
<dt><kbd class="kbd">g</kbd> <kbd class="kbd">r</kbd></dt>
<dd>Go to the Review queue</dd>
</dl>
</li>
<li class="summary-list-item">
<dl>
<dt><kbd class="kbd">g</kbd> <kbd class="kbd">a</kbd></dt>
<dd>Go to the system Audit log</dd>
</dl>
</li>
<li class="summary-list-item">
<dl>
<dt><kbd class="kbd">g</kbd> <kbd class="kbd">e</kbd></dt>
<dd>Go to system Health</dd>
</dl>
</li>
<li class="summary-list-item">
<dl>
<dt><kbd class="kbd">Esc</kbd></dt>
<dd>Close any open dialog</dd>
</dl>
</li>
</ol>
<form method="dialog" class="form" aria-label="Close shortcuts">
<button class="button" type="submit" aria-label="Close">Close</button>
</form>
</dialog>
<div id="htmx-indicator" aria-live="polite">
<div class="progress-spinner"
role="progressbar"
aria-label="Loading"
aria-busy="true"></div>
<span>Loading…</span>
</div>
<div id="toast-region"
class="sonner"
role="status"
aria-label="Notifications"
aria-live="polite"></div>
<script>
(function () {
'use strict';
var region = document.getElementById('toast-region');
function spawn(message, type) {
if (!region || !message) return;
var t = document.createElement('div');
t.className = 'toast';
t.setAttribute('role', 'status');
t.setAttribute('aria-live', 'polite');
if (type) t.setAttribute('data-type', String(type));
t.setAttribute('aria-label', String(message));
t.textContent = String(message);
region.appendChild(t);
setTimeout(function () { t.remove(); }, 5000);
}
window.lily = window.lily || {};
window.lily.toast = spawn;
document.body.addEventListener('showToast', function (evt) {
var d = evt.detail || {};
spawn(d.message || d.value || '', d.type || 'info');
});
})();
(function () {
'use strict';
var indicator = document.getElementById('htmx-indicator');
if (!indicator) return;
var inflight = 0;
function update() {
if (inflight > 0) {
indicator.setAttribute('data-active', 'true');
} else {
indicator.removeAttribute('data-active');
}
}
document.body.addEventListener('htmx:beforeRequest', function () {
inflight += 1;
update();
});
document.body.addEventListener('htmx:afterRequest', function () {
inflight = Math.max(0, inflight - 1);
update();
});
document.body.addEventListener('htmx:responseError', function () {
inflight = Math.max(0, inflight - 1);
update();
if (window.lily && window.lily.toast) {
window.lily.toast('Request failed', 'error');
}
});
document.body.addEventListener('htmx:sendError', function () {
inflight = Math.max(0, inflight - 1);
update();
if (window.lily && window.lily.toast) {
window.lily.toast('Network error', 'error');
}
});
})();
(function () {
'use strict';
function findCard(trigger) {
var id = trigger.getAttribute('aria-describedby');
return id ? document.getElementById(id) : null;
}
function open(card) { if (card) card.setAttribute('data-open', 'true'); }
function close(card) { if (card) card.removeAttribute('data-open'); }
document.addEventListener('mouseover', function (evt) {
var t = evt.target.closest('.hover-card-trigger');
if (t) open(findCard(t));
});
document.addEventListener('mouseout', function (evt) {
var t = evt.target.closest('.hover-card-trigger');
if (t) close(findCard(t));
});
document.addEventListener('focusin', function (evt) {
var t = evt.target.closest('.hover-card-trigger');
if (t) open(findCard(t));
});
document.addEventListener('focusout', function (evt) {
var t = evt.target.closest('.hover-card-trigger');
if (t) close(findCard(t));
});
document.addEventListener('keydown', function (evt) {
if (evt.key === 'Escape') {
document.querySelectorAll('.hover-card[data-open="true"]').forEach(close);
}
});
})();
(function () {
'use strict';
var entityPlural = "{{ entity_plural }}";
var nav = {
h: '/',
i: '/' + entityPlural,
s: '/' + entityPlural + '/search',
r: '/' + entityPlural + '/review-queue',
a: '/audit',
e: '/health'
};
var dlg = document.getElementById('shortcuts-dialog');
var pendingGoto = 0;
function isTyping(target) {
if (!target) return false;
var tag = target.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target.isContentEditable;
}
document.addEventListener('keydown', function (evt) {
if ((evt.metaKey || evt.ctrlKey) && evt.key === 'k') {
evt.preventDefault();
var pal = document.getElementById('command-palette');
if (pal) {
if (pal.open) {
pal.close();
} else {
pal.showModal();
}
}
return;
}
if (evt.metaKey || evt.ctrlKey || evt.altKey) return;
if (isTyping(evt.target)) return;
if (evt.key === '?') {
evt.preventDefault();
if (dlg) {
if (dlg.open) {
dlg.close();
} else {
dlg.showModal();
}
}
return;
}
if (evt.key === '/') {
var input = document.querySelector('input[type="search"], input[type="text"]');
if (input) {
evt.preventDefault();
input.focus();
}
return;
}
if (evt.key === 'g') {
pendingGoto = Date.now();
return;
}
if (pendingGoto && Date.now() - pendingGoto < 1500) {
var dest = nav[evt.key];
if (dest) {
evt.preventDefault();
pendingGoto = 0;
window.location.href = dest;
}
}
});
})();
(function () {
'use strict';
var picker = document.getElementById('theme-select');
if (!picker) return;
var current = (localStorage.getItem('lily-theme') || 'light');
picker.value = current;
picker.addEventListener('change', function () {
var t = picker.value;
if (/^[a-z0-9-]+$/.test(t)) {
var link = document.getElementById('lily-theme-css');
if (link) {
link.href = '/static/css/themes/' + t + '.css';
}
document.documentElement.setAttribute('data-theme', t);
try { localStorage.setItem('lily-theme', t); } catch (e) { }
}
if (window.lily && window.lily.toast) {
var label = picker.options[picker.selectedIndex] && picker.options[picker.selectedIndex].text;
window.lily.toast('Theme: ' + (label || 'default'), 'info');
}
});
})();
(function () {
'use strict';
var picker = document.getElementById('locale-select');
if (!picker) return;
var RTL = { ar: 1, fa: 1, ur: 1 };
var current = document.documentElement.getAttribute('lang') || '';
picker.value = (current === 'en') ? '' : current;
picker.addEventListener('change', function () {
var loc = picker.value;
if (loc && /^[a-z]{2}$/.test(loc)) {
document.documentElement.setAttribute('lang', loc);
document.documentElement.setAttribute('dir', RTL[loc] ? 'rtl' : 'ltr');
try { localStorage.setItem('lily-locale', loc); } catch (e) { }
} else {
document.documentElement.setAttribute('lang', 'en');
document.documentElement.setAttribute('dir', 'ltr');
try { localStorage.removeItem('lily-locale'); } catch (e) { }
}
if (window.lily && window.lily.toast) {
var label = picker.options[picker.selectedIndex] && picker.options[picker.selectedIndex].text;
window.lily.toast('Language: ' + (label || 'default'), 'info');
}
});
})();
(function () {
'use strict';
document.addEventListener('click', function (evt) {
var el = evt.target.closest('.clipboard-copy-button');
if (!el) return;
var text = el.getAttribute('data-clipboard-text') || el.textContent || '';
if (!navigator.clipboard) {
if (window.lily && window.lily.toast) {
window.lily.toast('Clipboard not supported by this browser', 'error');
}
return;
}
navigator.clipboard.writeText(text).then(function () {
el.setAttribute('data-copied', 'true');
setTimeout(function () { el.removeAttribute('data-copied'); }, 2000);
if (window.lily && window.lily.toast) {
window.lily.toast(el.getAttribute('data-copied-message') || 'Copied to clipboard', 'success');
}
});
});
})();
</script>
</body>
</html>