diff --git a/assets/mark-accent.svg b/assets/mark-accent.svg index 72b8b68..d55d04f 100644 --- a/assets/mark-accent.svg +++ b/assets/mark-accent.svg @@ -4,13 +4,13 @@ - - + + - - + + diff --git a/assets/mark-arc.svg b/assets/mark-arc.svg new file mode 100644 index 0000000..d3570ea --- /dev/null +++ b/assets/mark-arc.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/mark-disc.svg b/assets/mark-disc.svg new file mode 100644 index 0000000..da79b98 --- /dev/null +++ b/assets/mark-disc.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/mark-orbit.svg b/assets/mark-orbit.svg new file mode 100644 index 0000000..fa679a5 --- /dev/null +++ b/assets/mark-orbit.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/mark-seal.svg b/assets/mark-seal.svg index 22b519a..103ef81 100644 --- a/assets/mark-seal.svg +++ b/assets/mark-seal.svg @@ -31,19 +31,6 @@ - - - - - - - - - - - - - @@ -73,12 +60,12 @@ - - - - - - + + + + + + diff --git a/assets/mark-warm.svg b/assets/mark-warm.svg index d01e589..19f45a5 100644 --- a/assets/mark-warm.svg +++ b/assets/mark-warm.svg @@ -4,13 +4,13 @@ - - + + - - + + diff --git a/assets/mascot-bead.svg b/assets/mascot-bead.svg new file mode 100644 index 0000000..f7112cc --- /dev/null +++ b/assets/mascot-bead.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/assets/mascot-cairn.svg b/assets/mascot-cairn.svg new file mode 100644 index 0000000..c9e649f --- /dev/null +++ b/assets/mascot-cairn.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/assets/mascot-desk.svg b/assets/mascot-desk.svg new file mode 100644 index 0000000..28f8b1b --- /dev/null +++ b/assets/mascot-desk.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/mascot-dot.svg b/assets/mascot-dot.svg new file mode 100644 index 0000000..8330f39 --- /dev/null +++ b/assets/mascot-dot.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/assets/mascot-halo.svg b/assets/mascot-halo.svg new file mode 100644 index 0000000..7e71bb1 --- /dev/null +++ b/assets/mascot-halo.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/mascot-page.svg b/assets/mascot-page.svg new file mode 100644 index 0000000..215e2f5 --- /dev/null +++ b/assets/mascot-page.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/mascot-signal.svg b/assets/mascot-signal.svg new file mode 100644 index 0000000..8b45699 --- /dev/null +++ b/assets/mascot-signal.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/mascot-turn.svg b/assets/mascot-turn.svg new file mode 100644 index 0000000..2fde385 --- /dev/null +++ b/assets/mascot-turn.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/assets/mascot-twins.svg b/assets/mascot-twins.svg new file mode 100644 index 0000000..d35b09d --- /dev/null +++ b/assets/mascot-twins.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/mascot.svg b/assets/mascot.svg index 210bd9f..dedc9e1 100644 --- a/assets/mascot.svg +++ b/assets/mascot.svg @@ -1,27 +1,31 @@ - - - - - - - - - - - + + + + + + + + + + + + + + + + + - - - + - + - - - - + + + + diff --git a/scripts/build-identity.py b/scripts/build-identity.py index d42dfb3..5e45135 100644 --- a/scripts/build-identity.py +++ b/scripts/build-identity.py @@ -28,9 +28,7 @@ INK = logo.INK EDGE = logo.EDGE ACCENT = "#6a9fcc" -DEEP = "#0d1116" WARM = "#e1b06e" -WARM_INK = "#13110f" def svg(inner: str, view: str) -> str: @@ -136,6 +134,9 @@ def outline_glyphs(font_path: Path, text: str, size: float) -> list[tuple[str, f def seal_with_upem(upem: int) -> str: ticks = [] for i in range(72): + # Leave the bottom of the ring clear for the word. + if 30 <= i <= 42: + continue angle = math.radians(i * 5 - 90) inner = 64 if i % 6 else 58 outer = 72 @@ -144,24 +145,18 @@ def seal_with_upem(upem: int) -> str: ticks.append( f'' ) - size = 13.0 + size = 11.0 letters = outline_glyphs(DEPARTURE, "PIDEX", size) - gap = size * 0.42 - width = sum(advance for _, advance in letters) + gap * (len(letters) - 1) - radius = 52 - cursor = -width / 2 - glyphs = [] + gap = size * 0.2 + tracked = sum(advance for _, advance in letters) + gap * (len(letters) - 1) scale = size / upem + cursor = 90 - tracked / 2 + glyphs = [] for commands, advance in letters: - mid = cursor + advance / 2 - theta = mid / radius - x = 90 + radius * math.sin(theta) - y = 90 - radius * math.cos(theta) - deg = math.degrees(theta) if commands: glyphs.append( f'' + f'transform="translate({cursor:.2f} 154) scale({scale:.6f} {-scale:.6f})"/>' ) cursor += advance + gap cube = logo.cube_markup(logo.PRIMARY, "pi-face-seal") @@ -172,7 +167,7 @@ def seal_with_upem(upem: int) -> str: f'', *ticks, *glyphs, - '', + '', cube, "", ] @@ -282,44 +277,388 @@ def xy(p: float, q: float) -> tuple[float, float]: return [xy(1 + (p - 1) * scale, q * scale) for p, q in corners] -def mascot() -> str: - body, _ = iso_cube(78, 78, 78, logo.TOP, logo.RIGHT, logo.LEFT, EDGE) - dx = 78 * 0.8660254037844386 - dy = 78 * 0.5 - # Back tip of the top face is (cx, cy - dy). The Pi's top-left lands there. +def pi_on_face(cx: float, cy: float, e: float, clip_id: str = "mascot-face") -> str: + dx = e * 0.8660254037844386 + dy = e * 0.5 b = dy / logo.PI_SPAN - ty = (78 - dy) - 2 * b * 165.29 - pi_scale_matrix = ( - f"matrix({dx / logo.PI_SPAN:.8f} {b:.8f} {-dx / logo.PI_SPAN:.8f} {b:.8f} 78.00 {ty:.2f})" + # Back tip of the top face is (cx, cy - dy). The Pi's top-left lands there. + ty = (cy - dy) - 2 * b * 165.29 + matrix = f"matrix({dx / logo.PI_SPAN:.8f} {b:.8f} {-dx / logo.PI_SPAN:.8f} {b:.8f} {cx:.2f} {ty:.2f})" + face = " ".join(f"{x:.2f},{y:.2f}" for x, y in diamond(cx, cy, e, 1)) + return "\n".join( + [ + "", + f' ', + "", + f'', + f' ', + f' ', + f' ', + " ", + "", + ] + ) + + +def iso_prism( + cx: float, + cy: float, + width: float, + depth: float, + height: float, + top: str, + right: str, + left: str, + edge: str, +) -> str: + """Top-face centre at (cx, cy). A cube is width = depth = height.""" + rx = 0.8660254037844386 + + def pt(across: float, along: float, down: float = 0) -> tuple[float, float]: + return (cx + across * (width / 2) * rx, cy + along * (depth / 2) + down) + + back, right_pt, front, left_pt = pt(0, -1), pt(1, 0), pt(0, 1), pt(-1, 0) + + def drop(p: tuple[float, float]) -> tuple[float, float]: + return (p[0], p[1] + height) + + def poly(points: list[tuple[float, float]], fill: str) -> str: + body = " ".join(f"{x:.2f},{y:.2f}" for x, y in points) + return ( + f'' + ) + + return "\n".join( + [ + poly([left_pt, front, drop(front), drop(left_pt)], left), + poly([right_pt, front, drop(front), drop(right_pt)], right), + poly([back, right_pt, front, left_pt], top), + ] + ) + + +FOOT = "#243246" +FOOT_RIGHT = "#31475c" +FOOT_LEFT = "#1a2432" + + +def feet_at(cx: float, ground: float, spread: float, size: float) -> list[str]: + cy = ground - 1.5 * size + size * 0.45 + return [ + iso_cube(cx - spread, cy, size, FOOT, FOOT_RIGHT, FOOT_LEFT, EDGE)[0], + iso_cube(cx + spread, cy, size, FOOT, FOOT_RIGHT, FOOT_LEFT, EDGE)[0], + ] + + +def standing_dot(cx: float, ground: float, e: float) -> list[str]: + cy = ground - 1.5 * e + body, _ = iso_cube(cx, cy, e, logo.TOP, logo.RIGHT, logo.LEFT, EDGE) + mark = " ".join(f"{x:.2f},{y:.2f}" for x, y in diamond(cx, cy, e, 0.48)) + return [ + *feet_at(cx, ground, e * 0.28, e * 0.28), + body, + f'', + ] + + +def mascot() -> str: + # Both cubes share a front-bottom line, with a gap between them. + ground = 196.0 + body_e = 86.0 + body_cx, body_cy = 82.0, ground - 1.5 * body_e + side_e = 40.0 + side_cx, side_cy = 196.0, ground - 1.5 * side_e + body, _ = iso_cube(body_cx, body_cy, body_e, logo.TOP, logo.RIGHT, logo.LEFT, EDGE) + side, _ = iso_cube(side_cx, side_cy, side_e, logo.TOP, logo.RIGHT, logo.LEFT, EDGE) + dot = " ".join(f"{x:.2f},{y:.2f}" for x, y in diamond(side_cx, side_cy, side_e, 0.48)) + feet = [ + *feet_at(body_cx, ground, 26, 18), + *feet_at(side_cx, ground, 10, 11), + ] + return svg( + "\n".join( + [ + '', + *feet, + body, + pi_on_face(body_cx, body_cy, body_e), + side, + f'', + ] + ), + "0 0 260 230", + ) + + +def mascot_dot() -> str: + ground = 158.0 + return svg( + "\n".join( + [ + '', + *standing_dot(80, ground, 70), + ] + ), + "0 0 160 186", + ) + + +def mascot_page() -> str: + """A standing sheet. Thin in depth, so the broad face reads as a page.""" + ground = 198.0 + page = iso_prism(86, 64, 112, 14, 128, logo.TOP, logo.RIGHT, logo.LEFT, EDGE) + return svg( + "\n".join( + [ + '', + *feet_at(86, ground, 22, 14), + page, + '', + pi_group(58, 124, 46, INK), + *standing_dot(186, ground, 42), + ] + ), + "0 0 250 228", + ) + + +def mascot_desk() -> str: + """The pair at a low slab, with a sheet on the desk.""" + ground = 168.0 + desk = iso_prism(124, 158, 200, 86, 16, "#243246", "#31475c", "#1a2432", EDGE) + page = iso_prism(132, 118, 78, 12, 70, "#2a3b50", "#3d536b", "#1c2838", EDGE) + return svg( + "\n".join( + [ + '', + *feet_at(64, ground, 16, 12), + iso_cube(64, ground - 1.5 * 58, 58, logo.TOP, logo.RIGHT, logo.LEFT, EDGE)[0], + pi_on_face(64, ground - 1.5 * 58, 58, "desk-face"), + desk, + page, + '', + *standing_dot(200, 162, 30), + ] + ), + "0 0 270 224", + ) + + +def sphere(cx: float, cy: float, r: float, clip_id: str, *, glint: bool = True) -> str: + """A round bead: dark base, lit face, and a moonstone glint.""" + parts = [ + "", + f' ', + "", + f'', + f' ', + f' ', + ] + if glint: + parts.append( + f' ' + ) + parts += [ + "", + f'', + ] + return "\n".join(parts) + + +def ground_shadow(cx: float, y: float, rx: float) -> str: + return f'' + + +def mascot_signal() -> str: + """A cube with the Pi, and a bead held up on a stem.""" + ground = 168.0 + e = 64.0 + cx = 90.0 + cy = ground - 1.5 * e + tip_y = cy - e * 0.5 + head_r = 16.0 + head_y = tip_y - head_r - 14 + body, _ = iso_cube(cx, cy, e, logo.TOP, logo.RIGHT, logo.LEFT, EDGE) + return svg( + "\n".join( + [ + ground_shadow(cx, ground + 14, 62), + *feet_at(cx, ground, 16, 12), + body, + pi_on_face(cx, cy, e, "signal-face"), + ( + f'' + ), + sphere(cx, head_y, head_r, "signal-head"), + ] + ), + "0 0 180 196", + ) + + +def mascot_twins() -> str: + """The pair, drawn as two beads on one ground.""" + ground = 148.0 + big_r, small_r = 46.0, 24.0 + big_x, small_x = 74.0, 156.0 + big_y, small_y = ground - big_r, ground - small_r + size = 46.0 + return svg( + "\n".join( + [ + ground_shadow(112, ground + 14, 92), + sphere(big_x, big_y, big_r, "twin-big", glint=False), + pi_group(big_x - size / 2, big_y - size / 2 + 2, size, INK), + sphere(small_x, small_y, small_r, "twin-small"), + ] + ), + "0 0 210 176", + ) + + +def mascot_cairn() -> str: + """Three cubes stacked. Each one sits on the centre of the face below.""" + ground = 186.0 + cx = 96.0 + sizes = (68.0, 44.0, 26.0) + cursor = ground + placed: list[tuple[float, float, float]] = [] + for e in sizes: + cy = cursor - 1.5 * e + placed.append((cx, cy, e)) + cursor = cy + e * 0.35 + parts = [ground_shadow(cx, ground + 12, 72), *feet_at(cx, ground, 18, 13)] + for x, cy, e in placed: + parts.append(iso_cube(x, cy, e, logo.TOP, logo.RIGHT, logo.LEFT, EDGE)[0]) + top_x, top_cy, top_e = placed[-1] + dot = " ".join(f"{x:.2f},{y:.2f}" for x, y in diamond(top_x, top_cy, top_e, 0.46)) + parts.append(f'') + return svg("\n".join(parts), "0 0 190 214") + + +def mascot_turn() -> str: + """The open arc, with the centre dot grown into a bead.""" + cx, cy, radius = 112.0, 96.0, 68.0 + bead_r = 30.0 + return svg( + "\n".join( + [ + ground_shadow(cx, cy + bead_r + 16, 52), + ( + f'' + ), + sphere(cx, cy, bead_r, "turn-bead"), + ( + f'' + ), + ] + ), + "0 0 200 168", ) - dot_cube, _ = iso_cube(168, 118, 36, logo.TOP, logo.RIGHT, logo.LEFT, EDGE) - dot = diamond(168, 118, 36, 0.5) - dot_pts = " ".join(f"{x:.2f},{y:.2f}" for x, y in dot) - left_foot, _ = iso_cube(52, 168, 20, "#243246", "#31475c", "#1a2432", EDGE) - right_foot, _ = iso_cube(104, 168, 20, "#243246", "#31475c", "#1a2432", EDGE) + + +def mascot_halo() -> str: + """The standing cube, with a ring in the plane of its top face.""" + ground = 164.0 + e = 58.0 + cx = 104.0 + cy = ground - 1.5 * e + rx, ry = 84.0, 40.0 + body, _ = iso_cube(cx, cy, e, logo.TOP, logo.RIGHT, logo.LEFT, EDGE) + hidden = body.replace(logo.TOP, "#000").replace(logo.RIGHT, "#000").replace(logo.LEFT, "#000").replace(EDGE, "#000") return svg( "\n".join( [ - '', - left_foot, - right_foot, + ground_shadow(cx, ground + 14, 86), + "", + ' ', + ' ', + hidden, + " ", + "", + *feet_at(cx, ground, 14, 11), body, + pi_on_face(cx, cy, e, "halo-face"), + ( + f'' + ), + sphere(cx + rx, cy, 9, "halo-bead"), + ] + ), + "0 0 220 196", + ) + + +def disc() -> str: + # The Pi's square must sit inside the circle, so size is the inscribed square. + size = 84 + origin = 80 - size / 2 + return svg( + "\n".join( + [ + f'', + '', + pi_group(origin, origin, size, INK), + ] + ), + "0 0 160 160", + ) + + +def arc() -> str: + # A turn: centre, radius, and an open curve with round caps. + return svg( + "\n".join( + [ + f'', + f'', + ( + f'' + ), + ] + ), + "0 0 160 160", + ) + + +def orbit() -> str: + return svg( + "\n".join( + [ + f'', + f'', + f'', + f'', + ] + ), + "0 0 160 160", + ) + + +def bead() -> str: + """The square dot, drawn as a sphere.""" + return svg( + "\n".join( + [ + '', "", - ' ', - f' ', - " ", + ' ', "", - '', - f' ', - f' ', - f' ', - " ", + '', + ' ', + ' ', + f' ', "", - dot_cube, - f'', + f'', ] ), - "0 0 230 230", + "0 0 160 168", ) @@ -330,15 +669,30 @@ def main() -> None: write("mark-stencil.svg", stencil()) write("mark-blueprint.svg", blueprint()) write("mark-seal.svg", seal_with_upem(upem)) - write("mark-accent.svg", logo.build_mark_svg(logo.Palette("#6a9fcc", "#4b607c", "#2c4058", "#d5e4f2", DEEP), "pi-face-accent")) + write( + "mark-accent.svg", + logo.build_mark_svg(logo.Palette("#6a9fcc", "#4d6d8f", "#24384c", "#d5e4f2", INK), "pi-face-accent"), + ) write( "mark-warm.svg", - logo.build_mark_svg(logo.Palette(WARM, "#a56a42", "#6e4632", "#f4e4cf", WARM_INK), "pi-face-warm"), + logo.build_mark_svg(logo.Palette(WARM, "#8f6248", "#5c4034", "#f4e4cf", INK), "pi-face-warm"), ) write("logo-wordmark.svg", wordmark()) write("logo-editorial.svg", editorial()) write("logo-label.svg", label_lockup(upem)) write("mascot.svg", mascot()) + write("mascot-dot.svg", mascot_dot()) + write("mascot-page.svg", mascot_page()) + write("mascot-desk.svg", mascot_desk()) + write("mark-disc.svg", disc()) + write("mark-arc.svg", arc()) + write("mark-orbit.svg", orbit()) + write("mascot-bead.svg", bead()) + write("mascot-signal.svg", mascot_signal()) + write("mascot-twins.svg", mascot_twins()) + write("mascot-cairn.svg", mascot_cairn()) + write("mascot-turn.svg", mascot_turn()) + write("mascot-halo.svg", mascot_halo()) if __name__ == "__main__": diff --git a/src/renderer/components/AgentLoop.tsx b/src/renderer/components/AgentLoop.tsx new file mode 100644 index 0000000..5012b1f --- /dev/null +++ b/src/renderer/components/AgentLoop.tsx @@ -0,0 +1,26 @@ +const STEPS = [ + ['think', 'ds-loop-node--think'], + ['reply', 'ds-loop-node--reply'], + ['tool', 'ds-loop-node--tool'], + ['result', 'ds-loop-node--result'], +] as const; + +function AgentLoop() { + return ( +
+
+ + {STEPS.map(([label, place]) => ( + + {label} + + ))} +
+
+ ); +} + +export { AgentLoop }; diff --git a/src/renderer/components/DeskObjects.tsx b/src/renderer/components/DeskObjects.tsx new file mode 100644 index 0000000..4879d45 --- /dev/null +++ b/src/renderer/components/DeskObjects.tsx @@ -0,0 +1,57 @@ +import type { ReactNode } from 'react'; +import { PiMark } from './PiMark'; + +function ObjectStage({ label, children }: { label: string; children: ReactNode }) { + return ( +
+
{children}
+ + {label} + +
+ ); +} + +function SheetTurn() { + return ( + +
+
+ + + +
+
+
+ + ); +} + +function PanelStack() { + return ( + +
+
+
+
+
+ + ); +} + +function TokenSpin() { + return ( + +
+
+ +
+
+ +
+
+
+ ); +} + +export { PanelStack, SheetTurn, TokenSpin }; diff --git a/src/renderer/components/FrameLoader.tsx b/src/renderer/components/FrameLoader.tsx new file mode 100644 index 0000000..c067487 --- /dev/null +++ b/src/renderer/components/FrameLoader.tsx @@ -0,0 +1,32 @@ +const CORNERS = ['tl', 'tr', 'bl', 'br'] as const; + +function Plane({ back = false }: { back?: boolean }) { + const plane = ['ds-frame-loader-plane', back ? 'ds-frame-loader-plane--back' : 'ds-frame-loader-plane--front'] + .filter(Boolean) + .join(' '); + return ( +
+ {CORNERS.map(corner => ( + + ))} +
+ ); +} + +function FrameLoader({ label = 'loading' }: { label?: string }) { + return ( +
+
+
+ + +
+
+ + {label} + +
+ ); +} + +export { FrameLoader }; diff --git a/src/renderer/components/GlyphFamily.tsx b/src/renderer/components/GlyphFamily.tsx new file mode 100644 index 0000000..52f502d --- /dev/null +++ b/src/renderer/components/GlyphFamily.tsx @@ -0,0 +1,327 @@ +import { useEffect, useId, useState, type CSSProperties } from 'react'; +import { usePrefersReducedMotion, type MascotState } from './LiveMascots'; + +/** + * The Glyph family: the Pi mark stood up as a character. The P carries the face, the square + * dot is its companion. Everything here reads the look and state from a surrounding PiSurface. + */ + +// The mark on its 4×4 grid. The dot is one column wide and two cells tall. +const P_CELLS: [number, number][] = [[0, 0], [1, 0], [2, 0], [0, 1], [2, 1], [0, 2], [1, 2], [0, 3]]; +const DOT_CELLS: [number, number][] = [[3, 2], [3, 3]]; + +function pPath(ox: number, oy: number, u: number) { + const x = (n: number) => ox + n * u; + const y = (n: number) => oy + n * u; + return `M${x(0)} ${y(0)} H${x(3)} V${y(2)} H${x(2)} V${y(3)} H${x(1)} V${y(4)} H${x(0)} Z M${x(1)} ${y(1)} V${y(2)} H${x(2)} V${y(1)} Z`; +} + +function Eye({ x, y, w = 6, h = 11 }: { x: number; y: number; w?: number; h?: number }) { + return ; +} + +/** The mark with its face, cropped tight. For lockups and small sizes. */ +function GlyphMark({ size = 48, face = true }: { size?: number; face?: boolean }) { + const u = 22; + return ( + + ); +} + +/** Peek: the counter of the P is a window, and the eyes look out through it. */ +function GlyphPeek() { + const id = useId().replace(/:/g, ''); + const u = 24; + const ox = 18; + const oy = 36; + return ( + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +/** Carry: the dot rides on the P's head. */ +function GlyphCarry() { + const u = 22; + const ox = 36; + const oy = 58; + return ( + + + + + + + + + + + + + + + + ); +} + +/** Walk: the pair on the road. The ground moves while the run is going. */ +function GlyphWalk() { + const u = 20; + const ox = 58; + const oy = 52; + return ( + + + {Array.from({ length: 18 }, (_, i) => ( + + ))} + + + + + + + + + + + + + + + ); +} + +// Where each cell flies to while the mark takes itself apart, and where it lands in a heap. +const SCATTER = [ + [-30, -26, -35], [-6, -40, 20], [24, -30, 40], [-40, 4, -20], [36, -6, 25], + [-26, 30, 15], [10, 26, -30], [-14, 44, 45], [30, 18, -15], [22, 36, 30], +]; +const PILE = [ + [16, 118, 0], [40, 118, 6], [64, 118, -4], [88, 118, 3], [112, 118, -8], + [134, 120, 12], [28, 96, -10], [52, 96, 4], [76, 97, -6], [100, 95, 15], +]; + +/** Assemble: the mark built from its own grid. It comes apart while it thinks. */ +function GlyphAssemble() { + const u = 22; + const ox = 36; + const oy = 40; + const cells = [...P_CELLS, ...DOT_CELLS]; + return ( + + + + + {cells.map(([cx, cy], i) => { + const x = ox + cx * u; + const y = oy + cy * u; + const [sx, sy, sr] = SCATTER[i]; + const [px, py, pr] = PILE[i]; + const isDot = i >= P_CELLS.length; + return ( + + ); + })} + + + + + + + ); +} + +const LAYERS = 12; + +/** Extrude: the mark as a solid, stacked from flat layers. */ +function GlyphExtrude() { + const u = 22; + return ( +
+
+ {Array.from({ length: LAYERS }, (_, i) => ( + + ))} +
+ +
+ ); +} + +/** The wordmark, with the glyph beside it and the square dot as the tittle on the i. */ +function GlyphWordmark() { + return ( +
+ +
+ ); +} + +// Pixel glyph: 11 × 10 pixels packed into half blocks, two pixel rows per character. +type Eyes = 'center' | 'left' | 'right' | 'shut' | 'wide'; +type PixelSpec = { eyes: Eyes; dot: number | 'down' }; + +const EYE_COLS: Record = { center: [1, 4], left: [0, 3], right: [2, 5], shut: [], wide: [1, 4] }; + +function pixelGrid({ eyes, dot }: PixelSpec) { + const W = 11; + const H = 10; + const grid: ('p' | 'd' | null)[][] = Array.from({ length: H }, () => Array<'p' | 'd' | null>(W).fill(null)); + for (const [cx, cy] of P_CELLS) { + for (let dy = 0; dy < 2; dy++) for (let dx = 0; dx < 2; dx++) grid[2 + cy * 2 + dy][cx * 2 + dx] = 'p'; + } + for (const col of EYE_COLS[eyes]) { + grid[3][col] = null; + if (eyes === 'wide') grid[2][col] = null; + } + if (dot === 'down') { + for (let r = 8; r < 10; r++) for (let c = 7; c < 11; c++) grid[r][c] = 'd'; + } else { + for (let r = 6 + dot; r < 10 + dot; r++) for (let c = 6; c < 8; c++) grid[r][c] = 'd'; + } + const rows: { ch: string; dot: boolean }[][] = []; + for (let r = 0; r < H; r += 2) { + rows.push( + grid[r].map((top, c) => { + const bottom = grid[r + 1][c]; + const ch = top && bottom ? '█' : top ? '▀' : bottom ? '▄' : ' '; + return { ch, dot: top === 'd' || bottom === 'd' }; + }), + ); + } + return rows; +} + +const GLYPH_PIXEL: Record = { + idle: [ + { eyes: 'center', dot: 0 }, + { eyes: 'center', dot: 0 }, + { eyes: 'left', dot: 0 }, + { eyes: 'center', dot: 0 }, + { eyes: 'right', dot: 0 }, + { eyes: 'shut', dot: 0 }, + ], + thinking: [ + { eyes: 'right', dot: 0 }, + { eyes: 'right', dot: -1 }, + { eyes: 'right', dot: -2 }, + { eyes: 'right', dot: -1 }, + ], + input: [ + { eyes: 'wide', dot: -2 }, + { eyes: 'wide', dot: -2 }, + { eyes: 'wide', dot: 0 }, + ], + blocked: [ + { eyes: 'right', dot: 'down' }, + { eyes: 'right', dot: 'down' }, + { eyes: 'shut', dot: 'down' }, + ], + done: [ + { eyes: 'shut', dot: -2 }, + { eyes: 'shut', dot: -1 }, + { eyes: 'shut', dot: 0 }, + { eyes: 'shut', dot: 0 }, + ], +}; + +/** The glyph in block characters, for the terminal. The dot takes the state colour. */ +function GlyphPixel({ state }: { state: MascotState }) { + const reduced = usePrefersReducedMotion(); + const [frame, setFrame] = useState(0); + useEffect(() => { + setFrame(0); + if (reduced) return; + const timer = window.setInterval(() => setFrame((n) => n + 1), state === 'idle' ? 650 : 240); + return () => window.clearInterval(timer); + }, [state, reduced]); + const frames = GLYPH_PIXEL[state]; + const rows = pixelGrid(frames[frame % frames.length]); + return ( +
+      {rows.map((row, r) => (
+        
+          {row.map((cell, c) => (
+            
+              {cell.ch}
+            
+          ))}
+          {r < rows.length - 1 ? '\n' : null}
+        
+      ))}
+    
+ ); +} + +export { GlyphAssemble, GlyphCarry, GlyphExtrude, GlyphMark, GlyphPeek, GlyphPixel, GlyphWalk, GlyphWordmark }; diff --git a/src/renderer/components/LiveMascots.tsx b/src/renderer/components/LiveMascots.tsx new file mode 100644 index 0000000..d83bd34 --- /dev/null +++ b/src/renderer/components/LiveMascots.tsx @@ -0,0 +1,436 @@ +import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'; +import { PiMark } from './PiMark'; + +type MascotState = 'idle' | 'thinking' | 'input' | 'blocked' | 'done'; +type MascotTone = 'blue' | 'accent' | 'warm' | 'sage' | 'rust'; +type MascotGear = 'none' | 'antenna' | 'halo' | 'bead'; + +const PI_D = + 'M165.29 165.29H517.36V400H400V517.36H282.65V634.72H165.29ZM282.65 282.65V400H400V282.65Z'; +const PI_DOT_D = 'M517.36 400H634.72V634.72H517.36Z'; +const PI_SPAN = 634.72 - 165.29; +const RX = 0.8660254037844386; + +// Isometric cube, top-face centre at (cx, cy), edge e. Same geometry as build-identity.py. +function cubeFaces(cx: number, cy: number, e: number) { + const dx = e * RX; + const dy = e / 2; + const pts = (list: [number, number][]) => list.map(([x, y]) => `${x.toFixed(2)},${y.toFixed(2)}`).join(' '); + return { + top: pts([[cx, cy - dy], [cx + dx, cy], [cx, cy + dy], [cx - dx, cy]]), + right: pts([[cx + dx, cy], [cx, cy + dy], [cx, cy + dy + e], [cx + dx, cy + e]]), + left: pts([[cx - dx, cy], [cx, cy + dy], [cx, cy + dy + e], [cx - dx, cy + e]]), + // Maps (along, down) on the right face, origin at its front-top corner. + rightFace: `matrix(${RX} -0.5 0 1 ${cx.toFixed(2)} ${(cy + dy).toFixed(2)})`, + leftFace: `matrix(${-RX} -0.5 0 1 ${cx.toFixed(2)} ${(cy + dy).toFixed(2)})`, + topFace: (() => { + const b = dy / PI_SPAN; + const ty = cy - dy - 2 * b * 165.29; + return `matrix(${(dx / PI_SPAN).toFixed(6)} ${b.toFixed(6)} ${(-dx / PI_SPAN).toFixed(6)} ${b.toFixed(6)} ${cx.toFixed(2)} ${ty.toFixed(2)})`; + })(), + }; +} + +function Cube({ cx, cy, e, pi = false, children }: { cx: number; cy: number; e: number; pi?: boolean; children?: ReactNode }) { + const f = cubeFaces(cx, cy, e); + return ( + <> + + + + {pi ? ( + + + + + ) : null} + {children} + + ); +} + +// Writes the pointer position into --look-x / --look-y, each -1..1, on the stage element. +function usePointerLook() { + const ref = useRef(null); + useEffect(() => { + const node = ref.current; + if (!node) return; + const move = (event: PointerEvent) => { + const box = node.getBoundingClientRect(); + const x = ((event.clientX - box.left) / box.width) * 2 - 1; + const y = ((event.clientY - box.top) / box.height) * 2 - 1; + node.style.setProperty('--look-x', Math.max(-1, Math.min(1, x)).toFixed(3)); + node.style.setProperty('--look-y', Math.max(-1, Math.min(1, y)).toFixed(3)); + }; + const leave = () => { + node.style.setProperty('--look-x', '0'); + node.style.setProperty('--look-y', '0'); + }; + window.addEventListener('pointermove', move); + node.addEventListener('pointerleave', leave); + return () => { + window.removeEventListener('pointermove', move); + node.removeEventListener('pointerleave', leave); + }; + }, []); + return ref; +} + +function Eyes({ at, down, gap, w, h }: { at: number; down: number; gap: number; w: number; h: number }) { + return ( + + + + + ); +} + +/** Signals drawn above the head. Only the one for the current state is visible. */ +function HeadSignals({ cx, y }: { cx: number; y: number }) { + return ( + + + + + + + + + + + + + + + {[-1, 1].flatMap((sx) => + [-1, 1].map((sy) => ( + + )), + )} + + + ); +} + +function Gear({ gear, cx, top }: { gear: MascotGear; cx: number; top: number }) { + if (gear === 'antenna') { + return ( + + + + + ); + } + if (gear === 'halo') { + return ( + + + + + ); + } + if (gear === 'bead') { + return ( + + + + ); + } + return null; +} + +type BlinkProps = { + state: MascotState; + tone?: MascotTone; + gear?: MascotGear; + /** Body only, cropped tight. For avatars in lists. */ + compact?: boolean; + label?: string; +}; + +/** The cube and the dot with faces. Eyes follow the pointer, and the face and signals carry the agent state. */ +function BlinkMascot({ state, tone = 'blue', gear = 'none', compact = false, label }: BlinkProps) { + const ref = usePointerLook(); + const body = cubeFaces(110, 70, 90); + const dot = cubeFaces(226, 150, 40); + return ( +
+ +
+ ); +} + +// Text-only Blink for terminals and notifications. 7 columns, block elements only. +const PIXEL_FEET = ' ▀▘ ▝▀ '; +// Eyes and mouths are quarter- and half-cell holes, the way Clawd draws its eyes. +const PIXEL_EYES = { center: '▐█▛█▜█▌', left: '▐▛█▛██▌', right: '▐██▜█▜▌', shut: '▐█▀█▀█▌', wide: '▐█ █ █▌', flat: '▐█▄█▄█▌' }; +const PIXEL_MOUTH = { rest: '▐█████▌', smile: '▐█▙▄▟█▌', frown: '▐█▛▀▜█▌', open: '▐██▄██▌' }; + +// Six rows: signal, headroom, lid, eyes, mouth, feet. A lifted frame spends the headroom and tucks the feet. +function pixelFrame(signal: string, eyes: string, mouth: string, lift = false, shift = 0) { + const blank = ' '; + const rows = lift + ? [signal, '▗▄▄▄▄▄▖', eyes, mouth, ' ▘ ▝ ', blank] + : [signal, blank, '▗▄▄▄▄▄▖', eyes, mouth, PIXEL_FEET]; + const pad = (row: string) => (shift > 0 ? ` ${row.slice(0, -1)}` : shift < 0 ? `${row.slice(1)} ` : row); + return rows.map(pad).join('\n'); +} + +const PIXEL_FRAMES: Record = { + idle: [ + pixelFrame(' ', PIXEL_EYES.center, PIXEL_MOUTH.rest), + pixelFrame(' ', PIXEL_EYES.center, PIXEL_MOUTH.rest), + pixelFrame(' ', PIXEL_EYES.left, PIXEL_MOUTH.rest), + pixelFrame(' ', PIXEL_EYES.center, PIXEL_MOUTH.rest), + pixelFrame(' ', PIXEL_EYES.right, PIXEL_MOUTH.rest), + pixelFrame(' ', PIXEL_EYES.shut, PIXEL_MOUTH.rest), + ], + thinking: [ + pixelFrame(' ▪ ', PIXEL_EYES.right, PIXEL_MOUTH.rest), + pixelFrame(' ▪ ▪ ', PIXEL_EYES.right, PIXEL_MOUTH.rest), + pixelFrame(' ▪ ▪ ▪ ', PIXEL_EYES.right, PIXEL_MOUTH.rest), + pixelFrame(' ', PIXEL_EYES.right, PIXEL_MOUTH.rest), + ], + input: [ + pixelFrame(' ▮ ', PIXEL_EYES.wide, PIXEL_MOUTH.open), + pixelFrame(' ▮ ', PIXEL_EYES.wide, PIXEL_MOUTH.open, true), + pixelFrame(' ', PIXEL_EYES.wide, PIXEL_MOUTH.open), + ], + blocked: [ + pixelFrame(' ✕ ', PIXEL_EYES.flat, PIXEL_MOUTH.frown), + pixelFrame(' ✕ ', PIXEL_EYES.flat, PIXEL_MOUTH.frown, false, 1), + pixelFrame(' ✕ ', PIXEL_EYES.flat, PIXEL_MOUTH.frown, false, -1), + pixelFrame(' ✕ ', PIXEL_EYES.flat, PIXEL_MOUTH.frown), + pixelFrame(' ✕ ', PIXEL_EYES.flat, PIXEL_MOUTH.frown), + ], + done: [ + pixelFrame(' ', PIXEL_EYES.shut, PIXEL_MOUTH.smile), + pixelFrame('▪ ▪', PIXEL_EYES.shut, PIXEL_MOUTH.smile, true), + pixelFrame(' ▪ ▪ ', PIXEL_EYES.shut, PIXEL_MOUTH.smile), + pixelFrame(' ', PIXEL_EYES.center, PIXEL_MOUTH.smile), + ], +}; + +function usePrefersReducedMotion() { + const [reduced, setReduced] = useState(false); + useEffect(() => { + const query = window.matchMedia('(prefers-reduced-motion: reduce)'); + setReduced(query.matches); + const change = () => setReduced(query.matches); + query.addEventListener('change', change); + return () => query.removeEventListener('change', change); + }, []); + return reduced; +} + +type PixelSkin = 'mono' | 'terminal' | 'cube' | 'crest' | 'blueprint' | 'paper'; + +// The Pi mark on its 4×4 grid, packed two pixel rows per character with half blocks. +// ■■■· █▀█· +// ■·■· +// ■■·■ █▀·█ +// ■··■ +const PIXEL_PI = [' █▀█ ', ' █▀ █ ']; +const PIXEL_LID = '▗▄▄▄▄▄▖'; + +type PixelRow = { kind: 'signal' | 'pi' | 'lid' | 'body' | 'feet' | 'air'; text: string; shift: number }; + +function pixelRows(frame: string, skin: PixelSkin): PixelRow[] { + const lines = frame.split('\n'); + const lidAt = lines.findIndex((line) => line.includes('▄▄▄▄▄')); + const lid = lines[lidAt]; + const shift = lid.startsWith(' ') ? 1 : lid.startsWith(PIXEL_LID[0]) ? 0 : -1; + const rows: PixelRow[] = lines.map((text, i) => { + let kind: PixelRow['kind'] = 'body'; + if (i === 0) kind = 'signal'; + else if (i === lidAt) kind = 'lid'; + else if (i < lidAt || !text.trim()) kind = 'air'; + else if (i > lidAt + 2) kind = 'feet'; + return { kind, text, shift }; + }); + if (skin !== 'crest') return rows; + const pad = (row: string) => (shift > 0 ? ` ${row.slice(0, -1)}` : shift < 0 ? `${row.slice(1)} ` : row); + return [rows[0], ...PIXEL_PI.map((text) => ({ kind: 'pi' as const, text: pad(text), shift })), ...rows.slice(1)]; +} + +// The cube skin lights the right half of the body and shades the left, like the emblem faces. +function PixelCells({ row }: { row: PixelRow }) { + return ( + <> + {[...row.text].map((ch, i) => { + const col = i - row.shift; + const face = col < 3 ? 'l' : col > 3 ? 'r' : 'm'; + return ( + + {ch} + + ); + })} + + ); +} + +/** Blink drawn in block characters. Loops its state until the state changes. */ +function PixelMascot({ state, skin = 'mono' }: { state: MascotState; skin?: PixelSkin }) { + const reduced = usePrefersReducedMotion(); + const [frame, setFrame] = useState(0); + useEffect(() => { + setFrame(0); + if (reduced) return; + const timer = window.setInterval(() => setFrame((n) => n + 1), state === 'idle' ? 700 : 260); + return () => window.clearInterval(timer); + }, [state, reduced]); + const frames = PIXEL_FRAMES[state]; + const rows = pixelRows(frames[frame % frames.length], skin); + return ( +
+      {rows.map((row, i) => (
+        
+          {skin === 'cube' && row.kind === 'body' ?  : row.text}
+          {i < rows.length - 1 ? '\n' : null}
+        
+      ))}
+    
+ ); +} + +const PROMPT_LINES = [34, 58, 22, 46, 64, 30]; +const STEAM = ['{', 'π', '}', '›', ';', '_']; + +/** The code mascot. The right face is a terminal that types while the agent works. */ +function PromptMascot({ state }: { state: MascotState }) { + const ref = usePointerLook(); + const f = cubeFaces(140, 90, 110); + return ( +
+ +
+ ); +} + +/** A real 3D cube. It turns at rest, unfolds into its net while thinking, and snaps shut when done. */ +function FoldMascot({ state }: { state: MascotState }) { + const ref = usePointerLook(); + return ( +
+
+
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+
+ + +
+ ); +} + +export { BlinkMascot, FoldMascot, PixelMascot, PromptMascot, usePrefersReducedMotion }; +export type { MascotGear, MascotState, MascotTone, PixelSkin }; diff --git a/src/renderer/components/Loader.tsx b/src/renderer/components/Loader.tsx new file mode 100644 index 0000000..42c3a9c --- /dev/null +++ b/src/renderer/components/Loader.tsx @@ -0,0 +1,24 @@ +type LoaderProps = { + label?: string; + variant?: 'ticks' | 'rule'; +}; + +function Loader({ label = 'loading', variant = 'ticks' }: LoaderProps) { + return ( + + {variant === 'ticks' ? ( +