Fix the logo's pop-in with a traced vector logo
The logo was an 880 KB SVG wrapping a 1254px PNG, downloaded again on every page load. Keep that PNG as the master (artwork/logo.png) and have scripts/logo.py trace it with potrace into a 6 KB logo.svg (favicon, README) and logo.js, which brand.js draws inline in the header and on the login page so the logo arrives with the page. Remove the unused logo-white.svg. Static files now carry content ETags, so reloads get 304s instead of downloading the UI again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cf49df0346
commit
0f04dbb4b1
15 files changed
+215
-23049
No files matched your search
+11
-5
@@ -76,11 +76,12 @@ The first version, to be released as 0.1.0.
|
||||
name servers (and when glue addresses are needed), how reverse zones map
|
||||
to networks, and why dynamic updates give way to API tokens.
|
||||
|
||||
- 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.
|
||||
- A logo: a bearded Viking jarl in blue and white, whose crown holds a DNS
|
||||
tree (a root node and its branches). The master is `artwork/logo.png`;
|
||||
`scripts/logo.py` traces it into vector paths (`assets/logo.svg`, 6 KB,
|
||||
and `assets/logo.js`). It is shown on the login page, links home in the
|
||||
top left of the web UI, and is the favicon. The UI draws it inline, so it
|
||||
appears together with the page.
|
||||
- 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),
|
||||
@@ -214,6 +215,11 @@ The first version, to be released as 0.1.0.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The logo popped in after the page: it was an 880 KB SVG wrapping a
|
||||
1254-pixel PNG, downloaded in full on every page load. It is now traced
|
||||
into 6 KB of vector paths and drawn inline. Static files also get an ETag,
|
||||
so reloads revalidate them with 304 answers instead of downloading them
|
||||
again.
|
||||
- `jarl token create --save` stored the token at the top level of the
|
||||
CLI configuration even when a named server was selected; it now goes into
|
||||
that server's entry.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<p align="center"><img src="internal/web/static/assets/logo.svg" width="240" alt="Jarl logo: a Viking jarl in a spear-pointed crown holding a DNS tree"></p>
|
||||
<p align="center"><img src="internal/web/static/assets/logo.svg" width="240" alt="Jarl logo: a bearded Viking jarl in a crown holding a DNS tree"></p>
|
||||
|
||||
# Jarl
|
||||
|
||||
@@ -43,6 +43,9 @@ error: www.adbraun.lol has a CNAME, so it can't have any other records (found A)
|
||||
limited.
|
||||
* **Audit log** of every login, change and token, with user, token, IP and
|
||||
outcome.
|
||||
* **No token needed on the server**: root's `sudo jarl ...` talks to jarld
|
||||
over a local unix socket, so a lost token or password never locks you
|
||||
out.
|
||||
* **Query statistics** (optional, off by default): queries over time,
|
||||
busiest hours of the week, top names and clients, NXDOMAIN and refused
|
||||
queries, record types, cache hit rate. Shown in the web UI and the CLI,
|
||||
@@ -56,7 +59,8 @@ error: www.adbraun.lol has a CNAME, so it can't have any other records (found A)
|
||||
fallback.
|
||||
* One static Go binary for the daemon (web UI embedded, SQLite built in) and
|
||||
one for the CLI, run by a sandboxed systemd service as an unprivileged
|
||||
user.
|
||||
user. `make deb` builds a Debian package, and both come with bash
|
||||
completion.
|
||||
|
||||
## Quick start
|
||||
|
||||
@@ -150,7 +154,8 @@ is under the SIL Open Font License 1.1 (below).
|
||||
|
||||
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`.
|
||||
(`internal/web/static/assets/fonts/Cinzel-OFL.txt`). The logo is part of
|
||||
Jarl: a bearded Viking jarl in blue and white, whose crown holds a DNS tree
|
||||
(a root node with its branches). The master is `artwork/logo.png`, which
|
||||
`scripts/logo.py` traces into the vector `assets/logo.svg` and
|
||||
`assets/logo.js`.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 636 KiB |
@@ -263,6 +263,14 @@ of each token is stored, and the full token is shown once.
|
||||
what the ready-made `acme` role contains.
|
||||
* Administrators can do everything, including managing users and server
|
||||
internals. Admin rights are never available through a restricted token.
|
||||
* A token can be **rotated**: it gets a new secret but keeps its grants,
|
||||
optionally with a grace period for the old secret.
|
||||
|
||||
On the server itself, jarld also listens on a **local unix socket**. There
|
||||
the kernel says which system user is connecting, and root and the `jarl`
|
||||
user are administrators without a token, so `sudo jarl` needs no setup and
|
||||
nobody with root on the server can be locked out. Everyone else needs a
|
||||
token there too.
|
||||
|
||||
Details: [permissions.md](permissions.md).
|
||||
|
||||
|
||||
+11
-4
@@ -31,6 +31,8 @@ contrib/acme.sh/ acme.sh DNS API hook
|
||||
contrib/lego/ program for lego's exec DNS provider
|
||||
scripts/e2e.sh end-to-end test against a real named
|
||||
scripts/mkdeb.sh builds the Debian package (make deb)
|
||||
scripts/logo.py traces artwork/logo.png into the web UI's logo (logo.svg, logo.js)
|
||||
artwork/ the logo's master image
|
||||
```
|
||||
|
||||
The main flow is in `internal/engine/engine.go` (`applyOps`):
|
||||
@@ -112,10 +114,15 @@ 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. 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
|
||||
framework and no build step. The logo's master is `artwork/logo.png` (blue
|
||||
`#1d5287` and white on a transparent background). `scripts/logo.py` traces it
|
||||
with potrace (needs `potrace` and ImageMagick) into `assets/logo.svg` (the
|
||||
favicon and the README's logo) and `assets/logo.js` (the same paths); change
|
||||
the PNG and rerun the script rather than editing those files. The header and
|
||||
login page draw the logo inline from `logo.js` (`logo()` in `brand.js`), so
|
||||
it appears with the page instead of popping in after a separate download.
|
||||
Static files are served with `Cache-Control: no-cache` and a content ETag,
|
||||
so a reload revalidates them with cheap 304 answers. 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
|
||||
|
||||
+12
-2
@@ -10,6 +10,9 @@
|
||||
| Check the configuration | `jarl check`: full validation plus `named-checkconf -z` |
|
||||
| Logs | `journalctl -u jarl` (jarld, including `msg=audit` lines), `journalctl -u named` |
|
||||
| Restart / reload TLS | `systemctl restart jarl` / `systemctl reload jarl` |
|
||||
| Work on the server itself | `sudo jarl ...`: root uses jarld's local socket and needs no token ([cli.md](cli.md#on-the-server-itself)) |
|
||||
| Renew or replace a token | `jarl token rotate NAME --grace 1h` (same grants, new secret; [permissions.md](permissions.md#rotating-a-token)) |
|
||||
| Upgrade Jarl | Debian package: build the new one and `sudo apt install ./dist/jarl_*.deb`; otherwise `make && sudo make install && sudo systemctl restart jarl`. jarld upgrades its database when it starts |
|
||||
|
||||
Restarting jarld doesn't affect DNS: named keeps serving the files it
|
||||
loaded. jarld is only needed to make changes.
|
||||
@@ -98,12 +101,17 @@ Start named, then apply again.
|
||||
|
||||
## Recovering access
|
||||
|
||||
Revoking a token never locks you out: web logins don't depend on tokens,
|
||||
and root on the server can always use `sudo jarl` through the local
|
||||
socket, without a token.
|
||||
|
||||
| Problem | Fix (on the server) |
|
||||
|---------|---------------------|
|
||||
| Forgot the admin password | `sudo -u jarl jarld user passwd admin` |
|
||||
| Revoked or lost the CLI's token | `sudo jarl token create cli --user admin`, or Account → API tokens in the web UI |
|
||||
| Forgot the admin password | `sudo jarl user update admin --password`, or (also with jarld stopped) `sudo -u jarl jarld user passwd admin` |
|
||||
| Locked out by the login rate limit | wait 15 minutes, or `systemctl restart jarl` (the limiter is in memory) |
|
||||
| Lost every admin account | `sudo -u jarl jarld user add --admin rescue` |
|
||||
| Need a token without the web UI | `sudo -u jarl jarld user token admin cli` |
|
||||
| Need a token without the web UI | `sudo jarl token create cli --user admin`, or `sudo -u jarl jarld user token admin cli` |
|
||||
|
||||
## A secondary server
|
||||
|
||||
@@ -149,6 +157,8 @@ rndc reconfig
|
||||
## Monitoring
|
||||
|
||||
* `GET /healthz` (no authentication) reports that jarld is up.
|
||||
* On the server, a check can use the local socket and needs no token:
|
||||
`sudo jarl status -o json`.
|
||||
* `GET /api/v1/status` with a read-only token reports named reachability,
|
||||
drift and configuration problems. It is suitable for a monitoring check:
|
||||
```sh
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"jarl/internal/api"
|
||||
"jarl/internal/auth"
|
||||
@@ -120,6 +124,7 @@ func (s *Server) routes() {
|
||||
// Web UI
|
||||
if s.web != nil {
|
||||
files := http.FileServer(http.FS(s.web))
|
||||
etags := contentETags(s.web)
|
||||
m.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
w.Header().Set("Allow", "GET, HEAD")
|
||||
@@ -134,11 +139,17 @@ func (s *Server) routes() {
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Write(b)
|
||||
w.Header().Set("ETag", etags["index.html"])
|
||||
http.ServeContent(w, r, "index.html", time.Time{}, bytes.NewReader(b))
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(r.URL.Path, "/assets/") {
|
||||
// no-cache: the browser asks again each time, but with
|
||||
// the ETag an unchanged file costs a 304 and no body.
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
if tag, ok := etags[strings.TrimPrefix(r.URL.Path, "/")]; ok {
|
||||
w.Header().Set("ETag", tag)
|
||||
}
|
||||
files.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
@@ -233,3 +244,22 @@ func require(p *auth.Principal, a auth.Action) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// contentETags returns an ETag for every file of the embedded web UI,
|
||||
// from its content (embedded files have no modification time).
|
||||
func contentETags(fsys fs.FS) map[string]string {
|
||||
tags := map[string]string{}
|
||||
fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
b, err := fs.ReadFile(fsys, path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
sum := sha256.Sum256(b)
|
||||
tags[path] = `"` + hex.EncodeToString(sum[:12]) + `"`
|
||||
return nil
|
||||
})
|
||||
return tags
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
// Static files carry an ETag, so a browser's revalidation of an unchanged
|
||||
// file is a 304 without a body.
|
||||
func TestStaticETags(t *testing.T) {
|
||||
web := fstest.MapFS{
|
||||
"index.html": {Data: []byte("<!doctype html><title>Jarl</title>")},
|
||||
"assets/logo.js": {Data: []byte("export const x = 1;\n")},
|
||||
}
|
||||
s := &Server{cfg: Config{}, log: slog.New(slog.NewTextHandler(io.Discard, nil)), mux: http.NewServeMux(), limiter: newRateLimiter(), web: web}
|
||||
s.routes()
|
||||
for _, path := range []string{"/", "/assets/logo.js"} {
|
||||
w := httptest.NewRecorder()
|
||||
s.mux.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
|
||||
tag := w.Header().Get("ETag")
|
||||
if w.Code != 200 || tag == "" || w.Body.Len() == 0 {
|
||||
t.Fatalf("%s: %d etag %q", path, w.Code, tag)
|
||||
}
|
||||
r := httptest.NewRequest("GET", path, nil)
|
||||
r.Header.Set("If-None-Match", tag)
|
||||
w = httptest.NewRecorder()
|
||||
s.mux.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusNotModified || w.Body.Len() != 0 {
|
||||
t.Errorf("%s revalidated: %d, %d bytes", path, w.Code, w.Body.Len())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// Jarl web UI: router, layout and login.
|
||||
import { h, mount, toast, errorBox, plural } from "./ui.js";
|
||||
import { wordmark } from "./brand.js";
|
||||
import { wordmark, logo } 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";
|
||||
@@ -75,7 +75,7 @@ function renderLogin(message) {
|
||||
}
|
||||
} }, user, pass, h("button", { class: "primary", type: "submit" }, "Log in"), err);
|
||||
mount(app, h("div", { class: "login panel" },
|
||||
h("img", { class: "login-logo", src: "/assets/logo.svg", alt: "", width: 104, height: 104 }),
|
||||
logo(104, "login-logo"),
|
||||
h("h1", null, wordmark()),
|
||||
h("div", { class: "tag" }, "Rule your DNS holds"),
|
||||
message ? h("div", { class: "msg warn" }, message) : null,
|
||||
@@ -92,7 +92,7 @@ function layout() {
|
||||
const me = state.me;
|
||||
const header = h("header", { class: "top" },
|
||||
h("a", { class: "brand", href: "#/", title: "Dashboard" },
|
||||
h("img", { src: "/assets/logo.svg", alt: "", width: 30, height: 30 }),
|
||||
logo(30),
|
||||
h("span", { class: "brand-name" }, "Jarl"),
|
||||
h("span", { class: "brand-version" }, me.version)),
|
||||
navEl,
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
// The Jarl logo and the login page's wordmark, drawn as inline SVG so they
|
||||
// appear together with the page instead of loading afterwards.
|
||||
//
|
||||
// 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.
|
||||
import * as logoData from "./logo.js";
|
||||
|
||||
const NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
function el(tag, attrs, ...children) {
|
||||
@@ -37,3 +42,11 @@ export function wordmark() {
|
||||
return el("svg", { class: "wordmark", viewBox: "-4 -4 288 162", role: "img", "aria-label": "Jarl" },
|
||||
el("title", null, "Jarl"), letterG, rule);
|
||||
}
|
||||
|
||||
// logo returns the Jarl logo (traced from artwork/logo.png by
|
||||
// scripts/logo.py) as an SVG of the given size in pixels.
|
||||
export function logo(size, cls) {
|
||||
return el("svg", { class: cls || "logo", width: size, height: size, viewBox: logoData.viewBox, "aria-hidden": "true", focusable: "false" },
|
||||
el("g", { transform: logoData.transform },
|
||||
...logoData.layers.map(([fill, d]) => el("path", { fill, d }))));
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
|
Before Width: | Height: | Size: 861 KiB |
@@ -0,0 +1,8 @@
|
||||
// The Jarl logo, traced from artwork/logo.png by scripts/logo.py; don't edit.
|
||||
// brand.js draws it; logo.svg holds the same paths.
|
||||
export const viewBox = "14 14 1226 1226";
|
||||
export const transform = "translate(0 1254) scale(1 -1)";
|
||||
export const layers = [
|
||||
["#1d5287", "M618 1223 c-16 -21 -83 -110 -94 -126 l-11 -15 0 -37 0 -38 -2 -1 c-2 -2 -33 -20 -80 -47 -12 -7 -25 -14 -29 -17 -11 -6 -33 -19 -33 -19 -1 1 -7 51 -9 71 l-2 19 -30 22 c-51 37 -51 37 -90 66 -8 5 -15 9 -15 9 0 0 2 -8 4 -16 1 -9 5 -22 6 -30 4 -15 10 -42 30 -120 7 -27 15 -62 19 -78 4 -17 9 -37 11 -45 2 -8 8 -31 13 -51 l8 -36 5 0 5 0 -14 -40 c-35 -106 -50 -154 -49 -154 1 0 29 11 42 16 5 2 9 4 9 3 0 0 -5 -12 -12 -28 -6 -15 -14 -34 -18 -42 -7 -19 -30 -75 -30 -76 0 0 21 -30 41 -56 10 -15 41 -59 59 -84 12 -18 14 -20 14 -19 0 0 3 5 7 12 l5 11 3 -12 c1 -6 4 -20 6 -31 l4 -19 9 -9 c5 -5 15 -13 21 -19 44 -42 74 -71 74 -71 1 0 2 7 3 14 2 7 3 14 4 14 0 0 28 -28 63 -62 l62 -62 63 62 63 63 3 -14 c1 -8 3 -15 3 -15 0 0 11 10 24 23 13 12 36 35 52 49 26 26 28 27 29 32 0 2 3 15 5 29 3 13 5 24 6 25 0 2 2 -1 6 -9 4 -6 7 -11 7 -11 1 0 8 10 16 22 14 20 24 35 77 108 l21 29 -8 19 c-8 21 -23 56 -41 101 -7 14 -12 27 -12 27 1 0 12 -4 26 -10 14 -5 26 -10 26 -9 1 0 -31 98 -59 181 l-4 12 5 0 5 0 2 11 c12 48 25 104 43 176 11 42 40 166 46 188 l0 2 -3 -2 c-2 -1 -11 -8 -20 -14 -9 -7 -20 -15 -24 -18 -4 -3 -12 -8 -17 -12 -14 -11 -38 -28 -44 -33 -4 -2 -11 -7 -16 -11 l-10 -7 -6 -45 c-3 -25 -6 -45 -6 -45 -2 0 -45 25 -92 54 -3 1 -15 8 -26 15 -11 6 -22 12 -23 13 l-3 2 0 37 0 38 -18 24 c-10 14 -23 30 -28 38 -21 28 -67 89 -68 89 0 0 -4 -5 -9 -11z"],
|
||||
["#fff", "M603 1171 c-13 -17 -34 -46 -46 -63 l-24 -31 0 -38 c0 -21 0 -40 0 -41 -1 -3 -2 -4 -34 -23 -18 -10 -42 -24 -54 -31 -70 -41 -92 -53 -93 -53 0 0 -2 17 -4 38 -9 66 -10 74 -11 75 -1 1 -6 4 -10 7 -8 6 -27 20 -58 43 -7 5 -13 9 -14 9 0 -1 7 -31 43 -173 17 -68 31 -126 31 -128 1 -2 2 -3 2 -3 1 0 16 6 33 13 56 22 87 33 121 41 83 20 165 23 248 8 54 -10 101 -24 162 -51 30 -13 29 -12 30 -8 1 7 20 84 47 192 15 60 27 109 27 109 -1 0 -16 -11 -35 -25 -18 -13 -37 -27 -40 -29 -4 -3 -8 -6 -8 -6 0 -1 -4 -26 -7 -56 -4 -31 -8 -56 -8 -56 -1 0 -41 23 -70 40 -14 9 -32 19 -40 24 -19 11 -67 39 -68 39 -1 0 -1 19 -2 42 l0 41 -19 25 c-57 76 -75 100 -75 100 0 0 -11 -14 -24 -31z m34 -64 c18 -4 31 -18 34 -37 3 -19 -8 -40 -27 -48 l-6 -2 0 -39 0 -38 4 -2 c10 -4 19 -13 23 -24 l2 -6 5 -2 c71 -15 77 -16 78 -15 1 1 3 4 5 6 17 25 56 22 70 -7 10 -21 1 -45 -21 -55 -10 -5 -23 -5 -33 0 -12 5 -21 17 -24 28 0 2 -1 4 -2 4 0 0 -18 4 -39 8 -21 4 -39 8 -40 9 -2 0 -3 -1 -6 -5 -16 -24 -50 -24 -66 0 -3 4 -4 5 -5 5 -2 -1 -20 -5 -42 -9 l-40 -8 -1 -5 c-6 -22 -30 -36 -52 -29 -36 11 -39 61 -5 77 19 9 45 1 53 -17 2 -4 0 -4 28 2 13 3 31 7 40 9 l17 4 2 5 c4 11 12 20 22 25 l5 2 0 38 0 39 -4 1 c-13 4 -25 17 -28 32 -9 32 21 63 53 54z M616 1086 c-15 -8 -19 -28 -7 -40 16 -15 42 -4 42 18 0 18 -18 29 -35 22z M457 894 c-12 -7 -16 -22 -7 -32 14 -16 40 -3 36 18 -3 13 -18 20 -29 14z M780 895 c-16 -7 -17 -29 -2 -37 16 -9 34 4 30 22 -3 12 -17 19 -28 15z M594 809 c-48 -3 -98 -12 -142 -26 -7 -2 -13 -4 -13 -4 -1 0 -4 -9 -8 -19 l-7 -19 5 -1 c2 0 15 0 29 1 l24 1 13 -5 c44 -15 84 -28 86 -28 0 0 5 3 10 7 5 5 10 8 10 8 0 -1 0 -18 -1 -40 -3 -91 -3 -108 -2 -110 1 0 8 -5 15 -9 l14 -9 15 9 14 10 -1 32 c0 17 -1 50 -1 74 -1 23 -1 42 -1 42 0 1 5 -3 10 -7 l10 -8 50 17 50 17 21 -1 c11 -1 24 -1 28 -1 l8 0 -6 16 c-3 8 -6 17 -7 19 l-1 4 -11 3 c-68 22 -143 31 -211 27z M846 767 c0 -1 9 -26 15 -41 l5 -13 0 -20 c0 -23 3 -146 4 -167 0 -10 0 -14 -1 -13 -1 1 -11 27 -23 61 -3 10 -7 19 -7 20 -1 1 -13 -52 -13 -56 0 -3 4 -19 15 -60 l7 -26 14 -25 c7 -14 16 -30 19 -36 3 -6 8 -15 10 -19 l5 -9 -3 -34 c-2 -18 -3 -34 -3 -35 0 -2 7 7 28 37 15 21 36 49 45 62 l17 23 -4 10 c-7 16 -20 47 -34 81 -7 17 -18 44 -25 59 -6 14 -13 31 -15 36 -2 5 -4 9 -4 10 0 0 5 -3 10 -7 15 -12 27 -19 40 -25 6 -3 12 -6 13 -6 1 0 -4 16 -38 120 l-16 48 -16 7 c-22 10 -45 19 -45 18z M377 755 l-29 -13 -16 -47 c-22 -64 -40 -120 -40 -121 1 -1 20 8 29 13 4 3 14 10 21 15 7 6 13 10 13 10 1 0 -12 -30 -27 -66 -15 -37 -34 -81 -41 -98 l-13 -32 5 -7 c7 -9 47 -65 64 -89 7 -10 14 -20 16 -23 l5 -5 0 4 c0 2 -1 14 -2 27 -3 27 -4 35 -4 38 0 2 5 12 11 24 33 59 36 66 38 72 7 23 21 79 21 81 0 3 -12 56 -13 56 0 0 -5 -13 -10 -28 -13 -34 -20 -54 -21 -54 0 1 0 20 1 44 2 94 3 127 3 141 l0 15 8 21 c11 30 12 34 11 34 -1 0 -15 -6 -30 -12z M419 693 c-1 -9 -1 -24 -1 -34 l0 -17 21 -20 21 -19 2 -7 c1 -4 4 -13 6 -19 2 -7 6 -21 10 -31 3 -10 6 -19 6 -19 0 -1 14 9 30 22 l30 24 12 18 c6 9 12 19 14 21 2 3 2 3 3 33 l0 31 -5 -2 c-33 -13 -43 -17 -44 -17 0 1 4 7 10 14 5 7 10 14 10 14 -1 0 -7 -1 -14 -3 -21 -6 -23 -6 -35 -2 -9 3 -10 4 -24 17 l-15 13 -18 0 -19 0 0 -17z M783 697 c-23 -21 -31 -23 -58 -16 -8 2 -14 4 -15 4 0 0 5 -7 10 -14 5 -7 10 -14 10 -14 -1 0 -22 8 -40 15 -5 2 -9 4 -10 4 0 -1 0 -14 1 -31 l1 -29 12 -20 c7 -11 13 -20 14 -21 0 0 4 -4 9 -8 6 -4 19 -15 31 -24 12 -10 21 -17 22 -17 0 1 22 69 23 73 0 2 6 8 22 23 l21 21 0 17 c-1 10 -1 25 -1 34 l0 16 -19 0 -18 0 -15 -13z M568 554 c-20 -7 -21 -8 -50 -30 -59 -44 -59 -44 -70 -59 -26 -37 -37 -54 -53 -86 -11 -22 -11 -24 -11 -28 2 -8 11 -57 18 -92 l7 -34 8 -8 c30 -28 64 -60 65 -60 0 0 -2 11 -4 25 -3 24 -5 45 -5 56 0 16 13 46 29 69 6 8 18 22 19 22 0 0 -2 -6 -4 -14 -14 -46 -16 -85 -6 -136 l3 -18 51 -51 c38 -38 50 -49 50 -47 -6 28 -13 95 -15 143 -1 44 10 121 25 171 2 5 2 5 6 -9 11 -40 18 -85 21 -126 3 -34 -1 -96 -10 -159 -2 -12 -3 -21 -3 -22 0 0 23 23 51 50 l50 51 2 13 c5 24 6 36 6 61 0 32 -3 51 -12 82 -2 6 -3 10 -3 11 1 0 15 -16 21 -26 13 -18 20 -34 25 -52 3 -12 2 -22 -4 -66 -1 -15 -3 -27 -3 -27 0 -1 10 7 21 18 11 11 27 26 36 34 l16 15 1 10 c1 6 6 30 11 53 4 23 9 48 10 55 l2 13 -8 16 c-15 32 -27 51 -51 86 l-14 21 -17 13 c-70 54 -74 57 -99 64 l-7 2 -13 -8 c-7 -4 -17 -10 -23 -14 l-10 -6 -10 6 c-5 3 -16 9 -23 14 -7 4 -14 8 -14 8 -1 0 -6 -2 -12 -4z m94 -66 c28 -14 36 -19 45 -25 46 -35 73 -72 91 -126 6 -17 6 -20 1 -12 -2 2 -9 12 -16 22 -43 57 -73 82 -142 119 l-14 8 -17 -9 c-72 -39 -98 -62 -156 -141 l-3 -4 1 3 c1 6 7 25 12 36 16 40 41 71 78 100 11 9 15 11 48 28 20 10 37 19 37 19 0 0 16 -8 35 -18z"],
|
||||
];
|
||||
+1
-11480
File diff suppressed because it is too large.
Load diff
|
Before Width: | Height: | Size: 861 KiB After Width: | Height: | Size: 5.8 KiB |
@@ -99,12 +99,12 @@ header.top {
|
||||
}
|
||||
.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 .logo { 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); }
|
||||
header.top .brand:hover .logo { 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; }
|
||||
@@ -389,6 +389,6 @@ button.chip:hover { background: var(--accent-soft); border-color: var(--accent);
|
||||
.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; }
|
||||
header.top .brand .logo { transition: none; }
|
||||
header.top .brand:hover .logo { transform: none; }
|
||||
}
|
||||
+66
-64
@@ -1,70 +1,72 @@
|
||||
#!/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'<mask id="{mask_id}" maskUnits="userSpaceOnUse" x="0" y="0" width="128" height="128">'
|
||||
'<rect width="128" height="128" fill="#fff"/>'
|
||||
'<g fill="none" stroke="#000" stroke-linecap="round">' + "".join(f'<path stroke-width="{w}" d="{c}"/>' for c, w in cuts) + '</g></mask>')
|
||||
return f'''<path fill="none" stroke="{color}" stroke-width="3.6" stroke-linejoin="miter" stroke-miterlimit="8" d="{CROWN}"/>
|
||||
<path fill="{color}" d="M37 37 H91 V40.5 H37 Z"/>
|
||||
<path fill="{color}" d="M32 18 L27.5 9 L37 13.5 Z M96 18 L100.5 9 L91 13.5 Z M64 9 L61 3.5 L64 0.5 L67 3.5 Z"/>
|
||||
<g fill="none" stroke="{color}" stroke-width="2.3"><circle cx="64" cy="19" r="3"/><path d="M64 22 V28.5 M55.3 31.5 H72.7"/><circle cx="52.2" cy="31.5" r="3"/><circle cx="75.8" cy="31.5" r="3"/></g>
|
||||
<circle cx="64" cy="31.5" r="3.2" fill="{color}"/>
|
||||
<defs>{mask}</defs>
|
||||
<g fill="{color}" fill-rule="evenodd" mask="url(#{mask_id})">
|
||||
''' + "\n".join(f' <path d="{d}"/>' for d in shapes) + "\n </g>"
|
||||
DESC = """<!-- Jarl: a Viking ruler in a spear-pointed crown, drawn like a woodcut.
|
||||
The crown holds a DNS tree: the root node with its branches below.
|
||||
Generated by scripts/logo.py, which mirrors the left half, so it is
|
||||
exactly symmetrical; the beard's strands are cut out with a mask. -->"""
|
||||
# Builds the web UI's logo from the master image, artwork/logo.png (a blue
|
||||
# and white figure on a transparent background), by tracing it into vector
|
||||
# paths with potrace:
|
||||
#
|
||||
# internal/web/static/assets/logo.svg the favicon, and the README's logo
|
||||
# internal/web/static/assets/logo.js the same paths for the web UI, which
|
||||
# draws them inline (brand.js), so the
|
||||
# logo appears together with the page
|
||||
#
|
||||
# Both are a few KB, where the PNG is 650 KB. Needs potrace and ImageMagick
|
||||
# (convert). Run from the repository root after changing the master:
|
||||
#
|
||||
# python3 scripts/logo.py
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
MASTER = "artwork/logo.png"
|
||||
OUT = "internal/web/static/assets"
|
||||
BLUE = "#1d5287" # the master's blue
|
||||
WHITE = "#fff"
|
||||
PAD = 6 # pixels of the master around the figure
|
||||
|
||||
|
||||
def main(outdir):
|
||||
logo = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-labelledby="t">
|
||||
<title id="t">Jarl</title>
|
||||
{DESC}
|
||||
<circle cx="64" cy="64" r="64" fill="#2f5fb3"/>
|
||||
<g transform="translate(64 66) scale(0.8) translate(-64 -64)">
|
||||
{figure("#fff", "jarl-cut")}
|
||||
</g>
|
||||
</svg>
|
||||
'''
|
||||
white = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="20 -1 88 130" role="img" aria-labelledby="t">
|
||||
<title id="t">Jarl</title>
|
||||
{DESC}
|
||||
{figure("#fff", "jarl-cut-white")}
|
||||
</svg>
|
||||
'''
|
||||
open(outdir + "/logo.svg", "w").write(logo)
|
||||
open(outdir + "/logo-white.svg", "w").write(white)
|
||||
def run(*args):
|
||||
return subprocess.run(args, check=True, capture_output=True, text=True).stdout
|
||||
|
||||
|
||||
def trace(pbm):
|
||||
"""Traces a bitmap into one SVG path (in the master's pixels, y up)."""
|
||||
svg = run("potrace", "-b", "svg", "--flat", "-t", "4", "-a", "1.0", "-O", "0.4", "-u", "1", pbm, "-o", "-")
|
||||
return " ".join(re.search(r'<path d="([^"]*)"', svg, re.S).group(1).split())
|
||||
|
||||
|
||||
def main():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
base, white = os.path.join(tmp, "base.pbm"), os.path.join(tmp, "white.pbm")
|
||||
# The figure's whole shape (opaque pixels), drawn in blue...
|
||||
run("convert", MASTER, "-alpha", "extract", "-threshold", "50%", "-negate", base)
|
||||
# ...and its white parts on top; the blue lines show between them.
|
||||
run("convert", MASTER, "-background", "black", "-alpha", "remove", "-alpha", "off",
|
||||
"-colorspace", "gray", "-threshold", "75%", "-negate", white)
|
||||
layers = [(BLUE, trace(base)), (WHITE, trace(white))]
|
||||
height = int(run("identify", "-format", "%h", MASTER))
|
||||
w, h, x, y = map(int, re.match(r"(\d+)x(\d+)\+(\d+)\+(\d+)",
|
||||
run("convert", MASTER, "-alpha", "extract", "-threshold", "50%", "-format", "%@", "info:")).groups())
|
||||
side = max(w, h) + 2 * PAD
|
||||
view_box = f"{x + w / 2 - side / 2:g} {y + h / 2 - side / 2:g} {side:g} {side:g}"
|
||||
transform = f"translate(0 {height}) scale(1 -1)"
|
||||
|
||||
paths = "".join(f'<path fill="{fill}" d="{d}"/>' for fill, d in layers)
|
||||
with open(os.path.join(OUT, "logo.svg"), "w") as f:
|
||||
f.write(f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="{view_box}">'
|
||||
f'<!-- Generated by scripts/logo.py from {MASTER}. -->'
|
||||
f'<g transform="{transform}">{paths}</g></svg>\n')
|
||||
with open(os.path.join(OUT, "logo.js"), "w") as f:
|
||||
f.write(f"// The Jarl logo, traced from {MASTER} by scripts/logo.py; don't edit.\n"
|
||||
f"// brand.js draws it; logo.svg holds the same paths.\n"
|
||||
f'export const viewBox = "{view_box}";\n'
|
||||
f'export const transform = "{transform}";\n'
|
||||
"export const layers = [\n"
|
||||
+ "".join(f' ["{fill}", "{d}"],\n' for fill, d in layers)
|
||||
+ "];\n")
|
||||
for name in ("logo.svg", "logo.js"):
|
||||
print(f"{OUT}/{name}: {os.path.getsize(os.path.join(OUT, name))} bytes")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(sys.argv[1] if len(sys.argv) > 1 else "internal/web/static/assets")
|
||||
sys.exit(main())
|
||||
Reference in new issue
Block a user