npm package: @sprucelabs/spruce-heartwood-utils
Description: Heartwood Utilities
Latest version: 38.17.9

# @sprucelabs/spruce-heartwood-utils

Heartwood integration toolkit for Spruce skills. Use this package to load remote cards, register and render cross-skill views, apply shared theme behavior, drive animation/layout primitives, and test the full flow with built-in mocks.

---

## Installation

```sh
npm install @sprucelabs/spruce-heartwood-utils
```

```
yarn add @sprucelabs/spruce-heartwood-utils
```

This package is designed for use inside a Spruce skill or a React application. 

---

## Project Pitch

This module supports development in 2 ways.

1. It provides first-party utilities for common tasks in Skill development — remote view controllers, card registration, theming, plugins, and test helpers.
2. It offers animation and layout primitives for use in React.

`@sprucelabs/spruce-heartwood-utils` packages those into one module with explicit entrypoints so you can keep browser bundles clean while still getting first-class testing helpers.

---

## What You Get

- Remote view controller infrastructure:
`RemoteViewControllerFactoryImpl`, `CardRegistrar`, shared remote VC types
- Theming and plugins:
`loadActiveThemeForOrg`, `AutoLogoutPlugin`
- Test harness pieces:
`remoteVcAssert`, `fakeGetViews`, `MockRemoteViewControllerFactory`, `SpyAutoLogoutPlugin`
- Animation/layout primitives:
`Sizer`, `DelayedPlacer`, queue/show helpers, emitters, settings utilities

---

## Import Paths

- `@sprucelabs/spruce-heartwood-utils/web` for browser-safe runtime imports (recommended for Vite/Bun/ESM apps)
- `@sprucelabs/spruce-heartwood-utils/testing` for test helpers and mocks
- `@sprucelabs/spruce-heartwood-utils` keeps the legacy mixed export surface for existing consumers

---

## Get Started

### Web Only

Use this when you are shipping runtime/browser code and do not need test helpers in that code path.

```ts
import {
    CardRegistrar,
    RemoteViewControllerFactoryImpl,
    AutoLogoutPlugin,
    loadActiveThemeForOrg,
    Sizer,
} from '@sprucelabs/spruce-heartwood-utils/web'
```

### Tests Only

Use this in test suites where you need to fake remote views and assert event-driven card registration.

```ts
import {
    remoteVcAssert,
    fakeGetViews,
    MockRemoteViewControllerFactory,
    SpyAutoLogoutPlugin,
} from '@sprucelabs/spruce-heartwood-utils/testing'
```

### Web + Tests

Use split imports so runtime code stays browser-safe while tests get all mocks/helpers.

```ts
import {
    CardRegistrar,
    RemoteViewControllerFactoryImpl,
    remoteVcAssert,
    MockRemoteViewControllerFactory,
} from '@sprucelabs/spruce-heartwood-utils/testing'
```

---

## Remote View Controllers

When your skill needs to load and render cards that come from other skills at runtime, these are the types you work with. In tests, you swap in `MockRemoteViewControllerFactory` to control which cards appear without making real network calls.

### `RemoteViewControllerFactoryImpl`

Fetches compiled ViewController source from the `heartwood.get-skill-views` event, evaluates it in a sandboxed context, and returns instantiated controllers. You will typically interact with this class in tests to inject a mock, not to instantiate it in production — Heartwood manages the factory lifecycle.

```ts
import {
    RemoteViewControllerFactoryImpl,
    RemoteViewControllerFactory,
} from '@sprucelabs/spruce-heartwood-utils/web'
```

**Injecting a mock factory in tests:**

```ts
protected async beforeEach() {
    RemoteViewControllerFactoryImpl.Class = MockRemoteViewControllerFactory
    MockRemoteViewControllerFactory.reset()
}
```

**Loading a remote controller by ID:**

```ts
const vc = await remoteVcFactory.RemoteController(
    'your-skill.some-card',
    { someOption: true }
)
```

---

### `RemoteViewControllerFactory` (interface)

The interface that both `RemoteViewControllerFactoryImpl` and `MockRemoteViewControllerFactory` implement. Use this type for any field that may hold the real factory or a test mock:

```ts
import { RemoteViewControllerFactory } from '@sprucelabs/spruce-heartwood-utils/web'

private remoteVcFactory: RemoteViewControllerFactory
```

---

### `RemoteFactoryOptions`

Options passed to `RemoteViewControllerFactoryImpl.Factory()`.

```ts
import { RemoteFactoryOptions } from '@sprucelabs/spruce-heartwood-utils/web'

interface RemoteFactoryOptions {
    connectToApi: () => Promise<MercuryClient>
    vcFactory: VcFactoryForRemoteFactory
}
```

---

### `VcFactoryForRemoteFactory`

A narrowed subset of `ViewControllerFactory` containing only what the remote factory needs. Use this type when accepting or storing a factory reference to avoid a full `ViewControllerFactory` dependency:

```ts
import { VcFactoryForRemoteFactory } from '@sprucelabs/spruce-heartwood-utils/web'

private views: VcFactoryForRemoteFactory
```

---

## CardRegistrar

If your skill view needs to collect cards contributed by other skills — similar to a dashboard that aggregates content from across the platform — `CardRegistrar` handles the full lifecycle: emitting the event, receiving VC IDs from respondents, loading each card via the remote factory, and streaming results as they arrive.

```ts
import {
    CardRegistrar,
    CardRegistrarOptions,
    CardFetchOptions,
    EachCardHandler,
} from '@sprucelabs/spruce-heartwood-utils/web'
```

### `CardRegistrar.Registrar(options)`

Create a fresh registrar inside your skill view's `load()` so each load cycle gets a fresh client connection:

```ts
private async loadContributedCards(options: SkillViewControllerLoadOptions) {
    const client = await this.connectToApi()

    const registrar = CardRegistrar.Registrar({
        client,
        eventName: 'your-skill.register-cards::v2021_02_11',
        vcFactory: this.getVcFactory(),
        vcIdsTransformer: (payload) => payload.vcIds,
    })

    this.remoteCards = (await registrar.fetch()) as RemoteDashboardCard[]
    this.triggerRender()

    await Promise.all(this.remoteCards.map((vc) => vc.load?.(options)))
}
```

### `CardRegistrarOptions<Contract, Name>`

```ts
interface CardRegistrarOptions<Contract, Name> {
    client: MercuryClient
    eventName: Name
    vcFactory: VcFactoryForRemoteFactory
    vcIdsTransformer: (payload: ResponsePayload<Contract, Name>) => string[]
}
```

### `registrar.fetch(options?)`

Emits the event and loads all returned VC IDs. Returns `AbstractViewController<Card>[]`.

```ts
// Basic fetch — wait for all cards
const cards = await registrar.fetch()

// Streaming — render each batch as it arrives
await registrar.fetch({
    each: async (batch) => {
        this.remoteCards.push(...(batch as RemoteDashboardCard[]))
        this.triggerRender()
    },
})

// With target — scope the event to an organization
const cards = await registrar.fetch({
    target: { organizationId: 'org-123' },
})
```

### `EachCardHandler`

```ts
type EachCardHandler = (vcs: AbstractViewController<any>[]) => Promise<void> | void
```

### `CardFetchOptions<Contract, Name>`

```ts
type CardFetchOptions<Contract, Name> = {
    each?: EachCardHandler
    controllerOptionsHandler?: (vcId: string) => Record<string, any>
} & EmitPayload<Contract, Name>
```

---

## Types

### `RemoteDashboardCard`

The shape of a card loaded from another skill. Extends `ViewController<Card>` with an optional `load()` method so you can forward load arguments after the card is rendered.

```ts
import { RemoteDashboardCard } from '@sprucelabs/spruce-heartwood-utils/web'

interface RemoteDashboardCard extends ViewController<Card> {
    load?(options: SkillViewControllerLoadOptions): Promise<void>
}
```

**Typical pattern — store, render, and forward load args:**

```ts
private remoteCards: RemoteDashboardCard[] = []

// After fetch:
this.remoteCards = (await registrar.fetch()) as RemoteDashboardCard[]
await Promise.all(this.remoteCards.map((vc) => vc.load?.(options)))

// In render():
cards: [
    this.myLocalCardVc.render(),
    ...this.remoteCards.map((c) => c.render()),
]
```

---

## Theming

### `loadActiveThemeForOrg(client, organizationId)`

Fetches the active Heartwood theme for an organization so your skill can apply the same visual style.

```ts
import { loadActiveThemeForOrg } from '@sprucelabs/spruce-heartwood-utils/web'
```

**Signature:**

```ts
async function loadActiveThemeForOrg(
    client: MercuryClient,
    organizationId: string
): Promise<SkillTheme>
```

**Usage:**

```ts
const client = await this.connectToApi()
const theme = await loadActiveThemeForOrg(client, organizationId)
// pass theme to your view controller factory or apply to the UI
```

---

## Plugins

### `AutoLogoutPlugin`

Sends `enableAutoLogout` / `disableAutoLogout` commands to the native Heartwood device layer. Register it with your skill's `ViewControllerFactory` to give your skill views control over session timeout behavior.

```ts
import { AutoLogoutPlugin } from '@sprucelabs/spruce-heartwood-utils/web'

const plugin = factory.BuildPlugin(AutoLogoutPlugin)

plugin.enableAutoLogout(300)   // auto-logout after 300 seconds of inactivity
plugin.disableAutoLogout()     // cancel a pending auto-logout
```

---

### `AutoLogoutViewPlugin` (interface)

Use this type when you want to reference the plugin without coupling to the concrete class — for example, when declaring a field that holds either the real plugin or a spy:

```ts
import type { AutoLogoutViewPlugin } from '@sprucelabs/spruce-heartwood-utils/web'

// Field declaration — works with both AutoLogoutPlugin and SpyAutoLogoutPlugin:
private autoLogoutPlugin: AutoLogoutViewPlugin
```

The interface shape:

```ts
interface AutoLogoutViewPlugin extends ViewControllerPlugin {
    enableAutoLogout(durationSec: number): void
    disableAutoLogout(): void
}
```

---

### `SpyAutoLogoutPlugin`

A test double that records calls to `enableAutoLogout` and `disableAutoLogout`. Swap it in during test setup to assert your skill calls the plugin correctly:

```ts
import { SpyAutoLogoutPlugin } from '@sprucelabs/spruce-heartwood-utils/testing'

protected async beforeEach() {
    const factory = this.views.getFactory()
    factory.setPlugin('auto-logout', new SpyAutoLogoutPlugin())
}

@test()
protected async startsAutoLogoutAfterLoad() {
    await this.load()
    assert.isTrue(this.plugin.wasEnableAutoLogoutCalled)
    assert.isEqual(this.plugin.durationSec, 300)
}

private get plugin() {
    return this.views.getFactory().getPlugin('auto-logout') as SpyAutoLogoutPlugin
}
```

**Spy fields:**

```ts
class SpyAutoLogoutPlugin {
    wasEnableAutoLogoutCalled: boolean
    durationSec?: number
    wasDisableAutoLogoutCalled: boolean
}
```

---

## Test Utilities

Use these in your skill's test suite to verify that your skill correctly registers cards with Heartwood and responds to dashboard events. The typical scenario: Heartwood emits an event asking for cards; your skill responds with VC IDs; Heartwood loads and renders those cards. These utilities let you assert that entire flow without making real network calls.

### `remoteVcAssert`

High-level assertion helper. Verifies that your skill view emits the expected registration event and that the returned cards are rendered.

```ts
import { remoteVcAssert } from '@sprucelabs/spruce-heartwood-utils/testing'
```

**Basic assertion — confirm your skill view emits the event and renders cards:**

```ts
@test()
protected async registersCards() {
    await remoteVcAssert.assertSkillViewRendersRemoteCards({
        svc: this.vc,
        fqen: 'your-skill.register-cards::v2021_02_11',
        views: this.views,
    })
}
```

**With `shouldInvoke` — confirm each returned card has `load()` called on it:**

```ts
await remoteVcAssert.assertSkillViewRendersRemoteCards({
    svc: this.vc,
    fqen: 'your-skill.register-cards::v2021_02_11',
    views: this.views,
    shouldInvoke: {
        methodName: 'load',
        expectedParams: [this.views.getRouter().buildLoadOptions()],
    },
})
```

**With `expectedTarget` / `expectedPayload` — confirm the event is scoped correctly:**

```ts
await remoteVcAssert.assertSkillViewRendersRemoteCards({
    svc: this.vc,
    fqen: 'your-skill.register-cards::v2021_02_11',
    views: this.views,
    expectedTarget: { organizationId: 'org-123' },
})
```

---

### `AssertRendersRemoteCardsOptions<Svc>`

```ts
import { AssertRendersRemoteCardsOptions } from '@sprucelabs/spruce-heartwood-utils/testing'

interface AssertRendersRemoteCardsOptions<Svc extends SkillViewController = SkillViewController> {
    svc: Svc
    fqen: EventNames
    views: ViewFixture
    expectedTarget?: Record<string, any>
    expectedPayload?: Record<string, any>
    loadArgs?: ArgsFromSvc<Svc>
    shouldInvoke?: {
        methodName: string
        expectedParams?: any[]
    }
}
```

---

### `fakeGetViews`

Intercepts `heartwood.get-skill-views::v2021_02_11` and returns stub ViewController source. Useful when you need manual control over what remote views are returned in a test — `remoteVcAssert` uses this internally, but you can call it directly for custom scenarios.

```ts
import { fakeGetViews } from '@sprucelabs/spruce-heartwood-utils/testing'

// Stub two remote views:
await fakeGetViews.fakeGetViews(['your-skill.card-a', 'your-skill.card-b'])
```

The optional second argument is the name of a method to instrument on each stub VC. When provided, each stub controller gets a function at that name that records whether it was called and what arguments it received:

```ts
await fakeGetViews.fakeGetViews(['your-skill.card'], 'load')

// After your code triggers the stub:
const stub = /* get rendered card controller from your vc */
assert.isTrue(stub.wasHit)
assert.isEqualDeep(stub.passedParams, [expectedArgs])
```

> **Note:** The export is the `heartwoodEventFaker` object. Call it as `fakeGetViews.fakeGetViews(...)`.

---

### `MockRemoteViewControllerFactory`

A drop-in replacement for the real remote factory. Controls exactly which VC classes are returned for each card ID and provides assertion methods for verifying what your skill view loaded.

```ts
import {
    MockRemoteViewControllerFactory,
    RemoteViewControllerFactoryImpl,
} from '@sprucelabs/spruce-heartwood-utils/web'
import { MockDroppedInControllers } from '@sprucelabs/spruce-heartwood-utils/testing'
```

**Setup — inject the mock before your skill view creates its factory:**

```ts
protected async beforeEach() {
    RemoteViewControllerFactoryImpl.Class = MockRemoteViewControllerFactory
    MockRemoteViewControllerFactory.reset()

    MockRemoteViewControllerFactory.dropInRemoteController(
        'your-skill.some-card',
        YourCardViewController
    )
}
```

**Get the mock instance to run assertions:**

```ts
const mockFactory = MockRemoteViewControllerFactory.getInstance()
```

**Available assertions:**

```ts
// Assert a specific card was loaded:
mockFactory.assertFetchedRemoteController('your-skill.some-card')

// Assert it was loaded with specific constructor options:
mockFactory.assertFetchedRemoteController('your-skill.some-card', { organizationId: 'org-123' })

// Assert a card was NOT loaded:
mockFactory.assertDidNotFetchRemoteController('your-skill.other-card')

// Assert a skill view renders a specific remote card:
mockFactory.assertSkillViewRendersRemoteCard(this.vc, 'your-skill.some-card')

// Assert constructor options match exactly:
mockFactory.assertRemoteCardConstructorOptionsEqual('your-skill.some-card', { organizationId: 'org-123' })

// Assert views were loaded for a namespace:
mockFactory.assertLoadedViewsForNamespace('your-skill')
```

**Check without throwing:**

```ts
if (mockFactory.hasController('your-skill.some-card')) { ... }
```

---

### `MockDroppedInControllers`

Type for the map of card IDs to VC classes held by `MockRemoteViewControllerFactory`:

```ts
import { MockDroppedInControllers } from '@sprucelabs/spruce-heartwood-utils/testing'

type MockDroppedInControllers = Record<
    string,
    new (args: any) => ViewController<any>
>
```

---

## Device & Layout Utilities

These utilities are not re-exported through the main barrel and must be imported directly from their build paths.

### `getDeviceOrientation()`

Returns `'landscape'` or `'portrait'` based on the current viewport dimensions. Use this to make layout decisions that depend on device orientation.

```ts
import { getDeviceOrientation } from '@sprucelabs/spruce-heartwood-utils/build/components/controlBars/getDeviceOrientation'

const orientation = getDeviceOrientation()  // 'landscape' | 'portrait'

if (orientation === 'landscape') {
    // wide layout
} else {
    // tall layout
}
```

**Decision logic:**

```
if (clientWidth > clientHeight && clientWidth > 700)  → 'landscape'
else                                                   → 'portrait'
```

**Reacting to changes** — do not poll. Subscribe to the orientation change event instead:

```ts
await emitter.on('did-change-orientation', async () => {
    const orientation = getDeviceOrientation()
    // update layout
})
```

**Caveats:** Browser-only. Synchronous. Each call reads the DOM live — no caching.

---

### `skillViewState`

A singleton that reflects whether the currently active Heartwood skill view is in full-screen mode. Read this in your card components to skip height animations or adapt layout when the user is in full-screen.

```ts
import { skillViewState } from '@sprucelabs/spruce-heartwood-utils/build/components/skillViews/skillViewState'

if (skillViewState.isFullScreen) {
    // full-screen mode is active — skip height-dependent layout
}
```

**In tests — reset between cases:**

```ts
beforeEach(() => {
    skillViewState.isFullScreen = false
})
```

**Caveats:** Plain object — not reactive. Reading it does not subscribe to changes. Must be reset between tests since it is a module-level singleton.

---

## Animation

These are the same animation and layout primitives that Heartwood uses in its own card views. Import them to get consistent stagger, height animation, and absolute-positioning behavior in your skill's components.

### Quick Start

```ts
import {
    // Visibility queue
    useQueueShow, useShowNow,
    showRightAway, hideRightAway,
    queueShow, queueHide,
    queueCallback, callbackImmediately,
    clearPendingHideAndQueueShow, clearPendingShowAndQueueHide,
    stopQueue,
    // Layout components
    Sizer, DelayedPlacer,
    // Utilities
    sizeUtil, Settings,
    // Emitter — pass to Sizer and DelayedPlacer to coordinate layout events
    // Types
    AnimationEmitter,
    SizerProps,
    DelayedPlacerProps,
} from '@sprucelabs/spruce-heartwood-utils/web'
```

---

### Required CSS

> **⚠ Silent failure warning:** If CSS transitions are missing, animations will not play and no error will be thrown. Elements will simply snap instantly between states with no visual feedback. Every class listed below requires its own `transition` declaration — the system provides none.

**Inside a Heartwood skill view:** all required CSS is already provided by Heartwood's stylesheet. No extra setup needed.

**Outside Heartwood (standalone use):** supply every rule below yourself. Missing any one of them causes that animation to silently skip.

---

#### `queueShow` / `queueHide` — fade + slide transitions

`queueShow` removes the `hidden` class; `queueHide` adds it back. The `hidden` class sets the hidden state (opacity, position offset, pointer-events). **The transition must be declared on the element itself, not on `.hidden`** — if the transition is on `.hidden` it is removed at the same moment the class is removed and the browser never interpolates.

```css
/* Hidden state — sets opacity, nudge, and pointer-events */
.hidden {
    opacity: 0;
    transform: translateY(4px);
    pointer-events: none;
}

/* Transition on the element — this is what the browser animates */
.your-element {
    transition: opacity 200ms ease, transform 200ms ease;
}
```

Elements must also start with `hidden` in their `className` so they are invisible until `queueShow` fires:

```tsx
<div className="your-element hidden" ref={(ref) => { ref && queueShow(ref) }} />
```

---

#### `Sizer` — animated height

`Sizer` measures its content and writes `style.height` directly. The CSS `transition` on `.sizer` is what makes the height change animate smoothly. Without it the height jumps instantly.

```css
.sizer {
    transition: height 500ms ease;
    overflow: hidden;
}
```

---

#### `DelayedPlacer` — absolute positioning

`DelayedPlacer` writes `style.left` / `style.top` on the child. The `.placer` wrapper must be `position: relative` so the child's absolute coordinates are relative to it.

```css
.placer {
    position: relative;
}
.placer > * {
    position: absolute;
}
```

No transition is needed on `.placer` itself — placement jumps immediately to the measured position.

---

### System Architecture

The animation system has three cooperating layers:

```
queueShow / queueHide          — staggered CSS class toggling (show/hide via .hidden)
       │
       ▼
Sizer                          — measures content height, sets style.height for smooth animation
       │  emits did-resize-content
       ▼
DelayedPlacer                  — reads Sizer's placeholder position, writes absolute coordinates
```

Pass the **same emitter instance** to `Sizer` and `DelayedPlacer` when they are siblings so their events coordinate. Different emitter instances decouple them.

`Settings.disableAnimations()` is a global switch that makes every timeout fire at `0ms` and drains the queue synchronously — call it in your test `beforeEach` for deterministic tests.

---

### AnimationEmitter

The interface `Sizer` and `DelayedPlacer` use to communicate layout-change events. Pass an instance as the `emitter` prop to coordinate components. The package exports `SkillViewEmitter` for this:

```ts
import { SkillViewEmitter } from '@sprucelabs/spruce-heartwood-utils/web'

const emitter = SkillViewEmitter.getInstance()
```

`SkillViewEmitter` implements all three methods (`on`, `off`, `emit`) needed by `AnimationEmitter`.

The `AnimationEmitter` interface, for reference:

```ts
interface AnimationEmitter {
    on(event: string, handler: () => void): Promise<void> | void
    off(event: string, handler: () => void): Promise<void> | void
    emit(event: string): Promise<void> | void
}
```

#### Quick Setup

1. **Install**

   ```sh
   npm install @sprucelabs/spruce-heartwood-utils
   ```

2. **Create an emitter** — one per component tree, stable across renders:

   ```ts
   import { SkillViewEmitter } from '@sprucelabs/spruce-heartwood-utils/web'

   const emitter = useMemo(() => SkillViewEmitter.getInstance(), [])
   ```

3. **Wire to components and emit events** — pass the emitter to `Sizer` and/or `DelayedPlacer`, then signal layout changes:

   ```ts
   // After a React render cycle completes:
   await emitter.emit('did-render')

   // After a viewport resize:
   await emitter.emit('did-resize')
   ```

**Events:**

| Event | Who listens | Who emits | Meaning |
|---|---|---|---|
| `did-render` | Sizer, DelayedPlacer | your code | A React render cycle completed |
| `did-resize` | Sizer, DelayedPlacer | your code | Viewport or parent dimensions changed |
| `did-resize-content` | DelayedPlacer | Sizer | Sizer changed its measured height |
| `did-change-orientation` | DelayedPlacer | your code | Portrait ↔ landscape switch |
| `did-place-cards` | your listeners | DelayedPlacer | Placement calculation finished |

> Pass the **same function reference** to `on` and `off`. Inline arrow functions cannot be removed with `off`.

---

### Settings

A static class that controls animation behavior globally. The most important method for skill developers is `disableAnimations()` — call it in every test `beforeEach`.

```ts
class Settings {
    // Disable all animations. Makes timeouts fire at 0ms, queue drains synchronously.
    // Cannot be undone. Never call in production code.
    static disableAnimations(): void

    static getIsAnimationEnabled(): boolean

    // Returns animation duration in ms: landscape=500, portrait=1000. Returns 0 if disabled.
    static get animationDuration(): number
}
```

**In your test base class:**

```ts
protected async beforeEach() {
    Settings.disableAnimations()
}
```

> **One-way door:** there is no `enableAnimations()`. Each test file runs in its own module context so this is safe. Never call it in production.

---

### queueShow — Show/Hide Queue

A singleton FIFO queue that staggers visibility transitions by toggling the CSS class `hidden` on DOM elements. Staggering class removals across a 40ms interval creates natural cascading fade-in effects.

When animations are disabled (`Settings.disableAnimations()`), the entire queue drains synchronously — no real timers needed in tests.

**The queue is shared** across your entire skill view. `stopQueue()` stops all pending operations.

---

#### `queueShow(refOrNode, delay?, method?)`

Queues a show (removes `hidden`) on an element.

```ts
function queueShow(
    refOrNode: React.RefObject<HTMLElement | null> | HTMLElement | null,
    delay?: number,              // default: 40ms between queue steps
    method?: 'push' | 'unshift' // default: 'push' (end of queue)
): void
```

No-ops silently if the element is null, already visible, or already queued.

```tsx
// Standard pattern — queue on mount via ref callback:
<div className="your-card hidden" ref={(ref) => { ref && queueShow(ref) }} />

// Multiple elements — queue them all:
const items = containerRef.current?.querySelectorAll('.item') ?? []
for (const item of items) { queueShow(item as HTMLElement) }
```

---

#### `queueHide(refOrNode, delay?, method?)`

Queues a hide (adds `hidden`) on an element.

```ts
function queueHide(
    refOrNode: React.RefObject<HTMLElement> | HTMLElement,
    delay?: number,
    method?: 'push' | 'unshift'
): void
```

```ts
queueHide(loadingSpinnerRef)
```

---

#### `showRightAway(refOrNode)`

Jumps to the **front** of the queue. Use when an element must appear before anything else already queued.

```ts
function showRightAway(
    refOrNode: React.RefObject<HTMLElement | null> | HTMLElement
): void
```

```tsx
<div className="alert hidden" ref={(ref) => { ref && showRightAway(ref) }} />

<img onLoad={() => { imageRef && showRightAway(imageRef) }} />
```

---

#### `hideRightAway(refOrNode)`

Jumps a hide to the front of the queue.

```ts
function hideRightAway(refOrNode: React.RefObject<HTMLElement> | HTMLElement): void
```

```ts
hideRightAway(overlayRef.current)
```

---

#### `queueCallback(cb)`

Inserts an arbitrary callback at the end of the queue. Runs in sequence with show/hide steps.

```ts
function queueCallback(cb: () => void): void
```

```ts
// Trigger a re-render after queued animations complete:
queueCallback(() => this.triggerRender())
```

---

#### `callbackImmediately(cb)`

Inserts a callback at the front of the queue.

```ts
function callbackImmediately(cb: () => void): void
```

```ts
const enqueue = shouldPrioritize ? callbackImmediately : queueCallback
enqueue(() => this.handleReady())
```

---

#### `clearPendingHideAndQueueShow(refOrNode, delay?)`

Cancels a pending hide and queues a show for elements toggled back before their hide ran.

```ts
function clearPendingHideAndQueueShow(
    refOrNode: React.RefObject<HTMLElement> | HTMLElement,
    delay?: number   // default: 40ms
): void
```

> Prefer `showRightAway()` for most toggle cases.

---

#### `clearPendingShowAndQueueHide(refOrNode, delay?)`

Cancels a pending show and queues a hide.

```ts
function clearPendingShowAndQueueHide(
    refOrNode: React.RefObject<HTMLElement> | HTMLElement,
    delay?: number   // default: 40ms
): void
```

> Prefer `hideRightAway()` for most toggle cases.

---

#### `stopQueue()`

Stops the queue interval and abandons any remaining items. Call in test teardown to prevent queue state leaking between tests.

```ts
protected async afterEach() {
    await super.afterEach()
    stopQueue()
}
```

---

#### `useQueueShow(ref, delay?)` — React Hook

Calls `queueShow` on every render via `useEffect`. Use for elements that should fade in after each render.

```tsx
const ref = useRef<HTMLDivElement>(null)
useQueueShow(ref)

return <div ref={ref} className="your-panel hidden">...</div>
```

---

#### `useShowNow(ref, delay?)` — React Hook

Like `useQueueShow` but places itself at the front of the queue on every render. Use for elements that must appear before any other queued items — such as a primary heading that should always be visible before supporting content fades in.

```ts
function useShowNow(ref: React.RefObject<HTMLElement>, delay?: number): void
```

```tsx
const ref = useRef<HTMLHeadingElement>(null)
useShowNow(ref)

return <h1 ref={ref} className="your-heading hidden">Title</h1>
```

---

### Sizer — Animated Height Container

Wraps children in a div whose `height` is set via inline style to match the measured height of its content. Listens to `did-render` and `did-resize` events so it resizes whenever layout changes — enabling smooth CSS height transitions on content that would otherwise be `height: auto`. Emits `did-resize-content` so `DelayedPlacer` can re-place after a height change.

#### Quick Setup

1. **Install and add the required CSS**

   ```sh
   npm install @sprucelabs/spruce-heartwood-utils
   ```

   ```css
   .sizer {
       transition: height 500ms ease;
       overflow: hidden;
   }
   ```

   > Inside a Heartwood skill view this CSS is already provided — skip this step.

2. **Wrap your content**

   ```tsx
   import { Sizer } from '@sprucelabs/spruce-heartwood-utils/web'

   const emitter = useMemo(() => SkillViewEmitter.getInstance(), [])

   <Sizer emitter={emitter}>
       <YourContent />
   </Sizer>
   ```

3. **Signal layout changes** — emit after renders and on viewport resize; Sizer measures content and updates `style.height` automatically:

   ```ts
   await emitter.emit('did-render')
   await emitter.emit('did-resize')
   ```

   In tests, call `Settings.disableAnimations()` in `beforeEach` so height changes apply synchronously.

#### Props (`SizerProps`)

```ts
interface SizerProps {
    children: any

    // When false, renders children with no wrapper. Default: enabled.
    isEnabled?: boolean

    // CSS class on the outer .sizer div.
    className?: string

    // not in response to external layout events.
    emitter?: AnimationEmitter
}
```

#### Ref API

```ts
sizer.current?.resize(): boolean    // measure and apply new height; returns true if it changed
sizer.current?.showOverflow(): void // temporarily allow overflow (e.g. while a dropdown inside is open)
sizer.current?.hideOverflow(): void // re-apply overflow: hidden
```

#### Emitter Events

| Event | Direction | Meaning |
|---|---|---|
| `did-render` | listened | Re-rendered; measure and resize |
| `did-resize` | listened | Viewport changed; measure and resize |
| `did-resize-content` | emitted | Height changed (fires after `Settings.animationDuration` ms) |

#### Usage

```tsx
import { Sizer } from '@sprucelabs/spruce-heartwood-utils/web'

// Simplest 
<Sizer>
    {isExpanded && <YourExpandableContent />}
</Sizer>

// Animate in from zero height:
<Sizer emitter={emitter}>
    <YourCard />
</Sizer>

// Manually trigger resize when content changes outside React state:
const emitter = useMemo(() => SkillViewEmitter.getInstance(), [])
const sizer = useRef<React.ElementRef<typeof Sizer>>(null)

<Sizer ref={sizer} emitter={emitter}>
    <YourDynamicContent
        onContentChange={async () => {
            if (sizer.current?.resize()) {
                await emitter.emit('did-resize')
            }
        }}
    />
</Sizer>

// Staggered entrance with queueShow:
<Sizer emitter={emitter}>
    <div className="your-item hidden" ref={(ref) => { ref && queueShow(ref) }}>
        content
    </div>
</Sizer>
```

> Do not nest `Sizer` inside another `Sizer` for the same content — they will conflict.

---

### DelayedPlacer — Absolute Positioning

Positions a child element to match the location of its in-flow placeholder. Wraps children in a `position: relative` placeholder div, measures the placeholder's `offsetLeft`/`offsetTop`, and writes those values as `style.left` / `style.top` on the child. The deferred measurement lets the layout settle before placement, enabling cards and overlays to animate smoothly into position.

When `isEnabled={false}`, children render inline with no wrapper.

#### Quick Setup

1. **Install and add the required CSS**

   ```sh
   npm install @sprucelabs/spruce-heartwood-utils
   ```

   ```css
   .placer { position: relative; }
   .placer > * { position: absolute; }
   ```

   > Inside a Heartwood skill view this CSS is already provided — skip this step.

2. **Wrap your content** — `isEnabled`, `className`, and `isFocused` are all required:

   ```tsx
   import { DelayedPlacer } from '@sprucelabs/spruce-heartwood-utils/web'

   const emitter = useMemo(() => SkillViewEmitter.getInstance(), [])

   <DelayedPlacer
       className="placer__card"
       isEnabled={true}
       emitter={emitter}
       isFocused={() => true}
   >
       <YourCard />
   </DelayedPlacer>
   ```

   Pass `() => true` for `isFocused` when using outside Heartwood — placement is skipped when this returns false.

3. **Share the emitter with `Sizer`** when they are siblings so height changes automatically trigger re-placement:

   ```tsx
   <DelayedPlacer className="placer__card" isEnabled emitter={emitter} isFocused={isFocused}>
       <Sizer emitter={emitter}>
           <YourContent />
       </Sizer>
   </DelayedPlacer>
   ```

   `DelayedPlacer` emits `did-place-cards` when placement is complete. Listen for it if you need to react after cards settle.

#### Props (`DelayedPlacerProps`)

```ts
interface DelayedPlacerProps {
    children: any

    // Required. When true, wraps in .placer and manages absolute positioning.
    isEnabled: boolean

    // Required. CSS class on the outer .placer div.
    className: string

    // Event emitter. Listens for layout changes to re-measure and re-place.
    emitter?: AnimationEmitter
}
```

#### Ref API

```ts
delayedPlacer.current?.placeRightAway(): void  // re-measure and re-place immediately
```

#### Placer & Sizer Together For Card Placement

When using `DelayedPlacer` and `Sizer` together, the typical pattern is to place `Sizer` inside `DelayedPlacer` so that height changes trigger placement updates:

```tsx
<DelayedPlacer className="placer" isEnabled emitter={emitter} isFocused={isFocused}>
	<Sizer emitter={emitter}>
		<YourCard />
	</Sizer>
</DelayedPlacer>
```

#### Emitter Events

| Event | Direction | Meaning |
|---|---|---|
| `did-resize-content` | listened | Sizer changed height; re-place |
| `did-resize` | listened | Viewport changed; re-place |
| `did-render` | listened | Re-rendered; re-place |
| `did-change-orientation` | listened | Device rotated; re-place |
| `did-place-cards` | emitted | Placement finished (100ms after measuring) |

#### Usage

```tsx
import React, { useRef, useMemo } from 'react'
import { DelayedPlacer } from '@sprucelabs/spruce-heartwood-utils/web'

function YourPanel({ isFocused }: { isFocused: () => boolean }) {
    const placerRef = useRef<React.ElementRef<typeof DelayedPlacer>>(null)
    const emitter = useMemo(() => SkillViewEmitter.getInstance(), [])

    return (
        <DelayedPlacer
            className="placer__panel"
            isEnabled={true}
            emitter={emitter}
            ref={placerRef}
            isFocused={isFocused}
        >
            {yourContent}
        </DelayedPlacer>
    )
}
```

When you need to re-place after a content change that happens outside React state:

```ts
// Trigger placement immediately, bypassing the debounce:
placerRef.current?.placeRightAway()
```

> `isEnabled`, `className`, and `isFocused` are all required. The child must be `position: absolute` in CSS for placement to have visual effect.

---

### sizeUtil — DOM Measurement

A collection of DOM measurement helpers. Use these in your components when you need to measure element dimensions or positions the same way Heartwood does.

```ts
const sizeUtil = {
    bodyWidth(): number
    bodyHeight(): number

    // Absolute position (walks offsetParent chain, accounts for scroll)
    getTop(node: HTMLElement): number
    getLeft(node: HTMLElement): number
    getBottom(node: HTMLElement): number
    getRight(node: HTMLElement): number
    getPosition(node: HTMLElement): { x: number; y: number }

    // Local position relative to offsetParent
    getLocalTop(node: HTMLElement): number
    getLocalLeft(node: HTMLElement): number
    getLocalBottom(node: HTMLElement): number
    getLocalRight(node: HTMLElement): number

    // Dimensions
    getWidth(node: HTMLElement): number
    getHeight(node: HTMLElement, shouldGetPreciseHeight?: boolean): number
    //  true (default): getBoundingClientRect().height — sub-pixel, correct during transitions
    //  false: offsetHeight — integer, cheaper

    // Scroll
    getScrollWidth(node: HTMLElement): number
    getScrollHeight(node: HTMLElement): number
    getMaxScrollTop(node: HTMLElement): number
    getMaxScrollLeft(node: HTMLElement): number
    isScrolledAllTheWayRight(node: HTMLElement): boolean
    isScrolledAllTheWayLeft(node: HTMLElement): boolean

    // Hit testing
    doesIntersect({ x, y, node }: { x: number; y: number; node: HTMLElement }): boolean
}
```

`getPosition()` returns **absolute page coordinates** (distance from top-left of document), not viewport coordinates.

**Stubbing in tests** — `sizeUtil` is a plain object so individual methods are replaceable:

```ts
sizeUtil.bodyWidth = () => 1200
```

> Call measurement methods after layout has settled — inside `componentDidMount`, after `setTimeout`, or in an emitter event handler.

---

### How the Three Systems Coordinate

A complete example showing all three systems working together in a card component:

```tsx
import React, { useMemo } from 'react'
import {
    Sizer, DelayedPlacer, queueShow, Settings,
} from '@sprucelabs/spruce-heartwood-utils/web'

function YourCard({ isPlaced, isFocused }: { isPlaced: boolean; isFocused: () => boolean }) {
    const emitter = useMemo(() => SkillViewEmitter.getInstance(), [])

    return (
        <DelayedPlacer
            className="placer__card"
            isEnabled={isPlaced}
            emitter={emitter}
            isFocused={isFocused}
        >
            <Sizer emitter={emitter}>
                <div
                    className="card hidden"
                    ref={(ref) => { ref && queueShow(ref) }}
                >
                    your content
                </div>
            </Sizer>
        </DelayedPlacer>
    )
}
```

**What happens when content renders:**

```
React render
    → Sizer receives did-render → measures height → updates style.height
    → Sizer emits did-resize-content (after animation duration)
        → DelayedPlacer re-measures placeholder → updates style.left / style.top
        → DelayedPlacer emits did-place-cards
queueShow removes .hidden from each element with a 40ms stagger
```

**Test setup for animation components:**

```ts
import { Settings, stopQueue } from '@sprucelabs/spruce-heartwood-utils/web'
import { skillViewState } from '@sprucelabs/spruce-heartwood-utils/build/components/skillViews/skillViewState'

protected async beforeEach() {
    Settings.disableAnimations()
    skillViewState.isFullScreen = false
}

protected async afterEach() {
    await super.afterEach()
    stopQueue()
}
```