From a7d389be27643c264fdc0bab786f5d134e837cbb Mon Sep 17 00:00:00 2001 From: abrown38 Date: Thu, 8 Oct 2026 06:19:51 +0000 Subject: [PATCH] Web UI: Jarl logo and wordmark Add the logo, a Viking jarl drawn like a woodcut whose spear-pointed crown holds a DNS tree. It is generated by scripts/logo.py, which mirrors the left half so the figure is exactly symmetrical, as logo.svg (white on a Nordic-blue disc) and logo-white.svg. It links home in the header, sits on the login page and is the favicon. The login page shows an angular "JARL" wordmark drawn as SVG paths over a rule with a "DNS" node, coloured from the theme. The header's "Jarl" uses a self-hosted Cinzel Bold subset (SIL OFL); jarld serves .woff2 as font/woff2. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 10 ++ README.md | 11 +++ docs/development.md | 9 +- internal/web/static/assets/app.js | 9 +- internal/web/static/assets/brand.js | 39 ++++++++ .../web/static/assets/fonts/Cinzel-OFL.txt | 93 ++++++++++++++++++ .../web/static/assets/fonts/cinzel-bold.woff2 | Bin 0 -> 13364 bytes internal/web/static/assets/logo-white.svg | 30 ++++++ internal/web/static/assets/logo.svg | 33 +++++++ internal/web/static/assets/style.css | 31 +++++- internal/web/static/index.html | 3 +- internal/web/web.go | 6 ++ scripts/logo.py | 70 +++++++++++++ 13 files changed, 336 insertions(+), 8 deletions(-) create mode 100644 internal/web/static/assets/brand.js create mode 100644 internal/web/static/assets/fonts/Cinzel-OFL.txt create mode 100644 internal/web/static/assets/fonts/cinzel-bold.woff2 create mode 100644 internal/web/static/assets/logo-white.svg create mode 100644 internal/web/static/assets/logo.svg create mode 100755 scripts/logo.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6317050..2aa5598 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,16 @@ The first version, to be released as 0.1.0. ### Added +- A logo: a Viking jarl drawn like a woodcut, with a spear-pointed crown + that holds a DNS tree (a root node and its branches), a stern face and a + full carved beard. White on a Nordic-blue disc (`assets/logo.svg`), plus an all-white + variant (`assets/logo-white.svg`). It is shown on the login page, links + home in the top left of the web UI, and is the favicon. +- The login page shows an angular, Norse-style "JARL" wordmark drawn as + SVG paths (`assets/brand.js`) above a rule with a "DNS" node; it follows + the light and dark themes. The header's "Jarl" uses Cinzel Bold (SIL OFL), + self-hosted as a 13 KB Latin subset. + - Web UI: wherever an address match list is entered (server, view and zone options such as allow-query or allow-transfer, view match lists, ACL elements) the existing ACLs, TSIG keys and built-in names are offered as diff --git a/README.md b/README.md index cc271ce..d908a37 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +

Jarl logo: a Viking jarl in a spear-pointed crown holding a DNS tree

+ # Jarl *A Jarl rules a Hold; this one rules your zones.* @@ -139,3 +141,12 @@ review step. Version 0.1.0, the first release. It is feature-complete for one server's configuration. DNSSEC signing and managing several servers are not handled yet ([concepts.md](docs/concepts.md#what-jarl-deliberately-doesnt-do-yet)). + +## Credits + +The "Jarl" name in the web UI's header uses [Cinzel](https://github.com/NDISCOVER/Cinzel) Bold +by the Cinzel Project Authors, under the SIL Open Font License 1.1 +(`internal/web/static/assets/fonts/Cinzel-OFL.txt`). The logo +(`assets/logo.svg`, and `assets/logo-white.svg` for dark backgrounds) is part +of Jarl: a Viking ruler drawn like a woodcut, whose crown holds a DNS tree +(a root node with its branches). It is generated by `scripts/logo.py`. diff --git a/docs/development.md b/docs/development.md index 2b9f90f..d99a742 100644 --- a/docs/development.md +++ b/docs/development.md @@ -75,7 +75,14 @@ to that directory's `cli.yaml` to keep using the CLI against it. ### The web UI The UI is plain JavaScript modules under `internal/web/static/`, with no -framework and no build step. All DOM is built with the `h()` helper in +framework and no build step. The logo is `assets/logo.svg` (a white figure on +a Nordic-blue `#2f5fb3` disc) with an all-white variant +`assets/logo-white.svg`. Both are generated by `scripts/logo.py` (shapes are +given for the left half and mirrored); edit the script, not the SVGs. The +login page's "JARL" wordmark is drawn as SVG paths in `assets/brand.js`, so +it needs no font and takes the theme's text and accent colors. The header's +"Jarl" uses a Latin subset of Cinzel Bold in `assets/fonts/` (SIL OFL; keep +`Cinzel-OFL.txt` next to it). All DOM is built with the `h()` helper in `ui.js`, which never uses `innerHTML`, so record data and comments can't inject markup. The Content-Security-Policy only allows the app's own scripts and styles. To work on it, run jarld and reload the browser: the diff --git a/internal/web/static/assets/app.js b/internal/web/static/assets/app.js index cc61398..23c3203 100644 --- a/internal/web/static/assets/app.js +++ b/internal/web/static/assets/app.js @@ -1,5 +1,6 @@ // Jarl web UI: router, layout and login. import { h, mount, toast, errorBox, plural } from "./ui.js"; +import { wordmark } from "./brand.js"; import { api, get, state, loadDraft, onChange, setUnauthorizedHandler, setImmediate } from "./api.js"; import * as dashboard from "./pages/dashboard.js"; import * as zones from "./pages/zones.js"; @@ -74,7 +75,8 @@ function renderLogin(message) { } } }, user, pass, h("button", { class: "primary", type: "submit" }, "Log in"), err); mount(app, h("div", { class: "login panel" }, - h("h1", null, "Jarl"), + h("img", { class: "login-logo", src: "/assets/logo.svg", alt: "", width: 104, height: 104 }), + h("h1", null, wordmark()), h("div", { class: "tag" }, "Rule your DNS holds"), message ? h("div", { class: "msg warn" }, message) : null, form)); @@ -88,7 +90,10 @@ function layout() { const q = h("input", { type: "search", placeholder: "Search names, data…", "aria-label": "Search" }); const me = state.me; const header = h("header", { class: "top" }, - h("a", { class: "brand", href: "#/" }, "Jarl", h("span", null, me.version)), + h("a", { class: "brand", href: "#/", title: "Dashboard" }, + h("img", { src: "/assets/logo.svg", alt: "", width: 30, height: 30 }), + h("span", { class: "brand-name" }, "Jarl"), + h("span", { class: "brand-version" }, me.version)), navEl, h("div", { class: "spacer" }), h("form", { class: "search", onsubmit: (e) => { e.preventDefault(); if (q.value.trim()) location.hash = "#/search?q=" + encodeURIComponent(q.value.trim()); } }, q), diff --git a/internal/web/static/assets/brand.js b/internal/web/static/assets/brand.js new file mode 100644 index 0000000..d1b8ff3 --- /dev/null +++ b/internal/web/static/assets/brand.js @@ -0,0 +1,39 @@ +// The "JARL" wordmark for the login page: angular Norse-style letters +// drawn as paths (no font needed), over a rule with DNS nodes. The letters +// use the text color and the rule the accent color, so it follows the +// theme. +const NS = "http://www.w3.org/2000/svg"; + +function el(tag, attrs, ...children) { + const e = document.createElementNS(NS, tag); + for (const [k, v] of Object.entries(attrs || {})) e.setAttribute(k, v); + for (const c of children) e.append(c instanceof Node ? c : document.createTextNode(c)); + return e; +} + +// Letters on a 100-unit cap height; counters are cut with evenodd. +const letters = [ + // J: a heavy stem whose foot hooks back into a barb + { x: 0, d: "M14 0 H48 V10 H42 V80 L31 100 H5 L0 93 L19 84 H24 V10 H14 Z" }, + // A: peaked top, flaring legs, a diamond for a counter + { x: 60, d: "M35 0 L46 10 L64 90 L70 100 H50 L44 76 L35 62 L26 76 L20 100 H0 L6 90 L24 10 Z M35 26 L41 40 L35 54 L29 40 Z" }, + // R: squared bowl with cut corners, a long kicked leg + { x: 142, d: "M0 0 H48 L60 12 V40 L50 50 L66 100 H46 L34 60 H18 V100 H0 Z M18 14 H41 L46 19 V35 L41 46 H18 Z" }, + // L: the base ends in an upswept spike + { x: 220, d: "M0 0 H18 V84 H40 L58 70 L54 100 H0 Z" }, +]; + +export function wordmark() { + const letterG = el("g", { fill: "currentColor", "fill-rule": "evenodd" }, + ...letters.map((l) => el("path", { d: l.d, transform: `translate(${l.x} 0)` }))); + // The rule: tapered lines, two nodes and a "DNS" node in the middle. + const rule = el("g", { class: "wordmark-rule", transform: "translate(0 118)" }, + el("path", { d: "M2 18 L98 15.6 V20.4 Z M278 18 L182 15.6 V20.4 Z", class: "fill" }), + el("circle", { cx: 106, cy: 18, r: 4.6, class: "line" }), + el("path", { d: "M110.6 18 H122 M158 18 H169.4", class: "line" }), + el("circle", { cx: 140, cy: 18, r: 17, class: "line" }), + el("circle", { cx: 174, cy: 18, r: 4.6, class: "line" }), + el("text", { x: 140, y: 22.5, "text-anchor": "middle", class: "dns" }, "DNS")); + return el("svg", { class: "wordmark", viewBox: "-4 -4 288 162", role: "img", "aria-label": "Jarl" }, + el("title", null, "Jarl"), letterG, rule); +} diff --git a/internal/web/static/assets/fonts/Cinzel-OFL.txt b/internal/web/static/assets/fonts/Cinzel-OFL.txt new file mode 100644 index 0000000..afdfd14 --- /dev/null +++ b/internal/web/static/assets/fonts/Cinzel-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Cinzel Project Authors (https://github.com/NDISCOVER/Cinzel) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/internal/web/static/assets/fonts/cinzel-bold.woff2 b/internal/web/static/assets/fonts/cinzel-bold.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..e61af45de1cf50bf2b63d29ebe0996e9de73fb2c GIT binary patch literal 13364 zcmV-4G|S6(Pew8T0RR9105miJ5C8xG0C#u*05j460ssI200000000000000000000 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 + Jarl + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/internal/web/static/assets/logo.svg b/internal/web/static/assets/logo.svg new file mode 100644 index 0000000..5cc6323 --- /dev/null +++ b/internal/web/static/assets/logo.svg @@ -0,0 +1,33 @@ + + Jarl + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/internal/web/static/assets/style.css b/internal/web/static/assets/style.css index c442d39..820f50c 100644 --- a/internal/web/static/assets/style.css +++ b/internal/web/static/assets/style.css @@ -87,9 +87,23 @@ header.top { background: var(--panel); border-bottom: 1px solid var(--border); padding: 8px 16px; position: sticky; top: 0; z-index: 10; } -header.top .brand { font-weight: 700; font-size: 17px; letter-spacing: 0.5px; color: var(--text); } -header.top .brand span { color: var(--muted); font-weight: 400; font-size: 12px; margin-left: 6px; } -header.top .brand:hover { text-decoration: none; color: var(--accent); } +/* The brand face: Cinzel Bold (SIL OFL, assets/fonts/Cinzel-OFL.txt), + subset to Latin and served by jarld itself. */ +@font-face { + font-family: "Jarl Cinzel"; + src: url("fonts/cinzel-bold.woff2") format("woff2"); + font-weight: 700; + font-style: normal; + font-display: swap; +} +.brand-name { font-family: "Jarl Cinzel", "Cinzel", "Trajan Pro", Georgia, serif; font-weight: 700; letter-spacing: 1.5px; } +header.top .brand { display: inline-flex; align-items: center; gap: 8px; color: var(--text); text-decoration: none; } +header.top .brand img { display: block; transition: transform 0.15s; } +header.top .brand .brand-name { font-size: 20px; line-height: 1; padding-top: 2px; } +header.top .brand .brand-version { color: var(--muted); font-size: 12px; align-self: flex-end; padding-bottom: 2px; } +header.top .brand:hover { text-decoration: none; } +header.top .brand:hover .brand-name { color: var(--accent); } +header.top .brand:hover img { transform: scale(1.06); } nav.main { display: flex; gap: 2px; flex-wrap: wrap; } nav.main a { padding: 6px 10px; border-radius: var(--radius); color: var(--text); } nav.main a:hover { background: var(--info-soft); text-decoration: none; } @@ -215,7 +229,12 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } /* Login */ .login { max-width: 360px; margin: 10vh auto; } -.login h1 { font-size: 26px; text-align: center; margin-bottom: 4px; } +.login-logo { display: block; margin: 8px auto 10px; } +.login h1 { text-align: center; margin: 0 0 8px; } +.wordmark { display: block; width: 220px; height: auto; margin: 0 auto; color: var(--text); } +.wordmark-rule .fill { fill: var(--accent); } +.wordmark-rule .line { fill: none; stroke: var(--accent); stroke-width: 2.6; } +.wordmark-rule .dns { fill: var(--accent); font: 700 12.5px system-ui, sans-serif; letter-spacing: 0.5px; } .login .tag { text-align: center; color: var(--muted); margin-bottom: 18px; } .login form { display: flex; flex-direction: column; gap: 10px; } .login input { width: 100%; padding: 8px 10px; } @@ -362,3 +381,7 @@ button.chip:hover { background: var(--accent-soft); border-color: var(--accent); .name-preview .msg, .will-add .msg { margin: 4px 0 0; padding: 3px 8px; } .will-add { margin-top: 8px; font-size: 13px; } .will-add code { font-family: var(--mono); background: var(--code-bg); padding: 1px 6px; border-radius: 4px; word-break: break-all; } +@media (prefers-reduced-motion: reduce) { + header.top .brand img { transition: none; } + header.top .brand:hover img { transform: none; } +} diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 2f721c4..878bca3 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -4,7 +4,8 @@ Jarl - + + diff --git a/internal/web/web.go b/internal/web/web.go index 58505df..afe9f79 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -5,8 +5,14 @@ package web import ( "embed" "io/fs" + "mime" ) +func init() { + // Not in Go's built-in table, and the server sends nosniff. + mime.AddExtensionType(".woff2", "font/woff2") +} + //go:embed static var static embed.FS diff --git a/scripts/logo.py b/scripts/logo.py new file mode 100755 index 0000000..b67acb3 --- /dev/null +++ b/scripts/logo.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +# Generates the Jarl logo: internal/web/static/assets/logo.svg (white figure +# on a Nordic-blue disc) and logo-white.svg (the figure alone, for dark +# backgrounds). Shapes are given for the left half and the centre line and +# mirrored, so the figure is exactly symmetrical. Run from the repository +# root: python3 scripts/logo.py +import re, sys +def mirror_path(d): + toks = re.findall(r'[MLQCZHV]|-?\d+(?:\.\d+)?', d) + out=[]; cmd=None; nums=[] + def flush(): + if cmd is None: return + if cmd in 'MLQC': + v=[float(n) for n in nums] + out.append(cmd+" "+" ".join(f"{128-v[i]:g} {v[i+1]:g}" for i in range(0,len(v),2))) + elif cmd=='H': out.append("H "+" ".join(f"{128-float(n):g}" for n in nums)) + elif cmd=='V': out.append("V "+" ".join(nums)) + elif cmd=='Z': out.append("Z") + for t in toks: + if t.isalpha(): flush(); cmd=t; nums=[] + else: nums.append(t) + flush() + return " ".join(out) +LEFT = ['M33 46 L37 46 Q35 64 30 78 Q26 88 24 100 Q22 84 26 72 Q31 60 33 46 Z', 'M38 47 L42 47 Q41 66 37 82 Q34 92 33 104 Q31 88 34 76 Q37 62 38 47 Z', 'M41 51 L44.5 55 L44 63 L41.5 59 Z', 'M47 58 Q52.5 55 58 59.5 Q52.5 63 47 58 Z M51.4 59 L53 57.4 L54.6 59 L53 60.6 Z', 'M45 61.5 L54.5 65 L57.5 72.5 L50 73 L45.5 67.5 Z', 'M63 76 L57 75 L48 78 Q42 82 38 92 Q45 85 50 83 L57 80.5 L63 80 Z'] +CENTER = ['M40 46 H88 L86.5 50 L71.5 55.5 L66.5 62.5 L61.5 62.5 L56.5 55.5 L41.5 50 Z', 'M61.5 63 H66.5 L69 72.5 L64 75.5 L59 72.5 Z', 'M38 69 L45 73.5 L63 79.5 H65 L83 73.5 L90 69 Q95 84 92 96 L88 104 L86 101 L83 110 L80 106 L76 116 L73 111 L69 121 L66 116 L64 127 L62 116 L59 121 L55 111 L52 116 L48 106 L45 110 L42 101 L40 104 L36 96 Q33 84 38 69 Z'] +CUTS = [('M40 80 C42.5 86 37 91 39.5 99 C40.5 102 39 104 40 106', 2.0), ('M44.5 86 C47 92 42 97 44.5 106', 1.7), ('M48.5 85.5 C50.5 90 47 94 48.5 99', 1.5), ('M50.5 92 C53 99 48.5 104 51 113', 2.0), ('M54.5 86.5 C57 94 52.5 101 55 109', 1.7), ('M58 95 C59.5 101 56.5 106 58.5 116', 1.8), ('M60.8 85.5 C62.3 92 59.8 98 61.2 105', 1.5), ('M46 101.5 C47 104 45.5 106.5 46.5 109.5', 1.4), ('M62.2 109 C62.7 113 61.7 116 62.4 119.5', 1.4), ('M56.5 77.6 Q49.5 79.2 43 85.5', 1.3), ('M58.6 81.6 Q49.5 83.8 41.5 95', 2.4)] +CENTER_CUTS = [('M60.4 82.2 H67.6', 1.8)] +CROWN = "M36 44 L35 28 L32 18 L41 25 L50 30 L55 21 L64 9 L73 21 L78 30 L87 25 L96 18 L93 28 L92 44 Z" +def figure(color="#fff", mask_id="cut"): + shapes = CENTER + [p for d in LEFT for p in (d, mirror_path(d))] + cuts = [(p, w) for d, w in CUTS for p in (d, mirror_path(d))] + CENTER_CUTS + mask = (f'' + '' + '' + "".join(f'' for c, w in cuts) + '') + return f''' + + + + + {mask} + +''' + "\n".join(f' ' for d in shapes) + "\n " +DESC = """""" + + +def main(outdir): + logo = f''' + Jarl + {DESC} + + + {figure("#fff", "jarl-cut")} + + +''' + white = f''' + Jarl + {DESC} + {figure("#fff", "jarl-cut-white")} + +''' + open(outdir + "/logo.svg", "w").write(logo) + open(outdir + "/logo-white.svg", "w").write(white) + + +if __name__ == "__main__": + main(sys.argv[1] if len(sys.argv) > 1 else "internal/web/static/assets")