Commit graph

2812 commits

Author SHA1 Message Date
Kayshen-X 72792315e9 fix(html): fold inline-flow blocks into one styled snapshot text node
A block whose children are all inline (text plus <a>/<code>/<span>) was
captured as one node per inline child, and a wrapped run's rect was the
union of its line boxes anchored at the block's left edge — so
consecutive runs shared an origin and painted on top of each other
(the "Tehnegindearing paints..." smear in rich paragraphs and tables).

Fold an inline-formatting context into a single text node positioned
once at the inline content's own box, carrying per-run styling (link
colour/underline/href, code monospace, bold/italic) as segments with
CSS whitespace collapsing across inline boundaries. Single-<code>-only
cells are left unfolded so their pill background survives.

Captures from the current extension (no segments) still import as plain
text. Resolves the follow-up noted in da83157b7.
2026-08-05 22:15:08 +08:00
Fini 3e52873fca feat(editor): give decks a full workspace of navigation, generation and export surfaces
One commit for the surfaces that grew together across the same chrome:

- a bottom filmstrip and a left-rail slides tab with real revision-keyed
  thumbnails; both navigate by zoom-to-fit and reorder by moving the
  board in child order — the slideshow's and every exporter's order —
  without touching geometry
- the scene template center becomes an asset center: a top-bar entry,
  a templates/styles tab pair, fifty style guides rendered as pinnable
  cards whose pin collapses the planner's menu to one entry, and a
  one-line prompt-to-deck row that wraps the topic so design-type
  detection reads it as a deck in all fifteen locales
- a top-bar export button whose menu orders itself by scenario, with
  powerpoint first on a deck and the pdf row skipping the format
  dialog; its row gating shares one predicate module with the file
  menu so the two entrances cannot drift
- the file menu split into spine and paint sibling to stay under the
  size cap, plus the i18n catalog for all of the above in fifteen
  locales

Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
2026-08-05 00:57:49 +08:00
Fini 8311f88fae feat(editor): one-click acp presets for kimi, gemini and qwen
The dynamic ACP channel already handshakes any agent a user types in;
this seeds it with quick-add rows for the three CLIs that ship ACP
entry points, prefilled from their current documented invocations. A
missing binary dims the row but never blocks the press — PATH is a
snapshot and the handshake is the real answer. Alongside, thinking
suppression for Moonshot models narrows to the exact ids that accept
the field: kimi-k3 switched to reasoning_effort where sending both is
a documented 400, so a prefix rule would break the shipped preset. A
new guard walks every builtin preset's default model and fails unless
its thinking intent is explicitly classified.

Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
2026-08-05 00:55:17 +08:00
Fini 9ea4ab62f9 feat(desktop): one-click mcp registration for twelve clis
Adds Gemini CLI, Qwen Code, Cursor, Kimi and ZCode to the settings
toggles, each shape verified against the real binary or its own
bundled schema rather than documentation: Qwen only treats httpUrl as
streamable HTTP (a type field silently downgrades it to SSE), Kimi is
two products sharing one binary name and the surviving one reads
~/.kimi-code, and ZCode nests its servers under mcp.servers with
sibling keys that a writer must not clobber. The shared JSON merger
stays idempotent, preserves foreign servers and unrelated top-level
keys, and old seven-slot settings migrate with every saved toggle
intact.

Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
2026-08-05 00:52:13 +08:00
Fini 001ecfd617 fix(editor): accept ime punctuation that commits without a composition
Chinese IMEs insert CJK punctuation instantly, with no marked-text
session. On the web host those characters were invisible: the hidden
input only listened for composition events, and keydown was blocked
while composing. Text now flows through beforeinput whenever the
hidden input truly owns DOM focus, and the printable keydown branch
closes in that state so nothing double-inserts. Host-side contract
tests pin bare commits landing at the caret across mixed composed and
bare sequences on both the chat input and canvas text editing. The
matching macos platform-layer fix lives in the casement fork and
travels separately.

Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
2026-08-05 00:50:22 +08:00
Fini cacb5c98d2 feat(editor): export a deck as an editable powerpoint file
Structured slides become real DrawingML: text lands as absolutely
positioned text boxes with size, weight, colour and exact point line
spacing (the percentage form multiplies each font's own line height
and drifts per family), CJK families are written into the east-asian
slot so PowerPoint does not substitute them away, and per-side strokes
become thin filled bars so a divider does not turn into a box around
editable text. Whatever DrawingML cannot express rasters alone at its
exact rect instead of being dropped. The two shipped deck templates
export with every node structured and open as 14-15 KB files.

Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
2026-08-05 00:49:25 +08:00
Fini 12bb05243f feat(editor): emit a deck as a hyperframes video composition
A deck's structured slide markup already carries everything a
frame-driven renderer needs; this walks the same emitter into a
composition file — one scene per visible board in slideshow order,
hold time driven by how much text a viewer must read (clamped to a
3-10s window), hard cuts with a short content fade, and zero external
references. The renderer runs it deterministically to an mp4; two
renders of the shipped template hash identically.

Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
2026-08-05 00:48:46 +08:00
Fini cc9b7889e2 feat(editor): grow the template library to five deck styles and a card tier
Adds a knowledge-card scene (vertical and square social cards) and
four presentation styles beside the original warm-white deck: a dark
pitch deck, a paper-white lecture deck, a minimal keynote and a
gradient-tech deck with glassmorphism built from opaque composited
stops — the linter drops alpha channels, and a truly translucent card
would exempt its text from contrast checking forever. Every palette
pair ships with measured WCAG ratios baked into the generators, and
low-contrast probe documents prove the audit actually fires on each
template family.

Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
2026-08-05 00:47:50 +08:00
Fini dfbc80a8d7 feat(ai): teach the planning corpus real deck design
The slides domain skill never reached mid- and small-tier models: the
compact allowlist dropped it outright on the basic tier, and the
standard tier's budget truncated it to a fragment. Fix the pipeline
(allowlist, a deck budget tier keyed off the fixed-size board, guards
that assert each skill's last line survives into the assembled
prompt), then grow the corpus itself: four style tiers with measured
contrast floors, slide skeleton patterns lifted from the shipped
templates, outline modes per deck kind, and keyword routing. A stray
'deck' trigger that spent the budget on card-stack guidance is
narrowed, and the CJK typography skill no longer loses its slot to a
larger optional skill.

Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
2026-08-05 00:47:10 +08:00
Kayshen-X 6a5a9c8ad1 chore(extension): add extension build/lint scripts to the workspace
Wire the extension's build (build-wasm/package-extension) and its
lint-chain guards (extractor-sync, sw-imports, locales) into the
packages workspace scripts. The packageManager pin is unchanged
(reordered to the object tail by the formatter).
2026-08-04 21:52:03 +08:00
Kayshen-X f2e5383e89 chore(extension): wire lint-ignore and version-sync for the extension
oxlint/prettier ignore the vendored snapshot-extractor copy and the
generated wasm output; check-version-sync gains a check that the
extension manifest tracks the workspace version.
2026-08-04 21:48:10 +08:00
Kayshen-X da83157b73 feat(html): overhaul HTML and browser-snapshot import fidelity
A multi-phase campaign to make importing real modern web pages
(Tailwind output, landing pages, component-library HTML) faithful.

Layout: bake CSS transforms (translate/scale/rotate incl. the
translate(-50%,-50%) centering idiom), emulate flex-wrap via row
chunking, honor grid span/line placement, apply position:relative
offsets through synthetic wrapper frames (jian has no offset-without-
reflow), aspect-ratio, per-child auto-margin alignment, and a
configurable viewport height (threaded through the CLI and MCP).

Content: list markers (incl. reversed/roman/alpha), basic table layout
(tr->row, colspan), <picture>/srcset candidate selection, @font-face
visibility warnings, background-size/position mapped to the image
fill's crop transform, and text-shadow.

Diagnostics: replace the untyped Vec<String> warnings with a typed
ImportWarning enum (stable per-variant codes, byte-identical Display so
CLI/MCP output is unchanged), localized into all 15 locales, surfaced
through a non-modal post-import diagnostics panel wired into both hosts.
The desktop path now forwards every warning, not just the first.

Snapshot import: fix inverted child paint order (the cause of whole
overlays vanishing under full-bleed backgrounds), stop clipping text to
the browser-measured box under font-metric drift, vectorize inline SVG
to editable paths, and capture per-corner radii, background paint,
position/z-index, video posters, page background, and open shadow DOM.

The extension's tokenless snapshot-ingress route lives here too
(op-host-services), scoped to a chrome-extension origin and the single
insert-only import tool.

Known follow-up: rich inline text runs (links/code spans + wrapping)
can still overlap in the snapshot path.
2026-08-04 21:47:49 +08:00
Kayshen-X 64d70d672d feat(extension): add OpenPencil web-capture Chrome extension
MV3 extension that captures the rendered active tab (via the shared
snapshot-extractor contract) and imports it into OpenPencil. Logic lives
in the new op-chrome-extension-core crate (wasm): endpoint rules,
chunked-transfer integrity, /mcp envelope + reply classification,
download-name sanitisation, SSO/account session parsing, and hub
snapshot-inbox delivery. JS is glue only (chrome.* APIs, fetch, popup
DOM, injected page functions).

Capture: full page + element pick. Delivery: local ingress
(POST /api/import/web-snapshot) with /mcp fallback, JSON download, and —
when signed in to OP Hub — the account snapshot inbox. Flat popup UI,
15-locale strings with an in-popup language switcher, store packaging.

Why a crate: keeps the security-sensitive logic in tested Rust rather
than glue JS, and the SW/popup split keeps dynamic import() out of the
service-worker graph (guarded by check-sw-imports).
2026-08-04 21:43:01 +08:00
Kayshen-X 439ba56f89 fix(desktop): make window close work on linux desktops without zenity
rfd's xdg-portal backend implements message dialogs by shelling out to
zenity, so on zenity-less desktops (e.g. KDE Plasma) every prompt
silently resolved to Cancel — most visibly the unsaved-changes prompt
swallowed the window close and the app could only be exited via kill.

Route all message dialogs through a new message_dialog wrapper: on
linux it probes zenity then kdialog once per process and drives the
tool directly so a runtime failure is detectable, and every call site
applies an explicit safe fallback (the close path saves and proceeds
instead of aborting) when no answer could be obtained. macOS and
Windows keep the stock rfd native dialogs.

Fixes #197
2026-08-04 21:09:35 +08:00
Fini f4c228c7ae feat(editor): export a deck as a self-contained slideshow html file
A presenter often cannot install anything: the deliverable has to be
one file that opens in whatever browser the conference laptop has,
with no network. The File menu on a deck document now writes a single
.html whose slides are structured markup — real selectable text,
inline SVG icons and shapes, CSS fills — positioned by the resolved
layout the canvas painted, so the browser never re-runs layout. Any
node the emitter cannot express faithfully is rastered alone into a
data URI at its exact rect rather than dropped, so a fully opaque
board degrades to the flat image this replaces and nothing ever goes
missing. Click or the slideshow key set advances; a counter names the
current slide.

Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
2026-08-03 21:30:06 +08:00
Fini 89abab771c feat(editor): export a deck as one pdf page per slide
A deck's PDF used to be the page-level export: every slide plus the
canvas gaps between them on one sheet. A slides-tagged document now
writes one page per visible board, in the slideshow's document child
order, each page exactly the board's size with no margin, so a PDF
viewer presents it the way Preview does. Deck documents also preset
the export dialog to PDF, and the web daemon's export route emits the
same file as the desktop.

Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
2026-08-03 21:30:06 +08:00
Kayshen-X e95d7f521e fix(collab): close a gesture whose release never arrives
A native modal or a focus switch can swallow the left-button release, and the
redraw pass gates collaboration draining on the gesture's capture — so a
queued admission approval would wait for some later unrelated click. Close the
capture on focus loss.

Also holds the initiator back in the accept regression test: without the pause
the kernel could already have buffered the first Noise frame, letting the test
pass even with the blocking-mode reset removed.
2026-08-03 21:15:32 +08:00
Kayshen-X 26bdc112f6 feat(collab): ship built-in regional collaboration hubs
Public relay was reachable only by setting OPENPENCIL_COLLAB_BOOTSTRAP_URL,
so a released build could never collaborate: no end user sets an environment
variable. Release builds now carry a hub URL per region, injected at compile
time from the release pipeline's secrets — the source tree keeps no
production endpoint, and an uninjected build stays environment-configured.

A persisted preference picks the region, exposed as a switch on the create
screen; the environment variables remain overrides that win and fail closed.
Each endpoint gets its own bootstrap cache record so switching regions cannot
overwrite the other hub's anti-rollback floor.

Also surfaces an unreachable device key as its own message instead of a
misleading reconnect notice, wraps notice text inside its bubble, and centres
button labels against their own height (28px buttons painted low).
2026-08-03 21:05:36 +08:00
Kayshen-X a7ea70aa12 refactor(collab): split the control-plane pairing wire tests
tests.rs stood at 851 lines, over the reviewability cap. Move the pairing
publish/claim wire cases to a sibling module; pure code motion, same 18 tests.
2026-08-03 21:05:11 +08:00
Kayshen-X b8fa940213 feat(collab): paint remote cursors with the agent pointer
Presence drew its own flat triangle and square label while agent activity
drew a shadowed pencil silhouette with a capsule tag, so the canvas carried
two unrelated languages for what is always 'someone else's pointer'. Share
the sprite painter and keep only the colour distinct. The silhouette geometry
helpers move to a sibling module to hold the 800-line cap.
2026-08-03 21:04:48 +08:00
Kayshen-X 2f7283d41e fix(collab): defer queued actions until the gesture's capture closes
A pointer press opens the gesture's local-edit capture and queues the panel
action it hit, but the same press's frame drained that action immediately.
The session's document actor refuses to act while a capture is open, so every
in-panel admission approval failed with LocalEditAlreadyActive and surfaced as
a bogus rejection on both peers. Wait for the matching release, and let a
release that leaves an action pending schedule the frame that drains it.
2026-08-03 21:04:22 +08:00
Kayshen-X 54bb01309a fix(collab): normalize blocking mode on accepted sockets
The handshake bounds its reads with set_read_timeout, which the OS honours
only on a blocking socket. Owner listeners poll with set_nonblocking(true),
and BSD/macOS accept inherits that flag onto the accepted stream (Linux does
not), so the first responder read returned WouldBlock instantly and the owner
tore down every inbound connection before the initiator's first Noise frame
could arrive — breaking both relay and direct-LAN sessions on macOS while
Linux CI stayed green.
2026-08-03 21:03:30 +08:00
Fini a8b9371135 feat(editor): give a presenting deck controls to drive it with
Presenting only worked for someone who already knew the keys. Nothing on
screen said the arrow keys did anything, clicking did nothing at all —
the reflex for advancing a slide anywhere else — and there was no way
back to the deck's start without walking it one board at a time. The
editing chrome stayed up throughout, so a deck was presented inside the
same rails, tool column and panels the user designs in.

So the presentation now carries its own controls and nothing else. A
pill at the bottom holds step back, the position, step forward, and
exit, with the step buttons faded at the ends of the deck where they
would do nothing. Clicking the board advances; clicking the toolbar does
not, because the toolbar hit-tests first and swallows its own presses,
counter and padding included. The presenter keys follow Keynote: Enter
and Down join Space and Page Down going forward, Backspace and Up join
Page Up going back, Home and End jump to the title and closing slides.
The rails, tool column, chat and status bar stop painting, so the stage
is the whole width under the TopBar — which stays, since it carries the
preview toggle and is therefore a visible way out.

Hiding the chrome is paint-side policy read from the presenting state,
not a state change: no panel is closed, so ending the presentation
restores the layout with nothing having to remember it. The press ladder
needed the matching half. Most of the chrome sits below the preview tier
and was already unreachable, but the StatusBar, the rail-resize gutters
and the property popovers sit ABOVE it, and a hidden widget that still
answers presses leaves dead patches over the slide — so that tier now
declines while presenting and the press falls through to the board.

Two decisions worth naming. Clicking the last board does nothing rather
than exiting: an accidental exit in front of an audience costs the
presenter their place, a dead click costs nothing. And the toolbar
carries icons and numerals only, so no locale key can go missing behind
it and no label can appear in the wrong language.

The presenter keys live in their own module ahead of the editor's
shortcut table rather than as arms inside it. Backspace, Enter, Home,
End and Space each already have an earlier arm there, so as arms these
would have to sit at the top of a ninety-arm match and stay there —
an ordering dependency nothing would catch when it broke.

Also fixes two latent ones. Entering a presentation left behind the
device frame that width inference had already built, and a left-over
frame makes exit start the device merge animation instead of leaving, so
Exit could not close the presentation at all. And the presenting press
recorded its point through the editor's screen-to-document mapping,
which treats the band under the hidden rails as off-canvas — a click
there resolved to no point and did nothing. It is tracked in screen
space now, where the slop threshold is already written.

Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
2026-08-03 01:03:35 +08:00
Fini 221192276b feat(editor): present decks as a slideshow in preview
Preview runs a document as the thing it is. For an app design that means
the interactive app preview it already had. For a deck it did not mean
anything useful: the boards sat side by side at canvas zoom, and the
auto-wire pass read the row of frames as app screens and routed them,
so previewing a deck showed one slide picked by route order with no way
to reach the others.

A deck now presents. Entering preview frames board 0 to fill the
viewport, arrow keys / Space / Page Up / Down walk the deck clamped at
both ends, a corner counter says where the presenter is, and Escape
leaves through the ladder position preview already owned. Boards are
the page's top-level frames in authored child order — the order the
author built them in — never re-derived from where they sit on the
canvas, which would renumber a deck whose slides were nudged around.

The presentation reuses the pipeline rather than forking it: the same
viewport fit that frames an opened document, the same scene painter
every preview uses, with one clip to the board's own rect so the
surround reads as a letterbox. Paint re-frames each pass, which is
where the true canvas size lives and what keeps a presentation framed
across a window resize.

Two things found on the way: a Figma import kept the previous
document's scenario, because that install path rebuilds the shell state
and only carried the geometry latch across; and the two web save-path
call sites of the new metadata field were only reachable from the test
build, so the wasm check had passed over them.

Web is unchanged: that bundle has no preview mode at all, so there is
nothing to present through. The state and transitions live in
op-editor-core, ready for it when preview lands there.

Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
2026-08-03 00:12:25 +08:00
Fini a28c0ed29b feat(editor): record what a document is for as a scenario tag
Preview, and later the chrome around it, has to treat a slide deck
differently from an app design. Deriving that from the document at use
time means guessing, and the only cheap signal — artboard size — is
wrong often enough to matter: 1920x1080 documents that are not decks
are ordinary work, and mislabelling one changes what the editor does
to it.

So record the answer at the moment it is a fact. Opening a scene
template knows the scene from the catalogue entry. A generation turn
knows it asked for a deck, but only establishes the document when the
page held nothing first: generating slides onto a canvas that already
carries someone's app design adds to their document, it does not
redefine it.

The tag is UI policy and never touches the document model, so it
round-trips beside the authored-geometry latch in editorMeta, and any
value the reader does not recognize decodes to "unknown" rather than
failing a load. The save chain now threads one EditorMeta instead of a
widening pair of loose fields, so the next field added to the metadata
is written by every writer at once.

Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
2026-08-02 23:44:20 +08:00
Fini e7016a3302 fix(canvas): attach frame-name labels to the frames they name
The label band was built from raw magic numbers — baseline 18 px above the
frame, hit box from 32 px above it — which left the text's visual bottom
15 px clear of the frame. Because the offset is screen-space and does not
scale with zoom, that gap is the same 15 px however far out the canvas is,
so on a zoomed-out board the name reads as floating in empty canvas rather
than belonging to the frame beneath it.

Shrink the band instead of scaling it with zoom. A constant screen-space
offset is the right model for chrome — it is why the label stays legible
at any zoom — and the defect was only that the constant was too big;
scaling by zoom would have kept the same too-tall band at 100% and made
the doc-space clearance a moving target that the generator cannot reserve
space for ahead of time. So the geometry is now derived from the type it
draws rather than picked: a 6 px gap from the text's descender to the
frame edge, plus the 12 px font's 9 px cap height and 3 px descent, plus
3 px of box padding. Baseline lands 9 px above the frame and the hit box
spans 21 px to 3 px above it — down from a 32 px band, with the visible
gap cut from 15 px to 6.

Paint, hit-test and cull all read the same derived constants, so they
cannot drift apart. Three invariants are now asserted rather than assumed:
the hit box never reaches the frame's top edge (otherwise a click just
inside the frame would select the root via its label instead of the node
under the cursor), the glyphs and the taller generating icon stay inside
the box that hit-tests them, and the text stays within ~8 px of its frame.

ROW_LABEL_HEADROOM stays at 240 doc px, re-derived against the narrower
band: the extra margin costs nothing on an infinite canvas and the
zoomed-out end of the range is worth over-serving. Comments there and in
the slide template are updated to the new number.

Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
2026-08-02 23:43:33 +08:00
Fini e5e84ea932 fix(agent): give wrapped canvas rows headroom for frame labels
The canvas paints each top-level frame's name ABOVE the frame at a fixed
screen-space offset that does not scale with zoom (canvas_frame_labels.rs
puts the baseline at sy-18 and the label box top at sy-32). A row gap
equal to the column gap therefore cannot work: at the zoom where a
wrapped canvas is actually viewed, the gap is narrower than the label
itself, so every second-row label sat pressed against the bottom edge of
the row above.

For the 3x2 deck the numbers are exact: 6000 doc px of content framed by
zoom_to_fit into a 944-1424 px canvas region lands at zoom 0.14-0.22,
where a 120 doc px row gap is only 16-26 screen px against a label that
always occupies 32. It overlapped at every realistic window size.

Add 240 doc px of headroom to the VERTICAL step only — 33-52 screen px at
that zoom, clearing the label with room to spare. Columns are untouched:
labels are left-aligned to their own frame and never reach sideways. The
slide-deck template takes the same allowance on top of its own gap, so
both the generated canvas and the shipped template space rows the same
way.

Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
2026-08-02 23:31:36 +08:00
Fini 2b01566aac feat(agent): wrap multi-screen roots into rows on the canvas
A fan-out laid every screen root in one unbroken horizontal strip. Six
1920px deck boards are ~12,000px across, so the result could only be read
by panning, and twenty boards would be unusable.

Wrap by BOARD WIDTH, not by design type or screen name: width is the fact
that makes a strip unreadable, whereas "is this a deck" is a guess that
misfires on every design whose naming we did not anticipate. A row budget
of 8200px lands where each device class wants it — 1920 decks wrap at 4
per row, 1200 desktop screens at 6, and 390 phone screens at 17, i.e. a
mobile fan-out is unchanged for any realistic count.

The row step reads the same resolved height the scaffold builds with, so
a plan asking for `height: 0` ("size me from content") steps down by the
device-class preset instead of overlapping row one.

The slide-deck template follows the same convention, wrapping at 3 per
row so the six-slide deck reads as the 3x2 grid its preview shows.

Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
2026-08-02 21:27:19 +08:00
Fini 17a8fdcfdf feat(panels): open documents with top-level layers collapsed
A fully expanded tree stops answering the one question the LayerPanel
exists for. A six-slide deck opens as ~90 rows, so the boards themselves
scroll off the panel and the user cannot see what the document contains
without scrolling past every leaf of slide one.

Collapse only top-level containers on load: leaves have nothing to hide
and would render a dead disclosure arrow. `collapsed_layers` is view-only
state — not serialized, not in the undo snapshot, no history push — so
this changes what the panel shows on open and nothing about the document.

Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
2026-08-02 21:21:23 +08:00
Fini ad2f0e4a6a fix(panels): scroll the template grid on trackpad, and lay the deck out
Two defects from the same blind spot: verifying a thing one way and
assuming the other way works.

Trackpad scrolling over the Scene Template Center moved the canvas while
the card grid sat still. Wheel deltas route through `apply_wheel_inner`,
trackpad pans through `apply_pan_gesture` — two ladders — and the panel
was wired into only the first. Every mouse-wheel check passed. The
Prompt Center's own host test already covered both paths; this adds its
twin, which fails without the fix.

The shipped deck template opened as one visible slide with five stacked
underneath it. Its generator never set `x`/`y`, so every board defaulted
to the origin. It went unnoticed because the deck was only ever
inspected through per-frame rendering, which draws each board alone —
the stacking exists solely in the assembled document. `knowledge-carousel`
has laid its frames out since it shipped, so this was breaking an
existing convention rather than lacking one.

The catalogue now asserts that every multi-frame template positions each
frame and that no two share a spot, so the next template cannot repeat
it. Fixed in the generator, not the artefact, so regenerating keeps it.

Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
2026-08-02 21:21:13 +08:00
Fini 6880c37a3b feat(web): mount the scene template center in the browser host
Closes the last layer of the template centre. The catalogue, templates,
panel and desktop entry all shipped; the browser could not open the
panel at all, so finished work was unreachable there.

Five arms mirror the native ones, each delegating to the shared flow:
paint in the floating-panel pass, press above the Prompt Center in the
overlay ladder, hover, scroll, and Escape. No web-only logic — that fork
is exactly what the shared layer exists to prevent.

Two defects surfaced while wiring it, both invisible to a
`wasm32-unknown-unknown` check:

- The File menu's `NewFromTemplate` arm existed only on native. The web
  match over `FileMenuChoice` was never updated, so "new from template"
  would not have compiled once anyone built with `canvaskit` — the
  feature that carries the real code path. `cargo check --target
  wasm32` passes without touching it, which is why the project requires
  `--features canvaskit` for web work.
- The hover arm initially returned on `owns_point` without calling
  `clear_hover_below_topmost_panel`, leaving hover live in the layers
  underneath. That is the same gate the colour-variable popover was
  missing on 2026-07-29.

Verified with the gate that actually covers this host: 537 tests under
`--features canvaskit` (13 without it), plus the wasm target check.

Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
2026-08-02 21:21:13 +08:00
Fini 0ca9aa6bca fix(agent): centre a deck board's content instead of stacking it at the top
A slide root is a fixed 1080 tall while its sections hug their own
height, so content piled up from the top edge and left the lower half of
every board blank — visible on every deck generated so far.

`is_deck` now travels from `NormInfo` through `CleanupPolicy`, so the
judgement is the design type the planner already decided, not a guess
from the board's dimensions: a 1920x1080 design that is not a deck is a
perfectly ordinary thing to make.

Only `justifyContent` is written. The alternative — stretching sections
to `fill_container` — would distort whatever composition the model
produced; centring moves where the block sits without changing what it
is. A board that states its own distribution (`space_between` and
friends) is a composition rather than the default top-stack, and is left
untouched.

Both cases are covered by tests that build a board and run real cleanup,
including one asserting the pinned 1080 height survives the centring —
the two repairs touch the same node and must not undo each other.

Verified end to end: six boards, all 1920x1080, all centred.

Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
2026-08-02 21:21:13 +08:00
Fini 7807c06aec fix(agent): pin a deck's board to the projector aspect
`adjust_root_height_to_content` resized a cover slide to 1920x2277
(measured 2026-08-02). The 16:9 board is what the entire slides contract
rests on — `domains/slides.md` states it to the model, the `Slides`
preset encodes it, and projecting is the use case — so growing the root
to fit its content quietly cancels all three.

The guard already existed: `adjust_root_height_to_content` takes
`preserve_root_height`, added for prompt-stated sizes. A deck simply
never reached it, because its 1080 comes from the design type rather
than from a number the user typed. Same protection, one more way of
stating the height.

The board size is overwritten rather than filled in when absent: a plan
of 1920x0 or 1200x675 is proposing a board that is not 16:9, and the
slide is the one shape here that is not negotiable. All three planned
inputs — zero height, wrong width, an already-grown height — are pinned
in tests.

Verified end to end: six roots, all 1920x1080, none off-aspect.

Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
2026-08-02 21:21:13 +08:00
Fini d5498a051e fix(agent): repair text that is invisible against its own background
A generated deck cover shipped its title at 1.10:1 — `#FFFFFF` on
`#F1F5F9`, blank to the eye. `detect_text_bg_contrast` had caught exactly
this since 2026-05, resolved variables and all; it had simply never run
during generation. The orchestrator calls one lint detector
(`detect_missing_progress_rings`); the rest only execute through the MCP
`lint_document` tool a user invokes by hand.

The detector deliberately suggests no replacement, because "which brand
colour belongs here" is an intent question. Choosing a *readable* one is
not the same question: at ~1:1 the text is not styled, it is missing.
Every candidate comes from the document's own palette, so this repairs
without inventing anything — the contract half of the self-check split.

The judgement is the measured ratio, never the variable name. "Text must
not use `$color-surface`" would be wrong: white on a dark board is
correct, and the shipped deck template's closing slide does that. Tokens
are then tried in semantic order rather than by raw contrast — picking
the highest ratio puts `color-bg-deep` (a background token) on light
boards, readable but wrong.

`low_contrast_text` exposes the resolved pair the repair needs; it lived
only inside the issue's prose `reason`, and a fix should not parse an
error message.

The first version of this pass silently repaired nothing. It read
variables with a hand-rolled `get("value")` while a shipped variable is
`{"type":"color","value":[{value,theme},…]}`, and its candidate names
(`color-text`, `color-text-strong`) exist in no generated document. Unit
tests passed because their fixture shared both wrong assumptions — code
and test confirming each other. Resolution now goes through the lint
crate's own resolver, the names come from a real run, and the fixture is
copied verbatim from one. Two full generations also failed to exercise
the pass (one broken, one where the model happened to choose readable
colours), so the trigger is now forced by an integration test rather than
left to a sampled model run.

Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
2026-08-02 21:21:13 +08:00
Kayshen-X eb535d0a10 fix(collab): classify secure relay transport failures 2026-08-02 20:56:28 +08:00
Eslam Ahmad fd7ebbec7c fix(web): handle the new-from-template file-menu choice (#196)
`FileMenuChoice::NewFromTemplate` was added with the scene-template work
and wired into the native host, but the web host's `match` was left at the
original eight variants. Rust requires exhaustive matches, so op-host-web
stopped compiling under its own feature:

    error[E0004]: non-exhaustive patterns:
      `FileMenuChoice::NewFromTemplate` not covered
      --> crates/op-host-web/src/widget_host/chrome_menu_press.rs:32:78

That breaks `cargo test -p op-host-web --features canvaskit`, which
rust-check.yml runs, and `cargo build --features canvaskit` for the
wasm bundle. The other wasm gates use `--features web`, so nothing else
catches it.

The arm yields `None` rather than a `FileAction`: templates are
desktop-only so far, `FileAction` has no template variant, and the panel
is not wired on web. Returning early instead — as the native host's
equivalent match does — would skip the lines below that close the menu
and clear hover, leaving the menu stuck open on click. `None` dispatches
nothing while still closing the menu, so the row is inert rather than
broken.
2026-08-02 19:46:11 +08:00
Kayshen-X 15ff2bea43 fix(collab): report relay join failure stages 2026-08-02 19:36:55 +08:00
Eslam Ahmad 7f8340f2a0 fix(renderer): shape complex scripts instead of painting them in storage order (#194)
Native `draw_text` painted every run through `Canvas::draw_str`, which maps
to Skia's `drawSimpleText` — a plain cmap lookup with no bidi reordering and
no contextual glyph selection. Arabic therefore rendered in storage order
with isolated letterforms. CanvasKit's `drawText` has the same limitation on
web, and `drawScriptRun` there segmented a run by script before drawing the
segments left to right, so bidi was resolved inside each segment and then
undone across them.

The paragraph shaper was bypassed natively on purpose: jian's
`draw_text_paragraph` builds a fresh `FontCollection` on every call, which
is what produced the ~605ms chrome frames noted in crates/CLAUDE.md. That
cost is avoidable rather than inherent — `ParagraphBaseline` already keeps a
generation-guarded collection — so this caches the collection and routes
only the runs that genuinely need a shaper. Latin and CJK stay on the cmap
fast path, leaving the frame budget and their rendered output untouched.

Measurement moves with paint. `measure_text_*` summed isolated-glyph
advances, so wrap decisions and caret geometry were computed against widths
the painter never used and text overflowed its boxes. The routing predicate
lives in op-editor-core so the two hosts cannot disagree about which runs
are shaped, and so paint and measure within a host cannot drift apart.

Hebrew is deliberately out of scope and stays on the fast path.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 19:18:50 +08:00
Kayshen-X 4e423530e9 fix(collab): tolerate public relay handshake latency 2026-08-02 18:39:21 +08:00
Fini 748c6a6c5c fix(agent): scaffold a deck's screen roots in one insert
A top-level `InsertSubtree` carrying a single frame is treated as
"replace the empty fresh-canvas starter"
(`command_root_replace::prepare_root_frame_replacement`), which matches
ANY empty root rather than only a starter. Screen-group scaffolding
emitted one such insert per root — and a scaffold root IS empty at that
moment — so every new root swallowed the one before it. A six-slide deck
arrived as a single board, always the last slide.

Measured before: six `InsertSubtree ... applied=true` lines, one
surviving root, then `expected 6 screen-group scaffold roots, got 1`.
After: six roots at 1920x1080, laid out left to right, `[FINAL] Ok`.

The replacement path bails on `nodes.len() != 1`, so emitting the roots
as one insert both fixes it and states the truer shape: N screen roots
are one scaffold, not N independent insertions.

The test moves with the contract. It asserted "one command per root",
which locked in the behaviour that was broken; it now asserts the root
count, top-level placement, and the `nodes.len() != 1` property that
keeps the starter-replacement path off.

Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
2026-08-02 11:44:32 +08:00
Fini 20b63fa5e4 feat(ai): give the planning corpus a presentation-deck tier
`DesignType::Slides` fixed the compact planning path, but the production
path (`run.rs` -> `build_orchestrator_prompt`, Rich mode) states no sizes
in code at all — the planner's sizes come entirely from this corpus, and
it offered exactly three: component 400, mobile 375, desktop 1200.

So a deck request put the model in an impossible spot: `domains/slides.md`
told it "each slide is a 16:9 frame, 1920x1080" while decomposition.md
gave it 1200 as the only usable width. Measured end-to-end with
deepseek-v4-pro: it did the one reasonable thing available and planned a
1200x675 board — 16:9, derived from the wrong base. After this change the
same prompt plans 1920x1080.

The deck tier is numbered 4 rather than inserted as 3, so every existing
"type 1/2/3" reference in this file and its siblings keeps its meaning.
Numbering it 3 would have made one line read "type 3" for both decks and
dashboards.

Also adds a scaffold test asserting one root per screen group with unique
placeholder ids — that layer is correct, which is what narrows the
remaining "6 groups, 1 root" failure to the apply stage rather than the
builder.

Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
2026-08-02 11:02:33 +08:00
Fini 546be97002 feat(agent): plan presentation decks at projector size, one slide per screen
`DesignType` had no notion of a deck, so "做一个季度汇报 PPT" was
classified as a landing page and planned at 1200x0. The slides guidance
in `skills/domains/slides.md` was still loaded and still told the model
"each slide is a 16:9 frame, 1920x1080" — so the rules and the skeleton
contradicted each other, and the skeleton is what gets built.

Three places had to agree, and each would have silently defeated the
other two on its own:

- `DesignType::Slides` with a fixed 1920x1080 preset. Deck words also
  join the component disqualifiers, so "PPT 封面卡片" reads as a deck
  rather than as a card, and the check sits ahead of the mobile one
  because a deck viewed on a phone is still 16:9.
- The planning prompt's size rule, which otherwise fell through to the
  1200x0 default and would have restated the contradiction in the very
  prompt meant to fix it.
- A `screen` label per subtask. `screen_groups::group_subtasks_by_screen`
  splits subtasks into separate root frames by that label; without it a
  six-slide deck collapses onto one root and renders as six sections
  stacked inside a single 1920x1080 board. The schema head enumerates the
  subtask fields and does not list `screen`, so the rule shows the field
  in a concrete subtask rather than only naming it — otherwise the model
  is choosing between two instructions.

Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
2026-08-02 11:02:33 +08:00
Fini bf4a028ce9 feat(i18n): translate the scene template center
Adds the 18 `sceneTemplate.*` keys plus `fileMenu.newFromTemplate` to
all 15 locales. Until now the panel fell back to hardcoded Chinese, so
every non-Chinese user saw Chinese labels in an otherwise translated UI.

Simplified Chinese is taken verbatim from `scene_templates.toml` and
`TemplateScene::title_fallback()`, so the catalogue and the catalogue's
translation cannot drift apart while both exist.

The hardcoded fallbacks in `scene_template_panel` stay: they now only
fire when a key is missing, which is a backstop rather than the normal
path — and one that has already earned its place, since the panel
shipped ahead of these keys.

A dedicated integrity test asserts all 18 keys resolve in all 15
locales, on top of the existing cross-locale key-set equality.

The catalogue size moves 1273 -> 1291. The handoff for this work said
1263 -> 1281, computed against a stale baseline; taking that number
would have failed the integrity gate outright.

Co-authored-by: Codex <codex@openai.com>
Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
2026-08-02 11:02:33 +08:00
Fini 52e0bf1078 fix(agent): give loop-built page roots the planner's section gap
The orchestrator path sets the root's inter-section gap twice before a
document exists: `plan_normalize` writes 16 onto a mobile plan whose gap
is missing or zero, and `scaffold::resolve_section_gap` falls back to 20
when building the root. The agentic tool-loop goes through neither — the
model builds its own root — so sections breathe only if the model
remembers to write `gap`, and when it does not, every section sits flush
against the next.

Measured across 117 shipped roots: 67 carried gap 16, which is
`plan_normalize`'s value rather than any model's choice, and the roots
with no gap at all came from the loop. It reads as one model's defect —
one was missing it 5 times out of 5 — but it is a per-PATH defect: the
same model wrote a gap through the orchestrator, and another omitted it
through the loop. Any model that does not write `gap` lands here, and
the loop is the path we are moving toward.

The repaired value reuses the constants the working path already uses,
split by device exactly as the planner splits it, so no third default
enters the codebase. Only an absent or zero gap is touched; an explicit
value, including a tighter-than-default one, is the author's.

Written as a `PatchNodeData` rather than an `apply_root_transform`: that
helper rebuilds the subtree and hands the root a fresh id, which is
right for restructuring passes and wrong for setting one number —
everything holding the root id would be left pointing at a node that no
longer exists (four cleanup tests caught exactly that).

Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
2026-08-02 11:00:34 +08:00
Fini 9ab4715ce1 feat(desktop): open scene templates from File > new from template
Completes the entry point: the File menu gains a "new from template"
row that opens the centre, and a chosen card becomes a document.

The template loads through `load_editor_state_from_source` — the same
loader a real file goes through — so a document that parses as JSON but
fails schema conversion cannot reach a user as a click that does
nothing. A test runs all four shipped templates through it.

It opens UNSAVED with no bound path, like File > New rather than File >
Open. The shipped `.op` is read-only content; binding it as the save
target would let the first Cmd+S overwrite the template for everyone who
used it afterwards.

Loading happens in the frame drain rather than the press handler because
replacing the document has to re-run the collaboration gate, which the
panel press — which only opens a panel — correctly did not.

Native wiring mirrors the Prompt Center's tiers: paint in the floating
panel pass, press above it in the overlay ladder, hover reporting
`owns_point` so canvas hover is suppressed underneath, scroll swallowed
by the grid, and Escape closing the panel ahead of selection.

File-menu row indices shift by one below the new row; the row-map tests
move with them. One of them asserts a hover value is passed through
verbatim rather than a row meaning, so its number stays put.

Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
2026-08-02 11:00:34 +08:00
Fini 8a2afc7b51 feat(panels): add the scene template center panel
The panel layer for "pick a scene, get a document": header, search, a
scene chip row, and a two-column card grid painted from the embedded
previews. Geometry, hit-testing, painting, and the press/scroll/hover
transitions are all in op-editor-ui, so both hosts can mount it without
either growing its own copy.

It deliberately mirrors the Prompt Center's shape — same panel size,
same chip row, same card grid — because a user who has met one should
not have to learn the other. What differs is what a card does: a prompt
lands in the chat input, a template becomes a document. So there is no
save form, and choosing a card does not open anything here. It raises
`pending_open` for the host to drain, because loading a document is a
host capability (unsaved-work prompts, recent files) that a widget
reaching into would have to reimplement per host.

Opening either centre closes the other: both are full-size centred
panels and two at once would stack card grids the user cannot see past.

Two gates learned from earlier bugs are covered by tests rather than
left to review: hover is rejected outside the card viewport, so a
pointer under the panel cannot light up a row scrolled out of sight; and
`hover_scene_template_center` returns whether the pointer is over the
panel at all, which is what a host needs to suppress hover underneath —
the exact gate the colour-variable popover was missing on 2026-07-29
when hover fell through to the layer below.

Text width comes from the Prompt Center's existing estimate rather than
a second model, so both chip rows size the same label identically.

Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
2026-08-02 11:00:34 +08:00
Fini 048d3cf36f feat(editor): add the six-page presentation template
The `slides` scene had a filter chip and no content. This fills it with a
deck that covers the shapes a report actually needs — cover, agenda,
three-point argument, metrics, chart with its takeaway spelled out, and a
closing — so a user replaces text rather than building structure.

Typography and layout follow the `slides` domain contract rather than
being eyeballed: 1920x1080 frames at a fixed size (never fit_content —
projector aspect is a hard constraint), 120px safe margins, body at
26-34, titles at 64, and the metric numbers at 140 so each data slide has
exactly one visual anchor. Frames are generated through `oplib` against
the canonical schema, like the other templates, so they stay reproducible.

The card baker learns to tile multiple renders into a grid. A 16:9 deck
composited as a 1x6 strip is a 10:1 image that shrinks each slide to
~100px inside the 640x400 card — visible as noise, not as slides. Column
count is picked to land nearest the card's own aspect, which puts this
deck at 3x2 and leaves each slide legible.

Also ignores `__pycache__` repo-wide; the generators are run in place and
were leaving bytecode next to their sources.

Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
2026-08-02 11:00:34 +08:00
Fini 20cfbddd4b feat(editor): add the scene template catalogue
First layer of the scene template center: the data model behind "pick a
scene, get a finished document". Templates differ from Prompt Center
entries in what they produce — a document to edit, not text for the
model — so each entry embeds its `.op` and the catalogue refuses to load
when one is missing. A prompt without a preview still works; a template
without a document is a card that does nothing when clicked, which is
worse than no card at all.

The three step-0 documents move from `templates/` into the crate's
assets: they are shipped content now, and one authoritative copy beats a
source and an embedded duplicate that drift. `templates/step0` keeps the
generators and their full-resolution renders — the workshop, not the
shelf — and `previews.sh` follows the documents to their new home.

Card previews are baked to the prompt-center convention (640x400 JPEG,
13-15 KB) by a new generator. The panel paints every card through one
fixed rect with `ImageDrawMode::Fill`, so a 2160x2880 page render or a
5880x1440 overview would be cropped to an unreadable strip; the baker
fits the whole design inside the card and pads with the document's own
backdrop, sampled from the render's corners.

The TOML value parsers move to a shared `catalog_toml` rather than being
copied for the second catalogue. Each catalogue keeps its own error type
so the messages still name the right asset file; only the parsing is
shared. Prompt Center behaviour is unchanged (its 14 tests still pass).

Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
2026-08-02 11:00:34 +08:00
Fini d64a4ffe9b feat(editor): add step-0 scene templates for the mass-market track
Three finished scene documents — screenshot tutorial, before/after
comparison, and knowledge carousel — plus their preview renders and the
generator scripts that produced them.

These have lived outside version control since 2026-07-27 while the
mass-market direction was still provisional. They are about to become
the seed content for the scene-template entry point, so the documents
and the scripts that regenerate them need to travel together: a template
whose generator is lost can only be edited by hand from then on.

The `_generators` scripts build the documents through the canonical
schema rather than exporting from a live editor, which keeps them
reproducible and free of editor-session state.

Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
2026-08-02 11:00:34 +08:00
Kayshen-X ecebc3e748 refactor(editor): move the collab join field onto the unified text input
The join field was a bare String with a hand-rolled whole-field-selection
flag and no caret at all. It now rides TextInputState + TextInputView like
every other chrome input: blinking caret, real selection highlight, and
forward deletion for free. The field joins the active_text_input resolver
so the caret blink drives the shared redraw wake-up, and the bespoke
join_address_selected flag (a repeated source of stale-selection bugs)
is gone.
2026-08-02 08:53:19 +08:00