@dev.fast/whiteboard 0.5.0 → 0.5.2-preview.20261104.146
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{account-alias-Bw3PT3Qo.js → account-alias-BlYamXAG.js} +5 -5
- package/dist/{agent-cli-kHRrwO4n.js → agent-cli-D7fF5xmm.js} +9 -9
- package/dist/agent-cli-DLibAL4u.js +2 -0
- package/dist/{agent-client-CMacGbMp.js → agent-client-BCEbSPWU.js} +4 -4
- package/dist/{authoring-tools-CiLy46kh.js → authoring-tools-CkfxTVJm.js} +12 -8
- package/dist/{background-server-eZWXoVcI.js → background-server-BraP8pJ9.js} +4 -4
- package/dist/{background-server-BLNHbksB.js → background-server-CwToVN1g.js} +1 -1
- package/dist/build-info.json +1 -1
- package/dist/{cli-Bxmiqutp.js → cli-BpadBaGU.js} +1 -1
- package/dist/{cli-runner-DQPuu7kJ.js → cli-runner-BebYF50a.js} +22 -22
- package/dist/cli.js +2 -2
- package/dist/{fuzzy-match-DZjeOaPY.js → fuzzy-match-CxcsLQGx.js} +3 -3
- package/dist/{headless-host-Dl2KZ9IT.js → headless-host-FAlbekFD.js} +561 -415
- package/dist/{headless-host-B4r0s1ks.js → headless-host-cLipIufH.js} +1 -1
- package/dist/{import-YAIU6rB0.js → import-CZ1958L4.js} +234 -62
- package/dist/{local-data-C58vzLZR.js → local-data-DdFkxFK_.js} +41 -16
- package/dist/{mcp-COYWiGVn.js → mcp-Claiy9xE.js} +9 -6
- package/dist/{package-paths-Cg6fnaGp.js → package-paths-Dk7MOXxN.js} +1 -1
- package/dist/{profile--BYIjPkc.js → profile-Bsgzi9PA.js} +2 -2
- package/dist/profile-BxdzBxHW.js +2 -0
- package/dist/{remote-attach-C1prLjmj.js → remote-attach-LGTpwpUv.js} +4 -4
- package/dist/{remote-extensions-Bw029iIS.js → remote-extensions-BvV29_gk.js} +1 -1
- package/dist/{remote-extensions-MUUgbLJn.js → remote-extensions-L95HNwIH.js} +2 -2
- package/dist/{remote-language-server-z3rdHcLL.js → remote-language-server-BAznkpeu.js} +4 -4
- package/dist/{remote-uninstall-B3Slwor1.js → remote-uninstall-CLP79Cuk.js} +5 -5
- package/dist/{request-origin-B3_XI2VA.js → request-origin-D6B-zrL8.js} +27 -5
- package/dist/{review-app-launcher-BZEbDKuK.js → review-app-launcher-tTe8bTwg.js} +2 -2
- package/dist/{review-home-paths-LdGS4SpR.js → review-home-paths-BmZX2qiz.js} +1 -1
- package/dist/{review-telemetry-x8u-9EZb.js → review-telemetry-DACDMfqq.js} +4 -4
- package/dist/server/desktop-host.js +26 -38
- package/dist/{server-discovery-WmuFl2S3.js → server-discovery-CV5qf1J8.js} +2 -2
- package/dist/sharing/index.d.ts +137 -14
- package/dist/sharing/index.js +1 -1
- package/dist/{src-B2b8cCoI.js → src-BQDlJXkG.js} +44 -5
- package/dist/{src-DLWol7GZ.js → src-CrrW7lcY.js} +16 -1
- package/dist/{stored-document-migration-iwIQ4l5G.js → stored-document-migration-D1wK4yID.js} +3 -3
- package/dist/{thread-state-B7L1mpN_.js → thread-state-WTqSborE.js} +1 -1
- package/dist/{tool-failure-RLR8m9hq.js → tool-failure-YOFGHJCZ.js} +319 -55
- package/instructions/animation.md +69 -5
- package/instructions/animations/before-after-structure/README.md +27 -0
- package/instructions/animations/before-after-structure/animation.html +89 -0
- package/instructions/animations/before-after-structure/block.json +10 -0
- package/instructions/animations/before-after-structure/frames.json +313 -0
- package/instructions/animations/cache-fill-evict/README.md +24 -0
- package/instructions/animations/cache-fill-evict/animation.html +81 -0
- package/instructions/animations/cache-fill-evict/block.json +13 -0
- package/instructions/animations/cache-fill-evict/frames.json +79 -0
- package/instructions/animations/cache-fill-evict/scenarios.json +17 -0
- package/instructions/animations/log-replay/README.md +25 -0
- package/instructions/animations/log-replay/animation.html +86 -0
- package/instructions/animations/log-replay/block.json +10 -0
- package/instructions/animations/log-replay/frames.json +143 -0
- package/instructions/animations/log-replay/scenarios.json +18 -0
- package/instructions/animations/queue-workers/README.md +23 -0
- package/instructions/animations/queue-workers/animation.html +107 -0
- package/instructions/animations/queue-workers/block.json +13 -0
- package/instructions/animations/queue-workers/frames.json +119 -0
- package/instructions/animations/queue-workers/scenarios.json +18 -0
- package/instructions/animations/race-two-lanes/README.md +22 -0
- package/instructions/animations/race-two-lanes/animation.html +97 -0
- package/instructions/animations/race-two-lanes/block.json +1 -0
- package/instructions/animations/race-two-lanes/frames.json +234 -0
- package/instructions/animations/race-two-lanes/scenarios.json +19 -0
- package/instructions/animations/request-across-layers/README.md +24 -0
- package/instructions/animations/request-across-layers/animation.html +85 -0
- package/instructions/animations/request-across-layers/block.json +17 -0
- package/instructions/animations/request-across-layers/frames.json +58 -0
- package/instructions/animations/request-across-layers/scenarios.json +12 -0
- package/instructions/animations/request-timing/README.md +22 -0
- package/instructions/animations/request-timing/animation.html +120 -0
- package/instructions/animations/request-timing/block.json +21 -0
- package/instructions/animations/request-timing/markers.json +32 -0
- package/instructions/animations/request-timing/scenarios.json +4 -0
- package/instructions/animations/retry-backoff/README.md +22 -0
- package/instructions/animations/retry-backoff/animation.html +122 -0
- package/instructions/animations/retry-backoff/block.json +10 -0
- package/instructions/animations/retry-backoff/frames.json +65 -0
- package/instructions/animations/retry-backoff/scenarios.json +12 -0
- package/instructions/animations/state-machine/README.md +24 -0
- package/instructions/animations/state-machine/animation.html +95 -0
- package/instructions/animations/state-machine/block.json +9 -0
- package/instructions/animations/state-machine/frames.json +79 -0
- package/instructions/animations/state-machine/scenarios.json +12 -0
- package/instructions/animations/video-platform-architecture/README.md +24 -0
- package/instructions/animations/video-platform-architecture/animation.html +76 -0
- package/instructions/animations/video-platform-architecture/block.json +17 -0
- package/instructions/animations/video-platform-architecture/frames.json +97 -0
- package/instructions/animations/video-platform-architecture/scenarios.json +28 -0
- package/instructions/whiteboard.d.ts +168 -14
- package/package.json +6 -6
- package/src/animation-stage-kit.ts +150 -10
- package/src/cli-runner.ts +4 -1
- package/src/fixtures/blocks/clip.json +19 -0
- package/src/review-api/README.md +1 -0
- package/src/review-api/agent-cli.ts +3 -2
- package/src/review-api/animation-drafts.ts +151 -47
- package/src/review-api/animation-references.ts +92 -0
- package/src/review-api/animation-render.ts +106 -0
- package/src/review-api/authoring-tools.ts +10 -3
- package/src/review-api/document-text.ts +27 -12
- package/src/review-api/http.ts +52 -6
- package/src/review-api/instructions.ts +1 -1
- package/src/review-api/mcp.ts +10 -5
- package/src/review-api/public-tools.ts +71 -2
- package/src/review-api/store.ts +89 -12
- package/src/server/desktop-server.ts +17 -5
- package/src/server/headless-host.ts +4 -0
- package/src/server/review-gateway.ts +1 -0
- package/src/server/review-server-core.ts +71 -12
- package/src/sharing/client.ts +129 -32
- package/src/sharing/export.ts +42 -5
- package/src/sharing/file.ts +8 -0
- package/src/sharing/host.ts +202 -73
- package/src/sharing/import.ts +125 -33
- package/src/ui-telemetry-events.ts +25 -1
- package/dist/agent-cli-B8KBHuh_.js +0 -2
- package/dist/profile-BjcXi-oH.js +0 -2
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"key": "request",
|
|
4
|
+
"title": "The client posts a JSON body",
|
|
5
|
+
"label": "REQUEST",
|
|
6
|
+
"caption": "The body is untyped JSON: qty arrives as the string \"2\".",
|
|
7
|
+
"code": ["validate"]
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"key": "validate",
|
|
11
|
+
"title": "The API validates it into a DTO",
|
|
12
|
+
"label": "VALIDATE",
|
|
13
|
+
"caption": "validateCreateOrder parses the body; qty becomes a number and the service only ever sees a CreateOrderDto.",
|
|
14
|
+
"code": ["validate"]
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"key": "persist",
|
|
18
|
+
"title": "The service builds an Order and inserts it",
|
|
19
|
+
"label": "PERSIST",
|
|
20
|
+
"caption": "OrderService builds a domain Order, which has no id until the database assigns one.",
|
|
21
|
+
"code": ["service", "repository"]
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"key": "stored",
|
|
25
|
+
"title": "The database returns the row",
|
|
26
|
+
"label": "STORED",
|
|
27
|
+
"caption": "The insert returns the row with the id Postgres assigned.",
|
|
28
|
+
"code": ["repository"]
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"key": "map",
|
|
32
|
+
"title": "The row maps back to an Order",
|
|
33
|
+
"label": "MAP",
|
|
34
|
+
"caption": "The service returns a domain Order, not the row: the API never sees column names.",
|
|
35
|
+
"code": ["service"]
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"key": "respond",
|
|
39
|
+
"title": "The API answers 201 with a response body",
|
|
40
|
+
"label": "RESPOND",
|
|
41
|
+
"caption": "The API serializes the Order for the wire, exposing the id as a public string.",
|
|
42
|
+
"code": ["validate"]
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"key": "bad-request",
|
|
46
|
+
"title": "The client posts an invalid body",
|
|
47
|
+
"label": "BAD REQUEST",
|
|
48
|
+
"caption": "Same endpoint, but qty is \"-1\".",
|
|
49
|
+
"code": ["validate"]
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"key": "rejected",
|
|
53
|
+
"title": "Validation rejects it at the API",
|
|
54
|
+
"label": "REJECTED",
|
|
55
|
+
"caption": "The API answers 422 naming the field. The service and the database are never called.",
|
|
56
|
+
"code": ["validate"]
|
|
57
|
+
}
|
|
58
|
+
]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# One read, with and without the cache
|
|
2
|
+
|
|
3
|
+
A clip of one request crossing client, API, cache and database as a continuous trip, with an elapsed bar, played once as a cache hit and once as a miss.
|
|
4
|
+
|
|
5
|
+
## Use when
|
|
6
|
+
|
|
7
|
+
A change adds, removes or moves a cache, a retry or any hop on a request's path, and the reviewer has to feel what it does to latency: the point is how long each leg takes, not a list of states.
|
|
8
|
+
|
|
9
|
+
## Shows
|
|
10
|
+
|
|
11
|
+
A GET token rides from the client to the API and on to the cache. On a hit the cache answers at once; on a miss the read goes on to Postgres, waits there, and comes back through the cache to fill it. A 200 token rides home and the elapsed bar, which fills for the whole trip, stops at that scenario's total. The markers name the same five beats in both, so jumping to Cache lookup in either shows where the paths split.
|
|
12
|
+
|
|
13
|
+
## Technique
|
|
14
|
+
|
|
15
|
+
A clip: markers.json declares the beats, and the script builds one timeline per scenario with `whiteboard.clip(key)`, placing each marker with `.marker(key)` where the request reaches it. Tokens move by `left` percentages, so the trip follows the four-column layout at any width. Everything that differs between the scenarios (the result chip, the second token, the total) is a `set` on that scenario's timeline, so a jump back undoes it. The elapsed bar is one tween added at position 0 with the trip's length, scaled by the longest trip so the two scenarios' bars compare.
|
|
16
|
+
|
|
17
|
+
## Adapt
|
|
18
|
+
|
|
19
|
+
- Rename the four nodes and their `<small>` lines for your path, keeping each node's `data-form` to what it stands for (a window, bars, a drum, a cylinder), and keep the column centers in `client`, `api`, `cache`, `db` in step with the grid.
|
|
20
|
+
- Change the leg lengths in `hop` and `hold` to the proportions that matter, a slow query or a fast cache, and set `LONGEST` to the longest scenario's sum.
|
|
21
|
+
- Add a scenario (a stale entry, a timeout) with an entry in scenarios.json and one more `build` call that places every marker.
|
|
22
|
+
- Point `code` at your client call, handler, cache and query, and list them on the markers that show them.
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
<div class="trip">
|
|
2
|
+
<div class="stops">
|
|
3
|
+
<div class="wb-node" data-form="window" data-wb-open="client">Client<small>fetchOrder(7)</small></div>
|
|
4
|
+
<div class="wb-node" data-form="bars" data-wb-open="handler">API<small>GET /orders/7</small></div>
|
|
5
|
+
<div class="wb-node" id="cache" data-form="drum" data-wb-open="cache">Cache<small>orders:7</small></div>
|
|
6
|
+
<div class="wb-node" id="db" data-form="cylinder" data-color="teal" data-wb-open="query">Postgres<small>SELECT</small></div>
|
|
7
|
+
</div>
|
|
8
|
+
<div class="wb-lane track">
|
|
9
|
+
<span class="wb-token packet out" data-visible data-labelled>GET</span>
|
|
10
|
+
<span class="wb-token packet back" data-labelled data-tone="added">200</span>
|
|
11
|
+
</div>
|
|
12
|
+
<div class="wb-layers results">
|
|
13
|
+
<span class="wb-chip result hit" data-tone="added">hit: served from memory</span>
|
|
14
|
+
<span class="wb-chip result miss" data-tone="modified">miss: read from Postgres, then cached</span>
|
|
15
|
+
</div>
|
|
16
|
+
<div class="clock">
|
|
17
|
+
<span class="wb-caps">elapsed</span>
|
|
18
|
+
<div class="bar"><div class="fill"></div></div>
|
|
19
|
+
<span class="wb-layers totals">
|
|
20
|
+
<span class="wb-mono total hit">38 ms</span>
|
|
21
|
+
<span class="wb-mono total miss">212 ms</span>
|
|
22
|
+
</span>
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
<style>
|
|
27
|
+
.trip {
|
|
28
|
+
display: grid;
|
|
29
|
+
gap: var(--wb-space-16);
|
|
30
|
+
padding: var(--wb-space-20) var(--wb-space-24);
|
|
31
|
+
}
|
|
32
|
+
.stops {
|
|
33
|
+
display: grid;
|
|
34
|
+
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
35
|
+
justify-items: center;
|
|
36
|
+
gap: var(--wb-space-16);
|
|
37
|
+
}
|
|
38
|
+
.packet { top: 50%; }
|
|
39
|
+
.packet.out { left: 12.5%; }
|
|
40
|
+
.packet.back { left: 62.5%; }
|
|
41
|
+
.results { justify-items: center; min-height: var(--wb-space-24); }
|
|
42
|
+
.result, .total { opacity: 0; }
|
|
43
|
+
.clock {
|
|
44
|
+
display: grid;
|
|
45
|
+
grid-template-columns: max-content minmax(0, 1fr) max-content;
|
|
46
|
+
align-items: center;
|
|
47
|
+
gap: var(--wb-space-12);
|
|
48
|
+
}
|
|
49
|
+
.bar {
|
|
50
|
+
height: var(--wb-space-6);
|
|
51
|
+
border-radius: var(--wb-radius-control);
|
|
52
|
+
background: var(--wb-well);
|
|
53
|
+
overflow: hidden;
|
|
54
|
+
}
|
|
55
|
+
.fill {
|
|
56
|
+
height: 100%;
|
|
57
|
+
background: var(--wb-accent);
|
|
58
|
+
transform: scaleX(0);
|
|
59
|
+
transform-origin: left;
|
|
60
|
+
}
|
|
61
|
+
.totals { justify-items: end; color: var(--wb-ink-muted); }
|
|
62
|
+
</style>
|
|
63
|
+
|
|
64
|
+
<script>
|
|
65
|
+
// The centers of the four columns, as the packet's left edge.
|
|
66
|
+
const [client, api, cache, db] = ["12.5%", "37.5%", "62.5%", "87.5%"];
|
|
67
|
+
const out = document.querySelector(".packet.out");
|
|
68
|
+
const back = document.querySelector(".packet.back");
|
|
69
|
+
|
|
70
|
+
// One timeline per scenario. Every marker is placed on both, at the moment
|
|
71
|
+
// the request reaches it, so the strip jumps to the same beat in either.
|
|
72
|
+
const build = (key, miss) => {
|
|
73
|
+
const clip = whiteboard.clip(key);
|
|
74
|
+
let elapsed = 0;
|
|
75
|
+
|
|
76
|
+
const hop = (token, from, to, duration) => {
|
|
77
|
+
clip.add(token, { left: [from, to], duration, ease: "inOutQuad" });
|
|
78
|
+
elapsed += duration;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const hold = (target, duration) => {
|
|
82
|
+
clip.add(target, { scale: [1, 1.06, 1], duration });
|
|
83
|
+
elapsed += duration;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
clip.marker("sent");
|
|
87
|
+
hop(out, client, api, 700);
|
|
88
|
+
clip.marker("accepted");
|
|
89
|
+
hop(out, api, cache, 600);
|
|
90
|
+
clip.marker("lookup");
|
|
91
|
+
|
|
92
|
+
if (miss) {
|
|
93
|
+
clip.set(".result.miss", { opacity: 1 });
|
|
94
|
+
hop(out, cache, db, 600);
|
|
95
|
+
hold("#db", 900);
|
|
96
|
+
clip.set(out, { opacity: 0 }).set(back, { left: db, opacity: 1 });
|
|
97
|
+
hop(back, db, cache, 600);
|
|
98
|
+
} else {
|
|
99
|
+
clip.set(".result.hit", { opacity: 1 });
|
|
100
|
+
hold("#cache", 300);
|
|
101
|
+
clip.set(out, { opacity: 0 }).set(back, { left: cache, opacity: 1 });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
hop(back, cache, api, 600);
|
|
105
|
+
clip.marker("reply");
|
|
106
|
+
hop(back, api, client, 700);
|
|
107
|
+
clip.set(`.total.${miss ? "miss" : "hit"}`, { opacity: 1 });
|
|
108
|
+
clip.marker("received");
|
|
109
|
+
|
|
110
|
+
// The elapsed bar fills over the whole trip, from the start, on one
|
|
111
|
+
// scale for both scenarios: the miss's trip fills it.
|
|
112
|
+
clip.add(".fill", { scaleX: [0, elapsed / LONGEST], duration: elapsed, ease: "linear" }, 0);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// The miss's trip, in milliseconds: the sum of its hops and its hold.
|
|
116
|
+
const LONGEST = 4700;
|
|
117
|
+
|
|
118
|
+
build("hit", false);
|
|
119
|
+
build("miss", true);
|
|
120
|
+
</script>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"title": "One read, with and without the cache",
|
|
3
|
+
"code": {
|
|
4
|
+
"client": {
|
|
5
|
+
"source": "head/web/src/orders/api.ts#L14-L30",
|
|
6
|
+
"label": "fetchOrder"
|
|
7
|
+
},
|
|
8
|
+
"handler": {
|
|
9
|
+
"source": "head/src/api/orders/get.ts#L8-L36",
|
|
10
|
+
"label": "getOrder"
|
|
11
|
+
},
|
|
12
|
+
"cache": {
|
|
13
|
+
"source": "head/src/orders/cache.ts#L10-L44",
|
|
14
|
+
"label": "OrderCache.read"
|
|
15
|
+
},
|
|
16
|
+
"query": {
|
|
17
|
+
"source": "head/src/orders/repository.ts#L52-L70",
|
|
18
|
+
"label": "selectOrder"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"key": "sent",
|
|
4
|
+
"title": "Request sent",
|
|
5
|
+
"caption": "The client asks for order 7.",
|
|
6
|
+
"code": ["client"]
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"key": "accepted",
|
|
10
|
+
"title": "API accepts",
|
|
11
|
+
"caption": "getOrder parses the id and asks the cache first.",
|
|
12
|
+
"code": ["handler"]
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"key": "lookup",
|
|
16
|
+
"title": "Cache lookup",
|
|
17
|
+
"caption": "The cache either holds orders:7 or sends the read on to Postgres.",
|
|
18
|
+
"code": ["cache", "query"]
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"key": "reply",
|
|
22
|
+
"title": "API replies",
|
|
23
|
+
"caption": "The order is back in the API, which answers 200.",
|
|
24
|
+
"code": ["handler"]
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"key": "received",
|
|
28
|
+
"title": "Client renders",
|
|
29
|
+
"caption": "The reply lands. The elapsed bar shows what the cache saved.",
|
|
30
|
+
"code": ["client"]
|
|
31
|
+
}
|
|
32
|
+
]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Retry with exponential backoff
|
|
2
|
+
|
|
3
|
+
Attempts from a caller to a service on a time axis in seconds, with waits that double between them, in one path that succeeds on the third attempt and one that gives up after the maximum.
|
|
4
|
+
|
|
5
|
+
## Use when
|
|
6
|
+
|
|
7
|
+
A change adds or tunes a retry loop: the base delay, the multiplier, the attempt cap, or which errors count as retryable, and the reviewer needs to see when each attempt actually lands and how long the caller can be held.
|
|
8
|
+
|
|
9
|
+
## Shows
|
|
10
|
+
|
|
11
|
+
Each attempt drops a request from the caller lane to the service lane and lands a status chip. Between attempts, a wait line grows along the caller lane, its length proportional to the delay, against an axis ticked in seconds. One scenario ends with a 200 at 3s; the other runs four attempts and throws at 7s.
|
|
12
|
+
|
|
13
|
+
## Technique
|
|
14
|
+
|
|
15
|
+
A scene in hand-made HTML: the script lays attempts and waits out from two small arrays, positioning each by its time in seconds (`--t`). Each is tied to its frame with `data-enter`; `whiteboard.arrow` links an attempt's caller marker to its result, `whiteboard.travel` moves one `GET` token along it, and `whiteboard.timeline(frame)` reveals the result and grows the wait line, so a jump or reduced motion lands on the end state. Attempt markers carry `data-wb-open="retry"` and waits `data-wb-open="backoff"`; each frame's `code` lists the range it explains.
|
|
16
|
+
|
|
17
|
+
## Adapt
|
|
18
|
+
|
|
19
|
+
- Edit `attempts` and `waits` to your schedule: a different base, factor or cap, or jitter (give each wait its real `span`). Keep the axis domain (`8` in the CSS and the tick loop) just past the last attempt.
|
|
20
|
+
- Rename the status chips and the token label to your protocol, such as a gRPC code or a queue redelivery.
|
|
21
|
+
- Add a scenario for another branch, such as a non-retryable 400 that fails at once, as new frames and one new entry in `attempts`.
|
|
22
|
+
- Point `code` in block.json at your loop and delay function.
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
<div class="retry">
|
|
2
|
+
<div class="top">
|
|
3
|
+
<span class="wb-badge" data-wb-open="backoff">base 1s · ×2 · max 4 attempts</span>
|
|
4
|
+
<span class="wb-layers">
|
|
5
|
+
<span class="wb-badge" data-tone="added" data-enter="a3ok">returned 200 at 3s</span>
|
|
6
|
+
<span class="wb-badge" data-tone="removed" data-enter="giveup">threw 503 at 7s</span>
|
|
7
|
+
</span>
|
|
8
|
+
</div>
|
|
9
|
+
<span class="wb-caps">caller</span>
|
|
10
|
+
<div class="wb-lane track caller"></div>
|
|
11
|
+
<span class="wb-caps service-name">service</span>
|
|
12
|
+
<div class="wb-lane track service"></div>
|
|
13
|
+
<span></span>
|
|
14
|
+
<div class="track axis"></div>
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
<style>
|
|
18
|
+
.retry {
|
|
19
|
+
display: grid;
|
|
20
|
+
grid-template-columns: max-content minmax(0, 1fr);
|
|
21
|
+
align-items: center;
|
|
22
|
+
gap: var(--wb-space-12) var(--wb-space-16);
|
|
23
|
+
padding: var(--wb-space-20) var(--wb-space-24);
|
|
24
|
+
}
|
|
25
|
+
.top {
|
|
26
|
+
grid-column: 1 / -1;
|
|
27
|
+
display: flex;
|
|
28
|
+
justify-content: space-between;
|
|
29
|
+
margin-bottom: var(--wb-space-24);
|
|
30
|
+
}
|
|
31
|
+
.top .wb-layers { justify-items: end; }
|
|
32
|
+
.service-name, .service { margin-top: var(--wb-space-56); }
|
|
33
|
+
.track { position: relative; margin-inline: var(--wb-space-16); }
|
|
34
|
+
/* Seconds become a share of the track: the axis spans 0 to 8s. */
|
|
35
|
+
.at, .wait, .tick { position: absolute; left: calc(var(--t) * 100% / 8); }
|
|
36
|
+
.at { top: 50%; translate: -50% -50%; }
|
|
37
|
+
.wait { top: 50%; width: calc(var(--span) * 100% / 8); }
|
|
38
|
+
.wait-line {
|
|
39
|
+
height: var(--wb-space-2);
|
|
40
|
+
margin-top: calc(var(--wb-space-2) / -2);
|
|
41
|
+
background: var(--wb-accent);
|
|
42
|
+
transform-origin: left;
|
|
43
|
+
}
|
|
44
|
+
.wait-label {
|
|
45
|
+
position: absolute;
|
|
46
|
+
left: 50%;
|
|
47
|
+
bottom: var(--wb-space-6);
|
|
48
|
+
translate: -50% 0;
|
|
49
|
+
color: var(--wb-accent);
|
|
50
|
+
font: var(--wb-text-micro) var(--wb-font-mono);
|
|
51
|
+
white-space: nowrap;
|
|
52
|
+
}
|
|
53
|
+
.caller .wb-chip { background: var(--wb-surface); }
|
|
54
|
+
.axis { height: var(--wb-space-20); border-top: 1px solid var(--wb-rule); }
|
|
55
|
+
.tick {
|
|
56
|
+
top: 0;
|
|
57
|
+
translate: -50% 0;
|
|
58
|
+
padding-top: var(--wb-space-4);
|
|
59
|
+
color: var(--wb-ink-faint);
|
|
60
|
+
font: var(--wb-text-micro) var(--wb-font-mono);
|
|
61
|
+
}
|
|
62
|
+
.tick::before {
|
|
63
|
+
content: "";
|
|
64
|
+
position: absolute;
|
|
65
|
+
top: 0;
|
|
66
|
+
left: 50%;
|
|
67
|
+
height: var(--wb-space-4);
|
|
68
|
+
border-left: 1px solid var(--wb-rule);
|
|
69
|
+
}
|
|
70
|
+
</style>
|
|
71
|
+
|
|
72
|
+
<script>
|
|
73
|
+
// Attempt n starts after waiting base × 2^(n−1) seconds: at 0, 1, 3 and 7s.
|
|
74
|
+
const attempts = [
|
|
75
|
+
{ frame: "a1", n: 1, t: 0, status: "503" },
|
|
76
|
+
{ frame: "a2", n: 2, t: 1, status: "503" },
|
|
77
|
+
{ frame: "a3ok", n: 3, t: 3, status: "200", tone: "added" },
|
|
78
|
+
{ frame: "a3fail", n: 3, t: 3, status: "503" },
|
|
79
|
+
{ frame: "a4", n: 4, t: 7, status: "503" },
|
|
80
|
+
];
|
|
81
|
+
const waits = [
|
|
82
|
+
{ frame: "w1", t: 0, span: 1 },
|
|
83
|
+
{ frame: "w2", t: 1, span: 2 },
|
|
84
|
+
{ frame: "w3", t: 3, span: 4 },
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
const caller = document.querySelector(".caller");
|
|
88
|
+
const service = document.querySelector(".service");
|
|
89
|
+
const axis = document.querySelector(".axis");
|
|
90
|
+
const request = whiteboard.token({ label: "GET" });
|
|
91
|
+
|
|
92
|
+
const place = (parent, className, vars, html) => {
|
|
93
|
+
const element = document.createElement("div");
|
|
94
|
+
element.className = className;
|
|
95
|
+
for (const [name, value] of Object.entries(vars)) element.style.setProperty(`--${name}`, value);
|
|
96
|
+
element.innerHTML = html;
|
|
97
|
+
parent.append(element);
|
|
98
|
+
return element;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
for (let t = 0; t <= 8; t++) place(axis, "tick", { t }, `${t}s`);
|
|
102
|
+
|
|
103
|
+
for (const { frame, t, span } of waits) {
|
|
104
|
+
const wait = place(caller, "wait", { t, span }, `<div class="wait-line"></div><span class="wait-label">wait ${span}s</span>`);
|
|
105
|
+
wait.dataset.enter = frame;
|
|
106
|
+
wait.dataset.wbOpen = "backoff";
|
|
107
|
+
// The line grows at 0.5s per second of delay, so a longer wait reads as longer.
|
|
108
|
+
whiteboard.timeline(frame).add(wait.firstChild, { scaleX: [0, 1], duration: span * 500, ease: "linear" });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
for (const { frame, n, t, status, tone = "removed" } of attempts) {
|
|
112
|
+
const marker = place(caller, "at", { t }, `<span class="wb-chip">#${n}</span>`);
|
|
113
|
+
marker.dataset.enter = frame;
|
|
114
|
+
marker.dataset.wbOpen = "retry";
|
|
115
|
+
const result = place(service, "at", { t }, `<span class="wb-chip" data-tone="${tone}">${status}</span>`);
|
|
116
|
+
result.dataset.enter = frame;
|
|
117
|
+
|
|
118
|
+
const arrow = whiteboard.arrow(marker, result, { enter: frame });
|
|
119
|
+
whiteboard.travel(request, arrow, frame);
|
|
120
|
+
whiteboard.timeline(frame).add(result.firstChild, { opacity: [0, 1], duration: 250 }, 900);
|
|
121
|
+
}
|
|
122
|
+
</script>
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"key": "a1",
|
|
4
|
+
"title": "First attempt fails",
|
|
5
|
+
"label": "ATTEMPT 1",
|
|
6
|
+
"caption": "At 0s the caller sends the request and the service answers 503. The loop treats it as retryable instead of returning it.",
|
|
7
|
+
"code": ["retry"]
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"key": "w1",
|
|
11
|
+
"title": "Wait 1s",
|
|
12
|
+
"label": "WAIT 1S",
|
|
13
|
+
"caption": "The delay is base × 2^(attempt − 1), so 1s after the first failure. The caller sleeps and sends nothing.",
|
|
14
|
+
"code": ["backoff"]
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"key": "a2",
|
|
18
|
+
"title": "Second attempt fails",
|
|
19
|
+
"label": "ATTEMPT 2",
|
|
20
|
+
"caption": "At 1s the caller tries again and gets another 503.",
|
|
21
|
+
"code": ["retry"]
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"key": "w2",
|
|
25
|
+
"title": "Wait 2s",
|
|
26
|
+
"label": "WAIT 2S",
|
|
27
|
+
"caption": "The delay doubles to 2s, so the next attempt lands at 3s. Each failure gives the service longer to recover.",
|
|
28
|
+
"code": ["backoff"]
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"key": "a3ok",
|
|
32
|
+
"title": "Third attempt succeeds",
|
|
33
|
+
"label": "ATTEMPT 3",
|
|
34
|
+
"caption": "At 3s the service answers 200. The loop returns that response, after 3s spent waiting.",
|
|
35
|
+
"code": ["retry"]
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"key": "a3fail",
|
|
39
|
+
"title": "Third attempt fails",
|
|
40
|
+
"label": "ATTEMPT 3",
|
|
41
|
+
"caption": "At 3s the service still answers 503.",
|
|
42
|
+
"code": ["retry"]
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"key": "w3",
|
|
46
|
+
"title": "Wait 4s",
|
|
47
|
+
"label": "WAIT 4S",
|
|
48
|
+
"caption": "The delay doubles again to 4s. Attempts thin out as failures pile up, so a struggling service is not hammered.",
|
|
49
|
+
"code": ["backoff"]
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"key": "a4",
|
|
53
|
+
"title": "Last attempt fails",
|
|
54
|
+
"label": "ATTEMPT 4",
|
|
55
|
+
"caption": "At 7s the fourth attempt, the maximum, gets a 503 too.",
|
|
56
|
+
"code": ["retry"]
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"key": "giveup",
|
|
60
|
+
"title": "Give up",
|
|
61
|
+
"label": "GIVE UP",
|
|
62
|
+
"caption": "With no attempts left, the loop stops and throws the last 503 to the caller instead of waiting 8s more.",
|
|
63
|
+
"code": ["retry"]
|
|
64
|
+
}
|
|
65
|
+
]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"key": "succeeds",
|
|
4
|
+
"label": "Succeeds on attempt 3",
|
|
5
|
+
"frames": ["a1", "w1", "a2", "w2", "a3ok"]
|
|
6
|
+
},
|
|
7
|
+
{
|
|
8
|
+
"key": "gives-up",
|
|
9
|
+
"label": "Gives up after 4 attempts",
|
|
10
|
+
"frames": ["a1", "w1", "a2", "w2", "a3fail", "w3", "a4", "giveup"]
|
|
11
|
+
}
|
|
12
|
+
]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# State machine
|
|
2
|
+
|
|
3
|
+
An entity's states drawn as nodes with their legal transitions as arrows, where each frame names the current state and the transition tried, a token rides the arrow taken, and an illegal transition is refused without a state change.
|
|
4
|
+
|
|
5
|
+
## Use when
|
|
6
|
+
|
|
7
|
+
A change adds, removes or guards a transition, such as an order status, a job lifecycle or a subscription, and the reviewer needs to see which moves are legal from where and what happens to one that is not.
|
|
8
|
+
|
|
9
|
+
## Shows
|
|
10
|
+
|
|
11
|
+
- Happy path: draft, submit to queued, pay to paid, ship to shipped, then a cancel refused because the order has shipped.
|
|
12
|
+
- Cancellation: draft, queued, cancel to cancelled, then a late payment refused.
|
|
13
|
+
- The current state is ringed and the persisted `status` value sits below; a refused move draws a dashed red arrow, its token stops partway, and a 409 chip names the reason.
|
|
14
|
+
|
|
15
|
+
## Technique
|
|
16
|
+
|
|
17
|
+
Data-driven: each frame's `data` is `{ state, transition?: { event, from, to, refused? } }`, complete per frame, and one `whiteboard.on("*", ...)` draws any frame from it. Nodes are `.wb-node` elements in a CSS grid, the start and the final states as `data-form="pill"`; `whiteboard.arrow` draws the edges and one `whiteboard.token` rides them. The ride is a `whiteboard.animate` over points sampled from the arrow's path, so it skips itself on a jump and under reduced motion; it plays only when stepping forward.
|
|
18
|
+
|
|
19
|
+
## Adapt
|
|
20
|
+
|
|
21
|
+
- Replace the nodes and the `arrow(...)` calls with your states and legal transitions; place nodes with the grid, at most four columns.
|
|
22
|
+
- Write one frame per step in `frames.json`; a new scenario needs no script change.
|
|
23
|
+
- Set `refused` to the error your code returns for an illegal move.
|
|
24
|
+
- Point `code` at your transition table.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
<style>
|
|
2
|
+
.machine {
|
|
3
|
+
display: grid;
|
|
4
|
+
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
5
|
+
justify-items: center;
|
|
6
|
+
row-gap: var(--wb-space-72);
|
|
7
|
+
padding: var(--wb-space-24);
|
|
8
|
+
}
|
|
9
|
+
.machine [data-state="cancelled"] { grid-column: 2 / span 2; }
|
|
10
|
+
.wb-node[data-current] { box-shadow: 0 0 0 var(--wb-space-4) var(--wb-accent-soft); }
|
|
11
|
+
.status { padding-top: 0; justify-content: center; gap: var(--wb-space-8); }
|
|
12
|
+
</style>
|
|
13
|
+
|
|
14
|
+
<div class="machine">
|
|
15
|
+
<div class="wb-node" data-state="draft" data-form="pill">draft<small>editable</small></div>
|
|
16
|
+
<div class="wb-node" data-state="queued">queued<small>awaiting payment</small></div>
|
|
17
|
+
<div class="wb-node" data-state="paid">paid<small>awaiting shipment</small></div>
|
|
18
|
+
<div class="wb-node" data-state="shipped" data-form="pill">shipped<small>final</small></div>
|
|
19
|
+
<div class="wb-node" data-state="cancelled" data-form="pill">cancelled<small>final</small></div>
|
|
20
|
+
</div>
|
|
21
|
+
<div class="wb-row status">
|
|
22
|
+
<span class="wb-badge" data-wb-open="transitions"></span>
|
|
23
|
+
<span class="wb-chip" data-tone="removed"></span>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
<script>
|
|
27
|
+
const nodes = Object.fromEntries(
|
|
28
|
+
[...document.querySelectorAll(".wb-node")].map((node) => [node.dataset.state, node]),
|
|
29
|
+
);
|
|
30
|
+
const [badge, refusal] = document.querySelectorAll(".status > *");
|
|
31
|
+
const token = whiteboard.token();
|
|
32
|
+
const arrows = new Map();
|
|
33
|
+
|
|
34
|
+
const arrow = (from, to, options) => {
|
|
35
|
+
const made = whiteboard.arrow(nodes[from], nodes[to], options);
|
|
36
|
+
arrows.set(`${from}>${to}`, made);
|
|
37
|
+
return made;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// The legal transitions. A refused one gets its arrow when a frame tries it.
|
|
41
|
+
arrow("draft", "queued", { label: "submit" });
|
|
42
|
+
arrow("queued", "paid", { label: "pay" });
|
|
43
|
+
arrow("paid", "shipped", { label: "ship" });
|
|
44
|
+
arrow("queued", "cancelled", { label: "cancel" });
|
|
45
|
+
arrow("paid", "cancelled", { label: "cancel" });
|
|
46
|
+
|
|
47
|
+
const mark = (state) => {
|
|
48
|
+
for (const [name, node] of Object.entries(nodes)) {
|
|
49
|
+
node.toggleAttribute("data-current", name === state);
|
|
50
|
+
if (name === state) node.dataset.tone = "accent";
|
|
51
|
+
else delete node.dataset.tone;
|
|
52
|
+
}
|
|
53
|
+
badge.textContent = `status = ${state}`;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
let ride;
|
|
57
|
+
|
|
58
|
+
whiteboard.on("*", ({ frame, direction, instant }) => {
|
|
59
|
+
const { state, transition: move } = frame.data;
|
|
60
|
+
const key = move && `${move.from}>${move.to}`;
|
|
61
|
+
|
|
62
|
+
if (move?.refused && !arrows.has(key))
|
|
63
|
+
arrow(move.from, move.to, { label: move.event, tone: "removed", dashed: true, bend: -36 }).dataset.refused = "";
|
|
64
|
+
|
|
65
|
+
for (const [name, line] of arrows) {
|
|
66
|
+
if ("refused" in line.dataset) line.style.display = name === key ? "" : "none";
|
|
67
|
+
else if (name === key) line.dataset.tone = "accent";
|
|
68
|
+
else delete line.dataset.tone;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
refusal.style.display = move?.refused ? "" : "none";
|
|
72
|
+
refusal.textContent = move?.refused ? `${move.event}: 409 ${move.refused}` : "";
|
|
73
|
+
|
|
74
|
+
ride?.cancel();
|
|
75
|
+
const plays = move && direction === "forward" && !instant;
|
|
76
|
+
mark(plays && !move.refused ? move.from : state);
|
|
77
|
+
if (!plays) return;
|
|
78
|
+
|
|
79
|
+
// Sample the routed line: a refused move stops partway and fades.
|
|
80
|
+
const line = arrows.get(key).querySelector(".wb-line");
|
|
81
|
+
const reach = line.getTotalLength() * (move.refused ? 0.3 : 1);
|
|
82
|
+
const steps = Array.from({ length: 13 }, (_, i) => line.getPointAtLength((reach * i) / 12));
|
|
83
|
+
token.dataset.tone = move.refused ? "removed" : "accent";
|
|
84
|
+
ride = whiteboard.animate(
|
|
85
|
+
token,
|
|
86
|
+
steps.map(({ x, y }, i) => ({ transform: `translate(${x}px, ${y}px)`, opacity: i === 12 ? 0 : 1 })),
|
|
87
|
+
{ duration: 900, easing: "ease-in-out" },
|
|
88
|
+
);
|
|
89
|
+
ride.finished.then(
|
|
90
|
+
() => whiteboard.frame.key === frame.key && mark(state),
|
|
91
|
+
() => {},
|
|
92
|
+
);
|
|
93
|
+
return ride;
|
|
94
|
+
});
|
|
95
|
+
</script>
|