Skip to content

feat(files): preview .pptx in the docked pane, including uploads - #1122

Merged
philmerrell merged 11 commits into
developfrom
feature/pptx-preview-pane
Sep 16, 2026
Merged

philmerrell merged 11 commits into
developfrom
feature/pptx-preview-pane

Conversation

@philmerrell

Copy link
Copy Markdown
Contributor

Stacked on #1121 — based on claude/docx-artifact-viewer-439f94, so this diff is only the commits beyond it. GitHub will retarget to develop when #1121 merges.

What this does

Extends the docked preview pane to .pptx, wires it to uploaded files as well as generated ones, and fixes three defects found while verifying.

.xlsx is deliberately not included — see below.

xlsx — declined

Not a bundle-size call; three independent blockers:

  • SheetJS (xlsx) on npm is frozen at 0.18.5 (2022-03-24) with two unfixed advisories, including high-severity prototype pollution. npm audit reports fixAvailable: false — the fixes ship only from SheetJS's own CDN. Not something to parse user-uploaded files with.
  • ExcelJS hard-crashes (Cannot read properties of undefined (reading 'anchors')) on an openpyxl workbook containing a native chart — which is exactly what create_excel_spreadsheet's own documented example produces. Isolated: the identical workbook without the chart parses fine.
  • xlsx-preview, the only maintained ExcelJS-based grid renderer, returns <object type="text/html" data="blob:…"> per sheet plus an inline <script> with window.onload handlers. Angular's sanitiser strips the script; bypassing sanitisation on user-file HTML isn't a trade worth making.

Download-and-open remains the path for spreadsheets. Recorded in file-preview.model.ts so the next person doesn't re-litigate it.

pptx — shipped

pptx-preview 1.0.7 (ISC) renders 3/3 real agent-generated decks from the dev files store, including a 4.3 MB branded-template deck (7 slides, 15 images, 122 ms). Fidelity is genuine OOXML layout — decks built on an uploaded corporate template keep their branding from the slide master.

ECharts is stubbed out. pptx-preview imports it as a static namespace purely to draw native OOXML chart parts, which our decks never contain — create_powerpoint_presentation embeds charts as matplotlib PNGs via add_picture, and a survey of every .pptx in the dev store found zero chart parts. Shipping it anyway costs 337 kB gzipped, five sixths of the viewer's weight, and every release below 6.1.0 carries GHSA-fgmj-fm8m-jvvx.

lazy chunk minified gzipped
docx-preview (shipped baseline) 76 kB 18.5 kB
pptx-preview, echarts stubbed 213 kB 48 kB
pptx-preview, as-is 1.35 MB 357 kB

npm audit returns to its exact pre-existing baseline of 11 findings — this dependency contributes none. The cost is that an uploaded deck containing a native chart throws and reports as unreadable. shims/echarts-stub/README.md records the trade, and the two mechanisms that failed first (tsconfig paths doesn't reach a dependency's own imports; a file: spec under overrides resolves relative to wherever npm places the package).

Uploaded files, not just generated ones

The pane's only entry point was the Preview button on the generated-file download card. An uploaded deck fell through to the presigned-URL branch, which hands the browser an OOXML file it can't render — so "open" silently became "download", and the agent's fallback was to read the file and describe it in prose.

Attachment cards now route the formats the pane can render to the pane, keyed off the same isPreviewableFilename gate, so the two surfaces can't disagree.

Producer-tool fixes

Audited 17 sheets across four real workbooks rather than reading the docstrings. That corrected two hypotheses: get_column_letter, PatternFill, Alignment and Border are imported but demonstrated nowhere, yet the model already emits all four heavily (461/1150/1057 cells) — documenting them would be prompt-cache cost for no behaviour change.

The real gaps:

  • freeze_panes: 0 of 17 sheets. Every generated workbook scrolls its header off.
  • number_format: 16 cells across all 17 sheets. Currency renders as 1234567.891.
  • Formulas carry no cached value — all 4 in the corpus read back None under data_only=True. This blinds read_excel_spreadsheet to totals the agent itself just wrote, and Excel repairs it silently on open, so it would never surface as a bug report.
  • Speaker notes: 0 of 27 slides. read_powerpoint_presentation extracts notes the create tool never documented how to write.

System prompt

Two clauses, both from watching a real session.

Asked to preview an uploaded deck, the agent read it (9,053 characters of tool results into the conversation), then — pushed for a visual preview — called create_powerpoint_presentation and rebuilt the deck from scratch to obtain a Preview button. Not obtuse: FilePreviewStateService.open() has two callers, both user clicks, and no SSE event opens the pane, so its only verbs were read and create. Trimmed to four lines covering just that (+169 tokens of cacheable prefix).

Second: the KaTeX guidance said "other uses of $ should be use the HTML entity &#36;" with no scope, so the model applied it inside generated files — a deck came out holding the literal string &#36;100K, which is wrong in PowerPoint too. Now scoped to chat markdown.

⚠️ A prompt clause steers new conversations. Verified live: in a clean session "Show me the X deck" now answers "Click the Preview button" with zero tool calls; in a session that had already read-then-created four times, Haiku followed its own precedent.

Two defects found while verifying

  • A colour class on an <ng-icon> never applies — the component's host rule outranks it. The PPTX chip icon rendered black while the label beside it, with the identical class, came out orange. Fixed here by colouring the wrapper (ng-icon does inherit). ⚠️ Sampling the live DOM, none of the ng-icons carrying a colour class were getting it — text-gray-400 rendered black, text-gray-500 white — across ~449 call sites. Out of scope here, but real.
  • File-type chip text failed AA in light mode. Measured on the rendered DOM: 4 of 7 families under 4.5:1 (sheet 3.08, code/presentation 3.37, pdf 4.12). All clear at -700; dark mode was never affected.

Verification

Against a full local stack pointed at dev data, with inference-api running this branch:

  • Generated a real deck through create_powerpoint_presentation, clicked Preview, and read all four slides in the pane
  • Previewed a real uploaded deck — 10 slides, branding intact
  • Regression-checked .docx: correct viewer mounts, subtitle reads "Word document"
  • Rail resize re-fits without re-parsing; no horizontal overflow at 420/560/720 px; dark mode correct
  • Zero-slide guard fires on a real deck that trips it
  • 3,227 SPA tests and 1,572 backend tests pass

🤖 Generated with Claude Code

philmerrell and others added 11 commits September 15, 2026 14:17
…ching

Audited 17 sheets across four real agent-generated workbooks pulled from
the dev files store. Three gaps, all traceable to the docstring:

freeze_panes: set on 0 of 17 sheets. Every generated workbook scrolls its
header row off the top. The docstring never mentions it.

number_format: 16 cells across all 17 sheets carry anything but 'General',
and two of the four workbooks have none at all. Currency renders as
1234567.891 and rates as 0.1834. Also never mentioned.

Formulas are written with no cached result -- openpyxl does not evaluate
them. Excel fills them in on open, so a human never sees the problem, but
until then the cell is empty to every other reader: all four formulas in
the corpus read back as None under `data_only=True`. That blinds
read_excel_spreadsheet to totals the agent itself just wrote, and would
show a blank cell in the in-app preview pane.

Deliberately NOT added: column widths, fills, alignment and borders. The
audit shows the model already emits all four heavily (461 fill / 1150
alignment / 1057 border cells) with no example to copy, so documenting
them would be prefix cost for no behavior change. The three above are the
ones it never does unprompted.

Guidance is folded into the example the model copies rather than added as
separate notes, following the word-tool fix in 6498ad7.

This docstring is part of the cacheable `toolConfig` prefix, so it costs a
one-time cache re-write per session on the next turn after deploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
read_powerpoint_presentation extracts speaker notes, but
create_powerpoint_presentation never says how to write them -- the
toolset could read a capability it could not produce. Across four real
decks from the dev files store (27 slides), not one slide has notes.

The design guidance already pushes for sparse slides; without notes that
detail is simply lost rather than moved to where a presenter would say
it.

Same cacheable-prefix cost note as the Excel and Word docstring fixes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pptx-preview reads OOXML and reproduces each slide's own geometry,
including the theme inherited from slide masters and layouts — which is
what preserves branding on decks built from an uploaded template. Tested
against every .pptx in the dev files store: three real agent-generated
decks render correctly, including a 4.3 MB branded template deck with 15
images in 122 ms.

It reaches for ECharts in exactly one place, rendering a *native* OOXML
chart part, through a static namespace import that cannot be tree-shaken.
Our decks never contain one — create_powerpoint_presentation embeds
charts as matplotlib PNGs via add_picture — and the survey found zero
chart parts across all four decks. Shipping ECharts anyway would be
337 kB gzipped, five sixths of the viewer's weight, and every release
below 6.1.0 carries GHSA-fgmj-fm8m-jvvx.

So `echarts` resolves to a local stub instead: the lazy chunk is 48 kB
gzipped rather than 357 kB, and `npm audit` returns to its exact
pre-existing baseline of 11 findings — pptx-preview contributes none. The
cost is that an uploaded deck containing a native chart throws, and the
viewer reports it as unreadable. shims/echarts-stub/README.md records the
trade and why a top-level dependency is the mechanism that works
(tsconfig `paths` does not reach a dependency's own imports, and a
`file:` spec nested under `overrides` resolves relative to wherever npm
places the package).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The pane was already format-agnostic apart from the renderer, so this is
a viewer component plus a widened gate: previewKindFor() replaces the
docx-only predicate and drives both the viewer switch and the header's
subtitle, which was hardcoded to "Word document".

The MIME check now cross-references the extension rather than comparing
against a single constant. The extension picks the viewer before any
request is made, so a file named .pptx that the server records as a
.docx has to fail in the service rather than reach a renderer that
cannot read it.

Two things differ from the docx viewer, both forced by the library:

pptx-preview takes a fixed pixel size at init() and has no responsive
mode, so the deck is rendered once at 960px and CSS-zoomed to fit. A rail
drag re-fits without re-parsing. Same zoom-not-transform reasoning as the
docx viewer.

It also writes background, height and overflow as *inline* styles on its
wrapper — a black backdrop and a nested scroller — so those three
overrides carry !important. Without them the deck sits in a black
letterbox inside a second scrollbar, which is what it did until this was
caught in the browser.

The zero-slide guard is the one piece of behaviour with no counterpart in
the docx viewer. pptx-preview resolves *successfully* when it cannot make
sense of a presentation's theme or layout parts, returning no slides —
reproduced against PR873-Verification-Deck.pptx in the dev files store,
whose theme is stripped to 1.8 KB. Unchecked that paints an empty pane
with no error and no retry, which reads as the app being broken rather
than the file being unreadable.

.xlsx is deliberately still excluded, and file-preview.model.ts records
why: the npm build of SheetJS is frozen at a 2022 release with unfixed
advisories, and the only maintained grid renderer is built on ExcelJS,
which throws outright on a workbook containing a native chart — which is
what create_excel_spreadsheet's own documented example produces.

Verified in the browser against all four real decks: fidelity, fit at two
rail widths with no horizontal overflow, resize re-fit, the failure path,
and dark mode.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… one

The docked pane's only entry point was the Preview button on the
generated-file download card, so a deck the platform created previewed and
an identical deck the user uploaded did not. The uploaded one fell through
to the presigned-URL branch, which hands the browser an OOXML file it
cannot render — "open" silently became "download", and the agent's own
fallback was to read the file and describe it in prose.

Attachment cards now route the formats the pane can render to the pane,
keyed off `isPreviewableFilename` — the same gate the download card uses,
so the two surfaces cannot disagree about what is previewable. Markdown
keeps its modal and everything else still opens in a tab.

The button's accessible name follows the action rather than stating "Open"
for something that previews.

Verified against a real uploaded deck in dev: 10 slides, branding intact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ing $ into files

Two clauses, both prompted by watching a real session.

Asked to preview an uploaded deck, the agent read it, and when pushed for a
*visual* preview it called create_powerpoint_presentation and rebuilt the
deck from scratch — a lossy copy of a file already sitting in the session,
purely to obtain a Preview button, which only generated files had. It was
not being obtuse: the docked pane has exactly two callers, both user clicks,
and no SSE event can open it, so the model had no way to show anything. Its
only verbs were read and create.

The pane now also opens on uploaded files, so the prompt just has to say the
viewer exists and belongs to the user. It draws the line at "show me" (point
at the button) versus "tell me about" (read the file), because reading is
right for summarize/check/edit and wrong for looking — a text dump is not
what was asked for, and it puts the whole document in the cacheable history
where it is paid for on every later turn. That session had spent 9,053
characters of tool results on it.

The second clause fixes a systematic defect this work surfaced. The KaTeX
guidance said "other uses of $ should be use the HTML entity &#36;" with no
scope, so the model applied it inside generated files: a deck came out
holding the literal string "&#36;100K", which is wrong in PowerPoint too,
not just in our preview. I first wrote this off as noise because none of the
eight real files in dev contained the entity — but none of them contain a
dollar sign at all, so the corpus never had the chance to show it. Every
file that did carry currency reproduced it. The rule is now scoped to chat
markdown and explicitly excluded from files, code and tool arguments.

Verified against the running stack with inference-api pointed at this branch:
"Show me the preview-pane-test deck" now answers "Click the Preview button"
with zero tool calls, and a deck asked for with dollar amounts comes out
holding $100K, not &#36;100K.

Caveat worth knowing: this steers new conversations. In the session that had
already read-then-created four times, Haiku followed its own precedent and
rebuilt the deck anyway.

Base prompt grows 6,244 -> 7,695 chars (~362 tokens), so it costs a one-time
cache re-write per session on the next turn after deploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The gap that mattered — previewing an UPLOADED deck — is fixed in the UI
(9ab491e); it needs no prompt at all. What the prompt still has to prevent
is the model reading a file, or re-creating it, just to show it to someone.

Cut the explanation of what the viewer is, the uploaded-vs-generated aside
and the .xlsx exception, and kept the rule. 362 -> 169 tokens of cacheable
prefix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The card is the whole click target — there is no separate button — and the
only hint that clicking did anything was an "open in new tab" glyph that
faded in on hover. So at rest it advertised nothing, on a touch screen it
advertised nothing ever, and the glyph it did show described the wrong
action once .docx/.pptx started opening a side pane instead of a tab.

Previewable types now carry a persistent eye + "PREVIEW" in the header
strip, the same pairing the generated file's download card uses so the two
read as one feature. Other types keep the quieter hover hint.

Two contrast fixes found by measuring rather than eyeballing: the label
started at gray-500, which is 4.55:1 on the pptx header tint and scrapes
past AA by a hair, now gray-600 at 7.12:1; and the hover state used
primary-accessible, which is BSU navy and lands at 1.41:1 in dark mode --
effectively invisible. Hover is now a neutral emphasis that measures
14.92:1 there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The chip icon rendered black in both themes while the label beside it,
carrying the identical class, came out in the file type's accent. Cause:
a text-<colour> utility placed directly on an <ng-icon> never paints it —
the component's own host rule outranks a plain class. It does inherit, so
the accent now sits on the wrapper and reaches icon and label together.

⚠️ This is not confined to this card. Sampling the live DOM, none of the
ng-icons carrying a colour class were getting it: "text-gray-400" rendered
black, "text-gray-500" rendered white. They look right only where the
inherited colour happens to be right, and there are ~449 such call sites.
Out of scope here, but it is a real platform-wide bug.

PREVIEW now takes the same accent so the header strip reads as one unit,
at the same weight and opacity as the type chip rather than a dimmed
version — measured on the live DOM, the accent is already 3.37:1 on its
own light-mode tint, so anything held further back would be worse than a
label that is itself under AA.

⚠️ Also pre-existing and worth its own fix: that 3.37:1 is the type chip's
own contrast in light mode, below the 4.5:1 AA floor for text this size,
across every file type. Dark mode measures 8.60:1 and is fine. The likely
fix is a -700 step for the light-mode accent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…mode

The chip accents were set one step too light for the tints they sit on.
Measured off the rendered DOM rather than the palette, on the chips'
own light-mode grounds:

  family        -600    -700
  pdf           4.12    5.49
  doc           4.82    6.28
  sheet         3.08    4.72
  markdown      5.16    6.58
  image         5.78    7.24
  code          3.37    4.92
  presentation  3.37    4.92

Four of seven were under the 4.5:1 floor for text this size, including
both orange families used by the PPTX and HTML chips. Every family clears
it at -700, and the colour still reads as the file type. Dark mode was
never affected (8.60:1 on the pptx chip) and is untouched.

Applied to the five surfaces where the token colours chip *text*: the
attachment card, the generated-file download badge, the file card, the
artifact library and the shared artifact card. file-browser.page.ts keeps
-600 — there the token tints an icon, which answers to the 3:1 non-text
bar, and per the previous commit a colour class on an <ng-icon> does not
apply at all.

One caveat: the artifact-library and file-card chips sit on a -100 ground
rather than -50, where sheet lands at 4.4957:1 — short of 4.5 by 0.004,
inside sRGB rounding, and reported as 4.5 by tools that round. Every other
family there clears 5.0. Worth a look if that chip ever gets restyled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Artifacts pop their panel from onArtifact; a generated .docx or .pptx left
the user to find the Preview button themselves. Now the pane opens on it,
which is the whole point of having a viewer.

The hook hangs off tool_result rather than an SSE event of its own,
because the office tools do not have one — the download card is a
file_download inline visual inside the tool result. That placement also
gives the live-vs-hydrated distinction for free: tool_result only ever
arrives mid-stream, so reopening an old conversation replays the card
without seizing the rail, matching seedFromHydration on the artifact side.
Verified both ways in the browser.

It runs before the tool_use block lookup, not after, so an unmatched
result still surfaces its file — the block bookkeeping failing does not
mean the file is absent. Viewed-session only, the same guard as
onArtifact: a conversation streaming in the background must never take
the rail from the thread being read.

Formats the pane cannot render (.xlsx) are skipped rather than opening a
pane that could only show an error, and a legacy card carrying no
upload_id is ignored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Base automatically changed from claude/docx-artifact-viewer-439f94 to develop September 16, 2026 00:56
@philmerrell
philmerrell merged commit d818603 into develop Sep 16, 2026
@philmerrell
philmerrell deleted the feature/pptx-preview-pane branch September 16, 2026 01:59
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.

1 participant