Standardize TPEN ↔ tool messaging contract#564
Conversation
Collapse the four-message iframe boot to one lean TPEN_CONTEXT (project identity + URIs + sibling/column lists) and the two-message line-nav to a single UPDATE_CURRENT_LINE. Most tools never needed the hydrated page, so the heavy payload is now opt-in via REQUEST_HYDRATED_CONTEXT → TPEN_HYDRATED_CONTEXT (TPEN-Prompts uses this for templates). Inbound: drop the four-way alias soup (CURRENT_LINE_INDEX / SELECT_ANNOTATION / RETURN_LINE_ID / NAVIGATE_TO_LINE) and accept only NAVIGATE_TO_LINE. transcription-block stops handling RETURN_LINE_ID; line nav is owned by simple-transcription. The TPEN_ID_TOKEN flow is preserved unchanged — user-gated, posted to the iframe origin only, with a toast confirmation.
Clarifies the lean payload contract so future tool authors don't read the field name as a page IRI — it's `page.target`, the canvas IRI, matching Compare-Pages' usage.
cubap
left a comment
There was a problem hiding this comment.
There's enough here I don't like to review, though I can be convinced to let this ride.
| .find(p => p.id?.split('/').pop() === pageInQuery) ?? null | ||
| } | ||
|
|
||
| #getActiveLayerSiblings() { |
There was a problem hiding this comment.
siblings is all the pages, which isn't clearly the siblings, since that seems to have a subject in mind, but all the members of a set are siblings to each other so it isn't completely wrong.
I'm fairly sure most other uses of sibling, however, indicate a sideways move from a node, so this is misnamed.
There was a problem hiding this comment.
Renamed siblings → canvases end-to-end in 1af3adc (parent payload, helper #getActiveLayerCanvases) and CenterForDigitalHumanities/Compare-Pages@cd4d134 (consumer). Issue #566's body updated to match.
| * needs this for prompt-template rendering; most tools should use the | ||
| * lean `TPEN_CONTEXT` instead. | ||
| */ | ||
| async #buildHydratedTPENContext() { |
There was a problem hiding this comment.
I don't like this at all.
The naming matches it to TPENContext but the projection is very different. I would understand it to have all the TPENContext with some elements hydrated. Instead this has the full Project, a hydrated Page, and is missing manifest, annotationPage, columns, and sibling.
It seems like this is supplemental, not hydration. As supplemental, it feels like we could just ask for a hydrated Project or hydrated Page when you need it.
There was a problem hiding this comment.
Agreed — split into REQUEST_POPULATED_PROJECT / REQUEST_POPULATED_PAGE with matching TPEN_POPULATED_PROJECT / TPEN_POPULATED_PAGE replies in 1af3adc + CenterForDigitalHumanities/TPEN-Prompts@36c7f66. Each message's shape now matches its name; TPEN-Prompts' MessageHandler accumulates the two halves and calls acceptContext once both arrive.
|
|
||
| async #sendTPENContextToTool(targetWindow = this.#activeToolIframe?.contentWindow) { | ||
| this.#postToTool(await this.#buildTPENContext(), targetWindow) | ||
| #sendTPENContextToTool(targetWindow = this.#activeToolIframe?.contentWindow) { |
There was a problem hiding this comment.
targetWindow is already set as default in #postToTool() so having these aliases all define it isn't helpful. Further the alias doesn't really make things more clear or helpful, so just calling this.#postToTool(this.#buildTPENContext()) probably is clear enough.
There was a problem hiding this comment.
Inlined in 1af3adc — the iframe-load handler now calls this.#postToTool(this.#buildTPENContext(), iframe.contentWindow) directly and #sendTPENContextToTool is gone.
| } | ||
|
|
||
| #handleToolMessages(event) { | ||
| async #handleToolMessages(event) { |
There was a problem hiding this comment.
Compare to canvas-panel handling which also checks these events.
There was a problem hiding this comment.
transcription-canvas-panel/index.js is orphaned — flagged out of scope in the plan and not instantiated anywhere. Left alone here; eventual removal is its own piece of work.
| return | ||
| } | ||
|
|
||
| if (type === 'NAVIGATE_TO_LINE') { |
There was a problem hiding this comment.
This is a strong recommendation to have the tool ask to "navigate" to a line. Are there similar cases for "highlighting" a line or "acting on" a line? This may feel narrow, but I think it is enough for now.
There was a problem hiding this comment.
Agree it's narrow today — kept it that way deliberately. New line verbs (HIGHLIGHT_LINE, FLAG_LINE, etc.) get added when each becomes a concrete parent-side behavior we can define, rather than designing speculative envelopes now. Noted in the PR description's contract section.
| // handles UPDATE_LINE_TEXT (e.g. Line-Breaking, Preview-Transcription | ||
| // pushing edited line text into the active transcription block). | ||
| this.renderCleanup.onWindow('message', (event) => { | ||
| if (event.data?.type === "RETURN_LINE_ID") { |
There was a problem hiding this comment.
Should this be replaced with NAVIGATE? It seems to fit here better than the simple-transcription, above. Probably even more correct would be to watch TPEN.screen.activeLine or whatevs and just change that.
There was a problem hiding this comment.
Good architectural question — agreed it's worth it. Filing as a follow-up: this cut intentionally centralizes line-nav ownership in simple-transcription so the protocol has a single owner. Refactoring to a global TPEN.screen.activeLine subscription is a separate piece of work.
…line context send - TPEN_CONTEXT.siblings → TPEN_CONTEXT.canvases (clearer name; the list contains pages-as-canvases). Helper renamed to #getActiveLayerCanvases. - Replace TPEN_HYDRATED_CONTEXT (one envelope, mismatched shape) with two split request/reply pairs whose names match their projection: REQUEST_POPULATED_PROJECT → TPEN_POPULATED_PROJECT, REQUEST_POPULATED_PAGE → TPEN_POPULATED_PAGE. Page reply carries canvas + currentLineId since they're page-scoped. - Inline #sendTPENContextToTool. The wrapper added a redundant default arg with no clarity gain; iframe-load handler calls this.#postToTool(this.#buildTPENContext(), iframe.contentWindow) directly. Addresses cubap's review on PR #564 (comments 1, 2, 3). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Match parent rename in CenterForDigitalHumanities/TPEN-interfaces#564. Field shape unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace REQUEST_HYDRATED_CONTEXT / TPEN_HYDRATED_CONTEXT (one envelope with a mismatched grab-bag shape) with two request/reply pairs whose names match their projection: REQUEST_POPULATED_PROJECT → TPEN_POPULATED_PROJECT, REQUEST_POPULATED_PAGE → TPEN_POPULATED_PAGE. MessageHandler accumulates both halves into a populated bag and only calls app.acceptContext once both have arrived, so the workspace renders once with the full bundle. Mirrors parent change in CenterForDigitalHumanities/TPEN-interfaces#564. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Thanks for the review @cubap — the points were good and most landed in code. Summary of where each ended up:
PR description has a "Review responses" section with the same disposition list inline. Comments on the other 6 PRs in the cut also addressed:
Ready for another pass when you have a moment. |
Wrap the per-type branches in a single try/catch so async builder rejections (notably the vault-driven hydration in #buildPopulatedPage) surface as labeled console.errors instead of escaping the message listener as unhandled promise rejections.
) * Align with TPEN messaging contract — read siblings from TPEN_CONTEXT Replace the dedicated CANVASES boot message with the canonical lean TPEN_CONTEXT payload — sibling pages now arrive on the `siblings` field of TPEN_CONTEXT instead of a separate canvases-only message. * Read TPEN_CONTEXT.canvases (was .siblings) Match parent rename in CenterForDigitalHumanities/TPEN-interfaces#564. Field shape unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
) * Align with TPEN messaging contract — opt into TPEN_HYDRATED_CONTEXT The parent now ships a lean TPEN_CONTEXT (project identity + URIs) on boot. Prompt templates need fully-hydrated project/page/canvas, so on receipt of TPEN_CONTEXT we send REQUEST_HYDRATED_CONTEXT upstream and acceptContext() now consumes TPEN_HYDRATED_CONTEXT instead. init() also requests hydration eagerly to handle the case where the parent posted TPEN_CONTEXT before the listener was wired up; duplicate requests are cheap and idempotent on the parent side. * Drop redundant eager REQUEST_HYDRATED_CONTEXT in init() The MessageHandler listener attaches in the PromptsApp constructor at DOMContentLoaded, before the parent's iframe load event fires — so the 'listener not yet wired up' scenario the eager call was guarding against can't actually happen. The TPEN_CONTEXT case in MessageHandler is the single source of truth for kicking off the hydration handshake; exactly one round trip per boot. * Switch to split populated request/reply pairs Replace REQUEST_HYDRATED_CONTEXT / TPEN_HYDRATED_CONTEXT (one envelope with a mismatched grab-bag shape) with two request/reply pairs whose names match their projection: REQUEST_POPULATED_PROJECT → TPEN_POPULATED_PROJECT, REQUEST_POPULATED_PAGE → TPEN_POPULATED_PAGE. MessageHandler accumulates both halves into a populated bag and only calls app.acceptContext once both have arrived, so the workspace renders once with the full bundle. Mirrors parent change in CenterForDigitalHumanities/TPEN-interfaces#564. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Address review: slim init() JSDoc and file-level docstring cubap flagged the init() JSDoc and the message-handler file-level docstring as narration rather than documentation. Trim both: - main.js: init() JSDoc reduced to one line; the inline "paint sync" comment kept (the WHY is non-obvious) but tightened. - message-handler.js: file-level docstring no longer enumerates the whole protocol. Describes what this file does (route inbound parent messages into PromptsApp) and keeps the non-obvious parentOrigin capture rule. Addresses cubap's review on PR #16. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Fully reset populated accumulator on flush Replace the partial has* flag reset with a full accumulator reset, so stale project/page/canvas/currentLineId fields can't leak into a future bundle if the flush gate is ever loosened. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Standardize the postMessage contract between TPEN Interfaces and the splitscreen tools so there is one canonical message per direction, no alias soup, and lean default boot payloads. Also rides along with CenterForDigitalHumanities/TPEN-services#519, which fixes the broken Line History tool URL.
Canonical contract
Parent → Tool
TPEN_CONTEXT— single boot message on iframe load. Lean payload:Tools that need annotation bodies should
fetch(annotationPage)themselves — the parent no longer ships every line over postMessage.canvasvscanvases— these answer different questions and most tools read only one of them:canvasis what the user is currently working on (single URI). Page-Viewer paints this image, Preview-Transcription scopes its line list to it, Line-Breaking edits within it. Tools that don't care about the layer read this and ignore the rest.canvasesis what the user could compare to (list). Only Compare-Pages reads it, to populate its dropdown. The active canvas is included so the dropdown has a label for "you are here" — matches the legacyCANVASESshape this replaces.In a single-page layer they coincide; in a multi-page layer they diverge.
TPEN_POPULATED_PROJECT— sent only in reply toREQUEST_POPULATED_PROJECT. Carries the full active project (layers, pages, columns, members).TPEN_POPULATED_PAGE— sent only in reply toREQUEST_POPULATED_PAGE. Carries the active page with items resolved to full Annotations via the vault, the full canvas object, andcurrentLineId.These two replies replace the earlier combined
TPEN_HYDRATED_CONTEXT. Splitting them makes each message's projection match its name (you ask for a populated project, you get the project; same for the page) instead of one envelope with a mismatched grab-bag shape. TPEN-Prompts requests both on boot and renders templates once both have arrived.UPDATE_CURRENT_LINE— single line-nav delta.{ currentLineId }.TPEN_ID_TOKEN— preserved unchanged. User-gated, posted to iframe origin only, with toast confirmation. Most sensitive message in the protocol — explicitly flagged in code with a defensive comment.Tool → Parent
REQUEST_POPULATED_PROJECT— opt into the full active project.REQUEST_POPULATED_PAGE— opt into the populated page + canvas.REQUEST_TPEN_ID_TOKEN— request the token (parent gates).NAVIGATE_TO_LINE—{ lineId }. Single canonical name. Intentionally narrow — when tools need other line verbs (highlight, flag, delete, …), each will be added as its own well-defined message rather than overloading this one.UPDATE_LINE_TEXT—{ lineIndex, text }. Unchanged.Removed
MANIFEST_CANVAS_ANNOTATIONPAGE_ANNOTATION,CANVAS_ANNOTATIONPAGE_ANNOTATION,MANIFEST_CANVAS_ANNOTATIONPAGE,CANVAS_ANNOTATIONPAGE,MANIFEST_CANVAS,CANVAS,CANVASES,CURRENT_LINE_INDEX,SELECT_ANNOTATION,RETURN_LINE_ID.Changes in this repo
components/simple-transcription/index.js#buildTPENContext()produces the new boot payload. New helpers#getActiveLayerPage()and#getActiveLayerCanvases()source columns and the canvas list from the active layer.#buildPopulatedProject()and async#buildPopulatedPage()return the two split reply payloads. The Promise.allSettled hydration logic lives on the page reply (where it belongs).loadhandler reduced from four messages to one (TPEN_CONTEXT), and now calls#postToTool(this.#buildTPENContext(), iframe.contentWindow)directly — the prior#sendTPENContextToToolwrapper added a redundant default arg with no clarity gain.sendLineSelectionposts onlyUPDATE_CURRENT_LINE.#handleToolMessagesaccepts onlyREQUEST_TPEN_ID_TOKEN,REQUEST_POPULATED_PROJECT,REQUEST_POPULATED_PAGE,NAVIGATE_TO_LINE. Alias soup removed.#sendIdTokenToToolflagging the token as the most sensitive message.#buildTPENContextclarifies thatcanvases[i].idis the canvas IRI (page.target), not the page IRI — matches Compare-Pages' usage.components/transcription-block/index.jsRETURN_LINE_IDbranch — line nav is owned bysimple-transcriptionnow.UPDATE_LINE_TEXTbranch kept (drivesmarkLineDirty/scheduleLineSave/persistDraft).Coordinated cut
Hard cut — no dual-accept path. All PRs must merge together to avoid a broken intermediate state.
tpen-line-historyandcappellineed no code change —tpen-line-historyis unblocked solely by the Tools.js URL fix in CenterForDigitalHumanities/TPEN-services#519.Test plan
Developer-validated locally on 2026-05-08 (services on
:3012, interfaces on:4000, tool iframes served from:8080):jekyll sin tpen3-interfaces, open/transcribeagainst a project that has each tool added.UPDATE_CURRENT_LINE. Race-fixff25235confirmed — overlay is highlighted on first paint, no click required.UPDATE_CURRENT_LINE.UPDATE_LINE_TEXT.TPEN_CONTEXT.canvases(single-page test project — multi-page exercise covered by Broaden TPEN_CONTEXT.siblings to span all layers, not just the active one #566).REQUEST_POPULATED_PROJECTandREQUEST_POPULATED_PAGE, receivesTPEN_POPULATED_PROJECTandTPEN_POPULATED_PAGE, renders templates once both arrive; consent button produces a token. Standalone mode (?projectID=...) also boots.postMessageper iframe load (TPEN_CONTEXT) and one per line navigation (UPDATE_CURRENT_LINE). For TPEN-Prompts, exactly two additional request/reply pairs on boot (REQUEST_POPULATED_PROJECT→TPEN_POPULATED_PROJECT,REQUEST_POPULATED_PAGE→TPEN_POPULATED_PAGE). Negative-presence verified across all 5 iframe tools — no legacyMANIFEST_CANVAS_*,CANVASES,CURRENT_LINE_INDEX,SELECT_ANNOTATION, orRETURN_LINE_IDtraffic.TPEN_CONTEXTpayload contains only the documented fields (no full project, no hydrated items). The populated replies only ship to the tool that requested them (only TPEN-Prompts).npm run allTestsin tpen3-services passes — 187/187 ✅ (113 integration tests skipped, as expected without a live test stack).Companion follow-ups (deferred, do not block this cut)
Issues filed during local-stack testing for pre-existing gaps that were not introduced by this PR:
LINE_TEXT_UPDATEDmessage).TPEN_CONTEXT.canvasesto span all layers (legacyCANVASESwas also single-layer; this faithfully ports that constraint).https://app.t-pen.org/api/TPEN.jsimport prevents local-stack and dev-stack testing.Review responses
Address-by-address response to @cubap's first review pass:
siblingswas misnamed — agreed. Renamed tocanvasesacross the parent payload, the helper (#getActiveLayerCanvases), Compare-Pages' consumer, and Broaden TPEN_CONTEXT.siblings to span all layers, not just the active one #566's body. The list contains pages-as-canvases (each entry'sidispage.target, the canvas IRI), so the new name describes the contents directly.TPEN_HYDRATED_CONTEXTprojection mismatched its name — agreed. Split intoREQUEST_POPULATED_PROJECT/REQUEST_POPULATED_PAGEwith matchingTPEN_POPULATED_PROJECT/TPEN_POPULATED_PAGEreplies. Each message's shape now matches its name. TPEN-Prompts' MessageHandler accumulates the two halves and callsacceptContextonce both have arrived.#sendTPENContextToToolwrapper was redundant — agreed. Inlined; the iframe-load handler now callsthis.#postToTool(this.#buildTPENContext(), iframe.contentWindow)directly.components/transcription-canvas-panel/index.jsis orphaned (no longer instantiated; flagged in the plan as out of scope). Left alone here; eventual removal tracked separately from this contract cut.NAVIGATE_TO_LINEintentionally narrow. New verbs (e.g.HIGHLIGHT_LINE,FLAG_LINE) will be added as concrete needs arise rather than designing speculative envelopes now. Noted in the contract section above.TPEN.screen.activeLine? — agreed this is the right architectural question, but a follow-up. The current cut centralizes all line-nav insimple-transcriptionso the protocol has a single owner; refactoring to a globalTPEN.screen.activeLinesubscription would be a separate piece of work.Out of scope
components/transcription-canvas-panel/index.js(orphaned but left alone).