Skip to content

Feature: Visual Logic Editor - #96

Merged
jhweir merged 12 commits into
devfrom
feat/visual-logic-editor
Jul 31, 2026
Merged

Feature: Visual Logic Editor#96
jhweir merged 12 commits into
devfrom
feat/visual-logic-editor

Conversation

@jhweir

@jhweir jhweir commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Visual editing for conditions and bound content

Summary

Selecting a $if node or a data-bound we-text in the visual editor used to show a raw
JSON blob under "Dynamic props" — readable only to someone who already knows the operator
grammar. Worse, a token sitting in children had no editor at all: it isn't a string, so
the Content field skipped it, and the props-only scan never saw it. A we-text bound to
{ $store: 'spaceStore.currentSpace.name' } showed an empty panel, and a value-level
{ $if } in children showed 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 that
answers "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 $if condition in the built-in templates, 1780 of 1831
(97.2%) render as rows; the remainder are $find with a where clause, $not over a
comparison, and $concat operands.

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 empty
literal; { $count: { items: '' } } reads back as a count over an empty literal. Each
time, 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:

  • $if nodes get a "Show when" section — the condition as comparison rows with AND/OR
    grouping, over the values actually in scope at that node, instead of a JSON token.
  • Content handles every children shape — text, a value bound to data, or a
    conditional between two values, with a mode selector that converts between them in both
    directions. Token-valued children previously had no editor anywhere.
  • A grouped, searchable value picker over iteration variables, page state, store members
    and context refs — reused by every editor, and able to accept a typed path for anything
    the registry doesn't describe.
  • Dynamic props route through the same value editor, so a prop-level $if gets the
    builder too and only genuinely complex expressions stay as JSON.

Supporting fixes found along the way:

  • Store property lists now come from the model registry rather than a hand-maintained
    list that had drifted — spaceStore.currentSpace offered 5 properties and now offers 15.
  • $if branches re-read on every evaluation, so edits render live. A pre-existing
    renderer bug that affected every live-edited template, not just the new editors.
  • A lint rule for design-system props over the style/styles escape hatch, after the
    new components picked the habit up by copying their surroundings.

Changes

@we/schema-shared — the neutral model

  • scope.ts (new)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,
    because $local and iteration variables are used about as often as $store in real
    templates — 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 registry
    doesn'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/$or grouping, $not, $count, and the validation-state readers), for
    values (parseValue), for the prop-level $if (parseValueIf), and for classifying what
    shape a children array holds (classifyContent). Lives here rather than in the editor
    because it encodes token semantics, and because it is worth testing without Solid.

  • contextTypes.ts — adds StateMemberMeta.model, naming the model a store member
    holds instances of. StoreEntry.properties was hand-maintained and had drifted: it listed
    5 of Space's fields, so the picker could not offer access, discovery, url,
    coverImage, defaultThemeId or location, nor the base fields every Ad4mModel
    carries (id, author, createdAt, updatedAt) despite templates using them routinely.
    contextData.models already held the full generated definition, so the list was being
    duplicated by hand beside a correct copy.

  • propResolvers/conditional.tsresolveIfProp destructured condition/then/else
    once 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 findMutations patches
    tokens 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
    $arg path. This is a pre-existing renderer bug affecting every live-edited template, not
    just the new editors.

@we/app-framework — the editors

  • ValueRefPicker.tsx (new) — the grouped, searchable dropdown over everything in scope,
    plus OperandInput, which wraps it with plain-text and $count modes. Two persistent
    buttons (Aa for text, database for the picker) with the active mode highlighted, so
    every mode is one click from either. allowText={false} drops the text button where a
    literal makes no sense.

  • ConditionEditor.tsx (new) — comparison rows with AND/OR grouping. 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 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: the
    reference/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 a
    route back to the pickers; it used to be a one-way door.

  • ContentEditor.tsx (new) — owns children when it holds content rather than child
    nodes, with a Text / Data / Conditional mode selector that converts in both directions.
    Conversions carry content across: Text → Conditional seeds the then branch with the
    existing 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. $if nodes get a "Show when" section;
    Content handles every children shape; Dynamic props route through ValueEditor.
    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.

Repo hygiene

  • eslint.config.jsdesign-system/prefer-ds-props: flags style/styles object keys
    naming a CSS property that has a DS prop of the same meaning. CLAUDE.md already said to
    reserve styles for CSS with no DS equivalent, but the editor panels had drifted far
    enough 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 that
    accept DS props (we-*, 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
    because the repo lints with --max-warnings 0, so a warning fails 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.

  • packages/ai-context/*, CLAUDE.md, .cursor/rules/, .github/copilot-instructions.md
    regenerated alongside the fragments/stores.ts change, per the repo convention.

Known follow-ups

  • The DS-prop debt sweep. 7 files, 99 violations, listed in the
    design-system/prefer-ds-props-debt override. Delete entries as each is cleaned.
  • Cursor and Overflow are narrower than CSS. Cursor is 4 of ~35 keywords, so
    RightPanelContainer genuinely cannot express cursor: '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 until
    then.
  • Enum values aren't in the model registry. Space.access is 'personal' | 'shared' in
    practice but typed string, so comparing against it offers free text rather than a
    dropdown. Needs enum metadata on the model (@Property({ oneOf: [...] }) or inferring from
    a TS union).
  • Operator tokens inside children don't appear in the Layers tree. Selection is
    id-based and ensureNodeIds only assigns ids to SchemaNodes. They are reachable through
    the parent node's Content section, which is where you'd click anyway.
  • $concat has no builder. It is the largest remaining raw-JSON category in content
    positions (39 of 74 token-content nodes) and is really "text with values inserted" — a
    friendly control is possible.
  • Hand-maintained store metadata still drifts for members holding non-model shapes
    (TemplateSchema, ThemeData, AgentProfileSummary), which have no model registry entry
    to link to. The typed-path entry in the picker is the mitigation for now.
  • Action editing is untouched. $action still falls through to JSON. That was the
    planned next phase and needs structured action-parameter metadata (StoreEntry.actions is
    currently string[], with signatures existing only as prose).

Test plan

Verified:

  • pnpm --filter @we/schema-shared test — 538 pass (22 files), including 49 new tests
    across scope.test.ts, conditionModel.test.ts and propResolvers/conditional.test.ts
  • pnpm --filter @we/app-framework test — 62 pass
  • pnpm --filter @we/ai-context test — 10 pass
  • tsc --noEmit clean for @we/schema-shared and @we/app-framework
  • pnpm build succeeds for @we/schema-shared, @we/schema-solid, @we/app-framework
  • eslint clean across the touched packages; full-repo lint compared against the dev
    baseline — the new rule adds zero failures (12 pre-existing error files either side)
  • pnpm --filter @we/schema-shared validate — all 13 .schema.ts files validate against
    the regenerated context
  • Grammar coverage measured over the corpus: 1780/1831 conditions (97.2%) representable;
    74 token-content nodes that previously had no editor, 35 of which now get a real control
  • Scope resolution checked end-to-end against DefaultTemplate/routes/CardsRoute/SpacesList.ts
    — a $if inside $each over $query{Space} resolves $space.* from the model, plus
    page state and all seven stores
  • The new lint rule verified on a fixture: flags <we-select style={{ width }}> and
    <Column styles={{ 'max-height' }}>, ignores the DS-prop forms, white-space on
    we-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.access property and the stale
$if rendering 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 was
pushed into @we/schema-shared and tested there instead.

jhweir and others added 12 commits July 26, 2026 21:18
…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>
@netlify

netlify Bot commented Jul 31, 2026

Copy link
Copy Markdown

Deploy Preview for coasys-we ready!

Name Link
🔨 Latest commit 2e38d5f
🔍 Latest deploy log https://app.netlify.com/projects/coasys-we/deploys/6a6cba39ef579b0008e4ecdb
😎 Deploy Preview https://deploy-preview-96--coasys-we.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@jhweir
jhweir merged commit 768d304 into dev Jul 31, 2026
3 of 5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant