@bismawy/pi-arnative 0.5.1 → 0.5.2

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.2] - 2026-10-13
4
+
5
+ ### Fixed
6
+ - Restored the footer's two-tone labels. `0.5.1` moved the label colour onto `softOf(accent)`, which softens the *accent* hue, so cwd/branch/clock/status text inherited the accent (pink) instead of the border-accent (blue) tier and the footer collapsed into a single hue (`extensions/footer.ts`).
7
+ - Added `vars.secondarySoft` to all 11 themes, softened from the same value the `borderAccent` token resolves to (L 0.82, chroma × 0.18, hue kept), and `softSecondaryOf` in `lib/ansi.ts` to derive the identical value at runtime — `vars` names are not addressable via `fg()`, and Pi 1.2 rejects custom `colors` keys, so the declared and derived values must be produced by one recipe.
8
+ - Routed the header's model/thinking/token text, tool bodies, list rows and the usage dashboard onto the label tier. Icons, marks and the logo's second tone stay on the accent tier, so each line keeps its two tones (`extensions/section-headers.ts`, `extensions/tools.ts`, `extensions/usage.ts`, `extensions/arnative.ts`, `extensions/ui-render-tweaks.ts`).
9
+
10
+ ### Added
11
+ - `soften()` is now shared by the generator and the runtime, and `extensions/ui-render-tweaks.ts` asserts every declared `*Soft` var equals its derived value — a drifting or rogue soft colour fails `npm test` instead of shipping (`themes/gen-themes.mjs`, `lib/ansi.ts`).
12
+ - Behavioral test pinning the two-tone rule: labels take the border-accent hue, icons keep the accent hue (`test/footer-minimal.test.ts`).
13
+
14
+ ### Notes
15
+ - Label colour is deliberately softer than the pre-0.5.1 `#05dee7`. `SOFT_CHROMA_SCALE` in `lib/ansi.ts` is the single knob: `0.18` gives `#b2c9ca` on cyberpunk, `0.30` gives `#b2dadc`, `0.50` gives `#9adfe3`. Changing it regenerates all 11 themes and the guard forces them to stay in sync.
16
+
3
17
  ## [0.5.1] - 2026-10-13
4
18
 
5
19
  ### Fixed
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
9
9
  import { matchesKey, truncateToWidth, visibleWidth, type AutocompleteItem, type Component } from "@earendil-works/pi-tui";
10
- import { softOf, stripAnsi, thinkingColorOf } from "../lib/ansi.ts";
10
+ import { softSecondaryOf, stripAnsi, thinkingColorOf } from "../lib/ansi.ts";
11
11
  import { renderScreen, screenTitle, type ScreenText } from "../lib/box.ts";
12
12
  import { assert, isMain } from "../lib/check.ts";
13
13
  import { markedLabels } from "../lib/format.ts";
@@ -172,7 +172,7 @@ export class ListPicker implements Component {
172
172
  render(width: number): string[] {
173
173
  const th = this.theme;
174
174
  const bold = th.bold ?? ((t: string) => `\x1b[1m${t}\x1b[22m`);
175
- const soft = softOf(th);
175
+ const soft = softSecondaryOf(th);
176
176
  const edge = (text: string) => this.edge(text);
177
177
  const column = Math.max(0, ...this.rows.map((r) => visibleWidth(r.label)));
178
178
  const body: string[] = [];
@@ -11,7 +11,7 @@ import { execFile } from "node:child_process";
11
11
  import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
12
12
  import { tmpdir } from "node:os";
13
13
  import { dirname, join, resolve } from "node:path";
14
- import { softOf, stripAnsi } from "../lib/ansi.ts";
14
+ import { softOf, softSecondaryOf, stripAnsi } from "../lib/ansi.ts";
15
15
  import { assert, isMain } from "../lib/check.ts";
16
16
  import { capitalize, markedLabels, modelDisplayParts } from "../lib/format.ts";
17
17
  import { IS_WINDOWS_LIKE, SHORTCUT_RELOAD, SHORTCUT_TOGGLE_FOOTER } from "../lib/shortcuts.ts";
@@ -889,9 +889,12 @@ export default function (pi: ExtensionAPI) {
889
889
  const acc = (text: string) => theme.fg("accent", text);
890
890
  const dim = (text: string) => theme.fg("dim", text);
891
891
  const fgAny = theme.fg.bind(theme) as (color: string, text: string) => string;
892
- // `accentSoft` is a vars-only token on Pi 1.2+, so the soft tier is resolved
893
- // through the shared painter instead of a raw fg() probe.
892
+ // `accentSoft`/`secondarySoft` are vars-only tokens on Pi 1.2+, so both soft tiers
893
+ // are resolved through the shared painters instead of a raw fg() probe. Icons keep
894
+ // `accent`; the cwd/branch/clock/status text takes the border-accent tier, so the
895
+ // footer stays two-toned instead of the text inheriting the accent hue.
894
896
  const soft = softOf(theme as never);
897
+ const softLabel = softSecondaryOf(theme as never);
895
898
  // Diff colors: the same theme slots pi uses for a diff body, with the
896
899
  // plain soft color as fallback for a theme that omits them.
897
900
  const colorOr = (name: string, fallback: (t: string) => string) => (t: string) => {
@@ -901,12 +904,10 @@ export default function (pi: ExtensionAPI) {
901
904
  return fallback(t);
902
905
  }
903
906
  };
904
- // `primary`/`secondary` are vars, not tokens (Pi 1.2+ rejects custom `colors`
905
- // keys). Their arnative carriers are the official tokens they map to.
907
+ // `primary` is a var, not a token (Pi 1.2+ rejects custom `colors` keys); its
908
+ // arnative carrier is the official token it maps to.
906
909
  const primary = colorOr("mdHeading", acc);
907
- const primarySoft = soft;
908
- const secondary = colorOr("borderAccent", soft);
909
- const secondarySoft = soft;
910
+ const secondarySoft = softLabel;
910
911
  const diffAdd = colorOr("toolDiffAdded", soft);
911
912
  const diffDel = colorOr("toolDiffRemoved", soft);
912
913
  const sep = dim(" | ");
@@ -1044,7 +1045,7 @@ export default function (pi: ExtensionAPI) {
1044
1045
  try {
1045
1046
  const th = this.getActiveTheme();
1046
1047
  if (!th) return text;
1047
- return softOf(th as never)(text);
1048
+ return softSecondaryOf(th as never)(text);
1048
1049
  } catch {
1049
1050
  return text;
1050
1051
  }
@@ -33,7 +33,7 @@ import {
33
33
  } from "@earendil-works/pi-tui";
34
34
  import type { BoxTheme } from "../lib/box.ts";
35
35
  import { BRAND, renderBoxLines } from "../lib/box.ts";
36
- import { getActiveTheme, setActiveTheme, softOf, stripAnsi, themeOf } from "../lib/ansi.ts";
36
+ import { getActiveTheme, setActiveTheme, softOf, softSecondaryOf, stripAnsi, themeOf } from "../lib/ansi.ts";
37
37
  import { capitalize, expandKeyName, modelDisplayParts } from "../lib/format.ts";
38
38
  import { SHORTCUT_NEW_SESSION, SHORTCUT_NEXT_TAB, SHORTCUT_RELOAD } from "../lib/shortcuts.ts";
39
39
  import { HEADER_PRESET_KEY as HEADER_PRESET_CONFIG_KEY, loadChoice, saveChoice } from "../lib/preset-store.ts";
@@ -79,16 +79,31 @@ export function fgFirst(th: Themeish, names: string[], text: string): string {
79
79
  }
80
80
 
81
81
  /**
82
- * Painter for the header's soft tier. Pi 1.2.0 rejects a custom `accentSoft` key in
83
- * `colors`, so `fgFirst(["accentSoft", …])` silently degrades to plain text there and
84
- * the two-tone logo/header look flattens. `softOf` derives the same tint from the
85
- * resolved accent; a theme that genuinely defines `accentSoft` still wins first.
82
+ * Painter for the header's soft *accent* tier — the logo mark's second tone, paired with
83
+ * the accent `P`. Keeps the mark in one hue family instead of blue.
84
+ *
85
+ * Pi 1.2.0 rejects a custom `accentSoft` key in `colors`, so `fgFirst(["accentSoft", …])`
86
+ * silently degrades to plain text there and the two-tone look flattens. `softOf` derives
87
+ * the same tint from the resolved accent; a theme that genuinely defines `accentSoft`
88
+ * still wins first.
86
89
  */
87
90
  export function softFirst(th: Themeish, fallbacks: string[], text: string): string {
88
- const soft = softOf(th as never);
89
- const out = soft(text);
90
- if (out !== text) return out;
91
- return fgFirst(th, fallbacks, text);
91
+ return softOnTier(softOf(th as never), th, fallbacks, text);
92
+ }
93
+
94
+ /**
95
+ * Painter for the soft *label* tier of header text (model name, thinking level, token
96
+ * counts). Same colour language as the footer: marks take `accent`, their text takes the
97
+ * soft border-accent tier, so a header line and the footer under it agree.
98
+ */
99
+ export function softLabelFirst(th: Themeish, fallbacks: string[], text: string): string {
100
+ return softOnTier(softSecondaryOf(th as never), th, fallbacks, text);
101
+ }
102
+
103
+ /** Shared tail of the two soft painters: use the resolved soft tone, else the fallback tokens. */
104
+ function softOnTier(paint: (s: string) => string, th: Themeish, fallbacks: string[], text: string): string {
105
+ const out = paint(text);
106
+ return out !== text ? out : fgFirst(th, fallbacks, text);
92
107
  }
93
108
 
94
109
  /**
@@ -542,7 +557,7 @@ export class ArnativeHeader implements Component {
542
557
  const isActive = t.key === this.activeTab;
543
558
  const iconStyled = isActive ? fgFirst(th, ["accent"], t.icon) : fgFirst(th, ["dim"], t.icon);
544
559
  const labelStyled = isActive
545
- ? softFirst(th, ["accentSoft", "text"], `\x1b[1m${label}\x1b[22m`)
560
+ ? softLabelFirst(th, ["accentSoft", "text"], `\x1b[1m${label}\x1b[22m`)
546
561
  : fgFirst(th, ["dim"], label);
547
562
  return { key: t.key, plain, formatted: `${iconStyled} ${labelStyled}` };
548
563
  };
@@ -606,19 +621,19 @@ export class ArnativeHeader implements Component {
606
621
  // Model name in soft, its "(Provider)" tail dim.
607
622
  const provSuffix = fullName.match(/(\s*\([^()]*\))$/)?.[1] ?? "";
608
623
  const nameMain = provSuffix ? fullName.slice(0, -provSuffix.length) : fullName;
609
- let line = `${softFirst(th, ["accentSoft", "text"], nameMain)}${provSuffix ? fgFirst(th, ["dim"], provSuffix) : ""}${sep}${softFirst(th, ["accentSoft", "text"], `Thinking: ${thinkLevel}`)}`;
624
+ let line = `${softLabelFirst(th, ["accentSoft", "text"], nameMain)}${provSuffix ? fgFirst(th, ["dim"], provSuffix) : ""}${sep}${softLabelFirst(th, ["accentSoft", "text"], `Thinking: ${thinkLevel}`)}`;
610
625
 
611
626
  // All-time usage of that model (same as /usage): ↑... ↓... ...
612
627
  const usage = getModelAllTimeUsage(modelObj?.id);
613
628
  const tokenParts: string[] = [];
614
629
  if (usage.input > 0) {
615
- tokenParts.push(`${fgFirst(th, ["accent"], "↑")}${softFirst(th, ["accentSoft", "text"], formatTokens(usage.input))}`);
630
+ tokenParts.push(`${fgFirst(th, ["accent"], "↑")}${softLabelFirst(th, ["accentSoft", "text"], formatTokens(usage.input))}`);
616
631
  }
617
632
  if (usage.output > 0) {
618
- tokenParts.push(`${fgFirst(th, ["accent"], "↓")}${softFirst(th, ["accentSoft", "text"], formatTokens(usage.output))}`);
633
+ tokenParts.push(`${fgFirst(th, ["accent"], "↓")}${softLabelFirst(th, ["accentSoft", "text"], formatTokens(usage.output))}`);
619
634
  }
620
635
  if (usage.cacheRead > 0) {
621
- tokenParts.push(`${fgFirst(th, ["accent"], "\uf49b ")}${softFirst(th, ["accentSoft", "text"], formatTokens(usage.cacheRead))}`);
636
+ tokenParts.push(`${fgFirst(th, ["accent"], "\uf49b ")}${softLabelFirst(th, ["accentSoft", "text"], formatTokens(usage.cacheRead))}`);
622
637
  }
623
638
  if (tokenParts.length > 0) {
624
639
  line += `${sep}${tokenParts.join(" ")}`;
@@ -631,11 +646,11 @@ export class ArnativeHeader implements Component {
631
646
 
632
647
  if (this.activeTab === "Directory") {
633
648
  for (const line of wrapPath(getCwd(this.ctx), infoW)) {
634
- infoLines.push(softFirst(th, ["accentSoft", "text"], line));
649
+ infoLines.push(softLabelFirst(th, ["accentSoft", "text"], line));
635
650
  }
636
651
  } else if (this.activeTab === "Shortcut") {
637
652
  for (const line of wrapTokens(SHORTCUTS, infoW)) {
638
- infoLines.push(softFirst(th, ["accentSoft", "text"], line));
653
+ infoLines.push(softLabelFirst(th, ["accentSoft", "text"], line));
639
654
  }
640
655
  } else if (activeItems.length === 0) {
641
656
  infoLines.push(
@@ -647,7 +662,7 @@ export class ArnativeHeader implements Component {
647
662
  } else {
648
663
  for (const line of wrapCommaItems(activeItems, infoW)) {
649
664
  // Point 5: selected menu data uses soft only
650
- infoLines.push(softFirst(th, ["accentSoft", "text"], line));
665
+ infoLines.push(softLabelFirst(th, ["accentSoft", "text"], line));
651
666
  }
652
667
  }
653
668
 
@@ -39,7 +39,7 @@ import {
39
39
  import { Markdown, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
40
40
  import { homedir } from "node:os";
41
41
  import { renderBoxLines as box } from "../lib/box.ts";
42
- import { stripAnsi, softOf } from "../lib/ansi.ts";
42
+ import { stripAnsi, softSecondaryOf } from "../lib/ansi.ts";
43
43
  import { assert, isMain } from "../lib/check.ts";
44
44
  import { expandKeyName } from "../lib/format.ts";
45
45
 
@@ -443,7 +443,7 @@ if (ToolExecutionComponent?.prototype?.render && !(globalThis as Record<symbol,
443
443
  ? safeCall(() => own.call(this, { content: result.content, details: result.details }, opts, th, ctx))
444
444
  : null;
445
445
  return new Lines((width) => {
446
- const soft = softOf(th);
446
+ const soft = softSecondaryOf(th);
447
447
  const inner = Math.max(8, width - 4);
448
448
  const icon = isErr ? th.fg("error", "x") : th.fg("success", "✓");
449
449
  const boxed2 = componentLines(theirs, width - 6);
@@ -606,7 +606,7 @@ function minimal(
606
606
  return new Lines((width) => {
607
607
  // Read context.state live (never capture): renderResult may create it after renderCall.
608
608
  if (context.state?.hasResult) return [];
609
- const soft = softOf(theme);
609
+ const soft = softSecondaryOf(theme);
610
610
  const inner = Math.max(8, width - 4);
611
611
  const rows = titleRow(
612
612
  theme,
@@ -635,7 +635,7 @@ function minimal(
635
635
  const dur = fmtMs(context.toolCallId ? TIMINGS.get(context.toolCallId) : undefined);
636
636
  const icon = isErr ? theme.fg("error", "x") : theme.fg("success", "✓");
637
637
  return new Lines((width) => {
638
- const soft = softOf(theme);
638
+ const soft = softSecondaryOf(theme);
639
639
  const inner = Math.max(8, width - 4);
640
640
  const rows = titleRow(
641
641
  theme,
@@ -39,9 +39,9 @@ import {
39
39
  } from "@earendil-works/pi-tui";
40
40
  import { readdirSync, readFileSync } from "node:fs";
41
41
  import { renderBoxLines } from "../lib/box.ts";
42
- import { ansiBgOpen, getActiveTheme, setActiveTheme, stripAnsi, themeOf, softOf } from "../lib/ansi.ts";
42
+ import { ansiBgOpen, getActiveTheme, setActiveTheme, stripAnsi, themeOf, softSecondaryOf } from "../lib/ansi.ts";
43
43
  import { assert, isMain } from "../lib/check.ts";
44
- import { contrast, parseOklch, toHex } from "../lib/color.ts";
44
+ import { contrast, oklchToRgb, parseOklch, toHex } from "../lib/color.ts";
45
45
  import { expandKeyName } from "../lib/format.ts";
46
46
 
47
47
  type ActiveTheme = { fg(color: string, text: string): string; bg?(color: string, text: string): string } | null;
@@ -174,7 +174,7 @@ const SELECT_ROW_KEY = Symbol.for("pi-arnative.selectRowColors");
174
174
 
175
175
  /** Selected row body: `·` separators accent, the text around them soft. */
176
176
  export function selectRowText(row: string, th: { fg(c: string, t: string): string }): string {
177
- const soft = softOf(th);
177
+ const soft = softSecondaryOf(th);
178
178
  return row
179
179
  .split("·")
180
180
  .map((part) => soft(part))
@@ -396,6 +396,14 @@ if (isMain(import.meta.url)) {
396
396
  // hex, so resolve every token through lib/color before constructing it.
397
397
  const resolve = (value: string) => toHex(themeJson.vars[value] ?? value);
398
398
  const hex = (tok: string) => resolve(themeJson.colors[tok] ?? tok);
399
+ // Mirrors `soften()` (themes/gen-themes.mjs) and `softenColor` (lib/ansi.ts): the soft
400
+ // tier is the base color at L 0.82 with a fifth of its chroma. Both sides read the
401
+ // declared OKLCH string (not the 8-bit hex), so the comparison is not rounded twice.
402
+ const softenToHex = (base: string) => {
403
+ const o = parseOklch(base);
404
+ assert(o !== null, `cannot soften a non-oklch color: ${base}`);
405
+ return toHex(`oklch(82% ${oklchToRgb(0.82, o.C * 0.18, o.H).chroma} ${o.H})`);
406
+ };
399
407
  // The built-in theme adds fallbacks (scrollbarTrack<-muted, thinkingMax<-thinkingXhigh),
400
408
  // so the fg map must be complete; take the whole palette like pi's loader does.
401
409
  const fgPalette = Object.fromEntries(
@@ -508,8 +516,18 @@ if (isMain(import.meta.url)) {
508
516
  // Accent pill: accent bg + toolPendingBg colored text.
509
517
  assert(contrast(hex("accent"), hex("toolPendingBg")) >= 4.5, `theme ${f}: accent vs toolPendingBg contrast >= 4.5`);
510
518
  assert(isColor(d.vars.accentSoft ?? ""), `theme ${f}: vars.accentSoft is a color`);
519
+ assert(isColor(d.vars.secondarySoft ?? ""), `theme ${f}: vars.secondarySoft is a color`);
511
520
  assert(isColor(d.vars.primary ?? ""), `theme ${f}: vars.primary is a color`);
512
521
  assert(isColor(d.vars.secondary ?? ""), `theme ${f}: vars.secondary is a color`);
522
+ // The declared soft vars must equal what the extensions derive at runtime, or the
523
+ // footer label colour drifts from the theme file. `softenToHex` mirrors
524
+ // `soften()` in gen-themes.mjs and `softenColor` in lib/ansi.ts.
525
+ for (const [softName, baseName] of [["accentSoft", "accent"], ["secondarySoft", "borderAccent"]] as const) {
526
+ const base = d.vars[d.colors[baseName] ?? baseName]!;
527
+ const declared = toHex(d.vars[softName]!);
528
+ const derived = softenToHex(base);
529
+ assert(declared === derived, `theme ${f}: vars.${softName} ${declared} != runtime ${derived} (derived from ${baseName})`);
530
+ }
513
531
  const canvas = hex("userMessageBg");
514
532
  for (const tok of Object.keys(d.colors)) {
515
533
  if (nonText.has(tok) || tok === "thinkingMax") continue;
@@ -11,7 +11,7 @@ import { matchesKey, truncateToWidth, visibleWidth, type Component, type TUI, ty
11
11
  import { renderScreen, screenTitle } from "../lib/box.ts";
12
12
  import { assert, isMain } from "../lib/check.ts";
13
13
  import { capitalize } from "../lib/format.ts";
14
- import { thinkingColorOf, softOf } from "../lib/ansi.ts";
14
+ import { thinkingColorOf, softSecondaryOf } from "../lib/ansi.ts";
15
15
  import { legendUsage, matchesBack } from "../lib/shortcuts.ts";
16
16
  import { collectUsageSummary, estimateCost, formatTokens, getModelAllTimeUsage, hasPrice, loadPriceTable, type UsageSummary } from "../lib/usage-store.ts";
17
17
 
@@ -196,8 +196,9 @@ export class UsageModalComponent implements Component {
196
196
  // dashboard reads as belonging to the model that produced the usage.
197
197
  const edge = (t: string) => this.edgeOf(t);
198
198
  const boxW = width;
199
- // `accentSoft` is a vars-only token on Pi 1.2+; softOf resolves the soft accent either way.
200
- const soft = softOf(th);
199
+ // `secondarySoft` is a vars-only token on Pi 1.2+; softSecondaryOf resolves the soft
200
+ // label tier (the border-accent hue) and falls back to the accent tier.
201
+ const soft = softSecondaryOf(th);
201
202
  const acc = (s: string) => th.fg("accent", s);
202
203
  const dim = (s: string) => th.fg("dim", s);
203
204
  const plain = (s: string) => th.fg("text", s);
package/lib/ansi.ts CHANGED
@@ -38,6 +38,18 @@ export function setActiveTheme<T>(th: T | null): void {
38
38
  else delete (globalThis as Record<symbol, T>)[THEME_PROXY_KEY];
39
39
  }
40
40
 
41
+ /**
42
+ * Soft tier of a resolved theme color: clone at L 0.82 with a fifth of its chroma and
43
+ * the hue kept. The generator's `soften()` (themes/gen-themes.mjs) applies the identical
44
+ * recipe, so a declared `vars.*Soft` and this derived value agree. `chroma * 0.18` is
45
+ * the single tuning constant for how far a soft tone leans toward grey.
46
+ */
47
+ const SOFT_L = 0.82;
48
+ const SOFT_CHROMA_SCALE = 0.18;
49
+ function softenColor(c: { c?: number; h?: number }): { kind: "oklch"; l: number; c: number; h: number } {
50
+ return { kind: "oklch", l: SOFT_L, c: (c.c ?? 0) * SOFT_CHROMA_SCALE, h: c.h ?? 0 };
51
+ }
52
+
41
53
  /**
42
54
  * Painter for arnative's soft accent (`accentSoft`).
43
55
  *
@@ -45,9 +57,9 @@ export function setActiveTheme<T>(th: T | null): void {
45
57
  * addressable directly. 1.2.0 validates `colors` with `additionalProperties: false`
46
58
  * and rejects the whole theme if it carries one, so the token now lives only in
47
59
  * `vars` and `fg("accentSoft")` throws. The soft accent stays available through the
48
- * resolved `accent` color (1.2.0 exposes `theme.colors`): its hue and chroma are
49
- * softened to the generator's `*Soft` recipe, which keeps the two-tone look instead
50
- * of collapsing to grey. Falls back to `muted`, then `accent`, on any older theme.
60
+ * resolved `accent` color (both 1.1.0 and 1.2.0 expose `theme.colors`): its hue and
61
+ * chroma are softened to the generator's `*Soft` recipe, which keeps the two-tone look
62
+ * instead of collapsing to grey. Falls back to `muted`, then `accent`, on older themes.
51
63
  */
52
64
  export function softOf(th: {
53
65
  fg?(c: string, t: string): string;
@@ -65,13 +77,36 @@ export function softOf(th: {
65
77
  const accent = th?.colors?.accent;
66
78
  const style = th?.style?.bind(th);
67
79
  if (accent?.kind === "oklch" && style) {
68
- // The generator's `*Soft` recipe: lightness 0.82, a fifth of the accent chroma.
69
- const soft = { kind: "oklch" as const, l: 0.82, c: (accent.c ?? 0) * 0.18, h: accent.h ?? 0 };
80
+ const soft = softenColor(accent);
70
81
  return (s) => style(s, { fg: soft });
71
82
  }
72
83
  return (s) => fg("muted", s);
73
84
  }
74
85
 
86
+ /**
87
+ * Painter for the soft *label* tier (`secondarySoft`): the footer's cwd, branch, clock
88
+ * and status text.
89
+ *
90
+ * The two-tone footer puts icons on `accent` and their text on the border-accent tier.
91
+ * `secondarySoft` is declared only in `vars`, so it cannot be read back with `fg()`;
92
+ * it is derived instead from `borderAccent`, the official token that tier maps to. The
93
+ * generator softens that same value, so declared and derived agree. Falls back to
94
+ * `softOf` (the accent tier) when the theme exposes no resolvable `borderAccent`.
95
+ */
96
+ export function softSecondaryOf(th: {
97
+ fg?(c: string, t: string): string;
98
+ style?(t: string, o: { fg: unknown }): string;
99
+ colors?: Record<string, { kind?: string; c?: number; h?: number }>;
100
+ } | null): (s: string) => string {
101
+ const border = th?.colors?.borderAccent;
102
+ const style = th?.style?.bind(th);
103
+ if (border?.kind === "oklch" && style) {
104
+ const soft = softenColor(border);
105
+ return (s) => style(s, { fg: soft });
106
+ }
107
+ return softOf(th);
108
+ }
109
+
75
110
  /** pi defines one theme slot per thinking level (`thinkingOff`…`thinkingMax`), so a box
76
111
  * can follow the model instead of a hardcoded palette — switching theme keeps the
77
112
  * meaning of the color. Shared by `usage.ts` and `arnative.ts`. */
@@ -129,17 +164,26 @@ if (isMain(import.meta.url)) {
129
164
  };
130
165
  assert(softOf(noSoft)("x") === "<muted>x</muted>", "softOf falls back to muted without the token or colors");
131
166
  assert(softOf(null)("x") === "x", "softOf without a theme is identity");
132
- // Pi 1.2.0: acentSoft is rejected, so the soft tint is derived from `colors.accent`.
167
+ // Pi 1.2.0: accentSoft is rejected, so the soft tint is derived from `colors.accent`.
133
168
  const modern = {
134
169
  fg: (c: string, t: string) => {
135
170
  throw new Error(`Unknown theme color: ${c}`);
136
171
  },
137
172
  style: (t: string, o: { fg: unknown }) => `<soft:${JSON.stringify(o.fg)}>${t}</soft>`,
138
- colors: { accent: { kind: "oklch", c: 0.2, h: 355 } },
173
+ colors: { accent: { kind: "oklch", c: 0.2, h: 355 }, borderAccent: { kind: "oklch", c: 0.1, h: 200 } },
139
174
  };
140
175
  const modernOut = softOf(modern)("x");
141
176
  assert(modernOut.includes('"l":0.82') && modernOut.includes('"c":0.036'), "softOf derives the soft tint from accent (l=0.82, c=accent*0.18)");
142
177
  assert(modernOut.includes("x"), "softOf keeps the text");
178
+ // The label tier softens `borderAccent`, NOT `accent`: that is what keeps footer text
179
+ // on the blue tier instead of inheriting the pink accent hue.
180
+ const labelOut = softSecondaryOf(modern)("x");
181
+ assert(
182
+ labelOut.includes('"l":0.82') && labelOut.includes('"c":0.018') && labelOut.includes('"h":200'),
183
+ "softSecondaryOf softens borderAccent (l=0.82, c=borderAccent*0.18, same hue)",
184
+ );
185
+ assert(labelOut !== modernOut, "the soft label tier differs from the soft accent tier");
186
+ assert(softSecondaryOf({ fg: () => "", style: (t) => `<x>${t}</x>`, colors: {} })("x") === softOf({ fg: () => "", style: (t) => `<x>${t}</x>`, colors: {} })("x"), "softSecondaryOf falls back to softOf without a borderAccent");
143
187
 
144
188
  assert(thinkingColorSlot("off") === "thinkingOff", "thinking off maps to thinkingOff");
145
189
  assert(thinkingColorSlot(undefined) === "thinkingOff", "missing level falls back to thinkingOff");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bismawy/pi-arnative",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "Refined aesthetics. Cohesive tools. Built for Pi. Warm themes, rounded tool boxes, tabbed header, compact footer and a /usage dashboard.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -9,8 +9,9 @@
9
9
  "fgFaint": "oklch(49% 0.02 275)",
10
10
  "primary": "oklch(88% 0.18 95)",
11
11
  "secondary": "oklch(82% 0.139 200)",
12
+ "secondarySoft": "oklch(82% 0.02502 200)",
12
13
  "accent": "oklch(78% 0.146 355)",
13
- "accentSoft": "oklch(82% 0.026 355)",
14
+ "accentSoft": "oklch(82% 0.02628 355)",
14
15
  "accentBorder": "oklch(82% 0.114 355)",
15
16
  "success": "oklch(78% 0.15 145)",
16
17
  "error": "oklch(76% 0.143 25)",
@@ -9,8 +9,9 @@
9
9
  "fgFaint": "oklch(49% 0.012 285)",
10
10
  "primary": "oklch(78% 0.125 295)",
11
11
  "secondary": "oklch(82% 0.13 190)",
12
+ "secondarySoft": "oklch(82% 0.0234 190)",
12
13
  "accent": "oklch(78% 0.14 330)",
13
- "accentSoft": "oklch(82% 0.025 330)",
14
+ "accentSoft": "oklch(82% 0.0252 330)",
14
15
  "accentBorder": "oklch(82% 0.084 330)",
15
16
  "success": "oklch(78% 0.14 145)",
16
17
  "error": "oklch(76% 0.14 25)",
@@ -9,8 +9,9 @@
9
9
  "fgFaint": "oklch(49% 0.012 165)",
10
10
  "primary": "oklch(84% 0.13 165)",
11
11
  "secondary": "oklch(78% 0.1 195)",
12
+ "secondarySoft": "oklch(82% 0.01296 165)",
12
13
  "accent": "oklch(78% 0.12 165)",
13
- "accentSoft": "oklch(82% 0.022 165)",
14
+ "accentSoft": "oklch(82% 0.0216 165)",
14
15
  "accentBorder": "oklch(82% 0.072 165)",
15
16
  "success": "oklch(78% 0.12 145)",
16
17
  "error": "oklch(76% 0.12 25)",
@@ -9,8 +9,9 @@
9
9
  "fgFaint": "oklch(49% 0.015 70)",
10
10
  "primary": "oklch(84% 0.14 83)",
11
11
  "secondary": "oklch(78% 0.1 175)",
12
+ "secondarySoft": "oklch(82% 0.018 175)",
12
13
  "accent": "oklch(78% 0.137 45)",
13
- "accentSoft": "oklch(82% 0.025 45)",
14
+ "accentSoft": "oklch(82% 0.02466 45)",
14
15
  "accentBorder": "oklch(82% 0.084 45)",
15
16
  "success": "oklch(78% 0.14 145)",
16
17
  "error": "oklch(76% 0.14 25)",
@@ -9,8 +9,9 @@
9
9
  "fgFaint": "oklch(49% 0.02 145)",
10
10
  "primary": "oklch(88% 0.24 145)",
11
11
  "secondary": "oklch(76% 0.16 165)",
12
+ "secondarySoft": "oklch(82% 0.02376 144)",
12
13
  "accent": "oklch(78% 0.22 144)",
13
- "accentSoft": "oklch(82% 0.04 144)",
14
+ "accentSoft": "oklch(82% 0.0396 144)",
14
15
  "accentBorder": "oklch(82% 0.132 144)",
15
16
  "success": "oklch(78% 0.15 145)",
16
17
  "error": "oklch(76% 0.143 25)",
@@ -9,8 +9,9 @@
9
9
  "fgFaint": "oklch(49% 0.01 240)",
10
10
  "primary": "oklch(78% 0.08 217)",
11
11
  "secondary": "oklch(82% 0.09 195)",
12
+ "secondarySoft": "oklch(82% 0.0162 195)",
12
13
  "accent": "oklch(78% 0.07 310)",
13
- "accentSoft": "oklch(82% 0.013 310)",
14
+ "accentSoft": "oklch(82% 0.0126 310)",
14
15
  "accentBorder": "oklch(82% 0.042 310)",
15
16
  "success": "oklch(78% 0.07 145)",
16
17
  "error": "oklch(76% 0.07 25)",
@@ -9,8 +9,9 @@
9
9
  "fgFaint": "oklch(49% 0.012 70)",
10
10
  "primary": "oklch(84% 0.126 70)",
11
11
  "secondary": "oklch(78% 0.12 35)",
12
+ "secondarySoft": "oklch(82% 0.01512 70)",
12
13
  "accent": "oklch(78% 0.14 70)",
13
- "accentSoft": "oklch(82% 0.025 70)",
14
+ "accentSoft": "oklch(82% 0.0252 70)",
14
15
  "accentBorder": "oklch(82% 0.084 70)",
15
16
  "success": "oklch(78% 0.14 145)",
16
17
  "error": "oklch(76% 0.14 25)",
@@ -9,8 +9,9 @@
9
9
  "fgFaint": "oklch(49% 0.015 300)",
10
10
  "primary": "oklch(78% 0.178 339)",
11
11
  "secondary": "oklch(82% 0.145 185)",
12
+ "secondarySoft": "oklch(82% 0.0261 185)",
12
13
  "accent": "oklch(78% 0.142 50)",
13
- "accentSoft": "oklch(82% 0.026 50)",
14
+ "accentSoft": "oklch(82% 0.02556 50)",
14
15
  "accentBorder": "oklch(82% 0.102 50)",
15
16
  "success": "oklch(78% 0.15 145)",
16
17
  "error": "oklch(76% 0.143 25)",
@@ -9,8 +9,9 @@
9
9
  "fgFaint": "oklch(49% 0.012 295)",
10
10
  "primary": "oklch(82% 0.1 294)",
11
11
  "secondary": "oklch(78% 0.11 215)",
12
+ "secondarySoft": "oklch(82% 0.01512 294)",
12
13
  "accent": "oklch(78% 0.124 294)",
13
- "accentSoft": "oklch(82% 0.022 294)",
14
+ "accentSoft": "oklch(82% 0.02232 294)",
14
15
  "accentBorder": "oklch(82% 0.084 294)",
15
16
  "success": "oklch(78% 0.14 145)",
16
17
  "error": "oklch(76% 0.14 25)",
@@ -9,8 +9,9 @@
9
9
  "fgFaint": "oklch(49% 0.005 285)",
10
10
  "primary": "oklch(86% 0.015 285)",
11
11
  "secondary": "oklch(72% 0.025 240)",
12
+ "secondarySoft": "oklch(82% 0.00216 286)",
12
13
  "accent": "oklch(78% 0.02 286)",
13
- "accentSoft": "oklch(82% 0.004 286)",
14
+ "accentSoft": "oklch(82% 0.0036 286)",
14
15
  "accentBorder": "oklch(82% 0.012 286)",
15
16
  "success": "oklch(78% 0.02 145)",
16
17
  "error": "oklch(76% 0.02 25)",
@@ -9,8 +9,9 @@
9
9
  "fgFaint": "oklch(49% 0.012 232)",
10
10
  "primary": "oklch(82% 0.138 218)",
11
11
  "secondary": "oklch(78% 0.11 282)",
12
+ "secondarySoft": "oklch(82% 0.01404 218)",
12
13
  "accent": "oklch(78% 0.13 218)",
13
- "accentSoft": "oklch(82% 0.023 218)",
14
+ "accentSoft": "oklch(82% 0.0234 218)",
14
15
  "accentBorder": "oklch(82% 0.078 218)",
15
16
  "success": "oklch(78% 0.13 145)",
16
17
  "error": "oklch(76% 0.13 25)",
@@ -32,6 +32,23 @@ function oklch(L, C, H) {
32
32
  return `oklch(${+(L * 100).toFixed(1)}% ${round3(chroma)} ${Math.round(hue(H))})`;
33
33
  }
34
34
 
35
+ /**
36
+ * Soft tier of a color: clone at L 0.82 with a fifth of the GAMUT-CLAMPED chroma, hue
37
+ * kept. `lib/ansi.ts` softens the resolved token with this same recipe at runtime, so a
38
+ * `*Soft` var declared here and the extension's derived value agree. Grey-collapse is
39
+ * avoided because the hue is carried, not dropped.
40
+ *
41
+ * The chroma keeps five decimals (not the usual three): the soft tier multiplies it by
42
+ * 0.18, so a coarser round-trip shifts the last hex digit and the `declare == derive`
43
+ * guard in ui-render-tweaks.ts fails.
44
+ */
45
+ function soften(value) {
46
+ const o = parseOklch(value);
47
+ assert(o !== null, `cannot soften a non-oklch color: ${value}`);
48
+ const { chroma } = oklchToRgb(0.82, o.C * 0.18, o.H);
49
+ return `oklch(82% ${+chroma.toFixed(5)} ${Math.round(hue(o.H))})`;
50
+ }
51
+
35
52
  // --- shared ramps ----------------------------------------------------------
36
53
 
37
54
  const NEUTRAL = { fg: 0.88, fgMuted: 0.72, fgDim: 0.61, fgFaint: 0.49 };
@@ -252,6 +269,15 @@ function derive(spec) {
252
269
  const primaryVal = primarySeed ? oklch(primarySeed.L, primarySeed.C, primarySeed.H) : oklch(0.82, c, h);
253
270
  const secondaryVal = secondarySeed ? oklch(secondarySeed.L, secondarySeed.C, secondarySeed.H) : oklch(0.78, Math.min(c * 0.8, 0.12), roleHue("label"));
254
271
 
272
+ // Soft counterpart of the border-accent tier — the footer's label, clock and status
273
+ // colour. Softened from the SAME value the `borderAccent` token resolves to, because
274
+ // that token is what extensions read back at runtime (`softSecondaryOf` in
275
+ // lib/ansi.ts). One source keeps the declared value and the runtime one identical.
276
+ const accentBorderVal = oklch(0.82, c * 0.6, h);
277
+ const accentVal = oklch(0.78, c, h);
278
+ const borderAccentRole = spec.borderAccent ?? "accentBorder";
279
+ const borderAccentVal = { accentBorder: accentBorderVal, secondary: secondaryVal }[borderAccentRole] ?? borderAccentRole;
280
+
255
281
  const vars = {
256
282
  fg: oklch(NEUTRAL.fg, nc, nh),
257
283
  fgMuted: oklch(NEUTRAL.fgMuted, nc, nh),
@@ -259,14 +285,10 @@ function derive(spec) {
259
285
  fgFaint: oklch(NEUTRAL.fgFaint, nc, nh),
260
286
  primary: primaryVal,
261
287
  secondary: secondaryVal,
262
- accent: oklch(0.78, c, h),
263
- // Derived from the accent's GAMUT-CLAMPED chroma, not the raw spec chroma. Pi's schema
264
- // forbids custom keys in `colors`, so extensions resolve the soft tier at runtime by
265
- // softening the resolved `accent` (see `softOf` in lib/ansi.ts). Building it from the
266
- // same clamped chroma makes the declared value and the runtime one agree; using `c`
267
- // drifts wherever the raw chroma is out of gamut at the accent's lightness.
268
- accentSoft: oklch(0.82, oklchToRgb(0.78, c, h).chroma * 0.18, h),
269
- accentBorder: oklch(0.82, c * 0.6, h),
288
+ secondarySoft: soften(borderAccentVal),
289
+ accent: accentVal,
290
+ accentSoft: soften(accentVal),
291
+ accentBorder: accentBorderVal,
270
292
  success: oklch(0.78, Math.min(c, 0.15), sem("success")),
271
293
  error: oklch(0.76, Math.min(c, 0.15), sem("error")),
272
294
  warning: oklch(0.86, Math.min(c, 0.14), sem("warning")),