@openpresentation/opf-editor 0.24.0 → 0.24.1
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/README.md +152 -2
- package/dist/agent-handoff-dialog.d.ts +45 -0
- package/dist/agent-handoff-dialog.js +182 -0
- package/dist/agent-handoff.d.ts +33 -0
- package/dist/agent-handoff.js +95 -0
- package/dist/block-controls.js +8 -8
- package/dist/export-dom.js +57 -0
- package/dist/export-menu.d.ts +125 -0
- package/dist/export-menu.js +392 -0
- package/dist/json-editor.d.ts +7 -0
- package/dist/json-editor.js +67 -7
- package/dist/json-source.js +2 -1
- package/dist/slide-navigator.d.ts +44 -0
- package/dist/slide-navigator.js +191 -0
- package/dist/source-lines.js +206 -0
- package/dist/source-pane.d.ts +88 -0
- package/dist/source-pane.js +664 -0
- package/dist/styles.css +3339 -0
- package/dist/thumbnail-cache.d.ts +25 -0
- package/dist/thumbnail-cache.js +50 -0
- package/dist/thumbnail-strip.d.ts +50 -0
- package/dist/thumbnail-strip.js +310 -0
- package/package.json +44 -7
package/README.md
CHANGED
|
@@ -85,6 +85,40 @@ Run `npm run test:json` and `npm run test:json-browser` for the focused model an
|
|
|
85
85
|
|
|
86
86
|
Version 0.7.0 uses core 0.10.0 and renderer 0.8.0. Code source/metadata edits preserve line endings, literal tabs and cancellation/undo through accepted trace targets. Committed preview uses shared quote/code geometry; the active source textarea uses native browser editing. `paginateSlide` records a one-page readability-policy change in undo history, and an unchanged repeat is a no-op. Coordinated examples use PPTX 0.8.0 for exact code source/metadata recovery. Native formatting, font theme and geometry are not restored; quotes still import as editable text blocks with structure and readability-policy loss.
|
|
87
87
|
|
|
88
|
+
## Source pane (RR-98)
|
|
89
|
+
|
|
90
|
+
`createSourcePane` from `@openpresentation/opf-editor/source-pane` is the OPF source as JSON, YAML or Markdown, with the file name, the format menu, Format, Copy and Download, line numbers and the findings under the code. It is plain DOM and builds on `mountJsonCodeEditor`, `exact-source` and the field menu of `/json-options`; it adds no second editor and makes no network request.
|
|
91
|
+
|
|
92
|
+
```js
|
|
93
|
+
import { createSourcePane } from '@openpresentation/opf-editor/source-pane';
|
|
94
|
+
const pane = createSourcePane(container, {
|
|
95
|
+
editor, // the editor session: every edit goes through editor.applyPatch
|
|
96
|
+
name: 'deck', // shown as deck.opf.json; spaces and slashes become "-"
|
|
97
|
+
onRename: name => save(name),
|
|
98
|
+
format: 'json', // 'json' | 'yaml' | 'md'
|
|
99
|
+
onFormatChange: (format, previous) => remember(format),
|
|
100
|
+
source: authoredJson, // optional: the JSON bytes to keep (formatting, escapes, key order)
|
|
101
|
+
findings: report.findings, // or getFindings: () => validate(...).findings; default: the session's validation
|
|
102
|
+
onFindingSelect: (finding, line) => reveal(finding.path),
|
|
103
|
+
onCopy: file => clipboard.write(file.text), // default: navigator.clipboard.writeText
|
|
104
|
+
onDownload: file => saveAs(file.filename, file.text), // default: a Blob link named <name>.opf.<ext>
|
|
105
|
+
density: 'comfortable', // 'touch' makes the controls 40px
|
|
106
|
+
readOnly: false,
|
|
107
|
+
});
|
|
108
|
+
pane.setFormat('yaml'); pane.focusPath('/slides/1/title'); pane.getText(); pane.destroy();
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Methods: `setFindings(list | null)`, `setFormat(format)`, `focusPath(pointer)` (moves the cursor to the line of a JSON Pointer in any format; exact for JSON, best effort for YAML and Markdown), `getText()`, `refresh()`, `setName`, `setDensity`, `setReadOnly` and `destroy()`. A finding is `{ path, message, severity, line? }` (core `Finding` objects fit). Rows show the path in mono and the line at the right; a click moves the cursor and calls `onFindingSelect`.
|
|
112
|
+
|
|
113
|
+
- **JSON is canonical** and keeps the author's bytes: a YAML or Markdown edit rewrites only the changed JSON tokens (`exact-source`), and Undo restores the exact spelling.
|
|
114
|
+
- **YAML and Markdown** are core's browser-safe converters, `@openpresentation/opf/yaml` and `/markdown`. Switching format writes the session's document in the new format. An edit parses back to the document and reaches the session as one `replace` patch, after a short delay (`applyDelay`, default 250 ms) so one burst of typing is one undo step. A syntax error, or an OPF rule the session refuses, is listed with its line and never reaches the document; Undo, Redo and outside edits replace a draft that did not parse.
|
|
115
|
+
- **Nothing is lost silently.** Markdown writes what its dialect has no syntax for (a `design`, region keys, nested groups) as YAML in `opf-slide` and `opf-block` fences; each such part is a warning finding with its JSON Pointer and reason. Comments in YAML cannot survive a rewrite from the document: Format and outside edits drop them and say how many. A document that is not valid OPF cannot be written as YAML or Markdown; the pane stays in JSON and says so.
|
|
116
|
+
- **Accessibility.** The format button (`aria-label="Source format"`) opens a `role="menu"` of `menuitemradio` items: Arrow keys, Home, End, Enter, Escape (focus returns to the button) and Tab. The file name is a button that becomes a text input; Enter or Escape commits. Controls show a 2px `--opf-ui-ring` focus outline.
|
|
117
|
+
- **Styles.** `src/styles/source-pane.css` uses only `--opf-ui-*` tokens under the `opf-source-` prefix. It ships in the editor's `styles.css` (`@openpresentation/opf-editor/styles.css`), which also defines the tokens.
|
|
118
|
+
- **Dependency.** `@codemirror/lang-yaml` (MIT) highlights YAML; Markdown uses a small stream tokenizer built on `@codemirror/language`, so no Markdown, HTML, CSS or JavaScript grammar is added.
|
|
119
|
+
|
|
120
|
+
Icons are inline paths from [Lucide](https://lucide.dev) (ISC license). The harness is `examples/source-pane.html` (built by `npm run build:playground`); `npm run test:source-pane-browser` drives it.
|
|
121
|
+
|
|
88
122
|
## Scope
|
|
89
123
|
|
|
90
124
|
- Package: `@openpresentation/opf-editor`
|
|
@@ -478,6 +512,38 @@ OPF 0.19 layouts have a grid (`areas`) and named content regions. The slide mana
|
|
|
478
512
|
|
|
479
513
|
`@openpresentation/opf-editor/outline` and `/outline-view` edit the deck as an outline: a row per slide title, subtitle, text paragraph and list item, and a read-only row for content that is not text (a chart, a table, an image) or text that carries formatting, so nothing is flattened away. Typing commits as one change when the row loses focus or on Enter. Enter adds a line (after a title: a slide), Alt+Up and Alt+Down move a bullet with the bullets nested under it, or a slide with its text, Alt+Shift+Right demotes and Alt+Shift+Left promotes. Demoting a bullet nests it (`level`); promoting a top-level bullet turns it into a new slide that takes the bullets after it; demoting a plain slide makes it a bullet of the slide before it (refused, with the reason, when that would drop content such as notes, blocks or a design). Backspace on an empty line removes it. Tab keeps moving focus, so the outline is no keyboard trap.
|
|
480
514
|
|
|
515
|
+
## Slide navigator and thumbnail strip (RR-98)
|
|
516
|
+
|
|
517
|
+
Two framework-neutral DOM factories for the slide row under the canvas. Both follow the editor session on their own, take `density: "comfortable" | "touch"` (32px or 40px controls) and style themselves with the `--opf-ui-*` tokens (`src/styles/navigator.css` and `thumbnails.css`, part of `styles.css`; no fallbacks). Icons are the Lucide ChevronLeft and ChevronRight paths (ISC license), inlined; nothing loads from a network.
|
|
518
|
+
|
|
519
|
+
`@openpresentation/opf-editor/slide-navigator`: previous and next buttons around a mono counter ("01 / 03"; both numbers pad to the width of the slide count, so "001 / 120" from 100 slides). The buttons disable at the ends, and focus moves to the other one when the pressed one disables. With the navigator focused, Left, Right, Up, Down, Home and End move; `bindKeys(target)` adds page-level Left and Right that ignore typing in inputs, selects and editors (CodeMirror, contenteditable), menus, dialogs, modified keys and events another handler already took.
|
|
520
|
+
|
|
521
|
+
```js
|
|
522
|
+
import { createSlideNavigator } from "@openpresentation/opf-editor/slide-navigator";
|
|
523
|
+
const nav = createSlideNavigator(document.querySelector("#slide-nav"), {
|
|
524
|
+
editor, slideIndex: current, density: "comfortable",
|
|
525
|
+
onChange: (index) => { current = index; redraw(); }, // the user moved (also when a shrinking deck clamps the index)
|
|
526
|
+
labels: { previous: "Previous slide", next: "Next slide" },
|
|
527
|
+
});
|
|
528
|
+
nav.bindKeys(document); // returns an unbinder; destroy() removes every listener
|
|
529
|
+
nav.setSlide(3); // show a slide the host chose; no onChange
|
|
530
|
+
nav.refresh(); // the count follows the editor by itself; pass `presentation` (an object or a function) instead of `editor` for a static deck
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
`@openpresentation/opf-editor/thumbnail-strip`: a `nav` of real slide renders. Each item shows a mono index and the slide title ("Untitled" when it has none); the current one has `aria-current="true"`, an accent ring and is scrolled into view (the strip scrolls itself, never the page). It is horizontal by default; `orientation: "vertical"` stacks it. Arrow keys move focus along the strip (one tab stop), Enter or Space chooses. Thumbnails use the renderer, catalogs and fonts handle the canvas uses (`fonts`, `renderOptions`), wait for the faces the document needs, and are lazy: the items near the viewport draw first through IntersectionObserver (everything draws at once without it, or with `lazy: false`).
|
|
534
|
+
|
|
535
|
+
```js
|
|
536
|
+
import { createThumbnailStrip } from "@openpresentation/opf-editor/thumbnail-strip";
|
|
537
|
+
const strip = createThumbnailStrip(document.querySelector("#thumb-strip"), {
|
|
538
|
+
editor, fonts, renderOptions: { catalogs }, slideIndex: current, size: 128,
|
|
539
|
+
onSelect: (index) => { current = index; redraw(); },
|
|
540
|
+
});
|
|
541
|
+
strip.setSlide(current); // follow the host
|
|
542
|
+
strip.refresh({ clear: true }); // redraw now; clear drops the cache (after the host loaded fonts)
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
Thumbnails are cached by `@openpresentation/opf-editor/thumbnail-cache` (`createThumbnailCache({ toSvg, getOptions })`, promoted from the playground example with the same behavior). An entry is keyed by the slide's index, the slide count and the slide's own content, so an edit renders only the slides it changed, in one `toSvg` call that resolves the deck once. Deck metadata, section labels, registered catalogs and render settings are the cache's context: a change to any of them drops every entry, because inherited furniture and design change other slides too. `prepare(deck, indices)` renders only the listed slides, which is how the strip stays lazy. Share one cache between views with the strip's `cache` option.
|
|
546
|
+
|
|
481
547
|
## Data grid (RR-24)
|
|
482
548
|
|
|
483
549
|
`@openpresentation/opf-editor/data-grid` mounts a spreadsheet-like grid for a chart's inline data (the first column is the categories, each further column a series; an empty cell is a gap, never 0) and for tables (rich and styled cells, merged cells drawn with their spans). Cells edit from the keyboard (arrows, Enter, F2, Tab), paste TSV or CSV from Excel and Sheets, copy out as TSV, and rows and columns insert, delete and move, sort (stable and typed) and, for charts, swap. Numbers are read in one stated number format, never guessed; text that is not a number is refused inline with the reason. Every edit is one undoable patch, so the preview redraws from the session events.
|
|
@@ -610,6 +676,47 @@ Select a picture on the canvas and use **Crop picture** (or `canvas.cropImage(pa
|
|
|
610
676
|
|
|
611
677
|
At 900px and narrower the playground is one screen: the slide strip on top, the slide in the middle, and a bottom bar (Edit, Design, Find, Undo, Redo) whose Edit and Design open the inspector as a **bottom sheet** that leaves the slide in view above it; the header buttons sit behind More; nothing scrolls sideways down to 320px. On a touch screen the canvas grows each text block's selection target to about 44px (up to 24 slide units each way), a tap on text enters editing at that point, `touch-action: manipulation` removes the double-tap delay, and the field being typed in is scrolled above the on-screen keyboard (visual viewport). The inline field mirrors the slide text, which is smaller than 16px on a phone; iOS would zoom into it on focus, so the playground sets `maximum-scale=1` on the viewport only while a slide text field has the focus and restores it after (embedding hosts should do the same, or keep their own fields at 16px). `test/mobile-browser.mjs` drives six emulated devices (iPhone SE, iPhone 14 Pro, Pixel 7, a 320px Android, an iPad and an Android tablet: Chromium with each device's viewport, scale factor, touch and user agent) through the layout, the sheet, tap-to-type, find and replace by touch and a crop by touch; it does not replace a check on a real iOS Safari.
|
|
612
678
|
|
|
679
|
+
## Styling and theming (RR-98)
|
|
680
|
+
|
|
681
|
+
The editor panels ship one stylesheet. Import it once, next to your own CSS:
|
|
682
|
+
|
|
683
|
+
```js
|
|
684
|
+
import "@openpresentation/opf-editor/styles.css";
|
|
685
|
+
```
|
|
686
|
+
|
|
687
|
+
`npm run build` writes `dist/styles.css`: the token layer (`src/styles/tokens.css`) first, then every other `src/styles/*.css` in alphabetical order. It is plain CSS with no preprocessor, no `@import` and no `url()`: nothing is fetched. The package bundles no fonts either; load Inter and Geist Mono yourself (or leave the fallbacks: `system-ui` and `ui-monospace`). The panels that existed before it (design controls, review, slide manager, find, fill template, numbering, chart options, data grid, crop, the custom layout editor, the JSON editor and the restore banner) are styled with the tokens and nothing else. The find, review, crop and data-grid components still inject a small fallback stylesheet of their own, so they look as before when you do not import `styles.css`; with it, its rules win whichever `<style>` comes last.
|
|
688
|
+
|
|
689
|
+
**Tokens.** Every rule reads only `--opf-ui-*` custom properties, defined on `:root` and on `.opf-ui`, light by default. Override any of them on `:root`, on a wrapper with the class `opf-ui`, or on one component's container:
|
|
690
|
+
|
|
691
|
+
```css
|
|
692
|
+
.my-editor.opf-ui {
|
|
693
|
+
--opf-ui-accent: hsl(262 60% 38%);
|
|
694
|
+
--opf-ui-accent-strong: hsl(262 60% 38%);
|
|
695
|
+
--opf-ui-ring: hsl(262 60% 38%);
|
|
696
|
+
--opf-ui-radius: 4px;
|
|
697
|
+
}
|
|
698
|
+
```
|
|
699
|
+
|
|
700
|
+
| Group | Tokens |
|
|
701
|
+
|---|---|
|
|
702
|
+
| Surfaces | `--opf-ui-bg`, `--opf-ui-surface`, `--opf-ui-muted`, `--opf-ui-muted-2` |
|
|
703
|
+
| Text | `--opf-ui-fg`, `--opf-ui-muted-fg` |
|
|
704
|
+
| Accent | `--opf-ui-accent`, `--opf-ui-accent-fg`, `--opf-ui-accent-strong`, `--opf-ui-ring` |
|
|
705
|
+
| Lines | `--opf-ui-border`, `--opf-ui-border-strong` |
|
|
706
|
+
| Status | `--opf-ui-danger`, `--opf-ui-warn`, `--opf-ui-success` |
|
|
707
|
+
| Code | `--opf-ui-code-bg`, `--opf-ui-code-border` |
|
|
708
|
+
| Type | `--opf-ui-font` (`Inter, system-ui, sans-serif`), `--opf-ui-mono` (`"Geist Mono", ui-monospace, Menlo, monospace`) |
|
|
709
|
+
| Shape | `--opf-ui-radius-sm` 4px, `--opf-ui-radius` 6px, `--opf-ui-radius-md` 8px, `--opf-ui-radius-lg` 12px |
|
|
710
|
+
| Size, depth, motion | `--opf-ui-control-h` 32px, `--opf-ui-shadow-pop` (menus, popovers and dialogs only), `--opf-ui-ease` `120ms ease` (color and border only) |
|
|
711
|
+
|
|
712
|
+
Put the class `opf-ui` on an element that wraps a mounted component to get the base rules: the font, the text color, `box-sizing: border-box`, a 2px `--opf-ui-ring` outline with a 2px offset on `:focus-visible`, and no transitions under `prefers-reduced-motion: reduce`.
|
|
713
|
+
|
|
714
|
+
**Dark mode.** The dark values apply under `.dark .opf-ui`, `.opf-ui.dark`, `[data-theme="dark"]` (on the root or any ancestor) and `:root.dark`. The host decides when: toggle `class="dark"` or `data-theme="dark"` on `<html>`, or on the wrapper. The stylesheet does not read `prefers-color-scheme`, so a host that wants to follow the system setting sets the attribute from `matchMedia("(prefers-color-scheme: dark)")`. Dark also sets `color-scheme: dark`, so native controls follow.
|
|
715
|
+
|
|
716
|
+
**Touch.** `data-density="touch"` on an `.opf-ui` element raises `--opf-ui-control-h` from 32px to 40px for everything inside it (the mobile design). The new components take the same value as a `density: "comfortable" | "touch"` option.
|
|
717
|
+
|
|
718
|
+
The example page `examples/playground.html` loads this stylesheet and keeps only its own page layout in `examples/playground.css`. The JSON editor (CodeMirror) ships no CSS of its own; give its container the class `opf-json` or `opf-code`, or put it inside an `.opf-ui`, to style it. The restore banner is a host element: give it the class `restore-banner`.
|
|
719
|
+
|
|
613
720
|
## Review panel (RR-29)
|
|
614
721
|
|
|
615
722
|
`@openpresentation/opf-editor/review-panel` mounts the findings of core's `validate(presentation)` next to the document, grouped by category (format, references, policy, accessibility, layout, content): contrast, text that does not fit, missing alt text, reading order, fonts, links and more (the same rules as `opf validate`; see the [validate guide](https://github.com/OpenPresentation/opf/blob/main/docs/validate.md)). A finding is core's `Finding` (rule id, severity, category, JSON Pointer path, message, `fixes`), so a hosted reviewer that returns a `FindingReport`, such as pptx.dev's review, lists its findings in the same panel.
|
|
@@ -636,7 +743,7 @@ Findings show a severity word, the slide, the rule id and, for a hosted reviewer
|
|
|
636
743
|
|
|
637
744
|
## PDF, PNG and SVG downloads (RR-23)
|
|
638
745
|
|
|
639
|
-
`convert(deck, { format })` from `@openpresentation/opf-editor/export` turns the deck into files in the page, next to the PowerPoint export; the
|
|
746
|
+
`convert(deck, { format })` from `@openpresentation/opf-editor/export` turns the deck into files in the page, next to the PowerPoint export; the [Export menu](#export-menu-and-agent-handoff-rr-98) is a thin host over it. It has the name and the result of core's in-memory `convert` (OPF 0.18, RR-73): `{ files, findings }`, core's `ConvertResult`.
|
|
640
747
|
|
|
641
748
|
```js
|
|
642
749
|
import { convert } from "@openpresentation/opf-editor/export";
|
|
@@ -679,6 +786,49 @@ Catalog controls initialize an absent deck or slide `design` for known theme, co
|
|
|
679
786
|
|
|
680
787
|
Optional downloads can be deferred until requested: dynamically import `@openpresentation/opf-editor/export` inside the download handler. For a PowerPoint download, dynamically import `@openpresentation/opf-pptx` inside that handler too; the `/export` helper covers PDF, PNG and SVG. This defers conversion code, while canvas rendering and font measurement still have their own startup costs.
|
|
681
788
|
|
|
789
|
+
## Export menu and agent handoff (RR-98)
|
|
790
|
+
|
|
791
|
+
Three framework-neutral DOM factories, plain DOM like the other modules (no framework, no network, no fonts or CSS fetched at runtime). Their CSS is `src/styles/export-menu.css` and `src/styles/handoff.css`; it uses only the `--opf-ui-*` tokens (each with its light value as a fallback) and the class prefixes `opf-export-*` and `opf-handoff-*`.
|
|
792
|
+
|
|
793
|
+
```js
|
|
794
|
+
import { createExportMenu } from "@openpresentation/opf-editor/export-menu";
|
|
795
|
+
|
|
796
|
+
const menu = createExportMenu(document.querySelector("#export"), {
|
|
797
|
+
getDeck: () => editor.presentation, // required: called on every export; the menu exports a structuredClone
|
|
798
|
+
beforeExport: () => !canvas || canvas.commit(), // optional: return false to stop (commit an open canvas edit first)
|
|
799
|
+
fonts, // the one fonts handle the preview uses
|
|
800
|
+
catalogs: () => editor.catalogs, // an array, or a function that returns one
|
|
801
|
+
renderOptions,
|
|
802
|
+
exportPptx: async (deck, { signal, onProgress }) => toPptx(deck, { ... }), // PowerPoint, injected (see below)
|
|
803
|
+
onDownload: ({ blob, fileName, format }) => save(blob, fileName), // default: a Blob link
|
|
804
|
+
onAgentHandoff: ({ document, target }) => ..., // default: the dialog below
|
|
805
|
+
items: { slides: ["pdf", "png", "svg", "pptx"], source: ["json", "yaml", "markdown"], agent: true }, // hide groups or formats
|
|
806
|
+
mode: "menu", // "sheet": the mobile bottom sheet (scrim, drag handle, Close)
|
|
807
|
+
density: "comfortable", // "touch": 40px controls (sheet rows 44px)
|
|
808
|
+
disabled: false, // true or a reason string: disables the slide exports
|
|
809
|
+
});
|
|
810
|
+
menu.setDisabled("Showing the last valid slides. Fix the JSON to resume editing and downloads."); // or menu.setDisabled(true)
|
|
811
|
+
menu.setMode(matchMedia("(max-width: 900px)").matches ? "sheet" : "menu");
|
|
812
|
+
```
|
|
813
|
+
|
|
814
|
+
- **The menu.** A button (Download icon, "Export", ChevronDown) and a `role="menu"` in three groups: **Slides** (PowerPoint (.pptx), PDF, SVG slides, PNG images, each with a hint), **OPF source** (JSON, YAML, Markdown) and **For an agent** (Agent handoff). Items are `role="menuitem"` buttons; ArrowUp, ArrowDown, Home and End move between them, Escape closes and returns focus to the button, Tab closes and moves on. In `mode: "sheet"` the same menu is a `role="dialog"` bottom sheet that traps Tab, closes with Escape, Close or a tap on the scrim, and returns focus.
|
|
815
|
+
- **Slides.** PDF, PNG and SVG run this package's `convert` (above) with your `fonts`, `catalogs` and `renderOptions`; PNG and SVG arrive as one ZIP for several slides. `convertOptions` passes more `convert` options (`scale`, `includeHidden`, `converters`, ...). The menu imports the renderer through `/export`, so a host that bundles it needs `@openpresentation/opf-render` (an optional peer). **PowerPoint lives in `@openpresentation/opf-pptx`, which the editor does not import:** you inject `exportPptx(deck, { signal, onProgress })` and return the bytes (or `{ bytes, fileName?, warnings? }`). Load the fonts the deck needs first, as `examples/export-controls.js` does with `fontGate`. The PPTX item is offered only when `exportPptx` is given.
|
|
816
|
+
- **OPF source.** JSON is the document as "Save OPF" writes it (every catalog record the deck uses is embedded). YAML and Markdown are core's `toYaml` and `toMarkdown` (`@openpresentation/opf/yaml` and `/markdown`, loaded on first use); parts the Markdown dialect has no syntax for are written as YAML fences and reported as notes. File names are `name.opf.json`, `name.opf.yaml` and `name.opf.md`.
|
|
817
|
+
- **Progress, cancel, errors.** While an export runs a status panel under the button shows `onProgress` (a progress bar and the message) and a Cancel button that aborts an `AbortController` (`menu.cancel()` does the same, and `destroy()` too). Success, failure and cancellation each give a status line (`name.pdf · 2 slides · 38 KB · 1 note to review`), announced through an `aria-live="polite"` region; the converter's notes (and your PowerPoint `warnings`, core's YAML and Markdown notes, catalog references that did not resolve) are listed until dismissed. A result without notes hides itself after four seconds. `onStatus({ state, message, notes })` mirrors every change for a host footer, and `onExport({ blob, fileName, format, notes })` fires after a file was handed on.
|
|
818
|
+
- **Disabled.** `setDisabled(reason)` marks the slide exports `aria-disabled` with the reason as their `title` (`true` gives "Showing the last valid slides. Fix the JSON to resume editing and downloads."); source exports and the handoff stay available. While an export runs the others are disabled too.
|
|
819
|
+
|
|
820
|
+
```js
|
|
821
|
+
import { buildAgentHandoff } from "@openpresentation/opf-editor/agent-handoff";
|
|
822
|
+
import { createAgentHandoffDialog } from "@openpresentation/opf-editor/agent-handoff-dialog";
|
|
823
|
+
|
|
824
|
+
const { markdown, lines } = buildAgentHandoff(deck, { target: "pptx", fileName: "deck.opf.json" }); // "pptx" | "pdf" | "svg"
|
|
825
|
+
const dialog = createAgentHandoffDialog(document.body, { document: deck, target: "pdf", onClose: () => {} });
|
|
826
|
+
```
|
|
827
|
+
|
|
828
|
+
`buildAgentHandoff(document, { target, fileName, specUrl, agentGuideUrl })` is a pure function: a Markdown prompt for a coding agent (`# Task`, `## Setup`, `## Steps`, the Spec and Agent guide links, and `## deck.opf.json` with the exact `JSON.stringify(document, null, 2)` in a fenced block that no string in the deck can close). The agent installs `@openpresentation/cli` with `opf-render` (and `opf-pptx` for PowerPoint), validates the deck with `npx opf validate deck.opf.json` (exit 1 and the JSON path of each error for an invalid deck) and renders with `npx opf convert deck.opf.json deck.pptx` (or `deck.pdf`, `deck.svg`, one file per slide); these are the published `opf` command, run against the npm releases in a clean folder when written. The renderer's `@expo-google-fonts/*` packages are optional peers: the first conversion exits 2 with `peer-not-installed` and the exact `npm install` command, which the prompt tells the agent to run. The deck's text is data in the prompt, never instructions. `createAgentHandoffDialog(container, { document, target, onClose })` is the modal: a target switch (a `radiogroup`), the prompt read-only in mono with its line count, **Copy prompt** (Check and "Copied" for 1.2 seconds; when the browser refuses it selects the text and says so), **Download .md** and **Close**. It has `role="dialog"`, `aria-modal`, a focus trap and Escape, and returns focus to the opener. Nothing is sent anywhere.
|
|
829
|
+
|
|
830
|
+
Icons are inlined path data from [Lucide](https://lucide.dev) (ISC license), never loaded from a CDN.
|
|
831
|
+
|
|
682
832
|
## Optional React Bindings
|
|
683
833
|
|
|
684
834
|
React bindings are isolated under `@openpresentation/opf-editor/react` and require the host app to pass its React runtime. The core package does not add React to the critical path.
|
|
@@ -718,7 +868,7 @@ Svelte bindings are isolated under `@openpresentation/opf-editor/svelte` as acti
|
|
|
718
868
|
Public npm package publication is handled by `.github/workflows/release.yml` through npm Trusted Publishing (GitHub Actions OIDC) with npm provenance; no npm token is stored. The owner authorized agents to prepare and publish npm releases whenever a release is required (2026-09-29). This authorization does not waive any gate.
|
|
719
869
|
|
|
720
870
|
1. Open a release-prep PR containing only the version bump, `CHANGELOG.md` (assembled from `changes/` with `node scripts/changelog-fragments.mjs assemble --version X.Y.Z`), dependency ranges, lockfile and current-instruction docs. Publish in dependency order (core, then renderer, then PPTX, then editor): refresh this repo's lockfile only after the required `@openpresentation/opf`, `@openpresentation/opf-render` and `@openpresentation/opf-pptx` versions are on the registry (`npm install --package-lock-only`), then run `npm run test:packed` against them.
|
|
721
|
-
2. Merge after CI is green, then publish by pushing the git tag `opf-editor-v<version>` (or `@openpresentation/opf-editor@v<version>`) at the merge commit. The workflow verifies that the tag matches `package.json` and
|
|
871
|
+
2. Merge after CI is green, then publish by pushing the git tag `opf-editor-v<version>` (or `@openpresentation/opf-editor@v<version>`) at the merge commit. The workflow verifies that the tag matches `package.json`, that the push-to-`main` run of `ci.yml` on exactly the tagged commit succeeded (`scripts/ci-evidence.mjs`; it fails closed and re-runs no test, RR-100), and the artifact itself (audit, the packed install, `npm pack --dry-run`) before `npm publish --access public --provenance`. A tag pushed while CI still runs fails at the evidence step; re-run the failed job once CI is green. A manual `workflow_dispatch` runs the same job without the tag check and is a fallback only.
|
|
722
872
|
3. Verify with `npm view @openpresentation/opf-editor@<version> version gitHead dist.attestations` and, from the core repo, `node scripts/test-editor-publication.mjs <version> <release-commit> <this-checkout>`. Never republish an existing version.
|
|
723
873
|
|
|
724
874
|
## Shared dynamic composition
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { AgentHandoff, AgentHandoffTarget } from "./agent-handoff.js";
|
|
2
|
+
|
|
3
|
+
export interface AgentHandoffDialogOptions {
|
|
4
|
+
/** The OPF document the prompt embeds, unchanged, as JSON. */
|
|
5
|
+
document: unknown;
|
|
6
|
+
/** The selected target. Default `"pptx"`. */
|
|
7
|
+
target?: AgentHandoffTarget;
|
|
8
|
+
/** The name the agent saves the deck as (default `deck.opf.json`). */
|
|
9
|
+
fileName?: string;
|
|
10
|
+
specUrl?: string;
|
|
11
|
+
agentGuideUrl?: string;
|
|
12
|
+
/** `"touch"` makes the controls 40px. Default `"comfortable"` (32px). */
|
|
13
|
+
density?: "comfortable" | "touch";
|
|
14
|
+
/** Called once after the dialog closes (Close, X, Escape or a click on the scrim) and focus has been returned. Not called by `destroy()`. */
|
|
15
|
+
onClose?: () => void;
|
|
16
|
+
/** The author switched the target. */
|
|
17
|
+
onTargetChange?: (target: AgentHandoffTarget) => void;
|
|
18
|
+
/** "Download .md". Default: save `deck-handoff.md` through a Blob link. */
|
|
19
|
+
onDownload?: (download: { blob: Blob; fileName: string; format: "handoff" }) => void;
|
|
20
|
+
/** The element that gets focus back on close. Default: the element focused when the dialog opened. */
|
|
21
|
+
returnFocus?: HTMLElement | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface AgentHandoffDialog {
|
|
25
|
+
/** The overlay (scrim and dialog), already mounted in the container. */
|
|
26
|
+
element: HTMLElement;
|
|
27
|
+
/** The prompt now shown: `{ markdown, lines }`. */
|
|
28
|
+
getPrompt(): AgentHandoff;
|
|
29
|
+
setTarget(target: AgentHandoffTarget): void;
|
|
30
|
+
/** Replace the deck the prompt embeds. */
|
|
31
|
+
setDocument(document: unknown): void;
|
|
32
|
+
/** Copy the prompt; resolves false when the browser refused (the text is then selected). */
|
|
33
|
+
copy(): Promise<boolean>;
|
|
34
|
+
/** Close the dialog, return focus and call `onClose`. */
|
|
35
|
+
close(): void;
|
|
36
|
+
/** Remove the dialog without returning focus or calling `onClose`. */
|
|
37
|
+
destroy(): void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A modal dialog (`role="dialog"`, `aria-modal`) over the prompt `buildAgentHandoff` writes: a target switch, the read-only prompt in mono
|
|
42
|
+
* with its line count, Copy prompt (shows "Copied" for 1.2 s) and Close. Focus is trapped inside, Escape closes, and focus returns to the
|
|
43
|
+
* opener. CSS: `src/styles/handoff.css`, classes `opf-handoff-*`.
|
|
44
|
+
*/
|
|
45
|
+
export declare function createAgentHandoffDialog(container: HTMLElement, options: AgentHandoffDialogOptions): AgentHandoffDialog;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// RR-98: the agent handoff dialog. A modal that shows the prompt `buildAgentHandoff` writes for a coding agent, with a target switch
|
|
2
|
+
// (PowerPoint, PDF or SVG), the prompt read-only in mono with its line count, Copy prompt ("Copied" for 1.2 seconds) and Close.
|
|
3
|
+
// Plain DOM, no framework, no network: the prompt never leaves the page until the author pastes it. Styles: src/styles/handoff.css.
|
|
4
|
+
import { HANDOFF_TARGETS, buildAgentHandoff } from "./agent-handoff.js";
|
|
5
|
+
import { el, icon, saveBlob } from "./export-dom.js";
|
|
6
|
+
|
|
7
|
+
const COPIED_MS = 1200;
|
|
8
|
+
const FOCUSABLE = 'button:not([disabled]), textarea, [href], input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
9
|
+
const FILE_NAME = "deck-handoff.md";
|
|
10
|
+
let counter = 0;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Open the dialog in `container` (a host element, usually `document.body`). Options: `document` (the OPF deck the prompt embeds), `target`
|
|
14
|
+
* (`"pptx"` default, `"pdf"` or `"svg"`), `fileName`, `specUrl`, `agentGuideUrl` (all for `buildAgentHandoff`), `density`
|
|
15
|
+
* (`"comfortable"` or `"touch"`), `onClose`, `onDownload({ blob, fileName, format: "handoff" })` for "Download .md" (default: a Blob link),
|
|
16
|
+
* `onTargetChange(target)` and `returnFocus` (the element that gets focus back on close; default: the one focused when it opened).
|
|
17
|
+
* Returns `{ element, getPrompt(), setTarget(), setDocument(), copy(), close(), destroy() }`.
|
|
18
|
+
*/
|
|
19
|
+
export function createAgentHandoffDialog(container, options = {}) {
|
|
20
|
+
const doc = container.ownerDocument ?? document;
|
|
21
|
+
const id = `opf-handoff-${++counter}`;
|
|
22
|
+
const opener = options.returnFocus ?? doc.activeElement;
|
|
23
|
+
let target = options.target in HANDOFF_TARGETS ? options.target : "pptx";
|
|
24
|
+
let deck = options.document;
|
|
25
|
+
let copiedTimer, liveTimer, destroyed = false;
|
|
26
|
+
let handoff;
|
|
27
|
+
|
|
28
|
+
const closeButton = el(doc, "button", { type: "button", class: "opf-handoff-x", "aria-label": "Close" }, icon(doc, "X", 16));
|
|
29
|
+
const radios = Object.entries(HANDOFF_TARGETS).map(([key, spec]) => el(doc, "button", { type: "button", role: "radio", class: "opf-handoff-target", "data-target": key, text: spec.label }));
|
|
30
|
+
const group = el(doc, "div", { class: "opf-handoff-segments", role: "radiogroup", "aria-labelledby": `${id}-render` }, ...radios);
|
|
31
|
+
const count = el(doc, "span", { class: "opf-handoff-count" });
|
|
32
|
+
const prompt = el(doc, "textarea", { class: "opf-handoff-prompt", readonly: true, spellcheck: "false", wrap: "soft", "aria-label": "Prompt for a coding agent", "aria-describedby": `${id}-count` });
|
|
33
|
+
count.id = `${id}-count`;
|
|
34
|
+
const live = el(doc, "div", { class: "opf-handoff-live", role: "status", "aria-live": "polite", "aria-atomic": "true" });
|
|
35
|
+
const copyLabel = el(doc, "span", { text: "Copy prompt" });
|
|
36
|
+
const copyButton = el(doc, "button", { type: "button", class: "opf-handoff-button opf-handoff-copy", "data-primary": true }, icon(doc, "Copy"), copyLabel);
|
|
37
|
+
const downloadButton = el(doc, "button", { type: "button", class: "opf-handoff-button opf-handoff-download" }, icon(doc, "Download"), "Download .md");
|
|
38
|
+
const closeText = el(doc, "button", { type: "button", class: "opf-handoff-button opf-handoff-close", text: "Close" });
|
|
39
|
+
|
|
40
|
+
const dialog = el(doc, "div", { class: "opf-handoff-dialog", role: "dialog", "aria-modal": "true", "aria-labelledby": `${id}-title`, "aria-describedby": `${id}-description`, tabindex: "-1" },
|
|
41
|
+
el(doc, "div", { class: "opf-handoff-head" },
|
|
42
|
+
el(doc, "div", { class: "opf-handoff-intro" },
|
|
43
|
+
el(doc, "h2", { class: "opf-handoff-title", id: `${id}-title`, text: "Agent handoff" }),
|
|
44
|
+
el(doc, "p", { class: "opf-handoff-description", id: `${id}-description` }, "Paste this into a coding agent. It carries the deck JSON and tells the agent to install ", el(doc, "code", { text: "@openpresentation/opf" }), ", validate the deck and render it."),
|
|
45
|
+
),
|
|
46
|
+
closeButton,
|
|
47
|
+
),
|
|
48
|
+
el(doc, "div", { class: "opf-handoff-targets" }, el(doc, "span", { class: "opf-handoff-render", id: `${id}-render`, text: "Render to" }), group),
|
|
49
|
+
el(doc, "div", { class: "opf-handoff-box" }, el(doc, "div", { class: "opf-handoff-box-head" }, el(doc, "span", { class: "opf-handoff-file", text: FILE_NAME }), count), prompt),
|
|
50
|
+
el(doc, "p", { class: "opf-handoff-note", text: "Nothing is sent anywhere. The prompt stays in this browser until you paste it." }),
|
|
51
|
+
el(doc, "div", { class: "opf-handoff-foot" }, closeText, downloadButton, copyButton),
|
|
52
|
+
live,
|
|
53
|
+
);
|
|
54
|
+
const element = el(doc, "div", { class: "opf-handoff opf-ui", "data-density": options.density === "touch" ? "touch" : "comfortable" }, dialog);
|
|
55
|
+
|
|
56
|
+
function say(message) {
|
|
57
|
+
globalThis.clearTimeout(liveTimer);
|
|
58
|
+
live.textContent = "";
|
|
59
|
+
liveTimer = globalThis.setTimeout(() => { live.textContent = message; }, 20);
|
|
60
|
+
}
|
|
61
|
+
function build() {
|
|
62
|
+
handoff = buildAgentHandoff(deck, { target, fileName: options.fileName, specUrl: options.specUrl, agentGuideUrl: options.agentGuideUrl });
|
|
63
|
+
prompt.value = handoff.markdown;
|
|
64
|
+
count.textContent = `${handoff.lines} lines`;
|
|
65
|
+
for (const radio of radios) {
|
|
66
|
+
const on = radio.dataset.target === target;
|
|
67
|
+
radio.setAttribute("aria-checked", String(on));
|
|
68
|
+
radio.tabIndex = on ? 0 : -1;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function resetCopy() {
|
|
72
|
+
globalThis.clearTimeout(copiedTimer);
|
|
73
|
+
copiedTimer = undefined;
|
|
74
|
+
copyLabel.textContent = "Copy prompt";
|
|
75
|
+
copyButton.querySelector("svg")?.replaceWith(icon(doc, "Copy"));
|
|
76
|
+
delete copyButton.dataset.copied;
|
|
77
|
+
}
|
|
78
|
+
function setTarget(next, { focus = false } = {}) {
|
|
79
|
+
if (!(next in HANDOFF_TARGETS) || next === target) return;
|
|
80
|
+
target = next;
|
|
81
|
+
build();
|
|
82
|
+
resetCopy();
|
|
83
|
+
options.onTargetChange?.(target);
|
|
84
|
+
if (focus) radios.find((radio) => radio.dataset.target === target)?.focus();
|
|
85
|
+
}
|
|
86
|
+
async function copy() {
|
|
87
|
+
const text = handoff.markdown;
|
|
88
|
+
let ok = false;
|
|
89
|
+
try { await globalThis.navigator?.clipboard?.writeText(text); ok = true; }
|
|
90
|
+
catch {
|
|
91
|
+
// No Clipboard API (an insecure page, or permission refused): select the text and ask the browser to copy the selection.
|
|
92
|
+
prompt.focus();
|
|
93
|
+
prompt.select();
|
|
94
|
+
try { ok = Boolean(doc.execCommand?.("copy")); } catch { ok = false; }
|
|
95
|
+
}
|
|
96
|
+
if (destroyed) return ok;
|
|
97
|
+
if (!ok) { prompt.focus(); prompt.select(); say("Could not copy. The prompt is selected: press Ctrl+C or Command+C."); return false; }
|
|
98
|
+
globalThis.clearTimeout(copiedTimer);
|
|
99
|
+
copyLabel.textContent = "Copied";
|
|
100
|
+
copyButton.querySelector("svg")?.replaceWith(icon(doc, "Check"));
|
|
101
|
+
copyButton.dataset.copied = "true";
|
|
102
|
+
say("Prompt copied to the clipboard.");
|
|
103
|
+
copiedTimer = globalThis.setTimeout(resetCopy, COPIED_MS);
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
function download() {
|
|
107
|
+
const blob = new Blob([handoff.markdown], { type: "text/markdown" });
|
|
108
|
+
if (options.onDownload) options.onDownload({ blob, fileName: FILE_NAME, format: "handoff" });
|
|
109
|
+
else saveBlob(doc, blob, FILE_NAME);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function focusables() { return [...dialog.querySelectorAll(FOCUSABLE)].filter((node) => !node.hidden && node.getClientRects().length); }
|
|
113
|
+
function onKeydown(event) {
|
|
114
|
+
if (event.key === "Escape" && !event.defaultPrevented) { event.preventDefault(); event.stopPropagation(); close(); return; }
|
|
115
|
+
if (event.key !== "Tab") return;
|
|
116
|
+
const nodes = focusables();
|
|
117
|
+
if (!nodes.length) { event.preventDefault(); dialog.focus(); return; }
|
|
118
|
+
const first = nodes[0], last = nodes[nodes.length - 1], active = doc.activeElement;
|
|
119
|
+
if (event.shiftKey && (active === first || active === dialog)) { event.preventDefault(); last.focus(); }
|
|
120
|
+
else if (!event.shiftKey && active === last) { event.preventDefault(); first.focus(); }
|
|
121
|
+
else if (!dialog.contains(active)) { event.preventDefault(); first.focus(); }
|
|
122
|
+
}
|
|
123
|
+
// Focus that escapes (a click on the page behind the scrim, a script) comes straight back.
|
|
124
|
+
function onFocusin(event) { if (!destroyed && !dialog.contains(event.target)) (focusables()[0] ?? dialog).focus(); }
|
|
125
|
+
function onKeydownRadio(event) {
|
|
126
|
+
const keys = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 };
|
|
127
|
+
const at = radios.findIndex((radio) => radio.dataset.target === target);
|
|
128
|
+
let next;
|
|
129
|
+
if (event.key in keys) next = (at + keys[event.key] + radios.length) % radios.length;
|
|
130
|
+
else if (event.key === "Home") next = 0;
|
|
131
|
+
else if (event.key === "End") next = radios.length - 1;
|
|
132
|
+
else return;
|
|
133
|
+
event.preventDefault();
|
|
134
|
+
setTarget(radios[next].dataset.target, { focus: true });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function destroy() {
|
|
138
|
+
if (destroyed) return;
|
|
139
|
+
destroyed = true;
|
|
140
|
+
globalThis.clearTimeout(copiedTimer);
|
|
141
|
+
globalThis.clearTimeout(liveTimer);
|
|
142
|
+
doc.removeEventListener("keydown", onKeydown, true);
|
|
143
|
+
doc.removeEventListener("focusin", onFocusin, true);
|
|
144
|
+
element.remove();
|
|
145
|
+
}
|
|
146
|
+
function close() {
|
|
147
|
+
if (destroyed) return;
|
|
148
|
+
destroy();
|
|
149
|
+
if (opener?.isConnected && typeof opener.focus === "function") opener.focus();
|
|
150
|
+
options.onClose?.();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
build();
|
|
154
|
+
for (const radio of radios) {
|
|
155
|
+
radio.addEventListener("click", () => setTarget(radio.dataset.target));
|
|
156
|
+
radio.addEventListener("keydown", onKeydownRadio);
|
|
157
|
+
}
|
|
158
|
+
closeButton.addEventListener("click", close);
|
|
159
|
+
closeText.addEventListener("click", close);
|
|
160
|
+
copyButton.addEventListener("click", copy);
|
|
161
|
+
downloadButton.addEventListener("click", download);
|
|
162
|
+
// A press and release on the scrim closes; a drag that starts in the dialog (selecting prompt text) and ends on the scrim does not.
|
|
163
|
+
let pressedOnScrim = false;
|
|
164
|
+
element.addEventListener("pointerdown", (event) => { pressedOnScrim = event.target === element; });
|
|
165
|
+
element.addEventListener("click", (event) => { if (pressedOnScrim && event.target === element) close(); pressedOnScrim = false; });
|
|
166
|
+
doc.addEventListener("keydown", onKeydown, true);
|
|
167
|
+
doc.addEventListener("focusin", onFocusin, true);
|
|
168
|
+
container.append(element);
|
|
169
|
+
radios.find((radio) => radio.dataset.target === target)?.focus();
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
element,
|
|
173
|
+
/** The prompt as shown. */
|
|
174
|
+
getPrompt: () => ({ ...handoff }),
|
|
175
|
+
setTarget: (next) => setTarget(next),
|
|
176
|
+
/** Replace the deck the prompt embeds. */
|
|
177
|
+
setDocument(next) { deck = next; build(); resetCopy(); },
|
|
178
|
+
copy,
|
|
179
|
+
close,
|
|
180
|
+
destroy,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** The render targets of the handoff prompt. */
|
|
2
|
+
export type AgentHandoffTarget = "pptx" | "pdf" | "svg";
|
|
3
|
+
|
|
4
|
+
export interface AgentHandoffOptions {
|
|
5
|
+
/** What the agent renders to. Default `"pptx"`. */
|
|
6
|
+
target?: AgentHandoffTarget;
|
|
7
|
+
/** The name the agent saves the deck as, and the base name of the output file. Default `"deck.opf.json"`. */
|
|
8
|
+
fileName?: string;
|
|
9
|
+
/** The Spec link. Default `"https://openpresentation.org/spec"`. */
|
|
10
|
+
specUrl?: string;
|
|
11
|
+
/** The Agent guide link. Default `"https://openpresentation.org/agents"`. */
|
|
12
|
+
agentGuideUrl?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface AgentHandoff {
|
|
16
|
+
/** The prompt: `# Task`, `## Setup`, `## Steps`, the Spec and Agent guide links, then the deck in a fenced JSON block. */
|
|
17
|
+
markdown: string;
|
|
18
|
+
/** The number of lines in `markdown`. */
|
|
19
|
+
lines: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export declare const DEFAULT_SPEC_URL: "https://openpresentation.org/spec";
|
|
23
|
+
export declare const DEFAULT_AGENT_GUIDE_URL: "https://openpresentation.org/agents";
|
|
24
|
+
export declare const DEFAULT_HANDOFF_FILE_NAME: "deck.opf.json";
|
|
25
|
+
/** Each target's label (as the prompt and the dialog say it), output extension and the npm packages the agent installs. */
|
|
26
|
+
export declare const HANDOFF_TARGETS: Readonly<Record<AgentHandoffTarget, { readonly label: string; readonly extension: string; readonly packages: readonly string[] }>>;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The handoff prompt for a coding agent: install the published CLI (`@openpresentation/cli`, with the renderer and, for PowerPoint, the PPTX converter), save the deck, validate it with `npx opf validate`, render it with `npx opf convert`, report the
|
|
30
|
+
* output path and warnings. Pure: no DOM or network. The deck is embedded as `JSON.stringify(document, null, 2)`, unchanged, in a fence
|
|
31
|
+
* longer than any run of backticks in it. Throws `RangeError` for an unknown target and `TypeError` for a document that is not JSON.
|
|
32
|
+
*/
|
|
33
|
+
export declare function buildAgentHandoff(document: unknown, options?: AgentHandoffOptions): AgentHandoff;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// RR-98: the agent handoff prompt. A pure function: an OPF document in, a Markdown prompt out, for a coding agent that installs the
|
|
2
|
+
// published CLI, validates the deck and renders it to PowerPoint, PDF or SVG. No DOM, no network, no clock. The commands it tells the agent
|
|
3
|
+
// to run are the published `opf` command (@openpresentation/cli 0.24, with the renderer 0.24.1 and PPTX 0.24.0 peers), run in a clean
|
|
4
|
+
// folder: `opf validate deck.opf.json` (exit 1 for an invalid deck, with the JSON path of each finding) and `opf convert deck.opf.json
|
|
5
|
+
// deck.pptx`. The renderer's `@expo-google-fonts/*` packages are optional peers that npm does not install, and the first conversion exits 2
|
|
6
|
+
// with `peer-not-installed` naming the exact (pinned) `npm install` command; the prompt says to run it rather than pin versions here that
|
|
7
|
+
// would go stale.
|
|
8
|
+
|
|
9
|
+
/** The default Spec link in the prompt. */
|
|
10
|
+
export const DEFAULT_SPEC_URL = "https://openpresentation.org/spec";
|
|
11
|
+
/** The default Agent guide link in the prompt. */
|
|
12
|
+
export const DEFAULT_AGENT_GUIDE_URL = "https://openpresentation.org/agents";
|
|
13
|
+
/** The file name the prompt tells the agent to save the deck as. */
|
|
14
|
+
export const DEFAULT_HANDOFF_FILE_NAME = "deck.opf.json";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The render targets. `packages` are the npm packages the agent installs: PDF and SVG need the renderer, PowerPoint also needs the
|
|
18
|
+
* PPTX converter. `extension` is the output file's extension.
|
|
19
|
+
*/
|
|
20
|
+
export const HANDOFF_TARGETS = Object.freeze({
|
|
21
|
+
pptx: Object.freeze({ label: "PowerPoint (.pptx)", extension: "pptx", packages: Object.freeze(["@openpresentation/cli", "@openpresentation/opf-render", "@openpresentation/opf-pptx"]) }),
|
|
22
|
+
pdf: Object.freeze({ label: "PDF", extension: "pdf", packages: Object.freeze(["@openpresentation/cli", "@openpresentation/opf-render"]) }),
|
|
23
|
+
svg: Object.freeze({ label: "SVG slides", extension: "svg", packages: Object.freeze(["@openpresentation/cli", "@openpresentation/opf-render"]) }),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const VALIDATE = (file) => `npx opf validate ${file}`;
|
|
27
|
+
const CONVERT = (file, output) => `npx opf convert ${file} ${output}`;
|
|
28
|
+
|
|
29
|
+
/** The output file for a deck file: `deck.opf.json` gives `deck.pptx`; any other extension is replaced. */
|
|
30
|
+
function outputFor(fileName, extension) {
|
|
31
|
+
const base = fileName.replace(/\.opf\.(json|ya?ml|md)$/i, "").replace(/\.[^./\\]+$/, "");
|
|
32
|
+
return `${base || "deck"}.${extension}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A fence longer than any run of backticks in the text, so the deck's own strings can never close it. */
|
|
36
|
+
function fenceFor(text) {
|
|
37
|
+
const longest = Math.max(0, ...(text.match(/`+/g) ?? []).map((run) => run.length));
|
|
38
|
+
return "`".repeat(Math.max(3, longest + 1));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The handoff prompt for a coding agent.
|
|
43
|
+
*
|
|
44
|
+
* @param document The OPF document. It goes into the prompt as `JSON.stringify(document, null, 2)`, unchanged.
|
|
45
|
+
* @param options `target` `"pptx"` (default), `"pdf"` or `"svg"`; `fileName` the name the agent saves the deck as (default
|
|
46
|
+
* `deck.opf.json`; the output file takes its base name); `specUrl` and `agentGuideUrl` (defaults: openpresentation.org).
|
|
47
|
+
* @returns `{ markdown, lines }`: the prompt, and its line count.
|
|
48
|
+
*/
|
|
49
|
+
export function buildAgentHandoff(document, options = {}) {
|
|
50
|
+
const { target = "pptx", fileName = DEFAULT_HANDOFF_FILE_NAME, specUrl = DEFAULT_SPEC_URL, agentGuideUrl = DEFAULT_AGENT_GUIDE_URL } = options;
|
|
51
|
+
const spec = HANDOFF_TARGETS[target];
|
|
52
|
+
if (!spec) throw new RangeError(`Unknown handoff target "${target}". Use one of: ${Object.keys(HANDOFF_TARGETS).join(", ")}.`);
|
|
53
|
+
const json = JSON.stringify(document, null, 2);
|
|
54
|
+
if (typeof json !== "string") throw new TypeError("buildAgentHandoff needs an OPF document that can be written as JSON.");
|
|
55
|
+
const output = outputFor(fileName, spec.extension);
|
|
56
|
+
const fence = fenceFor(json);
|
|
57
|
+
const markdown = [
|
|
58
|
+
"# Task",
|
|
59
|
+
`Render this Open Presentation Format (OPF) deck to ${spec.label}.`,
|
|
60
|
+
"",
|
|
61
|
+
"## Setup",
|
|
62
|
+
"Needs Node 22 or later. In an empty folder:",
|
|
63
|
+
"",
|
|
64
|
+
"```sh",
|
|
65
|
+
`npm install ${spec.packages.join(" ")}`,
|
|
66
|
+
"```",
|
|
67
|
+
"",
|
|
68
|
+
"The renderer draws with font packages that npm does not install for you. If a render stops with `peer-not-installed`, run the `npm install` command in its message, then run the step again.",
|
|
69
|
+
"",
|
|
70
|
+
"## Steps",
|
|
71
|
+
`1. Save the JSON below as ${fileName}.`,
|
|
72
|
+
"2. Validate it with the opf command. Fix any schema errors; keep the slide content unchanged. It exits 1 for an invalid deck and names the JSON path of each error; findings with severity warning or info are advisory.",
|
|
73
|
+
"",
|
|
74
|
+
"```sh",
|
|
75
|
+
VALIDATE(fileName),
|
|
76
|
+
"```",
|
|
77
|
+
"",
|
|
78
|
+
`3. Render ${fileName} to ${spec.label} with the same command line tool. It writes ${spec.extension === "svg" ? `one file per slide, named from ${output}` : output}; the JSON it prints lists the files and any findings.`,
|
|
79
|
+
"",
|
|
80
|
+
"```sh",
|
|
81
|
+
CONVERT(fileName, output),
|
|
82
|
+
"```",
|
|
83
|
+
"",
|
|
84
|
+
"4. Report the output file path and any advisory warnings.",
|
|
85
|
+
"",
|
|
86
|
+
`Spec: ${specUrl}`,
|
|
87
|
+
`Agent guide: ${agentGuideUrl}`,
|
|
88
|
+
"",
|
|
89
|
+
`## ${fileName}`,
|
|
90
|
+
`${fence}json`,
|
|
91
|
+
json,
|
|
92
|
+
fence,
|
|
93
|
+
].join("\n");
|
|
94
|
+
return { markdown, lines: markdown.split("\n").length };
|
|
95
|
+
}
|