Skip to content

Commit df989fb

Browse files
Agent IXclaude
andcommitted
feat: redesign orbit header spinner with two satellites + depth fade
ORBIT_SPINNER is now a 10-frame, 5-cell-wide cycle of structural {glyph, tone} cells (was a 7-frame string array of width 4). Two satellites trace one full CW orbit per cycle through behind → out → closer → in → transit → out → farther → in → transit. Glyph (⋅ small/far vs ∘ big/close) and color (4-step blue gradient: 60 dim, 67 medDim, 75 med, 117 bright) both encode depth so the orbit reads as 3D. Planet glyph swapped to ⊝; PHASE_PASS is the planet at rest (" ⊝ "), PHASE_FAIL is padded to the new PHASE_WIDTH=5. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e82554a commit df989fb

7 files changed

Lines changed: 124 additions & 47 deletions

File tree

packages/cli/src/colors.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,11 @@ export const colors = {
1919

2020
// "IX blue" alias — single source of the accent colour used throughout.
2121
export const blue = pc.cyan;
22+
23+
// Orbit tone helpers — 4-step blue brightness gradient for the new orbit
24+
// spinner. Glyph (⋅ vs ∘) and color both track depth: closer = bigger + brighter.
25+
const c256 = (n: number) => (s: string) => `\x1b[38;5;${n}m${s}\x1b[0m`;
26+
export const orbitDim = c256(60); // dark steel — ⋅ at adj (deepest visible)
27+
export const orbitMedDim = c256(67); // steel — ⋅ at far (back at equator)
28+
export const orbitMed = c256(75); // cornflower — ∘ at far (front at equator)
29+
export const orbitBright = c256(117); // sky blue — ∘ at adj (closest)

packages/cli/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,10 +114,14 @@ export {
114114
PHASE_PASS,
115115
PHASE_FAIL,
116116
ORBIT_SPINNER,
117+
orbitFrameGlyphs,
117118
colorOrbitFrame,
118119
renderHeader,
119120
colorPods,
120121
colors,
121122
blue,
122123
type PhaseState,
124+
type OrbitCell,
125+
type OrbitFrame,
126+
type OrbitTone,
123127
} from "./style.js";

packages/cli/src/style.ts

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,25 @@
1010
*/
1111

1212
import pc from "picocolors";
13-
import { ORBIT_SPINNER, type PhaseState } from "@agent-ix/ix-ui-semantic";
14-
import { colors, blue } from "./colors.js";
13+
import {
14+
ORBIT_SPINNER,
15+
orbitFrameGlyphs,
16+
type PhaseState,
17+
type OrbitCell,
18+
type OrbitFrame,
19+
type OrbitTone,
20+
} from "@agent-ix/ix-ui-semantic";
21+
import {
22+
colors,
23+
blue,
24+
orbitDim,
25+
orbitMedDim,
26+
orbitMed,
27+
orbitBright,
28+
} from "./colors.js";
1529

16-
export type { PhaseState };
17-
export { ORBIT_SPINNER, colors, blue };
30+
export type { PhaseState, OrbitCell, OrbitFrame, OrbitTone };
31+
export { ORBIT_SPINNER, orbitFrameGlyphs, colors, blue };
1832

1933
// ── Layout ──────────────────────────────────────────────────────────────────
2034

@@ -29,7 +43,7 @@ export const ERROR_INDENT = ROW_INDENT + " ";
2943
/** Indent for outer-level flow rows (pipe, preflight, completion). */
3044
export const FLOW_INDENT = " ";
3145
/** Header indicator width — keeps `[ … ]` aligned across spinner/pass/fail. */
32-
export const PHASE_WIDTH = 4;
46+
export const PHASE_WIDTH = 5;
3347
/** Advance the orbit glyph every N ticks (3 × 80 ms = 240 ms). */
3448
export const HEADER_TICK_DIV = 3;
3549

@@ -70,23 +84,35 @@ export const GLYPH_COMPLETE = blue("✧");
7084

7185
// ── Header rendering ────────────────────────────────────────────────────────
7286

87+
const ORBIT_TONE: Record<OrbitTone, (s: string) => string> = {
88+
gray: pc.gray,
89+
dim: orbitDim,
90+
medDim: orbitMedDim,
91+
med: orbitMed,
92+
bright: orbitBright,
93+
};
94+
7395
/**
74-
* Per-glyph color the orbit frame: planet (⊙/⊚) gray, satellite (∘/⋅/⚬) blue.
96+
* Apply per-cell tones to an orbit frame: each non-space cell carries its
97+
* own tone (gray, dim, medDim, med, bright). Glyph and color together
98+
* encode depth — ⋅ dim = farther, ∘ bright = closer.
7599
*/
76-
export function colorOrbitFrame(frame: string): string {
77-
return [...frame]
78-
.map((ch) => {
79-
if (ch === "⊙" || ch === "⊚") return pc.gray(ch);
80-
if (ch === "∘" || ch === "⋅" || ch === "⚬") return blue(ch);
81-
return ch;
82-
})
100+
export function colorOrbitFrame(frame: OrbitFrame): string {
101+
return frame
102+
.map((c) => (typeof c === "string" ? c : ORBIT_TONE[c.tone](c.glyph)))
83103
.join("");
84104
}
85105

86-
/** Frozen "passed" header indicator — orbit at rest. */
87-
export const PHASE_PASS: string = colorOrbitFrame(ORBIT_SPINNER[5]);
106+
/** Frozen "passed" header indicator — orbit at rest, planet alone in gray. */
107+
export const PHASE_PASS: string = colorOrbitFrame([
108+
" ",
109+
" ",
110+
{ glyph: "⊝", tone: "gray" },
111+
" ",
112+
" ",
113+
]);
88114
/** Frozen "failed" header indicator — red ⊗, padded to PHASE_WIDTH. */
89-
export const PHASE_FAIL: string = " " + colors.red("⊗") + " ";
115+
export const PHASE_FAIL: string = " " + colors.red("⊗") + " ";
90116

91117
/** Wrap a header string in gray brackets with gray `·` separators. */
92118
export function renderHeader(text: string): string {

packages/cli/tests/frame.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,12 @@ const stripAnsi = (s: string): string => s.replace(/\x1b\[[0-9;]*m/g, "");
88

99
// FR-002-AC-2 (TC-113): passed status
1010
describe("FR-002-AC-2 (TC-113)", () => {
11-
it("renders frozen in passed state", () => {
11+
it("renders frozen in passed state", () => {
1212
const { lastFrame } = render(
1313
<Frame header="ix elements list" status="passed" />,
1414
);
1515
const out = stripAnsi(lastFrame() ?? "");
16-
expect(out).toContain("");
16+
expect(out).toContain("");
1717
expect(out).toContain("[ ix elements list ]");
1818
});
1919
});

packages/cli/tests/style.test.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
PHASE_PASS,
2323
PHASE_FAIL,
2424
ORBIT_SPINNER,
25+
orbitFrameGlyphs,
2526
colorOrbitFrame,
2627
renderHeader,
2728
colorPods,
@@ -59,10 +60,10 @@ describe("FR-016-AC-5 (TC-304)", () => {
5960
});
6061
});
6162

62-
// FR-016-AC-6: PHASE_WIDTH = 4 (TC-305) + TC-CB-04
63+
// FR-016-AC-6: PHASE_WIDTH = 5 (TC-305) + TC-CB-04
6364
describe("FR-016-AC-6 (TC-305, TC-CB-04)", () => {
64-
it("PHASE_WIDTH is 4", () => {
65-
expect(PHASE_WIDTH).toBe(4);
65+
it("PHASE_WIDTH is 5", () => {
66+
expect(PHASE_WIDTH).toBe(5);
6667
});
6768
it("PHASE_PASS is exactly PHASE_WIDTH cells (after stripping ANSI)", () => {
6869
expect(stripAnsi(PHASE_PASS)).toHaveLength(PHASE_WIDTH);
@@ -115,8 +116,8 @@ describe("FR-001-AC-12 (TC-111)", () => {
115116

116117
// FR-016-AC-10: PHASE_PASS / PHASE_FAIL
117118
describe("FR-016-AC-10 (TC-309)", () => {
118-
it("PHASE_PASS is the orbit at frame index 5", () => {
119-
expect(stripAnsi(PHASE_PASS)).toBe(ORBIT_SPINNER[5]);
119+
it("PHASE_PASS is the planet at rest (⊝, no satellites)", () => {
120+
expect(stripAnsi(PHASE_PASS)).toBe(" ⊝ ");
120121
});
121122
it("PHASE_FAIL contains a red ⊗", () => {
122123
expect(PHASE_FAIL).toContain("⊗");
@@ -127,12 +128,12 @@ describe("FR-016-AC-10 (TC-309)", () => {
127128
describe("FR-016-AC-11 (TC-310)", () => {
128129
it("preserves the underlying glyphs after coloring", () => {
129130
const colored = colorOrbitFrame(ORBIT_SPINNER[0]);
130-
expect(stripAnsi(colored)).toBe(ORBIT_SPINNER[0]);
131+
expect(stripAnsi(colored)).toBe(orbitFrameGlyphs(ORBIT_SPINNER[0]));
131132
});
132133
it("colors orbit and satellite glyphs", () => {
133-
// ORBIT[0] contains either ⊚ or ⊙ + a satellite. After coloring there
134-
// should be at least one color escape.
135-
const colored = colorOrbitFrame(ORBIT_SPINNER[0]);
134+
// Frame 1 contains the planet ⊝ plus two sats (⋅ dim, ∘ bright).
135+
// After coloring there should be at least one ANSI color escape.
136+
const colored = colorOrbitFrame(ORBIT_SPINNER[1]);
136137
expect(colored).toMatch(/\x1b\[/);
137138
});
138139
});

packages/semantic/src/spinners.ts

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,43 @@ export const BRAILLE_SPINNER = [
1313

1414
export const HEADER_SPINNER = ["⊕", "⊘", "⊗", "⊖"];
1515

16-
// Satellite orbiting a planet left→right. Planet ⊙ (or ⊚ when transiting)
17-
// is always at column 2; all frames are exactly 4 chars wide so the planet is
18-
// stable. Right-side trail recedes through three glyphs: adjacent ⚬ → gap ∘ →
19-
// gap ⋅. The trailing char is always a space so no additional separator is
20-
// needed when rendering before [ header ].
21-
export const ORBIT_SPINNER = [
22-
"∘⊙ ", // near left
23-
" ⊚ ", // transiting (satellite in front of planet)
24-
" ⊙⚬ ", // near right (adjacent)
25-
" ⊙ ∘", // receding right
26-
" ⊙ ⋅", // far right (fading)
27-
" ⊙ ", // behind
28-
"⋅⊙ ", // approaching left
16+
// ── Orbit spinner ──────────────────────────────────────────────────────────
17+
//
18+
// Two satellites 180° apart on a CW orbit, projected to a 5-cell horizontal
19+
// lane (planet ⊝ at col 2). Each satellite goes through:
20+
// behind → out → closer → in → transit → out → farther → in → transit
21+
// Glyph and color both track depth: ⋅ small/dim = farther, ∘ big/bright = closer.
22+
23+
export type OrbitTone = "gray" | "dim" | "medDim" | "med" | "bright";
24+
export type OrbitCell = " " | { glyph: string; tone: OrbitTone };
25+
export type OrbitFrame = OrbitCell[];
26+
27+
const cell = (glyph: string, tone: OrbitTone): OrbitCell => ({ glyph, tone });
28+
const PLANET = cell("⊝", "gray");
29+
const TRANSIT = cell("⊚", "gray");
30+
31+
// 10-frame cycle. Frames 5–9 are visually identical to 0–4 (the two sats
32+
// swap which one is "in front" at each transit).
33+
// n=0 ⊚ transit-front (front sat over planet)
34+
// n=1 ⋅⊝∘ out — back-adj-L (⋅ dim) / front-adj-R (∘ bright)
35+
// n=2 ⋅ ⊝ ∘ edge — back-far-L (⋅ medDim) / front-far-R (∘ med)
36+
// n=3 ∘ ⊝ ⋅ edge — front-far-L (∘ med) / back-far-R (⋅ medDim)
37+
// n=4 ∘⊝⋅ in — front-adj-L (∘ bright) / back-adj-R (⋅ dim)
38+
// n=5 ⊚ transit (other sat now in front)
39+
// n=6–9 identical to 1–4
40+
export const ORBIT_SPINNER: OrbitFrame[] = [
41+
[" ", " ", TRANSIT, " ", " "],
42+
[" ", cell("⋅", "dim"), PLANET, cell("∘", "bright"), " "],
43+
[cell("⋅", "medDim"), " ", PLANET, " ", cell("∘", "med")],
44+
[cell("∘", "med"), " ", PLANET, " ", cell("⋅", "medDim")],
45+
[" ", cell("∘", "bright"), PLANET, cell("⋅", "dim"), " "],
46+
[" ", " ", TRANSIT, " ", " "],
47+
[" ", cell("⋅", "dim"), PLANET, cell("∘", "bright"), " "],
48+
[cell("⋅", "medDim"), " ", PLANET, " ", cell("∘", "med")],
49+
[cell("∘", "med"), " ", PLANET, " ", cell("⋅", "medDim")],
50+
[" ", cell("∘", "bright"), PLANET, cell("⋅", "dim"), " "],
2951
];
52+
53+
/** Plain-text glyph string for a frame — useful for tests and logs. */
54+
export const orbitFrameGlyphs = (frame: OrbitFrame): string =>
55+
frame.map((c) => (typeof c === "string" ? c : c.glyph)).join("");

packages/semantic/tests/glyphs.test.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -59,17 +59,17 @@ describe("spinners", () => {
5959
expect(HEADER_SPINNER).toHaveLength(4);
6060
});
6161

62-
it("ORBIT_SPINNER has 7 frames", () => {
63-
expect(ORBIT_SPINNER).toHaveLength(7);
62+
it("ORBIT_SPINNER has 10 frames", () => {
63+
expect(ORBIT_SPINNER).toHaveLength(10);
6464
});
6565

66-
it("ORBIT_SPINNER frames are all 4 chars wide", () => {
66+
it("ORBIT_SPINNER frames are all 5 cells wide", () => {
6767
for (const frame of ORBIT_SPINNER) {
68-
expect(frame).toHaveLength(4);
68+
expect(frame).toHaveLength(5);
6969
}
7070
});
7171

72-
it("spinner frames are non-empty strings", () => {
72+
it("BRAILLE_SPINNER and HEADER_SPINNER frames are non-empty strings", () => {
7373
for (const frame of BRAILLE_SPINNER) {
7474
expect(typeof frame).toBe("string");
7575
expect(frame.length).toBeGreaterThan(0);
@@ -78,9 +78,21 @@ describe("spinners", () => {
7878
expect(typeof frame).toBe("string");
7979
expect(frame.length).toBeGreaterThan(0);
8080
}
81+
});
82+
83+
it("ORBIT_SPINNER cells are space or {glyph, tone}", () => {
8184
for (const frame of ORBIT_SPINNER) {
82-
expect(typeof frame).toBe("string");
83-
expect(frame.length).toBeGreaterThan(0);
85+
for (const cell of frame) {
86+
if (typeof cell === "string") {
87+
expect(cell).toBe(" ");
88+
} else {
89+
expect(typeof cell.glyph).toBe("string");
90+
expect(cell.glyph.length).toBeGreaterThan(0);
91+
expect(["gray", "dim", "medDim", "med", "bright"]).toContain(
92+
cell.tone,
93+
);
94+
}
95+
}
8496
}
8597
});
8698
});

0 commit comments

Comments
 (0)