Skip to content

Standardize TPEN ↔ tool messaging contract#564

Merged
thehabes merged 4 commits into
mainfrom
5-2-tools-align
May 8, 2026
Merged

Standardize TPEN ↔ tool messaging contract#564
thehabes merged 4 commits into
mainfrom
5-2-tools-align

Conversation

@thehabes
Copy link
Copy Markdown
Member

@thehabes thehabes commented May 8, 2026

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:

    {
      type: 'TPEN_CONTEXT',
      project: { id, label, slug },     // identity only
      manifest: '<URI>',
      canvas: '<URI>',
      annotationPage: '<URI>',
      currentLineId: '<URI|null>',
      columns: [...],                    // active layer's column ordering
      canvases: [{ id, label }, ...]     // pages in the active layer — `id` is the canvas IRI
    }

    Tools that need annotation bodies should fetch(annotationPage) themselves — the parent no longer ships every line over postMessage.

    canvas vs canvases — these answer different questions and most tools read only one of them:

    • canvas is 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.
    • canvases is 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 legacy CANVASES shape this replaces.

    In a single-page layer they coincide; in a multi-page layer they diverge.

  • TPEN_POPULATED_PROJECT — sent only in reply to REQUEST_POPULATED_PROJECT. Carries the full active project (layers, pages, columns, members).

  • TPEN_POPULATED_PAGE — sent only in reply to REQUEST_POPULATED_PAGE. Carries the active page with items resolved to full Annotations via the vault, the full canvas object, and currentLineId.

    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
    • Lean #buildTPENContext() produces the new boot payload. New helpers #getActiveLayerPage() and #getActiveLayerCanvases() source columns and the canvas list from the active layer.
    • New #buildPopulatedProject() and async #buildPopulatedPage() return the two split reply payloads. The Promise.allSettled hydration logic lives on the page reply (where it belongs).
    • Iframe load handler reduced from four messages to one (TPEN_CONTEXT), and now calls #postToTool(this.#buildTPENContext(), iframe.contentWindow) directly — the prior #sendTPENContextToTool wrapper added a redundant default arg with no clarity gain.
    • sendLineSelection posts only UPDATE_CURRENT_LINE.
    • #handleToolMessages accepts only REQUEST_TPEN_ID_TOKEN, REQUEST_POPULATED_PROJECT, REQUEST_POPULATED_PAGE, NAVIGATE_TO_LINE. Alias soup removed.
    • Defensive comment above #sendIdTokenToTool flagging the token as the most sensitive message.
    • JSDoc on #buildTPENContext clarifies that canvases[i].id is the canvas IRI (page.target), not the page IRI — matches Compare-Pages' usage.
  • components/transcription-block/index.js
    • Removed RETURN_LINE_ID branch — line nav is owned by simple-transcription now. UPDATE_LINE_TEXT branch kept (drives markLineDirty / scheduleLineSave / persistDraft).

Coordinated cut

Hard cut — no dual-accept path. All PRs must merge together to avoid a broken intermediate state.

tpen-line-history and cappelli need no code change — tpen-line-history is 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 s in tpen3-interfaces, open /transcribe against a project that has each tool added.
  • Page-Viewer renders the canvas image immediately on iframe load and highlights the active line on UPDATE_CURRENT_LINE. Race-fix ff25235 confirmed — overlay is highlighted on first paint, no click required.
  • Preview-Transcription lists all lines and reflects the active line on UPDATE_CURRENT_LINE.
  • Line-Breaking edits a line and the transcription-block receives UPDATE_LINE_TEXT.
  • Compare-Pages populates its dropdown from 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).
  • TPEN-Prompts boots, posts REQUEST_POPULATED_PROJECT and REQUEST_POPULATED_PAGE, receives TPEN_POPULATED_PROJECT and TPEN_POPULATED_PAGE, renders templates once both arrive; consent button produces a token. Standalone mode (?projectID=...) also boots.
  • History Tool loads (no 404 in console) — script returns 200 from the new GitHub Pages URL. Live-line tracking on local stack is gated on Hardcoded prod TPEN module URL prevents local-stack and dev-stack testing tpen-line-history#5 (hardcoded prod TPEN module URL); works on prod where module URLs dedupe.
  • DevTools: exactly one parent-origin postMessage per 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_PROJECTTPEN_POPULATED_PROJECT, REQUEST_POPULATED_PAGETPEN_POPULATED_PAGE). Negative-presence verified across all 5 iframe tools — no legacy MANIFEST_CANVAS_*, CANVASES, CURRENT_LINE_INDEX, SELECT_ANNOTATION, or RETURN_LINE_ID traffic.
  • The lean TPEN_CONTEXT payload 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 allTests in 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:

Review responses

Address-by-address response to @cubap's first review pass:

  1. siblings was misnamed — agreed. Renamed to canvases across 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's id is page.target, the canvas IRI), so the new name describes the contents directly.
  2. TPEN_HYDRATED_CONTEXT projection mismatched its name — agreed. Split into REQUEST_POPULATED_PROJECT / REQUEST_POPULATED_PAGE with matching TPEN_POPULATED_PROJECT / TPEN_POPULATED_PAGE replies. Each message's shape now matches its name. TPEN-Prompts' MessageHandler accumulates the two halves and calls acceptContext once both have arrived.
  3. #sendTPENContextToTool wrapper was redundant — agreed. Inlined; the iframe-load handler now calls this.#postToTool(this.#buildTPENContext(), iframe.contentWindow) directly.
  4. canvas-panel listenercomponents/transcription-canvas-panel/index.js is orphaned (no longer instantiated; flagged in the plan as out of scope). Left alone here; eventual removal tracked separately from this contract cut.
  5. Other line verbs (highlight, act-on) — kept NAVIGATE_TO_LINE intentionally 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.
  6. transcription-block: NAVIGATE here, or watch TPEN.screen.activeLine? — agreed this is the right architectural question, but a follow-up. The current cut centralizes all line-nav in simple-transcription so the protocol has a single owner; refactoring to a global TPEN.screen.activeLine subscription would be a separate piece of work.

Out of scope

  • Origin-gating tightening on tool message listeners (tracked separately).
  • components/transcription-canvas-panel/index.js (orphaned but left alone).

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.
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 8, 2026

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.
Copy link
Copy Markdown
Member

@cubap cubap left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

@thehabes thehabes May 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed siblingscanvases 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() {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

@thehabes thehabes May 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Compare to canvas-panel handling which also checks these events.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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') {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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") {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
thehabes added a commit to CenterForDigitalHumanities/Compare-Pages that referenced this pull request May 8, 2026
Match parent rename in CenterForDigitalHumanities/TPEN-interfaces#564.
Field shape unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
thehabes added a commit to CenterForDigitalHumanities/TPEN-Prompts that referenced this pull request May 8, 2026
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>
@thehabes
Copy link
Copy Markdown
Member Author

thehabes commented May 8, 2026

Thanks for the review @cubap — the points were good and most landed in code. Summary of where each ended up:

# Your comment Disposition
1 siblings is misnamed Code change — renamed siblingscanvases end-to-end in 1af3adc + CenterForDigitalHumanities/Compare-Pages@cd4d134.
2 TPEN_HYDRATED_CONTEXT projection mismatched its name Code change — 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.
3 #sendTPENContextToTool wrapper redundancy Code change — inlined in 1af3adc.
4 Compare to canvas-panel listener Out of scopetranscription-canvas-panel/index.js is orphaned (flagged out of scope in the plan). Leaving alone here; eventual removal is its own piece of work.
5 Other line verbs (highlight, act-on) Deferred by design — kept NAVIGATE_TO_LINE intentionally narrow. New verbs (HIGHLIGHT_LINE, FLAG_LINE, etc.) get added when each becomes a concrete need with a defined parent-side behavior, rather than designing speculative envelopes now. Noted in the PR description.
6 NAVIGATE in transcription-block, or watch TPEN.screen.activeLine? Follow-up — agree this is the right architectural question. The current cut 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.

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.
thehabes added a commit to CenterForDigitalHumanities/Compare-Pages that referenced this pull request May 8, 2026
)

* 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>
thehabes added a commit to CenterForDigitalHumanities/TPEN-Prompts that referenced this pull request May 8, 2026
)

* 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>
@thehabes thehabes merged commit ff3dde1 into main May 8, 2026
2 checks passed
@thehabes thehabes deleted the 5-2-tools-align branch May 8, 2026 21:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants