@sprucelabs/spruce-heartwood-utils 38.16.1 → 38.16.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/README.md +1105 -188
  2. package/package.json +1 -1
package/README.md CHANGED
@@ -1,240 +1,1157 @@
1
- <img src="./docs/images/hero.jpg">
1
+ # @sprucelabs/spruce-heartwood-utils
2
2
 
3
- # Heartwood Skill
3
+ Tools for building Spruce skills that integrate with Heartwood — remote card loading, auto-logout control, animation primitives, and testing utilities.
4
4
 
5
- [![AI TDD Contributor](https://regressionproof.ai/badge.svg)](https://regressionproof.ai)
5
+ ---
6
6
 
7
- The core UI framework and skill for the Spruce Experience Platform. Heartwood provides the view controller architecture, component library, theming system, and Storybook integration that powers all Spruce skills.
7
+ ## Table of Contents
8
8
 
9
- ## Overview
9
+ 1. [Remote View Controllers](#remote-view-controllers)
10
+ 2. [CardRegistrar](#cardregistrar)
11
+ 3. [Types](#types)
12
+ 4. [Theming](#theming)
13
+ 5. [Plugins](#plugins)
14
+ 6. [Test Utilities](#test-utilities)
15
+ 7. [Device & Layout Utilities](#device--layout-utilities)
16
+ 8. [Animation](#animation)
17
+ - [Quick Start](#quick-start)
18
+ - [Required CSS](#required-css)
19
+ - [System Architecture](#system-architecture)
20
+ - [AnimationEmitter](#animationemitter)
21
+ - [Settings](#settings)
22
+ - [queueShow — Show/Hide Queue](#queueshow--showhide-queue)
23
+ - [Sizer — Animated Height Container](#sizer--animated-height-container)
24
+ - [DelayedPlacer — Absolute Positioning](#delayedplacer--absolute-positioning)
25
+ - [sizeUtil — DOM Measurement](#sizeutil--dom-measurement)
26
+ - [How the Three Systems Coordinate](#how-the-three-systems-coordinate)
10
27
 
11
- Heartwood serves as:
12
- - **The primary UI layer** for Spruce XP
13
- - **A view controller framework** built on `@sprucelabs/heartwood-view-controllers`
14
- - **A skill management platform** that registers, loads, and serves view controllers dynamically
15
- - **A comprehensive component library** with 40+ UI components
28
+ ---
16
29
 
17
- ## Features
30
+ ## Remote View Controllers
18
31
 
19
- ### View Controllers
32
+ 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.
20
33
 
21
- Heartwood uses a controller-based architecture for building UIs...
34
+ ### `RemoteViewControllerFactoryImpl`
22
35
 
23
- ```typescript
24
- class MySkillViewController extends AbstractSkillViewController {
25
- private cardVc: CardViewController
36
+ 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.
26
37
 
27
- constructor(options: ViewControllerOptions) {
28
- super(options)
29
- this.cardVc = this.Controller('card', {
30
- header: { title: 'Welcome' },
31
- body: { items: [{ text: 'Hello World' }] }
32
- })
33
- }
38
+ ```ts
39
+ import {
40
+ RemoteViewControllerFactoryImpl,
41
+ RemoteViewControllerFactory,
42
+ } from '@sprucelabs/spruce-heartwood-utils'
43
+ ```
34
44
 
35
- render(): SkillView {
36
- return {
37
- layouts: [buildSkillViewLayout('one-col', {
38
- cards: [this.cardVc.render()]
39
- })]
40
- }
41
- }
45
+ **Injecting a mock factory in tests:**
46
+
47
+ ```ts
48
+ protected async beforeEach() {
49
+ RemoteViewControllerFactoryImpl.Class = MockRemoteViewControllerFactory
50
+ MockRemoteViewControllerFactory.reset()
51
+ }
52
+ ```
53
+
54
+ **Loading a remote controller by ID:**
55
+
56
+ ```ts
57
+ const vc = await remoteVcFactory.RemoteController(
58
+ 'your-skill.some-card',
59
+ { someOption: true }
60
+ )
61
+ ```
62
+
63
+ ---
64
+
65
+ ### `RemoteViewControllerFactory` (interface)
66
+
67
+ The interface that both `RemoteViewControllerFactoryImpl` and `MockRemoteViewControllerFactory` implement. Use this type for any field that may hold the real factory or a test mock:
68
+
69
+ ```ts
70
+ import { RemoteViewControllerFactory } from '@sprucelabs/spruce-heartwood-utils'
71
+
72
+ private remoteVcFactory: RemoteViewControllerFactory
73
+ ```
74
+
75
+ ---
76
+
77
+ ### `RemoteFactoryOptions`
78
+
79
+ Options passed to `RemoteViewControllerFactoryImpl.Factory()`.
80
+
81
+ ```ts
82
+ import { RemoteFactoryOptions } from '@sprucelabs/spruce-heartwood-utils'
83
+
84
+ interface RemoteFactoryOptions {
85
+ connectToApi: () => Promise<MercuryClient>
86
+ vcFactory: VcFactoryForRemoteFactory
87
+ }
88
+ ```
89
+
90
+ ---
91
+
92
+ ### `VcFactoryForRemoteFactory`
93
+
94
+ 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:
95
+
96
+ ```ts
97
+ import { VcFactoryForRemoteFactory } from '@sprucelabs/spruce-heartwood-utils'
98
+
99
+ private views: VcFactoryForRemoteFactory
100
+ ```
101
+
102
+ ---
103
+
104
+ ## CardRegistrar
105
+
106
+ 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.
107
+
108
+ ```ts
109
+ import {
110
+ CardRegistrar,
111
+ CardRegistrarOptions,
112
+ CardFetchOptions,
113
+ EachCardHandler,
114
+ } from '@sprucelabs/spruce-heartwood-utils'
115
+ ```
116
+
117
+ ### `CardRegistrar.Registrar(options)`
118
+
119
+ Create a fresh registrar inside your skill view's `load()` so each load cycle gets a fresh client connection:
120
+
121
+ ```ts
122
+ private async loadContributedCards(options: SkillViewControllerLoadOptions) {
123
+ const client = await this.connectToApi()
124
+
125
+ const registrar = CardRegistrar.Registrar({
126
+ client,
127
+ eventName: 'your-skill.register-cards::v2021_02_11',
128
+ vcFactory: this.getVcFactory(),
129
+ vcIdsTransformer: (payload) => payload.vcIds,
130
+ })
131
+
132
+ this.remoteCards = (await registrar.fetch()) as RemoteDashboardCard[]
133
+ this.triggerRender()
134
+
135
+ await Promise.all(this.remoteCards.map((vc) => vc.load?.(options)))
136
+ }
137
+ ```
138
+
139
+ ### `CardRegistrarOptions<Contract, Name>`
140
+
141
+ ```ts
142
+ interface CardRegistrarOptions<Contract, Name> {
143
+ client: MercuryClient
144
+ eventName: Name
145
+ vcFactory: VcFactoryForRemoteFactory
146
+ vcIdsTransformer: (payload: ResponsePayload<Contract, Name>) => string[]
42
147
  }
43
148
  ```
44
149
 
45
- **Built-in View Controllers:**
46
- - `heartwood.root` - Main dashboard
47
- - `heartwood.loading` - Loading states
48
- - `heartwood.error` - Error display
49
- - `heartwood.not-found` - 404 handling
50
- - `heartwood.scope-tool` - Organization/location scope management
51
-
52
- ### Storybook Integration
53
-
54
- Heartwood includes comprehensive Storybook support for component development and documentation:
55
-
56
- ```bash
57
- # Start Storybook dev server
58
- yarn storybook
59
-
60
- # Build static Storybook
61
- yarn build-storybook
62
- ```
63
-
64
- **44+ Story Categories:**
65
- - UI Components: buttons, cards, dialogs, forms, icons, dropdowns
66
- - Data Display: lists, tables, feeds, timelines
67
- - Navigation: pager, tabs, progress navigation
68
- - Calendars: day view, month view, event management
69
- - Charts: bar, line, polar area (via Chart.js)
70
- - Maps: location display and selection
71
- - Advanced: conferencing, streaming, swipe interactions
72
-
73
- Stories use the same view controller pattern as production code, making them excellent for testing and documentation.
74
-
75
- ### Events
76
-
77
- **Skill Events:**
78
-
79
- | Event | Description |
80
- |-------|-------------|
81
- | `heartwood.register-skill-views` | Register view controllers from other skills |
82
- | `heartwood.get-skill-views` | Fetch registered views by namespace |
83
- | `heartwood.list-views` | List all registered skill views |
84
- | `heartwood.did-register-skill-views` | Notification after registration complete |
85
- | `heartwood.upsert-theme` | Update skill theme |
86
- | `heartwood.get-active-theme` | Get current theme |
87
-
88
- **UI Events (SkillViewEmitter):**
89
-
90
- | Event | Description |
91
- |-------|-------------|
92
- | `did-change-orientation` | Device orientation changed |
93
- | `did-resize` | Window resized |
94
- | `did-render` | View rendered |
95
- | `will-change-route` | Before navigation |
96
- | `did-render-dialog` | Dialog displayed |
97
- | `did-keydown` | Keyboard input |
98
- | `did-scroll` | Page scrolled |
99
- | `connection-status-changed` | Mercury connection status |
100
-
101
- ### Component Library
102
-
103
- **Forms (32+ field types):**
104
- - Text, textarea, phone, email
105
- - Select, checkbox, toggle
106
- - Date, time, datetime, duration
107
- - Address, ratings, color picker
108
- - File upload, signature capture
109
-
110
- **Layout Components:**
111
- - Cards with headers, bodies, footers
112
- - Grid layouts (one-col, two-col, big-left, big-right)
113
- - Dialogs and modals
114
- - Slide panels
115
- - Tabs and accordions
116
-
117
- **Data Components:**
118
- - Lists with pagination
119
- - Tables with sorting
120
- - Feeds and activity streams
121
- - Progress indicators
122
-
123
- **Interactive Components:**
124
- - Buttons and button groups
125
- - Dropdowns and menus
126
- - Calendars and date pickers
127
- - Maps and location pickers
128
- - Charts and graphs
129
-
130
- ### Theming
131
-
132
- Heartwood supports per-namespace theming:
133
-
134
- ```typescript
135
- // Apply theme programmatically
136
- themeManager.setTheme({
137
- name: 'My Theme',
138
- colors: {
139
- primary: '#00f0ff',
140
- secondary: '#7b2fff'
150
+ ### `registrar.fetch(options?)`
151
+
152
+ Emits the event and loads all returned VC IDs. Returns `AbstractViewController<Card>[]`.
153
+
154
+ ```ts
155
+ // Basic fetch wait for all cards
156
+ const cards = await registrar.fetch()
157
+
158
+ // Streaming — render each batch as it arrives
159
+ await registrar.fetch({
160
+ each: async (batch) => {
161
+ this.remoteCards.push(...(batch as RemoteDashboardCard[]))
162
+ this.triggerRender()
141
163
  },
142
- fonts: { ... },
143
- borderRadius: '8px'
144
164
  })
165
+
166
+ // With target — scope the event to an organization
167
+ const cards = await registrar.fetch({
168
+ target: { organizationId: 'org-123' },
169
+ })
170
+ ```
171
+
172
+ ### `EachCardHandler`
173
+
174
+ ```ts
175
+ type EachCardHandler = (vcs: AbstractViewController<any>[]) => Promise<void> | void
176
+ ```
177
+
178
+ ### `CardFetchOptions<Contract, Name>`
179
+
180
+ ```ts
181
+ type CardFetchOptions<Contract, Name> = {
182
+ each?: EachCardHandler
183
+ controllerOptionsHandler?: (vcId: string) => Record<string, any>
184
+ } & EmitPayload<Contract, Name>
145
185
  ```
146
186
 
147
- Themes control: colors, fonts, card styles, border radius, calendar event colors, and more.
187
+ ---
148
188
 
149
- ### Navigation
189
+ ## Types
150
190
 
151
- Hash-based routing system:
191
+ ### `RemoteDashboardCard`
152
192
 
153
- ```typescript
154
- // Navigate to a view
155
- this.Router().redirect('my-skill.profile', { userId: '123' })
193
+ 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.
156
194
 
157
- // URL format: #views/my-skill.profile?userId=123
195
+ ```ts
196
+ import { RemoteDashboardCard } from '@sprucelabs/spruce-heartwood-utils'
197
+
198
+ interface RemoteDashboardCard extends ViewController<Card> {
199
+ load?(options: SkillViewControllerLoadOptions): Promise<void>
200
+ }
158
201
  ```
159
202
 
160
- Features:
161
- - View stack for back/forward navigation
162
- - Route restrictions for protected views
163
- - Deep linking support
203
+ **Typical pattern — store, render, and forward load args:**
164
204
 
165
- ### Authentication & Authorization
205
+ ```ts
206
+ private remoteCards: RemoteDashboardCard[] = []
166
207
 
167
- ```typescript
168
- // Check login status
169
- const isLoggedIn = this.getAuthenticator().isLoggedIn()
208
+ // After fetch:
209
+ this.remoteCards = (await registrar.fetch()) as RemoteDashboardCard[]
210
+ await Promise.all(this.remoteCards.map((vc) => vc.load?.(options)))
170
211
 
171
- // Check permissions
172
- const canEdit = await this.getAuthorizer().can({
173
- contractId: 'my-skill',
174
- permissionIds: ['can-edit-profile']
175
- })
212
+ // In render():
213
+ cards: [
214
+ this.myLocalCardVc.render(),
215
+ ...this.remoteCards.map((c) => c.render()),
216
+ ]
217
+ ```
218
+
219
+ ---
220
+
221
+ ## Theming
222
+
223
+ ### `loadActiveThemeForOrg(client, organizationId)`
224
+
225
+ Fetches the active Heartwood theme for an organization so your skill can apply the same visual style.
226
+
227
+ ```ts
228
+ import { loadActiveThemeForOrg } from '@sprucelabs/spruce-heartwood-utils'
229
+ ```
230
+
231
+ **Signature:**
232
+
233
+ ```ts
234
+ async function loadActiveThemeForOrg(
235
+ client: MercuryClient,
236
+ organizationId: string
237
+ ): Promise<SkillTheme>
238
+ ```
239
+
240
+ **Usage:**
241
+
242
+ ```ts
243
+ const client = await this.connectToApi()
244
+ const theme = await loadActiveThemeForOrg(client, organizationId)
245
+ // pass theme to your view controller factory or apply to the UI
246
+ ```
247
+
248
+ ---
249
+
250
+ ## Plugins
251
+
252
+ ### `AutoLogoutPlugin`
253
+
254
+ 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.
255
+
256
+ ```ts
257
+ import { AutoLogoutPlugin } from '@sprucelabs/spruce-heartwood-utils'
258
+
259
+ const plugin = factory.BuildPlugin(AutoLogoutPlugin)
260
+
261
+ plugin.enableAutoLogout(300) // auto-logout after 300 seconds of inactivity
262
+ plugin.disableAutoLogout() // cancel a pending auto-logout
176
263
  ```
177
264
 
178
- ### Scope System
265
+ ---
179
266
 
180
- Manage organization/location context:
267
+ ### `AutoLogoutViewPlugin` (interface)
181
268
 
182
- ```typescript
183
- // Get current scope
184
- const { organizationId, locationId } = this.getScope()
269
+ 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:
185
270
 
186
- // Scope selection UI
187
- this.Controller('heartwood.scope-selection-card', { ... })
271
+ ```ts
272
+ import { AutoLogoutViewPlugin } from '@sprucelabs/spruce-heartwood-utils'
273
+
274
+ interface AutoLogoutViewPlugin extends ViewControllerPlugin {
275
+ enableAutoLogout(durationSec: number): void
276
+ disableAutoLogout(): void
277
+ }
188
278
  ```
189
279
 
190
- ## Architecture
280
+ ---
281
+
282
+ ### `SpyAutoLogoutPlugin`
283
+
284
+ A test double that records calls to `enableAutoLogout` and `disableAutoLogout`. Swap it in during test setup to assert your skill calls the plugin correctly:
285
+
286
+ ```ts
287
+ import { SpyAutoLogoutPlugin } from '@sprucelabs/spruce-heartwood-utils'
191
288
 
289
+ protected async beforeEach() {
290
+ const factory = this.views.getFactory()
291
+ factory.setPlugin('auto-logout', new SpyAutoLogoutPlugin())
292
+ }
293
+
294
+ @test()
295
+ protected async startsAutoLogoutAfterLoad() {
296
+ await this.load()
297
+ assert.isTrue(this.plugin.wasEnableAutoLogoutCalled)
298
+ assert.isEqual(this.plugin.durationSec, 300)
299
+ }
300
+
301
+ private get plugin() {
302
+ return this.views.getFactory().getPlugin('auto-logout') as SpyAutoLogoutPlugin
303
+ }
192
304
  ```
193
- App (React)
194
- ├── AppEngine (Core runtime)
195
- │ ├── ViewControllerFactory
196
- │ ├── Router
197
- │ ├── Authenticator
198
- │ ├── ThemeManager
199
- │ └── RemoteViewControllerFactory
200
- ├── SkillViewNavigator
201
- ├── ControlBar (Navigation)
202
- └── Dialog System
305
+
306
+ **Spy fields:**
307
+
308
+ ```ts
309
+ class SpyAutoLogoutPlugin {
310
+ wasEnableAutoLogoutCalled: boolean
311
+ durationSec?: number
312
+ wasDisableAutoLogoutCalled: boolean
313
+ }
203
314
  ```
204
315
 
205
- ## Development
316
+ ---
317
+
318
+ ## Test Utilities
206
319
 
207
- ```bash
208
- # Install dependencies
209
- yarn
320
+ 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.
210
321
 
211
- # Build
212
- yarn build.dev
322
+ ### `remoteVcAssert`
213
323
 
214
- # Run tests
215
- yarn test
324
+ High-level assertion helper. Verifies that your skill view emits the expected registration event and that the returned cards are rendered.
216
325
 
217
- # Start Storybook
218
- yarn storybook
326
+ ```ts
327
+ import { remoteVcAssert } from '@sprucelabs/spruce-heartwood-utils'
328
+ ```
329
+
330
+ **Basic assertion — confirm your skill view emits the event and renders cards:**
219
331
 
220
- # Build for CDN
221
- yarn build.cdn
332
+ ```ts
333
+ @test()
334
+ protected async registersCards() {
335
+ await remoteVcAssert.assertSkillViewRendersRemoteCards({
336
+ svc: this.vc,
337
+ fqen: 'your-skill.register-cards::v2021_02_11',
338
+ views: this.views,
339
+ })
340
+ }
222
341
  ```
223
342
 
224
- ## Registering Skill Views
343
+ **With `shouldInvoke` confirm each returned card has `load()` called on it:**
225
344
 
226
- Other skills register their view controllers with Heartwood:
345
+ ```ts
346
+ await remoteVcAssert.assertSkillViewRendersRemoteCards({
347
+ svc: this.vc,
348
+ fqen: 'your-skill.register-cards::v2021_02_11',
349
+ views: this.views,
350
+ shouldInvoke: {
351
+ methodName: 'load',
352
+ expectedParams: [this.views.getRouter().buildLoadOptions()],
353
+ },
354
+ })
355
+ ```
227
356
 
228
- ```typescript
229
- await client.emitAndFlattenResponses('heartwood.register-skill-views::v2021_02_11', {
230
- target: { skillId: 'your-skill-id' },
231
- payload: {
232
- ids: ['root', 'profile', 'settings'],
233
- source: compiledViewControllerCode
357
+ **With `expectedTarget` / `expectedPayload` — confirm the event is scoped correctly:**
358
+
359
+ ```ts
360
+ await remoteVcAssert.assertSkillViewRendersRemoteCards({
361
+ svc: this.vc,
362
+ fqen: 'your-skill.register-cards::v2021_02_11',
363
+ views: this.views,
364
+ expectedTarget: { organizationId: 'org-123' },
365
+ })
366
+ ```
367
+
368
+ ---
369
+
370
+ ### `AssertRendersRemoteCardsOptions<Svc>`
371
+
372
+ ```ts
373
+ import { AssertRendersRemoteCardsOptions } from '@sprucelabs/spruce-heartwood-utils'
374
+
375
+ interface AssertRendersRemoteCardsOptions<Svc extends SkillViewController = SkillViewController> {
376
+ svc: Svc
377
+ fqen: EventNames
378
+ views: ViewFixture
379
+ expectedTarget?: Record<string, any>
380
+ expectedPayload?: Record<string, any>
381
+ loadArgs?: ArgsFromSvc<Svc>
382
+ shouldInvoke?: {
383
+ methodName: string
384
+ expectedParams?: any[]
234
385
  }
386
+ }
387
+ ```
388
+
389
+ ---
390
+
391
+ ### `fakeGetViews`
392
+
393
+ 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.
394
+
395
+ ```ts
396
+ import { fakeGetViews } from '@sprucelabs/spruce-heartwood-utils'
397
+
398
+ // Stub two remote views:
399
+ await fakeGetViews.fakeGetViews(['your-skill.card-a', 'your-skill.card-b'])
400
+
401
+ // Stub a view with a tracked method — stub exposes wasHit and passedParams:
402
+ await fakeGetViews.fakeGetViews(['your-skill.card'], 'load')
403
+ ```
404
+
405
+ > **Note:** The export is the `heartwoodEventFaker` object. Call it as `fakeGetViews.fakeGetViews(...)`.
406
+
407
+ ---
408
+
409
+ ### `MockRemoteViewControllerFactory`
410
+
411
+ 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.
412
+
413
+ ```ts
414
+ import {
415
+ MockRemoteViewControllerFactory,
416
+ RemoteViewControllerFactoryImpl,
417
+ } from '@sprucelabs/spruce-heartwood-utils'
418
+ ```
419
+
420
+ **Setup — inject the mock before your skill view creates its factory:**
421
+
422
+ ```ts
423
+ protected async beforeEach() {
424
+ RemoteViewControllerFactoryImpl.Class = MockRemoteViewControllerFactory
425
+ MockRemoteViewControllerFactory.reset()
426
+
427
+ MockRemoteViewControllerFactory.dropInRemoteController(
428
+ 'your-skill.some-card',
429
+ YourCardViewController
430
+ )
431
+ }
432
+ ```
433
+
434
+ **Get the mock instance to run assertions:**
435
+
436
+ ```ts
437
+ const mockFactory = MockRemoteViewControllerFactory.getInstance()
438
+ ```
439
+
440
+ **Available assertions:**
441
+
442
+ ```ts
443
+ // Assert a specific card was loaded:
444
+ mockFactory.assertFetchedRemoteController('your-skill.some-card')
445
+
446
+ // Assert it was loaded with specific constructor options:
447
+ mockFactory.assertFetchedRemoteController('your-skill.some-card', { organizationId: 'org-123' })
448
+
449
+ // Assert a card was NOT loaded:
450
+ mockFactory.assertDidNotFetchRemoteController('your-skill.other-card')
451
+
452
+ // Assert a skill view renders a specific remote card:
453
+ mockFactory.assertSkillViewRendersRemoteCard(this.vc, 'your-skill.some-card')
454
+
455
+ // Assert constructor options match exactly:
456
+ mockFactory.assertRemoteCardConstructorOptionsEqual('your-skill.some-card', { organizationId: 'org-123' })
457
+
458
+ // Assert views were loaded for a namespace:
459
+ mockFactory.assertLoadedViewsForNamespace('your-skill')
460
+ ```
461
+
462
+ **Check without throwing:**
463
+
464
+ ```ts
465
+ if (mockFactory.hasController('your-skill.some-card')) { ... }
466
+ ```
467
+
468
+ ---
469
+
470
+ ### `MockDroppedInControllers`
471
+
472
+ Type for the map of card IDs to VC classes held by `MockRemoteViewControllerFactory`:
473
+
474
+ ```ts
475
+ import { MockDroppedInControllers } from '@sprucelabs/spruce-heartwood-utils'
476
+
477
+ type MockDroppedInControllers = Record<
478
+ string,
479
+ new (args: any) => ViewController<any>
480
+ >
481
+ ```
482
+
483
+ ---
484
+
485
+ ## Device & Layout Utilities
486
+
487
+ These utilities are not re-exported through the main barrel and must be imported directly from their build paths.
488
+
489
+ ### `getDeviceOrientation()`
490
+
491
+ Returns `'landscape'` or `'portrait'` based on the current viewport dimensions. Use this to make layout decisions that depend on device orientation.
492
+
493
+ ```ts
494
+ import { getDeviceOrientation } from '@sprucelabs/spruce-heartwood-utils/build/components/controlBars/getDeviceOrientation'
495
+
496
+ const orientation = getDeviceOrientation() // 'landscape' | 'portrait'
497
+
498
+ if (orientation === 'landscape') {
499
+ // wide layout
500
+ } else {
501
+ // tall layout
502
+ }
503
+ ```
504
+
505
+ **Decision logic:**
506
+
507
+ ```
508
+ if (clientWidth > clientHeight && clientWidth > 700) → 'landscape'
509
+ else → 'portrait'
510
+ ```
511
+
512
+ **Reacting to changes** — do not poll. Subscribe to the orientation change event instead:
513
+
514
+ ```ts
515
+ await emitter.on('did-change-orientation', async () => {
516
+ const orientation = getDeviceOrientation()
517
+ // update layout
235
518
  })
236
519
  ```
237
520
 
238
- ## Documentation
521
+ **Caveats:** Browser-only. Synchronous. Each call reads the DOM live — no caching.
522
+
523
+ ---
524
+
525
+ ### `skillViewState`
526
+
527
+ 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.
528
+
529
+ ```ts
530
+ import { skillViewState } from '@sprucelabs/spruce-heartwood-utils/build/components/skillViews/skillViewState'
531
+
532
+ if (skillViewState.isFullScreen) {
533
+ // full-screen mode is active — skip height-dependent layout
534
+ }
535
+ ```
536
+
537
+ **In tests — reset between cases:**
538
+
539
+ ```ts
540
+ beforeEach(() => {
541
+ skillViewState.isFullScreen = false
542
+ })
543
+ ```
544
+
545
+ **Caveats:** Plain object — not reactive. Reading it does not subscribe to changes. Must be reset between tests since it is a module-level singleton.
546
+
547
+ ---
548
+
549
+ ## Animation
550
+
551
+ 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.
552
+
553
+ ### Quick Start
554
+
555
+ ```ts
556
+ import {
557
+ // Visibility queue
558
+ useQueueShow, useShowNow,
559
+ showRightAway, hideRightAway,
560
+ queueShow, queueHide,
561
+ queueCallback, callbackImmediately,
562
+ clearPendingHideAndQueueShow, clearPendingShowAndQueueHide,
563
+ stopQueue,
564
+ // Layout components
565
+ Sizer, DelayedPlacer,
566
+ // Utilities
567
+ sizeUtil, Settings,
568
+ // Types
569
+ AnimationEmitter,
570
+ SizerProps,
571
+ DelayedPlacerProps,
572
+ } from '@sprucelabs/spruce-heartwood-utils'
573
+ ```
574
+
575
+ ---
576
+
577
+ ### Required CSS
578
+
579
+ **Inside a Heartwood skill view:** all required CSS is already provided by Heartwood's stylesheet. No extra setup needed.
580
+
581
+ **Outside Heartwood (standalone use):** supply the following rules yourself.
582
+
583
+ The `queueShow` system toggles a `hidden` CSS class. Elements must start with `hidden` in their `className` and define their own transition:
584
+
585
+ ```css
586
+ .hidden {
587
+ opacity: 0;
588
+ transform: translateY(4px);
589
+ pointer-events: none;
590
+ }
591
+
592
+ /* Transition goes on the element, not .hidden */
593
+ .your-element {
594
+ transition: opacity 200ms ease, transform 200ms ease;
595
+ }
596
+ ```
597
+
598
+ `Sizer` uses `.sizer` and `.sizer__inner`:
599
+
600
+ ```css
601
+ .sizer {
602
+ transition: height 500ms ease;
603
+ overflow: hidden;
604
+ }
605
+ ```
606
+
607
+ `DelayedPlacer` uses `.placer`. Its child must be `position: absolute`:
608
+
609
+ ```css
610
+ .placer {
611
+ position: relative;
612
+ }
613
+ .placer > * {
614
+ position: absolute;
615
+ }
616
+ ```
617
+
618
+ ---
619
+
620
+ ### System Architecture
621
+
622
+ The animation system has three cooperating layers:
623
+
624
+ ```
625
+ queueShow / queueHide — staggered CSS class toggling (show/hide via .hidden)
626
+
627
+
628
+ Sizer — measures content height, sets style.height for smooth animation
629
+ │ emits did-resize-content
630
+
631
+ DelayedPlacer — reads Sizer's placeholder position, writes absolute coordinates
632
+ ```
633
+
634
+ Pass the **same emitter instance** to `Sizer` and `DelayedPlacer` when they are siblings so their events coordinate. Different emitter instances decouple them.
635
+
636
+ `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.
637
+
638
+ ---
639
+
640
+ ### AnimationEmitter
641
+
642
+ The interface `Sizer` and `DelayedPlacer` use to communicate layout-change events. You implement this and pass it as the `emitter` prop to coordinate components.
643
+
644
+ ```ts
645
+ interface AnimationEmitter {
646
+ on(event: string, handler: () => void): Promise<void> | void
647
+ off(event: string, handler: () => void): Promise<void> | void
648
+ emit(event: string): Promise<void> | void
649
+ }
650
+ ```
651
+
652
+ **Events:**
653
+
654
+ | Event | Who listens | Who emits | Meaning |
655
+ |---|---|---|---|
656
+ | `did-render` | Sizer, DelayedPlacer | your code | A React render cycle completed |
657
+ | `did-resize` | Sizer, DelayedPlacer | your code | Viewport or parent dimensions changed |
658
+ | `did-resize-content` | DelayedPlacer | Sizer | Sizer changed its measured height |
659
+ | `did-change-orientation` | DelayedPlacer | your code | Portrait ↔ landscape switch |
660
+ | `did-place-cards` | your listeners | DelayedPlacer | Placement calculation finished |
661
+
662
+ **Minimal implementation:**
663
+
664
+ ```ts
665
+ class SimpleEmitter implements AnimationEmitter {
666
+ private listeners: Record<string, Array<() => void>> = {}
667
+
668
+ on(event: string, handler: () => void): void {
669
+ ;(this.listeners[event] ??= []).push(handler)
670
+ }
671
+
672
+ off(event: string, handler: () => void): void {
673
+ this.listeners[event] = (this.listeners[event] ?? []).filter(
674
+ (h) => h !== handler
675
+ )
676
+ }
677
+
678
+ emit(event: string): void {
679
+ for (const h of this.listeners[event] ?? []) h()
680
+ }
681
+ }
682
+ ```
683
+
684
+ > Pass the **same function reference** to `on` and `off`. Inline arrow functions cannot be removed with `off`.
685
+
686
+ ---
687
+
688
+ ### Settings
689
+
690
+ A static class that controls animation behavior globally. The most important method for skill developers is `disableAnimations()` — call it in every test `beforeEach`.
691
+
692
+ ```ts
693
+ class Settings {
694
+ // Disable all animations. Makes timeouts fire at 0ms, queue drains synchronously.
695
+ // Cannot be undone. Never call in production code.
696
+ static disableAnimations(): void
697
+
698
+ static getIsAnimationEnabled(): boolean
699
+
700
+ // Returns animation duration in ms: landscape=500, portrait=1000. Returns 0 if disabled.
701
+ static get animationDuration(): number
702
+ }
703
+ ```
704
+
705
+ **In your test base class:**
706
+
707
+ ```ts
708
+ protected async beforeEach() {
709
+ Settings.disableAnimations()
710
+ }
711
+ ```
712
+
713
+ > **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.
714
+
715
+ ---
716
+
717
+ ### queueShow — Show/Hide Queue
718
+
719
+ 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.
720
+
721
+ When animations are disabled (`Settings.disableAnimations()`), the entire queue drains synchronously — no real timers needed in tests.
722
+
723
+ **The queue is shared** across your entire skill view. `stopQueue()` stops all pending operations.
724
+
725
+ ---
726
+
727
+ #### `queueShow(refOrNode, delay?, method?)`
728
+
729
+ Queues a show (removes `hidden`) on an element.
730
+
731
+ ```ts
732
+ function queueShow(
733
+ refOrNode: React.RefObject<HTMLElement | null> | HTMLElement | null,
734
+ delay?: number, // default: 40ms between queue steps
735
+ method?: 'push' | 'unshift' // default: 'push' (end of queue)
736
+ ): void
737
+ ```
738
+
739
+ No-ops silently if the element is null, already visible, or already queued.
740
+
741
+ ```tsx
742
+ // Standard pattern — queue on mount via ref callback:
743
+ <div className="your-card hidden" ref={(ref) => { ref && queueShow(ref) }} />
744
+
745
+ // Multiple elements — queue them all:
746
+ const items = containerRef.current?.querySelectorAll('.item') ?? []
747
+ for (const item of items) { queueShow(item as HTMLElement) }
748
+ ```
749
+
750
+ ---
751
+
752
+ #### `queueHide(refOrNode, delay?, method?)`
753
+
754
+ Queues a hide (adds `hidden`) on an element.
755
+
756
+ ```ts
757
+ function queueHide(
758
+ refOrNode: React.RefObject<HTMLElement> | HTMLElement,
759
+ delay?: number,
760
+ method?: 'push' | 'unshift'
761
+ ): void
762
+ ```
763
+
764
+ ```ts
765
+ queueHide(loadingSpinnerRef)
766
+ ```
767
+
768
+ ---
769
+
770
+ #### `showRightAway(refOrNode)`
771
+
772
+ Jumps to the **front** of the queue. Use when an element must appear before anything else already queued.
773
+
774
+ ```ts
775
+ function showRightAway(
776
+ refOrNode: React.RefObject<HTMLElement | null> | HTMLElement
777
+ ): void
778
+ ```
779
+
780
+ ```tsx
781
+ <div className="alert hidden" ref={(ref) => { ref && showRightAway(ref) }} />
782
+
783
+ <img onLoad={() => { imageRef && showRightAway(imageRef) }} />
784
+ ```
785
+
786
+ ---
787
+
788
+ #### `hideRightAway(refOrNode)`
789
+
790
+ Jumps a hide to the front of the queue.
791
+
792
+ ```ts
793
+ function hideRightAway(refOrNode: React.RefObject<HTMLElement> | HTMLElement): void
794
+ ```
795
+
796
+ ```ts
797
+ hideRightAway(overlayRef.current)
798
+ ```
799
+
800
+ ---
239
801
 
240
- For comprehensive documentation, visit [developer.spruce.bot](https://developer.spruce.bot).
802
+ #### `queueCallback(cb)`
803
+
804
+ Inserts an arbitrary callback at the end of the queue. Runs in sequence with show/hide steps.
805
+
806
+ ```ts
807
+ function queueCallback(cb: () => void): void
808
+ ```
809
+
810
+ ```ts
811
+ // Trigger a re-render after queued animations complete:
812
+ queueCallback(() => this.triggerRender())
813
+ ```
814
+
815
+ ---
816
+
817
+ #### `callbackImmediately(cb)`
818
+
819
+ Inserts a callback at the front of the queue.
820
+
821
+ ```ts
822
+ function callbackImmediately(cb: () => void): void
823
+ ```
824
+
825
+ ```ts
826
+ const enqueue = shouldPrioritize ? callbackImmediately : queueCallback
827
+ enqueue(() => this.handleReady())
828
+ ```
829
+
830
+ ---
831
+
832
+ #### `clearPendingHideAndQueueShow(refOrNode, delay?)`
833
+
834
+ Cancels a pending hide and queues a show for elements toggled back before their hide ran.
835
+
836
+ > Prefer `showRightAway()` for most toggle cases.
837
+
838
+ ---
839
+
840
+ #### `clearPendingShowAndQueueHide(refOrNode, delay?)`
841
+
842
+ Cancels a pending show and queues a hide.
843
+
844
+ > Prefer `hideRightAway()` for most toggle cases.
845
+
846
+ ---
847
+
848
+ #### `stopQueue()`
849
+
850
+ Stops the queue interval and abandons any remaining items. Call in test teardown to prevent queue state leaking between tests.
851
+
852
+ ```ts
853
+ protected async afterEach() {
854
+ await super.afterEach()
855
+ stopQueue()
856
+ }
857
+ ```
858
+
859
+ ---
860
+
861
+ #### `useQueueShow(ref, delay?)` — React Hook
862
+
863
+ Calls `queueShow` on every render via `useEffect`. Use for elements that should fade in after each render.
864
+
865
+ ```tsx
866
+ const ref = useRef<HTMLDivElement>(null)
867
+ useQueueShow(ref)
868
+
869
+ return <div ref={ref} className="your-panel hidden">...</div>
870
+ ```
871
+
872
+ ---
873
+
874
+ #### `useShowNow(ref, delay?)` — React Hook
875
+
876
+ Like `useQueueShow` but places itself at the front of the queue on every render.
877
+
878
+ ```ts
879
+ function useShowNow(ref: React.RefObject<HTMLElement>, delay?: number): void
880
+ ```
881
+
882
+ ---
883
+
884
+ ### Sizer — Animated Height Container
885
+
886
+ 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.
887
+
888
+ #### Props (`SizerProps`)
889
+
890
+ ```ts
891
+ interface SizerProps {
892
+ children: any
893
+
894
+ // When false, renders children with no wrapper. Default: enabled.
895
+ isEnabled?: boolean
896
+
897
+ // CSS class on the outer .sizer div.
898
+ className?: string
899
+
900
+ // When true, starts at height 0 and defers first measurement by 100ms.
901
+ // Use for content that should animate in from nothing.
902
+ shouldStartAtZero?: boolean
903
+
904
+ // When true, keeps overflow hidden throughout. When explicitly false,
905
+ // overflow is always visible (adds force-show-overflow class).
906
+ shouldHideOverflow?: boolean
907
+
908
+ // Event emitter. Without one, Sizer only resizes on React re-renders,
909
+ // not in response to external layout events.
910
+ emitter?: AnimationEmitter
911
+ }
912
+ ```
913
+
914
+ #### Ref API
915
+
916
+ ```ts
917
+ sizer.current?.resize(): boolean // measure and apply new height; returns true if it changed
918
+ ```
919
+
920
+ #### Emitter Events
921
+
922
+ | Event | Direction | Meaning |
923
+ |---|---|---|
924
+ | `did-render` | listened | Re-rendered; measure and resize |
925
+ | `did-resize` | listened | Viewport changed; measure and resize |
926
+ | `did-resize-content` | emitted | Height changed (fires after `Settings.animationDuration` ms) |
927
+
928
+ #### Usage
929
+
930
+ ```tsx
931
+ // Simplest — just clip overflow during height transition:
932
+ <Sizer shouldHideOverflow>
933
+ {isExpanded && <YourExpandableContent />}
934
+ </Sizer>
935
+
936
+ // Animate in from zero height:
937
+ <Sizer emitter={emitter} shouldStartAtZero>
938
+ <YourCard />
939
+ </Sizer>
940
+
941
+ // Manually trigger resize when content changes outside React state:
942
+ const emitter = useMemo(() => new SimpleEmitter(), [])
943
+ const sizer = useRef<React.ElementRef<typeof Sizer>>(null)
944
+
945
+ <Sizer shouldHideOverflow ref={sizer} emitter={emitter}>
946
+ <YourDynamicContent
947
+ onContentChange={async () => {
948
+ if (sizer.current?.resize()) {
949
+ await emitter.emit('did-resize')
950
+ }
951
+ }}
952
+ />
953
+ </Sizer>
954
+
955
+ // Staggered entrance with queueShow:
956
+ <Sizer emitter={emitter} shouldHideOverflow shouldStartAtZero>
957
+ <div className="your-item hidden" ref={(ref) => { ref && queueShow(ref) }}>
958
+ content
959
+ </div>
960
+ </Sizer>
961
+ ```
962
+
963
+ > Do not nest `Sizer` inside another `Sizer` for the same content — they will conflict.
964
+
965
+ ---
966
+
967
+ ### DelayedPlacer — Absolute Positioning
968
+
969
+ 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.
970
+
971
+ When `isEnabled={false}`, children render inline with no wrapper.
972
+
973
+ #### Props (`DelayedPlacerProps`)
974
+
975
+ ```ts
976
+ interface DelayedPlacerProps {
977
+ children: any
978
+
979
+ // Required. When true, wraps in .placer and manages absolute positioning.
980
+ isEnabled: boolean
981
+
982
+ // Required. CSS class on the outer .placer div.
983
+ className: string
984
+
985
+ // Event emitter. Listens for layout changes to re-measure and re-place.
986
+ emitter?: AnimationEmitter
987
+
988
+ // Required. Return true when this skill view is focused and in the foreground.
989
+ // Placement is skipped when false to avoid measuring off-screen content.
990
+ // Pass () => true for standalone use outside Heartwood.
991
+ isFocused: () => boolean
992
+ }
993
+ ```
994
+
995
+ #### Ref API
996
+
997
+ ```ts
998
+ delayedPlacer.current?.placeRightAway(): void // re-measure and re-place immediately
999
+ ```
1000
+
1001
+ #### Emitter Events
1002
+
1003
+ | Event | Direction | Meaning |
1004
+ |---|---|---|
1005
+ | `did-resize-content` | listened | Sizer changed height; re-place |
1006
+ | `did-resize` | listened | Viewport changed; re-place |
1007
+ | `did-render` | listened | Re-rendered; re-place |
1008
+ | `did-change-orientation` | listened | Device rotated; re-place |
1009
+ | `did-place-cards` | emitted | Placement finished (100ms after measuring) |
1010
+
1011
+ #### Usage
1012
+
1013
+ ```tsx
1014
+ const placerRef = React.createRef<DelayedPlacer>()
1015
+
1016
+ // Re-place after content changes:
1017
+ private onContentChange() {
1018
+ setTimeout(() => { placerRef.current?.placeRightAway() }, 50)
1019
+ }
1020
+
1021
+ render() {
1022
+ return (
1023
+ <DelayedPlacer
1024
+ className="placer__card"
1025
+ isEnabled={this.isPlacementEnabled}
1026
+ emitter={this.emitter}
1027
+ ref={placerRef}
1028
+ isFocused={() => true}
1029
+ >
1030
+ {yourCard}
1031
+ </DelayedPlacer>
1032
+ )
1033
+ }
1034
+ ```
1035
+
1036
+ > `isEnabled`, `className`, and `isFocused` are all required. The child must be `position: absolute` in CSS for placement to have visual effect.
1037
+
1038
+ ---
1039
+
1040
+ ### sizeUtil — DOM Measurement
1041
+
1042
+ 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.
1043
+
1044
+ ```ts
1045
+ const sizeUtil = {
1046
+ bodyWidth(): number
1047
+ bodyHeight(): number
1048
+
1049
+ // Absolute position (walks offsetParent chain, accounts for scroll)
1050
+ getTop(node: HTMLElement): number
1051
+ getLeft(node: HTMLElement): number
1052
+ getBottom(node: HTMLElement): number
1053
+ getRight(node: HTMLElement): number
1054
+ getPosition(node: HTMLElement): { x: number; y: number }
1055
+
1056
+ // Local position relative to offsetParent
1057
+ getLocalTop(node: HTMLElement): number
1058
+ getLocalLeft(node: HTMLElement): number
1059
+ getLocalBottom(node: HTMLElement): number
1060
+ getLocalRight(node: HTMLElement): number
1061
+
1062
+ // Dimensions
1063
+ getWidth(node: HTMLElement): number
1064
+ getHeight(node: HTMLElement, shouldGetPreciseHeight?: boolean): number
1065
+ // true (default): getBoundingClientRect().height — sub-pixel, correct during transitions
1066
+ // false: offsetHeight — integer, cheaper
1067
+
1068
+ // Scroll
1069
+ getScrollWidth(node: HTMLElement): number
1070
+ getScrollHeight(node: HTMLElement): number
1071
+ getMaxScrollTop(node: HTMLElement): number
1072
+ getMaxScrollLeft(node: HTMLElement): number
1073
+ isScrolledAllTheWayRight(node: HTMLElement): boolean
1074
+ isScrolledAllTheWayLeft(node: HTMLElement): boolean
1075
+
1076
+ // Hit testing
1077
+ doesIntersect({ x, y, node }: { x: number; y: number; node: HTMLElement }): boolean
1078
+ }
1079
+ ```
1080
+
1081
+ `getPosition()` returns **absolute page coordinates** (distance from top-left of document), not viewport coordinates.
1082
+
1083
+ **Stubbing in tests** — `sizeUtil` is a plain object so individual methods are replaceable:
1084
+
1085
+ ```ts
1086
+ sizeUtil.bodyWidth = () => 1200
1087
+ ```
1088
+
1089
+ > Call measurement methods after layout has settled — inside `componentDidMount`, after `setTimeout`, or in an emitter event handler.
1090
+
1091
+ ---
1092
+
1093
+ ### How the Three Systems Coordinate
1094
+
1095
+ A complete example showing all three systems working together in a card component:
1096
+
1097
+ ```tsx
1098
+ import {
1099
+ Sizer, DelayedPlacer, queueShow, Settings, AnimationEmitter,
1100
+ } from '@sprucelabs/spruce-heartwood-utils'
1101
+
1102
+ class SimpleEmitter implements AnimationEmitter {
1103
+ private listeners: Record<string, Array<() => void>> = {}
1104
+ on(event: string, handler: () => void) { (this.listeners[event] ??= []).push(handler) }
1105
+ off(event: string, handler: () => void) {
1106
+ this.listeners[event] = (this.listeners[event] ?? []).filter(h => h !== handler)
1107
+ }
1108
+ emit(event: string) { for (const h of this.listeners[event] ?? []) h() }
1109
+ }
1110
+
1111
+ function YourCard({ isPlaced, isFocused }: { isPlaced: boolean; isFocused: () => boolean }) {
1112
+ const emitter = useMemo(() => new SimpleEmitter(), [])
1113
+
1114
+ return (
1115
+ <DelayedPlacer
1116
+ className="placer__card"
1117
+ isEnabled={isPlaced}
1118
+ emitter={emitter}
1119
+ isFocused={isFocused}
1120
+ >
1121
+ <Sizer emitter={emitter} shouldHideOverflow shouldStartAtZero>
1122
+ <div
1123
+ className="card hidden"
1124
+ ref={(ref) => { ref && queueShow(ref) }}
1125
+ >
1126
+ your content
1127
+ </div>
1128
+ </Sizer>
1129
+ </DelayedPlacer>
1130
+ )
1131
+ }
1132
+ ```
1133
+
1134
+ **What happens when content renders:**
1135
+
1136
+ ```
1137
+ React render
1138
+ → Sizer receives did-render → measures height → updates style.height
1139
+ → Sizer emits did-resize-content (after animation duration)
1140
+ → DelayedPlacer re-measures placeholder → updates style.left / style.top
1141
+ → DelayedPlacer emits did-place-cards
1142
+ queueShow removes .hidden from each element with a 40ms stagger
1143
+ ```
1144
+
1145
+ **Test setup for animation components:**
1146
+
1147
+ ```ts
1148
+ protected async beforeEach() {
1149
+ Settings.disableAnimations()
1150
+ skillViewState.isFullScreen = false
1151
+ }
1152
+
1153
+ protected async afterEach() {
1154
+ await super.afterEach()
1155
+ stopQueue()
1156
+ }
1157
+ ```
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sprucelabs/spruce-heartwood-utils",
3
3
  "description": "Heartwood Utilities",
4
- "version": "38.16.1",
4
+ "version": "38.16.2",
5
5
  "skill": {
6
6
  "namespace": "heartwood"
7
7
  },