Gradient tile rectangles were dropped on import. PowerPoint's "from
corner" radial presets depend on them: fillToRect puts the focus on a
corner and tileRect stretches the gradient's box a whole size past it, so
the circle is centred on that corner and reaches the far one. Without it
(after the two-point radial change) such fills washed out. A linear
gradient now spans its tile rectangle; a radial one ends on the circle
around it, with the focus still placed in the shape's own box (fitted to
PowerPoint's export of the corpus deck tdf114848: mean diff over the
filled box 0.77). Round-trips through pptx.
Wild synthetic set: tile-rect gradients 16.74 -> 2.48.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Every transition and Effect Option was built in PowerPoint (COM), exported as
video and compared frame by frame with DeckCraft at the same progress
(mean abs diff over 150 variants 35.7 -> 15.0, 0-255).
- anim: transition_frame(kind, option, t, aspect) with measured curves
(smoothstep fades, ease-in-out moves, Cover/Uncover/Pan curves), directions
and option meanings, soft edges as per-corner opacity, 3-D effects as
perspective-projected cards, Dynamic Content moving slide contents over
cross-fading backgrounds (Source::OldBack/NewBack/OldContent/NewContent);
layer_mesh subdivides perspective quads so textures stay straight.
- New options: Page Curl Single, Glitter Hexagons, Shred In/Out, Zoom and
Rotate; PowerPoint-style Effect Options labels.
- render: composite_transition (CPU twin of the show), render_slide_part,
render_transition; deckcraft-cli render-transition FILE --slide N --t T.
- show: per-vertex opacity meshes, slide parts rendered once per transition.
- pptx: PowerPoint's elements and defaults (p:strips diagonal wipes, p14:warp
and p:newsflash for Zoom, p:zoom for Shape In/Out, glitter pattern, shred
rectangle/dir, prstTrans invX per preset, pageCurlSingle, defaults for
randomBar/doors/window/pan/zoom); PowerPoint reopens DeckCraft's files with
the same effects.
- Tests: measured curves, probes of which slide shows where at t, option
coverage, per-frame triangle budget, PowerPoint XML and defaults round trip.
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Engine: file.info and PowerPoint-style print slide ranges (#76)
file.info reports what File › Info shows (name, path, format, unsaved
changes, slide and hidden-slide counts, sections, size on disk,
properties). file.export's PDF `slides` also takes the range typed in
PowerPoint's Print page ("1-3, 5", 1-based); bad or out-of-range text is
an error instead of a silent full export.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* UI: the File tab (Backstage) like PowerPoint's (#76)
File (first ribbon tab) or Alt+F opens a full-window view in place of the
ribbon, slides and status bar: a side menu (Back, Home, New, Open, Info,
Save, Save As, Print, Export, Close; Account and Options at the bottom)
and the chosen page. Esc or Back returns; Up/Down walk the side menu and
show each page as it gets the focus; Enter runs Save/Close/Options.
Laid out after PowerPoint for Windows as observed: 160-pt menu a step
darker than the page, outline icons, current page in the accent colour.
The start screen is now File › Home (as in PowerPoint), with only Home,
New and Open at the top and no Back. Pages call existing commands and
file helpers: file.new {theme} and the sample deck (Home, New); recent
files with search, Browse and Recover Unsaved (Open); file.info and
file.properties, Accessibility Checker, Revert (Info); file.save /
file.saveAs / file.export by type (Save, Save As); a PDF with the Print
page's slides, layout (full page, notes, handouts 1-9), hidden slides and
frames, opened in the system viewer or downloaded on the web, with a
page preview (Print, Ctrl+P); Create PDF, Create Handouts and Change File
Type (Export); appearance and About (Account); Preferences (Options).
Share (cloud) is left out.
Close and Ctrl+W now ask Save / Don't Save / Cancel about unsaved
changes instead of closing at once. The Export dialog shares
SlideApp::export_as with the Export page. New UI commands: app.backstage
{page}, app.closePresentation, app.print; ui.inspect reports the page.
New icons drawn in code: back, save as, account, recent, new file, open
folder.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Docs: File tab (Backstage) in ui-parity, gaps, checklist and progress log (#76)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Text: Ctrl+arrows, Ctrl+Backspace/Delete and the other text keys work like PowerPoint (#268)
Observed black-box in PowerPoint for Windows on synthetic text:
- Word stops (crates/model/src/words.rs): words, punctuation runs, symbol
runs, tabs, line breaks and each CJK character are tokens; trailing spaces
join the token before. it's, a-b, 3.14, 1,000 are one word; e.g., a_b and
x@y.com are not. Grapheme clusters never split.
- Ctrl+Left/Right and Ctrl+Shift+Left/Right move by those stops, across
paragraphs; with a selection they go on from its start/end. In a
right-to-left paragraph Left and Right swap.
- Ctrl+Backspace deletes to the previous stop; Ctrl+Delete deletes the rest
of the word, or spaces plus the next word, then applies PowerPoint's smart
spacing. With a selection both work from its start.
- Ctrl+Up/Down by paragraph, Ctrl+Home/End to the text's ends.
- End on a wrapped line goes past the trailing space and the caret stays
drawn at that line's end (TextSel::line_end); End again stays, Home
returns to the same line.
- Ctrl+]/[ and Ctrl+Shift+>/< change font size (egui's Period/Comma/
bracket key names now match registry shortcuts).
- Shift+F3 cycles lower -> Title -> UPPER; a caret changes its word.
- Double-click selects the word without trailing spaces; triple-click
includes the paragraph mark.
- Mac keeps Option for words and Cmd for line/text ends (Session::mac_keys).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* rustfmt
* Canvas: one caret, drawn at a wrapped line's end after End
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* #133: Selection pane engine: restack inside groups, Show/Hide All, Lock like PowerPoint
- arrange.bringForward/sendBackward/bringToFront/sendToBack and arrange.reorder work on a
group's children, which stay in their group (as PowerPoint's pane arrows and drag do);
steps that change nothing leave no undo step.
- selection.select (pane click: Ctrl toggles, children and top-level objects don't mix,
picking a hidden object shows it), selection.showAll (everything, children too) and
selection.hideAll (top level).
- shape.lock cascades through groups; locked objects can be selected, formatted and typed
in, but not moved, sized, rotated, flipped, reshaped or cropped (commands and canvas).
- PPTX: Lock reads and writes PowerPoint's full locking attribute set per element kind
(observed by saving locked objects from PowerPoint); partial locks aren't Lock.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Front-most first with groups folding; drag a row to restack among its siblings (accent
insertion line); Bring Forward / Send Backward arrows; Show All / Hide All; per-row eye and
padlock; double-click or F2 renames in place; Ctrl+click toggles, Shift+click selects a run;
arrows, Home, End and Esc in the list; right-click menu. Lock/Unlock on the canvas menu,
greyed handles on locked objects. egui_kittest tests drive the pane.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Text: input method composition as commands, one undo step per composition (#101)
text.compose shows an input method's preedit in place at the insertion point and replaces it on
each update; an empty preedit cancels and restores the text and selection. text.commit replaces
the composition with the result as one undo step. Any other edit, key or click settles an open
composition first. The composition is underlined under the baseline (thicker under the part being
converted), and the caret line is measured for empty placeholders too.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* UI: Korean, Japanese and Chinese input and interface text (#101)
The slide turns the input method on while it takes typing, with the candidate window at the
caret, and forwards egui Ime events to text.compose / text.commit, so a Korean keyboard composes
syllables instead of separate jamo. Interface text in scripts the interface fonts lack (Hangul,
Simplified Chinese, Thai...) borrows faces from the font database (installed fonts such as Malgun
Gothic; nothing bundled): file names, titles, notes, outline, sections, comments and typed text.
Ported and widened from WordCraft's #565/#840 fallback. The notes pane keeps text typed before
focus leaves in the same frame (Escape, another slide). Control channel: ui.ime.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
PowerPoint's Normal view draws shapes, pictures and text that sit partly
or wholly off the slide on the area around it, at full strength, with
their effects, master and layout graphics included; the slide show,
exports and thumbnails clip to the slide. DeckCraft clipped everything to
the slide in the editor too.
- render: `slide_extent`/`layout_extent` estimate how far a slide's
drawing reaches (boxes, outlines, shadow, glow, reflection);
`render_slide_drawn` also reports what a render actually drew (text
spilling out of its box, table rows that grew); `pasteboard_region`
picks the pixels the editor renders: the slide plus what reaches off
it and is in view, rounded to 256 px tiles so scrolling rarely
re-renders. A slide with nothing off it renders exactly as before.
- ui-egui: the canvas texture covers that region (slide and master
views); it re-renders only when the slide, zoom or the region changes,
and a second time only if text drew beyond the estimate.
- Hit testing, selection, marquee and moves already worked in slide
space; tests now cover them off the slide.
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- engine: add animation.painter (Alt+Shift+C) and animation.painterApply (Alt+Shift+V) supporting single-use and sticky modes
- tools: apply animation painter on canvas shape click
- edit: disarm animation painter on deselect (Escape)
- ui-egui: add Animation Painter button in Advanced Animation ribbon group
- tests: unit tests for single and sticky animation painting
- docs/parity: mark Animation Painter as done (breadth parity to 80%)
- contributors: add Johannesgauss to contributors/people.toml
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Three causes, each making links look like they "don't save":
- With the caret at a word boundary (end of text, empty paragraph),
insert.hyperlink formatted an empty range and changed nothing. It now
inserts the address (or the slide's title, or `text`) as linked text, as
PowerPoint does. Typing right after a link no longer extends it.
- The slide show ignored hyperlinks and shape actions. A click on one now
follows it (ShowState::follow): slides, next/previous/first/last, Last
Slide Viewed, End Show, and URLs; the pointer turns into a hand over links.
- The desktop app was built without eframe's `links` feature, so
ctx.open_url did nothing natively (also the ribbon's app page link).
Enabled as in DesignCraft, EffectCraft and PrintCraft.
Saving was fine: links round-trip through .deckcraft and .pptx.
generated with Claude Code
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(pictures): animated GIFs play, with pause (media.gifPlay)
GIF pictures used to show only their first frame. The renderer now decodes every frame (cached, capped at 256 MB and 1024 px a side) and draws the frame for each GIF's play time, passed in RenderOpts::gif_times; exports and thumbnails pass none and keep frame 1. The editor canvas and the slide show redraw only when a GIF reaches its next frame. A corner button on hover (or selection) pauses and resumes a GIF from the same frame, through the new media.gifPlay command.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(pictures): animated GIFs play, with pause (media.gifPlay)
GIF pictures used to show only their first frame. The renderer now decodes every frame (cached, capped at 256 MB and 1024 px a side) and draws the frame for each GIF's play time, passed in RenderOpts::gif_times; exports and thumbnails pass none and keep frame 1. The editor canvas and the slide show redraw only when a GIF reaches its next frame. A corner button on hover (or selection) pauses and resumes a GIF from the same frame, through the new media.gifPlay command.
Also adds my entry to contributors/people.toml.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Animated GIFs: cap frames (1000), canvas (4096²), per-GIF (64 MB) and cache (192 MB) memory
A few MB of hostile GIF could decode to millions of tiny frames or a 100 Mpx canvas per frame,
and eight cached GIFs could hold 2 GB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
It ran file.new, so each click silently added another blank presentation
and looked like it did nothing. Home is now a UI command (app.home) that
shows the start screen over the open presentations, with a "Back to …"
button; Escape or Home again goes back. Creating or opening a presentation
from it leaves it. While it's open, keys don't reach the hidden
presentation (only app shortcuts and Escape). ui.inspect reports `home`.
generated with Claude Code
egui::Popup::menu closes on any click inside the popup, so the "More
Colors… #" field of every colour drop-down closed the menu before a value
could be typed. The colour drop-downs now open through
widgets::color_popup (CloseOnClickOutside): picking a colour still closes
them (color_grid calls ui.close()), and the other buttons in them
(Gradient…, the Weight, Dashes and Arrows submenus of Shape Outline) close
them explicitly.
generated with Claude Code
* Morph: a changed geometry morphs its outline (a circle turns into a star)
Paired shapes that differ only in geometry (another preset, other adjust
values, a custom path) and fill colour no longer cross-fade: one shape moves
and its outline turns from the old geometry into the new one.
- geom::morph aligns two outlines the way manim does: the same number of
contours, the same number of cubic curves per contour pair, then every
point moves in a straight line. Closed contours are turned and reversed so
the points travel least (a triangle does not twist into a circle), extra
points go to the longest curves, and a sub-path without partner grows out
of (or shrinks into) a point, so a cube's faces appear and a donut's hole
closes. Fill mode, outline and fill rule switch at the midpoint.
- anim::morph_frame lists such shapes in MorphFrame::paths; the renderer
draws them with the morphed geometry (PathMorph, a new render_blend
argument next to TextMorph).
Ported from the path morph in alexplain (MIT, same author).
generated with Claude Code
* Morph: differing looks of an outline cross-fade instead of switching
When a sub-path pair differs in fill mode, outline or fill rule, the two
looks no longer switch at the midpoint. A fill or outline only one side has
fades out (or in), so a filled shape turning into a line loses its fill
gradually. When both fill, the even-odd side lies opaque underneath and the
other fades over it, so a donut's hole fills smoothly while it shrinks.
generated with Claude Code
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Flips mirror a shape's geometry, never its text. The text was drawn with the shape's full
transform, so every label on a flipped arrow came out mirrored (flipH) or mirrored upside down
(flipV). As in PowerPoint, the text rect keeps its flipped place and the text is mirrored back
about the rect's centre: flipH text stays upright, flipV (or both flips) turns it 180°, rotation
still applies, and the same holds for shapes inside flipped groups.
render::text_transform is that transform (plus vertical-text rotation). Drawing, the PDF text
layer and link boxes, morph text, the editing caret and selection, and click-to-place-the-caret
all use it, so a click lands on the character drawn under it.
Co-authored-by: lloyd <lloyd@stackworx.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
On a slide change the show kept a clone of the old slide's texture handle
as the transition's "old" picture, then rendered the new slide into that
same texture with set(). Old and new were the same image, so every
transition (except Morph, which renders into its own texture) blended the
new slide with itself and looked like an instant cut. The old texture is
now taken out of show.tex, so the new slide gets a texture of its own.
generated with Claude Code
* Sprint 1: bidi and Arabic typography engine
fonts: ISO 15924 script and a:latin/a:ea/a:cs slot classification;
shape_range shapes an item with the whole paragraph as HarfRust
pre/post context, explicit script/language/direction, glyphs kept in
visual order; per-grapheme-cluster fallback (slot face, theme script
font, OS cascade families, bundled craft-fonts face); GDEF ligature
carets.
text: UAX #9 levels for the whole paragraph (a:pPr/@rtl), itemization
by level, script, face and shaping style (paint-only changes no longer
split words), logical line breaking at cluster boundaries, per-line
L1/L2 reordering (L4 mirroring by the shaper); RTL start edge and
bullets, justLow kashida; caret Affinity, logical/visual movement,
bidi hit testing and selection as the union of visual boxes.
model/pptx: theme a:font script fonts and algn="justLow" round-trip.
engine: format.rtl (Right-to-Left Text Direction), justifyLow align.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Fonts: the bundled script face comes before installed fonts; no Microsoft family names
Arabic, Hebrew, Thai and Devanagari clusters try the craft-fonts face first, so every machine
draws the same, then openly licensed or OS-native installed families, then any installed face.
Installed Microsoft faces are still used through that scan, but not named (AGENTS.md §1.1).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Tayeb <tayeb@192.168.11.179>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* Tables: rows grow to fit their text, cell text in the PDF text layer (#13)
A stored row height (a:tr/@h) is a minimum, as in PowerPoint: each row grows so its cells' text fits. Tables from pandoc or python-pptx store h="0" and drew every row on top of each other. Cell editing (click to edit, cell outline) uses the same heights, and the PDF text layer now includes table cell text.
* Tables: a grown table's box covers its drawn rows; resize scales from drawn heights
Handles, hit tests and marquee used the stored frame, so rows grown to fit their text drew
below the selection box (a file with h=0 rows had a 0 pt tall box). Resizing divided by the
stored row total, which is ~0 for those files.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Morph: shapes move, resize and blend in the slide show
Morph used to fall back to a plain crossfade in the show: morph_pairs and
morph_xfrm existed, but nothing rendered frames with them.
- anim: morph_frame builds a frame from two slides. Paired shapes move along
morph_xfrm (eased); a pure fill colour change blends, other content changes
cross-fade on the same moving box. Unpaired shapes fade out/in, shapes with
entrance animations or hidden media stay out of the frame.
- render: render_blend draws such a frame in one pass: both backdrops
(background, master and layout graphics) cross-fade, every shape resolves
against the slide it comes from, so placeholders keep their own layout.
Renderer::slide is split into canvas/fields/backdrop to share the code.
- ui-egui: the show renders morph frames with these, crossfade otherwise.
generated with Claude Code
* Contributors: add my own entry (alexanderalber)
generated with Claude Code
* Morph: Words and Characters options morph text token by token
The transition option (objects/words/characters) was stored and round-tripped
but ignored by the show. With Words or Characters, paired shapes whose text
differs now morph their text instead of cross-fading it:
- text: GlyphRun records per glyph the paragraph character it draws, so
glyphs can be grouped into words or characters.
- anim: morph_frame reads the option and lists such pairs in MorphFrame::text.
- render: morph_text draws them. Shared words (or characters) travel from their
place in the old text to their place in the new one and grow or shrink to the
new size; a different look cross-fades on the way. The remaining tokens fade
while following a scale and shift fitted to the shared ones, so a line moves
and rearranges as one. Rotated text falls back to a cross-fade.
draw_layout_with's glyph drawing moved into draw_glyph for reuse.
- ui-egui: the show passes the pairs to render_blend.
generated with Claude Code
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
The step clock ran before the frame's input actions were applied. A click
that started a step (PlayStep) was timed against the old step_start (when the
slide was entered), so after waiting longer than the step's duration the step
finished in the same frame: everything appeared at once, without animation.
Input actions are now applied before the clock is read.
generated with Claude Code
* About: contributors compiled in from contributors.json (grab bag + table, sortable, name toggle)
Adds the craftrules standard (standards/contributors.md) to DeckCraft's About window:
tabs About · Contributors · Models. contributors/contributors.json is generated by
craftrules/scripts/contributors.py and baked into the binary by crates/ui-egui/build.rs as
static tables. Each contributor line: @username, opt-in display/real name, PRs, commits,
lines +/−/Δ, binary assets +/−, first/last commit. Sortable by every column; alphabetical
ignores case and the leading @. The control channel's ui.dialog.open now honours its
documented `fields` param (e.g. About {"tab": "contributors"}).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Contributors: self-submitted names in contributors/people.toml; AGENTS.md credit-yourself section
build.rs reads contributors/people.toml next to the generated contributors.json and shows a name only
for entries the generator verified as written by that person (craftrules standards/contributors.md).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Contributors: regenerate (verified people.toml entries)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
- crates/media: probe (duration, rate/channels, size/fps; WMA/WMV recognised), audio decode to
f32 PCM (MP3, AAC M4A/ADTS, ALAC, WAV, AIFF, CAF, FLAC, Vorbis via symphonia; Opus with our
decoder in Ogg/WebM/MKV), video decode (MP4/MOV/WebM/MKV) with YUV->RGBA, poster frames,
trim/fade/volume, Player mixer + per-voice clock (device-clocked or wall-clocked), VideoFeed.
- Codec crates ported from FilmCraft: bitstream, h264, hevc, vp9, av1, isobmff, matroska, ogg, opus.
- insert.audio/insert.video probe and store duration/size and a poster frame; unplayable media
is embedded with a warning. New media.play/pause/toggle/stop/seek/info/posterFrame.
- UI: MediaHost (cpal output injected by the desktop app), control bar under media, video frames
on canvas and in the show; autoplay, click to play, loop, rewind, across slides, hide, full screen.
- Tests with in-code WAV/AIFF and CC0 self-generated fixtures (make.sh, ATTRIBUTION.md rows).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- apps/slidecraft-web: SlideApp in the browser via eframe's web runner (trunk; WebGPU with
WebGL2 fallback). Opens the sample deck (?blank for empty, ?show to present, ?webgl to force
WebGL2). Open/Insert use the browser file picker through Services::open_async + inbox;
Save/Export download; dropped files are read asynchronously.
- ui-egui: native-only synchronous drop handling (wasm uses the inbox), real clock on wasm
(js_sys::Date::now), About shows build commit/date when SLIDECRAFT_BUILD_SHA/DATE are set.
- .github/workflows/release.yml (push to `release` / workflow_dispatch): macOS universal
signed+notarized dmg, Windows x64/x86 signed msi + portable zip, Linux AppImage/deb/rpm/tar.gz
(x86_64, aarch64), Flatpak bundle, FreeBSD tar.gz (vmactions), web zip; draft GitHub Release
"SlideCraft v<version>" from [workspace.package] version. Same secret names as DesignCraft.
- freebsd.yml and packaging-lint.yml CI, ported from DesignCraft.
- packaging/: macOS (Info.plist, entitlements, cert import), Windows (WiX with new UpgradeCode,
.slidecraft ProgId, Open-with for pptx), Linux (.desktop, AppStream, MIME, nfpm, Flatpak),
FreeBSD and web scripts; shared setup in packaging/common.sh.
- apps/slidecraft/build.rs embeds icon + VERSIONINFO on Windows; slidecraft.icns/.ico generated
from the committed PNGs by packaging/icons.sh.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>