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
+34
View File
@@ -0,0 +1,34 @@
# LLeMbas configuration
# Copy to .env and edit. All variables are prefixed LEMBAS_.
# REQUIRED. Secret used to sign session cookies and to derive the key that
# encrypts stored API keys at rest. Generate one with:
# python -c "import secrets; print(secrets.token_urlsafe(48))"
# Changing this invalidates all sessions AND makes stored API keys unreadable.
LEMBAS_SECRET_KEY=
# Where the SQLite database and uploaded files live.
LEMBAS_DATA_DIR=./data
# HTTP server bind address.
LEMBAS_HOST=127.0.0.1
LEMBAS_PORT=8080
# Autoreload on code change. Development only.
LEMBAS_RELOAD=false
# debug | info | warning | error
LEMBAS_LOG_LEVEL=info
# Allow new accounts to register themselves. The very first account created is
# always an admin, regardless of this setting. Turn off once your users exist.
LEMBAS_ALLOW_SIGNUP=true
# Default theme for signed-out visitors: moria (dark) or shire (light).
LEMBAS_DEFAULT_THEME=moria
# Seconds a login session stays valid. Default 30 days.
LEMBAS_SESSION_TTL=2592000
# Seconds to wait on an upstream LLM endpoint before giving up.
LEMBAS_REQUEST_TIMEOUT=300
+27
View File
@@ -0,0 +1,27 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
build/
dist/
.venv/
venv/
# Tooling
.pytest_cache/
.ruff_cache/
.mypy_cache/
# LLeMbas runtime
.env
data/
*.db
*.db-journal
*.db-wal
*.db-shm
# Editors / OS
.vscode/
.idea/
.DS_Store
*.swp
+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

+67
View File
@@ -0,0 +1,67 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 342.25 72.00"
width="342.25" height="72.00" role="img" aria-label="LLeMbas">
<title>LLeMbas</title>
<defs>
<linearGradient id="l-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="l-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="l-clip">
<rect x="6" y="6" width="52" height="52" rx="13"/>
</clipPath>
</defs>
<style>
.base { fill: var(--lembas-ink, #1B1F23); }
.accent { fill: var(--lembas-gold, #C9A227); }
@media (prefers-color-scheme: dark) {
.base { fill: var(--lembas-ink, #EDE6D6); }
.accent { fill: var(--lembas-gold, #E0B252); }
}
</style>
<g transform="translate(4.0 4.0)">
<rect x="6" y="6" width="52" height="52" rx="13" fill="url(#l-wafer)"/>
<g clip-path="url(#l-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(#l-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(85.67 59.00)">
<path class="accent" data-char="L" d="M2.33 -44.28 7.41 -43.67C7.62 -38.24 7.62 -30.76 7.62 -24.51V-21.49C7.62 -15.10 7.62 -7.41 7.41 -1.92L2.33 -1.30V0.00H32.96L33.78 -13.39H32.47L28.15 -1.72H14.97C14.76 -7.14 14.69 -14.97 14.69 -21.49V-24.51C14.69 -30.76 14.76 -38.24 14.97 -43.67L19.98 -44.28V-45.59H2.33Z"/>
<path class="accent" data-char="L" d="M37.76 -44.28 42.84 -43.67C43.05 -38.24 43.05 -30.76 43.05 -24.51V-21.49C43.05 -15.10 43.05 -7.41 42.84 -1.92L37.76 -1.30V0.00H68.38L69.21 -13.39H67.90L63.58 -1.72H50.39C50.19 -7.14 50.12 -14.97 50.12 -21.49V-24.51C50.12 -30.76 50.19 -38.24 50.39 -43.67L55.41 -44.28V-45.59H37.76Z"/>
<path class="base" data-char="e" d="M87.88 -30.48C91.38 -30.48 93.72 -27.74 93.72 -22.59C93.72 -19.57 92.69 -18.54 89.53 -18.54H80.53C81.01 -27.33 84.38 -30.48 87.88 -30.48ZM87.95 0.89C93.10 0.89 97.42 -1.44 99.90 -6.73L98.93 -7.41C96.87 -4.87 94.20 -3.23 90.49 -3.23C84.72 -3.23 80.47 -8.10 80.47 -16.41V-16.96H99.35C99.62 -17.92 99.76 -18.74 99.76 -20.25C99.76 -27.39 94.81 -32.13 87.81 -32.13C80.19 -32.13 73.46 -25.75 73.46 -15.04C73.46 -4.94 79.85 0.89 87.95 0.89Z"/>
<path class="accent" data-char="M" d="M104.98 0.00H117.81V-1.30L112.32 -1.92V-40.16L126.60 0.00H129.14L143.42 -40.64V-21.49C143.42 -15.10 143.36 -7.35 143.15 -1.92L138.48 -1.30V0.00H155.78V-1.30L150.77 -1.92C150.56 -7.35 150.50 -15.10 150.50 -21.49V-24.51C150.50 -30.76 150.56 -38.24 150.77 -43.67L155.78 -44.28V-45.59H143.15L130.52 -10.16L118.02 -45.59H104.98V-44.28L110.33 -43.60V-1.92L104.98 -1.30Z"/>
<path class="base" data-char="b" d="M160.38 0.00 171.50 0.76V-4.26C173.84 -0.55 176.93 0.89 180.29 0.89C187.64 0.89 193.13 -5.49 193.13 -16.41C193.13 -26.78 187.84 -32.13 180.98 -32.13C176.79 -32.13 173.56 -29.80 171.50 -26.30V-36.25L171.78 -49.98L171.02 -50.67L160.11 -47.79V-46.69L164.84 -45.59V-14.28C164.84 -10.71 164.78 -5.70 164.64 -1.92L160.38 -1.30ZM178.10 -28.63C182.63 -28.63 186.06 -24.92 186.06 -16.00C186.06 -6.87 182.56 -2.68 178.23 -2.68C175.49 -2.68 173.70 -3.50 171.50 -5.84V-24.79C173.36 -27.12 175.56 -28.63 178.10 -28.63Z"/>
<path class="base" data-char="a" d="M221.49 0.76C224.58 0.76 226.50 -0.96 227.53 -3.43L226.70 -4.12C225.61 -2.88 224.99 -2.40 223.96 -2.40C222.79 -2.40 222.10 -3.16 222.10 -5.36V-20.67C222.10 -28.63 218.95 -32.13 211.94 -32.13C205.01 -32.13 200.07 -28.90 199.31 -24.24C199.65 -22.38 200.55 -21.35 202.40 -21.35C204.25 -21.35 205.83 -22.59 206.18 -25.54L206.93 -29.87C207.82 -30.14 208.65 -30.28 209.61 -30.28C213.80 -30.28 215.38 -28.15 215.38 -21.42V-19.02C212.77 -18.40 210.57 -17.78 209.06 -17.23C200.48 -14.49 198.14 -11.12 198.14 -6.87C198.14 -1.85 201.64 0.89 206.24 0.89C209.81 0.89 212.70 -0.41 215.44 -3.98C215.93 -0.96 217.92 0.76 221.49 0.76ZM204.73 -8.44C204.73 -11.47 206.11 -13.94 210.78 -16.00C211.67 -16.41 213.25 -16.96 215.38 -17.64V-5.36C213.11 -3.50 211.60 -3.09 209.54 -3.09C206.79 -3.09 204.73 -4.67 204.73 -8.44Z"/>
<path class="base" data-char="s" d="M240.78 0.89C248.40 0.89 252.59 -2.95 252.59 -8.93C252.59 -14.49 248.47 -16.55 244.42 -18.26L242.50 -19.09C239.13 -20.53 236.25 -22.04 236.25 -25.47C236.25 -28.49 238.44 -30.62 242.29 -30.62C244.01 -30.62 245.38 -30.41 246.61 -29.80L249.57 -21.76H250.73L251.01 -29.73C248.33 -31.31 245.72 -32.13 242.29 -32.13C234.94 -32.13 231.24 -28.01 231.24 -22.66C231.24 -17.23 235.15 -15.10 239.06 -13.46L240.99 -12.63C244.35 -11.19 247.30 -9.75 247.30 -6.25C247.30 -2.95 244.97 -0.55 240.78 -0.55C238.65 -0.55 237.14 -0.82 235.63 -1.37L232.54 -10.23H231.44L231.03 -1.72C234.12 0.00 236.93 0.89 240.78 0.89Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.9 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

+23
View File
@@ -0,0 +1,23 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 544.03 112.09"
width="544.03" height="112.09" 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. -->
<style>
.base { fill: var(--lembas-ink, #1B1F23); }
.accent { fill: var(--lembas-gold, #C9A227); }
@media (prefers-color-scheme: dark) {
.base { fill: var(--lembas-ink, #EDE6D6); }
.accent { fill: var(--lembas-gold, #E0B252); }
}
</style>
<g transform="translate(-5.07 110.15)">
<path class="accent" data-char="L" d="M5.07 -96.27 16.12 -94.93C16.57 -83.13 16.57 -66.87 16.57 -53.28V-46.72C16.57 -32.84 16.57 -16.12 16.12 -4.18L5.07 -2.84V0.00H71.64L73.43 -29.10H70.60L61.19 -3.73H32.54C32.09 -15.52 31.94 -32.54 31.94 -46.72V-53.28C31.94 -66.87 32.09 -83.13 32.54 -94.93L43.43 -96.27V-99.10H5.07Z"/>
<path class="accent" data-char="L" d="M82.09 -96.27 93.13 -94.93C93.58 -83.13 93.58 -66.87 93.58 -53.28V-46.72C93.58 -32.84 93.58 -16.12 93.13 -4.18L82.09 -2.84V0.00H148.66L150.45 -29.10H147.61L138.21 -3.73H109.55C109.10 -15.52 108.96 -32.54 108.96 -46.72V-53.28C108.96 -66.87 109.10 -83.13 109.55 -94.93L120.45 -96.27V-99.10H82.09Z"/>
<path class="base" data-char="e" d="M191.04 -66.27C198.66 -66.27 203.73 -60.30 203.73 -49.10C203.73 -42.54 201.49 -40.30 194.63 -40.30H175.07C176.12 -59.40 183.43 -66.27 191.04 -66.27ZM191.19 1.94C202.39 1.94 211.79 -3.13 217.16 -14.63L215.07 -16.12C210.60 -10.60 204.78 -7.01 196.72 -7.01C184.18 -7.01 174.93 -17.61 174.93 -35.67V-36.87H215.97C216.57 -38.96 216.87 -40.75 216.87 -44.03C216.87 -59.55 206.12 -69.85 190.90 -69.85C174.33 -69.85 159.70 -55.97 159.70 -32.69C159.70 -10.75 173.58 1.94 191.19 1.94Z"/>
<path class="accent" data-char="M" d="M228.21 0.00H256.12V-2.84L244.18 -4.18V-87.31L275.22 0.00H280.75L311.79 -88.36V-46.72C311.79 -32.84 311.64 -15.97 311.19 -4.18L301.04 -2.84V0.00H338.66V-2.84L327.76 -4.18C327.31 -15.97 327.16 -32.84 327.16 -46.72V-53.28C327.16 -66.87 327.31 -83.13 327.76 -94.93L338.66 -96.27V-99.10H311.19L283.73 -22.09L256.57 -99.10H228.21V-96.27L239.85 -94.78V-4.18L228.21 -2.84Z"/>
<path class="base" data-char="b" d="M348.66 0.00 372.84 1.64V-9.25C377.91 -1.19 384.63 1.94 391.94 1.94C407.91 1.94 419.85 -11.94 419.85 -35.67C419.85 -58.21 408.36 -69.85 393.43 -69.85C384.33 -69.85 377.31 -64.78 372.84 -57.16V-78.81L373.43 -108.66L371.79 -110.15L348.06 -103.88V-101.49L358.36 -99.10V-31.04C358.36 -23.28 358.21 -12.39 357.91 -4.18L348.66 -2.84ZM387.16 -62.24C397.01 -62.24 404.48 -54.18 404.48 -34.78C404.48 -14.93 396.87 -5.82 387.46 -5.82C381.49 -5.82 377.61 -7.61 372.84 -12.69V-53.88C376.87 -58.96 381.64 -62.24 387.16 -62.24Z"/>
<path class="base" data-char="a" d="M481.49 1.64C488.21 1.64 492.39 -2.09 494.63 -7.46L492.84 -8.96C490.45 -6.27 489.10 -5.22 486.87 -5.22C484.33 -5.22 482.84 -6.87 482.84 -11.64V-44.93C482.84 -62.24 475.97 -69.85 460.75 -69.85C445.67 -69.85 434.93 -62.84 433.28 -52.69C434.03 -48.66 435.97 -46.42 440.00 -46.42C444.03 -46.42 447.46 -49.10 448.21 -55.52L449.85 -64.93C451.79 -65.52 453.58 -65.82 455.67 -65.82C464.78 -65.82 468.21 -61.19 468.21 -46.57V-41.34C462.54 -40.00 457.76 -38.66 454.48 -37.46C435.82 -31.49 430.75 -24.18 430.75 -14.93C430.75 -4.03 438.36 1.94 448.36 1.94C456.12 1.94 462.39 -0.90 468.36 -8.66C469.40 -2.09 473.73 1.64 481.49 1.64ZM445.07 -18.36C445.07 -24.93 448.06 -30.30 458.21 -34.78C460.15 -35.67 463.58 -36.87 468.21 -38.36V-11.64C463.28 -7.61 460.00 -6.72 455.52 -6.72C449.55 -6.72 445.07 -10.15 445.07 -18.36Z"/>
<path class="base" data-char="s" d="M523.43 1.94C540.00 1.94 549.10 -6.42 549.10 -19.40C549.10 -31.49 540.15 -35.97 531.34 -39.70L527.16 -41.49C519.85 -44.63 513.58 -47.91 513.58 -55.37C513.58 -61.94 518.36 -66.57 526.72 -66.57C530.45 -66.57 533.43 -66.12 536.12 -64.78L542.54 -47.31H545.07L545.67 -64.63C539.85 -68.06 534.18 -69.85 526.72 -69.85C510.75 -69.85 502.69 -60.90 502.69 -49.25C502.69 -37.46 511.19 -32.84 519.70 -29.25L523.88 -27.46C531.19 -24.33 537.61 -21.19 537.61 -13.58C537.61 -6.42 532.54 -1.19 523.43 -1.19C518.81 -1.19 515.52 -1.79 512.24 -2.99L505.52 -22.24H503.13L502.24 -3.73C508.96 0.00 515.07 1.94 523.43 1.94Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

+66
View File
@@ -0,0 +1,66 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "lembas"
version = "0.1.0"
description = "LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints"
readme = "README.md"
requires-python = ">=3.11"
license = { file = "LICENSE" }
authors = [{ name = "Jaroslav Benes", email = "admin@ecoposta.sk" }]
keywords = ["llm", "webui", "openai", "chat", "self-hosted"]
classifiers = [
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
"Programming Language :: Python :: 3",
"Topic :: Communications :: Chat",
]
dependencies = [
"fastapi>=0.115",
"uvicorn[standard]>=0.32",
"jinja2>=3.1",
"sqlalchemy>=2.0",
"pydantic>=2.9",
"pydantic-settings>=2.6",
"httpx>=0.27",
"python-multipart>=0.0.12",
"argon2-cffi>=23.1",
"cryptography>=43.0",
"markdown-it-py>=3.0",
"mdit-py-plugins>=0.4",
"pygments>=2.18",
"nh3>=0.2.18",
"typer>=0.12",
]
[project.optional-dependencies]
dev = [
"pytest>=8.3",
"pytest-asyncio>=0.24",
"ruff>=0.7",
]
[project.scripts]
lembas = "lembas.cli:app"
[project.urls]
Homepage = "https://github.com/homer/LLeMbas"
[tool.hatch.build.targets.wheel]
packages = ["src/lembas"]
[tool.ruff]
line-length = 100
target-version = "py311"
src = ["src", "tests"]
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM", "C4"]
ignore = ["B008"] # FastAPI Depends() in defaults is idiomatic
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
filterwarnings = ["ignore::DeprecationWarning"]
+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()
+3
View File
@@ -0,0 +1,3 @@
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
__version__ = "0.1.0"
View File
+91
View File
@@ -0,0 +1,91 @@
"""Shared FastAPI dependencies: database sessions and the current user."""
from __future__ import annotations
from collections.abc import Iterator
from typing import Annotated
from fastapi import Depends, HTTPException, Request, status
from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session as DBSession
from lembas.db.models import User
from lembas.db.session import get_session_factory
from lembas.security.sessions import COOKIE_NAME, resolve_session
def get_db() -> Iterator[DBSession]:
"""One database session per request, always closed."""
session = get_session_factory()()
try:
yield session
finally:
session.close()
Db = Annotated[DBSession, Depends(get_db)]
def get_current_user(request: Request, db: Db) -> User | None:
"""Resolve the session cookie to a user, or None when signed out.
Cached on request.state so several dependencies in one request do not each
hit the sessions table.
"""
cached = getattr(request.state, "user", None)
if cached is not None:
return cached
user = resolve_session(db, request.cookies.get(COOKIE_NAME))
request.state.user = user
return user
CurrentUser = Annotated[User | None, Depends(get_current_user)]
class RedirectToLogin(HTTPException):
"""Signals "not signed in" so the exception handler can redirect a browser.
Raised instead of returning a response because dependencies cannot return
one. lembas.main turns this into a 303 for page loads and an HX-Redirect
header for HTMX requests, so a partial swap never renders a login form
inside the chat pane.
"""
def __init__(self, next_url: str = "/") -> None:
super().__init__(status_code=status.HTTP_401_UNAUTHORIZED, detail="Sign in required")
self.next_url = next_url
def require_user(request: Request, user: CurrentUser) -> User:
if user is None:
raise RedirectToLogin(next_url=request.url.path)
return user
RequiredUser = Annotated[User, Depends(require_user)]
def require_admin(user: RequiredUser) -> User:
if not user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="This area is restricted to administrators.",
)
return user
AdminUser = Annotated[User, Depends(require_admin)]
def is_htmx(request: Request) -> bool:
return request.headers.get("HX-Request") == "true"
def login_redirect(next_url: str = "/") -> RedirectResponse:
target = "/auth/login"
if next_url and next_url not in ("/", "/auth/login"):
from urllib.parse import quote
target = f"{target}?next={quote(next_url, safe='')}"
return RedirectResponse(target, status_code=status.HTTP_303_SEE_OTHER)
+64
View File
@@ -0,0 +1,64 @@
"""Application configuration, loaded from the environment and/or a .env file."""
from __future__ import annotations
import secrets
from functools import lru_cache
from pathlib import Path
from typing import Literal
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Runtime configuration. Every variable is prefixed ``LEMBAS_``."""
model_config = SettingsConfigDict(
env_prefix="LEMBAS_",
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
secret_key: str = Field(default="")
data_dir: Path = Path("./data")
host: str = "127.0.0.1"
port: int = 8080
reload: bool = False
log_level: Literal["debug", "info", "warning", "error"] = "info"
allow_signup: bool = True
default_theme: Literal["moria", "shire"] = "moria"
session_ttl: int = 60 * 60 * 24 * 30
request_timeout: float = 300.0
@field_validator("secret_key")
@classmethod
def _generate_secret_if_absent(cls, v: str) -> str:
# A generated key lets `lembas serve` work out of the box, but it changes
# on every restart: sessions drop and stored API keys become unreadable.
# main.py warns loudly about this. Never rely on it in production.
return v or secrets.token_urlsafe(48)
@property
def db_path(self) -> Path:
return self.data_dir / "lembas.db"
@property
def uploads_dir(self) -> Path:
return self.data_dir / "uploads"
def ensure_dirs(self) -> None:
self.data_dir.mkdir(parents=True, exist_ok=True)
self.uploads_dir.mkdir(parents=True, exist_ok=True)
@lru_cache
def get_settings() -> Settings:
"""Cached singleton so config is parsed once per process."""
return Settings()
settings = get_settings()
View File
+54
View File
@@ -0,0 +1,54 @@
"""Declarative base and column conventions shared by every model.
There is no Alembic in this project (SQLite only, schema created at startup).
That makes adding a column to an existing deployment a manual chore, so models
carry a few forward-looking columns that are not read yet -- see the notes on
``Message.parent_id`` and ``Message.content_parts_json``.
"""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from sqlalchemy import DateTime, MetaData, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
# Explicit naming convention so constraints have stable, predictable names.
NAMING_CONVENTION = {
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s",
}
def new_id() -> str:
"""Primary keys are UUID4 hex strings: URL-safe and non-enumerable."""
return uuid.uuid4().hex
def utcnow() -> datetime:
return datetime.now(UTC)
class Base(DeclarativeBase):
metadata = MetaData(naming_convention=NAMING_CONVENTION)
class UUIDPrimaryKey:
"""Mixin: opaque string primary key generated in Python."""
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
class Timestamps:
"""Mixin: creation and modification times, both timezone-aware UTC."""
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utcnow, nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False
)
+45
View File
@@ -0,0 +1,45 @@
"""All ORM models.
Importing this package registers every table on ``Base.metadata``, which is
what ``init_db()`` relies on to create the schema at startup. Any new model
module must be imported here or its table will silently never be created.
"""
from lembas.db.models.chat import (
ROLE_ASSISTANT,
ROLE_SYSTEM,
ROLE_TOOL,
ROLE_USER,
Chat,
Folder,
Message,
)
from lembas.db.models.connection import Connection, Model
from lembas.db.models.setting import Setting
from lembas.db.models.user import (
ROLE_ADMIN,
ROLE_PENDING,
Group,
Session,
User,
user_groups,
)
__all__ = [
"ROLE_ADMIN",
"ROLE_ASSISTANT",
"ROLE_PENDING",
"ROLE_SYSTEM",
"ROLE_TOOL",
"ROLE_USER",
"Chat",
"Connection",
"Folder",
"Group",
"Message",
"Model",
"Session",
"Setting",
"User",
"user_groups",
]
+117
View File
@@ -0,0 +1,117 @@
"""Folders, chats and messages."""
from __future__ import annotations
from typing import Any
from sqlalchemy import Boolean, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
from lembas.db.types import JSONDict, JSONList
ROLE_SYSTEM = "system"
ROLE_USER = "user"
ROLE_ASSISTANT = "assistant"
ROLE_TOOL = "tool"
class Folder(UUIDPrimaryKey, Timestamps, Base):
"""A user-owned, arbitrarily nested container for chats."""
__tablename__ = "folders"
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
parent_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("folders.id", ondelete="CASCADE")
)
name: Mapped[str] = mapped_column(String(200), nullable=False)
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
collapsed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
children: Mapped[list[Folder]] = relationship(
back_populates="parent",
cascade="all, delete-orphan",
order_by="Folder.position, Folder.name",
)
parent: Mapped[Folder | None] = relationship(back_populates="children", remote_side="Folder.id")
chats: Mapped[list[Chat]] = relationship(back_populates="folder")
def __repr__(self) -> str:
return f"<Folder {self.name}>"
class Chat(UUIDPrimaryKey, Timestamps, Base):
__tablename__ = "chats"
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
# Deleting a folder keeps its chats; they fall back to the unfiled list.
folder_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("folders.id", ondelete="SET NULL"), index=True
)
title: Mapped[str] = mapped_column(String(300), default="New chat")
# Set once the model writes the first reply, so auto-titling only runs once.
title_generated: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# Denormalised rather than a foreign key: chat history must survive an admin
# deleting a connection or a model disappearing upstream.
model_id: Mapped[str] = mapped_column(String(300), default="")
connection_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("connections.id", ondelete="SET NULL")
)
system_prompt: Mapped[str] = mapped_column(Text, default="")
params_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
pinned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
archived: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
folder: Mapped[Folder | None] = relationship(back_populates="chats")
messages: Mapped[list[Message]] = relationship(
back_populates="chat",
cascade="all, delete-orphan",
order_by="Message.created_at",
)
def __repr__(self) -> str:
return f"<Chat {self.title!r}>"
class Message(UUIDPrimaryKey, Timestamps, Base):
__tablename__ = "messages"
chat_id: Mapped[str] = mapped_column(
String(32), ForeignKey("chats.id", ondelete="CASCADE"), nullable=False, index=True
)
# Reserved for conversation branching (edit a message, regenerate a reply
# and keep both). Nothing reads it yet; it exists now because retrofitting a
# column onto a live SQLite database without migrations is painful.
parent_id: Mapped[str | None] = mapped_column(String(32), ForeignKey("messages.id"))
role: Mapped[str] = mapped_column(String(16), nullable=False)
content: Mapped[str] = mapped_column(Text, default="")
# Reserved for multimodal turns: [{"type": "image_url", ...}, ...].
# Plain-text messages leave this empty and use `content`.
content_parts_json: Mapped[list[Any]] = mapped_column(JSONList, default=list)
model_id: Mapped[str] = mapped_column(String(300), default="")
tool_calls_json: Mapped[list[Any]] = mapped_column(JSONList, default=list)
usage_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
# Non-empty when generation failed. Rendered as a styled error in the
# thread so a failed turn is never an unexplained blank bubble.
error: Mapped[str] = mapped_column(Text, default="")
# False while a reply is still streaming; flipped when the stream ends.
complete: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
chat: Mapped[Chat] = relationship(back_populates="messages")
def __repr__(self) -> str:
return f"<Message {self.role} {self.content[:40]!r}>"
+82
View File
@@ -0,0 +1,82 @@
"""OpenAI-compatible endpoint connections and their discovered models."""
from __future__ import annotations
from datetime import datetime
from typing import Any
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
from lembas.db.types import JSONDict
class Connection(UUIDPrimaryKey, Timestamps, Base):
"""A configured upstream endpoint speaking the OpenAI HTTP API.
Works for api.openai.com as well as LM Studio, vLLM, llama.cpp, Ollama's
compatibility layer, OpenRouter, and anything else exposing /v1.
"""
__tablename__ = "connections"
name: Mapped[str] = mapped_column(String(120), nullable=False)
base_url: Mapped[str] = mapped_column(String(500), nullable=False)
# Fernet ciphertext, never the raw key. See lembas.services.crypto.
# Empty string is legitimate: local endpoints often need no auth at all.
api_key_encrypted: Mapped[str] = mapped_column(Text, default="")
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
# Extra headers merged into every request (e.g. OpenRouter's HTTP-Referer).
extra_headers_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
# Result of the most recent "Test & refresh", surfaced in the admin list.
last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_error: Mapped[str] = mapped_column(Text, default="")
models: Mapped[list[Model]] = relationship(
back_populates="connection",
cascade="all, delete-orphan",
order_by="Model.model_id",
)
def __repr__(self) -> str:
return f"<Connection {self.name} {self.base_url}>"
class Model(UUIDPrimaryKey, Timestamps, Base):
"""A model advertised by a connection, cached locally.
Cached rather than fetched live so the chat UI stays responsive and keeps
working when an endpoint is briefly unreachable. Refreshed on demand from
the admin screen.
"""
__tablename__ = "models"
__table_args__ = (UniqueConstraint("connection_id", "model_id"),)
connection_id: Mapped[str] = mapped_column(
String(32), ForeignKey("connections.id", ondelete="CASCADE"), nullable=False, index=True
)
model_id: Mapped[str] = mapped_column(String(300), nullable=False)
display_name: Mapped[str] = mapped_column(String(300), default="")
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
# Endpoints do not reliably advertise capabilities, so these are admin
# overrides consumed by later passes (vision uploads, tool calling).
capabilities_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
# Default sampling params applied to new chats using this model.
params_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
connection: Mapped[Connection] = relationship(back_populates="models")
@property
def label(self) -> str:
return self.display_name or self.model_id
def __repr__(self) -> str:
return f"<Model {self.model_id}>"
+28
View File
@@ -0,0 +1,28 @@
"""Instance-wide settings, stored as a key/value table."""
from __future__ import annotations
from typing import Any
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from lembas.db.base import Base, Timestamps
from lembas.db.types import JSONDict
class Setting(Timestamps, Base):
"""One row per settings group, value is an arbitrary JSON object.
A key/value table rather than a wide typed table: admin settings grow with
every feature (tools, agents, image generation) and adding a column to a
live SQLite database without migrations is exactly what this avoids.
"""
__tablename__ = "settings"
key: Mapped[str] = mapped_column(String(120), primary_key=True)
value: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
def __repr__(self) -> str:
return f"<Setting {self.key}>"
+89
View File
@@ -0,0 +1,89 @@
"""Users, groups and login sessions."""
from __future__ import annotations
from datetime import datetime
from typing import Any
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Index, String, Table, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
from lembas.db.types import JSONDict
# Roles are a simple ordered ladder rather than a permission matrix. Groups
# (below) carry finer-grained permissions once the users/groups UI lands.
ROLE_ADMIN = "admin"
ROLE_USER = "user"
ROLE_PENDING = "pending" # registered but awaiting admin approval
user_groups = Table(
"user_groups",
Base.metadata,
Column("user_id", String(32), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True),
Column("group_id", String(32), ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True),
)
class User(UUIDPrimaryKey, Timestamps, Base):
__tablename__ = "users"
email: Mapped[str] = mapped_column(String(320), unique=True, nullable=False, index=True)
name: Mapped[str] = mapped_column(String(120), nullable=False)
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
role: Mapped[str] = mapped_column(String(16), default=ROLE_USER, nullable=False)
active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# Per-user preferences: theme, default model, composer behaviour, etc.
settings_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
groups: Mapped[list[Group]] = relationship(secondary=user_groups, back_populates="users")
sessions: Mapped[list[Session]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
@property
def is_admin(self) -> bool:
return self.role == ROLE_ADMIN
def __repr__(self) -> str:
return f"<User {self.email} role={self.role}>"
class Group(UUIDPrimaryKey, Timestamps, Base):
"""A named set of users. Permissions are enforced once the RBAC pass lands."""
__tablename__ = "groups"
name: Mapped[str] = mapped_column(String(120), unique=True, nullable=False)
description: Mapped[str] = mapped_column(Text, default="")
permissions_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
users: Mapped[list[User]] = relationship(secondary=user_groups, back_populates="groups")
class Session(UUIDPrimaryKey, Timestamps, Base):
"""Server-side login session.
Sessions live in the database rather than in a signed JWT so that logging
out, banning a user, or rotating a device actually revokes access
immediately instead of waiting for a token to expire.
"""
__tablename__ = "sessions"
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
# SHA-256 of the cookie value. The raw token is shown to the browser once
# and never stored, so a database leak does not hand over live sessions.
token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
user_agent: Mapped[str] = mapped_column(Text, default="")
ip_address: Mapped[str] = mapped_column(String(45), default="")
user: Mapped[User] = relationship(back_populates="sessions")
Index("ix_sessions_user_id", Session.user_id)
+102
View File
@@ -0,0 +1,102 @@
"""Engine, session factory and startup schema creation."""
from __future__ import annotations
import logging
from collections.abc import Iterator
from contextlib import contextmanager
from sqlalchemy import Engine, create_engine, event
from sqlalchemy.orm import Session, sessionmaker
from lembas.config import settings
from lembas.db.base import Base
log = logging.getLogger(__name__)
_engine: Engine | None = None
_SessionFactory: sessionmaker[Session] | None = None
@event.listens_for(Engine, "connect")
def _configure_sqlite(dbapi_connection, connection_record) -> None: # noqa: ANN001
"""Apply the pragmas SQLite needs to behave under a concurrent web server.
- WAL lets readers proceed while a write is in flight, which matters because
a streaming reply holds a write open for the length of the generation.
- foreign_keys is OFF by default in SQLite, so every ondelete= in the models
would be decoration without this.
- busy_timeout makes concurrent writers wait rather than fail instantly.
"""
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA foreign_keys=ON")
cursor.execute("PRAGMA busy_timeout=5000")
cursor.execute("PRAGMA synchronous=NORMAL")
cursor.close()
def get_engine() -> Engine:
global _engine
if _engine is None:
settings.ensure_dirs()
_engine = create_engine(
f"sqlite:///{settings.db_path}",
# FastAPI runs sync endpoints in a threadpool, so a connection can
# legitimately be used from a thread other than the one that made it.
connect_args={"check_same_thread": False},
echo=False,
future=True,
)
return _engine
def get_session_factory() -> sessionmaker[Session]:
global _SessionFactory
if _SessionFactory is None:
_SessionFactory = sessionmaker(
bind=get_engine(),
autoflush=False,
expire_on_commit=False,
)
return _SessionFactory
def init_db() -> None:
"""Create any missing tables.
This is ``CREATE TABLE IF NOT EXISTS`` only -- it never alters an existing
table. There is no migration tool in this project by design, so changing a
column on a model requires migrating the database by hand.
"""
import lembas.db.models # noqa: F401 (registers tables on the metadata)
Base.metadata.create_all(bind=get_engine())
log.debug("schema ensured at %s", settings.db_path)
@contextmanager
def session_scope() -> Iterator[Session]:
"""Transactional scope for background work and CLI commands.
Request handlers should use the `db` dependency in lembas.api.deps instead.
"""
factory = get_session_factory()
session = factory()
try:
yield session
session.commit()
except Exception:
session.rollback()
raise
finally:
session.close()
def reset_engine() -> None:
"""Drop cached engine/factory. Used by tests to rebind to a temp database."""
global _engine, _SessionFactory
if _engine is not None:
_engine.dispose()
_engine = None
_SessionFactory = None
+14
View File
@@ -0,0 +1,14 @@
"""Reusable column types.
SQLite stores JSON as text. Wrapping the JSON type in SQLAlchemy's mutation
tracking means ``obj.settings_json["theme"] = "shire"`` marks the row dirty --
without it, in-place edits of a dict column are silently dropped on flush.
"""
from __future__ import annotations
from sqlalchemy import JSON
from sqlalchemy.ext.mutable import MutableDict, MutableList
JSONDict = MutableDict.as_mutable(JSON)
JSONList = MutableList.as_mutable(JSON)
View File
View File
+40
View File
@@ -0,0 +1,40 @@
"""Password hashing.
Argon2id via argon2-cffi, using the library's current recommended parameters.
``needs_rehash`` lets stored hashes be upgraded transparently when those
defaults tighten in a future release.
"""
from __future__ import annotations
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerifyMismatchError, VerificationError
_hasher = PasswordHasher()
MIN_PASSWORD_LENGTH = 8
def hash_password(password: str) -> str:
return _hasher.hash(password)
def verify_password(password: str, password_hash: str) -> bool:
try:
return _hasher.verify(password_hash, password)
except (VerifyMismatchError, VerificationError, InvalidHashError):
return False
def needs_rehash(password_hash: str) -> bool:
try:
return _hasher.check_needs_rehash(password_hash)
except InvalidHashError:
return True
def validate_password(password: str) -> str | None:
"""Return a human-readable problem with the password, or None if it is fine."""
if len(password) < MIN_PASSWORD_LENGTH:
return f"Password must be at least {MIN_PASSWORD_LENGTH} characters."
return None
+99
View File
@@ -0,0 +1,99 @@
"""Login session lifecycle.
The browser holds an opaque random token in an httpOnly cookie. The database
stores only its SHA-256, so a dump of the sessions table cannot be replayed as
a live login. Tokens are compared by hash lookup, and revoking is a DELETE.
"""
from __future__ import annotations
import hashlib
import secrets
from datetime import UTC, datetime, timedelta
from sqlalchemy import select
from sqlalchemy.orm import Session as DBSession
from lembas.config import settings
from lembas.db.models import Session as SessionRow
from lembas.db.models import User
COOKIE_NAME = "lembas_session"
TOKEN_BYTES = 32
def _hash_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def create_session(
db: DBSession,
user: User,
*,
user_agent: str = "",
ip_address: str = "",
) -> str:
"""Open a session for a user and return the raw token for the cookie.
The raw token is returned exactly once and never persisted.
"""
token = secrets.token_urlsafe(TOKEN_BYTES)
row = SessionRow(
user_id=user.id,
token_hash=_hash_token(token),
expires_at=datetime.now(UTC) + timedelta(seconds=settings.session_ttl),
user_agent=user_agent[:500],
ip_address=ip_address[:45],
)
db.add(row)
user.last_login_at = datetime.now(UTC)
db.commit()
return token
def resolve_session(db: DBSession, token: str | None) -> User | None:
"""Return the signed-in user for a cookie value, or None.
Expired and orphaned sessions are cleaned up as they are encountered, which
keeps the table tidy without needing a scheduled job.
"""
if not token:
return None
row = db.scalar(select(SessionRow).where(SessionRow.token_hash == _hash_token(token)))
if row is None:
return None
# SQLite hands back naive datetimes even for timezone-aware columns.
expires_at = row.expires_at
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=UTC)
if expires_at < datetime.now(UTC):
db.delete(row)
db.commit()
return None
user = db.get(User, row.user_id)
if user is None or not user.active:
db.delete(row)
db.commit()
return None
return user
def revoke_session(db: DBSession, token: str | None) -> None:
if not token:
return
row = db.scalar(select(SessionRow).where(SessionRow.token_hash == _hash_token(token)))
if row is not None:
db.delete(row)
db.commit()
def revoke_all_for_user(db: DBSession, user: User) -> None:
"""Sign a user out everywhere. Used when deactivating or changing a password."""
for row in db.scalars(select(SessionRow).where(SessionRow.user_id == user.id)):
db.delete(row)
db.commit()
View File
+58
View File
@@ -0,0 +1,58 @@
"""Symmetric encryption for secrets stored in the database.
Only upstream API keys use this today. The key is derived from
``LEMBAS_SECRET_KEY`` rather than stored separately, which means rotating that
variable makes every stored API key unreadable -- decrypt() returns "" rather
than raising, so the app degrades to "re-enter your keys" instead of crashing.
"""
from __future__ import annotations
import base64
import hashlib
import logging
from functools import lru_cache
from cryptography.fernet import Fernet, InvalidToken
from lembas.config import settings
log = logging.getLogger(__name__)
@lru_cache
def _fernet() -> Fernet:
# Fernet requires a 32-byte urlsafe-base64 key; SECRET_KEY is free-form text.
digest = hashlib.sha256(settings.secret_key.encode("utf-8")).digest()
return Fernet(base64.urlsafe_b64encode(digest))
def encrypt(plaintext: str) -> str:
"""Encrypt a secret. Empty input stays empty -- keyless endpoints are valid."""
if not plaintext:
return ""
return _fernet().encrypt(plaintext.encode("utf-8")).decode("ascii")
def decrypt(ciphertext: str) -> str:
"""Decrypt a secret, returning "" if it cannot be read.
An unreadable value almost always means LEMBAS_SECRET_KEY changed. Failing
soft keeps the admin UI usable so the key can simply be re-entered.
"""
if not ciphertext:
return ""
try:
return _fernet().decrypt(ciphertext.encode("ascii")).decode("utf-8")
except (InvalidToken, ValueError):
log.warning("could not decrypt a stored secret; has LEMBAS_SECRET_KEY changed?")
return ""
def mask(secret: str) -> str:
"""Render a secret for display: never the whole thing, just enough to identify it."""
if not secret:
return ""
if len(secret) <= 8:
return "*" * len(secret)
return f"{secret[:3]}{'*' * 8}{secret[-4:]}"
View File
View File
@@ -0,0 +1,125 @@
{#
Icon sprite, inlined once at the top of <body>.
It lives here rather than in assets/ and is deliberately NOT loaded as an
external file: cross-document <use href="sprite.svg#id"> has patchy browser
support, while same-document <use href="#id"> is universal. Inlining also
costs zero extra requests.
Icons are 24x24, stroked (never filled), and inherit currentColor, so they
take the surrounding text colour in every theme automatically.
Use via the macro in _macros.html: {{ icon("send") }}
#}
<svg xmlns="http://www.w3.org/2000/svg" style="display:none" aria-hidden="true">
<defs>
<g id="icon-defaults"></g>
</defs>
<symbol id="i-send" viewBox="0 0 24 24"><path d="M12 20V5M5 12l7-7 7 7"/></symbol>
<symbol id="i-plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></symbol>
<symbol id="i-minus" viewBox="0 0 24 24"><path d="M5 12h14"/></symbol>
<symbol id="i-check" viewBox="0 0 24 24"><path d="M4.5 12.5 9 17 19.5 6.5"/></symbol>
<symbol id="i-x" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></symbol>
<symbol id="i-menu" viewBox="0 0 24 24"><path d="M4 7h16M4 12h16M4 17h16"/></symbol>
<symbol id="i-dots" viewBox="0 0 24 24">
<circle cx="12" cy="5" r="1.4"/><circle cx="12" cy="12" r="1.4"/><circle cx="12" cy="19" r="1.4"/>
</symbol>
<symbol id="i-chevron-right" viewBox="0 0 24 24"><path d="m9.5 5.5 7 6.5-7 6.5"/></symbol>
<symbol id="i-chevron-down" viewBox="0 0 24 24"><path d="m5.5 9.5 6.5 7 6.5-7"/></symbol>
<symbol id="i-chat" viewBox="0 0 24 24">
<path d="M20.5 12.2a7.7 7.7 0 0 1-8.3 7.7l-5 2.4.9-3.7a7.7 7.7 0 1 1 12.4-6.4Z"/>
</symbol>
<symbol id="i-folder" viewBox="0 0 24 24">
<path d="M3 7.5A2 2 0 0 1 5 5.5h3.6a1 1 0 0 1 .8.4l1.2 1.6H19a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/>
</symbol>
<symbol id="i-folder-open" viewBox="0 0 24 24">
<path d="M3 18.5V7.5a2 2 0 0 1 2-2h3.6a1 1 0 0 1 .8.4l1.2 1.6H18a2 2 0 0 1 2 2v1"/>
<path d="M3 18.5 5.6 11a1 1 0 0 1 .95-.7H21a1 1 0 0 1 .95 1.3l-2.1 6.2a2 2 0 0 1-1.9 1.4H5a2 2 0 0 1-2-2Z"/>
</symbol>
<symbol id="i-trash" viewBox="0 0 24 24">
<path d="M4 7h16M9.5 7V5.4A1.4 1.4 0 0 1 10.9 4h2.2a1.4 1.4 0 0 1 1.4 1.4V7"/>
<path d="m6.5 7 .9 11.6A1.5 1.5 0 0 0 8.9 20h6.2a1.5 1.5 0 0 0 1.5-1.4L17.5 7"/>
</symbol>
<symbol id="i-pencil" viewBox="0 0 24 24">
<path d="M4 20h4L19.3 8.7a2.4 2.4 0 0 0-3.4-3.4L4.6 16.6 4 20Z"/>
<path d="m14.8 6.4 2.8 2.8"/>
</symbol>
<symbol id="i-copy" viewBox="0 0 24 24">
<rect x="9" y="9" width="11" height="11" rx="2"/>
<path d="M15 6.5V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h.5"/>
</symbol>
<symbol id="i-refresh" viewBox="0 0 24 24">
<path d="M20 12a8 8 0 1 1-2.6-5.9"/>
<path d="M20 4v4.5h-4.5"/>
</symbol>
<symbol id="i-pin" viewBox="0 0 24 24">
<path d="M9 3.5h6l-.8 5.2 3 3.1-.7 2.2H7.5l-.7-2.2 3-3.1Z"/>
<path d="M12 14v6.5"/>
</symbol>
<symbol id="i-search" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="6.5"/><path d="m16 16 4 4"/>
</symbol>
<symbol id="i-attach" viewBox="0 0 24 24">
<path d="M17.5 8.5v7.8a5 5 0 0 1-10 0V7a3.2 3.2 0 0 1 6.4 0v9.1a1.5 1.5 0 0 1-3 0V8.5"/>
</symbol>
<symbol id="i-user" viewBox="0 0 24 24">
<circle cx="12" cy="8.3" r="3.8"/>
<path d="M4.5 20a7.5 7.5 0 0 1 15 0"/>
</symbol>
<symbol id="i-users" viewBox="0 0 24 24">
<circle cx="9.5" cy="8.5" r="3.4"/>
<path d="M3 19.5a6.5 6.5 0 0 1 13 0"/>
<path d="M16 5.6a3.4 3.4 0 0 1 0 5.9M17.5 14.4a5.6 5.6 0 0 1 3.5 5.1"/>
</symbol>
<symbol id="i-shield" viewBox="0 0 24 24">
<path d="M12 3.2 19.5 6v5.6c0 4.6-3.1 7.6-7.5 9.2-4.4-1.6-7.5-4.6-7.5-9.2V6Z"/>
</symbol>
<symbol id="i-logout" viewBox="0 0 24 24">
<path d="M14.5 4.5H18a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2h-3.5"/>
<path d="M9.5 8 5.5 12l4 4M5.5 12H15"/>
</symbol>
<symbol id="i-server" viewBox="0 0 24 24">
<rect x="3.5" y="4" width="17" height="6" rx="1.8"/>
<rect x="3.5" y="14" width="17" height="6" rx="1.8"/>
<path d="M7 7h.01M7 17h.01"/>
</symbol>
<symbol id="i-sliders" viewBox="0 0 24 24">
<path d="M4 8h10M18 8h2M4 16h4M12 16h8"/>
<circle cx="16" cy="8" r="2"/><circle cx="10" cy="16" r="2"/>
</symbol>
<symbol id="i-gear" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="3.2"/>
<path d="M12 3v2.2M12 18.8V21M3 12h2.2M18.8 12H21M5.6 5.6l1.6 1.6M16.8 16.8l1.6 1.6M18.4 5.6l-1.6 1.6M7.2 16.8l-1.6 1.6"/>
</symbol>
<symbol id="i-sun" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="4"/>
<path d="M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.3 5.3l1.4 1.4M17.3 17.3l1.4 1.4M18.7 5.3l-1.4 1.4M6.7 17.3l-1.4 1.4"/>
</symbol>
<symbol id="i-moon" viewBox="0 0 24 24">
<path d="M20.5 13.8A8.5 8.5 0 0 1 10.2 3.5a8.5 8.5 0 1 0 10.3 10.3Z"/>
</symbol>
<symbol id="i-sidebar" viewBox="0 0 24 24">
<rect x="3.5" y="4.5" width="17" height="15" rx="2"/>
<path d="M10 4.5v15"/>
</symbol>
<symbol id="i-archive" viewBox="0 0 24 24">
<rect x="3.5" y="4.5" width="17" height="4" rx="1.2"/>
<path d="M5 8.5v9a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-9M10 12.5h4"/>
</symbol>
<symbol id="i-warning" viewBox="0 0 24 24">
<path d="M12 4.2 21 19.5H3Z"/>
<path d="M12 10v4M12 16.8h.01"/>
</symbol>
<symbol id="i-leaf" viewBox="0 0 64 64">
<path d="M20.5 45.5C13.8 31.7 23.8 20.9 45.5 18.5 49.8 35 39.8 45.8 20.5 45.5Z"/>
<path d="M20.5 45.5C28 38 36 29 45.5 18.5"/>
</symbol>
</svg>
View File