Ctrl K
ComunidadGuías…

tools/build_icons.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""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())