safe-design 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- safe_design/__init__.py +41 -0
- safe_design/backends/__init__.py +5 -0
- safe_design/backends/css.py +58 -0
- safe_design/backends/curses_backend.py +103 -0
- safe_design/backends/textual.py +36 -0
- safe_design/color.py +34 -0
- safe_design/text.py +56 -0
- safe_design/tokens.py +235 -0
- safe_design-0.0.1.dist-info/METADATA +102 -0
- safe_design-0.0.1.dist-info/RECORD +12 -0
- safe_design-0.0.1.dist-info/WHEEL +4 -0
- safe_design-0.0.1.dist-info/licenses/LICENSE +21 -0
safe_design/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""safe-design — one token set, many render targets.
|
|
2
|
+
|
|
3
|
+
from safe_design import GROVE, Skin
|
|
4
|
+
from safe_design.backends import css, textual
|
|
5
|
+
|
|
6
|
+
The palette, the borders, the status glyphs, and stable agent coloring live in
|
|
7
|
+
`safe_design.tokens` and import no render engine. Backends read tokens; tokens
|
|
8
|
+
never read backends.
|
|
9
|
+
|
|
10
|
+
Backends are imported explicitly rather than re-exported here: `curses_backend`
|
|
11
|
+
imports curses, and a web build should not pay for that just by importing the
|
|
12
|
+
package.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from .color import xterm256
|
|
17
|
+
from .text import display_width, truncate
|
|
18
|
+
from .tokens import (
|
|
19
|
+
GROVE,
|
|
20
|
+
TOKEN_NAMES,
|
|
21
|
+
Skin,
|
|
22
|
+
agent_color_index,
|
|
23
|
+
agent_fallback_16,
|
|
24
|
+
agent_slot,
|
|
25
|
+
status_glyph,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"GROVE",
|
|
30
|
+
"Skin",
|
|
31
|
+
"TOKEN_NAMES",
|
|
32
|
+
"agent_color_index",
|
|
33
|
+
"agent_fallback_16",
|
|
34
|
+
"agent_slot",
|
|
35
|
+
"display_width",
|
|
36
|
+
"status_glyph",
|
|
37
|
+
"truncate",
|
|
38
|
+
"xterm256",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
__version__ = "0.0.1"
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""CSS backend — the third render target.
|
|
2
|
+
|
|
3
|
+
This is the one grove/theme.py never had, and the reason the package exists.
|
|
4
|
+
It emits the same tokens as the curses and Textual backends, converted through
|
|
5
|
+
the same `xterm256()`, so a token cannot mean one color in the terminal and a
|
|
6
|
+
different color on the web.
|
|
7
|
+
|
|
8
|
+
Output matches the skin contract the Squirrel already ships: `base.css` holds
|
|
9
|
+
structure and names no color; each skin fills `[data-skin="<name>"]` with
|
|
10
|
+
custom properties. The difference is that these are generated, so a token added
|
|
11
|
+
here appears in every skin at once, and cannot be forgotten in one of them.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from ..color import xterm256
|
|
16
|
+
from ..tokens import GROVE, Skin, TOKEN_NAMES
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _custom_property(token: str) -> str:
|
|
20
|
+
"""`input_bg` -> `--color-input-bg`."""
|
|
21
|
+
return f"--color-{token.replace('_', '-')}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def emit_skin(skin: Skin = GROVE, *, selector: str | None = None) -> str:
|
|
25
|
+
"""Emit one skin as a CSS rule of custom properties.
|
|
26
|
+
|
|
27
|
+
The default selector is `[data-skin="<name>"]`, matching the Squirrel's
|
|
28
|
+
inherited contract. Pass `selector=":root"` for the default skin.
|
|
29
|
+
"""
|
|
30
|
+
sel = selector if selector is not None else f'[data-skin="{skin.name}"]'
|
|
31
|
+
lines = [f"{sel} {{"]
|
|
32
|
+
for token in TOKEN_NAMES:
|
|
33
|
+
lines.append(f" {_custom_property(token)}: {xterm256(skin.palette[token])};")
|
|
34
|
+
for edge, glyph in sorted(skin.borders.items()):
|
|
35
|
+
lines.append(f' --border-{edge}: "{glyph}";')
|
|
36
|
+
for state, glyph in sorted(skin.glyphs.items()):
|
|
37
|
+
lines.append(f' --glyph-{state}: "{glyph}";')
|
|
38
|
+
lines.append("}")
|
|
39
|
+
return "\n".join(lines) + "\n"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def emit_stylesheet(skins: list[Skin], *, default: Skin | None = None) -> str:
|
|
43
|
+
"""Emit `:root` for the default skin plus one rule per named skin."""
|
|
44
|
+
default = default or (skins[0] if skins else GROVE)
|
|
45
|
+
parts = [
|
|
46
|
+
"/* Generated by safe-design. Do not edit by hand.",
|
|
47
|
+
" * Tokens come from safe_design.tokens; colors from safe_design.color.",
|
|
48
|
+
" */",
|
|
49
|
+
"",
|
|
50
|
+
emit_skin(default, selector=":root"),
|
|
51
|
+
]
|
|
52
|
+
parts.extend(emit_skin(s) for s in skins)
|
|
53
|
+
return "\n".join(parts)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def resolve(token: str, skin: Skin = GROVE) -> str:
|
|
57
|
+
"""Hex string for one token under one skin."""
|
|
58
|
+
return xterm256(skin.palette[token])
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""curses backend.
|
|
2
|
+
|
|
3
|
+
Everything from grove/theme.py that actually needed curses, and nothing else.
|
|
4
|
+
`import curses` lives here, not beside the palette, so a web build never pays
|
|
5
|
+
for a terminal library.
|
|
6
|
+
|
|
7
|
+
Behaviour is preserved from the original, including the 16-color degradation:
|
|
8
|
+
below 256 colors every semantic pair falls back to white, and the status glyphs
|
|
9
|
+
carry the meaning instead. Color is never the only signal.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import curses
|
|
14
|
+
|
|
15
|
+
from ..tokens import (
|
|
16
|
+
_AGENT_FALLBACK_16,
|
|
17
|
+
_AGENT_PALETTE,
|
|
18
|
+
GROVE,
|
|
19
|
+
Skin,
|
|
20
|
+
agent_slot,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
_PAIR: dict[str, int] = {
|
|
24
|
+
"primary": 20,
|
|
25
|
+
"secondary": 21,
|
|
26
|
+
"accent": 22,
|
|
27
|
+
"unread": 23,
|
|
28
|
+
"online": 24,
|
|
29
|
+
"idle": 25,
|
|
30
|
+
"busy": 26,
|
|
31
|
+
"healthy": 27,
|
|
32
|
+
"degraded": 28,
|
|
33
|
+
"down": 29,
|
|
34
|
+
"border": 30,
|
|
35
|
+
"input": 31,
|
|
36
|
+
}
|
|
37
|
+
_AGENT_PAIR_BASE = 40
|
|
38
|
+
|
|
39
|
+
_CURSES_16 = {
|
|
40
|
+
"cyan": curses.COLOR_CYAN,
|
|
41
|
+
"magenta": curses.COLOR_MAGENTA,
|
|
42
|
+
"yellow": curses.COLOR_YELLOW,
|
|
43
|
+
"green": curses.COLOR_GREEN,
|
|
44
|
+
"blue": curses.COLOR_BLUE,
|
|
45
|
+
"red": curses.COLOR_RED,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def init_pairs(skin: Skin = GROVE) -> None:
|
|
50
|
+
if not curses.has_colors():
|
|
51
|
+
return
|
|
52
|
+
curses.start_color()
|
|
53
|
+
curses.use_default_colors()
|
|
54
|
+
use256 = curses.COLORS >= 256
|
|
55
|
+
|
|
56
|
+
for token, pair_id in _PAIR.items():
|
|
57
|
+
if token == "input":
|
|
58
|
+
continue
|
|
59
|
+
fg = skin.palette[token] if use256 else curses.COLOR_WHITE
|
|
60
|
+
curses.init_pair(pair_id, fg, -1)
|
|
61
|
+
|
|
62
|
+
curses.init_pair(
|
|
63
|
+
_PAIR["input"],
|
|
64
|
+
skin.palette["primary"] if use256 else curses.COLOR_WHITE,
|
|
65
|
+
skin.palette["input_bg"] if use256 else -1,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
for i, idx in enumerate(_AGENT_PALETTE):
|
|
69
|
+
c = idx if use256 else _CURSES_16[_AGENT_FALLBACK_16[i]]
|
|
70
|
+
curses.init_pair(_AGENT_PAIR_BASE + i, c, -1)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def pair(name: str) -> int:
|
|
74
|
+
return curses.color_pair(_PAIR.get(name, 0))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def agent_pair(name: str) -> int:
|
|
78
|
+
return _AGENT_PAIR_BASE + agent_slot(name)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def safe_addstr(win, y: int, x: int, text: str, attr: int = 0) -> None:
|
|
82
|
+
if win is None:
|
|
83
|
+
return
|
|
84
|
+
h, w = win.getmaxyx()
|
|
85
|
+
if y < 0 or y >= h or x < 0 or x >= w:
|
|
86
|
+
return
|
|
87
|
+
clipped = text[: max(0, w - x)]
|
|
88
|
+
if not clipped:
|
|
89
|
+
return
|
|
90
|
+
try:
|
|
91
|
+
win.addstr(y, x, clipped, attr)
|
|
92
|
+
except curses.error:
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def draw_rounded_box(win, y: int, x: int, h: int, w: int, attr: int = 0,
|
|
97
|
+
skin: Skin = GROVE) -> None:
|
|
98
|
+
b = skin.borders
|
|
99
|
+
safe_addstr(win, y, x, b["tl"] + b["h"] * (w - 2) + b["tr"], attr)
|
|
100
|
+
safe_addstr(win, y + h - 1, x, b["bl"] + b["h"] * (w - 2) + b["br"], attr)
|
|
101
|
+
for row in range(1, h - 1):
|
|
102
|
+
safe_addstr(win, y + row, x, b["v"], attr)
|
|
103
|
+
safe_addstr(win, y + row, x + w - 1, b["v"], attr)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Textual / Rich backend.
|
|
2
|
+
|
|
3
|
+
Replaces grove/theme_textual.py. Two changes from the original:
|
|
4
|
+
|
|
5
|
+
1. It reads public tokens instead of reaching for `grove.theme._C`, so it no
|
|
6
|
+
longer transitively imports curses to look up a dict of integers.
|
|
7
|
+
2. It is skin-aware. The original bound module-level constants at import time;
|
|
8
|
+
these are functions of a skin, because a fleet with a persona picker needs
|
|
9
|
+
more than one palette alive in one process.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from ..color import xterm256
|
|
14
|
+
from ..tokens import GROVE, Skin, agent_color_index
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def color(token: str, skin: Skin = GROVE) -> str:
|
|
18
|
+
"""Hex string for a token, e.g. `color("degraded")` -> `#d78700`."""
|
|
19
|
+
return xterm256(skin.palette[token])
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def agent_color(name: str) -> str:
|
|
23
|
+
"""Hex string for an agent's stable identity color."""
|
|
24
|
+
return xterm256(agent_color_index(name))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def markup_bold_accent(skin: Skin = GROVE) -> str:
|
|
28
|
+
return f"[bold {color('accent', skin)}]"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def markup_dim(skin: Skin = GROVE) -> str:
|
|
32
|
+
return f"[dim {color('secondary', skin)}]"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def markup_status_dot(on: bool, skin: Skin = GROVE) -> str:
|
|
36
|
+
return f"[{color('healthy' if on else 'down', skin)}]{skin.glyphs['online']}[/]"
|
safe_design/color.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""xterm-256 index -> sRGB hex.
|
|
2
|
+
|
|
3
|
+
Every render backend that needs a hex string calls this. Parity between the
|
|
4
|
+
Textual backend and the CSS backend is therefore structural, not maintained
|
|
5
|
+
by hand: they cannot disagree because they do not each convert.
|
|
6
|
+
|
|
7
|
+
Lifted verbatim from grove/theme_textual.py::xterm256.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
_ANSI_16 = (
|
|
12
|
+
"#000000", "#800000", "#008000", "#808000", "#000080", "#800080",
|
|
13
|
+
"#008080", "#c0c0c0", "#808080", "#ff0000", "#00ff00", "#ffff00",
|
|
14
|
+
"#0000ff", "#ff00ff", "#00ffff", "#ffffff",
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def xterm256(n: int) -> str:
|
|
19
|
+
"""Convert an xterm-256 palette index to a `#rrggbb` string."""
|
|
20
|
+
n = int(n)
|
|
21
|
+
if not 0 <= n <= 255:
|
|
22
|
+
raise ValueError(f"xterm-256 index out of range: {n}")
|
|
23
|
+
if n < 16:
|
|
24
|
+
return _ANSI_16[n]
|
|
25
|
+
if n < 232:
|
|
26
|
+
n -= 16
|
|
27
|
+
r, g, b = n // 36, (n // 6) % 6, n % 6
|
|
28
|
+
|
|
29
|
+
def _v(x: int) -> int:
|
|
30
|
+
return 0 if x == 0 else 55 + x * 40
|
|
31
|
+
|
|
32
|
+
return f"#{_v(r):02x}{_v(g):02x}{_v(b):02x}"
|
|
33
|
+
grey = 8 + (n - 232) * 10
|
|
34
|
+
return f"#{grey:02x}{grey:02x}{grey:02x}"
|
safe_design/text.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Cell-width-correct text helpers.
|
|
2
|
+
|
|
3
|
+
The terminal measures cells, not codepoints. `len()` lies about CJK
|
|
4
|
+
ideographs (width 2), combining marks (width 0), and most emoji (width 2).
|
|
5
|
+
grove/theme.py::truncate used `len()`; this is the corrected form.
|
|
6
|
+
|
|
7
|
+
stdlib only — `unicodedata` carries the East Asian Width property.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import unicodedata
|
|
12
|
+
|
|
13
|
+
_WIDE = frozenset(("W", "F"))
|
|
14
|
+
_ELLIPSIS = "..."
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def char_width(ch: str) -> int:
|
|
18
|
+
"""Terminal cells occupied by a single character."""
|
|
19
|
+
if unicodedata.combining(ch):
|
|
20
|
+
return 0
|
|
21
|
+
if unicodedata.category(ch) == "Cf": # format chars: ZWJ, RLM, ...
|
|
22
|
+
return 0
|
|
23
|
+
return 2 if unicodedata.east_asian_width(ch) in _WIDE else 1
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def display_width(text: str) -> int:
|
|
27
|
+
"""Terminal cells occupied by a string."""
|
|
28
|
+
return sum(char_width(c) for c in text)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def truncate(text: str, width: int) -> str:
|
|
32
|
+
"""Truncate `text` to at most `width` terminal cells.
|
|
33
|
+
|
|
34
|
+
Reserves cells for an ellipsis when there is room for one, matching
|
|
35
|
+
grove/theme.py's contract (`width <= 5` truncates hard, no ellipsis).
|
|
36
|
+
Never splits a wide character across the boundary.
|
|
37
|
+
"""
|
|
38
|
+
if width <= 0:
|
|
39
|
+
return ""
|
|
40
|
+
if display_width(text) <= width:
|
|
41
|
+
return text
|
|
42
|
+
if width <= 5:
|
|
43
|
+
return _clip(text, width)
|
|
44
|
+
return _clip(text, width - len(_ELLIPSIS)) + _ELLIPSIS
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _clip(text: str, width: int) -> str:
|
|
48
|
+
out: list[str] = []
|
|
49
|
+
used = 0
|
|
50
|
+
for ch in text:
|
|
51
|
+
w = char_width(ch)
|
|
52
|
+
if used + w > width:
|
|
53
|
+
break
|
|
54
|
+
out.append(ch)
|
|
55
|
+
used += w
|
|
56
|
+
return "".join(out)
|
safe_design/tokens.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Render-neutral design tokens for the SAFE fleet.
|
|
2
|
+
|
|
3
|
+
Lifted from grove/theme.py (b17: WDASH). This module MUST NOT import a render
|
|
4
|
+
engine. curses, Textual, and CSS all consume these tokens; none of them owns
|
|
5
|
+
them. That inversion is the whole point of the package: grove/theme.py imported
|
|
6
|
+
curses at module scope, so every consumer of the palette — including the Textual
|
|
7
|
+
backend, which needs no curses at all — paid for a terminal library to read a
|
|
8
|
+
dict of integers.
|
|
9
|
+
|
|
10
|
+
Token values are xterm-256 palette indices. Two token names may resolve to the
|
|
11
|
+
same index: that is correct. A token names a *meaning*, not a color, and two
|
|
12
|
+
meanings can share a color without being the same token.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from types import MappingProxyType
|
|
19
|
+
from typing import Mapping
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# Base palette — verbatim from grove/theme.py::_C
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
_BASE_PALETTE: dict[str, int] = {
|
|
26
|
+
# surfaces
|
|
27
|
+
"bg": 235,
|
|
28
|
+
"input_bg": 236,
|
|
29
|
+
"border": 238,
|
|
30
|
+
# text
|
|
31
|
+
"primary": 253,
|
|
32
|
+
"secondary": 245,
|
|
33
|
+
"accent": 99,
|
|
34
|
+
"unread": 220,
|
|
35
|
+
# presence
|
|
36
|
+
"online": 77,
|
|
37
|
+
"idle": 243,
|
|
38
|
+
"busy": 214,
|
|
39
|
+
# health
|
|
40
|
+
"healthy": 77,
|
|
41
|
+
"degraded": 214,
|
|
42
|
+
"down": 203,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# Consent tokens (SCDS1 §0.4)
|
|
47
|
+
#
|
|
48
|
+
# The store console spec asks the contract to grow "status-semantic tokens the
|
|
49
|
+
# console needs and genealogy never did (grant / deny / warn / meter-fill)".
|
|
50
|
+
#
|
|
51
|
+
# Three of the four already exist under presence/health names. They are aliased,
|
|
52
|
+
# not invented — a gate that is granted is a thing that is healthy, and giving
|
|
53
|
+
# it a second index would let the two drift apart for no reason. `meter_fill`
|
|
54
|
+
# is the only genuinely new token; it takes `accent`, which is what the dev
|
|
55
|
+
# sketch's sap-flow gauge already drew.
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
#: alias -> canonical token. Resolved at *lookup*, never baked into a palette:
|
|
59
|
+
#: a skin that overrides `healthy` must move `grant` with it, or the two drift
|
|
60
|
+
#: apart and the alias buys nothing. Overriding an alias directly is an error;
|
|
61
|
+
#: override the canonical token it points at.
|
|
62
|
+
ALIASES: Mapping[str, str] = MappingProxyType({
|
|
63
|
+
"grant": "healthy",
|
|
64
|
+
"deny": "down",
|
|
65
|
+
"warn": "degraded",
|
|
66
|
+
"meter_fill": "accent",
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
CANONICAL_TOKENS: tuple[str, ...] = tuple(sorted(_BASE_PALETTE))
|
|
70
|
+
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
# Agent identity colors
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
_AGENT_PALETTE: tuple[int, ...] = (87, 213, 227, 120, 111, 209, 51)
|
|
76
|
+
|
|
77
|
+
#: 16-color fallback, index-aligned with _AGENT_PALETTE. Slots 0 and 6 collide
|
|
78
|
+
#: on cyan; on a 16-color terminal two agents may share a hue. Status glyphs
|
|
79
|
+
#: carry the signal there, per the never-color-alone rule.
|
|
80
|
+
_AGENT_FALLBACK_16: tuple[str, ...] = (
|
|
81
|
+
"cyan", "magenta", "yellow", "green", "blue", "red", "cyan",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
# Chrome
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
_BORDERS: dict[str, str] = {
|
|
89
|
+
"tl": "╭", "tr": "╮", "bl": "╰", "br": "╯", "h": "─", "v": "│",
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
#: ASCII fallback for TERM=dumb, legacy SSH, and Windows conhost.
|
|
93
|
+
_BORDERS_ASCII: dict[str, str] = {
|
|
94
|
+
"tl": "+", "tr": "+", "bl": "+", "br": "+", "h": "-", "v": "|",
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
_STATUS_GLYPHS: dict[str, str] = {
|
|
98
|
+
"online": "●", "idle": "○", "busy": "◐", "unknown": "·",
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
_STATUS_GLYPHS_ASCII: dict[str, str] = {
|
|
102
|
+
"online": "*", "idle": "o", "busy": "+", "unknown": ".",
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
# Skin
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
class _Palette(Mapping):
|
|
111
|
+
"""Canonical token values, with aliases resolved on every lookup.
|
|
112
|
+
|
|
113
|
+
An alias can never drift from the token it names, because it is not stored.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
__slots__ = ("_canonical",)
|
|
117
|
+
|
|
118
|
+
def __init__(self, canonical: Mapping[str, int]) -> None:
|
|
119
|
+
self._canonical = dict(canonical)
|
|
120
|
+
|
|
121
|
+
def __getitem__(self, token: str) -> int:
|
|
122
|
+
return self._canonical[ALIASES.get(token, token)]
|
|
123
|
+
|
|
124
|
+
def __iter__(self):
|
|
125
|
+
return iter(tuple(self._canonical) + tuple(ALIASES))
|
|
126
|
+
|
|
127
|
+
def __len__(self) -> int:
|
|
128
|
+
return len(self._canonical) + len(ALIASES)
|
|
129
|
+
|
|
130
|
+
def canonical(self) -> Mapping[str, int]:
|
|
131
|
+
return MappingProxyType(dict(self._canonical))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass(frozen=True)
|
|
135
|
+
class Skin:
|
|
136
|
+
"""A named set of token values.
|
|
137
|
+
|
|
138
|
+
A skin does not add or remove tokens — it supplies values for the ones that
|
|
139
|
+
exist. That is what makes a skin swappable: every consumer can rely on every
|
|
140
|
+
token resolving, in every skin, forever.
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
name: str
|
|
144
|
+
palette: Mapping[str, int]
|
|
145
|
+
borders: Mapping[str, str]
|
|
146
|
+
glyphs: Mapping[str, str]
|
|
147
|
+
|
|
148
|
+
def __post_init__(self) -> None:
|
|
149
|
+
supplied = self.palette
|
|
150
|
+
if isinstance(supplied, _Palette):
|
|
151
|
+
supplied = supplied.canonical()
|
|
152
|
+
missing = set(CANONICAL_TOKENS) - set(supplied)
|
|
153
|
+
if missing:
|
|
154
|
+
raise ValueError(
|
|
155
|
+
f"skin {self.name!r} is missing tokens: {sorted(missing)}"
|
|
156
|
+
)
|
|
157
|
+
aliased = set(supplied) & set(ALIASES)
|
|
158
|
+
if aliased:
|
|
159
|
+
raise ValueError(
|
|
160
|
+
f"skin {self.name!r} sets alias tokens directly: {sorted(aliased)}. "
|
|
161
|
+
f"Set the canonical token instead ("
|
|
162
|
+
f"{', '.join(f'{a} -> {ALIASES[a]}' for a in sorted(aliased))})."
|
|
163
|
+
)
|
|
164
|
+
object.__setattr__(self, "palette", _Palette(supplied))
|
|
165
|
+
object.__setattr__(self, "borders", MappingProxyType(dict(self.borders)))
|
|
166
|
+
object.__setattr__(self, "glyphs", MappingProxyType(dict(self.glyphs)))
|
|
167
|
+
|
|
168
|
+
def derive(self, name: str, **overrides: int) -> "Skin":
|
|
169
|
+
"""A new skin, same structure, some token values replaced.
|
|
170
|
+
|
|
171
|
+
Overriding a canonical token moves every alias that points at it.
|
|
172
|
+
"""
|
|
173
|
+
unknown = set(overrides) - set(CANONICAL_TOKENS) - set(ALIASES)
|
|
174
|
+
if unknown:
|
|
175
|
+
raise ValueError(
|
|
176
|
+
f"skin {name!r} defines tokens that do not exist: {sorted(unknown)}. "
|
|
177
|
+
"A skin fills the contract; it does not extend it."
|
|
178
|
+
)
|
|
179
|
+
aliased = set(overrides) & set(ALIASES)
|
|
180
|
+
if aliased:
|
|
181
|
+
raise ValueError(
|
|
182
|
+
f"skin {name!r} overrides alias tokens: {sorted(aliased)}. "
|
|
183
|
+
f"Override the canonical token instead ("
|
|
184
|
+
f"{', '.join(f'{a} -> {ALIASES[a]}' for a in sorted(aliased))})."
|
|
185
|
+
)
|
|
186
|
+
return Skin(
|
|
187
|
+
name=name,
|
|
188
|
+
palette={**self.palette.canonical(), **overrides},
|
|
189
|
+
borders=self.borders,
|
|
190
|
+
glyphs=self.glyphs,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def ascii(self) -> "Skin":
|
|
194
|
+
"""The same skin with ASCII chrome, for terminals without box drawing."""
|
|
195
|
+
return Skin(
|
|
196
|
+
name=f"{self.name}-ascii",
|
|
197
|
+
palette=self.palette,
|
|
198
|
+
borders=_BORDERS_ASCII,
|
|
199
|
+
glyphs=_STATUS_GLYPHS_ASCII,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
#: The palette Grove has shipped since April. Every other skin derives from it.
|
|
204
|
+
GROVE = Skin(
|
|
205
|
+
name="grove",
|
|
206
|
+
palette=_BASE_PALETTE,
|
|
207
|
+
borders=_BORDERS,
|
|
208
|
+
glyphs=_STATUS_GLYPHS,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
#: Every token a consumer may resolve — canonical tokens plus their aliases.
|
|
212
|
+
TOKEN_NAMES: tuple[str, ...] = tuple(sorted(set(CANONICAL_TOKENS) | set(ALIASES)))
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# ---------------------------------------------------------------------------
|
|
216
|
+
# Helpers — render-neutral, no curses
|
|
217
|
+
# ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
def status_glyph(state: str, skin: Skin = GROVE) -> str:
|
|
220
|
+
return skin.glyphs.get(state, skin.glyphs["unknown"])
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def agent_slot(name: str) -> int:
|
|
224
|
+
"""Stable slot for an agent name. Same agent, same color, forever."""
|
|
225
|
+
return int(hashlib.md5(name.encode()).hexdigest(), 16) % len(_AGENT_PALETTE)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def agent_color_index(name: str) -> int:
|
|
229
|
+
"""xterm-256 index for an agent's identity color."""
|
|
230
|
+
return _AGENT_PALETTE[agent_slot(name)]
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def agent_fallback_16(name: str) -> str:
|
|
234
|
+
"""Named 16-color fallback for an agent, for terminals below 256 colors."""
|
|
235
|
+
return _AGENT_FALLBACK_16[agent_slot(name)]
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: safe-design
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: One token set, many render targets — the SAFE fleet's shared design layer.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# safe-design
|
|
13
|
+
|
|
14
|
+
One token set, many render targets — the SAFE fleet's shared design layer.
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from safe_design import GROVE, Skin
|
|
18
|
+
from safe_design.backends import css, textual
|
|
19
|
+
|
|
20
|
+
textual.color("degraded") # '#ffaf00'
|
|
21
|
+
css.resolve("degraded") # '#ffaf00' — same token, same color, by construction
|
|
22
|
+
css.emit_skin(GROVE, selector=":root")
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Why it exists
|
|
26
|
+
|
|
27
|
+
The palette was already right. It lived in `grove/theme.py`, which imported `curses`
|
|
28
|
+
at module scope — so `grove/theme_textual.py` had to reach across a module boundary
|
|
29
|
+
for a private name (`from grove.theme import _C`) and pay for a terminal library to
|
|
30
|
+
read a dict of integers. Nothing outside Grove could import it at all.
|
|
31
|
+
|
|
32
|
+
So twenty-one other terminal UIs in `safe-app-store` re-derived it by hand, and the
|
|
33
|
+
fleet's own `tui-design` skill lists *"hardcoded colors clashing with user themes"*
|
|
34
|
+
as pitfall #1 while `story-timeline/app.py` carries sixty of them.
|
|
35
|
+
|
|
36
|
+
Tokens do not import render engines. Render engines import tokens.
|
|
37
|
+
|
|
38
|
+
## Layout
|
|
39
|
+
|
|
40
|
+
| Module | Imports | Role |
|
|
41
|
+
|---|---|---|
|
|
42
|
+
| `tokens.py` | stdlib | the palette, borders, glyphs, agent coloring |
|
|
43
|
+
| `color.py` | stdlib | `xterm256()` — the single index→hex converter |
|
|
44
|
+
| `text.py` | stdlib | cell-width-correct `truncate()` |
|
|
45
|
+
| `backends/curses_backend.py` | `curses` | the only module that imports curses |
|
|
46
|
+
| `backends/textual.py` | stdlib | Textual / Rich colors and markup |
|
|
47
|
+
| `backends/css.py` | stdlib | CSS custom properties, per skin |
|
|
48
|
+
|
|
49
|
+
## Parity is structural
|
|
50
|
+
|
|
51
|
+
Both the Textual and CSS backends resolve a token through the same `xterm256()`.
|
|
52
|
+
They cannot disagree, because neither one converts. `test_css_and_textual_agree_on_every_token`
|
|
53
|
+
exists to catch anyone who breaks that.
|
|
54
|
+
|
|
55
|
+
## Tokens
|
|
56
|
+
|
|
57
|
+
Base palette lifted verbatim from `grove/theme.py`: `bg`, `input_bg`, `border`,
|
|
58
|
+
`primary`, `secondary`, `accent`, `unread`, `online`, `idle`, `busy`, `healthy`,
|
|
59
|
+
`degraded`, `down`.
|
|
60
|
+
|
|
61
|
+
SCDS1 §0.4 asks the contract for `grant` / `deny` / `warn` / `meter-fill`. Three of
|
|
62
|
+
those already existed under health names, so they are **aliases**, not new colors:
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
grant -> healthy deny -> down warn -> degraded meter_fill -> accent
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Aliases resolve at **lookup**, never at definition. A skin that repaints `healthy`
|
|
69
|
+
moves `grant` with it. Setting an alias directly is refused:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
GROVE.derive("bbs", grant=10)
|
|
73
|
+
# ValueError: skin 'bbs' overrides alias tokens: ['grant'].
|
|
74
|
+
# Override the canonical token instead (grant -> healthy).
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Skins
|
|
78
|
+
|
|
79
|
+
A skin fills the contract; it never extends it. Every token resolves in every skin,
|
|
80
|
+
so a consumer can rely on `--color-warn` existing forever.
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
bbs = GROVE.derive("bbs", bg=0, primary=15, accent=14, healthy=10, down=9)
|
|
84
|
+
css.emit_skin(bbs) # [data-skin="bbs"] { --color-bg: #000000; ... }
|
|
85
|
+
GROVE.ascii() # same colors, +-| chrome, for TERM=dumb
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Agent identity colors
|
|
89
|
+
|
|
90
|
+
Hash-derived and stable. The same agent gets the same color forever, and no one
|
|
91
|
+
assigns it.
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
textual.agent_color("willow") # '#87ff87'
|
|
95
|
+
textual.agent_color("hanuman") # '#5fffff'
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Tests
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
python3 -m pytest tests/ -q
|
|
102
|
+
```
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
safe_design/__init__.py,sha256=YUtV8MJ4p1nr3EJ_2RM_P7iIJmbZYP_ZH2CTXDPj85Y,969
|
|
2
|
+
safe_design/color.py,sha256=KYCC5_QHQ60rvYzGQDk8L5DpZ4wghg6NZ4eYmK4oG20,1082
|
|
3
|
+
safe_design/text.py,sha256=kCDconb_90K2a5FEeIqujTbTpYrppZiA68RNQYoqwuM,1610
|
|
4
|
+
safe_design/tokens.py,sha256=ytbAD5vQwF-tesqHuIn3F7FUE9QOBDHy69v_QlJ1-Vs,8529
|
|
5
|
+
safe_design/backends/__init__.py,sha256=HLASuUv8iaCodJjZIk5XVeT7_pXPMTiLsUMRxp75Irg,137
|
|
6
|
+
safe_design/backends/css.py,sha256=k5ech4P3tFkM-ptJ6HBJhO90WTWylBJLYM0WpebGhiA,2314
|
|
7
|
+
safe_design/backends/curses_backend.py,sha256=LZr_ybNzlOcBq6eJBGvOLjbwAc4F3oN7VEXU2kSrD8E,2798
|
|
8
|
+
safe_design/backends/textual.py,sha256=ok9W-Up2fcYfAH0VHNPzC7JZnNKiOHN_4l-_dyUhGnU,1208
|
|
9
|
+
safe_design-0.0.1.dist-info/METADATA,sha256=HBdeDPnnAOmzaUBc-zntigupUbTvFRm53fieT8skX1k,3474
|
|
10
|
+
safe_design-0.0.1.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
11
|
+
safe_design-0.0.1.dist-info/licenses/LICENSE,sha256=TtvyaPs0viFQ5xtHtmBX1VmXWu9y4lUwYisVENkOUh0,1070
|
|
12
|
+
safe_design-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sean Campbell
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|