Merge remote-tracking branch 'origin/master' into material3-text-auto-resize

This commit is contained in:
Danila Poyarkov 2026-09-02 13:11:38 +03:00
commit 5b363f503a
6 changed files with 368 additions and 14 deletions

View file

@ -35,19 +35,6 @@
- Move MCP connections into their own Settings destination instead of presenting them as part of model configuration.
- Pan horizontally with Shift+wheel while preserving native horizontal trackpad movement.
### Performance
- Scope automation and Figma API layout reconciliation to graph nodes and parent containers actually changed by each mutation.
- Keep rapid trackpad zoom reversals and effect-heavy document navigation responsive by cancelling obsolete reconstruction and reusing safe raster snapshots.
- Show the FIG page list from a lightweight Kiwi scan before materializing the full document.
- Avoid redundant collaboration writes when synchronized node fields have not changed.
- Release obsolete streamed Markdown parser history after each AI response completes, preventing chat memory from multiplying with every streamed chunk. (#544)
- Open large documents faster by using cached world positions while finding layers under the pointer. (#527)
- Coalesce writable-document autosaves that overlap an active `.fig` export while preserving a trailing save for newer edits. (#528)
- Defer JSX generation and syntax highlighting until the Code panel is active, keeping large canvas selections responsive. (#500)
- Index Figma clipboard children once during import instead of rescanning every pasted node, keeping large flat pastes linear. (#500)
- Reduce peak memory during `.fig` export by sharing immutable binary resources with the isolated export graph.
### Fixed
- Preserve implicit fixed-size text inside imported Figma auto-layout frames across save and reload.
@ -95,6 +82,19 @@
- Preserve component links when pasting Figma instances so later component edits continue to update them.
- Stop local MCP servers after the app disconnects instead of leaving orphaned background processes. (#494)
### Performance
- Scope automation and Figma API layout reconciliation to graph nodes and parent containers actually changed by each mutation.
- Keep rapid trackpad zoom reversals and effect-heavy document navigation responsive by cancelling obsolete reconstruction and reusing safe raster snapshots.
- Show the FIG page list from a lightweight Kiwi scan before materializing the full document.
- Avoid redundant collaboration writes when synchronized node fields have not changed.
- Release obsolete streamed Markdown parser history after each AI response completes, preventing chat memory from multiplying with every streamed chunk. (#544)
- Open large documents faster by using cached world positions while finding layers under the pointer. (#527)
- Coalesce writable-document autosaves that overlap an active `.fig` export while preserving a trailing save for newer edits. (#528)
- Defer JSX generation and syntax highlighting until the Code panel is active, keeping large canvas selections responsive. (#500)
- Index Figma clipboard children once during import instead of rescanning every pasted node, keeping large flat pastes linear. (#500)
- Reduce peak memory during `.fig` export by sharing immutable binary resources with the isolated export graph.
## 0.14.0 — 2026-08-10

View file

@ -36,7 +36,8 @@
"lint:structure": "oxlint -c oxlint.json vite.config.ts vite/ .storybook/ src/ packages/scene-graph/src/ packages/scene-graph/scripts/ packages/core/src/ packages/vue/src/ packages/cli/src/ packages/mcp/src/ packages/harness/src/ packages/harness/tests/ packages/dom-css/src/ packages/dom-css/tests/ packages/dom-css/scripts/ packages/pen/src/ packages/pen/scripts/ packages/kiwi/src/ packages/kiwi/tests/ packages/kiwi/scripts/ packages/fig/src/ packages/fig/tests/ packages/fig/scripts/ tests/ scripts/ tools/",
"format": "oxfmt --write .oxfmtrc.json vite.config.ts vite/ .storybook/ src/ packages/scene-graph/src/ packages/scene-graph/scripts/ packages/core/src/ packages/cli/src/ packages/mcp/src/ packages/harness/src/ packages/harness/tests/ packages/vue/src/ packages/dom-css/src/ packages/dom-css/tests/ packages/dom-css/scripts/ packages/pen/src/ packages/pen/scripts/ packages/kiwi/src/ packages/kiwi/tests/ packages/kiwi/scripts/ packages/fig/src/ packages/fig/tests/ packages/fig/scripts/ tests scripts/ tools/",
"format:check": "bun run format && status=$(git status --porcelain -uall) && test -z \"$status\" || (echo \"$status\" && exit 1)",
"check": "bun run build:packages && bun run lint && tsgo --noEmit && bun run check:vue && bun run check:native-test && bun run check:i18n && bun run check:docs && bun run check:packages && bun run check:deps && bun run check:audit && bun run check:secrets && bun run check:monorepo && bun run check:arch && bun run test:type-shapes && bun run test:tools && bun run test:dupes",
"check": "bun run build:packages && bun run lint && tsgo --noEmit && bun run check:vue && bun run check:native-test && bun run check:i18n && bun run check:docs && bun run check:changelog && bun run check:packages && bun run check:deps && bun run check:audit && bun run check:secrets && bun run check:monorepo && bun run check:arch && bun run test:type-shapes && bun run test:tools && bun run test:dupes",
"check:changelog": "bun tools/release-packages/src/check-changelog.ts",
"check:deps": "knip --include unlisted,unresolved,binaries",
"check:docs": "bun --filter @open-pencil/docs check",
"check:audit": "bun audit --audit-level=critical",

View file

@ -3,6 +3,7 @@
"private": true,
"type": "module",
"scripts": {
"check-changelog": "bun src/check-changelog.ts",
"prepare-publish-dirs": "bun src/prepare-publish-dirs.ts",
"validate-tarballs": "bun src/validate-tarballs.ts",
"test": "bun test tests"

View file

@ -0,0 +1,191 @@
import { readFile } from 'node:fs/promises'
import type { Heading, List, ListItem, Root, RootContent } from 'mdast'
import { fromMarkdown } from 'mdast-util-from-markdown'
const ALLOWED_CATEGORIES = [
'Breaking changes',
'Added',
'Changed',
'Fixed',
'Performance',
'Security'
] as const
const CATEGORY_INDEX = new Map(ALLOWED_CATEGORIES.map((category, index) => [category, index]))
const RELEASE_HEADING = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)? — (\d{4}-\d{2}-\d{2})$/
export interface ChangelogValidationResult {
errors: string[]
}
interface PositionedNode {
position?: { start: { line: number } }
}
function headingText(heading: Heading): string {
return heading.children
.map((child) => ('value' in child && typeof child.value === 'string' ? child.value : ''))
.join('')
.trim()
}
function line(node: PositionedNode): number {
return node.position?.start.line ?? 0
}
function listItemSource(source: string, item: ListItem): string {
const start = item.position?.start.offset
const end = item.position?.end.offset
if (start === undefined || end === undefined) return ''
return source.slice(start, end).trim().replace(/\s+/g, ' ')
}
function sectionEnd(children: RootContent[], start: number): number {
for (let index = start + 1; index < children.length; index++) {
const node = children[index]
if (node?.type === 'heading' && node.depth === 2) return index
}
return children.length
}
function categoryHasItems(children: RootContent[], start: number, end: number): boolean {
for (let index = start + 1; index < end; index++) {
const node = children[index]
if (node?.type === 'heading' && node.depth <= 3) break
if (node?.type === 'list' && !node.ordered && node.children.length > 0) return true
}
return false
}
function isValidDate(value: string): boolean {
const [year, month, day] = value.split('-').map(Number)
if (year === undefined || month === undefined || day === undefined) return false
const date = new Date(Date.UTC(year, month - 1, day))
return (
date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day
)
}
function validateReleaseHeading(heading: Heading, errors: string[]): void {
const text = headingText(heading)
if (text === 'Unreleased') return
const match = RELEASE_HEADING.exec(text)
if (!match) {
errors.push(
`Line ${line(heading)}: invalid release heading "${text}"; expected "## x.y.z — YYYY-MM-DD".`
)
return
}
const date = match[1]
if (date && !isValidDate(date)) {
errors.push(`Line ${line(heading)}: invalid release date "${date}".`)
}
}
function validateUnreleased(
source: string,
children: RootContent[],
start: number,
end: number,
errors: string[]
): void {
const seenCategories = new Set<string>()
const seenBullets = new Map<string, number>()
let currentCategory: string | null = null
let previousCategoryIndex = -1
for (let index = start + 1; index < end; index++) {
const node = children[index]
if (!node) continue
if (node.type === 'heading') {
if (node.depth !== 3) {
errors.push(
`Line ${line(node)}: Unreleased content may only use level-three category headings.`
)
continue
}
const category = headingText(node)
const categoryIndex = CATEGORY_INDEX.get(category as (typeof ALLOWED_CATEGORIES)[number])
if (categoryIndex === undefined) {
currentCategory = null
errors.push(
`Line ${line(node)}: unsupported Unreleased category "${category}"; use ${ALLOWED_CATEGORIES.join(', ')}.`
)
continue
}
if (seenCategories.has(category)) {
errors.push(`Line ${line(node)}: duplicate Unreleased category "${category}".`)
}
if (categoryIndex < previousCategoryIndex) {
errors.push(`Line ${line(node)}: Unreleased category "${category}" is out of order.`)
}
if (!categoryHasItems(children, index, end)) {
errors.push(`Line ${line(node)}: Unreleased category "${category}" is empty.`)
}
seenCategories.add(category)
currentCategory = category
previousCategoryIndex = Math.max(previousCategoryIndex, categoryIndex)
continue
}
if (node.type === 'list' && !node.ordered) {
if (currentCategory === null) {
errors.push(
`Line ${line(node)}: Unreleased bullets must follow an allowed category heading.`
)
}
const list = node as List
for (const item of list.children) {
const bullet = listItemSource(source, item)
if (!bullet) continue
const firstLine = seenBullets.get(bullet)
if (firstLine !== undefined) {
errors.push(
`Line ${line(item)}: duplicate Unreleased bullet (first seen on line ${firstLine}).`
)
} else {
seenBullets.set(bullet, line(item))
}
}
continue
}
errors.push(`Line ${line(node)}: unexpected content in Unreleased.`)
}
}
export function validateChangelog(source: string): ChangelogValidationResult {
const tree = fromMarkdown(source) as Root
const errors: string[] = []
const releaseHeadings: Array<{ index: number; node: Heading }> = []
for (const [index, node] of tree.children.entries()) {
if (node.type === 'heading' && node.depth === 2) releaseHeadings.push({ index, node })
}
const unreleased = releaseHeadings.filter(({ node }) => headingText(node) === 'Unreleased')
if (unreleased.length !== 1) {
errors.push(`Expected exactly one "## Unreleased" section; found ${unreleased.length}.`)
}
for (const { node } of releaseHeadings) validateReleaseHeading(node, errors)
const current = unreleased[0]
if (current) {
validateUnreleased(
source,
tree.children,
current.index,
sectionEnd(tree.children, current.index),
errors
)
}
return { errors }
}
export async function validateChangelogFile(path: string): Promise<ChangelogValidationResult> {
return validateChangelog(await readFile(path, 'utf8'))
}

View file

@ -0,0 +1,15 @@
import { resolve } from 'node:path'
import { validateChangelogFile } from './changelog'
const path = resolve(process.argv[2] ?? 'CHANGELOG.md')
const { errors } = await validateChangelogFile(path)
if (errors.length > 0) {
process.stderr.write(
`Changelog validation failed:\n${errors.map((error) => `- ${error}`).join('\n')}\n`
)
process.exit(1)
}
process.stdout.write(`Changelog validation passed: ${path}\n`)

View file

@ -0,0 +1,146 @@
import { describe, expect, test } from 'bun:test'
import { validateChangelog } from '../src/changelog'
const validChangelog = `# Changelog
## Unreleased
### Breaking changes
- Replace the old API.
### Added
- Add **rich** release notes.
### Fixed
- Preserve user data.
## 1.2.0 — 2026-08-16
### Added
- Ship the previous feature.
## 0.1.0-alpha — 2026-03-01
### Added
- Publish the alpha.
`
function errors(source: string): string[] {
return validateChangelog(source).errors
}
describe('validateChangelog', () => {
test('accepts canonical Unreleased and release sections', () => {
expect(errors(validChangelog)).toEqual([])
})
test('requires exactly one Unreleased section', () => {
expect(errors(validChangelog.replace('## Unreleased', '## 1.3.0 — 2026-09-01'))).toContain(
'Expected exactly one "## Unreleased" section; found 0.'
)
expect(errors(`${validChangelog}\n## Unreleased\n`)).toContain(
'Expected exactly one "## Unreleased" section; found 2.'
)
})
test('rejects duplicate, unsupported, empty, and out-of-order categories', () => {
const source = `# Changelog
## Unreleased
### Fixed
- Fix one issue.
### Added
- Add one feature.
### Added
### Internal
- Refactor internals.
## 1.0.0 — 2026-01-01
### Added
- Ship version one.
`
const result = errors(source)
expect(result.some((error) => error.includes('category "Added" is out of order'))).toBe(true)
expect(result.some((error) => error.includes('duplicate Unreleased category "Added"'))).toBe(
true
)
expect(result.some((error) => error.includes('category "Added" is empty'))).toBe(true)
expect(
result.some((error) => error.includes('unsupported Unreleased category "Internal"'))
).toBe(true)
})
test('rejects duplicate Unreleased bullets while preserving Markdown distinctions', () => {
const source = `# Changelog
## Unreleased
### Added
- Add **one** feature.
- Add **one** feature.
- Add one feature.
## 1.0.0 — 2026-01-01
### Added
- Add **one** feature.
`
const result = errors(source)
expect(result.filter((error) => error.includes('duplicate Unreleased bullet'))).toHaveLength(1)
})
test('rejects malformed release headings and impossible dates', () => {
const malformed = validChangelog.replace('## 1.2.0 — 2026-08-16', '## v1.2.0 - August 16, 2026')
expect(errors(malformed).some((error) => error.includes('invalid release heading'))).toBe(true)
const impossible = validChangelog.replace('2026-08-16', '2026-02-31')
expect(errors(impossible).some((error) => error.includes('invalid release date'))).toBe(true)
})
test('rejects nested headings and uncategorized content inside Unreleased', () => {
const nested = validChangelog.replace(
'- Add **rich** release notes.',
'#### Details\n\n- Add **rich** release notes.'
)
expect(
errors(nested).some((error) =>
error.includes('Unreleased content may only use level-three category headings')
)
).toBe(true)
const uncategorized = validChangelog.replace(
'## Unreleased\n\n### Breaking changes',
'## Unreleased\n\n- Uncategorized.\n\n### Breaking changes'
)
expect(
errors(uncategorized).some((error) =>
error.includes('Unreleased bullets must follow an allowed category heading')
)
).toBe(true)
const prose = validChangelog.replace(
'## Unreleased\n\n### Breaking changes',
'## Unreleased\n\nUnexpected prose.\n\n### Breaking changes'
)
expect(errors(prose).some((error) => error.includes('unexpected content in Unreleased'))).toBe(
true
)
})
})