@liustack/pptwise 0.22.0

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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +127 -0
  3. package/README.zh-CN.md +136 -0
  4. package/cordis.patch.yml +5 -0
  5. package/dist/chunk-3ZUKISTY.js +114 -0
  6. package/dist/chunk-3ZUKISTY.js.map +1 -0
  7. package/dist/chunk-M35M4QUC.js +1167 -0
  8. package/dist/chunk-M35M4QUC.js.map +1 -0
  9. package/dist/chunk-VUOLBHD7.js +19 -0
  10. package/dist/chunk-VUOLBHD7.js.map +1 -0
  11. package/dist/chunk-WL5KWYKS.js +49762 -0
  12. package/dist/chunk-WL5KWYKS.js.map +1 -0
  13. package/dist/cli.js +4753 -0
  14. package/dist/cli.js.map +1 -0
  15. package/dist/index.d.ts +4224 -0
  16. package/dist/index.js +99 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/node.d.ts +7 -0
  19. package/dist/node.js +11 -0
  20. package/dist/node.js.map +1 -0
  21. package/dist/pixel-audit-H5K6JK3X.js +218 -0
  22. package/dist/pixel-audit-H5K6JK3X.js.map +1 -0
  23. package/dist/registry-C0GJH7ZT.d.ts +46 -0
  24. package/dsh/client.js +1398 -0
  25. package/dsh/index.js +141 -0
  26. package/dsh/preview-tool.js +1931 -0
  27. package/dsh/spawnHidden.js +109 -0
  28. package/package.json +113 -0
  29. package/skills/pptwise/SKILL.md +100 -0
  30. package/skills/pptwise/SKILL.zh-CN.md +102 -0
  31. package/skills/pptwise/references/branding.md +18 -0
  32. package/skills/pptwise/references/branding.zh-CN.md +21 -0
  33. package/skills/pptwise/references/components.md +35 -0
  34. package/skills/pptwise/references/components.zh-CN.md +40 -0
  35. package/skills/pptwise/references/density.md +17 -0
  36. package/skills/pptwise/references/density.zh-CN.md +22 -0
  37. package/skills/pptwise/references/images.md +42 -0
  38. package/skills/pptwise/references/images.zh-CN.md +47 -0
  39. package/skills/pptwise/references/layouts.md +37 -0
  40. package/skills/pptwise/references/layouts.zh-CN.md +42 -0
  41. package/skills/pptwise/references/spec.md +107 -0
  42. package/skills/pptwise/references/spec.zh-CN.md +112 -0
  43. package/skills/pptwise/references/validate.md +82 -0
  44. package/skills/pptwise/references/validate.zh-CN.md +87 -0
  45. package/skills/pptwise/scripts/run.ps1 +192 -0
  46. package/skills/pptwise/scripts/run.sh +229 -0
package/dsh/client.js ADDED
@@ -0,0 +1,1398 @@
1
+ // Browser half of the pptwise dsh plugin: the deck preview card.
2
+ //
3
+ // The host half (`./preview-tool.js`) registers `pptwise_preview` and puts
4
+ // the rendered bundle on `output.presentationMeta` — a channel persisted with
5
+ // the session log and never shown to the model. This file is what turns that
6
+ // payload into something a person can look at: a thumbnail strip in the tool
7
+ // card, and a full-size modal on click. No new tab, no localhost URL, no
8
+ // "open this link yourself", which is the whole reason the tool exists.
9
+ //
10
+ // `dsh.client.immediately` is not optional for this plugin. The client module
11
+ // table is lazy: a boot-graph row only loads when something imports its id,
12
+ // and nothing in the shell imports a third-party plugin. Without the flag
13
+ // this file is served and never executed — which is exactly how the first
14
+ // build shipped, with the tool rendering in the generic row and no error
15
+ // anywhere to say the card had never been loaded at all.
16
+ //
17
+ // Hand-written in the lazy-CJS bundle protocol (`window.__ModuleLoader__.load`
18
+ // with a factory returning cordis-plugin exports), matching the host half's
19
+ // zero-dependency stance and modlens's precedent: no build step, no JSX, no
20
+ // imports from dsh client packages beyond what `require` hands back at
21
+ // materialization time. `react` arrives through that same `require`, so this
22
+ // package still declares no react dependency of its own.
23
+ //
24
+ // It renders nothing itself. Every slide it shows is markup `renderSlideSvg`
25
+ // already produced, carried verbatim from the tool result — the same single
26
+ // rendering path the CLI, the review gallery and the promotional images all
27
+ // read from. A second renderer living in a UI is how those would drift into
28
+ // describing different products.
29
+ //
30
+ // The two halves of a card have different lifetimes, and that is the whole of
31
+ // what went wrong in the field. What it draws comes from one request; what its
32
+ // buttons do is a second request made minutes or days later, against files the
33
+ // user may since have deleted. Reopen a transcript after that and the card used
34
+ // to have two failure modes and no words for either: under Code Mode the fetch
35
+ // 404'd and the entire card — strip, Open, Download — rendered nothing, so the
36
+ // tool call read as if it had produced nothing; with a payload already in hand,
37
+ // the strip drew perfectly and the viewer opened on
38
+ // `{"error":"unknown preview id"}`, displayed as a document, framed by this
39
+ // card's own Close button. So: a missing deck is now an answer (the card says
40
+ // the deck is gone, and how long it was), every way into the viewer asks the
41
+ // route first, and the host half answers the iframe with a page rather than
42
+ // with JSON.
43
+ //
44
+ // The correction after that was learning to tell "gone" from "this request did
45
+ // not work" — see `verdictOf`. Collapsing the two meant one flaky response
46
+ // permanently retired a deck that was sitting on disk the whole time.
47
+ //
48
+ // It also *views* nothing of its own any more. The modal used to be a small
49
+ // React slideshow — arrow keys, a page counter, prev/next buttons, a stand-in
50
+ // for pages the server had declined to send — sitting next to the finished
51
+ // `preview.html` that `pptwise preview --html` writes for every run and that
52
+ // harnesses without a plugin UI are told to open. Two viewers, one deck, and
53
+ // only one of them tested. The modal is now an iframe pointing at that file,
54
+ // and keeps only the two things the file cannot do for itself: close, and hand
55
+ // over the .pptx.
56
+ window.__ModuleLoader__.load({
57
+ id: '@liustack/pptwise',
58
+ factory: (require) => {
59
+ var module = { exports: {} }
60
+ var exports = module.exports
61
+
62
+ var TOOL_NAME = 'pptwise_preview'
63
+
64
+ /**
65
+ * How many thumbnails the strip draws.
66
+ *
67
+ * The host half inlines exactly this many pages' markup
68
+ * (`THUMBNAIL_STRIP_PAGES`, ./preview-tool.js) and sends the rest as
69
+ * metadata only, so the two numbers are one decision written down twice.
70
+ * `stripPages` filters on markup as well as slicing, which is what keeps a
71
+ * disagreement between them showing up as a shorter strip instead of a row
72
+ * of empty boxes.
73
+ */
74
+ var STRIP_PAGES = 12
75
+
76
+ /**
77
+ * Where the full-size viewer lives — the html `preview --html` wrote.
78
+ *
79
+ * `#page=N` is that page's own way in, and a URL is the only thing this
80
+ * card can hand it: the frame is a document, not a component with props.
81
+ * N counts pages the way a reader does, so it is the thumbnail's own
82
+ * label rather than its index in a strip that may be a prefix of the deck.
83
+ */
84
+ function previewHtmlUrl(previewId, startPage) {
85
+ var url = '/pptwise/preview/' + previewId + '/html'
86
+ return startPage > 1 ? url + '#page=' + startPage : url
87
+ }
88
+
89
+ /** Where the deck itself lives — the bundle the strip draws from. */
90
+ function previewBundleUrl(previewId) {
91
+ return '/pptwise/preview/' + previewId
92
+ }
93
+
94
+ /**
95
+ * What one answer from the route says about the deck behind an id.
96
+ *
97
+ * This is the distinction the card was missing, and the bug it caused was
98
+ * not subtle: every non-2xx response collapsed into "expired", so a single
99
+ * 502 from a restarting harness, or a request that never left the tab,
100
+ * retired a deck that was sitting on disk the whole time — permanently,
101
+ * for the life of the page, with a reload the only way back.
102
+ *
103
+ * 404 and 410 are the route's two verdicts about the id itself: never
104
+ * heard of it, or heard of it and cannot serve what is left. Both are
105
+ * final, because nothing regenerates a preview (see the host half). Every
106
+ * other outcome — a 5xx, a proxy error, a socket that never opened — is a
107
+ * statement about this attempt and about nothing else, so the card holds
108
+ * its judgement and the next click tries again.
109
+ */
110
+ function verdictOf(res) {
111
+ if (!res) return Promise.resolve(UNREACHABLE_VERDICT)
112
+ if (res.ok) return Promise.resolve('alive')
113
+ // A status code does not say who produced it. The plugin's route may
114
+ // have failed to register, a proxy may have answered first, or the shell
115
+ // may be serving its own not-found page for a path it does not know —
116
+ // all of which look exactly like our 404 and none of which are evidence
117
+ // that a deck is gone. Only a stamped answer is allowed to retire one.
118
+ //
119
+ // Same-origin, so the header is readable without the server having to
120
+ // list it in `Access-Control-Expose-Headers`. If this route ever became
121
+ // cross-origin that header would have to be added, and until then a proxy
122
+ // that strips ours degrades everything to `unreachable` — a false
123
+ // negative, which is the safe direction: the card offers another try
124
+ // instead of retiring a live deck.
125
+ if (!fromPreviewRoute(res)) return Promise.resolve(UNREACHABLE_VERDICT)
126
+ // Not lumped in with the retryable failures, because retrying an
127
+ // identical request against a door that is closed does nothing. The user
128
+ // has to change something outside this card first.
129
+ //
130
+ // Only a *stamped* 401/403 reaches this line — the check above has
131
+ // already sent an unstamped one to `unreachable`. So this is a narrow
132
+ // door: a plain reverse proxy's own 401 does not carry our header and
133
+ // gets a Retry it may well be able to satisfy, which is the safe way
134
+ // round. The host half sends no 401/403 of its own at all.
135
+ if (res.status === 401 || res.status === 403) return Promise.resolve('refused')
136
+ if (res.status !== 404 && res.status !== 410) return Promise.resolve(UNREACHABLE_VERDICT)
137
+ // Both final, and they need opposite sentences in front of a person, so
138
+ // the status alone is not enough to act on. The route says which kind in
139
+ // the body; reading it is why this function is asynchronous.
140
+ return readFailureCode(res).then(function (code) {
141
+ return code === 'preview_damaged' ? 'damaged' : 'gone'
142
+ })
143
+ }
144
+
145
+ /** The header the host half stamps on every answer it writes. */
146
+ var ROUTE_HEADER = 'x-pptwise-preview'
147
+
148
+ function fromPreviewRoute(res) {
149
+ var headers = res && res.headers
150
+ if (!headers || typeof headers.get !== 'function') return false
151
+ return headers.get(ROUTE_HEADER) === '1'
152
+ }
153
+
154
+ /**
155
+ * The machine-readable half of a failure, or nothing.
156
+ *
157
+ * Read from the body rather than guessed from the prose. A body that will
158
+ * not parse is not a reason to give up on the status we already have, so
159
+ * this resolves with null and the caller falls back to the coarser reading.
160
+ */
161
+ function readFailureCode(res) {
162
+ if (!res || typeof res.json !== 'function') return Promise.resolve(null)
163
+ return res
164
+ .json()
165
+ .then(function (body) {
166
+ return body && typeof body.code === 'string' ? body.code : null
167
+ })
168
+ .catch(function () {
169
+ return null
170
+ })
171
+ }
172
+
173
+ /** A request that never produced a response says nothing about the deck. */
174
+ var UNREACHABLE_VERDICT = 'unreachable'
175
+
176
+ /**
177
+ * The verdicts a Retry button can honestly do something about.
178
+ *
179
+ * Only `unreachable`. `gone` and `damaged` are final by construction —
180
+ * nothing regenerates a preview — and `refused` is the route answering, so
181
+ * the identical request will be refused identically until something changes
182
+ * elsewhere. This is called from the render path, not just asserted about:
183
+ * a version of this that was only ever tested as a pure function shipped a
184
+ * Retry button on a refusal it could never satisfy.
185
+ */
186
+ function isRetryable(verdict) {
187
+ return verdict === UNREACHABLE_VERDICT
188
+ }
189
+
190
+ /** The verdicts that mean this deck will not open, whatever the user does next. */
191
+ function isFinal(verdict) {
192
+ return verdict === 'gone' || verdict === 'damaged' || verdict === 'refused'
193
+ }
194
+
195
+ /**
196
+ * Are these bytes a zip, and therefore plausibly a .pptx?
197
+ *
198
+ * `50 4b 03 04` is the local file header every zip starts with, and Office
199
+ * Open XML is a zip. This is not a deep validation and does not try to be —
200
+ * it is the one check that separates "a deck" from "an HTML page a proxy
201
+ * returned with status 200", which is the failure that actually happened.
202
+ */
203
+ function isZip(blob) {
204
+ if (!blob || typeof blob.slice !== 'function' || typeof blob.arrayBuffer !== 'function') {
205
+ return Promise.resolve(false)
206
+ }
207
+ return blob
208
+ .slice(0, 4)
209
+ .arrayBuffer()
210
+ .then(function (head) {
211
+ var bytes = new Uint8Array(head)
212
+ return bytes.length === 4 && bytes[0] === 0x50 && bytes[1] === 0x4b && bytes[2] === 0x03 && bytes[3] === 0x04
213
+ })
214
+ .catch(function () {
215
+ return false
216
+ })
217
+ }
218
+
219
+ /**
220
+ * What the card says once it knows the deck behind its id is gone.
221
+ *
222
+ * One sentence, used by the badge, the export button and the degraded row
223
+ * alike, because they are all reporting the same fact and a card that
224
+ * words it three ways reads as three different problems.
225
+ *
226
+ * It no longer says "expired", and that is a correctness fix rather than a
227
+ * wording preference: nothing expires a preview. There is no timer, no
228
+ * size budget and no sweep, so telling the user their preview timed out
229
+ * sent them looking for a retention setting that does not exist.
230
+ *
231
+ * It does not name a culprit either, and that is the same correction made
232
+ * twice. All the route observed is that nothing is at this id under the
233
+ * previews root this session is reading. Deletion is the usual reason and
234
+ * not the only one: `PPTWISE_HOME` decides that root, so a deck written
235
+ * under a different one is alive and out of reach, which is a fact about
236
+ * configuration and not about anything the user did wrong.
237
+ */
238
+ var MISSING_HINT =
239
+ 'The rendered deck for this preview is no longer on disk where this session looks for it. ' +
240
+ 'Previews are never removed on a timer, so it was either deleted or written under a different ' +
241
+ 'PPTWISE_HOME. Run pptwise_preview again to rebuild it.'
242
+
243
+ /** What the card says when it could not reach the route at all. */
244
+ var UNREACHABLE_HINT =
245
+ 'Could not reach the pptwise preview route. The deck may still be there — try again.'
246
+
247
+ /** What the card says when the route answered, but refused. */
248
+ var REFUSED_HINT =
249
+ 'The pptwise preview route refused this request. The deck may still be there, ' +
250
+ 'but this browser is not allowed to read it. Trying again will be refused the same way.'
251
+
252
+ /**
253
+ * What the card says when the deck was rendered and its bookkeeping is not
254
+ * readable.
255
+ *
256
+ * Deliberately never the word "deleted", and never "no longer on disk". The
257
+ * host half tells these two apart and says so in a machine-readable code;
258
+ * before this sentence existed the card threw that away and reported both
259
+ * as a deletion, so a `record.json` truncated by a full disk arrived at the
260
+ * user as an accusation that they had removed their own work. The pages are
261
+ * very likely still sitting next to the file that went bad.
262
+ */
263
+ var DAMAGED_HINT =
264
+ 'This preview cannot be opened: the file describing it is present but unreadable. ' +
265
+ 'The rendered pages may still be on disk — run pptwise_preview again to rebuild it.'
266
+
267
+ /** The sentence that goes with a verdict, in the one place that shows all of them. */
268
+ function hintFor(verdict) {
269
+ if (verdict === 'gone') return MISSING_HINT
270
+ if (verdict === 'damaged') return DAMAGED_HINT
271
+ if (verdict === 'refused') return REFUSED_HINT
272
+ return UNREACHABLE_HINT
273
+ }
274
+
275
+ /** The one word the badge shows, per verdict. Never "gone" for anything but gone. */
276
+ function badgeFor(verdict) {
277
+ if (verdict === 'damaged') return 'damaged'
278
+ if (verdict === 'refused') return 'refused'
279
+ return 'gone'
280
+ }
281
+
282
+ /** What the export button says once it knows it cannot succeed. */
283
+ function exportLabelFor(verdict) {
284
+ if (verdict === 'damaged') return 'Damaged'
285
+ if (verdict === 'refused') return 'Refused'
286
+ return 'Unavailable'
287
+ }
288
+
289
+ /**
290
+ * Everything the tool said in text, joined.
291
+ *
292
+ * This is the channel that survives Code Mode: a tool invoked from inside
293
+ * `run_code` is a sub-call, and the registry computes `presentationMeta`
294
+ * for top-level calls only, so on this repo's default agent preset the
295
+ * structured payload never exists. The result text always does — which is
296
+ * why both the preview id and, when the deck itself is gone, the one fact
297
+ * left about it are read back out of here.
298
+ */
299
+ function resultTextOf(block) {
300
+ var text = ''
301
+ var content = (block && (block.content || (block.result && block.result.content))) || []
302
+ for (var i = 0; i < content.length; i++) {
303
+ if (content[i] && typeof content[i].text === 'string') text += content[i].text
304
+ }
305
+ return text
306
+ }
307
+
308
+ /** The preview id the tool stamped into its own result text. */
309
+ function previewIdOf(block) {
310
+ var m = /pptwise-preview:([A-Za-z0-9-]+)/.exec(resultTextOf(block))
311
+ return m ? m[1] : null
312
+ }
313
+
314
+ /**
315
+ * How long the deck was, read back out of the tool's own summary line.
316
+ *
317
+ * The tool's summary line is what survives in a transcript once the
318
+ * rendered files are gone, and this is the fact the card reads back out of
319
+ * it. The bundle does not survive: it never enters the session log under
320
+ * Code Mode, and putting it there would mean a session log that grows by a
321
+ * deck's worth of SVG per call — the trade this plugin already decided
322
+ * against. So when the route can no longer serve the deck, one number
323
+ * parsed from a line already in the log is the difference between a card
324
+ * that says "there were nine pages here" and a card that vanishes as if
325
+ * the tool had never run.
326
+ *
327
+ * Parsed rather than carried in a second machine-readable stamp: the
328
+ * sentence is written by `modelSummary` in this plugin's own host half,
329
+ * one file away, and a test drives the real output of that function
330
+ * through this parser, so the two cannot drift apart without that test
331
+ * going red.
332
+ */
333
+ function pageCountOf(block) {
334
+ var m = /\brendered (\d+) pages?\b/.exec(resultTextOf(block))
335
+ return m ? Number(m[1]) : null
336
+ }
337
+
338
+ /**
339
+ * Pull the preview bundle out of a frozen tool-call node.
340
+ *
341
+ * `presentationMeta` is projected into the result's view/meta by the
342
+ * host; the exact field the runtime lands it on has moved between rc
343
+ * builds, so this reads the handful of shapes it can appear under rather
344
+ * than pinning one. Returning null simply falls through to the route,
345
+ * which is where a Code Mode sub-call's deck has to come from anyway.
346
+ */
347
+ function bundleOf(block) {
348
+ if (!block) return null
349
+ var candidates = [block.meta, block.resultView, block.result && block.result.meta, block.presentationMeta]
350
+ for (var i = 0; i < candidates.length; i++) {
351
+ var c = candidates[i]
352
+ if (c && c.card === 'pptwise-preview' && c.bundle && Array.isArray(c.bundle.pages)) return c.bundle
353
+ if (c && c.bundle && Array.isArray(c.bundle.pages)) return c.bundle
354
+ }
355
+ return null
356
+ }
357
+
358
+ /**
359
+ * Every page the deck has, markup or not.
360
+ *
361
+ * The pages past the strip arrive with `svg: null`, and they still belong
362
+ * here: this is what the card counts and what decides whether there is a
363
+ * deck at all. Filtering them out would make the header under-report a
364
+ * long deck's length, and would make a card vanish rather than say
365
+ * anything if a bundle ever arrived carrying no markup at all.
366
+ */
367
+ function viewablePages(bundle) {
368
+ return bundle && Array.isArray(bundle.pages) ? bundle.pages : []
369
+ }
370
+
371
+ function hasMarkup(page) {
372
+ return !!page && typeof page.svg === 'string' && page.svg.length > 0
373
+ }
374
+
375
+ /**
376
+ * The pages the strip can actually draw: the first `STRIP_PAGES`, minus
377
+ * any that arrived without markup. See `STRIP_PAGES` for why the filter is
378
+ * normally a no-op and why it is here anyway.
379
+ */
380
+ function stripPages(pages) {
381
+ return pages.slice(0, STRIP_PAGES).filter(hasMarkup)
382
+ }
383
+
384
+ /** The number a reader would call this page, not its index in the array. */
385
+ function pageNumberOf(page, index) {
386
+ return (page && typeof page.page === 'number' ? page.page : null) || index + 1
387
+ }
388
+
389
+ /**
390
+ * Namespace one page's internal ids.
391
+ *
392
+ * Every slide is a standalone SVG document whose ids only have to be
393
+ * unique inside itself. Several of them in one page merge id spaces, so a
394
+ * `url(#decor-tech-field)` on slide 6 would resolve against slide 3's
395
+ * definition of the same id. The host half solves this at build time for
396
+ * the files it writes (`src/lib/svg-ids.ts`); the same transform has to
397
+ * run here because this card mounts several documents into one DOM.
398
+ */
399
+ function namespaceIds(svg, prefix) {
400
+ return svg
401
+ .replace(/\bid="([^"]*)"/g, function (_m, id) {
402
+ return 'id="' + prefix + id + '"'
403
+ })
404
+ .replace(/url\(#([^)"']*)\)/g, function (_m, id) {
405
+ return 'url(#' + prefix + id + ')'
406
+ })
407
+ .replace(/\b(xlink:href|href)="#([^"]*)"/g, function (_m, attr, id) {
408
+ return attr + '="#' + prefix + id + '"'
409
+ })
410
+ }
411
+
412
+ function PreviewCard(react) {
413
+ var h = react.createElement
414
+ var useState = react.useState
415
+ var useEffect = react.useEffect
416
+
417
+ var COLORS = {
418
+ line: 'var(--dsw-alias-separator, rgba(127,127,127,0.28))',
419
+ dim: 'var(--dsw-alias-label-tertiary, rgba(127,127,127,0.85))',
420
+ text: 'var(--dsw-alias-label-primary, inherit)',
421
+ stage: 'var(--dsw-alias-fill-quaternary, rgba(127,127,127,0.12))',
422
+ }
423
+
424
+ /**
425
+ * The viewer's own two buttons, styled in a sheet rather than inline.
426
+ *
427
+ * Everything else in this file styles elements directly, which is fine
428
+ * until a button has to answer the pointer: a solid white button that
429
+ * does not change under the cursor reads as broken before it reads as
430
+ * plain, and `:hover` cannot be written inline at all.
431
+ *
432
+ * The colours have to live here too, not just the hover rules. An
433
+ * element's own `style` attribute outranks any sheet, so leaving
434
+ * `background` and `border` inline lets them win every hover — the
435
+ * transition still runs, which is what makes the result look wired up
436
+ * while the colour never moves. The alternative is `!important` on four
437
+ * declarations, which is the same bug with an override on top.
438
+ *
439
+ * Sized against the harness's shell rather than the card's: these sit
440
+ * over a full-screen view, so at the card's 13px/23px they read as
441
+ * smaller than the controls they are covering. Solid marks the errand —
442
+ * taking the deck away is why the row exists — and the outline marks
443
+ * the exit.
444
+ */
445
+ var MODAL_STYLE_ID = 'pptwise-modal-style'
446
+ var MODAL_BTN_BASE =
447
+ 'font:inherit;font-size:13px;padding:7px 16px;border-radius:8px;cursor:pointer;'
448
+ function ensureModalStyles() {
449
+ if (document.getElementById(MODAL_STYLE_ID)) return
450
+ var el = document.createElement('style')
451
+ el.id = MODAL_STYLE_ID
452
+ el.textContent =
453
+ '.pf-mbtn{' +
454
+ MODAL_BTN_BASE +
455
+ 'border:1px solid rgba(255,255,255,0.5);background:transparent;color:#fff;' +
456
+ 'transition:background-color .12s,border-color .12s}' +
457
+ '.pf-mbtn:hover{background:rgba(255,255,255,0.14);border-color:rgba(255,255,255,0.8)}' +
458
+ '.pf-mbtn-primary{' +
459
+ MODAL_BTN_BASE +
460
+ 'font-weight:600;border:1px solid transparent;background:#fff;color:#111;' +
461
+ 'transition:background-color .12s}' +
462
+ '.pf-mbtn-primary:hover{background:#e2e2e2}'
463
+ document.head.appendChild(el)
464
+ }
465
+
466
+ /** One slide, mounted as real SVG so it scales with its box. */
467
+ function Slide(props) {
468
+ var ref = react.useRef(null)
469
+ useEffect(
470
+ function () {
471
+ var el = ref.current
472
+ if (!el) return
473
+ el.innerHTML = namespaceIds(props.svg, props.prefix)
474
+ var svg = el.querySelector('svg')
475
+ if (svg) {
476
+ svg.setAttribute('width', '100%')
477
+ svg.setAttribute('height', '100%')
478
+ svg.style.display = 'block'
479
+ }
480
+ },
481
+ [props.svg, props.prefix],
482
+ )
483
+ return h('div', {
484
+ ref: ref,
485
+ style: { position: 'absolute', inset: 0 },
486
+ })
487
+ }
488
+
489
+ /** One thumbnail: a 16:9 box holding the slide, clickable into the viewer. */
490
+ function Frame(props) {
491
+ return h(
492
+ 'div',
493
+ {
494
+ style: {
495
+ position: 'relative',
496
+ aspectRatio: (props.width || 1280) + ' / ' + (props.height || 720),
497
+ background: COLORS.stage,
498
+ borderRadius: 6,
499
+ overflow: 'hidden',
500
+ border: '1px solid ' + COLORS.line,
501
+ cursor: props.onClick ? 'zoom-in' : 'default',
502
+ width: '100%',
503
+ },
504
+ onClick: props.onClick,
505
+ role: props.onClick ? 'button' : undefined,
506
+ tabIndex: props.onClick ? 0 : undefined,
507
+ onKeyDown: props.onClick
508
+ ? function (e) {
509
+ if (e.key === 'Enter' || e.key === ' ') {
510
+ e.preventDefault()
511
+ props.onClick()
512
+ }
513
+ }
514
+ : undefined,
515
+ title: props.title,
516
+ },
517
+ h(Slide, { svg: props.page.svg, prefix: props.prefix }),
518
+ )
519
+ }
520
+
521
+ /**
522
+ * Full-size viewer: the deck's own `preview.html`, in an iframe.
523
+ *
524
+ * Everything a reader does inside it — ←/→, the filmstrip, the
525
+ * light/dark surround, the audit findings panel — belongs to that page,
526
+ * which was written, tested and shipped for the harnesses that have no
527
+ * plugin UI. This modal contributes the two things the page has no way
528
+ * to offer from inside itself: a way out, and the export.
529
+ */
530
+ function Modal(props) {
531
+ var frameRef = react.useRef(null)
532
+
533
+ useEffect(
534
+ function () {
535
+ ensureModalStyles()
536
+ function onKey(e) {
537
+ if (e.key === 'Escape') props.onClose()
538
+ }
539
+ document.addEventListener('keydown', onKey)
540
+ // Once the reader clicks the deck, keystrokes go to the iframe's
541
+ // own document and never reach this one — so Escape has to be
542
+ // heard there too, or it stops working exactly when the modal is
543
+ // in use. The frame is same-origin by construction (its src is
544
+ // this plugin's own route), and the access is guarded anyway:
545
+ // losing Escape is not worth throwing inside an effect for. The
546
+ // listener needs no removal of its own, since it dies with the
547
+ // document when this modal unmounts.
548
+ var frame = frameRef.current
549
+ function listenInside() {
550
+ try {
551
+ var doc = frame.contentDocument
552
+ if (doc) doc.addEventListener('keydown', onKey)
553
+ } catch {
554
+ /* not reachable from here after all — the outer listener stands */
555
+ }
556
+ }
557
+ if (frame) frame.addEventListener('load', listenInside)
558
+ return function () {
559
+ document.removeEventListener('keydown', onKey)
560
+ if (frame) frame.removeEventListener('load', listenInside)
561
+ }
562
+ },
563
+ [props.onClose, props.src],
564
+ )
565
+
566
+ return h(
567
+ 'div',
568
+ {
569
+ style: {
570
+ position: 'fixed',
571
+ inset: 0,
572
+ zIndex: 9999,
573
+ // Opaque, not the usual translucent backdrop. This covers the
574
+ // whole viewport with a document, so there is nothing behind it
575
+ // worth a glimpse of — and a glimpse is all a translucent one
576
+ // gives. At 0.8 the harness's own composer stayed legible right
577
+ // under this modal's buttons and the eye read the two as one
578
+ // strip of controls; at 0.94 its title bar and its `Session
579
+ // log` pill still ghosted through at 6% white, the pill landing
580
+ // 14px outside the edge these buttons are aligned to.
581
+ background: '#000',
582
+ display: 'flex',
583
+ alignItems: 'center',
584
+ justifyContent: 'center',
585
+ padding: 24,
586
+ },
587
+ onClick: function (e) {
588
+ if (e.target === e.currentTarget) props.onClose()
589
+ },
590
+ },
591
+ h(
592
+ 'div',
593
+ {
594
+ style: {
595
+ width: 'min(100%, 1600px)',
596
+ height: '100%',
597
+ display: 'flex',
598
+ flexDirection: 'column',
599
+ gap: 14,
600
+ },
601
+ onClick: function (e) {
602
+ if (e.target === e.currentTarget) props.onClose()
603
+ },
604
+ },
605
+ // Above the frame, not below it. Below, this row lands in the
606
+ // strip of backdrop the harness's own input area occupies, and
607
+ // whatever the backdrop lets through sits on the same baseline.
608
+ h(
609
+ 'div',
610
+ {
611
+ style: {
612
+ display: 'flex',
613
+ alignItems: 'center',
614
+ justifyContent: 'flex-end',
615
+ gap: 10,
616
+ flex: '0 0 auto',
617
+ // Lines this row's right edge up with the Light/Dark toggle
618
+ // in the previewed page's own header, which sits one row
619
+ // below it. That page's own `header` rule reserves the same
620
+ // inset (`padding:11px 18px`, preview-html.ts); without
621
+ // matching it, two right-aligned rows 14px apart miss each
622
+ // other by just enough to look like a mistake, not a choice.
623
+ paddingRight: 18,
624
+ },
625
+ onClick: function (e) {
626
+ if (e.target === e.currentTarget) props.onClose()
627
+ },
628
+ },
629
+ props.previewId
630
+ ? h(ExportButton, {
631
+ previewId: props.previewId,
632
+ name: props.name,
633
+ draft: props.draft,
634
+ status: props.downloadStatus,
635
+ onStatus: props.onDownloadStatus,
636
+ className: 'pf-mbtn-primary',
637
+ })
638
+ : null,
639
+ h(
640
+ 'button',
641
+ { onClick: props.onClose, className: 'pf-mbtn' },
642
+ 'Close',
643
+ ),
644
+ ),
645
+ h('iframe', {
646
+ ref: frameRef,
647
+ src: props.src,
648
+ title: 'pptwise deck preview',
649
+ style: {
650
+ // Takes the rest of the dialog and lets the page inside do its
651
+ // own layout. Its stage carries `aspect-ratio: 16/9` and sizes
652
+ // itself off the viewport it is handed, so the slide keeps its
653
+ // shape at any frame size. Pinning the *frame* to 16:9 instead
654
+ // would have to reserve room for that page's header and
655
+ // filmstrip, and every guess at how much is wrong the moment
656
+ // either one changes height.
657
+ flex: '1 1 auto',
658
+ width: '100%',
659
+ minHeight: 0,
660
+ border: 0,
661
+ borderRadius: 8,
662
+ background: COLORS.stage,
663
+ },
664
+ }),
665
+ ),
666
+ )
667
+ }
668
+
669
+ /**
670
+ * The export.
671
+ *
672
+ * Fetched and saved by hand rather than left to a plain `download`
673
+ * anchor. A preview id can genuinely stop resolving — the user is free
674
+ * to delete what it names — and on a bare anchor that failure arrives as
675
+ * a 404 body the browser saves as a file, which is how the first version
676
+ * handed people a `pptx.json` and called it a download. Going through
677
+ * fetch means a failure can say so.
678
+ *
679
+ * The states are the verdicts, plus `busy` and `failed`. Anything final
680
+ * (`gone`, `damaged`, `refused`) disables the button and says which;
681
+ * `failed` is this attempt not working and leaves it clickable. Before
682
+ * that split, one dropped request turned a live export into a dead button
683
+ * for the rest of the session.
684
+ *
685
+ * This component owns no state. It reads `props.status` and reports
686
+ * through `props.onStatus(next, order)`, both of which belong to the
687
+ * card's store — see THE STORE for why a component keeping "just a
688
+ * little" of its own is how this went wrong twice. The `order` it passes
689
+ * back is one ticket for the whole attempt, in the download lane. Two
690
+ * clicks can still answer out of order, of course. What the ticket buys
691
+ * is that the older answer is the one discarded, and that an answer about
692
+ * the deck cannot discard this one.
693
+ */
694
+ function ExportButton(props) {
695
+ // `props.verdict` is the card telling this button what it already
696
+ // learned from the route. Before it, the only way to find out was to
697
+ // click and wait for the fetch to fail, so a card that knew perfectly
698
+ // well the export was dead still offered it as `Download .pptx`.
699
+ var status = isFinal(props.verdict) ? props.verdict : props.status
700
+ var setStatus = props.onStatus
701
+
702
+ function save() {
703
+ if (status === 'busy' || isFinal(status)) return
704
+ // One ticket for this whole attempt, in its own lane. Two clicks race
705
+ // the same way two Opens do, and the download lane is separate from
706
+ // the deck lane so a slow export cannot make a fresh verdict look
707
+ // stale — or the reverse.
708
+ var order = { lane: 'download', ticket: nextTicket() }
709
+ setStatus('busy', order)
710
+ fetch('/pptwise/preview/' + props.previewId + '/pptx')
711
+ .then(function (res) {
712
+ return verdictOf(res).then(function (verdict) {
713
+ if (verdict !== 'alive') {
714
+ var error = new Error('preview route answered ' + (res ? res.status : 'nothing'))
715
+ error.verdict = verdict
716
+ throw error
717
+ }
718
+ return res.blob()
719
+ })
720
+ })
721
+ .then(function (blob) {
722
+ // Look at the bytes before saving them. A 200 whose body is a
723
+ // login page, a proxy notice or an error document is exactly how
724
+ // the first version of this button handed people a `pptx.json`
725
+ // and called it a download — the status line said fine and the
726
+ // content was not a deck. A .pptx is a zip, so its first four
727
+ // bytes are the only claim worth checking.
728
+ return isZip(blob).then(function (ok) {
729
+ if (!ok) {
730
+ var error = new Error('the preview route returned something that is not a .pptx')
731
+ error.verdict = UNREACHABLE_VERDICT
732
+ throw error
733
+ }
734
+ return blob
735
+ })
736
+ })
737
+ .then(function (blob) {
738
+ var url = URL.createObjectURL(blob)
739
+ var a = document.createElement('a')
740
+ a.href = url
741
+ // Matches the name the host half gave the file it is serving
742
+ // (`exportName`, preview-tool.js): a deck with unfilled pages
743
+ // must not be saved under a name that reads as finished work.
744
+ a.download = (props.name || 'deck') + (props.draft ? '-draft' : '') + '.pptx'
745
+ document.body.appendChild(a)
746
+ a.click()
747
+ a.remove()
748
+ setTimeout(function () { URL.revokeObjectURL(url) }, 1000)
749
+ setStatus('idle', order)
750
+ })
751
+ .catch(function (error) {
752
+ // A rejection carrying no verdict is not the route speaking: the
753
+ // request never landed, or it landed and reading the body failed
754
+ // halfway. Neither says anything final about the deck. Only the
755
+ // route's own answers do — and `refused` counts among them, since
756
+ // clicking again sends the identical request to the same closed
757
+ // door. Folding 401/403 into `failed` offered a retry that could
758
+ // only fail again, under a sentence about the network.
759
+ var verdict = error && error.verdict
760
+ setStatus(isFinal(verdict) ? verdict : 'failed', order)
761
+ })
762
+ }
763
+
764
+ return h(
765
+ 'button',
766
+ {
767
+ onClick: save,
768
+ className: props.className,
769
+ style: props.style,
770
+ title: isFinal(status)
771
+ ? hintFor(status)
772
+ : status === 'failed'
773
+ ? UNREACHABLE_HINT
774
+ : props.draft
775
+ ? 'Download the editable .pptx. It is a draft: some pages are still unfilled placeholders'
776
+ : 'Download the editable .pptx',
777
+ },
778
+ status === 'busy'
779
+ ? 'Saving…'
780
+ : isFinal(status)
781
+ ? exportLabelFor(status)
782
+ : status === 'failed'
783
+ ? 'Retry download'
784
+ : 'Download .pptx',
785
+ )
786
+ }
787
+
788
+ /**
789
+ * The missing card's own line, under the header.
790
+ *
791
+ * Said out loud rather than left to a tooltip: the whole complaint this
792
+ * exists to answer is a card that told the user nothing at all.
793
+ */
794
+ function VerdictNote(props) {
795
+ return h(
796
+ 'div',
797
+ { style: { fontSize: 12, color: COLORS.dim, paddingTop: 2 } },
798
+ hintFor(props.verdict),
799
+ )
800
+ }
801
+
802
+ /**
803
+ * The badge that marks a deck the route will not serve, and says which
804
+ * kind of "will not".
805
+ *
806
+ * It used to read `gone` whatever had happened, which is how a damaged
807
+ * `record.json` reached the user as a deletion. The word comes from the
808
+ * verdict now, and the verdict comes from a code the route sends.
809
+ */
810
+ function VerdictBadge(props) {
811
+ return h(
812
+ 'span',
813
+ {
814
+ title: hintFor(props.verdict),
815
+ style: {
816
+ border: '1px solid ' + COLORS.line,
817
+ borderRadius: 5,
818
+ padding: '0 6px',
819
+ fontWeight: 600,
820
+ textTransform: 'uppercase',
821
+ fontSize: 10,
822
+ letterSpacing: '0.04em',
823
+ },
824
+ },
825
+ badgeFor(props.verdict),
826
+ )
827
+ }
828
+
829
+ // THE STORE.
830
+ //
831
+ // Seven review rounds produced seven defects of one shape: state that
832
+ // could not say which preview it was about, or a write that landed on the
833
+ // wrong one. Three of those were introduced by the fix for the previous
834
+ // one. What is left is one entry and one number, and this note describes
835
+ // only what the code in front of you does.
836
+ //
837
+ // ONE ENTRY. The card draws one deck at a time, so the store holds one
838
+ // deck's state, stamped with the preview id it belongs to. A render reads
839
+ // it only while that stamp matches the deck it is drawing (`mine`);
840
+ // otherwise it reads `UNASKED` and the deck that has scrolled past lends
841
+ // nothing to the one that replaced it — not its verdict, not its open
842
+ // viewer, not its download status. Arriving at a deck replaces the entry
843
+ // outright rather than adjusting the one already there, which is what
844
+ // makes that true of every field at once instead of field by field.
845
+ //
846
+ // ONE NUMBER. A monotonic counter hands out a ticket per request, and one
847
+ // per arrival. An entry starts life holding its arrival's ticket as the
848
+ // high-water mark of both lanes, and a write is accepted only if its
849
+ // ticket is at least the mark of the lane it names. That single
850
+ // comparison does two jobs:
851
+ //
852
+ // - within one stay, an older answer cannot overwrite a newer one. Two
853
+ // clicks on Open used to race last-writer-wins: the second request
854
+ // answered 410 and settled the card, then the first answered 200 and
855
+ // un-settled it, reopening a viewer for a deck the route had just
856
+ // disowned. "Last to arrive" and "most recent" are not the same thing,
857
+ // and only one of them is a fact about the deck.
858
+ //
859
+ // - across stays, an answer from a stay the card has left is refused by
860
+ // the same line, because the current entry's mark was taken when the
861
+ // card arrived — which is after that request went out. This is why
862
+ // there is no second stamp to compare. An earlier version addressed
863
+ // entries by `{ id, visit }` and matched the visit exactly, and it had
864
+ // to: a new entry started both lanes at zero, so a departed stay's
865
+ // ticket was the higher number and won. Seeding the marks from the
866
+ // arrival inverts that. The danger did not go away, the arithmetic
867
+ // turned round — leave the seed at zero and a viewer opens by itself
868
+ // over a deck the user has only just come back to.
869
+ //
870
+ // Two lanes, deck and download, each with a mark of its own, so a slow
871
+ // export cannot make a fresh verdict look stale, or the reverse.
872
+ //
873
+ // Nothing is written during a render — not the store, and not the counter
874
+ // below. The store is *read* during one, in `deck`, which is ordinary
875
+ // React. What this file must not do is let a render decide or remember
876
+ // anything, which is how the ref-based version broke under a render that
877
+ // was thrown away. Nothing is captured from a render either: a request
878
+ // takes its ticket when it is sent, and what it is measured against is
879
+ // whatever the store holds when it answers.
880
+ //
881
+ // `useLayoutEffect` rather than `useEffect` for the maintenance, and the
882
+ // reason is narrow: it puts the entry in place during the commit that
883
+ // paints the buttons, so there is no frame in which a click could land on
884
+ // a deck that has nowhere to write its answer. It is not load-bearing for
885
+ // ordering — the fetch effect waits for the entry through its dependency
886
+ // list rather than through hook order, so a passive spelling declared
887
+ // anywhere fetches just the same. That was measured, not reasoned: with
888
+ // `maintainStore` rewritten as a passive effect and moved below the fetch
889
+ // effect, all 189 tests across this plugin's two suites stay green. A
890
+ // structural test could tell the two spellings apart. No behaviour test
891
+ // in this suite does.
892
+ //
893
+ // What this does NOT do, stated because overclaiming here has itself been
894
+ // a recurring defect:
895
+ //
896
+ // - it does not stop a request that has been left behind from finishing.
897
+ // A download started on a previous visit still saves its file, because
898
+ // the user did ask for it; what it cannot do is report back.
899
+ // - it cannot stop a component from keeping state of its own and going
900
+ // around the store. See the tripwire test for what is and is not
901
+ // caught, and `.issues/…/design.md` §5.12 for the list of shapes that
902
+ // are known to slip past it.
903
+ // - the id stamp is not something a behaviour test in this suite can
904
+ // see, and that half was measured: drop it from the read and all 189
905
+ // tests stay green (round 9, K1). `maintainStore` is a layout effect,
906
+ // so the render that pairs the old entry with the new id is replaced
907
+ // before the browser paints and before any assertion runs. It is kept
908
+ // for what that render would do on the way past — mount the viewer's
909
+ // iframe against the new deck's URL on the strength of the old deck's
910
+ // open flag, and send the bundle request while the old entry still
911
+ // holds the marks it would be measured against. That half is read off
912
+ // React's commit order, not off a failing test, and it is free: the
913
+ // same comparison is already there for the fetch effect to wait on.
914
+ //
915
+ // `AbortController` was considered and left out: cancelling still leaves
916
+ // an abort rejection to handle, so it moves a check rather than removing
917
+ // one, and adds a second thing to keep in sync.
918
+
919
+ /**
920
+ * What a preview is, to this card, before anything has been asked about
921
+ * it — and what a render reads for a deck whose entry is not in place
922
+ * yet. `id` is null because that is precisely what makes it nobody's.
923
+ */
924
+ var UNASKED = {
925
+ id: null,
926
+ bundle: null,
927
+ verdict: null,
928
+ viewerOpen: false,
929
+ viewerPage: 1,
930
+ download: 'idle',
931
+ attempt: 0,
932
+ seen: { deck: 0, download: 0 },
933
+ }
934
+
935
+ /**
936
+ * A brand new entry for a deck this card has just arrived at.
937
+ *
938
+ * Both lanes start at the arrival's own ticket, which is the whole of how
939
+ * a request from an earlier stay is turned away: it took a smaller number
940
+ * before this arrival took its own.
941
+ */
942
+ function arrivalAt(id, ticket) {
943
+ return Object.assign({}, UNASKED, { id: id, seen: { deck: ticket, download: ticket } })
944
+ }
945
+
946
+ /**
947
+ * The card's monotonic counter: one number per request, and one per
948
+ * arrival.
949
+ *
950
+ * It lives in this factory's closure, so every card the plugin renders
951
+ * draws from the same sequence — which is more than the store needs (each
952
+ * card's numbers are only ever compared with its own) and keeps it out of
953
+ * component state, and therefore out of the render.
954
+ *
955
+ * Arrivals draw from the same sequence as requests on purpose, and it is
956
+ * not a convenience: an arrival's number has to be comparable with the
957
+ * tickets of requests already in flight, or it could not outrank them.
958
+ */
959
+ var lastTicket = 0
960
+ function nextTicket() {
961
+ lastTicket += 1
962
+ return lastTicket
963
+ }
964
+
965
+ /** The card itself: a strip of thumbnails, and a button that opens the viewer. */
966
+ return function PptwisePreviewCard(props) {
967
+ var direct = bundleOf(props.block)
968
+ var previewId = previewIdOf(props.block)
969
+ var pageCount = pageCountOf(props.block)
970
+
971
+ // One entry, stamped with the deck it belongs to — see THE STORE.
972
+ var store = useState(UNASKED)
973
+
974
+ /**
975
+ * Keep the entry pointed at the deck this card is showing.
976
+ *
977
+ * The only place an entry is replaced. Runs on commit, so a render
978
+ * React threw away changes nothing.
979
+ *
980
+ * Arriving at a deck builds a NEW entry, always, even for an id this
981
+ * card was showing a moment ago. That is the point: the previous stay
982
+ * and this one are two different recipients, so the requests still in
983
+ * flight from the previous one have a number to be turned away by (see
984
+ * `arrivalAt`). Nothing is carried over — carrying the download status
985
+ * alone was enough, once, to label a live deck's export `Unavailable`
986
+ * on the strength of the previous deck's 410.
987
+ *
988
+ * The store is left strictly alone when it already holds this deck, so
989
+ * a second run of this effect cannot replace a live entry. The number
990
+ * is taken outside the updater because React may run an updater more
991
+ * than once, and this one has to stay a plain function of `entry`.
992
+ */
993
+ function maintainStore() {
994
+ var arrival = nextTicket()
995
+ store[1](function (entry) {
996
+ return entry.id === previewId ? entry : arrivalAt(previewId, arrival)
997
+ })
998
+ }
999
+ react.useLayoutEffect(maintainStore, [previewId])
1000
+
1001
+ /**
1002
+ * The only way an asynchronous result changes anything.
1003
+ *
1004
+ * `order` is `{ lane, ticket }`: which sequence this write belongs to,
1005
+ * and where in it. UI actions that are their own newest intent take a
1006
+ * fresh ticket, and the two lanes are independent.
1007
+ *
1008
+ * One refusal, doing two jobs — see THE STORE. An older ticket never
1009
+ * overwrites a newer one in the same lane, and an answer from a stay
1010
+ * this card has left is older than the arrival that replaced it.
1011
+ */
1012
+ function commit(change, order) {
1013
+ store[1](function (entry) {
1014
+ if (order.ticket < entry.seen[order.lane]) return entry
1015
+ var seen = Object.assign({}, entry.seen)
1016
+ seen[order.lane] = order.ticket
1017
+ return Object.assign({}, entry, change, { seen: seen })
1018
+ })
1019
+ }
1020
+
1021
+ // Read side: this preview's own entry, or the state of a preview
1022
+ // nobody has asked about yet. Derived from props, so a render decides
1023
+ // nothing and remembers nothing. `mine` is also what the fetch effect
1024
+ // waits on: until the entry is this deck's, a request sent from here
1025
+ // would be measured against the previous deck's marks.
1026
+ var mine = previewId !== null && store[0].id === previewId
1027
+ var deck = mine ? store[0] : UNASKED
1028
+ var verdict = deck.verdict
1029
+ // Every way this deck will not open, under one name. `gone` used to
1030
+ // stand in for all of them, which is why a damaged preview and a
1031
+ // refused request both told the user their deck had been deleted.
1032
+ var settled = isFinal(verdict)
1033
+
1034
+ // Structured payload when the runtime computed one (native-mode,
1035
+ // top-level call); otherwise fetch it by id from the plugin's own
1036
+ // route, which is what Code Mode's sub-calls need.
1037
+ useEffect(
1038
+ function () {
1039
+ // `!mine` is the render between arriving at a deck and
1040
+ // `maintainStore` putting its entry in place. A request sent from
1041
+ // there would be ordered against the *previous* deck's marks, and
1042
+ // the entry it wrote into would be thrown away a moment later — so
1043
+ // it waits, and `mine` is in the dependency list below, which is
1044
+ // what brings it back the moment there is somewhere to write to.
1045
+ if (direct || !mine || deck.bundle) return
1046
+ var order = { lane: 'deck', ticket: nextTicket() }
1047
+ fetch(previewBundleUrl(previewId))
1048
+ .then(function (r) {
1049
+ // A 404 or 410 is an answer, not a failure to get one: the
1050
+ // harness is there and it has never heard of this deck, or
1051
+ // has and cannot serve what is left of it. Swallowing that is
1052
+ // what made a whole card — thumbnails, Open, Download —
1053
+ // disappear out of a reopened transcript without a word.
1054
+ // Anything else this records as `unreachable`, which the card
1055
+ // reads as "no verdict yet" rather than as a dead deck.
1056
+ return verdictOf(r).then(function (answer) {
1057
+ if (answer !== 'alive') {
1058
+ commit({ verdict: answer }, order)
1059
+ return null
1060
+ }
1061
+ return r.json()
1062
+ })
1063
+ })
1064
+ .then(function (b) {
1065
+ if (!b) return
1066
+ commit({ bundle: b, verdict: 'alive' }, order)
1067
+ })
1068
+ .catch(function () {
1069
+ // Not a verdict: the request never landed, or it landed and the
1070
+ // body would not parse. Either way that is a fact about this
1071
+ // attempt and not about the deck, and reporting it as a missing
1072
+ // preview would outlive its cause. The generic row is the
1073
+ // degrade, and every later click — Open, Download — asks the
1074
+ // route again from scratch.
1075
+ commit({ verdict: UNREACHABLE_VERDICT }, order)
1076
+ })
1077
+ },
1078
+ [direct, previewId, mine, deck.bundle, deck.attempt],
1079
+ )
1080
+
1081
+ var bundle = direct || deck.bundle
1082
+
1083
+ /**
1084
+ * Ask before opening, every time.
1085
+ *
1086
+ * The bundle a card is drawing came from one request, and the viewer
1087
+ * is a second one made minutes or days later — a browser tab left open
1088
+ * across a harness restart is enough to put a whole deck's lifetime
1089
+ * between them. Opening on the strength of the first request is how
1090
+ * the viewer landed on `{"error":"unknown preview id"}` rendered as a
1091
+ * document, framed by this card's own Close button. The check is one
1092
+ * loopback request against the route the iframe is about to hit
1093
+ * anyway, and it doubles as a refresh of the strip.
1094
+ *
1095
+ * The ticket is taken here, when the click happens, and it is the only
1096
+ * thing this request carries — see THE STORE. No write asks "is this
1097
+ * still current": arriving anywhere else, including back at this same
1098
+ * deck, takes a higher number, so an answer to a click the user walked
1099
+ * away from lands nowhere. Both halves of that have been a defect here.
1100
+ * First the wrong deck: click Open on A, let the card move to B while
1101
+ * A's request is in flight, and A's success opened the viewer — pointed
1102
+ * at B, which had been asked nothing at all. Then the wrong time: go
1103
+ * A → B → A, and A's first-stay success opened a viewer over the deck
1104
+ * the user had only just come back to.
1105
+ */
1106
+ function openViewer(page) {
1107
+ if (!previewId || settled) return
1108
+ var startAt = page > 1 ? page : 1
1109
+ // Two clicks on Open are two requests, and the one that answers
1110
+ // second is not necessarily the newer one. The ticket is what stops
1111
+ // an older 200 from undoing a newer 410 and reopening the viewer on
1112
+ // a deck the route has just disowned.
1113
+ var order = { lane: 'deck', ticket: nextTicket() }
1114
+ fetch(previewBundleUrl(previewId))
1115
+ .then(function (r) {
1116
+ return verdictOf(r).then(function (answer) {
1117
+ if (answer !== 'alive') {
1118
+ // Recorded, and therefore said out loud: this is the click
1119
+ // that used to do nothing visible at all when the route
1120
+ // refused it, leaving the button in place and the user
1121
+ // clicking it again. Every verdict now reaches the card.
1122
+ commit({ verdict: answer }, order)
1123
+ return null
1124
+ }
1125
+ return r.json()
1126
+ })
1127
+ })
1128
+ .then(function (b) {
1129
+ if (!b) return
1130
+ commit({ bundle: b, verdict: 'alive', viewerOpen: true, viewerPage: startAt }, order)
1131
+ })
1132
+ .catch(function () {
1133
+ // The modal stays shut either way, but only the route's own
1134
+ // verdict retires the deck. A click that never reached the
1135
+ // harness, or reached it and came back unreadable, leaves the
1136
+ // strip live and the button clickable, which is what makes trying
1137
+ // again the obvious next move rather than an impossible one.
1138
+ commit({ verdict: UNREACHABLE_VERDICT }, order)
1139
+ })
1140
+ }
1141
+
1142
+ if (!bundle) {
1143
+ // Nothing to draw and nothing said about why: the card refusing to
1144
+ // invent a deck it does not have.
1145
+ if (verdict === null || verdict === 'alive') return null
1146
+ // Otherwise the tool did run, and the transcript still says how long
1147
+ // the deck was. That number survives a deleted preview on purpose:
1148
+ // the host half keeps no gravestone on disk, so the tool's own
1149
+ // summary line, still sitting in the log, is the only evidence this
1150
+ // card can still read that nine pages once existed here. A card that vanishes instead is the one thing worse
1151
+ // than a card reporting a problem: the user is left doubting the deck
1152
+ // was ever generated.
1153
+ //
1154
+ // The Retry button appears only for the one verdict a retry can
1155
+ // change. `refused` and the final verdicts get the same row without
1156
+ // it, because a button that re-sends an identical request to a door
1157
+ // that just closed is a promise the card cannot keep.
1158
+ return h(
1159
+ 'div',
1160
+ { style: { display: 'flex', flexDirection: 'column', gap: 4, padding: '2px 0 6px' } },
1161
+ h(
1162
+ 'div',
1163
+ { style: { display: 'flex', alignItems: 'center', gap: 10, fontSize: 12, color: COLORS.dim } },
1164
+ h('span', { style: { color: COLORS.text, fontWeight: 600 } }, 'deck'),
1165
+ settled ? h(VerdictBadge, { verdict: verdict }) : null,
1166
+ pageCount !== null ? h('span', null, pageCount + ' pages') : null,
1167
+ isRetryable(verdict)
1168
+ ? h(
1169
+ 'button',
1170
+ {
1171
+ onClick: function () {
1172
+ commit({ attempt: deck.attempt + 1 }, { lane: 'deck', ticket: nextTicket() })
1173
+ },
1174
+ title: hintFor(verdict),
1175
+ style: {
1176
+ marginLeft: 'auto',
1177
+ font: 'inherit',
1178
+ fontSize: 12,
1179
+ padding: '3px 10px',
1180
+ borderRadius: 7,
1181
+ border: '1px solid ' + COLORS.line,
1182
+ background: 'transparent',
1183
+ color: COLORS.text,
1184
+ cursor: 'pointer',
1185
+ },
1186
+ },
1187
+ 'Retry',
1188
+ )
1189
+ : null,
1190
+ ),
1191
+ h(VerdictNote, { verdict: verdict }),
1192
+ )
1193
+ }
1194
+
1195
+ var pages = viewablePages(bundle)
1196
+ if (pages.length === 0) return null
1197
+
1198
+ var slide = bundle.slide || { width: 1280, height: 720 }
1199
+ var findingCount = bundle.pages.reduce(function (n, p) {
1200
+ return n + ((p.findings && p.findings.length) || 0)
1201
+ }, 0)
1202
+
1203
+ return h(
1204
+ 'div',
1205
+ { style: { display: 'flex', flexDirection: 'column', gap: 8, padding: '2px 0 6px' } },
1206
+ h(
1207
+ 'div',
1208
+ { style: { display: 'flex', alignItems: 'center', gap: 10, fontSize: 12, color: COLORS.dim } },
1209
+ h('span', { style: { color: COLORS.text, fontWeight: 600 } }, bundle.title || 'deck'),
1210
+ // The deck is on screen and the route has already said it cannot
1211
+ // serve it any more. Both halves are true, and the thumbnails are
1212
+ // the half worth keeping — they are the deck, not a picture of a
1213
+ // button. What goes is the promise that clicking them leads
1214
+ // anywhere.
1215
+ settled ? h(VerdictBadge, { verdict: verdict }) : null,
1216
+ // The deck still has placeholder pages. The export goes out
1217
+ // anyway (see `--draft` in preview-tool.js's `execute`), so this
1218
+ // badge is what keeps that from being a silent substitution: the
1219
+ // user is looking at unfinished work and the file they save says
1220
+ // so too.
1221
+ bundle.draft
1222
+ ? h(
1223
+ 'span',
1224
+ {
1225
+ title: 'Some pages are still unfilled placeholders — the export is labelled a draft',
1226
+ style: {
1227
+ border: '1px solid ' + COLORS.line,
1228
+ borderRadius: 5,
1229
+ padding: '0 6px',
1230
+ fontWeight: 600,
1231
+ textTransform: 'uppercase',
1232
+ fontSize: 10,
1233
+ letterSpacing: '0.04em',
1234
+ },
1235
+ },
1236
+ 'draft',
1237
+ )
1238
+ : null,
1239
+ h('span', null, bundle.pages.length + ' pages'),
1240
+ findingCount > 0 ? h('span', null, findingCount + ' audit findings') : null,
1241
+ // No viewer without an id, and none for an id the route has
1242
+ // already disowned: the full-size view is a route keyed by it. A
1243
+ // button that opened an empty frame would be worse than an absent
1244
+ // one, and the thumbnails below go inert for the same reason.
1245
+ previewId && !settled
1246
+ ? h(
1247
+ 'button',
1248
+ {
1249
+ onClick: function () { openViewer(1) },
1250
+ style: {
1251
+ marginLeft: 'auto',
1252
+ font: 'inherit',
1253
+ fontSize: 12,
1254
+ padding: '3px 10px',
1255
+ borderRadius: 7,
1256
+ border: '1px solid ' + COLORS.line,
1257
+ background: 'transparent',
1258
+ color: COLORS.text,
1259
+ cursor: 'pointer',
1260
+ },
1261
+ },
1262
+ 'Open',
1263
+ )
1264
+ : null,
1265
+ previewId
1266
+ ? h(ExportButton, {
1267
+ previewId: previewId,
1268
+ name: bundle.title,
1269
+ draft: bundle.draft,
1270
+ verdict: verdict,
1271
+ status: deck.download,
1272
+ onStatus: function (next, order) { commit({ download: next }, order) },
1273
+ style: {
1274
+ font: 'inherit',
1275
+ fontSize: 12,
1276
+ padding: '3px 10px',
1277
+ borderRadius: 7,
1278
+ border: '1px solid ' + COLORS.line,
1279
+ background: 'transparent',
1280
+ color: COLORS.text,
1281
+ cursor: 'pointer',
1282
+ },
1283
+ })
1284
+ : null,
1285
+ ),
1286
+ h(
1287
+ 'div',
1288
+ { style: { display: 'flex', gap: 8, overflowX: 'auto', paddingBottom: 2 } },
1289
+ stripPages(pages).map(function (page, i) {
1290
+ return h(
1291
+ 'div',
1292
+ { key: page.id || i, style: { flex: '0 0 auto', width: 132 } },
1293
+ h(Frame, {
1294
+ page: page,
1295
+ prefix: 'pft' + i + '-',
1296
+ width: slide.width,
1297
+ height: slide.height,
1298
+ title: (page.type || 'page') + ' ' + pageNumberOf(page, i),
1299
+ // Opens the viewer on this page. Clicking page 3 and
1300
+ // landing on page 1 is the kind of small lie that makes a
1301
+ // strip feel decorative.
1302
+ onClick:
1303
+ previewId && !settled
1304
+ ? function () { openViewer(pageNumberOf(page, i)) }
1305
+ : undefined,
1306
+ }),
1307
+ )
1308
+ }),
1309
+ ),
1310
+ settled ? h(VerdictNote, { verdict: verdict }) : null,
1311
+ // No id comparison here, and none needed: `deck` is this preview's own
1312
+ // entry or the unasked state, so a viewer left open on another deck
1313
+ // is not something this can read.
1314
+ deck.viewerOpen && previewId && !settled
1315
+ ? h(Modal, {
1316
+ src: previewHtmlUrl(previewId, deck.viewerPage),
1317
+ previewId: previewId,
1318
+ name: bundle.title,
1319
+ draft: bundle.draft,
1320
+ downloadStatus: deck.download,
1321
+ onDownloadStatus: function (next, order) { commit({ download: next }, order) },
1322
+ onClose: function () {
1323
+ commit({ viewerOpen: false, viewerPage: 1 }, { lane: 'deck', ticket: nextTicket() })
1324
+ },
1325
+ })
1326
+ : null,
1327
+ )
1328
+ }
1329
+ }
1330
+
1331
+ function registerCard(ctx) {
1332
+ // `slots` is a declared dependency (see `exports.inject` below), so
1333
+ // cordis does not apply this plugin until the service exists. The guard
1334
+ // stays as a belt-and-braces check, but it must never be the thing that
1335
+ // silently makes this card a no-op — which is exactly what happened
1336
+ // when `inject` was empty: apply ran before the slot registry was up,
1337
+ // this returned quietly, and the tool rendered in the generic row with
1338
+ // no sign anything had failed.
1339
+ if (!ctx.slots || typeof ctx.slots.inject !== 'function') {
1340
+ console.error('[pptwise] preview card skipped: no slot registry on this context')
1341
+ return
1342
+ }
1343
+ var react
1344
+ try {
1345
+ react = require('react')
1346
+ } catch (error) {
1347
+ console.error('[pptwise] preview card skipped: ' + error)
1348
+ return
1349
+ }
1350
+ var Card = PreviewCard(react)
1351
+ ctx.slots.inject('tool.call.toolview', function* () {
1352
+ yield ctx.slots.register({ name: 'tool.call.toolview', key: TOOL_NAME }, Card)
1353
+ })
1354
+ }
1355
+
1356
+ function apply(ctx) {
1357
+ // A card that fails to register is a console line, not a dead turn —
1358
+ // the generic tool row still renders the call, same posture the host
1359
+ // half takes for its own registrations.
1360
+ try {
1361
+ registerCard(ctx)
1362
+ } catch (error) {
1363
+ console.error('[pptwise] preview card registration skipped: ' + error)
1364
+ }
1365
+ }
1366
+
1367
+ exports.apply = apply
1368
+ // Declared, not sniffed: cordis holds this plugin until the slot registry
1369
+ // is up. The shipped `ui-skill` registrant declares the same service for
1370
+ // the same reason.
1371
+ exports.inject = ['slots']
1372
+ // Exposed for this repo's tests only; not part of the plugin contract.
1373
+ exports.__testing = {
1374
+ bundleOf: bundleOf,
1375
+ previewIdOf: previewIdOf,
1376
+ pageCountOf: pageCountOf,
1377
+ verdictOf: verdictOf,
1378
+ isRetryable: isRetryable,
1379
+ isFinal: isFinal,
1380
+ isZip: isZip,
1381
+ ROUTE_HEADER: ROUTE_HEADER,
1382
+ MISSING_HINT: MISSING_HINT,
1383
+ UNREACHABLE_HINT: UNREACHABLE_HINT,
1384
+ REFUSED_HINT: REFUSED_HINT,
1385
+ DAMAGED_HINT: DAMAGED_HINT,
1386
+ namespaceIds: namespaceIds,
1387
+ viewablePages: viewablePages,
1388
+ stripPages: stripPages,
1389
+ hasMarkup: hasMarkup,
1390
+ pageNumberOf: pageNumberOf,
1391
+ previewHtmlUrl: previewHtmlUrl,
1392
+ previewBundleUrl: previewBundleUrl,
1393
+ STRIP_PAGES: STRIP_PAGES,
1394
+ TOOL_NAME: TOOL_NAME,
1395
+ }
1396
+ return module.exports
1397
+ },
1398
+ })