diff --git a/docs/index.html b/docs/index.html index 1bdee633e..9550f141f 100644 --- a/docs/index.html +++ b/docs/index.html @@ -749,7 +749,7 @@
@@ -761,7 +761,7 @@
@@ -773,7 +773,7 @@
diff --git a/src/components/icons/provider-icons.tsx b/src/components/icons/provider-icons.tsx index 8fcce4c32..0a6333c56 100644 --- a/src/components/icons/provider-icons.tsx +++ b/src/components/icons/provider-icons.tsx @@ -98,20 +98,22 @@ export function AmplifierIcon(props: IconProps) { ) } -// Fresh* agent icons — the beaded ring family. Every fresh agent keeps its -// source CLI mark, shrunk to fit, framed by the same ring of fourteen beads -// in the same 24-unit box the plain CLI icons fill (the bead ring's outer -// extent, 11.8, matches the marks' native reach, so fresh and CLI icons read -// as one size). The ring is the fresh marker: monochrome (currentColor), no -// knockout machinery, legible at every rendered size. Each mark shrinks -// about the box center with ~1 unit of clearance to the beads' inner -// extent (10.6 - 1.2 = 9.4) — the transforms below compose the shrink with -// each mark's native fit-the-box transform. - -// Bead ring geometry: 14 dots on radius 10.6, each of radius 1.2. -const BEAD_COUNT = 14 -const BEAD_RING_RADIUS = 10.6 -const BEAD_RADIUS = 1.2 +// Fresh* agent icons — the 14-lobe star ring family. Every fresh agent keeps +// its source CLI mark, shrunk to fit, framed by the same ring in the same +// 24-unit box the plain CLI icons fill (the ring's outer disc, radius 11.8, +// matches the marks' native reach, so fresh and CLI icons read as one +// size). The ring is a solid disc with a 14-lobe star-shaped window cut out +// of it (one evenodd path): smooth outer contour, scalloped inner contour — +// the "double outline" look. Monochrome (currentColor), no knockout +// machinery, legible at every rendered size. Each mark shrinks about the +// box center with ≥ 1 unit of clearance to the window's valley extent +// (radius 9.0) — the transforms below compose the shrink with each mark's +// native fit-the-box transform. + +// Ring geometry: disc radius 11.8; star window with 14 lobes, valleys on +// radius 9.0, lobe apexes on radius ~10.2 (quadratic controls on ~11.63). +const FRESH_RING_PATH = + 'M12 0.2A11.8 11.8 0 1 1 11.99 0.2Z M12.000 3.000Q14.587 0.666 15.905 3.891Q19.249 2.911 19.036 6.389Q22.474 6.956 20.774 9.997Q23.626 12.000 20.774 14.003Q22.474 17.044 19.036 17.611Q19.249 21.089 15.905 20.109Q14.587 23.334 12.000 21.000Q9.413 23.334 8.095 20.109Q4.751 21.089 4.964 17.611Q1.526 17.044 3.226 14.003Q0.374 12.000 3.226 9.997Q1.526 6.956 4.964 6.389Q4.751 2.911 8.095 3.891Q9.413 0.666 12.000 3.000Z' const CLAUDE_MARK_PATH = 'M616.9,649.5h-209.7c0,0,0,104.7,0,104.7h-56.6c0,0,.2-104.5.2-104.5h-48.6s.2,104.5.2,104.5h-56.7c0,0,.2-104.4.2-104.4l-48.6-.7v-96.4c.1,0-104.8,0-104.8,0v-104.9s104.9,0,104.9,0v-201.6c0,0,628.9,0,628.9,0v201.6c0,0,104.9,0,104.9,0v104.9s-104.9,0-104.9,0v96.6c.1,0-56.5.4-56.5.4l.2,104.5h-48.6s.2-104.6.2-104.6h-56.6s.2,104.6.2,104.6h-48.6s.2-104.6.2-104.6ZM351.1,447.5l-.5-96.4h-48.4c0,0,0,96.6,0,96.6l48.8-.2ZM722,447.7l-.4-96.7h-56.5c0,0,0,96.8,0,96.8h56.9Z' @@ -125,9 +127,9 @@ const OPENCODE_MARK_PATHS = [ /** * Shared fresh* icon body: `mark` is the untouched source mark, stamped at * `markTransform` (the shrink-about-center composition that fits it inside - * the bead ring). The beads are plain circles — no masks, no useId-scoped - * ids — so simultaneous renders (tab bar, picker, headers, the deck - * serializer) all serialize and paint identically. + * the star ring's window). The ring is a single evenodd path — no masks, no + * useId-scoped ids — so simultaneous renders (tab bar, picker, headers, the + * deck serializer) all serialize and paint identically. */ function FreshAgentMarkIcon({ mark, markTransform, ...props }: IconProps & { mark: ReactNode; markTransform: string }) { return ( @@ -137,18 +139,7 @@ function FreshAgentMarkIcon({ mark, markTransform, ...props }: IconProps & { mar fill="none" {...props} > - {Array.from({ length: BEAD_COUNT }, (_, i) => { - const angle = (((i * 360) / BEAD_COUNT) - 90) * (Math.PI / 180) - return ( - - ) - })} + {mark} ) diff --git a/test/unit/client/components/Sidebar.test.tsx b/test/unit/client/components/Sidebar.test.tsx index 54b4dd954..7e2b74f52 100644 --- a/test/unit/client/components/Sidebar.test.tsx +++ b/test/unit/client/components/Sidebar.test.tsx @@ -812,7 +812,7 @@ describe('Sidebar Component - Session-Centric Display', () => { // Should NOT show "Claude CLI" label expect(screen.queryByText('Claude CLI: Freshclaude session')).not.toBeInTheDocument() - // The icon SVG should be the FreshclaudeIcon (24-unit beaded-ring + // The icon SVG should be the FreshclaudeIcon (24-unit star-ring // viewBox), not ClaudeIcon (75.73 64.08 872.25 872.25) const svg = rowButton!.querySelector('svg') expect(svg).toBeTruthy() diff --git a/test/unit/client/components/icons/fresh-agent-icons.test.tsx b/test/unit/client/components/icons/fresh-agent-icons.test.tsx index 25fa5c996..ec5e45239 100644 --- a/test/unit/client/components/icons/fresh-agent-icons.test.tsx +++ b/test/unit/client/components/icons/fresh-agent-icons.test.tsx @@ -17,7 +17,25 @@ afterEach(() => { cleanup() }) -describe('fresh* agent icons (beaded ring family)', () => { +type Vec = { x: number; y: number } + +/** Parse the star window of the ring path: valleys (Q endpoints) and controls. */ +function parseStarWindow(d: string) { + // Star subpath: first M after the disc subpath, then 14 quadratic segments. + const starMatch = /Z\s*M([\d.]+) ([\d.]+)((?:Q[\d.]+ [\d.]+ [\d.]+ [\d.]+)+)Z/.exec(d) + if (!starMatch) return null + const valleys: Vec[] = [{ x: Number(starMatch[1]), y: Number(starMatch[2]) }] + const controls: Vec[] = [] + const qs = starMatch[3].match(/Q([\d.]+) ([\d.]+) ([\d.]+) ([\d.]+)/g) ?? [] + for (const q of qs) { + const n = q.slice(1).match(/[\d.]+/g)!.map(Number) + controls.push({ x: n[0], y: n[1] }) + valleys.push({ x: n[2], y: n[3] }) + } + return { valleys, controls } +} + +describe('fresh* agent icons (14-lobe star ring family)', () => { it('freshcodex and freshopencode have dedicated icons, not their CLI marks', () => { expect(FreshcodexIcon).not.toBe(CodexIcon) expect(FreshopencodeIcon).not.toBe(OpencodeIcon) @@ -27,7 +45,7 @@ describe('fresh* agent icons (beaded ring family)', () => { expect(opencodeEntry?.icon).toBe(FreshopencodeIcon) }) - it('each fresh icon frames its source mark in a ring of fourteen beads', () => { + it('each fresh icon frames its source mark in the 14-lobe star ring', () => { const cases = [ [FreshclaudeIcon, 'freshclaude'], [FreshcodexIcon, 'freshcodex'], @@ -38,19 +56,42 @@ describe('fresh* agent icons (beaded ring family)', () => { const svg = container.querySelector('svg') expect(svg, name).toBeTruthy() expect(svg!.getAttribute('viewBox')).toBe('0 0 24 24') - // Fourteen bead circles sit directly under the svg, framing the mark - const beads = [...svg!.querySelectorAll('circle')].filter((c) => c.parentElement === svg) - expect(beads, `${name} bead count`).toHaveLength(14) - for (const bead of beads) { - expect(bead.getAttribute('r'), `${name} bead radius`).toBe('1.2') - const cx = Number(bead.getAttribute('cx')) - const cy = Number(bead.getAttribute('cy')) - expect(Math.hypot(cx - 12, cy - 12), `${name} bead on the ring`).toBeCloseTo(10.6, 2) + // A single evenodd ring path sits directly under the svg: a solid + // disc with a 14-lobe star-shaped window, framing the mark. + const ringPaths = [...svg!.querySelectorAll('path')].filter((p) => p.parentElement === svg) + expect(ringPaths, `${name} ring path count`).toHaveLength(1) + const d = ringPaths[0].getAttribute('d') ?? '' + expect(ringPaths[0].getAttribute('fill-rule'), `${name} evenodd window`).toBe('evenodd') + // Outer disc subpath: radius 11.8 fills the 24-unit box the same way + // the plain CLI marks do, so fresh and CLI icons read as one size. + expect(d.startsWith('M12 0.2A11.8 11.8 0 1 1 11.99 0.2Z'), `${name} disc subpath`).toBe(true) + expect(d.match(/M/g)?.length, `${name} subpath count`).toBe(2) + expect(d.match(/A/g)?.length, `${name} arc count`).toBe(1) + expect(d.match(/Q/g)?.length, `${name} lobe count`).toBe(14) + const star = parseStarWindow(d) + expect(star, `${name} star window`).toBeTruthy() + // Fourteen valleys (closing valley repeats the first) and controls. + expect(star!.valleys).toHaveLength(15) + expect(star!.controls).toHaveLength(14) + for (const v of star!.valleys) { + expect(Math.hypot(v.x - 12, v.y - 12), `${name} valley radius`).toBeCloseTo(9.0, 2) + } + for (const c of star!.controls) { + const r = Math.hypot(c.x - 12, c.y - 12) + expect(r, `${name} control radius`).toBeGreaterThan(11.5) + expect(r, `${name} control radius`).toBeLessThan(11.75) + } + // Each quadratic's apex (curve midpoint) is the lobe tip. + for (let i = 0; i < 14; i++) { + const p0 = star!.valleys[i] + const c = star!.controls[i] + const p1 = star!.valleys[i + 1] + const apex = { x: 0.25 * p0.x + 0.5 * c.x + 0.25 * p1.x, y: 0.25 * p0.y + 0.5 * c.y + 0.25 * p1.y } + const r = Math.hypot(apex.x - 12, apex.y - 12) + expect(r, `${name} lobe apex radius`).toBeGreaterThan(10.05) + expect(r, `${name} lobe apex radius`).toBeLessThan(10.35) } - // Ring outer extent (10.6 + 1.2 = 11.8) fills the 24-unit box the same - // way the plain CLI marks do, so fresh and CLI icons read as one size. - expect(10.6 + 1.2).toBeGreaterThanOrEqual(11.7) - // The mark is the only group, framed by the beads (not masked). + // The mark is the only group, framed by the ring (not masked). expect([...svg!.querySelectorAll('g')].filter((g) => g.parentElement === svg)).toHaveLength(1) } }) @@ -64,9 +105,10 @@ describe('fresh* agent icons (beaded ring family)', () => { expect(opencode).toContain('M520,180h200v300h-240') // OpenCode frame }) - it('shrinks each mark to fit inside the ring with breathing room', () => { - // The ring's inner extent is 10.6 - 1.2 = 9.4. Each mark's composed - // scale must place its farthest ink inside that with ~1 unit of air. + it('shrinks each mark to fit inside the star window with breathing room', () => { + // The ring window's inner extent is the valleys at radius 9.0. Each + // mark's composed scale must place its farthest ink inside that with + // ~1 unit of air (Option A proportions: reach 7.83–7.92). // Ink radii below are analyzer-measured in each mark's SOURCE viewBox // units (the same units its native fit-the-box transform consumes): // claude 423.2 (872.25-wide source), codex 8.215 (16.55), opencode @@ -83,8 +125,8 @@ describe('fresh* agent icons (beaded ring family)', () => { const scale = Number(/scale\(([\d.]+)\)/.exec(markGroup!.getAttribute('transform') ?? '')?.[1]) expect(scale).toBeGreaterThan(0) const reach = scale * inkRadius - expect(reach).toBeLessThanOrEqual(8.5) // ≥ 0.9 units clear of the beads - expect(reach).toBeGreaterThanOrEqual(7.0) // mark stays prominent inside the ring + expect(reach).toBeLessThanOrEqual(8.0) // ≥ 1.0 units clear of the valleys + expect(reach).toBeGreaterThanOrEqual(7.5) // mark stays prominent inside the ring } }) @@ -113,7 +155,10 @@ describe('fresh* agent icons (beaded ring family)', () => { const raw = renderToStaticMarkup(createElement(FreshclaudeIcon)) expect(raw.startsWith('