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.
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
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
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
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
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
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
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
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
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
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
`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
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
`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.
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>
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
`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
`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
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
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
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
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
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
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
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
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.
The ~500-char opc1_ fragment is retired from every production surface; a
relay session now shares one 10-char Crockford code (1 region char + 9
random, 45 bits). The full invite is sealed with a blake3 encrypt-then-MAC
under keys derived from the code and stored on the locator control plane
under an independent code_id; guests claim from exactly the region the
code names, so neither the id nor the bearer ticket reaches uninvolved
regions. The store tombstones exhausted claim budgets (an id can never be
re-published under a burned code), caps codes per device key, and keeps
per-route ingress body limits. Connect failures now distinguish invalid,
expired, and relay-not-configured across all 15 locales, and the threat
model documents the operator-grindable 45-bit residual risk.
A pasted invite code now replaces the whole field instead of appending to
stale content, Cmd/Ctrl+A selects the field so Backspace/Delete clears it
in one stroke, and a clear button sits inside the input. IME commits keep
insert semantics; only clipboard paths replace. Every blur and navigation
arm drops the whole-field selection so no destructive replace-on-type
state survives a click.
Two halves of the same problem: the relay learned who collaborates with
whom, and a guest could not safely join a stranger's session without an
invite.
Claim-minimized relay bearer. The relay authenticated each WSS connection
with the full collaboration ticket, whose claims carry the account subject,
device id, and optional display name and avatar. Now that collaboration is
cross-account, that let a relay operator reconstruct a social graph. The
relay reads exactly one field out of that ticket — the expiry it clamps the
session deadline to — and its authorization output, (route, role, expiry),
comes from the signed locator and the route capability, not from identity.
The disclosure was gratuitous.
A separate audience-scoped token now carries only issuer, audience,
version, scope, the channel binding to the caller's X25519 key, and the
time bounds. `VerifiedRelayTokenClaims` deliberately exposes no identity
accessor at all, so the relay cannot regress into reading one. No route or
role claim was added: route authorization already comes from the locator
plus the capability secret, and putting route ids in the token would move
the graph to the issuer, which also knows the account.
Scope of the guarantee, stated in the code so nobody over-reads it: this
defends against a third-party or regional relay operator. It does not
defend against the first party, who runs both the issuer and the relay and
can rejoin on the channel-binding key and the issuance time. It also
de-identifies rather than making the view unlinkable — the device's X25519
static is persistent and in the clear in every hello, so the operator still
builds a device graph, it just cannot name the nodes or join them to the
account namespace.
The two token types are domain-separated by both JWS `typ` and `aud`,
strictly compared, with `deny_unknown_fields` on disjoint claim structs, so
each is structurally invalid against the other's parser. That property is
what makes sharing one signing key defensible, so it is tested in both
directions. The relay dual-accepts during migration, discriminating on
`typ` before claim parsing, behind an env flag. The client never retries a
rejected minimized token with the full ticket — that would be a downgrade
any curious relay could trigger at will.
Guest owner confirmation. A guest joining over unpinned LAN discovery still
required the same account, because it has no approval prompt of its own and
mDNS names nobody. It now gets the explicit decision the owner already had:
the verified owner identity is surfaced and confirmed before the peer is
authorized, so nothing from the session — snapshot, presence, session name
— exists before the user decides. With that gate in place the unpinned LAN
path admits any issued account too.
Display name and avatar are attacker-chosen, so the projection separates
them from the account subject and device id at the type level, strips
invisible and bidi-control characters, and labels them as claimed. A
display name cannot occupy an authoritative row; the test uses another
account's UUID as the display name to prove it.
Both halves are pinned in the boundary gate: losing either asymmetry is
silent, because the code still compiles and every other check still passes
while nothing authenticates the peer.
Peer admission required the remote ticket's subject to equal the local
account on both sides, so only devices of one account could pair. That
made the product multi-device sync rather than collaboration.
The subject equality was the authorization, so it is replaced rather than
deleted. `PeerIdentityPolicy` states which accounts a peer may belong to,
and the two sides get different answers because they do not have the same
ability to tell who the peer is:
- The owner accepting a guest admits any issued account. Nothing at this
layer decides whether the guest joins — a human does, from the approval
prompt, which is shown the verified identity and which the admission
state machine makes unskippable (`Active` is reachable only through
`OwnerAuthorized`).
- A guest joining by invite or relay admits any issued account, because
the invite's signed locator already pinned the owner's Noise static key
and that pin is checked before admission runs. The device is
authenticated whatever account is behind it, which is what makes joining
a stranger's session safe.
- A guest joining over an unpinned LAN discovery still requires the same
account. A guest has no approval prompt — whatever it accepts, it
accepts silently — and nothing else names the peer there: mDNS is
spoofable and no key is known in advance. Relaxing it would let anyone
on the segment holding any valid ticket pose as the owner, undetected.
Opening this needs a way for the guest to confirm who it is joining,
which is a user-facing decision, not a protocol change.
Relaxing the account relaxes nothing else: issuer, expiry, and the binding
to the observed Noise static key are unchanged, and renewal still refuses
any mid-session change of issuer, subject, device id, or key. Tests cover
that a foreign subject is admitted while each of those still rejects, and
the unpinned-LAN case has its own regression guard.
Also fixes two things this uncovered. The live MCP tests in op-host-desktop
drove the endpoint with raw HTTP and no token, so they failed with 401
after bc75c765b authenticated it — they now send the instance token, which
is also a live check that the authentication works. And two constant
relations in the transport config were runtime asserts that clippy rejects
as constant-valued; they are compile-time asserts now, which is what a
relation between constants should have been.
Pure code motion restoring the workspace file-size cap after the
conflict-stash feature pushed four files over it:
- op-editor-core: notice kinds and the discarded-edit projection move from
collab_ui_state into a new collab_notice_ui module; collab_ui_state
re-exports them so import paths stay stable.
- op-host-desktop: the local-edit gestures (begin/finish/reapply) move to
collab_runtime/local_edit; the discarded-edit stash handling moves to
collab_runtime/discarded_edit; the fail-closed paths and failure-notice
mappings move to collab_runtime/failure; the owner-retaining guest test
harness and the conflict e2e test move to collab_runtime/conflict_tests.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
The live MCP endpoint on 127.0.0.1 was a bypass of the collaboration
admission model. Its per-instance token authenticated only the ping probe
and shutdown, so document reads and writes were available to any local
process, and nothing validated Origin or Host — a page in a browser on the
same machine could reach it by DNS rebinding. During a session that is the
shared document, not just this user's file.
Every stateful call now requires the instance token, compared without an
early exit. Host must be a numeric loopback literal on the bound port, and
an Origin, when present, must match it; a request with no Origin still
works, which is what real CLI clients send. OPTIONS, initialize, and ping
stay tokenless so CLI discovery keeps working, and CollabGatePolicy is
untouched — this sits in front of it.
The `op` CLI did not send the token, so authenticating tool calls would
have returned 401 for every `op` invocation against a live editor. The
token was already in the port file next to the port; it is now resolved
with the port and travels as a header. Ping and shutdown keep their
existing tokenless wire contract.
Bootstrap cache: reads now degrade like writes already did. An unreadable
or corrupt cache leaves this start with no anti-rollback generation floor,
which is the position an absent cache has always left it in, and which the
threat model already accepts because deleting the file achieves the same
thing with no more privilege than corrupting it. Refusing bought no
security and cost the ability to collaborate at all. The tests state the
price plainly: with no floor the lower-generation document is accepted,
and `rollback_floor_armed` has to report it.
Threat model: correct an overstatement. Peer admission requires the remote
ticket's subject to equal the local account, so the product pairs only
devices of one account today. A relay operator reconstructs which devices
of an account sync and when — not a cross-account collaboration graph.
Also records that the relay reads exactly one field out of the ticket it
verifies, the expiry, which makes the identity disclosure gratuitous
rather than load-bearing, and states what a minimized credential would and
would not buy.
A guest edit that loses a collaboration conflict is no longer silently
dropped behind a generic toast. The cancellation now carries the dropped
EditChanges end-to-end: the conflict notice names the discarded nodes and
fields (dedicated EditConflictDiscarded notice kind so the detail can never
attach to an unrelated conflict toast), and the dropped property intent is
stashed so the collab panel can resubmit it on request through the new
op_collab::reapply_property_changes API. The stash is created only for
genuine concurrency losses (property conflict / precondition failure) —
policy, permission, and size rejections map to their own notices — and is
cleared on every session-Ended path so it cannot outlive its session.
Addresses one blocker (replay consumed the stash before acquiring the edit
lane), four concerns, and one nit from external review. Verified by
op-host-desktop collab (136), op-editor-core (19 collab), op-editor-ui
(52 collab), op-collab, and op-i18n suites.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Follow-up to 0649cf40, which split renewal concurrency away from initial
authentication so an unauthenticated flood cannot starve live tunnels.
`online_challenge_timeout_keeps_its_auth_concurrency_permit` encoded the
old coupling as its contract — it saturated `max_auth_in_flight` to make a
second online challenge contend — so after the split nothing contended and
the test hung the whole suite. The property it covers (a timed-out online
challenge neither leaks its permit nor spawns another blocking task) is
unchanged; it now saturates the renewal budget that actually governs that
path. Its initial-path sibling was already passing and is untouched.
Roster: add `release_disconnected_peer`. A departed guest is retained for
same-epoch resume, but that retention is unbounded and counts against
`max_participants`, so a session that churns through guests eventually
admits nobody and each retained peer holds its result window until the
epoch ends. Releasing is refused for the owner and for a still-connected
peer, and it does not weaken id-space safety: a namespace can only be
reissued when the document carries none of its ids, which is checked
against the document rather than the roster.
Tickets: replace the derived `PartialEq` on `OpaqueTicket` with a
constant-time comparison. Nothing compares a ticket against a stored
secret today — verification lives in the host auth layer — but the derive
inherited `String`'s early-exit compare, so any future `==` on
attacker-supplied input would have been a timing oracle.
Bootstrap cache: a failed write now degrades instead of failing the
bootstrap. The document was already fully verified; what a failed write
costs is the anti-rollback generation floor for the next start, and the
threat model already accepts a missing floor because deleting the cache
has the same effect. An unwritable configuration directory must not mean
"cannot collaborate". The degradation travels with the document rather
than passing silently. Reads stay fail-closed: an absent cache reads as
`Ok(None)`, so only a cache that exists and is corrupt or unreadable
takes that path, and that is an anomaly rather than a first run.
Threat model: document what the relay operator actually sees. The bearer
ticket it verifies carries the account subject, device id, and optional
profile claims, so it learns who collaborates with whom — the asset table
previously implied those reached session participants only. Also adds the
invite-as-bearer-capability lifecycle and the local MCP endpoint, which
during a session reads and writes the shared document outside the
admission story. The relay-client doc comment no longer claims the relay
"sees only bytes".
Addresses a security review of the collaboration subsystem. No auth
bypass, key leak, or document-plaintext exposure was found; every
finding below is availability or trust-boundary hardening.
Landed as one commit because the pieces are not separable: the
inbound-direction ceiling spans op-collab, op-collab-transport, and the
desktop host atomically, the guarded accept spans transport, smoke, and
the desktop host, and the boundary-gate rules only hold against the
final state. Splitting would produce commits that fail to build or fail
the gate.
Relay server (public, internet-facing):
- Charge pre-pairing capacity per source address. The auth-concurrency
semaphore was taken before the WebSocket upgrade and the peer address
was discarded, so one host could pin every permit by connecting and
going silent.
- Give renewals their own budget. Reauthentication competed for the same
semaphore, so an unauthenticated flood progressively closed live
tunnels with a policy error.
- Release the pair registration when the ready status fails to send; the
counterpart only reclaims it if it reads its pairing notice.
- Require the X25519 key file to be owned by the running user; mode bits
alone do not establish trust.
- Summarise capacity rejections instead of logging one line each.
Locator service:
- Rate-limit publishes per client instead of process-wide. One
unauthenticated caller could consume the whole budget and 429 every
tenant's invite issuance.
Collaboration protocol:
- Size the inbound envelope ceiling from the authenticated remote role
rather than sharing the 64 MiB snapshot ceiling in both directions, so
an admitted guest cannot force a 64 MiB JSON parse per frame. The
ceiling is applied before the discriminator and before the generic
value decode; a peer-declared snapshot kind cannot raise it.
- Reject display names carrying Unicode format characters, which render
identically to an existing participant's name.
- Reject avatar URLs pointing at non-globally-routable addresses.
Transport:
- Reclaim a pending-handshake seat from a peer that has not produced a
valid first handshake message, and raise the global ceiling. Sixteen
seats held for the full handshake window let four addresses deny every
join.
- Put inbound reassembly under an aggregate budget; only the outbound
aggregate was bounded.
- Stop heartbeats from refreshing the idle deadline in receive_transfer.
- Filter IPv4 link-local discovery advertisements, matching IPv6.
Relay client and trust roots:
- Bound server-initiated reauthentication per connection by count and
minimum interval, sized from the protocol's own cadence.
- Close the policy-file TOCTOU window by identity-checking the opened
file, and reject group/world-writable or foreign-owned policy files.
- Stop discarding bootstrap cache-write failures, which silently
disabled the anti-rollback generation floor.
The catalog shipped with the mobile-app and freeform galleries only, so
four of the six categories opened empty — a filter chip that leads
nowhere reads as a broken panel, not an empty one.
Adds eight entries covering the remaining categories (two web pages, two
dashboards, two component systems, two modify instructions), each with
its bilingual body, a generated preview, and a 15-locale title key. The
catalog-size constant moves 1255 -> 1263 accordingly.
Starter prompts gain previews too: they were the first thing a new user
sees and were the only category rendering as bare text.
The native raster test now decodes four of the new previews instead of a
single hard-coded one, so a re-generated asset that regresses decoding
is caught for more than one image.
Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
The harness could only drive fresh generation, so the modify-category
prompts had no way to produce before/after thumbnails: their whole point
is what changes about an EXISTING document.
`OPENPENCIL_SMOKE_MODIFY_INPUT=<baseline.op>` runs a real document
through the same `build_modify_plan` -> `run_modify_turn` -> scoped host
apply path the desktop uses, so a thumbnail reflects the shipping code
rather than a harness-only shortcut.
The baseline is never overwritten — the output must go to a distinct
`OPENPENCIL_SMOKE_OUT` — and both files are SHA-256 addressed in the
summary, so a thumbnail set can prove every modify prompt started from
the identical input.
Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
A node whose stock search came back empty was parked in a terminal
failure state with no way back, so a design shipped with placeholder
slots even when a second attempt would have found art. Split the retry
policy out of `image_enrich_cli` into its own `retry` module and give
the session an explicit `retry_search_failures` entry point that
re-admits those nodes for a bounded, caller-managed retry.
Only Search/Auto nodes are re-admitted: an explicit Generate target that
failed is never silently converted into a stock search, since that would
substitute different art than the design asked for. `image_request_mode`
makes that distinction a property of the node rather than something each
call site re-derives.
Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
`plan_is_sidebar_dashboard` accepted a sidebar signal from ANY subtask,
so a landing page whose plan happens to carry a nav/menu section was
built on the two-column dashboard scaffold — a sidebar rail down the
left of a page that should be a full-width hero stack.
Require the signal to come from the FIRST subtask (a real sidebar is the
leading section, not an incidental one), and let a plan's landing-page
anatomy veto an ambiguous signal via `plan_has_landing_anatomy`. An
explicit landing-page request now vetoes every dashboard signal, while
an explicit dashboard or admin-console request still wins ahead of the
structural check, so the unambiguous cases are decided by what the user
asked for rather than by section keywords.
Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
A prompt that names its canvas size ("1200x800", "390 宽") had no path
to the root frame: planning always applied the desktop/mobile defaults,
and cleanup was free to grow the root past whatever was asked for.
Parse the request once (`request_dimensions`), apply it during plan
normalization, state it in the compact prompt so the model builds to the
same number, and carry a `preserve_requested_root_height` flag into
cleanup through an explicit `CleanupPolicy`. The policy defaults to the
historical behavior — only the fresh-root orchestrator path opts in — so
append and modify runs are untouched.
Cleanup needs the RESOLVED height to honor that flag without collapsing
real content, so `geometry_validation` grows `resolved_node_height`,
measuring the laid-out subtree against the node's own top edge rather
than trusting the declared value.
Planning corpus follows: the desktop sizes are labelled "Desktop
default", so an explicit request reads as an override rather than a
contradiction.
Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
The line grammar anchors every pattern on `\)$`, so an operation
written as `img=G(...),` was rejected as unparsable. A trailing `;` was
already stripped; a `,` was not — and the comma is the costlier miss,
because a model reaching for a list separator writes it on EVERY line.
All lines fail, the transaction rolls back, and nothing lands.
Measured 2026-07-31: five `G(...)` image fills rejected for one trailing
comma each. `Cannot parse operation` echoes the line without saying what
is wrong, so the model mis-diagnosed it as an ARGUMENT separator
problem, then started deleting and rebuilding subtrees it had already
committed — chasing node ids that were never stale, since ids are string
identities and a delete renumbers nothing. The design ended up worse
than before the failing batch. One rejected line is a retry; a rejected
batch is a demolition.
Only the line's own tail is trimmed, so a comma inside an argument body
is untouched: every real operation ends on `)`.
Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
`effective_path_env` always let the login shell's PATH lead. That is
right for a Dock/Finder launch, where the process inherits launchd's
stock PATH and the login shell is the only place the user's toolchain
exists — but wrong whenever the process PATH was customised, because
then it *is* the user's live intent and we were silently overriding
which binary gets resolved.
Measured: with two `codex` installs (an old npm global under homebrew
and a current one under nvm) and a `.zshrc` ordering homebrew first, the
app resolved the old binary while the user's terminal resolved the new
one. It reported a stale model catalog and, worse, rewrote the shared
`~/.codex/models_cache.json` with its own outdated list, so even the
cache fallback went backwards.
Decide the merge direction on a fact rather than a guess: if every entry
of the process PATH is a stock system directory it carries no intent and
the login shell leads; otherwise the process PATH leads. Login-only
entries are still appended either way, so nothing that used to be
reachable stops being reachable.
Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
`model_profile` declares `deepseek-v4-pro { thinking_disabled: true }`,
but the wire layer decided whether to actually send
`thinking:{"type":"disabled"}` from a model-name allowlist that only
covered MiniMax and GLM. DeepSeek matched neither, so the declaration
was silently dropped and every agent-loop turn leaked reasoning until
`max_tokens` ran out — which truncates a `batch_design` mid-JSON while
leaving the short read-only tool calls intact, so the transcript shows a
run of green tool calls and then simply stops.
The same list lived in three places (single-shot body, agent loop,
headless harness) and had already drifted: the harness matched GLM with
`starts_with` where production used `contains`, so a vendor-prefixed id
benchmarked with thinking on and shipped with it off. Collapse all three
onto `op_orchestrator::accepts_thinking_body_field`, next to the profile
table that states the intent, and add a guard test asserting every model
whose profile asks for thinking off can express that on the wire.
DeepSeek's field shape and its `effort=high` default are documented at
https://api-docs.deepseek.com/guides/thinking_mode/ — it is the same
`{"thinking":{"type":...}}` MiniMax and GLM take. Sending it
unconditionally is still wrong: a builtin provider may point at an
endpoint that rejects unknown body fields, so the table stays the
boundary and a new family is one line in one place.
Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
The cache parser filters on `visibility`, but the app-server parser
ignored the protocol's twin field `hidden`, so the two paths that feed
the same picker disagreed on what counts as a listable model. Today the
server withholds internal entries (`codex-auto-review`) from
`model/list` on its own, so this changes nothing against the current
build — it keeps a server that starts sending them from leaking an
unusable model into the picker.
Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x