diff --git a/docs/component-properties.md b/docs/component-properties.md index 49127ffd..24e5a22f 100644 --- a/docs/component-properties.md +++ b/docs/component-properties.md @@ -27,8 +27,18 @@ arrays, tuples, records, callbacks, and common built-in types. The braces button switches to a plain input for custom types such as `typeof theme` or imported aliases. Switching modes preserves the current type and union options; hovering a menu choice never changes the prop. Unsupported types open in input mode. -Imported or composite `Props` contracts remain read-only until their editable -fields are declared locally; clicking a prop never opens the full source editor. +Common fields in composite `Props` contracts stay editable when their declaration +is local and shared by the public and runtime types. Variant-dependent fields show +their conditions and explain why source editing is needed. Imported, generic, and +ambiguous declarations remain restricted. Add and reorder operations on composite +contracts still require source editing. Declaration locations appear in an information +tooltip, not as runtime binding chips. Clicking a prop never opens the full source editor. +Renames also update indexed and picked references to local contract types. + +Property rows show names and type icons. Type and default choices use the shared +custom dropdown, with matching expression toggles. Long option lists scroll after +six rows, with Add item outside the scrolling region. Property settings close on +outside clicks, including clicks in the canvas, and remain dismissible when read-only. The panel listens for external changes to the open component and automatically loads the latest properties. Stacki's own writes are filtered by the project diff --git a/electron/componentPreview.ts b/electron/componentPreview.ts index d2526992..e0409706 100644 --- a/electron/componentPreview.ts +++ b/electron/componentPreview.ts @@ -198,6 +198,12 @@ if (selected) { return isFinite(l) ? { l: l, t: t, w: r - l, h: b - t } : null; } var scheduled = false; + var lastStatus = ''; + function reportStatus(status) { + if (status === lastStatus) return; + lastStatus = status; + window.parent.postMessage({ type: 'avb:component-preview', status: status }, '*'); + } function fit() { scheduled = false; stage.style.transform = 'none'; @@ -205,6 +211,8 @@ if (selected) { var box = bbox(stage); if (!box || box.w < 1 || box.h < 1) { document.body.classList.add('avb-is-empty'); + stage.style.visibility = 'hidden'; + reportStatus('empty'); return; } document.body.classList.remove('avb-is-empty'); @@ -216,6 +224,7 @@ if (selected) { stage.style.transform = 'translate(' + tx + 'px,' + ty + 'px) scale(' + s + ')'; stage.style.visibility = 'visible'; + reportStatus('ready'); } function refit() { if (scheduled) return; diff --git a/electron/componentProperties.ts b/electron/componentProperties.ts index d2972cdb..37d25f99 100644 --- a/electron/componentProperties.ts +++ b/electron/componentProperties.ts @@ -6,11 +6,16 @@ import fs from 'node:fs'; import path from 'node:path'; import { assert } from '../shared/assert'; import { PROPERTY_LIMITS } from '../shared/component-properties'; -import type { ComponentProperties, PropertyChange } from '../shared/component-properties'; +import type { + ComponentProperties, + PropertyChange, + PropertyOptionRename, +} from '../shared/component-properties'; import { err, ok, type Result } from '../shared/result'; +import { literalOptions } from '../shared/property-options'; import { sameFilesystemPath } from './platform'; import { editPropertyDefinition, readComponentProperties } from './propertyDefinitions'; -import { renameComponentReferences } from './propertyRename'; +import { renameComponentOptionValues, renameComponentReferences } from './propertyRename'; interface PropertyLocation { readonly projectPath: string; @@ -100,37 +105,43 @@ function planPropertyChanges( if (change.kind === 'remove') { return planPropertyRemoval(request, first, change.name); } - if ( - change.kind !== 'save' || - !change.originalName || - change.originalName === change.property.name - ) { + const propertyRename = propertyRenameForChange(change); + const optionRenames = change.kind === 'save' ? change.optionRenames ?? [] : []; + if (!propertyRename && optionRenames.length === 0) { return ok([first]); } + if (change.kind !== 'save' || !change.originalName) { + return err({ + code: 'option', + message: 'Only an existing property can rename options.', + }); + } + const valid = validateOptionRenames(request.source, source, change, optionRenames); + if (!valid.ok) { + return valid; + } const consumers = readPropertyConsumers(request); if (!consumers.ok) { return consumers; } - const rename = { from: change.originalName, to: change.property.name }; const changes: FileChange[] = []; for (const consumer of consumers.value) { const { file, names } = consumer; const own = sameFilesystemPath(file, request.file); const original = own ? source : consumer.source; - const result = renameComponentReferences( - original, - names, - rename, - own ? 'definition' : 'consumer' - ); - if (!result.ok) { + const changed = planConsumerChange(original, names, change, propertyRename, optionRenames, own); + if (!changed.ok) { return err({ - code: result.error.code, - message: `${path.relative(request.projectPath, file)}: ${result.error.message}`, + code: changed.error.code, + message: `${path.relative(request.projectPath, file)}: ${changed.error.message}`, }); } - if (own || result.value !== original) { - changes.push({ file, before: own ? request.source : original, after: result.value }); + if (own || changed.value !== original) { + changes.push({ + file, + before: own ? request.source : original, + after: changed.value, + }); } } assert( @@ -140,6 +151,68 @@ function planPropertyChanges( return ok(changes); } +function propertyRenameForChange( + change: PropertyChange +): { readonly from: string; readonly to: string } | undefined { + if (change.kind !== 'save' || !change.originalName) { + return undefined; + } + return change.originalName === change.property.name + ? undefined + : { from: change.originalName, to: change.property.name }; +} + +function planConsumerChange( + source: string, + names: ReadonlySet, + change: Extract, + propertyRename: { readonly from: string; readonly to: string } | undefined, + optionRenames: readonly PropertyOptionRename[], + own: boolean +): Result { + const renamed = propertyRename + ? renameComponentReferences(source, names, propertyRename, own ? 'definition' : 'consumer') + : ok(source); + if (!renamed.ok || optionRenames.length === 0) { + return renamed; + } + return renameComponentOptionValues(renamed.value, names, change.property.name, optionRenames); +} + +function validateOptionRenames( + beforeSource: string, + afterSource: string, + change: Extract, + renames: readonly PropertyOptionRename[] +): Result { + if (renames.length === 0) { + return ok(undefined); + } + const before = readComponentProperties(beforeSource).properties.find( + (property) => property.name === change.originalName + ); + const after = readComponentProperties(afterSource).properties.find( + (property) => property.name === change.property.name + ); + const beforeOptions = before ? literalOptions(before.type) : undefined; + const afterOptions = after ? literalOptions(after.type) : undefined; + if (!beforeOptions || !afterOptions) { + return err({ + code: 'option', + message: 'Option renames require an editable literal union.', + }); + } + for (const rename of renames) { + if (!beforeOptions.includes(rename.from) || !afterOptions.includes(rename.to)) { + return err({ + code: 'option', + message: 'An option rename does not match the saved union.', + }); + } + } + return ok(undefined); +} + function commitPropertyChanges( changes: readonly FileChange[], noteWrite: (file: string, source: string) => void @@ -196,7 +269,10 @@ function rollbackPropertyChanges( message: `Save failed and recovery failed for: ${failed.join(', ')}. ${String(reason)}`, }); } - return err({ code: 'filesystem', message: `Save failed; changes restored. ${String(reason)}` }); + return err({ + code: 'filesystem', + message: `Save failed; changes restored. ${String(reason)}`, + }); } function planPropertyRemoval( diff --git a/electron/main.ts b/electron/main.ts index 97d4cc37..69bb019e 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -3000,19 +3000,25 @@ function writeChunks(model: ParserPageModel) { // Page IPC // --------------------------------------------------------------------------- +function parsePageSource(pagePath: string, source: string): IpcResults['page:read'] { + if (isMarkdownPage(pagePath)) { + return { ...parseMarkdownPage(source, { mdx: isMdx(pagePath) }), source }; + } + const parsed = parsePage(source, { locs: true }); + if (parsed.editable) {resolveChunks(parsed.model, pagePath, { locs: true });} + return { ...parsed, source }; +} + ipcMain.handle('page:read', async (_e, pagePath) => { const source = readSource(pagePath); // Markdown builds the same tree from a different syntax, so everything // downstream — navigator, props, text editing, undo — is unchanged. Only // the writer has to know which one it is; model.format carries that. - if (isMarkdownPage(pagePath)) { - return { ...parseMarkdownPage(source, { mdx: isMdx(pagePath) }), source }; - } - const parsed = parsePage(source); - if (parsed.editable) { - resolveChunks(parsed.model, pagePath); - } - return { ...parsed, source }; + return parsePageSource(pagePath, source); +}); + +ipcMain.handle('page:parse', async (_e, { pagePath, source }) => { + return parsePageSource(pagePath, source); }); // Astro's dev server serves a page's