"""Rebuild the inline icon sprite in web/index.html from Lucide. The panel used Unicode glyphs — ✕ ⚙ ✎ ▸ ⇩ — and they are not a set. Each one is drawn by whatever font the operating system decides on, so the weights disagree, the sizes disagree, and half of them are missing on a phone. That is the same class of problem as the emoji-width bug: a text character asked to be an icon. Lucide, not CoreUI: same licence terms in practice (ISC vs MIT, both fine) and a comparable set, but Lucide is stroke-based on a 24-unit grid with a 2px line, and CLIque is thin borders and small muted text. Filled icons read as a Bootstrap dashboard in a panel like this one. Swapping libraries is the SOURCE line below and the names in ICONS. One inline sprite in the page, not files and not a font: - `` does not work cross-document in every browser, so a separate file would need a fetch and a JS injection to be reliable. - An icon font needs a face, a download, and it breaks the moment a user's browser blocks remote fonts. - Inline is about 3 KB, has no request, and works with no network at all — which is the same promise the vendored xterm makes. `currentColor` throughout, so every icon follows the theme with no per-theme work, and every symbol keeps its viewBox so sizing is one CSS rule. The container is hidden by a rule in app.css rather than by an inline style or the `hidden` attribute. An inline style is a thing to ask the CSP about, and `hidden` is an HTML attribute being put on an element in the SVG namespace — neither is worth betting a page of invisible controls on. `` instantiates a `` regardless of whether its container is displayed. python3 tools/build_icons.py [--check] `--check` verifies the page matches this file's list without touching disk; that is what the test suite runs, so the sprite cannot quietly drift. """ from __future__ import annotations import argparse import re import sys import urllib.error import urllib.request from pathlib import Path ROOT = Path(__file__).resolve().parents[1] PAGE = ROOT / "clique" / "web" / "index.html" SOURCE = "https://unpkg.com/lucide-static@1.33.0/icons/{name}.svg" START = "" END = "" #: Every icon the panel draws, and what it is for. Adding one here and running #: this file is the whole procedure; nothing else knows the list. ICONS: dict[str, str] = { "plus": "new session, new folder, one more repeat", "minus": "one fewer repeat", "x": "close a tab, a sheet, a dialog", "chevron-left": "hide the sidebar; back out of one image", "chevron-right": "show the sidebar; a shut folder", "chevron-down": "an open folder", "settings": "settings, and a session's own settings", "download": "adopt a session another tool started", "arrow-down-to-line": "following output, or not", "pause": "output is paused, not following", "pencil": "rename, recolour or delete a folder", "image": "images this session made", "keyboard": "keyboard shortcuts", "filter": "show only running sessions", "maximize-2": "full screen", "minimize-2": "leave full screen", "bell": "the inbox — sessions waiting on you", "corner-down-left": "send this reply / accept the default (Enter)", "megaphone": "broadcast one message to every live session", "layout-grid": "the board — sessions by what they are doing", "more-horizontal": "the sidebar overflow menu — folders, adopt, broadcast, board", "lock": "a session held read-only for review — input is blocked", "notebook": "the side panel's Notes pane", "git-branch": "the side panel's Git / Checkpoint pane", "info": "the side panel's Session info pane", "arrow-right": "push a note into the terminal", "clock": "a reminder is set on a note", "trash": "delete a note line", "panel-right": "open or close the side panel", "rotate-cw": "reload the panel, for an installed app with no address bar", "paperclip": "attach a file to this session, the only route in from a phone", } _BODY = re.compile(r"]*>(?P.*)", re.S) def fetch(name: str) -> str: """The drawing instructions from one Lucide file, without its wrapper.""" url = SOURCE.format(name=name) with urllib.request.urlopen(url, timeout=30) as response: text = response.read().decode() found = _BODY.search(text) if not found: raise SystemExit(f"{name}: no body in {url}") body = found.group("body").strip() # Lucide sets stroke/fill on the root , which a does not # inherit from the site the way we want. They are moved onto the # symbol below, once, rather than repeated on every path. return re.sub(r"\s+", " ", body).replace("> <", "><") def sprite() -> str: """The whole block, ready to paste between the markers.""" lines = [ START, "", '", END] return "\n".join(lines) def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--check", action="store_true", help="report drift instead of writing") args = parser.parse_args() page = PAGE.read_text() if START not in page or END not in page: print(f"{PAGE} has no {START} … {END} block", file=sys.stderr) return 1 if args.check: # Offline, and without asking unpkg on every test run: the check is # that every icon named here has a symbol on the page and that the # page names no others. Whether the path data still matches upstream # is a question for the day someone deliberately regenerates. block = page.split(START, 1)[1].split(END, 1)[0] have = set(re.findall(r'