Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
414 changes: 414 additions & 0 deletions docs/superpowers/plans/2026-06-16-dl-1-design-token-layer.md

Large diffs are not rendered by default.

199 changes: 199 additions & 0 deletions docs/superpowers/specs/2026-06-15-pmoves-unified-design-language.md

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions pmoves/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3921,3 +3921,11 @@ overlay-up-apps: ## Start application services via overlay
overlay-down: ## Stop all overlay services
@$(OVERLAY_DC) $(OVERLAY_CORE) $(OVERLAY_AGENTS) $(OVERLAY_MEDIA) $(OVERLAY_UI) $(OVERLAY_WORKERS) $(OVERLAY_APPS) $(OVERLAY_PROFILES_FULL) down
@echo "✅ All overlay services stopped"

design-tokens: ## Regenerate pmoves/design token CSS + TS from agent_signatures.yaml
cd design && uv run --no-project --with pyyaml python generate.py

design-tokens-check: design-tokens ## Fail if committed pmoves/design/build drifted from generator output
@git diff --exit-code -- design/build \
|| { echo "❌ pmoves/design/build drifted — run 'make -C pmoves design-tokens' and commit"; exit 1; }
@echo "✅ design/build matches generator output"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
5 changes: 5 additions & 0 deletions pmoves/design/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# DL-1: the generated token build is intentionally committed so consumers
# (A2UI, Tailwind, preview.html) need no build step. Override the repo-root
# `build/` ignore for this directory only.
!build/
!build/**
76 changes: 76 additions & 0 deletions pmoves/design/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# PMOVES Design Token Layer (DL-1)

A build-time **design token layer** for PMOVES.AI. It reads the canonical persona
registry, merges it with structural base tokens and per-theme overlays, and emits
CSS custom properties + a typed TS token map plus a CSP-clean preview page.

DL-1 is the **token layer** (Model A in the A/B/C plan). It is intentionally
framework-free: stdlib Python + PyYAML for the generator, vanilla JS/CSS/HTML for
the runtime and preview.

## The registry contract (source of truth)

The accent colors come from **`pmoves/config/agent_signatures.yaml`** — the canonical
agent/persona signature registry. DL-1 **reads it read-only**; it never modifies the
registry or its schema.

- Top-level key: `signatures` → `{ <agent-id>: { color, accent, ... } }`.
- A theme references an agent **by id** (e.g. `claude-opus`, `4090-claude`, `darkxside`).
The generator resolves that id to the agent's `color` (primary/secondary) and
`accent` (the soft variant).
- If the registry's hex values change, the emitted tokens change on the next
`make design-tokens` run — values are sourced at build time, never hand-copied.
(The test fixtures in `tests/test_generate.py` assert the current registry values;
update them if the registry changes.)

## How to regenerate

```bash
make -C pmoves design-tokens
# or, from this directory:
uv run --no-project --with pyyaml python generate.py
```

This writes `build/tokens.<theme>.css` and `build/tokens.ts`. The `build/` output is
**committed** so consumers (Notebook UI, A2UI, Tailwind, the CF site) need no build
step. A scoped `.gitignore` here re-includes `build/` (the repo root ignores `build/`).

## Themes

| Theme | File | Lead accent | Notes |
|-------|------|-------------|-------|
| `pmoves-armor` (default) | `themes/pmoves-armor.json` | `claude-opus` violet | Cool armor; also matches bare `:root`. |
| `darkxside-skin` | `themes/darkxside-skin.json` | `darkxside` crimson | Warm persona skin. |

## Token naming

All custom properties are namespaced `--pm-*`:

- Accents: `--pm-accent`, `--pm-accent-soft`, `--pm-accent-2`, `--pm-signature`
- Surfaces: `--pm-bg`, `--pm-bg-tint`, `--pm-surface`, `--pm-void`, `--pm-void-elevated`
- Borders: `--pm-border-subtle`, `--pm-border-strong`
- Ink: `--pm-ink`, `--pm-ink-dim`, `--pm-ink-mute`, `--pm-ink-inverse`
- Structure: `--pm-radius`, `--pm-space-{xs,sm,md,lg,xl}`
- Type: `--pm-font-display`, `--pm-font-body`, `--pm-font-mono`

## Runtime + preview

- `theme-provider.js` — dependency-free `setTheme` / `currentTheme` / `toggleTheme`.
DL-3 will extend it with `setPersona()` resolving via the BoTZ Gateway.
- `preview.html` + `preview.css` + `preview.js` — self-contained, **CSP-clean**
showcase (no inline style/script bodies, no CDN). Serve and open:
`uv run python -m http.server 8799` → `http://localhost:8799/preview.html`.

## A/B/C model

- **A (shipped here):** token layer + generator + ThemeProvider + proof surface.
- **B (DL-2):** roll tokens into Notebook (`pmoves/ui`) + A2UI `ProvenancePalette` + CF site.
- **C (DL-3):** persona-adaptive runtime (`theme-provider.setPersona` → gateway).

## Credits — W1 theme lane (4090-claude)

The persona signature registry (`agent_signatures.yaml`) is owned and maintained by
the **W1 theme lane (4090-claude, PRs #1065/#1101)**. DL-1 consumes it with
**additive reads only** and does not touch any W1-owned file
(`pmoves/tools/agent_terminal_theme.py`, `pmoves/tools/botz_cli.py`,
`pmoves/services/botz-gateway/main.py`, or the registry itself).
27 changes: 27 additions & 0 deletions pmoves/design/build/tokens.darkxside-skin.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/* generated by pmoves/design/generate.py — do not edit */
:root[data-theme="darkxside-skin"] {
--pm-accent: #E11D48;
--pm-accent-2: #7C3AED;
--pm-accent-soft: #FB7185;
--pm-bg: #0a0608;
--pm-bg-tint: #140a0e;
--pm-border-strong: rgba(255,255,255,0.15);
--pm-border-subtle: rgba(255,255,255,0.08);
--pm-font-body: 'Exo 2', system-ui, sans-serif;
--pm-font-display: Orbitron, 'Exo 2', system-ui, sans-serif;
Comment thread
POWERFULMOVES marked this conversation as resolved.
Resolved
--pm-font-mono: 'JetBrains Mono', ui-monospace, monospace;
--pm-ink: #f8f8f8;
--pm-ink-dim: #a0a0a8;
--pm-ink-inverse: #050508;
--pm-ink-mute: #606068;
--pm-radius: 14px;
--pm-signature: #E11D48;
--pm-space-lg: 24px;
--pm-space-md: 16px;
--pm-space-sm: 8px;
--pm-space-xl: 40px;
--pm-space-xs: 4px;
--pm-surface: #12121a;
--pm-void: #050508;
--pm-void-elevated: #0a0a0f;
}
27 changes: 27 additions & 0 deletions pmoves/design/build/tokens.pmoves-armor.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/* generated by pmoves/design/generate.py — do not edit */
:root[data-theme="pmoves-armor"], :root:not([data-theme]) {
--pm-accent: #7C3AED;
--pm-accent-2: #0D9488;
--pm-accent-soft: #A78BFA;
--pm-bg: #050508;
--pm-bg-tint: #0a0a12;
--pm-border-strong: rgba(255,255,255,0.15);
--pm-border-subtle: rgba(255,255,255,0.08);
--pm-font-body: 'Exo 2', system-ui, sans-serif;
--pm-font-display: Orbitron, 'Exo 2', system-ui, sans-serif;
Comment thread
POWERFULMOVES marked this conversation as resolved.
Resolved
--pm-font-mono: 'JetBrains Mono', ui-monospace, monospace;
--pm-ink: #f8f8f8;
--pm-ink-dim: #a0a0a8;
--pm-ink-inverse: #050508;
--pm-ink-mute: #606068;
--pm-radius: 14px;
--pm-signature: #E11D48;
--pm-space-lg: 24px;
--pm-space-md: 16px;
--pm-space-sm: 8px;
--pm-space-xl: 40px;
--pm-space-xs: 4px;
--pm-surface: #12121a;
--pm-void: #050508;
--pm-void-elevated: #0a0a0f;
}
56 changes: 56 additions & 0 deletions pmoves/design/build/tokens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// generated by pmoves/design/generate.py — do not edit
export const themes = {
"darkxside-skin": {
"--pm-accent": "#E11D48",
"--pm-accent-2": "#7C3AED",
"--pm-accent-soft": "#FB7185",
"--pm-bg": "#0a0608",
"--pm-bg-tint": "#140a0e",
"--pm-border-strong": "rgba(255,255,255,0.15)",
"--pm-border-subtle": "rgba(255,255,255,0.08)",
"--pm-font-body": "'Exo 2', system-ui, sans-serif",
"--pm-font-display": "Orbitron, 'Exo 2', system-ui, sans-serif",
"--pm-font-mono": "'JetBrains Mono', ui-monospace, monospace",
"--pm-ink": "#f8f8f8",
"--pm-ink-dim": "#a0a0a8",
"--pm-ink-inverse": "#050508",
"--pm-ink-mute": "#606068",
"--pm-radius": "14px",
"--pm-signature": "#E11D48",
"--pm-space-lg": "24px",
"--pm-space-md": "16px",
"--pm-space-sm": "8px",
"--pm-space-xl": "40px",
"--pm-space-xs": "4px",
"--pm-surface": "#12121a",
"--pm-void": "#050508",
"--pm-void-elevated": "#0a0a0f"
},
"pmoves-armor": {
"--pm-accent": "#7C3AED",
"--pm-accent-2": "#0D9488",
"--pm-accent-soft": "#A78BFA",
"--pm-bg": "#050508",
"--pm-bg-tint": "#0a0a12",
"--pm-border-strong": "rgba(255,255,255,0.15)",
"--pm-border-subtle": "rgba(255,255,255,0.08)",
"--pm-font-body": "'Exo 2', system-ui, sans-serif",
"--pm-font-display": "Orbitron, 'Exo 2', system-ui, sans-serif",
"--pm-font-mono": "'JetBrains Mono', ui-monospace, monospace",
"--pm-ink": "#f8f8f8",
"--pm-ink-dim": "#a0a0a8",
"--pm-ink-inverse": "#050508",
"--pm-ink-mute": "#606068",
"--pm-radius": "14px",
"--pm-signature": "#E11D48",
"--pm-space-lg": "24px",
"--pm-space-md": "16px",
"--pm-space-sm": "8px",
"--pm-space-xl": "40px",
"--pm-space-xs": "4px",
"--pm-surface": "#12121a",
"--pm-void": "#050508",
"--pm-void-elevated": "#0a0a0f"
}
} as const;
export type ThemeName = keyof typeof themes;
96 changes: 96 additions & 0 deletions pmoves/design/generate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# pmoves/design/generate.py
"""DL-1 design token generator. Reads pmoves/config/agent_signatures.yaml
(registry owned by the W1 theme lane — 4090-claude PRs #1065/#1101; do NOT
modify the registry schema here) + base/theme JSON, emits CSS + TS tokens."""
from __future__ import annotations
import json, pathlib, re
DESIGN = pathlib.Path(__file__).resolve().parent
REPO = DESIGN.parents[1]
REGISTRY = REPO / "pmoves" / "config" / "agent_signatures.yaml"

# Reject values that would break out of a CSS declaration or the generated TS
# string map. The registry is trusted, but a typo (stray ; } " or newline) would
# otherwise produce broken build/ output that still commits clean — a silent
# failure. Single quotes/commas/parens are allowed (font stacks, rgba()).
_UNSAFE = re.compile(r'[;{}"\n\r]')

def load_registry() -> dict:
import yaml
data = yaml.safe_load(REGISTRY.read_text(encoding="utf-8")) or {}
return data.get("signatures", {})

def _agent(reg: dict, agent_id: str) -> dict:
if agent_id not in reg:
raise KeyError(f"theme references unknown agent id: {agent_id!r}")
return reg[agent_id]

def _field(agent: dict, field: str, agent_id: str) -> str:
if field not in agent:
raise KeyError(f"agent {agent_id!r} missing required theme field {field!r}")
return agent[field]

def _safe(key: str, val: str) -> str:
if not isinstance(val, str) or _UNSAFE.search(val):
raise ValueError(f"unsafe/invalid token value for {key!r}: {val!r}")
return val

def resolve_theme(theme: dict, reg: dict) -> dict:
base = json.loads((DESIGN / "tokens.base.json").read_text(encoding="utf-8"))
v: dict[str, str] = {}
# structural base
for k, val in base["color"].items():
v[f"--pm-{k}"] = val
v["--pm-radius"] = base["radius"]["lg"]
for k, val in base["space"].items():
v[f"--pm-space-{k}"] = val
v["--pm-font-display"] = base["font"]["display"]
v["--pm-font-body"] = base["font"]["body"]
v["--pm-font-mono"] = base["font"]["mono"]
# registry-sourced accents
acc = theme["accents"]
primary = _agent(reg, acc["primary"]); secondary = _agent(reg, acc["secondary"]); sig = _agent(reg, acc["signature"])
v["--pm-accent"] = _field(primary, "color", acc["primary"])
v["--pm-accent-soft"] = _field(primary, "accent", acc["primary"])
v["--pm-accent-2"] = _field(secondary, "color", acc["secondary"])
v["--pm-signature"] = _field(sig, "color", acc["signature"])
# theme overrides last
for k, val in theme.get("overrides", {}).get("color", {}).items():
v[f"--pm-{k}"] = val
# guard every emitted value against CSS/TS injection (silent-failure trap)
return {k: _safe(k, val) for k, val in v.items()}

DEFAULT_THEME = "pmoves-armor"

def emit_css(theme: dict, vars_: dict) -> str:
name = theme["name"]
sel = f'[data-theme="{name}"]'
if name == DEFAULT_THEME:
sel += ", :root:not([data-theme])"
lines = [f"/* generated by pmoves/design/generate.py — do not edit */",
f":root{sel} {{"]
for k in sorted(vars_):
lines.append(f" {k}: {vars_[k]};")
lines.append("}")
return "\n".join(lines) + "\n"

def emit_ts(themes: dict) -> str:
body = json.dumps(themes, indent=2, sort_keys=True)
return ("// generated by pmoves/design/generate.py — do not edit\n"
f"export const themes = {body} as const;\n"
"export type ThemeName = keyof typeof themes;\n")

def main() -> int:
reg = load_registry()
out = DESIGN / "build"; out.mkdir(exist_ok=True)
resolved = {}
for tf in sorted((DESIGN / "themes").glob("*.json")):
theme = json.loads(tf.read_text(encoding="utf-8"))
vars_ = resolve_theme(theme, reg)
resolved[theme["name"]] = vars_
(out / f"tokens.{theme['name']}.css").write_text(emit_css(theme, vars_), encoding="utf-8")
(out / "tokens.ts").write_text(emit_ts(resolved), encoding="utf-8")
print(f"generated {len(resolved)} themes -> {out}")
return 0

if __name__ == "__main__":
raise SystemExit(main())
12 changes: 12 additions & 0 deletions pmoves/design/preview.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/* pmoves/design/preview.css */
body { background: var(--pm-bg); color: var(--pm-ink); font-family: var(--pm-font-body); margin: 0; padding: var(--pm-space-xl); }
h1 { font-family: var(--pm-font-display); }
.cta { background: linear-gradient(135deg, var(--pm-accent), var(--pm-accent-2)); color: #fff; border: 0; padding: 10px 16px; border-radius: var(--pm-radius); cursor: pointer; }
.swatches { display: flex; gap: 12px; margin: 24px 0; }
.sw { width: 120px; height: 64px; border-radius: var(--pm-radius); display: flex; align-items: end; padding: 6px; font-size: 11px; color: #fff; border: 1px solid var(--pm-border-subtle); }
.sw[data-var="--pm-accent"]{background:var(--pm-accent)} .sw[data-var="--pm-accent-2"]{background:var(--pm-accent-2)}
.sw[data-var="--pm-signature"]{background:var(--pm-signature)} .sw[data-var="--pm-surface"]{background:var(--pm-surface)}
.motifs{display:flex;gap:20px;align-items:center}
.shard{width:0;height:0;border-left:14px solid transparent;border-right:14px solid transparent;border-bottom:24px solid var(--pm-accent-2)}
.hex{width:26px;height:23px;background:var(--pm-surface);border:1px solid var(--pm-accent);clip-path:polygon(25% 0,75% 0,100% 50%,75% 100%,25% 100%,0 50%)}
.star{font-size:28px;color:var(--pm-signature)}
24 changes: 24 additions & 0 deletions pmoves/design/preview.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en" data-theme="pmoves-armor">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>PMOVES Design Tokens — Preview</title>
<link rel="stylesheet" href="./build/tokens.pmoves-armor.css">
<link rel="stylesheet" href="./build/tokens.darkxside-skin.css">
<link rel="stylesheet" href="./preview.css">
</head>
<body>
<header><h1>PMOVES Design Tokens</h1>
<button id="toggle" class="cta">Toggle armor / skin</button></header>
<section class="swatches">
<span class="sw" data-var="--pm-accent">accent</span>
<span class="sw" data-var="--pm-accent-2">accent-2</span>
<span class="sw" data-var="--pm-signature">✦ signature</span>
<span class="sw" data-var="--pm-surface">surface</span>
</section>
<section class="motifs">
<span class="shard"></span><span class="hex"></span><span class="star">✦</span>
</section>
<script type="module" src="./preview.js"></script>
</body>
</html>
3 changes: 3 additions & 0 deletions pmoves/design/preview.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// pmoves/design/preview.js
import { toggleTheme } from "./theme-provider.js";
document.getElementById("toggle").addEventListener("click", () => toggleTheme());
Loading
Loading