|
| 1 | +"""repo_card.py -- the artifact a tool hands back, drawn field by field. |
| 2 | +
|
| 3 | +Every command here writes a receipt, and until now the README said so in a |
| 4 | +sentence. A reader deciding whether to trust the tool wants to see the thing: |
| 5 | +what fields come back, which one carries the verdict, and how they would check |
| 6 | +each field themselves. This draws that from a spec, so the picture is data in |
| 7 | +the repository and a gate can hold it against a receipt the tool actually |
| 8 | +emits. |
| 9 | +
|
| 10 | +Color still says one thing. Exactly one row carries the verdict and takes the |
| 11 | +verified green; a row that reports drift takes the drift iris. Every other row |
| 12 | +is ink and a hairline, because a field is structure and structure is not news. |
| 13 | +
|
| 14 | +The value column shows a literal only where the literal is stable. A hash or a |
| 15 | +byte count changes with the checkout, so those rows carry the shape of the |
| 16 | +value instead: how many entries, how many keys. A picture that shows a hash is |
| 17 | +a picture that is wrong by the next commit. |
| 18 | +""" |
| 19 | +from __future__ import annotations |
| 20 | + |
| 21 | +from repo_art import GROTESK, MONO, _esc, _num |
| 22 | + |
| 23 | +W = 960 |
| 24 | +PAD = 44 |
| 25 | +ROW_H = 46 |
| 26 | +TOP = 142 |
| 27 | +KEY_W = 186 |
| 28 | +VAL_W = 258 |
| 29 | +GUTTER = 26 |
| 30 | +NOTE_X = PAD + KEY_W + GUTTER + VAL_W + GUTTER |
| 31 | +NOTE_W = W - PAD - NOTE_X |
| 32 | + |
| 33 | +# The same two palettes the schematics use, so the whole set reads as one hand. |
| 34 | +STYLE = """ |
| 35 | + :root{ --void:#f4f3ef; --bone:#0b0c0e; --muted:#43474e; |
| 36 | + --hairline:rgba(11,12,14,.16); --card:rgba(255,255,255,.66); |
| 37 | + --verified:#1f7a52; --drift:#3a2bd6; } |
| 38 | + @media (prefers-color-scheme: dark){ |
| 39 | + :root{ --void:#0b0e0f; --bone:#eef1ee; --muted:#9aa39c; |
| 40 | + --hairline:rgba(238,241,238,.18); --card:rgba(255,255,255,.05); |
| 41 | + --verified:#5fae93; --drift:#a99cf5; } } |
| 42 | + .bg{ fill:var(--void); } |
| 43 | + .row{ fill:var(--card); stroke:var(--hairline); stroke-width:1.2; } |
| 44 | + .key{ fill:var(--bone); font-size:13px; font-weight:650; } |
| 45 | + .val{ fill:var(--muted); font-size:12px; } |
| 46 | + .s{ fill:var(--muted); font-size:11.5px; } |
| 47 | + .k{ fill:var(--muted); font-size:11px; letter-spacing:.16em; } |
| 48 | + .h{ fill:var(--bone); font-size:21px; font-weight:700; } |
| 49 | + .thin{ stroke:var(--hairline); stroke-width:1.2; fill:none; } |
| 50 | +""" |
| 51 | + |
| 52 | +TONE = {"verified": "var(--verified)", "drift": "var(--drift)", |
| 53 | + "none": "var(--hairline)"} |
| 54 | + |
| 55 | +# The column heads say what a row of this drawing is. A receipt reads as a |
| 56 | +# field and what comes back in it; something else in the repository reads as |
| 57 | +# something else, so a spec may name its own three and these are the default. |
| 58 | +HEADS = ("field", "what comes back", "how you check it") |
| 59 | + |
| 60 | +# What one character draws in the note and footnote columns, in pixels at |
| 61 | +# 11.5px. These are measured off a rendered probe rather than assumed, because |
| 62 | +# a budget counted in characters cannot tell an uppercase line from a |
| 63 | +# lowercase one: capitals run about a quarter wider, so a row of verdict |
| 64 | +# tokens fits a character count and still draws off the edge of the page. |
| 65 | +# |
| 66 | +# The weights round up. The drawing ships to readers whose machine resolves a |
| 67 | +# different face than the one measured, so a line that stops a little short is |
| 68 | +# a smaller defect than one that runs past the rule. |
| 69 | +UPPER, LOWER, DIGIT, SPACE, NARROW = 7.1, 5.75, 6.3, 3.2, 2.7 |
| 70 | +_NARROW = frozenset(".,;:'!|") |
| 71 | + |
| 72 | + |
| 73 | +def _advance(char: str) -> float: |
| 74 | + if char == " ": |
| 75 | + return SPACE |
| 76 | + if char.isupper(): |
| 77 | + return UPPER |
| 78 | + if char.islower(): |
| 79 | + return LOWER |
| 80 | + if char.isdigit(): |
| 81 | + return DIGIT |
| 82 | + return NARROW if char in _NARROW else LOWER |
| 83 | + |
| 84 | + |
| 85 | +def text_width(text: str) -> float: |
| 86 | + """What a line of note or footnote prose draws, in pixels.""" |
| 87 | + return sum(_advance(char) for char in text) |
| 88 | + |
| 89 | + |
| 90 | +# One line of the note column, in pixels. Two lines fit the row. |
| 91 | +NOTE_BUDGET = NOTE_W |
| 92 | +NOTE_LINES = 2 |
| 93 | + |
| 94 | +# The footnote runs the width of the page at the same size, so it holds more. |
| 95 | +FOOT_BUDGET = W - PAD * 2 |
| 96 | +FOOT_LINES = 3 |
| 97 | + |
| 98 | + |
| 99 | +def _wrap(text: str, width: float = NOTE_BUDGET, |
| 100 | + limit: int = NOTE_LINES) -> list[str]: |
| 101 | + """Greedy wrap by drawn width, cut to the lines the caller has room for.""" |
| 102 | + lines: list[str] = [] |
| 103 | + line = "" |
| 104 | + for word in text.split(): |
| 105 | + candidate = f"{line} {word}".strip() |
| 106 | + if text_width(candidate) > width and line: |
| 107 | + lines.append(line) |
| 108 | + line = word |
| 109 | + else: |
| 110 | + line = candidate |
| 111 | + if line: |
| 112 | + lines.append(line) |
| 113 | + return lines[:limit] |
| 114 | + |
| 115 | + |
| 116 | +def _row_y(index: int) -> float: |
| 117 | + return TOP + index * ROW_H |
| 118 | + |
| 119 | + |
| 120 | +def _row(index: int, field: dict) -> str: |
| 121 | + """One field: its name, what comes back in it, and how to check it.""" |
| 122 | + y = _row_y(index) |
| 123 | + tone = TONE[field.get("tone", "none")] |
| 124 | + accent = field.get("tone", "none") != "none" |
| 125 | + notes = "".join( |
| 126 | + f'<text class="s" x="{_num(NOTE_X)}" y="{_num(y + 20 + i * 15)}">' |
| 127 | + f"{_esc(line)}</text>" |
| 128 | + for i, line in enumerate(_wrap(field["note"]))) |
| 129 | + rule = (f'<rect x="{_num(PAD)}" y="{_num(y)}" width="3" ' |
| 130 | + f'height="{ROW_H - 8}" fill="{tone}"/>') if accent else "" |
| 131 | + return (f'<g><rect class="row" x="{_num(PAD)}" y="{_num(y)}" ' |
| 132 | + f'width="{W - PAD * 2}" height="{ROW_H - 8}" rx="3"/>{rule}' |
| 133 | + f'<text class="key" x="{_num(PAD + 16)}" y="{_num(y + 24)}" ' |
| 134 | + f'font-family="{MONO}">{_esc(field["key"])}</text>' |
| 135 | + f'<text class="val" x="{_num(PAD + KEY_W + GUTTER)}" ' |
| 136 | + f'y="{_num(y + 24)}" font-family="{MONO}"' |
| 137 | + f'{f" style={chr(34)}fill:{tone}{chr(34)}" if accent else ""}>' |
| 138 | + f'{_esc(field["value"])}</text>{notes}</g>') |
| 139 | + |
| 140 | + |
| 141 | +def _column_heads(labels: tuple[str, str, str] = HEADS) -> str: |
| 142 | + columns = (PAD + 16, PAD + KEY_W + GUTTER, NOTE_X) |
| 143 | + return "".join( |
| 144 | + f'<text class="k" x="{_num(x)}" y="{_num(TOP - 14)}" ' |
| 145 | + f'font-family="{MONO}">{_esc(label.upper())}</text>' |
| 146 | + for label, x in zip(labels, columns)) |
| 147 | + |
| 148 | + |
| 149 | +def _footnote(text: str, top: float) -> str: |
| 150 | + return "".join( |
| 151 | + f'<text class="s" x="{PAD}" y="{_num(top + i * 16)}">{_esc(line)}</text>' |
| 152 | + for i, line in enumerate(_wrap(text, FOOT_BUDGET, FOOT_LINES))) |
| 153 | + |
| 154 | + |
| 155 | +def card_svg(spec: dict) -> str: |
| 156 | + """A receipt drawn field by field, with the source that produced it.""" |
| 157 | + fields = spec["fields"] |
| 158 | + foot = _wrap(spec["footnote"], FOOT_BUDGET, FOOT_LINES) |
| 159 | + rule = _row_y(len(fields)) + 12 |
| 160 | + height = rule + 22 + len(foot) * 16 |
| 161 | + rows = "".join(_row(i, f) for i, f in enumerate(fields)) |
| 162 | + return ( |
| 163 | + f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {W} {_num(height)}" ' |
| 164 | + f'width="{W}" height="{_num(height)}" font-family="{GROTESK}" role="img" ' |
| 165 | + f'aria-label="{_esc(spec["alt"])}">' |
| 166 | + f"<style>{STYLE}</style>" |
| 167 | + f'<rect class="bg" width="{W}" height="{_num(height)}"/>' |
| 168 | + f'<text class="k" x="{PAD}" y="40" font-family="{MONO}">' |
| 169 | + f'{_esc(spec["kicker"].upper())}</text>' |
| 170 | + f'<text class="h" x="{PAD}" y="72">{_esc(spec["title"])}</text>' |
| 171 | + f'<text class="s" x="{PAD}" y="94" font-family="{MONO}" ' |
| 172 | + f'font-size="11.5">$ {_esc(spec["source"])}</text>' |
| 173 | + f'{_column_heads(tuple(spec.get("heads", HEADS)))}{rows}' |
| 174 | + f'<path class="thin" d="M{PAD} {_num(rule)}H{W - PAD}"/>' |
| 175 | + f'{_footnote(spec["footnote"], rule + 22)}' |
| 176 | + "</svg>") |
0 commit comments