diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..08b20ee --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,178 @@ +# CLAUDE.md + +Working notes for LLeMbas. Read this before changing anything. + +## What it is + +A self-hosted web UI for OpenAI-compatible LLM endpoints, written in Python and +themed after Middle-earth. Server-rendered FastAPI + Jinja + htmx; SQLite; +no JavaScript build step. + +## Commands + +```bash +. .venv/bin/activate +pip install -e ".[dev]" + +lembas serve # http://127.0.0.1:8080 +lembas info # paths + counts, useful when confused +lembas secret-key # generate LEMBAS_SECRET_KEY +lembas create-admin # create or promote an admin + +pytest # 70 tests, ~2s +ruff check . # lint (line length 100) +python scripts/build_artwork.py # regenerate all SVG artwork +python scripts/fetch_vendor.py # verify vendored JS against the lockfile +``` + +## Hard rules + +These are the constraints the project is built around. Breaking one is a +redesign, not a tweak. + +1. **No Node, no npm, no build step.** Browser libraries are downloaded once by + `scripts/fetch_vendor.py`, hash-pinned in `scripts/vendor.lock.json`, and + committed under `web/static/vendor/`. +2. **Nothing loads from a CDN at runtime.** A self-hosted tool must work + offline and must not report page views to a third party. +3. **No hard-coded colours outside `tokens.css`.** Every colour, space and + radius resolves through a CSS variable. That is what makes a new theme one + new block rather than an audit of every stylesheet. +4. **No migration tool.** SQLite only, schema created at startup by + `init_db()`, which is `CREATE TABLE IF NOT EXISTS` and never alters an + existing table. See "Changing the schema" below. +5. **Secrets never reach the browser.** API keys are Fernet-encrypted at rest + and only ever rendered masked. +6. **Model output is untrusted.** Everything from an endpoint goes through + `services/markdown.py` (markdown-it → nh3) or `escape_text()`. Never + `|safe` on anything that has not. + +## The flavour rule + +Middle-earth lives in the **artwork, theme names, empty states, loading lines +and error pages**. It does not live in the functional UI. + +Chats are called *Chats*, not *Tales*. Folders are *Folders*, not *Chapters*. +Buttons say what they do. Someone who has never read the books must be able to +use this without a glossary. The two themes are named `moria` and `shire`, and +the 404 says "Not all those who wander are lost. This page, however, is." — +that is the right amount. + +## Layout + +``` +src/lembas/ + main.py app factory, lifespan, error handlers + config.py pydantic-settings, all LEMBAS_* variables + cli.py typer entry points + api/ + deps.py Db / CurrentUser / RequiredUser / AdminUser + auth.py register, login, logout + pages.py full-page routes (chat shell, settings) + chats.py messaging + the SSE stream + folders.py folder CRUD + admin.py connections + models + preferences.py per-user theme + db/ + base.py Base, UUID/Timestamp mixins + session.py engine, SQLite pragmas, init_db, session_scope + models/ user, chat, connection, setting + security/ passwords (argon2), sessions + services/ + llm/openai_client.py httpx streaming + model discovery + chat.py request building, endpoint resolution, titles + markdown.py markdown-it + pygments + nh3 + crypto.py Fernet encrypt/decrypt/mask + sse.py event framing + web/ + templating.py render() -- always use this, not TemplateResponse + templates/ Jinja + static/ css, js, vendor, img +assets/ SVG masters (generated) +deploy/ systemd unit, nginx vhost, install/update scripts +``` + +## Things that will bite you + +**`render()`, not `TemplateResponse`.** `web/templating.py:render()` injects +`user`, `theme`, `version` and `allow_signup`. Templates assume they exist. If +you must call `templates.TemplateResponse` directly (the SSE path does, because +there is no `Request`), pass `user` explicitly — `chat/_message.html` renders +both roles and the user branch dereferences it. + +**The message template is the state machine.** `chat/_message.html` renders an +incomplete assistant message as a streaming shell carrying `sse-connect`, and a +complete one as finished output. That is the *only* thing that starts a +generation. A consequence worth knowing: loading a page whose last reply is +unfinished restarts it, which is how a dropped connection recovers. + +**SSE framing.** `services/sse.py:event()` splits payloads on newlines into +several `data:` lines. A raw newline in a single `data:` line truncates the +event — the failure shows up the first time a model emits a code block. + +**Streaming opens its own database session.** `api/chats.py:_generate()` uses +`session_scope()`, not the request's session, because streaming outlives the +request handler. + +**Escaping is chunk-safe on purpose.** `escape_text()` is `html.escape`, which +works character by character, so escaping stream chunks separately equals +escaping the whole string. `nh3.clean_text` would also be safe but escapes +spaces and slashes, tripling the size of every streamed token. + +**The fence renderer is replaced, not configured.** markdown-it's `highlight` +option re-wraps output in `
` unless the string starts with `` inside our wrapper. `markdown.py` overrides
+`renderer.rules["fence"]` instead. There is a regression test for this.
+
+**SVG `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/lembas/web/static/img/favicon.svg b/src/lembas/web/static/img/favicon.svg
new file mode 100644
index 0000000..6b4c4aa
--- /dev/null
+++ b/src/lembas/web/static/img/favicon.svg
@@ -0,0 +1,27 @@
+
diff --git a/src/lembas/web/static/img/logo-mark.svg b/src/lembas/web/static/img/logo-mark.svg
new file mode 100644
index 0000000..787cb74
--- /dev/null
+++ b/src/lembas/web/static/img/logo-mark.svg
@@ -0,0 +1,49 @@
+
diff --git a/src/lembas/web/static/js/app.js b/src/lembas/web/static/js/app.js
new file mode 100644
index 0000000..81ed765
--- /dev/null
+++ b/src/lembas/web/static/js/app.js
@@ -0,0 +1,162 @@
+/*
+ Client-side behaviour.
+
+ Everything here is progressive: the application is server-rendered and works
+ without this file, apart from the streaming reply, which is htmx's SSE
+ extension rather than anything hand-written below.
+*/
+(function () {
+ "use strict";
+
+ var THEME_KEY = "lembas-theme";
+ var THEMES = ["moria", "shire"];
+
+ /* --- Theme -------------------------------------------------------------
+ Stored locally so the choice applies instantly and survives being signed
+ out, and mirrored to the server so it follows the user to another device.
+ The server call is best-effort: a failure must not undo the local switch. */
+ function currentTheme() {
+ return document.documentElement.dataset.theme || THEMES[0];
+ }
+
+ function applyTheme(name) {
+ if (THEMES.indexOf(name) === -1) return;
+ document.documentElement.dataset.theme = name;
+ try {
+ localStorage.setItem(THEME_KEY, name);
+ } catch (e) { /* private mode */ }
+
+ document.querySelectorAll("[data-theme-toggle]").forEach(function (el) {
+ el.setAttribute("aria-label", name === "moria" ? "Switch to Shire (light)"
+ : "Switch to Moria (dark)");
+ });
+
+ if (document.body.dataset.authenticated === "true") {
+ fetch("/api/preferences/theme", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ theme: name })
+ }).catch(function () { /* preference is already applied locally */ });
+ }
+ }
+
+ function toggleTheme() {
+ applyTheme(currentTheme() === "moria" ? "shire" : "moria");
+ }
+
+ /* --- Textarea autosize -------------------------------------------------
+ Grows the composer with its content up to a cap, after which it scrolls. */
+ function autosize(el) {
+ if (!el) return;
+ var max = parseInt(el.dataset.maxHeight || "320", 10);
+ el.style.height = "auto";
+ el.style.height = Math.min(el.scrollHeight, max) + "px";
+ el.style.overflowY = el.scrollHeight > max ? "auto" : "hidden";
+ }
+
+ /* --- Copy --------------------------------------------------------------
+ Falls back to a hidden textarea because navigator.clipboard is unavailable
+ on pages served over plain http, which self-hosted installs often are. */
+ function copyText(text, trigger) {
+ function done() {
+ if (!trigger) return;
+ var original = trigger.getAttribute("aria-label");
+ trigger.classList.add("is-copied");
+ trigger.setAttribute("aria-label", "Copied");
+ setTimeout(function () {
+ trigger.classList.remove("is-copied");
+ if (original) trigger.setAttribute("aria-label", original);
+ }, 1400);
+ }
+
+ if (navigator.clipboard && window.isSecureContext) {
+ navigator.clipboard.writeText(text).then(done).catch(function () {});
+ return;
+ }
+ var scratch = document.createElement("textarea");
+ scratch.value = text;
+ scratch.setAttribute("readonly", "");
+ scratch.style.position = "fixed";
+ scratch.style.opacity = "0";
+ document.body.appendChild(scratch);
+ scratch.select();
+ try { document.execCommand("copy"); done(); } catch (e) { /* nothing to do */ }
+ document.body.removeChild(scratch);
+ }
+
+ /* --- Thread scrolling --------------------------------------------------
+ Only auto-scrolls when the reader is already near the bottom, so scrolling
+ up to re-read something is not yanked away by an incoming token. */
+ var STICK_THRESHOLD = 120;
+
+ function isNearBottom(el) {
+ return el.scrollHeight - el.scrollTop - el.clientHeight < STICK_THRESHOLD;
+ }
+
+ function scrollThread(force) {
+ var thread = document.getElementById("thread-scroll");
+ if (!thread) return;
+ if (force || isNearBottom(thread)) {
+ thread.scrollTop = thread.scrollHeight;
+ }
+ }
+
+ window.lembas = {
+ applyTheme: applyTheme,
+ toggleTheme: toggleTheme,
+ copyText: copyText,
+ scrollThread: scrollThread,
+ autosize: autosize
+ };
+
+ /* --- Wiring ------------------------------------------------------------ */
+ document.addEventListener("click", function (event) {
+ var toggle = event.target.closest("[data-theme-toggle]");
+ if (toggle) {
+ event.preventDefault();
+ toggleTheme();
+ return;
+ }
+
+ var copy = event.target.closest("[data-copy]");
+ if (copy) {
+ event.preventDefault();
+ var source = document.getElementById(copy.dataset.copy);
+ if (source) copyText(source.textContent.trim(), copy);
+ }
+ });
+
+ document.addEventListener("input", function (event) {
+ if (event.target.matches("[data-autosize]")) autosize(event.target);
+ });
+
+ /* Enter sends, Shift+Enter inserts a newline -- the convention every chat
+ application uses. Left alone on touch devices, where there is no easy
+ Shift and Enter should mean "new line". */
+ document.addEventListener("keydown", function (event) {
+ if (event.key !== "Enter" || event.shiftKey) return;
+ var composer = event.target.closest("[data-composer-input]");
+ if (!composer) return;
+ if (window.matchMedia("(pointer: coarse)").matches) return;
+ event.preventDefault();
+ var form = composer.closest("form");
+ if (form && composer.value.trim()) form.requestSubmit();
+ });
+
+ document.addEventListener("DOMContentLoaded", function () {
+ document.querySelectorAll("[data-autosize]").forEach(autosize);
+ scrollThread(true);
+ applyTheme(currentTheme());
+ });
+
+ /* After any htmx swap: re-measure the composer and follow new content. */
+ document.body.addEventListener("htmx:afterSwap", function () {
+ document.querySelectorAll("[data-autosize]").forEach(autosize);
+ scrollThread(false);
+ });
+
+ /* Tokens arriving over SSE are appended outside the normal swap cycle. */
+ document.body.addEventListener("htmx:sseMessage", function () {
+ scrollThread(false);
+ });
+})();
diff --git a/src/lembas/web/static/vendor/alpine.min.js b/src/lembas/web/static/vendor/alpine.min.js
new file mode 100644
index 0000000..ab371ef
--- /dev/null
+++ b/src/lembas/web/static/vendor/alpine.min.js
@@ -0,0 +1,5 @@
+(()=>{var ee=!1,re=!1,W=[],ne=-1,ie=!1;function Ve(t){Dn(t)}function Ue(){ie=!0}function qe(){ie=!1,We()}function Dn(t){W.includes(t)||W.push(t),We()}function Ke(t){let e=W.indexOf(t);e!==-1&&e>ne&&W.splice(e,1)}function We(){if(!re&&!ee){if(ie)return;ee=!0,queueMicrotask(In)}}function In(){ee=!1,re=!0;for(let t=0;tt.effect(e,{scheduler:r=>{oe?Ve(r):r()}}),se=t.raw}function ae(t){R=t}function Ye(t){let e=()=>{};return[n=>{let i=R(n);return t._x_effects||(t._x_effects=new Set,t._x_runEffects=()=>{t._x_effects.forEach(o=>o())}),t._x_effects.add(i),e=()=>{i!==void 0&&(t._x_effects.delete(i),j(i))},i},()=>{e()}]}function St(t,e){let r=!0,n,i,o=R(()=>{let s=t(),a=JSON.stringify(s);if(!r&&(typeof s=="object"||s!==n)){let c=typeof n=="object"?JSON.parse(i):n;queueMicrotask(()=>{e(s,c)})}n=s,i=a,r=!1});return()=>j(o)}async function Xe(t){Ue();try{await t(),await Promise.resolve()}finally{qe()}}var Ze=[],Qe=[],tr=[];function er(t){tr.push(t)}function et(t,e){typeof e=="function"?(t._x_cleanups||(t._x_cleanups=[]),t._x_cleanups.push(e)):(e=t,Qe.push(e))}function At(t){Ze.push(t)}function Ot(t,e,r){t._x_attributeCleanups||(t._x_attributeCleanups={}),t._x_attributeCleanups[e]||(t._x_attributeCleanups[e]=[]),t._x_attributeCleanups[e].push(r)}function ce(t,e){t._x_attributeCleanups&&Object.entries(t._x_attributeCleanups).forEach(([r,n])=>{(e===void 0||e.includes(r))&&(n.forEach(i=>i()),delete t._x_attributeCleanups[r])})}function rr(t){for(t._x_effects?.forEach(Ke);t._x_cleanups?.length;)t._x_cleanups.pop()()}var le=new MutationObserver(pe),ue=!1;function ut(){le.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),ue=!0}function fe(){kn(),le.disconnect(),ue=!1}var lt=[];function kn(){let t=le.takeRecords();lt.push(()=>t.length>0&&pe(t));let e=lt.length;queueMicrotask(()=>{if(lt.length===e)for(;lt.length>0;)lt.shift()()})}function m(t){if(!ue)return t();fe();let e=t();return ut(),e}var de=!1,vt=[];function nr(){de=!0}function ir(){de=!1,pe(vt),vt=[]}function pe(t){if(de){vt=vt.concat(t);return}let e=[],r=new Set,n=new Map,i=new Map;for(let o=0;o{s.nodeType===1&&s._x_marker&&r.add(s)}),t[o].addedNodes.forEach(s=>{if(s.nodeType===1){if(r.has(s)){r.delete(s);return}s._x_marker||e.push(s)}})),t[o].type==="attributes")){let s=t[o].target,a=t[o].attributeName,c=t[o].oldValue,l=()=>{n.has(s)||n.set(s,[]),n.get(s).push({name:a,value:s.getAttribute(a)})},u=()=>{i.has(s)||i.set(s,[]),i.get(s).push(a)};s.hasAttribute(a)&&c===null?l():s.hasAttribute(a)?(u(),l()):u()}i.forEach((o,s)=>{ce(s,o)}),n.forEach((o,s)=>{Ze.forEach(a=>a(s,o))});for(let o of r)e.some(s=>s.contains(o))||Qe.forEach(s=>s(o));for(let o of e)o.isConnected&&tr.forEach(s=>s(o));e=null,r=null,n=null,i=null}function Ct(t){return P(F(t))}function N(t,e,r){return t._x_dataStack=[e,...F(r||t)],()=>{t._x_dataStack=t._x_dataStack.filter(n=>n!==e)}}function F(t){return t._x_dataStack?t._x_dataStack:typeof ShadowRoot=="function"&&t instanceof ShadowRoot?F(t.host):t.parentNode?F(t.parentNode):[]}function P(t){return new Proxy({objects:t},$n)}function or(t,e){return t===null||t===Object.prototype?null:Object.prototype.hasOwnProperty.call(t,e)?t:or(Object.getPrototypeOf(t),e)}var $n={ownKeys({objects:t}){return Array.from(new Set(t.flatMap(e=>Object.keys(e))))},has({objects:t},e){return e==Symbol.unscopables?!1:t.some(r=>Object.prototype.hasOwnProperty.call(r,e)||Reflect.has(r,e))},get({objects:t},e,r){return e=="toJSON"?Ln:Reflect.get(t.find(n=>Reflect.has(n,e))||{},e,r)},set({objects:t},e,r,n){let i;for(let s of t)if(i=or(s,e),i)break;i||(i=t[t.length-1]);let o=Object.getOwnPropertyDescriptor(i,e);return o?.set&&o?.get?o.set.call(n,r)||!0:Reflect.set(i,e,r)}};function Ln(){return Reflect.ownKeys(this).reduce((e,r)=>(e[r]=Reflect.get(this,r),e),{})}function rt(t){let e=n=>typeof n=="object"&&!Array.isArray(n)&&n!==null,r=(n,i="")=>{Object.entries(Object.getOwnPropertyDescriptors(n)).forEach(([o,{value:s,enumerable:a}])=>{if(a===!1||s===void 0||typeof s=="object"&&s!==null&&s.__v_skip)return;let c=i===""?o:`${i}.${o}`;typeof s=="object"&&s!==null&&s._x_interceptor?n[o]=s.initialize(t,c,o):e(s)&&s!==n&&!(s instanceof Element)&&r(s,c)})};return r(t)}function Tt(t,e=()=>{}){let r={initialValue:void 0,_x_interceptor:!0,initialize(n,i,o){return t(this.initialValue,()=>jn(n,i),s=>me(n,i,s),i,o)}};return e(r),n=>{if(typeof n=="object"&&n!==null&&n._x_interceptor){let i=r.initialize.bind(r);r.initialize=(o,s,a)=>{let c=n.initialize(o,s,a);return r.initialValue=c,i(o,s,a)}}else r.initialValue=n;return r}}function jn(t,e){return e.split(".").reduce((r,n)=>r[n],t)}function me(t,e,r){if(typeof e=="string"&&(e=e.split(".")),e.length===1)t[e[0]]=r;else{if(e.length===0)throw error;return t[e[0]]||(t[e[0]]={}),me(t[e[0]],e.slice(1),r)}}var sr={};function x(t,e){sr[t]=e}function H(t,e){let r=Fn(e);return Object.entries(sr).forEach(([n,i])=>{Object.defineProperty(t,`$${n}`,{get(){return i(e,r)},enumerable:!1})}),t}function Fn(t){let[e,r]=he(t),n={interceptor:Tt,...e};return et(t,r),n}function ar(t,e,r,...n){try{return r(...n)}catch(i){nt(i,t,e)}}function nt(...t){return cr(...t)}var cr=Bn;function lr(t){cr=t}function Bn(t,e,r=void 0){t=Object.assign(t??{message:"No error message given."},{el:e,expression:r}),console.warn(`Alpine Expression Error: ${t.message}
+
+${r?'Expression: "'+r+`"
+
+`:""}`,e),setTimeout(()=>{throw t},0)}var it=!0;function Mt(t){let e=it;it=!1;let r=t();return it=e,r}function T(t,e,r={}){let n;return _(t,e)(i=>n=i,r),n}function _(...t){return ur(...t)}var ur=()=>{};function fr(t){ur=t}var dr;function pr(t){dr=t}function mr(t,e){let r={};H(r,t);let n=[r,...F(t)],i=typeof e=="function"?zn(n,e):Vn(n,e,t);return ar.bind(null,t,e,i)}function zn(t,e){return(r=()=>{},{scope:n={},params:i=[],context:o}={})=>{if(!it){ft(r,e,P([n,...t]),i);return}let s=e.apply(P([n,...t]),i);ft(r,s)}}var _e={};function Hn(t,e){if(_e[t])return _e[t];let r=Object.getPrototypeOf(async function(){}).constructor,n=/^[\n\s]*if.*\(.*\)/.test(t.trim())||/^(let|const)\s/.test(t.trim())?`(async()=>{ ${t} })()`:t,o=(()=>{try{let s=new r(["__self","scope"],`with (scope) { __self.result = ${n} }; __self.finished = true; return __self.result;`);return Object.defineProperty(s,"name",{value:`[Alpine] ${t}`}),s}catch(s){return nt(s,e,t),Promise.resolve()}})();return _e[t]=o,o}function Vn(t,e,r){let n=Hn(e,r);return(i=()=>{},{scope:o={},params:s=[],context:a}={})=>{n.result=void 0,n.finished=!1;let c=P([o,...t]);if(typeof n=="function"){let l=n.call(a,n,c).catch(u=>nt(u,r,e));n.finished?(ft(i,n.result,c,s,r),n.result=void 0):l.then(u=>{ft(i,u,c,s,r)}).catch(u=>nt(u,r,e)).finally(()=>n.result=void 0)}}}function ft(t,e,r,n,i){if(it&&typeof e=="function"){let o=e.apply(r,n);o instanceof Promise?o.then(s=>ft(t,s,r,n)).catch(s=>nt(s,i,e)):t(o)}else typeof e=="object"&&e instanceof Promise?e.then(o=>t(o)):t(e)}function hr(...t){return dr(...t)}function _r(t,e,r={}){let n={};H(n,t);let i=[n,...F(t)],o=P([r.scope??{},...i]),s=r.params??[];if(e.includes("await")){let a=Object.getPrototypeOf(async function(){}).constructor,c=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(async()=>{ ${e} })()`:e;return new a(["scope"],`with (scope) { let __result = ${c}; return __result }`).call(r.context,o)}else{let a=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(()=>{ ${e} })()`:e,l=new Function(["scope"],`with (scope) { let __result = ${a}; return __result }`).call(r.context,o);return typeof l=="function"&&it?l.apply(o,s):l}}var ye="x-";function O(t=""){return ye+t}function gr(t){ye=t}var Rt={};function p(t,e){return Rt[t]=e,{before(r){if(!Rt[r]){console.warn(String.raw`Cannot find directive \`${r}\`. \`${t}\` will use the default order of execution`);return}let n=G.indexOf(r);G.splice(n>=0?n:G.indexOf("DEFAULT"),0,t)}}}function xr(t){return Object.keys(Rt).includes(t)}function pt(t,e,r){if(e=Array.from(e),t._x_virtualDirectives){let o=Object.entries(t._x_virtualDirectives).map(([a,c])=>({name:a,value:c})),s=be(o);o=o.map(a=>s.find(c=>c.name===a.name)?{name:`x-bind:${a.name}`,value:`"${a.value}"`}:a),e=e.concat(o)}let n={};return e.map(wr((o,s)=>n[o]=s)).filter(Sr).map(qn(n,r)).sort(Kn).map(o=>Un(t,o))}function be(t){return Array.from(t).map(wr()).filter(e=>!Sr(e))}var ge=!1,dt=new Map,yr=Symbol();function br(t){ge=!0;let e=Symbol();yr=e,dt.set(e,[]);let r=()=>{for(;dt.get(e).length;)dt.get(e).shift()();dt.delete(e)},n=()=>{ge=!1,r()};t(r),n()}function he(t){let e=[],r=a=>e.push(a),[n,i]=Ye(t);return e.push(i),[{Alpine:B,effect:n,cleanup:r,evaluateLater:_.bind(_,t),evaluate:T.bind(T,t)},()=>e.forEach(a=>a())]}function Un(t,e){let r=()=>{},n=Rt[e.type]||r,[i,o]=he(t);Ot(t,e.original,o);let s=()=>{t._x_ignore||t._x_ignoreSelf||(n.inline&&n.inline(t,e,i),n=n.bind(n,t,e,i),ge?dt.get(yr).push(n):n())};return s.runCleanups=o,s}var Nt=(t,e)=>({name:r,value:n})=>(r.startsWith(t)&&(r=r.replace(t,e)),{name:r,value:n}),Pt=t=>t;function wr(t=()=>{}){return({name:e,value:r})=>{let{name:n,value:i}=Er.reduce((o,s)=>s(o),{name:e,value:r});return n!==e&&t(n,e),{name:n,value:i}}}var Er=[];function ot(t){Er.push(t)}function Sr({name:t}){return vr().test(t)}var vr=()=>new RegExp(`^${ye}([^:^.]+)\\b`);function qn(t,e){return({name:r,value:n})=>{r===n&&(n="");let i=r.match(vr()),o=r.match(/:([a-zA-Z0-9\-_:]+)/),s=r.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],a=e||t[r]||r;return{type:i?i[1]:null,value:o?o[1]:null,modifiers:s.map(c=>c.replace(".","")),expression:n,original:a}}}var xe="DEFAULT",G=["ignore","ref","id","data","anchor","bind","init","for","model","modelable","transition","show","if",xe,"teleport"];function Kn(t,e){let r=G.indexOf(t.type)===-1?xe:t.type,n=G.indexOf(e.type)===-1?xe:e.type;return G.indexOf(r)-G.indexOf(n)}function J(t,e,r={},n={}){return t.dispatchEvent(new CustomEvent(e,{detail:r,bubbles:!0,composed:!0,cancelable:!0,...n}))}function D(t,e){if(typeof ShadowRoot=="function"&&t instanceof ShadowRoot){Array.from(t.children).forEach(i=>D(i,e));return}let r=!1;if(e(t,()=>r=!0),r)return;let n=t.firstElementChild;for(;n;)D(n,e,!1),n=n.nextElementSibling}function E(t,...e){console.warn(`Alpine Warning: ${t}`,...e)}var Ar=!1;function Or(){Ar&&E("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),Ar=!0,document.body||E("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's `
+
+
+{% include "partials/icons.html" %}
+
+{% block body %}{% endblock %}
+
+
+
+
+
+{% block scripts %}{% endblock %}
+
+