Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,65 @@
"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"
}
},
"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"
}
}
}
}
}
250 changes: 240 additions & 10 deletions packages/experiments-realm/poster-board/poster-board.gts
Original file line number Diff line number Diff line change
@@ -1,11 +1,67 @@
import { CardDef, Component } from 'https://cardstack.com/base/card-api';
import {
CardDef,
Component,
FieldDef,
field,
contains,
containsMany,
getRelationshipMembershipState,
linksToMany,
} 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';
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: {
Expand Down Expand Up @@ -43,6 +99,103 @@ class Isolated extends Component<typeof PosterBoard> {
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);
});
}
Comment on lines +117 to +129

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[Claude Code 🤖] Non-blocking (design note, relevant to step 3). Frame settings are matched to cards by array index (Number(s.cardIndex) === index, where index is the position in the cards linksToMany). That means removing or reordering a linked card silently reassigns every persisted position after it to a different card — the settings don't move with the card they were authored for.

Step 3 (drag-to-move) will start writing real positions into these records, so the index-keyed model becomes load-bearing exactly when a mismatch is most visible (a user drags card A, later removes an earlier card, and card A's saved position now belongs to card B). Worth considering keying each FrameSettingsField by the linked card's reference/id instead of its slot index, so a position tracks its card across insert/remove/reorder. Not this PR's blocker — flagging now because step 3 builds directly on this shape.


Generated by Claude Code


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;
};
Comment on lines +175 to +185

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[Claude Code 🤖] Non-blocking (cleanup). getRelationshipMembershipState(owner, 'cards') is recomputed here on every brokenSlotAt(index) call, on top of the call in linkedRefs (which tilePlacements, tilesQuery, entryFor, and refAt all read through). Each call re-runs getField + peekAtField and rebuilds a fresh membership array.

To be fair to the design: Glimmer's per-reference caching keeps this off the pan/zoom hot path — these getters don't read rig state, so a pan frame doesn't recompute them — so it isn't a per-frame cost. It's the same array rebuilt several times per membership change, and it splits "read the cards' membership" across two call sites that must stay in sync.

Consider one source of truth, e.g. @cached get slots() returning getRelationshipMembershipState(owner, 'cards').membership ?? [] once; then linkedRefs is this.slots.map((s) => s.reference), brokenSlotAt(i) is this.slots[i] (test its kind), and entryFor / refAt index the same array. Purely a readability/efficiency cleanup.


Generated by Claude Code


// 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) => {
Expand All @@ -63,7 +216,10 @@ class Isolated extends Component<typeof PosterBoard> {
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);
Expand Down Expand Up @@ -168,25 +324,78 @@ class Isolated extends Component<typeof PosterBoard> {
<div
class='poster-board-root'
style={{this.rootStyle}}
role='application'
aria-label='Poster board canvas'
tabindex='0'
{{OnInsert this.handleInserted}}
{{on 'wheel' this.handleWheel}}
{{on 'pointerdown' this.handlePointerDown}}
{{on 'pointermove' this.handlePointerMove}}
{{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
>
<div class='poster-board-plane' style={{this.planeStyle}}>
<div class='poster-board-grid' aria-hidden='true'></div>
<header class='poster-board-hint'>
<h1 class='poster-board-hint-title'><@fields.cardTitle /></h1>
<p class='poster-board-hint-line'>Scroll or drag to pan · Pinch or
Shift + / Shift - to zoom</p>
</header>
{{#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. }}
<SearchResults @query={{this.tilesQuery}} @mode='none' as |results|>
{{#each this.tilePlacements key='index' as |tile|}}
<FittedCardContainer
@size='cardsgrid-tile'
@style={{this.tileStyle tile}}
class='poster-board-tile'
data-poster-board-tile
data-test-poster-board-tile={{tile.index}}
>
{{#let (this.brokenSlotAt tile.index) as |broken|}}
{{#if broken}}
<BrokenLinkTemplate
@brokenUrl={{broken.reference}}
@errorDoc={{broken.errorDoc}}
@state={{broken.kind}}
@format='fitted'
data-test-poster-board-broken-tile={{tile.index}}
/>
{{else}}
{{#let
(this.entryFor tile.index results.entries)
as |entry|
}}
{{#if entry}}
<entry.component class='poster-board-tile-card' />
{{else}}
{{#unless results.isLoading}}
{{#let (this.refAt tile.index) as |ref|}}
{{#if ref}}
<BrokenLinkTemplate
@brokenUrl={{ref}}
@errorDoc={{this.missingEntryErrorDoc}}
@state='not-found'
@format='fitted'
data-test-poster-board-broken-tile={{tile.index}}
/>
{{/if}}
{{/let}}
{{/unless}}
{{/if}}
{{/let}}
{{/if}}
{{/let}}
</FittedCardContainer>
{{/each}}
</SearchResults>
{{/let}}
{{#unless this.hasCards}}
<header class='poster-board-hint'>
<h1 class='poster-board-hint-title'><@fields.cardTitle /></h1>
<p class='poster-board-hint-line'>Scroll or drag to pan · Pinch or
Shift + / Shift - to zoom</p>
</header>
{{/unless}}
</div>

<div
Expand Down Expand Up @@ -261,6 +470,24 @@ class Isolated extends Component<typeof PosterBoard> {
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);
Expand Down Expand Up @@ -361,5 +588,8 @@ export class PosterBoard extends CardDef {
static icon = LayoutDashboardIcon;
static prefersWideFormat = true;

@field cards = linksToMany(() => CardDef);
@field frameSettings = containsMany(FrameSettingsField);

static isolated = Isolated;
}
Loading