From ee635daea8e81027dd3fbe18115f282ec64b967e Mon Sep 17 00:00:00 2001 From: Burcu Noyan Date: Fri, 17 Jul 2026 16:23:39 -0400 Subject: [PATCH 1/5] PosterBoard step 2: render linked cards at grid positions (read-only) Adds the v1 schema (cards linksToMany, FrameSettingsField with cardIndex/x/y) and renders each linked card as a fitted 280x364 tile positioned by its frame setting, falling back to a 4-column grid layout. The hint header now shows only on an empty board. Co-Authored-By: Claude Fable 5 --- poster-board/poster-board.gts | 107 +++++++++++++++++++++++++++-- poster-board/poster-board.test.gts | 49 ++++++++++++- 2 files changed, 148 insertions(+), 8 deletions(-) diff --git a/poster-board/poster-board.gts b/poster-board/poster-board.gts index 587c07e..439ee23 100644 --- a/poster-board/poster-board.gts +++ b/poster-board/poster-board.gts @@ -1,11 +1,51 @@ -import { CardDef, Component } from 'https://cardstack.com/base/card-api'; +import { + CardDef, + Component, + FieldDef, + field, + contains, + containsMany, + 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 { get } from '@ember/helper'; import { on } from '@ember/modifier'; import Modifier from 'ember-modifier'; import LayoutDashboardIcon from '@cardstack/boxel-icons/layout-dashboard'; import { RigState, SurfaceRig, type PanSession } from './rig'; +// Tile geometry in world-space pixels. The CSS tile size (--pb-tile-width / +// --pb-tile-height) must stay in sync: 280px = 17.5rem, 364px = 22.75rem. +const TILE_WIDTH = 280; +const TILE_HEIGHT = 364; +const TILE_GAP = 32; +const GRID_COLUMNS = 4; + +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: (index % GRID_COLUMNS) * (TILE_WIDTH + TILE_GAP), + y: 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: { @@ -43,6 +83,30 @@ class Isolated extends Component { return htmlSafe(`cursor: ${this.isPanning ? 'grabbing' : 'grab'};`); } + // ── Tile placement ───────────────────────────────────── + + get tilePlacements(): TilePlacement[] { + let cards = this.args.model?.cards ?? []; + let settings = this.args.model?.frameSettings ?? []; + return cards.map((_card, 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; + } + + tileStyle = (tile: TilePlacement) => + htmlSafe(`left: ${tile.x}px; top: ${tile.y}px;`); + // ── Wheel ────────────────────────────────────────────── handleWheel = (event: Event) => { @@ -159,11 +223,24 @@ class Isolated extends Component { >
-
-

<@fields.cardTitle />

-

Scroll to pan · ⌘ or Ctrl + scroll - to zoom · Drag to pan

-
+ {{#each this.tilePlacements key='index' as |tile|}} +
+ {{#let (get @fields.cards tile.index) as |LinkedCard|}} + + {{/let}} +
+ {{/each}} + {{#unless this.hasCards}} +
+

<@fields.cardTitle />

+

Scroll to pan · ⌘ or Ctrl + scroll + to zoom · Drag to pan

+
+ {{/unless}}
{ --pb-hud-zoom-min-width: 2.125rem; --pb-hud-border-radius: 0.5rem; --pb-hud-btn-border-radius: 0.3125rem; + --pb-tile-width: 17.5rem; + --pb-tile-height: 22.75rem; position: relative; width: 100%; height: 100%; @@ -229,6 +308,19 @@ class Isolated extends Component { will-change: transform; } + .poster-board-tile { + position: absolute; + width: var(--pb-tile-width); + height: var(--pb-tile-height); + container-name: fitted-card; + container-type: size; + } + + .poster-board-tile > :first-child { + width: 100%; + height: 100%; + } + .poster-board-grid { position: absolute; inset: calc(var(--pb-grid-extent) / -2); @@ -329,5 +421,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/poster-board/poster-board.test.gts b/poster-board/poster-board.test.gts index d3e2220..d7947b7 100644 --- a/poster-board/poster-board.test.gts +++ b/poster-board/poster-board.test.gts @@ -4,11 +4,15 @@ import { getService } from '@universal-ember/test-support'; import { module, test } from 'qunit'; -import { setupBaseRealm } from '@cardstack/host/tests/helpers/base-realm'; +import { 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 { PosterBoard } from './poster-board'; +import { FrameSettingsField, PosterBoard } from './poster-board'; async function renderPosterBoard() { let loader = getService('loader-service').loader; @@ -84,5 +88,46 @@ export function runTests() { .dom('[data-test-zoom-level]') .hasText('100%', 'zoom stays at 100% after momentum delay elapses'); }); + + test('poster-board renders linked cards at persisted and grid-default positions', async function (assert) { + let loader = getService('loader-service').loader; + + 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); + + let board = new PosterBoard({ + cards: [note1, note2], + frameSettings: [ + new FrameSettingsField({ cardIndex: 1, x: 500, y: 120 }), + ], + }); + await renderCard(loader, board, 'isolated'); + + 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: '0px', top: '0px' }, + 'card without settings lands at its 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] h1') + .doesNotExist('hint header is hidden when the board has cards'); + }); }); } From cb9dca30441ca01636fd3d7102e92a51288d0caf Mon Sep 17 00:00:00 2001 From: Burcu Noyan Date: Fri, 17 Jul 2026 17:57:24 -0400 Subject: [PATCH 2/5] Use the cardsgrid-tile fitted size for board tiles Tile dimensions now come from FITTED_FORMATS (cardsgrid-tile, 170x250) via fittedFormatById, feeding both the placement math and the --pb-tile-* CSS vars from one source instead of an invented 280x364. Co-Authored-By: Claude Fable 5 --- poster-board/poster-board.gts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/poster-board/poster-board.gts b/poster-board/poster-board.gts index 439ee23..27d77fc 100644 --- a/poster-board/poster-board.gts +++ b/poster-board/poster-board.gts @@ -13,13 +13,16 @@ import { htmlSafe } from '@ember/template'; import { get } from '@ember/helper'; import { on } from '@ember/modifier'; import Modifier from 'ember-modifier'; +import { fittedFormatById } from '@cardstack/boxel-ui/helpers'; import LayoutDashboardIcon from '@cardstack/boxel-icons/layout-dashboard'; import { RigState, SurfaceRig, type PanSession } from './rig'; -// Tile geometry in world-space pixels. The CSS tile size (--pb-tile-width / -// --pb-tile-height) must stay in sync: 280px = 17.5rem, 364px = 22.75rem. -const TILE_WIDTH = 280; -const TILE_HEIGHT = 364; +// Tiles use the shared cardsgrid-tile fitted size so boards show cards at a +// size their fitted views are designed for. The value feeds both the +// placement math and the --pb-tile-* CSS vars (via rootStyle). +const cardsgridTile = fittedFormatById.get('cardsgrid-tile')!; +const TILE_WIDTH = cardsgridTile.width; +const TILE_HEIGHT = cardsgridTile.height; const TILE_GAP = 32; const GRID_COLUMNS = 4; @@ -80,7 +83,9 @@ class Isolated extends Component { } get rootStyle() { - return htmlSafe(`cursor: ${this.isPanning ? 'grabbing' : 'grab'};`); + return htmlSafe( + `cursor: ${this.isPanning ? 'grabbing' : 'grab'}; --pb-tile-width: ${TILE_WIDTH}px; --pb-tile-height: ${TILE_HEIGHT}px;`, + ); } // ── Tile placement ───────────────────────────────────── @@ -294,8 +299,8 @@ class Isolated extends Component { --pb-hud-zoom-min-width: 2.125rem; --pb-hud-border-radius: 0.5rem; --pb-hud-btn-border-radius: 0.3125rem; - --pb-tile-width: 17.5rem; - --pb-tile-height: 22.75rem; + /* --pb-tile-width / --pb-tile-height come from rootStyle, derived + from the cardsgrid-tile entry in FITTED_FORMATS */ position: relative; width: 100%; height: 100%; From 761e35b7260a477a310b34a34cd820dd12e8cbae Mon Sep 17 00:00:00 2001 From: Burcu Noyan Date: Fri, 17 Jul 2026 18:01:46 -0400 Subject: [PATCH 3/5] Wrap board tiles in FittedCardContainer The boxel-ui container applies the cardsgrid-tile dimensions from FITTED_FORMATS itself, so the tile CSS no longer sets width/height and the --pb-tile-* vars are gone; the placement math still derives its constants from the same spec. Co-Authored-By: Claude Fable 5 --- poster-board/poster-board.gts | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/poster-board/poster-board.gts b/poster-board/poster-board.gts index 27d77fc..1e271cb 100644 --- a/poster-board/poster-board.gts +++ b/poster-board/poster-board.gts @@ -13,13 +13,14 @@ import { htmlSafe } from '@ember/template'; import { get } from '@ember/helper'; import { on } from '@ember/modifier'; import Modifier from 'ember-modifier'; +import { 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. The value feeds both the -// placement math and the --pb-tile-* CSS vars (via rootStyle). +// 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; @@ -83,9 +84,7 @@ class Isolated extends Component { } get rootStyle() { - return htmlSafe( - `cursor: ${this.isPanning ? 'grabbing' : 'grab'}; --pb-tile-width: ${TILE_WIDTH}px; --pb-tile-height: ${TILE_HEIGHT}px;`, - ); + return htmlSafe(`cursor: ${this.isPanning ? 'grabbing' : 'grab'};`); } // ── Tile placement ───────────────────────────────────── @@ -229,15 +228,16 @@ class Isolated extends Component {
{{#each this.tilePlacements key='index' as |tile|}} -
{{#let (get @fields.cards tile.index) as |LinkedCard|}} {{/let}} -
+ {{/each}} {{#unless this.hasCards}}
@@ -299,8 +299,6 @@ class Isolated extends Component { --pb-hud-zoom-min-width: 2.125rem; --pb-hud-border-radius: 0.5rem; --pb-hud-btn-border-radius: 0.3125rem; - /* --pb-tile-width / --pb-tile-height come from rootStyle, derived - from the cardsgrid-tile entry in FITTED_FORMATS */ position: relative; width: 100%; height: 100%; @@ -315,8 +313,6 @@ class Isolated extends Component { .poster-board-tile { position: absolute; - width: var(--pb-tile-width); - height: var(--pb-tile-height); container-name: fitted-card; container-type: size; } From f6f1468e42bc31e73cdc34c4165ca540e5fbb299 Mon Sep 17 00:00:00 2001 From: Burcu Noyan Date: Fri, 17 Jul 2026 18:28:53 -0400 Subject: [PATCH 4/5] Simplify tile wrapper styles and pad the default grid FittedCardContainer tiles no longer re-declare the fitted-card container context or force child sizing (the fitted rendering and CardContainer already provide both), and cards without frame settings now start 10px off the world origin instead of flush against it. Co-Authored-By: Claude Fable 5 --- poster-board/poster-board.gts | 15 ++++++--------- poster-board/poster-board.test.gts | 4 ++-- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/poster-board/poster-board.gts b/poster-board/poster-board.gts index 1e271cb..7eedd8d 100644 --- a/poster-board/poster-board.gts +++ b/poster-board/poster-board.gts @@ -26,6 +26,8 @@ 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; @@ -37,8 +39,10 @@ interface TilePlacement { function defaultPlacement(index: number): TilePlacement { return { index, - x: (index % GRID_COLUMNS) * (TILE_WIDTH + TILE_GAP), - y: Math.floor(index / GRID_COLUMNS) * (TILE_HEIGHT + TILE_GAP), + x: GRID_PADDING + (index % GRID_COLUMNS) * (TILE_WIDTH + TILE_GAP), + y: + GRID_PADDING + + Math.floor(index / GRID_COLUMNS) * (TILE_HEIGHT + TILE_GAP), }; } @@ -313,13 +317,6 @@ class Isolated extends Component { .poster-board-tile { position: absolute; - container-name: fitted-card; - container-type: size; - } - - .poster-board-tile > :first-child { - width: 100%; - height: 100%; } .poster-board-grid { diff --git a/poster-board/poster-board.test.gts b/poster-board/poster-board.test.gts index d7947b7..d895afe 100644 --- a/poster-board/poster-board.test.gts +++ b/poster-board/poster-board.test.gts @@ -116,8 +116,8 @@ export function runTests() { assert .dom('[data-test-poster-board-tile="0"]') .hasStyle( - { left: '0px', top: '0px' }, - 'card without settings lands at its grid-default slot', + { left: '10px', top: '10px' }, + 'card without settings lands at its padded grid-default slot', ); assert .dom('[data-test-poster-board-tile="1"]') From 82593e5f88bf4909c54297cac04f1e22bc7ee942 Mon Sep 17 00:00:00 2001 From: Burcu Noyan Date: Fri, 17 Jul 2026 18:38:30 -0400 Subject: [PATCH 5/5] Link demo cards with creative fitted views to the demo board MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wine bottles, a tier list, Wordle, and the blackjack table — cards whose fitted views don't traverse their own relations (CS-12220 tracks board tolerance for tiles that do). Two tiles use explicit frame settings, the rest flow into the default grid. Co-Authored-By: Claude Fable 5 --- .../PosterBoard/demo-poster-board.json | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/poster-board/PosterBoard/demo-poster-board.json b/poster-board/PosterBoard/demo-poster-board.json index 8c5e5c4..6048686 100644 --- a/poster-board/PosterBoard/demo-poster-board.json +++ b/poster-board/PosterBoard/demo-poster-board.json @@ -4,6 +4,45 @@ "attributes": { "cardInfo": { "name": "Demo Poster Board" + }, + "frameSettings": [ + { + "cardIndex": 3, + "x": 460, + "y": 340 + }, + { + "cardIndex": 4, + "x": 680, + "y": 90 + } + ] + }, + "relationships": { + "cards.0": { + "links": { + "self": "../../4b6602-wine-cellar-card-definition/WineBottle/011f0239-9a66-405f-a874-32bb4146ee7b" + } + }, + "cards.1": { + "links": { + "self": "../../d29736-tier-list/TierList/my-language-ranking" + } + }, + "cards.2": { + "links": { + "self": "../../blog-app/games/Wordle/play" + } + }, + "cards.3": { + "links": { + "self": "../../673fb6-blackjack-cardgame-definition/Blackjack/be0fd885-7672-4379-a001-ea8611da2ec2" + } + }, + "cards.4": { + "links": { + "self": "../../4b6602-wine-cellar-card-definition/WineBottle/7bc1c7b6-f051-4a45-aaba-8f41b62eed0d" + } } }, "meta": {