diff --git a/cli/src/index.ts b/cli/src/index.ts index db52b623..f00644a9 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -485,6 +485,13 @@ async function captureWidget(directory: string, options: CaptureOptions): Promis } warnings.push(...(nativeResult.warnings ?? [])); + // The runtime appends capture-only warnings here (see + // runtime/src/capture_warnings.zig): conditions the pixels show but do not + // explain, such as a canvas whose commands were drawn for another size. + const runtimeWarningsPath = join(captureRoot, "warnings.txt"); + if (existsSync(runtimeWarningsPath)) { + warnings.push(...readFileSync(runtimeWarningsPath, "utf8").split("\n").map((line) => line.trim()).filter((line) => line.length > 0)); + } const nativeRenderer = nativeResult.renderer; const nativeOutput = nativeResult.output; const nativePending = nativeResult.pending; @@ -2849,26 +2856,11 @@ function validateSource(project: SourceProject): string[] { const available = project.fonts.length === 0 ? "no bundled fonts were found next to widget.tsx" : `available bundled names: ${[...new Set(project.fonts.flatMap((font) => [font.stem, font.family]))].join(", ")}`; classErrors.add(`Unknown bundled font "${compiled.fontFamily}"; ${available}`); } - if (tag === "canvas") { - const sizes = compiled as Record; - for (const axis of ["width", "height"] as const) { - const percent = sizes[axis === "width" ? "widthPercent" : "heightPercent"] !== undefined; - if (percent || sizes[axis] === undefined) { - const shape = percent ? `a percentage ${axis}` : `no ${axis}`; - classErrors.add( - `CanvasNeedsExplicitSize: has ${shape}. A canvas has no intrinsic size, so inside a content-sized container a percentage resolves against 0 and every draw silently no-ops (ctx.${axis} === 0). Fix: give the canvas an explicit pixel ${axis}, e.g. ${axis === "width" ? "w-[312px]" : "h-[71px]"}.`, - ); - } - } - } } catch (error) { classErrors.add(error instanceof UtilityError ? error.message : String(error)); } for (const error of classErrors) errors.push(locationMessage(sourceFile, classAttribute, error)); } } - if (tag === "canvas" && !classAttribute) { - errors.push(locationMessage(sourceFile, node, `CanvasNeedsExplicitSize: has no class. A canvas has no intrinsic size and draws nothing without one; give it explicit pixel dimensions, e.g. class="w-[312px] h-[71px]".`)); - } if (tag === "canvas") { const opacityAncestor = canvasOpacityAncestor(node, context); if (opacityAncestor) { diff --git a/cli/test/canvas-size.test.mjs b/cli/test/canvas-size.test.mjs index 478a3f29..6bbfc909 100644 --- a/cli/test/canvas-size.test.mjs +++ b/cli/test/canvas-size.test.mjs @@ -29,24 +29,31 @@ export default widget({ name: "Canvas Size", size: [320, 200] }, () => ( `; } -test("check rejects a canvas with a percentage width", () => { +test("check accepts a canvas sized by layout with a percentage width", () => { const { root, widget } = fixture(source(` {}} />`)); try { const checked = runCli(root, "check", widget); - assert.equal(checked.status, 1); - assert.match(checked.stderr, /CanvasNeedsExplicitSize: has a percentage width/); - assert.match(checked.stderr, /w-\[312px\]/); + assert.equal(checked.status, 0, checked.stderr); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("check accepts a canvas that grows into its row", () => { + const { root, widget } = fixture(source(` {}} />`)); + try { + const checked = runCli(root, "check", widget); + assert.equal(checked.status, 0, checked.stderr); } finally { rmSync(root, { recursive: true, force: true }); } }); -test("check rejects a canvas with no class at all", () => { +test("check accepts a canvas with no class at all", () => { const { root, widget } = fixture(source(` {}} />`)); try { const checked = runCli(root, "check", widget); - assert.equal(checked.status, 1); - assert.match(checked.stderr, /CanvasNeedsExplicitSize: has no class/); + assert.equal(checked.status, 0, checked.stderr); } finally { rmSync(root, { recursive: true, force: true }); } diff --git a/docs/agent-widget-capture.md b/docs/agent-widget-capture.md index 809e0a13..9e6189df 100644 --- a/docs/agent-widget-capture.md +++ b/docs/agent-widget-capture.md @@ -138,6 +138,11 @@ timeout. `provenance` records the Weaver and Native SDK commits plus the exact bundled JavaScript hash. A dirty checkout is allowed but is called out in `warnings`. +`warnings` also carries runtime observations that the pixels show but cannot +explain. `CanvasDrewForStaleLayout` names a canvas whose last `onFrame` ran +for a different size than layout finally gave it; a blank or clipped canvas +with that line is a framework reproduction, not a widget bug. + After building the runtime, run the repository acceptance corpus with `npm run test:capture`. It checks non-flat pixels, semantic state changes, provider failure and fixture behavior, ambiguous-target candidates, artifact diff --git a/runtime/src/capture_warnings.zig b/runtime/src/capture_warnings.zig new file mode 100644 index 00000000..5a37d37a --- /dev/null +++ b/runtime/src/capture_warnings.zig @@ -0,0 +1,32 @@ +//! A capture warning is a condition the PNG shows but cannot explain: the +//! render succeeded, yet something an author would call broken is visible, +//! and the receipt should say why. Under `weaver capture` each warning is +//! appended to `/warnings.txt`; the CLI merges the +//! lines into the receipt's `warnings`. Ordinary widgets never call this. +const std = @import("std"); + +var io: ?std.Io = null; +var capture_path: []const u8 = &.{}; + +pub fn initCaptureSink(runtime_io: std.Io, path: []const u8) void { + io = runtime_io; + capture_path = path; +} + +pub fn report(comptime format: []const u8, args: anytype) void { + std.log.warn(format, args); + const runtime_io = io orelse return; + var buffer: [2048]u8 = undefined; + var writer = std.Io.Writer.fixed(&buffer); + writer.print(format ++ "\n", args) catch return; + appendLine(runtime_io, capture_path, writer.buffered()) catch |err| { + std.log.warn("capture warnings file unavailable: {s}; path={s}", .{ @errorName(err), capture_path }); + }; +} + +fn appendLine(runtime_io: std.Io, path: []const u8, line: []const u8) !void { + var file = try std.Io.Dir.cwd().createFile(runtime_io, path, .{ .read = true, .truncate = false }); + defer file.close(runtime_io); + const stat = try file.stat(runtime_io); + try file.writePositionalAll(runtime_io, line, stat.size); +} diff --git a/runtime/src/main.zig b/runtime/src/main.zig index e11a558d..87a1ba33 100644 --- a/runtime/src/main.zig +++ b/runtime/src/main.zig @@ -15,6 +15,7 @@ const storage_mod = @import("storage.zig"); const windows_monitor = if (builtin.os.tag == .windows) @import("platform/windows_monitor.zig") else struct {}; const tree_mod = @import("tree.zig"); const widget_diagnostic = @import("widget_diagnostic.zig"); +const capture_warnings = @import("capture_warnings.zig"); const widget_log = @import("widget_log.zig"); comptime { @@ -120,6 +121,10 @@ pub const Msg = union(enum) { right_press: native_sdk.canvas.WidgetPressEvent, slider: tree_mod.NodeId, canvas_frame: u64, + /// A presented frame laid a canvas out at a size the SDK has not been + /// told yet. The dispatch itself carries the fix: `syncNativeState` runs + /// before `update` and fires `onCanvasResize`. + canvas_layout: void, external_wake: struct { provider: bool, dev_reload: bool }, frame_moved: geometry_mod.Saved, }; @@ -245,6 +250,7 @@ fn update(model: *Model, msg: Msg, effects: *Effects) void { .on_fire = Effects.timerMsg(.timer), }); }, + .canvas_layout => {}, .canvas_frame => |timestamp_ns| { if (model.provider_poll_interval_ms <= 33) { dispatchProviderFrames(model, effects, "canvas frame"); @@ -617,6 +623,7 @@ fn onFrame(model: *const Model, frame: native_sdk.platform.GpuFrame) ?Msg { } } const engine = model.engine orelse return null; + if (canvasLayoutStale(model, frame)) return Msg{ .canvas_layout = {} }; const canvas_clock = engine.hasCanvasFrames(); if (!canvas_clock) return null; // Hybrid presentation now rejects a clean completion revision before it @@ -637,6 +644,29 @@ fn onFrame(model: *const Model, frame: native_sdk.platform.GpuFrame) ?Msg { /// that frame boundary into the ordinary update/rebuild path. Static widgets /// therefore hot-swap without borrowing a GPU completion from animation, /// input, or resize. +/// Layout sizes a canvas after the retained tree is built, but the SDK learns +/// that size only through `syncNativeState`, which Native runs at the start of +/// the next dispatch. A widget with nothing else to dispatch (a fixed-clock +/// capture, a live widget before its first event) would draw at its +/// class-declared size forever. So a presented frame whose canvas layout +/// disagrees with what the tree last published requests exactly one dispatch. +/// The comparison mirrors `syncNativeState` so the two agree after that sync. +fn canvasLayoutStale(model: *const Model, frame: native_sdk.platform.GpuFrame) bool { + const runtime = diagnostic_runtime orelse return false; + const layout = runtime.canvasWidgetLayout(frame.window_id, frame.label) catch return false; + for (&model.tree.canvases, 0..) |*canvas_state, index| { + if (!model.tree.canvas_occupied.isSet(index) or canvas_state.owner == 0) continue; + const widget_id = native_sdk.canvas.globalWidgetId(.stack, native_sdk.canvas.uiKey(canvas_state.owner)); + for (layout.nodes) |layout_node| { + if (layout_node.widget.id != widget_id) continue; + const laid_out = layout_node.frame.normalized(); + if (@max(laid_out.width, 0) != canvas_state.layout_width or @max(laid_out.height, 0) != canvas_state.layout_height) return true; + break; + } + } + return false; +} + fn onFrameRequested(_: *const Model) ?Msg { const provider = provider_wake_pending.swap(false, .acq_rel); const dev = dev_reload_pending.swap(false, .acq_rel); @@ -720,7 +750,10 @@ fn projectSameViewUpdate( ); switch (msg) { - .canvas_frame => {}, + // Both carry fresh canvas commands that the retained tree must show + // without a rebuild: a draw tick, or the redraw that follows a layout + // size reaching the SDK. + .canvas_frame, .canvas_layout => {}, else => return true, } @@ -1392,6 +1425,7 @@ pub fn main(init: std.process.Init) !void { // also go to a file the CLI copies into the capture receipt. if (capture_state_root) |root| { widget_diagnostic.initCaptureSink(init.io, try std.fs.path.join(allocator, &.{ root, "diagnostic.txt" })); + capture_warnings.initCaptureSink(init.io, try std.fs.path.join(allocator, &.{ root, "warnings.txt" })); } std.log.info("widget runtime starting pid={d}{s}", .{ platform.currentProcessId(), if (dev) " dev=true" else "" }); var storage = try storage_mod.Store.init(init.io, allocator, data_root, loaded.manifest.name); @@ -1674,6 +1708,23 @@ fn validateCapture(context: *anyopaque) !void { if (app_state.model.engine) |engine| { if (engine.renderFailed()) return error.CaptureWidgetFailed; } + reportCanvasLayoutMismatches(&app_state.model); +} + +/// A canvas whose retained commands were published for a different layout +/// size than the one it now has is showing stale geometry: typically a blank +/// or clipped bar. The PNG shows the symptom; this warning names the cause so +/// the receipt alone is enough to act on. With `canvasLayoutStale` in place +/// no correct widget reaches it; it is a tripwire for a regression there. +fn reportCanvasLayoutMismatches(model: *const Model) void { + for (&model.tree.canvases, 0..) |*canvas_state, index| { + if (!model.tree.canvas_occupied.isSet(index) or canvas_state.owner == 0 or canvas_state.command_count == 0) continue; + if (canvas_state.command_layout_width == canvas_state.layout_width and canvas_state.command_layout_height == canvas_state.layout_height) continue; + capture_warnings.report( + "CanvasDrewForStaleLayout: canvas node {d} drew its last frame for a {d}x{d} layout but layout now gives it {d}x{d}; onFrame did not run after layout sized it", + .{ canvas_state.owner, canvas_state.command_layout_width, canvas_state.command_layout_height, canvas_state.layout_width, canvas_state.layout_height }, + ); + } } fn loadCaptureProviderFixture( diff --git a/sdk/CONTRACT.md b/sdk/CONTRACT.md index aaf8c6e9..efa8c600 100644 --- a/sdk/CONTRACT.md +++ b/sdk/CONTRACT.md @@ -833,3 +833,16 @@ Click-to-seek uses the event's normalized local `u` coordinate. The handler clamps `event.u` to `[0, 1]`, multiplies by `durationMs`, and passes that absolute value to `seek(ms)`. The SDK owns whole-millisecond normalization. Click-only seek is the contracted example behavior; dragging is not implied. + + +## Canvas layout sizing amendment + +A `` is sized by layout like every other element. Explicit pixel +sizes, `w-full`, fractions, and `grow` all work. Inside `onFrame`, +`ctx.width` and `ctx.height` are the laid-out size, and `onFrame` runs again +whenever layout changes that size, including the first layout after mount. The +class-declared size is only the guess used for the very first draw; a +re-render never shrinks the draw size back to it. `weaver check` no longer +requires explicit pixel dimensions on a canvas (`CanvasNeedsExplicitSize` is +retired). A capture whose canvas commands were drawn for a different size than +the canvas's layout reports `CanvasDrewForStaleLayout` in `warnings`. diff --git a/sdk/src/reconciler.ts b/sdk/src/reconciler.ts index 032326d4..5d768189 100644 --- a/sdk/src/reconciler.ts +++ b/sdk/src/reconciler.ts @@ -198,8 +198,14 @@ interface CanvasBinding { timerId: number; surfaceClock: boolean; surfaceClockGeneration?: object; + /** Draw size. Starts as the class-declared size and becomes the layout size once the runtime reports it. */ width: number; height: number; + /** The class-declared size from the last render; only a guess until `layoutSized`. */ + declaredWidth: number; + declaredHeight: number; + /** Set once `onCanvasResize` has delivered a layout size; from then on re-renders must not shrink the draw size back to the declared one. */ + layoutSized: boolean; lastT?: number; nextT?: number; nativeTimestampStarted?: boolean; @@ -217,6 +223,7 @@ const colorCache: Record = Object.create(null) as Record runWidgetCallback("canvas resize callback", () => { const binding = canvases.get(id); if (!binding || !Number.isFinite(width) || !Number.isFinite(height) || width < 0 || height < 0) return; + binding.layoutSized = true; if (binding.width === width && binding.height === height) return; binding.width = width; binding.height = height; @@ -776,17 +783,26 @@ function updateCanvasBinding(id: number, onFrame: (ctx: CanvasCtx, frame: Canvas if (!binding) { binding = { onFrame, fps, timerId: 0, surfaceClock: false, width, height, + declaredWidth: width, declaredHeight: height, layoutSized: false, batch: new Float64Array(MAX_CANVAS_WIRE_VALUES), batchLength: 0, active: false, ctx: undefined as unknown as CanvasCtx, }; binding.ctx = createCanvasContext(binding); canvases.set(id, binding); } else { - const sizeChanged = binding.width !== width || binding.height !== height; + // The declared size is a guess until layout reports the real one. After + // that, a re-render must keep the layout size: the runtime refires + // onCanvasResize only when layout actually moves, so overwriting it here + // would leave a `grow` or `w-full` canvas drawing at its declared 0 forever. + const declaredChanged = binding.declaredWidth !== width || binding.declaredHeight !== height; + binding.declaredWidth = width; + binding.declaredHeight = height; binding.onFrame = onFrame; - binding.width = width; - binding.height = height; - if (sizeChanged) binding.ctx = createCanvasContext(binding); + if (declaredChanged && !binding.layoutSized) { + binding.width = width; + binding.height = height; + binding.ctx = createCanvasContext(binding); + } } if (rateChanged && binding.timerId !== 0) { native.clearInterval(binding.timerId); diff --git a/sdk/test/reconciler.test.mjs b/sdk/test/reconciler.test.mjs index 300cb7d9..4af497c0 100644 --- a/sdk/test/reconciler.test.mjs +++ b/sdk/test/reconciler.test.mjs @@ -722,3 +722,59 @@ test("hot swap captures and seeds root hook slots only when every slot type matc const malformed = await runHotSwapFixture({ seed: "not-json" }); assert.equal(malformed.fixtureAccepted, false); }); + +test("a canvas keeps its layout size across re-renders", async () => { + const source = ` + import { h, useState, widget } from "./src/index.ts"; + widget({ name: "Canvas layout size fixture", size: [240, 80] }, () => { + const [tick, setTick] = useState(0); + globalThis.rerender = () => setTick((value) => value + 1); + return h("row", { class: "w-full gap-2" }, + h("canvas", { + class: "w-0 grow h-[8px]", + onFrame(ctx) { globalThis.drawSizes.push([ctx.width, ctx.height]); ctx.clear(); }, + }), + h("text", null, String(tick)), + ); + }); + `; + const output = await build({ + stdin: { contents: source, resolveDir: fileURLToPath(new URL("..", import.meta.url)), sourcefile: "canvas-layout-size-fixture.ts" }, + bundle: true, format: "iife", platform: "neutral", write: false, + }); + let id = 0; + let canvasId = 0; + let resize; + const errors = []; + const native = { + ...isolatedNative(), + createNode(type) { id += 1; if (type === "canvas") canvasId = id; return id; }, + reportError(...args) { errors.push(args); }, + onCanvasResize(callback) { resize = callback; }, + setCanvasCommands() {}, + }; + const context = { native, drawSizes: [] }; + vm.runInNewContext(output.outputFiles[0].text, context); + assert.deepEqual(errors, []); + + // The first draw can only use the class-declared size: `w-0 grow` is 0 wide until layout runs. + assert.deepEqual(Array.from(context.drawSizes.at(-1)), [0, 8]); + + // Layout reports the real size; the canvas redraws at it. + resize(canvasId, 181, 8); + assert.deepEqual(Array.from(context.drawSizes.at(-1)), [181, 8]); + + // A re-render must not shrink the draw size back to the declared 0. + const drawsBefore = context.drawSizes.length; + context.rerender(); + await Promise.resolve(); + assert.ok(context.drawSizes.length > drawsBefore, "re-render redraws the one-shot canvas"); + assert.deepEqual(Array.from(context.drawSizes.at(-1)), [181, 8]); + + // Layout moving again is still honored. + resize(canvasId, 150, 8); + assert.deepEqual(Array.from(context.drawSizes.at(-1)), [150, 8]); + context.rerender(); + await Promise.resolve(); + assert.deepEqual(Array.from(context.drawSizes.at(-1)), [150, 8]); +}); diff --git a/test/capture-smoke.mjs b/test/capture-smoke.mjs index b350e753..5ca5cc78 100644 --- a/test/capture-smoke.mjs +++ b/test/capture-smoke.mjs @@ -4,6 +4,7 @@ import { createHash } from "node:crypto"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { inflateSync } from "node:zlib"; const root = process.cwd(); const cli = join(root, "cli", "bin", "weaver.js"); @@ -22,6 +23,19 @@ try { const text = capture("styling-text", "examples/styling-text"); assert.ok(text.renderer.pixelsDifferentFromClear > 0); + // A layout-sized canvas paints at its laid-out width on the first frame and + // keeps it across provider re-renders. The accent count is a real pixel + // probe; pixelsDifferentFromClear cannot tell a bar from its dark surface. + const canvasGrow = capture("canvas-grow", "test/fixtures/canvas-grow"); + assert.deepEqual(canvasGrow.warnings.filter((warning) => warning.startsWith("CanvasDrewForStaleLayout")), []); + assert.ok(countPixels(canvasGrow, [0x5e, 0xea, 0xd4]) > 1000, `grow canvas painted ${countPixels(canvasGrow, [0x5e, 0xea, 0xd4])} accent pixels`); + const canvasGrowTicked = capture("canvas-grow-ticked", "test/fixtures/canvas-grow", [ + "--action-file", join(root, "test", "capture", "advance-two-seconds.actions"), + ]); + assert.deepEqual(canvasGrowTicked.warnings.filter((warning) => warning.startsWith("CanvasDrewForStaleLayout")), []); + assert.match(snapshotText(canvasGrowTicked), /role=text name="02"/); + assert.ok(countPixels(canvasGrowTicked, [0x5e, 0xea, 0xd4]) > 1000, `grow canvas after re-render painted ${countPixels(canvasGrowTicked, [0x5e, 0xea, 0xd4])} accent pixels`); + const images = capture("styling-images", "examples/styling-images"); assert.equal(images.renderer.images, 3); assert.equal(images.pending.images, 0); @@ -240,6 +254,66 @@ function imageHash(receipt) { return fileHash(receipt.output.image); } +// Count pixels within `tolerance` of an RGB color in a capture PNG. +function countPixels(receipt, [red, green, blue], tolerance = 24) { + const { width, height, channels, pixels } = decodePng(readFileSync(receipt.output.image)); + let count = 0; + for (let index = 0; index < width * height; index += 1) { + const offset = index * channels; + if (channels === 4 && pixels[offset + 3] < 128) continue; + if (Math.abs(pixels[offset] - red) <= tolerance && Math.abs(pixels[offset + 1] - green) <= tolerance && Math.abs(pixels[offset + 2] - blue) <= tolerance) count += 1; + } + return count; +} + +// Minimal PNG decoder for capture output: 8-bit RGB or RGBA, no interlace. +function decodePng(bytes) { + assert.equal(bytes.readUInt32BE(12), 0x49484452, "PNG IHDR"); + const width = bytes.readUInt32BE(16); + const height = bytes.readUInt32BE(20); + const bitDepth = bytes[24]; + const colorType = bytes[25]; + const interlace = bytes[28]; + assert.equal(bitDepth, 8, "8-bit PNG"); + assert.equal(interlace, 0, "non-interlaced PNG"); + const channels = colorType === 6 ? 4 : colorType === 2 ? 3 : assert.fail(`unsupported PNG color type ${colorType}`); + const chunks = []; + for (let offset = 8; offset < bytes.length;) { + const length = bytes.readUInt32BE(offset); + const type = bytes.toString("latin1", offset + 4, offset + 8); + if (type === "IDAT") chunks.push(bytes.subarray(offset + 8, offset + 8 + length)); + offset += 12 + length; + } + const raw = inflateSync(Buffer.concat(chunks)); + const stride = width * channels; + const pixels = Buffer.alloc(stride * height); + let previous = Buffer.alloc(stride); + for (let row = 0; row < height; row += 1) { + const filter = raw[row * (stride + 1)]; + const line = raw.subarray(row * (stride + 1) + 1, (row + 1) * (stride + 1)); + const current = pixels.subarray(row * stride, (row + 1) * stride); + for (let index = 0; index < stride; index += 1) { + const left = index >= channels ? current[index - channels] : 0; + const up = previous[index]; + const upLeft = index >= channels ? previous[index - channels] : 0; + let predictor = 0; + if (filter === 1) predictor = left; + else if (filter === 2) predictor = up; + else if (filter === 3) predictor = (left + up) >> 1; + else if (filter === 4) { + const estimate = left + up - upLeft; + const distanceLeft = Math.abs(estimate - left); + const distanceUp = Math.abs(estimate - up); + const distanceUpLeft = Math.abs(estimate - upLeft); + predictor = distanceLeft <= distanceUp && distanceLeft <= distanceUpLeft ? left : distanceUp <= distanceUpLeft ? up : upLeft; + } else assert.equal(filter, 0, `PNG filter ${filter}`); + current[index] = (line[index] + predictor) & 0xff; + } + previous = current; + } + return { width, height, channels, pixels }; +} + function fileHash(path) { return createHash("sha256").update(readFileSync(path)).digest("hex"); } diff --git a/test/capture/advance-two-seconds.actions b/test/capture/advance-two-seconds.actions new file mode 100644 index 00000000..185af262 --- /dev/null +++ b/test/capture/advance-two-seconds.actions @@ -0,0 +1,7 @@ +{ + "schema": "weaver.capture.actions.v1", + "actions": [ + { "action": "advance-clock", "milliseconds": 1000 }, + { "action": "advance-clock", "milliseconds": 1000 } + ] +} diff --git a/test/fixtures/canvas-grow/tsconfig.json b/test/fixtures/canvas-grow/tsconfig.json new file mode 100644 index 00000000..318a70a8 --- /dev/null +++ b/test/fixtures/canvas-grow/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "jsx": "react-jsx", + "jsxImportSource": "@weaver/sdk", + "types": [], + "baseUrl": ".", + "paths": { + "@weaver/sdk": ["../../../sdk/index.d.ts"], + "@weaver/sdk/jsx-runtime": ["../../../sdk/jsx-runtime.d.ts"] + } + }, + "include": ["widget.tsx"] +} diff --git a/test/fixtures/canvas-grow/widget.tsx b/test/fixtures/canvas-grow/widget.tsx new file mode 100644 index 00000000..f093c7db --- /dev/null +++ b/test/fixtures/canvas-grow/widget.tsx @@ -0,0 +1,26 @@ +import { useProvider, widget } from "@weaver/sdk"; + +// A canvas sized by layout, not by an explicit pixel width. The bar must paint +// at the laid-out width on the first capture frame (a fixed-clock capture +// dispatches nothing on its own) and must keep that width after the time +// provider re-renders the component. +export default widget({ + name: "Canvas Grow", + size: [240, 70], + anchor: { corner: "top-right", offset: [24, 24] }, + subscribe: ["time"], +}, () => { + const time = useProvider("time"); + return ( + + + { + ctx.clear(); + ctx.fillRoundRect(0, 0, ctx.width, ctx.height, 3, "#5eead4"); + }} /> + {time.ss} + + grow canvas + + ); +});