Skip to content

Commit a50124b

Browse files
committed
release v0.3.9
1 parent 1e6ad70 commit a50124b

13 files changed

Lines changed: 986 additions & 694 deletions

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,3 +112,19 @@ and the project aims to adhere to [Semantic Versioning](https://semver.org/spec/
112112
- Loading example flows on mobile no longer risks a blank canvas due to mount/fit timing races.
113113
- Lesson content polish in L1/L3, including corrected locktime/CLTV wording and removal of stale “ready to broadcast” text in flows 1-3.
114114
- Improved Safari drag responsiveness in the paper skin by removing dashed-edge styling.
115+
116+
## [0.3.9] - 2026-03-20
117+
118+
### Added
119+
120+
- Exports (`full`, `simplified`, `LLM`) now include `runtimeSemantics` metadata describing sentinel precedence (`__FORCE00__`, `__EMPTY__`, `__NULL__`) and numeric type coercion rules.
121+
122+
### Changed
123+
124+
- Lesson 2 flow renamed to **Multisig: Bare P2MS and P2SH Multisig** (`p2_Bare_P2MS_and_P2SH_MultiSig.json`), with refreshed in-flow wording.
125+
126+
### Fixed
127+
128+
- Shared-flow imports now use more resilient fit-view timing to avoid occasional blank/offscreen canvas states.
129+
- Redo no longer causes a visible canvas blink during history restore.
130+
- Lesson 3 cleanup: removed redundant “Resulting TXID” helper comments.

backend/tests/test_flow_roundtrip.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,8 @@
5151
},
5252
},
5353
{
54-
"name": "p2_P2PK_and_P2SH_MultiSig.json",
55-
"path": ROOT / "src" / "my_tx_flows" / "p2_P2PK_and_P2SH_MultiSig.json",
54+
"name": "p2_Bare_P2MS_and_P2SH_MultiSig.json",
55+
"path": ROOT / "src" / "my_tx_flows" / "p2_Bare_P2MS_and_P2SH_MultiSig.json",
5656
"node_changes": {
5757
"node_DyhEXHsp": "1",
5858
},

package-lock.json

Lines changed: 6 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "rawbit",
3-
"version": "0.3.8",
3+
"version": "0.3.9",
44
"license": "MIT",
55
"type": "module",
66
"scripts": {

public/sitemap.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
<loc>https://rawbit.io/flows/p1_Intro_P2PKH_and_P2PK</loc>
1111
</url>
1212
<url>
13-
<loc>https://rawbit.io/flows/p2_P2PK_and_P2SH_MultiSig</loc>
13+
<loc>https://rawbit.io/flows/p2_Bare_P2MS_and_P2SH_MultiSig</loc>
1414
</url>
1515
<url>
1616
<loc>https://rawbit.io/flows/p3_Locktime_Intro</loc>

src/components/Flow.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1001,11 +1001,18 @@ function FlowContent() {
10011001
const instance = flowInstanceRef.current;
10021002
if (!instance) return;
10031003

1004-
const runFit = () => instance.fitView({ padding: 0.2, maxZoom: 2, duration: 350 });
1004+
const runFit = () =>
1005+
instance.fitView({ padding: 0.2, maxZoom: 2, duration: 350 });
10051006
// Use a double rAF so the React Flow store has applied imported nodes/edges
10061007
requestAnimationFrame(() => requestAnimationFrame(runFit));
10071008
}, []);
10081009

1010+
const fitSharedImportedFlow = useCallback(() => {
1011+
// Shared-flow loading can race WebKit layout readiness; use the resilient
1012+
// retry scheduler for this path only.
1013+
scheduleExampleFlowFit();
1014+
}, [scheduleExampleFlowFit]);
1015+
10091016
const handleImportTooltip = useCallback(
10101017
(filename?: string) => {
10111018
if (!filename) return;
@@ -1540,6 +1547,7 @@ function FlowContent() {
15401547
useSharedFlowLoader({
15411548
getNodes,
15421549
getEdges,
1550+
fitView: fitSharedImportedFlow,
15431551
getProtocolDiagramLayout: () => protocolDiagramLayout,
15441552
setProtocolDiagramLayout,
15451553
onNodesChange: rawOnNodesChange,
@@ -1639,8 +1647,7 @@ function FlowContent() {
16391647
});
16401648

16411649
if (hasMissingHandle) {
1642-
clearHighlights();
1643-
setEdges([]);
1650+
// Keep the current edges visible while handles remount to avoid a flash.
16441651
requestAnimationFrame(() => {
16451652
setTimeout(() => setEdges(restoredEdges), 0);
16461653
});

src/hooks/__tests__/useFileOperations.test.tsx

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,106 @@ describe("useFileOperations", () => {
110110
};
111111
};
112112

113+
const setupDownloadCapture = () => {
114+
const anchors: Array<HTMLAnchorElement> = [];
115+
const blobs: Blob[] = [];
116+
const originalBlob = globalThis.Blob;
117+
118+
class MockBlob {
119+
readonly size: number;
120+
readonly type: string;
121+
private readonly rawText: string;
122+
123+
constructor(parts: BlobPart[] = [], options?: BlobPropertyBag) {
124+
this.rawText = parts
125+
.map((part) => {
126+
if (typeof part === "string") return part;
127+
if (part instanceof ArrayBuffer) {
128+
return new TextDecoder().decode(part);
129+
}
130+
if (ArrayBuffer.isView(part)) {
131+
return new TextDecoder().decode(part);
132+
}
133+
return String(part);
134+
})
135+
.join("");
136+
this.size = this.rawText.length;
137+
this.type = options?.type ?? "";
138+
}
139+
140+
async text(): Promise<string> {
141+
return this.rawText;
142+
}
143+
}
144+
145+
vi.stubGlobal("Blob", MockBlob as unknown as typeof Blob);
146+
147+
const originalCreateElement = document.createElement.bind(document);
148+
const createElementSpy = vi
149+
.spyOn(document, "createElement")
150+
.mockImplementation((tagName: string) => {
151+
if (tagName.toLowerCase() === "a") {
152+
const anchor = {
153+
href: "",
154+
download: "",
155+
click: vi.fn(),
156+
} as unknown as HTMLAnchorElement;
157+
anchors.push(anchor);
158+
return anchor;
159+
}
160+
return originalCreateElement(tagName);
161+
});
162+
163+
const mutableURL = URL as typeof URL & {
164+
createObjectURL?: (blob: Blob) => string;
165+
revokeObjectURL?: (url: string) => void;
166+
};
167+
const originalCreateObjectURL = mutableURL.createObjectURL;
168+
const originalRevokeObjectURL = mutableURL.revokeObjectURL;
169+
Object.defineProperty(mutableURL, "createObjectURL", {
170+
configurable: true,
171+
writable: true,
172+
value: vi.fn((blob: Blob) => {
173+
blobs.push(blob);
174+
return `blob:mock-${blobs.length}`;
175+
}) as typeof mutableURL.createObjectURL,
176+
});
177+
Object.defineProperty(mutableURL, "revokeObjectURL", {
178+
configurable: true,
179+
writable: true,
180+
value: vi.fn() as typeof mutableURL.revokeObjectURL,
181+
});
182+
183+
const restore = () => {
184+
createElementSpy.mockRestore();
185+
if (originalCreateObjectURL) {
186+
Object.defineProperty(mutableURL, "createObjectURL", {
187+
configurable: true,
188+
writable: true,
189+
value: originalCreateObjectURL,
190+
});
191+
} else {
192+
Reflect.deleteProperty(mutableURL, "createObjectURL");
193+
}
194+
if (originalRevokeObjectURL) {
195+
Object.defineProperty(mutableURL, "revokeObjectURL", {
196+
configurable: true,
197+
writable: true,
198+
value: originalRevokeObjectURL,
199+
});
200+
} else {
201+
Reflect.deleteProperty(mutableURL, "revokeObjectURL");
202+
}
203+
if (originalBlob) {
204+
vi.stubGlobal("Blob", originalBlob);
205+
} else {
206+
Reflect.deleteProperty(globalThis, "Blob");
207+
}
208+
};
209+
210+
return { anchors, blobs, restore };
211+
};
212+
113213
it("notifies error when file exceeds byte limit", async () => {
114214
const { result, onError } = renderUseFileOperations();
115215
const bigContent = "x".repeat(MAX_FLOW_BYTES + 1);
@@ -456,6 +556,81 @@ describe("useFileOperations", () => {
456556
}
457557
});
458558

559+
it("adds runtime semantics metadata to full, simplified, and LLM exports", async () => {
560+
const { blobs, restore } = setupDownloadCapture();
561+
562+
try {
563+
const { result, nodes } = renderUseFileOperations({
564+
tabTitle: "Runtime Semantics",
565+
});
566+
nodes[0].selected = true;
567+
568+
act(() => {
569+
result.current.saveFlow();
570+
});
571+
act(() => {
572+
result.current.saveSimplifiedFlow();
573+
});
574+
await act(async () => {
575+
await result.current.saveLlmExport();
576+
});
577+
578+
expect(blobs).toHaveLength(3);
579+
580+
const readBlobText = async (blob: Blob): Promise<string> => {
581+
if (typeof blob.text === "function") {
582+
return blob.text();
583+
}
584+
if (typeof blob.arrayBuffer === "function") {
585+
const bytes = await blob.arrayBuffer();
586+
return new TextDecoder().decode(bytes);
587+
}
588+
try {
589+
return await new Response(blob as unknown as BodyInit).text();
590+
} catch {
591+
// fall through to explicit error
592+
}
593+
throw new Error("Unable to read downloaded blob content");
594+
};
595+
596+
const [full, simplified, llm] = await Promise.all(
597+
blobs.map(async (blob) => JSON.parse(await readBlobText(blob)) as Record<string, unknown>)
598+
);
599+
600+
const semanticsMatcher = expect.objectContaining({
601+
version: 1,
602+
inputResolution: expect.objectContaining({
603+
precedence: expect.arrayContaining([
604+
"__FORCE00__",
605+
"__EMPTY__",
606+
"__NULL__",
607+
"edge value",
608+
"manual text",
609+
]),
610+
sentinels: expect.objectContaining({
611+
__FORCE00__: expect.any(String),
612+
__EMPTY__: expect.any(String),
613+
__NULL__: expect.any(String),
614+
}),
615+
}),
616+
typeCoercion: expect.objectContaining({
617+
integerParams: expect.any(String),
618+
numberParams: expect.any(String),
619+
}),
620+
});
621+
622+
expect(full.runtimeSemantics).toEqual(semanticsMatcher);
623+
expect(simplified.runtimeSemantics).toEqual(semanticsMatcher);
624+
expect(llm.runtimeSemantics).toEqual(semanticsMatcher);
625+
const llmContext = llm.llmContext as { whatIsExported?: string[] } | undefined;
626+
expect(llmContext?.whatIsExported).toEqual(
627+
expect.arrayContaining([expect.stringContaining("Runtime semantics:")])
628+
);
629+
} finally {
630+
restore();
631+
}
632+
});
633+
459634
describe("export filenames", () => {
460635
const setupDownloadSpies = () => {
461636
const anchors: Array<HTMLAnchorElement> = [];

0 commit comments

Comments
 (0)