tools/build_icons.py
Rama main · réplica de lectura del repositorio original.
"""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:
- `<use href="external.svg#id">` 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. `<use>` instantiates
a `<symbol>` 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 = "<!-- icons:start -->"
END = "<!-- icons: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"<svg[^>]*>(?P<body>.*)</svg>", 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 <svg> body in {url}")
body = found.group("body").strip()
# Lucide sets stroke/fill on the root <svg>, which a <symbol> does not
# inherit from the <use> 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,
"<!-- Generated by tools/build_icons.py from Lucide (ISC). Do not hand-edit:",
" `python3 tools/build_icons.py` rewrites this block, and the test",
" suite fails if it does not match. -->",
'<svg id="sprite" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">',
]
for name, why in ICONS.items():
lines.append(f" <!-- {why} -->")
lines.append(
f' <symbol id="i-{name}" viewBox="0 0 24 24" fill="none"'
' stroke="currentColor" stroke-width="2" stroke-linecap="round"'
f' stroke-linejoin="round">{fetch(name)}</symbol>'
)
lines += ["</svg>", 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'<symbol id="i-([a-z0-9-]+)"', block))
want = set(ICONS)
if have != want:
for name in sorted(want - have):
print(f"missing from the page: {name}", file=sys.stderr)
for name in sorted(have - want):
print(f"on the page but not in ICONS: {name}", file=sys.stderr)
return 1
print(f"sprite carries all {len(want)} icons")
return 0
try:
block = sprite()
except (urllib.error.URLError, TimeoutError, OSError) as err:
print(f"could not reach Lucide: {err}", file=sys.stderr)
return 1
head, _, rest = page.partition(START)
_, _, tail = rest.partition(END)
PAGE.write_text(head + block + tail)
print(f"wrote {len(ICONS)} icons into {PAGE.relative_to(ROOT)}")
return 0
if __name__ == "__main__":
raise SystemExit(main())