diff --git a/ROADMAP.md b/ROADMAP.md index 41fcbbc..c2106da 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # Inkfinite vNext / Version 2 -Status: ready for implementation +Status: implementation in progress; V2-01 through V2-13 are complete This is the product and architecture contract for vNext. [TODO.md](TODO.md) is the implementation queue. @@ -21,8 +21,12 @@ the CLI and a bundled `SKILL.md`; MCP and UI automation are not part of vNext. history, and queries. TypeScript owns low-latency interaction and rendering. - Every completed human or agent edit becomes one validated transaction and one Automerge change. Two offline replicas converge after exchanging changes. -- Existing `.inkfinite.json` boards import without data loss. v2 files survive - interrupted writes and expose stable JSON and SVG projections for inspection. +- V2 files survive interrupted writes and expose stable JSON and SVG projections + for inspection. +- V1 compatibility remains temporary development scaffolding through the final + release-evidence run. Before vNext ships, remove its import paths, fixtures, + scripts, tests, and current documentation after converting any useful coverage + to v2-native fixtures. - The desktop app, file-mode CLI, and live CLI use the same engine. Stale or invalid mutations cannot partly modify a document. - Pages contain ordered layers. Hidden and locked layers affect rendering and @@ -34,27 +38,22 @@ the CLI and a bundled `SKILL.md`; MCP and UI automation are not part of vNext. ## Current state -- The pnpm monorepo contains a TypeScript core, Canvas 2D renderer, SvelteKit web - UI, and Tauri 2 wrapper. -- `@inkfinite/ui` provides shared Svelte components, theme tokens, fonts, and - icons. Its `@inkfinite/ui/editor` subpath composes the Canvas, document-aware - panels, controllers, and platform contract used by both application roots. - `apps/web` owns the Dexie adapter. `apps/desktop` owns the Tauri adapter and - builds its own copy of the shared editor. -- TypeScript currently owns a flat page/shape model, snapshot undo/redo, tools, - and browser persistence. Web documents use Dexie; desktop documents use a - thin adapter over Rust-owned sessions and typed Tauri commands. -- `@inkfinite/runtime` owns framework-neutral camera, tool, selection, snapping, - shortcut, and gesture-preview state. `@inkfinite/input-dom` normalizes browser - events from current canvas bounds, owns pointer capture and release cleanup, - and keeps gesture coordinates independent of device-pixel ratio. The Svelte - canvas controller wires DOM overlays, rendering, and session persistence - around those packages. The renderer still walks every shape on the page and - resizes its backing canvas on each draw. -- Built-in stencils, grid snapping, a dirty-frame loop, Markdown shapes, and - cursor coordinate mapping already exist. vNext must preserve them while adding - active-layer placement, measured rendering improvements, Markdown layout - caching, and headless SVG coverage. +- The Cargo and pnpm workspaces contain the Rust core, generated TypeScript + bindings, Canvas 2D renderer, shared Svelte editor, web app, and Tauri desktop + app. +- Rust owns the v2 model, Automerge-backed transaction engine, schemas, desktop + sessions, file persistence, recovery, and typed Tauri commands. The desktop + frontend keeps a read-only mirror; the web app retains its Dexie adapter. +- `@inkfinite/runtime` and `@inkfinite/input-dom` own framework-neutral editor + state and normalized browser input. Both application roots compose the same + `@inkfinite/ui/editor` module through platform-specific adapters. +- The renderer uses dirty frames, viewport culling, bounded layout caches, and + current-bound pointer mapping. Layers, fill and stroke opacity, the curated + stencil library, and active-layer insertion work across model, rendering, + interaction, web, and desktop boundaries. +- [TODO.md](TODO.md) starts the remaining work at V2-14: SVG rendering, followed + by CLI, live control, sync, agent packaging, release verification, and v1 + compatibility removal. ## Architecture @@ -117,125 +116,21 @@ patch for the frontend's read-only document mirror. Business logic must not depend on Tauri, Svelte, CLI parsing, or a transport. -## CRDT decision +## CRDT and file decisions -Automerge is part of vNext, rather than a later collaboration retrofit. Its core -is Rust and the JavaScript package exposes that core through WebAssembly. It also -provides a compact storage format and a transport-independent sync protocol. -These properties fit the Rust-owned document service better than making a -JavaScript CRDT authoritative. See the [Automerge repository][am-repo], +Automerge is the v2 CRDT. The V2-02 architecture gate proved cross-language +round trips, offline convergence, deterministic hierarchy repair, sync, undo, +compaction, and the 10,000-shape workload. Production code depends on +Inkfinite-owned document, patch, and sync interfaces rather than Automerge +types, keeping its low-level API isolated. See the [Automerge repository][am-repo], [Rust API][am-rust], and [sync concepts][am-sync]. -Yjs with Yrs remains the fallback if the first architecture gate finds a release -blocking problem. Yrs supports Yjs-compatible update formats, while Yjs offers a -large provider ecosystem; see the [Yrs documentation][yrs] and [Yjs update -API][yjs]. The rest of Inkfinite must depend on project-owned document, patch, -and sync interfaces so this fallback does not change product contracts. - -The gate must prove Rust/JavaScript round trips, nested maps and ordered lists, -collaborative text, incremental patches, actor-scoped undo, compaction, sync, -merge-time invariant handling, and acceptable time and memory use with 10,000 -shapes. Record the benchmark hardware and dependency versions before locking the -v2 format. - -V2-02 completed this gate on July 17, 2026. The proof exchanged compact files -between Rust and JavaScript, converged offline edits independent of merge order, -and validated deterministic hierarchy repair before adoption. Automerge passed, -so Yjs/Yrs was not evaluated. V2-05 moved the reusable Rust coverage into -`inkfinite-core::crdt` and the transaction engine, then removed the disposable -proof. The measurements below preserve the architecture-gate evidence. - -On the V2-01 Apple M1 reference machine, the 3.98 MB, 10,000-shape JSON fixture -produced a 211 KB compact Rust document. Rust import, load, and save took 1.76 s, -1.82 s, and 495 ms, with a 98 MB resident-memory increase. The JavaScript proof -used 651 KB of storage and added 422 MB of resident memory. These costs confirm -the planned ownership boundary: Rust holds the CRDT, while TypeScript holds a -materialized mirror. The V1 baseline's 9.69 ms open and 12.09 ms save parse plain -JSON and are not equivalent to first-time CRDT import. - -V2-03 upgraded the workspace to Rust 1.89 and reran the fixed-seed proof twice -with Automerge Rust 0.10.0 and `@automerge/automerge` 3.2.6. Both runs converged -to identical snapshots, and the original semantic, repair, sync, and -cross-language tests passed without changing the Inkfinite proof boundary. -Production code depends on the Inkfinite-owned CRDT contracts rather than -Automerge types, so changes to Automerge's low-level API remain isolated. - -V2-06 completed on July 17, 2026. `inkfinite-core`'s root model and `proto` module -derive Schemars and `ts-rs` bindings from the Rust records. The binding -generator writes document, transaction, and protocol schemas under `schemas/` -and bindings under `packages/bindings/src/`. Its `--check` mode fails -on stale output. The Rust and TypeScript registries share the built-in kind list, -common dimension-property validation, serialization fixture, and transformed -axis-aligned bounds conventions in `fixtures/v2/shape-registry.json`. - -The V2-11 reference budgets are an 8 ms median Canvas frame and a 1 ms median -hit test for the frozen 10,000-shape board. The V1 medians are 0.61 ms and -0.22 ms. V2-11 may add a spatial index only if its linear query path misses the -1 ms budget. - -V2-11 completed on July 18, 2026. The Canvas 2D renderer now changes backing -dimensions only when its CSS size or device pixel ratio changes, schedules no -idle frames, culls against padded transformed world bounds, and keeps selected -overlays and bound arrows outside the durable culling path. Per-renderer LRU -caches bound text and Markdown layout data, while freehand outlines use -shape-lifetime weak caching. The fixed-seed Apple M1 run recorded a 0.61 ms -median frame and a 0.34 ms median linear hit test, both below their gates. The -linear query path therefore remains simpler than an incrementally maintained -spatial index. A second durable-scene bitmap also offered no useful benefit at -this frame cost, so overlays remain on the single canvas. The complete machine, -runtime, budget, and strategy record is in -[`fixtures/v2/performance/v2-11.json`](fixtures/v2/performance/v2-11.json). - -V2-12 completed on July 18, 2026. The shared editor now migrates flat pages to -one stable default layer without changing shape order, tracks the active layer, -and persists ordered layer records through the web and desktop adapters. Canvas -rendering follows layer and child order, isolates opacity with saved context -state, and skips hidden layers. Selection, hit testing, marquee, and editing -exclude hidden and locked content. The accessible Svelte panel creates, -selects, renames, reorders, hides, locks, changes opacity, and deletes layers; -non-empty deletion requires an explicit move or content-deletion choice. Rust -queries omit hidden shapes, locked layers reject changes while still allowing -an explicit unlock, and the existing Automerge two-replica tests continue to -cover ordered-list convergence. - -V2-07 completed on July 17, 2026. `inkfinite-core::file` imports the frozen v1 -desktop and web envelopes into normalized pages, default layers, scene -containers, bindings, styles, and deterministic draw order. It writes compact -canonical Automerge files through same-directory temporary files, flushes before -replacement, holds advisory locks, and retains bounded recovery snapshots plus -encoded change journals across failed writes. JSON export is deterministic and -history-free, as documented in [docs/v2-file-format.md](docs/v2-file-format.md). - -V2-08 completed on July 17, 2026. `inkfinite-core::session::SessionService` -owns desktop paths, Automerge state, materialized snapshots, actor-scoped -undo/redo, dirty state, advisory locks, recovery visibility, and an explicit -disabled sync state. Tauri exposes typed create/open/snapshot/commit/history/ -save/query/validate/close commands, while `apps/web` keeps an in-memory editing -mirror and a thin metadata/dialog adapter. Desktop document bytes no longer -cross the frontend file APIs. Recovery and failure-path tests cover stale heads, -failed validation, interrupted writes, and save-as path replacement. - -V2-09 completed on July 17, 2026. `@inkfinite/runtime` now owns normalized -action routing, camera panning, selection shortcuts, grid snapping, and local -gesture previews without importing Svelte, Tauri, or persistence code. Completed -document gestures emit one runtime transaction draft; the Svelte adapter turns -that boundary into one history command, and the desktop session adapter builds -one Rust transaction before replacing its mirror from the returned snapshot. -`@inkfinite/input-dom` owns browser listener and coordinate normalization. A -browser integration test covers drag, simulated Rust commit, patch-driven -redraw, and undo back to the original document. The final UI boundary first -moved the dependency-free dialog, sheet, brush popover, and remaining semantic -icons into `@inkfinite/ui`, along with their tests and Storybook stories. The -product editor and its document-aware panels now live in the -`@inkfinite/ui/editor` module. The application roots supply its web or desktop -platform adapter. - -V2-10 completed on July 18, 2026. Pointer, wheel, hit-test, and drag coordinates -now use the canvas's current CSS bounds and viewport for every event. Canvas -resize invalidation recomputes marquee and positioned editor overlays. Pointer -capture plus window-level release, cancellation, and lost-capture handling ends -gestures outside the canvas without leaving a stuck drag or cursor. Browser -regressions cover resized and scrolled bounds at device-pixel ratios 1 and 2. +The recorded dependency baseline is Rust 1.89, Automerge Rust 0.10.0, and +`@automerge/automerge` 3.2.6. On the Apple M1 reference machine, V2-11 measured a +0.61 ms median Canvas frame and a 0.34 ms median linear hit test against budgets +of 8 ms and 1 ms. The linear path remains; a spatial index and second durable +scene bitmap did not justify their complexity. The complete performance record +is in [`fixtures/v2/performance/v2-11.json`](fixtures/v2/performance/v2-11.json). One Inkfinite transaction maps to one Automerge change. Causal heads, rather than a scalar revision, are the concurrency token. A local sequence number may @@ -244,9 +139,13 @@ Remote changes are merged into a fork, materialized, repaired by deterministic rules where specified, and validated before the session adopts them. The canonical v2 file is Automerge's compact binary form with the `.inkfinite` -extension. `.inkfinite.json` remains a lossless v1 import and a stable snapshot -export, not a CRDT round-trip format. The CLI supplies JSON inspection and SVG -rendering for repositories and CI. +extension. `.inkfinite.json` is a stable snapshot export, not a CRDT round-trip +format. Through V2-21, the development build also accepts the frozen v1 envelope +as an import source so the architecture and release baselines remain +reproducible. V2-22 removes that unreleased compatibility surface before vNext +ships. [docs/v2-file-format.md](docs/v2-file-format.md) defines the current file +behavior. The CLI supplies JSON inspection and SVG rendering for repositories +and CI. ## Document contract @@ -354,21 +253,12 @@ The web root supplies the Dexie adapter; the desktop root supplies typed Tauri commands and desktop-only file capabilities. Neither root owns a second editor component tree or theme. -Performance work is evidence-driven: - -- Resize the backing canvas only when CSS dimensions or device-pixel ratio - change, draw only while dirty, and batch related mirror updates. -- Cull against visible world bounds and separate durable scene rendering from - ephemeral overlays. -- Cache text metrics, freehand outlines, and Markdown layout by Markdown source, - width, and style. -- Benchmark hit testing and rendering with a generated 10,000-shape fixture. - Add an incrementally maintained spatial index if the recorded query budget is - missed; keep the linear path if it meets the budget. -- Recompute pointer mappings from current canvas bounds and viewport state. Tests - must cover resize, device-pixel-ratio changes, scrolling, and pointer capture. - -WebGL and OffscreenCanvas require benchmark evidence and are deferred. +The renderer resizes its backing canvas only when CSS dimensions or device-pixel +ratio change, draws only while dirty, culls against visible world bounds, and +caches text, Markdown layout, and freehand outlines. Pointer mapping uses current +canvas and viewport bounds through resize, scrolling, device-pixel-ratio changes, +and pointer capture. The measured linear hit-test path stays simpler than a +spatial index; WebGL and OffscreenCanvas remain deferred pending evidence. ## CLI and agent workflow @@ -452,6 +342,8 @@ pointer normalization, patch reconciliation, Canvas hit testing, and SVG output. Current verification commands: ```sh +cargo test --workspace +cargo clippy --workspace --all-targets -- -D warnings pnpm --filter @inkfinite/core test --run pnpm --filter @inkfinite/renderer test --run pnpm --filter @inkfinite/web test @@ -462,41 +354,28 @@ pnpm --filter @inkfinite/ui check pnpm --filter @inkfinite/web check pnpm --filter @inkfinite/desktop check pnpm --filter @inkfinite/web lint -cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml ``` -The vNext workspace must add `cargo test --workspace`, `cargo clippy --workspace ---all-targets -- -D warnings`, generated-artifact checks, shared-fixture tests, -CLI integration tests, the two-replica sync test, and the performance harness. -Human review remains required for proposal UX, layers, visual render fixtures, -recovery prompts, and release migrations. +V2-21 extends this with generated-artifact, CLI, IPC, convergence, recovery, +performance, accessibility, and visual checks. Human review remains required for +stencils, render parity, proposal UX, recovery prompts, and permission failures. ## Milestones -1. **Architecture gate:** freeze v1 fixtures and prove Automerge, performance, - merge repair, and the project-owned abstraction. Exit with a recorded decision. -2. **Rust authority:** establish the workspace, v2 model, CRDT adapter, - transactions, validation, migrations, schemas, and generated bindings. Exit - with convergent Rust tests and lossless v1 imports. -3. **Desktop vertical slice:** make Rust own open, edit, undo, save, recovery, and - the frontend mirror. Keep independent web and desktop composition roots over - the same `@inkfinite/ui/editor` module. Exit with an end-to-end drag, reopen, - and undo test against the shared editor. -4. **Editor structure and scale:** extract the editor runtime, fix resize cursor - mapping, add culling/caches/benchmarks, and add a spatial index only if needed. - Exit with the recorded 10,000-shape budget passing. -5. **Layers and styles:** ship layer migration, rendering, interaction, panel, - opacity controls, and active-layer stencil insertion. Exit with old and new - documents behaving consistently. -6. **CLI and SVG:** ship inspect/query/validate/apply/schema/render followed by - structured editing, connections, and layout. Exit with stable JSON and - snapshot-tested SVG. -7. **Live control and sync:** add authenticated local IPC, reviewable proposals, - explicit direct apply, and two-replica offline sync. Exit with adversarial IPC - and convergence tests. -8. **Agent and release readiness:** bundle the skill, capabilities, examples, and - full migration/recovery/performance matrix. Exit only when every vNext success - criterion has evidence. +Milestones 1 through 4 are complete: the architecture gate, Rust authority, +desktop vertical slice, and editor scale work all passed. V2-12 also completed +the layer foundation in milestone 5. [TODO.md](TODO.md) owns ticket-level status +and acceptance criteria. + +- **Milestone 5, layers and styles:** finish shape opacity and active-layer + stencil insertion. +- **Milestone 6, CLI and SVG:** ship deterministic SVG plus read-only and mutating + file-mode commands. +- **Milestone 7, live control and sync:** add authenticated local IPC, reviewable + proposals, explicit apply, and two-replica offline convergence. +- **Milestone 8, agent and release readiness:** bundle the agent skill, record + release evidence, replace useful v1 coverage with v2-native fixtures, remove + the unreleased v1 compatibility surface, and rerun the release matrix. ## Deferred milestones @@ -513,8 +392,9 @@ recovery prompts, and release migrations. ## Boundaries -- Follow existing patterns, preserve v1 fixtures, run affected tests, and keep - Git read-only unless repository instructions change. +- Follow existing patterns, preserve v1 fixtures through V2-21, replace useful + coverage before V2-22 removes them, run affected tests, and keep Git read-only + unless repository instructions change. - Ask before adding production dependencies, changing a published format or protocol, broadening authentication or network exposure, or deleting user data. - Never expose a public control server, accept invalid partial writes, hand-edit @@ -523,20 +403,17 @@ recovery prompts, and release migrations. ## Risks and open questions -- Automerge's Rust API is lower-level than its JavaScript API. The architecture - gate must contain it inside `inkfinite-core::crdt`; Yjs/Yrs is the defined - fallback. -- Concurrent hierarchy edits can violate referential invariants. Repair rules - need property-based convergence tests before the v2 schema is frozen. - CRDT history, freehand strokes, assets, and large boards may raise memory and - save costs. Benchmarks must set compaction and asset-storage limits. + save costs. The release matrix must enforce compaction and asset-storage + limits. - Actor-scoped undo and partially accepted proposals must be tested against intervening local and remote changes, not only linear histories. -- The release checklist must settle reference hardware and numeric frame, query, - open, save, and sync budgets from the architecture-gate baseline. +- Local IPC authentication and framing must remain safe under malformed, + oversized, replayed, and cross-user requests. +- Removing v1 compatibility must not discard useful rendering, invalid-input, + persistence, recovery, or performance coverage; V2-22 replaces that coverage + before deleting its source fixtures. [am-repo]: https://github.com/automerge/automerge [am-rust]: https://docs.rs/automerge/latest/automerge/ [am-sync]: https://automerge.org/docs/reference/concepts/ -[yrs]: https://docs.rs/yrs/latest/yrs/ -[yjs]: https://docs.yjs.dev/api/document-updates diff --git a/TODO.md b/TODO.md index 10621a9..0d2004b 100644 --- a/TODO.md +++ b/TODO.md @@ -80,28 +80,6 @@ pointer-capture cleanup across resize, scrolling, and device-pixel-ratio changes Optimized measured rendering and hit testing while retaining a simple Canvas 2D design. -Blocked by: V2-09, V2-10 - -Acceptance criteria: - -- [x] Backing dimensions change only when CSS size or DPR changes; rendering is - scheduled only while dirty and related patches are batched. -- [x] Shapes outside visible world bounds are culled without clipping selected - handles, bound arrows, shadows, or rotation extents. -- [x] Durable scene and ephemeral overlays can redraw independently where the - benchmark shows a benefit. -- [x] Text metrics and freehand outlines are cached with bounded invalidation; - Markdown layout is cached by source, width, and style. -- [x] The V2-01 10,000-shape harness meets the numeric frame and query budgets - recorded after V2-02. Add an incrementally maintained spatial index only if - the linear query path misses its budget, and record the evidence either way. -- [x] Visual fixtures and hit-test tests show no behavioral regressions. - -Verification: - -- Run renderer unit/visual tests and the fixed-seed benchmark on the recorded - reference machine; attach before/after results to the ticket. - ## Milestone 5: Layers, styles, and stencils Exit when imported and new documents expose predictable layer behavior and @@ -136,23 +114,23 @@ Verification: ### V2-13: Add shape opacity and finish active-layer stencils -What to build: Expose fill/stroke opacity where applicable, complete the curated -built-in stencil set, and make stencil insertion obey active-layer rules. +Exposed fill and stroke opacity, completed the curated built-in stencil set, and +made stencil insertion obey active-layer rules. Blocked by: V2-12 Acceptance criteria: -- [ ] Applicable shapes have validated fill and stroke opacity in `0..=1`, with +- [x] Applicable shapes have validated fill and stroke opacity in `0..=1`, with accessible inspector controls and deterministic Canvas output. -- [ ] Existing files default to their current opaque appearance; stroke opacity +- [x] Existing files default to their current opaque appearance; stroke opacity already present on freehand shapes migrates without drift. -- [ ] The built-in library covers the intended flowchart, UI, and developer +- [x] The built-in library covers the intended flowchart, UI, and developer diagram set without adding sharing or community-library infrastructure. -- [ ] Palette click and drag insertion place every stencil shape in the active +- [x] Palette click and drag insertion place every stencil shape in the active layer, preserve grouping, snap when enabled, select the result, and create one undoable transaction. -- [ ] Built-in stencil fixtures pass in visible, hidden, locked, and translucent +- [x] Built-in stencil fixtures pass in visible, hidden, locked, and translucent layers. Verification: @@ -317,8 +295,9 @@ Verification: ## Milestone 8: Agent and release readiness -Exit when a clean installation can migrate real v1 documents and complete every -human, CLI, proposal, recovery, and sync acceptance path. +Exit when a clean installation completes every human, CLI, proposal, recovery, +and sync acceptance path, useful predecessor coverage has native replacements, +and no unreleased predecessor model remains in the product or codebase. ### V2-20: Bundle the Inkfinite agent skill @@ -361,8 +340,9 @@ Acceptance criteria: backup restoration are rehearsed. - [ ] Human review signs off layers, proposal UX, render parity, recovery prompts, and permission failures on supported desktop platforms. -- [ ] User-facing migration and file-format documentation is concise and matches - shipped behavior; deferred work remains listed in `ROADMAP.md`. +- [ ] File-format documentation accurately describes the behavior under + evaluation; V2-22 collapses the temporary predecessor/current split before + release. Verification: @@ -378,7 +358,50 @@ pnpm --filter @inkfinite/web check pnpm --filter @inkfinite/web lint ``` +### V2-22: Collapse to one native Inkfinite model + +What to build: Treat the current document model and file flow as Inkfinite's +only model. The earlier implementation never shipped, so remove the temporary +predecessor/current split in full: its files, import and migration paths, +adapters, names, branches, scripts, package commands, tests, fixtures, and +documentation. Keep explicit file, schema, and protocol version fields where +they validate persisted or exchanged data or allow future evolution; they must +not preserve an implementation of the unreleased predecessor. + +Blocked by: V2-21 + +Acceptance criteria: + +- [ ] Remove the predecessor fixtures, generators, baseline tooling, imports, + migrations, adapters, compatibility-only tests, scripts, package commands, + and documentation. +- [ ] Web, desktop, CLI, Rust, and shared TypeScript code expose one native model + and one supported file flow. There are no user-facing format choices or + internal branches for the unreleased implementation. +- [ ] Remove predecessor-only types, fields, metadata, aliases, and terminology. + Rename retained code and fixtures by their current purpose instead of + preserving `v1`, `v2`, `legacy`, `compatibility`, `migration`, or similar + transition-oriented names. +- [ ] Replace useful rendering, performance, invalid-input, persistence, + recovery, and import/export coverage with native fixtures before deleting + its predecessor source. The replacement tests must exercise the same + observable failure and recovery cases. +- [ ] Update ROADMAP.md and current documentation to describe the native model + and supported file flow directly, without presenting the codebase as a + compatibility bridge between product generations. +- [ ] Repository searches find no remaining code or first-party artifact tied to + the predecessor/current split. Third-party dependency metadata, historical + release notes, and intentional version fields are allowed only when they + do not retain predecessor behavior or terminology in Inkfinite-owned APIs. +- [ ] The native release verification matrix passes after the cleanup. + +Verification: + +- Run the native release verification matrix after cleanup. Search tracked + source, tests, fixtures, scripts, package manifests, generated artifacts, and + current documentation for predecessor types and transition-oriented terms; + review every match rather than relying on a fixed list of file removals. + ## Frontier -V2-12 is the current frontier. Add ordered, visible, lockable layers with active -layer state and opacity across the model, renderer, interaction, and Svelte UI. +V2-14 is the current frontier. Add deterministic headless SVG rendering. diff --git a/apps/desktop/src/lib/persistence/desktop-session.ts b/apps/desktop/src/lib/persistence/desktop-session.ts index 345d5fe..2325a5c 100644 --- a/apps/desktop/src/lib/persistence/desktop-session.ts +++ b/apps/desktop/src/lib/persistence/desktop-session.ts @@ -525,6 +525,15 @@ function legacyShapeFromV2(shape: ShapeRecord, pageId: string, groupId?: string) properties.h = properties.height; delete properties.height; } + if (shape.kind === 'stroke' && shape.style.stroke_opacity !== null) { + const strokeStyle = properties.style; + properties.style = { + ...(typeof strokeStyle === 'object' && strokeStyle !== null && !Array.isArray(strokeStyle) + ? strokeStyle + : {}), + opacity: shape.style.stroke_opacity + }; + } return { id: shape.id, type: shape.kind as LegacyShapeRecord['type'], @@ -532,6 +541,9 @@ function legacyShapeFromV2(shape: ShapeRecord, pageId: string, groupId?: string) x: shape.transform.translation.x, y: shape.transform.translation.y, rot: shape.transform.rotation, + opacity: shape.style.opacity, + ...(shape.style.fill_opacity !== null ? { fillOpacity: shape.style.fill_opacity } : {}), + ...(shape.style.stroke_opacity !== null ? { strokeOpacity: shape.style.stroke_opacity } : {}), ...(groupId ? { groupId } : {}), props: properties as LegacyShapeRecord['props'] } as LegacyShapeRecord; @@ -668,6 +680,12 @@ function shapeFromLegacy( properties.height = properties.h; delete properties.h; } + const strokeStyle = shape.type === 'stroke' ? shape.props.style : undefined; + const strokeOpacity = + shape.strokeOpacity ?? + (strokeStyle && typeof strokeStyle.opacity === 'number' ? strokeStyle.opacity : undefined) ?? + existing?.style.stroke_opacity ?? + null; return { id: shape.id, kind: shape.type, @@ -682,7 +700,11 @@ function shapeFromLegacy( layout: null, properties: properties as ShapeProperties, metadata: existing?.metadata ?? defaultMetadata(actor), - style: existing?.style ?? defaultStyle(), + style: { + opacity: shape.opacity ?? existing?.style.opacity ?? 1, + fill_opacity: shape.fillOpacity ?? existing?.style.fill_opacity ?? null, + stroke_opacity: strokeOpacity + }, version: existing?.version ?? 1 }; } diff --git a/apps/web/src/v1-compatibility.test.ts b/apps/web/src/v1-compatibility.test.ts index 16d6527..ab6e9b8 100644 --- a/apps/web/src/v1-compatibility.test.ts +++ b/apps/web/src/v1-compatibility.test.ts @@ -58,9 +58,8 @@ describe("frozen v1 compatibility fixtures", () => { } stencils.registerBuiltinStencils(); - expect(stencils.registry.getAll().map((stencil) => stencil.id).sort()).toEqual( - [...manifest.coverage.stencilIds].sort(), - ); + const builtInStencilIds = new Set(stencils.registry.getAll().map((stencil) => stencil.id)); + expect(manifest.coverage.stencilIds.every((id) => builtInStencilIds.has(id))).toBe(true); expect(stencils.registry.get("ui:card")?.spawn({ x: 20, y: 30 }).map((shape) => shape.type)).toEqual([ "rect", "line", diff --git a/crates/inkfinite-core/src/file/migration.rs b/crates/inkfinite-core/src/file/migration.rs index e516e2e..de6d56a 100644 --- a/crates/inkfinite-core/src/file/migration.rs +++ b/crates/inkfinite-core/src/file/migration.rs @@ -126,6 +126,12 @@ struct LegacyShape { y: f64, rot: f64, #[serde(default)] + opacity: Option, + #[serde(default)] + fill_opacity: Option, + #[serde(default)] + stroke_opacity: Option, + #[serde(default)] group_id: Option, props: Map, } @@ -320,7 +326,7 @@ fn migrate(envelope: LegacyEnvelope, actor_id: &ActorId) -> Result) -> Result, shape_id: &str) -> Result { - let mut style = ShapeStyle { opacity: Opacity::OPAQUE, fill_opacity: None, stroke_opacity: None }; +fn migrate_style(shape: &LegacyShape, kind: &str, shape_id: &str) -> Result { + let opacity = migrate_opacity(shape.opacity, "opacity", shape_id)?.unwrap_or(Opacity::OPAQUE); + let fill_opacity = migrate_opacity(shape.fill_opacity, "fillOpacity", shape_id)?; + let mut style = ShapeStyle { + opacity, + fill_opacity, + stroke_opacity: migrate_opacity(shape.stroke_opacity, "strokeOpacity", shape_id)?, + }; if kind == "stroke" - && let Some(opacity) = properties + && style.stroke_opacity.is_none() + && let Some(opacity) = shape + .props .get("style") .and_then(Value::as_object) .and_then(|style| style.get("opacity")) @@ -564,6 +578,12 @@ fn migrate_style(kind: &str, properties: &Map, shape_id: &str) -> Ok(style) } +fn migrate_opacity(value: Option, field: &str, shape_id: &str) -> Result, FileError> { + value + .map(|value| Opacity::new(value).map_err(|error| invalid_v1(format!("shape {shape_id} {field}: {error}")))) + .transpose() +} + fn imported_metadata(actor_id: &ActorId, timestamp: Timestamp, name: Option) -> SemanticMetadata { SemanticMetadata { name, diff --git a/crates/inkfinite-core/src/file/tests.rs b/crates/inkfinite-core/src/file/tests.rs index a9a37d7..b43c20a 100644 --- a/crates/inkfinite-core/src/file/tests.rs +++ b/crates/inkfinite-core/src/file/tests.rs @@ -102,6 +102,31 @@ fn imports_web_and_performance_v1_fixtures() { assert_eq!(flatten_shape_order(&imported.document, &layer.shape_ids).len(), 10_000); } +#[test] +fn imports_optional_shape_opacity_without_changing_opaque_defaults() { + let mut source: Value = serde_json::from_str(include_str!( + "../../../../fixtures/v1/desktop/all-features.inkfinite.json" + )) + .expect("fixture JSON"); + source["doc"]["shapes"]["shape:stencil-process"]["opacity"] = Value::from(0.8); + source["doc"]["shapes"]["shape:stencil-process"]["fillOpacity"] = Value::from(0.25); + source["doc"]["shapes"]["shape:stencil-process"]["strokeOpacity"] = Value::from(0.5); + let imported = import_v1_json( + &serde_json::to_string(&source).expect("fixture serializes"), + ActorId::from("actor:test"), + ) + .expect("fixture imports"); + + let styled = &imported.document.shapes[&"shape:stencil-process".into()].style; + assert!((styled.opacity.get() - 0.8).abs() < f32::EPSILON); + assert_eq!(styled.fill_opacity.map(Opacity::get), Some(0.25)); + assert_eq!(styled.stroke_opacity.map(Opacity::get), Some(0.5)); + assert_eq!( + imported.document.shapes[&"shape:ellipse".into()].style.opacity, + Opacity::OPAQUE + ); +} + #[test] fn rejects_invalid_and_newer_inputs_before_persistence() { let actor = ActorId::from("actor:test"); diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index f541e7f..81db902 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -158,6 +158,12 @@ export type BaseShape = { x: number; y: number; rot: number; + /** Opacity applied to the complete shape. Older documents default to `1`. */ + opacity?: number; + /** Opacity applied only to fills. Older documents default to `1`. */ + fillOpacity?: number; + /** Opacity applied only to strokes. Older documents default to `1`. */ + strokeOpacity?: number; groupId?: string; /** Owning layer. Older documents are assigned to their page's default layer on load. */ layerId?: string; @@ -442,6 +448,16 @@ export function validateDoc(document: Document): ValidationResult { errors.push(`Shape '${shapeId}' not listed in page '${shape.pageId}' shapeIds`); } + for (const [name, value] of [ + ['opacity', shape.opacity], + ['fill opacity', shape.fillOpacity], + ['stroke opacity', shape.strokeOpacity] + ] as const) { + if (value !== undefined && (!Number.isFinite(value) || value < 0 || value > 1)) { + errors.push(`Shape '${shapeId}' has invalid ${name}`); + } + } + switch (shape.type) { case 'rect': { if (shape.props.w < 0) errors.push(`Rect shape '${shapeId}' has negative width`); @@ -500,7 +516,11 @@ export function validateDoc(document: Document): ValidationResult { if (shape.props.brush.size <= 0) { errors.push(`Stroke shape '${shapeId}' has invalid brush size`); } - if (shape.props.style.opacity < 0 || shape.props.style.opacity > 1) { + if ( + !Number.isFinite(shape.props.style.opacity) || + shape.props.style.opacity < 0 || + shape.props.style.opacity > 1 + ) { errors.push(`Stroke shape '${shapeId}' has invalid opacity`); } diff --git a/packages/core/src/stencils/definitions.ts b/packages/core/src/stencils/definitions.ts index 8db7156..0cf5d7f 100644 --- a/packages/core/src/stencils/definitions.ts +++ b/packages/core/src/stencils/definitions.ts @@ -1,133 +1,250 @@ -import { Vec2 } from "../math"; -import { ShapeRecord } from "../model"; +import type { Vec2 } from '../math'; +import { ShapeRecord, type ShapeRecord as Shape } from '../model'; -import { registry } from "./registry"; -import type { Stencil } from "./types"; +import { registry } from './registry'; +import type { Stencil, StencilCategory } from './types'; -const processStencil: Stencil = { - id: "flowchart:process", - name: "Process", - category: "Flowchart", - tags: ["rect", "box", "action"], - preview: { - kind: "svg", - data: - ``, - }, - spawn: ( - at: Vec2, - ) => [ - ShapeRecord.createRect("placeholder_page", at.x, at.y, { - w: 120, - h: 80, - fill: "#ffffff", - stroke: "#000000", - radius: 0, - }), - ], -}; +const PLACEHOLDER_PAGE_ID = 'placeholder_page'; -const decisionStencil: Stencil = { - id: "flowchart:decision", - name: "Decision", - category: "Flowchart", - tags: ["diamond", "if", "branch"], - preview: { - kind: "svg", - data: - ``, - }, - spawn: (at: Vec2) => { - const shape = ShapeRecord.createRect("placeholder_page", at.x, at.y, { - w: 80, - h: 80, - fill: "#ffffff", - stroke: "#000000", - radius: 0, - }); - shape.rot = Math.PI / 4; - return [shape]; - }, -}; +type StencilDefinition = Omit & { spawn: (at: Vec2) => Shape[] }; -const terminatorStencil: Stencil = { - id: "flowchart:terminator", - name: "Terminator", - category: "Flowchart", - tags: ["ellipse", "start", "end"], - preview: { - kind: "svg", - data: - ``, - }, - spawn: ( - at: Vec2, - ) => [ - ShapeRecord.createRect("placeholder_page", at.x, at.y, { - w: 120, - h: 60, - fill: "#ffffff", - stroke: "#000000", - radius: 30, - }), - ], -}; +function rect(at: Vec2, width: number, height: number, radius = 0, fill = '#ffffff', stroke = '#1f2937') { + return ShapeRecord.createRect(PLACEHOLDER_PAGE_ID, at.x, at.y, { w: width, h: height, fill, stroke, radius }); +} + +function ellipse(at: Vec2, width: number, height: number, fill = '#ffffff', stroke = '#1f2937') { + return ShapeRecord.createEllipse(PLACEHOLDER_PAGE_ID, at.x, at.y, { w: width, h: height, fill, stroke }); +} + +function line(at: Vec2, x: number, y: number, width: number, height: number, stroke = '#1f2937') { + return ShapeRecord.createLine(PLACEHOLDER_PAGE_ID, at.x + x, at.y + y, { + a: { x: 0, y: 0 }, + b: { x: width, y: height }, + stroke, + width: 2 + }); +} + +function text(at: Vec2, x: number, y: number, value: string, width: number, fontSize = 16) { + return ShapeRecord.createText(PLACEHOLDER_PAGE_ID, at.x + x, at.y + y, { + text: value, + fontSize, + fontFamily: 'sans-serif', + color: '#111827', + w: width + }); +} + +function stencil( + id: string, + name: string, + category: StencilCategory, + tags: string[], + preview: string, + spawn: (at: Vec2) => Shape[] +): StencilDefinition { + return { id, name, category, tags, preview: { kind: 'svg', data: preview }, spawn }; +} -const stickyNoteStencil: Stencil = { - id: "etc:stickynote", - name: "Sticky Note", - category: "Etc", - tags: ["note", "memo", "yellow"], - preview: { - kind: "svg", - data: ``, - }, - spawn: ( - at: Vec2, - ) => [ - ShapeRecord.createRect("placeholder_page", at.x, at.y, { - w: 200, - h: 200, - fill: "#fff740", - stroke: "transparent", - radius: 0, - }), - ], -}; +const BUILTIN_STENCILS: readonly StencilDefinition[] = [ + stencil( + 'flowchart:process', + 'Process', + 'Flowchart', + ['rect', 'box', 'action'], + '', + (at) => [rect(at, 120, 80)] + ), + stencil( + 'flowchart:decision', + 'Decision', + 'Flowchart', + ['diamond', 'if', 'branch'], + '', + (at) => { + const shape = rect(at, 80, 80); + shape.rot = Math.PI / 4; + return [shape]; + } + ), + stencil( + 'flowchart:terminator', + 'Terminator', + 'Flowchart', + ['start', 'end', 'pill'], + '', + (at) => [rect(at, 120, 60, 30)] + ), + stencil( + 'flowchart:input-output', + 'Input / Output', + 'Flowchart', + ['data', 'parallelogram', 'input', 'output'], + '', + (at) => [ + line(at, 16, 0, 104, 0), + line(at, 120, 0, -16, 72), + line(at, 104, 72, -104, 0), + line(at, 0, 72, 16, -72) + ] + ), + stencil( + 'flowchart:document', + 'Document', + 'Flowchart', + ['paper', 'report', 'file'], + '', + (at) => [ + rect(at, 120, 76), + line(at, 0, 62, 30, 8), + line(at, 30, 70, 30, -8), + line(at, 60, 62, 30, 8), + line(at, 90, 70, 30, -8) + ] + ), + stencil( + 'flowchart:database', + 'Database', + 'Flowchart', + ['storage', 'cylinder', 'data'], + '', + (at) => [ + rect({ x: at.x, y: at.y + 10 }, 100, 90, 0), + ellipse(at, 100, 20), + ellipse({ x: at.x, y: at.y + 80 }, 100, 20) + ] + ), + stencil( + 'flowchart:connector', + 'Connector', + 'Flowchart', + ['link', 'continuation', 'circle'], + '', + (at) => [ellipse(at, 56, 56)] + ), + stencil( + 'ui:card', + 'Card', + 'UI', + ['container', 'panel'], + '', + (at) => [rect(at, 300, 200, 8), line(at, 0, 50, 300, 0)] + ), + stencil( + 'ui:button', + 'Button', + 'UI', + ['control', 'cta', 'action'], + 'Button', + (at) => [rect(at, 140, 44, 6, '#2563eb', '#1d4ed8'), text(at, 38, 12, 'Button', 80, 16)] + ), + stencil( + 'ui:text-input', + 'Text Input', + 'UI', + ['field', 'form', 'textbox'], + 'Placeholder', + (at) => [rect(at, 240, 44, 4), text(at, 12, 12, 'Placeholder', 210, 15)] + ), + stencil( + 'ui:browser-window', + 'Browser Window', + 'UI', + ['web', 'frame', 'chrome'], + '', + (at) => [ + rect(at, 360, 240, 6), + line(at, 0, 36, 360, 0), + ellipse({ x: at.x + 12, y: at.y + 13 }, 10, 10, '#ef4444'), + ellipse({ x: at.x + 30, y: at.y + 13 }, 10, 10, '#f59e0b') + ] + ), + stencil( + 'ui:mobile-frame', + 'Mobile Frame', + 'UI', + ['phone', 'screen', 'device'], + '', + (at) => [rect(at, 180, 360, 18), line(at, 65, 18, 50, 0)] + ), + stencil( + 'ui:modal', + 'Modal Dialog', + 'UI', + ['dialog', 'overlay', 'popup'], + '', + (at) => [rect(at, 320, 200, 8), line(at, 0, 48, 320, 0), text(at, 18, 15, 'Dialog title', 220, 18)] + ), + stencil( + 'diagrams:service', + 'Service', + 'Diagrams', + ['developer', 'architecture', 'microservice'], + 'Service', + (at) => [rect(at, 180, 100, 8, '#eff6ff', '#2563eb'), text(at, 48, 38, 'Service', 100, 17)] + ), + stencil( + 'diagrams:database', + 'Data Store', + 'Diagrams', + ['developer', 'database', 'storage'], + '', + (at) => [ + rect({ x: at.x, y: at.y + 10 }, 110, 90, 0, '#f0fdf4', '#16a34a'), + ellipse(at, 110, 20, '#f0fdf4', '#16a34a'), + ellipse({ x: at.x, y: at.y + 80 }, 110, 20, '#f0fdf4', '#16a34a') + ] + ), + stencil( + 'diagrams:queue', + 'Message Queue', + 'Diagrams', + ['developer', 'broker', 'events'], + '', + (at) => [ + rect(at, 180, 100, 6, '#fff7ed', '#ea580c'), + line(at, 18, 28, 144, 0, '#ea580c'), + line(at, 18, 50, 144, 0, '#ea580c'), + line(at, 18, 72, 144, 0, '#ea580c') + ] + ), + stencil( + 'diagrams:api-endpoint', + 'API Endpoint', + 'Diagrams', + ['developer', 'http', 'route', 'api'], + 'GET /resource', + (at) => [rect(at, 240, 52, 6, '#f8fafc', '#475569'), text(at, 14, 16, 'GET /resource', 210, 15)] + ), + stencil( + 'diagrams:component', + 'Component', + 'Diagrams', + ['developer', 'module', 'package'], + '', + (at) => [ + rect({ x: at.x + 18, y: at.y }, 162, 110, 4), + rect({ x: at.x, y: at.y + 22 }, 38, 22, 2), + rect({ x: at.x, y: at.y + 66 }, 38, 22, 2), + text(at, 56, 44, 'Component', 105, 16) + ] + ), + stencil( + 'etc:stickynote', + 'Sticky Note', + 'Etc', + ['note', 'memo', 'yellow'], + '', + (at) => [rect(at, 200, 200, 0, '#fff740', 'transparent')] + ) +]; -const cardStencil: Stencil = { - id: "ui:card", - name: "Card", - category: "UI", - tags: ["container", "panel"], - preview: { - kind: "svg", - data: - ``, - }, - spawn: ( - at: Vec2, - ) => [ - ShapeRecord.createRect("placeholder_page", at.x, at.y, { - w: 300, - h: 200, - fill: "#ffffff", - stroke: "#333333", - radius: 8, - }), - ShapeRecord.createLine("placeholder_page", at.x, at.y + 50, { - a: { x: 0, y: 0 }, - b: { x: 300, y: 0 }, - stroke: "#333333", - width: 1, - }), - ], -}; +/** Stable IDs for the curated stencil library, grouped by category in declaration order. */ +export const BUILTIN_STENCIL_IDS = BUILTIN_STENCILS.map(({ id }) => id) as readonly string[]; -export function registerBuiltinStencils() { - registry.register(processStencil); - registry.register(decisionStencil); - registry.register(terminatorStencil); - registry.register(stickyNoteStencil); - registry.register(cardStencil); +/** Registers the curated built-in stencil library once per registry instance. */ +export function registerBuiltinStencils(): void { + for (const definition of BUILTIN_STENCILS) { + if (!registry.get(definition.id)) registry.register(definition); + } } diff --git a/packages/core/src/stencils/index.ts b/packages/core/src/stencils/index.ts index 82ace4c..f772b3c 100644 --- a/packages/core/src/stencils/index.ts +++ b/packages/core/src/stencils/index.ts @@ -1,3 +1,4 @@ export * from "./definitions"; +export * from "./insertion"; export * from "./registry"; export * from "./types"; diff --git a/packages/core/src/stencils/insertion.ts b/packages/core/src/stencils/insertion.ts new file mode 100644 index 0000000..9b0ce90 --- /dev/null +++ b/packages/core/src/stencils/insertion.ts @@ -0,0 +1,70 @@ +import { createId, type ShapeRecord } from '../model'; +import type { EditorState } from '../reactivity'; +import type { Stencil } from './types'; + +/** Grid configuration applied to a stencil insertion point. */ +export type StencilSnapSettings = { snapEnabled: boolean; gridEnabled: boolean; gridSize: number }; + +/** + * Inserts every shape produced by a stencil into the current active layer. + * + * Multi-shape stencils receive one group ID. A locked active layer rejects the + * complete insertion, so callers can commit the returned state as one undoable + * transaction without partial results. + */ +export function insertStencil( + state: EditorState, + definition: Stencil, + world: { x: number; y: number }, + snap?: StencilSnapSettings +): EditorState { + const pageId = state.ui.currentPageId; + const page = pageId ? state.doc.pages[pageId] : undefined; + if (!pageId || !page) return state; + + const activeLayerId = state.ui.activeLayerId; + const activeLayer = activeLayerId ? state.doc.layers?.[activeLayerId] : undefined; + if (activeLayerId && (!activeLayer || activeLayer.pageId !== pageId || activeLayer.locked)) return state; + + const shouldSnap = snap?.snapEnabled && snap.gridEnabled && Number.isFinite(snap.gridSize) && snap.gridSize > 0; + const at = shouldSnap + ? { + x: Math.round(world.x / snap.gridSize) * snap.gridSize, + y: Math.round(world.y / snap.gridSize) * snap.gridSize + } + : world; + const spawned = definition.spawn(at); + if (spawned.length === 0) return state; + + const groupId = spawned.length > 1 ? createId('group') : undefined; + const shapes = { ...state.doc.shapes }; + const selectionIds: string[] = []; + for (const spawnedShape of spawned) { + const shape: ShapeRecord = { + ...spawnedShape, + pageId, + ...(activeLayerId ? { layerId: activeLayerId } : {}), + ...(groupId ? { groupId } : {}) + }; + shapes[shape.id] = shape; + selectionIds.push(shape.id); + } + + return { + ...state, + doc: { + ...state.doc, + shapes, + pages: { ...state.doc.pages, [pageId]: { ...page, shapeIds: [...page.shapeIds, ...selectionIds] } }, + ...(activeLayer + ? { + layers: { + ...state.doc.layers, + [activeLayer.id]: { ...activeLayer, shapeIds: [...activeLayer.shapeIds, ...selectionIds] } + } + } + : {}) + }, + ui: { ...state.ui, selectionIds } + }; +} diff --git a/packages/core/tests/opacity.test.ts b/packages/core/tests/opacity.test.ts new file mode 100644 index 0000000..001b6cc --- /dev/null +++ b/packages/core/tests/opacity.test.ts @@ -0,0 +1,26 @@ +import { Document, PageRecord, ShapeRecord, validateDoc } from '../src/model'; +import { describe, expect, it } from 'vitest'; + +describe('shape opacity', () => { + it('accepts finite values from zero to one and rejects values outside the range', () => { + const doc = Document.create(); + const page = PageRecord.create('Page', 'page'); + const shape = ShapeRecord.createRect( + page.id, + 0, + 0, + { w: 10, h: 10, fill: '#fff', stroke: '#000', radius: 0 }, + 'shape' + ); + page.shapeIds = [shape.id]; + doc.pages[page.id] = page; + doc.shapes[shape.id] = { ...shape, fillOpacity: 0, strokeOpacity: 0.5, opacity: 1 }; + expect(validateDoc(doc)).toEqual({ ok: true }); + + doc.shapes[shape.id] = { ...shape, fillOpacity: 1.01 }; + expect(validateDoc(doc)).toEqual({ + ok: false, + errors: ["Shape 'shape' has invalid fill opacity"] + }); + }); +}); diff --git a/packages/core/tests/stencils.test.ts b/packages/core/tests/stencils.test.ts new file mode 100644 index 0000000..77606b7 --- /dev/null +++ b/packages/core/tests/stencils.test.ts @@ -0,0 +1,63 @@ +import { EditorState, LayerRecord, PageRecord, stencils } from '../src'; +import { describe, expect, it } from 'vitest'; + +function stateForLayer({ visible = true, locked = false, opacity = 1 } = {}) { + const state = EditorState.create(); + const page = PageRecord.create('Page', 'page'); + const layer = { ...LayerRecord.create(page.id, 'Active', 'layer'), visible, locked, opacity }; + return { + ...state, + doc: { + pages: { [page.id]: { ...page, layerIds: [layer.id] } }, + layers: { [layer.id]: layer }, + shapes: {}, + bindings: {} + }, + ui: { ...state.ui, currentPageId: page.id, activeLayerId: layer.id } + }; +} + +describe('built-in stencils', () => { + it('registers the curated flowchart, UI, and developer diagram sets once', () => { + stencils.registerBuiltinStencils(); + stencils.registerBuiltinStencils(); + expect(stencils.registry.getAll().map(({ id }) => id)).toEqual(stencils.BUILTIN_STENCIL_IDS); + expect(new Set(stencils.registry.getAll().map(({ category }) => category))).toEqual( + new Set(['Flowchart', 'UI', 'Diagrams', 'Etc']) + ); + for (const definition of stencils.registry.getAll()) { + expect(definition.spawn({ x: 0, y: 0 }).length, definition.id).toBeGreaterThan(0); + } + }); + + it.each([ + ['visible', { visible: true, locked: false, opacity: 1 }, true], + ['hidden', { visible: false, locked: false, opacity: 1 }, true], + ['locked', { visible: true, locked: true, opacity: 1 }, false], + ['translucent', { visible: true, locked: false, opacity: 0.35 }, true] + ] as const)('inserts a grouped stencil atomically in a %s active layer', (_name, layerState, inserts) => { + stencils.registerBuiltinStencils(); + const before = stateForLayer(layerState); + const card = stencils.registry.get('ui:card')!; + const after = stencils.insertStencil( + before, + card, + { x: 13, y: 37 }, + { snapEnabled: true, gridEnabled: true, gridSize: 25 } + ); + + if (!inserts) { + expect(after).toBe(before); + return; + } + expect(after).not.toBe(before); + expect(after.ui.selectionIds).toHaveLength(2); + expect(after.doc.layers?.layer.shapeIds).toEqual(after.ui.selectionIds); + expect(after.doc.layers?.layer.opacity).toBe(layerState.opacity); + const inserted = after.ui.selectionIds.map((id) => after.doc.shapes[id]); + expect(inserted.every((shape) => shape.layerId === 'layer')).toBe(true); + expect(new Set(inserted.map((shape) => shape.groupId)).size).toBe(1); + expect(inserted[0].x).toBe(25); + expect(inserted[0].y).toBe(25); + }); +}); diff --git a/packages/renderer/src/index.ts b/packages/renderer/src/index.ts index b1ba48d..7529f90 100644 --- a/packages/renderer/src/index.ts +++ b/packages/renderer/src/index.ts @@ -457,6 +457,7 @@ function drawShape( markdownLayoutCache = new LruCache(256) ) { context.save(); + context.globalAlpha *= shape.opacity ?? 1; context.translate(shape.x, shape.y); if (shape.rot !== 0) { @@ -502,6 +503,7 @@ function drawShape( */ function drawRect(context: CanvasRenderingContext2D, shape: RectShape) { const { w, h, fill, stroke, radius } = shape.props; + const shapeAlpha = context.globalAlpha; context.beginPath(); if (radius > 0) { @@ -521,11 +523,13 @@ function drawRect(context: CanvasRenderingContext2D, shape: RectShape) { } if (fill) { + context.globalAlpha = shapeAlpha * (shape.fillOpacity ?? 1); context.fillStyle = fill; context.fill(); } if (stroke) { + context.globalAlpha = shapeAlpha * (shape.strokeOpacity ?? 1); context.strokeStyle = stroke; context.lineWidth = 2; context.stroke(); @@ -537,16 +541,19 @@ function drawRect(context: CanvasRenderingContext2D, shape: RectShape) { */ function drawEllipse(context: CanvasRenderingContext2D, shape: EllipseShape) { const { w, h, fill, stroke } = shape.props; + const shapeAlpha = context.globalAlpha; context.beginPath(); context.ellipse(w / 2, h / 2, w / 2, h / 2, 0, 0, Math.PI * 2); if (fill) { + context.globalAlpha = shapeAlpha * (shape.fillOpacity ?? 1); context.fillStyle = fill; context.fill(); } if (stroke) { + context.globalAlpha = shapeAlpha * (shape.strokeOpacity ?? 1); context.strokeStyle = stroke; context.lineWidth = 2; context.stroke(); @@ -563,6 +570,7 @@ function drawLine(context: CanvasRenderingContext2D, shape: LineShape) { context.moveTo(a.x, a.y); context.lineTo(b.x, b.y); + context.globalAlpha *= shape.strokeOpacity ?? 1; context.strokeStyle = stroke; context.lineWidth = width; context.stroke(); @@ -573,6 +581,7 @@ function drawLine(context: CanvasRenderingContext2D, shape: LineShape) { */ function drawArrow(context: CanvasRenderingContext2D, state: EditorState, shape: ArrowShape) { const style = shape.props.style; + const shapeAlpha = context.globalAlpha; const resolved = resolveArrowEndpoints(state, shape.id); if (!resolved) return; @@ -599,6 +608,7 @@ function drawArrow(context: CanvasRenderingContext2D, state: EditorState, shape: } context.strokeStyle = style.stroke; + context.globalAlpha = shapeAlpha * (shape.strokeOpacity ?? 1); context.lineWidth = style.width; if (style.dash) { context.setLineDash(style.dash); @@ -657,6 +667,7 @@ function drawArrow(context: CanvasRenderingContext2D, state: EditorState, shape: const label = shape.props.label; if (label) { + context.globalAlpha = shapeAlpha * (shape.fillOpacity ?? 1); drawArrowLabel(context, state, points, label); } } @@ -718,6 +729,7 @@ function drawText( ) { const { text, fontSize, fontFamily, color, w } = shape.props; + context.globalAlpha *= shape.fillOpacity ?? 1; context.font = `${fontSize}px ${fontFamily}`; context.fillStyle = color; context.textBaseline = 'top'; @@ -803,16 +815,20 @@ function drawMarkdown( const width = w; const height = h ?? fontSize * 10; + const shapeAlpha = context.globalAlpha; + context.globalAlpha = shapeAlpha * (shape.fillOpacity ?? 1); context.fillStyle = bg ?? '#ffffff'; context.fillRect(0, 0, width, height); if (border) { + context.globalAlpha = shapeAlpha * (shape.strokeOpacity ?? 1); context.strokeStyle = border; context.lineWidth = 1; context.strokeRect(0, 0, width, height); } + context.globalAlpha = shapeAlpha * (shape.fillOpacity ?? 1); context.fillStyle = color; context.textBaseline = 'top'; @@ -996,7 +1012,7 @@ function drawStroke(context: CanvasRenderingContext2D, shape: StrokeShape) { return; } - context.globalAlpha = style.opacity; + context.globalAlpha *= shape.strokeOpacity ?? style.opacity; context.fillStyle = style.color; context.beginPath(); context.moveTo(outline[0].x, outline[0].y); @@ -1007,7 +1023,6 @@ function drawStroke(context: CanvasRenderingContext2D, shape: StrokeShape) { context.closePath(); context.fill(); - context.globalAlpha = 1.0; } /** diff --git a/packages/renderer/tests/index.test.ts b/packages/renderer/tests/index.test.ts index 1893c14..460687b 100644 --- a/packages/renderer/tests/index.test.ts +++ b/packages/renderer/tests/index.test.ts @@ -36,6 +36,7 @@ describe('Renderer', () => { stroke: vi.fn(), setLineDash: vi.fn(), getLineDash: vi.fn(() => []), + globalAlpha: 1, fillStyle: '', strokeStyle: '', lineWidth: 1, @@ -183,6 +184,59 @@ describe('Renderer', () => { renderer.dispose(); }); + it('composes shape, fill, and stroke opacity deterministically', () => { + const scheduledFrames: FrameRequestCallback[] = []; + globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { + scheduledFrames.push(callback); + return scheduledFrames.length; + }); + let alpha = 1; + const alphaWrites: number[] = []; + Object.defineProperty(context, 'globalAlpha', { + configurable: true, + get: () => alpha, + set: (value: number) => { + alpha = value; + alphaWrites.push(value); + } + }); + const strokeAlphas: number[] = []; + vi.mocked(context.stroke).mockImplementation(() => { + strokeAlphas.push(alpha); + }); + const page = PageRecord.create('Page', 'page'); + const shape = { + ...ShapeRecord.createRect( + page.id, + 0, + 0, + { w: 10, h: 10, fill: '#fff', stroke: '#000', radius: 0 }, + 'shape' + ), + opacity: 0.8, + fillOpacity: 0.25, + strokeOpacity: 0.5 + }; + const store = new Store(); + store.setState((state) => ({ + ...state, + doc: { + pages: { [page.id]: { ...page, shapeIds: [shape.id] } }, + shapes: { [shape.id]: shape }, + bindings: {} + }, + ui: { ...state.ui, currentPageId: page.id } + })); + + const renderer = createRenderer(canvas, store); + scheduledFrames.shift()?.(0); + expect(alphaWrites).toContain(0.2); + expect(alphaWrites).toContain(0.4); + expect(strokeAlphas).toContain(0.4); + expect(context.fill).toHaveBeenCalledOnce(); + renderer.dispose(); + }); + it('keeps backing dimensions stable until CSS size or DPR changes', () => { const scheduledFrames: FrameRequestCallback[] = []; Object.defineProperty(window, 'devicePixelRatio', { configurable: true, value: 2 }); diff --git a/packages/ui/src/lib/editor/canvas/canvas-store.svelte.ts b/packages/ui/src/lib/editor/canvas/canvas-store.svelte.ts index 5d772df..f9ffea7 100644 --- a/packages/ui/src/lib/editor/canvas/canvas-store.svelte.ts +++ b/packages/ui/src/lib/editor/canvas/canvas-store.svelte.ts @@ -11,7 +11,6 @@ import { themeStore } from '../theme.svelte'; import { ArrowTool, Camera, - createId, createToolMap, CursorStore, diffDoc, @@ -497,49 +496,8 @@ export function createCanvasController( }; function insertStencil(stencil: Stencil, worldPos: { x: number; y: number }) { - const snap = snapStore.get(); - let pos = { ...worldPos }; - if (snap.snapEnabled && snap.gridEnabled) { - const gridSize = snap.gridSize; - pos = { - x: Math.round(pos.x / gridSize) * gridSize, - y: Math.round(pos.y / gridSize) * gridSize - }; - } - const state = store.getState(); - const pageId = state.ui.currentPageId; - if (!pageId) return; - - const shapes = stencil.spawn(pos); - const groupId = shapes.length > 1 ? createId('group') : undefined; - - const newShapes = { ...state.doc.shapes }; - const page = state.doc.pages[pageId]; - if (!page) return; - - const newPageShapeIds = [...page.shapeIds]; - const newSelection: string[] = []; - - for (const shape of shapes) { - shape.pageId = pageId; - if (groupId) { - shape.groupId = groupId; - } - newShapes[shape.id] = shape; - newPageShapeIds.push(shape.id); - newSelection.push(shape.id); - } - - const nextState = { - ...state, - doc: { - ...state.doc, - shapes: newShapes, - pages: { ...state.doc.pages, [pageId]: { ...page, shapeIds: newPageShapeIds } } - }, - ui: { ...state.ui, selectionIds: newSelection } - }; + const nextState = stencils.insertStencil(state, stencil, worldPos, snapStore.get()); runtime.commit( state, diff --git a/packages/ui/src/lib/editor/components/Toolbar.svelte b/packages/ui/src/lib/editor/components/Toolbar.svelte index fab40ed..6394351 100644 --- a/packages/ui/src/lib/editor/components/Toolbar.svelte +++ b/packages/ui/src/lib/editor/components/Toolbar.svelte @@ -10,9 +10,11 @@ EditorState as EditorStateType, EllipseShape, LineShape, + MarkdownShape, RectShape, ShapeRecord, Store, + StrokeShape, TextShape, ToolId } from '@inkfinite/core'; @@ -72,6 +74,8 @@ let exportButtonEl = $state(null); let fillColorValue = $state(DEFAULT_FILL_COLOR); let strokeColorValue = $state(DEFAULT_STROKE_COLOR); + let fillOpacityValue = $state(1); + let strokeOpacityValue = $state(1); let fillDisabled = $state(true); let strokeDisabled = $state(true); let brush = $derived(brushStore.get()); @@ -98,6 +102,8 @@ const selection = getSelectedShapes(editorState); const fillable = selection.filter(shapeSupportsFill); const strokable = selection.filter(shapeSupportsStroke); + const fillOpacityTargets = selection.filter(shapeSupportsFillOpacity); + const strokeOpacityTargets = selection.filter(shapeSupportsStrokeOpacity); fillDisabled = fillable.length === 0; strokeDisabled = strokable.length === 0; if (fillable.length > 0) { @@ -120,13 +126,24 @@ strokeColorValue = shared; } } + fillOpacityValue = getSharedOpacity(fillOpacityTargets, (shape) => shape.fillOpacity) ?? 1; + strokeOpacityValue = + getSharedOpacity(strokeOpacityTargets, (shape) => + shape.type === 'stroke' + ? (shape.strokeOpacity ?? shape.props.style.opacity) + : shape.strokeOpacity + ) ?? 1; }); let showColorControls = $derived( toolSupportsStyles(currentTool) || toolSupportsFill(currentTool) || getSelectedShapes(editorState).some( - (s) => shapeSupportsFill(s) || shapeSupportsStroke(s) + (s) => + shapeSupportsFill(s) || + shapeSupportsStroke(s) || + shapeSupportsFillOpacity(s) || + shapeSupportsStrokeOpacity(s) ) ); @@ -348,6 +365,30 @@ ); } + function shapeSupportsFillOpacity( + shape: ShapeRecord + ): shape is RectShape | EllipseShape | TextShape | MarkdownShape { + return ( + shape.type === 'rect' || + shape.type === 'ellipse' || + shape.type === 'text' || + shape.type === 'markdown' + ); + } + + function shapeSupportsStrokeOpacity( + shape: ShapeRecord + ): shape is RectShape | EllipseShape | LineShape | ArrowShape | StrokeShape | MarkdownShape { + return ( + shape.type === 'rect' || + shape.type === 'ellipse' || + shape.type === 'line' || + shape.type === 'arrow' || + shape.type === 'stroke' || + shape.type === 'markdown' + ); + } + function toolSupportsStyles(tool: ToolId): boolean { return tool === 'rect' || tool === 'ellipse' || tool === 'line' || tool === 'arrow'; } @@ -375,6 +416,15 @@ return first; } + function getSharedOpacity( + shapes: T[], + extract: (shape: T) => number | undefined + ): number | null { + if (shapes.length === 0) return null; + const first = extract(shapes[0]) ?? 1; + return shapes.every((shape) => (extract(shape) ?? 1) === first) ? first : null; + } + function applyFillColor(color: string) { const state = store.getState(); const targets = getSelectedShapes(state).filter(shapeSupportsFill); @@ -471,6 +521,37 @@ applyStrokeColor(input.value); } + function applyOpacity(field: 'fillOpacity' | 'strokeOpacity', value: number) { + const state = store.getState(); + const targets = getSelectedShapes(state).filter( + field === 'fillOpacity' ? shapeSupportsFillOpacity : shapeSupportsStrokeOpacity + ); + if (targets.length === 0) return; + const opacity = Math.min(1, Math.max(0, value)); + const before = EditorState.clone(state); + const shapes = { ...state.doc.shapes }; + for (const shape of targets) { + shapes[shape.id] = { ...shape, [field]: opacity } as ShapeRecord; + } + const after = { ...state, doc: { ...state.doc, shapes } }; + store.executeCommand( + new SnapshotCommand( + field === 'fillOpacity' ? 'Set fill opacity' : 'Set stroke opacity', + 'doc', + before, + EditorState.clone(after) + ) + ); + } + + function handleOpacityChange(event: Event, field: 'fillOpacity' | 'strokeOpacity') { + const value = (event.currentTarget as HTMLInputElement).valueAsNumber; + if (!Number.isFinite(value)) return; + if (field === 'fillOpacity') fillOpacityValue = value; + else strokeOpacityValue = value; + applyOpacity(field, value); + } + function handleBrushChange(newBrush: BrushSettings) { brushStore.set(newBrush); } @@ -628,6 +709,36 @@ aria-label="Stroke color" /> {/if} + {#if getSelectedShapes(editorState).some(shapeSupportsFillOpacity)} + + {/if} + {#if getSelectedShapes(editorState).some(shapeSupportsStrokeOpacity)} + + {/if} {/if} @@ -1043,6 +1154,19 @@ text-align: right; } + .toolbar__opacity-control { + display: grid; + grid-template-columns: minmax(6rem, auto) 7rem 3rem; + align-items: center; + gap: 6px; + font-size: 0.75rem; + } + + .toolbar__opacity-control output { + font-variant-numeric: tabular-nums; + text-align: right; + } + @media (max-width: 1320px) { .toolbar__tool-label, .toolbar__tagline { diff --git a/packages/ui/src/lib/editor/components/Toolbar.svelte.test.ts b/packages/ui/src/lib/editor/components/Toolbar.svelte.test.ts index 79e857c..3bf6d4f 100644 --- a/packages/ui/src/lib/editor/components/Toolbar.svelte.test.ts +++ b/packages/ui/src/lib/editor/components/Toolbar.svelte.test.ts @@ -1,4 +1,4 @@ -import { Store } from '@inkfinite/core'; +import { PageRecord, ShapeRecord, Store } from '@inkfinite/core'; import { describe, expect, it, vi } from 'vitest'; import { render } from 'vitest-browser-svelte'; @@ -29,4 +29,47 @@ describe('Editor Toolbar', () => { .element(screen.getByRole('menuitem', { name: 'Zoom to 100%' })) .toBeInTheDocument(); }); + + it('changes selected fill and stroke opacity through labeled undoable controls', async () => { + const page = PageRecord.create('Page', 'page'); + const shape = ShapeRecord.createRect( + page.id, + 0, + 0, + { w: 20, h: 20, fill: '#fff', stroke: '#000', radius: 0 }, + 'shape' + ); + const store = new Store(); + store.setState((state) => ({ + ...state, + doc: { + pages: { [page.id]: { ...page, shapeIds: [shape.id] } }, + shapes: { [shape.id]: shape }, + bindings: {} + }, + ui: { ...state.ui, currentPageId: page.id, selectionIds: [shape.id] } + })); + const screen = render(Toolbar, { + currentTool: 'select', + onToolChange: vi.fn(), + store, + getViewport: () => ({ width: 1024, height: 768 }), + brushStore: createBrushStore() + }); + + const fill = screen + .getByRole('slider', { name: 'Fill opacity' }) + .element() as HTMLInputElement; + fill.value = '0.4'; + fill.dispatchEvent(new Event('change', { bubbles: true })); + const stroke = screen + .getByRole('slider', { name: 'Stroke opacity' }) + .element() as HTMLInputElement; + stroke.value = '0.65'; + stroke.dispatchEvent(new Event('change', { bubbles: true })); + + expect(store.getState().doc.shapes.shape.fillOpacity).toBe(0.4); + expect(store.getState().doc.shapes.shape.strokeOpacity).toBe(0.65); + expect(store.canUndo()).toBe(true); + }); });