Metadata-Version: 2.4
Name: safe-design
Version: 0.0.1
Summary: One token set, many render targets — the SAFE fleet's shared design layer.
License-Expression: MIT
License-File: LICENSE
Requires-Python: >=3.10
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == 'dev'
Description-Content-Type: text/markdown

# safe-design

One token set, many render targets — the SAFE fleet's shared design layer.

```python
from safe_design import GROVE, Skin
from safe_design.backends import css, textual

textual.color("degraded")        # '#ffaf00'
css.resolve("degraded")          # '#ffaf00'   — same token, same color, by construction
css.emit_skin(GROVE, selector=":root")
```

## Why it exists

The palette was already right. It lived in `grove/theme.py`, which imported `curses`
at module scope — so `grove/theme_textual.py` had to reach across a module boundary
for a private name (`from grove.theme import _C`) and pay for a terminal library to
read a dict of integers. Nothing outside Grove could import it at all.

So twenty-one other terminal UIs in `safe-app-store` re-derived it by hand, and the
fleet's own `tui-design` skill lists *"hardcoded colors clashing with user themes"*
as pitfall #1 while `story-timeline/app.py` carries sixty of them.

Tokens do not import render engines. Render engines import tokens.

## Layout

| Module | Imports | Role |
|---|---|---|
| `tokens.py` | stdlib | the palette, borders, glyphs, agent coloring |
| `color.py` | stdlib | `xterm256()` — the single index→hex converter |
| `text.py` | stdlib | cell-width-correct `truncate()` |
| `backends/curses_backend.py` | `curses` | the only module that imports curses |
| `backends/textual.py` | stdlib | Textual / Rich colors and markup |
| `backends/css.py` | stdlib | CSS custom properties, per skin |

## Parity is structural

Both the Textual and CSS backends resolve a token through the same `xterm256()`.
They cannot disagree, because neither one converts. `test_css_and_textual_agree_on_every_token`
exists to catch anyone who breaks that.

## Tokens

Base palette lifted verbatim from `grove/theme.py`: `bg`, `input_bg`, `border`,
`primary`, `secondary`, `accent`, `unread`, `online`, `idle`, `busy`, `healthy`,
`degraded`, `down`.

SCDS1 §0.4 asks the contract for `grant` / `deny` / `warn` / `meter-fill`. Three of
those already existed under health names, so they are **aliases**, not new colors:

```
grant -> healthy    deny -> down    warn -> degraded    meter_fill -> accent
```

Aliases resolve at **lookup**, never at definition. A skin that repaints `healthy`
moves `grant` with it. Setting an alias directly is refused:

```python
GROVE.derive("bbs", grant=10)
# ValueError: skin 'bbs' overrides alias tokens: ['grant'].
#             Override the canonical token instead (grant -> healthy).
```

## Skins

A skin fills the contract; it never extends it. Every token resolves in every skin,
so a consumer can rely on `--color-warn` existing forever.

```python
bbs = GROVE.derive("bbs", bg=0, primary=15, accent=14, healthy=10, down=9)
css.emit_skin(bbs)          # [data-skin="bbs"] { --color-bg: #000000; ... }
GROVE.ascii()               # same colors, +-| chrome, for TERM=dumb
```

## Agent identity colors

Hash-derived and stable. The same agent gets the same color forever, and no one
assigns it.

```python
textual.agent_color("willow")   # '#87ff87'
textual.agent_color("hanuman")  # '#5fffff'
```

## Tests

```
python3 -m pytest tests/ -q
```
