Start Housecarl: the logo, the wordmark and the skeleton
Housecarl will be a firewall manager for nftables, built the way Jarl and Steward are: housecarld (a daemon with a REST API and web UI), hc (the CLI) and SQLite. - The logo: a carved Nordic doorway in Jarl's blue (artwork/logo.svg), with a small version without the carvings, lighter on dark backgrounds. scripts/logo.py builds the web UI's copies and scripts/banner.py the README banner. - The HOUSECARL wordmark in Jarl's and Steward's angular letters, with new H, O, U and C (assets/brand.js), and the UI's color tokens. - housecarld and hc print their version; the Makefile, version package, AGENTS.md, CHANGELOG and the design sketch (docs/design.md). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
54beb0bba3
24 files changed
+949
No files matched your search
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env python3
|
||||
# Builds artwork/banner.svg, the README's banner: the logo above the
|
||||
# "HOUSECARL" wordmark and its FW rule, laid out as on the web UI's login
|
||||
# page. It reads both from the web UI's own sources, so it matches the login
|
||||
# page:
|
||||
#
|
||||
# internal/web/static/assets/logo.js the logo's paths (from scripts/logo.py)
|
||||
# internal/web/static/assets/brand.js the wordmark's letters and width
|
||||
#
|
||||
# The rule is drawn here with the same numbers as wordmark() in brand.js;
|
||||
# keep the two in step. The logo, letters and rule take the UI's colors,
|
||||
# switching to the dark theme's when the viewer prefers dark. Needs only
|
||||
# Python; run from the repository root after changing either file
|
||||
# (scripts/logo.py runs it):
|
||||
#
|
||||
# python3 scripts/banner.py
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
ASSETS = "internal/web/static/assets"
|
||||
OUT = "artwork/banner.svg"
|
||||
|
||||
# The logo's blue, the UI's --text and --accent (style.css), light and dark.
|
||||
STYLE = (".m{fill:#1d5287}.t{fill:#1d2330}.a{fill:#2f5fb3}.l{fill:none;stroke:#2f5fb3;stroke-width:2.6}"
|
||||
".g{fill:#2f5fb3;font:700 17px system-ui,sans-serif;letter-spacing:.5px}"
|
||||
"@media (prefers-color-scheme:dark){.m{fill:#6d9be8}.t{fill:#e3e7ee}.a,.g{fill:#6d9be8}.l{stroke:#6d9be8}}")
|
||||
|
||||
|
||||
def main():
|
||||
logo = open(f"{ASSETS}/logo.js").read()
|
||||
view_box = re.search(r'viewBox = "([^"]*)"', logo).group(1)
|
||||
full = json.loads(re.search(r"^export const full = (\".*\");$", logo, re.M).group(1))
|
||||
|
||||
brand = open(f"{ASSETS}/brand.js").read()
|
||||
letters = re.findall(r'\{ x: (\d+), d: "([^"]*)" \}', brand)
|
||||
w = int(re.search(r"^const W = (\d+);", brand, re.M).group(1))
|
||||
if len(letters) != 9:
|
||||
sys.exit("banner.py: brand.js no longer looks as expected; update the script")
|
||||
|
||||
# The login page's sizes in pixels (style.css): a 120px logo, 12px above
|
||||
# a 290px wordmark whose viewBox is W+8 units wide.
|
||||
mark_w, mark_h = w + 8, 176
|
||||
unit = mark_w / 290
|
||||
logo_size, gap = round(120 * unit, 1), round(12 * unit, 1)
|
||||
height = logo_size + gap + mark_h
|
||||
|
||||
c = w / 2
|
||||
rule = (f'<path class="a" d="M2 18 L{c - 60:g} 15.6 V20.4 Z M{w - 2} 18 L{c + 60:g} 15.6 V20.4 Z"/>'
|
||||
f'<circle class="l" cx="{c - 52:g}" cy="18" r="4.6"/>'
|
||||
f'<path class="l" d="M{c - 47.4:g} 18 H{c - 24:g} M{c + 24:g} 18 H{c + 47.4:g}"/>'
|
||||
f'<circle class="l" cx="{c:g}" cy="18" r="24"/>'
|
||||
f'<circle class="l" cx="{c + 52:g}" cy="18" r="4.6"/>'
|
||||
f'<text class="g" x="{c:g}" y="24.5" text-anchor="middle">FW</text>')
|
||||
|
||||
svg = [f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {mark_w} {height:g}" role="img" aria-label="Housecarl">',
|
||||
f"<!-- Generated by scripts/banner.py from {ASSETS}/logo.js and brand.js. -->",
|
||||
f"<title>Housecarl</title><style>{STYLE}</style>",
|
||||
f'<svg x="{(mark_w - logo_size) / 2:g}" width="{logo_size:g}" height="{logo_size:g}" viewBox="{view_box}">'
|
||||
f'<path class="m" fill-rule="evenodd" d="{full}"/></svg>',
|
||||
f'<svg y="{logo_size + gap:g}" width="{mark_w}" height="{mark_h}" viewBox="-4 -4 {mark_w} {mark_h}">'
|
||||
'<g class="t" fill-rule="evenodd">'
|
||||
+ "".join(f'<path transform="translate({x} 0)" d="{d}"/>' for x, d in letters) + "</g>"
|
||||
f'<g transform="translate(0 118)">{rule}</g></svg>',
|
||||
"</svg>"]
|
||||
with open(OUT, "w") as f:
|
||||
f.write("\n".join(svg) + "\n")
|
||||
print(f"{OUT}: {mark_w}x{height:g}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env python3
|
||||
# Builds the web UI's logo from the masters: artwork/logo.svg (a carved
|
||||
# Nordic doorway) and artwork/logo-small.svg (the same doorway without its
|
||||
# carvings, for small sizes). Each master is one evenodd path.
|
||||
#
|
||||
# internal/web/static/assets/logo.svg the favicon (a copy of the small one)
|
||||
# internal/web/static/assets/logo.js both paths for the web UI, which
|
||||
# draws them inline (brand.js), so the
|
||||
# logo appears together with the page
|
||||
#
|
||||
# It then runs scripts/banner.py to rebuild the README's banner. Run from
|
||||
# the repository root after changing a master:
|
||||
#
|
||||
# python3 scripts/logo.py
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
MASTERS = {"full": "artwork/logo.svg", "small": "artwork/logo-small.svg"}
|
||||
OUT = "internal/web/static/assets"
|
||||
SVG = "{http://www.w3.org/2000/svg}"
|
||||
|
||||
|
||||
def read(path):
|
||||
root = ET.parse(path).getroot()
|
||||
paths = root.findall(f"{SVG}path")
|
||||
if len(paths) != 1:
|
||||
sys.exit(f"logo.py: {path} should hold exactly one <path>")
|
||||
return root.attrib["viewBox"], paths[0].attrib["d"]
|
||||
|
||||
|
||||
def main():
|
||||
found = {name: read(path) for name, path in MASTERS.items()}
|
||||
views = {vb for vb, _ in found.values()}
|
||||
if len(views) != 1:
|
||||
sys.exit("logo.py: the full and small logos must share one viewBox")
|
||||
with open(os.path.join(OUT, "logo.js"), "w") as f:
|
||||
f.write("// The Housecarl logo, from artwork/logo.svg and artwork/logo-small.svg by\n"
|
||||
"// scripts/logo.py; don't edit. brand.js draws it; logo.svg is the small one.\n"
|
||||
f"export const viewBox = {json.dumps(views.pop())};\n"
|
||||
f"export const full = {json.dumps(found['full'][1])};\n"
|
||||
f"export const small = {json.dumps(found['small'][1])};\n")
|
||||
shutil.copyfile(MASTERS["small"], os.path.join(OUT, "logo.svg"))
|
||||
for name in ("logo.svg", "logo.js"):
|
||||
print(f"{OUT}/{name}: {os.path.getsize(os.path.join(OUT, name))} bytes")
|
||||
subprocess.run([sys.executable, "scripts/banner.py"], check=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in new issue
Block a user