From 3d9f743f2baa16479bb54b9ac03a48a4538dec5c Mon Sep 17 00:00:00 2001 From: Burcu Noyan Date: Tue, 11 Aug 2026 15:23:01 -0400 Subject: [PATCH 1/5] Render poster board tiles from prerendered card HTML Render linked cards at grid positions Co-Authored-By: Claude Fable 5 update test coverage fix types --- .../PosterBoard/demo-poster-board.json | 25 +- .../poster-board/poster-board.gts | 257 +++++++++++++++++- .../poster-board/poster-board.test.gts | 218 ++++++++++++++- packages/experiments-realm/tsconfig.json | 11 +- 4 files changed, 490 insertions(+), 21 deletions(-) diff --git a/packages/experiments-realm/poster-board/PosterBoard/demo-poster-board.json b/packages/experiments-realm/poster-board/PosterBoard/demo-poster-board.json index 8c5e5c490fb..47404b0c2db 100644 --- a/packages/experiments-realm/poster-board/PosterBoard/demo-poster-board.json +++ b/packages/experiments-realm/poster-board/PosterBoard/demo-poster-board.json @@ -3,14 +3,35 @@ "type": "card", "attributes": { "cardInfo": { - "name": "Demo Poster Board" - } + "name": "Demo Poster Board", + "summary": null, + "cardThumbnailURL": null, + "notes": null + }, + "frameSettings": [] }, "meta": { "adoptsFrom": { "module": "../poster-board", "name": "PosterBoard" } + }, + "relationships": { + "cards.0": { + "links": { + "self": "../../Author/jane-doe" + } + }, + "cards.1": { + "links": { + "self": "../../Author/0b9c06fd-3833-4947-a0b8-ac24b8e71ee7" + } + }, + "cards.2": { + "links": { + "self": "../../Contact/a01fc5c9-d70d-4b9c-aae4-384cf2b79b25" + } + } } } } diff --git a/packages/experiments-realm/poster-board/poster-board.gts b/packages/experiments-realm/poster-board/poster-board.gts index 23cd1b84c96..caab0bd8ba9 100644 --- a/packages/experiments-realm/poster-board/poster-board.gts +++ b/packages/experiments-realm/poster-board/poster-board.gts @@ -1,11 +1,67 @@ -import { CardDef, Component } from 'https://cardstack.com/base/card-api'; +import { + CardDef, + Component, + FieldDef, + field, + contains, + containsMany, + getRelationshipMembershipState, + linksToMany, +} from 'https://cardstack.com/base/card-api'; +import NumberField from 'https://cardstack.com/base/number'; import { tracked } from '@glimmer/tracking'; import { htmlSafe } from '@ember/template'; import { on } from '@ember/modifier'; import Modifier from 'ember-modifier'; +import { + searchEntryWireQueryFromQuery, + type RenderableSearchEntryLike, + type SearchEntryWireQuery, +} from '@cardstack/runtime-common'; +import { + BrokenLinkTemplate, + FittedCardContainer, +} from '@cardstack/boxel-ui/components'; +import { fittedFormatById } from '@cardstack/boxel-ui/helpers'; import LayoutDashboardIcon from '@cardstack/boxel-icons/layout-dashboard'; import { RigState, SurfaceRig, type PanSession } from './rig'; +// Tiles use the shared cardsgrid-tile fitted size so boards show cards at a +// size their fitted views are designed for. FittedCardContainer applies the +// dimensions; these constants drive the grid placement math. +const cardsgridTile = fittedFormatById.get('cardsgrid-tile')!; +const TILE_WIDTH = cardsgridTile.width; +const TILE_HEIGHT = cardsgridTile.height; +const TILE_GAP = 32; +const GRID_COLUMNS = 4; +// Breathing room between the world origin and the default grid (~--boxel-sp-xs) +const GRID_PADDING = 10; + +interface TilePlacement { + index: number; + x: number; + y: number; +} + +// Cards without a persisted frame setting flow into a fixed grid. +function defaultPlacement(index: number): TilePlacement { + return { + index, + x: GRID_PADDING + (index % GRID_COLUMNS) * (TILE_WIDTH + TILE_GAP), + y: + GRID_PADDING + + Math.floor(index / GRID_COLUMNS) * (TILE_HEIGHT + TILE_GAP), + }; +} + +export class FrameSettingsField extends FieldDef { + static displayName = 'Frame Settings'; + + @field cardIndex = contains(NumberField); + @field x = contains(NumberField); + @field y = contains(NumberField); +} + interface OnInsertSignature { Element: HTMLElement; Args: { @@ -27,6 +83,7 @@ class Isolated extends Component { private panSession: PanSession | null = null; private activePointerId: number | null = null; private rootElement: HTMLElement | null = null; + private keydownHandler: ((e: KeyboardEvent) => void) | null = null; get zoomLabel() { return Math.round(this.rig.magnify * 100) + '%'; @@ -43,6 +100,103 @@ class Isolated extends Component { return htmlSafe(`cursor: ${this.isPanning ? 'grabbing' : 'grab'};`); } + // ── Tile placement ───────────────────────────────────── + + // The linked cards' reference URLs, read from the relationship membership + // state — a pure read that never triggers the lazy link load, so the board + // renders tiles without ever fetching the linked instances. Index-aligned + // with the cards slots; only a `not-set` slot lacks a reference. + get linkedRefs(): (string | undefined)[] { + let owner = this.args.model as unknown as PosterBoard | undefined; + if (!owner) { + return []; + } + let { membership } = getRelationshipMembershipState(owner, 'cards'); + return (membership ?? []).map((slot) => slot.reference); + } + + get tilePlacements(): TilePlacement[] { + let settings = this.args.model?.frameSettings ?? []; + return this.linkedRefs.map((_ref, index) => { + let setting = settings.find((s) => Number(s.cardIndex) === index); + // Number() guards against non-numeric values in hand-edited JSON + let x = Number(setting?.x); + let y = Number(setting?.y); + if (setting && Number.isFinite(x) && Number.isFinite(y)) { + return { index, x, y }; + } + return defaultPlacement(index); + }); + } + + get hasCards() { + return this.tilePlacements.length > 0; + } + + // Tiles render as prerendered fitted HTML addressed by the linked cards' + // URLs. `fitted` is bound through `htmlQuery` — a bare `eq.format` would be + // read as an `item.` field path and rejected. Instance index rows key on + // the `.json` file URL, and `scope: 'cards'` drops each card's dual-indexed + // file row. Undefined (no linked cards) leaves the search idle. + get tilesQuery(): SearchEntryWireQuery | undefined { + let refs = this.linkedRefs.filter( + (ref): ref is string => ref !== undefined, + ); + if (refs.length === 0) { + return undefined; + } + return { + ...searchEntryWireQueryFromQuery({}, { scope: 'cards' }), + cardUrls: refs.map((ref) => `${ref}.json`), + filter: { eq: { htmlQuery: { eq: { format: 'fitted' } } } }, + }; + } + + // Results come back in engine order, not linked order, so each tile finds + // its own entry by reference URL (`entry.id` is the extensionless card id). + entryFor = ( + index: number, + entries: RenderableSearchEntryLike[], + ): RenderableSearchEntryLike | undefined => { + let ref = this.linkedRefs[index]; + return ref ? entries.find((entry) => entry.id === ref) : undefined; + }; + + // Empty string (a `not-set` slot) is falsy, so the template's `{{#if ref}}` + // guard skips the placeholder for slots with nothing to point at — glint + // doesn't narrow in templates, so the fallback keeps the type `string`. + refAt = (index: number): string => this.linkedRefs[index] ?? ''; + + // Terminal failures (error / not-found) per cards slot, index-aligned with + // tilePlacements. Since the board never loads its links, membership + // normally reports `not-loaded`; broken kinds surface here when the links + // were loaded elsewhere (e.g. the edit format), bringing the real errorDoc + // with them. Slots whose entry never arrives fall back to the synthesized + // not-found placeholder below. + brokenSlotAt = (index: number) => { + let owner = this.args.model as unknown as PosterBoard | undefined; + if (!owner) { + return undefined; + } + let { membership } = getRelationshipMembershipState(owner, 'cards'); + let rel = (membership ?? [])[index]; + return rel && (rel.kind === 'error' || rel.kind === 'not-found') + ? rel + : undefined; + }; + + // A card can lack an index entry entirely (deleted target, unsaved link, a + // reference outside the searched realms) — the wire document simply omits + // it, leaving no errorDoc to thread through. + missingEntryErrorDoc = { + status: 404, + title: 'Not Found', + message: 'This card has no entry in the search index', + }; + + tileStyle = (tile: TilePlacement) => + htmlSafe(`left: ${tile.x}px; top: ${tile.y}px;`); + // ── Wheel ────────────────────────────────────────────── handleWheel = (event: Event) => { @@ -63,7 +217,10 @@ class Isolated extends Component { return; } const target = event.target as HTMLElement; - if (target.closest('[data-poster-board-hud]')) { + // Pointers that start on the HUD or inside a card tile are not pans: + // capturing them would break the tile's own focus/selection behavior + // (and tile pointerdown becomes drag-to-move in step 3) + if (target.closest('[data-poster-board-hud], [data-poster-board-tile]')) { return; } this.panSession = this.surfaceRig.startPan(event.clientX, event.clientY); @@ -156,9 +313,15 @@ class Isolated extends Component { handleInserted = (el: HTMLElement) => { this.rootElement = el; + this.keydownHandler = this.handleKeyDown; + window.addEventListener('keydown', this.keydownHandler); }; willDestroy(): void { + if (this.keydownHandler) { + window.removeEventListener('keydown', this.keydownHandler); + this.keydownHandler = null; + } this.surfaceRig.destroy(); super.willDestroy(); } @@ -168,9 +331,6 @@ class Isolated extends Component {
{ {{on 'pointerup' this.handlePointerUp}} {{on 'pointercancel' this.handlePointerUp}} {{on 'keydown' this.handleKeyDown}} + role='application' + aria-label='Poster board canvas' + tabindex='0' data-test-poster-board >
-
-

<@fields.cardTitle />

-

Scroll or drag to pan · Pinch or - Shift + / Shift - to zoom

-
+ {{#let (component @context.searchResultsComponent) as |SearchResults|}} + {{! Overlays default on: each tile registers with the operator-mode + overlay layer, giving it the standard hover chrome (type chip, + options menu, selection, click-to-open) anchored to the tile. }} + + {{#each this.tilePlacements key='index' as |tile|}} + + {{#let (this.brokenSlotAt tile.index) as |broken|}} + {{#if broken}} + + {{else}} + {{#let + (this.entryFor tile.index results.entries) + as |entry| + }} + {{#if entry}} + + {{else}} + {{#unless results.isLoading}} + {{#let (this.refAt tile.index) as |ref|}} + {{#if ref}} + + {{/if}} + {{/let}} + {{/unless}} + {{/if}} + {{/let}} + {{/if}} + {{/let}} + + {{/each}} + + {{/let}} + {{#unless this.hasCards}} +
+

<@fields.cardTitle />

+

Scroll or drag to pan · Pinch or + Shift + / Shift - to zoom

+
+ {{/unless}}
{ will-change: transform; } + .poster-board-tile { + position: absolute; + } + + /* Tile content is display-only — the overlay layer owns hover/click. + The overlay binds its listeners to the card's root element, so that + element must keep receiving pointer events; only its descendants opt + out, so links/buttons in the card never intercept a click and text + never starts a selection. `user-select` resolves through the parent, + so setting it once on the root covers the subtree. */ + .poster-board-tile-card { + user-select: none; + } + + .poster-board-tile-card :deep(*) { + pointer-events: none; + } + .poster-board-grid { position: absolute; inset: calc(var(--pb-grid-extent) / -2); @@ -361,5 +595,8 @@ export class PosterBoard extends CardDef { static icon = LayoutDashboardIcon; static prefersWideFormat = true; + @field cards = linksToMany(() => CardDef); + @field frameSettings = containsMany(FrameSettingsField); + static isolated = Isolated; } diff --git a/packages/experiments-realm/poster-board/poster-board.test.gts b/packages/experiments-realm/poster-board/poster-board.test.gts index 35def2b7f06..8ec56c1625c 100644 --- a/packages/experiments-realm/poster-board/poster-board.test.gts +++ b/packages/experiments-realm/poster-board/poster-board.test.gts @@ -1,19 +1,127 @@ -import { click, triggerEvent } from '@ember/test-helpers'; +import type { TOC } from '@ember/component/template-only'; +import type Owner from '@ember/owner'; +import { click, render, triggerEvent } from '@ember/test-helpers'; + +import GlimmerComponent from '@glimmer/component'; import { getService } from '@universal-ember/test-support'; +import { provide } from 'ember-provide-consume-context'; + import { module, test } from 'qunit'; -import { setupBaseRealm } from '@cardstack/host/tests/helpers/base-realm'; -import { renderCard } from '@cardstack/host/tests/helpers/render-component'; +import { + CardContextName, + type Loader, + type RenderableSearchEntryLike, + type SearchEntryWireQuery, + type SearchResultsComponentSignature, +} from '@cardstack/runtime-common'; + +import { saveCard, testRealmURL } from '@cardstack/host/tests/helpers'; +import { + CardDef, + setupBaseRealm, +} from '@cardstack/host/tests/helpers/base-realm'; import { setupRenderingTest } from '@cardstack/host/tests/helpers/setup'; -import { PosterBoard } from './poster-board'; +import { FrameSettingsField, PosterBoard } from './poster-board'; + +// Host services register themselves in this registry from their own app files, +// which this package's TS project never loads. Declare the one service these +// tests touch, structurally, with just the member they use — `Loader` is the +// real type, so downstream `saveCard` / `loader.import` calls check for real. +declare module '@ember/service' { + interface Registry { + 'loader-service': { loader: Loader }; + } +} + +// The board renders its tiles through `@context.searchResultsComponent`, which +// only the host app provides (operator mode / index / prerender routes). The +// stub below stands in for it: it captures each query the board issues and +// yields a test-controlled entry set, so tile mapping and fallbacks are +// exercised deterministically without a live prerender index. +let stubEntries: RenderableSearchEntryLike[] = []; +let capturedQueries: (SearchEntryWireQuery | undefined)[] = []; -async function renderPosterBoard() { +function stubEntry(id: string): RenderableSearchEntryLike { + // The board stamps its content class through `...attributes`, the same way + // it lands on real prerendered HTML's root element. + let component: TOC<{ Element: Element }> = ; + return { + id, + type: 'card', + realmUrl: testRealmURL, + name: id.replace(testRealmURL, ''), + isError: false, + component, + }; +} + +class StubSearchResults extends GlimmerComponent { + constructor(owner: Owner, args: SearchResultsComponentSignature['Args']) { + super(owner, args); + capturedQueries.push(args.query); + } + + get results() { + return { + entries: stubEntries, + isLoading: false, + meta: { page: { total: stubEntries.length } }, + errors: undefined, + }; + } + + +} + +class TestCardContextProvider extends GlimmerComponent<{ + Blocks: { default: [] }; +}> { + @provide(CardContextName) + get cardContext() { + return { searchResultsComponent: StubSearchResults }; + } + + +} + +async function renderPosterBoard(board?: PosterBoard) { + let loader = getService('loader-service').loader; + let api = await loader.import< + typeof import('https://cardstack.com/base/card-api') + >('https://cardstack.com/base/card-api'); + let card = board ?? new PosterBoard({}); + let Board = api.getComponent(card); + await render( + , + ); +} + +async function makeSavedNotes() { let loader = getService('loader-service').loader; - let card = new PosterBoard({}); - await renderCard(loader, card, 'isolated'); + class Note extends CardDef { + static displayName = 'Note'; + } + loader.shimModule(`${testRealmURL}note`, { Note }); + let note1 = new Note(); + let note2 = new Note(); + await saveCard(note1, `${testRealmURL}Note/1`, loader); + await saveCard(note2, `${testRealmURL}Note/2`, loader); + return { note1, note2 }; } export function runTests() { @@ -21,6 +129,11 @@ export function runTests() { setupRenderingTest(hooks); setupBaseRealm(hooks); + hooks.beforeEach(function () { + stubEntries = []; + capturedQueries = []; + }); + test('poster-board renders its zoom toolbar and the controls zoom, reset, and fit', async function (assert) { await renderPosterBoard(); @@ -106,6 +219,97 @@ export function runTests() { assert.dom('[data-test-zoom-level]').hasText('83%', 'Shift+- zooms out'); }); + test('poster-board renders prerendered entries at persisted and grid-default positions, mapped by reference', async function (assert) { + let { note1, note2 } = await makeSavedNotes(); + + // Reversed relative to the linked order: the board must map each tile + // to its entry by reference, never by result position + stubEntries = [stubEntry(note2.id), stubEntry(note1.id)]; + + let board = new PosterBoard({ + cards: [note1, note2], + frameSettings: [ + new FrameSettingsField({ cardIndex: 1, x: 500, y: 120 }), + ], + }); + await renderPosterBoard(board); + + assert + .dom('[data-test-poster-board-tile]') + .exists({ count: 2 }, 'a tile renders per linked card'); + assert + .dom('[data-test-poster-board-tile="0"]') + .hasStyle( + { left: '10px', top: '10px' }, + 'card without settings lands at its padded grid-default slot', + ); + assert + .dom('[data-test-poster-board-tile="1"]') + .hasStyle( + { left: '500px', top: '120px' }, + 'card with frame settings renders at its persisted position', + ); + assert + .dom( + `[data-test-poster-board-tile="0"] [data-test-stub-entry="${note1.id}"]`, + ) + .exists('first tile shows the first linked card despite result order'); + assert + .dom( + `[data-test-poster-board-tile="1"] [data-test-stub-entry="${note2.id}"]`, + ) + .exists( + 'second tile shows the second linked card despite result order', + ); + assert + .dom('[data-test-poster-board] h1') + .doesNotExist('hint header is hidden when the board has cards'); + + await triggerEvent('[data-test-poster-board-tile="0"]', 'pointerdown', { + button: 0, + pointerId: 7, + clientX: 20, + clientY: 20, + }); + assert + .dom('[data-test-poster-board]') + .hasStyle( + { cursor: 'grab' }, + 'pointerdown on a card tile does not start a board pan', + ); + }); + + test("poster-board queries prerendered fitted html by the linked cards' URLs", async function (assert) { + await renderPosterBoard(); + assert.deepEqual( + capturedQueries, + [undefined], + 'a board with no cards issues no query', + ); + + let { note1, note2 } = await makeSavedNotes(); + capturedQueries = []; + stubEntries = [stubEntry(note1.id), stubEntry(note2.id)]; + await renderPosterBoard(new PosterBoard({ cards: [note1, note2] })); + + let query = capturedQueries[capturedQueries.length - 1]; + assert.deepEqual( + query?.cardUrls, + [`${note1.id}.json`, `${note2.id}.json`], + 'cards are addressed by their .json file URLs', + ); + assert.strictEqual( + query?.scope, + 'cards', + 'the card scope drops dual-indexed file rows', + ); + assert.deepEqual( + query?.filter, + { eq: { htmlQuery: { eq: { format: 'fitted' } } } }, + 'the fitted rendering is bound through htmlQuery', + ); + }); + test('poster-board zoom reset is not undone by pending pinch momentum', async function (assert) { await renderPosterBoard(); diff --git a/packages/experiments-realm/tsconfig.json b/packages/experiments-realm/tsconfig.json index 5479a2132fe..21c8b040400 100644 --- a/packages/experiments-realm/tsconfig.json +++ b/packages/experiments-realm/tsconfig.json @@ -34,9 +34,16 @@ "../base/*.d.ts", "../base/*" ], - "@cardstack/host/tests/*": ["../host/tests/*"] + "@cardstack/host/tests/*": ["../host/tests/*"], + "ember-provide-consume-context": [ + "../base/node_modules/ember-provide-consume-context/declarations/index.d.ts" + ] }, "types": ["@cardstack/local-types"] }, - "include": ["**/*.ts", "**/*.gts"] + "include": [ + "**/*.ts", + "**/*.gts", + "../host/node_modules/qunit-dom/dist/es/install.d.ts" + ] } From 4cce5d2f0c1b882335f2f3ed987af7733f7480ee Mon Sep 17 00:00:00 2001 From: Burcu Noyan Date: Wed, 12 Aug 2026 10:58:43 -0400 Subject: [PATCH 2/5] Scope keyboard shortcuts to the board element Co-Authored-By: Claude Fable 5 --- packages/experiments-realm/poster-board/poster-board.gts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/experiments-realm/poster-board/poster-board.gts b/packages/experiments-realm/poster-board/poster-board.gts index caab0bd8ba9..15396210116 100644 --- a/packages/experiments-realm/poster-board/poster-board.gts +++ b/packages/experiments-realm/poster-board/poster-board.gts @@ -83,7 +83,6 @@ class Isolated extends Component { private panSession: PanSession | null = null; private activePointerId: number | null = null; private rootElement: HTMLElement | null = null; - private keydownHandler: ((e: KeyboardEvent) => void) | null = null; get zoomLabel() { return Math.round(this.rig.magnify * 100) + '%'; @@ -313,15 +312,9 @@ class Isolated extends Component { handleInserted = (el: HTMLElement) => { this.rootElement = el; - this.keydownHandler = this.handleKeyDown; - window.addEventListener('keydown', this.keydownHandler); }; willDestroy(): void { - if (this.keydownHandler) { - window.removeEventListener('keydown', this.keydownHandler); - this.keydownHandler = null; - } this.surfaceRig.destroy(); super.willDestroy(); } From b9afbdd6334025c2106c981bc4a424d9b1caddf0 Mon Sep 17 00:00:00 2001 From: Burcu Noyan Date: Wed, 12 Aug 2026 11:55:10 -0400 Subject: [PATCH 3/5] update sample card --- .../PosterBoard/demo-poster-board.json | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/experiments-realm/poster-board/PosterBoard/demo-poster-board.json b/packages/experiments-realm/poster-board/PosterBoard/demo-poster-board.json index 47404b0c2db..0490b625048 100644 --- a/packages/experiments-realm/poster-board/PosterBoard/demo-poster-board.json +++ b/packages/experiments-realm/poster-board/PosterBoard/demo-poster-board.json @@ -31,6 +31,36 @@ "links": { "self": "../../Contact/a01fc5c9-d70d-4b9c-aae4-384cf2b79b25" } + }, + "cards.3": { + "links": { + "self": "@cardstack/base/Theme/boxel-brand-guide" + } + }, + "cards.4": { + "links": { + "self": "@cardstack/base/Theme/cardstack-brand-guide" + } + }, + "cards.5": { + "links": { + "self": "../../llm-model-environment/Model/bd5a43c0-0681-476d-af80-be4ad46d908c" + } + }, + "cards.6": { + "links": { + "self": "../../Country/argentina" + } + }, + "cards.7": { + "links": { + "self": "../../llm-model-environment/Environment/3f6cde92-99b2-4bb5-a471-20d907cb682b" + } + }, + "cards.8": { + "links": { + "self": "../../carving-turn-entry" + } } } } From 685dc5188d8ed7a26732ddf27023e3002e9e488c Mon Sep 17 00:00:00 2001 From: Burcu Noyan Date: Wed, 12 Aug 2026 11:58:39 -0400 Subject: [PATCH 4/5] fix base import link --- packages/experiments-realm/poster-board/poster-board.gts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/experiments-realm/poster-board/poster-board.gts b/packages/experiments-realm/poster-board/poster-board.gts index 15396210116..95d9c595265 100644 --- a/packages/experiments-realm/poster-board/poster-board.gts +++ b/packages/experiments-realm/poster-board/poster-board.gts @@ -7,8 +7,8 @@ import { containsMany, getRelationshipMembershipState, linksToMany, -} from 'https://cardstack.com/base/card-api'; -import NumberField from 'https://cardstack.com/base/number'; +} from '@cardstack/base/card-api'; +import NumberField from '@cardstack/base/number'; import { tracked } from '@glimmer/tracking'; import { htmlSafe } from '@ember/template'; import { on } from '@ember/modifier'; From 67373cf222517de84175fe8fb3ab0c7504312a5c Mon Sep 17 00:00:00 2001 From: Burcu Noyan Date: Wed, 12 Aug 2026 12:53:20 -0400 Subject: [PATCH 5/5] Fix poster-board test types under both TS projects Co-Authored-By: Claude Fable 5 --- .../poster-board/poster-board.test.gts | 65 +++++++------------ packages/experiments-realm/tsconfig.json | 5 +- 2 files changed, 26 insertions(+), 44 deletions(-) diff --git a/packages/experiments-realm/poster-board/poster-board.test.gts b/packages/experiments-realm/poster-board/poster-board.test.gts index 8ec56c1625c..8f1522b43a6 100644 --- a/packages/experiments-realm/poster-board/poster-board.test.gts +++ b/packages/experiments-realm/poster-board/poster-board.test.gts @@ -1,13 +1,11 @@ import type { TOC } from '@ember/component/template-only'; import type Owner from '@ember/owner'; -import { click, render, triggerEvent } from '@ember/test-helpers'; +import { click, triggerEvent } from '@ember/test-helpers'; import GlimmerComponent from '@glimmer/component'; import { getService } from '@universal-ember/test-support'; -import { provide } from 'ember-provide-consume-context'; - import { module, test } from 'qunit'; import { @@ -18,23 +16,30 @@ import { type SearchResultsComponentSignature, } from '@cardstack/runtime-common'; -import { saveCard, testRealmURL } from '@cardstack/host/tests/helpers'; +import { + provideConsumeContext, + saveCard, + testRealmURL, +} from '@cardstack/host/tests/helpers'; import { CardDef, setupBaseRealm, } from '@cardstack/host/tests/helpers/base-realm'; +import { renderCard } from '@cardstack/host/tests/helpers/render-component'; import { setupRenderingTest } from '@cardstack/host/tests/helpers/setup'; import { FrameSettingsField, PosterBoard } from './poster-board'; -// Host services register themselves in this registry from their own app files, -// which this package's TS project never loads. Declare the one service these -// tests touch, structurally, with just the member they use — `Loader` is the -// real type, so downstream `saveCard` / `loader.import` calls check for real. -declare module '@ember/service' { - interface Registry { - 'loader-service': { loader: Loader }; - } +import type { CardContext } from 'https://cardstack.com/base/card-api'; + +// This file is type-checked by two projects: experiments-realm's (which never +// loads host's service-registry augmentations, so `getService` returns a bare +// object) and host's (which loads the real `LoaderService` registry entry). A +// registry augmentation here would conflict with host's, so cast structurally +// to just the member these tests use — `Loader` is the real type, so +// downstream `saveCard` / `loader.import` calls check for real. +function loaderService(): { loader: Loader } { + return getService('loader-service') as unknown as { loader: Loader }; } // The board renders its tiles through `@context.searchResultsComponent`, which @@ -81,38 +86,13 @@ class StubSearchResults extends GlimmerComponent{{yield this.results}} } -class TestCardContextProvider extends GlimmerComponent<{ - Blocks: { default: [] }; -}> { - @provide(CardContextName) - get cardContext() { - return { searchResultsComponent: StubSearchResults }; - } - - -} - async function renderPosterBoard(board?: PosterBoard) { - let loader = getService('loader-service').loader; - let api = await loader.import< - typeof import('https://cardstack.com/base/card-api') - >('https://cardstack.com/base/card-api'); - let card = board ?? new PosterBoard({}); - let Board = api.getComponent(card); - await render( - , - ); + let loader = loaderService().loader; + await renderCard(loader, board ?? new PosterBoard({}), 'isolated'); } async function makeSavedNotes() { - let loader = getService('loader-service').loader; + let loader = loaderService().loader; class Note extends CardDef { static displayName = 'Note'; } @@ -132,6 +112,11 @@ export function runTests() { hooks.beforeEach(function () { stubEntries = []; capturedQueries = []; + // Partial by design: CardContextConsumer spreads defaults over what the + // provider supplies, so only the member the board reads is stubbed. + provideConsumeContext(CardContextName, { + searchResultsComponent: StubSearchResults, + } as unknown as CardContext); }); test('poster-board renders its zoom toolbar and the controls zoom, reset, and fit', async function (assert) { diff --git a/packages/experiments-realm/tsconfig.json b/packages/experiments-realm/tsconfig.json index 21c8b040400..bfff7a2b892 100644 --- a/packages/experiments-realm/tsconfig.json +++ b/packages/experiments-realm/tsconfig.json @@ -34,10 +34,7 @@ "../base/*.d.ts", "../base/*" ], - "@cardstack/host/tests/*": ["../host/tests/*"], - "ember-provide-consume-context": [ - "../base/node_modules/ember-provide-consume-context/declarations/index.d.ts" - ] + "@cardstack/host/tests/*": ["../host/tests/*"] }, "types": ["@cardstack/local-types"] },