Commit graph

7 commits

Author SHA1 Message Date
Danila Poyarkov 03b70328bf
feat: edit variables as tokens in the variables dialog (#907)
* feat: prototype the design tokens panel

Undoable editor actions for token fields and mode conditions, a token view model, and table, inspector, collection and stylesheet panels shown in a Storybook story with a real editor.

* feat: lay the tokens panel out for mobile

Below the mobile breakpoint the collection tabs become a select, the list shows one chosen mode with the CSS name under each token, and the token, the modes, and the stylesheet each open over the list behind Back. CodeViewer can fill its container for the full-screen stylesheet.

* feat: share drill-in navigation and put tokens on a listbox

PanelDrillIn gives detail views one back control that names where it returns, a slide preset from theme/motion, and focus that moves into the detail and back to what opened it. The Settings model editor and the tokens panel's mobile views use it. The token list is a Reka Listbox with arrow-key navigation and labelled groups, and the inspector and stylesheet swap with a short fade under the motion policy.

* feat: lay the tokens panel out by container width

The panel switches to the compact drill-in by its own measured width, and the list's columns follow the list's width through container queries, so the panel adapts inside dialogs and split views, not only on phones. src/AGENTS.md now says when to use container queries, measured size, and viewport breakpoints.

* test(core): group the variable undo tests in a domain folder

* feat: edit variables as tokens in the variables dialog

The variables dialog now hosts the tokens panel: a grouped token list with CSS names, an inspector for each token and for the collection and its modes, and the live stylesheet. It keeps adding variables by type, search, collection and mode management, and copying the document's stylesheet, and lays out by its own width down to phones.

useVariables().collections returns copies, so components given a collection see modes added or renamed in place, and addVariable returns the new ID so the panel can open it.

* fix: satisfy the type-aware lint in token updates and panel stories

* docs: describe the tokens panel in the translated variables guides

* feat: say when each mode applies in plain words

A mode's condition was a raw CSS field that designers could not read. Each non-default mode now picks when it applies (switched manually, system dark or light mode, high contrast, reduced motion, screen or container width, or custom CSS), with the CSS it writes shown underneath and a note on how the mode behaves on the canvas and in exported code. Presets only write the condition string modes already store, so files round-trip unchanged. Column headers name the condition in words, and deleting the collection moves into a menu beside its name.

* feat: bring the variables dialog up to Figma's

The dialog now covers what Figma's variables dialog offers: collections and groups in a sidebar with counts, a group, search and type filter, type icons, names and values edited in place, drag to reorder, multi-select with a context menu, the Delete key and a side panel to duplicate, group and delete, aliases chosen from a variable picker and detached back to the value they showed, hiding from publishing, and an expand toggle. It opens from View → Variables… and the command palette as well as the Design panel.

It also fixes review findings: a single-mode collection labels its value Value, column titles share one font, a CSS name is typed after a fixed -- and checked by the CSS parser before it is saved, and the stylesheet previews CSS with the format chosen when copying. AppInput applies an instance's input classes last so they can override adornment padding.

* feat: undo and redo inside the variables dialog

Canvas shortcuts stop while any dialog is open, so Cmd+Z did nothing in the variables dialog although every edit there is on the editor's history. Undo and redo now take a document scope: they also run when the topmost layer is a dialog that edits the document, which the variables dialog marks, while menus, pickers and Settings still hold them back and a field with uncommitted text keeps its own undo.

Enter commits a field and returns focus to the list. The panel drops selections, group filters and collections that undo removed. A color picker session undoes as one step through a coalesce key on updateVariableValue, and undoing a deletion puts the variable back in its place.

* feat: search variables like the command palette

The variables search matched a substring of the name only, so a CSS name, a hex color or a description found nothing, and the palette, AppPicker and AppCombobox each spelled out the same Fuse.js options. One helper in @open-pencil/vue now owns the matching: fuzzySearch ranks results for lists people pick from, and fuzzyFilter keeps a list's own order for lists people arrange. The panel searches names, CSS names, descriptions and every mode's value, alias names included; useVariables() searches names and descriptions.

* fix: say a token group once when its name repeats it

Kits that mirror Tailwind classes name tokens like Gap/gap-1, which derived --gap-gap-1. A group the next segment repeats is now said once, giving --gap-1.

* fix: keep the add variable menu under its button after a resize

The toolbar swaps the icon button for the labelled one when the dialog widens. Reka keeps the anchor it mounted with, so the menu opened at the window corner; keying the trigger remounts it with the new button.

* fix: leave bound layers alone when variables are added or reordered

Adding, copying, or reordering variables re-resolved every bound layer in the document. In the shadcn kit two Avatar instances are saved at 24 while their binding gives 40, so adding a number resized them and relaid out about 7,800 layers, freezing the browser for seconds. These changes alter no bound value and now only request a render, as renaming already did.

* refactor: let the variables dialog own its undo shortcuts

Undo and redo in the dialog went through a document scope in the global shortcut registry, which decided whether the dialog was on top by querying Reka's dismissable layers in DOM order. The dialog now listens on its own content with a tinykeys handler built from the command keybindings: menus and pickers it opens portal elsewhere, so their keys never reach it, and the registry is back to one scope.

* refactor: read mode conditions with css-tree

Presets were recognized by normalizing the condition with regular expressions and matching another. css-tree, which Core already ships through unifont, now parses the condition into its media or container feature, so spacing, case and comments are handled as CSS does, and a non-breaking space, which CSS does not treat as whitespace, no longer turns a custom condition into a preset.

* refactor: take the mode attribute hint from modeAttribute

The hint for a manually switched mode cut the brackets off its selector with a regular expression. It now reads the attribute name and value from modeAttribute, which the selector is built from.

* fix: keep a refused CSS name in focus and color picker sessions apart

Enter on a CSS name the parser refuses no longer hands the keyboard back to the list, so the name can be corrected. The typed name is read with parseCSSName instead of stripping a leading -- by hand, so a pasted var(--name) works too.

Each color picker session takes a random undo key; a counter restarted when the inspector remounted, so two sessions on the same token could merge into one undo step.

* fix: keep token expressions and cleared fields in step with their variable

Editing a number left its CSS expression recording the old number, so reopening the file dropped the expression as edited elsewhere. A number now updates its expression, and an alias drops it, in the same undo step.

Undoing a token field that had been unset kept the key with an undefined value; it is now removed.

* fix: preview and detach aliases in their own mode

An alias in the Dark column showed, and detached to, what its target gives in the mode the canvas is in. Both now resolve in the column's mode.

* fix: drop tokens a filter hides from the selection

A search, group, or type filter could hide selected tokens that stayed selected, so the inspector, the bulk actions, and the Delete key still acted on rows the list no longer showed.

* fix: keep a drill-in's list out of the tab order while its detail slides

The list stayed focusable until the detail finished sliding in, and became focusable again under a detail sliding out. It is now inert from the moment the detail opens and the departing detail is inert. A detail with no field focuses its back control, and hidden inputs are skipped.

* docs: drop a duplicated Stroke entry from the changelog

Two merges left the Stroke-extends-Fill breaking change twice; the copy that still said strokes render solid only is outdated, since gradient and image strokes now import and render.
2026-10-06 12:35:40 +00:00
Danila Poyarkov 9dce9fdcbc
feat(collab)!: sync layer trees as a CRDT and open each room in its own tab (#902)
* fix: stop ancestor walks from hanging on a parent cycle

A collaborator's concurrent reparent can leave two layers as each other's
parent. isDescendant, the design check's pageOf, and component sync walked
parentId without a bound, so applying such a change froze the editor.

Add SceneGraph.closest(), a bounded nearest-ancestor lookup, and use it for
these walks so bad data ends the walk instead of the tab.

* fix(collab): sync layer moves without parent cycles or stale child lists

Remote changes assigned each layer's synced parentId and childIds as plain
properties. Two peers moving layers into each other made them each other's
parent, a moved layer stayed listed under its old parent, reorders never
synced, and concurrent additions ended up in different orders or missing
from the parent's list.

Apply the tree after a change's properties: move layers to their synced
parents, skip a move that would make a layer its own ancestor and write the
layer's current parent and position back so every peer settles on it, and
derive each touched parent's childIds from its synced order followed by
unlisted children by id. Locally, a parent's child list syncs once after
each edit that adds, removes, moves, or reorders its children.

Fixes #888
Fixes #889

* refactor(scene-graph)!: move sibling order keys to scene-graph

Collaboration needs the same fractional keys as .fig export to order
siblings, and the app must not depend on @open-pencil/fig for them. Move
fractionalPosition, orderKeyBetween, and siblingOrderKeys to
@open-pencil/scene-graph/order-keys.

orderKeyBetween now always returns a key: when no printable key fits it
returns one above lo, which hasOrderKeyBetween detects, so callers no
longer branch on null. It takes an optional suffix, and siblingOrderKeys
can request one per key, so two peers inserting at one spot get distinct
keys. .fig export keeps its keys.

* fix(scene-graph): report instance child reorders as graph events

Instance sync sorted an instance's children in place, so nothing that
listens to graph events saw the new order; in a shared room the order
never reached other peers. Move each child that changes position with
insertChildAt, which reports the reorder.

* feat(collab): resolve the layer tree from each layer's parent history

Add a pure LayerTree for the shared document: each layer records every
parent it was moved under with a move counter and an order key. A layer
sits under its newest parent, ties broken by parent id; when concurrent
moves close a loop, the latest move in the loop falls back to the
layer's next entry until none is left, and a layer no parent can take
goes to its page (Evan Wallace's mutable tree hierarchy CRDT).

The result depends only on the entries, and resolution revisits only
changed, orphaned, and displaced layers. Seeded random runs check that
peers converge and that the incremental result matches a full one.

* fix(collab): sync layer moves as parent history and order keys

Each layer's shared map now records every parent it was moved under with
a move counter from a document-wide Lamport clock, and its order key
among siblings, instead of parentId and childIds. Every peer derives
parentId and childIds from these with LayerTree, so concurrent moves,
reorders, and additions merge, a loop from concurrent moves undoes its
latest move, and a layer whose new parent was deleted meanwhile returns
to its previous one.

A local edit's graph events are written once after the edit, in one
transaction. It records a parent entry for each layer whose parent
changed, a new entry for displaced layers on the touched paths so a move cannot pull
them back, and keys between the moved layers' neighbours with a random
suffix, so concurrent inserts at one spot get distinct keys. Remote
changes find their layers through each event's path, resolve only what
they touch, and move and sort layers through insertChildAt.

This replaces the childIds merge and the write-back of rejected moves:
a rejected move now resolves the same way on every peer from the shared
history, so nothing needs to be written back.

Fixes #888
Fixes #889

* feat(collab)!: convert saved rooms and keep mismatched builds apart

A room saved by an earlier build records parentId and childIds. When a
change brings in such layers, from this browser's storage or a peer,
convert them in one transaction: each layer's synced parent becomes its
only entry with counter 0, its position in the parent's synced childIds
becomes an order key, and the old fields go. The result depends only on
the document, so two peers converting at once write the same values,
and converting again writes nothing. The document's meta map records
treeFormat 2.

Builds that record the tree differently would corrupt each other's
rooms, so the collaboration namespace becomes openpencil/2 for Trystero
and the test relay alike, and each peer publishes its treeFormat in
awareness for future version messages.

* fix(collab): send a layer whose parents were all deleted back to its own page

Each layer's shared map records the page it was last placed on, and the
layers under a frame moved to another page are re-recorded. A layer whose
parent chain was deleted goes back to that page, falling back to the first
page only when the recorded one is gone. Saved rooms record pages when
they are converted.

* fix(collab): keep one root per room when peers edit their own documents

Joining a room keeps the joiner's earlier document in its graph, and an
undo or an edit made before the room arrived could still reach it. That
edit shared the joiner's root, every peer adopted it, and the room's
pages disappeared.

The room now records its root as claims in meta, each with the time it
was made, and every peer follows the earliest. Sharing claims the room,
and so does the first edit in a room nobody has shared, which now shares
the whole document as Share does. A peer shares only layers under the
room's root. Converted rooms claim the root with the most children.

Move counters must also be safe integers, so an oversized counter from
another peer cannot stop the move clock from advancing.

* fix(collab): rank root claims by how they were made, not by clocks

Root claims carried the claiming peer's wall-clock time, so a guest whose
clock ran behind the sharer's could still win the room with an edit made
before the room reached them. A claim now records whether it came from
Share or a converted room, or from the first edit in an unshared room;
a shared root outranks an edited one, and the lower id breaks a tie.
A claim also replaces an invalid value already stored for its root.

* fix(collab): keep every root claim through concurrent writes

A root claim was one key per root holding its kind, so two peers claiming
the same root by Share and by an edit at once kept only one of the two
values, and the shared claim could be lost. Each kind of claim on a root
is now its own key. Converting a saved room also claims its root unless
a shared claim exists, so a guest's earlier edited claim no longer keeps
the converted room from outranking it.

* fix(collab): mint layer IDs under a session of each editor window

Every editor window started its IDs at 0:1 from the same counter, so two
people adding layers to a shared room at once could mint the same IDs,
and one person's layers replaced the other's in the room. A joiner's
starting page also took the sharer's page ID and stayed in their list.

SceneGraph's default IDs now carry a session set with setIdSession, as
in Figma's sessionID:localID GUIDs. The editor picks a random 32-bit
session at startup, as Yjs does for each document's clientID; headless
tools keep session 0, so the CLI and MCP server give a file's layers the
same IDs on every run.

* fix(collab): let only Share set a room's root

A guest's first edit in a room whose contents had not arrived claimed
the room and wrote the guest's whole open document into it, images
included, and adopting the sharer's root later only hid it. Every room
starts with someone sharing a document, so a guest has nothing to claim:
only Share, or converting a saved room, now sets the room's root, as a
single value in meta, and a peer writes nothing until the root is known.

Unbinding a room also writes an edit still waiting to be sent, so a move
or deletion made just before leaving reaches the room.

* feat(collab)!: open each room in a tab of its own

Joining a room bound it to whatever tab was active, so a pasted link
could turn a saved file into the room's document, and a share link first
showed an editable blank document. A room is now a document: joining
always opens it in a new tab, or switches to the tab already showing it,
and only Share puts an existing tab's document into a room.

Every room tab owns its session (src/app/collab/rooms.ts and
session.ts), so several rooms can be live at once and keep syncing in
the background. The collaboration panel, presence, following, and the
/share/<id> address follow the active tab, and a canvas publishes its
cursor and selection only to its own tab's room.

A room tab derives its state: joining while its saved copy loads, then
waiting, with an explanation, while nobody who has the file is online;
live with others, or alone on this device's copy. Until the document
arrives the room's screen replaces the editor. Reloading a share link
rejoins it; leaving a room you joined keeps its file as a local unsaved
copy. "Connected" now means another peer answered. Pasted links and IDs
are normalised and validated, and invalid ones say so.

People join right away under a generated name such as "Teal Fox", with
a hint to set one; the one app-wide name is set in the share panel or
in Settings. On a phone, Share copies the room's link instead of making
a new room, and the presence popover shows the room's state.

* feat(desktop): open rooms from openpencil://join links and Home

The desktop app could not receive a share link: links point at the web
app, and openpencil:// only opened files. openpencil://join?room=<id>
now opens the room in a tab of its own. The native parser refuses
anything but a room ID, queues rooms for the frontend through
take_pending_rooms, and a second launch on Windows and Linux forwards
its link through the single-instance handler.

In a browser on a computer, the room's screen and the share panel offer
Open in desktop app, a link the browser hands to the app on click; it
never opens the app by itself. Home gains Join room…, which takes a
pasted room link or ID and opens the room in a new tab.

* feat(collab): set your name on the room screen

The room screen told someone joining under a generated name to set
their name but offered nowhere to do it before the file arrived. It now
has the same name field as the room panel.

* feat(collab): offer the desktop download beside Open in desktop app

A browser on a computer offers a room's openpencil://join link, which
does nothing where the app is not installed. The room screen and panel
now link to the latest release beside it.

* docs: describe joining rooms in the German, Polish, and Russian guides

Bring the translated collaboration pages up to the English one: Share as
the only way into a room, joining in a tab of its own, the waiting
screen, leaving with a local copy, and how layer moves merge. The
English page now names the panel's Leave room button.

* fix(collab): lay out the room screens like the app's empty states

The joining and waiting screens were a left-aligned card with a stray
spinner, a primary Copy link button beside an outline button and a
bare link, and a name field on a screen that lasts seconds. They now use
AppPlaceholder, centred over the tab: a heading, the explanation, the
two hints, secondary Copy link and Leave, a 'You'll appear as' line
whose Change opens a small rename popover, and the desktop handoff on
one muted line. The room panel lines its status dot up with wrapped
text, no longer selects the room link when it opens, and puts the
desktop links and Leave room on one footer line.

* fix(collab): show what a room tab is doing instead of a timed guess

A joined tab said nobody with the file was online five seconds after it
opened, whether or not it had reached the signaling service or met the
people already in the room. Its state now follows what the tab can
observe: connecting until the service answers, looking for people for as
long as that transport takes to introduce everyone, getting the file
from someone who says they have it, waiting when nobody who has it
showed up (naming other guests waiting too), and a can't-connect screen
when the service cannot be reached. Each peer says in its presence
whether it has the room's file.

* fix(collab): list other waiting guests with the explanation

The line naming other guests who are waiting too is information, not an
action, so it follows the hints above the buttons. Peers' hasFile flag
is optional, as older builds do not send it.

* fix(collab): send a canvas's cursor and selection to its tab's room again

Canvases read the editor through a proxy that follows the active tab,
and the room lookup by store never matched it, so pointer moves and
selections stopped reaching the room: collaborators lost each other's
cursors, selections, and page markers. The canvas now looks up its
room by its tab's own store, and its selection listener ends when the
canvas unmounts instead of piling up across tab switches.

* feat(collab): one avatar stack for the toolbar, the mobile pill, and pages

The toolbar, the page list, and the mobile HUD each drew the people in a
room their own way, and the mobile pill read 'Online: 3' in hard-coded
English beside a status dot too small to render. AvatarStack now draws
people overlapping with their agent counts and '+N', and every place
uses it: the toolbar wraps each avatar in its menu or hover card, the
mobile pill shows the room's state dot and the stack with a translated
name, and hovering a page with people on it opens a card with the stack
and who is there, with their agents, to follow. The mobile list is the
shared presence list, so it is translated and can follow agents too.

* docs: note the page hover card and mobile avatars in the changelog

* fix(collab): stop listening to a room once its tab leaves it

A session left its Yjs observers and its awareness listener attached
after dispose, relying on destroy() and the order of teardown not to
touch the tab again. It now removes them explicitly and clears its peer
list. Also fix a missing comma in the Polish collaboration guide.
2026-10-06 10:40:25 +00:00
Danila Poyarkov f465da8b81
fix: release documents when their tabs close (#904)
* fix(vue): release CanvasKit WebGL contexts when canvases go away

GetWebGLContext registers a canvas's context in CanvasKit's global
table, and the surface manager only called deleteContext on a failed
setup. Every destroyed canvas, and every surface rebuilt after a resize
or color-space change, stayed registered, and through the canvas
element CanvasKit kept the closed editor's component tree, store, and
graph alive.

The manager now keeps the handle and releases it on rebuild and
destroy. deleteContext also leaves CanvasKit holding the last current
context, so when the released context was current, a parking context
on a 1x1 offscreen canvas becomes current instead.

* fix(core): uninstall an editor's text measurer when it closes

setCanvasKit installed a global text measurer that closed over the
editor and its renderer, and nothing uninstalled it, so the last editor
to set up a canvas stayed alive after its tab closed and layout kept
measuring with its destroyed renderer. installTextMeasurer returns an
uninstall function; an editor uninstalls its measurer when disposed or
when its last renderer goes, and the most recent measurer still
installed takes over.

* fix(app): give editor stores their own effect scope

The first store is created during WorkspaceView's setup, so effects
created while building it joined the view's scope. Their cleanups
stayed registered there after the store was disposed and kept the
startup document alive for the life of the app. Stores now build inside
a detached effect scope that dispose stops.

* fix(app): follow the active tab in app-level editor subscriptions

App.vue provided a proxy that resolved to whichever store was active
when a property was read, so app-level composables such as the menu
and keyboard commands subscribed once to the startup store. They missed
events from later documents and kept that store alive, and Undo and
Redo availability came from the first document's history.

The app-level editor now moves event subscriptions to the active store,
and each tab's editor UI gets its own store through EditorTabScope, so
per-tab components stay bound to their document when it closes.
Selection capabilities read the undo history lazily instead of
capturing the first store's manager.

* fix(app): stop keeping closed documents in chat history and startup

The chat history kept the last editor it served only to compare it with
the next one; it now holds it weakly. WorkspaceView's first tab was a
top-level setup binding, which Vue keeps on the instance; it is now
block-scoped.

* test(app): check that documents closed in tabs are released

Opens a document in a new tab three times, closes each with discard,
forces garbage collection, and checks that weak references to the
closed graphs clear. On master all three graphs stay alive.

* refactor(app): provide the tab's store from a tab-keyed editor view

EditorTabScope existed only to provide a tab's store to its editor UI.
WorkspaceView now keys EditorWorkspace by tab, whose contents were
already remounted per tab, so the view provides the tab's store in its
own setup and the wrapper component goes away.

* fix(app): stop a store's effect scope when building the store throws

Effects created before the throw would otherwise stay alive with the partial store, which no caller can dispose.

* test(app): avoid empty callbacks in the store scope tests
2026-10-05 18:11:34 +00:00
Danila Poyarkov aa86873dd7
test: typecheck the test suites and fix what that found (#896)
* build: typecheck the test suites

Tests were in no TypeScript program: no tsconfig included tests/** or
packages/*/tests/**, and bun strips types without checking them, so a
fixture could drop a required field and keep passing until something
read it.

@types/bun moves to the root because it was installed per package only,
and #cli-tests/* joins the paths the root config already carries.

* test: fix the type errors the test suites were hiding

Typechecking the tests turned up 1123 errors. Most were ordinary
strictness, but some were real: `NodeChange` bound to Figma's plugin
typings rather than the Kiwi codec in thirteen .fig tests,
materializeInstance was called with six arguments against five so the
blobs and source children were dropped, CanvasKit pixels were written
to a plain object that never reached WASM, and assertions were made
through accessors that do not exist, so they asserted nothing.

Fixtures that had quietly lost a required field now carry it, nullable
results are narrowed through the existing expectDefined helper rather
than assumed, and stand-ins for CanvasKit and the editor go through one
named helper instead of an unexplained cast at each site.

No test was deleted, skipped, or weakened, and no `any`, non-null
assertion, or ts-expect-error was introduced.

* docs: record what typechecking the tests established

Pins the app program's global types with an assertion rather than a
note, since an unpinned types list lets any root @types package decide
which platform src/** is judged against.

The two environment faults that look like code regressions — Vite's
dependency pre-bundle outliving a package rebuild, and heavy .fig
suites failing under load — go to the development docs, where an
explanation belongs.

* fix: align @types/bun and keep node types resolvable when extended

The root manifest declared a newer @types/bun than every package, which
check:monorepo rejects, and pinning the app program's types left them
unresolvable from a config that extends this one out of tree.

* fix: fail the test typecheck when the compiler itself fails

The gate matched diagnostics by substring, so a compiler or config
failure that named no test file printed a pass while having checked
nothing. Diagnostics are now split by whether they name a file: an
unscoped one is the run failing and stops the gate, a test file's is a
finding, and a source file's stays out by design.

Also drops the parameter planComponentConstruction never read, and
makes the inner-shadow verification script exit non-zero when it
renders no image instead of logging and succeeding.

* chore: merge master into tests-typecheck
2026-10-05 12:42:38 +00:00
Danila Poyarkov f6848434ec
feat: check designs live with a Lint panel, canvas markers, and fixes (#804)
* feat: check designs live with a Check panel and canvas issue markers

Design lint only ran from the CLI and AI tools, and its rules were too noisy
to show continuously: on a real imported page 786 of 888 layers had a
warning. The rules now report where a finding is actionable (a hardcoded
color only when a variable matches it, nesting only where the limit is
crossed, instance sublayers through their main component) and carry
structured data, and Recommended keeps warnings for likely problems.

The app checks the current page after edits settle. The Check tab groups
issues by rule with hover highlighting, reveal on click, and one-step
variable binding. Errors and warnings are marked on the canvas with
clustered markers that roll up to visible ancestors when zoomed out; markers
explain themselves on hover, open Check on click, and toggle with
View > Design issues.

* fix: keep the right panel and markers stable

The Check tab made the right-panel tab row overflow at common window widths,
so focusing the zoom menu scrolled the row and shifted the panel. Code and
AI tabs now drop their labels to screen readers when the row is narrow.

Touch target names are matched as whole words: "Rectangle" contained "cta"
and marked every rectangle. Markers also stay drawn during interactive edits
instead of blinking while a value is scrubbed.

* fix(ui): show right panel tab labels whenever they fit

* fix(ui): name the design check tab Lint and keep panel tabs consistent

The tab was an unlabelled icon between labelled Code and AI tabs. It is now
Lint, with the same icon and label anatomy as its neighbours, and its icon
takes the severity color instead of a count badge. All labelled tabs show
their labels when the row fits and drop them together when it does not.

* refactor(ui): build the Lint panel from shared components

Issue groups use AppCollapsible, actions use AppButton, and the severity
filters are a Reka toggle group with keyboard navigation. Issue rows no
longer nest a button inside a button. Panel state, visibility and the
focused-issue scroll live in useDesignCheckPanel, the rules menu is its own
component, and rule preferences change through preference actions.
Severity ordering reuses Core's ranking, detail numbers follow the app
language, and the check debounce uses useTimeoutFn.

* fix(lint): check the WCAG AA touch target size in the Recommended preset

Recommended flagged a 394 × 39 input because it required the 44 × 44 AAA size. It now checks the 24 × 24 AA minimum through a minSize option; Strict and Accessibility keep 44 × 44.

* feat(lint): fix design issues from rules, the Lint panel, the CLI, and agents

Rules attach fixes as data: a safe fix keeps the design as it looks (bind a
color to the variable it matches, round subpixel geometry that layout does
not own), a suggestion changes values (snap radius and spacing to the
scale, raise small text to the minimum). One Core applier re-validates
each fix against the current graph and merges changes per layer.

The Lint panel offers a fix per row and Fix all for safe fixes as one undo
step; openpencil lint --fix writes the fixed document; the lint and
lint_fix tools expose the same to MCP and AI chat.

The design-check spec's Close button is now 24 x 20: at 24 x 24 it passes
the WCAG AA touch target size that Recommended checks.

* feat(lint): pin issues outside the view to the canvas edge

Errors and warnings on layers outside the viewport had no marker, so a
check could report issues nobody could see. They are now pinned to the
canvas edge where a ray from the viewport center toward them leaves it,
with a chevron pointing their way; pins in one direction merge like
markers. Hovering lists them under the direction they lie in, and
clicking reveals and opens the most severe, nearest one.

Pins keep clear of UI floating over the canvas: the toolbar marks itself
with data-canvas-obstacle, and canvases report such rectangles to the
renderer through getOverlayObstacles each frame.

* feat(lint): mark layers with design issues in the Layers panel

Like an IDE marks files with problems and the folders holding them, a
layer with errors or warnings shows the most severe as an icon, and a
collapsed layer with issues inside it shows a dot in that color.
Suggestions stay in the Lint panel, as on the canvas, and the marks
follow the View → Design issues toggle.

* feat(lint): show issues per page and across the document

Loaded pages beyond the current one are now checked in the background,
one page at a time while the editor is idle, and checked again only when
an edit touches them; pages a large .fig file has not loaded are left
alone until opened rather than forced in. The page list shows each page's
errors and warnings like an IDE's problem count, and the Lint panel gains
a Document scope that lists every page's issues, tags the ones on other
pages, and switches to a row's page when it is opened.

* test(lint): use the core-tests alias and no comma operator in lint tests

Master now rejects ../../ imports and the comma operator in tests.

* refactor(app): create the Lint session with the editor store modules

The composition root passed its line budget once master added recent
pages; the Lint session belongs with the other per-editor services that
the modules factory creates and disposes.

* docs(changelog): keep master's latest Unreleased entries
2026-10-04 10:08:39 +00:00
Danila Poyarkov 4895e55c22
fix(app): end a page switch quietly when another one takes over (#845)
Switching pages again before the previous page finished preparing
aborted the first switch, which rejected with an AbortError. Callers
start page switches without awaiting them, so the rejection surfaced as
an error toast.
2026-10-03 20:18:15 +04:00
Danila Poyarkov 79fee711c7
feat(app): jump between pages from the command palette (#801)
* fix(vue): keep the command palette open when a command opens a step

CommandPaletteRoot emitted select for every item, including one that only opens its children, so a host that closes on select closed the palette instead of showing the step. useCommandPalette.select now reports whether a command ran, and the root emits only then.

Disabled items were marked only with Reka's data-disabled; expose aria-disabled so assistive technology announces them.

* feat(app): jump between pages from the command palette

The palette had no way to reach a page. It now lists the pages visited recently in the tab, offers a Go to page step with every page, and finds any page by name.

Recent pages are tracked per editor session from page changes and reset when the document is replaced. Palette items can be search-only, so pages beyond the recent ones appear only when the query matches them. The divider-page rule moves out of PageListRoot so the palette skips dividers the same way, and useCommandPalette is exported from the package root.

* fix(vue): list every item in a command palette step

The result limit also applied to a step's unfiltered list, so Go to page showed only the first 12 pages. A step the user opened now lists all of its items until they search; search results and the top-level list keep the limit.

The palette spec's page setup and current-page observation move to tests/helpers/pages as a setup mutation, a probe, and a Pages-panel driver.

* test(pages): match page rows by exact name

* refactor(app): express page lists with es-toolkit

Recent pages are take(uniq([visited, ...previous]), max); the palette builds its lookup with keyBy and its lists with without, compact, take, and difference instead of hand-built maps, sets, and slices. Tests count with range.
2026-10-01 20:26:35 +04:00