File size: 9,240 Bytes
2cf7040
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9432271
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2cf7040
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9432271
 
2cf7040
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9432271
2cf7040
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
"""Shared visual identity for the sibyl CLI surface.

Sister module to `_banner.py`. Where the banner is the identity-reveal
moment for `sibyl init`, this module supplies the granular building
blocks every subcommand uses to share one coherent look:

  - 24-bit-truecolor β†’ 256-color β†’ plain-text degradation cascade
  - Brand palette derived from the lab creme paper face (rule 46)
  - Letter-spaced eyebrow labels, gradient titles, ASCII rule dividers
  - Key/value rows, status chips, success/warn/error glyphs
  - Pulsing accents for live states (activation, upgrade, watching)

Voice constraint: precise, editorial, restrained. Gradients flow over
2–3 stops max. No rainbow. The terminal is paper.
"""
from __future__ import annotations

import os
import sys
from typing import Iterable

# ─── Palette (RGB Β· derived from rule 46 creme-paper tokens) ─────────
# Names map 1:1 to CSS custom properties on lab artifacts.

PAPER       = (245, 241, 230)   # --paper        β€” foreground accent on dark
PAPER_DEEP  = (237, 230, 211)   # --paper-deep   β€” depth on creme
CARD        = (253, 251, 245)   # --card         β€” slightly lifted creme
INK         = (21,  17,  10)    # --ink          β€” main text on creme
INK_SOFT    = (44,  39,  29)    # --ink-soft     β€” body text
INK_MUTE    = (106, 99,  86)    # --ink-mute     β€” secondary text
INK_FAINT   = (152, 145, 127)   # --ink-faint    β€” tertiary text
RULE        = (216, 208, 187)   # --rule         β€” hairline
RULE_STRONG = (184, 174, 147)   # --rule-strong  β€” emphasised hairline
ACCENT      = (138, 106, 42)    # --accent       β€” ochre highlight
ACCENT_WARM = (160, 132, 56)    # --accent-warm  β€” softer ochre
ACCENT_GOLD = (224, 194, 119)   # mid gold       β€” gradient bridge
ACCENT_PALE = (244, 229, 184)   # pale gold      β€” gradient top
JADE        = (45,  110, 106)   # --jade         β€” cool counterpoint
PULSE       = (29,  138, 130)   # --pulse        β€” brighter jade (live signal)
ERROR       = (162, 58,  42)    # --error        β€” measured red

# Status glyphs (Unicode, terminal-safe in modern fonts)
GLYPH_OK    = "βœ“"
GLYPH_WARN  = "⚠"
GLYPH_ERR   = "βœ—"
GLYPH_DOT   = "Β·"
GLYPH_ARROW = "β†’"
GLYPH_BULLET = "β–Έ"


# ─── Terminal capability detection ────────────────────────────────────

def supports_truecolor() -> bool:
    """24-bit RGB ANSI. Same heuristic as _banner.py."""
    if os.environ.get("NO_COLOR"):
        return False
    if os.environ.get("TERM", "").lower() == "dumb":
        return False
    # SIBYL_FORCE_COLOR=1 β€” explicit override for non-tty rendering
    # (CI logs, doc captures, dev inspection inside the Claude harness).
    if os.environ.get("SIBYL_FORCE_COLOR") == "1":
        return True
    if not sys.stdout.isatty():
        return False
    colorterm = os.environ.get("COLORTERM", "").lower()
    if "truecolor" in colorterm or "24bit" in colorterm:
        return True
    term_program = os.environ.get("TERM_PROGRAM", "").lower()
    if term_program in {"iterm.app", "wezterm", "ghostty", "vscode", "tabby"}:
        return True
    term = os.environ.get("TERM", "").lower()
    if any(k in term for k in ("256color", "kitty", "alacritty", "xterm-direct")):
        return True
    return False


def supports_color() -> bool:
    """Any color at all (3/4-bit fallback)."""
    if os.environ.get("NO_COLOR"):
        return False
    if os.environ.get("TERM", "").lower() == "dumb":
        return False
    if os.environ.get("SIBYL_FORCE_COLOR") == "1":
        return True
    return sys.stdout.isatty()


_TC = supports_truecolor()
_C  = supports_color()
RESET = "\033[0m" if _C else ""


def rgb(r: int, g: int, b: int) -> str:
    """24-bit foreground escape (no-op if color disabled)."""
    if not _TC:
        return ""
    return f"\033[38;2;{r};{g};{b}m"


def rgb_bg(r: int, g: int, b: int) -> str:
    if not _TC:
        return ""
    return f"\033[48;2;{r};{g};{b}m"


def color(text: str, c: tuple[int, int, int]) -> str:
    if not _TC:
        return text
    return f"{rgb(*c)}{text}{RESET}"


# ─── Gradient Β· char-by-char RGB interpolation ────────────────────────

def _interp(a: int, b: int, t: float) -> int:
    return round(a + (b - a) * t)


def gradient(text: str, *stops: tuple[int, int, int]) -> str:
    """Color a string with a gradient across N stops, one char at a time.

    Plain-text fallback: returns the input unchanged when color is off.
    Whitespace is preserved (uncolored to keep terminals consistent).
    """
    if not _TC or len(stops) < 2 or not text:
        return text
    out = []
    chars = list(text)
    # Distribute char index across stop segments
    n = max(1, len(chars) - 1)
    segs = len(stops) - 1
    for i, ch in enumerate(chars):
        if ch == " ":
            out.append(ch)
            continue
        seg_f = (i / n) * segs
        seg_i = min(int(seg_f), segs - 1)
        t = seg_f - seg_i
        a = stops[seg_i]
        b = stops[seg_i + 1]
        r = _interp(a[0], b[0], t)
        g = _interp(a[1], b[1], t)
        bb = _interp(a[2], b[2], t)
        out.append(f"\033[38;2;{r};{g};{bb}m{ch}")
    return "".join(out) + RESET


def gradient_gold(text: str) -> str:
    """Pale-gold β†’ deep-ochre flow. The brand's headline gradient."""
    return gradient(text, ACCENT_PALE, ACCENT_GOLD, ACCENT)


def gradient_jade(text: str) -> str:
    """Pulse β†’ jade. Used for success states + live indicators."""
    return gradient(text, PULSE, JADE)


# ─── Style primitives ─────────────────────────────────────────────────

def dim(s: str) -> str:
    return color(s, INK_FAINT)


def muted(s: str) -> str:
    return color(s, INK_MUTE)


def soft(s: str) -> str:
    return color(s, INK_SOFT)


def ink(s: str) -> str:
    return color(s, INK)


def ok(s: str) -> str:
    return color(s, PULSE)


def warn(s: str) -> str:
    return color(s, ACCENT_WARM)


def err(s: str) -> str:
    return color(s, ERROR)


def accent(s: str) -> str:
    return color(s, ACCENT)


def bold(s: str) -> str:
    if not _C:
        return s
    return f"\033[1m{s}{RESET}"


# ─── Composite primitives ─────────────────────────────────────────────

def eyebrow(label: str) -> str:
    """Uppercase letter-spaced ochre label. Editorial section marker."""
    spaced = " ".join(label.upper())
    return color(spaced, ACCENT)


def divider(width: int = 60, *, glyph: str = "─") -> str:
    """Creme-paper rule line."""
    return color(glyph * width, RULE)


def section_header(name: str, *, subtitle: str | None = None, width: int = 60) -> str:
    """The standard subcommand opener.

       ─ <name> ────────────────────────────────────────
       <subtitle, dim>
    """
    name_part = f" {gradient_gold(name)} "
    # Stripped-color length for visible width calc
    visible_name_len = len(f" {name} ")
    rule_left = "─"
    rule_right = "─" * max(3, width - 1 - visible_name_len)
    head = color(rule_left, RULE) + name_part + color(rule_right, RULE)
    if subtitle:
        return head + "\n" + dim(subtitle)
    return head


def chip(text: str, *, palette: str = "accent") -> str:
    """Compact inline label Β· [text]."""
    palettes = {
        "accent": ACCENT,
        "jade":   PULSE,
        "warn":   ACCENT_WARM,
        "error":  ERROR,
        "mute":   INK_MUTE,
    }
    c = palettes.get(palette, ACCENT)
    return color(f"[{text}]", c)


def kv(label: str, value: str, *, label_width: int = 16, value_color: str = "ink") -> str:
    """One left-aligned label / value row.

    Used across status / whoami / devices for the LOCAL / SERVER blocks.
    """
    palettes = {
        "ink": INK, "soft": INK_SOFT, "mute": INK_MUTE, "faint": INK_FAINT,
        "accent": ACCENT, "ok": PULSE, "warn": ACCENT_WARM, "err": ERROR,
    }
    val_color = palettes.get(value_color, INK_SOFT)
    return f"  {color(label.ljust(label_width), INK_FAINT)} {color(value, val_color)}"


def block_title(text: str) -> str:
    """Sub-section title within a command output. Like 'LOCAL' or 'SERVER'."""
    return "\n" + eyebrow(text)


def success_line(text: str) -> str:
    """Single-line success marker with gradient + glyph."""
    return f"  {ok(GLYPH_OK)} {gradient_jade(text)}"


def warn_line(text: str) -> str:
    return f"  {warn(GLYPH_WARN)} {warn(text)}"


def err_line(text: str) -> str:
    return f"  {err(GLYPH_ERR)} {err(text)}"


def hr_caption(caption: str, *, width: int = 60) -> str:
    """Caption line under a divider β€” small, muted, centered."""
    pad = max(0, (width - len(caption)) // 2)
    return " " * pad + dim(caption)


def footer_credits(*, width: int = 60) -> str:
    """Bottom-of-output line. Used at end of long outputs."""
    return color("─" * width, RULE) + "\n" + dim("  sibyl labs Β· memory you can hold in your hand")