Feature: Visual Logic Editor - #96
Merged
Merged
Conversation
…odel Two framework-neutral pieces the visual editor needs to offer pickers instead of raw JSON. They live here rather than in the editor because they encode renderer semantics — what is in context at a node, and what the operator tokens mean — and so they can be unit-tested without Solid. scope.ts — getScopeAtNode() walks root→node and collects every reference the renderer would have in context there: $each/$single iteration variables, $local fields from $localState/$queries ancestors, store state, and the neutral context refs. Item fields are inferred from whatever backs `items`: a $query entity resolves through the model registry, a $store path through the store's declared properties, a literal array through its first object's keys. Groups are ordered nearest-scope-first, since $local and iteration variables are used about as often as $store in real templates. conditionModel.ts — a strict parse/serialize grammar over the boolean operators, plus the value-position helpers (parseValue / parseValueIf) for `children` entries and value-producing props. Strictness is the point: anything not representable exactly returns null so callers fall back to raw JSON, and the builder never silently rewrites an expression it only partly understood. Measured over every $if condition in the built-in templates, 1780/1831 (97.2%) are representable; the rest are $find with a where clause, $not over a comparison, and $concat operands. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nents The three controls that replace raw JSON editing in the inspector. Not wired up yet — that follows in the next commit. ValueRefPicker — the grouped, searchable dropdown over everything getScopeAtNode reports, plus a literal mode and a "count of a list" mode. OperandInput composes it into one control that expresses both "compare against this data" and "compare against this fixed value", typing the literal input from whichever side holds a known reference. Built to be reused by the action editor next. ConditionEditor — comparison rows with AND/OR grouping over a condition token. Keeps a draft separate from the prop so an in-progress row survives, and adopts external edits (undo/redo, AI changes) without clobbering it. A JSON toggle stays available at all times, so the builder is never a ceiling; conditions outside the grammar open straight into JSON with a "custom expression" note. ValueEditor — picks the narrowest editor a value token allows: the reference/ literal picker for plain values, a nested condition plus two branches for the prop-level $if, and the JSON editor for expressions with no row equivalent ($concat, $map, $plural, $action). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Wires the new controls into the inspector, replacing the "Dynamic props" JSON
blobs that were the only way to see or change a node's logic.
- $if nodes get a "Show when" section: the condition as comparison rows over the
values actually in scope at that node, rather than a JSON token.
- Content handles every `children` shape, not just a lone string. A token in
children previously had no editor anywhere — it isn't a string, so the text
field skipped it, and the props-only scan never saw it. So a we-text bound to
{ $store: 'spaceStore.currentSpace.name' } showed an empty panel, and a
value-level { $if } in children showed nothing at all on the node that held it.
Now: text → text area, a single token → value picker, a value-level $if →
condition plus then/else rows, several tokens → labelled JSON. Across the
built-in templates 74 nodes had token content and no editor; 35 now get a real
control and the rest are at least visible.
- Dynamic props route through the same ValueEditor, so a prop-level $if gets the
builder too and only genuinely complex expressions stay as JSON.
- SchemaNode-valued props ($if's then/else) no longer appear as JSON blobs: they
are whole subtrees, already navigable in the Layers tree, and listing them twice
buried the props that are only editable in the panel.
Known gap: operator tokens inside `children` still don't appear in the Layers
tree, since selection is id-based and ensureNodeIds only assigns ids to
SchemaNodes. They are reachable through the parent node's Content section.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Content section could show each shape but never convert between them: a value bound to data could be turned back into text (the picker's "use a fixed value" entry did that incidentally), but a conditional had no way out and plain text had no way in to either mode. Authoring anything other than text meant hand-editing JSON. Adds a mode selector — Text / Data / Conditional — that converts in both directions, and moves the section into its own ContentEditor component now that it carries real state. Conversions preserve what they can rather than clearing the field. Text → Conditional seeds the "then" branch with the existing text, so the natural way to author one is to write the common case first and then add the condition; Conditional → Text collapses back to that same branch. Switching mode never destroys content. Text conversions apply immediately since the result is always complete, but a half-built binding or conditional has no valid token to write — so the schema keeps rendering the old content and the panel says so, instead of writing a broken $if or silently clearing the node. "Custom" ($concat, $plural and friends) is a mode you can leave but not choose: it appears in the selector only while active, so those nodes can be converted to one of the real modes without touching JSON. classifyContent / contentAsText move to @we/schema-shared with the other value helpers — they answer "what shape is this children array", which is schema semantics rather than UI, and they are worth testing directly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CLAUDE.md says to reach for `styles` only for CSS with no DS equivalent, but the
editor panels had drifted far enough that copying the surrounding file actively
worked against the rule — InspectorPanel alone carries ~124 raw-style uses. The new
logic editors inherited that by imitation.
Converts every DS-expressible use in them: style={{ width }} → width=,
styles={{ 'max-height' }} → maxHeight=, style={{ flex: '1' }} → flex=,
we-scroll-area's own maxHeight prop, and three wrapper divs promoted to Column with
position/zIndex/top/left/mt/minWidth/maxWidth. What remains is deliberate:
white-space (no DS equivalent) and one <div ref> the outside-click handler needs —
no <Column ref=> precedent exists in the repo.
Adds an ESLint rule so this is mechanical rather than remembered. Core
no-restricted-syntax with an esquery selector — no new plugin — flagging
style/styles object keys that name a CSS property with a DS prop of the same
meaning.
Scoped so it does not cry wolf. The naive version flagged 521 sites repo-wide,
almost all raw <div style> where there is no DS prop to switch to; limiting it to
elements that actually accept DS props (we-* primitives, Column/Row/Grid/Card)
cuts that to 99 across 7 files. @we/components and @we/widgets are excluded — they
*implement* DS props and legitimately write raw CSS — as are the React playgrounds.
Error rather than warning: the repo lints with --max-warnings 0, so a warning
would fail CI identically while reading as advisory. The 7 pre-existing files sit
in an explicit debt override to be swept file by file; new files get the rule from
birth. Verified the rule adds zero failures against the dev baseline.
Known limit: a DS type union narrower than the CSS value space can make a hit
unfixable — RightPanelContainer needs cursor: 'ew-resize' and the Cursor type has
only four keywords. The fix there is to widen the DS type, not to disable the rule.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The value picker offered spaceStore.currentSpace.uuid and .name but not .access, so a condition on it could not be rebuilt after being deleted — the only way back was hand-editing JSON. The cause was that StoreEntry.properties is hand-maintained, and had drifted: it listed 5 of Space's fields. access, discovery, url, coverImage, defaultThemeId and location were all missing, as were the base fields every Ad4mModel instance carries (id, author, createdAt, updatedAt) — despite templates using .author and .createdAt routinely. contextData.models already holds the full, generated Space definition, so the field list was being duplicated by hand next to a correct copy. Adds StateMemberMeta.model, naming the model a member holds instances of, and resolves properties from the model registry when it is set (unioned with any declared ones, since a store may expose computed fields the model doesn't have). Annotates the four model-typed members: spaceStore.currentSpace, adamStore's personal/sharedSpaces, and spaceStore.signalTypes. spaceStore.currentSpace now offers 15 properties instead of 5, and stays correct as Space changes. The prose descriptions are corrected to match, and the generated context files are regenerated alongside the fragment per the repo's convention. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fixing the store metadata removes today's dead end, but the registry describes stores by hand and so will never be exactly complete — and no list can cover arbitrary nesting. Without a way to say "the path I want isn't offered", any gap sends the author back to the JSON editor. The search box now doubles as a path entry: when the typed text isn't already in the list, the picker offers it as a reference. `inferRefKind` decides what kind it is from the first segment — a known store name, a $localState field, or a $-prefixed context ref — and returns null when the root matches nothing known, so a typo can't create a token that silently reads undefined. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Editing a conditional's then/else — or swapping its condition — did not show up on the canvas. The change was in the schema, but the rendered output only caught up after the subtree remounted, which in practice meant leaving visual editing mode, changing route and coming back. resolveIfProp destructured condition/then/else from the token once, at resolve time, and closed over them. The memo below re-ran whenever the *data behind the condition* changed, so a store-driven conditional flipped branches correctly — but an edit to the branch values themselves was never read again. The visual editor renders from a Solid store and findMutations patches tokens in place, so no reference changes and nothing invalidates: exactly the case the eager destructure misses. (The renderer already had this lesson: its string-child branch calls resolveProp *inside* the reactive expression, with a comment saying why.) Reads the spec inside the memo instead, and inside the per-call closure on the $arg path so an edited branch takes effect on the next invocation rather than the next mount. Whether a condition uses $arg still decides the return shape once — that is a property of how the template was authored, not something an edit flips mid-session. Tests drive the resolver with a non-caching memo, so re-evaluation is observable without pulling Solid into this package: resolve, mutate the token, read again. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The button beside a literal input did nothing. It asked for reference mode by writing an empty reference, but an empty reference serializes to '' — which reads back as an empty *literal*. Callers that write through on every change (ValueEditor, used for the then/else branches) therefore bounced straight back to the literal input, and the control never appeared. Holds the intent as local UI state instead of deriving it from the operand. That also makes the switch non-destructive in both directions: nothing is written until a reference is actually chosen, so the node keeps its current value while the picker is open, and backing out via "use a fixed value" restores the literal that was already there rather than clearing it. The same button on the $count and validation-state rows had the same defect and is fixed by the same change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ffer both modes
Choosing "Count of a list…" wrote { $count: { items: '' } } straight to the schema.
That token is not valid — an empty `items` reads back as a count over an empty
literal — so the editor could not parse it back either, and dropped the field into
the raw JSON view showing a fragment with no indication of what to type. The count
row is now held locally until a list is actually picked, so the nested picker
appears immediately and the schema keeps its previous value until the choice is
complete.
Replaces the single mode-switch button with a persistent pair: Aa goes straight to
plain text entry, database opens the picker (whose footer still reaches list
counts). There had been three different icons for what is really two actions, and
reaching plain text from a count meant going through the picker first — two clicks
for a switch that should be one. Both are always present with the active mode
highlighted, so every mode is one click from either.
That makes the picker's own "Use a fixed value…" entry redundant, so it and the
allowLiteral prop are gone. A new allowText prop lets a caller drop the text button
where a literal makes no sense.
The render is an explicit Switch over a mode memo. Three nested Show/fallback pairs
had made the reading order the inverse of the logical one, and this adds a fourth
state.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The raw JSON editor was a one-way door. Once a value was an expression the builder can't represent — a $concat, or the malformed $count above — the only editor on offer was JSON, with no route back to a picked value or plain text. Adds the picker affordance beside the "custom expression" note: it swaps in the value control without writing anything, so the existing expression survives until a replacement is chosen. Restructures the branching as an explicit Switch over a mode memo, for the same reason as the sibling change: three levels of nested Show/fallback read in the inverse of their logical order. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A fixed value belongs on the right of a comparison — it names what you are testing against, and is usually the only way to author the condition at all, since a string like 'shared' exists nowhere in data. On the left it makes the condition constant. The built-in templates bear that out: across 1782 parsed conditions the left operand is a reference 1823 times and a literal 10, while the right is a literal 965 times against 135 references. So the left operand no longer offers the text button. Existing literals there still render in a text input — the mode is derived from the value, not the button — they just aren't encouraged, and the picker is one click away. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
✅ Deploy Preview for coasys-we ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Visual editing for conditions and bound content
Summary
Selecting a
$ifnode or a data-boundwe-textin the visual editor used to show a rawJSON blob under "Dynamic props" — readable only to someone who already knows the operator
grammar. Worse, a token sitting in
childrenhad no editor at all: it isn't a string, sothe Content field skipped it, and the props-only scan never saw it. A
we-textbound to{ $store: 'spaceStore.currentSpace.name' }showed an empty panel, and a value-level{ $if }inchildrenshowed nothing on the node that held it.This branch replaces that with row-based editors driven by what is actually in scope at
the node. Two framework-neutral pieces land in
@we/schema-shared— a scope resolver thatanswers "what can this node's props refer to?", and a strict parse/serialize grammar over
the boolean operators — with the Solid editors composed on top. The grammar is deliberately
strict: anything it can't represent exactly falls back to the JSON editor rather than being
approximated, so the builder never silently rewrites an expression it only partly
understood. Measured against every
$ifcondition in the built-in templates, 1780 of 1831(97.2%) render as rows; the remainder are
$findwith a where clause,$notover acomparison, and
$concatoperands.The recurring bug class found while testing is worth naming, because it bit in four
separate places: writing an in-progress state to the schema when it has no valid
serialized form. An empty reference serializes to
'', which reads back as an emptyliteral;
{ $count: { items: '' } }reads back as a count over an empty literal. Eachtime, the editor bounced straight out of the mode just requested. Mode is now local UI
state everywhere, and nothing is written until the choice is complete — which also means
backing out of a mode never costs you the value that was there.
What the branch delivers
Twelve commits. In the visual editor:
$ifnodes get a "Show when" section — the condition as comparison rows with AND/ORgrouping, over the values actually in scope at that node, instead of a JSON token.
childrenshape — text, a value bound to data, or aconditional between two values, with a mode selector that converts between them in both
directions. Token-valued
childrenpreviously had no editor anywhere.and context refs — reused by every editor, and able to accept a typed path for anything
the registry doesn't describe.
$ifgets thebuilder too and only genuinely complex expressions stay as JSON.
Supporting fixes found along the way:
list that had drifted —
spaceStore.currentSpaceoffered 5 properties and now offers 15.$ifbranches re-read on every evaluation, so edits render live. A pre-existingrenderer bug that affected every live-edited template, not just the new editors.
style/stylesescape hatch, after thenew components picked the habit up by copying their surroundings.
Changes
@we/schema-shared— the neutral modelscope.ts(new) —getScopeAtNode()walks root→node and collects every reference therenderer would have in context there:
$each/$singleiteration variables,$localfields from
$localState/$queriesancestors, store state, and the neutral contextrefs. Item fields are inferred from whatever backs
items(a$queryentity resolvesthrough the model registry, a
$storepath through the store's declared properties, aliteral array through its first object's keys). Groups are ordered nearest-scope-first,
because
$localand iteration variables are used about as often as$storein realtemplates — a store-only picker would have covered under half of real conditions.
inferRefKind()resolves a hand-typed path so the picker can offer paths the registrydoesn't list, returning null when the root matches nothing rather than guessing.
conditionModel.ts(new) — parse/serialize for conditions ($eq/$ne/$gt/$lt/$in,$and/$orgrouping,$not,$count, and the validation-state readers), forvalues (
parseValue), for the prop-level$if(parseValueIf), and for classifying whatshape a
childrenarray holds (classifyContent). Lives here rather than in the editorbecause it encodes token semantics, and because it is worth testing without Solid.
contextTypes.ts— addsStateMemberMeta.model, naming the model a store memberholds instances of.
StoreEntry.propertieswas hand-maintained and had drifted: it listed5 of
Space's fields, so the picker could not offeraccess,discovery,url,coverImage,defaultThemeIdorlocation, nor the base fields everyAd4mModelcarries (
id,author,createdAt,updatedAt) despite templates using them routinely.contextData.modelsalready held the full generated definition, so the list was beingduplicated by hand beside a correct copy.
propResolvers/conditional.ts—resolveIfPropdestructuredcondition/then/elseonce at resolve time and closed over them. The memo re-ran when the data behind the
condition changed, so store-driven conditionals worked, but an edit to a branch value was
never re-read. The visual editor renders from a Solid store and
findMutationspatchestokens in place, so no reference changes and nothing invalidates — the canvas only caught
up on remount. Now reads the spec inside the memo, and inside the per-call closure on the
$argpath. This is a pre-existing renderer bug affecting every live-edited template, notjust the new editors.
@we/app-framework— the editorsValueRefPicker.tsx(new) — the grouped, searchable dropdown over everything in scope,plus
OperandInput, which wraps it with plain-text and$countmodes. Two persistentbuttons (
Aafor text,databasefor the picker) with the active mode highlighted, soevery mode is one click from either.
allowText={false}drops the text button where aliteral makes no sense.
ConditionEditor.tsx(new) — comparison rows with AND/OR grouping. Keeps a draftseparate from the prop so an in-progress row survives, and adopts external edits
(undo/redo, AI changes) without clobbering it. A JSON toggle is available at all times, so
the builder is never a ceiling. Text entry is offered on the right operand only: a fixed
value there is how you name what you're testing against and is often the only way to
author the condition at all, whereas on the left it makes the condition constant. The
corpus bears this out — the left operand is a reference 1823 times and a literal 10, while
the right is a literal 965 times against 135 references.
ValueEditor.tsx(new) — picks the narrowest editor a value token allows: thereference/literal picker for plain values, a nested condition plus two branches for the
prop-level
$if, and JSON for expressions with no row equivalent. The JSON view now has aroute back to the pickers; it used to be a one-way door.
ContentEditor.tsx(new) — ownschildrenwhen it holds content rather than childnodes, with a Text / Data / Conditional mode selector that converts in both directions.
Conversions carry content across: Text → Conditional seeds the
thenbranch with theexisting text, which is also the natural way to author one. "Custom" (
$concat,$plural)is a mode you can leave but not choose, so those nodes can be converted without touching
JSON.
InspectorPanel.tsx— wires the above in.$ifnodes get a "Show when" section;Content handles every
childrenshape; Dynamic props route throughValueEditor.SchemaNode-valued props (
$if'sthen/else) no longer appear as JSON blobs — they arewhole subtrees, already navigable in the Layers tree, and listing them twice buried the
props that are only editable in the panel.
Repo hygiene
eslint.config.js—design-system/prefer-ds-props: flagsstyle/stylesobject keysnaming a CSS property that has a DS prop of the same meaning. CLAUDE.md already said to
reserve
stylesfor CSS with no DS equivalent, but the editor panels had drifted farenough that copying the surrounding file worked against the rule, which is exactly how the
new components picked it up. Core
no-restricted-syntax, no new plugin.Scoped so it doesn't cry wolf: the naive version flagged 521 sites repo-wide, almost all
raw
<div style>where there is no DS prop to switch to. Limiting it to elements thataccept DS props (
we-*,Column/Row/Grid/Card) cuts that to 99 across 7 files.@we/componentsand@we/widgetsare excluded — they implement DS props andlegitimately write raw CSS — as are the React playgrounds. Error rather than warning
because the repo lints with
--max-warnings 0, so a warning fails CI identically whilereading as advisory. The 7 pre-existing files sit in an explicit debt override to be swept
file by file; new files get the rule from birth.
packages/ai-context/*,CLAUDE.md,.cursor/rules/,.github/copilot-instructions.md—regenerated alongside the
fragments/stores.tschange, per the repo convention.Known follow-ups
design-system/prefer-ds-props-debtoverride. Delete entries as each is cleaned.CursorandOverfloware narrower than CSS.Cursoris 4 of ~35 keywords, soRightPanelContainergenuinely cannot expresscursor: 'ew-resize'through a DS prop —the one case where the new lint rule flags something unfixable. The fix is widening the
type in
@we/design-types, not disabling the rule. That file is in the debt list untilthen.
Space.accessis'personal' | 'shared'inpractice but typed
string, so comparing against it offers free text rather than adropdown. Needs enum metadata on the model (
@Property({ oneOf: [...] })or inferring froma TS union).
childrendon't appear in the Layers tree. Selection isid-based and
ensureNodeIdsonly assigns ids to SchemaNodes. They are reachable throughthe parent node's Content section, which is where you'd click anyway.
$concathas no builder. It is the largest remaining raw-JSON category in contentpositions (39 of 74 token-content nodes) and is really "text with values inserted" — a
friendly control is possible.
(
TemplateSchema,ThemeData,AgentProfileSummary), which have no model registry entryto link to. The typed-path entry in the picker is the mitigation for now.
$actionstill falls through to JSON. That was theplanned next phase and needs structured action-parameter metadata (
StoreEntry.actionsiscurrently
string[], with signatures existing only as prose).Test plan
Verified:
pnpm --filter @we/schema-shared test— 538 pass (22 files), including 49 new testsacross
scope.test.ts,conditionModel.test.tsandpropResolvers/conditional.test.tspnpm --filter @we/app-framework test— 62 passpnpm --filter @we/ai-context test— 10 passtsc --noEmitclean for@we/schema-sharedand@we/app-frameworkpnpm buildsucceeds for@we/schema-shared,@we/schema-solid,@we/app-frameworkeslintclean across the touched packages; full-repo lint compared against thedevbaseline — the new rule adds zero failures (12 pre-existing error files either side)
pnpm --filter @we/schema-shared validate— all 13.schema.tsfiles validate againstthe regenerated context
74 token-content nodes that previously had no editor, 35 of which now get a real control
DefaultTemplate/routes/CardsRoute/SpacesList.ts— a
$ifinside$eachover$query{Space}resolves$space.*from the model, pluspage state and all seven stores
<we-select style={{ width }}>and<Column styles={{ 'max-height' }}>, ignores the DS-prop forms,white-spaceonwe-text, and raw<div style>Manual testing in the running app was done by @jamesabbott across several rounds, which is
where the four in-progress-state bugs, the missing
Space.accessproperty and the stale$ifrendering were found; each was fixed and re-tested in the app.Not covered by automated tests: the Solid components themselves have no component tests —
@we/app-framework's vitest config has no Solid plugin, so the logic they depend on waspushed into
@we/schema-sharedand tested there instead.