safe-design 0.0.1__tar.gz

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,66 @@
1
+ name: release
2
+
3
+ # Publishes to PyPI via Trusted Publishing (OIDC). There is no PYPI_API_TOKEN
4
+ # secret in this repo, and there should never be one: GitHub mints a short-lived
5
+ # token per run, scoped to this workflow in this repository. Nothing long-lived
6
+ # exists to leak.
7
+ #
8
+ # One-time setup on pypi.org -> Your projects -> Publishing -> Add a pending publisher:
9
+ # PyPI project name: safe-design
10
+ # Owner: rudi193-cmd
11
+ # Repository: safe-design
12
+ # Workflow name: release.yml
13
+ # Environment: (leave blank)
14
+ #
15
+ # Then push a tag: git tag v0.0.1 && git push origin v0.0.1
16
+
17
+ on:
18
+ push:
19
+ tags: ["v*"]
20
+ workflow_dispatch:
21
+
22
+ jobs:
23
+ test:
24
+ runs-on: ubuntu-latest
25
+ steps:
26
+ - uses: actions/checkout@v4
27
+ - uses: actions/setup-python@v5
28
+ with:
29
+ python-version: "3.12"
30
+ - run: python -m pip install -e ".[dev]"
31
+ - run: python -m pytest tests/ -q
32
+
33
+ build:
34
+ needs: test
35
+ runs-on: ubuntu-latest
36
+ steps:
37
+ - uses: actions/checkout@v4
38
+ - uses: actions/setup-python@v5
39
+ with:
40
+ python-version: "3.12"
41
+ - name: Build sdist and wheel
42
+ run: |
43
+ python -m pip install --upgrade build
44
+ python -m build
45
+ - name: Check metadata
46
+ run: |
47
+ python -m pip install --upgrade twine
48
+ python -m twine check dist/*
49
+ - uses: actions/upload-artifact@v4
50
+ with:
51
+ name: dist
52
+ path: dist/
53
+
54
+ publish:
55
+ needs: build
56
+ runs-on: ubuntu-latest
57
+ # No `environment:` — the pending publisher on PyPI is configured with the
58
+ # environment field blank, and the OIDC claim must match it exactly.
59
+ permissions:
60
+ id-token: write # OIDC — this is the entire credential story
61
+ steps:
62
+ - uses: actions/download-artifact@v4
63
+ with:
64
+ name: dist
65
+ path: dist/
66
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,33 @@
1
+ name: test
2
+
3
+ on:
4
+ push:
5
+ branches: [master]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ matrix:
13
+ python-version: ["3.10", "3.12", "3.13"]
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: actions/setup-python@v5
17
+ with:
18
+ python-version: ${{ matrix.python-version }}
19
+ - name: Install
20
+ run: python -m pip install -e ".[dev]"
21
+ - name: Test
22
+ run: python -m pytest tests/ -q
23
+ - name: Tokens must not import a render engine
24
+ run: |
25
+ python - <<'EOF'
26
+ import sys
27
+ import safe_design.tokens, safe_design.color, safe_design.text
28
+ import safe_design.backends.css, safe_design.backends.textual
29
+ leaked = {"curses", "textual", "rich"} & set(sys.modules)
30
+ if leaked:
31
+ raise SystemExit(f"render engine leaked into the token layer: {sorted(leaked)}")
32
+ print("token layer is render-neutral")
33
+ EOF
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .pytest_cache/
4
+ .venv/
5
+ venv/
6
+ build/
7
+ dist/
8
+ *.egg-info/
@@ -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.
@@ -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,91 @@
1
+ # safe-design
2
+
3
+ One token set, many render targets — the SAFE fleet's shared design layer.
4
+
5
+ ```python
6
+ from safe_design import GROVE, Skin
7
+ from safe_design.backends import css, textual
8
+
9
+ textual.color("degraded") # '#ffaf00'
10
+ css.resolve("degraded") # '#ffaf00' — same token, same color, by construction
11
+ css.emit_skin(GROVE, selector=":root")
12
+ ```
13
+
14
+ ## Why it exists
15
+
16
+ The palette was already right. It lived in `grove/theme.py`, which imported `curses`
17
+ at module scope — so `grove/theme_textual.py` had to reach across a module boundary
18
+ for a private name (`from grove.theme import _C`) and pay for a terminal library to
19
+ read a dict of integers. Nothing outside Grove could import it at all.
20
+
21
+ So twenty-one other terminal UIs in `safe-app-store` re-derived it by hand, and the
22
+ fleet's own `tui-design` skill lists *"hardcoded colors clashing with user themes"*
23
+ as pitfall #1 while `story-timeline/app.py` carries sixty of them.
24
+
25
+ Tokens do not import render engines. Render engines import tokens.
26
+
27
+ ## Layout
28
+
29
+ | Module | Imports | Role |
30
+ |---|---|---|
31
+ | `tokens.py` | stdlib | the palette, borders, glyphs, agent coloring |
32
+ | `color.py` | stdlib | `xterm256()` — the single index→hex converter |
33
+ | `text.py` | stdlib | cell-width-correct `truncate()` |
34
+ | `backends/curses_backend.py` | `curses` | the only module that imports curses |
35
+ | `backends/textual.py` | stdlib | Textual / Rich colors and markup |
36
+ | `backends/css.py` | stdlib | CSS custom properties, per skin |
37
+
38
+ ## Parity is structural
39
+
40
+ Both the Textual and CSS backends resolve a token through the same `xterm256()`.
41
+ They cannot disagree, because neither one converts. `test_css_and_textual_agree_on_every_token`
42
+ exists to catch anyone who breaks that.
43
+
44
+ ## Tokens
45
+
46
+ Base palette lifted verbatim from `grove/theme.py`: `bg`, `input_bg`, `border`,
47
+ `primary`, `secondary`, `accent`, `unread`, `online`, `idle`, `busy`, `healthy`,
48
+ `degraded`, `down`.
49
+
50
+ SCDS1 §0.4 asks the contract for `grant` / `deny` / `warn` / `meter-fill`. Three of
51
+ those already existed under health names, so they are **aliases**, not new colors:
52
+
53
+ ```
54
+ grant -> healthy deny -> down warn -> degraded meter_fill -> accent
55
+ ```
56
+
57
+ Aliases resolve at **lookup**, never at definition. A skin that repaints `healthy`
58
+ moves `grant` with it. Setting an alias directly is refused:
59
+
60
+ ```python
61
+ GROVE.derive("bbs", grant=10)
62
+ # ValueError: skin 'bbs' overrides alias tokens: ['grant'].
63
+ # Override the canonical token instead (grant -> healthy).
64
+ ```
65
+
66
+ ## Skins
67
+
68
+ A skin fills the contract; it never extends it. Every token resolves in every skin,
69
+ so a consumer can rely on `--color-warn` existing forever.
70
+
71
+ ```python
72
+ bbs = GROVE.derive("bbs", bg=0, primary=15, accent=14, healthy=10, down=9)
73
+ css.emit_skin(bbs) # [data-skin="bbs"] { --color-bg: #000000; ... }
74
+ GROVE.ascii() # same colors, +-| chrome, for TERM=dumb
75
+ ```
76
+
77
+ ## Agent identity colors
78
+
79
+ Hash-derived and stable. The same agent gets the same color forever, and no one
80
+ assigns it.
81
+
82
+ ```python
83
+ textual.agent_color("willow") # '#87ff87'
84
+ textual.agent_color("hanuman") # '#5fffff'
85
+ ```
86
+
87
+ ## Tests
88
+
89
+ ```
90
+ python3 -m pytest tests/ -q
91
+ ```
@@ -0,0 +1,18 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "safe-design"
7
+ version = "0.0.1"
8
+ description = "One token set, many render targets — the SAFE fleet's shared design layer."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ dependencies = []
13
+
14
+ [project.optional-dependencies]
15
+ dev = ["pytest>=8"]
16
+
17
+ [tool.hatch.build.targets.wheel]
18
+ packages = ["src/safe_design"]
@@ -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']}[/]"
@@ -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}"
@@ -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)
@@ -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,177 @@
1
+ """safe-design test suite.
2
+
3
+ Run: python3 -m pytest tests/ -q
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import pytest
8
+
9
+ from safe_design import GROVE, agent_color_index, display_width, truncate, xterm256
10
+ from safe_design.backends import css, textual
11
+ from safe_design.tokens import TOKEN_NAMES, Skin
12
+
13
+
14
+ # --------------------------------------------------------------------------
15
+ # Parity — the reason the package exists
16
+ # --------------------------------------------------------------------------
17
+
18
+ def test_css_and_textual_agree_on_every_token():
19
+ """A token cannot mean one color in the terminal and another on the web."""
20
+ for token in TOKEN_NAMES:
21
+ assert css.resolve(token, GROVE) == textual.color(token, GROVE), token
22
+
23
+
24
+ def test_every_token_reaches_the_stylesheet():
25
+ sheet = css.emit_skin(GROVE)
26
+ for token in TOKEN_NAMES:
27
+ assert f"--color-{token.replace('_', '-')}:" in sheet, token
28
+
29
+
30
+ # --------------------------------------------------------------------------
31
+ # Values lifted from grove/theme.py must not drift
32
+ # --------------------------------------------------------------------------
33
+
34
+ @pytest.mark.parametrize("token,index", [
35
+ ("bg", 235), ("border", 238), ("secondary", 245), ("primary", 253),
36
+ ("accent", 99), ("unread", 220), ("online", 77), ("idle", 243),
37
+ ("busy", 214), ("healthy", 77), ("degraded", 214), ("down", 203),
38
+ ("input_bg", 236),
39
+ ])
40
+ def test_base_palette_matches_grove(token, index):
41
+ assert GROVE.palette[token] == index
42
+
43
+
44
+ def test_xterm256_known_conversions():
45
+ assert xterm256(0) == "#000000"
46
+ assert xterm256(15) == "#ffffff"
47
+ assert xterm256(232) == "#080808"
48
+ assert xterm256(255) == "#eeeeee"
49
+
50
+
51
+ def test_xterm256_rejects_out_of_range():
52
+ with pytest.raises(ValueError):
53
+ xterm256(256)
54
+
55
+
56
+ # --------------------------------------------------------------------------
57
+ # Consent tokens (SCDS1 §0.4) are aliases, not new colors
58
+ # --------------------------------------------------------------------------
59
+
60
+ def test_consent_tokens_alias_existing_health_tokens():
61
+ assert GROVE.palette["grant"] == GROVE.palette["healthy"]
62
+ assert GROVE.palette["deny"] == GROVE.palette["down"]
63
+ assert GROVE.palette["warn"] == GROVE.palette["degraded"]
64
+ assert GROVE.palette["meter_fill"] == GROVE.palette["accent"]
65
+
66
+
67
+ def test_aliases_follow_the_canonical_token_through_a_derive():
68
+ """The bug this package was written to prevent, asserted.
69
+
70
+ A skin that repaints `healthy` must move `grant` with it. Resolving aliases
71
+ at definition time silently left `grant` on the parent skin's green.
72
+ """
73
+ bbs = GROVE.derive("bbs", healthy=10, down=9, degraded=11, accent=14)
74
+ assert bbs.palette["grant"] == bbs.palette["healthy"] == 10
75
+ assert bbs.palette["deny"] == bbs.palette["down"] == 9
76
+ assert bbs.palette["warn"] == bbs.palette["degraded"] == 11
77
+ assert bbs.palette["meter_fill"] == bbs.palette["accent"] == 14
78
+
79
+
80
+ def test_aliases_stay_in_sync_in_emitted_css():
81
+ sheet = css.emit_skin(GROVE.derive("bbs", healthy=10))
82
+ assert "--color-healthy: #00ff00;" in sheet
83
+ assert "--color-grant: #00ff00;" in sheet
84
+
85
+
86
+ def test_overriding_an_alias_directly_is_refused():
87
+ with pytest.raises(ValueError, match="overrides alias tokens"):
88
+ GROVE.derive("bad", grant=10)
89
+
90
+
91
+ # --------------------------------------------------------------------------
92
+ # Agent identity coloring is stable
93
+ # --------------------------------------------------------------------------
94
+
95
+ def test_agent_color_is_stable_across_calls():
96
+ assert agent_color_index("hanuman") == agent_color_index("hanuman")
97
+
98
+
99
+ def test_agent_color_index_is_in_the_palette():
100
+ for name in ("willow", "hanuman", "loki", "skirnir", "jeles", "ada"):
101
+ assert agent_color_index(name) in (87, 213, 227, 120, 111, 209, 51)
102
+
103
+
104
+ # --------------------------------------------------------------------------
105
+ # Skin contract: a skin fills tokens, it never invents them
106
+ # --------------------------------------------------------------------------
107
+
108
+ def test_derive_overrides_a_token():
109
+ dos = GROVE.derive("dos", bg=17, accent=11)
110
+ assert dos.palette["bg"] == 17
111
+ assert dos.palette["accent"] == 11
112
+ assert dos.palette["primary"] == GROVE.palette["primary"]
113
+
114
+
115
+ def test_derive_rejects_unknown_tokens():
116
+ with pytest.raises(ValueError, match="do not exist"):
117
+ GROVE.derive("bad", chartreuse=42)
118
+
119
+
120
+ def test_skin_must_supply_every_token():
121
+ with pytest.raises(ValueError, match="missing tokens"):
122
+ Skin(name="partial", palette={"bg": 0}, borders=GROVE.borders, glyphs=GROVE.glyphs)
123
+
124
+
125
+ def test_skin_palette_is_immutable():
126
+ with pytest.raises(TypeError):
127
+ GROVE.palette["bg"] = 1 # type: ignore[index]
128
+
129
+
130
+ def test_ascii_skin_swaps_chrome_not_color():
131
+ a = GROVE.ascii()
132
+ assert a.borders["tl"] == "+"
133
+ assert a.glyphs["online"] == "*"
134
+ assert a.palette["accent"] == GROVE.palette["accent"]
135
+
136
+
137
+ def test_derived_skin_emits_its_own_selector():
138
+ sheet = css.emit_skin(GROVE.derive("bbs", bg=0))
139
+ assert '[data-skin="bbs"]' in sheet
140
+ assert "--color-bg: #000000;" in sheet
141
+
142
+
143
+ def test_stylesheet_has_root_plus_named_skins():
144
+ sheet = css.emit_stylesheet([GROVE.derive("dos", bg=17)], default=GROVE)
145
+ assert ":root {" in sheet
146
+ assert '[data-skin="dos"]' in sheet
147
+
148
+
149
+ # --------------------------------------------------------------------------
150
+ # Cell width — grove/theme.py::truncate used len(); this is the fix
151
+ # --------------------------------------------------------------------------
152
+
153
+ def test_display_width_counts_cells_not_codepoints():
154
+ assert display_width("abc") == 3
155
+ assert display_width("日本語") == 6 # wide: 2 cells each
156
+ assert display_width("é") == 1 # e + combining acute
157
+
158
+
159
+ def test_truncate_never_exceeds_the_cell_budget():
160
+ assert display_width(truncate("日本語テキスト", 8)) <= 8
161
+
162
+
163
+ def test_truncate_does_not_split_a_wide_character():
164
+ # budget 7 -> "..." (3) + 4 cells -> two wide chars, not two-and-a-half
165
+ out = truncate("日本語テキスト", 7)
166
+ assert out.endswith("...")
167
+ assert display_width(out) <= 7
168
+
169
+
170
+ def test_truncate_preserves_grove_contract():
171
+ assert truncate("abc", 10) == "abc"
172
+ assert truncate("abcdefgh", 5) == "abcde" # width <= 5: hard clip
173
+ assert truncate("abcdefgh", 6) == "abc..."
174
+
175
+
176
+ def test_truncate_zero_width():
177
+ assert truncate("abc", 0) == ""