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.
- safe_design-0.0.1/.github/workflows/release.yml +66 -0
- safe_design-0.0.1/.github/workflows/tests.yml +33 -0
- safe_design-0.0.1/.gitignore +8 -0
- safe_design-0.0.1/LICENSE +21 -0
- safe_design-0.0.1/PKG-INFO +102 -0
- safe_design-0.0.1/README.md +91 -0
- safe_design-0.0.1/pyproject.toml +18 -0
- safe_design-0.0.1/src/safe_design/__init__.py +41 -0
- safe_design-0.0.1/src/safe_design/backends/__init__.py +5 -0
- safe_design-0.0.1/src/safe_design/backends/css.py +58 -0
- safe_design-0.0.1/src/safe_design/backends/curses_backend.py +103 -0
- safe_design-0.0.1/src/safe_design/backends/textual.py +36 -0
- safe_design-0.0.1/src/safe_design/color.py +34 -0
- safe_design-0.0.1/src/safe_design/text.py +56 -0
- safe_design-0.0.1/src/safe_design/tokens.py +235 -0
- safe_design-0.0.1/tests/test_safe_design.py +177 -0
|
@@ -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,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,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) == ""
|