2026-03-29 15:47:02 +00:00
|
|
|
import { expect, test, type Page } from '@playwright/test'
|
|
|
|
|
|
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
|
|
|
import type { SceneNode, Stroke } from '@open-pencil/scene-graph'
|
|
|
|
|
|
2026-05-05 23:22:08 +00:00
|
|
|
import { CanvasHelper } from '#tests/helpers/canvas'
|
fix: convert document colour profiles, keep text edits live, and fix .fig exports (#716)
* fix(vue): update instance text properties while typing
Instance text property edits only reached the canvas on Enter or blur, so
the canvas and layer tree lagged behind the field. Emit model updates as
the text changes, commit on blur or Enter, and route bursts through the
existing interactive-edit lease and undo batch so rapid edits collapse
into one transaction.
* fix(vue): present the canvas in sRGB to keep P3 blends correct
CanvasKit 0.41 wraps sRGB on-screen surfaces as RGBA8 but every other
color space as RGBA16F, while browser drawing buffers stay RGBA8 when
their color space changes. Requesting DISPLAY_P3 therefore produced
invalid destination copies and broken blends: black rectangles and brown
Overlay fills over Display-P3 documents. Keep presentation in sRGB and
read the buffer back rather than trusting the setter, leaving the
document color space and its stored colors untouched.
* perf(fig): encode glyph path commands without per-coordinate allocation
Glyph outline encoding allocated an ArrayBuffer, DataView, and Uint8Array
for every coordinate and spread each byte into a number array, so recovery
snapshots and text-heavy exports blocked the main thread for 159-167ms.
Size the output once and write through a single DataView; encoded bytes are
unchanged.
* refactor(vue): separate component property edit resolution from batching
The live text path had grown a boolean flag through a single applyValue that
resolved the edit, chose the batch, and mutated instances, which made the
two entry points differ only by that flag.
Resolve an edit once, keep a named batch key, and let setValue and
setTextValue state their own batching policy. Watch the page and selection
sources directly now that selection is replaced by identity, drop the
redundant scene dependencies the useSceneComputed wrapper already tracks,
move the variant option projection next to the swap projection, and resolve
the swap candidate list once per controls pass instead of once per control.
* feat(vue): restore wide-gamut P3 presentation where the renderer supports it
CanvasKit wraps sRGB on-screen surfaces as RGBA_8888 and every other color
space as RGBA_F16, with the pixel format deliberately not exposed, so a
Display-P3 surface only matches the browser buffer when that buffer is
floating point. Chromium 122+ provides drawingBufferStorage for that; this
negotiates the pairing, keeps the sRGB fallback everywhere else, and warns
with the existing dismissible banner when a Display-P3 document cannot be
presented in wide gamut.
Software rasterizers advertise the float extensions but fail an offscreen
framebuffer attach on the first content frame, so they stay on sRGB, as do
WebKit and Firefox, which have no drawingBufferStorage. A new
document:color-space-changed event recreates the surface when a P3 document
arrives after mount, which previously kept whatever surface the first
document created.
* refactor(web): report the canvas presentation instead of re-deriving it
The wide-gamut notice decided availability from a capability probe, which can
disagree with the surface: configurePresentation also falls back when the
float storage install is rejected or the color space setter is ignored. Pass
the surface's actual result through a new onPresentation option, mirror the
document color space into app state, and let the notice read both, so it
appears exactly when a Display-P3 document is really presented in sRGB. That
also removes two editor-event subscriptions and a tab watcher.
Rename SafariBanner to FileApiBanner, since the condition is the File System
Access API rather than Safari, and move the availability check and picker call
into one capability module instead of repeating them at each save site.
* refactor(web): point capability notices at one neutral support reference
The file API notice linked "Use Chrome" to a Chrome download page while
naming Edge as inert text, and the wide-gamut notice offered no browser
guidance at all. Both now link to the caniuse support table for the API that
decides the capability, so the advice is vendor-neutral and stays correct as
versions move.
External link behavior moves into one primitive: SettingsLink and both
notices share it, gaining rel="noopener noreferrer" and the desktop opener
path, which the notices need because the wide-gamut notice also renders in
Tauri where a raw anchor cannot open an external page.
* fix(fig): stop failing .fig export on unencodable OpenType feature tags
The Kiwi schema types toggledOn/OffOTFeatures as its OpenTypeFeature enum,
which has no PNUM, TNUM, LNUM, ONUM, FRAC, SMCP, C2SC, SUPS, or SUBS member.
Features that map to a typed axis were only written when enabled, so a
disabled one fell through to a raw tag, and encoding then rejected it:
`Invalid value "PNUM" for enum "OpenTypeFeature"`. Because save and recovery
snapshots share that export path, any text using those features could not be
written to a `.fig` file at all — the demo's own typography comparison hit it
in every run.
Disabled mapped tags now clear their axis to the schema's neutral NORMAL value,
an enabled tag on the same axis wins over a disabled sibling so "TNUM on, PNUM
off" still means tabular figures, and tags with no Kiwi representation are
dropped instead of poisoning the whole export.
* perf(core): recompute layout only for the pages a component edit affects
Editing a component recomputed layout for the entire graph, which cost tens
of milliseconds per edit in documents with several populated pages. Layout now
runs once per affected page: the pages of the edited subtrees, their
components, and every instance of those components, which may live on another
page.
The layout function is injected so the scoping contract is testable, and the
existing behaviour is kept when no page can be resolved.
* feat(core): follow the document colour profile when painting
Numbers in a document are coordinates in the profile that document declares,
so painting into a surface with a different profile has to convert them.
Nothing did: stored values were handed to the GPU as-is, which is why a
Display-P3 document looked more saturated on a wide-gamut display than on an
sRGB one, and why export labels and stored values disagreed.
Rendering now resolves each colour from the document's profile into the
surface's profile, reporting clipping when a wider profile does not fit, and
OKHCL colours resolve into the requested target instead of being baked to
sRGB. New documents also default to sRGB, matching Figma, so Display P3 is
reserved for documents that declare it rather than being assumed for
everything OpenPencil creates.
* test(canvas): exercise the P3 spec on the paint page
The P3 rendering spec used the demo's reference page and the shared
`selectDemoReferencePage` helper. The paint page covers the same ground —
gradients, shadows, blurs, multiply and screen blends, an alpha mask — and
the helper is going away with the reference page, so this keeps the spec
independent of that demo content.
The card specs now follow whichever page owns the card instead of switching
by page name, which works for either demo layout.
2026-09-17 21:43:10 +00:00
|
|
|
import { waitForDemo } from '#tests/helpers/demo'
|
2026-07-14 14:06:28 +00:00
|
|
|
import { propertyItems, propertySection } from '#tests/helpers/properties'
|
2026-05-05 23:22:08 +00:00
|
|
|
|
2026-03-29 15:47:02 +00:00
|
|
|
async function dragSlider(page: Page, canvas: CanvasHelper, testId: string, ratio: number) {
|
2026-07-14 14:06:28 +00:00
|
|
|
const slider = page.getByTestId(testId).locator(':scope > [data-orientation="horizontal"]')
|
2026-03-29 15:47:02 +00:00
|
|
|
const box = await slider.boundingBox()
|
|
|
|
|
if (!box) throw new Error(`Missing slider: ${testId}`)
|
2026-07-14 14:06:28 +00:00
|
|
|
await slider.click({
|
|
|
|
|
position: {
|
|
|
|
|
x: Math.max(2, Math.min(box.width - 2, box.width * ratio)),
|
|
|
|
|
y: box.height / 2
|
|
|
|
|
}
|
2026-03-29 15:47:02 +00:00
|
|
|
})
|
|
|
|
|
await canvas.waitForRender()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function openStrokePicker(page: Page) {
|
2026-07-14 14:06:28 +00:00
|
|
|
await propertyItems(page, 'strokes')
|
|
|
|
|
.first()
|
|
|
|
|
.getByRole('button', { name: 'Stroke', exact: true })
|
|
|
|
|
.click()
|
|
|
|
|
await expect(page.locator('[data-picker-content]')).toBeVisible()
|
2026-03-29 15:47:02 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function chooseFormat(page: Page, label: 'RGB' | 'HSL' | 'HSB' | 'OkHCL') {
|
2026-05-15 09:49:49 +00:00
|
|
|
await page.getByTestId('color-format-select').click()
|
2026-03-29 15:47:02 +00:00
|
|
|
await page.getByRole('option', { name: label, exact: true }).click()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function getSelectedStroke(page: Page) {
|
|
|
|
|
return page.evaluate(() => {
|
2026-05-06 09:19:39 +00:00
|
|
|
const store = window.openPencil?.getStore?.()
|
2026-05-05 23:59:56 +00:00
|
|
|
if (!store) throw new Error('OpenPencil store not initialized')
|
2026-03-29 15:47:02 +00:00
|
|
|
const id = [...store.state.selectedIds][0]
|
|
|
|
|
const node = store.graph.getNode(id)
|
|
|
|
|
return node?.strokes?.[0] ?? null
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test('stroke picker updates stroke color on a rectangle', async ({ page }) => {
|
|
|
|
|
const canvas = new CanvasHelper(page)
|
|
|
|
|
await page.goto('/')
|
|
|
|
|
await canvas.waitForInit()
|
|
|
|
|
|
|
|
|
|
await canvas.drawRect(120, 120, 180, 120)
|
2026-07-14 14:06:28 +00:00
|
|
|
await propertySection(page, 'Stroke').getByRole('button', { name: 'Add stroke' }).click()
|
2026-03-29 15:47:02 +00:00
|
|
|
await canvas.waitForRender()
|
|
|
|
|
|
|
|
|
|
const before = await getSelectedStroke(page)
|
|
|
|
|
await openStrokePicker(page)
|
|
|
|
|
await dragSlider(page, canvas, 'color-slider-hue', 0.7)
|
|
|
|
|
const after = await getSelectedStroke(page)
|
|
|
|
|
|
|
|
|
|
expect(after).not.toBeNull()
|
2026-05-03 18:03:40 +00:00
|
|
|
expect(
|
|
|
|
|
before?.color.r !== after?.color.r ||
|
|
|
|
|
before?.color.g !== after?.color.g ||
|
|
|
|
|
before?.color.b !== after?.color.b
|
|
|
|
|
).toBe(true)
|
2026-03-29 15:47:02 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('stroke picker alpha slider updates stroke opacity and alpha', async ({ page }) => {
|
|
|
|
|
const canvas = new CanvasHelper(page)
|
|
|
|
|
await page.goto('/')
|
|
|
|
|
await canvas.waitForInit()
|
|
|
|
|
|
|
|
|
|
await canvas.drawRect(120, 120, 180, 120)
|
2026-07-14 14:06:28 +00:00
|
|
|
await propertySection(page, 'Stroke').getByRole('button', { name: 'Add stroke' }).click()
|
2026-03-29 15:47:02 +00:00
|
|
|
await canvas.waitForRender()
|
|
|
|
|
|
|
|
|
|
await openStrokePicker(page)
|
|
|
|
|
await dragSlider(page, canvas, 'color-slider-alpha', 0.25)
|
|
|
|
|
const after = await getSelectedStroke(page)
|
|
|
|
|
|
|
|
|
|
expect(after).not.toBeNull()
|
|
|
|
|
expect(after?.opacity).toBeLessThan(1)
|
|
|
|
|
expect(after?.color.a).toBeCloseTo(after?.opacity ?? 0, 3)
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-03 18:03:40 +00:00
|
|
|
test('stroke picker hsb saturation and brightness sliders update stroke color on demo card', async ({
|
|
|
|
|
page
|
|
|
|
|
}) => {
|
2026-03-29 15:47:02 +00:00
|
|
|
const canvas = new CanvasHelper(page)
|
|
|
|
|
await page.goto('/demo')
|
|
|
|
|
await canvas.waitForInit()
|
fix: convert document colour profiles, keep text edits live, and fix .fig exports (#716)
* fix(vue): update instance text properties while typing
Instance text property edits only reached the canvas on Enter or blur, so
the canvas and layer tree lagged behind the field. Emit model updates as
the text changes, commit on blur or Enter, and route bursts through the
existing interactive-edit lease and undo batch so rapid edits collapse
into one transaction.
* fix(vue): present the canvas in sRGB to keep P3 blends correct
CanvasKit 0.41 wraps sRGB on-screen surfaces as RGBA8 but every other
color space as RGBA16F, while browser drawing buffers stay RGBA8 when
their color space changes. Requesting DISPLAY_P3 therefore produced
invalid destination copies and broken blends: black rectangles and brown
Overlay fills over Display-P3 documents. Keep presentation in sRGB and
read the buffer back rather than trusting the setter, leaving the
document color space and its stored colors untouched.
* perf(fig): encode glyph path commands without per-coordinate allocation
Glyph outline encoding allocated an ArrayBuffer, DataView, and Uint8Array
for every coordinate and spread each byte into a number array, so recovery
snapshots and text-heavy exports blocked the main thread for 159-167ms.
Size the output once and write through a single DataView; encoded bytes are
unchanged.
* refactor(vue): separate component property edit resolution from batching
The live text path had grown a boolean flag through a single applyValue that
resolved the edit, chose the batch, and mutated instances, which made the
two entry points differ only by that flag.
Resolve an edit once, keep a named batch key, and let setValue and
setTextValue state their own batching policy. Watch the page and selection
sources directly now that selection is replaced by identity, drop the
redundant scene dependencies the useSceneComputed wrapper already tracks,
move the variant option projection next to the swap projection, and resolve
the swap candidate list once per controls pass instead of once per control.
* feat(vue): restore wide-gamut P3 presentation where the renderer supports it
CanvasKit wraps sRGB on-screen surfaces as RGBA_8888 and every other color
space as RGBA_F16, with the pixel format deliberately not exposed, so a
Display-P3 surface only matches the browser buffer when that buffer is
floating point. Chromium 122+ provides drawingBufferStorage for that; this
negotiates the pairing, keeps the sRGB fallback everywhere else, and warns
with the existing dismissible banner when a Display-P3 document cannot be
presented in wide gamut.
Software rasterizers advertise the float extensions but fail an offscreen
framebuffer attach on the first content frame, so they stay on sRGB, as do
WebKit and Firefox, which have no drawingBufferStorage. A new
document:color-space-changed event recreates the surface when a P3 document
arrives after mount, which previously kept whatever surface the first
document created.
* refactor(web): report the canvas presentation instead of re-deriving it
The wide-gamut notice decided availability from a capability probe, which can
disagree with the surface: configurePresentation also falls back when the
float storage install is rejected or the color space setter is ignored. Pass
the surface's actual result through a new onPresentation option, mirror the
document color space into app state, and let the notice read both, so it
appears exactly when a Display-P3 document is really presented in sRGB. That
also removes two editor-event subscriptions and a tab watcher.
Rename SafariBanner to FileApiBanner, since the condition is the File System
Access API rather than Safari, and move the availability check and picker call
into one capability module instead of repeating them at each save site.
* refactor(web): point capability notices at one neutral support reference
The file API notice linked "Use Chrome" to a Chrome download page while
naming Edge as inert text, and the wide-gamut notice offered no browser
guidance at all. Both now link to the caniuse support table for the API that
decides the capability, so the advice is vendor-neutral and stays correct as
versions move.
External link behavior moves into one primitive: SettingsLink and both
notices share it, gaining rel="noopener noreferrer" and the desktop opener
path, which the notices need because the wide-gamut notice also renders in
Tauri where a raw anchor cannot open an external page.
* fix(fig): stop failing .fig export on unencodable OpenType feature tags
The Kiwi schema types toggledOn/OffOTFeatures as its OpenTypeFeature enum,
which has no PNUM, TNUM, LNUM, ONUM, FRAC, SMCP, C2SC, SUPS, or SUBS member.
Features that map to a typed axis were only written when enabled, so a
disabled one fell through to a raw tag, and encoding then rejected it:
`Invalid value "PNUM" for enum "OpenTypeFeature"`. Because save and recovery
snapshots share that export path, any text using those features could not be
written to a `.fig` file at all — the demo's own typography comparison hit it
in every run.
Disabled mapped tags now clear their axis to the schema's neutral NORMAL value,
an enabled tag on the same axis wins over a disabled sibling so "TNUM on, PNUM
off" still means tabular figures, and tags with no Kiwi representation are
dropped instead of poisoning the whole export.
* perf(core): recompute layout only for the pages a component edit affects
Editing a component recomputed layout for the entire graph, which cost tens
of milliseconds per edit in documents with several populated pages. Layout now
runs once per affected page: the pages of the edited subtrees, their
components, and every instance of those components, which may live on another
page.
The layout function is injected so the scoping contract is testable, and the
existing behaviour is kept when no page can be resolved.
* feat(core): follow the document colour profile when painting
Numbers in a document are coordinates in the profile that document declares,
so painting into a surface with a different profile has to convert them.
Nothing did: stored values were handed to the GPU as-is, which is why a
Display-P3 document looked more saturated on a wide-gamut display than on an
sRGB one, and why export labels and stored values disagreed.
Rendering now resolves each colour from the document's profile into the
surface's profile, reporting clipping when a wider profile does not fit, and
OKHCL colours resolve into the requested target instead of being baked to
sRGB. New documents also default to sRGB, matching Figma, so Display P3 is
reserved for documents that declare it rather than being assumed for
everything OpenPencil creates.
* test(canvas): exercise the P3 spec on the paint page
The P3 rendering spec used the demo's reference page and the shared
`selectDemoReferencePage` helper. The paint page covers the same ground —
gradients, shadows, blurs, multiply and screen blends, an alpha mask — and
the helper is going away with the reference page, so this keeps the spec
independent of that demo content.
The card specs now follow whichever page owns the card instead of switching
by page name, which works for either demo layout.
2026-09-17 21:43:10 +00:00
|
|
|
await waitForDemo(page)
|
2026-03-29 15:47:02 +00:00
|
|
|
|
fix: convert document colour profiles, keep text edits live, and fix .fig exports (#716)
* fix(vue): update instance text properties while typing
Instance text property edits only reached the canvas on Enter or blur, so
the canvas and layer tree lagged behind the field. Emit model updates as
the text changes, commit on blur or Enter, and route bursts through the
existing interactive-edit lease and undo batch so rapid edits collapse
into one transaction.
* fix(vue): present the canvas in sRGB to keep P3 blends correct
CanvasKit 0.41 wraps sRGB on-screen surfaces as RGBA8 but every other
color space as RGBA16F, while browser drawing buffers stay RGBA8 when
their color space changes. Requesting DISPLAY_P3 therefore produced
invalid destination copies and broken blends: black rectangles and brown
Overlay fills over Display-P3 documents. Keep presentation in sRGB and
read the buffer back rather than trusting the setter, leaving the
document color space and its stored colors untouched.
* perf(fig): encode glyph path commands without per-coordinate allocation
Glyph outline encoding allocated an ArrayBuffer, DataView, and Uint8Array
for every coordinate and spread each byte into a number array, so recovery
snapshots and text-heavy exports blocked the main thread for 159-167ms.
Size the output once and write through a single DataView; encoded bytes are
unchanged.
* refactor(vue): separate component property edit resolution from batching
The live text path had grown a boolean flag through a single applyValue that
resolved the edit, chose the batch, and mutated instances, which made the
two entry points differ only by that flag.
Resolve an edit once, keep a named batch key, and let setValue and
setTextValue state their own batching policy. Watch the page and selection
sources directly now that selection is replaced by identity, drop the
redundant scene dependencies the useSceneComputed wrapper already tracks,
move the variant option projection next to the swap projection, and resolve
the swap candidate list once per controls pass instead of once per control.
* feat(vue): restore wide-gamut P3 presentation where the renderer supports it
CanvasKit wraps sRGB on-screen surfaces as RGBA_8888 and every other color
space as RGBA_F16, with the pixel format deliberately not exposed, so a
Display-P3 surface only matches the browser buffer when that buffer is
floating point. Chromium 122+ provides drawingBufferStorage for that; this
negotiates the pairing, keeps the sRGB fallback everywhere else, and warns
with the existing dismissible banner when a Display-P3 document cannot be
presented in wide gamut.
Software rasterizers advertise the float extensions but fail an offscreen
framebuffer attach on the first content frame, so they stay on sRGB, as do
WebKit and Firefox, which have no drawingBufferStorage. A new
document:color-space-changed event recreates the surface when a P3 document
arrives after mount, which previously kept whatever surface the first
document created.
* refactor(web): report the canvas presentation instead of re-deriving it
The wide-gamut notice decided availability from a capability probe, which can
disagree with the surface: configurePresentation also falls back when the
float storage install is rejected or the color space setter is ignored. Pass
the surface's actual result through a new onPresentation option, mirror the
document color space into app state, and let the notice read both, so it
appears exactly when a Display-P3 document is really presented in sRGB. That
also removes two editor-event subscriptions and a tab watcher.
Rename SafariBanner to FileApiBanner, since the condition is the File System
Access API rather than Safari, and move the availability check and picker call
into one capability module instead of repeating them at each save site.
* refactor(web): point capability notices at one neutral support reference
The file API notice linked "Use Chrome" to a Chrome download page while
naming Edge as inert text, and the wide-gamut notice offered no browser
guidance at all. Both now link to the caniuse support table for the API that
decides the capability, so the advice is vendor-neutral and stays correct as
versions move.
External link behavior moves into one primitive: SettingsLink and both
notices share it, gaining rel="noopener noreferrer" and the desktop opener
path, which the notices need because the wide-gamut notice also renders in
Tauri where a raw anchor cannot open an external page.
* fix(fig): stop failing .fig export on unencodable OpenType feature tags
The Kiwi schema types toggledOn/OffOTFeatures as its OpenTypeFeature enum,
which has no PNUM, TNUM, LNUM, ONUM, FRAC, SMCP, C2SC, SUPS, or SUBS member.
Features that map to a typed axis were only written when enabled, so a
disabled one fell through to a raw tag, and encoding then rejected it:
`Invalid value "PNUM" for enum "OpenTypeFeature"`. Because save and recovery
snapshots share that export path, any text using those features could not be
written to a `.fig` file at all — the demo's own typography comparison hit it
in every run.
Disabled mapped tags now clear their axis to the schema's neutral NORMAL value,
an enabled tag on the same axis wins over a disabled sibling so "TNUM on, PNUM
off" still means tabular figures, and tags with no Kiwi representation are
dropped instead of poisoning the whole export.
* perf(core): recompute layout only for the pages a component edit affects
Editing a component recomputed layout for the entire graph, which cost tens
of milliseconds per edit in documents with several populated pages. Layout now
runs once per affected page: the pages of the edited subtrees, their
components, and every instance of those components, which may live on another
page.
The layout function is injected so the scoping contract is testable, and the
existing behaviour is kept when no page can be resolved.
* feat(core): follow the document colour profile when painting
Numbers in a document are coordinates in the profile that document declares,
so painting into a surface with a different profile has to convert them.
Nothing did: stored values were handed to the GPU as-is, which is why a
Display-P3 document looked more saturated on a wide-gamut display than on an
sRGB one, and why export labels and stored values disagreed.
Rendering now resolves each colour from the document's profile into the
surface's profile, reporting clipping when a wider profile does not fit, and
OKHCL colours resolve into the requested target instead of being baked to
sRGB. New documents also default to sRGB, matching Figma, so Display P3 is
reserved for documents that declare it rather than being assumed for
everything OpenPencil creates.
* test(canvas): exercise the P3 spec on the paint page
The P3 rendering spec used the demo's reference page and the shared
`selectDemoReferencePage` helper. The paint page covers the same ground —
gradients, shadows, blurs, multiply and screen blends, an alpha mask — and
the helper is going away with the reference page, so this keeps the spec
independent of that demo content.
The card specs now follow whichever page owns the card instead of switching
by page name, which works for either demo layout.
2026-09-17 21:43:10 +00:00
|
|
|
await page.evaluate(async () => {
|
2026-05-06 09:19:39 +00:00
|
|
|
const store = window.openPencil?.getStore?.()
|
2026-05-05 23:59:56 +00:00
|
|
|
if (!store) throw new Error('OpenPencil store not initialized')
|
2026-03-29 15:47:02 +00:00
|
|
|
const nodes = Array.from(store.graph.nodes.values())
|
2026-05-03 18:03:40 +00:00
|
|
|
const card =
|
|
|
|
|
nodes.find((node) => node.name === 'Card' && node.type === 'COMPONENT') ??
|
|
|
|
|
nodes.find((node) => node.name === 'Card')
|
2026-03-29 15:47:02 +00:00
|
|
|
if (!card) throw new Error('Card not found')
|
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
|
|
|
let owner: SceneNode | undefined = card
|
fix: convert document colour profiles, keep text edits live, and fix .fig exports (#716)
* fix(vue): update instance text properties while typing
Instance text property edits only reached the canvas on Enter or blur, so
the canvas and layer tree lagged behind the field. Emit model updates as
the text changes, commit on blur or Enter, and route bursts through the
existing interactive-edit lease and undo batch so rapid edits collapse
into one transaction.
* fix(vue): present the canvas in sRGB to keep P3 blends correct
CanvasKit 0.41 wraps sRGB on-screen surfaces as RGBA8 but every other
color space as RGBA16F, while browser drawing buffers stay RGBA8 when
their color space changes. Requesting DISPLAY_P3 therefore produced
invalid destination copies and broken blends: black rectangles and brown
Overlay fills over Display-P3 documents. Keep presentation in sRGB and
read the buffer back rather than trusting the setter, leaving the
document color space and its stored colors untouched.
* perf(fig): encode glyph path commands without per-coordinate allocation
Glyph outline encoding allocated an ArrayBuffer, DataView, and Uint8Array
for every coordinate and spread each byte into a number array, so recovery
snapshots and text-heavy exports blocked the main thread for 159-167ms.
Size the output once and write through a single DataView; encoded bytes are
unchanged.
* refactor(vue): separate component property edit resolution from batching
The live text path had grown a boolean flag through a single applyValue that
resolved the edit, chose the batch, and mutated instances, which made the
two entry points differ only by that flag.
Resolve an edit once, keep a named batch key, and let setValue and
setTextValue state their own batching policy. Watch the page and selection
sources directly now that selection is replaced by identity, drop the
redundant scene dependencies the useSceneComputed wrapper already tracks,
move the variant option projection next to the swap projection, and resolve
the swap candidate list once per controls pass instead of once per control.
* feat(vue): restore wide-gamut P3 presentation where the renderer supports it
CanvasKit wraps sRGB on-screen surfaces as RGBA_8888 and every other color
space as RGBA_F16, with the pixel format deliberately not exposed, so a
Display-P3 surface only matches the browser buffer when that buffer is
floating point. Chromium 122+ provides drawingBufferStorage for that; this
negotiates the pairing, keeps the sRGB fallback everywhere else, and warns
with the existing dismissible banner when a Display-P3 document cannot be
presented in wide gamut.
Software rasterizers advertise the float extensions but fail an offscreen
framebuffer attach on the first content frame, so they stay on sRGB, as do
WebKit and Firefox, which have no drawingBufferStorage. A new
document:color-space-changed event recreates the surface when a P3 document
arrives after mount, which previously kept whatever surface the first
document created.
* refactor(web): report the canvas presentation instead of re-deriving it
The wide-gamut notice decided availability from a capability probe, which can
disagree with the surface: configurePresentation also falls back when the
float storage install is rejected or the color space setter is ignored. Pass
the surface's actual result through a new onPresentation option, mirror the
document color space into app state, and let the notice read both, so it
appears exactly when a Display-P3 document is really presented in sRGB. That
also removes two editor-event subscriptions and a tab watcher.
Rename SafariBanner to FileApiBanner, since the condition is the File System
Access API rather than Safari, and move the availability check and picker call
into one capability module instead of repeating them at each save site.
* refactor(web): point capability notices at one neutral support reference
The file API notice linked "Use Chrome" to a Chrome download page while
naming Edge as inert text, and the wide-gamut notice offered no browser
guidance at all. Both now link to the caniuse support table for the API that
decides the capability, so the advice is vendor-neutral and stays correct as
versions move.
External link behavior moves into one primitive: SettingsLink and both
notices share it, gaining rel="noopener noreferrer" and the desktop opener
path, which the notices need because the wide-gamut notice also renders in
Tauri where a raw anchor cannot open an external page.
* fix(fig): stop failing .fig export on unencodable OpenType feature tags
The Kiwi schema types toggledOn/OffOTFeatures as its OpenTypeFeature enum,
which has no PNUM, TNUM, LNUM, ONUM, FRAC, SMCP, C2SC, SUPS, or SUBS member.
Features that map to a typed axis were only written when enabled, so a
disabled one fell through to a raw tag, and encoding then rejected it:
`Invalid value "PNUM" for enum "OpenTypeFeature"`. Because save and recovery
snapshots share that export path, any text using those features could not be
written to a `.fig` file at all — the demo's own typography comparison hit it
in every run.
Disabled mapped tags now clear their axis to the schema's neutral NORMAL value,
an enabled tag on the same axis wins over a disabled sibling so "TNUM on, PNUM
off" still means tabular figures, and tags with no Kiwi representation are
dropped instead of poisoning the whole export.
* perf(core): recompute layout only for the pages a component edit affects
Editing a component recomputed layout for the entire graph, which cost tens
of milliseconds per edit in documents with several populated pages. Layout now
runs once per affected page: the pages of the edited subtrees, their
components, and every instance of those components, which may live on another
page.
The layout function is injected so the scoping contract is testable, and the
existing behaviour is kept when no page can be resolved.
* feat(core): follow the document colour profile when painting
Numbers in a document are coordinates in the profile that document declares,
so painting into a surface with a different profile has to convert them.
Nothing did: stored values were handed to the GPU as-is, which is why a
Display-P3 document looked more saturated on a wide-gamut display than on an
sRGB one, and why export labels and stored values disagreed.
Rendering now resolves each colour from the document's profile into the
surface's profile, reporting clipping when a wider profile does not fit, and
OKHCL colours resolve into the requested target instead of being baked to
sRGB. New documents also default to sRGB, matching Figma, so Display P3 is
reserved for documents that declare it rather than being assumed for
everything OpenPencil creates.
* test(canvas): exercise the P3 spec on the paint page
The P3 rendering spec used the demo's reference page and the shared
`selectDemoReferencePage` helper. The paint page covers the same ground —
gradients, shadows, blurs, multiply and screen blends, an alpha mask — and
the helper is going away with the reference page, so this keeps the spec
independent of that demo content.
The card specs now follow whichever page owns the card instead of switching
by page name, which works for either demo layout.
2026-09-17 21:43:10 +00:00
|
|
|
while (owner && owner.type !== 'CANVAS') {
|
|
|
|
|
owner = owner.parentId ? store.graph.getNode(owner.parentId) : undefined
|
|
|
|
|
}
|
|
|
|
|
if (owner && owner.id !== store.state.currentPageId) await store.switchPage(owner.id)
|
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
|
|
|
const stroke: Stroke = {
|
2026-10-04 09:38:05 +00:00
|
|
|
type: 'SOLID',
|
2026-05-03 18:03:40 +00:00
|
|
|
color: { r: 0.9, g: 0.9, b: 0.92, a: 1 },
|
|
|
|
|
weight: 1,
|
|
|
|
|
opacity: 1,
|
|
|
|
|
visible: true,
|
|
|
|
|
align: 'INSIDE'
|
|
|
|
|
}
|
2026-03-29 15:47:02 +00:00
|
|
|
store.updateNodeWithUndo(card.id, { strokes: [stroke] }, 'Add demo card stroke')
|
2026-05-14 19:05:43 +00:00
|
|
|
store.select([card.id])
|
2026-03-29 15:47:02 +00:00
|
|
|
})
|
|
|
|
|
await canvas.waitForRender()
|
|
|
|
|
|
2026-07-14 14:06:28 +00:00
|
|
|
await expect(propertyItems(page, 'strokes')).toBeVisible()
|
2026-03-29 15:47:02 +00:00
|
|
|
await openStrokePicker(page)
|
|
|
|
|
await chooseFormat(page, 'HSB')
|
|
|
|
|
|
|
|
|
|
const beforeS = await getSelectedStroke(page)
|
|
|
|
|
await dragSlider(page, canvas, 'color-slider-hsb-s', 0.6)
|
|
|
|
|
const afterS = await getSelectedStroke(page)
|
|
|
|
|
expect(afterS).not.toBeNull()
|
2026-05-03 18:03:40 +00:00
|
|
|
expect(
|
|
|
|
|
beforeS?.color.r !== afterS?.color.r ||
|
|
|
|
|
beforeS?.color.g !== afterS?.color.g ||
|
|
|
|
|
beforeS?.color.b !== afterS?.color.b
|
|
|
|
|
).toBe(true)
|
2026-03-29 15:47:02 +00:00
|
|
|
|
|
|
|
|
const beforeB = await getSelectedStroke(page)
|
|
|
|
|
await dragSlider(page, canvas, 'color-slider-hsb-b', 0.2)
|
|
|
|
|
const afterB = await getSelectedStroke(page)
|
|
|
|
|
expect(afterB).not.toBeNull()
|
2026-05-03 18:03:40 +00:00
|
|
|
expect(
|
|
|
|
|
beforeB?.color.r !== afterB?.color.r ||
|
|
|
|
|
beforeB?.color.g !== afterB?.color.g ||
|
|
|
|
|
beforeB?.color.b !== afterB?.color.b
|
|
|
|
|
).toBe(true)
|
2026-03-29 15:47:02 +00:00
|
|
|
})
|
2026-07-14 14:06:28 +00:00
|
|
|
|
|
|
|
|
test('bound stroke picker is non-destructive, rolls back Escape, and detaches in one undo step', async ({
|
|
|
|
|
page
|
|
|
|
|
}) => {
|
|
|
|
|
const canvas = new CanvasHelper(page)
|
|
|
|
|
await page.goto('/')
|
|
|
|
|
await canvas.waitForInit()
|
|
|
|
|
await canvas.drawRect(120, 120, 180, 120)
|
|
|
|
|
await propertySection(page, 'Stroke').getByRole('button', { name: 'Add stroke' }).click()
|
|
|
|
|
await canvas.waitForRender()
|
|
|
|
|
|
|
|
|
|
const before = await page.evaluate(() => {
|
|
|
|
|
const store = window.openPencil?.getStore?.()
|
|
|
|
|
if (!store) throw new Error('OpenPencil store not initialized')
|
|
|
|
|
const collection = store.graph.createCollection('Colors')
|
|
|
|
|
const variable = store.graph.createVariable('stroke-brand', 'COLOR', collection.id, {
|
|
|
|
|
r: 0.9,
|
|
|
|
|
g: 0.1,
|
|
|
|
|
b: 0.2,
|
|
|
|
|
a: 1
|
|
|
|
|
})
|
|
|
|
|
const id = [...store.state.selectedIds][0]
|
|
|
|
|
if (!id) throw new Error('Expected selected node')
|
|
|
|
|
store.graph.bindVariable(id, 'strokes/0/color', variable.id)
|
|
|
|
|
store.state.sceneVersion++
|
|
|
|
|
const node = store.getNode(id)
|
|
|
|
|
return { color: node?.strokes[0]?.color, binding: node?.boundVariables['strokes/0/color'] }
|
|
|
|
|
})
|
|
|
|
|
await canvas.waitForRender()
|
|
|
|
|
|
|
|
|
|
await openStrokePicker(page)
|
|
|
|
|
expect(
|
|
|
|
|
await page.evaluate(() => {
|
|
|
|
|
const store = window.openPencil?.getStore?.()
|
|
|
|
|
if (!store) throw new Error('OpenPencil store not initialized')
|
|
|
|
|
const id = [...store.state.selectedIds][0]
|
|
|
|
|
return id ? store.getNode(id)?.boundVariables['strokes/0/color'] : undefined
|
|
|
|
|
})
|
|
|
|
|
).toBe(before.binding)
|
|
|
|
|
|
|
|
|
|
await dragSlider(page, canvas, 'color-slider-hue', 0.55)
|
|
|
|
|
await page.keyboard.press('Escape')
|
|
|
|
|
await canvas.waitForRender()
|
|
|
|
|
const afterEscape = await page.evaluate(() => {
|
|
|
|
|
const store = window.openPencil?.getStore?.()
|
|
|
|
|
if (!store) throw new Error('OpenPencil store not initialized')
|
|
|
|
|
const id = [...store.state.selectedIds][0]
|
|
|
|
|
const node = id ? store.getNode(id) : null
|
|
|
|
|
return { color: node?.strokes[0]?.color, binding: node?.boundVariables['strokes/0/color'] }
|
|
|
|
|
})
|
|
|
|
|
expect(afterEscape).toEqual(before)
|
|
|
|
|
|
|
|
|
|
await openStrokePicker(page)
|
|
|
|
|
await dragSlider(page, canvas, 'color-slider-hue', 0.75)
|
|
|
|
|
await propertyItems(page, 'strokes')
|
|
|
|
|
.first()
|
|
|
|
|
.getByRole('button', { name: 'Stroke', exact: true })
|
|
|
|
|
.click()
|
|
|
|
|
await canvas.waitForRender()
|
|
|
|
|
expect((await getSelectedStroke(page))?.color).not.toEqual(before.color)
|
|
|
|
|
|
|
|
|
|
await canvas.undo()
|
|
|
|
|
await canvas.waitForRender()
|
|
|
|
|
const restored = await page.evaluate(() => {
|
|
|
|
|
const store = window.openPencil?.getStore?.()
|
|
|
|
|
if (!store) throw new Error('OpenPencil store not initialized')
|
|
|
|
|
const id = [...store.state.selectedIds][0]
|
|
|
|
|
const node = id ? store.getNode(id) : null
|
|
|
|
|
return { color: node?.strokes[0]?.color, binding: node?.boundVariables['strokes/0/color'] }
|
|
|
|
|
})
|
|
|
|
|
expect(restored).toEqual(before)
|
|
|
|
|
})
|