Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .plans/stars-demo-editor-gaps-implementation-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,12 @@ Mockups: `.plans/mockups/stars-bounds-event-no-code-actions.svg` and `.plans/moc

### 6F — Enforce the no-code boundary

- [ ] Do not add a Script, Expression, Formula, Callback Body, TypeScript, or JavaScript field anywhere in this workflow.
- [ ] Do not introduce implicit identifiers such as `bounds`, `exitSide`, or `self`; every available target, property, filter, and value source must be discoverable through labeled controls.
- [ ] Do not introduce callable functions such as `rand(...)`; Random Range is a typed value-source option with visible Min, Max, and Seed fields.
- [x] Do not add a Script, Expression, Formula, Callback Body, TypeScript, or JavaScript field anywhere in this workflow.
- [x] Do not introduce implicit identifiers such as `bounds`, `exitSide`, or `self`; every available target, property, filter, and value source must be discoverable through labeled controls.
- [x] Do not introduce callable functions such as `rand(...)`; Random Range is a typed value-source option with visible Min, Max, and Seed fields.
- [x] Keep the Set Property property list allowlisted and registry-described. Users cannot type arbitrary object paths such as `bounds.minX` or mutate unknown runtime state.
- [ ] Keep event fields available only through labeled selectors and compatible controls; do not expose an untyped event object or JSON expression context.
- [ ] Require a separate user-approved product proposal before any future in-editor scripting initiative. That proposal would need to define language choice, API/reference discovery, types, editor tooling, sandbox/security model, determinism, debugging, persistence/versioning, publishing, and migration independently of this demo.
- [x] Keep event fields available only through labeled selectors and compatible controls; do not expose an untyped event object or JSON expression context.
- [x] Require a separate user-approved product proposal before any future in-editor scripting initiative. That proposal would need to define language choice, API/reference discovery, types, editor tooling, sandbox/security model, determinism, debugging, persistence/versioning, publishing, and migration independently of this demo.

## Phase 7 — Assemble and Prove the Faithful Stars Workflow

Expand Down
47 changes: 39 additions & 8 deletions src/editor/Inspector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2112,6 +2112,13 @@ function AttachmentInspector({
: (scene.groups[attachment.target.groupId]?.name ?? attachment.target.groupId);
const eventBlockTrigger = attachment.eventId ? scene.eventBlocks?.[attachment.eventId]?.trigger : attachment.trigger;
const canTargetEventSource = eventBlockTrigger?.type === 'bounds';
const canUseEventFields = eventBlockTrigger?.type === 'bounds';
const eventFieldOptions = [
['positionX', 'Position X'],
['positionY', 'Position Y'],
['priorPositionX', 'Prior Position X'],
['priorPositionY', 'Prior Position Y'],
] as const;
const supportedPresetIds = new Set([
'MoveUntil',
'MoveTo',
Expand Down Expand Up @@ -2139,6 +2146,15 @@ function AttachmentInspector({
'RemoveSelfFromCollection',
]);
const supportedPresets = registry.actions.filter((entry) => entry.implemented && supportedPresetIds.has(entry.type));
const setPropertyEntry = registry.actions.find((entry) => entry.type === 'SetProperty');
const registryPropertyKeys = (setPropertyEntry?.propertyTargets ?? [])
.map((target) => String(target.key))
.filter((key): key is 'x' | 'y' | 'tint' | 'alpha' | 'visible' | 'vx' | 'vy' =>
key === 'x' || key === 'y' || key === 'tint' || key === 'alpha' || key === 'visible' || key === 'vx' || key === 'vy'
);
const allowedSetPropertyKeys = registryPropertyKeys.length > 0
? registryPropertyKeys
: (['x', 'y', 'tint', 'alpha', 'visible', 'vx', 'vy'] as const);
const params = attachment.params ?? {};
const world = getSceneWorld(scene);
const foldouts = useInspectorFoldouts();
Expand Down Expand Up @@ -2951,13 +2967,11 @@ function AttachmentInspector({
onUpdate({ ...attachment, params: { ...params, property, valueSource: { kind: 'constant', value: defaultValue } } });
}}
>
<option value="x">X</option>
<option value="y">Y</option>
<option value="tint">Tint</option>
<option value="alpha">Alpha</option>
<option value="visible">Visible</option>
<option value="vx">Velocity X</option>
<option value="vy">Velocity Y</option>
{allowedSetPropertyKeys.map((property) => (
<option key={property} value={property}>
{property === 'x' ? 'X' : property === 'y' ? 'Y' : property === 'tint' ? 'Tint' : property === 'alpha' ? 'Alpha' : property === 'visible' ? 'Visible' : property === 'vx' ? 'Velocity X' : 'Velocity Y'}
</option>
))}
</select>
</label>

Expand All @@ -2973,15 +2987,32 @@ function AttachmentInspector({
onUpdate({ ...attachment, params: { ...params, valueSource: { kind: 'randomRange', min: 0, max: 720, seed: 'set-property' } } });
return;
}
if (kind === 'eventField' && canUseEventFields) {
onUpdate({ ...attachment, params: { ...params, valueSource: { kind: 'eventField', field: 'positionX' } } });
return;
}
onUpdate({ ...attachment, params: { ...params, valueSource: { kind: 'constant', value: params.property === 'visible' ? true : 0 } } });
}}
>
<option value="constant">Constant</option>
{String(params.property ?? 'x') !== 'visible' && <option value="randomRange">Random Range</option>}
{canUseEventFields && String(params.property ?? 'x') !== 'visible' && <option value="eventField">Event field</option>}
</select>
</label>

{String((params.valueSource as any)?.kind ?? 'constant') === 'randomRange' ? (
{String((params.valueSource as any)?.kind ?? 'constant') === 'eventField' ? (
<label className="field">
<span>Event field</span>
<select
aria-label="Set Property Event Field"
data-testid="attachment-setproperty-event-field-select"
value={String((params.valueSource as any)?.field ?? 'positionX')}
onChange={(e) => onUpdate({ ...attachment, params: { ...params, valueSource: { kind: 'eventField', field: e.target.value } } })}
>
{eventFieldOptions.map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</select>
</label>
) : String((params.valueSource as any)?.kind ?? 'constant') === 'randomRange' ? (
<>
<div className="inspector-grid-2">
<label className="field">
Expand Down
6 changes: 6 additions & 0 deletions src/model/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,12 @@ function validateAttachments(scene: SceneSpec): void {
const targetB = JSON.stringify((block as any).target);
if (targetA !== targetB) throw new Error(`Attachment ${id} target must match eventBlock ${a.eventId} target`);
}
if (a.targetMode === 'event-source') {
const trigger = a.trigger ?? (a.eventId ? (eventBlocks as any)[a.eventId]?.trigger : undefined);
if (trigger?.type !== 'bounds') {
throw new Error(`Attachment ${id} event-source target requires a Bounds event trigger`);
}
}
if (a.trigger) validateAttachmentTrigger(a.trigger, `Attachment ${id} trigger`);
if (a.condition) validateInlineCondition(a.condition, `Attachment ${id} condition`);
if (a.presetId === 'SetProperty') validateSetPropertyAttachment(a, `Attachment ${id}`);
Expand Down
53 changes: 53 additions & 0 deletions tests/editor/attachment-inspector-set-property.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,4 +111,57 @@ describe('Attachment inspector Set Property', () => {
expect(boundsMarkup).toContain('Event source');
expect(startMarkup).not.toContain('data-testid="attachment-target-mode-select"');
});

it('keeps Set Property controls finite and exposes typed event fields by label', () => {
const scene = baseScene();
scene.eventBlocks = {
wrap: {
id: 'wrap',
target: { type: 'group', groupId: 'g1' },
trigger: { type: 'bounds', boundsEvent: 'wrapped', axis: 'y', side: 'any' },
} as any,
};

const markup = renderToStaticMarkup(
renderAttachmentInspector(
{
id: 'att-bounds',
target: { type: 'group', groupId: 'g1' },
eventId: 'wrap',
targetMode: 'event-source',
presetId: 'SetProperty',
enabled: true,
order: 0,
params: { property: 'x', valueSource: { kind: 'eventField', field: 'positionX' } },
} as any,
baseProject(),
scene,
{
arrange: [],
conditions: [],
actions: [{
type: 'SetProperty', displayName: 'Set Property', category: 'state', implemented: true,
propertyTargets: [
{ key: 'x', type: 'number' },
{ key: 'y', type: 'number' },
{ key: 'tint', type: 'color' },
{ key: 'alpha', type: 'number' },
{ key: 'visible', type: 'boolean' },
{ key: 'vx', type: 'number' },
{ key: 'vy', type: 'number' },
],
}],
} as any,
() => {},
() => {}
)
);

expect(markup).toContain('data-testid="attachment-setproperty-event-field-select"');
expect(markup).toContain('Position X');
expect(markup).not.toContain('bounds.minX');
expect(markup).not.toContain('rand(');
expect(markup).not.toContain('Script');
expect(markup).not.toContain('Expression');
});
});
5 changes: 5 additions & 0 deletions tests/model/validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,11 @@ describe('model validation', () => {
(scene.eventBlocks.ev1 as any).trigger = { type: 'bounds', boundsEvent: 'wrapped', axis: 'y', side: 'bottom' };
(scene.attachments.a1 as any).targetMode = 'script';
expect(() => validateSceneSpec(scene)).toThrow(/targetMode/i);

(scene.attachments.a1 as any).targetMode = 'event-source';
(scene.attachments.a1 as any).eventId = undefined;
(scene.attachments.a1 as any).trigger = { type: 'start' };
expect(() => validateSceneSpec(scene)).toThrow(/event-source.*bounds/i);
});

it('A17 project validation allows cloud and path asset sources', () => {
Expand Down
Loading