Skip to content

Feature: Model authoring - #123

Merged
jhweir merged 49 commits into
devfrom
feat/model-authoring
Aug 20, 2026
Merged

Feature: Model authoring#123
jhweir merged 49 commits into
devfrom
feat/model-authoring

Conversation

@jhweir

@jhweir jhweir commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Model authoring: spaces define their own content models

Branch: feat/model-authoring (from dev) — 49 commits, 75 files.

Summary

Communities can now create and edit content models in a space — through a structured wizard, or by
naming one and having the space's language model fill in the rest — and tune the interpretation
hints AI extraction runs on, per space, for core vocabulary (TaskBlock, EventBlock) and their own
models alike.

The architecture is one IR, two UIs, one adoption gate. Both authoring routes produce the
same draft, lowered onto the same ModelManifest, validated by the same gate module manifests pass,
stored in the space as a Shape record (the Template storage precedent: one flag-typed entity plus
the definition JSON in a file-storage property), compiled per-dataset, and projected to SHACL — after
which $query, includes and extraction see the entity through machinery that already existed (the
foreign-schema rail). The manifest document is the source of truth; the SHACL is its staleness-checked
projection, exactly the relationship decorated classes have with theirs. This is stage-0
infrastructure for the content-models plan: created models are the flat-record corner of the shape
continuum, and stage A extends this IR rather than replacing it.

The wizard went through five rounds of manual testing, and most of the second half of this branch is
what that found. The thread running through it: what the machine wrote and what the author wrote
were never distinguished
, so a generation could neither be re-run safely nor produce a coherent
second answer. That distinction is now explicit and is what makes the AI route usable rather than
one-shot.

Changes

The IR

Manifest fidelity (backend-shared/manifest.ts, manifestEntry.ts, ad4m/manifestCompiler.ts,
neutralManifest.ts, perspectiveHelpers.ts, models/scripts/generateCoreManifest.mjs +
regenerated coreManifest.ts, golden tests)

  • PropertySchema gains interpretationHint, identity, options; EntitySchema gains
    interpretationHint. The declared-manifest path was the one path that silently dropped hints — it
    can now carry everything the executor reads off a stored shape.
  • Threaded through every projection (compiler decorators, SHACL→entry, entry→neutral, generator); the
    golden test compares hints and identity, so a future miss fails loudly instead of surfacing as "the
    model extracted nothing".
  • validateManifest: at-most-one-identity rule, options type/default checks, externalEntities
    (targets may name core vocabulary or sibling shapes), and a fix for abstract being silently
    stripped by the Zod schema.

Validation at registration (moduleRegistry.ts) — definition.entities.manifest passes
validateManifest before registration, instead of failing on the first dataset switch, far from the
module that shipped it.

Space-owned interpretation

Hints (ad4m/interpretationHints.ts, sdnaModels.ts, backendPorts.ts, both adapters)

  • New SchemaPort surface: interpretationHints / setInterpretationHints /
    resetInterpretationHints, keyed by predicate.
  • A we://interpretation_customized marker on the shape node makes hints space-owned: shapeIsStale
    yields on hints for marked shapes (structure — paths, identity — stays code-owned and compared as
    before), so a community's tuning survives space switches. Reset rewrites the declared hints and
    clears the marker, so release improvements flow again.
  • Known v1 edge, documented in the module: a structural refresh rewrites the shape graph and drops
    customizations.

Reading hints back (shared/shapes/hintEditor.ts + test) — hintToDisplay states one rule for
the class hint and every property hint, which previously disagreed. An empty hint is stored by
removing its link, so "cleared on purpose" and "never touched" read back identically; the customized
marker is the tiebreak. Not customized → an absent hint means the declaration applies. Customized →
the space owns these, so an absent hint is one somebody deleted and must not be resurrected. The old
property branch reached for the declaration only when nothing at all was stored — never true for a
saved model — so every property opened blank while the class hint came through correctly.

The Shape entity and its adoption rail

models/entities/Shape.ts, modelRegistry.ts, backendPortsAdapter.ts, app-shell ShapeStore.tsx,
shared/shapes/shapeDraft.ts

  • Shape: name, description, icon, shapeId (stable identity URI — the ecosystem convergence
    mechanism), forkedFrom lineage, version, definition (manifest JSON). Installs with the space
    SDNA set; predicates and the type flag are resolved before storing under we://shape/<uuid>/…, so
    definitions are self-describing and a rename can never re-mint the storage keys existing data lives
    under.
  • SchemaPort.declareInDataset + mergeDynamicModels: compiled shape classes register in their own
    dataset only (native classes still shadow everything), additive alongside the foreign-schema sweep's
    replace semantics.
  • ShapeStore owns the rail — load → validate → compile → register → ensure SHACL, per space switch;
    invalid definitions surface as per-shape problems rather than vanishing.
  • Draft↔manifest conversion is pure and tested (shared/shapes/shapeDraft.ts): identifier/type/option
    validation with wizard-facing messages, predicate preservation through edit round-trips, and the
    v1 additive-only edit guard. additiveViolations compares what each surviving predicate
    means, not merely that it survives: a count: number edited to count: text keeps the storage key
    and silently changes how every stored value reads. Kind changes, scalar type changes, relation
    re-targets and many→one are refused; one→many is the single widening that needs no migration.
  • draftMember is now the one supported way to build a row, and always ends in syncDerived.
    Spreading a blank row and assigning over it — what every construction site did — left defaultOptions
    describing the blank, so a generated select arrived carrying its allowed values beside a default
    picker offering only "None", and only retyping those values ever put the two in step. Booleans had
    the same fault.

The wizard

templates/default … SettingsRoute/ModelsSection.ts, ShapeStore.tsx, ai-context store entries

A Models card in space settings: the space's models with problems visible, the wizard, per-space
AI-hint tuning with the customized badge and reset lifecycle, and delete with confirm (data and SDNA
remain; only the definition goes).

  • A draft row is a member of one of the IR's two kinds: property (type/required/hint/options/
    default) or relationship (target + one-or-many). It was one row with a reference pseudo-type
    that hid half its own inputs — a tagged union pretending not to be one, when EntitySchema has
    carried properties and relations as separate maps all along.
  • Identity is one picker below the fields rather than a switch per row: at most one member can be
    it, so N independent switches could express a violation the save then had to refuse. Keyed by a
    draft-local row id, so renaming or reordering the chosen field keeps the choice. Labelled
    "Identifying field" — "Unique field" would promise a constraint nothing enforces.
  • Drag-to-reorder, since declaration order is what the manifest stores and will be the field order
    of the derived creation form. This needed two fixes in we-sortable first, both no-ops for an item
    without form controls: an optional [data-we-handle] grab area, and a rule that Space or Enter
    arriving from a text-entry element is typing rather than a pickup — without which a hint field could
    not accept spaces at all.
  • Row buttons are pinned top-right as the drag handle's counterpart, outside the wrapping content, so
    a narrow modal no longer folds them under the fields where they read as belonging to whichever field
    they landed beside. Required is a checkbox rather than a switch: a switch says "this takes effect
    now", which is a setting, and its label changed width as it toggled.
  • A collapsed property shows its hint as a truncated footnote, which is what let generation stop
    expanding every row it produced. The condition leads with the reactive operand deliberately —
    $and short-circuits, and leading with the non-reactive $member.hint left rows that mount without
    one with no reactive dependency at all, so the line could never appear however much was typed.
  • Relationship targets are grouped and labelled — this space's models, block types, other apps' — with
    core infrastructure (Template, Theme, AgentSettings) no longer offered.

AI authoring

shared/ai/shapeGeneration.ts, backend-shared/languageModel.ts, ad4m/languageModelPort.ts,
ShapeStore.tsx

One forced tool call whose schema mirrors the draft — the closed type set is unrepresentable to get
wrong — validated through the same gates as a hand-built draft, with up to two tool-result repair
turns. The result lands in the wizard with any remaining problems displayed: generation proposes, the
human saves.

  • It runs on the node's own language model through BackendPorts.languageModel, with the agent's
    personal Anthropic key as fallback. Gating on the key alone hid the whole AI surface from anyone who
    had not used the template editor, however capable their node was.
  • Generation re-runs. The old guard latched off the moment any row was touched — which every
    generated row is — so the first attempt was the only attempt, and spending it on a bare name was a
    trap. generateIntent replaces it: generate (nothing to lose), regenerate (a proposal nobody
    has touched, so one click and no dialog), replace (rows somebody wrote, so it asks first), none
    (nothing to work from — the only disabled state). The store decides, because only it can tell a
    proposal from somebody's own work.
  • A re-run follows the rename. The prompt was built from whatever the draft held, so a previous
    generation's own description and hint were quoted back as part of the next question: renaming a
    model to "MovieNight" while its generated description still discussed books returned a model about
    both. authoredFields draws the line — a field still holding exactly what generation put there is
    output, not intent, so it neither steers the next prompt nor survives it, while anything typed does
    both. A side effect worth having: pressing Regenerate twice on an untouched draft is now a fresh
    attempt rather than a slow convergence on the first.
  • The button sits beside the name, where a name is all it needs and where it can no longer claim to
    produce only "fields" — it answers the description, hint and icon too, for anything left blank.

Design system

Fixes found by building the wizard, all general enough to belong where they live:

  • we-modal: scrollbar-gutter: stable, so crossing the scroll threshold is not also a relayout —
    expanding one section used to take the bar's width out of the content box and narrow every control.
    Modals scroll their content only, with header and footer slotted out of the scroll region.
  • ConditionalRenderer: a reveal wrapper relaxes its automatic minimum on both axes. A grid item's
    automatic minimum is its content on either axis, so a block-revealing section kept min-width: auto
    — and nowrap text has a min-content width of the whole line, which pushed such a section out of its
    container and took its own ellipsis out of reach.
  • we-select gains an opt-in fit sized to its widest option, and keeps that width on hover;
    we-number-input sizes from its content, measured rather than guessed; we-date-picker keeps its
    icon inside the control and themes its time row; we-form-field gains the type ramp we-text
    already defines, with the description above the control; we-icon-picker scales its text with its
    size; anchored panels float in the top layer through one shared floating-panel helper; dark-scheme
    native widgets; overflow reaches [part='base']; z-index tokens.

CI

One commit here is not about model authoring — the workflow change — and would land faster as its own
PR off dev, since dev is red without it and nothing on any branch can go green until it lands.
The two fixes below it are this branch's own defects, so they belong here whatever happens to the
workflow change.

The required build now installs the versions this repo pins. It used to clone AD4M, build its SDK
from source and point pnpm.overrides at the result — judging every WE change against whatever AD4M
dev held at the moment the job ran, after verifying and then discarding an install of the pinned
versions. So the same WE commit could go from green to red with no WE change, and did: dev has been
red since 18 Aug, when WE took a dedup fix that exists in a published tag and on an AD4M feature
branch but not on AD4M dev. The stated justification — broken workspace: refs in the published
packages — does not hold: @coasys/ad4m publishes none at all, and @coasys/ad4m-connect's single
one sits in devDependencies, which a consumer never installs.

Testing WE against unreleased AD4M keeps its value and moves to ad4m-compat.yaml — nightly against
AD4M dev, on demand against any branch — where it can warn without gating, and where a cross-repo
change is requested explicitly rather than inferred from two repositories sharing a branch name. Two
things fall out: the install can be frozen, and the generated-files check drops all three exclusions,
which existed only to hide the workflow's own rewrites.

It immediately exposed two defects of this branch's own, both of which had been sitting behind a
failure that had nothing to do with them — which is the argument for the change in miniature:

  • @we/design-utils reached for String.replaceAll (ES2021) while the workspace targets ES2020, so
    that package's tsc --noEmit failed. tsup transpiles without checking against a package's lib,
    so every build passed, and CI never reached its Typecheck step. Fixed with a shared global regex
    rather than retargeting the workspace, which is not this PR's call to make.
  • Three date-picker assertions were pinned to a locale. The picker formats through
    toLocale*String(undefined, …) deliberately — a 12-hour reader should read "2:30 PM" while
    "14:30" is what gets stored — so toMatch(/14:30/) asserted the machine it was written on and
    failed on a runner formatting US English. Two siblings had already met this and hedged with
    /2:30|14:30/, which is weaker still: that alternation passes even when the picker renders the
    wrong one. All three now derive their expectation from the same Intl call the component makes,
    so they are stricter than before (toBe against a computed reference) and say nothing about
    locale.

Known follow-ups

  • Renaming or removing properties (and renaming models) waits on the shape-versioning/migration design
    note (content-models plan stage F); v1 is additive-only by guard.
  • Space shapes cannot extends WeNode yet (cross-manifest extends isn't compilable), so comments and
    signals don't attach to shape instances.
  • Structural refreshes drop hint customizations (documented in interpretationHints.ts); needs the
    merge story from the versioning note.
  • Derived creation forms for shape instances (content-models stage D) — shapes are queryable and
    extractable now, but templates render them by hand.
  • Concurrent shape edits are last-write-wins on the definition blob (Template precedent).
  • generateShapeDraft (prose → whole draft) has no control wired to it; it stays as the engine a
    spoken route would need. Decide whether to keep or drop it before merge.
  • Built-in models offer hint tuning only. Extending a core model with a space's own slots, and forking
    one with lineage, are the structural counterparts — see the content-models plan.
  • A long hint's truncation is pinned inline via styles, because something in the running app relaxes
    it on hover and the rule was not found by reading (searched: every :hover in the design system, the
    interop stylesheet, we-sortable, the themes). Worth another look if the same symptom appears
    elsewhere.
  • The we-preview harness was not exercised.

Test plan

  • The whole required CI pipeline, run locally as CI now runs it — pnpm lint, pnpm lint:css,
    pnpm build, pnpm validate:schemas, pnpm typecheck, pnpm test: all pass, against the
    pinned dependency versions. This is the point of the CI change: the pipeline a developer can
    run is the pipeline that gates the merge
  • pnpm build leaves the tree clean with no exclusions, so the strengthened generated-files
    check passes
  • The full suite under CI's own locale and timezone — LANG=en-US TZ=UTC, and again under
    America/Los_Angeles, since a western timezone shifts dates as well as formats. 2,253 tests,
    no failures, so nothing else in the workspace depends on the machine it runs on. (Confirmed
    first that LANG does move Node's ICU locale here, so the runs meant something: en-US
    reproduces CI's "2:30 PM" exactly)
  • we-validate-schemas — 28 schemas, no issues
  • Manual, five rounds through the wizard — each round's findings are in the branch's commits:
    first-contact bugs, the default-value controls, the property/relationship split, the AI route,
    and the layout/animation pass
  • New tests: draft↔manifest round-trip and edit guards; the draftMember factory deriving pickers
    at construction (including a generated select and boolean); authoredFields and
    memberSignature; hintToDisplay's four cases; staleness yield rules; golden manifest tests
    comparing hints and identity; we-sortable's handle and text-entry rules; the reveal wrapper's
    minimum on both axes
  • Manual: create a model via the wizard and via Generate; query it with $query; edit additively;
    edit TaskBlock hints, run extraction, switch spaces, confirm hints survive; reset hints
  • Manual: open the AI hints editor on a saved model and check whether the badge reads "Using
    defaults" or "Customized for this space". The fallback fix stops masking the difference: if the
    hints show but the badge says defaults, they may not be reaching the SHACL — and since the
    executor builds extraction prompts from the shapes stored in the perspective, that would make
    them inert for extraction
  • Manual: a second peer joins the space and sees/queries the shape
  • Manual: drag a field by its handle to reorder; reorder from the keyboard (Tab to a handle, Space,
    arrows, Space); confirm typing spaces in a hint field still types spaces

jhweir and others added 30 commits August 18, 2026 20:00
The declared-manifest path — the one that makes a model definition data —
was also the one path that silently dropped interpretation hints and the
identity dedup key. PropertySchema/EntitySchema now carry them, the
compiler threads them through the same decorators hand-written models
use, the generator emits them, and the golden test compares them so a
future miss fails loudly instead of surfacing as 'the model extracted
nothing'. Also adds manifest-level 'options' (closed value sets) for
forms and prompts, an at-most-one-identity validation rule, and fixes
'abstract' being stripped by the Zod schema on validated manifests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The executor reads hints off the shapes stored in the perspective, so the
space was always the natural override point — but shapeIsStale rewrote any
stored shape that differed from the class declaration, reverting a tuned
hint on the next space switch. A we://interpretation_customized marker on
the shape node now records the community's decision: the staleness check
yields on hints (class and property) for marked shapes while structure —
paths and the identity dedup key — stays code-owned and compared as before.

New SchemaPort surface (interpretationHints / setInterpretationHints /
resetInterpretationHints, keyed by predicate) implemented on both backends;
reset rewrites the declared hints and clears the marker so release
improvements flow again. Known v1 edge, documented in interpretationHints.ts:
a structural refresh rewrites the whole shape graph and drops customizations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
moduleRegistry handed definition.entities.manifest straight to
schemas.declare(), so a malformed manifest registered fine and failed on
the first dataset switch, far from the module that shipped it. Refused at
registration instead, through the same loud path as predicate violations —
a gate that must be unbypassable before user-authored manifests share this
compile path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A Shape record is one content model a space carries: scalar metadata
(name, icon, stable shapeId identity, forkedFrom lineage, version) plus
the ModelManifest JSON in a file-storage property — the Template storage
precedent applied to models. Definitions are stored with every predicate
and type flag already resolved, so a reading peer compiles them without
any minting rule and a rename can never re-mint the predicates existing
data lives under.

Substrate to carry it: validateManifest accepts externalEntities so a
shape may target core vocabulary and sibling shapes; compileManifest
accepts resolveExternal for the same reach at compile time; a new
SchemaPort.declareInDataset registers compiled classes per dataset
(mergeDynamicModels — additive, unlike the foreign-schema sweep's
replace) so a shape resolves in its own space and nowhere else, with
native classes still shadowing everything. Shape installs with the
space SDNA set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On every switch into a WE space, Shape records are read, their manifest
documents validated through the same gate module manifests pass (against
core vocabulary, foreign entities and sibling shapes), compiled and
registered for that dataset only, and their SHACL ensured into the space —
after which $query, forms and extraction see the entity through machinery
that already existed. Invalid definitions surface as per-shape problems
rather than vanishing.

The store also owns both editing surfaces' state: the wizard draft (shared
by the structured editor and, next, the LLM flow — one review path) with
the v1 additive-only edit guard and predicate preservation from
shared/shapes/shapeDraft.ts, and the hint editor with the customized/reset
lifecycle over SchemaPort.interpretationHints. Classified space-settings in
the capability tiers: defining what a Sighting is here is the community
shaping its own container.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A Models card in the default template's settings route: the space's own
models listed with their adoption problems visible, a New model wizard
(name/icon/description, class-level AI hint, property rows over the closed
type set — text/number/boolean/date/select/reference — with required,
identity, per-property hints, defaults, and reference targets picked from
what this space can name), and per-space AI-hint tuning for core
vocabulary and space shapes alike, with the customized badge and
reset-to-defaults lifecycle.

All form state binds to shapeStore (the aiForm precedent) so the LLM flow
lands in the identical review path; the only $localState is the delete
confirmation. shapeStore documented in the ai-context store entries so the
schema validator and CLAUDE.md know it; draft option lists become one
comma-separated string field, since a form bound to two representations
of one value is how it drifts from what it saves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
generateShapeDraft turns a plain-language description into the same
ShapeDraft the wizard edits, via one forced define_model tool call whose
schema mirrors the draft — the closed type set is unrepresentable to get
wrong. The candidate is lowered and gated exactly as a hand-built draft
(draftToManifest + validateManifest + name-collision check); refusals go
back to the model as tool_result errors for up to two repair turns, and
whatever survives lands in the open wizard with any remaining problems
displayed. Generation proposes, the human saves — nothing reaches the
space without the same review path the structured editor uses.

Offered for new models only (a generated draft would replace stored
predicates wholesale on an edit) and gated on the agent's Claude API key.
Lives beside aiInfra as part of the same browser-calls-a-model surface a
backend-executed assistant would replace.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New model refused to open: a schema $action with no declared args passes
the DOM event as the first argument, so openShapeWizard read a MouseEvent
as a record id, found no such shape, and toasted. Anything but a real id
now means 'new model'.

Typing dropped focus after every character: the renderer keys $each rows
by object reference and captures context refs as plain values, so the
immutable-replace setters remounted the very input being typed in on each
keystroke. Typed-field edits (names, hints, defaults, options) now mutate
the draft in place — the DOM already shows the text, and save() reads the
mutated state — while discrete edits (type, switches, targets) keep the
replace so a row's conditional inputs re-render.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…by drag

The row was one form with a 'reference' pseudo-type that hid half its own
inputs — a tagged union pretending not to be one, when EntitySchema has
carried properties and relations as separate maps all along. Now two add
buttons and two row shapes: a property has type/required/hint/default, a
relationship has a target and one-or-many and nothing else.

Drag-to-reorder, because declaration order is the order the manifest
stores and will be the field order of the derived creation form. That
needed two fixes in we-sortable first, both no-ops for an item without
form controls: an optional [data-we-handle] grab area (otherwise dragging
to select text starts a drag), and a rule that Space or Enter arriving
from a text-entry element is typing rather than a pickup — without which
a hint field could not accept spaces at all, since the keyboard pickup
would swallow them.

Also from testing the wizard:
- Optional/Required on the switch, which had the same word on both sides.
- Identity is a single picker below the fields, not a per-row toggle: at
  most one member can be it, and N switches can express a violation the
  save then has to refuse, where one picker cannot. Keyed by a draft-local
  row id so renaming or reordering the chosen field keeps the choice.
- Delete pinned to the row's corner. It was wrapping onto its own line
  because the switch group claimed the slack; anchoring it there also
  stops it moving as a row's conditional inputs change.
- The relationship target picker is grouped and labelled — this space's
  models, then block types, then other apps' — and no longer offers core
  infrastructure (Template, Theme, AgentSettings) that no community shape
  should point at. This is also where 'add a photo' becomes discoverable.

And a guard the predicate check missed: a surviving predicate can still be
redefined out from under its data (count: number edited to count: text
keeps the storage key and changes how every stored value reads).
additiveViolations now compares what each predicate means — kind, scalar
type, relation target, and many→one — allowing one→many as the one
widening that needs no migration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
size drove the control's height and nothing else: the trigger's label and
placeholder pinned --we-font-size-400 in the shadow CSS, and its preview
and caret icons hardcoded size="sm". A picker asked for at sm came out
the right height holding full-size text.

Brought in line with its siblings — we-select, we-input and
we-date-picker all pair the height map with a SIZE_DEFAULTS type scale
merged through getInstanceProps, and sized primitives set
--we-context-icon-size per :host([size=…]) so nested icons follow. size
now reflects, which is what those host rules match on; md is unchanged,
so nothing that did not ask for a size moves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lacing it

A description tells you how to fill a field in, so it has to be read
before you do; an error is a reaction to what you already did. They sat
in one slot rendered as 'error ? error : description', which put the
instructions after the control and — worse — deleted them the moment the
reader got the value wrong. aria-describedby already named both ids, so
the markup was the only thing insisting they were alternatives.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… labels

Description and AI hint sat next to each other as near-identical
textareas whose placeholders paraphrased their own labels, so nothing
said why there were two. The explanation moves into each field's
description — where it persists while you type and is read out with the
control — and the placeholders become worked examples that differ in
grammatical register: a noun phrase for what the record is, an
instruction for when AI should make one. The hint example carries an
include-then-exclude clause, which is the shape a workable hint needs and
is easier to copy than to explain.

Examples are book recommendations throughout rather than bird sightings:
an AI hint only matters when extraction runs over conversations, so the
example has to be something people actually say to each other.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
All three parts were font-size 300 — 'body' — so a field's label rendered
at the same size as its own help text and only colour separated them.
Worse, a form-field label sat a step larger than the we-text
variant='label' beside it: both spellings appear ~45 times each across
the templates, often in one view, so the app carried two label sizes.

Taken from we-text's own variants rather than invented: label is 'label'
(200 + medium, the weight it already had), description and error are
'footnote' (100), with the error weighted so it still carries at the
smaller size. The values are pinned in the tests against the component's
own stylesheet, since jsdom resolves neither the cascade nor custom
properties and a computed style there would only report that a var is a
var.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
"Model name must be a single identifier" reads as "one word only",
when multi-word names are the normal case and need nothing but their
spaces closing up — which is exactly the misreading that put a space in
the wizard's own placeholder. The message now does the transformation and
names the result: 'Model names run words together, each capitalised — try
"BookRecommendation" instead of "Book Recommendation"', and the same
for property and relationship names in camelCase ('due date' → 'dueDate').

An empty field is asked for a name rather than offered a suggestion, and
a name starting with a digit — which joining words cannot rescue — gets
the rule stated rather than a suggestion that would be refused again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… a gutter

Three fixes from testing the fields list.

The drop indicator was never missing — it was painting behind the dialog.
we-modal promotes itself into the browser's top layer via popover=manual,
and no z-index reaches above the top layer, so the ghost and the drop
line, both appended to document.body with z-index 9998/9999, rendered
underneath it. What read as 'drag works but has no drop bar' was actually
no ghost either; the feedback was the source row's 0.3 opacity. They now
join the top layer the same way, feature-detected, with the UA popover
defaults reset. The advice in z-index.ts is corrected too — picking a
bigger token cannot win against the top layer.

A property card was painted neutral-50, which is exactly what we-input
fills itself with, so every field vanished into its own container.
Surfaces take the surface step (neutral-0) and controls keep their
recessed one; the relationship row keeps its primary-50 tint, which is a
different step and so still reads. Overriding bg on the inputs would have
been the wrong direction — hover, active and focus all move to
neutral-75, so the override would have to be repeated four times per call
site to stay coherent.

The drag handle moves into a gutter beside the whole card rather than
sitting inside its first line, sized to one control so it stays level
with the first row as the card grows and shrinks with its conditional
inputs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three things from working in the fields list.

A default value took the same text input whatever the property was, so
'many' for a number or 'yes' for a boolean were mistakes the save had to
refuse. Each type now gets the control that cannot express the mistake: a
number input, a date picker (which emits the YYYY-MM-DD the rest of the
system reads), and a picker over the declared values for a select. A
boolean's picker is three-valued, because unset is not false and the
manifest distinguishes them. What is stored is unchanged — the draft
holds text either way, coerced once at lowering.

The property row hid six inputs of unequal importance on one line: a
name, a type and required are structural and always relevant, while a
hint, a default and a value list are secondary and usually empty. The
first three stay on the row; the rest move behind a caret, where there is
room to label them and to give the hint the textarea the class-level hint
already had. A row opens when it is added, when a model is generated, and
whenever a validation error names it — which is why draftToManifest now
reports the rows a refusal is about, not just the sentences.

Relationship rows keep their single line: a target and a cardinality is
genuinely all there is, so there is nothing to put behind a caret.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
we-textarea passed rows through and then overrode it in CSS: a hard
min-height of 80px is about three lines, so rows below the default
rendered at three and read as the prop being ignored. Floored at one
control height instead.

we-select's trigger was unclickable with nothing chosen. all:unset leaves
the button no height of its own, so with no value and no placeholder it
was full width and zero tall — the caret was the only hit area on the
row. The :empty::before fallback written for exactly this never fired,
because placeholder is a property on the host and attr() reads
attributes. The button is stretched and centred, and the attribute is
mirrored onto it.

The number default used we-number-input, whose stepper starts from a
number the draft does not have: the field holds text, empty meaning
unset, so plus did nothing until minus had coerced it, and once set there
was no way back to unset. It is a we-input with type=number now — empty
is expressible, the browser refuses letters, and no stepper state is
involved. Every default control also takes a fixed width rather than
filling the panel, and a placeholder, since a default is a short value.

Default value moves above AI hint, so the panel reads allowed values →
the value picked from them → the prose about the field.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…y out

You found it: the wrapper a reveal builds sets overflow:hidden for the
element's whole life, not for the animation's. The clip is what makes the
effect work — a growing track has to hide what overflows it — but held on
afterwards it silently cuts anything painting outside its own box, which
is why inputs in the expandable panel lost their hover ring. A select's
dropdown inside one would have been cut off the same way. Both renderers
now clip while closed or moving and release once settled open.

The other half of the same section: an omitted exitTransition now mirrors
the enter in $if, which the operator documentation has always described
and only $animate actually did. A panel that eased open vanished on
close, and the workaround in a template was to restate the same config
twice.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…value

The type reads Boolean and the manifest stores default: true / false, so
a picker offering Yes/No was the odd register out — and the admin setting
a default is often the one writing that field's AI hint next, where the
values named have to be the ones the model emits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- A date could be set and never unset, so any optional date field was a
  one-way door. we-date-picker gains a clear button when it holds a
  value, emitting the same change event with an empty string.
- Typed select values never reached the default picker. The row is
  mutated in place while typing so the input keeps focus, which means
  nothing hanging off the row can be reactive — the picker now reads its
  entries from a store memo keyed by rowId, so a commit updates it
  without remounting the row and stealing focus.
- The native number spinners could not be themed and were the one
  control in a form that did not match. Hidden; we-number-input remains
  the control for when stepping is the point.
- The seeded row played its opening animation on every open, because the
  draft and the expanded set were two separate writes: the wizard mounted
  collapsed and then expanded. Batched, so it is simply open.
- Clicking the backdrop discarded a half-written model silently. The
  modal's close routes through a guard that asks first, and only when
  there is something to lose — a pristine wizard still closes at a touch.
  Policy in the store rather than a new prop on the primitive, since the
  store is what knows whether the draft is dirty.
- A growing modal pushed its content off the screen, buttons included:
  maxHeight lands on the base but nothing bounded the overflow. It
  scrolls now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four components open a panel anchored to a trigger and did it three
different ways. we-popover used the Popover API with Floating UI.
we-icon-picker hand-rolled position:fixed from a rect read on open, which
escapes clipping but has nothing watching the trigger, so the panel
drifted away from it as soon as anything scrolled. we-select and
we-date-picker used plain absolute positioning, which any ancestor with a
non-visible overflow clips — a modal, a scroll area, a mid-animation
reveal, which is most of where they are used. Making the modal scroll
made that last one impossible to ignore.

openFloatingPanel is the one mechanism: promote into the browser's top
layer with the Popover API, so no z-index, overflow or stacking context
can clip or hide it — including we-modal, which is itself in the top
layer and cannot be beaten any other way — then anchor with Floating UI
and keep it anchored with autoUpdate. A popover is not reparented, so
keyboard handling, aria-activedescendant and shadow-scoped styles all
keep working, which is what made this safe to retrofit. Degrades to the
old positioning where the Popover API is missing.

All four now call it, we-popover included: leaving the original as its
own variant is how the divergence started. The z-index note is corrected
again — those tokens are now a fallback, not the mechanism.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Promoting a panel into the top layer also handed it the UA's own
[popover] rules, and one of them beats inheritance: a panel that had
always taken its text colour from the component around it got
color: CanvasText instead. All three set their own background and none
set a colour, so themed text went black on a themed surface. The helper
resets colour and inset now, which is the same class of thing
POPOVER_RESETS does for the drag ghost.

And the number field lost its arrows: hiding the native spinners answered
'they look like browser defaults' by removing them rather than replacing
them. we-number-input already draws design-system steppers — it just
could not express 'no number', which is why the wizard was not using it.
It can now: an empty value renders empty, clearing the field takes a
value back, and stepping an empty field starts from the minimum (or zero
when unbounded) instead of doing arithmetic on an empty string, which is
what made one button work and the other appear dead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The host carries the border and any width prop, but the row inside sized
itself to its content — so a number input given a width drew its box at
that width and left the space after the + button empty. The row fills the
host now and the field takes the slack.

'No default' was truncating to 'No def' in a field three characters wide.
'None' is what this wizard already calls not-set in the identity picker,
so both pickers and every placeholder now say it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One width was applied to every default control. A select needs room for
its option labels and a date for its format, but a number input already
shrink-wraps to its steppers and a few digits — stretched to 220px it
just put a gap between the number and the buttons that belong to it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Removing the width did not shrink it: we-form-field lays its control out
in a column, and a column stretches its children across the cross axis,
so the input filled the row instead of sizing to itself. alignSelf: start
is the opt-out — the control now takes the width of its own steppers and
a few digits, whatever the size or type scale.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
width:100% on the inner row was the reason it kept claiming a whole row.
The host is an inline-flex box that shrink-wraps when given no width, and
a percentage width against a shrink-to-fit parent resolves from the
available space instead — so the row asked for everything going. As a
flex item it grows into a width when there is one and reports its own
content width when there is not, which is what both cases wanted.

Also a guard test: a backtick inside a css template comment closes the
template, and the parse error lands somewhere else entirely. That has
bitten four times in this branch, always while writing a comment naming a
property in backticks. Now it fails as itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… characters

The width came from the input, as James spotted. A bare flex:1 leaves the
field's intrinsic width to the input itself, and an input's is its size
attribute — twenty characters, about 180px — which the host then
shrink-wrapped around: 180 plus two 32px steppers is the 249px it kept
drawing at. Replacing width:3em with flex:1 removed the only thing that
had been overriding it.

A definite basis (flex: 1 1 3em) is what the surrounding box measures
instead, with min-width:0 so the input's own min-content cannot put it
back. Still grows into a width where one is given, which is what the
earlier fix was for.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Neither alignSelf: start on the host nor a definite flex basis inside the
component stopped it stretching to the panel width, and the built bundle
confirms both shipped — so the reasoning was wrong somewhere I could not
find by reading. An explicit width is the one thing that reliably holds,
and a number wants roughly its steppers plus a few digits.

The two primitive changes stay: the row filling the host is what stops
the gap after the + button when a width is given, and the definite basis
is right regardless of what else is going on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ran it in a browser instead of reasoning about it again — we-preview boots
the whole app over the in-memory backend and has playwright-core, so a
throwaway harness rendering the wizard's own structure could report every
box in the chain. Three things were true at once, which is why each fix
from the outside failed:

- The host computes display:flex, not the inline-flex its defaults
  declare, so in a form field's column it is block-level and fills the
  row. Only an inline style beats the design system's own sheet, so the
  width has to come from a DS prop on the consumer; a :host rule in the
  component was silently overridden, and has been removed rather than
  left there lying.
- The field's intrinsic width is an input's twenty characters, which is
  what a shrink-to-fit ancestor measures. A flex basis did not help,
  because a growable item still contributes its max-content width.
- The size attribute has no effect at all here, since all:unset above it
  leaves the UA sizing behind.

An explicit width in ch does work, and taking it from the value means the
field hugs a short number and widens for a long one: empty 108px, '5'
100px, '1234567890' 162px, none of them clipped. The hardcoded 124px is
gone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
jhweir and others added 15 commits August 19, 2026 18:16
A select fills its container by default and should keep doing so: option
text is usually somebody's data, and a control that hugged it would be as
wide as the longest space name in the list. But that is wrong for a
handful of short known words — true/false, or the values a field itself
declares — where 220px is mostly empty.

fit measures the *widest option*, not the current one. Sizing from the
selection would resize the control every time somebody picked something
and shift everything beside it; measured in a browser, this holds at 55px
across both an empty boolean picker and one showing True, and grows to
90px for longer options.

The sizer stacks every label in one grid cell inside the button, hidden
without being removed so it still contributes width while painting
nothing and staying out of the accessibility tree. It sat beside the
button first, which collapsed the button to nothing and clipped the very
label it was there to size — visible immediately in a screenshot. The
placeholder moves into a real span while we are here, replacing an
:empty::before that could not carry ellipsis.

The relationship target picker deliberately keeps its fixed width: its
options are model names, which is exactly the case fit is wrong for.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A fitted we-select sat at its option width until the pointer arrived, then
jumped to the full width of its container.

Nothing about the element changes on hover — same inline style, same classes —
so the cause is the cascade. Design system props reach the element as
--we-select-* custom properties consumed by a generated stylesheet, and that
sheet re-declares width in its own interaction rules. The :host([fit]) rule in
this component's stylesheet therefore held until the first hover and lost from
then on.

So the width goes inline, in updated(), where the generated sheet cannot reach
it. An explicit width still wins: fit is only the default-sizing opinion, and
it is read back through getInstanceProps rather than off the element, since
width is assigned by whoever mounts this and is not declared here.

Measured in a browser rather than inferred — the same cascade is why an
equivalent :host rule on we-number-input never applied at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…odals scroll

A modal with more content than it had room for spilled down the page instead of
scrolling — the wizard, once it had enough properties to fill the screen.

Two causes, both measured in a browser rather than inferred.

The generated stylesheet emits the overflow longhands after the shorthand in the
same declaration block, so they replace it. Nothing ever sets --we-x-overflow-x
(the DS prop is `overflow`), which made the declaration invalid at computed-value
time and resolved to `visible`. That discarded the overflow prop for every
primitive that sets one, and beat any overflow rule a component wrote for itself,
since the generated sheet is adopted last. The longhands now fall back to the
shorthand they replace, via a {p} placeholder that resolves to the component's
variable prefix. we-scroll-area and we-menu get the values they always declared;
no menu is height-bounded, so nothing that used to be reachable is now clipped.

The modal then still could not be scrolled to the top: its base centred content
on the main axis, which does nothing while the box grows with its content, but
centres the *overflow* too once maxHeight clamps it — pushing the first field
above the top edge where no scrolling reaches. The host still centres the modal
in the viewport, which is the centring anybody actually sees.

Also gives we-date-picker's clear button somewhere to be: it sat flush against
the calendar icon, reading as one control, and a bare 14px glyph is a target most
people miss.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The five types covered the common cases and left three gaps where the control,
not the model, was doing the limiting.

**Long text.** `text` renders a single-line input, so a description or notes
field was cramped by the widget. The IR now carries `multiline` on a property —
a fact about the value in the same way `type` is, since paragraph prose is not
the same kind of thing as a title whatever both compile to. Declared rather than
guessed from the property's name, so a reopened model comes back as what its
author built.

**Date & time.** A calendar day and an instant are different facts — a birthday
has no time, a shift start is meaningless without one — and RDF draws the same
line (xsd:date vs xsd:dateTime), so the IR now does too. we-date-picker gains
`showTime`: a time field in the panel, midnight rather than a guessed hour on a
chosen day, the panel staying open on the day so the time is still reachable,
and a time given before a day dated today. The compiler never reads the scalar
type — AD4M links are untyped — so splitting them costs nothing there.

**Image.** The IR has had `format: 'file'` and `readAs: 'dataUri'` all along,
and the compiler binds them to the file-storage language; the wizard simply did
not offer them. Its detail panel drops the default-value field for an image,
since the value is a file somebody uploads and an empty box labelled "Default
value" only invites the attempt.

Deliberately not added: multi-select, which needs cardinality on properties (the
IR has it only on relations); a person type, which needs an agent picker; and
JSON, which the IR permits but which nothing can query into, SHACL cannot
validate and the extraction hints cannot describe — structure that deserves to
exist deserves a relationship to another model.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reverts the long-text, date/datetime and image property types added in 526d3f6,
keeping only we-date-picker's showTime — which is the control the one remaining
date type needs.

Properties are scalars; content is a relation to a block. An image belongs to an
ImageBlock, which carries alt text and dimensions and can be signalled on,
commented on and drawn in the graph — none of which a URL in a string can do. The
relationship picker already offers every block type, so an image property was not
just inconsistent with that but redundant with something the wizard already
shipped, by the poorer of the two routes. The rule is now written down beside the
type list, since it is what explains the absences there.

Long text changed exactly one thing — which widget renders — so it was a
presentation flag in a neutral manifest, and `multiline` would have made
`widget`, `placeholder` and `rows` easier to add after it. Presentation belongs
to templates, which is the architecture; if a generated record editor later needs
more than the scalar type, that belongs in a sidecar on the Shape record rather
than in the IR.

Splitting date from datetime bought nothing either: manifestCompiler never reads
a scalar type, so nothing enforced the distinction, and it left authors one more
decision to get wrong. One type now, with an optional time — a day chosen with no
time stays a bare YYYY-MM-DD and only becomes an instant once somebody says which
one, so the value records whether a time was meant instead of every date
acquiring a midnight nobody chose.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e the time row

The clear button pushed the calendar icon out through the right-hand border. The
display field is a flex item, so its automatic minimum size is its own content:
it refused to shrink below the date it was showing and shoved everything beside
it out of the box. Widening the clear button is what made it visible, but a long
enough value would always have done it. The field now shrinks and truncates, and
neither icon is what gives way when room runs short.

The time row was built with all: unset, so it was a raw browser control sitting
under a themed panel — and it reached for the neutral ramp where the panel around
it uses role tokens, which are what follow a theme. It is now built the way
we-input builds its field: same border, padding, hover and focus treatment, on
role tokens throughout.

Its native picker glyph is gone for the same reason we-input hides the native
number spinners — the browser draws it for the OS scheme rather than the theme,
so on a dark theme it was a black clock on a dark panel and no CSS reaches it. A
themed button replaces it and opens the same picker through showPicker(), so
nothing is lost.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…l their content only

Two fixes from testing, one mechanism each.

The time picker's popup was white with bright blue squares on a dark theme. The
browser draws that popup itself, coloured for color-scheme and nothing else — no
token reaches it, and the same goes for scrollbars and native dropdowns
everywhere. Darkness is not a separate flag to keep in sync: a negative
multiplier *is* the inversion of the lightness scale, so themeToStyle now derives
color-scheme from it. Both theme paths flow through that function — the global
applyThemeVars and the scoped per-space style — and color-scheme inherits through
shadow boundaries, so it reaches the input the popup anchors to. A theme that
never touches the multiplier stays silent and inherits the ambient scheme, which
is right: it did not change the polarity.

The modal scrolled everything, title and close button included. Scrolling
[part='base'] was the first fix and wrong by one element — the close button is
anchored to base, so it rode away with the content. base now clips and an inner
[part='content'] around the default slot scrolls, which pins the close button
structurally. Named header/footer slots ride outside the scroll: an unfilled slot
is display: contents and costs the layout nothing, and a filled one makes the
slotted node a flex row of base itself, sharing its gap and padding. The model
wizard and hint editor slot their titles and action rows accordingly, and the
pattern is documented in the ai-context fragment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"ImageBlock — block" said the name twice, every group's suffix was shaped
differently, and "— another app" asserted an origin nothing established —
getForeignShacl returns every SHACL shape in the dataset that is not WE's own,
whoever put it there (checked: neither the executor nor WE auto-installs
anything that would justify the claim). All three are one problem: groups being
encoded as per-item string suffixes.

So we-select learns what the list actually is. SelectOption gains `icon` and
`group`: consecutive options sharing a group render under one non-interactive
heading (role=presentation, skipped by keyboard navigation, dropped when its
options are all filtered out), icons draw beside labels in the list and on the
chosen value, and the fit sizer measures icon and gap so fitted widths stay
honest. Truncation moves to a label span, so an icon is never what gets clipped.

The relationship targets then group as "This space" (each shape wearing the icon
its author picked in this wizard), "Blocks" (wearing the block composer's own
iconography, so a block is drawn the same way wherever it is named), and "Other
models in this space" — which claims only where the model lives, the one thing
that is actually known.

Also "Points at…" → "Links to…", which is friendlier and more literally true at
once: a perspective is made of links.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tion icons

Three small fixes from testing.

The modal's new scroll region clipped focus rings: a field stretches to the
scroller's full width and its ring paints just outside itself, which the
overflow clipping killed dead — measured at exactly 0px of room on every side.
The scroller now carries 4px of padding taken back by an equal negative margin,
so nothing moves and the ring has room to paint (the widest ring is 2px).

The wizard wrapped its member rows: moving the width from the inner Column onto
the modal made 720px include the modal's own padding — space-900 each side, so
the rows lost 128px. The wizard and hint editor widths now account for it.

Option icons in we-select are 16px and tinted primary-700, in the list and on
the chosen value alike — they are wayfinding, not content, and read better as a
system of accent markers than a column of dark glyphs. we-icon defaults its fill
to currentColor, so one stylesheet rule covers every consumer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t generate themselves

Four pieces from testing and design discussion.

**The time picker's popup is now ours.** The calendar half of we-date-picker was
custom precisely because native popups cannot be themed; the time half stopping
at showPicker() was inconsistent with the component's own reasoning. The clock
button now opens a floating list — half-hour steps, labelled for the locale
(2:30 PM or 14:30, storing HH:mm either way), dressed exactly as we-select's
listbox, opened pre-scrolled to the value or its nearest half hour. A combobox
rather than wheels or a clock face, because time-picking is two tasks: common
times want recognition, exact times want typing — and the field beside the list
already types. It stays a native time input on purpose: touch devices keep the
OS wheel, which no web dropdown improves on (and which color-scheme now renders
dark correctly — that fix stays, it covers scrollbars and every other UA
surface, not just the popup this replaces).

**Drag-down showed its drop one row late.** _indexAt speaks the dragged-inclusive
index space and the drop path converts before committing — but the indicator
drew from the unconverted index against a dragged-exclusive list. The two spaces
agree when dragging up and differ by one when dragging down, so the line sat a
row below where the drop would land, and a downward drag had to be taken a row
too far before the line reached the place already meant.

**Fields can generate themselves.** An "Auto-generate fields" button on the
Fields row runs the same generation as the describe-it flow, prompted by what
the author already wrote — name, description, AI hint. Their words survive;
the generation contributes the structure, plus answers for anything left blank.
Enabled only when there is context to work from and no member row has been
started, since generation replaces the member list wholesale and quietly
discarding typed rows is how a click becomes a loss. New models only, same as
the describe-it box.

**A new draft opens with no members.** The pre-added blank row asserted the
shape of the model before its author said anything — and put a property first
when the right first move may be a relationship or the generate button. An
empty state stands in its place; saving with nothing still fails with "a model
needs at least one property". The member name inputs also now say which kind of
row they name — they carry no label, so the placeholder is doing label duty,
and the identifier validation already teaches the camelCase spelling at the
moment it matters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Model generation was gated on an Anthropic API key in agent settings — which
only the template editor's AI panel ever sets — so on any profile without one
the wizard's whole AI surface silently did not exist, including for the person
who commissioned it. The expectation was always the AD4M-configured LLM: WE's
other AI surfaces (transcription, extraction) already run on the node's models,
and the key-gated path was the odd one out.

The backend contract gains a language-model port — available() and one-shot
prompt(system, input) — optional on the same terms as transcription. The AD4M
adapter implements it over the executor's AI tasks: one task, found by name and
reused (tasks persist across sessions, so creating one per call would
accumulate), bound to the literal model id "default" so changing the default
LLM in AI settings changes what answers, with no re-registration.

Generation itself now runs on either transport: the backend port when the node
has an LLM (preferred), the stored Anthropic key as fallback. The loop is one
implementation over provider-neutral turns; the executor path carries the
output schema in the system prompt — text is all the port speaks — and the
parser forgives code fences and prose around the object, with the existing
repair turns feeding validation errors back either way.

And when neither is configured, the wizard now says so: a one-line hint stands
in the describe-it box's place naming what would be there and pointing at
Settings → AI, instead of the feature simply not existing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The wizard had two ways to ask AI for a model: a "Describe it instead" box
pinned above the form, and the generate-fields button on the Fields row. Two
entry points to one outcome, and the box duplicated the very fields it sat on
top of — you typed a description into it, an inch above a Description field.
Since field generation already reads name, description and AI hint, and already
fills whichever of them the author left blank, the button subsumed the box.

So the box goes, its aiDescription state with it, and "Auto-generate fields"
becomes "Generate fields" — the one AI route, fed by the form's own fields.
Discoverability moves into the empty state, at the moment it is relevant rather
than as a permanent panel: the generate route when a model is configured, and
where to configure one when not.

The richer-guidance story stays coherent without a prose box: description wants
the one-liner every member will see, and the AI hint field is prompt payload by
design — the legitimate home for what these records contain.

shapeStore.generateShapeDraft (prose → whole draft) stays, unwired: it is the
engine a spoken route needs — press record, transcription in, the same review
form out — and deleting it would be rework the moment that lands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Typing a name and description left "Generate fields" disabled; adding an empty
property enabled it — exactly backwards, and both for one reason. setShapeField
mutated the draft in place without publishing the signal, so the guard memo
never re-ran while the context fields were typed, and first recomputed when a
structural change (adding a row) happened to republish. In-place mutation is the
member rows' concession — they live in an $each keyed by object identity, where
replacement remounts the row and drops input focus. The top-level fields are
ordinary controlled inputs outside any keyed loop; they now publish.

The same staleness also worked in reverse, against the guard's whole purpose:
member rows do mutate in place, so typing into one could leave the button
enabled a keystroke long and a click would replace rows somebody just wrote.
The action now recomputes the guard fresh at call time, and the member name and
hint inputs publish on blur so the button's state follows a touched row as soon
as it is left.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tion shrink sideways

Two layout faults with the same shape — a box refusing to be the size its
container offers — found while working through the model wizard, and both
general enough to fix where they live rather than in the template.

`we-modal` now sets `scrollbar-gutter: stable` on its scroll region. Crossing
the scroll threshold was also a relayout: the bar took its width out of the
content box, so expanding one section of a form narrowed every control in the
modal. Nearly free here, since the gutter is the 6px of the scrollbar token
rather than a native bar's width, against a transparent track.

`ConditionalRenderer` relaxes the reveal wrapper's automatic minimum on both
axes, not just the one being revealed. A grid item's automatic minimum size is
its content on either axis, so a block-revealing section kept `min-width: auto`
— and a line of `white-space: nowrap` text has a min-content width of the whole
line. Such a section refused to shrink, pushed out of its container, and took
its own `text-overflow: ellipsis` out of reach on the way. The wrapper exists
only to animate; it has no business imposing a minimum on either axis.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…uthor's own words

Five rounds of manual testing on the model wizard, and the thread running
through them is the same: what the machine wrote and what the author wrote were
never distinguished, so a re-run could neither be offered safely nor produce a
coherent answer.

**Generation re-runs.** `canAutoGenerateFields` latched off the moment any row
was touched — which every generated row is — so the first attempt was the only
attempt, and spending it on a bare name was a trap. It is replaced by
`generateIntent`: 'generate' (nothing to lose), 'regenerate' (a proposal nobody
has touched, so one click), 'replace' (rows somebody wrote, so it asks first),
'none' (nothing to work from, the only disabled state). The store makes the
ask/don't-ask decision because only it can tell the two apart, via a signature
over the rows it last produced.

**A re-run follows the rename.** The prompt was built from whatever the draft
held, so a previous generation's own description and hint were quoted back as
part of the next question — renaming a model to "MovieNight" while its generated
description still discussed books returned a model about both. `authoredFields`
draws the line: a field still holding exactly what generation put there is
output, not intent, so it neither steers the next prompt nor survives it, while
anything typed does both. Pressing Regenerate twice over an untouched draft is
now a fresh attempt rather than a slow convergence on the first.

**A generated `select` arrived with an empty default picker.** `defaultOptions`
is derived, and every construction site spread a blank row and assigned over it,
leaving the picker describing the blank until the allowed values were retyped.
The new `draftMember` factory always ends in `syncDerived`, which makes that
unrepresentable rather than something each caller must remember. Booleans had
the same fault.

**A hint written in the wizard came back blank.** The hint editor reached for
the declaration only when *nothing at all* was stored — never true for a saved
model — so every property opened empty while the class hint, whose fallback was
written the other way, came through. `hintToDisplay` states one rule for both,
using the customized marker to separate "never touched" from "deliberately
cleared", which storage cannot distinguish on its own.

**The wizard's shape**, from the same testing: generate moves beside the name
(where a name is all it needs, and where it can no longer claim to make only
"fields"); icon leads the row, since generation fills it; a collapsed property
shows its hint, so generation no longer expands every row it produced; row
buttons are pinned top-right like the drag handle rather than wrapping under the
fields; Required becomes a checkbox; "Identifies duplicates" becomes
"Identifying field", which names the role without promising a constraint nothing
enforces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@netlify

netlify Bot commented Aug 20, 2026

Copy link
Copy Markdown

Deploy Preview for coasys-we ready!

Name Link
🔨 Latest commit 2ad87e3
🔍 Latest deploy log https://app.netlify.com/projects/coasys-we/deploys/6a87081b44ac4f0008561ba9
😎 Deploy Preview https://deploy-preview-123--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 and others added 4 commits August 20, 2026 14:12
…ES2020 target

`declCSS`/`stateDeclCSS` reached for `String.replaceAll`, which is ES2021. The
workspace's tsconfig targets ES2020, so `lib` is ES2020 and the method exists
nowhere the type checker looks — `@we/design-utils`' own `tsc --noEmit` failed
on both call sites.

It went unnoticed because nothing ran the whole pipeline: tsup transpiles
without checking against the package's `lib`, so every build passed, and CI
never reached its Typecheck step because the AD4M source build ahead of it was
failing for unrelated reasons.

A shared global regex rather than a bump to the workspace target: retargeting
every package is a repo-wide decision, and this is two call sites. `String
.replace` resets a global pattern's `lastIndex` itself, so one shared regex is
safe across calls.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The build cloned the AD4M repo, compiled its SDK from source, and pointed
`pnpm.overrides` at the result — so it judged every WE change against whatever
AD4M's `dev` branch happened to hold at the moment the job ran, having first
verified and then discarded an install of the versions this repo actually pins.

Three consequences, all of them observed. The same WE commit could go from green
to red with no WE change: `dev` sat red for two days while every developer's
machine built cleanly, because an AD4M decorator option WE depends on lives in a
published tag and on a feature branch, but not on AD4M `dev`. A green build said
nothing about the combination anyone runs or would ship. And the failure arrived
as a TypeScript error inside WE's own source, naming nothing that would lead a
reader to another repository.

The premise for it does not hold either. Published `@coasys/ad4m` carries no
`workspace:` refs at all, and `@coasys/ad4m-connect`'s single one sits in
`devDependencies`, which a consumer never installs — which is why the pinned
install this job already performed, and every developer's machine, work fine.

So the required build installs the lockfile and stops there. Testing WE against
unreleased AD4M keeps its value and moves to `ad4m-compat.yaml`, which runs
nightly against AD4M `dev` and on demand against any branch — the cross-repo
path, still available, no longer able to decide whether a WE change may merge.

Two things fall out. The install can be frozen, since nothing rewrites the
manifest or the lockfile mid-run. And the generated-files check drops all three
of its exclusions — `package.json`, `pnpm-lock.yaml` and `ad4m/` were things
this workflow put there and then agreed not to notice, so the check now covers
the whole tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`shows no time in the field until the value has one` matched `/14:30/`, which is
not what the picker promises. It formats through `toLocale*String(undefined, …)`
deliberately — a 12-hour reader should read "2:30 PM" while "14:30" is what gets
stored — so the assertion was pinned to the machine it was written on. It passed
here and failed on a CI runner formatting US English.

Two sibling assertions had already met this and hedged with `/2:30|14:30/`: the
same defect, noticed and worked around rather than fixed, and weaker for it —
an alternation like that also passes on a picker rendering the wrong one.

All three now derive their expectation from the same `Intl` call the component
makes, so they stay exact about the minute while saying nothing about which
locale is running. `el.value` assertions stay literal, since the stored value is
24-hour by contract.

Verified under en-GB, en-US, de-DE and ja-JP, and the whole workspace suite
under en-US in both UTC and America/Los_Angeles — 2,253 tests, no failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jhweir
jhweir merged commit 6a727d0 into dev Aug 20, 2026
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