From e20d74b281f0d381c07a4413e72e1260fa708cc8 Mon Sep 17 00:00:00 2001 From: syalr2 Date: Tue, 10 Nov 2026 09:54:22 -0500 Subject: [PATCH 1/3] Model yoke stitch targets, schedules and shared drawing geometry --- lib/yoke-layout.ts | 67 +++++++++++++ lib/yoke-plan.test.ts | 214 ++++++++++++++++++++++++++++++++++++++++++ lib/yoke-plan.ts | 197 ++++++++++++++++++++++++++++++++++++++ lib/yoke-project.ts | 100 ++++++++++++++++++++ 4 files changed, 578 insertions(+) create mode 100644 lib/yoke-layout.ts create mode 100644 lib/yoke-plan.test.ts create mode 100644 lib/yoke-plan.ts create mode 100644 lib/yoke-project.ts diff --git a/lib/yoke-layout.ts b/lib/yoke-layout.ts new file mode 100644 index 0000000..825f608 --- /dev/null +++ b/lib/yoke-layout.ts @@ -0,0 +1,67 @@ +export type YokeView = "whole" | "repeat" | "chart"; +export const YOKE_CANVAS = { width: 1000, height: 800 }; +const TAU = Math.PI * 2; +const margin = 40; + +export function yokeGeometry( + counts: number[], + repeats: number, + view: YokeView, + aspect: number, +) { + const shownCounts = counts.map((count) => + view === "whole" ? count : count / repeats, + ); + const largest = Math.max(...shownCounts); + const rowHeight = (YOKE_CANVAS.height - margin * 2) / counts.length; + const cellWidth = Math.min( + rowHeight * aspect, + (YOKE_CANVAS.width - margin * 2) / largest, + ); + const arc = view === "whole" ? TAU : TAU / repeats; + const inner = view === "whole" ? 95 : 80; + const outer = view === "whole" ? 355 : 690; + return { + shownCounts, + cellWidth, + rowHeight, + arc, + inner, + outer, + radialHeight: (outer - inner) / counts.length, + cx: 500, + cy: view === "whole" ? 400 : 20, + startAngle: view === "whole" ? -Math.PI / 2 : Math.PI / 2 - arc / 2, + }; +} + +/** Shared geometry for drawing AND hit testing; CSS scaling is handled by callers. */ +export function yokeCellAt( + counts: number[], + repeats: number, + view: YokeView, + aspect: number, + x: number, + y: number, +) { + const g = yokeGeometry(counts, repeats, view, aspect); + if (view === "chart") { + const row = Math.floor((YOKE_CANVAS.height - margin - y) / g.rowHeight); + if (row < 0 || row >= counts.length) return null; + const left = (YOKE_CANVAS.width - g.shownCounts[row] * g.cellWidth) / 2; + const column = Math.floor((x - left) / g.cellWidth); + return column >= 0 && column < g.shownCounts[row] ? { row, column } : null; + } + const dx = x - g.cx, + dy = y - g.cy; + const radius = Math.hypot(dx, dy); + const row = Math.floor((radius - g.inner) / g.radialHeight); + if (row < 0 || row >= counts.length) return null; + const angle = (((Math.atan2(dy, dx) - g.startAngle) % TAU) + TAU) % TAU; + if (angle >= g.arc) return null; + const column = Math.min( + g.shownCounts[row] - 1, + Math.floor((angle / g.arc) * g.shownCounts[row]), + ); + return { row, column }; +} diff --git a/lib/yoke-plan.test.ts b/lib/yoke-plan.test.ts new file mode 100644 index 0000000..294e8d3 --- /dev/null +++ b/lib/yoke-plan.test.ts @@ -0,0 +1,214 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + calculateYokeTargets, + defaultYokeMeasurements as defaults, + convertYokeUnits, + suggestYokeSchedule, + yokeRoundCounts, +} from "./yoke-plan"; +import { + createYokeProject, + paintYoke, + yokeProjectSchema, +} from "./yoke-project"; +import { yokeGeometry, yokeCellAt, type YokeView } from "./yoke-layout"; + +const plan = calculateYokeTargets(defaults); +const events = suggestYokeSchedule(plan, defaults.repeats); +test("underarms are counted twice in the body and once in each sleeve", () => { + const target = calculateYokeTargets({ + ...defaults, + repeats: 2, + chest: 100, + bodyEase: 10, + }); + assert.equal(target.body, 220); + assert.equal(target.sleeve, 72); + assert.equal(target.underarm, 8); + assert.equal(target.end, 332); + assert.equal(target.bodyFromYoke, 204); + assert.equal(target.sleeveFromYoke, 64); + assert.equal(target.bodyFromYoke + 2 * target.sleeveFromYoke, target.end); + assert.equal(target.bodyFromYoke + 2 * target.underarm, target.body); + assert.equal(target.sleeveFromYoke + target.underarm, target.sleeve); +}); +test("repeat rounding exposes its effect on actual measurements", () => { + assert.equal(plan.neck, 84); + assert.equal(plan.neckAdjustment, -4); + assert.equal(plan.actualNeck, 42); + assert.equal(plan.end % defaults.repeats, 0); + assert.equal( + plan.actualBody, + (plan.end - 2 * plan.sleeve + 4 * plan.underarm) / 2, + ); +}); +test("unit conversion preserves stitch counts, full rounds and short-row allowance", () => { + const inches = calculateYokeTargets(convertYokeUnits(defaults, "in")); + for (const key of [ + "neck", + "end", + "body", + "sleeve", + "underarm", + "rounds", + "shortRowPairs", + ] as const) + assert.equal(inches[key], plan[key]); + const roundtrip = convertYokeUnits(convertYokeUnits(defaults, "in"), "cm"); + assert.ok(Math.abs(roundtrip.neck - defaults.neck) < 1e-10); +}); +test("short rows add local height, never full rounds or stitches", () => { + const without = calculateYokeTargets({ ...defaults, backRise: 0 }); + assert.equal(without.rounds, plan.rounds); + assert.equal(without.end, plan.end); + assert.equal(without.shortRowPairs, 0); + assert.equal(plan.shortRowPairs, 3); + assert.equal(plan.actualBackRise, 6 / 2.8); +}); +test("invalid geometry and impossible measurements are rejected before allocation", () => { + for (const update of [ + { gaugeStitches: 0 }, + { gaugeRows: NaN }, + { depth: Infinity }, + { underarm: 40 }, + { repeats: 2.5 }, + { depth: 100 }, + { neck: 200 }, + { chest: -1 }, + ]) { + assert.throws(() => calculateYokeTargets({ ...defaults, ...update })); + } +}); +test("suggested schedules are internally consistent across gauges, counts and repeat choices", () => { + let checked = 0; + for (const gaugeStitches of [12, 20, 28]) + for (const gaugeRows of [18, 28, 36]) + for (const repeats of [4, 8, 12, 20]) + for (const chest of [75, 100, 140]) { + const measurements = { + ...defaults, + gaugeStitches, + gaugeRows, + repeats, + chest, + }; + const target = calculateYokeTargets(measurements); + const counts = yokeRoundCounts( + target, + repeats, + suggestYokeSchedule(target, repeats), + ); + assert.equal(counts.length, target.rounds); + assert.equal(counts[0], target.neck); + assert.equal(counts.at(-1), target.end); + assert.ok( + counts.every( + (count, index) => + count % repeats === 0 && (!index || count >= counts[index - 1]), + ), + ); + checked++; + } + assert.equal(checked, 108); +}); +test("short five-round schedules retain unique increase rounds and viable counts", () => { + const target = calculateYokeTargets({ ...defaults, depth: 1.8 }); + assert.equal(target.rounds, 5); + assert.doesNotThrow(() => + yokeRoundCounts( + target, + defaults.repeats, + suggestYokeSchedule(target, defaults.repeats), + ), + ); +}); +test("schedule validation rejects duplicate rounds, nonmultiples, missing targets and oversized jumps", () => { + for (const schedule of [ + [...events, events[0]], + [{ round: 2, count: plan.end - 1 }], + [], + [{ round: 2, count: plan.end }], + [{ round: 151, count: plan.end }], + ]) { + assert.throws(() => yokeRoundCounts(plan, defaults.repeats, schedule)); + } +}); +test("painting repeats preserves other rounds and supports independent variations", () => { + const original = createYokeProject(defaults, events); + const copy = JSON.stringify(original); + const next = paintYoke(original, 0, 2, 1, true); + const width = plan.neck / defaults.repeats; + assert.equal( + next.cells[0].filter((color) => color === 1).length, + defaults.repeats, + ); + assert.ok( + next.cells[0].every( + (color, column) => color === (column % width === 2 ? 1 : 0), + ), + ); + assert.equal(next.cells[1], original.cells[1]); + assert.equal(JSON.stringify(original), copy); + const one = paintYoke(original, 0, 2, 1, false); + assert.equal(one.cells[0].filter((color) => color === 1).length, 1); + assert.equal(paintYoke(one, -1, 0, 1, false), one); + assert.equal(paintYoke(one, 0, 0, 99, false), one); +}); +test("project validation rejects wrong cell counts and missing palette colours", () => { + const project = createYokeProject(defaults, events); + assert.equal(yokeProjectSchema.safeParse(project).success, true); + assert.equal( + yokeProjectSchema.safeParse({ ...project, version: 2 }).success, + false, + ); + assert.equal( + yokeProjectSchema.safeParse({ ...project, cells: [] }).success, + false, + ); + assert.equal( + yokeProjectSchema.safeParse({ + ...project, + palette: ["#000000", "#ffffff"], + cells: project.cells.map((row) => row.map(() => 2)), + }).success, + false, + ); +}); +test("all drawing views hit exactly the cells they render, including outside boundaries", () => { + const counts = yokeRoundCounts(plan, defaults.repeats, events); + for (const view of ["whole", "repeat", "chart"] as YokeView[]) { + const g = yokeGeometry(counts, defaults.repeats, view, 1.4); + assert.equal( + g.arc, + view === "whole" ? 2 * Math.PI : (2 * Math.PI) / defaults.repeats, + ); + for (const row of [0, 10, counts.length - 1]) + for (const column of [ + 0, + Math.floor(g.shownCounts[row] / 2), + g.shownCounts[row] - 1, + ]) { + const angle = + g.startAngle + ((column + 0.5) / g.shownCounts[row]) * g.arc; + const radius = g.inner + (row + 0.5) * g.radialHeight; + const x = + view === "chart" + ? (1000 - g.shownCounts[row] * g.cellWidth) / 2 + + (column + 0.5) * g.cellWidth + : g.cx + Math.cos(angle) * radius; + const y = + view === "chart" + ? 760 - (row + 0.5) * g.rowHeight + : g.cy + Math.sin(angle) * radius; + assert.deepEqual( + yokeCellAt(counts, defaults.repeats, view, 1.4, x, y), + { row, column }, + ); + } + assert.equal( + yokeCellAt(counts, defaults.repeats, view, 1.4, -50, -50), + null, + ); + } +}); diff --git a/lib/yoke-plan.ts b/lib/yoke-plan.ts new file mode 100644 index 0000000..5b201b5 --- /dev/null +++ b/lib/yoke-plan.ts @@ -0,0 +1,197 @@ +import { z } from "zod"; + +const length = z.number().finite().positive().max(400); +export const yokeMeasurementsSchema = z.object({ + unit: z.enum(["cm", "in"]), + gaugeStitches: length, + gaugeRows: length, + gaugeMeasure: length, + neck: length, + chest: length, + bodyEase: z.number().finite().min(-20).max(60), + upperArm: length, + sleeveEase: z.number().finite().min(-10).max(30), + underarm: length, + depth: length, + backRise: z.number().finite().min(0).max(15), + repeats: z.number().int().min(2).max(40), +}); +export type YokeMeasurements = z.infer; +export const defaultYokeMeasurements: YokeMeasurements = { + unit: "cm", + gaugeStitches: 20, + gaugeRows: 28, + gaugeMeasure: 10, + neck: 44, + chest: 96, + bodyEase: 10, + upperArm: 32, + sleeveEase: 4, + underarm: 4, + depth: 22, + backRise: 2, + repeats: 12, +}; +export const increaseEventSchema = z.object({ + round: z.number().int().min(2).max(150), + count: z.number().int().min(1).max(1200), +}); +export type IncreaseEvent = z.infer; +export type YokePlan = ReturnType; + +/** Finished body includes TWO underarms; each finished sleeve includes ONE. */ +export function calculateYokeTargets(value: YokeMeasurements) { + const input = yokeMeasurementsSchema.parse(value); + const stitchesPerUnit = input.gaugeStitches / input.gaugeMeasure; + const rowsPerUnit = input.gaugeRows / input.gaugeMeasure; + const body = Math.round((input.chest + input.bodyEase) * stitchesPerUnit); + const sleeve = Math.round( + (input.upperArm + input.sleeveEase) * stitchesPerUnit, + ); + const underarm = Math.round(input.underarm * stitchesPerUnit); + const requestedNeck = Math.round(input.neck * stitchesPerUnit); + const requestedEnd = body + 2 * sleeve - 4 * underarm; + const multiple = (count: number) => + Math.round(count / input.repeats) * input.repeats; + const neck = multiple(requestedNeck); + const end = multiple(requestedEnd); + const rounds = Math.round(input.depth * rowsPerUnit); + const shortRowPairs = Math.round((input.backRise * rowsPerUnit) / 2); + const actualBody = end - 2 * (sleeve - underarm) + 2 * underarm; + if (body <= 2 * underarm || sleeve <= underarm || underarm < 1) + throw new Error( + "The underarm allowance must leave stitches for the body and both sleeves.", + ); + if (neck < input.repeats * 2 || end <= neck) + throw new Error( + "Choose a lower neck count, a larger lower yoke, or fewer repeats.", + ); + if (end > 1200 || end / input.repeats > 200 || rounds < 5 || rounds > 150) + throw new Error( + "This first version supports 5–150 full rounds, up to 1,200 total stitches and 200 stitches per repeat.", + ); + if (Math.ceil(Math.log2(end / neck)) > rounds - 1) + throw new Error( + "There are too few rounds for that amount of increase. Increase the yoke length or revise the target counts.", + ); + return { + neck, + end, + rounds, + body: actualBody, + sleeve, + underarm, + bodyFromYoke: actualBody - 2 * underarm, + sleeveFromYoke: sleeve - underarm, + actualNeck: neck / stitchesPerUnit, + actualBody: actualBody / stitchesPerUnit, + actualSleeve: sleeve / stitchesPerUnit, + actualDepth: rounds / rowsPerUnit, + neckAdjustment: neck - requestedNeck, + bodyAdjustment: actualBody - body, + shortRowPairs, + actualBackRise: (shortRowPairs * 2) / rowsPerUnit, + }; +} + +/** An editable scaffold, NOT a validated garment-fitting prescription. */ +export function suggestYokeSchedule( + plan: YokePlan, + repeats: number, +): IncreaseEvent[] { + const start = plan.neck / repeats, + end = plan.end / repeats; + const stages = Math.min( + plan.rounds - 1, + end - start, + Math.max(4, Math.ceil(Math.log2(end / start))), + ); + let previousRound = 1, + previousCount = start; + return Array.from({ length: stages }, (_, index) => { + const remaining = stages - index - 1; + const fraction = + stages === 4 + ? [0.15, 0.32, 0.5, 0.72][index] + : (index + 1) / (stages + 1); + const round = Math.min( + plan.rounds - remaining, + Math.max(previousRound + 1, Math.round(1 + (plan.rounds - 1) * fraction)), + ); + const ideal = Math.round(start * (end / start) ** ((index + 1) / stages)); + const count = Math.min( + previousCount * 2, + end - remaining, + Math.max(previousCount + 1, ideal, Math.ceil(end / 2 ** remaining)), + ); + previousRound = round; + previousCount = count; + return { round, count: count * repeats }; + }); +} + +export function yokeRoundCounts( + plan: YokePlan, + repeats: number, + events: IncreaseEvent[], +) { + let count = plan.neck; + let previousRound = 1; + for (const candidate of events) { + const event = increaseEventSchema.parse(candidate); + if (event.round <= previousRound || event.round > plan.rounds) + throw new Error( + "Increase rounds must be in ascending order, within the yoke.", + ); + if ( + event.count <= count || + event.count % repeats !== 0 || + event.count > plan.end + ) + throw new Error( + "Each count must increase by whole repeats without exceeding the lower-yoke target.", + ); + if (event.count > 2 * count) + throw new Error( + "Split this increase across more rounds: a round cannot more than double its stitch count.", + ); + previousRound = event.round; + count = event.count; + } + if (count !== plan.end) + throw new Error(`The last increase must reach ${plan.end} stitches.`); + let eventIndex = 0; + count = plan.neck; + return Array.from({ length: plan.rounds }, (_, index) => { + if (events[eventIndex]?.round === index + 1) + count = events[eventIndex++].count; + return count; + }); +} + +export function convertYokeUnits( + input: YokeMeasurements, + unit: "cm" | "in", +): YokeMeasurements { + if (unit === input.unit) return input; + const factor = unit === "in" ? 1 / 2.54 : 2.54; + return { + ...input, + unit, + ...Object.fromEntries( + ( + [ + "gaugeMeasure", + "neck", + "chest", + "bodyEase", + "upperArm", + "sleeveEase", + "underarm", + "depth", + "backRise", + ] as const + ).map((key) => [key, input[key] * factor]), + ), + }; +} diff --git a/lib/yoke-project.ts b/lib/yoke-project.ts new file mode 100644 index 0000000..e310798 --- /dev/null +++ b/lib/yoke-project.ts @@ -0,0 +1,100 @@ +import { z } from "zod"; +import { + calculateYokeTargets, + increaseEventSchema, + yokeMeasurementsSchema, + yokeRoundCounts, + type YokeMeasurements, + type IncreaseEvent, +} from "./yoke-plan"; + +const paletteSchema = z + .array(z.string().regex(/^#[0-9a-f]{6}$/i)) + .min(2) + .max(8); +export const yokeProjectSchema = z + .object({ + format: z.literal("knitplot-yoke"), + version: z.literal(1), + measurements: yokeMeasurementsSchema, + events: z.array(increaseEventSchema).max(149), + palette: paletteSchema, + cells: z.array(z.array(z.number().int().min(0).max(7)).max(1200)).max(150), + }) + .superRefine((project, ctx) => { + try { + const counts = yokeRoundCounts( + calculateYokeTargets(project.measurements), + project.measurements.repeats, + project.events, + ); + if ( + project.cells.length !== counts.length || + project.cells.some( + (row, index) => + row.length !== counts[index] || + row.some((color) => color >= project.palette.length), + ) + ) + throw new Error("Invalid yoke stitch data."); + } catch (error) { + ctx.addIssue({ + code: "custom", + message: error instanceof Error ? error.message : "Invalid yoke plan.", + }); + } + }); +export type YokeProject = z.infer; + +export function createYokeProject( + measurements: YokeMeasurements, + events: IncreaseEvent[], +): YokeProject { + const counts = yokeRoundCounts( + calculateYokeTargets(measurements), + measurements.repeats, + events, + ); + return { + format: "knitplot-yoke", + version: 1, + measurements: { ...measurements }, + events: events.map((event) => ({ ...event })), + palette: ["#f2eadf", "#334c44", "#c86f52"], + cells: counts.map((count) => Array(count).fill(0)), + }; +} + +export function paintYoke( + project: YokeProject, + row: number, + column: number, + color: number, + allRepeats: boolean, +): YokeProject { + if ( + !Number.isInteger(row) || + !Number.isInteger(column) || + !Number.isInteger(color) || + !project.cells[row] || + column < 0 || + column >= project.cells[row].length || + color < 0 || + color >= project.palette.length + ) + return project; + const width = project.cells[row].length / project.measurements.repeats; + const cells = project.cells.slice(); + let changed = false; + cells[row] = project.cells[row].map((previous, index) => { + if ( + (allRepeats ? index % width === column % width : index === column) && + previous !== color + ) { + changed = true; + return color; + } + return previous; + }); + return changed ? { ...project, cells } : project; +} From 5a259514ab737190b36cb8016af7d8f3acb1e213 Mon Sep 17 00:00:00 2001 From: syalr2 Date: Tue, 10 Nov 2026 09:54:22 -0500 Subject: [PATCH 2/3] Add development yoke workspace with linked drawing views and safe drafts --- app/yoke-designer/page.dev.tsx | 8 + app/yoke-designer/yoke.css | 156 ++++++++++++++++++ components/use-yoke-draft.ts | 78 +++++++++ components/yoke-canvas.tsx | 149 +++++++++++++++++ components/yoke-designer.tsx | 286 ++++++++++++++++++++++++++++++++ components/yoke-planner.tsx | 291 +++++++++++++++++++++++++++++++++ docs/yoke-designer.md | 65 ++++++++ e2e/editor.spec.ts | 1 + e2e/harness/app/yoke/page.tsx | 6 + e2e/yoke.spec.ts | 192 ++++++++++++++++++++++ playwright.config.ts | 2 +- 11 files changed, 1233 insertions(+), 1 deletion(-) create mode 100644 app/yoke-designer/page.dev.tsx create mode 100644 app/yoke-designer/yoke.css create mode 100644 components/use-yoke-draft.ts create mode 100644 components/yoke-canvas.tsx create mode 100644 components/yoke-designer.tsx create mode 100644 components/yoke-planner.tsx create mode 100644 docs/yoke-designer.md create mode 100644 e2e/harness/app/yoke/page.tsx create mode 100644 e2e/yoke.spec.ts diff --git a/app/yoke-designer/page.dev.tsx b/app/yoke-designer/page.dev.tsx new file mode 100644 index 0000000..1ffdd35 --- /dev/null +++ b/app/yoke-designer/page.dev.tsx @@ -0,0 +1,8 @@ +import { notFound } from "next/navigation"; +import { YokeDesigner } from "@/components/yoke-designer"; +import "./yoke.css"; + +export default function YokeDesignerPage() { + if (process.env.NODE_ENV !== "development") notFound(); + return ; +} diff --git a/app/yoke-designer/yoke.css b/app/yoke-designer/yoke.css new file mode 100644 index 0000000..20875bb --- /dev/null +++ b/app/yoke-designer/yoke.css @@ -0,0 +1,156 @@ +.yoke-designer { + max-width: 1600px; + margin: auto; + padding: 24px; +} +.yoke-header, +.yoke-header nav, +.yoke-toolbar, +.yoke-view-tabs, +.yoke-summary { + display: flex; + gap: 12px; + align-items: center; + flex-wrap: wrap; +} +.yoke-header { + justify-content: space-between; + margin-bottom: 24px; +} +.yoke-header h1 { + font-size: 25px; + margin: 0; +} +.yoke-header h1 span { + color: var(--accent-deep); +} +.yoke-header p { + margin: 5px 0; + font-size: 12px; +} +.yoke-workspace { + display: grid; + grid-template-columns: 340px minmax(0, 1fr); + gap: 20px; + align-items: start; +} +.yoke-panel { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 14px; + padding: 20px; + min-width: 0; +} +.yoke-panel h2 { + font-size: 22px; + margin: 0 0 8px; +} +.yoke-panel h3 { + font-size: 17px; + margin: 20px 0 8px; +} +.yoke-panel p, +.yoke-panel li { + font-size: 13px; + line-height: 1.5; +} +.yoke-panel .yoke-hint, +.yoke-panel small { + color: #686658; + font-size: 12px; +} +.yoke-fields { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + margin: 16px 0; +} +.yoke-panel .yoke-fields label { + display: flex; + flex-direction: column; +} +.yoke-panel label { + display: grid; + gap: 6px; + font-size: 12px; + min-width: 0; +} +.yoke-panel input:not([type="checkbox"]):not([type="color"]), +.yoke-panel select { + width: 100%; + min-width: 0; + padding: 7px; + border: 1px solid var(--line); + border-radius: 6px; + background: white; + font: inherit; +} +.yoke-panel small { + line-height: 1.4; +} +.yoke-schedule > div { + display: grid; + grid-template-columns: 1fr 1fr 30px; + gap: 8px; + align-items: end; + margin-bottom: 10px; +} +.yoke-summary { + justify-content: space-between; + font-size: 13px; + margin-bottom: 16px; +} +.yoke-toolbar { + margin-top: 16px; +} +.yoke-toolbar label { + display: flex; + align-items: center; +} +.yoke-colour { + display: flex; + align-items: center; + gap: 3px; +} +.yoke-colour button { + border: 2px solid transparent; + border-radius: 50%; + width: 28px; + height: 28px; +} +.yoke-colour button[aria-pressed="true"] { + outline: 2px solid var(--accent-deep); + outline-offset: 2px; +} +.yoke-colour input { + width: 24px; + height: 24px; + padding: 0; + border: 0; +} +.yoke-canvas-scroll { + width: 100%; + overflow: auto; + background: white; + border-radius: 8px; +} +.yoke-canvas-scroll canvas { + display: block; + max-width: none; + height: auto; + touch-action: none; + user-select: none; + cursor: crosshair; +} +.yoke-drawing details { + margin-top: 14px; + font-size: 13px; +} +@media (max-width: 900px) { + .yoke-workspace { + grid-template-columns: 1fr; + } + .yoke-designer { + padding: 12px; + } +} diff --git a/components/use-yoke-draft.ts b/components/use-yoke-draft.ts new file mode 100644 index 0000000..98a273e --- /dev/null +++ b/components/use-yoke-draft.ts @@ -0,0 +1,78 @@ +"use client"; +import { + startTransition, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { yokeProjectSchema, type YokeProject } from "@/lib/yoke-project"; + +const KEY = "knitplot-yoke-draft-v1"; +/** Isolated from regular charts; refuses to overwrite a corrupt or concurrently edited draft. */ +export function useYokeDraft(initial: YokeProject) { + const [project, setProject] = useState(initial); + const [ready, setReady] = useState(false); + const [error, setError] = useState(""); + const stored = useRef(null); + const latest = useRef(project); + useLayoutEffect(() => { + latest.current = project; + }, [project]); + useEffect(() => { + try { + const text = localStorage.getItem(KEY); + const parsed = text ? yokeProjectSchema.parse(JSON.parse(text)) : initial; + stored.current = text; + startTransition(() => { + setProject(parsed); + setReady(true); + }); + } catch { + startTransition(() => { + setError( + "The saved yoke draft could not be read. It has been left untouched. Download your work before leaving.", + ); + setReady(true); + }); + } + }, [initial]); + useEffect(() => { + if (!ready) return; + function save() { + try { + if (localStorage.getItem(KEY) !== stored.current) throw new Error(); + const text = JSON.stringify(latest.current); + if (text !== stored.current) localStorage.setItem(KEY, text); + stored.current = text; + setError(""); + return true; + } catch { + setError( + "This yoke draft could not be saved, or another window changed it. Download your work before leaving.", + ); + return false; + } + } + const timeout = setTimeout(save, 200); + const beforeUnload = (event: BeforeUnloadEvent) => { + if (!save()) { + event.preventDefault(); + event.returnValue = ""; + } + }; + const hide = () => { + if (document.visibilityState === "hidden") save(); + }; + window.addEventListener("beforeunload", beforeUnload); + window.addEventListener("pagehide", save); + document.addEventListener("visibilitychange", hide); + return () => { + clearTimeout(timeout); + window.removeEventListener("beforeunload", beforeUnload); + window.removeEventListener("pagehide", save); + document.removeEventListener("visibilitychange", hide); + }; + }, [project, ready]); + return { project, setProject, ready, error }; +} diff --git a/components/yoke-canvas.tsx b/components/yoke-canvas.tsx new file mode 100644 index 0000000..9ab7a3b --- /dev/null +++ b/components/yoke-canvas.tsx @@ -0,0 +1,149 @@ +"use client"; +import { useEffect, useRef, type PointerEvent } from "react"; +import type { YokeProject } from "@/lib/yoke-project"; +import { + YOKE_CANVAS, + yokeGeometry, + yokeCellAt, + type YokeView, +} from "@/lib/yoke-layout"; + +type Props = { + project: YokeProject; + view: YokeView; + zoom: number; + onPaint: (row: number, column: number) => void; + onStrokeStart: () => void; +}; +export function YokeCanvas({ + project, + view, + zoom, + onPaint, + onStrokeStart, +}: Props) { + const ref = useRef(null); + const drawing = useRef(false); + const counts = project.cells.map((row) => row.length); + const aspect = + project.measurements.gaugeRows / project.measurements.gaugeStitches; + useEffect(() => { + const canvas = ref.current; + const context = canvas?.getContext("2d"); + if (!canvas || !context) return; + context.clearRect(0, 0, canvas.width, canvas.height); + const g = yokeGeometry( + project.cells.map((row) => row.length), + project.measurements.repeats, + view, + aspect, + ); + const increaseRows = new Set( + project.events.map((event) => event.round - 1), + ); + project.cells.forEach((row, r) => { + const count = g.shownCounts[r]; + const bottom = YOKE_CANVAS.height - 40 - r * g.rowHeight; + for (let c = 0; c < count; c++) { + context.fillStyle = project.palette[row[c]]; + context.beginPath(); + if (view === "chart") { + context.rect( + (canvas.width - count * g.cellWidth) / 2 + c * g.cellWidth, + bottom - g.rowHeight, + g.cellWidth, + g.rowHeight, + ); + } else { + const a = g.startAngle + (c / count) * g.arc; + const b = g.startAngle + ((c + 1) / count) * g.arc; + const inner = g.inner + r * g.radialHeight; + const outer = inner + g.radialHeight; + context.arc(g.cx, g.cy, inner, a, b); + context.arc(g.cx, g.cy, outer, b, a, true); + context.closePath(); + } + context.fill(); + context.strokeStyle = "rgba(45,52,45,.22)"; + context.lineWidth = 0.5; + context.stroke(); + } + if (increaseRows.has(r)) { + context.beginPath(); + if (view === "chart") { + context.moveTo((canvas.width - count * g.cellWidth) / 2, bottom); + context.lineTo((canvas.width + count * g.cellWidth) / 2, bottom); + } else { + context.arc( + g.cx, + g.cy, + g.inner + r * g.radialHeight, + g.startAngle, + g.startAngle + g.arc, + ); + } + context.strokeStyle = "#be7755"; + context.lineWidth = 1.5; + context.stroke(); + } + }); + context.fillStyle = "#334c44"; + context.font = "15px sans-serif"; + context.textAlign = "center"; + context.fillText( + view === "whole" + ? "Back · start of round" + : view === "repeat" + ? "Neck edge · round 1" + : "Neck edge · round 1 at bottom", + 500, + view === "chart" ? 790 : 15, + ); + if (view === "whole") context.fillText("Front", 500, 790); + }, [project, view, aspect]); + + function paint(event: PointerEvent) { + const box = event.currentTarget.getBoundingClientRect(); + const cell = yokeCellAt( + counts, + project.measurements.repeats, + view, + aspect, + ((event.clientX - box.left) / box.width) * YOKE_CANVAS.width, + ((event.clientY - box.top) / box.height) * YOKE_CANVAS.height, + ); + if (cell) onPaint(cell.row, cell.column); + } + return ( +
+ { + if (event.button !== 0) return; + event.preventDefault(); + onStrokeStart(); + drawing.current = true; + event.currentTarget.setPointerCapture(event.pointerId); + paint(event); + }} + onPointerMove={(event) => { + if (drawing.current) paint(event); + }} + onPointerUp={() => { + drawing.current = false; + }} + onPointerCancel={() => { + drawing.current = false; + }} + onLostPointerCapture={() => { + drawing.current = false; + }} + /> +
+ ); +} diff --git a/components/yoke-designer.tsx b/components/yoke-designer.tsx new file mode 100644 index 0000000..240c9ac --- /dev/null +++ b/components/yoke-designer.tsx @@ -0,0 +1,286 @@ +"use client"; +import Link from "next/link"; +import { useLayoutEffect, useRef, useState } from "react"; +import { + calculateYokeTargets, + defaultYokeMeasurements, + suggestYokeSchedule, + type YokeMeasurements, + type IncreaseEvent, +} from "@/lib/yoke-plan"; +import { + createYokeProject, + paintYoke, + yokeProjectSchema, + type YokeProject, +} from "@/lib/yoke-project"; +import type { YokeView } from "@/lib/yoke-layout"; +import { YokePlanner } from "./yoke-planner"; +import { YokeCanvas } from "./yoke-canvas"; +import { useYokeDraft } from "./use-yoke-draft"; + +const initial = createYokeProject( + defaultYokeMeasurements, + suggestYokeSchedule( + calculateYokeTargets(defaultYokeMeasurements), + defaultYokeMeasurements.repeats, + ), +); +export function YokeDesigner() { + const { project, setProject, ready, error } = useYokeDraft(initial); + const [view, setView] = useState("repeat"); + const [color, setColor] = useState(1); + const [allRepeats, setAllRepeats] = useState(true); + const [zoom, setZoom] = useState(100); + const [history, setHistory] = useState([]); + const [future, setFuture] = useState([]); + const [message, setMessage] = useState(""); + const input = useRef(null); + const latestProject = useRef(project); + useLayoutEffect(() => { + latestProject.current = project; + }, [project]); + const plan = calculateYokeTargets(project.measurements); + function remember() { + setHistory((old) => [...old.slice(-19), project]); + setFuture([]); + } + function replace(next: YokeProject) { + if ( + project.cells.some((row) => row.some((value) => value !== 0)) && + !window.confirm( + "Replace this drawing with the new plan? Download the current yoke first if you want to keep it. You can also undo this change.", + ) + ) + return; + remember(); + setProject(next); + setColor(1); + setMessage(""); + } + function apply(measurements: YokeMeasurements, events: IncreaseEvent[]) { + replace({ + ...createYokeProject(measurements, events), + palette: project.palette, + }); + } + function download() { + const url = URL.createObjectURL( + new Blob([JSON.stringify(project)], { type: "application/json" }), + ); + const link = document.createElement("a"); + link.href = url; + link.download = "my-yoke.knitplot-yoke"; + link.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + } + if (!ready) + return ( +
+

Loading yoke draft…

+
+ ); + return ( +
+
+
+

+ KnitPlot · Yoke designer +

+

Development preview

+
+ + { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file) return; + try { + if (file.size > 2_000_000) + throw new Error("That draft is too large."); + const next = yokeProjectSchema.parse( + JSON.parse(await file.text()), + ); + if (latestProject.current !== project) { + setMessage( + "Your drawing changed while the file was opening. Open the file again when you are ready to replace it.", + ); + return; + } + replace(next); + } catch { + setMessage( + "That file is not a valid KnitPlot yoke draft. Your current drawing is unchanged.", + ); + } + }} + /> +
+ {error || message ?

{error || message}

: null} +
+ +
+
+ + {plan.neck} → {plan.end} stitches + + + {plan.rounds} full rounds · {project.measurements.repeats} repeats + +
+
+ {( + [ + ["whole", "Whole yoke"], + ["repeat", "One repeat"], + ["chart", "Standard chart"], + ] as const + ).map(([id, label]) => ( + + ))} +
+
+ {project.palette.map((hex, index) => ( +
+
+ ))} + + + + +
+

+ Design layout · warm lines mark increase rounds. Repeat and standard + views edit the first repeat; the whole-yoke view can edit any + repeat. +

+ + setProject((current) => + paintYoke(current, row, column, color, allRepeats), + ) + } + /> +
+ Current shaping plan +
    + {project.events.map((event, index) => ( +
  • + Round {event.round}: increase{" "} + {event.count - + (index ? project.events[index - 1].count : plan.neck)}{" "} + → {event.count} stitches ( + {event.count / project.measurements.repeats} per repeat). +
  • + ))} +
+

+ Body at split: {plan.bodyFromYoke} stitches. Each sleeve on hold:{" "} + {plan.sleeveFromYoke}. Cast on {plan.underarm} at each underarm. +

+

+ {plan.shortRowPairs} short-row pairs planned before colourwork; + not included in these full-round drawing views. +

+
+
+
+
+ ); +} diff --git a/components/yoke-planner.tsx b/components/yoke-planner.tsx new file mode 100644 index 0000000..cb08b43 --- /dev/null +++ b/components/yoke-planner.tsx @@ -0,0 +1,291 @@ +"use client"; +import { useState, type FormEvent } from "react"; +import { ZodError } from "zod"; +import { + calculateYokeTargets, + convertYokeUnits, + suggestYokeSchedule, + yokeRoundCounts, + type YokeMeasurements, + type IncreaseEvent, + type YokePlan, +} from "@/lib/yoke-plan"; + +type NumberKey = Exclude; +const fields: { key: NumberKey; label: string; help?: string }[] = [ + { key: "gaugeStitches", label: "Gauge stitches" }, + { key: "gaugeRows", label: "Gauge rows" }, + { key: "gaugeMeasure", label: "Gauge measured over" }, + { + key: "neck", + label: "Finished neck opening", + help: "Circumference at the base of the neckband; neckband not included.", + }, + { key: "chest", label: "Body chest / bust" }, + { key: "bodyEase", label: "Body ease" }, + { key: "upperArm", label: "Body upper arm" }, + { key: "sleeveEase", label: "Sleeve ease" }, + { + key: "underarm", + label: "Each underarm width", + help: "Width cast on at each underarm when separating sleeves.", + }, + { + key: "depth", + label: "Full-round yoke length", + help: "Along the fabric from the base of the front neckline to the sleeve split. Not vertical armhole depth; excludes extra back short rows.", + }, + { + key: "backRise", + label: "Extra back-neck height", + help: "Optional plain-colour short rows before the motif. Enter 0 to omit.", + }, + { key: "repeats", label: "Repeats around yoke" }, +]; +function drafts(measurements: YokeMeasurements) { + return Object.fromEntries( + fields.map(({ key }) => [key, String(measurements[key])]), + ) as Record; +} +function read( + values: Record, + unit: "cm" | "in", +): YokeMeasurements { + if (Object.values(values).some((value) => value.trim() === "")) + throw new Error( + "Fill in every measurement, using 0 for no ease or no short rows.", + ); + return { + ...Object.fromEntries( + Object.entries(values).map(([key, value]) => [key, Number(value)]), + ), + unit, + } as YokeMeasurements; +} +type Candidate = { + measurements: YokeMeasurements; + targets: YokePlan; + source: string; +}; +type Props = { + measurements: YokeMeasurements; + events: IncreaseEvent[]; + onApply: (measurements: YokeMeasurements, events: IncreaseEvent[]) => void; +}; + +export function YokePlanner({ measurements, events, onApply }: Props) { + const [unit, setUnit] = useState(measurements.unit); + const [values, setValues] = useState(() => drafts(measurements)); + const [candidate, setCandidate] = useState(null); + const [schedule, setSchedule] = useState( + events.map((event) => ({ + round: String(event.round), + count: String(event.count), + })), + ); + const [error, setError] = useState(""); + const source = JSON.stringify({ values, unit }); + const dirty = candidate !== null && candidate.source !== source; + const number = (value: number) => value.toFixed(1); + function calculate(event: FormEvent) { + event.preventDefault(); + try { + const next = read(values, unit); + const targets = calculateYokeTargets(next); + setCandidate({ measurements: next, targets, source }); + setSchedule( + suggestYokeSchedule(targets, next.repeats).map((entry) => ({ + round: String(entry.round), + count: String(entry.count), + })), + ); + setError(""); + } catch (error) { + setError( + error instanceof Error && !(error instanceof ZodError) + ? error.message + : "Check the measurements: use finite numbers, positive gauge and lengths, and 2–40 whole repeats.", + ); + } + } + function apply() { + if (!candidate || dirty) return; + try { + const nextEvents = schedule.map((event) => ({ + round: Number(event.round), + count: Number(event.count), + })); + yokeRoundCounts( + candidate.targets, + candidate.measurements.repeats, + nextEvents, + ); + onApply(candidate.measurements, nextEvents); + setError(""); + } catch (error) { + setError( + error instanceof Error && !(error instanceof ZodError) + ? error.message + : "Enter valid whole rounds and stitch counts.", + ); + } + } + return ( +
+

Plan the yoke

+

+ Top-down pullover · one consistent blocked gauge +

+
+ +
+ {fields.map(({ key, label, help }) => ( + + ))} +
+ +
+ {error ?

{error}

: null} + {candidate ? ( +
+

Calculated targets

+

+ {candidate.targets.neck} → {candidate.targets.end} stitches ·{" "} + {candidate.targets.rounds} full rounds +

+

+ Finished neck {number(candidate.targets.actualNeck)}{" "} + {candidate.measurements.unit} · body{" "} + {number(candidate.targets.actualBody)} {candidate.measurements.unit}{" "} + · upper sleeve {number(candidate.targets.actualSleeve)}{" "} + {candidate.measurements.unit} +

+

+ Full-round fabric length: {number(candidate.targets.actualDepth)}{" "} + {candidate.measurements.unit}, before extra back-neck short rows. +

+

+ Repeat rounding: neck{" "} + {candidate.targets.neckAdjustment >= 0 ? "+" : ""} + {candidate.targets.neckAdjustment} stitches; body{" "} + {candidate.targets.bodyAdjustment >= 0 ? "+" : ""} + {candidate.targets.bodyAdjustment} stitches. Sleeve targets + unchanged. +

+

+ Separate {candidate.targets.bodyFromYoke} body stitches and{" "} + {candidate.targets.sleeveFromYoke} per sleeve. Add{" "} + {candidate.targets.underarm} at each underarm. +

+

Increase schedule

+

+ Editable starting spacing, not a tested fitting prescription. Counts + are after working each increase round. +

+
+ {schedule.map((entry, index) => ( +
+ + + +
+ ))} +
+ +

+ {candidate.targets.shortRowPairs + ? `Short-row planning allowance: ${candidate.targets.shortRowPairs} pairs (${number(candidate.targets.actualBackRise)} ${candidate.measurements.unit} extra at centre back). Turn positions and knitting instructions are not generated yet.` + : "No neckline short rows."} +

+ {dirty ? ( +

+ Measurements changed. Calculate targets again before applying. +

+ ) : null} + +
+ ) : null} +
+ ); +} diff --git a/docs/yoke-designer.md b/docs/yoke-designer.md new file mode 100644 index 0000000..3097074 --- /dev/null +++ b/docs/yoke-designer.md @@ -0,0 +1,65 @@ +# Yoke designer: development slice + +Available at `/yoke-designer` in `next dev` only. Production must return 404. +No production accounts, APIs, database tables or regular chart storage are changed. + +## Implemented + +- One consistent blocked stitch/row gauge. Knitters adjust needles to maintain it. +- Targets from body chest + ease, upper arm + separate sleeve ease, finished + neckline, underarm width and full-round front yoke length along the fabric. +- Explicit conversion between cm and inches, including the gauge measurement span. +- Repeat-compatible neck and lower-yoke rounding, with resulting finished sizes + and stitch adjustments shown before applying. +- Editable increase rounds with post-increase counts. Whole-repeat increments, + ascending rounds, final target and at-most-doubling constraints are validated. +- Whole-yoke, single-repeat and standard chart drawing views sharing one model. + Ring and wedge views are design projections, not fit/physics simulations. + A repeat has an angular span of 2π / repeat count. +- All-repeat painting or individual-repeat variations, zoom and undo/redo. +- Versioned local draft, export/import `.knitplot-yoke`, schema validation, + quota warnings and optimistic comparison before browser-storage writes. + Other windows and damaged drafts must never be silently overwritten. + +## Stitch accounting + +For the simple top-down body/sleeve separation used here, B is the final body +stitch count, S is each upper sleeve count and U is each underarm cast-on count. + +- Body from yoke = B − 2U +- Each sleeve held from yoke = S − U +- Total lower yoke = B + 2S − 4U + +Round neck and total lower-yoke counts to repeat multiples. Any rounding at the +lower yoke is assigned to the body target and disclosed; sleeve targets stay fixed. +This is an explicit arithmetic convention, not a universal sizing formula. + +Short-row allowance = desired extra back height × row gauge, rounded to pairs. +This adds local back height only, NOT full rounds to every stitch or live stitches. + +## Deliberate limits / next decisions + +- The generated increase spacing is an editable engineering scaffold, not a + tested fit prescription. It is NOT taken from a published sweater pattern. + Internal arithmetic tests do not validate fit. Shoulder checkpoints, measurement + diagrams and comparisons against test-knitted designs are still required. +- Short-row pair count is planning metadata only: no turn positions, double-stitch + operations, instructions or local-height rendering yet. First intended support: + plain-colour short rows before colourwork, without increases during those rows. +- Shaping events presently specify resulting counts, not M1L/M1R/kfb placements. + No ordinary `.knitplot` export is offered until those placements and connections + are represented correctly. The existing realistic knitted renderer is unchanged. +- Need an entry path for counts from an existing pattern, not measurements only. +- Need measurement illustrations and shoulder/neckline fit controls, band-specific + repeats, selection tools, and integration with My Charts / Knit Mode. +- Changing the applied structure resets the drawing only after confirmation if + there are painted cells; undo retains the prior project. +- Limits: 1,200 stitches around, 200 per repeat, 150 full rounds, 2–40 repeats. + These limits are independent of the ordinary chart-maker limits. + +## Verification + +`lib/yoke-plan.test.ts` covers arithmetic, rounding, units, short-row separation, +schedule consistency, immutable painting, file schema and rendering/hit-test +agreement. `e2e/yoke.spec.ts` exercises the real UI in the isolated localhost +harness; `e2e/editor.spec.ts` verifies the prototype is absent in production. diff --git a/e2e/editor.spec.ts b/e2e/editor.spec.ts index 0b05758..075b718 100644 --- a/e2e/editor.spec.ts +++ b/e2e/editor.spec.ts @@ -101,6 +101,7 @@ test("development samples are not production routes", async ({ request }) => { const response = await request.get("/stitch-samples/yarn"); expect(response.status()).toBe(404); expect((await request.get("/e2e/harness")).status()).toBe(404); + expect((await request.get("/yoke-designer")).status()).toBe(404); }); test("chart dimensions allow typing intermediate values and reset on tab changes", async ({ diff --git a/e2e/harness/app/yoke/page.tsx b/e2e/harness/app/yoke/page.tsx new file mode 100644 index 0000000..5eed2e3 --- /dev/null +++ b/e2e/harness/app/yoke/page.tsx @@ -0,0 +1,6 @@ +import { YokeDesigner } from "../../../../components/yoke-designer"; +import "../../../../app/yoke-designer/yoke.css"; + +export default function YokeHarness() { + return ; +} diff --git a/e2e/yoke.spec.ts b/e2e/yoke.spec.ts new file mode 100644 index 0000000..05deebb --- /dev/null +++ b/e2e/yoke.spec.ts @@ -0,0 +1,192 @@ +import { test, expect, type Page } from "@playwright/test"; +import type { YokeProject } from "../lib/yoke-project"; +import { yokeGeometry, type YokeView } from "../lib/yoke-layout"; +const KEY = "knitplot-yoke-draft-v1"; +const saved = (page: Page) => + page.evaluate( + (key) => JSON.parse(localStorage.getItem(key)!) as YokeProject, + KEY, + ); +async function paint( + page: Page, + project: YokeProject, + view: YokeView, + row: number, + column: number, +) { + const canvas = page.getByRole("img", { name: /drawing canvas/ }); + const box = (await canvas.boundingBox())!; + const g = yokeGeometry( + project.cells.map((row) => row.length), + project.measurements.repeats, + view, + project.measurements.gaugeRows / project.measurements.gaugeStitches, + ); + const angle = g.startAngle + ((column + 0.5) / g.shownCounts[row]) * g.arc; + const radius = g.inner + (row + 0.5) * g.radialHeight; + const x = + view === "chart" + ? (1000 - g.shownCounts[row] * g.cellWidth) / 2 + + (column + 0.5) * g.cellWidth + : g.cx + Math.cos(angle) * radius; + const y = + view === "chart" + ? 760 - (row + 0.5) * g.rowHeight + : g.cy + Math.sin(angle) * radius; + await canvas.click({ + position: { x: (x / 1000) * box.width, y: (y / 800) * box.height }, + }); +} +test.beforeEach(async ({ page }) => { + await page.goto("/yoke"); + await expect( + page.getByRole("heading", { name: /KnitPlot · Yoke designer/ }), + ).toBeVisible(); + await expect + .poll(async () => (await saved(page))?.format) + .toBe("knitplot-yoke"); +}); + +test("all views share yarn cells, repeat editing, zoom, history and saved drafts", async ({ + page, +}) => { + const original = await saved(page); + await paint(page, original, "repeat", 2, 1); + await expect + .poll( + async () => (await saved(page)).cells[2].filter((c) => c === 1).length, + ) + .toBe(12); + await page.getByRole("button", { name: "Whole yoke", exact: true }).click(); + await page.getByLabel("Edit all repeats").uncheck(); + await page.getByRole("button", { name: "Paint with yarn colour 3" }).click(); + await paint(page, original, "whole", 2, 2); + await expect + .poll( + async () => (await saved(page)).cells[2].filter((c) => c === 2).length, + ) + .toBe(1); + await page + .getByRole("button", { name: "Standard chart", exact: true }) + .click(); + await page.getByLabel("Yoke zoom", { exact: true }).selectOption("200"); + await paint(page, original, "chart", original.cells.length - 1, 0); + await expect.poll(async () => (await saved(page)).cells.at(-1)![0]).toBe(2); + await page.getByRole("button", { name: "Undo", exact: true }).click(); + await expect.poll(async () => (await saved(page)).cells.at(-1)![0]).toBe(0); + await page.getByRole("button", { name: "Redo", exact: true }).click(); + await expect.poll(async () => (await saved(page)).cells.at(-1)![0]).toBe(2); + await page.reload(); + await expect( + page.getByRole("heading", { name: /KnitPlot · Yoke designer/ }), + ).toBeVisible(); + expect((await saved(page)).cells.at(-1)![0]).toBe(2); +}); + +test("measurement drafts accept intermediate values, validate schedule, and preserve work until applied", async ({ + page, +}) => { + const original = await saved(page); + await page.getByLabel("Repeats around yoke", { exact: true }).fill("1"); + await expect( + page.getByLabel("Repeats around yoke", { exact: true }), + ).toHaveValue("1"); + await page.getByLabel("Repeats around yoke", { exact: true }).fill("10"); + await page + .getByRole("button", { name: "Calculate targets", exact: true }) + .click(); + await expect( + page.getByRole("heading", { name: "Calculated targets" }), + ).toBeVisible(); + expect((await saved(page)).measurements.repeats).toBe( + original.measurements.repeats, + ); + await page.getByLabel("Increase 1 round", { exact: true }).fill("1"); + await page.getByRole("button", { name: "Apply plan to drawing" }).click(); + await expect(page.locator(".yoke-designer [role=alert]")).toContainText( + "valid whole rounds", + ); + await page + .getByRole("button", { name: "Calculate targets", exact: true }) + .click(); + await page.getByRole("button", { name: "Apply plan to drawing" }).click(); + await expect + .poll(async () => (await saved(page)).measurements.repeats) + .toBe(10); +}); + +test("a second window cannot silently overwrite the first window's yoke draft", async ({ + page, + context, +}) => { + const second = await context.newPage(); + await second.goto("/yoke"); + await expect( + second.getByRole("heading", { name: /KnitPlot · Yoke designer/ }), + ).toBeVisible(); + const original = await saved(page); + await paint(page, original, "repeat", 1, 1); + await expect + .poll( + async () => (await saved(page)).cells[1].filter((c) => c === 1).length, + ) + .toBe(12); + await second + .getByRole("button", { name: "Paint with yarn colour 3" }) + .click(); + await paint(second, original, "repeat", 1, 2); + await expect(second.locator(".yoke-designer [role=alert]")).toContainText( + "another window changed it", + ); + expect((await saved(page)).cells[1].filter((c) => c === 2).length).toBe(0); +}); + +test("yoke draft download roundtrips through the validated file importer", async ({ + page, +}) => { + const original = await saved(page); + await paint(page, original, "repeat", 1, 1); + await expect.poll(async () => (await saved(page)).cells[1][1]).toBe(1); + const expected = await saved(page); + const event = page.waitForEvent("download"); + await page + .getByRole("button", { name: "Download yoke draft", exact: true }) + .click(); + const download = await event; + const stream = await download.createReadStream(); + const chunks: Buffer[] = []; + for await (const chunk of stream!) chunks.push(Buffer.from(chunk)); + const buffer = Buffer.concat(chunks); + expect(JSON.parse(buffer.toString("utf8"))).toEqual(expected); + await page.getByRole("button", { name: "Undo", exact: true }).click(); + await expect.poll(async () => (await saved(page)).cells[1][1]).toBe(0); + await page.locator('input[type="file"]').setInputFiles({ + name: "test.knitplot-yoke", + mimeType: "application/json", + buffer, + }); + await expect.poll(async () => (await saved(page)).cells[1][1]).toBe(1); + expect(await saved(page)).toEqual(expected); +}); + +test("corrupt yoke drafts are not overwritten and normal chart storage is untouched", async ({ + page, +}) => { + await page.evaluate((key) => { + localStorage.setItem(key, "not-json"); + localStorage.setItem("colorwork-chart-v1", "separate chart workspace"); + }, KEY); + await page.reload(); + await expect(page.locator(".yoke-designer [role=alert]")).toContainText( + /could not be/, + ); + await expect( + page.getByRole("button", { name: "Download yoke draft", exact: true }), + ).toBeVisible(); + expect(await page.evaluate((key) => localStorage.getItem(key), KEY)).toBe( + "not-json", + ); + expect( + await page.evaluate(() => localStorage.getItem("colorwork-chart-v1")), + ).toBe("separate chart workspace"); +}); diff --git a/playwright.config.ts b/playwright.config.ts index a27e3f7..4be6a0c 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -25,7 +25,7 @@ export default defineConfig({ }, { name: "saving", - testMatch: "saving.spec.ts", + testMatch: ["saving.spec.ts", "yoke.spec.ts"], use: { ...devices["Desktop Chrome"], baseURL: "http://127.0.0.1:3109" }, }, ], From 43502ec5570e6e087ed119063c637e6ad8ad02ef Mon Sep 17 00:00:00 2001 From: syalr2 Date: Wed, 11 Nov 2026 20:07:29 -0500 Subject: [PATCH 3/3] Ship the circular yoke designer and front sweater preview --- app/globals.css | 117 +- app/yoke-designer/yoke.css | 385 ++++- components/chart-grid.tsx | 15 +- components/chart-maker.tsx | 895 ++++++----- components/cloud-chart-actions.tsx | 5 +- components/connected-knit-preview.tsx | 156 +- components/export-tools.tsx | 58 +- components/instructions-view.tsx | 468 +++--- components/knit-mode.tsx | 571 +++++-- components/my-charts-library.tsx | 2 + components/use-device-pixel-ratio.ts | 24 + components/use-yoke-draft.ts | 56 +- components/use-yoke-navigation.ts | 222 +++ components/use-yoke-stitches.ts | 349 ++++ components/use-yoke-tools.ts | 193 +++ components/yoke-canvas-label.ts | 18 + components/yoke-canvas-render.ts | 209 +++ components/yoke-canvas.tsx | 351 +++-- components/yoke-designer.tsx | 165 +- components/yoke-drawing-tools.tsx | 195 +++ components/yoke-editor.tsx | 441 ++++++ components/yoke-gauge-panel.tsx | 142 ++ components/yoke-instructions-sheet.tsx | 96 ++ components/yoke-knitted-preview.tsx | 111 ++ components/yoke-measurement-help.tsx | 356 +++++ components/yoke-measurement-point.tsx | 48 + components/yoke-photo-sample.tsx | 93 ++ components/yoke-plan-dialog.tsx | 268 ++++ components/yoke-planner.tsx | 413 +++-- components/yoke-program-canvas-render.ts | 201 +++ components/yoke-repeat-controls.tsx | 215 +++ components/yoke-rounding-review.tsx | 64 + components/yoke-schedule-editor.tsx | 143 ++ components/yoke-short-row-planner.tsx | 169 ++ components/yoke-standard-chart.tsx | 62 + components/yoke-stitch-program-grid.tsx | 137 ++ components/yoke-stitch-revision-dialog.tsx | 150 ++ database/tests/persistence.test.mjs | 89 ++ docs/yoke-designer.md | 231 ++- docs/yoke-illustration-prompts.md | 19 + docs/yoke-short-rows.md | 23 + docs/yoke-stitch-revisions.md | 112 ++ e2e/editor.spec.ts | 20 + e2e/yoke-editor.spec.ts | 1573 +++++++++++++++++++ e2e/yoke-preview.spec.ts | 485 ++++++ e2e/yoke.spec.ts | 570 ++++++- lib/chart-route-handlers.test.ts | 14 + lib/chart-route-handlers.ts | 17 +- lib/chart-schema.ts | 106 +- lib/chart-summary.ts | 2 + lib/chart.ts | 2 + lib/cloud-charts.ts | 17 +- lib/cloud-save.ts | 3 +- lib/connected-preview.ts | 98 +- lib/connected-preview.worker.ts | 12 +- lib/gauge-schema.ts | 7 + lib/http.ts | 39 +- lib/knit-sequence.ts | 81 + lib/preview-freshness.ts | 55 + lib/preview-layout-request.ts | 7 + lib/project-file.ts | 4 + lib/stitch-schema.ts | 19 + lib/workspace-persistence.ts | 3 +- lib/workspace.test.ts | 23 + lib/workspace.ts | 65 +- lib/yoke-canvas-view.ts | 55 + lib/yoke-chart.test.ts | 208 +++ lib/yoke-chart.ts | 84 + lib/yoke-document.test.ts | 95 ++ lib/yoke-document.ts | 128 ++ lib/yoke-editing.test.ts | 318 ++++ lib/yoke-editing.ts | 341 ++++ lib/yoke-fabric-preview.test.ts | 160 ++ lib/yoke-fabric-preview.ts | 290 ++++ lib/yoke-fill.ts | 96 ++ lib/yoke-history.test.ts | 60 + lib/yoke-history.ts | 24 + lib/yoke-knit.test.ts | 236 +++ lib/yoke-knit.ts | 246 +++ lib/yoke-layout.ts | 32 +- lib/yoke-measurement-fields.ts | 144 ++ lib/yoke-measurement-mode.test.ts | 79 + lib/yoke-measurement-mode.ts | 55 + lib/yoke-mirror.test.ts | 79 + lib/yoke-mirror.ts | 144 ++ lib/yoke-photo-preview.test.ts | 151 ++ lib/yoke-photo-preview.ts | 267 ++++ lib/yoke-plan.ts | 201 ++- lib/yoke-preview-view.ts | 20 + lib/yoke-program-chart.ts | 221 +++ lib/yoke-program-drawing.ts | 92 ++ lib/yoke-program-from-project.ts | 34 + lib/yoke-program-integration.test.ts | 222 +++ lib/yoke-program-state.ts | 114 ++ lib/yoke-program-surface.ts | 71 + lib/yoke-program-wire.test.ts | 190 +++ lib/yoke-program-wire.ts | 103 ++ lib/yoke-project.ts | 104 +- lib/yoke-repeat-labels.test.ts | 46 + lib/yoke-repeat-labels.ts | 45 + lib/yoke-repeat-mirror.ts | 19 + lib/yoke-repeat-scope.ts | 12 + lib/yoke-rounding.test.ts | 47 + lib/yoke-rounding.ts | 71 + lib/yoke-shaping.test.ts | 116 ++ lib/yoke-shaping.ts | 187 +++ lib/yoke-short-row-layout.test.ts | 61 + lib/yoke-short-row-layout.ts | 74 + lib/yoke-short-rows.test.ts | 153 ++ lib/yoke-short-rows.ts | 107 ++ lib/yoke-stitch-program-schema.ts | 62 + lib/yoke-stitch-program.test.ts | 189 +++ lib/yoke-stitch-program.ts | 221 +++ lib/yoke-stitch-revision.ts | 182 +++ lib/yoke-sweater-preview.test.ts | 93 ++ lib/yoke-sweater-preview.ts | 291 ++++ lib/yoke-test-fixtures.ts | 75 + lib/yoke-validation.test.ts | 26 + lib/yoke-view-transform.test.ts | 88 ++ lib/yoke-view-transform.ts | 37 + package-lock.json | 13 + package.json | 1 + playwright.config.ts | 7 +- public/images/yoke/back-rise-diagram-v2.png | Bin 0 -> 1149796 bytes public/images/yoke/body-diagram.png | Bin 0 -> 1305393 bytes public/images/yoke/photo-sweater-front.webp | Bin 0 -> 489786 bytes public/images/yoke/sweater-diagram.png | Bin 0 -> 1431077 bytes public/images/yoke/underarm-diagram.png | Bin 0 -> 1352603 bytes 128 files changed, 16879 insertions(+), 1267 deletions(-) create mode 100644 components/use-device-pixel-ratio.ts create mode 100644 components/use-yoke-navigation.ts create mode 100644 components/use-yoke-stitches.ts create mode 100644 components/use-yoke-tools.ts create mode 100644 components/yoke-canvas-label.ts create mode 100644 components/yoke-canvas-render.ts create mode 100644 components/yoke-drawing-tools.tsx create mode 100644 components/yoke-editor.tsx create mode 100644 components/yoke-gauge-panel.tsx create mode 100644 components/yoke-instructions-sheet.tsx create mode 100644 components/yoke-knitted-preview.tsx create mode 100644 components/yoke-measurement-help.tsx create mode 100644 components/yoke-measurement-point.tsx create mode 100644 components/yoke-photo-sample.tsx create mode 100644 components/yoke-plan-dialog.tsx create mode 100644 components/yoke-program-canvas-render.ts create mode 100644 components/yoke-repeat-controls.tsx create mode 100644 components/yoke-rounding-review.tsx create mode 100644 components/yoke-schedule-editor.tsx create mode 100644 components/yoke-short-row-planner.tsx create mode 100644 components/yoke-standard-chart.tsx create mode 100644 components/yoke-stitch-program-grid.tsx create mode 100644 components/yoke-stitch-revision-dialog.tsx create mode 100644 docs/yoke-illustration-prompts.md create mode 100644 docs/yoke-short-rows.md create mode 100644 docs/yoke-stitch-revisions.md create mode 100644 e2e/yoke-editor.spec.ts create mode 100644 e2e/yoke-preview.spec.ts create mode 100644 lib/gauge-schema.ts create mode 100644 lib/knit-sequence.ts create mode 100644 lib/preview-freshness.ts create mode 100644 lib/stitch-schema.ts create mode 100644 lib/yoke-canvas-view.ts create mode 100644 lib/yoke-chart.test.ts create mode 100644 lib/yoke-chart.ts create mode 100644 lib/yoke-document.test.ts create mode 100644 lib/yoke-document.ts create mode 100644 lib/yoke-editing.test.ts create mode 100644 lib/yoke-editing.ts create mode 100644 lib/yoke-fabric-preview.test.ts create mode 100644 lib/yoke-fabric-preview.ts create mode 100644 lib/yoke-fill.ts create mode 100644 lib/yoke-history.test.ts create mode 100644 lib/yoke-history.ts create mode 100644 lib/yoke-knit.test.ts create mode 100644 lib/yoke-knit.ts create mode 100644 lib/yoke-measurement-fields.ts create mode 100644 lib/yoke-measurement-mode.test.ts create mode 100644 lib/yoke-measurement-mode.ts create mode 100644 lib/yoke-mirror.test.ts create mode 100644 lib/yoke-mirror.ts create mode 100644 lib/yoke-photo-preview.test.ts create mode 100644 lib/yoke-photo-preview.ts create mode 100644 lib/yoke-preview-view.ts create mode 100644 lib/yoke-program-chart.ts create mode 100644 lib/yoke-program-drawing.ts create mode 100644 lib/yoke-program-from-project.ts create mode 100644 lib/yoke-program-integration.test.ts create mode 100644 lib/yoke-program-state.ts create mode 100644 lib/yoke-program-surface.ts create mode 100644 lib/yoke-program-wire.test.ts create mode 100644 lib/yoke-program-wire.ts create mode 100644 lib/yoke-repeat-labels.test.ts create mode 100644 lib/yoke-repeat-labels.ts create mode 100644 lib/yoke-repeat-mirror.ts create mode 100644 lib/yoke-repeat-scope.ts create mode 100644 lib/yoke-rounding.test.ts create mode 100644 lib/yoke-rounding.ts create mode 100644 lib/yoke-shaping.test.ts create mode 100644 lib/yoke-shaping.ts create mode 100644 lib/yoke-short-row-layout.test.ts create mode 100644 lib/yoke-short-row-layout.ts create mode 100644 lib/yoke-short-rows.test.ts create mode 100644 lib/yoke-short-rows.ts create mode 100644 lib/yoke-stitch-program-schema.ts create mode 100644 lib/yoke-stitch-program.test.ts create mode 100644 lib/yoke-stitch-program.ts create mode 100644 lib/yoke-stitch-revision.ts create mode 100644 lib/yoke-sweater-preview.test.ts create mode 100644 lib/yoke-sweater-preview.ts create mode 100644 lib/yoke-test-fixtures.ts create mode 100644 lib/yoke-validation.test.ts create mode 100644 lib/yoke-view-transform.test.ts create mode 100644 lib/yoke-view-transform.ts create mode 100644 public/images/yoke/back-rise-diagram-v2.png create mode 100644 public/images/yoke/body-diagram.png create mode 100644 public/images/yoke/photo-sweater-front.webp create mode 100644 public/images/yoke/sweater-diagram.png create mode 100644 public/images/yoke/underarm-diagram.png diff --git a/app/globals.css b/app/globals.css index 0e7f9e3..2278181 100644 --- a/app/globals.css +++ b/app/globals.css @@ -395,6 +395,25 @@ button:disabled { cursor: default; opacity: 0.42; } +.sidebar .secondary-button, +.sidebar .resize-chart-button, +.yoke-repeat-navigation .secondary-button, +.yoke-editing-scope .secondary-button { + transition: + background-color 0.22s ease, + border-color 0.22s ease, + color 0.22s ease; +} +.sidebar .secondary-button:hover:not(:disabled), +.sidebar .resize-chart-button:hover:not(:disabled), +.yoke-repeat-navigation .secondary-button:hover:not(:disabled), +.yoke-editing-scope .secondary-button:hover:not(:disabled) { + background-color: #f4f5f0; + border-color: #c5cabb; + box-shadow: 0 1px 2px rgba(60, 50, 35, 0.04); + color: var(--ink); + transform: none; +} .primary-button { background: var(--accent); border-color: var(--accent); @@ -3027,6 +3046,13 @@ button:disabled { padding: 18px; align-items: start; } +.chart-stack { + display: flex; + flex-direction: column; + gap: 18px; + min-width: 0; + width: 100%; +} .panel { background: var(--panel); @@ -3354,7 +3380,7 @@ select:focus { .editor { display: flex; flex-direction: column; - min-height: 620px; + min-height: 0; overflow: hidden; } .panel-heading { @@ -3638,6 +3664,9 @@ select:focus { height: 31px; width: 49px; } +.repeat-controls .yoke-preview-view-select { + width: 130px; +} .repeat-controls .repeat-style select { width: 88px; } @@ -3858,6 +3887,56 @@ select:focus { font-size: 10px; line-height: 1.4; } +.knit-section-size { + align-items: center; + color: var(--muted); + display: flex; + font-size: 11px; + gap: 8px; + justify-content: space-between; +} +.knit-section-size select { + flex: 0 0 64px; + font-size: 12px; + height: 28px; + min-height: 28px; + padding: 3px 7px; + width: 64px; +} +.knit-section-location { + align-items: baseline; + color: var(--muted); + display: flex; + flex-wrap: wrap; + font-size: 11px; + gap: 6px 12px; + justify-content: space-between; +} +.knit-jump-control summary { + color: var(--accent); + cursor: pointer; + font-size: 11px; +} +.knit-jump-control[open] { + flex-basis: 100%; +} +.knit-jump-fields { + display: grid; + gap: 10px; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + margin-top: 6px; +} +.knit-jump-fields label { + font-size: 11px; + gap: 3px; + min-width: 0; +} +.knit-jump-fields select { + font-size: 12px; + height: 28px; + min-height: 28px; + padding: 3px 7px; +} .knit-mode-settings-grid { display: grid; gap: 8px; @@ -4007,8 +4086,42 @@ select:focus { .knit-row-controls button { min-height: 46px; } +.knit-step-controls button { + font-size: 12px; + line-height: 1.35; + min-height: 34px; + padding: 5px 8px; +} .knit-reset { background: transparent; + border-color: var(--line-soft); + border-radius: 999px; + box-shadow: none; + color: var(--muted); + font-size: 12px; + justify-self: center; + line-height: 1.4; + max-width: 100%; + min-height: 32px; + padding: 6px 14px; +} +.knit-reset:hover:not(:disabled) { + border-color: var(--line); + box-shadow: none; + transform: none; +} +.knit-short-row-prelude { + color: var(--ink); + font-size: 12px; + line-height: 1.5; +} +.knit-short-row-prelude > p { + margin: 0 0 4px; +} +.knit-short-row-prelude summary { + color: var(--muted); + cursor: pointer; + font-size: 11px; } .knit-keyboard-hint { color: var(--muted); @@ -4773,7 +4886,7 @@ select:focus { width: 100%; } .editor { - min-height: 560px; + min-height: 0; order: 1; } .preview { diff --git a/app/yoke-designer/yoke.css b/app/yoke-designer/yoke.css index 20875bb..112f76c 100644 --- a/app/yoke-designer/yoke.css +++ b/app/yoke-designer/yoke.css @@ -3,6 +3,111 @@ margin: auto; padding: 24px; } +.yoke-plan-dialog { + width: min(1250px, 95vw); + max-width: 1250px; + max-height: 92vh; + overflow: auto; +} +.yoke-plan-comparison { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 16px; + margin: 20px 0; +} +.yoke-plan-comparison figure { + min-width: 0; + margin: 0; +} +.yoke-plan-comparison figcaption { + font-weight: 600; + margin-bottom: 8px; +} +.yoke-transfer-options { + padding: 16px; + border: 1px solid var(--line); + border-radius: 8px; +} +.yoke-transfer-options label { + display: inline-flex; + align-items: center; + gap: 8px; + margin: 8px 20px 8px 0; +} +.yoke-gauge-field { + display: grid; + gap: 5px; + margin: 8px 0; +} +.yoke-gauge-field input, +.yoke-gauge-field select { + min-width: 0; + width: 100%; +} +.yoke-gauge-panel .yoke-hint { + color: var(--muted); + font-size: 12px; + line-height: 1.5; + margin: 8px 0 12px; +} +.yoke-gauge-panel .yoke-update-gauge { + font-size: 12px; + min-height: 32px; + width: 100%; +} +.yoke-gauge-panel .text-button { + font-size: 11px; + line-height: 1.5; + margin-top: 8px; + text-align: left; +} +.yoke-editing-scope .secondary-button { + font-size: 12px; + min-height: 32px; +} +.editor > .yoke-view-tabs { + margin: 0; + padding: 16px 20px 6px; +} +.yoke-editor-panel .yoke-view-tabs { + gap: 12px 18px; +} +.yoke-editor-panel .yoke-view-tabs > select { + font-size: 12px; + min-height: 32px; + width: auto; +} +.yoke-editor-panel .yoke-repeat-toggle { + margin-left: 0; + font-size: 12px; + width: auto; +} +.yoke-plan-summary p { + color: var(--muted); + font-size: 12px; + line-height: 1.5; + margin: 6px 0; +} +.yoke-plan-summary p:first-child { + color: var(--ink); + font-size: 13px; + font-weight: 500; +} +.yoke-plan-summary button { + font-size: 12px; + min-height: 32px; + margin-top: 6px; +} +.yoke-zoom-controls select { + background: transparent; + border: 0; + box-shadow: none; + color: var(--muted); + font-size: 12px; + min-height: 32px; + padding: 3px; + width: 65px; +} .yoke-header, .yoke-header nav, .yoke-toolbar, @@ -30,10 +135,29 @@ } .yoke-workspace { display: grid; - grid-template-columns: 340px minmax(0, 1fr); + grid-template-columns: minmax(0, 1fr); gap: 20px; align-items: start; } +.yoke-short-row-inputs { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); + gap: 12px; + margin: 16px 0; +} +.yoke-short-row-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.yoke-short-row-map { + display: block; + max-width: 700px; + width: 100%; + margin: 20px auto; + font: 12px sans-serif; + fill: var(--ink); +} .yoke-panel { background: var(--panel); border: 1px solid var(--line); @@ -41,6 +165,15 @@ padding: 20px; min-width: 0; } +.yoke-planner-measurements { + display: grid; + grid-template-columns: minmax(280px, 340px) minmax(0, 1fr); + align-items: start; + gap: 24px; +} +.yoke-planner-measurements .yoke-measurement-guide { + position: static; +} .yoke-panel h2 { font-size: 22px; margin: 0 0 8px; @@ -88,6 +221,26 @@ .yoke-panel small { line-height: 1.4; } +.yoke-measurement-control { + display: grid; + gap: 8px; + margin: 16px 0; + font-size: 12px; +} +.yoke-panel .yoke-measurement-toggle { + display: flex; + justify-content: flex-start; + gap: 10px; + white-space: normal; +} +.yoke-measurement-toggle .knit-toggle-track { + flex-shrink: 0; +} +.yoke-measurement-toggle input:not(:checked) ~ span:last-child, +.yoke-measurement-toggle:has(input:checked) > span:first-child { + color: var(--muted); + font-weight: 400; +} .yoke-schedule > div { display: grid; grid-template-columns: 1fr 1fr 30px; @@ -133,6 +286,37 @@ overflow: auto; background: white; border-radius: 8px; + max-height: 75vh; +} +.yoke-editing-scope { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px 18px; + padding: 12px 20px; + font-size: 13px; +} +.yoke-editing-scope label { + display: flex; + align-items: center; + gap: 8px; +} +.yoke-repeat-notice { + font-size: 13px; + margin: 0; + padding: 0 20px 12px; + color: #675b47; +} +.yoke-selection-tools { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 5px; + width: 100%; +} +.yoke-selection-tools button { + font-size: 12px; + min-height: 30px; } .yoke-canvas-scroll canvas { display: block; @@ -142,11 +326,210 @@ user-select: none; cursor: crosshair; } +.yoke-canvas-navigable { + height: min(70vh, 700px); + overscroll-behavior: contain; +} +.yoke-canvas-navigable canvas { + margin-inline: auto; +} +.yoke-repeat-navigation { + display: flex; + align-items: center; + flex-wrap: wrap; + max-width: 100%; + gap: 12px; + padding: 0; +} +.yoke-repeat-navigation > button { + font-size: 12px; + min-height: 32px; +} .yoke-drawing details { margin-top: 14px; font-size: 13px; } +.yoke-measurement-help { + margin: 12px 0; + font-size: 12px; +} +.yoke-measurement-help svg { + display: block; + max-width: 260px; + margin: 8px auto; +} +.yoke-schedule-editor fieldset { + border: 1px solid var(--line); + border-radius: 8px; + margin: 12px 0; + padding: 10px; + min-width: 0; +} +.yoke-schedule-editor legend { + font-size: 12px; + font-weight: 600; +} +.yoke-schedule-editor label { + margin-bottom: 8px; +} +.yoke-schedule-editor small { + display: block; + overflow-wrap: anywhere; + margin-bottom: 8px; +} +.yoke-standard-chart { + min-width: 0; +} +.yoke-pending-shaping { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + margin: 0 20px 14px; + padding: 12px; + border: 1px solid var(--line); + border-radius: 9px; + background: var(--accent-soft); + font-size: 12px; +} +.yoke-pending-shaping button { + font-size: 12px; + min-height: 32px; +} +.yoke-measurement-guide { + margin-bottom: 20px; + padding: 14px; + border: 1px solid var(--line); + border-radius: 10px; + background: #faf8f1; +} +.yoke-measurement-guide summary { + cursor: pointer; + font-weight: 600; +} +.yoke-measurement-diagrams { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} +.yoke-measurement-diagrams figure { + margin: 0 auto; + width: 100%; + max-width: 340px; +} +.yoke-measurement-diagrams figcaption { + font-size: 13px; + text-align: center; + padding: 8px 0; +} +.yoke-measurement-diagrams svg { + width: 100%; + display: block; +} +.measurement-outline { + fill: #ece9df; + stroke: #5b6258; + stroke-width: 1.5; + stroke-linejoin: round; +} +.measurement-lines { + fill: none; + stroke: #bba78c; + stroke-width: 1.8; +} +.measurement-lines.is-active { + stroke: #b45439; + stroke-width: 3; +} +.measurement-point { + cursor: pointer; +} +.measurement-point circle { + fill: #fff; + stroke: #7b8979; + stroke-width: 1.5; +} +.measurement-point text { + fill: #334c44; + font: 600 13px sans-serif; +} +.measurement-point.is-active circle, +.measurement-point:focus circle { + fill: #334c44; + stroke: #334c44; +} +.measurement-point.is-active text, +.measurement-point:focus text { + fill: white; +} +.measurement-point[aria-disabled="true"] { + opacity: 0.45; + cursor: default; +} +.measurement-labels { + fill: #555c50; + font: 11px sans-serif; +} +.measurement-detail { + align-self: start; +} +.measurement-detail p { + margin: 4px 0 0; + font-size: 12px; + line-height: 1.5; +} +.measurement-guide-selection { + border-left: 3px solid #b45439; + padding: 8px 12px; + margin: 12px 0; +} +.measurement-guide-selection p { + margin: 6px 0 0; +} +.measurement-guide-legend { + display: flex; + gap: 6px; + flex-wrap: wrap; +} +.measurement-guide-legend button { + font-size: 11px; + padding: 6px 9px; +} +.measurement-guide-legend button[aria-pressed="true"] { + color: white; + background: #334c44; +} +@media (max-width: 500px) { + .yoke-measurement-diagrams { + grid-template-columns: 1fr; + } +} +.yoke-rounding-review { + border: 1px solid #c79d63; + border-radius: 8px; + padding: 12px; + margin: 12px 0; + background: #fff8eb; +} +.yoke-rounding-review button { + display: block; + margin-bottom: 8px; + font-size: 12px; + text-align: left; +} +.yoke-rounding-review label { + display: flex; + align-items: center; + margin-top: 12px; +} @media (max-width: 900px) { + .yoke-planner-measurements { + grid-template-columns: minmax(0, 1fr); + } + .yoke-planner-measurements .yoke-measurement-guide { + position: static; + grid-row: 1; + } .yoke-workspace { grid-template-columns: 1fr; } diff --git a/components/chart-grid.tsx b/components/chart-grid.tsx index d083a76..fb891ed 100644 --- a/components/chart-grid.tsx +++ b/components/chart-grid.tsx @@ -24,6 +24,8 @@ type Props = { onSelectionChange: (selection: CellSelection | null) => void; onStrokeStart: () => void; onPaint: (row: number, column: number, flood: boolean) => void; + yarnOnly?: boolean; + onStrokeEnd?: () => void; }; export function ChartGrid({ @@ -35,9 +37,12 @@ export function ChartGrid({ onSelectionChange, onStrokeStart, onPaint, + yarnOnly = false, + onStrokeEnd, }: Props) { const svgRef = useRef(null); const drawing = useRef(false); + const strokeActive = useRef(false); const selecting = useRef(false); const selectionStart = useRef<{ row: number; column: number } | null>(null); const previousCell = useRef(""); @@ -87,6 +92,7 @@ export function ChartGrid({ return; } onStrokeStart(); + strokeActive.current = true; drawing.current = tool !== "fill"; previousCell.current = `${cell.row}:${cell.column}`; onPaint(cell.row, cell.column, tool === "fill"); @@ -115,9 +121,12 @@ export function ChartGrid({ } function endPointerAction() { + const finish = strokeActive.current; + strokeActive.current = false; drawing.current = false; selecting.current = false; selectionStart.current = null; + if (finish) onStrokeEnd?.(); } const selectionBounds = selection @@ -311,9 +320,11 @@ export function ChartGrid({ : tool === "fill" ? "Click an area to fill its yarn colour. Stitch symbols stay in place." : tool === "select" - ? "Drag a rectangle to fill, copy, cut, paste, move, or apply a stitch action." + ? yarnOnly + ? "Drag a rectangle to fill, copy, cut, paste or move yarn colours." + : "Drag a rectangle to fill, copy, cut, paste, move, or apply a stitch action." : `Click and drag to ${tool === "eraser" ? "erase the yarn colour" : "paint"}${document.guides.symmetry !== "none" ? " with live symmetry" : ""}.`} - {document.stitchActions && !structureAction + {document.stitchActions && !structureAction && !yarnOnly ? " Use Remove stitch to erase symbols without changing yarn colours." : ""}

diff --git a/components/chart-maker.tsx b/components/chart-maker.tsx index 4809c1a..f8a050c 100644 --- a/components/chart-maker.tsx +++ b/components/chart-maker.tsx @@ -1,14 +1,24 @@ "use client"; +import "@/app/yoke-designer/yoke.css"; import Link from "next/link"; +import { stringifyKnitplot } from "@/lib/yoke-program-wire"; import { useSyncedDraft } from "@/components/use-synced-draft"; import { ColourPalettePanel } from "@/components/colour-palette-panel"; import { StitchPalettePanel } from "@/components/stitch-palette-panel"; import { ChartMeasurementsPanel } from "@/components/chart-measurements-panel"; import { AccountAvatar } from "@/components/account-avatar"; -import { SetStateAction, useEffect, useRef, useState } from "react"; +import { SetStateAction, useEffect, useMemo, useRef, useState } from "react"; import { AiChartGenerator } from "@/components/ai-chart-generator"; import { ChartGrid } from "@/components/chart-grid"; +import { YokeEditor } from "@/components/yoke-editor"; +import { YokeKnittedPreview } from "@/components/yoke-knitted-preview"; +import { restoreYokeHistoryDocument } from "@/lib/yoke-history"; +import { + emptyStitchDraft, + type YokeStitchDraft, +} from "@/components/use-yoke-stitches"; +import { YokePlanDialog } from "@/components/yoke-plan-dialog"; import { ExportTools } from "@/components/export-tools"; import { ImageImporter } from "@/components/image-importer"; import { InstructionsView } from "@/components/instructions-view"; @@ -74,6 +84,7 @@ import { remember, travelHistory, type TabHistory } from "@/lib/history"; import { useCloudSync } from "@/components/use-cloud-sync"; import { responseJson } from "@/lib/http"; import { tabSnapshot, workspaceSave } from "@/lib/workspace"; +import { samePreviewFabric } from "@/lib/preview-freshness"; import { useLocalWorkspace } from "@/components/use-local-workspace"; import { useCloudRecovery } from "@/components/use-cloud-recovery"; const colorDefaults = ["#d6a84b", "#6b7da8", "#884d67", "#5f795e", "#29282d"]; @@ -88,12 +99,14 @@ type ChartMakerProps = { viewer: Viewer | null; accountsEnabled: boolean; aiConnected: boolean; + localTestChart?: ChartDocument; }; export function ChartMaker({ viewer, accountsEnabled, aiConnected, + localTestChart, }: ChartMakerProps) { const [tabs, setTabs] = useState([ { @@ -104,6 +117,30 @@ export function ChartMaker({ }, ]); const [activeTabId, setActiveTabId] = useState(INITIAL_TAB_ID); + const [yokeStitchDrafts, setYokeStitchDrafts] = useState< + Record + >({}); + const hasPendingYokeStitches = tabs.some( + (tab) => yokeStitchDrafts[tab.id]?.edits.length, + ); + function confirmLeavingEditor(event: { preventDefault(): void }) { + if ( + hasPendingYokeStitches && + !window.confirm( + "You have unapplied shaping changes. Leave without applying them?", + ) + ) + event.preventDefault(); + } + useEffect(() => { + if (!hasPendingYokeStitches) return; + const warn = (event: BeforeUnloadEvent) => { + event.preventDefault(); + event.returnValue = ""; + }; + window.addEventListener("beforeunload", warn); + return () => window.removeEventListener("beforeunload", warn); + }, [hasPendingYokeStitches]); const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? tabs[0]; const document = activeTab.document; const [tool, setTool] = useState("pencil"); @@ -119,9 +156,7 @@ export function ChartMaker({ }); const [previewDocuments, setPreviewDocuments] = useState< Record - >({ - [INITIAL_TAB_ID]: cloneDocument(defaultDocument), - }); + >(() => ({ [INITIAL_TAB_ID]: tabs[0].document })); const preview = activeTab.preview; const previewDocument = previewDocuments[activeTabId] ?? document; const [chartZoom, setChartZoom] = useState(100); @@ -129,6 +164,7 @@ export function ChartMaker({ const [selectionClipboard, setSelectionClipboard] = useState(null); const [knitModeOpen, setKnitModeOpen] = useState(false); + const [newYokeOpen, setNewYokeOpen] = useState(false); const [patternImporterOpen, setPatternImporterOpen] = useState(false); const [dimensionDraft, setDimensionDraft] = useSyncedDraft( `${activeTabId}:${document.width}:${document.height}`, @@ -183,7 +219,7 @@ export function ChartMaker({ setActiveTabId(next.activeTabId); setPreviewDocuments((current) => ({ ...current, - [incoming.id]: cloneDocument(incoming.document), + [incoming.id]: incoming.document, })); if (next.activeTabId !== activeTabId) prepareTabView(incoming.document); }, @@ -259,7 +295,7 @@ export function ChartMaker({ ); setPreviewDocuments( Object.fromEntries( - workspace.tabs.map((tab) => [tab.id, cloneDocument(tab.document)]), + workspace.tabs.map((tab) => [tab.id, tab.document]), ), ); const active = workspace.tabs.find( @@ -301,14 +337,14 @@ export function ChartMaker({ if (!past.length) return; const next = travelHistory(history, document, "undo"); setHistories((current) => ({ ...current, [activeTabId]: next.history })); - setDocument(next.document); + setDocument(restoreYokeHistoryDocument(document, next.document)); } function redo() { if (!future.length) return; const next = travelHistory(history, document, "redo"); setHistories((current) => ({ ...current, [activeTabId]: next.history })); - setDocument(next.document); + setDocument(restoreYokeHistoryDocument(document, next.document)); } function paint(row: number, column: number, shouldFill: boolean) { @@ -497,7 +533,8 @@ export function ChartMaker({ function refreshPreview() { setPreviewDocuments((current) => ({ ...current, - [activeTabId]: cloneDocument(document), + // Give the renderer a fresh reference so the button can also retry a failure. + [activeTabId]: { ...document }, })); updatePreviewSettings({ renderedRepeatX: preview.repeatX, @@ -748,6 +785,11 @@ export function ChartMaker({ } function openProject(nextDocument: ChartDocument) { + if (nextDocument.yoke || document.yoke) { + if (!addDocumentTab(cloneDocument(nextDocument))) + throw new Error("Close a chart tab before opening another project."); + return; + } commit(() => cloneDocument(nextDocument)); setSelectedColor(nextDocument.palette[1]?.id ?? nextDocument.palette[0].id); setTool("pencil"); @@ -777,10 +819,9 @@ export function ChartMaker({ prepareTabView(nextTab.document); } - function addTab() { - if (tabs.length >= MAX_CHART_TABS) return; + function addDocumentTab(nextDocument: ChartDocument) { + if (tabs.length >= MAX_CHART_TABS) return false; const id = `chart-${crypto.randomUUID()}`; - const nextDocument = cloneDocument(defaultDocument); setTabs((current) => [ ...current, { @@ -792,20 +833,24 @@ export function ChartMaker({ ]); setPreviewDocuments((current) => ({ ...current, - [id]: cloneDocument(nextDocument), + [id]: nextDocument, })); setHistories((current) => ({ ...current, [id]: { past: [], future: [] } })); setActiveTabId(id); prepareTabView(nextDocument); + return true; + } + function addTab() { + addDocumentTab(cloneDocument(defaultDocument)); } function duplicateTab() { if (tabs.length >= MAX_CHART_TABS) return; const id = `chart-${crypto.randomUUID()}`; - const nextDocument = cloneDocument({ + const nextDocument = { ...document, name: `${document.name || "Untitled chart"} copy`, - }); + }; setTabs((current) => [ ...current, { @@ -817,7 +862,7 @@ export function ChartMaker({ ]); setPreviewDocuments((current) => ({ ...current, - [id]: cloneDocument(current[activeTabId] ?? document), + [id]: current[activeTabId] ?? document, })); setHistories((current) => ({ ...current, [id]: { past: [], future: [] } })); setActiveTabId(id); @@ -846,7 +891,7 @@ export function ChartMaker({ ]); setPreviewDocuments((current) => ({ ...current, - [id]: cloneDocument(example.document), + [id]: example.document, })); setHistories((current) => ({ ...current, [id]: { past: [], future: [] } })); setActiveTabId(id); @@ -861,7 +906,7 @@ export function ChartMaker({ const response = await fetch("/api/charts", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ + body: stringifyKnitplot({ document, knitProgress: activeTab.knitProgress, preview: activeTab.preview, @@ -946,6 +991,11 @@ export function ChartMaker({ return; } setTabs((current) => current.filter((tab) => tab.id !== id)); + setYokeStitchDrafts((current) => { + const next = { ...current }; + delete next[id]; + return next; + }); setHistories((current) => { const next = { ...current }; delete next[id]; @@ -965,11 +1015,16 @@ export function ChartMaker({ setClosingTabId(null); } + const previewMatchesChart = useMemo( + () => samePreviewFabric(document, previewDocument), + [document, previewDocument], + ); const previewOutdated = - JSON.stringify(document) !== JSON.stringify(previewDocument) || - preview.repeatX !== preview.renderedRepeatX || - preview.repeatY !== preview.renderedRepeatY || - preview.repeatStyle !== preview.renderedRepeatStyle; + !previewMatchesChart || + (!document.yoke && + (preview.repeatX !== preview.renderedRepeatX || + preview.repeatY !== preview.renderedRepeatY || + preview.repeatStyle !== preview.renderedRepeatStyle)); const knitProgress = tabs.find((tab) => tab.id === activeTabId)?.knitProgress ?? defaultKnitProgress; @@ -1060,21 +1115,47 @@ export function ChartMaker({
+ {process.env.NODE_ENV === "development" && localTestChart ? ( + + ) : null} - {viewer && aiConnected ? ( - + {!document.yoke ? ( + <> + {viewer && aiConnected ? ( + + ) : null} + + ) : null} - setPatternImporterOpen(true)} + onNewBlank={addTab} + onNewYoke={() => setNewYokeOpen(true)} + tabsFull={tabs.length >= MAX_CHART_TABS} + onNavigateAway={confirmLeavingEditor} /> = MAX_CHART_TABS} @@ -1107,13 +1192,18 @@ export function ChartMaker({ ) : ( - + Sign in )} @@ -1168,357 +1258,407 @@ export function ChartMaker({
- - -
-
-

Chart editor

-
-
- - - - - {selection && !structureAction ? ( - - ) : null} - {selection && structureAction ? ( - - ) : null} - {selection ? ( - - ) : null} - {selection ? : null} - {selectionClipboard?.tabId === activeTabId ? ( - - ) : null} - {selection ? ( - - - - + + + + {selection && !structureAction ? ( + + ) : null} + {selection && structureAction ? ( + + ) : null} + {selection ? ( + + ) : null} + {selection ? ( + + ) : null} + {selectionClipboard?.tabId === activeTabId ? ( + + ) : null} + {selection ? ( + + + + + + + ) : null} + {selection ? ( + + ) : null} + +
+
+
+ +
+ +
+ +
+

Knitted up

+ approximate fabric at your gauge +
+ + Show / hide + +
+
+
+ Repeats + + + - - ) : null} - {selection ? ( - - ) : null} - -
-
+ {previewOutdated ? ( +

+ Preview needs a refresh. +

+ ) : null} + +
+
- - - - -
- -
-

Knitted up

- approximate fabric at your gauge -
- - Show / hide - -
-
-
- Repeats - - - - -
- {previewOutdated ? ( -

- Preview needs a refresh. -

- ) : null} - -
-
+ + )} + {newYokeOpen ? ( + = MAX_CHART_TABS} + onClose={() => setNewYokeOpen(false)} + onCommit={(next) => addDocumentTab(next)} + /> + ) : null} + {!knitModeOpen ? (