Skip to content

stb: composite facet framework - #5546

Merged
norman-abramovitz merged 27 commits into
cloudfoundry:developfrom
nabramovitz:norm/feat/stb-composite-facet-framework
Jul 3, 2026
Merged

stb: composite facet framework#5546
norman-abramovitz merged 27 commits into
cloudfoundry:developfrom
nabramovitz:norm/feat/stb-composite-facet-framework

Conversation

@nabramovitz

Copy link
Copy Markdown
Contributor

Closes #5517

WIP — still collecting look-and-feel corrections from live testing; the underlying framework is complete (601 stb tests green).

Adds the composite facet framework to the Stratos Theme Builder:

  • Composite facet model: comma-list and positional-tuple composites covering themed CSS shorthand properties, with structured gradient editing (per-stop color/position).
  • Background layer editing: layer add/remove with labels, live paint-stack summary showing the emitted CSS, dual light/dark preview.
  • Delete-tier primitives: per-value Clear (fall back to snapshot), transparent keyword, and per-node Reset to the pristine loaded model.
  • Occlusion handling: when a page background is covered by an overlay child (e.g. the login backdrop), the editor surfaces a hint and a jump link instead of silently editing an invisible layer.
  • Fixes a color-picker outside-click handler leak that made the picker work only once per page load.
  • Docs: text contrast against arbitrary backgrounds (tools/stb/docs/text-contrast-and-dark-mode.md) — break-even luminance, AA guarantees, gradient/scrim handling.

…ge (cloudfoundry#5517)

Composite background emit + projection (plan Commit 2, Tasks 3-5):
- facets.ts: facetValueCss/gradientCss/backgroundCss composite emit
  helpers; FACET_PROPS carries the flat longhands + background.color
  (the projector routes background.color per-property; the emitter
  emits background only via backgroundCss). backgroundCss emits a
  literal backstop color + reversed image layers, and skips {token}
  colors (projected to :root, not scoped-overridden).
- css-emitter.ts: wire backgroundCss into the light + dark scoped
  blocks; the flat facet loop skips background.* so a literal
  backstop color is not emitted twice.
- projector.ts: colorOf reads text.color ?? background.color;
  leafValueOf returns backstop color, else content, else the topmost
  image layer ref, else legacy asset.ref.

Minimal UI plumbing pulled forward so the tree stays green (composite
model removed surface.background): element-edit leverValueToFacets
writes background.color; element-tree/element-columns read it.

Migrates the remaining test fixtures to the composite shape.
297 tests green; typecheck + lint clean.
…loudfoundry#5517)

Task 8: facets-edit background helpers (setBackstop/addLayer/setLayer/
removeLayer/reorderLayer). element-edit.ts's color->background.color
route was already landed in a prior commit; verified, not duplicated.

Task 9 slice 4a: renderBackground in facet-tree.ts — backstop color
swatch row + image layer rows (file input, thumbnail label) + gradient
layers as a read-only summary row (full editing is slice 4b) + remove/
move-up/move-down controls + "+ image" footer button. Callbacks wired
through lever-editor.ts -> main.ts following the onAddGroup pattern.
Standalone asset leaf now guarded to <img>-role elements only.
Review fixes for the gradient layer editor (C4b):
- type switch now constructs the target Gradient arm explicitly
  (carries stops/repeating, position where supported) instead of
  spreading the old object, so arm-specific fields (angle, shape,
  size, fromAngle) can't leak into arms that don't own them
- DOM tests for stop add/remove/position edit, conic position
  editing, and cross-arm field dropping
…ndry#5517)

Task 2's migration renamed surface.background to background.color but
deriveDark's role selection wasn't updated, so background swatches
derived with the foreground role (contrast lift) instead of darkening.
…yer clear (cloudfoundry#5517)

Final-review fix wave, four findings:

1 (critical): composite edit handlers in main.ts no longer call
  selectElement after mutating — rebuilding the popover mid-edit tore
  down the focused input (one typed character per interaction). The
  lever editor's model-change effect re-renders the tree instead;
  suppression narrowed to text-entry controls so file inputs refresh.
  Companion: the gradient row tracks its current gradient in a let
  updated on every write, so consecutive edits to two gradient fields
  compose instead of clobbering (mount-time closure was stale).

2: leverPatchesFor is dark-aware — in dark preview the inline
  background composes from facetsDark.background only; with no dark
  override no patch is sent (scoped blocks + built-in dark CSS own
  it). previewDark is subscribed in the levers effect so a toggle
  re-sends patches.

3: applyLevers background branch always assigns both inline props, so
  a patch that omits a component clears the stale value (removing the
  last layer drops the old background-image). No patch = no touch.

4: leafValueOf comment now states the precedence IS the CSS
  cascade/paint order (deliberate) and why color must not win over
  the image (it is also token-routed; winning here would double-emit).

Tests: +10 (gradient clobber x3 browsers, spacing focus survival,
file-input re-render, dark patch composition x3, stale clear +
no-touch); one stop-add/remove test updated to composed semantics.
406 green.
…cloudfoundry#5517)

- background is now a collapsible GroupEntry (branch header + Expand/Collapse
  all + Isolate), same as text/surface/spacing; no remove affordance since
  background isn't wired to onRemoveGroup
- each background row gets a kind subtitle: "color", "image — <ref>" /
  "image — (no image)", "gradient — <type>"
- backstop label renamed to "color" to match the CSS property it emits
  (background-color); model field stays background.color
- usability-grade CSS for .stb-facet-bg-row/.stb-facet-bg-gradient*: bordered
  tinted rows, indent + left-rule for layers under the color base
- lever popover growth: raised the max-height cap to calc(100vh - 2rem),
  bounded max-width growth to min(640px, 92vw); resize:both (manual) and the
  drag handle are unchanged — v1 auto-grows until the user's first manual
  resize, documented in the CSS comment
…placement (cloudfoundry#5517)

Systemic blank-value guard at emit: a blank literal (empty/whitespace
string) means "unset, emit nothing" everywhere CSS is emitted from a
facet: facetLiteralCss, fontFamilyCss, spacingDeclarations,
backgroundCss/composeLayerImage, and the contentAssetDeclarations
topmost-image backward scan (which now skips a blank-ref image layer
instead of masking a real lower one). Token entries are never blank.

Two comment-only closures: css-emitter.ts documents that the dark axis
is color-only (no dark fontFamilyCss); migrate-facets.ts documents
itself as one-shot fixture tooling with no production callers.

Compare-mode dark column: the lever-editor panel's stb-preview-dark
class now also tracks compareMode, not just previewDark — compare mode
pins previewDark false while a live dark pane is visible, so the dark
column was dimming backwards.

Color picker placement: reuses the lever-editor's positionAbovePanes
(moved to popover.ts, exported) so the picker no longer collapses onto
the light pane in compare mode.
…loudfoundry#5517)

- Instrument the shared confirm-dialog <p> as
  shared.confirm-dialog.message (stba-role=paragraph) with a values
  sidecar entry; regenerate the shared branding model.
- Content facet gains format?: 'plain'|'subset' (absent = plain,
  byte-identical to the old shape everywhere).
- New closed-grammar subset parser (src/content/subset-format.ts):
  **bold**/_italic_/newline only; DOM construction + escaped-text HTML
  serialization; no innerHTML; unterminated markers stay literal.
- apply-levers + preview shim render subset via DOM construction (shim
  mirrors the parser in plain JS — it accepts '*' postMessage, so no
  innerHTML sink); plain content keeps textContent.
- Editor content leaf gets a plain|formatted select wired through
  onContentEdit/contentValue; dialog message is the first subset user.
…5517)

- Lever editor remembers its dragged position (and size, when actually
  resized) module-level across the selectElement teardown/rebuild;
  reopen reuses the rect clamped to the viewport. Explicit Close clears
  the memory (one-line CLEAR_MEMORY_ON_CLOSE policy, easy to flip).
- Gradient stops now honor the blank-literal guard: blank stop literals
  are skipped (no dangling empty stop), and a gradient whose stops are
  ALL blank emits nothing — its layer is skipped in composition on both
  the scoped-CSS and live-preview patch paths.
Norm's call: re-placing the editor on every reopen would irritate.
Broader editor-session memory (open groups, isolate, unsaved values)
deferred until it demonstrably bothers users.
…ploads (cloudfoundry#5517)

Deletes the dead background LeverPatch leg (public/preview-shim.js never
handled kind:'background', so it never reached the real preview iframe) and
adds blob-URL rewriting to the scoped-blocks CSS path, so user-uploaded
background images actually resolve inside the iframe instead of 404ing.

- src/state/branding-assets.ts: new rewriteAssetUrls(css, store, callsiteKey),
  with per-callsite revoke-on-replace so live panes don't clobber each
  other's object URLs; attachAssetBlobs collapsed to asset-only.
- src/ui/preview-pane.ts: applyScopedBlocksToPreview rewrites asset refs
  before sending STB_APPLY_BLOCKS; leverPatchesFor drops the dead
  background patch + its now-unused dark param.
- src/ui/export-dialog.ts: comment documenting the deliberate asymmetry
  (export keeps raw refs; the bundle ships real files at those paths).
- src/metadata/facets.ts: deleted backgroundPatch.
- src/iframe-bridge/apply-levers.ts: deleted the 'background' kind, its
  branch, and its fields.

Tests: deleted 11 dead-leg-only tests, added 4 (3 rewriteAssetUrls TDD +
1 re-pointing the pinned-dark-pane requirement at the scoped-blocks leg).
586 baseline - 11 + 4 = 579 tests / 61 files, green. typecheck/test/lint
clean.
…cloudfoundry#5517)

Review fixes for the last commit's rewriteAssetUrls:
- strip one pair of matching surrounding quotes (+ trim whitespace) from
  captured url() refs before store lookup, so user-authored scopedBlock
  CSS (which conventionally quotes) resolves; unknown refs pass through
  with original text untouched.
- double-buffer minted object URLs per callsite key instead of revoking
  synchronously at mint time — the previous batch is still what the
  iframe's current stylesheet paints until STB_APPLY_BLOCKS is applied,
  so only the batch two calls back is revoked.
- de-dupe object URL mints within one call: a ref repeated N times
  shares one mint.
- fix stale composeLayerImage comment referencing deleted backgroundPatch.
…loudfoundry#5517)

Only the outside-click path unregistered its document-level handler;
the Close button and open-replaces-open paths leaked it, so the next
picker was closed instantly by the previous picker's handler — every
swatch click after the first close appeared dead.
…dry#5517)

Close is not delete: every edit commits live, and nothing offered the
way back. Adds the delete tier — clearFacetProp (one value back to
snapshot-decides), resetNode (whole element back to the pristine loaded
model), a Clear button in the color picker, and the 'transparent'
keyword as a deliberate paint-nothing literal.
…ry#5517)

An invisible edit reads as a broken editor. Live-pass findings:
- backdrop hint + jump: a page's visible background may belong to a
  full-bleed child (login's Background overlay); background edits on
  the page node are painted over by it — say so and link there
- covered-backstop note when image/gradient layers hide the color
- Reset button (element back to snapshot) and picker Clear/dark-clear
  wired through the lever editor to the new state primitives
- MDN help opens a docked reusable popup, not a new tab (MDN and
  every peer block framing; verified 2026-07-02)
…5517)

Verified WCAG luminance core (0.179 break-even, sqrt(21) AA guarantee),
the three failure modes of naive black/white pickers (unverified derived
shades, wrong reference surface, uncomposited alpha), gradient/image/
scrim handling via the facet layer stack, OKLCH-derive-then-WCAG-verify,
and Stratos platform notes (.dark-theme class, Tailwind v4 theme inline).
…ry#5517)

The delete-a-layer control was an unlabeled 12px glyph — invisible as
an affordance during the live pass. Layer remove is now '× remove' with
explanatory titles on remove/reorder/stop buttons, and the background
group shows a live read-only summary of the exact CSS it emits (paint
order, real values) so what-covers-what is readable at a glance.

@norman-abramovitz norman-abramovitz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Initial stb that has some of the features stratos needs

@norman-abramovitz
norman-abramovitz marked this pull request as ready for review July 3, 2026 05:21
@norman-abramovitz
norman-abramovitz merged commit a53a745 into cloudfoundry:develop Jul 3, 2026
15 checks passed
@nabramovitz
nabramovitz deleted the norm/feat/stb-composite-facet-framework branch July 3, 2026 06:40
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.

stb: model background as an ordered layer stack (top→down) over a color backstop

2 participants