openpencil/tests/e2e/stroke-picker/basic.spec.ts

215 lines
7.6 KiB
TypeScript
Raw Normal View History

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'
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'
import { propertyItems, propertySection } from '#tests/helpers/properties'
2026-03-29 15:47:02 +00:00
async function dragSlider(page: Page, canvas: CanvasHelper, testId: string, ratio: number) {
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}`)
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) {
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') {
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(() => {
const store = window.openPencil?.getStore?.()
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)
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)
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 () => {
const store = window.openPencil?.getStore?.()
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 = {
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')
store.select([card.id])
2026-03-29 15:47:02 +00:00
})
await canvas.waitForRender()
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
})
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)
})