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.
@@ -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,5 @@
1
+ """Render backends.
2
+
3
+ Import the one you need. `curses_backend` imports curses; `css` and `textual`
4
+ import nothing beyond the stdlib.
5
+ """
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.