Skip to content

feat(slide-deck): present imported PPTX decks in the page - #825

Merged
btopro merged 6 commits into
haxtheweb:masterfrom
SanikaA3:feat/2947-slide-deck
Sep 15, 2026
Merged

btopro merged 6 commits into
haxtheweb:masterfrom
SanikaA3:feat/2947-slide-deck

Conversation

@SanikaA3

@SanikaA3 SanikaA3 commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Refs #2947. This is the front end half — the element that consumes the deck.json written by the import route in haxtheweb/haxcms-nodejs#26.

<slide-deck source="files/decks/my-talk/deck.json"></slide-deck>

How it renders

Content is layered so a deck degrades rather than breaks:

  • Text always renders. The manifest's per slide text and speaker notes are what assistive technology and site search read, and are enough on their own for the deck to be readable and navigable.
  • The real slides are the enhancement. When the manifest names a pptx, the original file is painted over that text by @aiden0z/pptx-renderer (Apache-2.0), dynamically imported only once the deck intersects the viewport. A deck further down a page costs nothing until it is looked at, and if the renderer cannot run the text layer simply stays visible.

Only the renderer's low level API is used (parseZipbuildPresentationrenderSlide), so all navigation and chrome belongs to the element rather than the library. The painted surface is aria-hidden; the manifest text is the accessibility contract.

What's included

Slide and grid modes (grid pairs each slide with its speaker notes), prev/next with arrow key, Home and End support, per deck #<deck-id>-slide-<n> deep links, copy link, and fullscreen present mode. All twelve UI strings are translated across the nine scaffolded locales.

Two decisions worth flagging

pdfjs is deliberately left unconfigured. The renderer declares pdfjs-dist as a peer dependency, but only uses it for EMF embedded PDF fallbacks, loaded from a runtime URL. Wiring that up would mean fetching a library at runtime, so that narrow case falls back to slide text instead. Happy to revisit as a vendored opt-in if it turns out to matter.

Import paths resolve against the site base. source, pptx and the files/decks/<name>/ image paths the import route rewrites into slides[].html are all site relative, so they resolve against document.baseURI — the <base href> HAXcms points at the site root. Resolving them against location or against deck.json breaks them on nested page routes.

Verification

Built a six slide deck covering text, bullets, preset autoshapes, a table, an embedded raster image and a chart, and rendered it through the same low level path the element uses:

  • 6/6 slides rendered, zero renderer errors and zero console errors
  • correct 960×540 geometry picked up from the deck
  • ~430 ms cold end to end, including the renderer import; ~15 ms per slide warm

Element test suite is 7/7, covering manifest load, navigation clamping, grid mode, the text layer staying available when nothing is painted, and an unreadable manifest reporting rather than throwing.

The renderer path itself was verified separately against a real .pptx fixture — slide painted over the text layer, repaint on navigation, and aspect ratio derived from the deck. Those fixtures were temporary and are not part of this PR; a mutation check (pointing the manifest at a missing .pptx) confirmed the assertions were real, and incidentally confirmed the degradation path: the renderer tests failed while all element tests still passed.

Scope

This PR is the element only. The drag a .pptx into hax-body → "Render as presentation" workflow and the Merlin import program from the plan are not included, since they touch hax-store/hax-tray-upload and the site editor. #2947 should stay open for those and for the PHP backend parity.

🤖 Generated with Claude Code

Adds the front end half of #2947. The import route landing in
haxcms-nodejs#26 writes files/decks/<name>/deck.json plus the original
.pptx; this is the element that consumes it:

  <slide-deck source="files/decks/my-talk/deck.json"></slide-deck>

Content is layered so a deck degrades rather than breaks. The manifest's
per slide text and speaker notes always render and are what assistive
technology and site search read. When the manifest names a pptx, the real
file is painted over that text by @aiden0z/pptx-renderer (Apache-2.0),
imported dynamically only once the deck intersects the viewport, so a deck
further down a page costs nothing until it is looked at. If the renderer
cannot run the text layer simply stays visible.

Only the renderer's low level API is used (parseZip -> buildPresentation
-> renderSlide) so all navigation and chrome belongs to the element, not
the library.

Includes slide and grid modes, speaker notes, prev/next with arrow key,
Home and End support, per deck #<deck-id>-slide-<n> deep links, copy link,
and fullscreen present mode. The painted surface is aria-hidden; the
manifest text is the accessibility contract.

Two details worth flagging:

- pdfjs is deliberately left unconfigured. The renderer declares it as a
  peer dependency but only uses it for EMF embedded PDF fallbacks, loaded
  from a runtime URL. Wiring it would mean fetching a library at runtime,
  so that narrow case falls back to slide text instead.
- Manifest media paths are relative to deck.json, not to the page, so they
  are resolved against the manifest URL. Resolving against location would
  break every image on a nested page route.

Verified with a 6 slide deck covering text, preset autoshapes, a table, an
embedded image and a chart: 6/6 slides rendered with no errors, ~430ms
cold including the renderer import. Element suite is 7/7.

Refs #2947

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@SanikaA3
SanikaA3 requested a review from btopro as a code owner September 12, 2026 04:15
@vercel

vercel Bot commented Sep 12, 2026

Copy link
Copy Markdown

@SanikaA3 is attempting to deploy a commit to the HAXTheWeb Team on Vercel.

A member of the Team first needs to authorize it.

@vercel

vercel Bot commented Sep 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
webcomponents Ready Ready Preview Sep 15, 2026 3:07pm UTC

Request Review

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

URL resolution, unsafe manifest rendering, stale asynchronous state, missing notes, and renderer lifecycle issues currently affect correctness and security.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds a HAX-compatible slide-deck component for accessible, lazily rendered PPTX presentations.

Changes:

  • Implements slide/grid navigation, deep links, fullscreen mode, and fallback text.
  • Integrates the PPTX renderer with lazy loading.
  • Adds tests, localization, HAX metadata, documentation, and project scaffolding.
File summaries
File Description
elements/slide-deck/slide-deck.js Implements the presentation component.
elements/slide-deck/lib/slide-deck-renderer.js Wraps the PPTX rendering API.
elements/slide-deck/lib/slide-deck.haxProperties.json Defines HAX configuration.
elements/slide-deck/test/slide-deck.test.js Tests loading, navigation, accessibility, and errors.
elements/slide-deck/demo/index.html Provides the component demo.
elements/slide-deck/demo/deck.json Provides a sample manifest.
elements/slide-deck/README.md Documents usage and properties.
elements/slide-deck/package.json Defines package metadata and dependencies.
elements/slide-deck/locales/slide-deck.ar.json Adds Arabic UI translations.
elements/slide-deck/locales/slide-deck.ar.haxProperties.json Adds Arabic HAX translations.
elements/slide-deck/locales/slide-deck.bn.json Adds Bengali UI translations.
elements/slide-deck/locales/slide-deck.bn.haxProperties.json Adds Bengali HAX translations.
elements/slide-deck/locales/slide-deck.es.json Adds Spanish UI translations.
elements/slide-deck/locales/slide-deck.es.haxProperties.json Adds Spanish HAX translations.
elements/slide-deck/locales/slide-deck.fr.json Adds French UI translations.
elements/slide-deck/locales/slide-deck.fr.haxProperties.json Adds French HAX translations.
elements/slide-deck/locales/slide-deck.hi.json Adds Hindi UI translations.
elements/slide-deck/locales/slide-deck.hi.haxProperties.json Adds Hindi HAX translations.
elements/slide-deck/locales/slide-deck.ja.json Adds Japanese UI translations.
elements/slide-deck/locales/slide-deck.ja.haxProperties.json Adds Japanese HAX translations.
elements/slide-deck/locales/slide-deck.pt.json Adds Portuguese UI translations.
elements/slide-deck/locales/slide-deck.pt.haxProperties.json Adds Portuguese HAX translations.
elements/slide-deck/locales/slide-deck.ru.json Adds Russian UI translations.
elements/slide-deck/locales/slide-deck.ru.haxProperties.json Adds Russian HAX translations.
elements/slide-deck/locales/slide-deck.zh.json Adds Chinese UI translations.
elements/slide-deck/locales/slide-deck.zh.haxProperties.json Adds Chinese HAX translations.
elements/slide-deck/LICENSE Adds the Apache 2.0 license.
elements/slide-deck/index.html Redirects to component documentation.
elements/slide-deck/gulpfile.cjs Adds development watch tasks.
elements/slide-deck/.github/workflows/main.yml Adds build and deployment automation.
elements/slide-deck/.travis.yml Adds legacy test configuration.
elements/slide-deck/.surgeignore Configures Surge deployment files.
elements/slide-deck/.npmignore Excludes dependencies from publication.
elements/slide-deck/.nojekyll Disables Jekyll processing.
elements/slide-deck/.gitignore Defines ignored generated files.
elements/slide-deck/.editorconfig Defines editor formatting rules.
elements/slide-deck/.dddignore Configures DDD audit exclusions.
Review details

Suppressed comments (4)

elements/slide-deck/slide-deck.js:108

  • Changing source can leave the component in an inconsistent state: loadDeck() neither invalidates an earlier fetch nor disposes _renderer. A previously painted deck will therefore render the new manifest with the old PPTX, and a slower old request can overwrite a newer source; the retained slide number can also be out of range. Version/cancel loads and reset the renderer and slide when beginning a new source.
    elements/slide-deck/slide-deck.js:446
  • The backend's notes value is raw PPTX text rather than trusted HTML, so unsafeHTML(slide.notes) turns literal markup in speaker notes into executable DOM. Render notes through normal Lit interpolation (or sanitize them if markup becomes part of the contract).
    elements/slide-deck/slide-deck.js:474
  • Slide mode has another unsanitized slide.html sink. Even if the current importer escapes PPTX text, source accepts any absolute URL, so the component cannot treat every fetched manifest as trusted; sanitize before passing the value to unsafeHTML.
    elements/slide-deck/lib/slide-deck-renderer.js:84
  • The scale is calculated only when a slide is rendered. When the host width changes—especially on entering/exiting the advertised fullscreen mode—the stage resizes but this fixed transform does not, leaving the slide undersized or clipped until navigation triggers another render. Observe stage resizing and recompute the transform (and disconnect that observer during disposal).
    handle.element.style.transformOrigin = "top left";
    handle.element.style.transform = `scale(${target.clientWidth / this.presentation.width})`;
  • Files reviewed: 36/37 changed files
  • Comments generated: 8
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread elements/slide-deck/slide-deck.js Outdated
>
<div>
<strong>${this.t.slide} ${slide.number}</strong>
${unsafeHTML(this._resolveMedia(slide.html))}
if (!slide) {
return;
}
this.dispose(index);
Comment thread elements/slide-deck/slide-deck.js Outdated
presenting: { type: Boolean, reflect: true },
deck: { type: Object },
status: { type: String, reflect: true },
rendered: { type: Boolean },
Comment thread elements/slide-deck/slide-deck.js Outdated
this.status = "loading";
this.rendered = false;
try {
const manifestUrl = new URL(this.source, globalThis.location.href);
Comment thread elements/slide-deck/slide-deck.js Outdated
Comment on lines +144 to +145
// media inside the manifest is stored beside it, not beside the page
this._base = manifestUrl;
Comment thread elements/slide-deck/slide-deck.js Outdated
Comment on lines +189 to +193
if (!this._renderer) {
const { DeckRenderer } = await import("./lib/slide-deck-renderer.js");
this._renderer = await DeckRenderer.load(
new URL(this.deck.pptx, this._base).href,
);
Comment thread elements/slide-deck/slide-deck.js Outdated
Comment on lines +251 to +254
_readHash() {
const match = new RegExp(`^#${this.hashPrefix}-slide-(\\d+)$`).exec(
globalThis.location.hash,
);
Comment on lines +472 to +475
<div class="text">
<h3>${current.title}</h3>
${unsafeHTML(this._resolveMedia(current.html))}
</div>`}
SanikaA3 and others added 5 commits September 13, 2026 14:10
The PPTX import writes site relative paths: source is
files/decks/<name>/deck.json, pptx is files/decks/<name>/original.pptx,
and slide media in slides[].html points into files/decks/<name>/ too.

The element resolved source against location.href and everything inside
the manifest against the manifest's own URL. On a HAXcms page below the
site root the manifest 404'd, and where it did load, the pptx and media
paths doubled up as files/decks/<name>/files/decks/<name>/...

Resolve against document.baseURI instead, which is the <base href>
HAXcms points at the site root. Slide media then resolves on its own, so
_resolveMedia and the stored manifest base are removed.

Addresses Copilot review on haxtheweb#825.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Only one slide is ever on stage, but the renderer kept every slide handle
it had painted in a Map and only disposed a handle when that same index
was painted again. Navigating 1 -> 2 -> 3 left slides 1 and 2, and their
chart instances, alive until the element disconnected.

Hold a single handle and release it before painting the next slide. The
blob URL cache stays, since embedded media is shared across slides.

The scale was also computed once per paint, so entering full screen or
resizing the window left the slide undersized or clipped until the next
navigation. A ResizeObserver on the stage now refits it, and is
disconnected whenever the slide is released.

Addresses Copilot review on haxtheweb#825.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
slides[].html went straight to unsafeHTML. source accepts any URL, so a
manifest cannot be treated as trusted, and an event handler or
javascript: URL inside it would run in the page. Pass it through
sanitizeHTMLString from @haxtheweb/utils first, as other elements do for
the same sink.

Speaker notes are not markup at all: the import route stores the PPTX
notes text joined with newlines, unescaped. Rendering them with
unsafeHTML turned literal markup in a note into live DOM. Interpolate
them as text instead, keeping their line breaks with pre-line.

Notes also only appeared in grid mode, although the element documents
them as part of the text layer that assistive technology and search
read. They now render in the slide view as well.

The demo manifest's notes are now plain text to match what the import
writes.

Addresses Copilot review on haxtheweb#825.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
rendered was not reflected, so :host([rendered]) never matched and the
fallback text stayed visible beneath every painted slide.

Changing source left the element inconsistent. A slower earlier manifest
could overwrite a newer one, the previous deck's renderer stayed around
to paint the old pptx under the new manifest, and the current slide
could fall outside the new deck, crashing render on a null slide. Each
load now releases the renderer, ignores responses from superseded
loads, and clamps the slide into range.

The renderer was also only stored after its import, fetch and parse had
finished, so navigating in that window started another load per call
(three fetches of the pptx for 1 -> 2 -> 3) with paints racing each
other. The pending load is now shared, and a paint overtaken by newer
navigation, a mode change or a new source is dropped.

Addresses Copilot review on haxtheweb#825.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
deck-id, or the deck title, was interpolated into a RegExp. An id such
as "draft[1" threw while loading and left the deck unavailable, and an
id containing spaces never matched because the browser percent-encodes
the hash. The id is now URI-encoded once and matched as a plain prefix.

Syncing the hash also passed a bare "#fragment" to history.replaceState,
which resolves against the document base. On a HAXcms page that base is
the site root, so every slide change rewrote /site/my-page to
/site/#... The URL is now built from location.href, as copyLink already
does.

Addresses Copilot review on haxtheweb#825.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@btopro
btopro merged commit c11b09a into haxtheweb:master Sep 15, 2026
5 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 15, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants