Merge feat/ui-polish: plan graph rendering (dagre engine, owner skin), stats-control polish, overlay dock scroll
# Conflicts: # package.json
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
PlanPopup,
|
||||
blockLayout,
|
||||
topoFor,
|
||||
edgePathD,
|
||||
wrapTitle,
|
||||
GAP_X,
|
||||
NODE_H,
|
||||
@@ -58,10 +59,55 @@ describe("plan block layout (pure)", () => {
|
||||
assert.ok(layout.x.get("x") === 0 && layout.x.get("y") === NODE_W + GAP_X);
|
||||
});
|
||||
|
||||
it("wrapTitle wraps greedily and ellipsizes overflow", () => {
|
||||
it("edgePathD draws dagre's route through curveBasis — mouth pulled back from the border", () => {
|
||||
// Straight 3-point route (source border → mid → target border): the
|
||||
// basis curve starts exactly at the border point; the END pulls
|
||||
// back by HEAD_TIP_OVERHANG + the docking gap (92 − 3.538 − 4) so the
|
||||
// head's TIP floats 4 short of the border with its mouth at the
|
||||
// path's end, on a straight shaft (owner, rounds 11–12).
|
||||
const d = edgePathD([{ x: 80, y: 44 }, { x: 80, y: 68 }, { x: 80, y: 92 }]);
|
||||
assert.ok(d.startsWith("M80,44"), "starts at the source border point");
|
||||
assert.ok(d.endsWith("L80,84.462"), "mouth pulled back; tip floats 4 off the border");
|
||||
// The whole run stays on-axis (every x is 80): a collinear route
|
||||
// renders straight — the shaft stub never bends a straight edge.
|
||||
const xs = [...d.matchAll(/(?:[MLC]|,)(-?[\d.]+),/g)].map((m) => m[1]);
|
||||
assert.ok(xs.length > 0 && xs.every((v) => v === "80"), "collinear route renders on-axis");
|
||||
// A perpendicular entry with the target box is the SAME geometry.
|
||||
const pd = edgePathD([{ x: 80, y: 44 }, { x: 80, y: 68 }, { x: 80, y: 92 }], { x: 0, y: 92, w: 160, h: 44 });
|
||||
assert.equal(pd, d);
|
||||
});
|
||||
|
||||
it("edgePathD clamps the entry to ±40° of the face perpendicular (owner, round 14)", () => {
|
||||
const box = { x: 20, y: 100, w: 160, h: 44 };
|
||||
// A 60° tilt into the top face swings to the 40° cone edge: the
|
||||
// mouth lands at (97.726, 93.289) and the final segment runs at
|
||||
// exactly 40° (Δx/Δy = tan 40°). The tip floats DOCK_GAP (4) off the
|
||||
// face along the normal: border (100,100) → tip (100,96).
|
||||
const clamped = edgePathD([{ x: 70, y: 70 }, { x: 100, y: 100 }], box);
|
||||
assert.ok(clamped.endsWith("L97.726,93.289"), "swung to the cone edge");
|
||||
// final L's from-point is the previous number pair; recompute direction
|
||||
const nums = [...clamped.matchAll(/(-?[\d.]+)/g)].map((m) => Number(m[1]));
|
||||
const ax = nums[nums.length - 4]!;
|
||||
const ay = nums[nums.length - 3]!;
|
||||
const bx = nums[nums.length - 2]!;
|
||||
const by = nums[nums.length - 1]!;
|
||||
assert.ok(Math.abs((bx - ax) / (by - ay) - Math.tan((40 * Math.PI) / 180)) < 0.01, "40° cone edge");
|
||||
// Tip = mouth + u'·overhang floats 4 above the face.
|
||||
const tipY = by + 0.766 * 3.538;
|
||||
assert.ok(Math.abs(tipY - 96) < 0.01, "docking gap along the face normal");
|
||||
// A shallow (~9.5°) tilt is WITHIN the limit: no swing.
|
||||
const free = edgePathD([{ x: 90, y: 40 }, { x: 100, y: 100 }], box);
|
||||
assert.ok(free.endsWith("L99.418,92.51"), "unclamped within the cone");
|
||||
});
|
||||
|
||||
it("wrapTitle wraps greedily and NEVER drops content (boxes grow instead)", () => {
|
||||
assert.deepEqual(wrapTitle("Chain rule intuition"), ["Chain rule intuition"]); // exactly 20 chars — fits one line
|
||||
assert.deepEqual(wrapTitle("Chain rule intuition and fluency"), ["Chain rule intuition", "and fluency"]);
|
||||
assert.deepEqual(wrapTitle("supercalifragilisticexpialidocious word"), ["supercalifragilisti…", "word"]); // oversized word hard-clamped, overflow dropped
|
||||
// Oversized word hard-chunked, nothing lost (owner, round 7).
|
||||
assert.deepEqual(wrapTitle("supercalifragilisticexpialidocious word"), [
|
||||
"supercalifragilistic",
|
||||
"expialidocious word",
|
||||
]);
|
||||
assert.deepEqual(wrapTitle("supercalifragilistic"), ["supercalifragilistic"]); // exactly maxChars — fits
|
||||
});
|
||||
});
|
||||
@@ -82,11 +128,14 @@ describe("plan block render", () => {
|
||||
assert.match(html, /data-testid="plan-block-full"/);
|
||||
// Borderless: the wrapper carries no border/rounded chrome.
|
||||
assert.ok(!/data-testid="plan-block-full"[^>]*border/.test(html), "no wrapping chrome");
|
||||
// Fills the column: the svg is w-full with the layout extent as viewBox
|
||||
// (leftmost/rightmost nodes hug the edges), height natural.
|
||||
assert.match(html, /<svg[^>]*class="block h-auto w-full"/);
|
||||
// Two stacked nodes: 2*NODE_H + GAP_Y = 136 natural height, width = one node column.
|
||||
assert.match(html, /viewBox="0 0 160 136"/);
|
||||
// Fills the column UP TO the chat-font cap: the svg is w-full +
|
||||
// mx-auto with maxWidth = extent × TEXT_CAP_SCALE, so node text never
|
||||
// renders past the 16.5px prose size (owner, plan-graph round 1).
|
||||
assert.match(html, /<svg[^>]*class="block h-auto w-full mx-auto"/);
|
||||
assert.match(html, /max-width:232\.32px/); // (160+16) × 1.32
|
||||
// Two stacked nodes: 2*NODE_H + GAP_Y = 136 extent + GRAPH_PAD on
|
||||
// every side (strokes/glow never clip at the edges).
|
||||
assert.match(html, /viewBox="-8 -8 176 152"/);
|
||||
// Snapshot statuses render as carried.
|
||||
assert.match(html, /data-testid="plan-node-a" data-status="visited"/);
|
||||
assert.match(html, /data-testid="plan-node-b" data-status="upcoming"/);
|
||||
@@ -135,8 +184,13 @@ describe("plan popup (live derivation)", () => {
|
||||
assert.match(html, /data-testid="plan-node-a" data-status="visited"/);
|
||||
assert.match(html, /data-testid="plan-node-b" data-status="current"/);
|
||||
assert.match(html, /data-testid="plan-node-c" data-status="upcoming"/);
|
||||
// The current node is the glow target.
|
||||
assert.match(html, /stroke-primary\/70 animate-pulse/);
|
||||
// The current node is the glow target: a faint yellow fill, blurred
|
||||
// so it bleeds over the edges — static, no pulse (owner, round 6).
|
||||
assert.match(html, /fill-yellow-400\/25/);
|
||||
assert.match(html, /feGaussianBlur/);
|
||||
assert.doesNotMatch(html, /animate-pulse/);
|
||||
// Arrows into not-yet-visited nodes are grayed out (owner, round 6).
|
||||
assert.match(html, /marker-end="url\(#.*-dim\)"/);
|
||||
assert.match(html, /live — statuses derive from your check outcomes/);
|
||||
});
|
||||
|
||||
|
||||
@@ -34,9 +34,12 @@ describe("stats bar", () => {
|
||||
assert.match(html, /data-testid="stats-bar"/);
|
||||
assert.match(html, /data-testid="stats-context">—</);
|
||||
assert.match(html, /data-testid="stats-cost">—</);
|
||||
// no turns, no settings, catalog not loaded → both control labels "—"
|
||||
// no turns, no settings, catalog not loaded → both control labels
|
||||
// "—" (the thinking button shows just the level, like the model
|
||||
// button shows just the model name — no prefix, no dot separator)
|
||||
assert.match(html, /data-testid="stats-model"[^>]*>\s*<span[^>]*>—<\/span>/);
|
||||
assert.match(html, /Thinking —</);
|
||||
assert.match(html, /data-testid="stats-thinking"[^>]*>\s*<span[^>]*>—<\/span>/);
|
||||
assert.ok(!html.includes("aria-hidden>·"), "no dot between the controls");
|
||||
// the control reads as a button: pill border + dropdown chevron
|
||||
assert.match(html, /data-testid="stats-model"[^>]*rounded-full[^>]*>/);
|
||||
assert.match(html, /▾/);
|
||||
|
||||
Reference in New Issue
Block a user