Skip to content

Design of formulas

  • type ahead

done

  • simplest mental model
  • read [[algebra]]
  • all plain attributes (eg, "x") refer to self SO
  • dot-prefix (eg, ".x") refers to parent SO
  • SO name.attribute name -> reference a non-parent SO (eg, "A.x")
  • for attributes with invariant = true -> use default derived formula
    • derived formulas use bare self-refs (eg "X - w")
  • empty formula for position attributes -> silently use parent.attribute + value
    • empty formula for length attributes -> silently use value

Bugs

  • values are computed wrong for .s and .e
    • NOT grab value from parent
    • compute value such that position coincides with parent’s attribute
  • 2' resolves to 1' 12"
  • value column too narrow, increase by 20px
  • values for start in all axes for root MUST ALWAYS be ZERO
  • for attributes with formulas, disable the value cell
  • stretch a parent -> rotates children (ack!)
  • restore on launch also rotates children
  • invariant flag ignored during resolve → added enforce_invariants()
  • w value shows geometry instead of attribute value → resolve rule: always read .value
  • X row shows old user formula after invariant change → set_invariant must clear formula first
  • persisted formula on invariant attribute survives reload → rebind_formulas clears invariant formulas at top
  • compound imperial .w - 1 1/2" didn't parse → added try_compound_feet/inches to Tokenizer
  • algebra fails 1" + 2" -> 1'
  • 3' - 1/4" -> does not work in value cells
  • display formulas with spaces between tokens (eg. ".l - front_th + dado")

Examples

For the "x" row:

FormulaResolves to
(empty)parent.x_min + x_min
x * 2self.x_min * 2
.x * 2parent.x_min * 2
A.x * 2A.x_min * 2
.x + A.Xparent.x_min + A.x_max

Proposal

Mental model: x means "my own x." .x means "parent's x" (dot-prefix, like A.x with name omitted). Only name another SO when you mean something other than your parent. Empty formula = parent.attribute + value — the default relationship every child already has.

Per architecture, aliases go in Constraints.resolve and .write. Tokenizer, compiler, evaluator stay untouched.

Four changes, in order

  1. Alias map in Constraintsresolve_alias(attribute) maps xx_min, yy_min, w → computed width, etc. Called inside resolve() before get_bound(). Derived aliases (w, h, d) compute from two bounds (x_max - x_min). Reverse propagation: writing w = 300 sets x_max = x_min + 300.
  2. Bare attribute → parent reference — bare identifier like x (no dot) currently throws. Change: bare identifier → reference to parent SO with that attribute. x * 2 tokenizes as { type: 'reference', object: <parent_id>, attribute: 'x' }. Needs parent id passed into tokenizer context or resolved later in Constraints.
  3. Empty formula default — when formula is empty/null, Constraints silently treats it as parent.<attribute> + <current_value>. The identity relationship — child at offset from parent.
  4. Invariant-derived formulas — when an attribute has invariant = true, it's not a source of truth — it's derived from the other two in its axis. Constraints generates its formula from the alias map's "For Invariant" column: if x is invariant, xX - w (position derived from far edge minus width). Only one attribute per axis can be invariant. The other two are edited directly; the invariant one recomputes.

Tests

  • Alias resolution: xx_min, Xx_max, w → width
  • Bare attribute: x * 2 with parent context → parent.x_min * 2
  • Cross-SO reference: A.x * 2A.x_min * 2
  • WRONG: Empty formula: evaluates to parent.x_min + value
  • Reverse propagation through aliases: change result, w updates x_max
  • Invariant default: marking x as invariant gives it formula X - w; changing X or w recomputes x
  • Only one invariant per axis: marking x invariant means w and X stay editable

Proposal — center letter, kept as a read-only reference (Way B)

Adds a new bare letter for the center of a direction, alongside the three letters that name start, end, and length. The bare form is c; the axis-qualified forms are y.c, .x.c, and so on.

The new letter is read-only. Formulas may reference it. The compiler does not rewrite it into "start plus end over two"; it stays a real reference. A new branch in the resolver computes the value on read. Reverse propagation never writes through a center reference — there is no write path for center, period. A drag on a cell whose formula reads a center is refused, and an alert is displayed "cannot drag a center"

Compared to the simpler "compile-time expansion" alternative, this version is more code but unlocks reading the center from any code that walks an axis's cells (debug logs, optional tooltips) without re-deriving it inline. Note: the attributes panel deliberately does not gain a row for center — see the phase-three decision.

What changes

  1. Bare-name table. The table that today lists three letters — start, end, length — gains a fourth entry for center. The letter's own concrete form per direction is the same letter prefixed by the direction name (for example x_center for the first direction's center).

  2. Accepted-letter list. The validation list the parser uses to reject unknown letters gains the new letter.

  3. Compile-time bind step. Where a bare letter today is rewritten to its concrete form, the new letter takes the same path. After the bind step, a reference to center looks like any other reference, with attribute name set to the per-direction concrete form.

  4. Resolver — read. A new branch in the resolver: when asked for a center, look up the SO, take its start and its end on that direction, return the average. No new field is stored on the axis; the value is computed every time it is read.

  5. Reverse propagation — refuses center, with a visible alert in a new status strip. The writer that today pushes a target value into a free reference adds a guard at the top: if the reference points at a center, the write is refused and the running app posts a short message reading "cannot drag a center" to a new on-screen status strip. The strip is a new piece of user-interface; its exact placement and look are decided at implementation time. The drag does not commit; the corner, edge, or face the user grabbed snaps back to where it started.

  6. Self-loop check at edit time. When a formula is being set on the start, end, or length of a direction, scan the formula for a center reference that points at the same direction on the same part. If found, reject the formula at the moment it is typed — this is the only case where center can introduce a loop through one part on its own. Loops that span multiple parts or multiple directions through center are not chased here; they would surface at run time. The existing chain detector is unchanged.

Interaction with the invariant mechanism — decided

Center sits outside the invariant mechanism entirely. The user still picks one of start, end, length to be the recomputed cell on each direction. Center is always computed on every read and is never the user-chosen invariant. The catalog rule about exactly one recomputed cell per direction stays unchanged.

What this means in practice:

  • The user-interface that today lets the user point the recomputed marker at one of the three storage cells does not gain a fourth choice for center.
  • Center has no stored value to save. The save file contains start, end, and length per direction, the same as today.
  • Formulas that reference center are saved with the bare letter intact. A user-typed formula such as c + 50 is stored as the literal text c + 50 — not as the equivalent expansion in terms of start and end. On load, the same text is read back and the bind step turns the letter into the same reference it had before saving.
  • The deserializer needs no migration for older saved files. The save format already stores formula text untokenized; older files that do not use the new letter open as before. Files that use it work as soon as the bind step learns to recognize it.
  • The invariant pass that recomputes one of the three storage cells after a change does not run for center — center has no stored value to restore.

Tests for the center letter

The complete test plan lives in each phase's "What gets tested" subsection further down — every test is attached to the phase that puts it in place. The whole-feature acceptance set passes when phase two is done, which is the moment the feature reaches end users.

What this leaves for later

The attributes panel does not gain a row for center; that decision is recorded in phase three. If a future reviewer revisits and decides to add a row, the resolver already returns the value on read, so the row would be a small UI change.

If a future feature wants to allow writing through center (for example a "drag the SO by its center" gesture), it has to revisit the read-only decision and add a writer with explicit semantics. The current proposal deliberately leaves no half-built writer to mislead a future reader.

Risk assessment

The risk picture for the current proposal — read-only, drag refused with a visible alert, cycle detection at edit time, center outside the invariant mechanism.

Reading the levels. Each item's [low], [medium], or [high] tag is the residual risk — what is left after the mitigation listed alongside the item is actually applied. The mitigation is part of the proposal, not optional. An item without its mitigation can be one or two levels worse than the tag suggests; an item with its mitigation in place sits at the level shown.

Higher-stakes — needs care during implementation

  • The self-loop check has to fire on the right host cells. The check rejects a formula on start, end, or length of a direction that references the center of that same direction on that same SO. It must not fire when the host is the angle cell on the same direction (angle is not part of the start-end equation), and it must not fire for cross-direction or cross-SO references. Mitigation: a small set of tests that walks every combination — same-direction-and-SO is rejected, cross-direction is accepted, cross-SO is accepted, host-on-angle is accepted. Evidence — the place where the check would sit, alongside the existing cycle entry: di/src/lib/ts/algebra/Evaluator.ts:72-81

  • [low] All bind-step entry points are assumed to know the new letter. The proposal makes this an explicit assumption: every place in the running app that calls the shared shorthand-to-reference step — when the user types a formula, when a saved file is loaded, and when a part is renamed — is updated as part of the work. The residual risk is the regression case: a future code change adds a fourth entry point or edits an existing one in a way that drops the new letter. Mitigation has two parts:

    1. A checklist used during the initial implementation. Walk every place in the running app that calls the shared shorthand-to-reference step. Mark each one as "knows about the new letter" only after a code change confirms it does. The list is short — three places at landing time: typing a formula, loading a saved file, renaming a part.
    2. A round-trip test, kept in continuous integration after landing. Drive a formula that uses the new letter through all four user actions one after the other: type it, save the world, reload from the saved data, then rename a part the formula points at. After the round-trip, the formula must still resolve to the same number it did right after typing. The test guards against any future regression that breaks an entry point or adds a new one without coverage.

    Evidence — the bind step: di/src/lib/ts/algebra/Constraints.ts:179

  • The drag-time alert lives on a new status strip that does not exist today. A new piece of the running interface — a status strip that displays short transient messages — has to be added before the alert can be wired. The strip is a small surface but it is new code: a publish-to-strip helper, a place on the page for the strip to render, and rules for how long a message stays before it fades or gets replaced. The first message the strip ever shows is "cannot drag a center"; later features may publish their own messages through the same strip. Mitigation: complete the strip as its own commit before the center work lands, with a placeholder message, so the strip's behavior is settled before center starts using it.

Medium — manageable, real

  • The translation tables that move formulas between axis-agnostic and concrete forms have no entry for the new letter today. Adding center means either expanding both tables or making the translator skip center references. Either choice has to be decided; missing the decision means a formula round-trip silently corrupts text that contains the new letter. Evidence — the two translation tables: di/src/lib/ts/algebra/Constraints.ts:97-117

  • Visual snap-back on a refused drag. When the user grabs a corner whose formula uses center and drags, the corner has to visibly return to where it started after the alert fires. I AM GUESSING that the drag system commits visual position optimistically as the user moves the mouse — if so, refusing at drag-end means the corner sits in the wrong place during the drag and snaps back at release. That is a usable result but not pretty. The alternative — checking up front and refusing immediately — needs the drag-start to know the corner cannot move, which means the drag-start has to inspect the formula on every grab. Either choice is workable; the implementer picks.

  • Performance on heavily-center-referenced scenes. Each read of a center value does the start-plus-end-divided-by-two arithmetic on every read. I AM GUESSING this never matters in practice, because center references are rare and the arithmetic is two reads and a divide. If a future scene has tens of thousands of center references in a tight inner loop, profiling will show it; until then, no work is needed.

Low — surface adjustments

  • Documentation outside the project's notes (the on-screen help, the build-notes panel, any user-facing reference) might list only the existing letters. Each spot has to be updated.
  • Catalog bookkeeping: a new rule lands and the count line at the top of the rules file changes by one.
  • Older saved files do not have center references, so they open as before. No migration needed.
  • A typo c in a saved file from before this change — if such a file exists — used to fail to bind. After the change, the same typo binds to the new letter and the formula now resolves to a number. I AM GUESSING this is a non-issue because pre-change saves with a literal c would have been rejected and the user would have fixed them at the time.

Risk-mitigations worth doing

  • Land the resolver and the bind step first. Verify all six tests pass before touching the drag-refusal path. The center-reading half of the proposal is the simpler half and benefits from being done on its own.
  • Add the drag-refusal as a second commit, gated behind a flag for the first iteration so a real bug can be reverted in one line.
  • The self-loop test — a formula on start, end, or length of a direction that references center of that same direction on that same SO — is the most important test in the suite. Keep it explicit and well-named so a regression is immediately readable.

Honest framing

With every bind-step entry point assumed to know the new letter (the proposal lands all three at the same time as the rest of the work), the residual risk picture is now relatively flat. Multi-part or cross-direction cycles through center surface at run time and are accepted as out of scope for the simple loop check; the chain detector can be taught about center later if real users hit such a cycle. The drag-time alert channel was a real implementation question; that one is settled — the new status strip described in phase zero. Documentation drift remains the smallest risk.

Implementation in phases

Four phases. Phase zero, phase one, and phase two are all required for the feature to be completed. Phase three is observability polish and can be skipped or deferred. Phases one and two split the work for clean code review and clean revert paths, but they do not separately complete this feature — phase one without phase two leaves drags failing silently, which is a usability bug. Phase one can sit on a development branch on its own; it cannot reach end users without phase two also landing.

Phase 0 — the new status strip (done 2026-04-28)

A short on-screen surface that displays brief transient messages. done on 2026-04-28 as its own piece of work. The first iteration carries a placeholder caller hooked under the name di_status so a developer can fire messages from the browser console; the center-letter work in phase two will be the first real caller and the placeholder comes out then.

Layout
  • The strip sits along the bottom edge of the graph region, between the build-notes button on the left and the guides slider on the right.
  • Height: the standard common-button height — the strip lines up vertically with the build-notes button and reads as a sibling of it.
  • Empty space below the strip down to the graph region's bottom edge: one standard layout gap.
  • Empty space between the strip's left edge and the build-notes button's right edge: one standard layout gap.
  • Empty space between the strip's right edge and the guides slider's left edge: one standard layout gap.
Behavior
  • The strip is invisible by default. When no message is current, the strip takes no visual space — the area between the build-notes button and the guides slider looks empty.
  • A new message is published through a small helper. If no message is currently shown, the new one appears immediately. If a message is already shown, the new one is added to the back of a queue.
  • A current message stays visible until the user clicks anywhere on the page — any button, any spot on the canvas, any other widget. The click that dismisses the message also performs whatever else the click would normally do; the strip dismissal is a side effect, not a blocker.
  • When the current message is dismissed, the next message in the queue takes its place. When the queue is empty after dismissal, the strip goes back to invisible.
  • Messages of the "error" kind are rendered in red text. Other kinds are rendered in the default text color.
  • All messages are rendered horizontally centered inside the strip.
What gets coded
  1. ✅ A new component file at di/src/lib/svelte/main/Status_Strip.svelte. It owns the visual element, reads from the small status-store described below, and renders the current message text — red if the message is an error, default color otherwise, always centered.
  2. ✅ A small store at di/src/lib/ts/managers/Status.ts. Exposes show(text, kind), dismiss(), and clear(). The store holds a list — the first entry is the currently visible message, the rest are queued.
  3. ✅ A click hook on the page: any click anywhere calls dismiss() once. Wired into the existing central mouse handler in di/src/lib/ts/events/Events.ts.
  4. ✅ A two-line addition to the graph component to render the strip as a sibling of the build-notes button and the guides slider.
  5. ✅ One placeholder caller during phase zero only — the helper is hooked to the page on startup so a developer can run di_status.show('hello') or di_status.show('boom', 'error') from the browser console. The property name is di_status rather than status because the browser already owns window.status as a built-in deprecated string and assigning to it silently coerces the value. The placeholder gets removed when phase two lands.

Implementation status (2026-04-28): done. Code-complete, unit-test-complete, type-check clean, visual verification done on the running page. Twelve tests pass — the nine listed below plus three extras (no-op dismiss, distinct kinds across the queue, runaway dedup at one hundred publishes). The strip appears at the bottom of the canvas between the build-notes button and the guides slider, error messages render in red, click-anywhere dismisses, the queue surfaces the next message in order. The only remaining cleanup is removing the temporary console-exposed caller when phase two lands.

Tests for the strip
  • Empty default. Before any message is published, the store is empty and the strip is invisible.
  • Publish makes visible. Publishing a message when none is shown puts that message at the front of the store and the strip becomes visible.
  • Second publish queues. Publishing a second message while one is shown puts the second at the back of the store; the first stays at the front and the strip keeps showing the first.
  • Dismiss promotes the next. Calling the dismiss helper removes the front of the store. If a queued message exists, it becomes the front and the strip shows it. If the queue is empty, the store is empty and the strip becomes invisible.
  • Clear empties. Calling the clear helper removes every message — front and queue. The strip becomes invisible.
  • Kinds preserved across the queue. An error-kind message and a default-kind message survive queueing and dismissal with their kinds preserved — the strip renders the error in red and the other in the default color.
  • Dedup against the current message. Publishing a message that exactly matches the currently shown message is a no-op — the queue does not grow.
  • Dedup against the back of the queue. Publishing a message that exactly matches the message at the back of the queue is a no-op — the queue does not grow.
  • Many publishes do not run away. Publishing the same message a hundred times in succession leaves the queue with at most one copy of that message.

The visual placement and the click-anywhere-to-dismiss wiring are not unit-testable. They will be exercised by the developer running the placeholder caller and clicking the page. A future browser-driven test (the Playwright plan) would assert on the strip's pixel position, on the red text for an error message, and on the dismiss-on-click behavior.

Open question for the implementer — answered

The build-notes button width and the guides slider width were not published as named layout values before this work. They are now: two new style values, one for each width, are read from the constants table on startup and used by the strip's left and right offsets. If either neighbor's width changes, only the constants table needs updating.

This phase is done. The center-letter work can now proceed to phase one whenever a developer picks it up; phase two will wire the silent refusal in phase one to the strip that already exists.

Risks specific to phase zero (retrospective)

The five risks below were the picture before the work done. After the work landed: none of them surfaced as real bugs. The notes are kept here as a reference for the reader who wants to understand the design choices.

  • [low] The click-anywhere-to-dismiss hook reuses the existing central handler. All clicks on the page already pass through one place — the handle in the events file. The dismiss is a one-line call added there, so there is no new global hook and no ordering question with other handlers; the dismiss simply runs alongside whatever else the central handle already does on every click. Mitigation: the call site is a single line in one file.
  • [low] Empty-state layout shift. When the strip is invisible, the area between the build-notes button and the guides slider has to look the same as it does today. If the strip takes vertical space when empty, the canvas region shifts up and down each time a message arrives or leaves. Mitigation: the empty state uses no height at all (or absolute positioning so layout flow is untouched).
  • [low] Neighbor-width fragility. The strip's left and right edges depend on the build-notes button width and the guides slider width. I AM GUESSING those widths are not currently published as named values. If they are not, the strip's left and right offsets become hard-coded numbers — fragile if either neighbor's width ever changes. Mitigation: publish the two widths as named layout values when the strip lands.
  • [low] Queue race during dismissal. A new message published in the same tick as a dismiss-click could be ordered ambiguously: did it land before or after the dismiss? Mitigation: keep the queue logic synchronous and well-defined — publish appends to the back, dismiss removes from the front, both run to completion before the next event.
  • [low] No browser test coverage during this phase. The visual placement and the click-anywhere wiring are not unit-testable. The phase relies on the developer running the placeholder caller and clicking the page. A regression that creeps in later between releases could go unnoticed until the browser-driven tests land.

Phase 1 — center reads, drag refused silently

Lands the read-side of the new letter end to end, plus a clean refusal of any drag whose math would land on a center reference. No alert yet — the refusal is silent, which is wrong from a usability standpoint but not broken.

What lands:

  • The bare-name table gains the new letter.
  • The accepted-letter list gains the letter.
  • The bind step recognizes it at every entry point — the user-input compile path, the file-load path, and the rename path.
  • The resolver gains a branch: when asked for a center, return start-plus-end-over-two on that direction.
  • The reverse-propagation writer gains a guard at the top: if the reference points at a center, return early without writing.
  • The bind step gains a small self-loop check: when a formula is being set on the start, end, or length of a direction, scan the formula for a center reference that points at the same direction on the same SO; reject if found. The check runs at the moment a formula is set, before the formula commits. The existing chain detector is unchanged.

What gets tested:

  • Forward read, bare form. A formula on a start cell that reads the host direction's center evaluates to start-plus-end over two on that direction.
  • Forward read, axis-qualified form. A formula on an end cell that reads a different direction's center (in the form <axis>.c) evaluates to start-plus-end over two on that named direction.
  • Forward read, mixed forms. A formula that mixes the bare form and an axis-qualified form in one expression evaluates correctly.
  • Forward read, arithmetic. A formula c + 50 evaluates to the host direction's center plus fifty.
  • Self-loop rejection. Setting a formula on the start, end, or length of a direction where the formula references the center of the same direction on the same SO is rejected at edit time; the formula does not commit; the rejection is reported to the caller of the formula-set step.
  • Self-loop, exempt cells. Setting a formula on the angle cell of a direction where the formula references the center of that same direction is accepted — angle is independent of start-end.
  • Self-loop, out-of-scope cases accepted. A formula that references the center of a different direction is accepted. A formula that references the center of a different SO is accepted. (Cross-direction and cross-SO cycles are explicitly out of scope; if a real user hits one, surface it then.)
  • Drag silently does nothing. The user grabs a corner, edge, or face whose number comes from a formula that mentions the center letter and tries to drag it. After the drag attempt, every number on the SO and its ancestors is unchanged. (No alert in phase one — that lands in phase two.)
  • Drag with a locked sibling. The user grabs a cell whose formula reads the center of a direction where one of the two storage cells on the same direction is locked. The drag is refused. The locked cell's value is unchanged; the unlocked cell's value is unchanged.
  • Round-trip through every entry point. Type a formula that uses the new letter; save the world; reload from the saved data; rename a part the formula points at. After the round-trip, the formula still resolves to the same number it did right after typing.
  • Translation round-trip. A formula that contains the new letter is converted from concrete-form to axis-agnostic form and back. The text comes back unchanged.
  • Catalog count. The catalog count line at the top of the rules file still adds up after the change — no rule is silently broken.

What does not land in phase one: the status strip alert, the visible snap-back animation, any change to the parts panel.

This phase is reviewable and revertable on its own as a code-change unit. It is not enough on its own: the silent refusal of a drag is a usability bug. Phase two must land before this work reaches users.

Risks specific to phase one
  • [low] Self-loop check fires on the wrong host or skips the right one. The check rejects a formula on start, end, or length of a direction that references center of that same direction on that same SO. It must not fire on the angle cell, and it must not fire for cross-direction or cross-SO references. Multi-part cycles through center are explicitly out of scope; if a real user hits one, surface it then. Mitigation: a small set of tests covering every combination — same-direction-and-SO rejected, cross-direction accepted, cross-SO accepted, angle-host accepted.
  • [low] All bind-step entry points are assumed to know the new letter. The proposal lands all three entry points (typing a formula, loading a saved file, renaming a part) at the same time, so on landing there is no entry point without the knowledge. The residual risk is regression: a future code change adds a fourth entry point or edits an existing one in a way that drops the new letter. Mitigation has two parts. First, a checklist used during the initial implementation: walk every place that calls the shared shorthand-to-reference step and mark each as "knows about the new letter" only after a code change confirms it. Second, a round-trip test kept in continuous integration: drive a formula that uses the new letter through type, save, load, and rename in sequence, and confirm the formula still resolves to the same number afterward. The test stays in the suite to catch any later regression.
  • [low] Translation tables miss the new letter. The two tables that move formulas between concrete and axis-agnostic forms have no entry for center today. If the tables are not extended (or not made to skip center references), a formula round-trip silently corrupts text that contains the new letter. Mitigation: explicit decision when the work lands — either extend the tables or make the translator skip center references — and a test that round-trips a center-using formula through translation.
  • [low] Silent refusal feels broken to a user — phase two is the cure. A drag that does nothing is a real usability problem. The proposal treats phase two as non-optional, so phase one cannot reach end users without phase two also in place. Mitigation: phase one is held on a development branch until phase two is done; the two together constitute completion.
  • [low] Reverse-propagation guard order. The guard for center sits at the top of the writer. If the guard is added below the existing locked-target check or below the multi-reference check, the wrong refusal path wins for some edge cases. Mitigation: a test that triggers a drag where the formula uses center and one underlying cell is locked — confirms which refusal path fires.

Phase 2 — visible refusal alert

Wires the silent refusal from phase one to the status strip from phase zero. The user sees what happened and why.

What lands:

  • The reverse-propagation guard, instead of returning silently, publishes the message "cannot drag a center" to the status strip and then returns early.
  • If the corner moved visibly during the drag (because the drag tool commits visual position optimistically), it snaps back to where it started when the drag is refused. I AM GUESSING the drag tool commits visual position as the user moves; if so, the snap-back is a small render-side change in the drag-end path. If the drag tool only commits at end, no snap-back is needed.

What gets tested:

  • Refused drag posts the message. The user grabs a corner, edge, or face whose formula reads a center and tries to drag it. After the attempt: every number on the SO and its ancestors is unchanged, and the status strip displays "cannot drag a center."
  • Error styling. The refusal message renders in red text and is horizontally centered inside the strip.
  • Click-to-dismiss after refusal. After the refusal message appears, the user clicks anywhere on the page. The strip becomes invisible (no queued message remained).
  • Dedup under rapid drags. The user repeatedly tries to drag a center-using cell five times in a row. The strip publishes one message, not five — the queue does not fill with duplicates.
  • Snap-back to original position. After a refused drag, the corner the user grabbed is at the same screen position it was at before the drag started. (If the drag tool commits visual position optimistically during the drag, this asserts the snap-back path; if not, this asserts the absence of any spurious visual change.)
  • Refusal across drag handle types. The same test repeats for a face drag and an edge drag, not just a corner drag.

This phase is non-optional — phase one cannot reach end users without it. It depends on phase zero (the strip) and phase one (the silent refusal already in place).

Risks specific to phase two
  • [medium] Snap-back jitter. I AM GUESSING that the drag tool commits visual position to the canvas as the user moves the mouse. If so, refusing at drag-end means the corner sits in the wrong place during the drag and snaps back at release — that is functional but not pretty. The alternative is to refuse up front, which means the drag-start has to inspect the formula on every grab. Either choice is workable; the implementer picks. Mitigation: try the drag-end approach first (less intrusive), and if the visual feel is poor, escalate to the drag-start approach in a follow-up.
  • [low] Strip behaves wrong under rapid drags. A user who repeatedly tries to drag a corner whose formula uses center publishes the same message many times in quick succession. Without dedup, the queue fills with the same message. Mitigation: in the strip's queue logic, ignore a published message that exactly matches the currently shown message — or that matches the back of the queue.
  • [low] The text "cannot drag a center" is fixed in code. If a future feature adds another reason a drag is refused, two refusal paths now publish to the same channel and the user has to read the message to know what happened. Mitigation: the message text lives in one named constant per refusal reason, not scattered across the writer code.

Phase 3 — center visible to developers (optional, later)

Pure observability polish. Not required for the formula layer to work.

Decision: the attributes panel does not gain a new row for center. The three storage cells (start, end, length) are the only rows the user sees per direction. Center is purely a formula-layer value; the user reads it by typing a formula that references it, not by reading a row in the panel.

What lands:

  • Debug logs that print an SO's state include the center on each direction. A developer reading a trace sees the computed value next to the three stored ones.
  • Optional: a hover tooltip on an SO can include the center, for the case where a developer or curious user wants to see the value without typing a formula.

What gets tested:

  • Resolver correctness. A focused unit test builds an SO, writes start and end values on every direction, and asks the resolver for the center on each direction. The result equals start-plus-end over two for every direction.
  • Resolver after change. After editing the start of one direction, the resolver returns the new center on that direction. (Re-reads compute fresh from the latest start and end — there is no stale cache.)
  • Debug-log inclusion. When the per-SO debug print is enabled, the printed text contains the center value next to start, end, and length for every direction.
  • Optional tooltip — only if the tooltip lands. Hover over an SO and confirm the tooltip text includes the center on each direction, formatted in the user's chosen unit system and precision.

This phase is optional. It does nothing for formulas; it pays off only for developers reading debug logs and (if the tooltip lands) curious users hovering on an SO.

Risks specific to phase three
  • [low] Debug-log noise. Including center on every direction in every SO's debug print roughly doubles the line count for an SO trace. If traces are noisy already, the extra columns hurt readability. Mitigation: print center only when explicitly asked — for example, gated by a debug flag — rather than in the default trace.
  • [low] Tooltip clutter. If the tooltip lands and includes center alongside other facts about the SO, it grows in height. A long tooltip can obscure the canvas behind it. Mitigation: keep the tooltip layout tight; if center makes it too tall, drop one of the other lines or split center onto its own toggleable hover.
  • [low] Scope creep back into a panel row. A reviewer or future user may revisit the no-row decision — "why can't I just see the center value next to start and end?" The decision was deliberate (center is a formula-layer concept, not a stored cell), but the request is natural. Mitigation: a short note in the milestone file recording the reason for the no-row decision, so the next reader does not re-litigate it from scratch.

Order of operations

Phase zero is done. Phase one and phase two are still required for the feature to reach end users. Phase three (debug logs and optional tooltips) is observability polish and can be skipped or deferred without affecting the user-visible feature.

The remaining sequence is one → two → (three). Phase one can be picked up immediately. Phase two cannot be merged before phase one (no refusal path to wire up). Phase one cannot be merged to a release branch ahead of phase two; the two reach end users together.