chore: commit all pending changes

This commit is contained in:
James Peter
2025-08-23 19:23:58 +10:00
parent 638471d5cf
commit baa1d52e2b
7 changed files with 331 additions and 568 deletions
+101 -73
View File
@@ -107,22 +107,31 @@ jobs:
with:
targets: ${{ matrix.target }}
# Smart caching for Rust builds - handles everything automatically
# Smart caching for Rust builds with better restore keys
- name: Setup Rust Cache
uses: Swatinem/rust-cache@v2
with:
# Ensure cache is unique per target
# Unique cache per target
key: ${{ matrix.target }}
# Cache on failure to speed up debugging
# Use restore keys for better cache hits across version changes
prefix-key: "v2-rust"
# Save cache even on failure for debugging
cache-on-failure: true
# Working directory for the cache
# Cache all crates to improve hit rate
cache-all-crates: true
# Working directory
workspaces: "codex-rs -> target"
# Additional cache directories
cache-directories: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git
# Compiler cache across all OSes; integrates with GitHub Actions cache
# Compiler cache across all OSes
- name: Setup sccache
uses: mozilla-actions/[email protected]
with:
# Keep separate caches per compilation target
# Separate caches per target
cache-key: ${{ matrix.target }}
- name: Install mold (Linux)
@@ -137,88 +146,106 @@ jobs:
sudo apt-get update
sudo apt-get install -y musl-tools pkg-config
- name: Set version in Cargo.toml (workspace only)
- name: Set version in Cargo.toml and Cargo.lock
run: |
VERSION="${{ needs.determine-version.outputs.version }}"
echo "📝 Setting version to ${VERSION} in Cargo.toml files"
echo "📝 Setting version to ${VERSION}"
# Update the workspace version in root Cargo.toml
cd codex-rs
# Use sed for all platforms (works on Windows bash too)
# Save the current version for replacement
CURRENT_VERSION=$(grep '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/')
echo "Current version in Cargo.toml: $CURRENT_VERSION"
# Update version in root Cargo.toml
sed -i.bak "s/^version = \".*\"/version = \"${VERSION}\"/" Cargo.toml
# Verify the update
echo "Updated Cargo.toml:"
grep "^version = " Cargo.toml || true
# Update Cargo.lock to reflect only workspace version bumps, without
# floating third-party dependency versions or git revisions.
echo "🔒 Updating Cargo.lock for workspace members only"
# Extract member directories from [workspace] table and update each workspace crate
awk '/^\s*\[workspace\]/{ws=1} ws && /^members\s*=\s*\[/{f=1; next} f && /\]/{f=0} f{ gsub(/[",]/,""); if($1!="") print $1 }' Cargo.toml \
| while IFS= read -r dir; do
if [[ -f "$dir/Cargo.toml" ]]; then
NAME=$(sed -n 's/^name\s*=\s*"\(.*\)"/\1/p' "$dir/Cargo.toml" | head -n1)
if [[ -n "$NAME" ]]; then
echo " • cargo update -p $NAME"
cargo update -p "$NAME"
fi
# Update version in Cargo.lock WITHOUT regenerating it
# This preserves all dependency versions and maintains cache validity
if [ -f "Cargo.lock" ]; then
echo "🔒 Updating version in existing Cargo.lock (preserving dependencies)"
# Get list of workspace members
WORKSPACE_MEMBERS=$(awk '/^\s*\[workspace\]/{ws=1} ws && /^members\s*=\s*\[/{f=1; next} f && /\]/{f=0} f{ gsub(/[",]/,""); if($1!="") print $1 }' Cargo.toml)
# For each workspace member, get its package name and update version in Cargo.lock
for dir in $WORKSPACE_MEMBERS; do
if [ -f "$dir/Cargo.toml" ]; then
# Update version in member's Cargo.toml
sed -i.bak "s/^version = \".*\"/version = \"${VERSION}\"/" "$dir/Cargo.toml"
# Get the package name
PKG_NAME=$(grep '^name = ' "$dir/Cargo.toml" | head -1 | sed 's/name = "\(.*\)"/\1/')
if [ -n "$PKG_NAME" ]; then
echo " • Updating $PKG_NAME to version $VERSION in Cargo.lock"
# Update this package's version in Cargo.lock
# Use a more precise pattern that handles the package block structure
awk -v pkg="$PKG_NAME" -v old="$CURRENT_VERSION" -v new="$VERSION" '
/^\[\[package\]\]$/ { in_pkg=1; is_target=0; next_line_name=1 }
in_pkg && next_line_name && /^name = / {
next_line_name=0
if ($0 == "name = \"" pkg "\"") {
is_target=1
}
}
in_pkg && is_target && /^version = / {
print "version = \"" new "\""
is_target=0
in_pkg=0
next
}
in_pkg && /^$/ { in_pkg=0; is_target=0 }
{ print }
' Cargo.lock > Cargo.lock.tmp && mv Cargo.lock.tmp Cargo.lock
fi
done
# Now normalize the Cargo.lock workspace versions to ensure consistency
echo "🔧 Normalizing Cargo.lock workspace versions"
# Build list of workspace crate names
awk '/^\s*\[workspace\]/{ws=1} ws && /^members\s*=\s*\[/{f=1; next} f && /\]/{f=0} f{ gsub(/[",]/,""); if($1!="") print $1 }' Cargo.toml \
| while IFS= read -r dir; do
if [[ -f "$dir/Cargo.toml" ]]; then
sed -n 's/^name\s*=\s*"\(.*\)"/\1/p' "$dir/Cargo.toml"
fi
done
# Also update any references to these packages in dependencies sections
for dir in $WORKSPACE_MEMBERS; do
if [ -f "$dir/Cargo.toml" ]; then
PKG_NAME=$(grep '^name = ' "$dir/Cargo.toml" | head -1 | sed 's/name = "\(.*\)"/\1/')
if [ -n "$PKG_NAME" ]; then
# Update dependency references (these appear in different format)
sed -i "s/\"$PKG_NAME $CURRENT_VERSION\"/\"$PKG_NAME $VERSION\"/g" Cargo.lock
fi
done \
> .workspace-crate-names
if [[ -s .workspace-crate-names ]]; then
awk -v ver="$VERSION" 'FNR==NR { if ($0!="") want[$0]=1; next }
/^\[\[package\]\]$/ { pkg=""; print; next }
/^name = \"/ && pkg=="" { if (match($0, /^name = \"([^\"]+)\"/, m)) pkg=m[1]; print; next }
/^version = \"/ {
if (pkg != "" && (pkg in want)) { print "version = \"" ver "\"" } else { print }
next
}
{ print }
' .workspace-crate-names Cargo.lock > Cargo.lock.tmp && mv Cargo.lock.tmp Cargo.lock
fi
done
echo "✅ Cargo.lock updated (dependencies preserved)"
else
echo "⚠️ No Cargo.lock found, will be created during build"
fi
rm -f .workspace-crate-names
# Ensure Cargo.lock is regenerated for this runner's target so that
# subsequent `cargo build --locked` does not fail due to missing
# target-specific dependencies. This does not publish changes; it only
# updates the lockfile in the workspace checkout for this job.
echo "🔒 Regenerating Cargo.lock for current runner target"
cargo generate-lockfile
# Clean up backup files
find . -name "*.bak" -delete
shell: bash
- name: Build binaries
shell: bash
env:
# Use mold linker on Linux when available for faster linking
RUSTFLAGS: ${{ contains(matrix.os, 'ubuntu') && '-C link-arg=-fuse-ld=mold' || '' }}
# Skip macOS codesigning in CI to allow cross-arch builds
# Use mold linker on Linux for faster linking
# Optionally suppress warnings in release builds (remove -Awarnings if you want to see them)
RUSTFLAGS: ${{ contains(matrix.os, 'ubuntu') && '-Awarnings -C link-arg=-fuse-ld=mold' || '-Awarnings' }}
# Skip macOS codesigning in CI
SKIP_SIGNING: '1'
# sccache performs best with incremental disabled for release builds
# Disable incremental compilation for release builds (better with sccache)
CARGO_INCREMENTAL: "0"
# Increase cache capacity a bit to improve hit rate across targets
# Increase sccache cache size
SCCACHE_CACHE_SIZE: "10G"
# Show more detail on failures
RUST_BACKTRACE: "1"
run: |
cd codex-rs
# mozilla-actions/sccache-action sets RUSTC_WRAPPER and enables GHA cache
command -v sccache >/dev/null 2>&1 && echo "Using sccache for compilation caching" || echo "sccache not available; proceeding without it"
# Use all available cores
# Show sccache status
command -v sccache >/dev/null 2>&1 && echo "Using sccache for compilation caching" || echo "sccache not available"
# Calculate parallel jobs
if [[ "${{ matrix.os }}" == "windows-latest" ]]; then
# Windows: Use NUMBER_OF_PROCESSORS env var
export CARGO_BUILD_JOBS=$((${NUMBER_OF_PROCESSORS:-2} * 2))
else
# Unix-like: Prefer nproc if available, otherwise getconf/sysctl
CORES=$((
$( (command -v nproc >/dev/null 2>&1 && nproc) \
|| (getconf _NPROCESSORS_ONLN 2>/dev/null) \
@@ -228,10 +255,12 @@ jobs:
export CARGO_BUILD_JOBS=$((CORES * 2))
fi
echo "Building with $CARGO_BUILD_JOBS parallel jobs"
# Use --locked to ensure reproducible builds
# Build with --locked to ensure reproducible builds
# If Cargo.lock doesn't exist or is invalid, this will fail (which is what we want)
cargo build --release --locked --target ${{ matrix.target }} --bin code --bin code-tui --bin code-exec
# Show sccache stats if available (non-fatal)
# Show sccache stats
command -v sccache >/dev/null 2>&1 && sccache --show-stats || true
- name: Prepare artifacts
@@ -327,7 +356,7 @@ jobs:
echo "skip_push=false" >> $GITHUB_OUTPUT
fi
# Ensure the tag exists at HEAD, regardless of whether package.json changed
# Ensure the tag exists at HEAD
if ! git rev-parse "v${NEW_VERSION}" >/dev/null 2>&1; then
git tag "v${NEW_VERSION}"
echo "Created tag v${NEW_VERSION}"
@@ -356,9 +385,8 @@ jobs:
- name: Push tag
run: |
# Push the tag even if no commit was made in this job
# Push the tag even if no commit was made
git push origin "v${{ steps.version.outputs.version }}" || true
- name: Push changes
run: |
@@ -390,4 +418,4 @@ jobs:
files: |
release-assets/*
env:
GITHUB_TOKEN: ${{ secrets.GH_PAT || secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GH_PAT || secrets.GITHUB_TOKEN }}
+29 -8
View File
@@ -20,6 +20,25 @@ fi
echo "Building code binary (${PROFILE} mode)..."
# Check Cargo.lock validity (fast, non-blocking check)
if ! cargo metadata --locked --format-version 1 >/dev/null 2>&1; then
echo "⚠️ Warning: Cargo.lock appears out of date or inconsistent"
echo " This might mean:"
echo " • You've modified Cargo.toml dependencies"
echo " • You've changed workspace crate versions"
echo " • The lockfile is missing entries"
echo ""
echo " Run 'cargo update' to update all dependencies, or"
echo " Run 'cargo update -p <crate-name>' to update specific crates"
echo ""
echo " Continuing with unlocked build for development..."
echo ""
USE_LOCKED=""
else
# Lockfile is valid, use it for consistent builds
USE_LOCKED="--locked"
fi
# Select the cargo/rustc toolchain to match deploy
# Prefer rustup with the toolchain pinned in rust-toolchain.toml or $RUSTUP_TOOLCHAIN
USE_CARGO="cargo"
@@ -57,13 +76,8 @@ fi
# Build for native target (no --target flag) for maximum speed
# This reuses the host stdlib and normal cache
# In fast dev profile, suppress compiler warnings for a clean output while
# still surfacing errors. This does not affect other profiles.
if [ "$PROFILE" = "dev-fast" ]; then
export RUSTFLAGS="${RUSTFLAGS:-} -Awarnings"
fi
${USE_CARGO} build --profile "${PROFILE}" --bin code --bin code-tui --bin code-exec
# Build with or without --locked based on lockfile validity
${USE_CARGO} build ${USE_LOCKED} --profile "${PROFILE}" --bin code --bin code-tui --bin code-exec
# Check if build succeeded
if [ $? -eq 0 ]; then
@@ -98,7 +112,14 @@ if [ $? -eq 0 ]; then
echo " → Optimized for fast iteration (incremental builds enabled)"
echo " → For production build, use: PROFILE=release-prod ./build-fast.sh"
fi
# If lockfile was out of date, remind user
if [ -z "$USE_LOCKED" ]; then
echo ""
echo "⚠️ Remember: Built without --locked due to Cargo.lock issues"
echo " Consider running 'cargo update' and committing the changes"
fi
else
echo "❌ Build failed"
exit 1
fi
fi
-44
View File
@@ -1,44 +0,0 @@
#!/usr/bin/env bash
# Production build script - optimized for size and performance
set -euo pipefail
cd codex-rs
echo "Building code binary (production release mode)..."
echo "This will take longer but produce the most optimized binary."
# Build with the release-prod profile for maximum optimization
cargo build --profile release-prod --bin code --target aarch64-apple-darwin
# Check if build succeeded
if [ $? -eq 0 ]; then
echo "✅ Production build successful!"
echo "Binary location: ./target/aarch64-apple-darwin/release-prod/code"
# Update symlinks for production binary
mkdir -p ./target/release
if [ -e "./target/release/code" ]; then
rm -f ./target/release/code
fi
ln -sf ./aarch64-apple-darwin/release-prod/code ./target/release/code
# Update the symlink in codex-cli/bin for npm wrapper
CODEX_CLI_BIN_CODE="../codex-cli/bin/code-aarch64-apple-darwin"
if [ -e "$CODEX_CLI_BIN_CODE" ]; then
rm -f "$CODEX_CLI_BIN_CODE"
fi
ln -sf ../../codex-rs/target/aarch64-apple-darwin/release-prod/code "$CODEX_CLI_BIN_CODE"
echo "✅ Symlinks updated"
echo ""
echo "Production binary ready: code"
echo "Binary size: $(du -h ./target/aarch64-apple-darwin/release-prod/code | cut -f1)"
echo ""
echo "This build is optimized for distribution with:"
echo " → Maximum link-time optimization (LTO=fat)"
echo " → Single codegen unit for best performance"
echo " → Symbols stripped for smaller size"
else
echo "❌ Build failed"
exit 1
fi
+199 -195
View File
File diff suppressed because it is too large Load Diff
-14
View File
@@ -1,14 +0,0 @@
#!/bin/bash
# Script to migrate from enum-based to trait-based HistoryCell
echo "Starting migration to trait-based HistoryCell..."
# Backup the old files
cp codex-rs/tui/src/history_cell.rs codex-rs/tui/src/history_cell.rs.backup
cp codex-rs/tui/src/chatwidget.rs codex-rs/tui/src/chatwidget.rs.backup
# Replace history_cell.rs with the new version
cp codex-rs/tui/src/history_cell_new.rs codex-rs/tui/src/history_cell.rs
echo "Files backed up and new history_cell.rs in place"
echo "Now manually updating chatwidget.rs..."
+2 -1
View File
@@ -8,7 +8,8 @@
"build": "cd codex-rs && cargo build --release --bin code --bin code-tui --bin code-exec",
"build:dev": "cd codex-rs && cargo build --bin code --bin code-tui --bin code-exec",
"build:quick": "cd codex-rs && cargo build --release --bin code",
"test:local": "cd codex-cli && npm link && code --version"
"test:local": "cd codex-cli && npm link && code --version",
"fix": "cargo fmt && cargo fix --allow-dirty && cargo clippy --fix --allow-dirty"
},
"devDependencies": {
"prettier": "^3.5.3"
-233
View File
@@ -1,233 +0,0 @@
#!/usr/bin/env node
/**
* Plot the virtual cursor path used in codex-rs/browser/src/page.rs.
*
* It mirrors the current algorithm: distance-based duration, optional curved midpoint,
* rotation-driven overshoot timing (translation holds final after ~82%), and easing.
*
* Usage:
* node scripts/plot_cursor_path.js --from 100,100 --to 900,520 --out /tmp/cursor_path.svg
* node scripts/plot_cursor_path.js --from 200,300 --to 400,350 --no-curve --pxPerSec 120 --min 600 --max 4200
*/
const fs = require('fs');
function parsePoint(s) {
const [x, y] = String(s).split(',').map(Number);
if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error(`Invalid point: ${s}`);
return { x, y };
}
function parseArgs(argv) {
const args = {
from: { x: 120, y: 90 },
to: { x: 860, y: 520 },
out: 'cursor_path.svg',
fps: 60,
// Mirror MOTION constants from page.rs (current tuning)
pxPerSec: 120,
min: 600,
max: 4200,
easing: 'cubic-bezier(0.18, 0.9, 0.18, 1)',
arrowScale: 1.50,
badgeScale: 1.70,
badgeDelay: 40,
jitter: 0,
rotateMaxDeg: 16,
arrowTilt: 0.85,
badgeTilt: 0.60,
overshootDeg: 4.0,
arrowOvershootScale: 0.5,
badgeOvershootScale: 0.85,
overshootAt: 0.7,
curveFactor: 0.25,
curveMaxPx: 70,
curveAlternate: true,
tipX: 0,
tipY: 0,
badgeOffX: 12,
badgeOffY: 4,
noCurve: false,
};
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
const next = argv[i + 1];
const setNum = (k, v) => { if (v === undefined) throw new Error(`Missing value for ${k}`); args[k] = Number(v); i++; };
switch (a) {
case '--from': args.from = parsePoint(next); i++; break;
case '--to': args.to = parsePoint(next); i++; break;
case '--out': args.out = next; i++; break;
case '--fps': setNum('fps', next); break;
case '--pxPerSec': setNum('pxPerSec', next); break;
case '--min': setNum('min', next); break;
case '--max': setNum('max', next); break;
case '--no-curve': args.noCurve = true; break;
default:
if (a.startsWith('--')) {
const k = a.slice(2);
if (k in args) {
const val = next;
if (val === undefined) throw new Error(`Missing value for ${a}`);
const n = Number(val);
args[k] = Number.isFinite(n) ? n : val;
i++;
}
}
}
}
return args;
}
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function dist(x0, y0, x1, y1) { const dx = x1 - x0, dy = y1 - y0; return Math.hypot(dx, dy); }
function lerp(a, b, t) { return a + (b - a) * t; }
function lerp2(a, b, t) { return { x: lerp(a.x, b.x, t), y: lerp(a.y, b.y, t) }; }
// Evaluate cubic-bezier easing at t; return y for x=t mapping
function cubicBezierAt(p1x, p1y, p2x, p2y, t) {
// Borrowed from https://github.com/gre/bezier-easing (MIT) inlined minimal.
function A(aA1, aA2) { return 1.0 - 3.0 * aA2 + 3.0 * aA1; }
function B(aA1, aA2) { return 3.0 * aA2 - 6.0 * aA1; }
function C(aA1) { return 3.0 * aA1; }
function calcBezier(aT, aA1, aA2) { return ((A(aA1, aA2) * aT + B(aA1, aA2)) * aT + C(aA1)) * aT; }
function getSlope(aT, aA1, aA2) { return 3.0 * A(aA1, aA2) * aT * aT + 2.0 * B(aA1, aA2) * aT + C(aA1); }
function getTforX(aX) {
// Newton-Raphson
let aT = aX;
for (let i = 0; i < 4; i++) {
const slope = getSlope(aT, p1x, p2x);
if (slope === 0) return aT;
const x = calcBezier(aT, p1x, p2x) - aX;
aT -= x / slope;
}
return aT;
}
const x = getTforX(t);
const y = calcBezier(x, p1y, p2y);
return y;
}
function durationForDistance(d, motion) {
let ms = (d / Math.max(1, motion.pxPerSec)) * 1000;
if (motion.jitter > 0) {
const j = (Math.random() * 2 - 1) * motion.jitter;
ms = ms * (1 + j);
}
ms = Math.min(motion.max, Math.max(motion.min, ms));
return Math.round(ms);
}
function computePath(args) {
const o = { ...args };
const ax0 = Math.round(args.from.x - o.tipX);
const ay0 = Math.round(args.from.y - o.tipY);
const ax1 = Math.round(args.to.x - o.tipX);
const ay1 = Math.round(args.to.y - o.tipY);
const bx0 = ax0 + o.badgeOffX, by0 = ay0 + o.badgeOffY;
const bx1 = ax1 + o.badgeOffX, by1 = ay1 + o.badgeOffY;
const d = dist(ax0, ay0, ax1, ay1);
const base = durationForDistance(d, o);
const aDur = Math.round(base * o.arrowScale);
const bDur = Math.round(base * o.badgeScale);
// Curve midpoint
let mx = (ax0 + ax1) / 2;
let my = (ay0 + ay1) / 2;
if (!o.noCurve) {
const dx = ax1 - ax0, dy = ay1 - ay0;
const len = Math.hypot(dx, dy) || 1;
const nqx = -dy / len, nqy = dx / len;
const curveMag = Math.min(o.curveMaxPx || 0, Math.max(0, d * (o.curveFactor || 0)));
const curveSign = 1; // stateless plot (no alternation)
mx = Math.round(mx + nqx * curveMag * curveSign);
my = Math.round(my + nqy * curveMag * curveSign);
}
const offsets = [0.0, 0.5, 0.82, 0.90, 1.0];
for (let i = 1; i < offsets.length; i++) {
if (!(offsets[i] > offsets[i - 1])) throw new Error('Non-increasing keyframe offsets');
}
const pts = [ {x: ax0, y: ay0}, {x: mx, y: my}, {x: ax1, y: ay1} ];
const easing = o.easing;
const m = easing.match(/cubic-bezier\(([^)]+)\)/);
const bez = m ? m[1].split(',').map(Number).map(v => clamp(v, -10, 10)) : [0.25, 1, 0.5, 1];
const samples = [];
const totalMs = aDur;
const step = Math.max(1, Math.round(1000 / o.fps));
for (let tms = 0; tms <= totalMs; tms += step) {
const t = tms / totalMs; // 0..1
let segStart = 0, segEnd = 0.5, A = pts[0], B = pts[1];
if (t >= 0.5 && t < 0.82) { segStart = 0.5; segEnd = 0.82; A = pts[1]; B = pts[2]; }
else if (t >= 0.82) { segStart = 0.82; segEnd = 1.0; A = pts[2]; B = pts[2]; }
const local = (t - segStart) / Math.max(1e-6, (segEnd - segStart));
const eased = cubicBezierAt(bez[0], bez[1], bez[2], bez[3], clamp(local, 0, 1));
const P = lerp2(A, B, eased);
samples.push({ tms, x: P.x, y: P.y });
}
const fallback = [];
for (let tms = 0; tms <= totalMs; tms += step) {
const t = tms / totalMs;
const eased = cubicBezierAt(0.25, 1, 0.5, 1, t);
const P = lerp2({x: ax0, y: ay0}, {x: ax1, y: ay1}, eased);
fallback.push({ tms, x: P.x, y: P.y });
}
return { aDur, bDur, pts: { start: {x: ax0, y: ay0}, mid: {x: mx, y: my}, end: {x: ax1, y: ay1} }, samples, fallback };
}
function toSVG(data, width, height, margin = 20) {
// Fit points into the viewport
const xs = data.samples.map(p => p.x).concat([data.pts.start.x, data.pts.mid.x, data.pts.end.x]);
const ys = data.samples.map(p => p.y).concat([data.pts.start.y, data.pts.mid.y, data.pts.end.y]);
const minX = Math.min(...xs), maxX = Math.max(...xs);
const minY = Math.min(...ys), maxY = Math.max(...ys);
const spanX = Math.max(1, maxX - minX);
const spanY = Math.max(1, maxY - minY);
const sx = (x) => margin + (x - minX) * (width - 2*margin) / spanX;
const sy = (y) => margin + (y - minY) * (height - 2*margin) / spanY;
const path = data.samples.map((p, i) => `${i===0?'M':'L'} ${sx(p.x).toFixed(1)} ${sy(p.y).toFixed(1)}`).join(' ');
const fbPath = data.fallback.map((p, i) => `${i===0?'M':'L'} ${sx(p.x).toFixed(1)} ${sy(p.y).toFixed(1)}`).join(' ');
const dots = [data.pts.start, data.pts.mid, data.pts.end]
.map((p, i) => `<circle cx="${sx(p.x).toFixed(1)}" cy="${sy(p.y).toFixed(1)}" r="3" fill="${['#2a9d8f','#f4a261','#e76f51'][i]}"/>`).join('\n ');
return `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
<rect x="0" y="0" width="100%" height="100%" fill="#0b0d10"/>
<g stroke="#444" stroke-width="1" fill="none" opacity="0.7">
${Array.from({length: 10}).map((_,i)=>`<line x1="${margin}" x2="${width-margin}" y1="${margin + i*(height-2*margin)/10}" y2="${margin + i*(height-2*margin)/10}"/>`).join('\n ')}
</g>
<path d="${fbPath}" stroke="#6c757d" stroke-dasharray="4 3" stroke-width="2" fill="none"/>
<path d="${path}" stroke="#00abff" stroke-width="2.5" fill="none"/>
${dots}
<text x="${margin}" y="${height - margin/2}" fill="#ddd" font-family="monospace" font-size="12">
curved (blue) vs fallback (gray), duration: ${data.aDur}ms
</text>
</svg>`;
}
function main() {
const args = parseArgs(process.argv);
const data = computePath(args);
const svg = toSVG(data, 900, 480);
fs.writeFileSync(args.out, svg, 'utf8');
console.log(`Wrote ${args.out}`);
console.log(`Duration (arrow): ${data.aDur} ms | samples: ${data.samples.length}`);
console.log('Key points:', data.pts);
console.log('First 5 samples:', data.samples.slice(0,5));
}
if (require.main === module) {
try { main(); } catch (e) { console.error('Error:', e.message); process.exit(1); }
}