From 0b35bbbd28b0064cdc4a751741f0a96d2dbe24b0 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Fri, 8 May 2026 11:04:00 -0500 Subject: [PATCH 1/4] =?UTF-8?q?Standardize=20TPEN=20=E2=86=94=20tool=20mes?= =?UTF-8?q?saging=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- components/simple-transcription/index.js | 133 ++++++++++++++--------- components/transcription-block/index.js | 13 +-- 2 files changed, 85 insertions(+), 61 deletions(-) diff --git a/components/simple-transcription/index.js b/components/simple-transcription/index.js index f9525978..091857e4 100644 --- a/components/simple-transcription/index.js +++ b/components/simple-transcription/index.js @@ -969,10 +969,10 @@ export default class SimpleTranscriptionInterface extends HTMLElement { /** * Resolve each item in `page.items` to a full Annotation via the vault. * Vault fetches for AnnotationPages return children as bare `{id, type}` - * refs — downstream tools need hydrated targets/selectors/bodies. Returns - * a shallow copy of the page with the items array replaced; errors on - * individual items fall back to the original ref so partial hydration - * still produces a usable payload. + * refs — downstream tools that opt into the hydrated payload need hydrated + * targets/selectors/bodies. Returns a shallow copy of the page with the + * items array replaced; errors on individual items fall back to the + * original ref so partial hydration still produces a usable payload. */ async #hydratePageItems(page) { if (!Array.isArray(page?.items) || page.items.length === 0) return page @@ -983,9 +983,62 @@ export default class SimpleTranscriptionInterface extends HTMLElement { return { ...page, items } } - async #buildTPENContext() { + /** + * Find the layer page entry that matches the active page in the URL. Used + * to source the column ordering and the sibling-page list for the lean + * TPEN_CONTEXT payload. + */ + #getActiveLayerPage() { + const pageInQuery = TPEN.screen?.pageInQuery + if (!pageInQuery) return null + return TPEN.activeProject?.layers + ?.flatMap(layer => layer.pages || []) + .find(p => p.id?.split('/').pop() === pageInQuery) ?? null + } + + #getActiveLayerSiblings() { + const pageInQuery = TPEN.screen?.pageInQuery + if (!pageInQuery) return [] + const layer = TPEN.activeProject?.layers + ?.find(l => l.pages?.some(p => p.id?.split('/').pop() === pageInQuery)) + return layer?.pages?.map(p => ({ id: p.target, label: p.label })) ?? [] + } + + /** + * Lean boot payload sent to every iframe tool on load. Carries identity + * fields and URIs only — tools that need annotation bodies should fetch + * `annotationPage` directly, and tools that need the fully-hydrated + * project/page/canvas objects should send `REQUEST_HYDRATED_CONTEXT`. + */ + #buildTPENContext() { + const project = TPEN.activeProject ?? null + const layerPage = this.#getActiveLayerPage() return { type: 'TPEN_CONTEXT', + project: project ? { + id: project._id ?? project.id ?? null, + label: project.label ?? null, + slug: project.slug ?? null + } : null, + manifest: project?.manifest?.[0] ?? null, + canvas: this.#canvas?.id ?? this.#canvas?.['@id'] ?? null, + annotationPage: this.#page?.id ?? null, + currentLineId: this.#getCurrentLineId(), + columns: layerPage?.columns ?? [], + siblings: this.#getActiveLayerSiblings() + } + } + + /** + * Heavy payload sent only in reply to `REQUEST_HYDRATED_CONTEXT`. Carries + * the full active project, the hydrated page (items resolved to full + * Annotations via the vault), and the full canvas object. TPEN-Prompts + * needs this for prompt-template rendering; most tools should use the + * lean `TPEN_CONTEXT` instead. + */ + async #buildHydratedTPENContext() { + return { + type: 'TPEN_HYDRATED_CONTEXT', project: TPEN.activeProject ?? null, page: await this.#hydratePageItems(this.#page), canvas: this.#canvas ?? null, @@ -998,10 +1051,15 @@ export default class SimpleTranscriptionInterface extends HTMLElement { targetWindow.postMessage(message, this._iframeOrigin) } - async #sendTPENContextToTool(targetWindow = this.#activeToolIframe?.contentWindow) { - this.#postToTool(await this.#buildTPENContext(), targetWindow) + #sendTPENContextToTool(targetWindow = this.#activeToolIframe?.contentWindow) { + this.#postToTool(this.#buildTPENContext(), targetWindow) } + // The TPEN ID token is the most sensitive message in this protocol. It is + // user-gated (only sent in reply to REQUEST_TPEN_ID_TOKEN), posted to the + // iframe origin only, and surfaced via a toast so the user always sees the + // grant. Do not send it unprompted, broadcast it with targetOrigin '*', or + // log it. #sendIdTokenToTool(targetWindow = this.#activeToolIframe?.contentWindow) { const idToken = TPEN.getAuthorization() @@ -1095,37 +1153,11 @@ export default class SimpleTranscriptionInterface extends HTMLElement { this._iframeOrigin = new URL(tool.url).origin iframe.addEventListener('load', () => { - const target = iframe.contentWindow - this.#sendTPENContextToTool(target) - - this.#postToTool({ - type: 'MANIFEST_CANVAS_ANNOTATIONPAGE_ANNOTATION', - manifest: TPEN.activeProject?.manifest?.[0] ?? '', - canvas: this.#canvas?.id ?? this.#canvas?.['@id'] ?? '', - annotationPage: this.#page?.id ?? '', - annotation: TPEN.activeLineIndex >= 0 - ? this.#page?.items?.[TPEN.activeLineIndex]?.id ?? null - : null, - columns: TPEN.activeProject?.layers - ?.flatMap(layer => layer.pages || []) - .find(p => p.id?.split('/').pop() === TPEN.screen?.pageInQuery)?.columns || [] - }, target) - - this.#postToTool({ - type: 'CANVASES', - canvases: TPEN.activeProject?.layers - ?.find(layer => layer.pages?.some(p => p.id?.split('/').pop() === TPEN.screen?.pageInQuery)) - ?.pages?.flatMap(p => ({ id: p.target, label: p.label })) ?? [] - }, target) - - this.#postToTool({ type: 'CURRENT_LINE_INDEX', lineId: this.#getCurrentLineId() }, target) + this.#sendTPENContextToTool(iframe.contentWindow) }) const sendLineSelection = () => { - const currentLineId = this.#getCurrentLineId() - this.#postToTool({ type: 'UPDATE_CURRENT_LINE', currentLineId }) - - this.#postToTool({ type: 'CURRENT_LINE_INDEX', lineId: currentLineId }) + this.#postToTool({ type: 'UPDATE_CURRENT_LINE', currentLineId: this.#getCurrentLineId() }) } this.#toolCleanup.onEvent(TPEN.eventDispatcher, 'tpen-transcription-previous-line', sendLineSelection) this.#toolCleanup.onEvent(TPEN.eventDispatcher, 'tpen-transcription-next-line', sendLineSelection) @@ -1143,35 +1175,32 @@ export default class SimpleTranscriptionInterface extends HTMLElement { this.checkMagnifierVisibility?.() } - #handleToolMessages(event) { + async #handleToolMessages(event) { // Validate message origin if iframe origin is set if (this._iframeOrigin && event.origin !== this._iframeOrigin) { return } - if (event.data?.type === 'REQUEST_TPEN_ID_TOKEN') { + const type = event.data?.type + if (!type) return + + if (type === 'REQUEST_TPEN_ID_TOKEN') { this.#sendIdTokenToTool(event.source) return } - - // Handle incoming messages from tools - const lineId = event.data?.lineId ?? event.data?.lineid ?? event.data?.annotation // handle different casing and properties - - if (!lineId) return - - // Handle all line navigation message types - if (event.data?.type === "CURRENT_LINE_INDEX" || - event.data?.type === "RETURN_LINE_ID" || - event.data?.type === "SELECT_ANNOTATION" || - event.data?.type === "NAVIGATE_TO_LINE") { - // Tool is telling us to navigate to a specific line - // Line ID might be full URI or just the ID part + + if (type === 'REQUEST_HYDRATED_CONTEXT') { + this.#postToTool(await this.#buildHydratedTPENContext(), event.source) + return + } + + if (type === 'NAVIGATE_TO_LINE') { + const lineId = event.data?.lineId + if (!lineId) return const lineIndex = this.#page?.items?.findIndex(item => { const itemId = item.id ?? item['@id'] - // Match either full ID or just the last part after the last slash return itemId === lineId || itemId?.endsWith?.(`/${lineId}`) || itemId?.split?.('/').pop() === lineId }) - if (lineIndex !== undefined && lineIndex !== -1) { TPEN.activeLineIndex = lineIndex this.updateLines() diff --git a/components/transcription-block/index.js b/components/transcription-block/index.js index 8e8e4cc8..4a420ce5 100644 --- a/components/transcription-block/index.js +++ b/components/transcription-block/index.js @@ -155,16 +155,11 @@ export default class TranscriptionBlock extends HTMLElement { if (typeof index === 'number') this.scheduleLineSave(index) }) - // Window message handler for external tool communication + // Window message handler for external tool communication. Line + // navigation is owned by simple-transcription; this listener only + // 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") { - const lineIndex = this.#page.items.findIndex(item => item.id === event.data.lineId) - if (lineIndex !== -1) { - this.moveToLine(lineIndex, 'next') - this.updateTranscriptionUI() - } - } - if (event.data?.type === "UPDATE_LINE_TEXT") { if (typeof event.data.lineIndex === 'number') { this.shadowRoot.querySelector('.transcription-input').value = event.data.text From 4b3ba49d30a9f990ee043cacaab9fe805ef8be5c Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Fri, 8 May 2026 11:27:19 -0500 Subject: [PATCH 2/4] Document siblings[i].id as canvas IRI in TPEN_CONTEXT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- components/simple-transcription/index.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/components/simple-transcription/index.js b/components/simple-transcription/index.js index 091857e4..ea01db23 100644 --- a/components/simple-transcription/index.js +++ b/components/simple-transcription/index.js @@ -1009,6 +1009,12 @@ export default class SimpleTranscriptionInterface extends HTMLElement { * fields and URIs only — tools that need annotation bodies should fetch * `annotationPage` directly, and tools that need the fully-hydrated * project/page/canvas objects should send `REQUEST_HYDRATED_CONTEXT`. + * + * Note: each entry in `siblings` is `{ id, label }` where `id` is the + * **canvas IRI** for that sibling page (i.e. `page.target`), not the page + * IRI itself. This matches Compare-Pages' usage (it fetches the IRI as a + * IIIF canvas). Tools that need the page id should derive it from the + * canvas IRI or request the hydrated payload. */ #buildTPENContext() { const project = TPEN.activeProject ?? null From 1af3adc1897f532082f0f82ef60fcbe407c92067 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Fri, 8 May 2026 15:13:35 -0500 Subject: [PATCH 3/4] =?UTF-8?q?Address=20review:=20rename=20siblings?= =?UTF-8?q?=E2=86=92canvases,=20split=20populated=20payload,=20inline=20co?= =?UTF-8?q?ntext=20send?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- components/simple-transcription/index.js | 63 ++++++++++++++---------- 1 file changed, 38 insertions(+), 25 deletions(-) diff --git a/components/simple-transcription/index.js b/components/simple-transcription/index.js index ea01db23..dcbb252a 100644 --- a/components/simple-transcription/index.js +++ b/components/simple-transcription/index.js @@ -985,7 +985,7 @@ export default class SimpleTranscriptionInterface extends HTMLElement { /** * Find the layer page entry that matches the active page in the URL. Used - * to source the column ordering and the sibling-page list for the lean + * to source the column ordering and the canvas list for the lean * TPEN_CONTEXT payload. */ #getActiveLayerPage() { @@ -996,7 +996,7 @@ export default class SimpleTranscriptionInterface extends HTMLElement { .find(p => p.id?.split('/').pop() === pageInQuery) ?? null } - #getActiveLayerSiblings() { + #getActiveLayerCanvases() { const pageInQuery = TPEN.screen?.pageInQuery if (!pageInQuery) return [] const layer = TPEN.activeProject?.layers @@ -1007,14 +1007,15 @@ export default class SimpleTranscriptionInterface extends HTMLElement { /** * Lean boot payload sent to every iframe tool on load. Carries identity * fields and URIs only — tools that need annotation bodies should fetch - * `annotationPage` directly, and tools that need the fully-hydrated - * project/page/canvas objects should send `REQUEST_HYDRATED_CONTEXT`. + * `annotationPage` directly, and tools that need the fully-populated + * project or page should send `REQUEST_POPULATED_PROJECT` / + * `REQUEST_POPULATED_PAGE`. * - * Note: each entry in `siblings` is `{ id, label }` where `id` is the - * **canvas IRI** for that sibling page (i.e. `page.target`), not the page - * IRI itself. This matches Compare-Pages' usage (it fetches the IRI as a - * IIIF canvas). Tools that need the page id should derive it from the - * canvas IRI or request the hydrated payload. + * Note: each entry in `canvases` is `{ id, label }` where `id` is the + * **canvas IRI** for that page (i.e. `page.target`), not the page IRI + * itself. This matches Compare-Pages' usage (it fetches the IRI as a IIIF + * canvas). Tools that need the page id should derive it from the canvas + * IRI or request the populated page. */ #buildTPENContext() { const project = TPEN.activeProject ?? null @@ -1031,21 +1032,32 @@ export default class SimpleTranscriptionInterface extends HTMLElement { annotationPage: this.#page?.id ?? null, currentLineId: this.#getCurrentLineId(), columns: layerPage?.columns ?? [], - siblings: this.#getActiveLayerSiblings() + canvases: this.#getActiveLayerCanvases() } } /** - * Heavy payload sent only in reply to `REQUEST_HYDRATED_CONTEXT`. Carries - * the full active project, the hydrated page (items resolved to full - * Annotations via the vault), and the full canvas object. TPEN-Prompts - * needs this for prompt-template rendering; most tools should use the - * lean `TPEN_CONTEXT` instead. + * Reply payload for `REQUEST_POPULATED_PROJECT`. Carries the full active + * project (layers, pages, columns, members, …). Lean `TPEN_CONTEXT` only + * carries project identity, so tools that need the full graph must ask + * for it explicitly. */ - async #buildHydratedTPENContext() { + #buildPopulatedProject() { return { - type: 'TPEN_HYDRATED_CONTEXT', - project: TPEN.activeProject ?? null, + type: 'TPEN_POPULATED_PROJECT', + project: TPEN.activeProject ?? null + } + } + + /** + * Reply payload for `REQUEST_POPULATED_PAGE`. Carries the active page + * with items resolved to full Annotations via the vault, the full canvas + * object, and the current line id. TPEN-Prompts uses this for + * prompt-template rendering. + */ + async #buildPopulatedPage() { + return { + type: 'TPEN_POPULATED_PAGE', page: await this.#hydratePageItems(this.#page), canvas: this.#canvas ?? null, currentLineId: this.#getCurrentLineId() @@ -1057,10 +1069,6 @@ export default class SimpleTranscriptionInterface extends HTMLElement { targetWindow.postMessage(message, this._iframeOrigin) } - #sendTPENContextToTool(targetWindow = this.#activeToolIframe?.contentWindow) { - this.#postToTool(this.#buildTPENContext(), targetWindow) - } - // The TPEN ID token is the most sensitive message in this protocol. It is // user-gated (only sent in reply to REQUEST_TPEN_ID_TOKEN), posted to the // iframe origin only, and surfaced via a toast so the user always sees the @@ -1159,7 +1167,7 @@ export default class SimpleTranscriptionInterface extends HTMLElement { this._iframeOrigin = new URL(tool.url).origin iframe.addEventListener('load', () => { - this.#sendTPENContextToTool(iframe.contentWindow) + this.#postToTool(this.#buildTPENContext(), iframe.contentWindow) }) const sendLineSelection = () => { @@ -1195,8 +1203,13 @@ export default class SimpleTranscriptionInterface extends HTMLElement { return } - if (type === 'REQUEST_HYDRATED_CONTEXT') { - this.#postToTool(await this.#buildHydratedTPENContext(), event.source) + if (type === 'REQUEST_POPULATED_PROJECT') { + this.#postToTool(this.#buildPopulatedProject(), event.source) + return + } + + if (type === 'REQUEST_POPULATED_PAGE') { + this.#postToTool(await this.#buildPopulatedPage(), event.source) return } From 0ccf3f5939442c60339f9b46110c999274a6643e Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Fri, 8 May 2026 16:02:26 -0500 Subject: [PATCH 4/4] Guard tool message handler against unhandled rejections 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. --- components/simple-transcription/index.js | 48 +++++++++++++----------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/components/simple-transcription/index.js b/components/simple-transcription/index.js index dcbb252a..e4e5d3cf 100644 --- a/components/simple-transcription/index.js +++ b/components/simple-transcription/index.js @@ -1198,32 +1198,36 @@ export default class SimpleTranscriptionInterface extends HTMLElement { const type = event.data?.type if (!type) return - if (type === 'REQUEST_TPEN_ID_TOKEN') { - this.#sendIdTokenToTool(event.source) - return - } + try { + if (type === 'REQUEST_TPEN_ID_TOKEN') { + this.#sendIdTokenToTool(event.source) + return + } - if (type === 'REQUEST_POPULATED_PROJECT') { - this.#postToTool(this.#buildPopulatedProject(), event.source) - return - } + if (type === 'REQUEST_POPULATED_PROJECT') { + this.#postToTool(this.#buildPopulatedProject(), event.source) + return + } - if (type === 'REQUEST_POPULATED_PAGE') { - this.#postToTool(await this.#buildPopulatedPage(), event.source) - return - } + if (type === 'REQUEST_POPULATED_PAGE') { + this.#postToTool(await this.#buildPopulatedPage(), event.source) + return + } - if (type === 'NAVIGATE_TO_LINE') { - const lineId = event.data?.lineId - if (!lineId) return - const lineIndex = this.#page?.items?.findIndex(item => { - const itemId = item.id ?? item['@id'] - return itemId === lineId || itemId?.endsWith?.(`/${lineId}`) || itemId?.split?.('/').pop() === lineId - }) - if (lineIndex !== undefined && lineIndex !== -1) { - TPEN.activeLineIndex = lineIndex - this.updateLines() + if (type === 'NAVIGATE_TO_LINE') { + const lineId = event.data?.lineId + if (!lineId) return + const lineIndex = this.#page?.items?.findIndex(item => { + const itemId = item.id ?? item['@id'] + return itemId === lineId || itemId?.endsWith?.(`/${lineId}`) || itemId?.split?.('/').pop() === lineId + }) + if (lineIndex !== undefined && lineIndex !== -1) { + TPEN.activeLineIndex = lineIndex + this.updateLines() + } } + } catch (err) { + console.error(`[simple-transcription] tool message handler threw on type=${type}`, err) } } }