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:
@@ -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
@@ -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
|
||||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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"]
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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}>"
|
||||||
@@ -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}>"
|
||||||
@@ -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}>"
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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:]}"
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user