de87923add
The welcome 'Tip:' line hardcoded #b48cff (label) and #9ccfff (body)
with an additional \x1b[2m dim on the body, ignoring the active theme.
On any light theme this dropped the body to ~1.5:1 contrast on a white
background (WCAG AA needs >=4.5:1), making the line effectively
invisible. Switching between light variants did not help because the
colors were not theme-derived.
renderWelcomeTip in packages/coding-agent/src/modes/components/welcome.ts
now paints the label through theme.fg('customMessageLabel', ...) and the
body through theme.fg('muted', ...), drops the manual dim, and wraps the
whole line with theme.italic(...). Both tokens are tuned per theme, so
the line stays vivid on dark backgrounds and readable on light ones
(e.g. light theme's customMessageLabel #7e57c2 = 5.21:1 on white).
A regression test pins the contract: dark/light themes must produce
different bytes for the same tip, and no manual \x1b[2m may remain.
Fixes #3337
94 lines
3.8 KiB
TypeScript
94 lines
3.8 KiB
TypeScript
import { beforeAll, describe, expect, it } from "bun:test";
|
|
import { renderWelcomeTip } from "@oh-my-pi/pi-coding-agent/modes/components/welcome";
|
|
import { initTheme, setTheme, theme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
|
|
import { visibleWidth } from "@oh-my-pi/pi-tui";
|
|
describe("renderWelcomeTip", () => {
|
|
beforeAll(async () => {
|
|
await initTheme(false);
|
|
});
|
|
|
|
it("wraps long tips under the label instead of truncating", () => {
|
|
const tip = "Next time you see spaghetti try creating a TTSR rule that prevents this pattern before it spreads";
|
|
const width = 44;
|
|
const lines = renderWelcomeTip(tip, width);
|
|
const plain = lines.map(line => Bun.stripANSI(line));
|
|
|
|
expect(plain.length).toBeGreaterThan(1);
|
|
expect(plain.join(" ")).not.toContain("…");
|
|
expect(plain[0]).toStartWith(" Tip: Next time");
|
|
expect(plain[1]).toStartWith(" ");
|
|
for (const line of plain) {
|
|
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
|
|
}
|
|
});
|
|
|
|
it("replaces a trailing [NEW] marker with a rainbow NEW! tag", () => {
|
|
const lines = renderWelcomeTip("Try the shiny advisor [NEW]", 60);
|
|
const plain = lines.map(line => Bun.stripANSI(line)).join("\n");
|
|
const styled = lines.join("\n");
|
|
|
|
expect(plain).toContain("Try the shiny advisor");
|
|
expect(plain).not.toContain("[NEW]"); // literal marker stripped
|
|
expect(plain).toContain("NEW!"); // replaced by the visible tag
|
|
expect(styled).toContain("\x1b[1m"); // tag is bold
|
|
expect(styled).not.toBe(plain); // tag carries SGR color escapes
|
|
});
|
|
|
|
it("keeps the NEW! tag within the box width", () => {
|
|
// A width that leaves the wrapped body ending near the right edge forces
|
|
// the tag onto its own continuation line rather than overflowing.
|
|
for (const width of [24, 40, 60]) {
|
|
const lines = renderWelcomeTip("Turn on the advisor to review every turn [NEW]", width);
|
|
for (const line of lines) {
|
|
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
|
|
}
|
|
expect(lines.map(l => Bun.stripANSI(l)).join("\n")).toContain("NEW!");
|
|
}
|
|
});
|
|
|
|
it("shimmers the tag across phases without changing visible text", () => {
|
|
const tip = "Fresh feature here [NEW]";
|
|
const still = renderWelcomeTip(tip, 60, 0);
|
|
const shifted = renderWelcomeTip(tip, 60, 0.5);
|
|
|
|
expect(shifted.join("\n")).not.toBe(still.join("\n")); // hues rotate
|
|
expect(shifted.map(l => Bun.stripANSI(l))).toEqual(still.map(l => Bun.stripANSI(l)));
|
|
});
|
|
|
|
it("leaves tips without the marker untouched", () => {
|
|
const lines = renderWelcomeTip("Plain old tip", 60);
|
|
const plain = lines.map(line => Bun.stripANSI(line)).join("\n");
|
|
expect(plain).not.toContain("NEW!");
|
|
expect(plain).toContain("Tip: Plain old tip");
|
|
});
|
|
|
|
it("derives label and body colors from the active theme, with no manual dim layer", async () => {
|
|
// Regression for #3337: hardcoded #b48cff/#9ccfff plus a manual `\x1b[2m`
|
|
// dropped the body to ~1.5:1 contrast on any light-theme background.
|
|
await setTheme("dark");
|
|
const darkLabelAnsi = theme.getFgAnsi("customMessageLabel");
|
|
const darkMutedAnsi = theme.getFgAnsi("muted");
|
|
const dark = renderWelcomeTip("Welcome aboard friend", 60).join("\n");
|
|
|
|
await setTheme("light");
|
|
const lightLabelAnsi = theme.getFgAnsi("customMessageLabel");
|
|
const lightMutedAnsi = theme.getFgAnsi("muted");
|
|
const light = renderWelcomeTip("Welcome aboard friend", 60).join("\n");
|
|
|
|
// Each theme paints with its own tokens.
|
|
expect(dark).toContain(darkLabelAnsi);
|
|
expect(dark).toContain(darkMutedAnsi);
|
|
expect(light).toContain(lightLabelAnsi);
|
|
expect(light).toContain(lightMutedAnsi);
|
|
|
|
// Switching themes must change the emitted bytes — the previous bug
|
|
// produced byte-identical output for both.
|
|
expect(dark).not.toBe(light);
|
|
|
|
// The manual `\x1b[2m` dim is gone; muted/label tokens carry their own
|
|
// theme-tuned luminance.
|
|
expect(dark).not.toContain("\x1b[2m");
|
|
expect(light).not.toContain("\x1b[2m");
|
|
});
|
|
});
|