Working chat: auth, connections, streaming, folders

LLeMbas now runs end to end. Register, add an OpenAI-compatible
connection, and hold a real streaming conversation organised into
folders. Verified against the local llama-swap instance.

Streaming is the one genuinely tricky part. Sending a message returns
two HTML fragments -- the user bubble and an empty assistant bubble
carrying an sse-connect -- and that attribute is the ONLY thing that
starts a generation. Rendering an incomplete assistant message as a
streaming shell falls out of the same template, which means loading a
page whose last reply never finished simply picks it up again.

Details worth knowing about, each commented where it matters:

- SSE payloads are split across several data: lines. A raw newline in
  one data: line truncates the event, which shows up the first time a
  model emits a code block.
- Markdown is rendered server-side by the same helper for both the page
  and the final streamed frame, so the two cannot disagree. The fence
  renderer is replaced outright rather than using markdown-it's
  highlight option, which re-wraps output in a second <pre>.
- escape_text is html.escape, not nh3.clean_text: it escapes character
  by character, so escaping stream chunks separately equals escaping
  the whole string.
- The stream opens its own session via session_scope(); it outlives the
  request handler and the dependency-scoped session may be closed.
- Deleting a folder keeps the chats inside it (FK is SET NULL). Losing
  a conversation to a mis-clicked folder delete is unforgivable.
- Login failures use one message for "no such account" and "wrong
  password" so the form cannot enumerate registered addresses.

Also adds deploy/ for the gamebox install at https://chat.lan: system
unit, nginx vhost with buffering off (buffering on turns streaming into
one lump at the end), and install/update scripts following the same
service-user and /srv bind-mount conventions as llama-swap and comfyui.

70 tests, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-07-21 11:04:13 +02:00
parent 5ef2af6a9f
commit dd9e0e9440
59 changed files with 6273 additions and 12 deletions
+113
View File
@@ -0,0 +1,113 @@
/* Administration screens. */
.admin-scroll {
flex: 1;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--border-strong) transparent;
}
.admin-page {
max-width: 46rem;
margin: 0 auto;
padding: var(--sp-6) var(--sp-5) var(--sp-12);
}
.admin-lede {
color: var(--ink-muted);
font-size: var(--text-sm);
line-height: var(--leading-relaxed);
margin-bottom: var(--sp-6);
max-width: 42rem;
}
.admin-section-title {
display: flex;
align-items: center;
gap: var(--sp-2);
font-size: var(--text-lg);
margin: var(--sp-8) 0 var(--sp-4);
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--sp-5);
margin-bottom: var(--sp-4);
}
.card__title {
display: flex;
align-items: center;
gap: var(--sp-2);
font-size: var(--text-md);
margin-bottom: var(--sp-4);
}
.form-grid { display: block; }
.form-grid .field:last-of-type { margin-bottom: 0; }
.field--actions { margin-top: var(--sp-5); margin-bottom: 0; }
.connection__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-3);
margin-bottom: var(--sp-4);
flex-wrap: wrap;
}
.connection__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-3);
margin-top: var(--sp-5);
padding-top: var(--sp-4);
border-top: 1px solid var(--border);
flex-wrap: wrap;
}
/* Reachable status at a glance: green working, red errored, grey disabled. */
.status-dot {
width: 0.55rem;
height: 0.55rem;
border-radius: var(--radius-full);
flex: none;
background: var(--ink-faint);
}
.status-dot.is-ok { background: var(--success); }
.status-dot.is-bad { background: var(--danger); }
.status-dot.is-off { background: var(--ink-faint); }
.model-list { list-style: none; margin: 0; padding: 0; }
.model-list__item {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-3);
padding: var(--sp-2) 0;
border-bottom: 1px solid var(--border);
}
.model-list__item:last-child { border-bottom: 0; }
.model-list__id {
font-family: var(--font-mono);
font-size: var(--text-sm);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nav-item.is-disabled {
opacity: 0.45;
cursor: default;
}
.nav-item.is-disabled:hover { background: none; color: var(--ink-muted); }
.field__hint code {
font-family: var(--font-mono);
font-size: 0.92em;
padding: 0.05em 0.3em;
border-radius: var(--radius-sm);
background: var(--code-bg);
border: 1px solid var(--code-border);
}
+444
View File
@@ -0,0 +1,444 @@
/*
Application styles.
Rules here resolve colour, spacing and radius through the variables in
tokens.css and never hard-code a value. Layout is flexbox and grid only --
no framework, no preprocessor, no build step.
*/
/* --- Reset ---------------------------------------------------------------- */
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
height: 100%;
}
body {
margin: 0;
font-family: var(--font-body);
font-size: var(--text-base);
line-height: var(--leading-normal);
color: var(--ink);
background: var(--bg);
-webkit-font-smoothing: antialiased;
}
h1, h2, h3, h4, h5, h6 {
margin: 0;
font-family: var(--font-display);
font-weight: 600;
line-height: var(--leading-tight);
letter-spacing: 0.01em;
}
p { margin: 0 0 var(--sp-4); }
p:last-child { margin-bottom: 0; }
a {
color: var(--accent);
text-decoration-color: color-mix(in srgb, var(--accent) 40%, transparent);
text-underline-offset: 2px;
}
a:hover { color: var(--accent-hover); }
button, input, textarea, select {
font: inherit;
color: inherit;
}
/* A single, consistent focus ring. Never remove it without a replacement. */
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
:focus:not(:focus-visible) { outline: none; }
.visually-hidden {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
/* --- Icons ---------------------------------------------------------------- */
.icon {
width: 1.25em;
height: 1.25em;
flex: none;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
.icon--sm { width: 1em; height: 1em; }
.icon--lg { width: 1.5em; height: 1.5em; }
/* The leaf is a filled silhouette, not a stroked pictogram. */
.icon--leaf { fill: currentColor; stroke: none; }
/* --- Buttons -------------------------------------------------------------- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--sp-2);
padding: 0.5rem 0.9rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface-raised);
color: var(--ink);
font-size: var(--text-sm);
font-weight: 500;
cursor: pointer;
transition: background var(--transition-fast), border-color var(--transition-fast),
color var(--transition-fast);
white-space: nowrap;
}
.btn:hover:not(:disabled) {
background: var(--surface-hover);
border-color: var(--border-strong);
}
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn--primary {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-ink);
}
.btn--primary:hover:not(:disabled) {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
.btn--danger { color: var(--danger); border-color: var(--border); }
.btn--danger:hover:not(:disabled) {
background: var(--danger-soft);
border-color: var(--danger);
}
.btn--ghost { background: transparent; border-color: transparent; }
.btn--ghost:hover:not(:disabled) { background: var(--surface-hover); border-color: transparent; }
.btn--icon {
padding: 0.4rem;
border-radius: var(--radius);
background: transparent;
border-color: transparent;
color: var(--ink-muted);
}
.btn--icon:hover:not(:disabled) { background: var(--surface-hover); color: var(--ink); }
.btn--block { width: 100%; }
.btn--sm { padding: 0.3rem 0.6rem; font-size: var(--text-xs); }
/* --- Forms ---------------------------------------------------------------- */
.field { margin-bottom: var(--sp-4); }
.field__label {
display: block;
margin-bottom: var(--sp-2);
font-size: var(--text-sm);
font-weight: 500;
color: var(--ink-muted);
}
.field__hint {
margin-top: var(--sp-2);
font-size: var(--text-xs);
color: var(--ink-faint);
line-height: var(--leading-normal);
}
.input,
.textarea,
.select {
width: 100%;
padding: 0.55rem 0.7rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-sunken);
color: var(--ink);
font-size: var(--text-base);
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.input:focus,
.textarea:focus,
.select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.input::placeholder, .textarea::placeholder { color: var(--ink-faint); }
.textarea { resize: vertical; min-height: 5rem; line-height: var(--leading-normal); }
.input--mono { font-family: var(--font-mono); font-size: var(--text-sm); }
.checkbox {
display: flex;
align-items: center;
gap: var(--sp-2);
font-size: var(--text-sm);
cursor: pointer;
}
.checkbox input { accent-color: var(--accent); width: 1rem; height: 1rem; }
/* --- Alerts --------------------------------------------------------------- */
.alert {
display: flex;
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4);
border: 1px solid var(--border);
border-left-width: 3px;
border-radius: var(--radius);
font-size: var(--text-sm);
margin-bottom: var(--sp-4);
background: var(--surface);
}
.alert--error { border-left-color: var(--danger); background: var(--danger-soft); color: var(--ink); }
.alert--success { border-left-color: var(--success); background: var(--success-soft); }
.alert--warning { border-left-color: var(--warning); background: var(--warning-soft); }
.alert__icon { color: var(--danger); flex: none; margin-top: 0.15rem; }
/* --- Badges --------------------------------------------------------------- */
.badge {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
padding: 0.1rem 0.45rem;
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: 500;
background: var(--surface-active);
color: var(--ink-muted);
}
.badge--gold { background: var(--gold-soft); color: var(--gold); }
.badge--success { background: var(--success-soft); color: var(--success); }
.badge--danger { background: var(--danger-soft); color: var(--danger); }
/* --- Application shell ---------------------------------------------------- */
.shell {
display: flex;
height: 100dvh;
overflow: hidden;
}
.sidebar {
width: var(--sidebar-width);
flex: none;
display: flex;
flex-direction: column;
background: var(--bg-sunken);
border-right: 1px solid var(--border);
transition: margin-left var(--transition);
}
.sidebar[hidden] { display: none; }
.sidebar__header {
display: flex;
align-items: center;
gap: var(--sp-2);
height: var(--header-height);
padding: 0 var(--sp-3);
flex: none;
}
.sidebar__brand {
display: flex;
align-items: center;
gap: var(--sp-2);
color: var(--ink);
text-decoration: none;
font-family: var(--font-display);
font-size: var(--text-lg);
font-weight: 600;
letter-spacing: 0.01em;
min-width: 0;
}
.sidebar__brand:hover { color: var(--ink); }
.sidebar__brand .brand-mark { width: 1.6rem; height: 1.6rem; flex: none; }
.sidebar__brand .brand-llm { color: var(--gold); }
.sidebar__actions { padding: 0 var(--sp-3) var(--sp-3); display: flex; gap: var(--sp-2); }
.sidebar__scroll {
flex: 1;
overflow-y: auto;
padding: 0 var(--sp-2) var(--sp-3);
scrollbar-width: thin;
scrollbar-color: var(--border-strong) transparent;
}
.sidebar__footer {
flex: none;
border-top: 1px solid var(--border);
padding: var(--sp-2);
}
.main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
background: var(--bg);
}
.topbar {
display: flex;
align-items: center;
gap: var(--sp-3);
height: var(--header-height);
flex: none;
padding: 0 var(--sp-4);
border-bottom: 1px solid var(--border);
background: var(--bg);
}
.topbar__title {
font-family: var(--font-display);
font-size: var(--text-md);
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
flex: 1;
}
.topbar__spacer { flex: 1; }
/* --- Sidebar navigation --------------------------------------------------- */
.nav-group { margin-bottom: var(--sp-4); }
.nav-group__label {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--sp-2) var(--sp-2) var(--sp-1);
font-size: var(--text-xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--ink-faint);
}
.nav-item {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: 0.4rem var(--sp-2);
border-radius: var(--radius);
color: var(--ink-muted);
text-decoration: none;
font-size: var(--text-sm);
cursor: pointer;
position: relative;
}
.nav-item:hover { background: var(--surface-hover); color: var(--ink); }
.nav-item.is-active { background: var(--surface-active); color: var(--ink); font-weight: 500; }
.nav-item__label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
/* Row actions stay hidden until the row is hovered or focused within, so the
list reads calmly, but they remain keyboard reachable. */
.nav-item__actions {
display: flex;
gap: 0.1rem;
opacity: 0;
transition: opacity var(--transition-fast);
}
.nav-item:hover .nav-item__actions,
.nav-item:focus-within .nav-item__actions { opacity: 1; }
.nav-empty {
padding: var(--sp-3) var(--sp-2);
font-size: var(--text-xs);
color: var(--ink-faint);
font-style: italic;
}
/* --- Auth screens --------------------------------------------------------- */
.auth {
min-height: 100dvh;
display: grid;
place-items: center;
padding: var(--sp-6);
background:
radial-gradient(ellipse 60% 50% at 50% 0%, var(--gold-soft), transparent 70%),
var(--bg);
}
.auth__card {
width: 100%;
max-width: 25rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
padding: var(--sp-8);
box-shadow: var(--shadow-lg);
}
.auth__brand { display: grid; place-items: center; gap: var(--sp-3); margin-bottom: var(--sp-6); }
.auth__brand .brand-mark { width: 3.5rem; height: 3.5rem; }
.auth__title {
font-size: var(--text-2xl);
font-family: var(--font-display);
text-align: center;
}
.auth__subtitle {
text-align: center;
color: var(--ink-muted);
font-size: var(--text-sm);
margin-top: var(--sp-2);
}
.auth__footer {
margin-top: var(--sp-5);
padding-top: var(--sp-4);
border-top: 1px solid var(--border);
text-align: center;
font-size: var(--text-sm);
color: var(--ink-muted);
}
/* --- Empty states --------------------------------------------------------- */
.empty {
flex: 1;
display: grid;
place-content: center;
justify-items: center;
gap: var(--sp-3);
padding: var(--sp-10);
text-align: center;
}
.empty__mark { width: 4.5rem; height: 4.5rem; opacity: 0.85; }
.empty__title { font-size: var(--text-xl); font-family: var(--font-display); }
.empty__text {
color: var(--ink-muted);
font-size: var(--text-sm);
max-width: 32rem;
font-style: italic;
}
/* --- Utilities ------------------------------------------------------------ */
.stack > * + * { margin-top: var(--sp-4); }
.row { display: flex; align-items: center; gap: var(--sp-3); }
.row--between { justify-content: space-between; }
.muted { color: var(--ink-muted); }
.faint { color: var(--ink-faint); }
.text-sm { font-size: var(--text-sm); }
.text-xs { font-size: var(--text-xs); }
.mono { font-family: var(--font-mono); }
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* --- Small screens -------------------------------------------------------- */
@media (max-width: 48rem) {
.sidebar {
position: fixed;
inset: 0 auto 0 0;
z-index: 40;
box-shadow: var(--shadow-lg);
}
.sidebar[data-collapsed="true"] { display: none; }
}
+336
View File
@@ -0,0 +1,336 @@
/*
Chat thread, composer, message bodies and code blocks.
Loaded only on chat pages. Like app.css, every value resolves through
tokens.css.
*/
/* --- Thread --------------------------------------------------------------- */
.thread-scroll {
flex: 1;
overflow-y: auto;
scroll-behavior: smooth;
scrollbar-width: thin;
scrollbar-color: var(--border-strong) transparent;
}
.thread {
max-width: var(--thread-max-width);
margin: 0 auto;
padding: var(--sp-6) var(--sp-5) var(--sp-8);
display: flex;
flex-direction: column;
gap: var(--sp-6);
}
.thread__intro {
display: grid;
place-items: center;
gap: var(--sp-3);
text-align: center;
padding: var(--sp-12) 0 var(--sp-6);
}
/* --- Messages ------------------------------------------------------------- */
.msg {
display: grid;
grid-template-columns: 2rem 1fr;
gap: var(--sp-3);
align-items: start;
}
.msg__gutter {
display: grid;
place-items: center;
width: 2rem;
height: 2rem;
border-radius: var(--radius-full);
overflow: hidden;
}
.msg__mark { width: 2rem; height: 2rem; }
.msg__initial {
width: 2rem; height: 2rem;
display: grid;
place-items: center;
border-radius: var(--radius-full);
background: var(--surface-active);
color: var(--ink-muted);
font-size: var(--text-sm);
font-weight: 600;
}
.msg__main { min-width: 0; }
.msg__meta {
display: flex;
align-items: baseline;
gap: var(--sp-2);
margin-bottom: var(--sp-1);
}
.msg__author {
font-family: var(--font-display);
font-size: var(--text-base);
font-weight: 600;
}
.msg__model {
font-size: var(--text-xs);
color: var(--ink-faint);
font-family: var(--font-mono);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 16rem;
}
.msg__body {
line-height: var(--leading-relaxed);
overflow-wrap: break-word;
}
/* User turns and mid-stream assistant text are plain text, so newlines and
runs of spaces have to survive. */
.msg__body--plain,
.msg__body--streaming { white-space: pre-wrap; }
.msg--user .msg__body--plain {
background: var(--bubble-user);
padding: var(--sp-3) var(--sp-4);
border-radius: var(--radius-lg);
display: inline-block;
max-width: 100%;
}
.msg__error { margin: var(--sp-2) 0; align-items: flex-start; }
/* --- Streaming indicator -------------------------------------------------- */
/* Shown until the first token arrives, then hidden by the sibling selector
below -- no JavaScript involved in either direction. */
.msg__waiting { padding: var(--sp-2) 0; }
.msg__body--streaming:not(:empty) + .msg__waiting { display: none; }
.dots { display: inline-flex; gap: 0.25rem; align-items: center; }
.dots i {
width: 0.4rem;
height: 0.4rem;
border-radius: var(--radius-full);
background: var(--ink-faint);
animation: dot-pulse 1.3s ease-in-out infinite;
}
.dots i:nth-child(2) { animation-delay: 0.18s; }
.dots i:nth-child(3) { animation-delay: 0.36s; }
@keyframes dot-pulse {
0%, 60%, 100% { opacity: 0.28; transform: translateY(0); }
30% { opacity: 1; transform: translateY(-2px); }
}
/* A caret trailing the text while it streams. */
.msg__body--streaming::after {
content: "";
display: inline-block;
width: 0.45rem;
height: 1.05em;
margin-left: 1px;
vertical-align: text-bottom;
background: var(--gold);
opacity: 0.75;
animation: caret 1.05s steps(1) infinite;
}
@keyframes caret { 0%, 49% { opacity: 0.75; } 50%, 100% { opacity: 0; } }
/* --- Message actions ------------------------------------------------------ */
.msg__actions {
display: flex;
gap: var(--sp-1);
margin-top: var(--sp-2);
opacity: 0;
transition: opacity var(--transition-fast);
}
.msg:hover .msg__actions,
.msg:focus-within .msg__actions { opacity: 1; }
.msg__actions .is-copied { color: var(--success); }
/* --- Rendered Markdown ---------------------------------------------------- */
.msg__body > :first-child { margin-top: 0; }
.msg__body > :last-child { margin-bottom: 0; }
.msg__body h1, .msg__body h2, .msg__body h3,
.msg__body h4, .msg__body h5, .msg__body h6 {
margin: var(--sp-5) 0 var(--sp-2);
}
.msg__body h1 { font-size: var(--text-xl); }
.msg__body h2 { font-size: var(--text-lg); }
.msg__body h3 { font-size: var(--text-md); }
.msg__body ul, .msg__body ol { margin: 0 0 var(--sp-4); padding-left: var(--sp-6); }
.msg__body li { margin-bottom: var(--sp-1); }
.msg__body blockquote {
margin: 0 0 var(--sp-4);
padding: var(--sp-1) var(--sp-4);
border-left: 3px solid var(--border-strong);
color: var(--ink-muted);
font-style: italic;
}
.msg__body hr { border: 0; border-top: 1px solid var(--border); margin: var(--sp-5) 0; }
.msg__body :not(pre) > code {
font-family: var(--font-mono);
font-size: 0.875em;
padding: 0.13em 0.36em;
border-radius: var(--radius-sm);
background: var(--code-bg);
border: 1px solid var(--code-border);
}
.msg__body table {
width: 100%;
border-collapse: collapse;
margin: 0 0 var(--sp-4);
font-size: var(--text-sm);
display: block;
overflow-x: auto;
}
.msg__body th, .msg__body td {
border: 1px solid var(--border);
padding: var(--sp-2) var(--sp-3);
text-align: left;
}
.msg__body th { background: var(--surface); font-weight: 600; }
.msg__body img { max-width: 100%; height: auto; border-radius: var(--radius); }
/* --- Code blocks ---------------------------------------------------------- */
.code-block {
margin: 0 0 var(--sp-4);
border: 1px solid var(--code-border);
border-radius: var(--radius);
background: var(--code-bg);
overflow: hidden;
}
.code-block__label {
padding: var(--sp-1) var(--sp-3);
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--ink-faint);
border-bottom: 1px solid var(--code-border);
background: color-mix(in srgb, var(--code-bg) 60%, var(--surface));
}
.code-block__pre {
margin: 0;
padding: var(--sp-3) var(--sp-4);
overflow-x: auto;
font-family: var(--font-mono);
font-size: var(--text-sm);
line-height: 1.55;
}
.code-block__pre code { font-family: inherit; background: none; border: 0; padding: 0; }
/*
Pygments token colours, mapped onto theme tokens rather than a fixed scheme,
so code follows the active theme. classprefix "pg-" is set in markdown.py.
*/
.pg-c, .pg-c1, .pg-cm, .pg-cs, .pg-cp { color: var(--ink-faint); font-style: italic; }
.pg-k, .pg-kn, .pg-kd, .pg-kc, .pg-kr, .pg-kt { color: var(--gold); }
.pg-s, .pg-s1, .pg-s2, .pg-sb, .pg-sd, .pg-se, .pg-sh, .pg-si, .pg-sx { color: var(--success); }
.pg-m, .pg-mi, .pg-mf, .pg-mh, .pg-mo { color: var(--danger); }
.pg-nf, .pg-nd { color: var(--accent); }
.pg-nc, .pg-nn { color: var(--accent-hover); font-weight: 600; }
.pg-nb, .pg-bp { color: var(--accent); }
.pg-nv, .pg-vi, .pg-vg, .pg-vc { color: var(--ink); }
.pg-o, .pg-ow, .pg-p { color: var(--ink-muted); }
.pg-err { color: var(--danger); }
.pg-gd { color: var(--danger); }
.pg-gi { color: var(--success); }
/* --- Composer ------------------------------------------------------------- */
.composer {
flex: none;
padding: var(--sp-3) var(--sp-5) var(--sp-4);
border-top: 1px solid var(--border);
background: var(--bg);
}
.composer__form {
max-width: var(--thread-max-width);
margin: 0 auto;
display: flex;
gap: var(--sp-2);
align-items: flex-end;
padding: var(--sp-2);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
background: var(--surface);
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.composer__form:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.composer__input {
flex: 1;
border: 0;
background: none;
resize: none;
padding: var(--sp-2);
font-size: var(--text-base);
line-height: var(--leading-normal);
max-height: 20rem;
}
.composer__input:focus { outline: none; }
.composer__send { border-radius: var(--radius-full); padding: 0.55rem 0.7rem; }
.composer__hint {
max-width: var(--thread-max-width);
margin: var(--sp-2) auto 0;
font-size: var(--text-xs);
color: var(--ink-faint);
text-align: center;
}
.select--compact {
width: auto;
max-width: 16rem;
padding: 0.3rem 0.5rem;
font-size: var(--text-sm);
}
/* --- Folders -------------------------------------------------------------- */
.folder__row { padding-right: var(--sp-1); }
.folder__toggle {
display: flex;
align-items: center;
gap: var(--sp-2);
flex: 1;
min-width: 0;
background: none;
border: 0;
padding: 0;
color: inherit;
font: inherit;
cursor: pointer;
text-align: left;
}
.folder__chevron {
display: inline-flex;
transition: transform var(--transition-fast);
}
.folder__chevron.is-open { transform: rotate(90deg); }
.folder__contents { padding-left: var(--sp-4); }
.nav-item__link {
display: flex;
align-items: center;
gap: var(--sp-2);
flex: 1;
min-width: 0;
color: inherit;
text-decoration: none;
}
/* --- Theme toggle --------------------------------------------------------- */
/* Only the icon for the theme you would switch TO is shown. */
:root[data-theme="moria"] .theme-icon--dark { display: none; }
:root[data-theme="shire"] .theme-icon--light { display: none; }
/* Alpine sets x-cloak until it has initialised; without this, collapsed
folders flash open on every page load. */
[x-cloak] { display: none !important; }
+191
View File
@@ -0,0 +1,191 @@
/*
Design tokens.
Every colour, space and radius in the application resolves through a variable
declared here. Component CSS must never hard-code a hex value -- that is what
makes adding a theme a matter of writing one new block rather than auditing
every stylesheet.
Themes are selected with data-theme on <html>. `moria` is the default and is
declared on :root so the page is styled even before the theme script runs.
*/
:root {
/* --- Type ------------------------------------------------------------- */
--font-display: "Iowan Old Style", "Palatino Linotype", Palatino, Palladio,
"URW Palladio L", "Book Antiqua", Baskerville, Georgia, serif;
--font-body: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue",
Arial, sans-serif;
--font-mono: ui-monospace, "SF Mono", "JetBrains Mono", "Fira Code",
"Cascadia Code", Menlo, Consolas, monospace;
--text-xs: 0.75rem;
--text-sm: 0.8125rem;
--text-base: 0.9375rem;
--text-md: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.375rem;
--text-2xl: 1.75rem;
--text-3xl: 2.25rem;
--leading-tight: 1.25;
--leading-normal: 1.6;
--leading-relaxed: 1.75;
/* --- Space (4px scale) ------------------------------------------------ */
--sp-1: 0.25rem;
--sp-2: 0.5rem;
--sp-3: 0.75rem;
--sp-4: 1rem;
--sp-5: 1.25rem;
--sp-6: 1.5rem;
--sp-8: 2rem;
--sp-10: 2.5rem;
--sp-12: 3rem;
--sp-16: 4rem;
/* --- Radius & shadow -------------------------------------------------- */
--radius-sm: 4px;
--radius: 8px;
--radius-lg: 12px;
--radius-xl: 18px;
--radius-full: 999px;
/* --- Layout ----------------------------------------------------------- */
--sidebar-width: 17rem;
--thread-max-width: 48rem;
--header-height: 3.25rem;
--transition-fast: 120ms ease;
--transition: 200ms ease;
}
/*
---------------------------------------------------------------------------
MORIA (default, dark)
Deep stone and lamplight: the halls under the mountain. Surfaces are cool and
near-neutral so the gold and mithril accents carry all the colour.
---------------------------------------------------------------------------
*/
:root,
:root[data-theme="moria"] {
color-scheme: dark;
--bg: #101317;
--bg-sunken: #0B0E11;
--surface: #171B21;
--surface-raised: #1E242B;
--surface-hover: #232A32;
--surface-active: #2A323B;
--border: #2A313A;
--border-strong: #3A434E;
--ink: #E4E8EC;
--ink-muted: #A2ADB8;
--ink-faint: #6E7883;
--ink-inverse: #0B0E11;
/* Mithril: the cool primary, used for focus and interactive accents. */
--accent: #8FB3CC;
--accent-hover: #A9C6DA;
--accent-ink: #0B0E11;
--accent-soft: rgba(143, 179, 204, 0.14);
/* Rune gold: the warm accent. Brand colour, and the assistant's mark. */
--gold: #E0B252;
--gold-hover: #EDC46F;
--gold-soft: rgba(224, 178, 82, 0.13);
/* Ember: destructive actions and errors. */
--danger: #E2795A;
--danger-hover: #EC8E72;
--danger-soft: rgba(226, 121, 90, 0.14);
--success: #7FB77E;
--success-soft: rgba(127, 183, 126, 0.14);
--warning: #DFAE58;
--warning-soft: rgba(223, 174, 88, 0.14);
--bubble-user: #232B34;
--bubble-assistant: transparent;
--code-bg: #0C0F13;
--code-border: #262D36;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
--shadow: 0 4px 14px rgba(0, 0, 0, 0.45);
--shadow-lg: 0 12px 34px rgba(0, 0, 0, 0.55);
--scrim: rgba(6, 8, 10, 0.66);
}
/*
---------------------------------------------------------------------------
SHIRE (light)
Parchment, ink and moss: warm, low-contrast, easy to read for a long time.
Backgrounds are deliberately off-white -- pure #FFF next to the gold accent
reads as clinical rather than as paper.
---------------------------------------------------------------------------
*/
:root[data-theme="shire"] {
color-scheme: light;
--bg: #F6F1E4;
--bg-sunken: #EDE6D4;
--surface: #FDFBF5;
--surface-raised: #FFFFFF;
--surface-hover: #F1EADA;
--surface-active: #E7DEC9;
--border: #DED3BB;
--border-strong: #C6B896;
--ink: #2C2419;
--ink-muted: #6A5C48;
--ink-faint: #94856D;
--ink-inverse: #FDFBF5;
/* Hobbit-door blue-green: the cool primary. */
--accent: #3E6B7A;
--accent-hover: #325867;
--accent-ink: #FDFBF5;
--accent-soft: rgba(62, 107, 122, 0.12);
--gold: #A8801A;
--gold-hover: #8E6B12;
--gold-soft: rgba(168, 128, 26, 0.13);
--danger: #A6432B;
--danger-hover: #8C3722;
--danger-soft: rgba(166, 67, 43, 0.11);
--success: #4F7A3F;
--success-soft: rgba(79, 122, 63, 0.12);
--warning: #98701A;
--warning-soft: rgba(152, 112, 26, 0.13);
--bubble-user: #EDE4CF;
--bubble-assistant: transparent;
--code-bg: #F2EBD9;
--code-border: #DED3BB;
--shadow-sm: 0 1px 2px rgba(72, 58, 34, 0.09);
--shadow: 0 4px 14px rgba(72, 58, 34, 0.11);
--shadow-lg: 0 12px 34px rgba(72, 58, 34, 0.16);
--scrim: rgba(44, 36, 25, 0.4);
}
/* Respect a stated preference for reduced motion everywhere, at once. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
+264
View File
@@ -0,0 +1,264 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 420"
width="1280" height="420" role="img"
aria-label="LLeMbas - Waybread for the long road of thought">
<title>LLeMbas</title>
<desc>Waybread for the long road of thought. A mallorn leaf and wafer above the mountains at night.</desc>
<defs>
<linearGradient id="b-sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#080B0F"/>
<stop offset="0.62" stop-color="#101822"/>
<stop offset="1" stop-color="#1A2530"/>
</linearGradient>
<radialGradient id="b-glow" cx="0.5" cy="0.54" r="0.5">
<stop offset="0" stop-color="#C9A227" stop-opacity="0.22"/>
<stop offset="1" stop-color="#C9A227" stop-opacity="0"/>
</radialGradient>
<!-- Cool light sitting just above the ridge line, so the far mountains
separate from the near ones instead of merging into one dark mass. -->
<radialGradient id="b-horizon" cx="0.5" cy="1" r="0.72">
<stop offset="0" stop-color="#4E6C86" stop-opacity="0.30"/>
<stop offset="1" stop-color="#4E6C86" stop-opacity="0"/>
</radialGradient>
<linearGradient id="b-wafer" x1="0" y1="0" x2="0.3" y2="1">
<stop offset="0" stop-color="#EACB74"/>
<stop offset="0.5" stop-color="#C9A227"/>
<stop offset="1" stop-color="#916F13"/>
</linearGradient>
<linearGradient id="b-leaf" x1="0.1" y1="1" x2="0.9" y2="0">
<stop offset="0" stop-color="#93A5B6"/>
<stop offset="0.4" stop-color="#F1F6FA"/>
<stop offset="1" stop-color="#B8C7D5"/>
</linearGradient>
<clipPath id="b-clip">
<rect x="6" y="6" width="52" height="52" rx="13"/>
</clipPath>
</defs>
<rect width="1280" height="420" fill="url(#b-sky)"/>
<g fill="#FFFFFF">
<circle cx="579.0" cy="167.9" r="1.80" opacity="0.49"/>
<circle cx="650.0" cy="176.2" r="0.84" opacity="0.52"/>
<circle cx="806.2" cy="237.9" r="0.72" opacity="0.38"/>
<circle cx="116.1" cy="242.9" r="1.50" opacity="0.21"/>
<circle cx="1257.2" cy="289.4" r="1.45" opacity="0.59"/>
<circle cx="201.6" cy="4.5" r="1.29" opacity="0.22"/>
<circle cx="243.5" cy="72.6" r="0.64" opacity="0.49"/>
<circle cx="563.9" cy="252.7" r="1.27" opacity="0.61"/>
<circle cx="639.7" cy="198.7" r="1.19" opacity="0.37"/>
<circle cx="1277.0" cy="298.7" r="1.69" opacity="0.65"/>
<circle cx="403.6" cy="68.9" r="0.98" opacity="0.23"/>
<circle cx="980.8" cy="120.1" r="1.70" opacity="0.44"/>
<circle cx="1226.3" cy="254.2" r="0.60" opacity="0.32"/>
<circle cx="1165.1" cy="141.0" r="1.87" opacity="0.45"/>
<circle cx="93.5" cy="188.8" r="1.61" opacity="0.36"/>
<circle cx="111.5" cy="99.8" r="1.85" opacity="0.69"/>
<circle cx="151.0" cy="73.9" r="0.73" opacity="0.22"/>
<circle cx="1020.2" cy="53.3" r="1.33" opacity="0.48"/>
<circle cx="244.1" cy="219.6" r="0.77" opacity="0.61"/>
<circle cx="149.1" cy="126.2" r="0.88" opacity="0.36"/>
<circle cx="1242.8" cy="241.0" r="1.00" opacity="0.77"/>
<circle cx="269.7" cy="118.3" r="1.71" opacity="0.61"/>
<circle cx="128.4" cy="296.8" r="0.88" opacity="0.35"/>
<circle cx="989.0" cy="98.7" r="0.99" opacity="0.23"/>
<circle cx="115.3" cy="174.8" r="0.92" opacity="0.58"/>
<circle cx="475.8" cy="136.0" r="1.85" opacity="0.50"/>
<circle cx="735.5" cy="260.0" r="0.84" opacity="0.28"/>
<circle cx="1162.8" cy="245.3" r="0.92" opacity="0.31"/>
<circle cx="946.5" cy="282.1" r="0.86" opacity="0.82"/>
<circle cx="1129.2" cy="181.1" r="1.15" opacity="0.25"/>
<circle cx="49.5" cy="288.8" r="0.91" opacity="0.65"/>
<circle cx="328.9" cy="247.1" r="1.38" opacity="0.38"/>
<circle cx="224.6" cy="216.1" r="0.69" opacity="0.33"/>
<circle cx="716.0" cy="255.7" r="1.40" opacity="0.37"/>
<circle cx="1174.2" cy="61.2" r="0.62" opacity="0.36"/>
<circle cx="570.5" cy="18.1" r="0.83" opacity="0.43"/>
<circle cx="732.4" cy="39.5" r="1.07" opacity="0.78"/>
<circle cx="1255.0" cy="197.1" r="1.50" opacity="0.57"/>
<circle cx="179.6" cy="10.5" r="0.62" opacity="0.79"/>
<circle cx="897.2" cy="288.8" r="0.63" opacity="0.61"/>
<circle cx="617.3" cy="219.1" r="1.01" opacity="0.85"/>
<circle cx="96.3" cy="163.8" r="1.56" opacity="0.78"/>
<circle cx="943.5" cy="211.1" r="1.63" opacity="0.79"/>
<circle cx="450.3" cy="205.5" r="1.77" opacity="0.76"/>
<circle cx="534.0" cy="237.2" r="1.72" opacity="0.56"/>
<circle cx="799.9" cy="114.7" r="1.36" opacity="0.59"/>
<circle cx="102.7" cy="191.8" r="1.89" opacity="0.77"/>
<circle cx="932.1" cy="116.5" r="1.56" opacity="0.57"/>
<circle cx="563.9" cy="251.5" r="0.71" opacity="0.68"/>
<circle cx="38.1" cy="180.4" r="1.23" opacity="0.33"/>
<circle cx="893.9" cy="149.2" r="1.40" opacity="0.80"/>
<circle cx="327.5" cy="3.4" r="0.99" opacity="0.63"/>
<circle cx="259.3" cy="50.9" r="1.78" opacity="0.62"/>
<circle cx="565.7" cy="267.5" r="1.03" opacity="0.63"/>
<circle cx="254.1" cy="129.3" r="1.65" opacity="0.79"/>
<circle cx="1126.7" cy="115.3" r="1.36" opacity="0.39"/>
<circle cx="174.3" cy="148.9" r="1.69" opacity="0.75"/>
<circle cx="910.4" cy="285.0" r="0.96" opacity="0.29"/>
<circle cx="576.8" cy="82.5" r="0.88" opacity="0.46"/>
<circle cx="800.9" cy="148.2" r="1.01" opacity="0.74"/>
<circle cx="1257.0" cy="135.7" r="0.70" opacity="0.20"/>
<circle cx="1117.2" cy="12.4" r="1.52" opacity="0.56"/>
<circle cx="395.6" cy="237.5" r="0.62" opacity="0.27"/>
<circle cx="582.2" cy="7.4" r="1.68" opacity="0.34"/>
<circle cx="180.3" cy="14.1" r="1.42" opacity="0.48"/>
<circle cx="806.4" cy="196.5" r="1.65" opacity="0.82"/>
<circle cx="876.2" cy="59.8" r="1.22" opacity="0.30"/>
<circle cx="13.8" cy="141.7" r="1.53" opacity="0.30"/>
<circle cx="348.6" cy="103.7" r="1.51" opacity="0.53"/>
<circle cx="786.5" cy="226.9" r="1.11" opacity="0.71"/>
<circle cx="1160.0" cy="26.2" r="1.81" opacity="0.66"/>
<circle cx="166.3" cy="136.1" r="1.41" opacity="0.79"/>
<circle cx="482.3" cy="170.6" r="1.74" opacity="0.71"/>
<circle cx="1208.7" cy="139.1" r="1.45" opacity="0.32"/>
<circle cx="924.1" cy="245.5" r="1.43" opacity="0.66"/>
<circle cx="273.0" cy="270.0" r="1.87" opacity="0.83"/>
<circle cx="687.3" cy="237.2" r="1.02" opacity="0.79"/>
<circle cx="1095.4" cy="104.6" r="0.71" opacity="0.48"/>
<circle cx="704.4" cy="230.5" r="1.23" opacity="0.20"/>
<circle cx="1035.7" cy="19.2" r="1.64" opacity="0.30"/>
<circle cx="428.8" cy="236.4" r="0.78" opacity="0.28"/>
<circle cx="661.2" cy="217.1" r="1.69" opacity="0.64"/>
<circle cx="1210.6" cy="147.8" r="1.83" opacity="0.24"/>
<circle cx="283.4" cy="158.0" r="0.98" opacity="0.67"/>
<circle cx="817.8" cy="156.8" r="1.70" opacity="0.56"/>
<circle cx="399.0" cy="114.4" r="1.70" opacity="0.78"/>
<circle cx="266.5" cy="255.2" r="1.86" opacity="0.53"/>
<circle cx="733.4" cy="60.3" r="1.30" opacity="0.52"/>
<circle cx="774.7" cy="8.3" r="1.86" opacity="0.53"/>
<circle cx="512.7" cy="240.3" r="1.33" opacity="0.51"/>
<circle cx="884.5" cy="19.8" r="1.30" opacity="0.46"/>
<circle cx="1224.8" cy="277.0" r="0.95" opacity="0.50"/>
<circle cx="162.5" cy="130.1" r="1.66" opacity="0.78"/>
<circle cx="610.0" cy="95.2" r="0.85" opacity="0.59"/>
<circle cx="1184.3" cy="38.8" r="1.61" opacity="0.20"/>
<circle cx="248.5" cy="68.2" r="1.49" opacity="0.40"/>
<circle cx="454.8" cy="185.9" r="0.74" opacity="0.67"/>
<circle cx="157.2" cy="153.1" r="0.93" opacity="0.31"/>
<circle cx="678.9" cy="131.0" r="1.09" opacity="0.46"/>
<circle cx="677.6" cy="47.9" r="0.87" opacity="0.60"/>
<circle cx="817.2" cy="158.9" r="1.71" opacity="0.59"/>
<circle cx="1096.7" cy="69.8" r="1.56" opacity="0.72"/>
<circle cx="1155.4" cy="94.8" r="1.01" opacity="0.80"/>
<circle cx="279.2" cy="299.5" r="1.75" opacity="0.27"/>
<circle cx="306.4" cy="218.0" r="0.94" opacity="0.25"/>
<circle cx="1065.2" cy="126.5" r="1.63" opacity="0.26"/>
<circle cx="515.6" cy="205.6" r="0.62" opacity="0.31"/>
<circle cx="873.5" cy="273.4" r="1.86" opacity="0.26"/>
<circle cx="647.3" cy="227.4" r="1.25" opacity="0.64"/>
<circle cx="241.9" cy="21.2" r="0.74" opacity="0.21"/>
<circle cx="706.1" cy="154.4" r="1.34" opacity="0.28"/>
<circle cx="236.2" cy="61.2" r="1.69" opacity="0.84"/>
<circle cx="1186.4" cy="28.6" r="0.68" opacity="0.82"/>
<circle cx="591.5" cy="229.4" r="1.02" opacity="0.49"/>
<circle cx="659.6" cy="129.0" r="1.38" opacity="0.19"/>
<circle cx="897.3" cy="253.3" r="0.84" opacity="0.48"/>
<circle cx="946.3" cy="121.6" r="0.85" opacity="0.29"/>
<circle cx="656.1" cy="4.6" r="1.76" opacity="0.72"/>
<circle cx="902.0" cy="258.2" r="1.42" opacity="0.45"/>
<circle cx="767.5" cy="151.3" r="1.88" opacity="0.72"/>
<circle cx="330.6" cy="273.4" r="1.57" opacity="0.70"/>
<circle cx="1042.7" cy="121.7" r="1.77" opacity="0.77"/>
<circle cx="889.3" cy="230.2" r="1.59" opacity="0.45"/>
<circle cx="925.0" cy="21.2" r="1.04" opacity="0.49"/>
<circle cx="13.6" cy="106.7" r="1.43" opacity="0.60"/>
<circle cx="297.1" cy="283.4" r="1.47" opacity="0.41"/>
<circle cx="844.5" cy="170.9" r="1.29" opacity="0.44"/>
<circle cx="1279.9" cy="192.7" r="1.51" opacity="0.69"/>
<circle cx="1254.5" cy="6.8" r="1.40" opacity="0.67"/>
<circle cx="328.5" cy="120.5" r="0.67" opacity="0.31"/>
</g>
<rect y="180" width="1280" height="240" fill="url(#b-horizon)"/>
<rect width="1280" height="420" fill="url(#b-glow)"/>
<g transform="translate(120 90) rotate(-18) scale(0.42) translate(-32 -32)" opacity="0.16"><path d="M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z" fill="#E0B252"/></g>
<g transform="translate(250 250) rotate(24) scale(0.3) translate(-32 -32)" opacity="0.17"><path d="M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z" fill="#E0B252"/></g>
<g transform="translate(1035 95) rotate(12) scale(0.36) translate(-32 -32)" opacity="0.17"><path d="M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z" fill="#E0B252"/></g>
<g transform="translate(1160 215) rotate(-32) scale(0.46) translate(-32 -32)" opacity="0.18"><path d="M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z" fill="#E0B252"/></g>
<g transform="translate(905 300) rotate(40) scale(0.26) translate(-32 -32)" opacity="0.17"><path d="M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z" fill="#E0B252"/></g>
<g transform="translate(185 300) rotate(-8) scale(0.24) translate(-32 -32)" opacity="0.18"><path d="M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z" fill="#E0B252"/></g>
<!-- Ridge lines, furthest first. Each is lighter than the one in front of it,
which is what reads as distance. -->
<polygon points="0.0,366.0 77.4,260.4 99.7,287.8 209.3,307.1 222.5,340.5 301.6,290.7 339.9,314.6 467.1,267.1 496.3,282.9 606.7,228.9 632.9,259.8 746.4,307.3 778.6,334.3 861.2,310.5 896.2,334.5 1013.6,227.8 1044.7,263.3 1135.1,235.4 1159.3,271.3 1280.0,304.0 1280.0,366.0 1280,999 0,999" fill="#1C2836"/>
<polygon points="0.0,392.0 76.5,282.7 92.5,305.1 157.2,334.8 195.6,347.7 306.6,319.4 331.0,337.8 404.6,292.3 419.7,305.8 478.9,333.4 502.2,359.5 591.3,344.5 610.7,372.4 673.6,307.7 696.0,329.2 781.8,302.5 807.3,323.8 939.9,310.5 956.3,320.6 1092.6,317.2 1110.4,344.2 1216.2,299.7 1251.5,314.1 1280.0,288.9 1280.0,392.0 1280,999 0,999" fill="#111A25"/>
<polygon points="0.0,416.0 71.3,356.9 100.4,368.9 176.0,352.0 209.4,364.3 309.1,378.7 321.9,389.3 428.2,386.8 461.4,395.6 538.3,388.1 576.7,403.3 707.1,360.5 720.7,370.5 819.7,384.5 856.9,395.1 927.4,350.8 943.4,368.4 1038.7,363.5 1060.3,375.6 1133.4,388.3 1168.4,397.2 1280.0,380.1 1280.0,416.0 1280,999 0,999" fill="#080D13"/>
<rect y="415" width="1280" height="5" fill="#C9A227" opacity="0.55"/>
<!-- Lockup. Colours are fixed rather than themed: the banner carries its own
night sky, so it must not follow the reader's colour scheme. -->
<g transform="translate(304.75 118.00) scale(2.1250)">
<rect x="6" y="6" width="52" height="52" rx="13" fill="url(#b-wafer)"/>
<g clip-path="url(#b-clip)" fill="none" stroke-linecap="round">
<g stroke="#7A5C10" stroke-opacity="0.38" stroke-width="2">
<path d="M32 6 V58"/>
<path d="M6 32 H58"/>
</g>
<g stroke="#F6E3A8" stroke-opacity="0.3" stroke-width="1">
<path d="M33.2 6 V58"/>
<path d="M6 33.2 H58"/>
</g>
</g>
<rect x="7.1" y="7.1" width="49.8" height="49.8" rx="11.9"
fill="none" stroke="#7A5C10" stroke-opacity="0.3" stroke-width="1.2"/>
<g>
<path d="M21.2 44.8 L17 49.4" stroke="#8A9AA8" stroke-width="3"
stroke-linecap="round" fill="none"/>
<path d="M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z" fill="url(#b-leaf)"/>
<path d="M20.5 45.5 C28 38 36 29 45.5 18.5" fill="none" stroke="#61758A" stroke-opacity="0.5"
stroke-width="1.5" stroke-linecap="round"/>
<g fill="none" stroke="#61758A" stroke-opacity="0.32"
stroke-width="1" stroke-linecap="round">
<path d="M26.9 38.8 Q25.2 35.8 24.9 32.1"/>
<path d="M32.3 33.1 Q30.9 30.3 30.4 26.9"/>
<path d="M37.8 27.0 Q36.6 24.6 36.3 21.7"/>
<path d="M26.9 38.8 Q30.5 40.1 33.7 40.3"/>
<path d="M32.3 33.1 Q35.8 34.3 38.6 34.5"/>
<path d="M37.8 27.0 Q40.8 27.9 43.2 28.1"/>
</g>
</g>
</g>
<g transform="translate(470.08 232.00)">
<style>.base { fill: #EDE6D6; } .accent { fill: #E0B252; }</style>
<path class="accent" data-char="L" d="M4.67 -88.57 14.83 -87.33C15.24 -76.48 15.24 -61.52 15.24 -49.02V-42.98C15.24 -30.21 15.24 -14.83 14.83 -3.84L4.67 -2.61V0.00H65.91L67.56 -26.78H64.95L56.30 -3.43H29.93C29.52 -14.28 29.39 -29.93 29.39 -42.98V-49.02C29.39 -61.52 29.52 -76.48 29.93 -87.33L39.96 -88.57V-91.18H4.67Z"/>
<path class="accent" data-char="L" d="M75.52 -88.57 85.68 -87.33C86.10 -76.48 86.10 -61.52 86.10 -49.02V-42.98C86.10 -30.21 86.10 -14.83 85.68 -3.84L75.52 -2.61V0.00H136.76L138.41 -26.78H135.80L127.15 -3.43H100.79C100.38 -14.28 100.24 -29.93 100.24 -42.98V-49.02C100.24 -61.52 100.38 -76.48 100.79 -87.33L110.81 -88.57V-91.18H75.52Z"/>
<path class="base" data-char="e" d="M175.76 -60.97C182.76 -60.97 187.43 -55.47 187.43 -45.18C187.43 -39.13 185.37 -37.07 179.06 -37.07H161.07C162.03 -54.65 168.76 -60.97 175.76 -60.97ZM175.90 1.79C186.20 1.79 194.85 -2.88 199.79 -13.46L197.87 -14.83C193.75 -9.75 188.39 -6.45 180.98 -6.45C169.44 -6.45 160.93 -16.20 160.93 -32.82V-33.92H198.69C199.24 -35.84 199.52 -37.49 199.52 -40.51C199.52 -54.79 189.63 -64.26 175.62 -64.26C160.38 -64.26 146.93 -51.49 146.93 -30.07C146.93 -9.89 159.70 1.79 175.90 1.79Z"/>
<path class="accent" data-char="M" d="M209.95 0.00H235.63V-2.61L224.64 -3.84V-80.33L253.21 0.00H258.29L286.85 -81.29V-42.98C286.85 -30.21 286.71 -14.69 286.30 -3.84L276.96 -2.61V0.00H311.56V-2.61L301.54 -3.84C301.13 -14.69 300.99 -30.21 300.99 -42.98V-49.02C300.99 -61.52 301.13 -76.48 301.54 -87.33L311.56 -88.57V-91.18H286.30L261.03 -20.32L236.04 -91.18H209.95V-88.57L220.66 -87.19V-3.84L209.95 -2.61Z"/>
<path class="base" data-char="b" d="M320.76 0.00 343.01 1.51V-8.51C347.68 -1.10 353.86 1.79 360.59 1.79C375.28 1.79 386.26 -10.99 386.26 -32.82C386.26 -53.55 375.69 -64.26 361.96 -64.26C353.58 -64.26 347.13 -59.59 343.01 -52.59V-72.50L343.56 -99.96L342.05 -101.34L320.21 -95.57V-93.37L329.69 -91.18V-28.56C329.69 -21.42 329.55 -11.40 329.28 -3.84L320.76 -2.61ZM356.19 -57.26C365.25 -57.26 372.12 -49.84 372.12 -31.99C372.12 -13.73 365.12 -5.36 356.47 -5.36C350.97 -5.36 347.40 -7.00 343.01 -11.67V-49.57C346.72 -54.24 351.11 -57.26 356.19 -57.26Z"/>
<path class="base" data-char="a" d="M442.97 1.51C449.15 1.51 453.00 -1.92 455.06 -6.87L453.41 -8.24C451.21 -5.77 449.98 -4.81 447.92 -4.81C445.58 -4.81 444.21 -6.32 444.21 -10.71V-41.33C444.21 -57.26 437.89 -64.26 423.89 -64.26C410.02 -64.26 400.13 -57.81 398.62 -48.47C399.31 -44.76 401.09 -42.70 404.80 -42.70C408.51 -42.70 411.67 -45.18 412.35 -51.08L413.86 -59.73C415.65 -60.28 417.30 -60.56 419.22 -60.56C427.59 -60.56 430.75 -56.30 430.75 -42.84V-38.04C425.53 -36.80 421.14 -35.56 418.12 -34.47C400.96 -28.97 396.29 -22.24 396.29 -13.73C396.29 -3.71 403.29 1.79 412.49 1.79C419.63 1.79 425.40 -0.82 430.89 -7.96C431.85 -1.92 435.83 1.51 442.97 1.51ZM409.47 -16.89C409.47 -22.93 412.21 -27.87 421.55 -31.99C423.34 -32.82 426.50 -33.92 430.75 -35.29V-10.71C426.22 -7.00 423.20 -6.18 419.08 -6.18C413.59 -6.18 409.47 -9.34 409.47 -16.89Z"/>
<path class="base" data-char="s" d="M481.56 1.79C496.80 1.79 505.18 -5.90 505.18 -17.85C505.18 -28.97 496.94 -33.09 488.84 -36.53L484.99 -38.17C478.26 -41.06 472.50 -44.08 472.50 -50.94C472.50 -56.99 476.89 -61.24 484.58 -61.24C488.01 -61.24 490.76 -60.83 493.23 -59.59L499.13 -43.53H501.47L502.02 -59.46C496.66 -62.61 491.44 -64.26 484.58 -64.26C469.89 -64.26 462.47 -56.02 462.47 -45.31C462.47 -34.47 470.30 -30.21 478.13 -26.91L481.97 -25.27C488.70 -22.38 494.60 -19.50 494.60 -12.50C494.60 -5.90 489.93 -1.10 481.56 -1.10C477.30 -1.10 474.28 -1.65 471.26 -2.75L465.08 -20.46H462.88L462.06 -3.43C468.24 0.00 473.87 1.79 481.56 1.79Z"/>
</g>
<g transform="translate(351.54 300.00)">
<style>.tag { fill: #9AA7B4; }</style>
<path class="tag" data-char="W" d="M7.61 0.23H8.46L19.17 -22.00L21.34 0.23H22.20L33.80 -25.03L36.56 -25.38L36.71 -26.00H29.07L28.95 -25.38L32.56 -25.03L23.56 -5.01L21.58 -25.03L24.87 -25.38L24.99 -26.00H16.45L16.34 -25.38L19.56 -25.03L9.86 -4.97L8.58 -25.03L12.15 -25.38L12.26 -26.00H3.34L3.22 -25.38L5.78 -25.03Z"/>
<path class="tag" data-char="a" d="M37.72 -5.12C37.72 -7.68 38.77 -11.64 40.82 -14.09C41.91 -15.41 43.31 -16.30 44.94 -16.30C46.06 -16.30 46.92 -15.83 47.58 -15.25L45.64 -5.63C43.04 -2.64 41.21 -1.44 39.85 -1.44C38.22 -1.44 37.72 -2.91 37.72 -5.12ZM46.96 0.47C49.28 0.47 50.84 -1.71 52.04 -3.69L51.57 -4.07C50.21 -2.52 48.93 -1.51 48.00 -1.51C47.61 -1.51 47.38 -1.75 47.38 -2.13C47.38 -2.68 47.50 -3.14 47.65 -3.96L50.53 -18.01L50.21 -18.32L48.47 -16.96C47.69 -17.62 46.72 -18.01 45.79 -18.01C41.02 -18.01 35.20 -9.93 35.20 -4.19C35.20 -0.93 36.75 0.47 38.61 0.47C41.02 0.47 43.27 -1.63 45.40 -4.54C44.90 -2.25 44.90 -1.79 44.90 -1.40C44.90 -0.19 45.87 0.47 46.96 0.47Z"/>
<path class="tag" data-char="y" d="M50.87 10.05C51.69 10.05 52.54 9.86 53.47 9.31C56.97 7.30 59.80 3.14 61.78 0.00C63.72 -3.10 65.23 -5.90 66.55 -8.34C67.95 -10.83 68.73 -12.30 69.46 -13.89C69.73 -14.55 70.32 -15.76 70.32 -16.76C70.32 -17.50 70.04 -18.16 69.15 -18.16C68.03 -18.16 67.56 -17.39 66.98 -14.47C65.85 -8.89 64.26 -5.70 61.39 -0.85C61.31 -5.32 60.81 -11.76 60.34 -15.02C60.03 -17.11 59.33 -18.01 57.82 -18.01C56.04 -18.01 54.95 -16.45 53.71 -13.50L54.17 -13.16C55.41 -15.13 56.07 -16.03 56.85 -16.03C57.36 -16.03 57.74 -15.56 57.94 -13.93C58.40 -10.17 59.06 -3.61 59.33 2.17C57.78 4.46 56.07 6.52 53.82 8.11C53.47 8.38 53.05 8.69 52.66 8.89L52.16 8.34C51.34 7.41 50.49 6.91 49.48 6.91C48.62 6.91 47.73 7.37 47.58 8.19C47.93 9.51 49.32 10.05 50.87 10.05Z"/>
<path class="tag" data-char="b" d="M75.40 -4.35C75.40 -6.09 76.02 -8.11 76.18 -8.93L76.80 -11.91C79.44 -14.90 81.34 -16.10 82.73 -16.10C84.36 -16.10 85.02 -14.79 85.02 -12.42C85.02 -9.82 83.94 -5.86 81.88 -3.41C80.79 -2.13 79.47 -1.28 77.84 -1.28C75.94 -1.28 75.40 -2.72 75.40 -4.35ZM76.76 0.47C81.80 0.47 87.55 -7.61 87.55 -13.35C87.55 -16.61 85.84 -18.01 83.94 -18.01C81.57 -18.01 79.20 -15.91 76.99 -12.96L80.21 -28.56L79.82 -28.87L74.08 -27.24L74.00 -26.70L77.30 -26.12L73.61 -8.34C73.30 -6.91 72.92 -5.36 72.92 -4.00C72.92 -1.40 74.04 0.47 76.76 0.47Z"/>
<path class="tag" data-char="r" d="M90.92 0.00 91.23 0.31 93.56 0.00C93.95 -2.64 94.38 -5.20 94.88 -7.76L95.39 -10.28C96.70 -12.81 98.14 -14.94 99.54 -16.10C100.35 -15.25 101.09 -14.82 101.90 -14.82C103.11 -14.82 103.88 -15.64 103.92 -16.69C103.57 -17.73 102.68 -18.01 101.75 -18.01C99.69 -18.01 97.79 -16.10 95.58 -11.84L96.78 -17.70L96.43 -18.01L90.84 -16.38L90.77 -15.83L94.10 -15.29Z"/>
<path class="tag" data-char="e" d="M114.09 -17.23C115.29 -17.23 115.80 -16.22 115.80 -15.06C115.80 -12.46 113.70 -9.74 107.18 -7.80C107.88 -13.19 111.64 -17.23 114.09 -17.23ZM109.94 0.47C112.89 0.47 115.10 -1.40 116.57 -4.00L116.11 -4.31C115.02 -3.07 113.04 -1.47 111.10 -1.47C108.89 -1.47 107.07 -2.95 107.07 -5.98C107.07 -6.33 107.07 -6.67 107.10 -7.02C116.07 -9.55 118.05 -12.42 118.05 -15.06C118.05 -17.04 116.69 -18.01 114.36 -18.01C109.94 -18.01 104.62 -12.19 104.62 -5.32C104.62 -1.55 106.79 0.47 109.94 0.47Z"/>
<path class="tag" data-char="a" d="M122.12 -5.12C122.12 -7.68 123.17 -11.64 125.23 -14.09C126.31 -15.41 127.71 -16.30 129.34 -16.30C130.47 -16.30 131.32 -15.83 131.98 -15.25L130.04 -5.63C127.44 -2.64 125.61 -1.44 124.26 -1.44C122.63 -1.44 122.12 -2.91 122.12 -5.12ZM131.36 0.47C133.69 0.47 135.24 -1.71 136.44 -3.69L135.98 -4.07C134.62 -2.52 133.34 -1.51 132.41 -1.51C132.02 -1.51 131.79 -1.75 131.79 -2.13C131.79 -2.68 131.90 -3.14 132.06 -3.96L134.93 -18.01L134.62 -18.32L132.87 -16.96C132.10 -17.62 131.13 -18.01 130.19 -18.01C125.42 -18.01 119.60 -9.93 119.60 -4.19C119.60 -0.93 121.15 0.47 123.01 0.47C125.42 0.47 127.67 -1.63 129.81 -4.54C129.30 -2.25 129.30 -1.79 129.30 -1.40C129.30 -0.19 130.27 0.47 131.36 0.47Z"/>
<path class="tag" data-char="d" d="M141.41 -5.12C141.41 -7.92 142.69 -12.30 145.02 -14.67C146.03 -15.64 147.27 -16.30 148.63 -16.30C149.75 -16.30 150.61 -15.83 151.27 -15.25L149.29 -5.51C146.69 -2.60 144.90 -1.44 143.54 -1.44C141.91 -1.44 141.41 -2.91 141.41 -5.12ZM150.64 0.47C152.97 0.47 154.53 -1.71 155.73 -3.69L155.26 -4.07C153.90 -2.52 152.62 -1.51 151.69 -1.51C151.30 -1.51 151.07 -1.75 151.07 -2.13C151.07 -2.68 151.19 -3.14 151.34 -3.96L156.43 -28.56L156.08 -28.87L150.37 -27.24L150.26 -26.70L153.59 -26.12L151.77 -17.27C151.07 -17.73 150.26 -18.01 149.48 -18.01C144.71 -18.01 138.89 -9.93 138.89 -4.19C138.89 -0.93 140.44 0.47 142.30 0.47C144.71 0.47 146.96 -1.63 149.05 -4.50L148.90 -3.65C148.63 -2.33 148.59 -1.82 148.59 -1.36C148.59 -0.19 149.56 0.47 150.64 0.47Z"/>
<path class="tag" data-char="f" d="M161.32 10.05C163.22 10.05 164.81 9.08 166.09 7.57C167.95 5.32 169.16 2.02 169.62 -0.97C170.44 -6.17 171.25 -11.41 172.07 -16.61H176.99L177.19 -17.54H172.22C172.30 -17.97 172.34 -18.39 172.41 -18.82C173.35 -24.84 175.29 -27.63 177.30 -28.60L178.70 -27.01C179.48 -26.08 180.02 -25.61 180.84 -25.61C181.42 -25.61 182.04 -25.88 182.19 -26.74C181.96 -28.21 180.06 -29.26 178.12 -29.26C175.67 -29.26 171.33 -27.13 170.01 -18.74C169.97 -18.39 169.89 -18.01 169.85 -17.66L166.24 -17.23V-16.61H169.70C168.88 -11.37 168.07 -6.17 167.25 -0.97C166.90 1.16 166.32 4.42 165.04 6.75C164.54 7.64 163.92 8.42 163.14 8.93L162.56 8.34C161.67 7.45 161.04 6.91 159.88 6.91C159.03 6.91 158.17 7.37 158.02 8.19C158.37 9.51 159.73 10.05 161.32 10.05Z"/>
<path class="tag" data-char="o" d="M182.35 0.47C187.51 0.47 191.27 -5.12 191.27 -11.33C191.27 -15.79 188.64 -18.01 185.45 -18.01C180.33 -18.01 176.57 -12.42 176.57 -6.21C176.57 -1.75 179.17 0.47 182.35 0.47ZM182.35 -0.31C180.10 -0.31 179.09 -2.29 179.09 -5.98C179.09 -12.19 182.08 -17.23 185.45 -17.23C187.70 -17.23 188.75 -15.25 188.75 -11.60C188.75 -5.36 185.73 -0.31 182.35 -0.31Z"/>
<path class="tag" data-char="r" d="M194.77 0.00 195.08 0.31 197.41 0.00C197.79 -2.64 198.22 -5.20 198.73 -7.76L199.23 -10.28C200.55 -12.81 201.99 -14.94 203.38 -16.10C204.20 -15.25 204.93 -14.82 205.75 -14.82C206.95 -14.82 207.73 -15.64 207.77 -16.69C207.42 -17.73 206.53 -18.01 205.59 -18.01C203.54 -18.01 201.64 -16.10 199.42 -11.84L200.63 -17.70L200.28 -18.01L194.69 -16.38L194.61 -15.83L197.95 -15.29Z"/>
<path class="tag" data-char="t" d="M219.18 0.47C221.54 0.47 223.29 -1.71 224.49 -3.69L224.03 -4.07C222.71 -2.52 221.39 -1.51 220.46 -1.51C220.07 -1.51 219.84 -1.75 219.84 -2.13C219.84 -2.68 219.99 -3.14 220.15 -3.96L222.79 -16.61H227.29L227.48 -17.54H222.98L224.22 -23.44H223.52L220.77 -17.70L216.85 -17.23V-16.61H220.42L217.74 -3.73C217.47 -2.37 217.35 -1.82 217.35 -1.36C217.35 -0.19 218.13 0.47 219.18 0.47Z"/>
<path class="tag" data-char="h" d="M228.53 0.31 230.86 0.00C231.24 -2.64 231.63 -5.20 232.18 -7.76L233.22 -12.84C235.82 -14.94 237.73 -15.95 239.16 -15.95C239.94 -15.95 240.56 -15.44 240.56 -14.44C240.56 -13.54 240.17 -11.99 239.90 -10.79L238.23 -3.61C237.92 -2.25 237.92 -1.71 237.92 -1.24C237.92 -0.08 238.93 0.47 239.86 0.47C242.27 0.47 243.90 -1.71 245.10 -3.69L244.63 -4.07C243.27 -2.52 241.88 -1.51 241.14 -1.51C240.79 -1.51 240.44 -1.79 240.44 -2.25C240.44 -2.64 240.56 -3.34 240.75 -4.15L242.50 -11.84C242.77 -13.00 243.04 -14.20 243.04 -15.37C243.04 -17.07 242.19 -18.01 240.67 -18.01C238.46 -18.01 235.75 -16.03 233.42 -13.74L236.52 -28.56L236.21 -28.87L230.39 -27.24L230.31 -26.70L233.50 -26.16C233.22 -24.37 232.87 -22.51 232.49 -20.68L228.22 0.00Z"/>
<path class="tag" data-char="e" d="M256.86 -17.23C258.06 -17.23 258.56 -16.22 258.56 -15.06C258.56 -12.46 256.47 -9.74 249.95 -7.80C250.65 -13.19 254.41 -17.23 256.86 -17.23ZM252.70 0.47C255.65 0.47 257.87 -1.40 259.34 -4.00L258.87 -4.31C257.79 -3.07 255.81 -1.47 253.87 -1.47C251.66 -1.47 249.83 -2.95 249.83 -5.98C249.83 -6.33 249.83 -6.67 249.87 -7.02C258.84 -9.55 260.81 -12.42 260.81 -15.06C260.81 -17.04 259.46 -18.01 257.13 -18.01C252.70 -18.01 247.39 -12.19 247.39 -5.32C247.39 -1.55 249.56 0.47 252.70 0.47Z"/>
<path class="tag" data-char="l" d="M272.84 0.47C275.29 0.47 277.04 -1.71 278.24 -3.69L277.77 -4.07C276.41 -2.52 275.06 -1.51 274.28 -1.51C273.93 -1.51 273.58 -1.79 273.58 -2.25C273.58 -2.64 273.74 -3.34 273.89 -4.15L278.94 -28.56L278.63 -28.87L272.88 -27.24L272.81 -26.70L275.91 -26.16C275.64 -24.37 275.29 -22.51 274.90 -20.68L271.37 -3.61C271.10 -2.25 271.06 -1.71 271.06 -1.24C271.06 -0.08 271.91 0.47 272.84 0.47Z"/>
<path class="tag" data-char="o" d="M286.50 0.47C291.67 0.47 295.43 -5.12 295.43 -11.33C295.43 -15.79 292.79 -18.01 289.61 -18.01C284.49 -18.01 280.72 -12.42 280.72 -6.21C280.72 -1.75 283.32 0.47 286.50 0.47ZM286.50 -0.31C284.25 -0.31 283.24 -2.29 283.24 -5.98C283.24 -12.19 286.23 -17.23 289.61 -17.23C291.86 -17.23 292.91 -15.25 292.91 -11.60C292.91 -5.36 289.88 -0.31 286.50 -0.31Z"/>
<path class="tag" data-char="n" d="M299.23 0.31 301.56 0.00C301.95 -2.64 302.34 -5.20 302.88 -7.76L303.89 -12.84C306.53 -14.94 308.43 -15.95 309.87 -15.95C310.60 -15.95 311.22 -15.44 311.22 -14.44C311.22 -13.54 310.84 -11.99 310.56 -10.79L308.93 -3.61C308.62 -2.25 308.59 -1.71 308.59 -1.24C308.59 -0.08 309.59 0.47 310.53 0.47C312.97 0.47 314.56 -1.71 315.76 -3.69L315.30 -4.07C313.98 -2.52 312.58 -1.51 311.81 -1.51C311.46 -1.51 311.11 -1.79 311.11 -2.25C311.11 -2.64 311.22 -3.34 311.42 -4.15L313.16 -11.84C313.44 -13.00 313.71 -14.20 313.71 -15.37C313.71 -17.07 312.89 -18.01 311.38 -18.01C309.13 -18.01 306.41 -16.03 304.08 -13.70L304.94 -17.70L304.59 -18.01L298.84 -16.38L298.77 -15.83L302.10 -15.29L298.92 0.00Z"/>
<path class="tag" data-char="g" d="M324.19 -5.28C327.91 -5.28 330.28 -8.38 330.94 -11.76C331.21 -13.08 331.64 -14.59 332.02 -15.60C334.08 -15.79 335.52 -16.14 335.52 -17.54C335.52 -17.81 335.40 -18.20 335.24 -18.39C335.01 -18.51 334.62 -18.55 334.24 -18.55C332.76 -18.55 331.48 -17.81 330.82 -13.97V-13.66C330.74 -16.73 328.92 -18.16 326.16 -18.16C322.32 -18.16 319.30 -14.51 319.30 -10.01C319.30 -8.07 320.03 -6.75 321.24 -6.01C319.53 -4.73 318.60 -3.49 318.60 -2.17C318.60 -0.97 319.22 -0.19 320.42 0.19C317.82 1.40 315.61 3.34 315.61 5.98C315.61 8.58 317.78 10.05 321.20 10.05C327.29 10.05 331.36 6.40 331.36 2.60C331.36 0.70 330.16 -0.85 326.55 -1.24L322.90 -1.63C321.16 -1.82 320.46 -2.48 320.46 -3.30C320.46 -3.88 320.69 -4.58 321.70 -5.78C322.44 -5.43 323.25 -5.28 324.19 -5.28ZM324.38 -6.01C322.71 -6.01 321.78 -7.45 321.78 -10.44C321.78 -14.55 323.56 -17.46 325.97 -17.46C327.64 -17.46 328.57 -16.18 328.57 -13.16C328.57 -9.08 326.75 -6.01 324.38 -6.01ZM317.82 5.24C317.82 3.34 319.02 1.75 321.12 0.39C321.31 0.43 321.47 0.43 321.62 0.47L325.50 0.89C328.57 1.24 329.19 2.48 329.19 4.19C329.19 6.60 326.71 8.65 322.63 8.65C319.68 8.65 317.82 7.33 317.82 5.24Z"/>
<path class="tag" data-char="r" d="M344.48 0.00 344.79 0.31 347.12 0.00C347.51 -2.64 347.93 -5.20 348.44 -7.76L348.94 -10.28C350.26 -12.81 351.70 -14.94 353.10 -16.10C353.91 -15.25 354.65 -14.82 355.46 -14.82C356.67 -14.82 357.44 -15.64 357.48 -16.69C357.13 -17.73 356.24 -18.01 355.31 -18.01C353.25 -18.01 351.35 -16.10 349.14 -11.84L350.34 -17.70L349.99 -18.01L344.40 -16.38L344.33 -15.83L347.66 -15.29Z"/>
<path class="tag" data-char="o" d="M364.12 0.47C369.28 0.47 373.04 -5.12 373.04 -11.33C373.04 -15.79 370.40 -18.01 367.22 -18.01C362.10 -18.01 358.33 -12.42 358.33 -6.21C358.33 -1.75 360.93 0.47 364.12 0.47ZM364.12 -0.31C361.87 -0.31 360.86 -2.29 360.86 -5.98C360.86 -12.19 363.84 -17.23 367.22 -17.23C369.47 -17.23 370.52 -15.25 370.52 -11.60C370.52 -5.36 367.49 -0.31 364.12 -0.31Z"/>
<path class="tag" data-char="a" d="M378.01 -5.12C378.01 -7.68 379.06 -11.64 381.11 -14.09C382.20 -15.41 383.60 -16.30 385.23 -16.30C386.35 -16.30 387.21 -15.83 387.87 -15.25L385.93 -5.63C383.33 -2.64 381.50 -1.44 380.14 -1.44C378.51 -1.44 378.01 -2.91 378.01 -5.12ZM387.24 0.47C389.57 0.47 391.13 -1.71 392.33 -3.69L391.86 -4.07C390.50 -2.52 389.22 -1.51 388.29 -1.51C387.90 -1.51 387.67 -1.75 387.67 -2.13C387.67 -2.68 387.79 -3.14 387.94 -3.96L390.81 -18.01L390.50 -18.32L388.76 -16.96C387.98 -17.62 387.01 -18.01 386.08 -18.01C381.31 -18.01 375.49 -9.93 375.49 -4.19C375.49 -0.93 377.04 0.47 378.90 0.47C381.31 0.47 383.56 -1.63 385.69 -4.54C385.19 -2.25 385.19 -1.79 385.19 -1.40C385.19 -0.19 386.16 0.47 387.24 0.47Z"/>
<path class="tag" data-char="d" d="M397.30 -5.12C397.30 -7.92 398.58 -12.30 400.90 -14.67C401.91 -15.64 403.16 -16.30 404.51 -16.30C405.64 -16.30 406.49 -15.83 407.15 -15.25L405.17 -5.51C402.57 -2.60 400.79 -1.44 399.43 -1.44C397.80 -1.44 397.30 -2.91 397.30 -5.12ZM406.53 0.47C408.86 0.47 410.41 -1.71 411.61 -3.69L411.15 -4.07C409.79 -2.52 408.51 -1.51 407.58 -1.51C407.19 -1.51 406.96 -1.75 406.96 -2.13C406.96 -2.68 407.07 -3.14 407.23 -3.96L412.31 -28.56L411.96 -28.87L406.26 -27.24L406.14 -26.70L409.48 -26.12L407.66 -17.27C406.96 -17.73 406.14 -18.01 405.37 -18.01C400.59 -18.01 394.77 -9.93 394.77 -4.19C394.77 -0.93 396.33 0.47 398.19 0.47C400.59 0.47 402.84 -1.63 404.94 -4.50L404.79 -3.65C404.51 -2.33 404.47 -1.82 404.47 -1.36C404.47 -0.19 405.44 0.47 406.53 0.47Z"/>
<path class="tag" data-char="o" d="M427.76 0.47C432.92 0.47 436.68 -5.12 436.68 -11.33C436.68 -15.79 434.04 -18.01 430.86 -18.01C425.74 -18.01 421.98 -12.42 421.98 -6.21C421.98 -1.75 424.58 0.47 427.76 0.47ZM427.76 -0.31C425.51 -0.31 424.50 -2.29 424.50 -5.98C424.50 -12.19 427.49 -17.23 430.86 -17.23C433.11 -17.23 434.16 -15.25 434.16 -11.60C434.16 -5.36 431.13 -0.31 427.76 -0.31Z"/>
<path class="tag" data-char="f" d="M434.20 10.05C436.10 10.05 437.69 9.08 438.97 7.57C440.84 5.32 442.04 2.02 442.50 -0.97C443.32 -6.17 444.13 -11.41 444.95 -16.61H449.88L450.07 -17.54H445.10C445.18 -17.97 445.22 -18.39 445.30 -18.82C446.23 -24.84 448.17 -27.63 450.19 -28.60L451.59 -27.01C452.36 -26.08 452.90 -25.61 453.72 -25.61C454.30 -25.61 454.92 -25.88 455.08 -26.74C454.84 -28.21 452.94 -29.26 451.00 -29.26C448.56 -29.26 444.21 -27.13 442.89 -18.74C442.85 -18.39 442.78 -18.01 442.74 -17.66L439.13 -17.23V-16.61H442.58C441.77 -11.37 440.95 -6.17 440.14 -0.97C439.79 1.16 439.21 4.42 437.93 6.75C437.42 7.64 436.80 8.42 436.02 8.93L435.44 8.34C434.55 7.45 433.93 6.91 432.76 6.91C431.91 6.91 431.06 7.37 430.90 8.19C431.25 9.51 432.61 10.05 434.20 10.05Z"/>
<path class="tag" data-char="t" d="M460.01 0.47C462.37 0.47 464.12 -1.71 465.32 -3.69L464.86 -4.07C463.54 -2.52 462.22 -1.51 461.29 -1.51C460.90 -1.51 460.67 -1.75 460.67 -2.13C460.67 -2.68 460.82 -3.14 460.98 -3.96L463.61 -16.61H468.12L468.31 -17.54H463.81L465.05 -23.44H464.35L461.60 -17.70L457.68 -17.23V-16.61H461.25L458.57 -3.73C458.30 -2.37 458.18 -1.82 458.18 -1.36C458.18 -0.19 458.96 0.47 460.01 0.47Z"/>
<path class="tag" data-char="h" d="M469.36 0.31 471.69 0.00C472.07 -2.64 472.46 -5.20 473.01 -7.76L474.05 -12.84C476.65 -14.94 478.56 -15.95 479.99 -15.95C480.77 -15.95 481.39 -15.44 481.39 -14.44C481.39 -13.54 481.00 -11.99 480.73 -10.79L479.06 -3.61C478.75 -2.25 478.75 -1.71 478.75 -1.24C478.75 -0.08 479.76 0.47 480.69 0.47C483.10 0.47 484.73 -1.71 485.93 -3.69L485.46 -4.07C484.10 -2.52 482.71 -1.51 481.97 -1.51C481.62 -1.51 481.27 -1.79 481.27 -2.25C481.27 -2.64 481.39 -3.34 481.58 -4.15L483.33 -11.84C483.60 -13.00 483.87 -14.20 483.87 -15.37C483.87 -17.07 483.02 -18.01 481.50 -18.01C479.29 -18.01 476.58 -16.03 474.25 -13.74L477.35 -28.56L477.04 -28.87L471.22 -27.24L471.14 -26.70L474.33 -26.16C474.05 -24.37 473.70 -22.51 473.32 -20.68L469.05 0.00Z"/>
<path class="tag" data-char="o" d="M494.16 0.47C499.32 0.47 503.08 -5.12 503.08 -11.33C503.08 -15.79 500.44 -18.01 497.26 -18.01C492.14 -18.01 488.37 -12.42 488.37 -6.21C488.37 -1.75 490.97 0.47 494.16 0.47ZM494.16 -0.31C491.90 -0.31 490.90 -2.29 490.90 -5.98C490.90 -12.19 493.88 -17.23 497.26 -17.23C499.51 -17.23 500.56 -15.25 500.56 -11.60C500.56 -5.36 497.53 -0.31 494.16 -0.31Z"/>
<path class="tag" data-char="u" d="M509.21 0.47C511.46 0.47 514.14 -1.51 516.47 -3.80C516.16 -2.33 516.12 -1.82 516.12 -1.36C516.12 -0.19 516.90 0.47 517.94 0.47C520.31 0.47 522.10 -1.71 523.30 -3.69L522.79 -4.07C521.47 -2.52 520.16 -1.51 519.26 -1.51C518.87 -1.51 518.64 -1.75 518.64 -2.13C518.64 -2.68 518.76 -3.14 518.91 -3.96L521.75 -17.70L521.40 -18.01L519.03 -17.39C518.64 -14.79 518.21 -12.34 517.71 -9.78L516.66 -4.70C514.06 -2.60 512.16 -1.59 510.76 -1.59C509.99 -1.59 509.41 -2.10 509.41 -3.14C509.41 -4.00 509.79 -5.55 510.07 -6.75L512.43 -17.70L512.12 -18.01L506.38 -16.38L506.26 -15.83L509.60 -15.29L507.43 -5.70C507.16 -4.54 506.88 -3.34 506.88 -2.17C506.88 -0.47 507.70 0.47 509.21 0.47Z"/>
<path class="tag" data-char="g" d="M531.68 -5.28C535.41 -5.28 537.77 -8.38 538.43 -11.76C538.70 -13.08 539.13 -14.59 539.52 -15.60C541.58 -15.79 543.01 -16.14 543.01 -17.54C543.01 -17.81 542.90 -18.20 542.74 -18.39C542.51 -18.51 542.12 -18.55 541.73 -18.55C540.26 -18.55 538.98 -17.81 538.32 -13.97V-13.66C538.24 -16.73 536.41 -18.16 533.66 -18.16C529.82 -18.16 526.79 -14.51 526.79 -10.01C526.79 -8.07 527.53 -6.75 528.73 -6.01C527.02 -4.73 526.09 -3.49 526.09 -2.17C526.09 -0.97 526.71 -0.19 527.92 0.19C525.32 1.40 523.10 3.34 523.10 5.98C523.10 8.58 525.28 10.05 528.69 10.05C534.79 10.05 538.86 6.40 538.86 2.60C538.86 0.70 537.66 -0.85 534.05 -1.24L530.40 -1.63C528.65 -1.82 527.96 -2.48 527.96 -3.30C527.96 -3.88 528.19 -4.58 529.20 -5.78C529.93 -5.43 530.75 -5.28 531.68 -5.28ZM531.87 -6.01C530.21 -6.01 529.27 -7.45 529.27 -10.44C529.27 -14.55 531.06 -17.46 533.47 -17.46C535.13 -17.46 536.07 -16.18 536.07 -13.16C536.07 -9.08 534.24 -6.01 531.87 -6.01ZM525.32 5.24C525.32 3.34 526.52 1.75 528.61 0.39C528.81 0.43 528.96 0.43 529.12 0.47L533.00 0.89C536.07 1.24 536.69 2.48 536.69 4.19C536.69 6.60 534.20 8.65 530.13 8.65C527.18 8.65 525.32 7.33 525.32 5.24Z"/>
<path class="tag" data-char="h" d="M543.75 0.31 546.08 0.00C546.47 -2.64 546.85 -5.20 547.40 -7.76L548.44 -12.84C551.04 -14.94 552.95 -15.95 554.38 -15.95C555.16 -15.95 555.78 -15.44 555.78 -14.44C555.78 -13.54 555.39 -11.99 555.12 -10.79L553.45 -3.61C553.14 -2.25 553.14 -1.71 553.14 -1.24C553.14 -0.08 554.15 0.47 555.08 0.47C557.49 0.47 559.12 -1.71 560.32 -3.69L559.85 -4.07C558.50 -2.52 557.10 -1.51 556.36 -1.51C556.01 -1.51 555.66 -1.79 555.66 -2.25C555.66 -2.64 555.78 -3.34 555.97 -4.15L557.72 -11.84C557.99 -13.00 558.26 -14.20 558.26 -15.37C558.26 -17.07 557.41 -18.01 555.90 -18.01C553.68 -18.01 550.97 -16.03 548.64 -13.74L551.74 -28.56L551.43 -28.87L545.61 -27.24L545.53 -26.70L548.72 -26.16C548.44 -24.37 548.10 -22.51 547.71 -20.68L543.44 0.00Z"/>
<path class="tag" data-char="t" d="M565.40 0.47C567.77 0.47 569.52 -1.71 570.72 -3.69L570.25 -4.07C568.93 -2.52 567.61 -1.51 566.68 -1.51C566.30 -1.51 566.06 -1.75 566.06 -2.13C566.06 -2.68 566.22 -3.14 566.37 -3.96L569.01 -16.61H573.51L573.71 -17.54H569.21L570.45 -23.44H569.75L566.99 -17.70L563.07 -17.23V-16.61H566.64L563.97 -3.73C563.70 -2.37 563.58 -1.82 563.58 -1.36C563.58 -0.19 564.36 0.47 565.40 0.47Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 36 KiB

+27
View File
@@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"
role="img" aria-label="LLeMbas">
<title>LLeMbas</title>
<defs>
<linearGradient id="f-wafer" x1="0" y1="0" x2="0.3" y2="1">
<stop offset="0" stop-color="#EACB74"/>
<stop offset="0.5" stop-color="#C9A227"/>
<stop offset="1" stop-color="#916F13"/>
</linearGradient>
<linearGradient id="f-leaf" x1="0.1" y1="1" x2="0.9" y2="0">
<stop offset="0" stop-color="#93A5B6"/>
<stop offset="0.4" stop-color="#F1F6FA"/>
<stop offset="1" stop-color="#B8C7D5"/>
</linearGradient>
<clipPath id="f-clip">
<rect x="6" y="6" width="52" height="52" rx="13"/>
</clipPath>
</defs>
<rect x="2" y="2" width="60" height="60" rx="14" fill="url(#f-wafer)"/>
<g transform="translate(32 32) scale(1.16) translate(-32 -32)">
<path d="M20.6 44.6 L15.6 50.1" stroke="#8A9AA8" stroke-width="3.4"
stroke-linecap="round" fill="none"/>
<path d="M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z" fill="url(#f-leaf)"/>
<path d="M20.5 45.5 C28 38 36 29 45.5 18.5" fill="none" stroke="#61758A" stroke-opacity="0.45"
stroke-width="1.8" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+49
View File
@@ -0,0 +1,49 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"
role="img" aria-label="LLeMbas">
<title>LLeMbas</title>
<desc>A silver mallorn leaf laid across a scored golden lembas wafer.</desc>
<defs>
<linearGradient id="m-wafer" x1="0" y1="0" x2="0.3" y2="1">
<stop offset="0" stop-color="#EACB74"/>
<stop offset="0.5" stop-color="#C9A227"/>
<stop offset="1" stop-color="#916F13"/>
</linearGradient>
<linearGradient id="m-leaf" x1="0.1" y1="1" x2="0.9" y2="0">
<stop offset="0" stop-color="#93A5B6"/>
<stop offset="0.4" stop-color="#F1F6FA"/>
<stop offset="1" stop-color="#B8C7D5"/>
</linearGradient>
<clipPath id="m-clip">
<rect x="6" y="6" width="52" height="52" rx="13"/>
</clipPath>
</defs>
<rect x="6" y="6" width="52" height="52" rx="13" fill="url(#m-wafer)"/>
<g clip-path="url(#m-clip)" fill="none" stroke-linecap="round">
<g stroke="#7A5C10" stroke-opacity="0.38" stroke-width="2">
<path d="M32 6 V58"/>
<path d="M6 32 H58"/>
</g>
<g stroke="#F6E3A8" stroke-opacity="0.3" stroke-width="1">
<path d="M33.2 6 V58"/>
<path d="M6 33.2 H58"/>
</g>
</g>
<rect x="7.1" y="7.1" width="49.8" height="49.8" rx="11.9"
fill="none" stroke="#7A5C10" stroke-opacity="0.3" stroke-width="1.2"/>
<g>
<path d="M21.2 44.8 L17 49.4" stroke="#8A9AA8" stroke-width="3"
stroke-linecap="round" fill="none"/>
<path d="M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z" fill="url(#m-leaf)"/>
<path d="M20.5 45.5 C28 38 36 29 45.5 18.5" fill="none" stroke="#61758A" stroke-opacity="0.5"
stroke-width="1.5" stroke-linecap="round"/>
<g fill="none" stroke="#61758A" stroke-opacity="0.32"
stroke-width="1" stroke-linecap="round">
<path d="M26.9 38.8 Q25.2 35.8 24.9 32.1"/>
<path d="M32.3 33.1 Q30.9 30.3 30.4 26.9"/>
<path d="M37.8 27.0 Q36.6 24.6 36.3 21.7"/>
<path d="M26.9 38.8 Q30.5 40.1 33.7 40.3"/>
<path d="M32.3 33.1 Q35.8 34.3 38.6 34.5"/>
<path d="M37.8 27.0 Q40.8 27.9 43.2 28.1"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+162
View File
@@ -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);
});
})();
File diff suppressed because one or more lines are too long
+290
View File
@@ -0,0 +1,290 @@
/*
Server Sent Events Extension
============================
This extension adds support for Server Sent Events to htmx. See /www/extensions/sse.md for usage instructions.
*/
(function() {
/** @type {import("../htmx").HtmxInternalApi} */
var api
htmx.defineExtension('sse', {
/**
* Init saves the provided reference to the internal HTMX API.
*
* @param {import("../htmx").HtmxInternalApi} api
* @returns void
*/
init: function(apiRef) {
// store a reference to the internal API.
api = apiRef
// set a function in the public API for creating new EventSource objects
if (htmx.createEventSource == undefined) {
htmx.createEventSource = createEventSource
}
},
getSelectors: function() {
return ['[sse-connect]', '[data-sse-connect]', '[sse-swap]', '[data-sse-swap]']
},
/**
* onEvent handles all events passed to this extension.
*
* @param {string} name
* @param {Event} evt
* @returns void
*/
onEvent: function(name, evt) {
var parent = evt.target || evt.detail.elt
switch (name) {
case 'htmx:beforeCleanupElement':
var internalData = api.getInternalData(parent)
// Try to remove remove an EventSource when elements are removed
var source = internalData.sseEventSource
if (source) {
api.triggerEvent(parent, 'htmx:sseClose', {
source,
type: 'nodeReplaced',
})
internalData.sseEventSource.close()
}
return
// Try to create EventSources when elements are processed
case 'htmx:afterProcessNode':
ensureEventSourceOnElement(parent)
}
}
})
/// ////////////////////////////////////////////
// HELPER FUNCTIONS
/// ////////////////////////////////////////////
/**
* createEventSource is the default method for creating new EventSource objects.
* it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed.
*
* @param {string} url
* @returns EventSource
*/
function createEventSource(url) {
return new EventSource(url, { withCredentials: true })
}
/**
* registerSSE looks for attributes that can contain sse events, right
* now hx-trigger and sse-swap and adds listeners based on these attributes too
* the closest event source
*
* @param {HTMLElement} elt
*/
function registerSSE(elt) {
// Add message handlers for every `sse-swap` attribute
if (api.getAttributeValue(elt, 'sse-swap')) {
// Find closest existing event source
var sourceElement = api.getClosestMatch(elt, hasEventSource)
if (sourceElement == null) {
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
return null // no eventsource in parentage, orphaned element
}
// Set internalData and source
var internalData = api.getInternalData(sourceElement)
var source = internalData.sseEventSource
var sseSwapAttr = api.getAttributeValue(elt, 'sse-swap')
var sseEventNames = sseSwapAttr.split(',')
for (var i = 0; i < sseEventNames.length; i++) {
const sseEventName = sseEventNames[i].trim()
const listener = function(event) {
// If the source is missing then close SSE
if (maybeCloseSSESource(sourceElement)) {
return
}
// If the body no longer contains the element, remove the listener
if (!api.bodyContains(elt)) {
source.removeEventListener(sseEventName, listener)
return
}
// swap the response into the DOM and trigger a notification
if (!api.triggerEvent(elt, 'htmx:sseBeforeMessage', event)) {
return
}
swap(elt, event.data)
api.triggerEvent(elt, 'htmx:sseMessage', event)
}
// Register the new listener
api.getInternalData(elt).sseEventListener = listener
source.addEventListener(sseEventName, listener)
}
}
// Add message handlers for every `hx-trigger="sse:*"` attribute
if (api.getAttributeValue(elt, 'hx-trigger')) {
// Find closest existing event source
var sourceElement = api.getClosestMatch(elt, hasEventSource)
if (sourceElement == null) {
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
return null // no eventsource in parentage, orphaned element
}
// Set internalData and source
var internalData = api.getInternalData(sourceElement)
var source = internalData.sseEventSource
var triggerSpecs = api.getTriggerSpecs(elt)
triggerSpecs.forEach(function(ts) {
if (ts.trigger.slice(0, 4) !== 'sse:') {
return
}
var listener = function (event) {
if (maybeCloseSSESource(sourceElement)) {
return
}
if (!api.bodyContains(elt)) {
source.removeEventListener(ts.trigger.slice(4), listener)
}
// Trigger events to be handled by the rest of htmx
htmx.trigger(elt, ts.trigger, event)
htmx.trigger(elt, 'htmx:sseMessage', event)
}
// Register the new listener
api.getInternalData(elt).sseEventListener = listener
source.addEventListener(ts.trigger.slice(4), listener)
})
}
}
/**
* ensureEventSourceOnElement creates a new EventSource connection on the provided element.
* If a usable EventSource already exists, then it is returned. If not, then a new EventSource
* is created and stored in the element's internalData.
* @param {HTMLElement} elt
* @param {number} retryCount
* @returns {EventSource | null}
*/
function ensureEventSourceOnElement(elt, retryCount) {
if (elt == null) {
return null
}
// handle extension source creation attribute
if (api.getAttributeValue(elt, 'sse-connect')) {
var sseURL = api.getAttributeValue(elt, 'sse-connect')
if (sseURL == null) {
return
}
ensureEventSource(elt, sseURL, retryCount)
}
registerSSE(elt)
}
function ensureEventSource(elt, url, retryCount) {
var source = htmx.createEventSource(url)
source.onerror = function(err) {
// Log an error event
api.triggerErrorEvent(elt, 'htmx:sseError', { error: err, source })
// If parent no longer exists in the document, then clean up this EventSource
if (maybeCloseSSESource(elt)) {
return
}
// Otherwise, try to reconnect the EventSource
if (source.readyState === EventSource.CLOSED) {
retryCount = retryCount || 0
retryCount = Math.max(Math.min(retryCount * 2, 128), 1)
var timeout = retryCount * 500
window.setTimeout(function() {
ensureEventSourceOnElement(elt, retryCount)
}, timeout)
}
}
source.onopen = function(evt) {
api.triggerEvent(elt, 'htmx:sseOpen', { source })
if (retryCount && retryCount > 0) {
const childrenToFix = elt.querySelectorAll("[sse-swap], [data-sse-swap], [hx-trigger], [data-hx-trigger]")
for (let i = 0; i < childrenToFix.length; i++) {
registerSSE(childrenToFix[i])
}
// We want to increase the reconnection delay for consecutive failed attempts only
retryCount = 0
}
}
api.getInternalData(elt).sseEventSource = source
var closeAttribute = api.getAttributeValue(elt, "sse-close");
if (closeAttribute) {
// close eventsource when this message is received
source.addEventListener(closeAttribute, function() {
api.triggerEvent(elt, 'htmx:sseClose', {
source,
type: 'message',
})
source.close()
});
}
}
/**
* maybeCloseSSESource confirms that the parent element still exists.
* If not, then any associated SSE source is closed and the function returns true.
*
* @param {HTMLElement} elt
* @returns boolean
*/
function maybeCloseSSESource(elt) {
if (!api.bodyContains(elt)) {
var source = api.getInternalData(elt).sseEventSource
if (source != undefined) {
api.triggerEvent(elt, 'htmx:sseClose', {
source,
type: 'nodeMissing',
})
source.close()
// source = null
return true
}
}
return false
}
/**
* @param {HTMLElement} elt
* @param {string} content
*/
function swap(elt, content) {
api.withExtensions(elt, function(extension) {
content = extension.transformResponse(content, null, elt)
})
var swapSpec = api.getSwapSpecification(elt)
var target = api.getTarget(elt)
api.swap(target, content, swapSpec, { contextElement: elt })
}
function hasEventSource(node) {
return api.getInternalData(node).sseEventSource != null
}
})()
File diff suppressed because one or more lines are too long
+68
View File
@@ -0,0 +1,68 @@
{#
Shared template macros.
Import at the top of any template that needs them:
{% from "_macros.html" import icon, brand, mark %}
#}
{# An icon from the inlined sprite. `name` omits the "i-" prefix. #}
{% macro icon(name, cls="") -%}
<svg class="icon {{ cls }}" aria-hidden="true"><use href="#i-{{ name }}"/></svg>
{%- endmacro %}
{#
The leaf-and-wafer mark, inlined rather than referenced as <img> so it can
scale with the surrounding font size. Gradient ids are suffixed with `uid`
because ids are document-global: two marks on one page with the same ids
means the second silently reuses the first one's gradients.
#}
{% macro mark(cls="brand-mark", uid="a") -%}
<svg class="{{ cls }}" viewBox="0 0 64 64" role="img" aria-label="LLeMbas">
<defs>
<linearGradient id="mk-{{ uid }}-w" x1="0" y1="0" x2="0.3" y2="1">
<stop offset="0" stop-color="#EACB74"/>
<stop offset="0.5" stop-color="#C9A227"/>
<stop offset="1" stop-color="#916F13"/>
</linearGradient>
<linearGradient id="mk-{{ uid }}-l" x1="0.1" y1="1" x2="0.9" y2="0">
<stop offset="0" stop-color="#93A5B6"/>
<stop offset="0.4" stop-color="#F1F6FA"/>
<stop offset="1" stop-color="#B8C7D5"/>
</linearGradient>
<clipPath id="mk-{{ uid }}-c"><rect x="6" y="6" width="52" height="52" rx="13"/></clipPath>
</defs>
<rect x="6" y="6" width="52" height="52" rx="13" fill="url(#mk-{{ uid }}-w)"/>
<g clip-path="url(#mk-{{ uid }}-c)" fill="none" stroke-linecap="round">
<g stroke="#7A5C10" stroke-opacity="0.38" stroke-width="2">
<path d="M32 6 V58"/><path d="M6 32 H58"/>
</g>
<g stroke="#F6E3A8" stroke-opacity="0.3" stroke-width="1">
<path d="M33.2 6 V58"/><path d="M6 33.2 H58"/>
</g>
</g>
<rect x="7.1" y="7.1" width="49.8" height="49.8" rx="11.9" fill="none"
stroke="#7A5C10" stroke-opacity="0.3" stroke-width="1.2"/>
<path d="M21.2 44.8 L17 49.4" stroke="#8A9AA8" stroke-width="3"
stroke-linecap="round" fill="none"/>
<path d="M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z"
fill="url(#mk-{{ uid }}-l)"/>
<path d="M20.5 45.5 C28 38 36 29 45.5 18.5" fill="none" stroke="#61758A"
stroke-opacity="0.5" stroke-width="1.5" stroke-linecap="round"/>
</svg>
{%- endmacro %}
{#
The wordmark as live text rather than the outlined SVG: it stays selectable,
searchable and readable to a screen reader, and scales with the user's font
size. The capitals spelling LLM take the gold accent.
#}
{% macro wordmark() -%}
<span class="brand-llm">LL</span>e<span class="brand-llm">M</span>bas
{%- endmacro %}
{% macro brand(href="/", uid="a") -%}
<a class="sidebar__brand" href="{{ href }}">
{{ mark(uid=uid) }}
<span>{{ wordmark() }}</span>
</a>
{%- endmacro %}
@@ -0,0 +1,98 @@
{% from "_macros.html" import icon %}
{#
One connection: an editable form plus its current status.
Swapped in place by the "Test & refresh" button, so this fragment has to be
able to render on its own as well as inside the list.
#}
<section class="card connection" id="connection-{{ connection.id }}">
<form method="post" action="/admin/connections/{{ connection.id }}" class="form-grid">
<div class="connection__head">
<div class="row" style="gap: var(--sp-2); min-width: 0">
<span class="status-dot {{ 'is-ok' if connection.enabled and not connection.last_error
else 'is-bad' if connection.last_error else 'is-off' }}"
aria-hidden="true"></span>
<strong class="truncate">{{ connection.name }}</strong>
{% if model_count is defined %}
<span class="badge">{{ model_count }} model{{ '' if model_count == 1 else 's' }}</span>
{% endif %}
{% if not connection.enabled %}
<span class="badge">disabled</span>
{% endif %}
</div>
<div class="row" style="gap: var(--sp-2)">
<button class="btn btn--sm" type="submit"
hx-post="/admin/connections/{{ connection.id }}/test"
hx-target="#connection-{{ connection.id }}" hx-swap="outerHTML"
formnovalidate>
{{ icon("refresh", "icon--sm") }} Test &amp; refresh
</button>
<button class="btn btn--sm btn--primary" type="submit">Save</button>
</div>
</div>
{% if message %}
<div class="alert alert--{{ message_kind|default('success') }}">
{% if message_kind == "error" %}{{ icon("warning", "alert__icon") }}{% endif %}
<span>{{ message }}</span>
</div>
{% elif connection.last_error %}
<div class="alert alert--error">
{{ icon("warning", "alert__icon") }}
<span>{{ connection.last_error }}</span>
</div>
{% endif %}
<div class="field">
<label class="field__label" for="name-{{ connection.id }}">Name</label>
<input class="input" id="name-{{ connection.id }}" name="name"
value="{{ connection.name }}" required>
</div>
<div class="field">
<label class="field__label" for="url-{{ connection.id }}">Base URL</label>
<input class="input input--mono" id="url-{{ connection.id }}" name="base_url"
value="{{ connection.base_url }}" required>
</div>
<div class="field">
<label class="field__label" for="key-{{ connection.id }}">API key</label>
<input class="input input--mono" id="key-{{ connection.id }}" name="api_key"
type="password" autocomplete="off"
value="{{ unchanged if connection.api_key_encrypted else '' }}"
placeholder="No key set">
<p class="field__hint">
{% if connection.api_key_encrypted %}
Currently <code>{{ masked }}</code>. Leave the dots alone to keep it,
or clear the field to remove the key entirely.
{% else %}
No key is stored for this endpoint.
{% endif %}
</p>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="enabled" value="true"
{{ 'checked' if connection.enabled }}>
<span>Enabled — its models are offered in chats</span>
</label>
</div>
<div class="connection__footer">
<span class="text-xs faint">
{% if connection.last_checked_at %}
Last checked {{ connection.last_checked_at.strftime("%Y-%m-%d %H:%M") }} UTC
{% else %}
Never checked
{% endif %}
</span>
<button class="btn btn--sm btn--danger" type="submit"
formaction="/admin/connections/{{ connection.id }}/delete" formnovalidate
onclick="return confirm('Delete the connection “{{ connection.name }}”? Existing chats keep their history.')">
{{ icon("trash", "icon--sm") }} Delete
</button>
</div>
</form>
</section>
@@ -0,0 +1,73 @@
{% extends "base.html" %}
{% from "_macros.html" import icon, brand %}
{#
Shared chrome for the admin area: its own narrow nav rather than the chat
sidebar, so administration is visibly a different place from chatting.
#}
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}">
<link rel="stylesheet" href="{{ url_for('static', path='css/admin.css') }}">
{% endblock %}
{% block body_attrs %} data-authenticated="true"{% endblock %}
{% block body %}
<div class="shell">
<aside class="sidebar">
<div class="sidebar__header">
{{ brand(uid="admin") }}
</div>
<nav class="sidebar__scroll" aria-label="Administration">
<div class="nav-group">
<div class="nav-group__label">Administration</div>
<a class="nav-item {{ 'is-active' if section == 'connections' }}"
href="/admin/connections">
{{ icon("server", "icon--sm") }}
<span class="nav-item__label">Connections</span>
</a>
<a class="nav-item {{ 'is-active' if section == 'models' }}" href="/admin/models">
{{ icon("sliders", "icon--sm") }}
<span class="nav-item__label">Models</span>
</a>
</div>
<div class="nav-group">
<div class="nav-group__label">Not yet built</div>
<span class="nav-item is-disabled">
{{ icon("users", "icon--sm") }}
<span class="nav-item__label">Users &amp; groups</span>
</span>
<span class="nav-item is-disabled">
{{ icon("gear", "icon--sm") }}
<span class="nav-item__label">Tools</span>
</span>
</div>
</nav>
<div class="sidebar__footer">
<a class="nav-item" href="/chat">
{{ icon("chat", "icon--sm") }}
<span class="nav-item__label">Back to chats</span>
</a>
</div>
</aside>
<main class="main">
<header class="topbar">
<h1 class="topbar__title">{% block heading %}Administration{% endblock %}</h1>
<button class="btn btn--icon" type="button" data-theme-toggle aria-label="Switch theme">
<span class="theme-icon theme-icon--dark">{{ icon("moon") }}</span>
<span class="theme-icon theme-icon--light">{{ icon("sun") }}</span>
</button>
</header>
<div class="admin-scroll">
<div class="admin-page">
{% block admin_content %}{% endblock %}
</div>
</div>
</main>
</div>
{% endblock %}
@@ -0,0 +1,76 @@
{% extends "admin/_layout.html" %}
{% from "_macros.html" import icon %}
{% set section = "connections" %}
{% block title %}Connections - LLeMbas{% endblock %}
{% block heading %}Connections{% endblock %}
{% block admin_content %}
<p class="admin-lede">
Any endpoint that speaks the OpenAI HTTP API: OpenAI itself, or a local
runner such as LM Studio, vLLM, llama.cpp or Ollama. LLeMbas asks each one
for its model list and offers those models in the chat picker.
</p>
{% if message %}
<div class="alert alert--success">{{ message }}</div>
{% endif %}
<section class="card">
<h2 class="card__title">Add a connection</h2>
<form method="post" action="/admin/connections" class="form-grid">
<div class="field">
<label class="field__label" for="new-name">Name</label>
<input class="input" id="new-name" name="name" required placeholder="Local LM Studio">
</div>
<div class="field">
<label class="field__label" for="new-url">Base URL</label>
<input class="input input--mono" id="new-url" name="base_url" required
placeholder="http://localhost:1234/v1">
<p class="field__hint">
With or without the trailing <code>/v1</code> — both are accepted.
</p>
</div>
<div class="field">
<label class="field__label" for="new-key">API key</label>
<input class="input input--mono" id="new-key" name="api_key" type="password"
autocomplete="off" placeholder="sk-…">
<p class="field__hint">
Encrypted before it is stored, and never sent back to the browser.
Leave empty for endpoints that need no key.
</p>
</div>
<div class="field field--actions">
<button class="btn btn--primary" type="submit">
{{ icon("plus", "icon--sm") }} Add and load models
</button>
</div>
</form>
</section>
<h2 class="admin-section-title">
Configured connections
<span class="badge">{{ connections|length }}</span>
</h2>
{% if not connections %}
<div class="empty" style="padding: var(--sp-10) 0">
{{ icon("server", "empty__mark") }}
<p class="empty__text">
Nothing configured yet. Add a connection above and its models appear here.
</p>
</div>
{% endif %}
<div id="connection-list">
{% for connection in connections %}
{% with masked = masked[connection.id],
model_count = model_counts[connection.id] %}
{% include "admin/_connection_row.html" %}
{% endwith %}
{% endfor %}
</div>
{% endblock %}
@@ -0,0 +1,51 @@
{% extends "admin/_layout.html" %}
{% from "_macros.html" import icon %}
{% set section = "models" %}
{% block title %}Models - LLeMbas{% endblock %}
{% block heading %}Models{% endblock %}
{% block admin_content %}
<p class="admin-lede">
Every model discovered on each connection. Disable the ones you do not want
cluttering the chat picker — nothing is deleted, and re-running
“Test &amp; refresh” will not bring a disabled model back on.
</p>
{% for connection in connections %}
<section class="card">
<h2 class="card__title">
{{ connection.name }}
<span class="badge">{{ connection.models|length }}</span>
{% if not connection.enabled %}<span class="badge">connection disabled</span>{% endif %}
</h2>
{% if not connection.models %}
<p class="muted text-sm">
No models loaded. Run “Test &amp; refresh” on
<a href="/admin/connections">the connections page</a>.
</p>
{% else %}
<ul class="model-list">
{% for model in connection.models %}
<li class="model-list__item">
<code class="model-list__id">{{ model.model_id }}</code>
<form method="post" action="/admin/models/{{ model.id }}/toggle">
<button class="btn btn--sm {{ 'btn--primary' if model.enabled }}" type="submit">
{% if model.enabled %}{{ icon("check", "icon--sm") }} Enabled{% else %}Disabled{% endif %}
</button>
</form>
</li>
{% endfor %}
</ul>
{% endif %}
</section>
{% else %}
<div class="empty" style="padding: var(--sp-10) 0">
{{ icon("server", "empty__mark") }}
<p class="empty__text">
No connections yet. <a href="/admin/connections">Add one</a> to load models.
</p>
</div>
{% endfor %}
{% endblock %}
+47
View File
@@ -0,0 +1,47 @@
{% extends "base.html" %}
{% from "_macros.html" import icon, mark, wordmark %}
{% block title %}Sign in - LLeMbas{% endblock %}
{% block body %}
<main class="auth">
<div class="auth__card">
<div class="auth__brand">
{{ mark(cls="brand-mark", uid="auth") }}
<h1 class="auth__title">{{ wordmark() }}</h1>
<p class="auth__subtitle">Waybread for the long road of thought.</p>
</div>
{% if error %}
<div class="alert alert--error" role="alert">
{{ icon("warning", "alert__icon") }}
<span>{{ error }}</span>
</div>
{% endif %}
<form method="post" action="/auth/login" class="stack">
<input type="hidden" name="next" value="{{ next|default('/') }}">
<div class="field">
<label class="field__label" for="email">Email</label>
<input class="input" type="email" id="email" name="email" required
autocomplete="username" autofocus value="{{ email|default('') }}">
</div>
<div class="field">
<label class="field__label" for="password">Password</label>
<input class="input" type="password" id="password" name="password" required
autocomplete="current-password">
</div>
<button class="btn btn--primary btn--block" type="submit">Sign in</button>
</form>
{% if allow_signup %}
<p class="auth__footer">
No account yet? <a href="/auth/register">Create one</a>.
</p>
{% endif %}
</div>
</main>
{% endblock %}
@@ -0,0 +1,60 @@
{% extends "base.html" %}
{% from "_macros.html" import icon, mark, wordmark %}
{% block title %}{% if first_run %}Set up LLeMbas{% else %}Create an account{% endif %}{% endblock %}
{% block body %}
<main class="auth">
<div class="auth__card">
<div class="auth__brand">
{{ mark(cls="brand-mark", uid="auth") }}
<h1 class="auth__title">{{ wordmark() }}</h1>
{% if first_run %}
<p class="auth__subtitle">
Nobody has claimed this instance yet. The first account becomes its administrator.
</p>
{% else %}
<p class="auth__subtitle">Create your account.</p>
{% endif %}
</div>
{% if error %}
<div class="alert alert--error" role="alert">
{{ icon("warning", "alert__icon") }}
<span>{{ error }}</span>
</div>
{% endif %}
<form method="post" action="/auth/register" class="stack">
<div class="field">
<label class="field__label" for="name">Name</label>
<input class="input" type="text" id="name" name="name" required autofocus
autocomplete="name" value="{{ name|default('') }}">
</div>
<div class="field">
<label class="field__label" for="email">Email</label>
<input class="input" type="email" id="email" name="email" required
autocomplete="username" value="{{ email|default('') }}">
</div>
<div class="field">
<label class="field__label" for="password">Password</label>
<input class="input" type="password" id="password" name="password" required
autocomplete="new-password" minlength="8">
<p class="field__hint">At least 8 characters.</p>
</div>
<button class="btn btn--primary btn--block" type="submit">
{% if first_run %}Create administrator account{% else %}Create account{% endif %}
</button>
</form>
{% if not first_run %}
<p class="auth__footer">
Already have an account? <a href="/auth/login">Sign in</a>.
</p>
{% endif %}
</div>
</main>
{% endblock %}
+43
View File
@@ -0,0 +1,43 @@
<!doctype html>
<html lang="en" data-theme="{{ theme }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}LLeMbas{% endblock %}</title>
<meta name="description" content="LLeMbas - a web UI for your language models.">
<meta name="color-scheme" content="dark light">
<link rel="icon" href="{{ url_for('static', path='img/favicon.svg') }}" type="image/svg+xml">
<link rel="stylesheet" href="{{ url_for('static', path='css/tokens.css') }}">
<link rel="stylesheet" href="{{ url_for('static', path='css/app.css') }}">
{% block head %}{% endblock %}
{#
Applied before first paint so a reload never flashes the wrong theme. The
server already rendered data-theme from the signed-in user's preference; this
only corrects it for a visitor whose local choice differs (or who is signed
out, where the server has nothing to go on).
#}
<script>
(function () {
try {
var stored = localStorage.getItem("lembas-theme");
if (stored && stored !== document.documentElement.dataset.theme) {
document.documentElement.dataset.theme = stored;
}
} catch (e) { /* private mode: the server-rendered theme stands */ }
})();
</script>
</head>
<body{% block body_attrs %}{% endblock %}>
{% include "partials/icons.html" %}
{% block body %}{% endblock %}
<script src="{{ url_for('static', path='vendor/htmx.min.js') }}" defer></script>
<script src="{{ url_for('static', path='vendor/htmx-ext-sse.js') }}" defer></script>
<script src="{{ url_for('static', path='vendor/alpine.min.js') }}" defer></script>
<script src="{{ url_for('static', path='js/app.js') }}" defer></script>
{% block scripts %}{% endblock %}
</body>
</html>
@@ -0,0 +1,93 @@
{% from "_macros.html" import icon, mark %}
{#
One message bubble, in either of two states.
An incomplete assistant message renders the streaming shell: it carries the
sse-connect that opens the reply stream. This is deliberately the ONLY thing
that starts a generation, which means a page load showing an unfinished reply
picks it up again -- reloading after a dropped connection retries rather than
leaving a permanently half-written answer.
A complete message renders its finished body: Markdown for the assistant,
escaped plain text for everyone else.
#}
{% set streaming = (message.role == "assistant" and not message.complete) %}
<article class="msg msg--{{ message.role }}" id="msg-{{ message.id }}"
{% if streaming %}
hx-ext="sse"
sse-connect="/api/chats/{{ chat.id }}/messages/{{ message.id }}/stream"
sse-close="close"
{% endif %}>
<div class="msg__gutter" aria-hidden="true">
{% if message.role == "assistant" %}
{{ mark(cls="msg__mark", uid="m" ~ message.id) }}
{% else %}
<span class="msg__initial">{{ (user.name or "?")[0]|upper }}</span>
{% endif %}
</div>
<div class="msg__main">
<header class="msg__meta">
<span class="msg__author">
{{ "LLeMbas" if message.role == "assistant" else (user.name or "You") }}
</span>
{% if message.model_id %}
<span class="msg__model" title="{{ message.model_id }}">{{ message.model_id }}</span>
{% endif %}
</header>
{% if streaming %}
{# Tokens are appended here as they arrive. The cursor is a CSS
pseudo-element on the empty parent, so it disappears by itself once
the first token lands. #}
<div class="msg__body msg__body--streaming" id="stream-{{ message.id }}"
sse-swap="token" hx-swap="beforeend"></div>
<div class="msg__waiting">
<span class="dots"><i></i><i></i><i></i></span>
</div>
{% elif message.error %}
<div class="alert alert--error msg__error" role="alert">
{{ icon("warning", "alert__icon") }}
<div>
<strong>The reply could not be completed.</strong>
<div class="text-sm" style="margin-top: var(--sp-1)">{{ message.error }}</div>
</div>
</div>
{% if message.content %}
<div class="msg__body">{{ body_html|safe }}</div>
{% endif %}
{% elif message.role == "assistant" %}
<div class="msg__body">{{ body_html|safe }}</div>
{% else %}
<div class="msg__body msg__body--plain">{{ message.content }}</div>
{% endif %}
{% if not streaming %}
<footer class="msg__actions">
<button class="btn btn--icon btn--sm" type="button"
data-copy="msg-body-{{ message.id }}" aria-label="Copy message">
{{ icon("copy", "icon--sm") }}
</button>
{% if message.role == "assistant" %}
<button class="btn btn--icon btn--sm" type="button"
hx-post="/api/chats/{{ chat.id }}/messages/{{ message.id }}/regenerate"
hx-target="#msg-{{ message.id }}" hx-swap="outerHTML"
aria-label="Regenerate reply">
{{ icon("refresh", "icon--sm") }}
</button>
{% endif %}
</footer>
{# The raw source, so the copy button yields Markdown rather than rendered
text. A hidden div and not a <script>: script content is raw text, so
the escaping Jinja applies would be copied out literally as entities. #}
<div hidden id="msg-body-{{ message.id }}">{{ message.content }}</div>
{% endif %}
</div>
{% if streaming %}
{# Receives the finished bubble and replaces this whole article with it. #}
<div hidden sse-swap="done" hx-target="#msg-{{ message.id }}" hx-swap="outerHTML"></div>
{% endif %}
</article>
@@ -0,0 +1,10 @@
{#
Out-of-band updates sent alongside the finished reply.
A chat is named from its first exchange, which happens on the server while
the reply streams. These two fragments push the new title into the page
without the client having to poll or reload.
#}
<span id="chat-title" hx-swap-oob="true">{{ chat.title }}</span>
<span id="chat-link-label-{{ chat.id }}" hx-swap-oob="true"
class="nav-item__label">{{ chat.title }}</span>
+11
View File
@@ -0,0 +1,11 @@
{#
One exchange: the user's message plus the empty assistant bubble that will
stream into it. Returned by POST /api/chats/{id}/messages and appended to the
thread in a single swap, so the pair always arrives together.
#}
{% with message = user_message %}
{% include "chat/_message.html" %}
{% endwith %}
{% with message = assistant_message %}
{% include "chat/_message.html" %}
{% endwith %}
+120
View File
@@ -0,0 +1,120 @@
{% extends "base.html" %}
{% from "_macros.html" import icon, mark %}
{% block title %}{{ chat.title if chat else "Chats" }} - LLeMbas{% endblock %}
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}">
{% endblock %}
{% block body_attrs %} data-authenticated="true"{% endblock %}
{% block body %}
<div class="shell">
{% include "partials/sidebar.html" %}
<main class="main">
<header class="topbar">
<button class="btn btn--icon" type="button" aria-label="Toggle sidebar"
onclick="document.getElementById('sidebar').toggleAttribute('hidden')">
{{ icon("sidebar") }}
</button>
{% if chat %}
<h1 class="topbar__title"><span id="chat-title">{{ chat.title }}</span></h1>
{% if models %}
<form hx-patch="/api/chats/{{ chat.id }}" hx-swap="none" hx-trigger="change from:find select">
<select class="select select--compact" name="model_id" aria-label="Model">
{% for model in models %}
<option value="{{ model.model_id }}" {{ 'selected' if model.model_id == chat.model_id }}>
{{ model.label }}
</option>
{% endfor %}
</select>
</form>
{% endif %}
{% else %}
<h1 class="topbar__title">Chats</h1>
{% endif %}
</header>
{% if not chat %}
{# No chat selected. #}
<div class="empty">
{{ mark(cls="empty__mark", uid="empty") }}
<h2 class="empty__title">The road goes ever on</h2>
<p class="empty__text">
Pick a chat from the side, or start a new one.
</p>
<button class="btn btn--primary" hx-post="/api/chats" hx-swap="none">
{{ icon("plus", "icon--sm") }} New chat
</button>
</div>
{% elif not models %}
{# Nothing to talk to yet. This is the state every fresh install lands in,
so it points straight at the fix rather than just reporting a problem. #}
<div class="empty">
{{ icon("server", "empty__mark") }}
<h2 class="empty__title">No models available</h2>
<p class="empty__text">
{% if user.is_admin %}
Add an OpenAI-compatible connection and LLeMbas will load its models.
{% else %}
No model connections have been set up yet. Ask an administrator.
{% endif %}
</p>
{% if user.is_admin %}
<a class="btn btn--primary" href="/admin/connections">
{{ icon("server", "icon--sm") }} Set up a connection
</a>
{% endif %}
</div>
{% else %}
<div class="thread-scroll" id="thread-scroll">
<div class="thread" id="thread">
{% if not messages %}
<div class="thread__intro">
{{ mark(cls="empty__mark", uid="intro") }}
<h2 class="empty__title">What would you ask?</h2>
<p class="empty__text">Speak, friend, and enter.</p>
</div>
{% endif %}
{% for message in messages %}
{# Markdown was rendered server-side in pages.py, keyed by message
id, so this loop stays a lookup rather than a render. #}
{% with body_html = bodies.get(message.id, "") %}
{% include "chat/_message.html" %}
{% endwith %}
{% endfor %}
</div>
</div>
<div class="composer">
<form class="composer__form"
hx-post="/api/chats/{{ chat.id }}/messages"
hx-target="#thread" hx-swap="beforeend"
hx-on::after-request="if (event.detail.successful) {
this.reset();
const t = this.querySelector('textarea');
window.lembas.autosize(t);
window.lembas.scrollThread(true);
}">
<textarea class="composer__input" name="content" rows="1"
data-autosize data-max-height="320" data-composer-input
placeholder="Send a message…" aria-label="Message"></textarea>
<button class="btn btn--primary composer__send" type="submit" aria-label="Send">
{{ icon("send", "icon--sm") }}
</button>
</form>
<p class="composer__hint">
Enter to send, Shift+Enter for a new line.
</p>
</div>
{% endif %}
</main>
</div>
{% endblock %}
+18
View File
@@ -0,0 +1,18 @@
{% extends "base.html" %}
{% from "_macros.html" import mark %}
{% block title %}{{ status_code }} - LLeMbas{% endblock %}
{% block body %}
<main class="auth">
<div class="auth__card" style="text-align: center">
{{ mark(cls="empty__mark", uid="err") }}
<h1 class="auth__title" style="margin-top: var(--sp-4)">{{ status_code }}</h1>
<p class="empty__text" style="margin: var(--sp-3) auto var(--sp-5)">{{ flavour }}</p>
{% if detail and detail != flavour %}
<p class="text-sm muted" style="margin-bottom: var(--sp-5)">{{ detail }}</p>
{% endif %}
<a class="btn btn--primary" href="/">Back to your chats</a>
</div>
</main>
{% endblock %}
@@ -0,0 +1,21 @@
{% from "_macros.html" import icon %}
{#
A single chat row. `chat_item` rather than `chat` so this can be included
from a page that already has the open chat bound to `chat`.
#}
<div class="nav-item {% if chat and chat.id == chat_item.id %}is-active{% endif %}"
data-chat-id="{{ chat_item.id }}">
<a class="nav-item__link" href="/chat/{{ chat_item.id }}">
{{ icon("chat", "icon--sm") }}
<span class="nav-item__label" id="chat-link-label-{{ chat_item.id }}">{{ chat_item.title }}</span>
</a>
<span class="nav-item__actions">
<button class="btn btn--icon btn--sm" type="button"
hx-delete="/api/chats/{{ chat_item.id }}"
hx-confirm="Delete “{{ chat_item.title }}”? This cannot be undone."
hx-swap="none"
aria-label="Delete chat">
{{ icon("trash", "icon--sm") }}
</button>
</span>
</div>
@@ -0,0 +1,46 @@
{% from "_macros.html" import icon %}
{#
A folder and everything under it. Recursive: a folder includes this same
template for each of its children, so nesting has no fixed depth limit.
Open/closed state is local to the browser (Alpine), not persisted per
request -- collapsing a folder should not cost a round trip.
#}
<div class="folder" x-data="{ open: {{ 'false' if folder.collapsed else 'true' }} }">
<div class="nav-item folder__row">
<button class="folder__toggle" type="button" @click="open = !open"
:aria-expanded="open ? 'true' : 'false'">
<span class="folder__chevron" :class="open && 'is-open'">
{{ icon("chevron-right", "icon--sm") }}
</span>
<span x-show="open">{{ icon("folder-open", "icon--sm") }}</span>
<span x-show="!open" x-cloak>{{ icon("folder", "icon--sm") }}</span>
<span class="nav-item__label">{{ folder.name }}</span>
</button>
<span class="nav-item__actions">
<button class="btn btn--icon btn--sm" type="button"
hx-delete="/api/folders/{{ folder.id }}"
hx-confirm="Delete the folder “{{ folder.name }}”? Chats inside it are kept."
hx-swap="none"
aria-label="Delete folder">
{{ icon("trash", "icon--sm") }}
</button>
</span>
</div>
<div class="folder__contents" x-show="open" x-cloak>
{% for child in folder.children %}
{% with folder = child %}
{% include "partials/_folder.html" %}
{% endwith %}
{% endfor %}
{% for chat_item in folder.chats %}
{% include "partials/_chat_link.html" %}
{% endfor %}
{% if not folder.children and not folder.chats %}
<p class="nav-empty">Empty</p>
{% endif %}
</div>
</div>
@@ -0,0 +1,72 @@
{% from "_macros.html" import icon, brand %}
{#
Sidebar: brand, new chat, the folder tree, then unfiled chats.
Folders render recursively through _folder.html. Chats appear under their
folder, and any chat without one falls to the flat list at the bottom.
#}
<aside class="sidebar" id="sidebar" x-data="{ }">
<div class="sidebar__header">
{{ brand(uid="side") }}
</div>
<div class="sidebar__actions">
<button class="btn btn--primary btn--block" hx-post="/api/chats" hx-swap="none">
{{ icon("plus", "icon--sm") }} New chat
</button>
<button class="btn btn--icon" hx-post="/api/folders" hx-swap="none"
hx-vals='{"name": "New folder"}' aria-label="New folder" title="New folder">
{{ icon("folder") }}
</button>
</div>
<nav class="sidebar__scroll" id="sidebar-tree" aria-label="Chats">
{% if folders %}
<div class="nav-group">
<div class="nav-group__label">Folders</div>
{% for folder in folders %}
{% include "partials/_folder.html" %}
{% endfor %}
</div>
{% endif %}
<div class="nav-group">
<div class="nav-group__label">Chats</div>
{% if unfiled_chats %}
{% for chat_item in unfiled_chats %}
{% include "partials/_chat_link.html" %}
{% endfor %}
{% else %}
<p class="nav-empty">No chats yet. The road begins here.</p>
{% endif %}
</div>
</nav>
<div class="sidebar__footer">
<div class="row row--between">
<a class="nav-item" href="/settings" style="flex: 1">
{{ icon("user", "icon--sm") }}
<span class="nav-item__label">{{ user.name }}</span>
{% if user.is_admin %}<span class="badge badge--gold">admin</span>{% endif %}
</a>
</div>
<div class="row" style="gap: var(--sp-1); margin-top: var(--sp-1)">
{% if user.is_admin %}
<a class="btn btn--icon" href="/admin" aria-label="Admin settings" title="Admin settings">
{{ icon("shield") }}
</a>
{% endif %}
<button class="btn btn--icon" type="button" data-theme-toggle
aria-label="Switch theme" title="Switch theme">
<span class="theme-icon theme-icon--dark">{{ icon("moon") }}</span>
<span class="theme-icon theme-icon--light">{{ icon("sun") }}</span>
</button>
<span class="topbar__spacer"></span>
<form method="post" action="/auth/logout">
<button class="btn btn--icon" type="submit" aria-label="Sign out" title="Sign out">
{{ icon("logout") }}
</button>
</form>
</div>
</div>
</aside>
+70
View File
@@ -0,0 +1,70 @@
{% extends "base.html" %}
{% from "_macros.html" import icon %}
{% block title %}Your settings - LLeMbas{% endblock %}
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}">
<link rel="stylesheet" href="{{ url_for('static', path='css/admin.css') }}">
{% endblock %}
{% block body_attrs %} data-authenticated="true"{% endblock %}
{% block body %}
<div class="shell">
{% include "partials/sidebar.html" %}
<main class="main">
<header class="topbar">
<h1 class="topbar__title">Your settings</h1>
</header>
<div class="admin-scroll">
<div class="admin-page">
<section class="card">
<h2 class="card__title">Account</h2>
<div class="field">
<span class="field__label">Name</span>
<p>{{ user.name }}</p>
</div>
<div class="field">
<span class="field__label">Email</span>
<p class="mono text-sm">{{ user.email }}</p>
</div>
<div class="field">
<span class="field__label">Role</span>
<p>
<span class="badge {{ 'badge--gold' if user.is_admin }}">{{ user.role }}</span>
</p>
</div>
</section>
<section class="card">
<h2 class="card__title">Appearance</h2>
<div class="row" style="gap: var(--sp-3)">
<button class="btn" type="button" onclick="window.lembas.applyTheme('moria')">
{{ icon("moon", "icon--sm") }} Moria
</button>
<button class="btn" type="button" onclick="window.lembas.applyTheme('shire')">
{{ icon("sun", "icon--sm") }} Shire
</button>
</div>
<p class="field__hint" style="margin-top: var(--sp-3)">
Moria is the dark theme, Shire the light one. Your choice is saved
to this browser and to your account.
</p>
</section>
<section class="card">
<h2 class="card__title">Session</h2>
<form method="post" action="/auth/logout">
<button class="btn btn--danger" type="submit">
{{ icon("logout", "icon--sm") }} Sign out
</button>
</form>
</section>
</div>
</div>
</main>
</div>
{% endblock %}
+58
View File
@@ -0,0 +1,58 @@
"""Jinja environment and the context every template receives."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from fastapi import Request
from fastapi.templating import Jinja2Templates
from lembas import __version__
from lembas.config import settings
from lembas.db.models import User
TEMPLATE_DIR = Path(__file__).parent / "templates"
STATIC_DIR = Path(__file__).parent / "static"
templates = Jinja2Templates(directory=str(TEMPLATE_DIR))
templates.env.trim_blocks = True
templates.env.lstrip_blocks = True
def resolve_theme(user: User | None) -> str:
"""Theme to render with on the server.
Only ever a first guess: the inline script in base.html corrects it from
localStorage before first paint. Getting it close server-side is what stops
a signed-in user seeing a flash of the wrong theme on every navigation.
"""
if user is not None:
chosen = (user.settings_json or {}).get("theme")
if chosen in ("moria", "shire"):
return chosen
return settings.default_theme
def render(
request: Request,
template: str,
context: dict[str, Any] | None = None,
**kwargs: Any,
):
"""Render a template with the globals every page expects.
Using this instead of templates.TemplateResponse directly is what
guarantees `user` and `theme` are always defined, so templates never need
to guard against a missing variable.
"""
user = getattr(request.state, "user", None)
payload: dict[str, Any] = {
"request": request,
"user": user,
"theme": resolve_theme(user),
"version": __version__,
"allow_signup": settings.allow_signup,
}
payload.update(context or {})
return templates.TemplateResponse(request, template, payload, **kwargs)