From 55984a38df8b3723df2502f2f7642130217cb77c Mon Sep 17 00:00:00 2001
From: Timothy Ricks
Date: Fri, 18 Sep 2026 20:12:49 -0500
Subject: [PATCH 1/3] feat: release v0.1.31 with clearer component property
editing
Edit common local props without flattening variant contracts, and show declaration provenance as metadata. Unify property rows and dropdown controls, keep option add actions outside scrolling lists, and make settings dismissible.
---
docs/component-properties.md | 14 +-
electron/propertyContracts.ts | 300 ++++++++++++++++++++++++
electron/propertyDefinitions.ts | 125 +++++++++-
electron/propertyOrigins.ts | 162 +++++++++++++
electron/propertyRename.ts | 28 ++-
package-lock.json | 4 +-
package.json | 2 +-
shared/component-properties.ts | 52 +++-
src/panels/ComponentPropertiesPanel.tsx | 110 ++++-----
src/panels/ListField.tsx | 105 ++++-----
src/panels/PropertyDeclarationInfo.tsx | 43 ++++
src/panels/PropertyDefault.tsx | 49 ++--
src/panels/PropertyEditor.tsx | 115 ++++++---
src/panels/PropertyReadOnlyFields.tsx | 46 ++++
src/panels/PropertyReorder.tsx | 2 +-
src/panels/PropertyType.tsx | 97 ++++++--
src/panels/componentProperties.css | 153 +++---------
src/styles.css | 21 +-
src/ui/ListFieldRow.tsx | 54 +++++
test/component-properties-panel.test.js | 105 ++++++++-
test/component-properties.test.js | 266 +++++++++++++++++++++
21 files changed, 1518 insertions(+), 335 deletions(-)
create mode 100644 electron/propertyContracts.ts
create mode 100644 electron/propertyOrigins.ts
create mode 100644 src/panels/PropertyDeclarationInfo.tsx
create mode 100644 src/panels/PropertyReadOnlyFields.tsx
create mode 100644 src/ui/ListFieldRow.tsx
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/propertyContracts.ts b/electron/propertyContracts.ts
new file mode 100644
index 00000000..cbd47b44
--- /dev/null
+++ b/electron/propertyContracts.ts
@@ -0,0 +1,300 @@
+import ts from 'typescript';
+import { assert } from '../shared/assert';
+import { PROPERTY_LIMITS } from '../shared/component-properties';
+import type { PropertyEditing } from '../shared/component-properties';
+import { isAstroProps, propertyKey, syntaxNodes } from './propertySyntax';
+import type { PropertySyntax } from './propertySyntax';
+
+type Declaration = ts.TypeAliasDeclaration | ts.InterfaceDeclaration;
+interface Usage {
+ readonly member: ts.PropertySignature;
+ readonly conditional: boolean;
+}
+interface Work {
+ readonly node: ts.Node;
+ readonly conditional: boolean;
+ readonly path: readonly string[];
+}
+interface Branch {
+ readonly condition: string;
+ readonly fields: ReadonlyMap;
+}
+interface Contract {
+ readonly members: ReadonlyMap;
+ readonly names: ReadonlySet;
+ readonly branches: readonly (readonly Branch[])[];
+ readonly complete: boolean;
+}
+export interface PropertyContracts {
+ readonly names: ReadonlySet;
+ readonly editable: ReadonlyMap;
+ readonly editing: (name: string) => PropertyEditing;
+ readonly conditions: (name: string) => readonly string[];
+}
+
+// A common member must be the same declaration in both the public contract and
+// the runtime assertion. Editing it then preserves every variant and runtime type.
+export function readPropertyContracts(document: PropertySyntax): PropertyContracts {
+ const declarations = new Map();
+ for (const statement of document.syntax.statements) {
+ if (ts.isTypeAliasDeclaration(statement) || ts.isInterfaceDeclaration(statement)) {
+ if (declarations.has(statement.name.text)) {
+ return unavailableContracts('Duplicate type declarations require editing in source.');
+ }
+ declarations.set(statement.name.text, statement);
+ }
+ }
+ const assertions = syntaxNodes(document.syntax)
+ .filter(ts.isAsExpression)
+ .filter((node) => isAstroProps(node.expression));
+ const attributes = htmlAttributeImports(document.syntax);
+ const publicContract = readContract(declarations.get('Props'), declarations, attributes);
+ const runtimeContracts = assertions.map((node) =>
+ readContract(node.type, declarations, attributes)
+ );
+ const editable = new Map();
+ const reasons = new Map();
+ for (const [name, usages] of publicContract.members) {
+ const member = usages[0]?.member;
+ assert(member !== undefined, 'Contract field has a declaration');
+ const reason = commonMemberRestriction(name, publicContract, runtimeContracts);
+ if (reason) {
+ reasons.set(name, reason);
+ } else {
+ editable.set(name, member);
+ }
+ }
+ assert(editable.size <= PROPERTY_LIMITS.fieldsMax, 'Editable contract fields are bounded');
+ return {
+ names: new Set(
+ [publicContract, ...runtimeContracts].flatMap((contract) => [...contract.names])
+ ),
+ editable,
+ editing: (name) =>
+ editable.has(name)
+ ? { kind: 'editable' }
+ : {
+ kind: 'restricted',
+ reason:
+ reasons.get(name) ??
+ 'This prop is inherited or imported. Edit its declaration in source.',
+ },
+ conditions: (name) => contractConditions(publicContract, name),
+ };
+}
+
+function unavailableContracts(reason: string): PropertyContracts {
+ return {
+ names: new Set(),
+ editable: new Map(),
+ editing: () => ({ kind: 'restricted', reason }),
+ conditions: () => [],
+ };
+}
+
+function commonMemberRestriction(
+ name: string,
+ contract: Contract,
+ runtimeContracts: readonly Contract[]
+): string | undefined {
+ const usages = contract.members.get(name) ?? [];
+ if (usages.some((usage) => usage.conditional)) {
+ return 'This prop has variant rules. Edit its declaration in source to preserve those rules.';
+ }
+ if (new Set(usages.map((usage) => usage.member)).size !== 1) {
+ return 'This prop combines multiple declarations. Edit those declarations in source.';
+ }
+ if (!contract.complete) {
+ return 'This prop depends on a shared or computed type that must be edited in source.';
+ }
+ const member = usages[0]?.member;
+ for (const runtime of runtimeContracts) {
+ const matches = runtime.members.get(name) ?? [];
+ if (
+ !runtime.complete ||
+ matches.length === 0 ||
+ matches.some((match) => match.member !== member || match.conditional)
+ ) {
+ return 'This prop has a separate runtime type. Edit both declarations together in source.';
+ }
+ }
+ return undefined;
+}
+
+function readContract(
+ root: ts.Node | undefined,
+ declarations: ReadonlyMap,
+ attributes: ReadonlySet
+): Contract {
+ const work: Work[] = root ? [{ node: root, conditional: false, path: [] }] : [];
+ const members = new Map();
+ const names = new Set();
+ const branches: (readonly Branch[])[] = [];
+ let complete = root !== undefined;
+ for (let count = 0; count < PROPERTY_LIMITS.nodesMax && work.length > 0; count++) {
+ const item = work.pop();
+ assert(item !== undefined, 'Contract traversal has a work item');
+ if (ts.isPropertySignature(item.node)) {
+ const name = propertyKey(item.node.name);
+ if (name) {
+ const usages = members.get(name) ?? [];
+ assert(usages.length < PROPERTY_LIMITS.fieldsMax, 'Contract field usages are bounded');
+ members.set(name, [...usages, { member: item.node, conditional: item.conditional }]);
+ }
+ } else {
+ if (ts.isTypeAliasDeclaration(item.node) || ts.isInterfaceDeclaration(item.node)) {
+ names.add(item.node.name.text);
+ }
+ if (ts.isUnionTypeNode(item.node)) {
+ branches.push(unionBranches(item.node));
+ }
+ const children = contractChildren(item, declarations, attributes);
+ if (children === undefined) {
+ complete = false;
+ } else {
+ work.push(...children);
+ }
+ }
+ assert(work.length <= PROPERTY_LIMITS.nodesMax, 'Contract traversal queue is bounded');
+ }
+ assert(work.length === 0, 'Contract traversal completes within its budget');
+ assert(members.size <= PROPERTY_LIMITS.fieldsMax, 'Contract field count is bounded');
+ return { members, names, branches, complete };
+}
+
+function contractChildren(
+ item: Work,
+ declarations: ReadonlyMap,
+ attributes: ReadonlySet
+): readonly Work[] | undefined {
+ const { node } = item;
+ if (ts.isTypeAliasDeclaration(node) || ts.isInterfaceDeclaration(node)) {
+ if (node.typeParameters || item.path.includes(node.name.text) || item.path.length >= 64) {
+ return undefined;
+ }
+ if (node.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)) {
+ return undefined;
+ }
+ const next = { ...item, path: [...item.path, node.name.text] };
+ if (ts.isTypeAliasDeclaration(node)) {
+ return [{ ...next, node: node.type }];
+ }
+ return [
+ ...node.members.map((member) => ({ ...next, node: member })),
+ ...(node.heritageClauses ?? [])
+ .flatMap((clause) => clause.types)
+ .map((type) => ({ ...next, node: type })),
+ ];
+ }
+ if (ts.isTypeLiteralNode(node)) {
+ return node.members.map((member) => ({ ...item, node: member }));
+ }
+ if (ts.isUnionTypeNode(node) || ts.isIntersectionTypeNode(node)) {
+ return node.types.map((type) => ({
+ ...item,
+ node: type,
+ conditional: item.conditional || ts.isUnionTypeNode(node),
+ }));
+ }
+ if (ts.isParenthesizedTypeNode(node)) {
+ return [{ ...item, node: node.type }];
+ }
+ if (ts.isTypeReferenceNode(node) || ts.isExpressionWithTypeArguments(node)) {
+ const name = ts.isTypeReferenceNode(node) ? node.typeName.getText() : node.expression.getText();
+ // DOM attributes are inherited, but locally authored members remain editable.
+ if (attributes.has(name) && !declarations.has(name)) {
+ return [];
+ }
+ const declaration = declarations.get(name);
+ return declaration && !node.typeArguments ? [{ ...item, node: declaration }] : undefined;
+ }
+ return undefined;
+}
+
+function htmlAttributeImports(syntax: ts.SourceFile): ReadonlySet {
+ const names = new Set();
+ for (const statement of syntax.statements) {
+ if (ts.isImportDeclaration(statement) && ts.isStringLiteral(statement.moduleSpecifier)) {
+ const bindings = statement.importClause?.namedBindings;
+ if (
+ statement.moduleSpecifier.text === 'astro/types' &&
+ bindings &&
+ ts.isNamedImports(bindings)
+ ) {
+ for (const specifier of bindings.elements) {
+ if ((specifier.propertyName ?? specifier.name).text === 'HTMLAttributes') {
+ names.add(specifier.name.text);
+ }
+ }
+ }
+ }
+ }
+ return names;
+}
+
+function unionBranches(node: ts.UnionTypeNode): readonly Branch[] {
+ const fields = node.types.map(branchFields);
+ const discriminator = [...(fields[0]?.keys() ?? [])].find((name) =>
+ fields.every((branch) => literalBranchValue(branch.get(name)?.type) !== undefined)
+ );
+ return fields.map((branch, index) => ({
+ fields: branch,
+ condition: discriminator
+ ? `${discriminator} = ${literalBranchValue(branch.get(discriminator)?.type)}`
+ : `Variant branch ${index + 1}`,
+ }));
+}
+
+function branchFields(root: ts.TypeNode): ReadonlyMap {
+ const work = [root];
+ const fields = new Map();
+ for (let count = 0; count < PROPERTY_LIMITS.nodesMax && work.length; count++) {
+ const node = work.pop();
+ assert(node !== undefined, 'Variant branch work item exists');
+ if (ts.isTypeLiteralNode(node)) {
+ for (const member of node.members) {
+ if (ts.isPropertySignature(member)) {
+ const name = propertyKey(member.name);
+ if (name) {
+ fields.set(name, member);
+ }
+ }
+ }
+ } else if (ts.isIntersectionTypeNode(node)) {
+ work.push(...node.types);
+ } else if (ts.isParenthesizedTypeNode(node)) {
+ work.push(node.type);
+ }
+ }
+ assert(work.length === 0, 'Variant branch traversal is bounded');
+ return fields;
+}
+
+function literalBranchValue(type: ts.TypeNode | undefined): string | undefined {
+ if (!type) {
+ return undefined;
+ }
+ const types = ts.isUnionTypeNode(type) ? type.types : [type];
+ return types.every((value) => ts.isLiteralTypeNode(value)) ? type.getText() : undefined;
+}
+
+function contractConditions(contract: Contract, name: string): readonly string[] {
+ const common = contract.members.get(name)?.find((usage) => !usage.conditional)?.member;
+ const conditions = contract.branches
+ .filter((branches) => branches.some((branch) => branch.fields.has(name)))
+ .flatMap((branches) =>
+ branches.map((branch) => {
+ const member = branch.fields.get(name) ?? common;
+ const state = member
+ ? member.type?.kind === ts.SyntaxKind.NeverKeyword
+ ? 'not allowed'
+ : member.questionToken
+ ? 'optional'
+ : 'required'
+ : 'not declared';
+ return `${branch.condition}: ${state}`;
+ })
+ );
+ assert(conditions.length <= PROPERTY_LIMITS.fieldsMax, 'Property variant conditions are bounded');
+ return conditions;
+}
diff --git a/electron/propertyDefinitions.ts b/electron/propertyDefinitions.ts
index 3e8428f4..9c898551 100644
--- a/electron/propertyDefinitions.ts
+++ b/electron/propertyDefinitions.ts
@@ -1,3 +1,8 @@
+import { readPropertyOrigins } from './propertyOrigins';
+import { readPropertyContracts } from './propertyContracts';
+import type { PropertyContracts } from './propertyContracts';
+import type { PropertyOrigins } from './propertyOrigins';
+import type { SchemaField } from './astroParser.types';
import { createPropertyTypeReader } from './propertyTypes';
import { bindComponentDefault } from './propertyRename';
import ts from 'typescript';
@@ -6,6 +11,7 @@ import type {
ComponentProperties,
ComponentProperty,
PropertyChange,
+ PropertyEditing,
} from '../shared/component-properties';
import { PROPERTY_LIMITS } from '../shared/component-properties';
import { err, ok, type Result } from '../shared/result';
@@ -35,18 +41,32 @@ interface Definitions {
export function readComponentProperties(source: string): ComponentProperties {
const definitions = readDefinitions(source);
+ const origins = readPropertyOrigins(definitions.document);
+ const fields = readDefinitionFields(definitions);
+ const contracts = readPropertyContracts(definitions.document);
+ return {
+ source,
+ properties: [...fields.values()].map((property) =>
+ sourcedProperty(property, definitions, origins, contracts)
+ ),
+ frontmatter: definitions.document.frontmatter,
+ advanced: definitions.advanced,
+ };
+}
+
+function readDefinitionFields(definitions: Definitions): ReadonlyMap {
const fields = new Map();
const names = new Set([
...definitions.members.map((member) => propertyKey(member.name)),
...definitions.bindings.map((binding) => propertyKey(binding.propertyName ?? binding.name)),
]);
- for (const field of parsePropSchema(source)) {
+ for (const field of parsePropSchema(definitions.document.source)) {
if (!definitions.advanced && !names.has(field.name)) {
continue;
}
fields.set(field.name, {
name: field.name,
- type: field.type || 'unknown',
+ type: schemaPropertyType(field),
required: !field.optional,
readonly: false,
defaultValue: '',
@@ -86,14 +106,74 @@ export function readComponentProperties(source: string): ComponentProperties {
}
assert(fields.size <= PROPERTY_LIMITS.fieldsMax, 'Component field count is bounded');
assert(new Set(fields.keys()).size === fields.size, 'Component property names are unique');
+ return fields;
+}
+
+function schemaPropertyType(field: SchemaField): string {
+ if (field.type === 'enum' && field.options?.length) {
+ return field.options
+ .map((value) => (field.numeric ? value : JSON.stringify(value)))
+ .join(' | ');
+ }
+ return ['enum', 'other', 'code', 'attrs', 'slot', 'style'].includes(field.type)
+ ? 'unknown'
+ : field.type || 'unknown';
+}
+
+function sourcedProperty(
+ property: ComponentProperty,
+ definitions: Definitions,
+ origins: PropertyOrigins,
+ contracts: PropertyContracts
+): ComponentProperty {
+ const binding = definitions.bindings.find(
+ (item) => propertyKey(item.propertyName ?? item.name) === property.name
+ );
+ const types = (origins.members.get(property.name) ?? [])
+ .flatMap((member) => (member.type ? [definitions.readType(member.type)] : []))
+ .filter((type) => type !== 'never');
+ const member = contracts.editable.get(property.name);
return {
- source,
- properties: [...fields.values()],
- frontmatter: definitions.document.frontmatter,
- advanced: definitions.advanced,
+ ...property,
+ ...(member
+ ? {
+ required: member.questionToken === undefined,
+ readonly:
+ member.modifiers?.some((item) => item.kind === ts.SyntaxKind.ReadonlyKeyword) ?? false,
+ description: readDescription(member, definitions.document.frontmatter),
+ }
+ : {}),
+ type: definitions.advanced && types.length ? [...new Set(types)].join(' | ') : property.type,
+ origin: origins.origin(property.name, binding),
+ editing: definitions.advanced
+ ? contractEditing(definitions, contracts, property.name)
+ : {
+ kind: 'editable',
+ },
+ conditions: contracts.conditions(property.name),
};
}
+function contractEditing(
+ definitions: Definitions,
+ contracts: PropertyContracts,
+ name: string
+): PropertyEditing {
+ if (
+ definitions.patterns.length > 1 ||
+ definitions.bindings.some((binding) => !ts.isIdentifier(binding.name))
+ ) {
+ return {
+ kind: 'restricted',
+ reason: 'This prop uses complex destructuring. Edit it in source.',
+ };
+ }
+ if (!/^[A-Za-z_$][\w$]*$/.test(name)) {
+ return { kind: 'restricted', reason: 'This prop uses a quoted name. Edit it in source.' };
+ }
+ return contracts.editing(name);
+}
+
export function editPropertyDefinition(source: string, change: PropertyChange): Result {
const definitions = readDefinitions(source);
if (change.kind === 'source') {
@@ -104,12 +184,7 @@ export function editPropertyDefinition(source: string, change: PropertyChange):
return ok(replaceFrontmatter(definitions.document, change.frontmatter.replace(/\s*$/, '\n')));
}
if (definitions.advanced) {
- return err({
- code: 'advanced',
- message:
- 'This contract uses imported, generic, or composite props. ' +
- 'Declare editable fields in Props.',
- });
+ return editCommonDefinition(definitions, change);
}
if (change.kind === 'order') {
return reorderDefinitions(definitions, change.names);
@@ -120,6 +195,32 @@ export function editPropertyDefinition(source: string, change: PropertyChange):
return saveDefinition(definitions, change.originalName, change.property);
}
+function editCommonDefinition(
+ definitions: Definitions,
+ change: Exclude
+): Result {
+ if (change.kind === 'order' || (change.kind === 'save' && !change.originalName)) {
+ return err({
+ code: 'advanced',
+ message: 'Add or reorder declarations in this combined type in source.',
+ });
+ }
+ const name = change.kind === 'remove' ? change.name : change.originalName;
+ // Renderer permissions are descriptive only; every write rechecks the current source.
+ const contracts = readPropertyContracts(definitions.document);
+ const access = contractEditing(definitions, contracts, name);
+ if (access.kind === 'restricted') {
+ return err({ code: 'restricted', message: access.reason });
+ }
+ const member = contracts.editable.get(name);
+ assert(member !== undefined, 'An editable common property has a declaration');
+ assert(propertyKey(member.name) === name, 'The edit targets the requested declaration');
+ const common = { ...definitions, members: [member] };
+ return change.kind === 'remove'
+ ? removeDefinition(common, name)
+ : saveDefinition(common, name, change.property);
+}
+
function readDefinitions(source: string): Definitions {
const document = readPropertySyntax(source);
const declarations = document.syntax.statements.filter((statement) => {
diff --git a/electron/propertyOrigins.ts b/electron/propertyOrigins.ts
new file mode 100644
index 00000000..af22b90d
--- /dev/null
+++ b/electron/propertyOrigins.ts
@@ -0,0 +1,162 @@
+import ts from 'typescript';
+import { assert } from '../shared/assert';
+import { PROPERTY_LIMITS } from '../shared/component-properties';
+import type { PropertyOrigin, PropertySource } from '../shared/component-properties';
+import { propertyKey, syntaxNodes } from './propertySyntax';
+import type { PropertySyntax } from './propertySyntax';
+
+type Declaration = ts.InterfaceDeclaration | ts.TypeAliasDeclaration;
+interface Work {
+ readonly node: ts.Node;
+ readonly owner: string;
+}
+export interface PropertyOrigins {
+ readonly members: ReadonlyMap;
+ readonly origin: (name: string, binding: ts.BindingElement | undefined) => PropertyOrigin;
+}
+
+// Trace only the prop contract graph: similarly named fields in unrelated objects are not sources.
+// Props is authoritative; a widened Astro.props assertion supplies missing fields only.
+export function readPropertyOrigins(document: PropertySyntax): PropertyOrigins {
+ const declarations = new Map();
+ for (const statement of document.syntax.statements) {
+ if (ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement)) {
+ declarations.set(statement.name.text, statement);
+ }
+ }
+ const roots = [
+ 'Props',
+ ...syntaxNodes(document.syntax)
+ .filter(ts.isAsExpression)
+ .filter((node) => node.expression.getText() === 'Astro.props')
+ .map((node) => node.type.getText()),
+ ];
+ const members = new Map();
+ const sources = new Map();
+ assert(roots.length <= PROPERTY_LIMITS.nodesMax, 'Property origin roots are bounded');
+ for (const root of new Set(roots)) {
+ const found = readOriginMembers(declarations.get(root), declarations);
+ for (const [name, entries] of found) {
+ if (!members.has(name)) {
+ members.set(
+ name,
+ entries.map((entry) => entry.member)
+ );
+ sources.set(
+ name,
+ entries.map((entry) =>
+ originSource(
+ document,
+ entry.member,
+ `${entry.owner}.${name}`,
+ entry.member.type?.getText() ?? 'unknown'
+ )
+ )
+ );
+ }
+ }
+ }
+ assert(members.size <= PROPERTY_LIMITS.fieldsMax, 'Property origin fields are bounded');
+ return {
+ members,
+ origin: (name, binding) => ({
+ declarations: sources.get(name) ?? [],
+ ...(binding?.initializer
+ ? {
+ defaultValue: originSource(
+ document,
+ binding,
+ `Astro.props.${name}`,
+ binding.initializer.getText()
+ ),
+ }
+ : {}),
+ }),
+ };
+}
+
+interface OriginMember {
+ readonly member: ts.PropertySignature;
+ readonly owner: string;
+}
+function readOriginMembers(
+ root: Declaration | undefined,
+ declarations: ReadonlyMap
+): ReadonlyMap {
+ const work: Work[] = root ? [{ node: root, owner: root.name.text }] : [];
+ const seen = new Set();
+ const members = new Map();
+ for (let count = 0; count < PROPERTY_LIMITS.nodesMax && work.length > 0; count++) {
+ const item = work.pop();
+ assert(item !== undefined, 'A property origin work item exists');
+ if (seen.has(item.node)) {
+ continue;
+ }
+ seen.add(item.node);
+ if (ts.isPropertySignature(item.node)) {
+ const name = propertyKey(item.node.name);
+ if (name) {
+ const previous = members.get(name) ?? [];
+ assert(previous.length < PROPERTY_LIMITS.fieldsMax, 'Property source count is bounded');
+ members.set(name, [...previous, { member: item.node, owner: item.owner }]);
+ }
+ } else {
+ work.push(...originChildren(item, declarations).reverse());
+ assert(work.length <= PROPERTY_LIMITS.nodesMax, 'Property origin queue is bounded');
+ }
+ }
+ assert(work.length === 0, 'Property origin traversal completes within bounds');
+ return members;
+}
+
+function originChildren(item: Work, declarations: ReadonlyMap): Work[] {
+ const { node, owner } = item;
+ if (ts.isTypeAliasDeclaration(node)) {
+ return [{ node: node.type, owner: node.name.text }];
+ }
+ if (ts.isInterfaceDeclaration(node)) {
+ return [
+ ...node.members.map((member) => ({ node: member, owner: node.name.text })),
+ ...(node.heritageClauses ?? [])
+ .flatMap((clause) => clause.types)
+ .flatMap((type) => originReference(type.expression.getText(), declarations)),
+ ];
+ }
+ if (ts.isTypeLiteralNode(node)) {
+ return node.members.map((member) => ({ node: member, owner }));
+ }
+ if (ts.isUnionTypeNode(node) || ts.isIntersectionTypeNode(node)) {
+ return node.types.map((type) => ({ node: type, owner }));
+ }
+ if (ts.isParenthesizedTypeNode(node)) {
+ return [{ node: node.type, owner }];
+ }
+ if (ts.isTypeReferenceNode(node)) {
+ // Generic transformations can rename or omit fields; do not invent per-field provenance.
+ return node.typeArguments ? [] : originReference(node.typeName.getText(), declarations);
+ }
+ return [];
+}
+
+function originReference(name: string, declarations: ReadonlyMap): Work[] {
+ const declaration = declarations.get(name);
+ return declaration ? [{ node: declaration, owner: name }] : [];
+}
+
+function originSource(
+ document: PropertySyntax,
+ node: ts.Node,
+ label: string,
+ expression: string
+): PropertySource {
+ const offset = node.getStart(document.syntax);
+ assert(offset >= 0, 'Property source offset is nonnegative');
+ assert(offset <= document.frontmatter.length, 'Property source offset is inside frontmatter');
+ return {
+ label,
+ expression,
+ line:
+ document.syntax.getLineAndCharacterOfPosition(offset).line +
+ document.source.slice(0, document.start).split('\n').length,
+ };
+}
diff --git a/electron/propertyRename.ts b/electron/propertyRename.ts
index 490ba910..aecf3f9e 100644
--- a/electron/propertyRename.ts
+++ b/electron/propertyRename.ts
@@ -3,6 +3,7 @@
import { parse } from '@astrojs/compiler/sync';
import type { Node, AttributeNode } from '@astrojs/compiler/types';
import ts from 'typescript';
+import { readPropertyContracts } from './propertyContracts';
import { assert } from '../shared/assert';
import { PROPERTY_LIMITS } from '../shared/component-properties';
import { err, ok, type Result } from '../shared/result';
@@ -21,7 +22,12 @@ export interface PropertyRename {
}
type PropertyReferenceEdit =
- | { readonly kind: 'rename'; readonly from: string; readonly to: string }
+ | {
+ readonly kind: 'rename';
+ readonly from: string;
+ readonly to: string;
+ readonly typeNames: ReadonlySet;
+ }
| {
readonly kind: 'default';
readonly from: string;
@@ -36,7 +42,16 @@ export function renameComponentReferences(
rename: PropertyRename,
owner: 'definition' | 'consumer'
): Result {
- return editComponentReferences(source, names, { kind: 'rename', ...rename }, owner);
+ const typeNames =
+ owner === 'definition'
+ ? readPropertyContracts(readPropertySyntax(source)).names
+ : new Set();
+ return editComponentReferences(
+ source,
+ names,
+ { kind: 'rename', ...rename, typeNames: new Set(['Props', ...typeNames]) },
+ owner
+ );
}
export function bindComponentDefault(
@@ -309,7 +324,7 @@ function renameAstroAccess(
const edits: SourceEdit[] = [];
for (const node of syntaxNodes(syntax)) {
if (rename.kind === 'rename') {
- const typeEdit = renameIndexedPropType(node, start, rename);
+ const typeEdit = renameIndexedPropType(node, start, rename, rename.typeNames);
if (typeEdit) {
edits.push(typeEdit);
}
@@ -382,7 +397,8 @@ function defaultBindingName(source: string): string {
function renameIndexedPropType(
node: ts.Node,
start: number,
- rename: PropertyRename
+ rename: PropertyRename,
+ typeNames: ReadonlySet
): SourceEdit | undefined {
if (
!ts.isLiteralTypeNode(node) ||
@@ -398,11 +414,11 @@ function renameIndexedPropType(
const indexed =
ts.isIndexedAccessTypeNode(parent) &&
ts.isTypeReferenceNode(parent.objectType) &&
- parent.objectType.typeName.getText() === 'Props';
+ typeNames.has(parent.objectType.typeName.getText());
const picked =
ts.isTypeReferenceNode(parent) &&
['Pick', 'Omit'].includes(parent.typeName.getText()) &&
- parent.typeArguments?.[0]?.getText() === 'Props';
+ typeNames.has(parent.typeArguments?.[0]?.getText() ?? '');
if (!indexed && !picked) {
return undefined;
}
diff --git a/package-lock.json b/package-lock.json
index fece4b5a..dbd3b1cd 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "stacki",
- "version": "0.1.30",
+ "version": "0.1.31",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "stacki",
- "version": "0.1.30",
+ "version": "0.1.31",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
diff --git a/package.json b/package.json
index 8e8b1146..8f84c5c3 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "stacki",
"productName": "Stacki",
- "version": "0.1.30",
+ "version": "0.1.31",
"description": "Stacki — a visual builder for Astro projects — pages, layouts, components, props, live preview, and GitHub publishing.",
"main": "dist/electron/main.js",
"author": "Timothy Ricks ",
diff --git a/shared/component-properties.ts b/shared/component-properties.ts
index 31e2b775..86e8cf9a 100644
--- a/shared/component-properties.ts
+++ b/shared/component-properties.ts
@@ -1,5 +1,5 @@
// Source text is the revision token: edits cannot overwrite a newer disk revision.
-import { boolean, list, object, text } from './boundary';
+import { boolean, count, list, object, optional, text } from './boundary';
import { LIMITS } from './limits';
import { toRecord } from './record';
import type { Result } from './result';
@@ -21,6 +21,21 @@ export interface ComponentProperty {
readonly readonly: boolean;
readonly defaultValue: string;
readonly description: string;
+ readonly origin?: PropertyOrigin;
+ readonly editing?: PropertyEditing;
+ readonly conditions?: readonly string[];
+}
+export type PropertyEditing =
+ | { readonly kind: 'editable' }
+ | { readonly kind: 'restricted'; readonly reason: string };
+export interface PropertySource {
+ readonly label: string;
+ readonly expression: string;
+ readonly line: number;
+}
+export interface PropertyOrigin {
+ readonly declarations: readonly PropertySource[];
+ readonly defaultValue?: PropertySource;
}
export interface ComponentProperties {
readonly source: string;
@@ -66,8 +81,43 @@ export function parseComponentProperty(input: unknown): ComponentProperty {
readonly: boolean,
defaultValue: propertyText,
description: propertyText,
+ origin: optional(parsePropertyOrigin),
+ editing: optional(parsePropertyEditing),
+ conditions: optional((value) => propertyList(value, propertyText)),
})(input);
}
+
+function parsePropertyEditing(input: unknown): PropertyEditing {
+ const value = toRecord(input);
+ if (value?.['kind'] === 'editable') {
+ return { kind: 'editable' };
+ }
+ if (value?.['kind'] === 'restricted') {
+ const reason = propertyText(value['reason']);
+ if (reason.trim()) {
+ return { kind: 'restricted', reason };
+ }
+ }
+ throw new Error('Invalid property editing permission');
+}
+
+function parsePropertyOrigin(input: unknown): PropertyOrigin {
+ return object({
+ declarations: (value) => propertyList(value, parsePropertySource),
+ defaultValue: optional(parsePropertySource),
+ })(input);
+}
+
+function parsePropertySource(input: unknown): PropertySource {
+ const source = object({ label: propertyText, expression: propertyText, line: count })(input);
+ if (!source.label.trim()) {
+ throw new Error('Property source label must be nonempty');
+ }
+ if (source.line < 1 || source.line > PROPERTY_LIMITS.sourceCharsMax) {
+ throw new Error('Property source line is out of bounds');
+ }
+ return source;
+}
export function propertyList(input: unknown, parse: (input: unknown) => T): readonly T[] {
const values = list(parse)(input);
if (values.length > PROPERTY_LIMITS.fieldsMax) {
diff --git a/src/panels/ComponentPropertiesPanel.tsx b/src/panels/ComponentPropertiesPanel.tsx
index 813132ca..d0687627 100644
--- a/src/panels/ComponentPropertiesPanel.tsx
+++ b/src/panels/ComponentPropertiesPanel.tsx
@@ -7,7 +7,9 @@ import type {
PropertyChange,
} from '../../shared/component-properties';
import useListReorder from '../ui/useListReorder';
-import { TrashIcon } from '../ui/Icons';
+import { literalOptions } from '../propertyOptions';
+import ListFieldRow from '../ui/ListFieldRow';
+import { ComponentPropertiesIcon, FieldNumberIcon, FieldSwitchIcon } from '../ui/Icons';
import { PropertyGrip, movePropertyItem } from './PropertyReorder';
import { PropertiesIcon } from '../ui/PropertiesIcon';
import { PropertyEditor } from './PropertyEditor';
@@ -69,7 +71,9 @@ function PropertyPanelContent({ data, busy, save }: PropertyContentProps) {
setSelection({
@@ -95,12 +99,6 @@ function PropertyPanelContent({ data, busy, save }: PropertyContentProps) {
/>
>
)}
- {data.advanced && (
-
- This component imports or combines its prop contract. Declare fields in Props to edit
- them.
-
- )}
{
if (await save(change)) {
@@ -159,61 +159,63 @@ function PropertyList({ data, busy, onSelect, onChange }: PropertyListProps) {
};
const reorder = useListReorder({ count: data.properties.length, onMove: move, disabled });
return (
-
+
{data.properties.length === 0 && (
No properties yet. Add a property to define what each instance can customize.
)}
{data.properties.map((property, index) => (
-
}
+ grip={
+
+ }
+ disabled={busy}
+ removeDisabled={busy || !propertyIsEditable(property, data)}
+ removeLabel={`Delete ${property.name}`}
+ onOpen={() => onSelect(property)}
+ onRemove={() => void onChange({ kind: 'remove', name: property.name })}
>
-
-
onSelect(property)}>
-
- {property.type.includes('|')
- ? '◇'
- : property.type === 'boolean'
- ? '◉'
- : property.type === 'number'
- ? '#'
- : property.type === 'string'
- ? 'T'
- : '{}'}
-
-
-
- {property.name}
- {property.required && (
-
- {' '}
- *
-
- )}
-
- {property.type}
+ {property.name}
+ {property.required && (
+
+ {' '}
+ *
-
-
void onChange({ kind: 'remove', name: property.name })}
- >
-
-
-
+ )}
+
))}
);
}
+
+function PropertyTypeIcon({ type }: { readonly type: string }) {
+ if (literalOptions(type)) {
+ return ;
+ }
+ switch (type) {
+ case 'boolean':
+ return ;
+ case 'number':
+ return ;
+ case 'string':
+ return 'T';
+ default:
+ return '{}';
+ }
+}
+
+function propertyIsEditable(property: ComponentProperty, data: ComponentProperties): boolean {
+ return property.editing ? property.editing.kind === 'editable' : !data.advanced;
+}
diff --git a/src/panels/ListField.tsx b/src/panels/ListField.tsx
index 8f142326..e943ec71 100644
--- a/src/panels/ListField.tsx
+++ b/src/panels/ListField.tsx
@@ -2,7 +2,8 @@ import React, { useEffect, useRef, useState } from 'react';
import type { Item } from '../arrayValue';
import { assert } from '../../shared/assert';
import { LIMITS } from '../../shared/limits';
-import { DragIcon, PlusIcon, TrashIcon, CloseIcon } from '../ui/Icons.jsx';
+import { PlusIcon, CloseIcon } from '../ui/Icons.jsx';
+import ListFieldRow from '../ui/ListFieldRow';
import { arrayItems, arrayText, blankLike, itemLabel, moveItem } from '../arrayValue.js';
// A prop that takes a list, edited as a list.
@@ -176,9 +177,13 @@ export default function ListField(props: ListFieldProps) {
return (
event.preventDefault()} onDrop={state.drop}>
{state.items.length === 0 && note ?
{note}
: null}
- {state.items.map((item, index) => (
-
- ))}
+ {state.items.length > 0 && (
+
closeListRowEditor(state)}>
+ {state.items.map((item, index) => (
+
+ ))}
+
+ )}
startListDrag(event, state, index)}
- onDragEnd={() => setDrag({ kind: 'idle' })}
- onDragOver={(event) => {
- if (drag.kind === 'idle') {
- return;
- }
- event.preventDefault();
- const box = event.currentTarget.getBoundingClientRect();
- const gap = event.clientY - box.top < box.height / 2 ? index : index + 1;
- setDrag({ ...drag, gap });
- }}
- onDrop={(event) => {
- event.preventDefault();
- event.stopPropagation();
- drop();
+ startListDrag(event, state, index),
+ onDragEnd: () => setDrag({ kind: 'idle' }),
+ onDragOver: (event) => {
+ if (drag.kind === 'idle') {
+ return;
+ }
+ event.preventDefault();
+ const box = event.currentTarget.getBoundingClientRect();
+ const gap = event.clientY - box.top < box.height / 2 ? index : index + 1;
+ setDrag({ ...drag, gap });
+ },
+ onDrop: (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ drop();
+ },
}}
+ expanded={open}
+ disabled={state.disabled}
+ removeDisabled={state.disabled || state.items.length <= state.itemsMin}
+ removeLabel={`Remove ${itemLabel(item) || 'item'}`}
+ onOpen={(event) => toggleListRow(event, state, index, item)}
+ onRemove={() => remove(index)}
>
-
-
-
- toggleListRow(event, state, index, item)}
- >
- {itemLabel(item) || Empty }
-
- remove(index)}
- >
-
-
-
+ {itemLabel(item) || Empty }
+
);
}
@@ -438,13 +430,7 @@ function ListEditor({ state }: { readonly state: ListState }) {
}
)
}
- onClose={() => {
- write(items, {
- immediate: true,
- change: { kind: 'edit', index: editor.index },
- });
- setEditor({ kind: 'closed' });
- }}
+ onClose={() => closeListRowEditor(state)}
/>
);
}
@@ -466,3 +452,14 @@ function ListEditor({ state }: { readonly state: ListState }) {
}
}
}
+
+function closeListRowEditor(state: ListState): void {
+ if (state.editor.kind === 'existing') {
+ // Commit before dismissing so scrolling cannot leave a popup detached from its row.
+ state.write(state.items, {
+ immediate: true,
+ change: { kind: 'edit', index: state.editor.index },
+ });
+ state.setEditor({ kind: 'closed' });
+ }
+}
diff --git a/src/panels/PropertyDeclarationInfo.tsx b/src/panels/PropertyDeclarationInfo.tsx
new file mode 100644
index 00000000..438feffd
--- /dev/null
+++ b/src/panels/PropertyDeclarationInfo.tsx
@@ -0,0 +1,43 @@
+import type { ComponentProperty } from '../../shared/component-properties';
+import { HelpCircleIcon } from '../ui/Icons';
+
+// Declaration locations are metadata, not runtime value bindings.
+export function PropertyDeclarationInfo({ property }: { readonly property: ComponentProperty }) {
+ const origin = property.origin;
+ const sources = origin?.declarations ?? [];
+ const details = sources.map(
+ (source) => `${source.label} (line ${source.line}): ${source.expression}`
+ );
+ if (origin?.defaultValue) {
+ details.push(`Default (line ${origin.defaultValue.line}): ${origin.defaultValue.expression}`);
+ }
+ if (details.length === 0) {
+ return null;
+ }
+ return (
+
+
+
+ );
+}
+
+export function PropertyConditions({ property }: { readonly property: ComponentProperty }) {
+ if (!property.conditions?.length) {
+ return null;
+ }
+ return (
+
+
Variant rules
+
+ {property.conditions.map((condition, index) => (
+ {condition}
+ ))}
+
+
+ );
+}
diff --git a/src/panels/PropertyDefault.tsx b/src/panels/PropertyDefault.tsx
index d4707040..b2713511 100644
--- a/src/panels/PropertyDefault.tsx
+++ b/src/panels/PropertyDefault.tsx
@@ -2,6 +2,8 @@ import { useState } from 'react';
import type { ComponentProperty } from '../../shared/component-properties';
import { PROPERTY_LIMITS } from '../../shared/component-properties';
import { literalOptions } from '../propertyOptions';
+import Dropdown from '../ui/Dropdown';
+import { BracesIcon } from '../ui/Icons';
interface DefaultProps {
readonly property: ComponentProperty;
@@ -14,17 +16,22 @@ export function PropertyDefault({ property, onChange }: DefaultProps) {
const [mode, setMode] = useState<'value' | 'expression'>(
simple && isLiteralDefault(property) ? 'value' : 'expression'
);
+ const expression = mode === 'expression';
+ const action = expression ? 'Use the default value control' : 'Write a default expression';
return (
-
+
Default value
{simple && (
setMode((current) => (current === 'value' ? 'expression' : 'value'))}
>
- {mode === 'value' ? '{ }' : 'Value'}
+
)}
@@ -42,16 +49,6 @@ export function PropertyDefault({ property, onChange }: DefaultProps) {
onChange={(event) => onChange(event.target.value)}
/>
)}
-
-
- {property.defaultValue
- ? 'Used when a value is omitted or undefined.'
- : 'No default value.'}
-
- onChange('')}>
- Clear
-
-
);
}
@@ -60,18 +57,20 @@ function DefaultControl({ property, onChange }: DefaultProps) {
if (options || property.type === 'boolean') {
const choices = [...new Set(options ?? ['true', 'false'])];
return (
-
onChange(event.target.value)}
- >
- No default
- {choices.map((choice) => (
-
- {propertyDefaultText(choice) ?? choice}
-
- ))}
-
+ options={[
+ { value: '', label: 'No default' },
+ ...choices.map((choice) => ({
+ value: choice,
+ label: propertyDefaultText(choice) ?? choice,
+ })),
+ ]}
+ onChange={onChange}
+ livePreview={false}
+ searchable
+ searchPlaceholder="Search defaults…"
+ />
);
}
if (property.type === 'number') {
diff --git a/src/panels/PropertyEditor.tsx b/src/panels/PropertyEditor.tsx
index aacdbba7..8e158d83 100644
--- a/src/panels/PropertyEditor.tsx
+++ b/src/panels/PropertyEditor.tsx
@@ -1,21 +1,26 @@
+import PropertyReadOnlyFields from './PropertyReadOnlyFields';
+import { PropertyConditions, PropertyDeclarationInfo } from './PropertyDeclarationInfo';
import { TrashIcon } from '../ui/Icons';
import { PropertyOptions } from './PropertyOptions';
import type { OptionRename } from './PropertyOptions';
import { PropertyType } from './PropertyType';
import { PropertyDefault, propertyDefaultText } from './PropertyDefault';
export { literalOptions } from '../propertyOptions';
-import { useState } from 'react';
+import { useRef, useState } from 'react';
+import useDismiss from '../ui/useDismiss';
import type { ComponentProperty, PropertyChange } from '../../shared/component-properties';
import { PROPERTY_LIMITS } from '../../shared/component-properties';
interface PropertyEditorProps {
readonly property: ComponentProperty;
readonly originalName: string;
- readonly disabled: boolean;
+ readonly access: 'editable' | 'readonly' | 'saving';
readonly onClose: () => void;
readonly onSave: (change: PropertyChange) => Promise
;
}
export function PropertyEditor(props: PropertyEditorProps) {
+ const editorRef = useRef(null);
+ useDismiss(editorRef, props.access !== 'saving', props.onClose);
const [property, setProperty] = useState(props.property);
const [error, setError] = useState('');
const update = (key: Key, value: ComponentProperty[Key]) => {
@@ -30,54 +35,94 @@ export function PropertyEditor(props: PropertyEditorProps) {
void props.onSave({ kind: 'save', originalName: props.originalName, property });
};
return (
-
+
- {props.originalName ? 'Property settings' : 'New property'}
+
+
{props.originalName ? 'Property settings' : 'New property'}
+
+
×
-
-
- update('type', type)} />
-
+
+ {props.access === 'readonly' ? (
+
+ ) : (
+
setProperty((previous) => changePropertyOptions(previous, type, rename))
}
/>
-
- update('defaultValue', value)}
- />
-
- Tooltip
-
- {error && (
-
- {error}
-
- )}
-
-
+ )}
);
}
+interface PropertyEditorFieldsProps extends PropertyControls {
+ readonly originalName: string;
+ readonly access: 'editable' | 'saving';
+ readonly onSave: PropertyEditorProps['onSave'];
+ readonly error: string;
+ readonly submit: () => void;
+ readonly changeOptions: (type: string, rename?: OptionRename) => void;
+}
+function PropertyEditorFields({
+ property,
+ originalName,
+ access,
+ onSave,
+ update,
+ error,
+ submit,
+ changeOptions,
+}: PropertyEditorFieldsProps) {
+ return (
+
+
+ update('type', type)} />
+ changeOptions(type, rename)}
+ />
+
+ update('defaultValue', value)}
+ />
+
+ Tooltip
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+ );
+}
+
function PropertyActions({
originalName,
onSave,
diff --git a/src/panels/PropertyReadOnlyFields.tsx b/src/panels/PropertyReadOnlyFields.tsx
new file mode 100644
index 00000000..98090839
--- /dev/null
+++ b/src/panels/PropertyReadOnlyFields.tsx
@@ -0,0 +1,46 @@
+import type { ComponentProperty } from '../../shared/component-properties';
+
+export default function PropertyReadOnlyFields({
+ property,
+}: {
+ readonly property: ComponentProperty;
+}) {
+ const reason =
+ property.editing?.kind === 'restricted'
+ ? property.editing.reason
+ : 'Edit this prop’s declaration in source.';
+ return (
+
+
{reason}
+
+
+
+
+
+ Required
+
+
+
+ Readonly
+
+
+
+
+
+ );
+}
+
+function ReadOnlyField({ label, value }: { readonly label: string; readonly value: string }) {
+ return (
+
+ {label}
+
+
+ );
+}
diff --git a/src/panels/PropertyReorder.tsx b/src/panels/PropertyReorder.tsx
index 53e347cc..21a5ddde 100644
--- a/src/panels/PropertyReorder.tsx
+++ b/src/panels/PropertyReorder.tsx
@@ -42,7 +42,7 @@ export function PropertyGrip({
return (
[] = [
- { value: 'string', label: 'string', hint: 'Text / link' },
- { value: 'number', label: 'number' },
- { value: 'boolean', label: 'boolean', hint: 'Switch' },
- { value: UNION_TYPE, label: 'Options (union)', hint: 'Literal choices' },
- { value: 'readonly string[]', label: 'Array', hint: 'readonly string[]' },
- { value: 'readonly [string, number]', label: 'Tuple', hint: 'readonly [string, number]' },
- { value: 'Record', label: 'Record', hint: 'Object / attributes' },
- { value: 'object', label: 'object' },
- { value: '(value: string) => void', label: 'Function', hint: 'Callback' },
- { value: 'Date', label: 'Date' },
- { value: 'Promise', label: 'Promise', hint: 'Promise' },
- { value: 'bigint', label: 'bigint' },
- { value: 'symbol', label: 'symbol' },
- { value: 'null', label: 'null' },
- { value: 'undefined', label: 'undefined' },
- { value: 'unknown', label: 'unknown' },
- { value: 'never', label: 'never' },
- { value: 'void', label: 'void' },
- { value: 'any', label: 'any', hint: 'Unchecked value' },
+ { value: 'string', label: 'string', hint: 'Text / link', icon: },
+ { value: 'number', label: 'number', icon: },
+ { value: 'boolean', label: 'boolean', hint: 'Switch', icon: },
+ {
+ value: UNION_TYPE,
+ label: 'Options (union)',
+ hint: 'Literal choices',
+ icon: ,
+ },
+ {
+ value: 'readonly string[]',
+ label: 'Array',
+ hint: 'readonly string[]',
+ icon: ,
+ },
+ {
+ value: 'readonly [string, number]',
+ label: 'Tuple',
+ hint: 'readonly [string, number]',
+ icon: ,
+ },
+ {
+ value: 'Record',
+ label: 'Record',
+ hint: 'Object / attributes',
+ icon: ,
+ },
+ { value: 'object', label: 'object', icon: },
+ {
+ value: '(value: string) => void',
+ label: 'Function',
+ hint: 'Callback',
+ icon: ,
+ },
+ { value: 'Date', label: 'Date', icon: },
+ {
+ value: 'Promise',
+ label: 'Promise',
+ hint: 'Promise',
+ icon: ,
+ },
+ { value: 'bigint', label: 'bigint', icon: },
+ { value: 'symbol', label: 'symbol', icon: },
+ { value: 'null', label: 'null', icon: },
+ { value: 'undefined', label: 'undefined', icon: },
+ { value: 'unknown', label: 'unknown', icon: },
+ { value: 'never', label: 'never', icon: },
+ { value: 'void', label: 'void', icon: },
+ { value: 'any', label: 'any', hint: 'Unchecked value', icon: },
];
export function PropertyType({
@@ -89,5 +136,13 @@ function propertyTypeChoices(type: string): readonly DropdownOption[] {
);
return choices.some((option) => option.value === type)
? choices
- : [{ value: type, label: type || 'Choose a type', hint: 'Custom type' }, ...choices];
+ : [
+ {
+ value: type,
+ label: type || 'Choose a type',
+ hint: 'Custom type',
+ icon: ,
+ },
+ ...choices,
+ ];
}
diff --git a/src/panels/componentProperties.css b/src/panels/componentProperties.css
index 9f4e5294..ae69566d 100644
--- a/src/panels/componentProperties.css
+++ b/src/panels/componentProperties.css
@@ -33,111 +33,14 @@
background: var(--component-green-soft);
overflow-wrap: anywhere;
}
-.property-list {
- border: 1px solid var(--border-strong);
- border-radius: 6px;
-}
-.property-row {
- display: flex;
- align-items: center;
- min-width: 0;
-}
-.property-row + .property-row {
- border-top: 1px solid var(--border);
-}
-.property-row:first-child {
- border-top-left-radius: 5px;
- border-top-right-radius: 5px;
-}
-.property-row:last-child {
- border-bottom-left-radius: 5px;
- border-bottom-right-radius: 5px;
-}
-.property-row:hover {
- background: var(--bg-active);
-}
-/* Keep the row highlight continuous, with separate feedback for the delete action. */
-.component-properties .property-row > button:where(:not(.property-remove)):hover:not(:disabled) {
- background: transparent;
-}
-.property-row-main {
- flex: 1;
- justify-content: flex-start;
- text-align: left;
- background: transparent;
- min-width: 0;
- padding: 10px 7px;
- gap: 8px;
-}
-.property-type-icon {
- color: var(--text-dim);
- width: 18px;
- flex-shrink: 0;
- text-align: center;
-}
-.property-row-label {
- display: flex;
- flex-direction: column;
- gap: 3px;
- min-width: 0;
-}
-.property-row-label > span {
- overflow-wrap: anywhere;
-}
-.property-row-label small {
- color: var(--text-dim);
- overflow: hidden;
- text-overflow: ellipsis;
-}
.property-required {
color: var(--collection-purple);
}
-.component-properties .property-grip,
-.component-properties .property-remove {
- flex-shrink: 0;
- width: 24px;
- min-width: 24px;
- padding: 3px;
- align-self: stretch;
- color: var(--text-dim);
- background: transparent;
-}
-.component-properties .property-grip {
- cursor: grab;
- touch-action: none;
-}
-.component-properties .property-grip:active {
- cursor: grabbing;
-}
-.component-properties .property-remove {
- width: 32px;
- min-width: 32px;
- min-height: 32px;
- margin: 4px;
- border-radius: 4px;
-}
-.property-grip svg,
-.property-remove svg {
- opacity: 0;
-}
-.property-row:hover svg,
-.property-row:focus-within svg {
- opacity: 1;
-}
-.property-row.is-dragging {
- background: var(--bg-panel-2);
- opacity: 0.65;
-}
.property-row.drop-before {
- box-shadow: inset 0 2px 0 var(--accent);
+ box-shadow: inset 0 1px 0 var(--accent);
}
.property-row.drop-after {
- box-shadow: inset 0 -2px 0 var(--accent);
-}
-.component-properties .property-remove:hover:not(:disabled),
-.component-properties .property-remove:focus-visible:not(:disabled) {
- color: var(--red);
- background: var(--bg-active);
+ box-shadow: inset 0 -1px 0 var(--accent);
}
.property-help {
color: var(--text-dim);
@@ -185,6 +88,30 @@
.property-editor textarea {
resize: vertical;
}
+.property-readonly-fields {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+.property-editor-title {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+.property-editor header .property-declaration-info {
+ padding: 2px;
+ color: var(--text-dim);
+ cursor: help;
+}
+.property-conditions {
+ color: var(--text-dim);
+ font-size: 11px;
+ line-height: 1.5;
+}
+.property-conditions ul {
+ margin: 6px 0 0;
+ padding-left: 16px;
+}
.property-editor .property-code {
font-family: var(--mono);
line-height: 1.5;
@@ -209,17 +136,11 @@
gap: 6px;
color: var(--text-dim);
}
-.property-default-title {
- display: flex;
- align-items: center;
- justify-content: space-between;
-}
-.property-default-title small {
- padding-top: 4px;
-}
-.property-default-title > span {
- margin-bottom: 6px;
- color: var(--text-dim);
+.property-options .list-field-items {
+ /* Keep six rows visible while leaving the add button outside the scrolling region. */
+ max-height: min(180px, 30vh);
+ overflow-y: auto;
+ overscroll-behavior-y: contain;
}
.property-actions {
display: flex;
@@ -254,19 +175,23 @@
background: transparent;
color: var(--text-dim);
}
-.property-type {
+.property-type,
+.property-default {
display: flex;
flex-direction: column;
gap: 6px;
}
-.property-type-heading {
+.property-type-heading,
+.property-default-title {
display: flex;
align-items: center;
color: var(--text-dim);
}
-.property-type:hover .prop-expr-toggle:not(.on) {
+.property-type:hover .prop-expr-toggle:not(.on),
+.property-default:hover .prop-expr-toggle:not(.on) {
color: var(--text-dim);
}
-.property-type .dd-trigger {
+.property-type .dd-trigger,
+.property-default .dd-trigger {
width: 100%;
}
diff --git a/src/styles.css b/src/styles.css
index d348888e..9fed9613 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -698,12 +698,28 @@ html[data-platform='win32'] .titlebar { padding-right: 148px; }
.list-field-row.is-after { box-shadow: inset 0 -1px 0 0 var(--accent); }
.list-field-grip {
display: flex;
+ padding: 0;
+ border: 0;
+ background: none;
color: var(--text-faint);
opacity: 0;
cursor: grab;
flex-shrink: 0;
+ touch-action: none;
+}
+.list-field-row:hover .list-field-grip,
+.list-field-row:focus-within .list-field-grip { opacity: 1; }
+.list-field-row .list-field-grip:hover { background: none; }
+.list-field-grip:active { cursor: grabbing; }
+.list-field-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 14px;
+ margin-right: 8px;
+ vertical-align: middle;
+ color: var(--text-dim);
}
-.list-field-row:hover .list-field-grip { opacity: 1; }
/* A button, so `justify-content: center` and the filled background come with
it from the base rule — neither of which a row of text wants. The row draws
its own hover; a second one inside it reads as a control inside a control. */
@@ -756,7 +772,8 @@ html[data-platform='win32'] .titlebar { padding-right: 148px; }
border-radius: 4px;
flex-shrink: 0;
}
-.list-field-row:hover .list-field-remove { opacity: 1; }
+.list-field-row:hover .list-field-remove,
+.list-field-row:focus-within .list-field-remove { opacity: 1; }
.list-field-remove:hover { color: var(--red); background: var(--bg-active); }
/* A button, so it arrives centred from the base rule — which reads as a
caption under the list rather than the next row of it. It is the row after
diff --git a/src/ui/ListFieldRow.tsx b/src/ui/ListFieldRow.tsx
new file mode 100644
index 00000000..136f89b2
--- /dev/null
+++ b/src/ui/ListFieldRow.tsx
@@ -0,0 +1,54 @@
+import type { HTMLAttributes, MouseEventHandler, ReactNode, Ref } from 'react';
+import { DragIcon, TrashIcon } from './Icons';
+
+interface ListFieldRowProps {
+ readonly children: ReactNode;
+ readonly icon?: ReactNode;
+ readonly grip?: ReactNode;
+ readonly className?: string;
+ readonly triggerClassName?: string;
+ readonly rowProps?: HTMLAttributes & { readonly ref?: Ref };
+ readonly expanded?: boolean;
+ readonly disabled: boolean;
+ readonly removeDisabled: boolean;
+ readonly removeLabel: string;
+ readonly onOpen: MouseEventHandler;
+ readonly onRemove: () => void;
+}
+
+// Property definitions and option values share one row so their sizing cannot drift apart.
+export default function ListFieldRow(props: ListFieldRowProps) {
+ return (
+
+ {props.grip ?? (
+
+
+
+ )}
+
+ {props.icon && (
+
+ {props.icon}
+
+ )}
+ {props.children}
+
+
+
+
+
+ );
+}
diff --git a/test/component-properties-panel.test.js b/test/component-properties-panel.test.js
index ecf4273a..8f159d15 100644
--- a/test/component-properties-panel.test.js
+++ b/test/component-properties-panel.test.js
@@ -392,15 +392,38 @@ const { gap = 'small', other = 'large', ...rest } = Astro.props;
Object.getOwnPropertyDescriptor(prototype, 'value').set.call(element, value);
element.dispatchEvent(new window.Event('input', { bubbles: true }));
});
+ const pressDown = async (element) =>
+ act(async () => element.dispatchEvent(new window.MouseEvent('mousedown', { bubbles: true })));
assert.equal(document.querySelector('.property-options input'), null);
await act(async () => document.querySelector('.property-options .list-field-text').click());
const firstOption = document.querySelector('.list-item-field input');
assert.equal(document.activeElement, firstOption);
assert.equal(firstOption.selectionStart, 0);
assert.equal(firstOption.selectionEnd, 'small'.length);
+ await pressDown(firstOption);
+ assert.ok(document.querySelector('.property-editor'));
await setValue(firstOption, 'compact');
await act(async () => document.querySelector('.list-item-editor [title="Close"]').click());
- assert.equal(document.querySelector('[aria-label="Default option"]').value, '"compact"');
+ const defaultLabel = () => document.querySelector('.property-default .dd-label').textContent;
+ assert.equal(document.querySelector('.property-default select'), null);
+ assert.equal(defaultLabel(), 'compact');
+ await act(async () => document.querySelector('.property-default .dd-trigger').click());
+ await setValue(document.querySelector('.dd-search'), 'medium');
+ assert.equal(document.querySelectorAll('.dd-option').length, 1);
+ await pressDown(document.querySelector('.dd-option'));
+ assert.ok(document.querySelector('.property-editor'));
+ await act(async () =>
+ document
+ .querySelector('.dd-option')
+ .dispatchEvent(new window.MouseEvent('mouseover', { bubbles: true }))
+ );
+ assert.equal(defaultLabel(), 'compact');
+ await act(async () => document.querySelector('.dd-option').click());
+ assert.equal(defaultLabel(), 'medium');
+ await act(async () => document.querySelector('.property-default .dd-trigger').click());
+ await setValue(document.querySelector('.dd-search'), 'compact');
+ await act(async () => document.querySelector('.dd-option').click());
+ assert.equal(defaultLabel(), 'compact');
await dragOptions(2, 0);
assert.equal(optionValues()[0], 'large');
await setValue(document.querySelector('.property-editor label input'), 'spacing');
@@ -524,16 +547,88 @@ const { gap = 'small', other = 'large', ...rest } = Astro.props;
await act(async () => document.querySelectorAll('.property-options .list-field-text')[1].click());
await setValue(document.querySelector('.list-item-field input'), 'false');
await key('Escape', {}, document.querySelector('.list-item-field input'));
- assert.equal(document.querySelector('[aria-label="Default option"]').value, 'false');
+ assert.equal(defaultLabel(), 'false');
assert.equal(document.querySelector('.list-item-editor'), null);
+ // Settings use the shared dismissal behavior for app chrome and canvas clicks.
+ // Nested option popovers and dropdowns above remain inside the settings editor.
+ const editsBeforeDismiss = edits.length;
+ await pressDown(document.querySelector('.property-component'));
+ assert.equal(document.querySelector('.property-editor') === null, true);
+ assert.equal(edits.length, editsBeforeDismiss);
+ await act(async () => document.querySelector('.property-row-main').click());
+ await act(async () =>
+ window.dispatchEvent(
+ new window.MessageEvent('message', {
+ data: { type: 'avb:click-node', path: '0' },
+ })
+ )
+ );
+ assert.equal(document.querySelector('.property-editor') === null, true);
+ assert.equal(edits.length, editsBeforeDismiss);
// Unsupported contracts still open property settings rather than raw source.
- data = { ...data, source: 'unsupported contract', advanced: true };
+ data = {
+ ...data,
+ source: 'unsupported contract',
+ advanced: true,
+ properties: data.properties.map((field) => ({
+ ...field,
+ origin: {
+ declarations: [{ label: `Tag.${field.name}`, expression: field.type, line: 3 }],
+ defaultValue: {
+ label: `Astro.props.${field.name}`,
+ expression: field.defaultValue,
+ line: 6,
+ },
+ },
+ })),
+ };
await notify();
await act(async () => document.querySelector('.property-row-main').click());
assert.match(document.body.textContent, /Property settings/);
assert.equal(document.querySelector('[aria-label="Component TypeScript source"]'), null);
- assert.equal(document.querySelector('.property-options .list-field-row').draggable, false);
- assert.equal(document.querySelector('.property-options .list-field-add').disabled, true);
+ assert.equal(document.querySelector('.property-readonly-fields textarea').readOnly, true);
+ assert.equal(document.querySelector('.property-source-tag'), null);
+ assert.match(document.querySelector('.property-declaration-info').title, /Tag\./);
+ assert.match(document.querySelector('.property-declaration-info').title, /line 3/);
+ assert.equal(document.querySelector('[aria-label="Close property settings"]').disabled, false);
+ await pressDown(document.querySelector('.property-declaration-info'));
+ assert.ok(document.querySelector('.property-editor'));
+ await act(async () => document.querySelector('[aria-label="Close property settings"]').click());
+ assert.equal(document.querySelector('.property-editor'), null);
+ await act(async () => document.querySelector('.property-row-main').click());
+ await pressDown(document.querySelector('.property-component'));
+ assert.equal(document.querySelector('.property-editor'), null);
+ assert.equal(edits.length, editsBeforeDismiss);
+ // Common fields in a combined contract use normal editing; only affected props are restricted.
+ data = {
+ ...data,
+ source: 'combined contract',
+ properties: [
+ { ...property, name: 'eyebrow', editing: { kind: 'editable' }, conditions: [] },
+ {
+ ...property,
+ name: 'image',
+ editing: {
+ kind: 'restricted',
+ reason: 'This prop has variant rules. Edit its declaration in source.',
+ },
+ conditions: ['variant = "default": not allowed', 'variant = "cover": optional'],
+ },
+ ],
+ };
+ await notify();
+ assert.equal(document.querySelector('[aria-label="Delete eyebrow"]').disabled, false);
+ assert.equal(document.querySelector('[aria-label="Delete image"]').disabled, true);
+ await act(async () => document.querySelector('.property-row-main').click());
+ assert.equal(document.querySelector('.property-editor fieldset').disabled, false);
+ assert.equal(document.querySelector('.property-readonly-fields'), null);
+ assert.equal(document.querySelector('.property-editor label input').value, 'eyebrow');
+ assert.doesNotMatch(document.body.textContent, /This view is read-only/);
+ await act(async () => document.querySelector('[aria-label="Close property settings"]').click());
+ await act(async () => document.querySelectorAll('.property-row-main')[1].click());
+ assert.match(document.querySelector('.property-conditions').textContent, /default.*not allowed/);
+ assert.match(document.querySelector('.property-readonly-fields').textContent, /variant rules/);
+ assert.equal(document.querySelector('.property-editor fieldset'), null);
let completeRead;
readProperties = () =>
new Promise((resolve) => {
diff --git a/test/component-properties.test.js b/test/component-properties.test.js
index 2a7684db..577e12bb 100644
--- a/test/component-properties.test.js
+++ b/test/component-properties.test.js
@@ -297,6 +297,272 @@ test('failed writes restore all files already written', () => {
});
});
+test('composite props trace their own declarations and literal defaults', () => {
+ const composite = `---
+type Unrelated = { element: number };
+type Tag = { /** Render tag. */ element?: "button" } | { element: "link" };
+type Props = { render?: boolean } & Tag;
+type AllProps = { element?: "button" | "link"; extra?: string };
+const { element = "button", render = true, extra } = Astro.props as AllProps;
+---
+
`;
+ const data = readComponentProperties(composite);
+ assert.equal(data.advanced, true);
+ const element = data.properties.find((field) => field.name === 'element');
+ assert.equal(element.type, '"button" | "link"');
+ assert.equal(element.defaultValue, '"button"');
+ assert.deepEqual(element.origin.declarations, [
+ { label: 'Tag.element', expression: '"button"', line: 3 },
+ { label: 'Tag.element', expression: '"link"', line: 3 },
+ ]);
+ assert.deepEqual(element.origin.defaultValue, {
+ label: 'Astro.props.element',
+ expression: '"button"',
+ line: 6,
+ });
+ assert.equal(
+ data.properties.find((field) => field.name === 'extra').origin.declarations[0].label,
+ 'AllProps.extra'
+ );
+ assert.deepEqual(parseComponentProperties(data), data);
+});
+
+test('source tracing terminates cycles and avoids inventing generic or imported sources', () => {
+ for (const declaration of [
+ 'type Props = Props & { title?: string };',
+ 'type Base = { title?: number }; type Props = Omit ;',
+ 'import type { Props } from "./shared";',
+ ]) {
+ const data = readComponentProperties(
+ `---\n${declaration}\n` + 'const { title = "Hello" } = Astro.props;\n---\n
'
+ );
+ const title = data.properties.find((field) => field.name === 'title');
+ assert.equal(title.origin.declarations.length, declaration.startsWith('type Props') ? 1 : 0);
+ assert.equal(title.origin.defaultValue.expression, '"Hello"');
+ }
+});
+
+test('source metadata rejects malformed and oversized inputs at the contract boundary', () => {
+ const data = readComponentProperties(source);
+ const origin = data.properties[0].origin;
+ for (const invalid of [
+ null,
+ {},
+ { declarations: {} },
+ { declarations: Array(PROPERTY_LIMITS.fieldsMax + 1).fill(origin.declarations[0]) },
+ ...[0, -1, 1.5, '2', PROPERTY_LIMITS.sourceCharsMax + 1].map((line) => ({
+ declarations: [{ label: 'Props.title', expression: 'string', line }],
+ })),
+ { declarations: [{ label: '', expression: 'string', line: 1 }] },
+ { declarations: [{ label: 'Props.title', expression: false, line: 1 }] },
+ { declarations: [], defaultValue: { label: 'Astro.props.title', line: 1 } },
+ ]) {
+ assert.throws(() =>
+ parseComponentProperties({
+ ...data,
+ properties: [{ ...data.properties[0], origin: invalid }],
+ })
+ );
+ }
+});
+
+const compositeCard = `---
+import type { HTMLAttributes } from 'astro/types';
+type Theme = "inherit" | "light" | "dark";
+type Base = HTMLAttributes<"div"> & {
+ /** Small text above the heading. */
+ eyebrow?: string;
+ theme?: Theme;
+ variant?: "default" | "cover" | "stacked";
+ unused?: string;
+};
+type Props = Base & (
+ | { variant?: "default"; image?: never }
+ | { variant: "cover"; image?: string }
+ | { variant: "stacked"; image?: string; theme?: never }
+);
+type AllProps = Base & { image?: string };
+type Caption = Base["eyebrow"];
+type RuntimeCaption = AllProps["eyebrow"];
+type Selected = Pick ;
+type Other = { eyebrow: number };
+type OtherCaption = Other["eyebrow"];
+const { eyebrow, theme = "inherit", variant = "default", image, ...rest } = Astro.props as AllProps;
+---
+{eyebrow} {Astro.props.eyebrow}
`;
+
+test('common props remain editable while variant restrictions stay specific to each prop', () => {
+ const data = readComponentProperties(compositeCard);
+ assert.equal(data.advanced, true);
+ const eyebrow = data.properties.find((field) => field.name === 'eyebrow');
+ assert.deepEqual(eyebrow.editing, { kind: 'editable' });
+ assert.equal(eyebrow.description, 'Small text above the heading.');
+ assert.deepEqual(eyebrow.conditions, []);
+ const image = data.properties.find((field) => field.name === 'image');
+ assert.equal(image.editing.kind, 'restricted');
+ assert.deepEqual(image.conditions, [
+ 'variant = "default": not allowed',
+ 'variant = "cover": optional',
+ 'variant = "stacked": optional',
+ ]);
+ const theme = data.properties.find((field) => field.name === 'theme');
+ assert.equal(theme.editing.kind, 'restricted');
+ assert.equal(theme.conditions.at(-1), 'variant = "stacked": not allowed');
+ assert.equal(
+ editPropertyDefinition(compositeCard, {
+ kind: 'save',
+ originalName: 'image',
+ property: { ...image, editing: { kind: 'editable' } },
+ }).ok,
+ false
+ );
+ assert.deepEqual(parseComponentProperties(data), data);
+});
+
+test('common declaration edits preserve variants, runtime assertions and unrelated types', () => {
+ const eyebrow = readComponentProperties(compositeCard).properties.find(
+ (p) => p.name === 'eyebrow'
+ );
+ const changed = value(
+ editPropertyDefinition(compositeCard, {
+ kind: 'save',
+ originalName: 'eyebrow',
+ property: {
+ ...eyebrow,
+ type: '"Small" | "Large"',
+ defaultValue: '"Small"',
+ required: true,
+ readonly: true,
+ description: 'New tooltip',
+ },
+ })
+ );
+ assert.match(changed, /readonly eyebrow: "Small" \| "Large"/);
+ assert.match(changed, /eyebrow = "Small"/);
+ assert.match(changed, /New tooltip/);
+ const variants = (text) => text.slice(text.indexOf('type Props'), text.indexOf('type AllProps'));
+ assert.equal(variants(changed), variants(compositeCard));
+ assert.match(changed, /Astro.props as AllProps/);
+ assert.match(changed, /type Other = \{ eyebrow: number \}/);
+ const reread = readComponentProperties(changed).properties.find((p) => p.name === 'eyebrow');
+ assert.equal(reread.required, true);
+ assert.equal(reread.readonly, true);
+ assert.equal(reread.description, 'New tooltip');
+ assert.equal(reread.editing.kind, 'editable');
+ const removed = value(editPropertyDefinition(compositeCard, { kind: 'remove', name: 'unused' }));
+ assert.doesNotMatch(removed, /unused\?:/);
+ assert.equal(variants(removed), variants(compositeCard));
+});
+
+test('renaming a common prop updates its instances, runtime binding and related type references', () => {
+ project(({ root, component, page }) => {
+ fs.writeFileSync(component, compositeCard);
+ fs.writeFileSync(
+ page,
+ '---\nimport Card from "../components/Card.astro";\n---\n' +
+ ' '
+ );
+ const eyebrow = readComponentProperties(compositeCard).properties.find(
+ (p) => p.name === 'eyebrow'
+ );
+ const updated = value(
+ updateComponentProperties(
+ {
+ projectPath: root,
+ file: component,
+ source: compositeCard,
+ change: {
+ kind: 'save',
+ originalName: 'eyebrow',
+ property: { ...eyebrow, name: 'kicker' },
+ },
+ },
+ () => {}
+ )
+ );
+ assert.equal(updated.properties.find((p) => p.name === 'kicker').editing.kind, 'editable');
+ const after = fs.readFileSync(component, 'utf8');
+ assert.match(after, /kicker: eyebrow/);
+ assert.match(after, /Astro.props.kicker/);
+ assert.match(after, /Base\["kicker"\]/);
+ assert.match(after, /AllProps\["kicker"\]/);
+ assert.match(after, /Pick /);
+ assert.match(after, /Other\["eyebrow"\]/);
+ assert.match(after, /\{eyebrow\}/);
+ assert.equal(fs.readFileSync(page, 'utf8').match(/kicker=/g).length, 2);
+ });
+});
+
+test('ambiguous common declarations remain restricted without flattening their contracts', () => {
+ for (const declaration of [
+ 'type Props = { eyebrow?: string } & { eyebrow?: "narrow" };',
+ 'type Props = Base; type Base = Props & { eyebrow?: string };',
+ 'type Base = { eyebrow?: T }; type Props = Base;',
+ 'export type Base = { eyebrow?: string }; type Props = Base;',
+ 'import type { Shared } from "./shared"; type Props = Shared & { eyebrow?: string };',
+ 'import type { HTMLAttributes } from "./shared"; ' +
+ 'type Props = HTMLAttributes & { eyebrow?: string };',
+ 'type Props = { eyebrow?: string }; type AllProps = { eyebrow?: string };',
+ ]) {
+ const assertion = declaration.includes('type AllProps') ? ' as AllProps' : '';
+ const source = `---\n${declaration}\nconst { eyebrow } = Astro.props${assertion};\n---\n`;
+ const eyebrow = readComponentProperties(source).properties.find((p) => p.name === 'eyebrow');
+ assert.equal(eyebrow.editing.kind, 'restricted', declaration);
+ assert.equal(
+ editPropertyDefinition(source, {
+ kind: 'save',
+ originalName: 'eyebrow',
+ property: { ...eyebrow, description: 'Changed' },
+ }).ok,
+ false
+ );
+ }
+});
+
+test('common props support aliased Astro attribute imports without changing their aliases', () => {
+ const source = compositeCard
+ .replace('import type { HTMLAttributes }', 'import type { HTMLAttributes as Attributes }')
+ .replace('HTMLAttributes<"div">', 'Attributes<"div">');
+ const eyebrow = readComponentProperties(source).properties.find(
+ (field) => field.name === 'eyebrow'
+ );
+ assert.equal(eyebrow.editing.kind, 'editable');
+ const output = value(
+ editPropertyDefinition(source, {
+ kind: 'save',
+ originalName: 'eyebrow',
+ property: { ...eyebrow, description: 'Changed' },
+ })
+ );
+ assert.match(output, /type Base = Attributes<"div">/);
+ assert.match(output, /Changed/);
+});
+
+test('property permissions and conditions are validated at the boundary', () => {
+ const data = readComponentProperties(compositeCard);
+ const field = data.properties[0];
+ for (const editing of [
+ null,
+ {},
+ { kind: 'restricted' },
+ { kind: 'restricted', reason: '' },
+ { kind: 'restricted', reason: false },
+ { kind: 'other' },
+ ]) {
+ assert.throws(() => parseComponentProperties({ ...data, properties: [{ ...field, editing }] }));
+ }
+ for (const conditions of [
+ 'bad',
+ [42],
+ Array(PROPERTY_LIMITS.fieldsMax + 1).fill('rule'),
+ ['x'.repeat(PROPERTY_LIMITS.textCharsMax + 1)],
+ ]) {
+ assert.throws(() =>
+ parseComponentProperties({ ...data, properties: [{ ...field, conditions }] })
+ );
+ }
+});
+
test('contracts reject malformed payloads and enforce resource bounds', () => {
assert.deepEqual(
parseComponentProperties(readComponentProperties(source)),
From 01ff1d9cf07cda3b4fa966cd5cda7aa054f10334 Mon Sep 17 00:00:00 2001
From: Timothy Ricks
Date: Sun, 20 Sep 2026 07:51:23 -0500
Subject: [PATCH 2/3] v0.1.32
---
electron/componentPreview.ts | 9 +
electron/componentProperties.ts | 116 +++++-
electron/propertyContracts.ts | 106 ++++-
electron/propertyDefinitions.ts | 91 ++++-
electron/propertyRename.ts | 403 ++++++++++++++++++--
package-lock.json | 4 +-
package.json | 4 +-
scripts/{clean-build.ts => clean-build.mts} | 0
scripts/run-tests.ts | 6 +-
scripts/tsconfig.json | 2 +-
shared/component-properties.ts | 66 +++-
shared/property-options.ts | 20 +
src/App.tsx | 56 ++-
src/dataSuggest.ts | 13 +-
src/paletteModel.ts | 20 +
src/panels/CanvasView.tsx | 10 +-
src/panels/ComponentPropertiesPanel.tsx | 8 +-
src/panels/PalettePanel.tsx | 54 ++-
src/panels/PropertyDefault.tsx | 20 +-
src/panels/PropertyEditor.tsx | 160 ++++++--
src/panels/PropertyOptions.tsx | 26 +-
src/panels/componentProperties.css | 8 +
src/panels/propAttributes.tsx | 248 +++++++-----
src/panels/propBindings.tsx | 77 +++-
src/propertyOptions.ts | 21 +-
src/styles.css | 18 +-
src/variablesBridge.ts | 63 ++-
test/component-hover-preview.test.js | 3 +
test/component-preview.test.js | 13 +-
test/component-properties-panel.test.js | 132 ++++++-
test/component-properties.test.js | 336 ++++++++++++++--
test/palette-model.test.js | 18 +
test/palette-preview.test.js | 41 +-
test/preview-lifecycle.test.js | 7 +-
test/prop-attributes.js | 68 +++-
test/scope-completions.js | 9 +
test/vars-panel.js | 25 +-
test/vars-view.js | 7 +
38 files changed, 1930 insertions(+), 358 deletions(-)
rename scripts/{clean-build.ts => clean-build.mts} (100%)
create mode 100644 shared/property-options.ts
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/propertyContracts.ts b/electron/propertyContracts.ts
index cbd47b44..bf08f9fd 100644
--- a/electron/propertyContracts.ts
+++ b/electron/propertyContracts.ts
@@ -24,6 +24,7 @@ interface Contract {
readonly names: ReadonlySet;
readonly branches: readonly (readonly Branch[])[];
readonly complete: boolean;
+ readonly inheritedAttributes: boolean;
}
export interface PropertyContracts {
readonly names: ReadonlySet;
@@ -48,10 +49,12 @@ export function readPropertyContracts(document: PropertySyntax): PropertyContrac
.filter(ts.isAsExpression)
.filter((node) => isAstroProps(node.expression));
const attributes = htmlAttributeImports(document.syntax);
- const publicContract = readContract(declarations.get('Props'), declarations, attributes);
+ const imports = importedTypeNames(document.syntax);
+ const publicContract = readContract(declarations.get('Props'), declarations, attributes, imports);
const runtimeContracts = assertions.map((node) =>
- readContract(node.type, declarations, attributes)
+ readContract(node.type, declarations, attributes, imports)
);
+ const contracts = [publicContract, ...runtimeContracts];
const editable = new Map();
const reasons = new Map();
for (const [name, usages] of publicContract.members) {
@@ -67,12 +70,17 @@ export function readPropertyContracts(document: PropertySyntax): PropertyContrac
assert(editable.size <= PROPERTY_LIMITS.fieldsMax, 'Editable contract fields are bounded');
return {
names: new Set(
- [publicContract, ...runtimeContracts].flatMap((contract) => [...contract.names])
+ contracts.flatMap((contract) => [...contract.names])
),
editable,
editing: (name) =>
editable.has(name)
? { kind: 'editable' }
+ : inheritedAttributeCanOverride(name, contracts)
+ ? {
+ kind: 'override',
+ reason: 'This HTML attribute will be declared locally when you save it.',
+ }
: {
kind: 'restricted',
reason:
@@ -92,6 +100,16 @@ function unavailableContracts(reason: string): PropertyContracts {
};
}
+function inheritedAttributeCanOverride(name: string, contracts: readonly Contract[]): boolean {
+ const publicContract = contracts[0];
+ if (!publicContract?.inheritedAttributes) {
+ return false;
+ }
+ return contracts.every(
+ (contract) => contract.inheritedAttributes && !contract.members.has(name)
+ );
+}
+
function commonMemberRestriction(
name: string,
contract: Contract,
@@ -124,16 +142,19 @@ function commonMemberRestriction(
function readContract(
root: ts.Node | undefined,
declarations: ReadonlyMap,
- attributes: ReadonlySet
+ attributes: ReadonlySet,
+ imports: ReadonlySet
): Contract {
const work: Work[] = root ? [{ node: root, conditional: false, path: [] }] : [];
const members = new Map();
const names = new Set();
const branches: (readonly Branch[])[] = [];
let complete = root !== undefined;
+ let inheritedAttributes = false;
for (let count = 0; count < PROPERTY_LIMITS.nodesMax && work.length > 0; count++) {
const item = work.pop();
assert(item !== undefined, 'Contract traversal has a work item');
+ inheritedAttributes ||= isInheritedAstroAttributes(item.node, declarations, attributes);
if (ts.isPropertySignature(item.node)) {
const name = propertyKey(item.node.name);
if (name) {
@@ -148,7 +169,7 @@ function readContract(
if (ts.isUnionTypeNode(item.node)) {
branches.push(unionBranches(item.node));
}
- const children = contractChildren(item, declarations, attributes);
+ const children = contractChildren(item, declarations, attributes, imports);
if (children === undefined) {
complete = false;
} else {
@@ -159,13 +180,26 @@ function readContract(
}
assert(work.length === 0, 'Contract traversal completes within its budget');
assert(members.size <= PROPERTY_LIMITS.fieldsMax, 'Contract field count is bounded');
- return { members, names, branches, complete };
+ return { members, names, branches, complete, inheritedAttributes };
+}
+
+function isInheritedAstroAttributes(
+ node: ts.Node,
+ declarations: ReadonlyMap,
+ attributes: ReadonlySet
+): boolean {
+ if (!ts.isTypeReferenceNode(node) && !ts.isExpressionWithTypeArguments(node)) {
+ return false;
+ }
+ const name = ts.isTypeReferenceNode(node) ? node.typeName.getText() : node.expression.getText();
+ return attributes.has(name) && !declarations.has(name);
}
function contractChildren(
item: Work,
declarations: ReadonlyMap,
- attributes: ReadonlySet
+ attributes: ReadonlySet,
+ imports: ReadonlySet
): readonly Work[] | undefined {
const { node } = item;
if (ts.isTypeAliasDeclaration(node) || ts.isInterfaceDeclaration(node)) {
@@ -205,12 +239,49 @@ function contractChildren(
if (attributes.has(name) && !declarations.has(name)) {
return [];
}
+ // Omit only narrows known Astro attributes, so it cannot hide another local declaration.
+ if (
+ ts.isTypeReferenceNode(node) &&
+ isOmittedAstroAttributes(node, declarations, attributes, imports)
+ ) {
+ return [];
+ }
const declaration = declarations.get(name);
return declaration && !node.typeArguments ? [{ ...item, node: declaration }] : undefined;
}
return undefined;
}
+function isOmittedAstroAttributes(
+ node: ts.TypeReferenceNode,
+ declarations: ReadonlyMap,
+ attributes: ReadonlySet,
+ imports: ReadonlySet
+): boolean {
+ if (node.typeName.getText() !== 'Omit') {
+ return false;
+ }
+ if (declarations.has('Omit')) {
+ return false;
+ }
+ if (imports.has('Omit')) {
+ return false;
+ }
+ const argumentsList = node.typeArguments;
+ if (argumentsList?.length !== 2) {
+ return false;
+ }
+ const inherited = argumentsList[0];
+ if (!inherited || !ts.isTypeReferenceNode(inherited)) {
+ return false;
+ }
+ const inheritedName = inherited.typeName.getText();
+ if (!attributes.has(inheritedName)) {
+ return false;
+ }
+ return !declarations.has(inheritedName);
+}
+
function htmlAttributeImports(syntax: ts.SourceFile): ReadonlySet {
const names = new Set();
for (const statement of syntax.statements) {
@@ -232,6 +303,27 @@ function htmlAttributeImports(syntax: ts.SourceFile): ReadonlySet {
return names;
}
+function importedTypeNames(syntax: ts.SourceFile): ReadonlySet {
+ const names = new Set();
+ for (const statement of syntax.statements) {
+ if (ts.isImportDeclaration(statement)) {
+ const clause = statement.importClause;
+ if (clause?.name) {
+ names.add(clause.name.text);
+ }
+ const bindings = clause?.namedBindings;
+ if (bindings && ts.isNamespaceImport(bindings)) {
+ names.add(bindings.name.text);
+ } else if (bindings) {
+ for (const specifier of bindings.elements) {
+ names.add(specifier.name.text);
+ }
+ }
+ }
+ }
+ return names;
+}
+
function unionBranches(node: ts.UnionTypeNode): readonly Branch[] {
const fields = node.types.map(branchFields);
const discriminator = [...(fields[0]?.keys() ?? [])].find((name) =>
diff --git a/electron/propertyDefinitions.ts b/electron/propertyDefinitions.ts
index 9c898551..c59a47e1 100644
--- a/electron/propertyDefinitions.ts
+++ b/electron/propertyDefinitions.ts
@@ -171,7 +171,14 @@ function contractEditing(
if (!/^[A-Za-z_$][\w$]*$/.test(name)) {
return { kind: 'restricted', reason: 'This prop uses a quoted name. Edit it in source.' };
}
- return contracts.editing(name);
+ const editing = contracts.editing(name);
+ if (editing.kind === 'override' && !definitions.container) {
+ return {
+ kind: 'restricted',
+ reason: 'This inherited prop has no unambiguous local declaration to update.',
+ };
+ }
+ return editing;
}
export function editPropertyDefinition(source: string, change: PropertyChange): Result {
@@ -189,6 +196,9 @@ export function editPropertyDefinition(source: string, change: PropertyChange):
if (change.kind === 'order') {
return reorderDefinitions(definitions, change.names);
}
+ if (change.kind === 'options') {
+ return saveDefinitionOptions(definitions, change.name, change.type);
+ }
if (change.kind === 'remove') {
return removeDefinition(definitions, change.name);
}
@@ -205,39 +215,46 @@ function editCommonDefinition(
message: 'Add or reorder declarations in this combined type in source.',
});
}
- const name = change.kind === 'remove' ? change.name : change.originalName;
+ const name = change.kind === 'save' ? change.originalName : change.name;
// Renderer permissions are descriptive only; every write rechecks the current source.
const contracts = readPropertyContracts(definitions.document);
const access = contractEditing(definitions, contracts, name);
if (access.kind === 'restricted') {
return err({ code: 'restricted', message: access.reason });
}
+ if (access.kind === 'override') {
+ if (change.kind === 'remove') {
+ return err({ code: 'inherited', message: 'Inherited HTML attributes cannot be deleted.' });
+ }
+ if (change.kind === 'save' && change.property.name !== name) {
+ return err({ code: 'inherited', message: 'Inherited HTML attributes cannot be renamed.' });
+ }
+ return change.kind === 'options'
+ ? saveDefinitionOptions(definitions, name, change.type)
+ : saveDefinition(definitions, name, change.property);
+ }
const member = contracts.editable.get(name);
assert(member !== undefined, 'An editable common property has a declaration');
assert(propertyKey(member.name) === name, 'The edit targets the requested declaration');
const common = { ...definitions, members: [member] };
- return change.kind === 'remove'
- ? removeDefinition(common, name)
- : saveDefinition(common, name, change.property);
+ if (change.kind === 'remove') {
+ return removeDefinition(common, name);
+ }
+ if (change.kind === 'options') {
+ return saveDefinitionOptions(common, name, change.type);
+ }
+ return saveDefinition(common, name, change.property);
}
function readDefinitions(source: string): Definitions {
const document = readPropertySyntax(source);
- const declarations = document.syntax.statements.filter((statement) => {
- if (ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement)) {
- return statement.name.text === 'Props';
- }
- return false;
- });
+ const declarations = document.syntax.statements.filter(
+ (statement): statement is ts.InterfaceDeclaration | ts.TypeAliasDeclaration =>
+ (ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement)) &&
+ statement.name.text === 'Props'
+ );
const declaration = declarations[0];
- const container =
- declaration && ts.isInterfaceDeclaration(declaration)
- ? declaration
- : declaration &&
- ts.isTypeAliasDeclaration(declaration) &&
- ts.isTypeLiteralNode(declaration.type)
- ? declaration.type
- : undefined;
+ const container = definitionContainer(declaration);
const members = container?.members.filter(ts.isPropertySignature) ?? [];
const patterns = syntaxNodes(document.syntax)
.filter(ts.isVariableDeclaration)
@@ -262,7 +279,9 @@ function readDefinitions(source: string): Definitions {
(declaration !== undefined &&
ts.isTypeAliasDeclaration(declaration) &&
declaration.typeParameters !== undefined) ||
- (declaration !== undefined && container === undefined) ||
+ (declaration !== undefined &&
+ ts.isTypeAliasDeclaration(declaration) &&
+ !ts.isTypeLiteralNode(declaration.type)) ||
(container !== undefined && container.members.length !== members.length) ||
(container !== undefined &&
ts.isInterfaceDeclaration(container) &&
@@ -281,6 +300,22 @@ function readDefinitions(source: string): Definitions {
};
}
+function definitionContainer(
+ declaration: ts.InterfaceDeclaration | ts.TypeAliasDeclaration | undefined
+): ts.InterfaceDeclaration | ts.TypeLiteralNode | undefined {
+ if (!declaration || ts.isInterfaceDeclaration(declaration)) {
+ return declaration;
+ }
+ if (ts.isTypeLiteralNode(declaration.type)) {
+ return declaration.type;
+ }
+ if (!ts.isIntersectionTypeNode(declaration.type)) {
+ return undefined;
+ }
+ const literals = declaration.type.types.filter(ts.isTypeLiteralNode);
+ return literals.length === 1 ? literals[0] : undefined;
+}
+
function readDescription(member: ts.PropertySignature, source: string): string {
const leading = source.slice(member.getFullStart(), member.getStart());
const match = /\/\*\*([\s\S]*?)\*\//.exec(leading);
@@ -365,6 +400,22 @@ function saveDefinition(
return ok(output);
}
+function saveDefinitionOptions(
+ definitions: Definitions,
+ name: string,
+ type: string
+): Result {
+ const property = readComponentProperties(definitions.document.source).properties.find(
+ (field) => field.name === name
+ );
+ if (!property) {
+ return err({ code: 'missing', message: 'This property no longer exists. Reload the panel.' });
+ }
+ assert(property.name === name, 'Option reorder targets the requested property');
+ assert(type.length <= PROPERTY_LIMITS.textCharsMax, 'Option type is bounded');
+ return saveDefinition(definitions, name, { ...property, type });
+}
+
function renderSavedDefinition(
definitions: Definitions,
member: ts.PropertySignature,
diff --git a/electron/propertyRename.ts b/electron/propertyRename.ts
index aecf3f9e..1f15e09e 100644
--- a/electron/propertyRename.ts
+++ b/electron/propertyRename.ts
@@ -6,6 +6,7 @@ import ts from 'typescript';
import { readPropertyContracts } from './propertyContracts';
import { assert } from '../shared/assert';
import { PROPERTY_LIMITS } from '../shared/component-properties';
+import type { PropertyOptionRename } from '../shared/component-properties';
import { err, ok, type Result } from '../shared/result';
import {
applySourceEdits,
@@ -21,6 +22,16 @@ export interface PropertyRename {
readonly to: string;
}
+type OptionScalar =
+ | { readonly kind: 'string'; readonly value: string }
+ | { readonly kind: 'number'; readonly value: number }
+ | { readonly kind: 'boolean'; readonly value: boolean };
+interface ParsedOptionRename {
+ readonly from: OptionScalar;
+ readonly to: OptionScalar;
+ readonly replacement: string;
+}
+
type PropertyReferenceEdit =
| {
readonly kind: 'rename';
@@ -54,6 +65,281 @@ export function renameComponentReferences(
);
}
+export function renameComponentOptionValues(
+ source: string,
+ names: ReadonlySet,
+ propertyName: string,
+ renames: readonly PropertyOptionRename[]
+): Result {
+ const parsed = parseOptionRenames(renames);
+ if (!parsed.ok) {
+ return parsed;
+ }
+ const root = parseAstroRenameSource(source);
+ if (!root.ok) {
+ return root;
+ }
+ const edits: SourceEdit[] = [];
+ const pending: Node[] = [root.value];
+ for (let index = 0; index < pending.length; index += 1) {
+ assert(pending.length <= PROPERTY_LIMITS.nodesMax, 'Astro option rename node budget');
+ const node = pending[index];
+ assert(node !== undefined, 'Queued Astro option node exists');
+ const result = renameNodeOptions(source, node, names, propertyName, parsed.value);
+ if (!result.ok) {
+ return result;
+ }
+ edits.push(...result.value);
+ if ('children' in node) {
+ if (node.type === 'element' && ['script', 'style'].includes(node.name)) {
+ continue;
+ }
+ pending.push(...node.children);
+ }
+ }
+ return ok(applySourceEdits(source, edits));
+}
+
+function parseAstroRenameSource(source: string): Result {
+ try {
+ const result = parse(source, { position: true });
+ const diagnostic = result.diagnostics.find((entry) => entry.severity === 1);
+ return diagnostic ? err({ code: 'syntax', message: diagnostic.text }) : ok(result.ast);
+ } catch (error: unknown) {
+ return err({ code: 'syntax', message: String(error) });
+ }
+}
+
+function parseOptionRenames(
+ renames: readonly PropertyOptionRename[]
+): Result {
+ assert(renames.length <= PROPERTY_LIMITS.fieldsMax, 'Option rename count is bounded');
+ const parsed: ParsedOptionRename[] = [];
+ const sources = new Set();
+ for (const rename of renames) {
+ const from = optionScalar(rename.from);
+ const to = optionScalar(rename.to);
+ if (!from || !to) {
+ return err({
+ code: 'option',
+ message: 'Option renames require literal values.',
+ });
+ }
+ const key = optionScalarKey(from);
+ if (sources.has(key)) {
+ return err({
+ code: 'option',
+ message: 'An option value is renamed more than once.',
+ });
+ }
+ sources.add(key);
+ parsed.push({ from, to, replacement: rename.to });
+ }
+ return ok(parsed);
+}
+
+function renameNodeOptions(
+ source: string,
+ node: Node,
+ names: ReadonlySet,
+ propertyName: string,
+ renames: readonly ParsedOptionRename[]
+): Result {
+ if (!('attributes' in node) || node.type !== 'component' || !names.has(node.name)) {
+ return ok([]);
+ }
+ const edits: SourceEdit[] = [];
+ for (const attribute of node.attributes) {
+ const result = renameOptionAttribute(source, attribute, propertyName, renames);
+ if (!result.ok) {
+ return result;
+ }
+ edits.push(...result.value);
+ }
+ return ok(edits);
+}
+
+function renameOptionAttribute(
+ source: string,
+ attribute: AttributeNode,
+ propertyName: string,
+ renames: readonly ParsedOptionRename[]
+): Result {
+ const start = offset(source, attribute);
+ if (attribute.kind === 'spread') {
+ return renameOptionSpread(source, attribute, propertyName, renames, start);
+ }
+ if (attribute.name !== propertyName) {
+ return ok([]);
+ }
+ if (attribute.kind === 'quoted') {
+ return renameQuotedOption(source, attribute, renames, start);
+ }
+ if (attribute.kind !== 'expression' && attribute.kind !== 'template-literal') {
+ return ok([]);
+ }
+ const position = source.indexOf(attribute.value, start + attribute.name.length);
+ if (position < 0) {
+ return positionError();
+ }
+ return ok(renameOptionExpression(attribute.value, position, renames));
+}
+
+function renameQuotedOption(
+ source: string,
+ attribute: AttributeNode,
+ renames: readonly ParsedOptionRename[],
+ start: number
+): Result {
+ const rename = renames.find(
+ (entry) => entry.from.kind === 'string' && entry.from.value === attribute.value
+ );
+ if (!rename) {
+ return ok([]);
+ }
+ const raw = attribute.raw;
+ if (raw === undefined) {
+ return positionError();
+ }
+ const position = source.indexOf(raw, start + attribute.name.length);
+ if (position < 0) {
+ return positionError();
+ }
+ const text =
+ rename.to.kind === 'string' ? JSON.stringify(rename.to.value) : `{${rename.replacement}}`;
+ return ok([{ start: position, end: position + raw.length, text }]);
+}
+
+function renameOptionSpread(
+ source: string,
+ attribute: AttributeNode,
+ propertyName: string,
+ renames: readonly ParsedOptionRename[],
+ position: number
+): Result {
+ const prefix = 'const spread = (';
+ const syntax = ts.createSourceFile(
+ 'spread.ts',
+ prefix + attribute.name + ');',
+ ts.ScriptTarget.Latest,
+ true,
+ ts.ScriptKind.TS
+ );
+ const object = syntaxNodes(syntax).find(ts.isObjectLiteralExpression);
+ if (!object || optionSpreadIsDynamic(object)) {
+ return err({
+ code: 'spread',
+ message:
+ 'An instance uses a dynamic prop spread. ' +
+ 'Make its props explicit before renaming this option.',
+ });
+ }
+ const start = source.indexOf(attribute.name, Math.max(0, position - attribute.name.length - 4));
+ if (start < 0 || start > position + attribute.name.length + 4) {
+ return positionError();
+ }
+ const edits: SourceEdit[] = [];
+ for (const property of object.properties) {
+ if (!ts.isPropertyAssignment(property) || propertyKey(property.name) !== propertyName) {
+ continue;
+ }
+ edits.push(...renameOptionExpressionNode(property.initializer, start - prefix.length, renames));
+ }
+ return ok(edits);
+}
+
+function optionSpreadIsDynamic(object: ts.ObjectLiteralExpression): boolean {
+ return object.properties.some(
+ (property) =>
+ ts.isSpreadAssignment(property) ||
+ (property.name !== undefined && ts.isComputedPropertyName(property.name))
+ );
+}
+
+function renameOptionExpression(
+ expression: string,
+ start: number,
+ renames: readonly ParsedOptionRename[]
+): readonly SourceEdit[] {
+ const prefix = 'const value = (';
+ const syntax = ts.createSourceFile(
+ 'option.ts',
+ prefix + expression + ');',
+ ts.ScriptTarget.Latest,
+ true,
+ ts.ScriptKind.TSX
+ );
+ const declaration = syntaxNodes(syntax).find(ts.isVariableDeclaration);
+ const initializer = declaration?.initializer;
+ assert(initializer !== undefined, 'Option expression has an initializer');
+ const value = ts.isParenthesizedExpression(initializer) ? initializer.expression : initializer;
+ return renameOptionExpressionNode(value, start - prefix.length, renames);
+}
+
+function renameOptionExpressionNode(
+ expression: ts.Expression,
+ start: number,
+ renames: readonly ParsedOptionRename[]
+): readonly SourceEdit[] {
+ const edits: SourceEdit[] = [];
+ for (const node of syntaxNodes(expression)) {
+ if (ts.isNumericLiteral(node) && ts.isPrefixUnaryExpression(node.parent)) {
+ continue;
+ }
+ const scalar = optionScalarNode(node);
+ const rename = scalar
+ ? renames.find((entry) => optionScalarKey(entry.from) === optionScalarKey(scalar))
+ : undefined;
+ if (rename) {
+ edits.push({
+ start: start + node.getStart(),
+ end: start + node.end,
+ text: rename.replacement,
+ });
+ }
+ }
+ return edits;
+}
+
+function optionScalar(expression: string): OptionScalar | undefined {
+ const prefix = 'const value = (';
+ const syntax = ts.createSourceFile(
+ 'literal.ts',
+ prefix + expression + ');',
+ ts.ScriptTarget.Latest,
+ true,
+ ts.ScriptKind.TS
+ );
+ const declaration = syntaxNodes(syntax).find(ts.isVariableDeclaration);
+ const initializer = declaration?.initializer;
+ if (!initializer || !ts.isParenthesizedExpression(initializer)) {
+ return undefined;
+ }
+ return optionScalarNode(initializer.expression);
+}
+
+function optionScalarNode(node: ts.Node): OptionScalar | undefined {
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
+ return { kind: 'string', value: node.text };
+ }
+ if (ts.isNumericLiteral(node)) {
+ const value = Number(node.text);
+ return Number.isFinite(value) ? { kind: 'number', value } : undefined;
+ }
+ if (ts.isPrefixUnaryExpression(node) && node.operator === ts.SyntaxKind.MinusToken) {
+ const value = ts.isNumericLiteral(node.operand) ? -Number(node.operand.text) : Number.NaN;
+ return Number.isFinite(value) ? { kind: 'number', value } : undefined;
+ }
+ if (node.kind === ts.SyntaxKind.TrueKeyword || node.kind === ts.SyntaxKind.FalseKeyword) {
+ return { kind: 'boolean', value: node.kind === ts.SyntaxKind.TrueKeyword };
+ }
+ return undefined;
+}
+
+function optionScalarKey(value: OptionScalar): string {
+ return `${value.kind}:${String(value.value)}`;
+}
+
export function bindComponentDefault(
source: string,
name: string,
@@ -113,6 +399,38 @@ function editComponentReferences(
}
edits.push(...result.value);
}
+ const nodes = editAstroNodes(source, root, names, rename, owner);
+ if (!nodes.ok) {
+ return nodes;
+ }
+ edits.push(...nodes.value);
+ if (rename.kind === 'default' && edits.length > 0) {
+ if (edits.some((edit) => edit.start < rename.declarationEnd)) {
+ return err({
+ code: 'default-order',
+ message:
+ 'A prop read precedes its default. ' +
+ 'Move the Astro.props binding before that read in source.',
+ });
+ }
+ // A unique alias prevents a template loop variable from shadowing the defaulted prop.
+ edits.push({
+ start: rename.declarationEnd,
+ end: rename.declarationEnd,
+ text: `\nconst ${rename.binding} = ${rename.local};`,
+ });
+ }
+ return ok(applySourceEdits(source, edits));
+}
+
+function editAstroNodes(
+ source: string,
+ root: Node,
+ names: ReadonlySet,
+ rename: PropertyReferenceEdit,
+ owner: 'definition' | 'consumer'
+): Result {
+ const edits: SourceEdit[] = [];
const pending: { readonly node: Node; readonly expression: boolean }[] = [
{ node: root, expression: false },
];
@@ -130,27 +448,14 @@ function editComponentReferences(
continue;
}
for (const child of entry.node.children) {
- pending.push({ node: child, expression: entry.node.type === 'expression' });
+ pending.push({
+ node: child,
+ expression: entry.node.type === 'expression',
+ });
}
}
}
- if (rename.kind === 'default' && edits.length > 0) {
- if (edits.some((edit) => edit.start < rename.declarationEnd)) {
- return err({
- code: 'default-order',
- message:
- 'A prop read precedes its default. ' +
- 'Move the Astro.props binding before that read in source.',
- });
- }
- // A unique alias prevents a template loop variable from shadowing the defaulted prop.
- edits.push({
- start: rename.declarationEnd,
- end: rename.declarationEnd,
- text: `\nconst ${rename.binding} = ${rename.local};`,
- });
- }
- return ok(applySourceEdits(source, edits));
+ return ok(edits);
}
function renameNode(
@@ -169,7 +474,10 @@ function renameNode(
rename.kind === 'rename' &&
node.attributes.some((attribute) => attribute.name === rename.to)
) {
- return err({ code: 'collision', message: `An instance already passes ${rename.to}.` });
+ return err({
+ code: 'collision',
+ message: `An instance already passes ${rename.to}.`,
+ });
}
for (const attribute of node.attributes) {
if (target && rename.kind === 'rename') {
@@ -255,27 +563,8 @@ function renameSpread(
position: number
): Result {
const prefix = 'const spread = (';
- const syntax = ts.createSourceFile(
- 'spread.ts',
- prefix + attribute.name + ');',
- ts.ScriptTarget.Latest,
- true,
- ts.ScriptKind.TS
- );
- const object = syntaxNodes(syntax).find(ts.isObjectLiteralExpression);
- const variable = syntaxNodes(syntax).find(ts.isVariableDeclaration);
- const expression = variable?.initializer;
- if (
- !object ||
- !expression ||
- !ts.isParenthesizedExpression(expression) ||
- expression.expression !== object ||
- object.properties.some(
- (property) =>
- ts.isSpreadAssignment(property) ||
- (property.name !== undefined && ts.isComputedPropertyName(property.name))
- )
- ) {
+ const object = literalSpreadObject(attribute.name, prefix);
+ if (!object) {
return err({
code: 'spread',
message:
@@ -288,7 +577,10 @@ function renameSpread(
return positionError();
}
if (object.properties.some((property) => propertyKey(property.name) === rename.to)) {
- return err({ code: 'collision', message: `A spread already contains ${rename.to}.` });
+ return err({
+ code: 'collision',
+ message: `A spread already contains ${rename.to}.`,
+ });
}
const edits: SourceEdit[] = [];
for (const property of object.properties) {
@@ -309,6 +601,29 @@ function renameSpread(
return ok(edits);
}
+function literalSpreadObject(
+ expression: string,
+ prefix: string
+): ts.ObjectLiteralExpression | undefined {
+ const syntax = ts.createSourceFile(
+ 'spread.ts',
+ prefix + expression + ');',
+ ts.ScriptTarget.Latest,
+ true,
+ ts.ScriptKind.TS
+ );
+ const nodes = syntaxNodes(syntax);
+ const object = nodes.find(ts.isObjectLiteralExpression);
+ const initializer = nodes.find(ts.isVariableDeclaration)?.initializer;
+ if (!object || !initializer || !ts.isParenthesizedExpression(initializer)) {
+ return undefined;
+ }
+ if (initializer.expression !== object || optionSpreadIsDynamic(object)) {
+ return undefined;
+ }
+ return object;
+}
+
function renameAstroAccess(
source: string,
start: number,
@@ -375,7 +690,11 @@ function astroReferenceEdit(
change: PropertyReferenceEdit
): SourceEdit {
if (change.kind === 'default') {
- return { start: start + access.getStart(), end: start + access.end, text: change.binding };
+ return {
+ start: start + access.getStart(),
+ end: start + access.end,
+ text: change.binding,
+ };
}
return {
start: start + key.getStart(),
diff --git a/package-lock.json b/package-lock.json
index dbd3b1cd..4e36ba34 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "stacki",
- "version": "0.1.31",
+ "version": "0.1.32",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "stacki",
- "version": "0.1.31",
+ "version": "0.1.32",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
diff --git a/package.json b/package.json
index 8f84c5c3..b7195d68 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "stacki",
"productName": "Stacki",
- "version": "0.1.31",
+ "version": "0.1.32",
"description": "Stacki — a visual builder for Astro projects — pages, layouts, components, props, live preview, and GitHub publishing.",
"main": "dist/electron/main.js",
"author": "Timothy Ricks ",
@@ -174,7 +174,7 @@
"test:vitemodules": "node --experimental-vm-modules --test test/vite-modules.test.ts",
"performance:report": "npm run build:runtime && node dist/scripts/performance-report.js",
"integration:dev": "node test/integration/dev-lifecycle.js",
- "build:clean": "node --experimental-transform-types scripts/clean-build.ts",
+ "build:clean": "node --disable-warning=ExperimentalWarning --experimental-transform-types scripts/clean-build.mts",
"build:renderer-runtime": "npm run build:morph && npm run build:preload && npm run build:assets",
"build:morph": "tsc -p electron/tsconfig.morph.json",
"build:preload": "tsc -p electron/tsconfig.preload.json",
diff --git a/scripts/clean-build.ts b/scripts/clean-build.mts
similarity index 100%
rename from scripts/clean-build.ts
rename to scripts/clean-build.mts
diff --git a/scripts/run-tests.ts b/scripts/run-tests.ts
index 716172ca..6c0358d5 100644
--- a/scripts/run-tests.ts
+++ b/scripts/run-tests.ts
@@ -59,7 +59,11 @@ const staticGates: readonly GateCommand[] = [
[
'build:clean',
node,
- ['--experimental-transform-types', path.join(root, 'scripts', 'clean-build.ts')],
+ [
+ '--disable-warning=ExperimentalWarning',
+ '--experimental-transform-types',
+ path.join(root, 'scripts', 'clean-build.mts'),
+ ],
],
['build:contracts', node, [typeScript, '-p', path.join('shared', 'tsconfig.json')]],
['build:electron', node, [typeScript, '-p', path.join('electron', 'tsconfig.json')]],
diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json
index 9daabc89..52c52591 100644
--- a/scripts/tsconfig.json
+++ b/scripts/tsconfig.json
@@ -19,5 +19,5 @@
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
},
- "include": ["*.ts"]
+ "include": ["*.ts", "*.mts"]
}
diff --git a/shared/component-properties.ts b/shared/component-properties.ts
index 86e8cf9a..5203c2e8 100644
--- a/shared/component-properties.ts
+++ b/shared/component-properties.ts
@@ -27,6 +27,7 @@ export interface ComponentProperty {
}
export type PropertyEditing =
| { readonly kind: 'editable' }
+ | { readonly kind: 'override'; readonly reason: string }
| { readonly kind: 'restricted'; readonly reason: string };
export interface PropertySource {
readonly label: string;
@@ -43,10 +44,20 @@ export interface ComponentProperties {
readonly frontmatter: string;
readonly advanced: boolean;
}
+export interface PropertyOptionRename {
+ readonly from: string;
+ readonly to: string;
+}
export type PropertyChange =
- | { readonly kind: 'save'; readonly originalName: string; readonly property: ComponentProperty }
+ | {
+ readonly kind: 'save';
+ readonly originalName: string;
+ readonly property: ComponentProperty;
+ readonly optionRenames?: readonly PropertyOptionRename[];
+ }
| { readonly kind: 'remove'; readonly name: string }
| { readonly kind: 'order'; readonly names: readonly string[] }
+ | { readonly kind: 'options'; readonly name: string; readonly type: string }
| { readonly kind: 'source'; readonly frontmatter: string };
export function propertyText(input: unknown): string {
@@ -92,10 +103,10 @@ function parsePropertyEditing(input: unknown): PropertyEditing {
if (value?.['kind'] === 'editable') {
return { kind: 'editable' };
}
- if (value?.['kind'] === 'restricted') {
+ if (value?.['kind'] === 'override' || value?.['kind'] === 'restricted') {
const reason = propertyText(value['reason']);
if (reason.trim()) {
- return { kind: 'restricted', reason };
+ return { kind: value['kind'], reason };
}
}
throw new Error('Invalid property editing permission');
@@ -109,7 +120,11 @@ function parsePropertyOrigin(input: unknown): PropertyOrigin {
}
function parsePropertySource(input: unknown): PropertySource {
- const source = object({ label: propertyText, expression: propertyText, line: count })(input);
+ const source = object({
+ label: propertyText,
+ expression: propertyText,
+ line: count,
+ })(input);
if (!source.label.trim()) {
throw new Error('Property source label must be nonempty');
}
@@ -128,21 +143,52 @@ export function propertyList(input: unknown, parse: (input: unknown) => T): r
export function parsePropertyChange(input: unknown): PropertyChange {
const value = toRecord(input);
switch (value?.['kind']) {
- case 'save':
- return {
+ case 'save': {
+ const saved = {
kind: 'save',
- ...object({ originalName: propertyText, property: parseEditableProperty })(input),
- };
+ ...object({
+ originalName: propertyText,
+ property: parseEditableProperty,
+ })(input),
+ } as const;
+ const optionRenames = optional((item) => propertyList(item, parsePropertyOptionRename))(
+ value['optionRenames']
+ );
+ return optionRenames === undefined ? saved : { ...saved, optionRenames };
+ }
case 'remove':
return { kind: 'remove', name: propertyName(value['name']) };
case 'order':
- return { kind: 'order', names: propertyList(value['names'], propertyName) };
+ return {
+ kind: 'order',
+ names: propertyList(value['names'], propertyName),
+ };
+ case 'options':
+ return {
+ kind: 'options',
+ name: propertyName(value['name']),
+ type: propertyText(value['type']),
+ };
case 'source':
- return { kind: 'source', frontmatter: propertySource(value['frontmatter']) };
+ return {
+ kind: 'source',
+ frontmatter: propertySource(value['frontmatter']),
+ };
default:
throw new Error('Unknown component property change');
}
}
+
+function parsePropertyOptionRename(input: unknown): PropertyOptionRename {
+ const rename = object({ from: propertyText, to: propertyText })(input);
+ if (!rename.from.trim() || !rename.to.trim()) {
+ throw new Error('Property option rename values must be nonempty');
+ }
+ if (rename.from === rename.to) {
+ throw new Error('Property option rename must change the value');
+ }
+ return rename;
+}
export function parseComponentProperties(input: unknown): ComponentProperties {
return object({
source: propertySource,
diff --git a/shared/property-options.ts b/shared/property-options.ts
new file mode 100644
index 00000000..db422e72
--- /dev/null
+++ b/shared/property-options.ts
@@ -0,0 +1,20 @@
+import { PROPERTY_LIMITS } from './component-properties';
+
+// Only literal unions expose option controls. Complex unions remain in the type editor.
+export function literalOptions(type: string): readonly string[] | undefined {
+ const tokens = type.match(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|-?\d+(?:\.\d+)?|true|false/g);
+ if (!tokens || tokens.length < 1 || tokens.length > PROPERTY_LIMITS.fieldsMax) {
+ return undefined;
+ }
+ const remaining = type.replace(
+ /"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|-?\d+(?:\.\d+)?|true|false/g,
+ ''
+ );
+ if (!/^\s*(?:\|\s*)*$/.test(remaining)) {
+ return undefined;
+ }
+ if ((remaining.match(/\|/g)?.length ?? 0) !== tokens.length - 1) {
+ return undefined;
+ }
+ return tokens;
+}
diff --git a/src/App.tsx b/src/App.tsx
index 17feb24c..5645ca1f 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -326,6 +326,31 @@ function outermostNode(nodes: readonly PageNode[] | null | undefined): PageNode
return list.find((n) => n.kind === 'element' || n.kind === 'component') || list[0] || null;
}
+interface OpenFileOptions {
+ readonly nextStack: SetStateAction;
+ readonly selectionPath: string | null;
+}
+
+// Returning from a component should land on the instance that opened it. The
+// path is stored with the child stack entry because node ids can change when
+// the parent file is parsed again during the return trip.
+function openFileSelection(
+ entry: OpenFile,
+ result: EditorPageState,
+ selectionPath: string | null,
+): PageNode | null {
+ if (!result.editable) {return null;}
+ if (selectionPath) {
+ const localPath = selectionPath.split('|').at(-1);
+ assert(localPath !== undefined, 'A stored component path must have a local path');
+ const selected = nodeAtPath(result.model.nodes, localPath.split('.').map(Number));
+ if (selected) {return selected;}
+ }
+ return entry.kind === 'component'
+ ? openingSelection(result.model.nodes)
+ : outermostNode(result.model.nodes);
+}
+
function collectUsedNames(model: PageModel): Set {
const used = new Set();
const walk = (list: readonly PageNode[]): void => {
@@ -965,7 +990,7 @@ export default function App() {
// `currentPage` is simply whatever is being edited, so saving, undo, the
// navigator, and the props panel all follow without special cases.
const openFile = useCallback(
- async (entry: OpenFile, nextStack: SetStateAction | undefined) => {
+ async (entry: OpenFile, options: OpenFileOptions) => {
const request = ++pageLoadRef.current;
try {
await flushSave();
@@ -1006,15 +1031,11 @@ export default function App() {
// Publish path, model and stack together. Clearing the model first would
// unmount the inspector and resize the whole preview during every drill.
pageStateRef.current = { currentPage: entry, pageState: nextState };
- if (nextStack) {setEditStack(nextStack);}
+ setEditStack(options.nextStack);
setCurrentPage(entry);
setPageState(nextState);
setHoverNodeId(null);
- const start = result.editable
- ? entry.kind === 'component'
- ? openingSelection(result.model.nodes)
- : outermostNode(result.model.nodes)
- : null;
+ const start = openFileSelection(entry, result, options.selectionPath);
setSelectedId(start?.id ?? null);
dropPageHistory(); // page snapshots don't apply to another page; commands stay
},
@@ -1030,7 +1051,7 @@ export default function App() {
async (page: ScanResult['pages'][number]) => {
// Opening a page from the switcher leaves any component drill-down.
const entry: OpenFile = { ...page, kind: 'page' };
- await openFile(entry, [entry]);
+ await openFile(entry, { nextStack: [entry], selectionPath: null });
},
[openFile]
);
@@ -1106,7 +1127,7 @@ export default function App() {
const next = result.pages[0] || null;
if (next) {
const entry: OpenFile = { ...next, kind: 'page' };
- await openFile(entry, [entry]);
+ await openFile(entry, { nextStack: [entry], selectionPath: null });
} else {
setEditStack([]);
setCurrentPage(null);
@@ -1208,9 +1229,13 @@ export default function App() {
focusWhole,
hostKey: hostPath ?? null,
};
- await openFile(entry, (s) =>
- s.some((e) => e.path === comp.path) ? s : [...s, entry]
- );
+ await openFile(entry, {
+ nextStack: (stackBeforeOpen) =>
+ stackBeforeOpen.some((openEntry) => openEntry.path === comp.path)
+ ? stackBeforeOpen
+ : [...stackBeforeOpen, entry],
+ selectionPath: null,
+ });
},
[scan.components, scan.layouts, openFile, showToast]
);
@@ -1219,10 +1244,15 @@ export default function App() {
const closeComponent = useCallback(async () => {
const stack = editStackRef.current;
if (stack.length < 2) {return;}
+ const closing = stack.at(-1);
+ assert(closing, 'Component stack must contain the component being closed');
const next = stack.slice(0, -1);
const parent = next.at(-1);
assert(parent, 'Component stack must retain its parent');
- await openFile(parent, next);
+ await openFile(parent, {
+ nextStack: next,
+ selectionPath: closing.hostKey ?? null,
+ });
}, [openFile]);
// ----------------------------------------------------------------
diff --git a/src/dataSuggest.ts b/src/dataSuggest.ts
index 48c2f24e..74dcafc7 100644
--- a/src/dataSuggest.ts
+++ b/src/dataSuggest.ts
@@ -908,6 +908,16 @@ export interface ScopeChip {
readonly path: string;
}
+const TYPE_OPERATORS = new Set(['as', 'satisfies']);
+
+function isTypeSyntaxIdentifier(source: string, from: number, root: string): boolean {
+ if (TYPE_OPERATORS.has(root)) {
+ return true;
+ }
+ const before = source.slice(0, from).trimEnd();
+ return /(?:^|[^\w$])(?:as|satisfies)$/.test(before);
+}
+
/**
* The names an expression NAMES, as ranges, so a field can draw them as chips.
*
@@ -970,7 +980,8 @@ export function scopeChips(text: unknown, names: Iterable | Set
// Not a property of something else — the `data` in `post.data` is part of
// that chip, not one of its own.
const before = src.slice(0, i).trimEnd();
- if (!before.endsWith('.') && inScope.has(path.split('.')[0] ?? '')) {
+ const root = path.split('.')[0] ?? '';
+ if (!before.endsWith('.') && inScope.has(root) && !isTypeSyntaxIdentifier(src, i, root)) {
out.push({ from: i, to: end, path });
}
i = j;
diff --git a/src/paletteModel.ts b/src/paletteModel.ts
index 52e968ee..ec01aa8f 100644
--- a/src/paletteModel.ts
+++ b/src/paletteModel.ts
@@ -13,6 +13,26 @@ export type ComponentUsageResult =
| { readonly kind: 'error'; readonly message: string }
| { readonly kind: 'ready'; readonly files: readonly ComponentUsageFile[] };
+export interface ComponentPreviewMessage {
+ readonly status: 'empty' | 'ready';
+}
+
+export function parseComponentPreviewMessage(input: unknown): ComponentPreviewMessage | undefined {
+ if (typeof input !== 'object' || input === null) {
+ return undefined;
+ }
+ if (!('type' in input) || input.type !== 'avb:component-preview') {
+ return undefined;
+ }
+ if (!('status' in input)) {
+ return undefined;
+ }
+ if (input.status === 'empty' || input.status === 'ready') {
+ return { status: input.status };
+ }
+ return undefined;
+}
+
export function parseComponentUsage(input: unknown): ComponentUsageResult {
const value = record(input);
const error = optional(text)(value['error']);
diff --git a/src/panels/CanvasView.tsx b/src/panels/CanvasView.tsx
index 6e779809..9b29889a 100644
--- a/src/panels/CanvasView.tsx
+++ b/src/panels/CanvasView.tsx
@@ -9,6 +9,7 @@ const BREAKPOINTS = [
const GAP_PX = 120;
const PAGE_HEIGHT_PX_MAX = 30_000;
const PAGE_HEIGHT_PX_MIN = 200;
+const PAGE_HEIGHT_VIEWPORTS_MAX = 1.25;
const ZOOM_MIN = 0.05;
const ZOOM_MAX = 4;
type BreakpointKey = (typeof BREAKPOINTS)[number]['key'];
@@ -118,10 +119,17 @@ function useCanvasPointer(
function layoutFrames(heights: Partial>) {
let x = 0;
return BREAKPOINTS.map((breakpoint) => {
+ // The overview cannot know the eventual browser viewport, and stretching
+ // its iframe can make vh-based pages report their own new frame height.
+ // One and a quarter screens preserve context while tightly bounding that loop.
+ const heightMax = Math.min(
+ PAGE_HEIGHT_PX_MAX,
+ breakpoint.viewportHeight * PAGE_HEIGHT_VIEWPORTS_MAX,
+ );
const height = clamp(
heights[breakpoint.key] ?? breakpoint.viewportHeight,
PAGE_HEIGHT_PX_MIN,
- PAGE_HEIGHT_PX_MAX,
+ heightMax,
);
const frame = { ...breakpoint, x, height };
x += breakpoint.width + GAP_PX;
diff --git a/src/panels/ComponentPropertiesPanel.tsx b/src/panels/ComponentPropertiesPanel.tsx
index d0687627..6c9edd9d 100644
--- a/src/panels/ComponentPropertiesPanel.tsx
+++ b/src/panels/ComponentPropertiesPanel.tsx
@@ -128,6 +128,7 @@ function PropertySelectionEditor({
key={selection.originalName}
property={selection.value}
originalName={selection.originalName}
+ frontmatter={data.frontmatter}
access={
busy ? 'saving' : propertyIsEditable(selection.value, data) ? 'editable' : 'readonly'
}
@@ -137,6 +138,7 @@ function PropertySelectionEditor({
onClose();
}
}}
+ onCommit={save}
/>
)}
>
@@ -182,7 +184,7 @@ function PropertyList({ data, busy, onSelect, onChange }: PropertyListProps) {
/>
}
disabled={busy}
- removeDisabled={busy || !propertyIsEditable(property, data)}
+ removeDisabled={busy || !propertyIsRemovable(property, data)}
removeLabel={`Delete ${property.name}`}
onOpen={() => onSelect(property)}
onRemove={() => void onChange({ kind: 'remove', name: property.name })}
@@ -217,5 +219,9 @@ function PropertyTypeIcon({ type }: { readonly type: string }) {
}
function propertyIsEditable(property: ComponentProperty, data: ComponentProperties): boolean {
+ return property.editing ? property.editing.kind !== 'restricted' : !data.advanced;
+}
+
+function propertyIsRemovable(property: ComponentProperty, data: ComponentProperties): boolean {
return property.editing ? property.editing.kind === 'editable' : !data.advanced;
}
diff --git a/src/panels/PalettePanel.tsx b/src/panels/PalettePanel.tsx
index 7e426735..0e28d9bb 100644
--- a/src/panels/PalettePanel.tsx
+++ b/src/panels/PalettePanel.tsx
@@ -9,6 +9,7 @@ import { rankInsertItems } from '../insertRank';
import {
componentPreviewURL,
groupPaletteComponents,
+ parseComponentPreviewMessage,
parseComponentUsage,
prettyComponentName,
} from '../paletteModel';
@@ -380,14 +381,53 @@ function PalettePopups({
/>
)}
{preview && props.devUrl && (
-
-
{prettyComponentName(preview.component.name)}
-
-
+
)}
>
);
}
+
+function ComponentPreviewPopup({
+ preview,
+ devURL,
+ trailingSlash,
+}: {
+ readonly preview: NonNullable['value']>;
+ readonly devURL: string;
+ readonly trailingSlash: TrailingSlash;
+}) {
+ const frameRef = useRef(null);
+ const [ready, setReady] = useState(false);
+ useEffect(() => {
+ const receive = (event: MessageEvent): void => {
+ if (event.source !== frameRef.current?.contentWindow) {
+ return;
+ }
+ const message = parseComponentPreviewMessage(event.data);
+ if (message) {
+ setReady(message.status === 'ready');
+ }
+ };
+ window.addEventListener('message', receive);
+ return () => window.removeEventListener('message', receive);
+ }, []);
+ return (
+
+
{prettyComponentName(preview.component.name)}
+
+
+ );
+}
diff --git a/src/panels/PropertyDefault.tsx b/src/panels/PropertyDefault.tsx
index b2713511..7da4eb0a 100644
--- a/src/panels/PropertyDefault.tsx
+++ b/src/panels/PropertyDefault.tsx
@@ -4,12 +4,14 @@ import { PROPERTY_LIMITS } from '../../shared/component-properties';
import { literalOptions } from '../propertyOptions';
import Dropdown from '../ui/Dropdown';
import { BracesIcon } from '../ui/Icons';
+import { ExpressionBindingField } from './propBindings';
interface DefaultProps {
readonly property: ComponentProperty;
+ readonly frontmatter: string;
readonly onChange: (value: string) => void;
}
-export function PropertyDefault({ property, onChange }: DefaultProps) {
+export function PropertyDefault({ property, frontmatter, onChange }: DefaultProps) {
const simple =
['string', 'number', 'boolean'].includes(property.type) ||
literalOptions(property.type) !== undefined;
@@ -36,17 +38,17 @@ export function PropertyDefault({ property, onChange }: DefaultProps) {
)}
{mode === 'value' && simple ? (
-
+
) : (
-
diff --git a/src/panels/PropertyEditor.tsx b/src/panels/PropertyEditor.tsx
index 8e158d83..f4d4fe55 100644
--- a/src/panels/PropertyEditor.tsx
+++ b/src/panels/PropertyEditor.tsx
@@ -2,38 +2,34 @@ import PropertyReadOnlyFields from './PropertyReadOnlyFields';
import { PropertyConditions, PropertyDeclarationInfo } from './PropertyDeclarationInfo';
import { TrashIcon } from '../ui/Icons';
import { PropertyOptions } from './PropertyOptions';
-import type { OptionRename } from './PropertyOptions';
+import type { PropertyOptionChange } from './PropertyOptions';
import { PropertyType } from './PropertyType';
import { PropertyDefault, propertyDefaultText } from './PropertyDefault';
-export { literalOptions } from '../propertyOptions';
+import { literalOptions } from '../propertyOptions';
+export { literalOptions };
import { useRef, useState } from 'react';
import useDismiss from '../ui/useDismiss';
-import type { ComponentProperty, PropertyChange } from '../../shared/component-properties';
+import { assert } from '../../shared/assert';
+import type {
+ ComponentProperty,
+ PropertyChange,
+ PropertyOptionRename,
+} from '../../shared/component-properties';
import { PROPERTY_LIMITS } from '../../shared/component-properties';
interface PropertyEditorProps {
readonly property: ComponentProperty;
readonly originalName: string;
+ readonly frontmatter: string;
readonly access: 'editable' | 'readonly' | 'saving';
readonly onClose: () => void;
readonly onSave: (change: PropertyChange) => Promise;
+ readonly onCommit: (change: PropertyChange) => Promise;
}
export function PropertyEditor(props: PropertyEditorProps) {
const editorRef = useRef(null);
useDismiss(editorRef, props.access !== 'saving', props.onClose);
- const [property, setProperty] = useState(props.property);
- const [error, setError] = useState('');
- const update = (key: Key, value: ComponentProperty[Key]) => {
- setProperty((previous) => ({ ...previous, [key]: value }));
- setError('');
- };
- const submit = (): void => {
- if (!/^[A-Za-z_$][\w$]*$/.test(property.name)) {
- setError('Use a name such as title, imageUrl, or isVisible.');
- return;
- }
- void props.onSave({ kind: 'save', originalName: props.originalName, property });
- };
+ const draft = usePropertyEditorDraft(props);
return (
@@ -50,37 +46,99 @@ export function PropertyEditor(props: PropertyEditorProps) {
+ {props.property.editing?.kind === 'override' && (
+
{props.property.editing.reason}
+ )}
{props.access === 'readonly' ? (
-
+
) : (
- setProperty((previous) => changePropertyOptions(previous, type, rename))
- }
+ update={draft.update}
+ error={draft.error}
+ submit={draft.submit}
+ changeOptions={draft.changeOptions}
/>
)}
);
}
+function usePropertyEditorDraft(props: PropertyEditorProps) {
+ const [property, setProperty] = useState(props.property);
+ const [optionRenames, setOptionRenames] = useState([]);
+ const [error, setError] = useState('');
+ const update = (key: Key, value: ComponentProperty[Key]) => {
+ setProperty((previous) => ({ ...previous, [key]: value }));
+ if (key === 'type') {
+ // Free-form type edits have no reliable old-to-new option identity.
+ setOptionRenames([]);
+ }
+ setError('');
+ };
+ const submit = (): void => {
+ if (!/^[A-Za-z_$][\w$]*$/.test(property.name)) {
+ setError('Use a name such as title, imageUrl, or isVisible.');
+ return;
+ }
+ const change: PropertyChange =
+ optionRenames.length > 0
+ ? {
+ kind: 'save',
+ originalName: props.originalName,
+ property,
+ optionRenames,
+ }
+ : { kind: 'save', originalName: props.originalName, property };
+ void props.onSave(change);
+ };
+ const changeOptions = (change: PropertyOptionChange): void => {
+ const previous = property;
+ const next = changePropertyOptions(
+ previous,
+ change.type,
+ change.kind === 'draft' ? change.rename : undefined
+ );
+ const nextRenames = changePropertyOptionRenames(optionRenames, change);
+ setProperty(next);
+ setOptionRenames(nextRenames);
+ setError('');
+ if (change.kind === 'reorder' && props.originalName && nextRenames.length === 0) {
+ void props
+ .onCommit({
+ kind: 'options',
+ name: props.originalName,
+ type: change.type,
+ })
+ .then((saved) => {
+ if (!saved) {
+ setProperty((current) =>
+ current.type === change.type ? { ...current, type: previous.type } : current
+ );
+ }
+ });
+ }
+ };
+ return { property, error, update, submit, changeOptions };
+}
+
interface PropertyEditorFieldsProps extends PropertyControls {
readonly originalName: string;
+ readonly frontmatter: string;
readonly access: 'editable' | 'saving';
readonly onSave: PropertyEditorProps['onSave'];
readonly error: string;
readonly submit: () => void;
- readonly changeOptions: (type: string, rename?: OptionRename) => void;
+ readonly changeOptions: (change: PropertyOptionChange) => void;
}
function PropertyEditorFields({
property,
originalName,
+ frontmatter,
access,
onSave,
update,
@@ -88,26 +146,33 @@ function PropertyEditorFields({
submit,
changeOptions,
}: PropertyEditorFieldsProps) {
+ const inherited = property.editing?.kind === 'override';
return (
-
+
update('type', type)} />
changeOptions(type, rename)}
+ onChange={changeOptions}
/>
update('defaultValue', value)}
/>
Tooltip
)}
-
+
);
}
function PropertyActions({
originalName,
+ removable,
onSave,
submit,
}: {
readonly originalName: string;
+ readonly removable: boolean;
readonly onSave: PropertyEditorProps['onSave'];
readonly submit: () => void;
}) {
return (
- {originalName && (
+ {originalName && removable && (
@@ -172,7 +246,8 @@ function PropertyIdentity({
update('name', event.target.value)}
/>
@@ -210,7 +285,7 @@ function PropertyFlags({ property, update }: PropertyControls) {
function changePropertyOptions(
property: ComponentProperty,
type: string,
- rename?: OptionRename
+ rename?: PropertyOptionRename
): ComponentProperty {
if (rename && property.defaultValue.trim()) {
const current = propertyDefaultText(property.defaultValue);
@@ -223,3 +298,24 @@ function changePropertyOptions(
}
return { ...property, type };
}
+
+function changePropertyOptionRenames(
+ current: readonly PropertyOptionRename[],
+ change: PropertyOptionChange
+): readonly PropertyOptionRename[] {
+ const options = literalOptions(change.type);
+ assert(options !== undefined, 'Edited property options remain a literal union');
+ const retained = current.filter((rename) => options.includes(rename.to));
+ const rename = change.kind === 'draft' ? change.rename : undefined;
+ if (!rename) {
+ return retained;
+ }
+ const previous = retained.find((item) => item.to === rename.from);
+ const from = previous?.from ?? rename.from;
+ const withoutPrevious = retained.filter((rename) => rename !== previous);
+ if (from === rename.to) {
+ return withoutPrevious;
+ }
+ assert(withoutPrevious.length < PROPERTY_LIMITS.fieldsMax, 'Option rename count is bounded');
+ return [...withoutPrevious, { from, to: rename.to }];
+}
diff --git a/src/panels/PropertyOptions.tsx b/src/panels/PropertyOptions.tsx
index d26e4140..d6f3f813 100644
--- a/src/panels/PropertyOptions.tsx
+++ b/src/panels/PropertyOptions.tsx
@@ -1,19 +1,23 @@
import { useRef } from 'react';
import { assert } from '../../shared/assert';
import { PROPERTY_LIMITS } from '../../shared/component-properties';
+import type { PropertyOptionRename } from '../../shared/component-properties';
import { arrayItems, arrayText, moveItem } from '../arrayValue';
import { literalOptions } from '../propertyOptions';
import ListField from './ListField';
import type { ListFieldChange } from './ListField';
-export interface OptionRename {
- readonly from: string;
- readonly to: string;
-}
+export type PropertyOptionChange =
+ | {
+ readonly kind: 'draft';
+ readonly type: string;
+ readonly rename: PropertyOptionRename | undefined;
+ }
+ | { readonly kind: 'reorder'; readonly type: string };
interface PropertyOptionsProps {
readonly type: string;
readonly disabled: boolean;
- readonly onChange: (type: string, rename?: OptionRename) => void;
+ readonly onChange: (change: PropertyOptionChange) => void;
}
export function PropertyOptions({ type, disabled, onChange }: PropertyOptionsProps) {
@@ -37,7 +41,12 @@ export function PropertyOptions({ type, disabled, onChange }: PropertyOptionsPro
if (immediate) {
scalarKind.current = undefined;
}
- onChange(next.options.join(' | '), next.rename);
+ const nextType = next.options.join(' | ');
+ onChange(
+ change.kind === 'move'
+ ? { kind: 'reorder', type: nextType }
+ : { kind: 'draft', type: nextType, rename: next.rename }
+ );
};
return (
@@ -64,7 +73,10 @@ function changePropertyOptionList(
value: string,
change: ListFieldChange,
scalarKind: 'text' | 'literal'
-): { readonly options: readonly string[]; readonly rename?: OptionRename } {
+): {
+ readonly options: readonly string[];
+ readonly rename?: PropertyOptionRename;
+} {
assert(options.length <= PROPERTY_LIMITS.fieldsMax, 'Option count is bounded');
switch (change.kind) {
case 'move':
diff --git a/src/panels/componentProperties.css b/src/panels/componentProperties.css
index ae69566d..12bffaa4 100644
--- a/src/panels/componentProperties.css
+++ b/src/panels/componentProperties.css
@@ -195,3 +195,11 @@
.property-default .dd-trigger {
width: 100%;
}
+.property-expression-binding {
+ display: flex;
+ width: 100%;
+}
+.property-expression-binding > .expr-input {
+ flex: 1;
+ min-width: 0;
+}
diff --git a/src/panels/propAttributes.tsx b/src/panels/propAttributes.tsx
index 0014bad8..41095ab9 100644
--- a/src/panels/propAttributes.tsx
+++ b/src/panels/propAttributes.tsx
@@ -11,7 +11,7 @@ import StyleEditor, { collapseDeclarations } from '../ui/StyleEditor';
import AssetField from '../ui/AssetField';
import { looksLikeAssetPath, mediaKindFor } from '../ui/AssetThumb';
import { BindField, BindHandle, FieldDataPicker, ValueCodeEditor } from './propBindings';
-import { BracesIcon, PlusIcon, TrashIcon, ElementImageIcon } from '../ui/Icons';
+import { BracesIcon, PlusIcon, TrashIcon, ElementImageIcon, MaximizeIcon } from '../ui/Icons';
interface AttributeNode {
readonly id: string;
@@ -48,6 +48,7 @@ interface AttrEditorProps extends ContextProps {
readonly pos: FieldPosition;
readonly name: string;
readonly value: string;
+ readonly syntax: 'pair' | 'spread';
readonly isNew: boolean;
readonly dataCtx?: SourceContext | null | undefined;
readonly onCommitName: (name: string) => void;
@@ -66,6 +67,9 @@ interface ObjectAttrsFieldProps extends ContextProps {
const decodeAttr = (v: Attr | null | undefined) =>
v == null || v.type === 'bare' ? '' : v.type === 'expr' ? `{${v.value}}` : String(v.value);
+const attributeDisplayName = (name: string, value: Attr | undefined): string =>
+ value?.type === 'spread' ? `{...${value.value}}` : name;
+
const encodeAttr = (text: string): Attr => {
if (text === '') {
return { type: 'bare' };
@@ -97,30 +101,38 @@ export function AttributesSection(props: AttributesSectionProps) {
{names.length > 0 && (
- {names.map((name) => (
-
openEditor(name)}
- >
-
{name}
-
=
-
{decodeAttr(node.props?.[name])}
-
{
- e.stopPropagation();
- if (editor?.attr === name) {
- setEditor(null);
- }
- onSetProp(name, undefined, true);
- }}
+ {names.map((name) => {
+ const value = node.props?.[name];
+ const syntax = value?.type === 'spread' ? 'spread' : 'pair';
+ return (
+ openEditor(name)}
>
-
-
-
- ))}
+ {attributeDisplayName(name, value)}
+ {syntax === 'pair' && (
+ <>
+ =
+ {decodeAttr(value)}
+ >
+ )}
+ {
+ e.stopPropagation();
+ if (editor?.attr === name) {
+ setEditor(null);
+ }
+ onSetProp(name, undefined, true);
+ }}
+ >
+
+
+
+ );
+ })}
)}
@@ -153,6 +165,8 @@ function AttributesSectionPopup({ state }: { readonly state: AttributesState })
if (!editor) {
return null;
}
+ const value = editor.attr ? node.props?.[editor.attr] : undefined;
+ const syntax = value?.type === 'spread' ? 'spread' : 'pair';
return (
{
const clean = newName.trim();
@@ -252,7 +267,7 @@ export function parseAttrPaste(text: string): readonly AttributePair[] {
function AttrEditor(props: AttrEditorProps) {
const state = useAttrEditor(props);
- const { ref, pos, isStyleValue, assetMode, setAssetMode } = state;
+ const { ref, pos, syntax, isStyleValue, assetMode, setAssetMode } = state;
return (
-
Value
-
-
-
setAssetMode((v) => !v)}
- >
-
-
-
+ {syntax === 'pair' && (
+
+
Value
+
+
+
setAssetMode((v) => !v)}
+ >
+
+
+
+ )}
);
}
function useAttrEditor(props: AttrEditorProps) {
- const { name, value, isNew, bindCtx, onCommitName, onClose } = props;
+ const { name, value, syntax, isNew, bindCtx, onCommitName, onClose } = props;
const [draftName, setDraftName] = useState(name);
const [draftValue, setDraftValue] = useState(value);
const ref = useRef(null);
@@ -320,7 +337,11 @@ function useAttrEditor(props: AttrEditorProps) {
}, []);
const focusValue = !isNew && !mounted.current;
- const commitName = () => onCommitName(draftName);
+ const commitName = () => {
+ if (syntax === 'pair') {
+ onCommitName(draftName);
+ }
+ };
useAttrEditorDismiss(ref, onClose);
return {
@@ -366,17 +387,29 @@ function useAttrEditorDismiss(ref: RefObject, onClose: () => void)
}, [onClose, ref]);
}
function AttributeName({ state }: { readonly state: AttributeState }) {
- const { isNew, draftName, setDraftName, setDraftValue, onCommitPair, onCommitMany, commitName } =
- state;
+ const {
+ syntax,
+ isNew,
+ draftName,
+ setDraftName,
+ setDraftValue,
+ onCommitPair,
+ onCommitMany,
+ commitName,
+ } = state;
return (
Name
{
+ if (syntax === 'spread') {
+ return;
+ }
const text = e.clipboardData.getData('text');
// Only when it actually looks like markup — a plain name paste
// must keep behaving like a paste into a text box.
@@ -462,74 +495,80 @@ function AttributeBinding({ state }: { readonly state: AttributeState }) {
insertAt,
setInsertAt,
ref,
- bigAt,
setBigAt,
} = state;
+ const valueRef = useRef
(null);
return (
- {
- if (e.key !== '=' || e.metaKey || e.ctrlKey || e.altKey || bigAt) {
- return;
- }
- e.preventDefault();
- e.stopPropagation();
- // Kept on screen: the field sits deep in a right-hand panel, so a box
- // wider than it and anchored to its left edge runs off the window.
- const r = e.currentTarget.getBoundingClientRect();
- const width = Math.min(Math.max(r.width, 320), window.innerWidth - 16);
- setBigAt({
- left: Math.max(8, Math.min(r.left, window.innerWidth - width - 8)),
- top: Math.max(8, Math.min(r.top, window.innerHeight - 320)),
- width,
- });
- }}
- >
-
{
- const text = decodeAttr(next);
- setDraftValue(text);
- onChangeValue(text);
- }}
- />
- {/* The dot belongs to the FIELD, and lives inside its box: hanging it off
- the row put it above the row's own edge, so hovering it left the row —
- which hid it, which put the pointer back on the row, which showed it
- again. A dot that flickers under the pointer. */}
- {
- if (insertAt) {
- setInsertAt(null);
- return;
- }
- const r = (host || ref.current)?.getBoundingClientRect();
- if (!r) {
- return;
+ <>
+
+ {
+ const text = decodeAttr(next);
+ setDraftValue(text);
+ onChangeValue(text);
+ }}
+ />
+ {/* The dot belongs to the FIELD, and lives inside its box: hanging it off
+ the row put it above the row's own edge, so hovering it left the row —
+ which hid it, which put the pointer back on the row, which showed it
+ again. A dot that flickers under the pointer. */}
+ {
+ if (insertAt) {
+ setInsertAt(null);
+ return;
+ }
+ const r = (host || ref.current)?.getBoundingClientRect();
+ if (!r) {
+ return;
+ }
+ setInsertAt({
+ left: r.left,
+ top: Math.min(r.bottom + 4, Math.max(60, window.innerHeight - 340)),
+ width: Math.max(r.width, 240),
+ });
+ }}
+ />
+
+ {
+ const host = valueRef.current;
+ if (host) {
+ setBigAt(expandedAttributeEditorPosition(host));
}
- setInsertAt({
- left: r.left,
- top: Math.min(r.bottom + 4, Math.max(60, window.innerHeight - 340)),
- width: Math.max(r.width, 240),
- });
}}
- />
-
+ >
+
+
+ >
);
}
+
+function expandedAttributeEditorPosition(host: HTMLElement): FieldPosition {
+ const rectangle = host.getBoundingClientRect();
+ const viewportWidth = Math.max(window.innerWidth, 32);
+ const width = Math.min(Math.max(rectangle.width, 760), viewportWidth - 16);
+ return {
+ left: Math.max(8, Math.min(rectangle.left, viewportWidth - width - 8)),
+ top: Math.max(8, Math.min(rectangle.top, window.innerHeight - 320)),
+ width,
+ };
+}
function AttributePopups({ state }: { readonly state: AttributeState }) {
const {
bigAt,
@@ -734,6 +773,7 @@ function ObjectAttrsFieldPopup({ state }: { readonly state: ObjectAttributesStat
dataCtx={bindCtx}
name={editor.index != null ? (entries[editor.index]?.key ?? '') : ''}
value={editor.index != null ? decodeRaw(entries[editor.index]?.raw ?? '') : ''}
+ syntax="pair"
isNew={editor.index == null}
onCommitName={(newName) => {
const clean = newName.trim();
diff --git a/src/panels/propBindings.tsx b/src/panels/propBindings.tsx
index 9369e3a6..056238ad 100644
--- a/src/panels/propBindings.tsx
+++ b/src/panels/propBindings.tsx
@@ -72,6 +72,11 @@ interface ConditionFieldProps extends BindingContextProps {
readonly chipsOf: ChipsOf;
readonly onSetText: (value: string) => void;
}
+interface ExpressionBindingFieldProps extends BindingContextProps {
+ readonly value: string;
+ readonly placeholder?: string;
+ readonly onChange: (value: string) => void;
+}
interface BindHandleProps {
readonly active: boolean;
readonly onOpen: (host: Element | null) => void;
@@ -88,6 +93,7 @@ interface BindFieldProps extends BindingContextProps {
readonly value?: Attr | null | undefined;
readonly field?: FieldDefinition;
readonly placeholder?: string | undefined;
+ readonly wrapCode?: boolean;
readonly apiRef?: MutableRefObject;
readonly onChange: ValueChange;
}
@@ -260,6 +266,70 @@ export function ConditionField({ test, scope, chipsOf, bindCtx, onSetText }: Con
);
}
+export function ExpressionBindingField({
+ value,
+ placeholder,
+ bindCtx,
+ onChange,
+}: ExpressionBindingFieldProps) {
+ const [pick, setPick] = useState(null);
+ const apiRef = useRef(null);
+ const wrapRef = useRef(null);
+ const scope = scopeCompletions(bindCtx || {});
+ const scopeNames = new Set(scope.map((item) => item.label.split('.')[0] ?? ''));
+ const chipsOf = (text: string) => scopeChips(text, scopeNames);
+ const open = (chip: TemplateHole | null): void => {
+ const rectangle = wrapRef.current?.getBoundingClientRect();
+ if (!rectangle) {
+ return;
+ }
+ setPick({
+ chip,
+ pos: {
+ left: rectangle.left,
+ top: Math.min(rectangle.bottom + 4, Math.max(60, window.innerHeight - 340)),
+ width: Math.max(rectangle.width, 240),
+ },
+ });
+ };
+ return (
+ <>
+
+
+ (pick ? setPick(null) : open(null))} />
+
+ {pick ? (
+ {
+ const chip = pick.chip;
+ setPick(null);
+ if (chip) {
+ apiRef.current?.replaceRange(chip.from, chip.to, path);
+ } else {
+ apiRef.current?.insert(path);
+ }
+ }}
+ onClose={() => setPick(null)}
+ />
+ ) : null}
+ >
+ );
+}
+
export function BindHandle({ active, onOpen }: BindHandleProps) {
return (
;
const srcEditor = ;
@@ -684,9 +755,9 @@ function BindFieldView({ state }: { readonly state: BindingViewState }) {
// look like one.
chipsOf={codeChips}
onChipClick={(hit) => open(hit)}
- // Code keeps its shape: wrapping an array across the panel's width throws
- // away the indentation that says what belongs to what. It scrolls instead.
- wrap={false}
+ // Expanded editors keep authored line structure. Compact inline callers
+ // can opt into wrapping so the whole value remains readable in the panel.
+ wrap={wrapCode ?? false}
onChange={(v) => onChange({ type: 'expr', value: v })}
onCommit={(v) => v !== expr && onChange({ type: 'expr', value: v }, true)}
/>
diff --git a/src/propertyOptions.ts b/src/propertyOptions.ts
index 2a9e3147..3f5aade2 100644
--- a/src/propertyOptions.ts
+++ b/src/propertyOptions.ts
@@ -1,20 +1 @@
-import { PROPERTY_LIMITS } from '../shared/component-properties';
-
-// Only literal unions expose option controls. Complex unions remain in the type editor.
-export function literalOptions(type: string): readonly string[] | undefined {
- const tokens = type.match(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|-?\d+(?:\.\d+)?|true|false/g);
- if (!tokens || tokens.length < 1 || tokens.length > PROPERTY_LIMITS.fieldsMax) {
- return undefined;
- }
- const remaining = type.replace(
- /"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|-?\d+(?:\.\d+)?|true|false/g,
- ''
- );
- if (!/^\s*(?:\|\s*)*$/.test(remaining)) {
- return undefined;
- }
- if ((remaining.match(/\|/g)?.length ?? 0) !== tokens.length - 1) {
- return undefined;
- }
- return tokens;
-}
+export { literalOptions } from '../shared/property-options';
diff --git a/src/styles.css b/src/styles.css
index 9fed9613..b0d68e4b 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -608,6 +608,13 @@ html[data-platform='win32'] .titlebar { padding-right: 148px; }
.attr-row + .attr-row { border-top: 1px solid var(--border); }
.attr-row:hover, .attr-row.editing { background: var(--bg-hover); }
.attr-row .attr-name { color: var(--text-dim); flex-shrink: 0; }
+.attr-row.spread .attr-name {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
.attr-row .attr-eq { color: var(--text-faint); flex-shrink: 0; }
.attr-row .attr-value {
color: var(--text);
@@ -889,10 +896,12 @@ html[data-platform='win32'] .titlebar { padding-right: 148px; }
.attr-editor-row:has(.cm-editor) > span {
padding-top: 6px;
}
-.attr-editor-row > .attr-asset-toggle { align-self: flex-start; }
+.attr-editor-row > .attr-asset-toggle,
+.attr-editor-row > .attr-expand-toggle { align-self: flex-start; }
-/* Always present, never in the way: switches the value to the file picker. */
-.attr-asset-toggle {
+/* Compact actions beside the value: expand its code or switch it to a file. */
+.attr-asset-toggle,
+.attr-expand-toggle {
flex-shrink: 0;
width: 22px;
height: 26px;
@@ -901,7 +910,8 @@ html[data-platform='win32'] .titlebar { padding-right: 148px; }
color: var(--text-faint);
border-radius: 6px;
}
-.attr-asset-toggle:hover:not(:disabled) { background: var(--bg-hover); color: var(--text); }
+.attr-asset-toggle:hover:not(:disabled),
+.attr-expand-toggle:hover:not(:disabled) { background: var(--bg-hover); color: var(--text); }
.attr-asset-toggle.on { color: var(--accent); }
.style-editor {
diff --git a/src/variablesBridge.ts b/src/variablesBridge.ts
index cda9d950..7e6bd2cd 100644
--- a/src/variablesBridge.ts
+++ b/src/variablesBridge.ts
@@ -60,6 +60,7 @@ export interface VariablesSnapshot {
readonly values: Readonly>;
}
export const VARIABLES_LIMITS = { entriesMax: 100_000, fileCharsMax: 1_048_576 } as const;
+const PRIVATE_VARIABLE_PREFIX = '--_';
export function parseCSSVariables(input: unknown): Result {
const source = record(input);
@@ -85,7 +86,67 @@ export async function readCSSVariables(
return { ok: false, error: error instanceof Error ? error.message : String(error) };
}
// Invalid wire shapes indicate a contract bug, so parsing stays outside the I/O catch.
- return parseCSSVariables(response);
+ const result = parseCSSVariables(response);
+ return result.ok ? { ok: true, value: variablesForPanel(result.value) } : result;
+}
+
+// A leading underscore marks a private implementation token. The values map stays
+// complete because visible variables may resolve through private variables even though
+// the private declarations themselves do not belong in the panel.
+function variablesForPanel(snapshot: VariablesSnapshot): VariablesSnapshot {
+ const files = snapshot.files.map(variableFileForPanel);
+ return { files, values: snapshot.values };
+}
+
+function variableFileForPanel(file: VariableFile): VariableFile {
+ if (file.error !== undefined) {
+ return file;
+ }
+ const groups = file.groups
+ .map((group) => ({
+ ...group,
+ blocks: group.blocks
+ .map(variableBlockForPanel)
+ .filter((block): block is VariableBlock => block !== undefined),
+ }))
+ .filter((group) => variableGroupCount(group) > 0);
+ const count = groups.reduce((total, group) => total + variableGroupCount(group), 0);
+ return { ...file, groups, count };
+}
+
+function variableGroupCount(group: VariableGroup): number {
+ return group.blocks.reduce(
+ (blockTotal, block) =>
+ blockTotal +
+ block.rows.reduce(
+ (rowTotal, row) => rowTotal + row.cells.filter((cell) => cell !== null).length,
+ 0,
+ ),
+ 0,
+ );
+}
+
+function variableBlockForPanel(block: VariableBlock): VariableBlock | undefined {
+ const rows = block.rows
+ .map(variableRowForPanel)
+ .filter((row): row is VariableRow => row !== undefined);
+ if (block.rows.length > 0 && rows.length === 0) {
+ return undefined;
+ }
+ if (block.kind === 'matrix') {
+ return { ...block, rows };
+ }
+ return { ...block, rows };
+}
+
+function variableRowForPanel(row: VariableRow): VariableRow | undefined {
+ const cells = row.cells.map((cell) =>
+ cell?.name.startsWith(PRIVATE_VARIABLE_PREFIX) ? null : cell,
+ );
+ if (!cells.some((cell) => cell !== null)) {
+ return undefined;
+ }
+ return { ...row, cells };
}
// One budget covers all nested collections, so separate small arrays cannot evade the cap.
diff --git a/test/component-hover-preview.test.js b/test/component-hover-preview.test.js
index 1fae2580..4eb4d96e 100644
--- a/test/component-hover-preview.test.js
+++ b/test/component-hover-preview.test.js
@@ -110,6 +110,9 @@ test('generated Astro route keeps its default slot and loads the selected source
assert.match(template, /src\/layouts\/\*\*\/\*\.astro/);
assert.match(template, //);
assert.match(template, /\{name\}<\/C>/);
+ assert.match(template, /avb:component-preview/);
+ assert.match(template, /reportStatus\('empty'\)/);
+ assert.match(template, /reportStatus\('ready'\)/);
assert.doesNotMatch(template, /__STACKI_PREVIEW_/);
assert.doesNotMatch(template, /require\(|componentPreview\.js|node:/,
'edge-rendered pages cannot import the app CommonJS or Node helpers');
diff --git a/test/component-preview.test.js b/test/component-preview.test.js
index f8f2b77e..cf569be2 100644
--- a/test/component-preview.test.js
+++ b/test/component-preview.test.js
@@ -152,6 +152,11 @@ test('component navigation keeps the real iframe and inspector mounted while loa
assert.equal(writes.at(-1).pagePath, card.path);
assert.equal(writes.at(-1).model.nodes[0].props.title.value, 'card edit during return');
assert.equal(outgoing.filter((message) => message.type === 'avb:track').at(-1).scope, '');
+ assert.equal(
+ __componentPanels.PropsPanel.node.name,
+ 'Card',
+ 'closing a component selects the instance that opened it',
+ );
const failedRead = hold(card.path);
await openCard();
@@ -219,7 +224,11 @@ test('component navigation keeps the real iframe and inspector mounted while loa
assert.equal(track.focus, '0.0', `${fixture.label}: the original page instance retains focus`);
await back();
unchanged();
- assert.equal(__componentPanels.PropsPanel.node.name, 'main', `${fixture.label}: the page still opens on its outermost node`);
+ assert.equal(
+ __componentPanels.PropsPanel.node.name,
+ 'Card',
+ `${fixture.label}: closing selects the component instance`,
+ );
}
for (const fixture of [
{ source: '---\nconst render = true;\n---\n{render && (<>Text only>)}', kind: 'cond' },
@@ -236,7 +245,7 @@ test('component navigation keeps the real iframe and inspector mounted while loa
assert.deepEqual(track.paths, fixture.kind ? ['src/components/Card.astro|0', '0.0'] : ['0.0']);
await back();
unchanged();
- assert.equal(__componentPanels.PropsPanel.node.name, 'main');
+ assert.equal(__componentPanels.PropsPanel.node.name, 'Card');
}
} finally {
await act(async () => root.unmount());
diff --git a/test/component-properties-panel.test.js b/test/component-properties-panel.test.js
index 8f159d15..2321c93a 100644
--- a/test/component-properties-panel.test.js
+++ b/test/component-properties-panel.test.js
@@ -35,12 +35,26 @@ test('component properties lifecycle and controls', async () => {
loader: { '.css': 'empty' },
logLevel: 'silent',
});
- const dom = new JSDOM('
', { pretendToBeVisual: true });
- for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'Element', 'Node']) {
+ const dom = new JSDOM('
', {
+ pretendToBeVisual: true,
+ });
+ for (const key of [
+ 'window',
+ 'document',
+ 'navigator',
+ 'HTMLElement',
+ 'Element',
+ 'Node',
+ 'MutationObserver',
+ 'Window',
+ 'DOMRect',
+ ]) {
global[key] = key === 'window' ? dom.window : dom.window[key];
}
global.IS_REACT_ACT_ENVIRONMENT = true;
window.HTMLElement.prototype.scrollIntoView = () => {};
+ window.Range.prototype.getBoundingClientRect = () => new window.DOMRect();
+ window.Range.prototype.getClientRects = () => [];
const React = require('react');
const { act } = React;
const { createRoot } = require('react-dom/client');
@@ -139,6 +153,16 @@ test('component properties lifecycle and controls', async () => {
editComponentProperties: async (request) => {
edits.push(request);
order.push('write');
+ if (request.change.kind === 'options') {
+ data = {
+ ...data,
+ source: `${data.source}\noptions:${request.change.type}`,
+ properties: data.properties.map((field) =>
+ field.name === request.change.name ? { ...field, type: request.change.type } : field
+ ),
+ };
+ return { ok: true, value: data };
+ }
if (applyDefinition) {
data = applyDefinition(data.source, request.change);
return { ok: true, value: data };
@@ -220,8 +244,14 @@ test('component properties lifecycle and controls', async () => {
const target = rows[Math.min(gap, rows.length - 1)];
const fire = async (element, type, clientY) =>
act(async () => {
- const event = new window.MouseEvent(type, { bubbles: true, cancelable: true, clientY });
- Object.defineProperty(event, 'dataTransfer', { value: { setData: () => {} } });
+ const event = new window.MouseEvent(type, {
+ bubbles: true,
+ cancelable: true,
+ clientY,
+ });
+ Object.defineProperty(event, 'dataTransfer', {
+ value: { setData: () => {} },
+ });
element.dispatchEvent(event);
});
await fire(rows[from], 'dragstart', from * 40);
@@ -229,7 +259,10 @@ test('component properties lifecycle and controls', async () => {
await fire(target, 'drop', gap * 40);
};
await drag('[aria-label="Reorder title"]', '.property-row', 0, 2);
- assert.deepEqual(edits[0].change, { kind: 'order', names: ['variant', 'title'] });
+ assert.deepEqual(edits[0].change, {
+ kind: 'order',
+ names: ['variant', 'title'],
+ });
assert.deepEqual(order.slice(-5), ['saving', 'flush', 'write', 'refresh', 'idle']);
await act(async () => document.querySelector('[aria-label="Add property"]').click());
assert.match(document.body.textContent, /New property/);
@@ -262,8 +295,18 @@ test('component properties lifecycle and controls', async () => {
await dragOptions(0, 2);
assert.equal(optionValues()[0], 'Outline');
assert.equal(optionValues()[1], 'Solid');
+ assert.deepEqual(edits.at(-1).change, {
+ kind: 'options',
+ name: 'variant',
+ type: '"Outline" | "Solid"',
+ });
await dragOptions(1, 0);
assert.equal(optionValues()[0], 'Solid');
+ assert.deepEqual(edits.at(-1).change, {
+ kind: 'options',
+ name: 'variant',
+ type: '"Solid" | "Outline"',
+ });
await act(async () => document.querySelector('.property-options .list-field-remove').click());
assert.equal(optionValues()[0], 'Outline');
assert.equal(document.querySelector('.property-options .list-field-remove').disabled, true);
@@ -333,7 +376,10 @@ test('component properties lifecycle and controls', async () => {
assert.equal(reads, readsBeforeSave + 1);
assert.match(document.querySelector('.property-list').textContent, /afterSave/);
// A deletion/read failure recovers when a later external edit restores it.
- readProperties = async () => ({ ok: false, error: { code: 'missing', message: 'File missing' } });
+ readProperties = async () => ({
+ ok: false,
+ error: { code: 'missing', message: 'File missing' },
+ });
await notify();
assert.match(document.body.textContent, /File missing/);
readProperties = async () => ({ ok: true, value: data });
@@ -442,6 +488,7 @@ const { gap = 'small', other = 'large', ...rest } = Astro.props;
assert.equal(editedGap.description, 'Choose space between items.');
assert.equal(editedGap.type, `'large' | "compact" | 'medium'`);
assert.equal(editedGap.defaultValue, '"compact"');
+ assert.deepEqual(edits.at(-1).change.optionRenames, [{ from: "'small'", to: '"compact"' }]);
assert.match(data.source, /other\?: ContainerGap/);
assert.match(data.source, /Props extends HTMLAttributes<'section'>/);
assert.match(data.source, /type ContainerGap = 'small' \| 'medium' \| 'large'/);
@@ -499,6 +546,42 @@ const { gap = 'small', other = 'large', ...rest } = Astro.props;
document.querySelector('input[aria-label="Type expression"]').value,
'typeof designTheme'
);
+ // A default that names another frontmatter value is shown as a binding. Its
+ // chip opens the same scoped picker used by prop values and can be repointed.
+ const boundDefault = `---
+const fallback = 'h3';
+interface Props {
+ tag?: string;
+ variant?: string;
+}
+const { tag = 'h2', variant = tag } = Astro.props;
+---
+{variant} `;
+ data = readComponentProperties(boundDefault);
+ await notify();
+ await act(async () =>
+ [...document.querySelectorAll('.property-row-main')]
+ .find((button) => button.textContent.includes('variant'))
+ .click()
+ );
+ const defaultChip = document.querySelector('.property-default .cm-chip');
+ assert.equal(defaultChip.textContent, 'tag');
+ assert.equal(document.querySelector('.property-default textarea'), null);
+ await pressDown(defaultChip);
+ assert.equal(document.querySelector('.bind-menu .dp-row.selected .dp-key').textContent, 'tag');
+ await act(async () =>
+ [...document.querySelectorAll('.bind-menu .dp-row')]
+ .find((row) => row.title.startsWith('fallback'))
+ .click()
+ );
+ assert.equal(document.querySelector('.property-default .cm-content').textContent, 'fallback');
+ await act(async () =>
+ [...document.querySelectorAll('button')]
+ .find((button) => button.textContent === 'Save property')
+ .click()
+ );
+ assert.equal(data.properties.find((field) => field.name === 'variant').defaultValue, 'fallback');
+ assert.match(data.source, /variant = fallback/);
// The shared editor handles add/edit/remove and drag while the adapter keeps
// boolean/number literals distinct from strings with the same visible label.
data = {
@@ -604,7 +687,12 @@ const { gap = 'small', other = 'large', ...rest } = Astro.props;
...data,
source: 'combined contract',
properties: [
- { ...property, name: 'eyebrow', editing: { kind: 'editable' }, conditions: [] },
+ {
+ ...property,
+ name: 'eyebrow',
+ editing: { kind: 'editable' },
+ conditions: [],
+ },
{
...property,
name: 'image',
@@ -629,6 +717,36 @@ const { gap = 'small', other = 'large', ...rest } = Astro.props;
assert.match(document.querySelector('.property-conditions').textContent, /default.*not allowed/);
assert.match(document.querySelector('.property-readonly-fields').textContent, /variant rules/);
assert.equal(document.querySelector('.property-editor fieldset'), null);
+ // Inherited Astro attributes can be overridden locally without exposing a
+ // rename or delete that would leave the inherited contract in place.
+ data = {
+ ...data,
+ source: 'inherited attribute',
+ properties: [
+ {
+ ...property,
+ name: 'class',
+ type: 'unknown',
+ editing: {
+ kind: 'override',
+ reason: 'This HTML attribute will be declared locally when you save it.',
+ },
+ },
+ ],
+ };
+ await notify();
+ assert.equal(document.querySelector('[aria-label="Delete class"]').disabled, true);
+ await act(async () => document.querySelector('.property-row-main').click());
+ assert.equal(document.querySelector('.property-editor fieldset').disabled, false);
+ assert.equal(document.querySelector('.property-readonly-fields'), null);
+ assert.equal(document.querySelector('.property-editor label input').disabled, true);
+ assert.equal(
+ document.querySelector('textarea[placeholder="Describe how to use this property…"]').readOnly,
+ false
+ );
+ assert.match(document.querySelector('.property-editor').textContent, /declared locally/);
+ assert.equal(document.querySelector('.property-actions .danger'), null);
+ await act(async () => document.querySelector('[aria-label="Close property settings"]').click());
let completeRead;
readProperties = () =>
new Promise((resolve) => {
diff --git a/test/component-properties.test.js b/test/component-properties.test.js
index 577e12bb..3d08cd16 100644
--- a/test/component-properties.test.js
+++ b/test/component-properties.test.js
@@ -10,7 +10,10 @@ const {
readComponentProperties,
editPropertyDefinition,
} = require('../dist/electron/propertyDefinitions');
-const { renameComponentReferences } = require('../dist/electron/propertyRename');
+const {
+ renameComponentOptionValues,
+ renameComponentReferences,
+} = require('../dist/electron/propertyRename');
const {
loadComponentProperties,
updateComponentProperties,
@@ -108,7 +111,12 @@ test('adds fields to components with and without a Props type or frontmatter', (
editPropertyDefinition(original, {
kind: 'save',
originalName: '',
- property: { ...property, name: 'count', type: 'number', defaultValue: '3' },
+ property: {
+ ...property,
+ name: 'count',
+ type: 'number',
+ defaultValue: '3',
+ },
})
);
const fields = readComponentProperties(output).properties;
@@ -122,7 +130,10 @@ test('adds fields to components with and without a Props type or frontmatter', (
test('reorders fields and literal options while preserving their docs and defaults', () => {
const output = value(
- editPropertyDefinition(source, { kind: 'order', names: ['image', 'variant', 'title'] })
+ editPropertyDefinition(source, {
+ kind: 'order',
+ names: ['image', 'variant', 'title'],
+ })
);
assert.deepEqual(
readComponentProperties(output).properties.map((field) => field.name),
@@ -131,17 +142,13 @@ test('reorders fields and literal options while preserving their docs and defaul
assert.match(output, /Title shown above the card/);
const reordered = value(
editPropertyDefinition(source, {
- kind: 'save',
- originalName: 'variant',
- property: {
- ...property,
- name: 'variant',
- type: "'outline' | 'solid'",
- defaultValue: "'solid'",
- },
+ kind: 'options',
+ name: 'variant',
+ type: "'outline' | 'solid'",
})
);
- assert.match(reordered, /variant\?: 'outline' \| 'solid'/);
+ assert.match(reordered, /readonly variant: 'outline' \| 'solid'/);
+ assert.match(reordered, /variant = 'solid'/);
assert.equal(
editPropertyDefinition(source, { kind: 'order', names: ['title', 'title'] }).ok,
false
@@ -236,10 +243,40 @@ test('rename handles expressions, shorthand, literal spreads, comments and UTF-8
);
});
+test('option rename updates static values on only the resolved component prop', () => {
+ const original =
+ '😀 ' +
+ ' ' +
+ ' ' +
+ ' ' +
+ ' ';
+ const output = value(
+ renameComponentOptionValues(original, new Set(['Card']), 'variant', [
+ { from: "'solid'", to: "'filled'" },
+ ])
+ );
+ assert.match(output, //);
+ assert.match(output, /😀 ', new Set(['Card']), 'variant', [
+ { from: "'solid'", to: "'filled'" },
+ ]).ok,
+ false
+ );
+});
+
test('project-wide rename follows import aliases and does not touch unrelated components', () => {
project(({ root, component, page }) => {
const result = updateComponentProperties(
- { projectPath: root, file: component, source, change: save({ name: 'heading' }) },
+ {
+ projectPath: root,
+ file: component,
+ source,
+ change: save({ name: 'heading' }),
+ },
() => {}
);
assert.equal(value(result).properties[0].name, 'heading');
@@ -250,6 +287,94 @@ test('project-wide rename follows import aliases and does not touch unrelated co
});
});
+test('project-wide option rename updates every static instance value and the default', () => {
+ project(({ root, component, page }) => {
+ fs.writeFileSync(
+ page,
+ `---\nimport Alias from '../components/Card.astro';\n` +
+ `import Other from '../components/Other.astro';\nconst ready = true;\n---\n` +
+ ` ` +
+ ` `
+ );
+ const variant = readComponentProperties(source).properties.find(
+ (field) => field.name === 'variant'
+ );
+ const result = updateComponentProperties(
+ {
+ projectPath: root,
+ file: component,
+ source,
+ change: {
+ kind: 'save',
+ originalName: 'variant',
+ property: {
+ ...variant,
+ type: "'filled' | 'outline'",
+ defaultValue: "'filled'",
+ },
+ optionRenames: [{ from: "'solid'", to: "'filled'" }],
+ },
+ },
+ () => {}
+ );
+ assert.equal(
+ value(result).properties.find((field) => field.name === 'variant').type,
+ "'filled' | 'outline'"
+ );
+ assert.match(fs.readFileSync(component, 'utf8'), /variant = 'filled'/);
+ const content = fs.readFileSync(page, 'utf8');
+ assert.match(content, / {
+ project(({ root, component, page }) => {
+ const beforePage =
+ `---\nimport Alias from '../components/Card.astro';\n---\n` + ` `;
+ fs.writeFileSync(page, beforePage);
+ const variant = readComponentProperties(source).properties.find(
+ (field) => field.name === 'variant'
+ );
+ const write = fs.writeFileSync;
+ let writes = 0;
+ fs.writeFileSync = (...argumentsList) => {
+ writes += 1;
+ if (writes === 2) {
+ throw new Error('Simulated option write failure');
+ }
+ return write(...argumentsList);
+ };
+ try {
+ const result = updateComponentProperties(
+ {
+ projectPath: root,
+ file: component,
+ source,
+ change: {
+ kind: 'save',
+ originalName: 'variant',
+ property: {
+ ...variant,
+ type: "'filled' | 'outline'",
+ defaultValue: "'filled'",
+ },
+ optionRenames: [{ from: "'solid'", to: "'filled'" }],
+ },
+ },
+ () => {}
+ );
+ assert.equal(result.ok, false);
+ assert.match(result.error.message, /restored/);
+ assert.equal(fs.readFileSync(component, 'utf8'), source);
+ assert.equal(fs.readFileSync(page, 'utf8'), beforePage);
+ } finally {
+ fs.writeFileSync = write;
+ }
+ });
+});
+
test('source conflicts and unresolved spreads leave every project file untouched', () => {
project(({ root, component, page }) => {
const before = fs.readFileSync(page, 'utf8');
@@ -284,7 +409,12 @@ test('failed writes restore all files already written', () => {
};
try {
const result = updateComponentProperties(
- { projectPath: root, file: component, source, change: save({ name: 'heading' }) },
+ {
+ projectPath: root,
+ file: component,
+ source,
+ change: save({ name: 'heading' }),
+ },
() => {}
);
assert.equal(result.ok, false);
@@ -349,7 +479,9 @@ test('source metadata rejects malformed and oversized inputs at the contract bou
null,
{},
{ declarations: {} },
- { declarations: Array(PROPERTY_LIMITS.fieldsMax + 1).fill(origin.declarations[0]) },
+ {
+ declarations: Array(PROPERTY_LIMITS.fieldsMax + 1).fill(origin.declarations[0]),
+ },
...[0, -1, 1.5, '2', PROPERTY_LIMITS.sourceCharsMax + 1].map((line) => ({
declarations: [{ label: 'Props.title', expression: 'string', line }],
})),
@@ -502,6 +634,9 @@ test('ambiguous common declarations remain restricted without flattening their c
'import type { Shared } from "./shared"; type Props = Shared & { eyebrow?: string };',
'import type { HTMLAttributes } from "./shared"; ' +
'type Props = HTMLAttributes & { eyebrow?: string };',
+ 'import type { Omit } from "./shared"; ' +
+ 'import type { HTMLAttributes } from "astro/types"; ' +
+ 'type Props = Omit, "eyebrow"> & { eyebrow?: string };',
'type Props = { eyebrow?: string }; type AllProps = { eyebrow?: string };',
]) {
const assertion = declaration.includes('type AllProps') ? ' as AllProps' : '';
@@ -538,12 +673,104 @@ test('common props support aliased Astro attribute imports without changing thei
assert.match(output, /Changed/);
});
+test('inherited Astro attributes can be declared locally and edited', () => {
+ const source = `---
+import type { HTMLAttributes } from "astro/types";
+type Props = HTMLAttributes<"div"> & {
+ render?: boolean;
+};
+const { render = true, class: className, ...rest } = Astro.props;
+---
+
`;
+ const inherited = readComponentProperties(source).properties.find(
+ (field) => field.name === 'class'
+ );
+ assert.equal(inherited.editing.kind, 'override');
+ const output = value(
+ editPropertyDefinition(source, {
+ kind: 'save',
+ originalName: 'class',
+ property: {
+ ...inherited,
+ type: 'string',
+ required: true,
+ readonly: true,
+ defaultValue: '"eyebrow"',
+ description: 'Classes for the outer element.',
+ },
+ })
+ );
+ assert.match(output, /readonly class: string/);
+ assert.match(output, /Classes for the outer element\./);
+ assert.match(output, /class: className = "eyebrow"/);
+ const reread = readComponentProperties(output).properties.find(
+ (field) => field.name === 'class'
+ );
+ assert.equal(reread.editing.kind, 'editable');
+ assert.equal(reread.defaultValue, '"eyebrow"');
+ assert.equal(
+ editPropertyDefinition(source, {
+ kind: 'save',
+ originalName: 'class',
+ property: { ...inherited, name: 'className' },
+ }).ok,
+ false
+ );
+ assert.equal(editPropertyDefinition(source, { kind: 'remove', name: 'class' }).ok, false);
+});
+
+test('props remain editable through omitted Astro attribute inheritance', () => {
+ const source = `---
+import type { HTMLAttributes } from "astro/types";
+type Base = Omit, "src" | "width"> & {
+ /** Original image tooltip. */
+ src: ImageMetadata | string;
+ width?: number;
+};
+type Props = Base & (
+ | { variant?: "default" }
+ | { variant: "cover" }
+);
+type AllProps = Base & { variant?: "default" | "cover" };
+const { src, width, variant = "default", ...rest } = Astro.props as AllProps;
+---
+ `;
+ const imageSource = readComponentProperties(source).properties.find(
+ (field) => field.name === 'src'
+ );
+ assert.deepEqual(imageSource.editing, { kind: 'editable' });
+ const output = value(
+ editPropertyDefinition(source, {
+ kind: 'save',
+ originalName: 'src',
+ property: {
+ ...imageSource,
+ type: 'ImageMetadata | string | URL',
+ required: false,
+ readonly: true,
+ defaultValue: '"/placeholder.png"',
+ description: 'Updated image tooltip.',
+ },
+ })
+ );
+ assert.match(output, /readonly src\?: ImageMetadata \| string \| URL/);
+ assert.match(output, /src = "\/placeholder\.png"/);
+ assert.match(output, /Updated image tooltip\./);
+ assert.equal(
+ readComponentProperties(output).properties.find((field) => field.name === 'src').editing.kind,
+ 'editable'
+ );
+});
+
test('property permissions and conditions are validated at the boundary', () => {
const data = readComponentProperties(compositeCard);
const field = data.properties[0];
for (const editing of [
null,
{},
+ { kind: 'override' },
+ { kind: 'override', reason: '' },
+ { kind: 'override', reason: false },
{ kind: 'restricted' },
{ kind: 'restricted', reason: '' },
{ kind: 'restricted', reason: false },
@@ -558,7 +785,10 @@ test('property permissions and conditions are validated at the boundary', () =>
['x'.repeat(PROPERTY_LIMITS.textCharsMax + 1)],
]) {
assert.throws(() =>
- parseComponentProperties({ ...data, properties: [{ ...field, conditions }] })
+ parseComponentProperties({
+ ...data,
+ properties: [{ ...field, conditions }],
+ })
);
}
});
@@ -569,16 +799,56 @@ test('contracts reject malformed payloads and enforce resource bounds', () => {
readComponentProperties(source)
);
assert.deepEqual(parsePropertyChange(save()), save());
+ assert.deepEqual(
+ parsePropertyChange({
+ ...save(),
+ optionRenames: [{ from: "'solid'", to: "'filled'" }],
+ }),
+ { ...save(), optionRenames: [{ from: "'solid'", to: "'filled'" }] }
+ );
+ assert.deepEqual(
+ parsePropertyChange({
+ kind: 'options',
+ name: 'variant',
+ type: "'outline' | 'solid'",
+ }),
+ { kind: 'options', name: 'variant', type: "'outline' | 'solid'" }
+ );
for (const input of [
null,
{},
{ kind: 'save', originalName: 'title', property: {} },
{ kind: 'order', names: [42] },
+ { kind: 'options', name: 'bad name', type: 'string' },
+ { kind: 'options', name: 'variant', type: 42 },
{ kind: 'remove', name: 'bad name' },
- { kind: 'source', frontmatter: 'x'.repeat(PROPERTY_LIMITS.sourceCharsMax + 1) },
- { kind: 'save', originalName: '', property: { ...property, required: 'yes' } },
- { kind: 'save', originalName: '', property: { ...property, type: 'x'.repeat(32769) } },
- { kind: 'order', names: Array(PROPERTY_LIMITS.fieldsMax + 1).fill('title') },
+ {
+ kind: 'source',
+ frontmatter: 'x'.repeat(PROPERTY_LIMITS.sourceCharsMax + 1),
+ },
+ {
+ kind: 'save',
+ originalName: '',
+ property: { ...property, required: 'yes' },
+ },
+ {
+ kind: 'save',
+ originalName: '',
+ property: { ...property, type: 'x'.repeat(32769) },
+ },
+ { ...save(), optionRenames: [{ from: '', to: "'filled'" }] },
+ { ...save(), optionRenames: [{ from: "'solid'", to: "'solid'" }] },
+ {
+ ...save(),
+ optionRenames: Array(PROPERTY_LIMITS.fieldsMax + 1).fill({
+ from: '1',
+ to: '2',
+ }),
+ },
+ {
+ kind: 'order',
+ names: Array(PROPERTY_LIMITS.fieldsMax + 1).fill('title'),
+ },
]) {
assert.throws(() => parsePropertyChange(input));
}
@@ -586,7 +856,10 @@ test('contracts reject malformed payloads and enforce resource bounds', () => {
assert.throws(() => applySourceEdits('abc', [{ start: 1, end: 4, text: '' }]), /never overlap/);
project(({ root }) =>
assert.equal(
- loadComponentProperties({ projectPath: root, file: path.join(root, 'missing.astro') }).ok,
+ loadComponentProperties({
+ projectPath: root,
+ file: path.join(root, 'missing.astro'),
+ }).ok,
false
)
);
@@ -697,7 +970,12 @@ test('renames resolve tsconfig aliases and MDX instances', () => {
'import Feature from "@/components/Card.astro";\n\n# Hello\n\n' + ' '
);
const result = updateComponentProperties(
- { projectPath: root, file: component, source, change: save({ name: 'heading' }) },
+ {
+ projectPath: root,
+ file: component,
+ source,
+ change: save({ name: 'heading' }),
+ },
() => {}
);
value(result);
@@ -715,7 +993,12 @@ test('re-exports, runtime aliases, and dynamic imports cannot cause partial rena
fs.writeFileSync(path.join(root, 'src/index.ts'), contents);
const before = fs.readFileSync(page, 'utf8');
const result = updateComponentProperties(
- { projectPath: root, file: component, source, change: save({ name: 'heading' }) },
+ {
+ projectPath: root,
+ file: component,
+ source,
+ change: save({ name: 'heading' }),
+ },
() => {}
);
assert.equal(result.ok, false);
@@ -837,7 +1120,12 @@ test('renaming a local prop on inherited Props updates website instances', () =>
fs.writeFileSync(component, input);
value(
updateComponentProperties(
- { projectPath: root, file: component, source: input, change: save({ name: 'heading' }) },
+ {
+ projectPath: root,
+ file: component,
+ source: input,
+ change: save({ name: 'heading' }),
+ },
() => {}
)
);
diff --git a/test/palette-model.test.js b/test/palette-model.test.js
index 3329ef56..08f4d432 100644
--- a/test/palette-model.test.js
+++ b/test/palette-model.test.js
@@ -24,6 +24,24 @@ test('preview URLs respect the project slash policy and preserve encoded file id
}
});
+test('component preview messages accept only known status updates', () => {
+ for (const status of ['empty', 'ready']) {
+ assert.deepEqual(
+ model.parseComponentPreviewMessage({ type: 'avb:component-preview', status }),
+ { status },
+ );
+ }
+ for (const value of [
+ null,
+ {},
+ { type: 'other', status: 'ready' },
+ { type: 'avb:component-preview' },
+ { type: 'avb:component-preview', status: 'unknown' },
+ ]) {
+ assert.equal(model.parseComponentPreviewMessage(value), undefined);
+ }
+});
+
test('component usage parser preserves success and operating-error variants', () => {
assert.deepEqual(model.parseComponentUsage({ files: [file], total: 2 }), {
kind: 'ready',
diff --git a/test/palette-preview.test.js b/test/palette-preview.test.js
index 4d098812..92cefccf 100644
--- a/test/palette-preview.test.js
+++ b/test/palette-preview.test.js
@@ -1,6 +1,6 @@
// Goal: palette hover previews identify the exact component file and cancel
// obsolete hover requests. Methodology: mount the real palette, dispatch pointer
-// entry/exit events, and inspect the resulting iframe across duplicate names.
+// entry/exit events, inspect iframe identity, and apply validated ready/empty statuses.
const test = require('node:test');
const assert = require('node:assert/strict');
@@ -51,7 +51,17 @@ test('hover preview targets exact files and cancels obsolete delayed opens', asy
createFrom: { kind: 'unavailable', reason: 'Select an element.' },
})));
await checkCancelledHover(context);
- await checkPreviewTarget(context, 'Interactive', 'src/components/Interactive/AccordionItem.astro');
+ const interactiveFrame = await checkPreviewTarget(
+ context,
+ 'Interactive',
+ 'src/components/Interactive/AccordionItem.astro',
+ );
+ await sendPreviewStatus(context, interactiveFrame, 'empty');
+ assert.equal(
+ document.querySelector('.comp-preview').style.visibility,
+ 'hidden',
+ 'An empty component has no preview box',
+ );
await checkPreviewTarget(context, 'Marketing', 'src/components/Marketing/AccordionItem.astro');
await checkPreviewTarget(context, 'layouts', 'src/layouts/Base.astro');
await React.act(async () => {
@@ -89,8 +99,33 @@ async function checkPreviewTarget(context, folder, componentPath) {
assert.equal(url.origin, 'http://localhost:4321');
assert.equal(url.pathname, '/__avb/preview/');
assert.equal(url.searchParams.get('c'), name);
- assert.equal(url.searchParams.get('p'), componentPath, 'Duplicate basenames retain file identity');
+ assert.equal(
+ url.searchParams.get('p'),
+ componentPath,
+ 'Duplicate basenames retain file identity',
+ );
assert.equal(iframe.title, `${name} preview`);
+ const preview = document.querySelector('.comp-preview');
+ assert.equal(preview.style.visibility, 'hidden', 'The box waits for rendered preview content');
+ await context.act(async () => {
+ window.dispatchEvent(new context.dom.window.MessageEvent('message', {
+ source: iframe.contentWindow,
+ data: { type: 'avb:component-preview', status: 'unknown' },
+ }));
+ });
+ assert.equal(preview.style.visibility, 'hidden', 'Malformed preview status is ignored');
+ await sendPreviewStatus(context, iframe, 'ready');
+ assert.equal(preview.style.visibility, 'visible', 'Rendered content reveals the preview box');
+ return iframe;
+}
+
+async function sendPreviewStatus(context, iframe, status) {
+ await context.act(async () => {
+ window.dispatchEvent(new context.dom.window.MessageEvent('message', {
+ source: iframe.contentWindow,
+ data: { type: 'avb:component-preview', status },
+ }));
+ });
}
async function pointerEvent({ dom, act }, item, type) {
diff --git a/test/preview-lifecycle.test.js b/test/preview-lifecycle.test.js
index 28798d19..6ff9657d 100644
--- a/test/preview-lifecycle.test.js
+++ b/test/preview-lifecycle.test.js
@@ -162,9 +162,12 @@ test('preview owns only mounted frames and cleans canceled/unmounted drags', asy
await send(canvasFrame, { type: 'avb:page-height', height: Infinity });
assert.equal(document.querySelector('.canvas-frame').style.height, '900px');
await send(canvasFrame, { type: 'avb:page-height', height: 80000 });
- assert.equal(document.querySelector('.canvas-frame').style.height, '30000px');
+ assert.equal(document.querySelector('.canvas-frame').style.height, '1125px');
await send(oldFrame, { type: 'avb:page-height', height: 1000 });
- assert.equal(document.querySelector('.canvas-frame').style.height, '30000px');
+ assert.equal(document.querySelector('.canvas-frame').style.height, '1125px');
+ const phoneFrame = document.querySelectorAll('iframe')[2].contentWindow;
+ await send(phoneFrame, { type: 'avb:page-height', height: 80000 });
+ assert.equal(document.querySelectorAll('.canvas-frame')[2].style.height, '1015px');
await render({ refreshKey: 1 });
assert.equal(document.querySelector('.canvas-frame').style.height, '900px');
await act(async () => { pointer(document.querySelector('.canvas-view'), 'pointerdown'); await settle(); });
diff --git a/test/prop-attributes.js b/test/prop-attributes.js
index f0c2a3e2..e1010866 100644
--- a/test/prop-attributes.js
+++ b/test/prop-attributes.js
@@ -1,5 +1,6 @@
-// Exercise attribute literal parsing and real paste events. The object field must
-// handle both paste callback paths, preserve existing rows, and reject size bounds.
+// Exercise attribute literal parsing, real paste events, and expanded value editing.
+// The object field must preserve rows and reject bounds; the value editor must open
+// from a press and provide enough width to read a JavaScript expression.
const assert = require('node:assert/strict');
const path = require('node:path');
const fs = require('node:fs');
@@ -38,9 +39,13 @@ dom.window.Range.prototype.getClientRects = () => [];
const React = require('react');
const { createRoot } = require('react-dom/client');
const { act } = React;
-const { ObjectAttrsField, parseObjectLiteral, serializeObjectLiteral, parseAttrPaste } = require(
- output,
-);
+const {
+ AttributesSection,
+ ObjectAttrsField,
+ parseObjectLiteral,
+ serializeObjectLiteral,
+ parseAttrPaste,
+} = require(output);
assert.deepEqual(parseAttrPaste('id="hero" tabindex={2} disabled'), [
{ name: 'id', value: 'hero' },
{ name: 'tabindex', value: '{2}' },
@@ -128,6 +133,59 @@ const paste = async (text) => {
const before = writes;
await paste(`id="${'x'.repeat(8193)}"`);
assert.equal(writes, before);
+ await act(async () =>
+ root.render(
+ React.createElement(AttributesSection, {
+ node: {
+ id: 'section',
+ props: {
+ 'class:list': { type: 'expr', value: '["icon", variant]' },
+ '...rest': { type: 'spread', value: 'rest' },
+ },
+ },
+ names: ['class:list', '...rest'],
+ bindCtx: { frontmatter: 'const variant = "medium";' },
+ onSetProp: () => {},
+ onRenameProp: () => {},
+ }),
+ ),
+ );
+ await act(async () =>
+ document
+ .querySelector('.attr-row')
+ .dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })),
+ );
+ const valueField = document.querySelector('.attr-value-field');
+ assert.ok(valueField);
+ valueField.getBoundingClientRect = () => new dom.window.DOMRect(600, 300, 280, 40);
+ await act(async () =>
+ valueField.dispatchEvent(
+ new dom.window.MouseEvent('pointerdown', { bubbles: true, cancelable: true }),
+ ),
+ );
+ assert.equal(document.querySelector('.attr-editor.var-src'), null);
+ assert.ok(valueField.querySelector('.cm-lineWrapping'));
+ await act(async () =>
+ document
+ .querySelector('button[title="Expand value"]')
+ .dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })),
+ );
+ const expanded = document.querySelector('.attr-editor.var-src');
+ assert.ok(expanded);
+ assert.equal(expanded.style.width, '760px');
+ const spreadRow = document.querySelectorAll('.attr-row')[1];
+ assert.equal(spreadRow.querySelector('.attr-name').textContent, '{...rest}');
+ assert.equal(spreadRow.querySelector('.attr-eq'), null);
+ assert.equal(spreadRow.querySelector('.attr-value'), null);
+ await act(async () =>
+ spreadRow.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })),
+ );
+ const spreadEditor = document.querySelector('.attr-editor');
+ const spreadName = spreadEditor.querySelector('input');
+ assert.equal(spreadName.value, '{...rest}');
+ assert.equal(spreadName.readOnly, true);
+ assert.equal(spreadEditor.querySelectorAll('.attr-editor-row').length, 1);
+ assert.doesNotMatch(spreadEditor.textContent, /Value/);
await act(async () => root.unmount());
dom.window.close();
console.log(
diff --git a/test/scope-completions.js b/test/scope-completions.js
index 43924487..4962530f 100644
--- a/test/scope-completions.js
+++ b/test/scope-completions.js
@@ -103,6 +103,15 @@ const year = new Date().getFullYear();`;
chipped('content === "content"').length === 1,
JSON.stringify(chipped('content === "content"'))
);
+ const typedNames = new Set(['src', 'as', 'string']);
+ const typedChips = scopeChips('src as string', typedNames).map((chip) =>
+ 'src as string'.slice(chip.from, chip.to)
+ );
+ check(
+ 'a TypeScript assertion chips only its runtime value',
+ typedChips.join(',') === 'src',
+ JSON.stringify(typedChips)
+ );
check('nothing in scope, nothing chipped', scopeChips('render && content', new Set()).length === 0);
if (failures.length) {
diff --git a/test/vars-panel.js b/test/vars-panel.js
index 3616c6d0..bb40c1bb 100644
--- a/test/vars-panel.js
+++ b/test/vars-panel.js
@@ -25,12 +25,19 @@ const settle = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms));
fs.mkdirSync(path.join(dir, 'src', 'styles'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'styles', 'tokens.css'),
- ':root {\n --blue: #0af;\n --ink: #111;\n}\n\n.light { --bg: white; }\n.dark { --bg: black; }\n'
+ ':root {\n --blue: #0af;\n --ink: #111;\n --_private: #f0f;\n}\n\n' +
+ '.light { --bg: white; }\n.dark { --bg: black; }\n' +
+ '.private { --_only: hidden; }\n'
+ );
+ fs.writeFileSync(
+ path.join(dir, 'src', 'styles', 'other.css'),
+ '.card { --lift: 2px; --shade: 4px; --_private: 8px; }\n'
);
- fs.writeFileSync(path.join(dir, 'src', 'styles', 'other.css'), '.card { --lift: 2px; --shade: 4px; }\n');
// One rule, so one group: there is no inside to show.
- fs.writeFileSync(path.join(dir, 'src', 'styles', 'motion.css'), ':root { --ease: linear; --duration: 200ms; }\n');
-
+ fs.writeFileSync(
+ path.join(dir, 'src', 'styles', 'motion.css'),
+ ':root { --ease: linear; --duration: 200ms; --_private: 1; }\n'
+ );
const esbuild = require('esbuild');
const buildDir = path.join(__dirname, '..', 'node_modules', '.stacki-test');
fs.mkdirSync(buildDir, { recursive: true });
@@ -89,7 +96,7 @@ const settle = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms));
await render();
check('stylesheets are listed', names().join('|') === 'motion.css|other.css|tokens.css', names().join('|'));
check(
- 'with what each one holds',
+ 'with counts that omit private variables',
all('.cms-collection-count').map((n) => n.textContent).join('|') === '2 variables|2 variables|4 variables',
all('.cms-collection-count').map((n) => n.textContent).join('|')
);
@@ -123,14 +130,18 @@ const settle = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms));
check('opening a stylesheet selects its first group', !!selected, JSON.stringify(selected));
check('which is the first one', selected?.index === 0 && selected.file.endsWith('tokens.css'), JSON.stringify(selected));
- check('and the groups inside are listed', names().join('|') === ':root|Light', names().join('|'));
+ check(
+ 'and groups without visible variables are omitted',
+ names().join('|') === ':root|Light',
+ names().join('|')
+ );
check(
'the open one is marked',
all('.cms-collection.on').length === 1 && all('.cms-collection.on')[0].textContent.includes(':root'),
all('.cms-collection').map((n) => n.className).join('|')
);
check(
- 'a group of modes says how many',
+ 'groups also omit private variables from their counts',
all('.cms-collection-count').map((n) => n.textContent).join('|') === '2 variables|2 modes',
all('.cms-collection-count').map((n) => n.textContent).join('|')
);
diff --git a/test/vars-view.js b/test/vars-view.js
index ce48ea65..953f9d6e 100644
--- a/test/vars-view.js
+++ b/test/vars-view.js
@@ -25,6 +25,8 @@ const STYLESHEET = `/* =========================================================
========================================================================== */
:root {
+ --_panel-private: #ff00ff;
+
/* Curves */
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
--ease-linear: linear;
@@ -172,6 +174,11 @@ const STYLESHEET = `/* =========================================================
// --- the :root group: a comment section and a family table ----------------
await show(0);
check('the sheet renders', !!find('.vars-table'), container.innerHTML.slice(0, 200));
+ check(
+ 'private variables are absent from the sheet',
+ !rowNames().some((name) => name.includes('_panel-private')),
+ rowNames().join('|'),
+ );
check('a comment becomes a heading', texts('.vars-section').includes('Swatches'), texts('.vars-section').join());
check('swatch names are rows', rowNames().includes('light-100'), rowNames().join('|'));
check('a colour gets a swatch', all('.u-color-swatch').length >= 3, `${all('.u-color-swatch').length}`);
From 9375bd4aba612430d915be853f603bbab74f629c Mon Sep 17 00:00:00 2001
From: Timothy Ricks
Date: Fri, 25 Sep 2026 06:20:53 -0500
Subject: [PATCH 3/3] v0.1.33
---
electron/main.ts | 36 +-
electron/morphClient.ts | 10 +-
electron/preload.ts | 1 +
package-lock.json | 534 ++++++++++++++++-
package.json | 7 +-
shared/ipc-payloads.ts | 1 +
shared/ipc-results.ts | 37 +-
shared/limits.ts | 2 +
shared/page-node.ts | 30 +-
shared/preload-api.ts | 1 +
src/App.tsx | 791 +++++++++++++++++--------
src/appBridge.ts | 27 +-
src/appTypes.ts | 1 +
src/bridge.ts | 8 +
src/codePanelModel.ts | 149 +++++
src/pagePersistence.ts | 21 +-
src/panels/CodePanel.tsx | 67 +++
src/panels/PreviewPane.tsx | 67 ++-
src/panels/PreviewSizeControls.tsx | 53 ++
src/panels/PreviewToolbar.tsx | 5 +
src/panels/StylePanel.tsx | 2 +
src/panels/previewViewport.css | 48 ++
src/panels/previewViewport.ts | 31 +
src/style-panel/EmbedEditor.tsx | 560 ++++++++++++++---
src/style-panel/embed-editor.css | 171 +++++-
src/style-panel/lib/css-rule-view.ts | 128 ++++
src/style-panel/lib/css.ts | 21 +-
src/style-panel/lib/host.ts | 4 +
src/style-panel/lib/resolved.ts | 71 ++-
src/style-panel/lib/selectors.ts | 54 ++
src/style-panel/lib/webflow.ts | 7 +-
src/style-panel/tokens.css | 4 +
src/styles.css | 62 ++
src/ui/CodeEditor.tsx | 200 ++++++-
src/ui/LeftRail.tsx | 4 +
src/ui/astroHighlight.ts | 209 +++++++
test/code-editor-lifecycle.js | 142 ++++-
test/code-panel-model.js | 77 +++
test/component-preview.test.js | 5 +-
test/contracts/main-boundaries.test.ts | 17 +-
test/contracts/page-node.test.ts | 10 +-
test/css-code.js | 90 ++-
test/lazy-panels.test.js | 179 ++++--
test/morph.js | 28 +
test/preview-lifecycle.test.js | 77 +++
test/section-dot.js | 37 ++
test/selector-well.js | 397 ++++++++++++-
test/style-target.js | 44 +-
48 files changed, 3972 insertions(+), 555 deletions(-)
create mode 100644 src/codePanelModel.ts
create mode 100644 src/panels/CodePanel.tsx
create mode 100644 src/panels/PreviewSizeControls.tsx
create mode 100644 src/panels/previewViewport.css
create mode 100644 src/panels/previewViewport.ts
create mode 100644 src/style-panel/lib/css-rule-view.ts
create mode 100644 src/ui/astroHighlight.ts
create mode 100644 test/code-panel-model.js
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