Scaffold project, data model and artwork

Establish the LLeMbas foundation: FastAPI/Jinja/SQLite layout, the ORM
schema, and the original SVG identity.

Notable decisions, all recorded in comments at the point they matter:

- No Alembic. SQLite only, schema created at startup, so models carry a
  few columns nothing reads yet (Message.parent_id for branching,
  content_parts_json for multimodal turns). Adding them later to a live
  database without migrations is the painful path.
- Sessions are server-side rows keyed by a SHA-256 of the cookie value,
  not JWTs, so logout and bans revoke access immediately.
- Upstream API keys are Fernet-encrypted with a key derived from
  LEMBAS_SECRET_KEY. decrypt() fails soft to "" so rotating the secret
  degrades to re-entering keys rather than crashing the admin UI.
- Artwork is generated by scripts/build_artwork.py rather than hand-drawn
  per file: the mallorn leaf appears in the icon, favicon, lockup and
  banner, and one source is the only way those stay in sync. The wordmark
  is Source Serif 4 (OFL) converted to outlines, because a README banner
  cannot load a webfont and <text> would render in whatever serif the
  viewer happens to have.
- Icons live in a template partial, not assets/, because same-document
  <use href="#id"> is universally supported and the cross-document form
  is not.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-07-21 10:34:48 +02:00
parent 0665027bc6
commit 5ef2af6a9f
32 changed files with 2041 additions and 0 deletions
+473
View File
@@ -0,0 +1,473 @@
#!/usr/bin/env python3
"""Generate every LLeMbas SVG asset from one source of truth.
Why a generator rather than five hand-written files: the leaf mark appears in
the icon, the favicon, the lockup and the banner. Keeping the geometry in one
place is the only way those stay identical as the mark is tuned.
Why the wordmark is outlines and not <text>: a README banner on GitHub or Gitea
cannot load a webfont, so <text> would render in whatever serif the viewer
happens to have. Outlines look the same everywhere. Letterforms come from
Source Serif 4 (Adobe, SIL OFL 1.1); only the handful of glyphs actually used
are extracted, as a static drawing -- no font binary is redistributed.
This is a design-time tool. The application never imports it, and the generated
files are committed. Re-run it only when the artwork itself changes:
pip install fonttools
python scripts/build_artwork.py
"""
from __future__ import annotations
import argparse
import random
import sys
from pathlib import Path
try:
from fontTools.pens.boundsPen import BoundsPen
from fontTools.pens.svgPathPen import SVGPathPen
from fontTools.pens.transformPen import TransformPen
from fontTools.ttLib import TTFont
except ImportError: # pragma: no cover - design-time tool
sys.exit("fontTools is required for this script: pip install fonttools")
ROOT = Path(__file__).resolve().parent.parent
ASSETS = ROOT / "assets"
FONT_SEMIBOLD = Path("/usr/share/fonts/adobe-source-serif/SourceSerif4Display-Semibold.otf")
FONT_ITALIC = Path("/usr/share/fonts/adobe-source-serif/SourceSerif4Display-It.otf")
WORDMARK = "LLeMbas"
TAGLINE = "Waybread for the long road of thought"
# The capitals of LLeMbas spell LLM. Those three glyphs carry the accent colour.
ACCENT_GLYPHS = frozenset({0, 1, 3})
# --- Palette -----------------------------------------------------------------
GOLD_LIGHT = "#EACB74"
GOLD = "#C9A227"
GOLD_DARK = "#916F13"
GOLD_SCORE = "#7A5C10"
GOLD_HILIGHT = "#F6E3A8"
RUNE_GOLD = "#E0B252"
LEAF_EDGE = "#93A5B6"
LEAF_LIGHT = "#F1F6FA"
LEAF_MID = "#B8C7D5"
LEAF_VEIN = "#61758A"
LEAF_STEM = "#8A9AA8"
NIGHT_TOP = "#080B0F"
NIGHT_MID = "#101822"
NIGHT_LOW = "#1A2530"
PARCHMENT = "#EDE6D6"
INK = "#1B1F23"
MUTED = "#9AA7B4"
# --- The mallorn leaf --------------------------------------------------------
# Drawn once, in a 64x64 box, and reused everywhere. Tuned so the silhouette
# still reads as a leaf at 16px, where veins and score lines disappear.
LEAF_BLADE = (
"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"
)
LEAF_MIDRIB = "M20.5 45.5 C28 38 36 29 45.5 18.5"
LEAF_STEM_PATH = "M21.2 44.8 L17 49.4"
LEAF_VEINS = [
"M26.9 38.8 Q25.2 35.8 24.9 32.1",
"M32.3 33.1 Q30.9 30.3 30.4 26.9",
"M37.8 27.0 Q36.6 24.6 36.3 21.7",
"M26.9 38.8 Q30.5 40.1 33.7 40.3",
"M32.3 33.1 Q35.8 34.3 38.6 34.5",
"M37.8 27.0 Q40.8 27.9 43.2 28.1",
]
HEADER = '<svg xmlns="http://www.w3.org/2000/svg"'
# --- Type --------------------------------------------------------------------
class TextRun:
"""A string converted to SVG outlines, positioned with the baseline at y=0."""
def __init__(self, font_path: Path, text: str, cap_px: float):
if not font_path.exists():
sys.exit(f"font not found: {font_path}")
font = TTFont(font_path)
cmap = font.getBestCmap()
glyph_set = font.getGlyphSet()
hmtx = font["hmtx"]
# Scale to cap height rather than em size: it is what the eye measures.
cap_height = getattr(font["OS/2"], "sCapHeight", None) or font["head"].unitsPerEm * 0.7
scale = cap_px / cap_height
self.glyphs: list[dict] = []
pen_x = 0.0
x0 = y0 = float("inf")
x1 = y1 = float("-inf")
for index, char in enumerate(text):
if char == " ":
pen_x += hmtx[cmap[ord(" ")]][0] * scale
continue
name = cmap.get(ord(char))
if name is None:
sys.exit(f"{font_path.name} has no glyph for {char!r}")
# Font coordinates run upwards; SVG's run downwards, hence -scale.
transform = (scale, 0, 0, -scale, pen_x, 0)
svg_pen = SVGPathPen(glyph_set, ntos=lambda v: f"{v:.2f}")
glyph_set[name].draw(TransformPen(svg_pen, transform))
bounds_pen = BoundsPen(glyph_set)
glyph_set[name].draw(TransformPen(bounds_pen, transform))
if bounds_pen.bounds:
gx0, gy0, gx1, gy1 = bounds_pen.bounds
x0, y0 = min(x0, gx0), min(y0, gy0)
x1, y1 = max(x1, gx1), max(y1, gy1)
self.glyphs.append(
{"char": char, "index": index, "path": svg_pen.getCommands()}
)
pen_x += hmtx[name][0] * scale
self.x0, self.y0, self.x1, self.y1 = x0, y0, x1, y1
self.width = x1 - x0
self.height = y1 - y0
def paths(
self,
accent_indices: frozenset[int] = frozenset(),
*,
indent: str = " ",
base_class: str = "base",
accent_class: str = "accent",
) -> str:
"""Render the glyphs, offset so the run's left edge sits at x=0.
Class names are caller-supplied because <style> inside an SVG is scoped
to the whole document, not to the group it sits in. Two runs in one file
sharing a class name means the second rule silently recolours the first.
"""
out = []
for glyph in self.glyphs:
cls = accent_class if glyph["index"] in accent_indices else base_class
out.append(
f'{indent}<path class="{cls}" data-char="{glyph["char"]}" '
f'd="{glyph["path"]}"/>'
)
return "\n".join(out)
@property
def origin_shift(self) -> str:
"""Transform placing the run's top-left at the current origin."""
return f"translate({-self.x0:.2f} {-self.y0:.2f})"
def type_style(indent: str = " ") -> str:
"""Colour rules for outlined type.
The CSS variables let the application theme the type when the SVG is
inlined into a page. The literal fallbacks matter just as much: opened as a
standalone file or loaded through <img>, no page CSS reaches the document,
so the media query is the only thing keeping the wordmark legible on a dark
background.
"""
return f"""{indent}<style>
{indent} .base {{ fill: var(--lembas-ink, {INK}); }}
{indent} .accent {{ fill: var(--lembas-gold, {GOLD}); }}
{indent} @media (prefers-color-scheme: dark) {{
{indent} .base {{ fill: var(--lembas-ink, {PARCHMENT}); }}
{indent} .accent {{ fill: var(--lembas-gold, {RUNE_GOLD}); }}
{indent} }}
{indent}</style>"""
# --- The mark ----------------------------------------------------------------
def mark_defs(prefix: str) -> str:
return f""" <defs>
<linearGradient id="{prefix}-wafer" x1="0" y1="0" x2="0.3" y2="1">
<stop offset="0" stop-color="{GOLD_LIGHT}"/>
<stop offset="0.5" stop-color="{GOLD}"/>
<stop offset="1" stop-color="{GOLD_DARK}"/>
</linearGradient>
<linearGradient id="{prefix}-leaf" x1="0.1" y1="1" x2="0.9" y2="0">
<stop offset="0" stop-color="{LEAF_EDGE}"/>
<stop offset="0.4" stop-color="{LEAF_LIGHT}"/>
<stop offset="1" stop-color="{LEAF_MID}"/>
</linearGradient>
<clipPath id="{prefix}-clip">
<rect x="6" y="6" width="52" height="52" rx="13"/>
</clipPath>
</defs>"""
def mark_body(prefix: str, *, detail: bool = True) -> str:
"""The wafer-and-leaf mark in a 64x64 box.
detail=False drops the score lines, rim and veins for small-size use.
"""
parts = [f' <rect x="6" y="6" width="52" height="52" rx="13" fill="url(#{prefix}-wafer)"/>']
if detail:
parts.append(f""" <g clip-path="url(#{prefix}-clip)" fill="none" stroke-linecap="round">
<g stroke="{GOLD_SCORE}" stroke-opacity="0.38" stroke-width="2">
<path d="M32 6 V58"/>
<path d="M6 32 H58"/>
</g>
<g stroke="{GOLD_HILIGHT}" 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="{GOLD_SCORE}" stroke-opacity="0.3" stroke-width="1.2"/>""")
parts.append(f""" <g>
<path d="{LEAF_STEM_PATH}" stroke="{LEAF_STEM}" stroke-width="3"
stroke-linecap="round" fill="none"/>
<path d="{LEAF_BLADE}" fill="url(#{prefix}-leaf)"/>
<path d="{LEAF_MIDRIB}" fill="none" stroke="{LEAF_VEIN}" stroke-opacity="0.5"
stroke-width="1.5" stroke-linecap="round"/>""")
if detail:
veins = "\n".join(f' <path d="{v}"/>' for v in LEAF_VEINS)
parts.append(f""" <g fill="none" stroke="{LEAF_VEIN}" stroke-opacity="0.32"
stroke-width="1" stroke-linecap="round">
{veins}
</g>""")
parts.append(" </g>")
return "\n".join(parts)
# --- Asset builders ----------------------------------------------------------
def build_logo_mark() -> str:
return f"""{HEADER} 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>
{mark_defs("m")}
{mark_body("m")}
</svg>
"""
def build_favicon() -> str:
"""Small-size variant: no score lines or veins, larger blade, tighter tile."""
return f"""{HEADER} viewBox="0 0 64 64" width="64" height="64"
role="img" aria-label="LLeMbas">
<title>LLeMbas</title>
{mark_defs("f")}
<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="{LEAF_STEM}" stroke-width="3.4"
stroke-linecap="round" fill="none"/>
<path d="{LEAF_BLADE}" fill="url(#f-leaf)"/>
<path d="{LEAF_MIDRIB}" fill="none" stroke="{LEAF_VEIN}" stroke-opacity="0.45"
stroke-width="1.8" stroke-linecap="round"/>
</g>
</svg>
"""
def build_wordmark() -> str:
"""Standalone type. Inherits colour so it can sit on any background."""
run = TextRun(FONT_SEMIBOLD, WORDMARK, 100)
return f"""{HEADER} viewBox="0 0 {run.width:.2f} {run.height:.2f}"
width="{run.width:.2f}" height="{run.height:.2f}" role="img" aria-label="LLeMbas">
<title>LLeMbas</title>
<!-- Source Serif 4 (SIL OFL 1.1) outlines. The capitals L, L and M spell out
LLM and take the accent colour; see scripts/build_artwork.py. -->
{type_style(" ")}
<g transform="{run.origin_shift}">
{run.paths(ACCENT_GLYPHS, indent=" ")}
</g>
</svg>
"""
def build_lockup() -> str:
"""Horizontal mark + wordmark, for the application header."""
cap = 46.0
run = TextRun(FONT_SEMIBOLD, WORDMARK, cap)
mark_size = 64.0
gap = 20.0
pad = 4.0
height = mark_size + pad * 2
text_x = pad + mark_size + gap
# Optically centre on the cap height rather than the full glyph bounds, so
# the ascender of "b" and the overshoot of "e" do not shift the baseline.
baseline_y = height / 2 + cap / 2
width = text_x + run.width + pad
return f"""{HEADER} viewBox="0 0 {width:.2f} {height:.2f}"
width="{width:.2f}" height="{height:.2f}" role="img" aria-label="LLeMbas">
<title>LLeMbas</title>
{mark_defs("l")}
{type_style(" ")}
<g transform="translate({pad} {pad})">
{mark_body("l")}
</g>
<g transform="translate({text_x - run.x0:.2f} {baseline_y:.2f})">
{run.paths(ACCENT_GLYPHS, indent=" ")}
</g>
</svg>
"""
def _mountains(width: float, base_y: float, seed: int, height: float, colour: str) -> str:
"""One jagged ridge line spanning the full width."""
rng = random.Random(seed)
points = [(0.0, base_y)]
x = 0.0
while x < width:
step = rng.uniform(width * 0.045, width * 0.11)
x = min(x + step, width)
peak = base_y - rng.uniform(height * 0.35, height)
points.append((x, peak))
# A short shoulder after each peak keeps the ridge from looking like a saw.
if x < width:
x = min(x + rng.uniform(width * 0.01, width * 0.03), width)
points.append((x, peak + rng.uniform(height * 0.08, height * 0.25)))
points.append((width, base_y))
coords = " ".join(f"{px:.1f},{py:.1f}" for px, py in points)
return f' <polygon points="{coords} {width:.0f},999 0,999" fill="{colour}"/>'
def _stars(width: float, height: float, count: int, seed: int) -> str:
rng = random.Random(seed)
out = []
for _ in range(count):
sx = rng.uniform(0, width)
sy = rng.uniform(0, height)
r = rng.uniform(0.6, 1.9)
opacity = rng.uniform(0.18, 0.85)
out.append(
f' <circle cx="{sx:.1f}" cy="{sy:.1f}" r="{r:.2f}" opacity="{opacity:.2f}"/>'
)
return "\n".join(out)
def _drifting_leaves(seed: int) -> str:
"""A few mallorn leaves adrift in the sky, well behind the type."""
rng = random.Random(seed)
placements = [
(120, 90, 0.42, -18), (250, 250, 0.30, 24), (1035, 95, 0.36, 12),
(1160, 215, 0.46, -32), (905, 300, 0.26, 40), (185, 300, 0.24, -8),
]
out = []
for cx, cy, scale, rot in placements:
opacity = rng.uniform(0.10, 0.19)
out.append(
f' <g transform="translate({cx} {cy}) rotate({rot}) '
f'scale({scale}) translate(-32 -32)" opacity="{opacity:.2f}">'
f'<path d="{LEAF_BLADE}" fill="{RUNE_GOLD}"/></g>'
)
return "\n".join(out)
def build_banner() -> str:
"""README hero.
Carries its own dark background rather than relying on the page, because a
README is rendered on a light background as often as a dark one.
"""
width, height = 1280.0, 420.0
cap = 92.0
run = TextRun(FONT_SEMIBOLD, WORDMARK, cap)
tag = TextRun(FONT_ITALIC, TAGLINE, 26.0)
mark_size = 136.0
gap = 34.0
lockup_w = mark_size + gap + run.width
lockup_x = (width - lockup_w) / 2
baseline_y = 232.0
mark_y = baseline_y - cap / 2 - mark_size / 2
tag_x = (width - tag.width) / 2 - tag.x0
tag_y = baseline_y + 68.0
mark_scale = mark_size / 64.0
return f"""{HEADER} viewBox="0 0 {width:.0f} {height:.0f}"
width="{width:.0f}" height="{height:.0f}" role="img"
aria-label="LLeMbas - {TAGLINE}">
<title>LLeMbas</title>
<desc>{TAGLINE}. 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="{NIGHT_TOP}"/>
<stop offset="0.62" stop-color="{NIGHT_MID}"/>
<stop offset="1" stop-color="{NIGHT_LOW}"/>
</linearGradient>
<radialGradient id="b-glow" cx="0.5" cy="0.54" r="0.5">
<stop offset="0" stop-color="{GOLD}" stop-opacity="0.22"/>
<stop offset="1" stop-color="{GOLD}" 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>
{mark_defs("b").removeprefix(" <defs>").removesuffix(" </defs>").rstrip()}
</defs>
<rect width="{width:.0f}" height="{height:.0f}" fill="url(#b-sky)"/>
<g fill="#FFFFFF">
{_stars(width, 300, 130, 11)}
</g>
<rect y="180" width="{width:.0f}" height="240" fill="url(#b-horizon)"/>
<rect width="{width:.0f}" height="{height:.0f}" fill="url(#b-glow)"/>
{_drifting_leaves(5)}
<!-- Ridge lines, furthest first. Each is lighter than the one in front of it,
which is what reads as distance. -->
{_mountains(width, 366, 3, 150, "#1C2836")}
{_mountains(width, 392, 8, 112, "#111A25")}
{_mountains(width, 416, 21, 74, "#080D13")}
<rect y="{height - 5:.0f}" width="{width:.0f}" height="5" fill="{GOLD}" 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({lockup_x:.2f} {mark_y:.2f}) scale({mark_scale:.4f})">
{mark_body("b")}
</g>
<g transform="translate({lockup_x + mark_size + gap - run.x0:.2f} {baseline_y:.2f})">
<style>.base {{ fill: {PARCHMENT}; }} .accent {{ fill: {RUNE_GOLD}; }}</style>
{run.paths(ACCENT_GLYPHS, indent=" ")}
</g>
<g transform="translate({tag_x:.2f} {tag_y:.2f})">
<style>.tag {{ fill: {MUTED}; }}</style>
{tag.paths(indent=" ", base_class="tag")}
</g>
</svg>
"""
# --- Entry point -------------------------------------------------------------
BUILDERS = {
"logo-mark.svg": build_logo_mark,
"favicon.svg": build_favicon,
"wordmark.svg": build_wordmark,
"logo-lockup.svg": build_lockup,
"banner.svg": build_banner,
}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--out", type=Path, default=ASSETS)
parser.add_argument("--only", nargs="*", choices=sorted(BUILDERS), default=None)
args = parser.parse_args()
args.out.mkdir(parents=True, exist_ok=True)
for filename in args.only or BUILDERS:
path = args.out / filename
path.write_text(BUILDERS[filename](), encoding="utf-8")
print(f"wrote {path.relative_to(ROOT)} ({path.stat().st_size:,} bytes)")
if __name__ == "__main__":
main()