Skip to content

feat(surface): <Surface> primitive — paint / layer / hit-test / elevation / backdrop (A23)#90

Merged
re-marked merged 13 commits into
mainfrom
feat/surface-primitive
May 17, 2026
Merged

feat(surface): <Surface> primitive — paint / layer / hit-test / elevation / backdrop (A23)#90
re-marked merged 13 commits into
mainfrom
feat/surface-primitive

Conversation

@re-marked

Copy link
Copy Markdown
Owner

Closes #57 (A23).

The foundational rectangle primitive every desktop UI element in yokai will be built from. Replaces ad-hoc <Box> + position + zIndex patterns that every higher-level primitive (<Window>, modal panels, popovers, tooltips, dropdowns, drag ghosts, backdrops) would otherwise rediscover with subtly incompatible numbers.

Surface in one sentence

A <Surface> is a paintable, layered (named z-band), optionally hit-test-bounded region with optional drop-shadow elevation and optional auto-rendered backdrop scrim. Not a window — no titlebar, no drag, no resize, no focus ownership; just the paint substrate.

What's in this PR (12 commits, no v1/v2 split)

# Commit What
1 feat(surface): SurfaceProps types + resolveZIndex layer helper (A23) Types + pure layer-to-zIndex resolver + 19 truth-table tests
2 feat(surface): shadowCells pure cell math for elevation 0-5 (A23) Drop-shadow cell-list math + 14 geometry tests
3 feat(jsx): declare surfaceHitTestBoundary + surfaceElevation ink-box attrs (A23) New host attributes (compile-only)
4 feat(hit-test): honor surfaceHitTestBoundary attribute (A23) Boundary occlusion branch + 6 hit-test tests
5 feat(render): paint Surface elevation shadow before ink-box content (A23) Shadow paint pass + 6 render-pipeline tests
6 feat(warn): dev warnings for non-absolute Surface flags (A23) Three ifXWithoutAbsolute helpers in warn.ts
7 feat(surface): Surface React shell + tests + barrel (A23) The component + 25 DOM/passthrough/warn tests
8 feat(api): export Surface from package index (A23) Public exports
9 refactor(Draggable): paint outer Box through Surface (A23) Migration — no public API change, 33 tests stay green
10 refactor(Resizable): paint outer Box through Surface clip='hidden' (A23) Migration — 28 tests stay green
11 feat(examples): surface demo — layer stack, backdrop, elevation, boundary, composition (A23) pnpm demo:surface
12 docs(surface): component reference, internals, cross-refs, changelog, CLAUDE.md, AGENTS.md (A23) docs/components/surface.md, docs/internals/surface.md, CLAUDE.md key-files row + things-to-know bullet, changelog Unreleased

Layer band table

Layer z Use
'base' 0 (omitted) Default — equivalent to no layer
'docked' 100 Sidebars / status bars / taskbars
'overlay' 1000 Floating panels (Window body)
'dropdown' 2000 Select / menu dropdowns
'modal' 3000 Modal dialogs + backdrops
'popover' 4000 Popovers anchored to elements
'tooltip' 5000 Tooltips above popovers
'drag-ghost' 6000 Drag previews above everything

Gaps of 100 / 1000 leave room for numeric zIndex nudges within a band.

Design questions — committed answers

  1. Thin wrapper or host marker? Hybrid — React-side normalizer over <ink-box> plus two new host attributes for the truly host-level concerns.
  2. hitTestBoundary default? false. Explicit opt-in for modals; tooltips / drag ghosts don't need it.
  3. layer abstraction? Yes — named enum + numeric escape hatch. Explicit zIndex wins.
  4. clip default? 'visible' (Box parity). Resizable's migration explicitly sets clip="hidden" to preserve its prior behavior.
  5. Shadow / backdrop in v1? Yes — entire primitive ships in one PR per the spec.

Defaults preserve Box parity

A default <Surface> (no layer, no zIndex, no hitTestBoundary / elevation / backdrop) renders an ink-box with no surface-specific attributes and no extra siblings — byte-identical to a <Box> with the same props. Tested explicitly in Surface.test.tsx > default Surface (no layer, no extras) → ink-box with no zIndex and no surface attrs.

Test plan

  • pnpm typecheck clean
  • pnpm lint clean
  • pnpm build clean
  • pnpm test859 passed (was 770 on main pre-PR; +89 across 4 new test files: layer, shadow, hit-test boundary, render elevation, Surface component)
  • pnpm demo:drag, pnpm demo:constrained-drag, pnpm demo:drag-and-drop, pnpm demo:scratchpad, pnpm demo:resizable — Draggable/Resizable demos work unchanged after migration
  • pnpm demo:surface — visual showcase of every Surface feature

Out of scope (explicit follow-ups)

  • A4 (<Window> primitive — composes Surface + Draggable + Resizable + title chrome)
  • A18 (focus / hover-scoped event routing inside Window)
  • Phase 2 Modal / Popover / Tooltip / Menu / DragGhost wrappers (each picks a layer preset)
  • Multi-tone shadow shading (the dim field on ShadowCell is captured for future use; v1 paints all shadow cells with a single reserved color)
  • Shadow re-paint when a lower-z sibling dirties under a clean-blit Surface (documented limitation; follow-up could register shadow cells with pendingClears or parent-tracked dirty regions)

re-marked and others added 12 commits May 17, 2026 14:15
Co-Authored-By: Mark <psyhik17@gmail.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Mark <psyhik17@gmail.com>
Co-Authored-By: Claude <noreply@anthropic.com>
…attrs (A23)

Co-Authored-By: Mark <psyhik17@gmail.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Mark <psyhik17@gmail.com>
Co-Authored-By: Claude <noreply@anthropic.com>
…A23)

Co-Authored-By: Mark <psyhik17@gmail.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Mark <psyhik17@gmail.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Mark <psyhik17@gmail.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Mark <psyhik17@gmail.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Mark <psyhik17@gmail.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Mark <psyhik17@gmail.com>
Co-Authored-By: Claude <noreply@anthropic.com>
…dary, composition (A23)

Co-Authored-By: Mark <psyhik17@gmail.com>
Co-Authored-By: Claude <noreply@anthropic.com>
… CLAUDE.md, AGENTS.md (A23)

Co-Authored-By: Mark <psyhik17@gmail.com>
Co-Authored-By: Claude <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c95a30ab99

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +108 to +111
style={{
...rest,
position,
zIndex: resolvedZ,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Restore Box flex defaults in Surface

When Surface builds the underlying ink-box style here, it omits the defaults that Box always supplies (flexDirection='row', flexWrap='nowrap', flexGrow=0, flexShrink=1). A default Surface therefore falls back to the Yoga defaults (column and flexShrink=0), so any migrated Draggable/Resizable or new Surface with multiple children and no explicit flex props will stack children vertically and stop shrinking where the equivalent Box used to lay them out in a row. Add the same defaults before spreading the caller's style props to preserve Box parity.

Useful? React with 👍 / 👎.

// `components/Surface/shadow.ts` for the cell math.
const elevation = node.attributes.surfaceElevation
if (typeof elevation === 'number' && elevation > 0 && node.style.position === 'absolute') {
paintShadowCells(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Clear shadow damage outside the surface rect

Painting elevation shadows writes cells outside the surface's own cached layout rect, but the dirty/moved clear path still only clears {x,y,width,height} for the node. When an elevated absolute Surface moves, shrinks, or lowers/removes elevation, the old shadow band outside the previous rect is never cleared and can remain on screen until some other node happens to repaint those cells. The shadow extent needs to be included in damage/clears or tracked separately.

Useful? React with 👍 / 👎.

…#90)

Co-Authored-By: Mark <psyhik17@gmail.com>
Co-Authored-By: Claude <noreply@anthropic.com>
@re-marked
re-marked merged commit 07bcc77 into main May 17, 2026
1 check passed
@re-marked
re-marked deleted the feat/surface-primitive branch May 17, 2026 17: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.

A23: Add low-level <Surface> primitive as the foundation for Window / Tooltip / Portal / Popover

1 participant