966 Commits
Author SHA1 Message Date
Elena Torró 4943285e63 🐛 Fix inner and outer rect strokes ignoring corner radius (#11926) 2026-09-25 14:12:01 +02:00
Alejandro Alonso ec5a1edbed ✨ Make export follow the active renderer only (#11910)
Drop the separate :wasm-export flag and wasm-export/v1 feature. Single
export, clipboard PNG, plugins, and batch :is-wasm now key off
render-wasm/v1 alone. The exporter trusts :is-wasm for headless WASM
and always keeps a worker pool ready.
2026-09-25 12:40:56 +02:00
Elena Torró 63baf86152 🐛 Fix groups, masks and booleans drop shadow cases (#11911)
* 🐛 Fix blocky and black drop shadows on masked groups

* 🐛 Fix drop shadow spread on paths, bools and circles
2026-09-25 11:50:19 +02:00
Andrey Antukh 5ef70c7284 ✨ Serialize render-wasm builds per checkout (#11903)
Protect shared setup, compilation, artifact copy, and target cleanup with
one flock lock per checkout. Route watch builds and frontend cleanup
through the protected scripts.

Document the lock contract and normalize the frontend and exporter
build:wasm commands.

Closes #11901

AI-assisted-by: Space Bunny Free
2026-09-25 09:42:27 +02:00
Alejandro Alonso e3e75725d4 ✨ Export WASM SVG background blur via foreignObject (#11765)
Emit an XHTML foreignObject with CSS backdrop-filter for visible
background blur. The inner div uses clip-path:path(...) for the GPU
fill+stroke silhouette (glyph outlines for text). The FO sits outside
opacity and layer-blur filters. Stacked on the masks SVG export work.
2026-09-24 15:29:25 +02:00
Belén Albeza a34b4011cf 🎉 Render different stroke styles per side (#11883)
* 🎉 Render different stroke styles per side

* 🔧 Stabilise per-side stroke snapshots against clip id churn

SVG clip ids come from a counter that lives for the whole test
process, so a snapshot holding one depends on how many clips other
tests emitted first. The masked-group export tests that landed on
develop shifted that count and broke two per-side snapshots, whose
only diff was the id.

Route the remaining per-side snapshots through with_stable_clip_ids,
which renumbers ids in order of appearance. No snapshot in the suite
now carries a generated id.

AI-assisted-by: claude-opus-5
2026-09-24 13:20:33 +02:00
Alejandro Alonso ad7b93beb7 ✨ Export masked groups to WASM SVG as alpha masks (#11652)
Paint the mask subtree into a native <mask mask-type="alpha"> and wrap
content under mask="url(#…)", matching GPU DstIn (soft/group masks).
Drop silhouettes use the same alpha mask; empty-fill SVG paths inherit
group fills.

Closes #11378
2026-09-24 11:25:34 +02:00
Belén Albeza 142f3d9de8 🐛 Fix RTL auto-width text growing away from its right edge (#11775)
This makes RTL texts in auto-width to grow towards their left side in
the text editor v3.

AI-assisted-by: claude-opus-5
2026-09-23 13:02:17 +02:00
Belén Albeza 34427c8a28 🐛 Fix borders not fully rendered next to masked groups (#11807)
A masked group now closes its focus scope on the mask pass, pops the
nested stacks on the content pass only, and draws its exit strokes on
the content pass. The focus depth also resets when each tile's walk
starts.

Closes #11805

AI-assisted-by: claude-opus-5
2026-09-23 12:45:02 +02:00
Miguel de Benito Delgado f0880cf3f9 🐛 Crop cached drop shadows to their filter bounds (#11801)
Snapshot only the scaled shadow bounds instead of the full reusable
filter surface. This avoids retaining viewport-sized textures for each
small shadow while keeping cached pixels valid when the surface is reused.

Use the bounded snapshot for both shape and frame shadow caches. Add
regression tests for rounding, scaling, surface limits, and snapshot reuse.

AI-assisted-by: gpt-6
2026-09-22 11:09:45 +02:00
Andrey Antukh 117c8db0bb Merge remote-tracking branch 'origin/staging' into develop 2026-09-22 10:24:30 +02:00
Andrey Antukh d68531b783 ⬆️ Update devenv dependencies (#11790)
* ⬆️ Update devenv dependencies

Update Node.js, OpenCode, clj-kondo, Babashka, Pixi, GitHub CLI, uv,
and Serena to their current stable releases.

AI-assisted-by: gpt-5.6-sol

* ⬆️ Update devenv to Java 27

Use Zulu JDK 27 in the development image for compatibility testing.
Update the official checksums for both supported architectures.

AI-assisted-by: gpt-5.6-sol

* 🐳 Replace MinIO with RustFS in devenv

Run RustFS as the development S3 service and wait for its health check.
Install a pinned AWS CLI with checksums and use it to create the bucket
idempotently from each backend entry point.

Keep the old MinIO volume untouched and use a new RustFS volume.

AI-assisted-by: gpt-5.6-sol

* 🐳 Replace MailCatcher with persistent Mailpit

Run Mailpit as the devenv SMTP sink while preserving mailer:1025 and the
localhost:1080 UI.

Store its SQLite inbox in a named volume and wait for the readiness
endpoint before starting runtime containers. Bind the web UI to loopback so
development emails stay local.

AI-assisted-by: gpt-5.6-sol

* ⬆️ Update Node.js to 24.21.0

Align the host NVM version with the Node.js version used by devenv.

AI-assisted-by: gpt-5.6-sol

* ⬆️ Update devenv to PostgreSQL 18.6

Run PostgreSQL 18 with its versioned volume layout and a TCP readiness
check that ignores the temporary initialization server.

Install the matching client, create penpot_nexus, and preserve the old
PostgreSQL 16 volume for rollback or logical migration.

AI-assisted-by: gpt-5.6-sol

* 🐳 Expose RustFS ports in devenv

Publish the RustFS S3 API and management console on localhost port 9000
and 9001.

Keep both bindings on loopback so object storage is not exposed to the local
network.

AI-assisted-by: gpt-5.6-sol

* 🐳 Install standalone pnpm in devenv

Install pnpm 12.5.0 from architecture-specific release archives and
verify their published checksums.

Remove the Corepack setup while allowing pnpm to honor the project
packageManager pins.

AI-assisted-by: gpt-5.6-sol

* 🔥 Remove corepack, use system pnpm everywhere

Corepack is gone from Node 25+, so every `corepack enable` call
fails. pnpm now ships as a system binary (devenv, CI runners and
Docker images install it directly) and auto-downloads the version
pinned in `packageManager` on mismatch.

Scripts, workflows and Dockerfiles call `pnpm` straight away; the
three deploy workflows use a single `pnpm/setup@v2` step; and the
new `scripts/sync-pnpm-version` stamps all 35 `packageManager`
fields from the system pnpm, replacing the `corepack use` sweep.

AI-assisted-by: muse-spark-1.3-contributor

* 🐛 Fix exporter watch missing render-wasm build step

The exporter watch compiled CLJS requiring the generated
src/app/wasm/shared.js, which only render-wasm/build export
produces. Without it shadow-cljs failed with a cryptic missing
./shared.js dependency. Run build:wasm before watching, as
the frontend watch:app and exporter scripts/build already do.

AI-assisted-by: muse-spark-1.3-contributor

* 🔧 Add opencode V2 support and adapt plugins

Register the penpot tools for both opencode V1 (server())
and V2 (setup() with JSON Schema inputs) from a single
dependency-free plugin file, sharing the psql and
paren-repair runners between both paths.

Install the opencode2 binary side-by-side with V1 in the
devenv image and document the dual registration in the
paren-repair and psql memories.

AI-assisted-by: muse-spark-1.3-contributor

* ⬆️ Update pnpm and opencode
2026-09-22 10:22:31 +02:00
Belén Albeza 0581c35452 🐛 Fix drop shadows in masks (#11754)
* 🐛 Fix shadows on a masked group in the WASM renderer

A masked group renders in two passes: its content, then the mask shape
composited with DstIn so everything outside the mask silhouette is
erased. Both happen inside one save_layer, and the group's drop shadow
was composited into that same layer before the mask pass — so the mask
erased it. A drop shadow lives mostly outside the silhouette, so it
disappeared entirely.

Inner shadows never drew at all: render_fill_inner_shadows needs fill
geometry to paint into, and a group has none.

Both now ride on an image filter set on the masked-group layer, which
Skia evaluates after the mask is composited, so the shadow comes from
the real masked pixels rather than the group's own, empty geometry.
The effects compose in the order the SVG renderer uses for a group:
drop shadows, then the source, then inner shadows, with the layer blur
over all of it.

That layer is opened on a canvas carrying no transform, so the filter
is built in device units. Shadow::scale_to_device does that rather
than scale_content, because radius_to_sigma is affine: scaling the
radius applies its constant term once at device scale, while a filter
built in document space has the term scaled by the canvas matrix. The
two would blur differently by 0.5 · (scale - 1) sigma, visible as a
masked group's own shadow being narrower than the same shadow on its
parent. The masked-group layer blur had the same flaw.

Three paths are suppressed for masked groups so nothing is drawn twice:
the silhouette composite, the nested_shadows inheritance that would
reach text descendants, and the fill inner-shadow pass.

Every save and restore around that layer is keyed on the shape alone.
Enter and exit run on different walker passes, and a pan or zoom in
between changes fast mode, so deriving them from render state could
leave the canvas clip stack unbalanced.

Refs #11697

AI-assisted-by: claude-opus-5

* 🐛 Fix a container's drop shadow over a masked group in WASM

A container builds its drop shadow by drawing each descendant as a
black silhouette and blurring the result. The walk descends only
through children that can be flattened, and a masked group never can,
so it stopped there and asked the group to draw its own geometry. A
group has none, so nothing was drawn and the shadow layer stayed
blank: no shadow at all for a group, board or frame holding a masked
group. This is what the file attached to the issue reproduces.

render_drop_black_shadow now draws the masked silhouette for such a
group — content children flat black, DstIn the mask, and only then the
offset, blur and spread. Masking after the blur would trim the shadow
along the wrong edge.

The walk recurses, so it narrows the clip the way the main walker
does: content a clipping container hides must not widen the shadow.
The clip rule now lives in one place, shared with the walker, and a
test pins the two against each other. The shadow layer is sized to the
silhouette plus the shadow's reach rather than falling back to the
clip, so a wide blur is not cut at the tile edge.

Spread keeps the renderer's existing behaviour: the silhouette goes
through the same get_drop_shadow_filter every other shadow uses, so a
masked group gains no ordering of its own.

Closes #11697

AI-assisted-by: claude-opus-5
2026-09-18 13:04:11 +02:00
Elena Torró 8124ecf148 ⚡ Improve canvas snapshot and nested drop shadows calculation (#11766)
* ⚡ Capture canvas snapshot only when the renderer is idle

* ⚡ Skip descendant silhouettes for frames with nested drop shadows

* 🔧 Add a profiling build mode for render-wasm
2026-09-18 09:21:52 +02:00
Elena Torró 4c940296f1 🐛 Fix fixed-scroll on the viewer (#11752) 2026-09-17 09:43:13 +02:00
Elena Torró cc9c60507d 🐛 Do not apply stroke style to caps (#11744) 2026-09-17 09:39:39 +02:00
Alejandro Alonso 20547f658e 🐛 Preserve tile coverage when packing atlas slots (#11749)
Packed atlas compose inset Linear samples after blitting the full
drawable into the slot, which dropped edge texels and opened
multi-pixel gaps on text that crossed tile seams (#11696), especially
under HiDPI packing. Write the drawable into the inset content rect
and clamp-pad the 1px frame so Linear compose keeps coverage without
bleeding into the next cell.

Closes #11696
2026-09-16 16:40:33 +02:00
Alejandro Alonso fcae641c7b 🐛 Skip content clip on WASM SVG drop silhouettes (#11655)
Clipped frames (show-content=false) were applying clipPath to the drop
silhouette. Spread/offset fills were outset correctly but then truncated
to the true selrect, so spread rings vanished on export. Match the GPU:
paint drop silhouettes outside the content clip; keep clipping on the
real content pass only.

Closes #11653
2026-09-16 16:08:17 +02:00
Alejandro Alonso b8af89c1b8 ✨ Export text strokes to WASM SVG (#11578)
SkSVGDevice drops save_layer composition for text strokes, so emit
center strokes (with <g opacity> when alpha), inner strokes via glyph
clipPath, and outer strokes via inverse-glyph luminance mask.

Closes #11386
2026-09-16 15:54:07 +02:00
ShreyashandShreyash Agare 76e9289fcf 🐛 Fix stroke to path dropping caps and markers (#11614)
* 🐛 Fix stroke to path dropping caps and markers

Caps and markers (round ends, arrows, square/diamond/circle markers) are
painted separately by `handle_stroke_caps` after the stroke itself, so
`stroke_to_path` — which only outlines the shape path — dropped them.
Converting a stroke to a path lost every end decoration except the
Round/Round and Square/Square pairs Skia draws natively, and even those
were missing because the outline paint kept the default butt cap.

Cap geometry now lives in `shapes/stroke_paths.rs` as plain path
builders, shared by the canvas renderer and by `stroke_to_path`, which
unions the caps into the outline for open paths and honors
`to_skia_linecap`. The SVG export no longer overlays the caps a second
time on top of the expanded outline, which would double the alpha of
translucent strokes; the clip silhouette for image strokes gets them
from the outline too.

Co-authored-by: Shreyash Agare <agareshreyash26@gmail.com>
2026-09-16 12:46:00 +02:00
Alejandro Alonso 3adf9ade14 ✨ Export drop and inner shadows to WASM SVG (#11593)
SkSVGDevice drops save_layer image-filters, so re-emit visible drop and
inner shadows (plus optional layer blur) as one native SVG filter chain
matching classic order: flood, drops, SourceGraphic, inners, blur.

Closes #11379
2026-09-11 13:52:57 +02:00
Alejandro Alonso ffad71bdc5 🐛 Hard-clip tile atlas blit to avoid seam hairlines (#11639)
AA clip on the Current→atlas blit softens shared tile edges so
the canvas background shows through as 1px lines at the 512px
grid when tiles are composed with SrcOver.

Closes #11638
2026-09-11 09:22:36 +02:00
Elena Torró 32ed9b5a08 🐛 Fix board clip on drag (#11620) 2026-09-11 08:27:11 +02:00
Elena Torró 0913545b41 🐛 Fix letter spacing and position when flattening text to path (#11555)
* 🐛 Fix letter spacing and position when flattening text to path

* ♻️ Simplify text to path conversion and add flatten tests
2026-09-11 08:20:22 +02:00
Andrey Antukh 37dab75e1a Merge remote-tracking branch 'origin/staging' into develop 2026-09-10 20:29:44 +02:00
Alejandro Alonso aa78ca0be8 🐛 Keep text image fills aligned during drag (#11610)
Cached Skia paragraphs bake absolute image/gradient shaders at layout
time. On move, clones reuse those paragraphs while painting at the new
selrect, so glyphs move and the fill stays put. Record the paint origin
when layout is built and translate the canvas when painting from cache
so shaders track the text. Also sync bounds before update_layout so
fills bake against the current container.
2026-09-10 16:55:45 +02:00
Alejandro Alonso dbe5941a23 ✨ Export layer blur to WASM SVG (#11580)
SkSVGDevice drops paint image-filters, so re-emit visible layer blur as a
native feGaussianBlur filter on the composite <g>. Match canvas sigma via
radius_to_sigma(value * scale), and skip Skia blur filters on the SVG
VectorRenderer path so shapes do not vanish.

Closes #11380
2026-09-10 14:25:47 +02:00
Alejandro Alonso dc12f1db91 ✨ Export image-filled strokes to WASM SVG as linked images (#11559)
Closes #11384

Skia's SVG backend drops save_layer+SrcIn, so image strokes are re-emitted
as a linked <image> clipped to an opaque stroke silhouette (filled outline,
clip-rule evenodd). Open-path caps join the silhouette and grow the image
dest by cap_bounds_margin so markers stay textured.
2026-09-10 13:29:14 +02:00
Elena Torró 9b24907992 🐛 Fix render text-decoration on spans and update regression tests (#11583) 2026-09-10 09:42:09 +02:00
Elena Torró 61caefbcf3 🔧 Support independent image bounds on wasm export (#11590) 2026-09-09 18:27:22 +02:00
Elena Torró 6586631293 🐛 Fix wasm boolean paths with opposite winding operand (#11551) 2026-09-09 18:19:29 +02:00
Elena Torró 3d4a5ca2aa 🔧 Use mutex so only one test owns global at a time (#11585) 2026-09-09 17:10:04 +02:00
Danny ShirelyandAndrey Antukh d45c6710b7 🎉 Implement independent image bounds resizing (#11430)
* 🎉 Implement independent image bounds resizing

Add canvas resize interaction mode that allows users to resize an
image object's bounding box independently from the underlying bitmap
content without scaling or distortion while holding the Mod key.

AI-assisted-by: gemini-2.5-pro

* ♻️ Address reviewer feedback from elenatorro

- Remove legacy cfh/image-shape? check in shape-has-image-fill?
- Guard bounds-resize with positive dimensions instead of clamping scalev to preserve flipping
- Remove :metadata from transform-attrs in modifiers.cljs
- Restore preserveAspectRatio logic based on keep-ar? in fills.cljs
- Compute source rect against destination rect for raster and SVG fills in WASM renderer

* 🔧 Fix clippy needless borrow warnings in wasm image fills

---------

Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-09-09 15:16:56 +02:00
Alonso Torres bf2fcba3be 🐛 Fix problems with pixel precission in WASM renderer (#11560) 2026-09-09 15:11:58 +02:00
Andrey Antukh eca1d81692 🔧 Remove legacy pnpm build key and clarify updating doc
Drop the ignored-since-pnpm-11 onlyBuiltDependencies entry from
render-wasm/pnpm-workspace.yaml, keeping allowBuilds as the single
source of build approvals. Clarify the updating-pnpm gotcha so it no
longer claims pnpm writes ignoredBuiltDependencies.

AI-assisted-by: muse-spark-1.3-contributor
2026-09-09 11:45:00 +02:00
Alejandro Alonso e67ebacef2 🐛 Export svg-raw shapes through WASM SVG draw_svg (#11567)
SVG export treated SVGRaw like a fill/stroke leaf and never called
draw_svg, so imported SVG content exported as an empty document.

Relates to #10546
2026-09-09 11:16:01 +02:00
Andrey Antukh c1bd3cb9f0 Merge remote-tracking branch 'origin/staging' into develop 2026-09-09 10:31:41 +02:00
Andrey Antukh 5c474939ac 🔧 Pin all pnpm workspaces to one shared pnpm store
Set storeDir in every pnpm-workspace.yaml: `.pnpm-store` at the repo
root and `../.pnpm-store` in the ten module workspaces, so all of them
resolve to <repo>/.pnpm-store. pnpm resolves the value against the
workspace root, and nested workspaces do not inherit settings, which
had left the root workspace and the modules on two different stores.

Add scripts/clean-node-modules: removes every workspace node_modules
in one pass (ignores external/ and .opencode/), keeps the shared store
unless --store removes it too.

Verified: every workspace resolves the same store path; reinstalls
after a full clean reuse the cache with zero downloads;
frozen-lockfile installs pass in all 11 workspaces with no lockfile
changes; the frontend storybook suite stays green.

AI-assisted-by: omen-alpha
2026-09-09 08:04:11 +02:00
Andrey Antukh e4d1816117 ⬆️ Update pnpm to 12.3.4 across all workspaces
Run `corepack use pnpm@next-12` (resolved to 12.3.4) on every directory
with a package.json: the repo root, the 11 module workspaces, and all
submodules. Every packageManager field now carries the same pinned
version and hash; the root and backend move off 11.20.0.

Fix the composable-test-suite workspace config (esbuild allowBuilds
placeholder left by pnpm 12) so its install passes, and add the missing
packageManager fields to frontend/packages/ui and mcp/packages/plugin,
since corepack only updates existing fields.

Document the canonical update procedure in
.serena/memories/workflow/updating-pnpm.md.

AI-assisted-by: omen-alpha
2026-09-09 08:04:11 +02:00
Andrey Antukh f695553469 Merge remote-tracking branch 'origin/staging' into develop 2026-09-08 21:15:58 +02:00
Alejandro Alonso 2e5157e6aa ✨ Export image fills to WASM SVG as linked images (#11529)
Emit clipped `<image href>` for fills with a registered media URL instead
of Skia base64 embeds. Register URLs from the frontend and exporter via
store_image_url. Cover rect, path, frame, and text (glyph clip) cases in
SVG export tests.

Closes #11383
2026-09-08 17:30:53 +02:00
Elena Torró 7b135b80b2 🐛 Fix background blur clipping (#11532) 2026-09-07 15:51:27 +02:00
Alejandro Alonso 269aa36f82 ✨ Export solid and patterned strokes to WASM SVG (#11513)
* ✨ Export solid inner and outer strokes to WASM SVG

SkSVGDevice drops save_layer+Clear used for path Outer strokes, so
solid Inner/Outer strokes on closed shapes are expanded to filled
outlines via stroke_to_path. Center and open paths stay on the shared
stroke path.

Closes #11381

* ✨ Export dotted, dashed, and mixed strokes to WASM SVG

PathEffects do not survive SkSVGDevice, so dotted/dashed/mixed strokes
expand via stroke_to_path with the effect kept, producing filled
outline geometry for all alignments including Center.

Closes #11382

* 🐛 Fix WASM SVG stroke export edge cases

Keep expanded stroke outlines in local space under the leaf CTM so
rotated rects/paths are not double-transformed; overlay open-path caps
after dotted expansion; draw frame strokes outside the content clip;
and route per-side rect/frame strokes through the evenodd band path.
2026-09-07 13:50:58 +02:00
Elena Torró d82038a570 🐛 Fix paragraph span restyle on line change (#11503)
* 🐛 Fix paragraph span restyle on line change

* 🐛 Fix text shape boundaries on resize
2026-09-04 13:43:23 +02:00
Elena Torró 0533be100d 🐛 Fix text selection render overlay and double/triple click flash (#11512) 2026-09-04 12:38:31 +02:00
Alejandro Alonso 86aebd3429 ⚡ Reduce WASM text selrect churn after batch page load (#11471)
* 🐛 Align WASM auto-width text size with HTML measurement

Include the HTML paragraph-set 1px right margin in Skia auto-width
so WASM and DOM measurements match.

* ⚡ Scope and defer post-font WASM text relayout

Track pending font faces per shape, force-relayout only affected
texts, defer stale-selrect sync until the first tiles paint, and
batch request-render after attr updates. Tolerate missing font-face
keys so callers that omit them do not blow up.
2026-09-04 12:24:37 +02:00
Elena Torró eb8d3a2fef 🐛 Fix paragraph selection (#11493) 2026-09-03 16:17:38 +02:00
Alejandro Alonso c5897bc50a ⚡ Paint plain text directly onto Current (#11355)
* ♻️ Share text layout paragraphs across modifier clones

Store Skia paragraphs in Rc so TextContentLayout::clone keeps the
cached layout for rotate/pan modifiers. Add layout.clear() and treat
needs_update as paragraphs-empty only.

* ⚡ Reuse cached Skia paragraphs when painting text

Add try_paint_from_layout_cache to paint from TextContent.layout when
versions match, skipping ParagraphBuilder rebuild and layout on each
frame. Wire into the layered text path for plain fills without strokes
or effects.

* ⚡ Paint plain text directly onto Current

Extend can_render_directly for stroke-free text and skip the empty
save_layer in draw_text when no stroke-group opacity is set. Plain text
paints into Current without the Fills/Strokes blit.
2026-09-02 16:17:06 +02:00
Alejandro Alonso b92112da54 🐛 Export multiple solid fills to WASM SVG (#11467)
Shapes and text with stacked solid fills were exported without fill
attributes because merge_fills builds a Skia shader that SkSVGDevice
does not serialize. Draw each fill layer separately for SVG export,
bottom to top, matching the classic exporter.

Closes #11466
2026-09-02 13:40:05 +02:00
Elena Torró 03e6f119e5 ♻️ Clean unnecessary methods (#11472)
* ♻️ Remove duplicated/unused set-children code

* ♻️ Remove unused methods
2026-09-02 12:58:52 +02:00