feat(app): harmonic dot rail — persistent Thinking, traveling dot, ThinkingMeta - #262
Merged
Conversation
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
jeonghun-jj-lee
marked this pull request as ready for review
August 27, 2026 17:24
…g dot Pure geometry + timing module (mirrors wave-geometry.ts pattern): - harmonicRadius(): polar sampling of Y_l^m silhouettes for 4 modes - harmonicPath(): generates closed SVG paths with 64 equal-angle samples - HARMONIC_PATHS: pre-computed paths for SMIL animate interpolation - Timing constants: MODE_HOLD_MS=2300, ROTATION_PERIOD_MS=12000 All paths share identical command structure (M + 63L + Z) enabling native SVG <animate attributeName="d"> interpolation. Part of #261
…Line - thought-rail.tsx: running dot now renders <HarmonicDot> (13px SVG with SMIL morph + CSS rotation) instead of static 7px span with box-shadow - thinking-line.tsx: removed wave SVG and cycling gerund, kept only the meta row (elapsed, tokens, esc hint) - amicode.css: simplified .amc-thinking from 2-col grid to inline-block - index.css: replaced thought-rail-breathe with thought-rail-grow (150ms scale transition) and thought-rail-harmonic-rotate (12s linear) - Respects prefers-reduced-motion: static 13px circle, no animation - New HarmonicDot component + re-export shim (amicode-harmonic-dot.tsx) Closes #261
Reworked the morph animation from a linear cycle through shapes to a pulse rhythm where the sphere is home base: sphere (350ms) → morph out (175ms) → shape (500ms) → morph back (175ms) Four pulses per cycle (4.8s total), each with a different harmonic at a different orientation for maximum visual contrast: 1. dumbbell vertical (0°) 2. clover diagonal (45°) 3. dumbbell horizontal (90°) 4. pinched vertical (0°) Implementation: - harmonic-geometry.ts: added rotatedHarmonicPath(), PULSE_SEQUENCE, explicit-hold SMIL keyframes (17 values with duplicate-value holds) - harmonic-dot.tsx: removed rotation <g> wrapper, simplified to single <path> with the new SMIL attributes - index.css: removed thought-rail-harmonic-rotate keyframes Tests: 32 pass (up from 17), covering pulse timing, rotation invariants, SMIL structure, and the hold/morph pattern.
…r sequence Added two new harmonic modes: - Mode 5: trefoil — 3-lobe shape via (1+cos(3θ))/2 (Y_3 family, m=0) - Mode 6: star-8 — 8-lobe shape via |cos(4θ)| (Y_4 family, m=4) Expanded pulse sequence to 10 entries (was 4), cycling through all 5 usable shapes (modes 2–6) at 45°-increment rotations: clover@0° → rosette@45° → pinched@90° → trefoil@0° → star@45° → clover@45° → rosette@0° → pinched@135° → trefoil@45° → star@0° No adjacent pulses share the same shape. Full cycle is now 12s (10 × 1.2s per pulse). Removed dumbbell (mode 1) from all sequences. MODE_COUNT: 5 → 7 (added trefoil + star-8) PULSE_COUNT: 4 → 10 CYCLE_MS: 4800 → 12000
Added mode 1 (dumbbell) back to the pulse sequence, constrained to 45° and 90° rotations only — never vertical (0°), which avoids the phallic silhouette. Same constraint applied to mode 2 (pinched). New 10-pulse sequence uses all 6 shapes (modes 1–6): clover@0° → dumbbell@90° → rosette@45° → trefoil@0° → pinched@135° → star@45° → clover@45° → dumbbell@45° → rosette@0° → trefoil@45° Added test: 'skinny shapes (modes 1, 2) are never at 0°'
…raint Two changes: 1. Angles are now RANDOM — each HarmonicDot mount picks fresh rotation angles from the allowed 45°-increment set. The mode ORDER stays fixed (for visual contrast), but angles vary per streaming turn. 2. Fixed the vertical constraint — the dumbbell's natural orientation (rotation=0°) is HORIZONTAL in SVG coordinates (lobes at left/right). Rotation=90° makes it vertical (phallic). So the constraint is now: modes 1 and 2 exclude 90° and 270° (not 0°). New exports: randomPulseSequence(), buildSmil(sequence) Tests: 41 pass (added randomness verification, constraint checks)
New mode 7: |P_4^0(cosθ)| = |35cos⁴θ − 30cos²θ + 3|/8 Four lobes along the polar axis with two pinched waists between them. Visually reads as a more complex version of the single pinch. - Added to PULSE_MODES as the 11th pulse (cycle now 13.2s) - Constrained to non-vertical angles (same as modes 1, 2) - MODE_COUNT: 7 → 8, PULSE_COUNT: 10 → 11
The 3-lobe trefoil used (1+cos(3θ))/2, which is a rose curve, not a Y_l^m cross-section. Removed from the pulse sequence entirely. New 10-pulse sequence uses only proper harmonics: clover(Y_2^2) → dumbbell(Y_1^0) → rosette(Y_3^3) → double-pinch(P_4^0) → pinched(P_2^0) → star(Y_4^4) → clover → dumbbell → rosette → star MODE_COUNT stays 8 (mode 5 slot still exists for compat, just unused). PULSE_COUNT: 11 → 10, CYCLE_MS: 13200 → 12000.
The harmonic dot now appears immediately next to the timer during the initial thinking phase (before content arrives). Once tokens start flowing and the rail dot takes over, the inline dot departs with a smooth CSS transition: translate(-16px, -14px) + scale(0.538) + fade over 300ms — creating the illusion of the dot migrating from the timer to the rail gutter. - thinking-line.tsx: renders HarmonicDot in .amc-thinking-dot wrapper - amicode.css: .amc-thinking-meta is now inline-flex for alignment; .amc-thinking-dot transitions on departure; reduced-motion honored
…ar on content
Removed the complex translate departure animation. The dot now:
- Sits in the rail gutter (absolute left: -20px, vertically centred)
matching the thought-rail dot's horizontal position
- Simply unmounts (Show when={!hasContent()}) when tokens arrive
- The rail dot's grow animation handles the 'appearance' on content
No transition animation — clean disappear/appear handoff.
…itioning
The dot was not appearing in the rail gutter because it was rendered
inside ThinkingLine (which sits inside px-4 padding). Moved the
HarmonicDot rendering to TimelineThinkingRow in message-timeline.tsx,
positioned absolutely at the same coordinates as ThoughtRail dots:
top: DEFAULT_DOT_CENTRE - HARMONIC_SIZE/2 = 4.5px
left: LINE_X - HARMONIC_SIZE/2 = 4.5px
These anchor to session-turn-message-container (position: relative),
placing the dot in the padding gutter — exactly where the rail dots
appear on AssistantPart rows.
ThinkingLine reverted to pure metadata (no dot rendering).
Dot unmounts when tokens arrive (Show when={tokens == null}).
Moved dot rendering from TimelineThinkingRow to the Thinking case in renderTimelineRow, directly inside session-turn-message-container (which has position:relative and px-4 padding). Dot at left:0 places it at the container's left padding-box edge — in the 16px gutter before the text content starts.
The dot's resting state (sphere) is now a hollow ring (fill-opacity: 0, stroke only). When a harmonic shape pulses in, fill-opacity transitions to 1 (solid fill). Creates the visual of the ring 'filling up' with each excitation and draining back to hollow. Implementation: second <animate attributeName="fill-opacity"> on the same <path>, sharing keyTimes/dur with the d-attribute animation. Values: 0 during sphere holds, transitions 0→1 during morph-out, 1 during shape holds, 1→0 during morph-back.
…ricPrecision Three fixes for the pixelated/line-through-ring issues: 1. Render SVG at 2x internal resolution (26-unit viewBox scaled to 13 CSS px) so the browser has finer path data to rasterize — eliminates the jagged sub-pixel aliasing at small sizes. 2. Remove shape-rendering="geometricPrecision" which was disabling the browser's anti-aliasing hinting and making the 13px stroke look blocky. 3. Add a background-coloured disc (--v2-background-bg-base) behind the animated path so the rail line terminates cleanly at the ring edge instead of showing through the hollow centre. Stroke-width scaled to 4 viewBox units = 2 CSS px (unchanged visual weight). Test updated for new viewBox bounds.
Reverts the 2x-viewBox and stroke-based ring — both were fuzzy at 13px. New approach: the harmonic path is rendered as a SOLID FILL (original crisp geometricPrecision rendering at 1:1 viewBox). A smaller circle (r=3.5) filled with --v2-background-bg-base is layered ON TOP, punching a visual hole to create a ~2.5px ring. This also masks the rail line that runs behind the dot. No stroke, no fill-opacity animation, no scaled viewBox — just two layers that stay sharp at any DPI.
…l to centre Three visual fixes: 1. Outer fill uses --accent (bright yellow) not --accent-edge (darker). 2. Inner background disc animates opacity in sync with the shape morph: opacity 1 during sphere holds (ring visible, masks rail line), transitions to 0 during harmonics (disc disappears, shape is solid). 3. Rail line renders AFTER the dot in DOM order so it paints on top of the SVG's background disc — the line now visually reaches the dot centre through the ring's hollow interior, rather than appearing to stop at the ring edge.
Replace opacity fade with radius animation on the inner background disc: - During sphere hold: r = 3.5 (full ring visible, masks rail line) - During morph-out: r shrinks 3.5 → 0 (ring closes as shape blooms) - During harmonic hold: r = 0 (solid shape, no hole) - During morph-back: r grows 0 → 3.5 (ring opens from centre) Also restores rail line to render BEFORE the dot (behind in z-order) so the inner disc properly masks it. INNER_R exported from geometry module for shared use.
Replace the two-layer approach (solid path + background circle) with a single filled path using fill-rule="evenodd". The path draws the outer harmonic shape CW and an inner circle CCW — evenodd punches the hole. Sphere state: outer circle + inner circle at r=3.5 → visible ring. Harmonic state: outer shape + inner points collapsed to centre (r=0) → solid. SMIL interpolates between donut paths point-by-point, so the ring smoothly closes as the shape blooms and reopens when returning to sphere. One path, one animation, no stroke, no layered elements — crisp filled geometry at native 13px.
…he start The Thinking row now renders a ThoughtRail (first=true, last=true, running=true) when no assistant parts exist yet. This puts the harmonic dot in the rail gutter at LINE_X from the very first moment of streaming, consistent with where it'll appear on AssistantPart rows. Once assistant parts arrive, assistantTokensForTurn becomes nonzero and the Thinking row's rail disappears — the dot 'transports' to the last AssistantPart row's rail as before. Removes the standalone HarmonicDot from the Thinking case (and its now-unused import).
…rcle The donut path's evenodd hole is transparent, so the rail line behind the SVG showed through the ring's interior. Fix: add a background- coloured circle (--v2-background-bg-base) INSIDE the SVG, behind the donut path. It fills the hole area, masking the line. The circle's radius animates in sync with the donut's inner contour (INNER_R → 0 → INNER_R) so it shrinks away during harmonic phases (where the shape is solid and naturally covers the line) and grows back for sphere phases (where the ring hole would expose it). The rail line still reaches dotCentre geometrically — it connects with harmonic shapes whose lobes extend inward past the ring edge.
Revert the Thinking-row rail experiment. The Thinking row now just shows 'Thinking...' (the timer). No harmonic dot, no rail line. The rail appears naturally once the first AssistantPart row arrives, with its own running harmonic dot. Clean separation: status indicator for thinking, rail for output.
The Thinking row now participates in the rail system (before any
assistant output arrives):
- rail() returns {first, last, running} → ThoughtRail renders the
harmonic dot in the gutter (lone running step = dot only, no spine)
- railLabel() returns 'Thinking...' → ThoughtRailLabel renders the
uppercase eyebrow label inline with the dot
- ThinkingLine (timer + tokens) renders as content below the label
Once the first AssistantPart row appears (assistantTokensForTurn > 0),
the Thinking row drops its rail and label — the AssistantPart rows
take over with their own rail dots and lines. No duplicate dots.
Two fixes: 1. The Thinking row's rail now disappears based on hasAssistantParts() (checks for any assistant message with parts) rather than assistantTokensForTurn() (only counts text tokens). Tool calls like shell commands have parts but no text tokens, so the old check left the Thinking rail visible alongside the shell command's rail dot. 2. 'Thinking...' label uses normal text-xs font (not uppercase mono). It's rendered as content inside TimelineThinkingRow with leading-[22px] to align with the dot, matching the faint metadata style — not the structural furniture style of tool labels like 'SHELL'.
…, Shell) Renders as bold 'Thinking' label + muted timer inline, matching the style of 'Explored 1 read', 'Edit message-timeline.tsx', 'Worked in shell 2 commands'. Uses text-14-medium text-text-strong for the label and font-normal text-text-base for the ThinkingLine detail. No railLabel eyebrow — the content itself IS the first text line that the rail dot aligns with.
…r after Before assistant parts exist: renders as 'Thinking' (bold) + timer, like a tool group header, with harmonic dot on the rail. Once output starts streaming (hasAssistantParts = true): the 'Thinking' label disappears, the rail drops, and just the timer + token count remain as compact metadata below the rail content.
Both the rail() check and the hasOutput prop now use assistantTokensForTurn — they flip together when actual output tokens arrive. Previously rail used hasAssistantParts (fires earlier, when parts land in store) while content used tokens, causing the dot to disappear before the label. Now 'Thinking' + harmonic dot stay visible until the model produces real output tokens, then both disappear simultaneously as the AssistantPart rows take over.
…run once)
SolidJS component bodies execute once — the early-return
'if (props.hasOutput) return null' never re-evaluated after mount.
Wrap in <Show when={!props.hasOutput}> so the DOM reactively
disappears when tokens arrive.
The Thinking row no longer hides — it stays visible on the rail just like tool group rows do. Behaviour: - Before output: first=true, last=true, running=true → harmonic dot - After output: first=true, last=false, running=false → done dot, line continues down to the AssistantPart rows AssistantPart rows set first=false when the turn is running (Thinking row sits above). Gap (pt-3) applied to the first AssistantPart in a running turn so it spaces correctly below the Thinking step.
Elongated pill shapes looked odd at 45° diagonals. Restrict to 0°/180° so they always render as a horizontal lozenge.
Moved Thinking row construction to push BEFORE AssistantPart rows in rows.ts. Now the timeline renders as: ● Thinking 12s · ↑ 234 tokens ← first rail step (done dot) | ● [prose output] ← subsequent steps | ✦ Working in shell 2 commands ← running step (harmonic dot) The Thinking row is always the first step of the turn. AssistantPart rows come after it. First AssistantPart gets first=false when turn is running (Thinking is above it) and pt-3 gap for spacing.
…t bottom Architecture change to the thought rail: - Thinking row: always the FIRST rail step, persists after turn completion (like Shell/Edit groups). Harmonic dot animates HERE while the turn is running. Shows just "Thinking" label. - AssistantPart rows: always "done" dots (they represent completed output that has already appeared). - ThinkingMeta row: NEW tail row, renders LAST in the turn, shows elapsed time + token count. Does NOT participate in the rail (no dot). Always visible at the bottom for running stats. - shouldRenderRail: always true now (Thinking guarantees ≥2 nodes). - computeTurnDuration: extracted helper in rows.ts for completed turn elapsed time.
The dot no longer pins permanently to the Thinking row. It travels: - No output yet (reasoning withheld): dot animates on Thinking - Output has landed: dot moves to the LAST AssistantPart - Turn complete: all dots static Rule: running = turnRunning && (lastAssistantPart for parts, !hasOutput for Thinking). The dot only leaves Thinking once a settled part actually appears on the rail.
…-thinking) Removed the text-13 text-text-dimmed override. Both the live ThinkingLine and the completed-state fallback now render under the .amc-thinking class (font-size: 11px, --v2-text-text-muted) which is what it always was.
hasAssistantParts() was checking raw parts (including withheld reasoning) which could return true before any AssistantPart row actually existed in the timeline. This caused the harmonic dot to vanish from Thinking while no AssistantPart row had a dot yet — leaving a partial white rail stub with no animation. Fix: check the projected timelineRows() for actual AssistantPart rows instead of raw sync parts. The dot now only leaves Thinking when a renderable, settled row is visible on the rail.
jeonghun-jj-lee
force-pushed
the
harmonic-dot
branch
from
August 27, 2026 23:45
8db17be to
b40b514
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The harmonic dot on the thought rail now travels down as output lands.
Changes
Thinking row (first rail step)
Harmonic dot travels
ThinkingMeta row (tail)
Other
Tests