Skip to content

Feature: Sidebar fragments - #117

Merged
jhweir merged 11 commits into
devfrom
feat/sidebar-fragments
Aug 12, 2026
Merged

Feature: Sidebar fragments#117
jhweir merged 11 commits into
devfrom
feat/sidebar-fragments

Conversation

@jhweir

@jhweir jhweir commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Collapsible sidebar as template fragments

Status: implemented on feat/sidebar-fragments. Ten commits; the widget is still in place
behind a switch, so both versions can be compared in the running app.

Summary

The shell's left rail is a layer-5 widget — CollapsibleSidebar (~380 lines TSX + ~180 SCSS) — whose
entire job is arrangement. packages/templates/shell/src/Sidebar.schema.ts hands it an items array
and a slots.header, and everything about how those become UI is compiled code. That contradicts the
rule the architecture is built on:

Code owns only what data cannot express. Behaviour and focus management, accessibility semantics,
browser APIs, measurement, performance-critical rendering — that is the whole list. Everything above
it is arrangement, and arrangement stays data.
docs/architecture/template-fragments.md

The consequence is not theoretical. The sidebar is one opaque node: the visual editor stops at it, AI
can only edit props somebody predicted, and a user cannot make an item two lines, put a SignalControl
in a group header, move a badge, or add a section that is neither an item nor a group. ModuleRail.schema.ts
and the default template's own SpaceSidebar.ts are both pure schema and prove the shape works.

This branch rebuilds the rail as three fragments in @we/template-kit, and closes the five substrate
gaps that stand in the way. Four of those five are independently useful and none of them are large.

Scope note. The rail is shell chrome, registered as core:sidebar on the dock-left anchor —
not part of the default template. The default template's sidebar is already plain nodes and is not
touched.


What was verified before planning

Each behaviour the widget owns, checked against what schema can actually express today:

Widget behaviour Schema equivalent Status
Fixed rail, width transition collapsed↔expanded Column + position/zIndex/width/transition works
Expand on hover $localState { expanded } + onMouseEnter/onMouseLeave$setLocal works
Label reveal (max-width: 0 → 500px + opacity) DS props driven by $if on $local works, but magic number — see step 1
Item: icon-or-avatar, label, badge, active, hover we-button + we-icon/we-avatar/we-badge, hoverProps works
Group header + sibling action + tooltip plain nodes, as ModuleRail already does works
Persist expanded state $localState … persist works (widget does not do this today)
Group collapse (grid-template-rows: 0fr → 1fr) props.styles raw-CSS escape hatch works, wrong layer — step 1
Per-group collapse state for data-generated groups blocked — step 3
Drag-to-reorder the Spaces group we-sortable blocked — step 2
pointermove fallback collapse not expressible — see follow-ups

Supporting facts confirmed in source rather than assumed:

  • createLayoutComponent spreads unrecognised props onto its div, so onMouseEnter/onMouseLeave
    reach the DOM on Column/Row/Grid/Card.
  • semanticValidation.ts:668 waves any onXxx prop through, so the validator will not reject them.
  • isStaticValue recurses into object values and propMemos wraps deepUnwrap(resolveProp(...)) in a
    memo, so a token nested inside an object prop (styles: { … : { $if: … } }) does resolve reactively.
  • ConditionalRenderer renders through RenderSchema, so $localState declared on an $if then
    branch works.
  • Shell slots are rendered via RenderSchema in TemplateLayout.tsx, so $localState works in chrome.

Changes

1. reveal transition effect — @we/schema-shared + @we/schema-solid

Why. The template system's animation vocabulary has exactly one axis. transitionUtils.ts maps
fadeopacity, slide/scaletransform, pulse → a keyframe. There is no size axis, and no
notion of a property easing when its value changes. The DS layer reinforces this deliberately:
ANIMATABLE_STATE_PROPS in 3-primitives/src/shared/helpers.ts was narrowed from all after
instrumentation caught border-widths animating on hover and causing flicker — "nothing that can move a
box" — and the transition sits on [part=base] rather than :host because the host owns geometry.

Geometry animation was therefore ruled out of the automatic surface on purpose, leaving the raw
transition DS prop as the stated per-instance escape hatch. That prop is used zero times across
packages/templates, which is why the gap has not surfaced before.

So the single most common in-place animation there is — reveal something to its natural height — has no
expression, and the widget spells it as raw CSS. Blessing the escape hatch in a fragment would push that
incantation onto every author who copies the expansion.

What. Add reveal to TransitionEffect, alongside fade/slide/scale/pulse:

{ type: 'reveal', duration?: ms, easing?: string, delay?: ms, axis?: 'block' | 'inline' }

Implemented as the 0fr → 1fr grid technique, which is the correct mechanism — it eases to the content's
actual auto size with no measurement. The max-height guess CollapsedContent uses (5000px) is
wrong for a reason worth recording: the easing is a lie, most of the duration is spent traversing empty
space, and it breaks silently past the guess. interpolate-size/calc-size(auto) is the eventual
browser answer but is not cross-browser baseline yet.

ConditionalRenderer already renders exactly the two-element structure the technique needs — an outer
wrapper carrying the animated style, and an inner div wrapping the content. The outer takes
display: grid + grid-template-rows, the inner takes overflow: hidden; min-height: 0. No new
primitive, no new component, no author-facing CSS. axis: 'inline' uses grid-template-columns, which
is what replaces the label reveal's magic max-width: 500px.

Composes with the existing effects the same way slide does:

enterTransition: [{ type: 'reveal', duration: 300 }, { type: 'fade', duration: 180 }]

Mirror the same effect into AnimateRenderer for the stay-mounted case.

Decide explicitly during implementation: $if unmounts content after the exit duration, so a
collapsed group leaves the DOM — a behaviour change from the widget, which keeps collapsed groups
mounted. Better for memory, worse for anything inside holding state. $animate covers the other case.

Implementation wrinkles to handle, not discover:

  • wrapperStyle in ConditionalRenderer copies SIZE_PROPS (width/height/max-*) from the content
    onto the wrapper. A copied height will fight a grid wrapper — reconcile the two.
  • The wrapper carries pointer-events: none and the inner auto; confirm that still holds mid-reveal.
  • Exit timing reads duration off the first effect in the array. With reveal first this is correct;
    worth a comment so the ordering is not treated as cosmetic.

Rejected alternative: a we-disclosure primitive. It needs no measurement, so by the project's own
rule it does not clear the bar for code, and it would be less composable than an effect that sits in the
vocabulary authors already know.

2. we-sortable schema compatibility — @we/primitives

Why. This is the one hard blocker. Every schema node is wrapped in a display: contents div
(SchemaRenderer.tsx:1090). we-sortable finds its items with slot.assignedElements() and reads
data-we-id off them (sortable.ts:69-73). From schema-land it would therefore see N wrappers that
carry no id and — being display: contents — have zero bounding rects, breaking both identity and
drop-index maths. There is no way to set an attribute on the wrapper from a schema; only node-level
styles reaches it, and that takes a style object, not attributes.

$each is fine here — it returns a bare <For> with no wrapper of its own, so the sortable does see N
elements rather than one.

What. In the primitive, when an assigned element carries no data-we-id, resolve the id from its
first matching element descendant, and take the rect from that descendant too. Roughly 15 lines in
_getItemId / _getItems.

Worth doing on its own merits: as it stands, a primitive we ship cannot be used by any template.

3. Dynamic $localState keying — @we/schema-shared

Why. $localState fields are named, and $local resolves a static dot path. Per-group collapse
state therefore has to be one declared boolean per group. Today's sidebar has literal groups (Spaces,
Apps) so parity is not blocked — but the whole point of the exercise is that a user can generate groups
from data, and a $each over categories cannot give each one its own state. The same limit bites any
list wanting per-row expansion.

What. A set-membership spelling, because the read side already exists:

"$localState": { "collapsedGroups": { "type": "object", "initial": [] } }

// read
{ "$in": ["$group.id", { "$local": "collapsedGroups" }] }

// write
{ "$toggleLocalIn": "collapsedGroups", "value": "$group.id" }

One new action token. value resolves through the prop pipeline, so context refs, $store and literals
all work. Preferred over a computed-key $setLocal (which would break the token's string type and the
validation that depends on it) and over merge-with-computed-key (merge keys are literal by design).

Check OPERATORS.md before settling the name — the repo's rule is that a new operator needs the
workaround to be genuinely absent, which it is here.

4. Grid.rows prop — @we/components

Why. Independent of everything above. Grid.template maps only to grid-template-columns
(Grid.solid.tsx finalizeStyle), so grid rows are unreachable from DS props entirely. Three existing
call sites already reach for the styles escape hatch to get them — InspectorPanel.tsx:1664,
InspectorPanel.tsx:1797, module-system/call/src/store.ts:596 — none of which is animating anything.

What. A rows prop mirroring template, folded into finalizeStyle. Migrate the three call sites.

5. Tokenise the transition DS prop — @we/design-utils

Why. buildLayoutStyles line 671 is a bare style.transition = props.transition — the only DS prop
that bypasses the token system. Meanwhile animation.transition tokens exist ('300' → 250ms) and are
emitted as --we-transition-* (we-menu-group already reads var(--we-transition-300)). So
transition: '300' silently emits invalid CSS that the browser drops, indistinguishable from a typo at
author time. This is exactly the bug class already fixed for the top/right/bottom/left offsets,
and the fix is documented in the comment immediately above.

What. Resolve duration tokens by shape the way tokenVar already does for offsets, so '300'
becomes the token and '300ms ease-in-out', 'width 300ms ease' etc. pass through untouched.

6. The fragments — @we/template-kit

Three fragments, not one. The kit's conventions are explicit that an over-parameterised fragment is
worse than the duplication it replaces, because it also hides it.

Fragment Owns Ambient contract
railShell({ side, width, collapsedWidth, hoverExpand, persistKey, header, footer, children }) $localState { expanded }, the width transition, fixed positioning writes $local: 'expanded'
railGroup({ id, label, action, reorderable, onReorder, children }) header row, group action, reveal collapse reads expanded; owns its collapse entry
railItem({ icon | avatar, label, badge, active, onClick }) the button, the label reveal reads expanded

Reading expanded up the tree rather than threading it through every layer mirrors the documented
displayMode contract in lists/cards.ts. Per the kit's rules, every read up the tree and every write
into it is declared in each fragment's doc comment, and each carries a comment saying why it exists.

These sit outside we/ — they name no store. Store references stay at the call site.

Estimate: ~200–250 lines of TS replacing ~380 TSX + ~180 SCSS.

7. Cut over Sidebar.schema.ts, then retire the widget

packages/templates/shell/src/Sidebar.schema.ts grows from ~190 to roughly ~260 lines and keeps its
current structure — profile / settings / marketplace, the reorderable Spaces group with its create
action, the Apps group, the footer, the WE-logo header.

Once it has no call sites, delete CollapsibleSidebar from 5-widgets and drop it from
componentRegistry.tsx, slotRegistry.ts and the indexer's generated context data. Regenerate with
pnpm --filter @we/ai-context generate-context.

Do this last and do not skimp on it. docs/architecture/template-fragments.md is blunt about why:
most instances will be untouched copies of the first expansion forever, and a bug in it cannot be fixed
centrally for templates users have already saved. The seed matters more than a default would.


How to compare the two

USE_FRAGMENT_RAIL at the top of packages/templates/shell/src/Sidebar.schema.ts. true is the
fragments, false is the widget; both definitions live in that file and the widget is otherwise
untouched and still registered.

Worth exercising specifically: hover open and close; the Spaces group collapsing and reopening;
dragging a space to reorder it and reloading to confirm it stuck; the rail remembering it was left
open across a reload; and the visual editor being able to select a node inside the rail, which is
the thing the widget could never allow.

Two changes not in the original plan

Both found while building on top of the reveal, both pre-existing and both silent:

  • $if exit timing read the first effect's duration, so [{fade,200},{slide,700}] unmounted the
    node at 200ms and cut the slide at under a third of its length. It now waits for the longest effect.
  • $if with a transition swallowed the content's flex. Inside a Row or Column the animation
    wrapper is the flex item, so flex: '1' on the then-node was read by an element the parent is not
    sizing and did nothing at all. The wrapper already mirrors width/height for exactly this reason.

What was descoped

The plan said the Grid.rows change would migrate three call sites. Two migrated (the inspector's
box-model diagrams). The third — the call module's stageStyle — is not a Grid usage at all: it
builds a whole style record including grid-auto-flow and grid-auto-columns, neither of which a
rows prop covers. It stays as it was.

The we-hover-region primitive is still deferred, so the widget's pointermove fallback for a
dropped mouseleave has no equivalent. If the rail is seen sticking open after a template switch,
that is this; it self-corrects on the next hover.


Known follow-ups

  • Hover robustness. The widget carries a deliberate pointermove fallback
    (CollapsibleSidebar.solid.tsx:63-76) for browsers dropping mouseleave during heavy DOM work — a
    real bug from template switching. Schema gets bare onMouseEnter/onMouseLeave, so the rail can stick
    open. The symptom is cosmetic and self-corrects on the next hover. The clean fix is a small
    we-hover-region primitive emitting a hoverchange event that a schema wires to $setLocal — note a
    primitive can only publish state outward via events, never into $local directly. Held until the
    sticking is observed rather than predicted.
  • <Index><For>: the widget uses Index with per-field memos, $each uses For. At sidebar
    scale (tens of items) this is not expected to matter; worth a glance if the spaces list gets long.
  • CollapsedContent's max-height: 5000px clamp could move to the reveal mechanism once it exists.
    Out of scope here.

Test plan

Verified:

  • pnpm --filter @we/schema-shared validate — 27 schemas, no issues
  • pnpm -r run test — every package passing (kit 57, schema-shared 563, schema-solid 67,
    design-utils 48)
  • pnpm build — full monorepo including all three app targets
  • npx eslint . --max-warnings 0 — clean
  • tsc --noEmit on every touched package. Three pre-existing errors remain, all confirmed
    present on the branch point: two BackendConfig.uuid in schema-solid tests, one Leaflet
    Map | null in the location-picker, one Props import path in a schema-shared test.
  • Unit: reveal — CSS output per axis, composition with fade, no implied opacity, first-wins
    when several are given, and transitionSpan taking the longest rather than the first
  • Unit: $toggleLocalIn — add, remove, value through the prop pipeline (a $each context
    ref), empty field, refusal on a non-array field, no-op on an undeclared one
  • Unit: transition token resolution — bare token, duration slot, delay slot, comma-separated
    halves independently, real CSS durations untouched
  • Unit: the rail's expansion is semantically valid, reveals inline for labels and block for
    groups, holds collapsed groups as a set, and carries data-we-id on a native element
  • we-sortable's wrapper resolution is covered by the last of those (the id must land on a
    div), but the drag geometry itself is not — 3-primitives has no test harness, and adding
    one for this was out of scope

Not verified, and needing a person in front of the running app:

  • Hover open/close feels right, and the group collapse eases to natural height in both directions
  • The label reveal shows no clipping artefact mid-animation
  • Drag-to-reorder the Spaces group works and survives a reload — the one behaviour with a real
    chance of still being wrong, since it depends on runtime DOM geometry no test here exercises
  • The rail remembers it was left open across a reload
  • The two migrated Grid.rows call sites look unchanged
  • The visual editor can select and edit a node inside the rail

jhweir added 11 commits August 12, 2026 23:09
fade moves opacity, slide and scale move transform. Between them they cover
every animation that does not touch layout, which left the most ordinary one
in any interface — something opening in place — with no expression at all.
The design system rules geometry out of its automatic surface deliberately
(ANIMATABLE_STATE_PROPS, narrowed from `all` after instrumentation caught
border-widths animating on hover), so the only way to write a disclosure was
a raw CSS string in the `styles` escape hatch.

reveal fills that in. The wrapper becomes a single-track grid going 0fr -> 1fr
and the inner div clips, which eases to the content's real auto size — the
thing a plain height transition cannot do. Both renderers already had the
two-element structure the technique needs; it just had to mean something.

Not a max-height guess: that applies the easing curve to the guess rather than
to the height, so most of the duration crosses space that is not there, and it
fails silently once content outgrows the number. Not `interpolate-size` either,
which will be the right answer once it is cross-browser baseline.

$if reveals and then unmounts; $animate reveals and keeps its child, for a
section holding state that must survive being closed. axis: 'inline' opens
sideways, for a label appearing beside an icon.

Also fixes an exit-timing bug found on the way: the unmount timer read its
duration off the *first* effect, so [{fade,200},{slide,700}] tore the node out
at 200ms and cut the slide at under a third. It now waits for the longest.
The schema renderer wraps every node in a `display: contents` div, so
`assignedElements()` handed this primitive the wrappers rather than the nodes
an author wrote. Those carry no `data-we-id` and, having no box, return a zero
rect — so every id came out empty and every drop index was computed against
0x0 rectangles stacked at the origin. A drag-to-reorder written in a template
would have looked implemented and done nothing.

Resolving each slotted child to its identified descendant fixes both halves at
once, because `display: contents` promotes its children to be the container's
real flex items: the element carrying the id is also the element carrying the
geometry. A child that identifies itself is returned untouched, so the TSX
callers that put the attribute on the slotted element are unaffected.

Found while planning the sidebar rebuild, but not specific to it — as it stood,
no template could use a primitive we ship.
$localState field names are fixed when the template is written and $local
walks a static path, so "is *this* group collapsed?" had no spelling once the
groups came from a $query or a $store. A template could only pre-declare a flag
per group it already knew about — exactly the set a data-driven list does not
have. Anything wanting per-row expansion had to give up and expand everything.

Inverting it fixes the mismatch: the field holds the ids that are on, so the
varying part moves into the value where an expression can reach it. The read
side needed nothing new — { $in: ['$group.id', { $local: 'collapsed' }] }
already worked, which is a decent sign this is the missing half rather than a
new mechanism.

Adds an 'array' local-state type to declare the field, admitted to the Zod
initial union as a bare array (structurally distinct from a token object, so a
malformed token still cannot slip through as a plain-object initial).

The validator now tracks declared field types, because both ways of getting
this wrong are otherwise silent: a missing `value` toggles undefined in and out
of the array forever, and pointing it at a boolean replaces that boolean with
an array on first click, so every read of it downstream starts answering a
different question.
Grid's `template` maps to grid-template-columns, so the row axis had no prop at
all and every use of it reached for the `styles` escape hatch. Nothing exotic
was going on at those call sites — the inspector's box-model diagram wants a
fixed label band top and bottom, which is ordinary layout, and ordinary layout
is what DS props are for.

Migrates the inspector's two. The third pre-existing use of grid-template-rows,
in the call module's stage style, is not a Grid usage — it builds a whole style
record including grid-auto-flow and grid-auto-columns, neither of which this
covers — so it stays as it is.
It was the last DS prop that bypassed the token system — a raw passthrough, so
`transition: '300'` emitted an unitless value the browser drops, silently and
indistinguishably from a typo. Same bug the offset props had, same fix:
discriminate by shape, so a token becomes a var and '300ms', '0.2s' and
'ease-in-out' pass through untouched.

Consistency is the smaller half of it. --we-transition-* is what a theme's
animationSpeed preset overrides — 'instant' sets every one to 0ms — so a
duration written as a token honours a reduced-motion choice, and one written as
'300ms' quietly ignores it. Until now the prop offered no way to write the
former.

Applied in both places a transition is emitted: buildLayoutStyles for the Solid
layout components, and the primitives' instance var, so we-* elements resolve
it the same way.
Inside a Row or Column the animation wrapper is the flex item, so `flex: '1'`
declared on the then-node was being read by an element the parent is not
sizing. It did nothing, silently — the node just failed to grow, which reads as
a styling opinion rather than a dropped instruction.

The wrapper already mirrors width/height for exactly this reason (it is meant
to be invisible to layout but is a real box); flex belongs in the same list.
CollapsibleSidebar was a layer-5 widget whose entire job was arrangement,
reached through an `items` array — data in, but not structure. That is the
distinction the project turns on: a prop is a customisation somebody predicted
and shipped, a node tree is every customisation including the ones nobody
thought of. Asked to put a second line under a space's name, a progress bar in
a group header, or a section that is neither an item nor a group, the widget's
answer was "wait for a release", and the follow-up had no answer at all. The
whole rail was also one opaque node, so the visual editor stopped at it.

Nothing in it needed code. Hover is two handlers, the width is a transition,
the group collapse is a reveal, and the reorder is we-sortable — a primitive
precisely because pointer capture and drag geometry are code.

Three fragments rather than one, per the kit's rule that an over-parameterised
fragment is worse than the duplication it replaced: railShell owns the box and
the two ambient fields, railGroup a heading and its set membership, railItem a
row. Both readers of `expanded` read it up the tree, as cardShell does with
displayMode.

USE_FRAGMENT_RAIL in Sidebar.schema.ts switches between the two so they can be
compared in place. The widget is untouched and still registered; it goes once
the fragments have been lived with.

Two deliberate differences from the widget, both noted at the node that causes
them. Items are top-aligned rather than vertically centred — centring a
scrollable flex column clips the top of it once the space list is long enough
to scroll, which is a bug the widget has. And a group's heading leaves rather
than fading in place, which stops each group reserving a strip of nothing above
it at collapsed width.

The rail also now remembers whether it was left open, which the widget never
did. A preference, so it is per device and never travels in a shared link.
The kit's rule: a fragment and its recipe are two renderings of one decision,
and a drifted recipe teaches the AI a shape the codebase has stopped using.
A template authored in the browser cannot import the kit, so the JSON is what
it actually needs.

Carries the two traps with it — the label is mounted rather than narrowed to
zero (a hidden-but-present label is still in the accessibility tree and still
found by find-in-page), and data-we-id belongs on a native element, because a
web component's props are assigned as DOM properties and the attribute
we-sortable looks for would never exist.
Adds the three rail fragments and their ambient contract to the kit listing and
the scope table — the table is the only place a reader can see that railItem
outside a railShell fails silently, which is the one way to get these wrong.

The listing had also fallen behind on its own account: seven fragments and the
input tier were missing from it.
The custom-elements manifest is rebuilt as part of the primitives build, so the
sortable comment only reached the generated reference on a full build rather
than on generate-context alone.
@netlify

netlify Bot commented Aug 12, 2026

Copy link
Copy Markdown

Deploy Preview for coasys-we ready!

Name Link
🔨 Latest commit 54e575a
🔍 Latest deploy log https://app.netlify.com/projects/coasys-we/deploys/6a7cf61ccdee560008c68696
😎 Deploy Preview https://deploy-preview-117--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 bca58a1 into dev Aug 12, 2026
4 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