diff --git a/.agents/skills/boxel-development/references/dev-fitted-formats.md b/.agents/skills/boxel-development/references/dev-fitted-formats.md index bc30ff5f4f4..7eeeeb89be8 100644 --- a/.agents/skills/boxel-development/references/dev-fitted-formats.md +++ b/.agents/skills/boxel-development/references/dev-fitted-formats.md @@ -1,42 +1,152 @@ -## Fitted Format Essentials +# Fitted Format — Container-Query Standard -**Four sub-formats strategy:** +Distilled from the boxel-workspaces `container-query-fitted-layout.md` +standard (the full 1000-line guide with worked examples). Everything here +is CSS-only — no JS modifiers, no ResizeObserver. -- **Badge** (≤150px width, <170px height) - Exportable graphics -- **Strip** (>150px width, <170px height) - Dropdown/chooser panels -- **Tile** (<400px width, ≥170px height) - Grid viewing -- **Card** (≥400px width, ≥170px height) - Full layout +## The contract: the parent owns the cell size -**Container query skeleton:** +A fitted card never imposes its own size. The host wraps every fitted +template in `.field-component-card.fitted-format` with +`width: 100%; height: 100%; overflow: hidden; container-type: size; +container-name: fitted-card`. + +**Query the host's `fitted-card` container. NEVER create your own +container on the root.** (An older version of this reference prescribed a +local `container-type: size` wrapper — that is WRONG and superseded: a +container cannot be styled by its own queries, so grid-template/padding +switches on your root silently stop working.) + +The template root is a single `.fit` grid that fills the host wrapper: ```css -.fitted-container { - container-type: size; - width: 100%; +.fit { + width: 100%; /* the ONE sanctioned sizing declaration on the root */ height: 100%; + display: grid; } - -/* Hide all by default */ -.badge, -.strip, -.tile, -.card { - display: none; - padding: clamp(0.25rem, 2%, 0.5rem); +@container fitted-card (max-height: 80px) { + .fit { + grid-template-rows: auto; + } /* queries against the HOST container style your root fine */ } +``` + +Keep OFF the root: fixed/min/max dimensions, `border`, `border-radius`, +`box-shadow`, `container-type`, `container-name` — the host owns all of +them. Background/foreground pairing from the theme is fine +(`background-color: var(--card); color: var(--card-foreground)`). + +## Prefer `` for standard compositions + +For the standard composition — image/placeholder + eyebrow + title + +subtitle + meta + footer + badges — use the `FittedCard` component from +`@cardstack/boxel-ui/components` instead of hand-rolling. It implements +this whole standard internally (host-container queries, aspect-ratio +layout switching) and exposes `--fc-*` custom properties for tuning: + +```gts +import { FittedCard } from '@cardstack/boxel-ui/components'; + + + <:placeholder> + <:eyebrow>Non-fiction + <:title><@fields.cardTitle /> + <:subtitle><@fields.cardDescription /> + <:meta>150 mins + <:footer>320 pages2024 + +``` + +No-media card types: omit `@imageUrl`, `:image`, AND `:placeholder` — the +image column disappears and content switches to its no-image layout. +Tune with `--fc-*` variables and `@container fitted-card (...)` overrides +from your scoped CSS; don't fork the layout. + +**Hand-roll only special templates** (a dark terminal ticker, a ticket +stub, a boarding pass with an SVG flight path) — then follow the rest of +this reference. + +## Size classification — plan content per quantum BEFORE writing CSS + +Height quanta (what the host uses each size for): + +| Quantum | Range | Visible regions | Host usage | +| ------- | --------- | ---------------------------------- | ----------------------- | +| h40 | ≤50px | head only | badges, inline mentions | +| h65 | 50–80px | head + meta | chooser dropdowns | +| h105 | 80–130px | head + body + meta | strips, search results | +| h170 | 130–200px | head + body + [tags] + meta | tiles | +| h275 | 200–320px | hero + head + body + [tags] + meta | large tiles, cards | +| h445 | >320px | all, spacious | expanded cards | -/* Activate by size - NO GAPS! */ -@container (max-width: 150px) and (max-height: 169px) { - .badge { - display: flex; +Width classes: + +| Class | Range | Behavior | +| ------ | --------- | -------------------------------------------------------------- | +| narrow | ≤170px | hide tags, clamp meta to 1 line, hide subhead at small heights | +| medium | 170–260px | hide tags, clamp meta to 1 line | +| wide | >260px | show tags; horizontal thumbnails at h40/h65/h105 | + +Tags hide below 260px because wrapping pills consume unpredictable +height. Write the **content matrix first** — for each quantum, decide +which fields appear — then implement it as `@container fitted-card` +rules. A fitted view that only looks right at one size is a defect. + +```css +@container fitted-card (max-height: 50px) { + /* h40: head only */ +} +@container fitted-card (min-height: 50.1px) and (max-height: 80px) { + /* h65 */ +} +@container fitted-card (min-height: 80.1px) and (max-height: 130px) { + /* h105 */ +} +@container fitted-card (min-height: 130.1px) and (max-height: 200px) { + /* h170 */ +} +@container fitted-card (min-height: 200.1px) { + /* h275+: hero appears */ +} +@container fitted-card (max-width: 260px) { + .r-tags { + display: none; } } ``` -**Content priority:** +## Overflow discipline (every region, no exceptions) + +- Body/content rows: `minmax(0, 1fr)` — never `auto` (auto rows grow + with content and blow the cell). +- `overflow: hidden` on every region. +- Text clamps: `display: -webkit-box; -webkit-box-orient: vertical; +-webkit-line-clamp: N; overflow: hidden`. +- `min-height: 0` on any nested flex/grid child that must shrink. + +## Container-query units caveat + +`cqw`/`cqh`/`cqmin` only resolve against an actual container. Inside a +fitted template they resolve against the host's `fitted-card` container — +fine. On other surfaces (isolated/embedded) they silently fall back to +the VIEWPORT unless you establish a container on that surface with +`container-type: inline-size` first. + +## Parent-side note + +When a parent embeds fitted children (`<@fields.x @format='fitted' />`), +the parent sizes the cell (e.g. a grid of `160px × 180px` tiles) and may +need `@displayContainer={{false}}` to suppress double chrome — see +`dev-delegated-rendering.md`. + +## Media fields (cardinal rule, applies to ALL formats) -1. Title/Name -2. Image -3. Short ID -4. Key info -5. Status badges +An image on a card is `@field image = linksTo(() => ImageDef)` (or +FileDef) pointing at a real realm file — write the binary into the +workspace and reference it. NEVER design a field that stores +`data:`/base64/blob strings in JSON attributes, not even "just a +placeholder" — inline media bytes in instance JSON are a hard rule +violation that corrupts diffs, bloats the index, and breaks the file's +identity. No-media-yet instances leave the link empty and the template +renders a placeholder block/icon. diff --git a/.agents/skills/boxel-workspace-cardinal-rules/SKILL.md b/.agents/skills/boxel-workspace-cardinal-rules/SKILL.md new file mode 100644 index 00000000000..e1d1ed175d0 --- /dev/null +++ b/.agents/skills/boxel-workspace-cardinal-rules/SKILL.md @@ -0,0 +1,96 @@ +# Boxel cardinal rules — silent-failure traps + +Rules discovered the hard way in a downstream Boxel workspace: each one passes lint and +often passes indexing too, then breaks silently — corrupting the realm's index, +crashing at render, or dropping data with no error. Check every card/field you write +against this list before finishing an issue. + +## 1. DateField vs DateTimeField value format + +`DateField` values are `YYYY-MM-DD` (no `T`). `DateTimeField` values are full ISO +datetimes (`2026-07-16T14:30:00.000Z`, with `T`). Putting a datetime string in a +`DateField` (or vice versa) passes lint and indexes fine, then **crashes at render** +with `RangeError: Invalid time value` when a user actually opens the card. Naming +convention to follow when picking the field type: a `*At` suffix (`createdAt`, +`publishedAt`) means `DateTimeField`; a `*Date`/`*On` suffix or bare `dob` means +`DateField`. + +## 2. Never put an external URL in `relationships..links.self` + +If a `linksTo`/`linksToMany` field's JSON `links.self` points at a URL the indexer +can't parse as a card (an external website, an image CDN URL, anything not a card +resource), **the failed parse poisons the JSONB write and rolls back the WHOLE +REALM's indexing transaction** — every other file in the same push silently fails to +index too, with no error pointing at the actual bad file. For an external image/URL, +use the pair pattern instead: `linksTo(ImageDef)` (or a similar file/media field) + +`contains(UrlField)` as two separate fields, never one relationship pointing straight +at an external URL. + +## 3. `linksToMany` JSON uses indexed top-level keys, never an array + +Correct: + +```json +"relationships": { + "items.0": { "links": { "self": "../foo" } }, + "items.1": { "links": { "self": "../bar" } } +} +``` + +Wrong (rejected outright — "instance ... is not a card resource document"): + +```json +"relationships": { + "items": { "links": { "self": ["../foo", "../bar"] } } +} +``` + +## 4. Never inline media or binary bytes in card JSON + +No `data:` URIs, `blob:` URIs, base64, or raw media bytes in any JSON string field or +attribute. Store media as a realm file linked via `linksTo(FileDef)` (or a FileDef +subtype: `ImageDef`, `CsvFileDef`, etc.) instead — never embed the bytes directly in +the instance JSON. + +## 5. Every query needs a realm scope, and don't start it before the realm is known + +A card-owned query with a missing or empty `realm` argument silently falls back to +searching **every realm the server can see**, not just the current one. Always scope +queries to the current card's own realm, and don't kick off the query before that +realm URL is actually resolved. Cap general-purpose result sets (~100) rather than +pulling unbounded result sets. + +## 6. Query-filter shape: `type` selects instances, `on` only scopes predicates + +To select every instance of a type, filter on `{ type: }` directly — never wrap +it in `{ on: }` alone (`on` only scopes _other_ predicates like `eq`/`contains`; +a bare `{ on: ref }` with nothing else matches nothing). Build refs with a `codeRef()` +helper, not a manually-constructed object. + +## 7. Don't push more than ~30 files through one atomic batch to a fresh realm + +Large atomic pushes (30+ files via a single bulk-write endpoint) can report success +while silently dropping some files' indexing jobs. For bulk kit/asset installs, push +in smaller batches and verify each batch's expected file count actually shows up in a +realm search before pushing the next batch. + +## 8. NEVER curl / HTTP-GET a `https://cardstack.com/base/*` module URL to inspect a base card + +Base card module URLs (`https://cardstack.com/base/theme`, `.../base/card-api`, +`.../base/cards/structured-theme`, etc.) are **loader-resolved module references, not +fetchable HTTP resources.** `cardstack.com` is a marketing site — a direct GET or a +realm op (`_mtimes`, `boxel file read`) against `cardstack.com/base/...` returns a +generic Webflow **404 HTML page** (`data-wf-domain=... %%PUBLISH_URL_REPLACEMENT%%`), +NOT the card. Do not keep retrying it — that page will never become the schema. To +learn a base card's fields/shape, use the **`get_card_schema` tool** (it resolves +through the realm server), or read an existing instance of that card already in the +target realm. Same rule for any published `*.boxel.site` / `*.boxel.build` URL: those +are Webflow-published sites, not realms — never point realm operations at them. + +Also: many base cards are **default exports**, so the schema ref is `name: "default"`, +NOT the class name. The base **Theme** card is the default export of +`https://cardstack.com/base/theme` (the module is `export default Theme`) — query it as +module `https://cardstack.com/base/theme`, name `default` (querying `#Theme` fails). +`StructuredTheme` is likewise the default export of `base/structured-theme`. When a +`get_card_schema` call fails with "named export is a CardDef", retry with `name: +"default"` before assuming the card is unreachable — do NOT fall back to curling the URL. diff --git a/packages/software-factory/.agents/skills-orchestrator/boxel-delegated-render-control/SKILL.md b/packages/software-factory/.agents/skills-orchestrator/boxel-delegated-render-control/SKILL.md new file mode 100644 index 00000000000..659a6a7fb01 --- /dev/null +++ b/packages/software-factory/.agents/skills-orchestrator/boxel-delegated-render-control/SKILL.md @@ -0,0 +1,80 @@ +--- +name: boxel-delegated-render-control +description: Controlling child-card renders from the parent — fitted vs embedded format choice, the host-injected CardContainer chrome, and the plural-field/atom/stagger/divider traps. Use when embedding cards via <@fields.X @format='...' /> or when an embedded/fitted child looks wrong (empty boxes, collapsed grids, double borders, invisible atoms). +--- + +# Delegated Render Control + +When a parent card renders a child via `<@fields.X @format='...' />`, the +host wraps the child in a `CardContainer` with chrome you didn't write +(rounded corners, 1px halo, light background, `overflow: hidden`). Two +decisions dominate the outcome: **which format** you pick, and **who owns +the chrome**. The full mechanics — exact injected DOM/CSS, override layers, +recipes, and the child-side contract — live in +`references/delegated-render-control.md`. + +## Format choice = who owns the cell size (decide BEFORE styling) + +| Format | Who controls the box size? | Use when | +| ---------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `embedded` | **The child.** Width fluid, height = the card's natural content. | Vertical lists, feeds, roster rows, variable-height items. | +| `fitted` | **The parent.** Child fills the box you give it (`width/height: 100%`, `container-type: size`). | Uniform tile grids (calendar cells, portraits, badge strips) where you deliberately set the cell size. | + +**The single most common rendering bug:** picking `fitted` for a list of +variable-height cards — short content leaves a big empty box below each row. +The fix is the format choice, upstream of any CSS. Decision rule: _did you +set the cell size?_ Yes → `fitted` (+ `min-height`/`aspect-ratio` on the +cell). No → `embedded`. + +## Who owns the chrome — three override layers (pick the lowest that works) + +1. **Theme cascade** — if the linked card has `cardInfo.theme`, the theme's + `--background`, `--foreground`, `--border`, `--radius` flow into the + wrapper. Cleanest, but only when the child instance actually has a theme. +2. **`:deep()` from the parent's ` + +{{!-- ✅ Child outer is clean; design lives inside --}} +
+ ... +
+ +``` + +### Per format — what's safe and what isn't on the outermost element + +`CardContainer` — which wraps every card render, not just field embeds — +already applies the theme's `background-color`, `color`, `font-family` (the +theme's `--font-sans`, falling back to the Boxel sans stack), and body +`font-size` on its root; templates inherit all of them for free. Don't +declare any of these on the root unless you're deviating: `font-family` only +when the whole card should use something other than `--font-sans` (such as +`--font-serif`); `background-color`/`color` only when a pairing other than +the theme's main background/foreground is preferred. `fitted` and `embedded` +card templates MAY make that switch (e.g. `background-color: var(--card); +color: var(--card-foreground)`); `isolated` and CardDef `edit` templates +keep the theme's pair. + +| Format | OK on outermost | NOT OK on outermost (host/parent owns) | +| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| `isolated` | inner padding, inner grid/flex layout, `min-height` for content | `border-radius`, `border`, `box-shadow`, `overflow`, background/foreground overrides (use the theme's) | +| `embedded` | same as isolated — plus MAY use a different background/foreground pairing from the theme (e.g. `--card` + `--card-foreground`) | `border-radius`, `border`, `box-shadow`, `overflow`, `width`/`height`/`max-width` | +| `fitted` | a background/foreground pairing different from the theme's (e.g. `--card` + `--card-foreground`), inner padding, inner grid template, inner gap | `border-radius`, `border`, `box-shadow`, `width`, `height`, `min-height`, `max-height`, `container-type`, `container-name` (the host sets these) | +| `atom` | inline content only (text node, small inline icon) | `padding` (host provides), `border`, `border-radius`, `background`, any `display` other than inline-by-default | +| `edit` | form field spacing, internal stack/grid layout | outer chrome same as isolated (keep the theme's background/foreground) | + +**Compound-field templates are the exception to the edit/embedded rows:** a +FieldDef's `embedded` and `edit` templates render nested _inside_ a card +surface, so they may choose a different background/foreground combo to +distinguish themselves from the surrounding card — `--card` + +`--card-foreground` is the usual choice. + +### When your design genuinely demands a specific outer treatment + +Put it on the **Theme card**, not the child's format CSS. The +CardContainer's `--themed` cascade picks up `--radius`, `--background`, +`--border`, `--foreground` from the theme and applies them at the wrapper. +Every card linked to that theme inherits the outer treatment without +competing with the host. + +An editorial sharp-corner aesthetic, for example: + +``` +Theme/.json cssVariables: + --radius: 0; /* ← wrapper picks up via --_boxel-radius */ + --background: #f4eee1; /* ← wrapper background = brand paper */ + --border: #1a1814; /* ← halo when --boundaries on = brand ink */ +``` + +Every card linked to that theme renders with sharp corners + paper +background + ink halo, automatically. No format CSS needed. + +### Why this matters — the failure mode + +When the parent (e.g., a showcase card) embeds your card: + +- **Contract honored:** parent's `:deep(.boxel-card-container) { +border-radius: 0; background: var(--paper); }` overrides cleanly. Sharp + corners, brand paper, no double-borders. +- **Contract violated:** child's own `border-radius: 12px` on + `.news-isolated` competes with parent's `:deep` override. Specificity + wars. Or child's `box-shadow` stacks under the halo creating a + double-border. Or child's `background: white` blocks the parent's + `--paper` cascade — embedded child looks pasted on instead of integrated. + +The single most common symptom in agent-generated cards is rounded-corner +embedded children inside a sharp-corner parent. Cause: every child added +`border-radius: 8px` to its outer because "cards have rounded corners." Fix: +strip the outer decoration; trust the wrapper. + +### Self-check before declaring a format done + +Before any `static isolated|embedded|fitted|atom|edit = class { ... }` is +considered complete: + +1. Does the outermost element have `border-radius`, `border`, or + `box-shadow`? If yes — move that decision to the Theme card, or remove + it. For `isolated` and CardDef `edit`, also don't override the theme's + background/foreground; `fitted` and `embedded` may use a different + pairing (e.g. `--card` + `--card-foreground`). +2. For `fitted`: does the outermost element set `width`, `height`, + `min/max-height`, `container-type`, or `container-name`? If yes — remove. + The host sets these on `.field-component-card.fitted-format`. + (`background-color` paired with `color` is fine on a fitted root — + boxel-ui's `FittedCard` sets both.) +3. For `atom`: is the outermost element doing anything more than inline text + - maybe one inline icon? If yes — restructure. Atoms are inline content, + not chips. (Chips are the parent's job; see `@displayContainer={{false}}` + recipes.) +4. Open the card both standalone (in the stack) and embedded inside another + card. Does it look right in BOTH contexts? If only one, the child is + decorating the outer. + +Design the content; trust the wrapper. + +## Source files (verify before depending on details) + +In the Boxel monorepo: + +- `packages/boxel-ui/addon/src/components/card-container/index.gts` — + CardContainer + the global selector + `--themed` cascade +- `packages/base/field-component.gts` — per-format classes + + `displayContainer` arg +- `packages/catalog-realm/crm-app/contact.gts` — production examples of + `@displayContainer={{false}}` for atom +- `packages/catalog-realm/sprint-planner/sprint-task.gts` — atom with custom + class + display:contents diff --git a/packages/software-factory/.agents/skills-orchestrator/boxel-fetch-contexts/SKILL.md b/packages/software-factory/.agents/skills-orchestrator/boxel-fetch-contexts/SKILL.md new file mode 100644 index 00000000000..15ff03068ec --- /dev/null +++ b/packages/software-factory/.agents/skills-orchestrator/boxel-fetch-contexts/SKILL.md @@ -0,0 +1,70 @@ +--- +name: boxel-fetch-contexts +description: Fetch contexts — who carries auth where in Boxel (card code vs prerenderer vs CLI/orchestrator), and why raw fetch() from card code 401s on private realms. Use when card code needs to load cards, files, or external APIs, or when a fetch returns 401/unauthenticated data. +--- + +# Fetch contexts — who carries auth where + +Boxel code runs in several different execution contexts, and each carries +authentication differently. Most "my fetch returns 401" and "why is this +data empty" bugs come from using the wrong data path for the context the +code runs in. + +## 1. Card code in the host — use store APIs, not raw fetch + +Card code running inside the host (the Boxel app in the user's browser) has +an **authenticated session** — but that auth lives in the store and host +tools, not in the global `fetch`. Reach data through: + +- **CardContext store APIs** — `store.search`, `getCard` / `getCards` — for + loading and querying cards. +- **Host tools** — `@cardstack/boxel-host/tools/*` (e.g. `search-cards`, + `fetch-card-json`, `authed-fetch`) — for app-level operations. + +Do NOT reach for raw `fetch()` to realm URLs from card code. + +## 2. Raw fetch() to a private realm URL returns 401 + +Realm auth does **not** ride on naked `fetch`. A plain +`fetch('https://…/my-realm/some-file.png')` from card code against a +private realm returns **401** — there is no cookie or header magic attached +for you. Use the store / `getCard` / host tools instead. + +For **external HTTP APIs**, use the realm server's request-forward proxy: +`POST /_request-forward` with `{ url, method, headers, requestBody }` +(surfaced in card code as `SendRequestViaProxyCommand` / +`@cardstack/boxel-host/tools/send-request-via-proxy`). The proxy carries +**server-side credentials for approved services** — this is also how +OpenRouter access works in production. + +## 3. The prerenderer context — authed, but not interactive + +The prerenderer (screenshots, indexing) runs with **realm credentials**, so +it can read what it needs — but it is **NOT an interactive session**. In +particular, async renderers may not have painted when `'ready'` fires: +anything that lazy-loads, animates in, or fetches after first render can be +missing from screenshots and indexed HTML. Don't assume prerendered output +equals what an interactive user sees. + +## 4. CLI / orchestrator contexts — the Boxel profile + +CLI and orchestrator tooling authenticates via the **Boxel profile** — a +JWT obtained through `getRealmToken`. That JWT can also drive Playwright +against the host when browser automation needs an authenticated session. + +## Rule of thumb + +| Situation | Use | +| ----------------------------------------- | --------------------------------------------------------------------------------------- | +| I'm in card code and need cards | CardContext store APIs (`store.search`, `getCard`) | +| Card code needs an external API | The request-forward proxy, via a host tool (`send-request-via-proxy`) or `authed-fetch` | +| Card code needs file bytes from the realm | The file card's own component / host APIs — never raw `fetch` to the URL | +| Orchestrator / tooling | The boxel-cli client (profile JWT) | + +## Binary media — never inline it + +Regardless of context: binary media must **never** be inlined into card JSON +(no `data:` / base64 bytes in attributes). Store media as a realm file — +generated bytes go through `WriteBinaryFileCommand` +(`@cardstack/boxel-host/tools/write-binary-file`), then link the file via +`linksTo(FileDef / ImageDef / PngDef)`. diff --git a/packages/software-factory/.agents/skills-orchestrator/boxel-live-surfaces/SKILL.md b/packages/software-factory/.agents/skills-orchestrator/boxel-live-surfaces/SKILL.md new file mode 100644 index 00000000000..d33b3e29808 --- /dev/null +++ b/packages/software-factory/.agents/skills-orchestrator/boxel-live-surfaces/SKILL.md @@ -0,0 +1,148 @@ +--- +name: boxel-live-surfaces +description: Live, self-updating card lists via @context.searchResultsComponent — the SearchEntryWireQuery contract, query traps, clickable tiles, and live activity feeds. Use when a card needs to list other cards in the realm (home/dashboard sections, feeds, grids) or when a search section renders nothing. +--- + +# Boxel Live Surfaces + +`@context.searchResultsComponent` is the preferred way for a card to render +a live list of other cards. It re-runs as the realm changes — new instances +appear automatically, and the host prerenders each result so the card never +loads every model into memory. + +Fuller worked examples live in `references/`: + +- `references/app-card-home-with-search.md` — the Home/app-card pattern (one live section per CardDef in a family). +- `references/live-activity-feed-card.md` — append-only activity/log feed recipe. + +## The wire-query contract (get this wrong → silent empty render) + +`@context.searchResultsComponent` consumes a **WIRE query, not a plain +`Query`**. Build it with `searchEntryWireQueryFromQuery(query)` from +`@cardstack/runtime-common`, then attach: + +- `realms: []` — an **array**; there is no `realm` key. +- the display format via `filter.eq.htmlQuery = { eq: { format: 'fitted' } }`. + +Invoke it in **block form** (`as |results|`), iterate `results.entries`, and +render `` inside a parent-sized cell. **A plain Query +passed to a self-closed `<@context.searchResultsComponent @query={{q}} />` +renders NOTHING, silently.** + +```gts +import { cached } from '@glimmer/tracking'; +import { + codeRef, + realmURL, // the Symbol — NEVER Symbol.for('realmURL') + searchEntryWireQueryFromQuery, + type Query, + type SearchEntryWireQuery, +} from '@cardstack/runtime-common'; + +// @ts-expect-error import.meta is supported by the Boxel host +const here: string = import.meta.url; + +// inside the Component class: +get taskRef() { return codeRef(here, './task', 'Task'); } +get realms(): string[] { + const url = this.args.model?.[realmURL]; + return url ? [url.href] : []; // [] while loading — never query unscoped +} +get tasksQuery(): Query { + const ref = this.taskRef; + return { + filter: { type: ref }, // type, not bare `on` — see traps below + sort: [{ by: 'dueDate', on: ref, direction: 'asc' }], + }; +} +@cached +get tasksWireQuery(): SearchEntryWireQuery { + const q = searchEntryWireQueryFromQuery(this.tasksQuery); + return { + ...q, + realms: this.realms, + filter: { + ...q.filter, + eq: { ...q.filter?.eq, htmlQuery: { eq: { format: 'fitted' } } }, + }, + }; +} +``` + +```gts +
    + <@context.searchResultsComponent @query={{this.tasksWireQuery}} @mode='hover' as |results|> + {{#if results.isLoading}}
  • Loading…
  • {{/if}} + {{#each results.entries key='id' as |entry|}} +
  • + {{else}} +
  • No tasks yet.
  • + {{/each}} + +
+``` + +## The three query traps (each silently returns zero rows) + +1. **`filter: { type: ref }` to select all cards of a type.** Never + `filter: { on: ref }` — `on` is a scope for predicates, not a filter on + its own. `{ on: ref }` with no predicate returns zero rows. +2. **Custom-field sorts require `on: ref`.** Only `lastModified`, + `createdAt`, and `cardURL` are valid sort keys without `on`. Sorting on + any custom field — the sort expression MUST include `on: ref`. +3. **Use `codeRef(here, path, name)`, not raw URL construction.** And + import `realmURL` as a Symbol from `@cardstack/runtime-common` — don't + write `Symbol.for('realmURL')` (it produces a different Symbol that + doesn't match what the host injected). + +## Realm-scoped and bounded — always + +Every card-owned query MUST be realm-scoped and bounded. A missing or empty +realm argument falls back to querying **every available realm**. Default to +the current card's realm (via the `realmURL` Symbol), do not start the query +until that realm is known (return `[]` and let `results.isLoading` show), +and cap general-purpose hydration at a sensible page size. + +## Counts without hydration + +For KPI/funnel numbers, put `page: { size: 1 }` on the wire query and read +`results.meta.page.total` — one row of HTML, full count. + +## `@cached` the wire-query getters + +Decorate wire-query getters with `@cached` so SearchResults keeps ONE live +subscription per section instead of resubscribing on every re-render. + +## Making tiles clickable + +| Mode | What enables click | Mechanism | +| ---------------------------- | ------------------------------------------------------------------------------ | ----------------------------------------------- | +| **Interact / Code** (in-app) | `{{@context.cardComponentModifier ...}}` on the tile's container | Pushes the card onto the Boxel app's card stack | +| **Host** (published site) | `` transparent overlay inside a `position: relative` cell | Plain browser navigation | + +Always wire one of these — otherwise the tiles render beautifully and do +nothing on click. Use the **overlay** pattern for Host mode, never +`` wrapping (the height-100% chain through the +card chrome breaks silently and fitted cards collapse to zero height). In +monitor-style cards use +`this.args.viewCard(url, 'isolated', { openCardInRightMostStack: true })` — +never `` (full-page navigation drops the surface). + +## Churn warning — live means LIVE + +Every live section re-runs on **EVERY index change in the realm** — any card +created, edited, or deleted. A realm that is written every few seconds (sync +loops, log writers, autosave-heavy edit forms) makes every section flash its +loading state continuously. Keep high-frequency writers out of the realm the +dashboard watches, prefer showing stale results over a loading state during +revalidation, keep the number of live sections per card modest, and use +`@mode='none'` (prerendered HTML, no hover hydration — cheaper) for +read-only sections; `@mode='hover'` for browsable grids. + +## The store.search unhydrated-linksTo trap + +Cards returned by store search APIs have **unhydrated `linksTo` fields** — +reading `card.someLink.title` gives you nothing. To reach a linked card's +data from a search result, fetch the card-source JSON document and read +`relationships..links.self` to get the linked card's URL, then load +that card explicitly. diff --git a/packages/software-factory/.agents/skills-orchestrator/boxel-live-surfaces/references/app-card-home-with-search.md b/packages/software-factory/.agents/skills-orchestrator/boxel-live-surfaces/references/app-card-home-with-search.md new file mode 100644 index 00000000000..203de754a93 --- /dev/null +++ b/packages/software-factory/.agents/skills-orchestrator/boxel-live-surfaces/references/app-card-home-with-search.md @@ -0,0 +1,431 @@ +# app-card-home-with-search — Every card family needs a home + +**What this gives you:** A `Home` CardDef (typically named after the brand) +that sits at the top of a card family and uses +`@context.searchResultsComponent` to dynamically list every Meet / Listing / +Project / etc. in the realm. `prefersWideFormat = true` so it opens +edge-to-edge. The user lands on it, sees the realm at a glance, drills in +from there. + +**When to use:** Whenever you build a card _family_ — 2+ related CardDefs +(Meet + Swimmer + Club, Project + Task + Person, Show + Listing + Venue, +etc.). Building a single utility card? Skip this pattern. Building anything +where the user will accumulate instances over time? Build the home. + +**Why it matters:** + +- **Discoverability.** A realm with 5 CardDefs and no home shows users an + `index.json` `CardsGrid` of mixed cards in adoption order. A home puts the + brand voice up front and arranges the suite the way the designer intended. +- **Editorial framing.** The home is where you set the typography pairing, + the eyebrow voice, the color story. Children inherit through the theme + cascade. +- **Live by construction.** `@context.searchResultsComponent` re-runs as the + realm changes — new instances appear automatically, no manual + relationship-wiring on the home. + +**Recipe shape:** + +```gts +// surge.gts (or whatever the brand demands) +import { + CardDef, + Component, + field, + contains, + linksTo, +} from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; +import TextAreaField from 'https://cardstack.com/base/text-area'; +import { + codeRef, + realmURL, + searchEntryWireQueryFromQuery, + type Query, + type SearchEntryWireQuery, +} from '@cardstack/runtime-common'; +import BoltIcon from '@cardstack/boxel-icons/bolt'; +import { Meet } from './meet'; + +// @ts-expect-error import.meta is supported by the Boxel host +const here: string = import.meta.url; + +export class Surge extends CardDef { + static displayName = 'Surge'; + static icon = BoltIcon; + static prefersWideFormat = true; // ← edge-to-edge home + + @field welcome = contains(StringField); + @field tagline = contains(TextAreaField); + @field headlineMeet = linksTo(() => Meet); // optional spotlight pin + + @field cardTitle = contains(StringField, { + computeVia: function (this: Surge) { + return this.cardInfo?.name?.trim()?.length + ? this.cardInfo.name + : (this.welcome ?? 'SURGE'); + }, + }); + + static isolated = class Isolated extends Component { + // codeRef(here, relPath, ExportName) returns { module, name } — the canonical CodeRef + get meetRef() { + return codeRef(here, './meet', 'Meet'); + } + get swimmerRef() { + return codeRef(here, './swimmer', 'Swimmer'); + } + get realms(): string[] { + const url = this.args.model?.[realmURL]; + return url ? [url.href] : []; + } + // filter: { type: ref } — match ALL cards of a type. + // `on` would be wrong: `on` is a SCOPE inside a predicate, not a filter. + // Custom-field sorts require `on: ref` — only lastModified, createdAt, cardURL work without it. + get meetsQuery(): Query { + const ref = this.meetRef; + return { + filter: { type: ref }, + sort: [{ by: 'dates.start', on: ref, direction: 'desc' }], + }; + } + get swimmersQuery(): Query { + const ref = this.swimmerRef; + return { + filter: { type: ref }, + sort: [{ by: 'lastName', on: ref, direction: 'asc' }], + }; + } + + // Each section wraps its Query in searchEntryWireQueryFromQuery, then + // attaches `realms` + the fitted display format via filter.eq.htmlQuery. + // This is what <@context.searchResultsComponent> consumes. + get meetsWireQuery(): SearchEntryWireQuery { + const q = searchEntryWireQueryFromQuery(this.meetsQuery); + return { + ...q, + realms: this.realms, + filter: { + ...q.filter, + eq: { ...q.filter?.eq, htmlQuery: { eq: { format: 'fitted' } } }, + }, + }; + } + get swimmersWireQuery(): SearchEntryWireQuery { + const q = searchEntryWireQueryFromQuery(this.swimmersQuery); + return { + ...q, + realms: this.realms, + filter: { + ...q.filter, + eq: { ...q.filter?.eq, htmlQuery: { eq: { format: 'fitted' } } }, + }, + }; + } + + + }; + + static embedded = class Embedded extends Component { + /* brand card */ + }; + static fitted = class Fitted extends Component { + /* mini wordmark */ + }; +} +``` + +```json +// Surge/home.json — the canonical home instance +{ + "data": { + "type": "card", + "attributes": { + "welcome": "SURGE", + "tagline": "The youth swim meet platform.", + "cardInfo": { "name": "SURGE — Home", "summary": "Realm home." } + }, + "relationships": { + "headlineMeet": { + "links": { "self": "../Meet/mid-atlantic-senior-sectionals-2026" } + }, + "cardInfo.theme": { "links": { "self": "../Theme/surge" } } + }, + "meta": { "adoptsFrom": { "module": "../surge", "name": "Surge" } } + } +} +``` + +**`@context.searchResultsComponent` is live by construction — mind the cost +on multi-section homes.** + +Each `@context.searchResultsComponent` section subscribes its query to realm +change events. Every time ANY card in the realm is created, edited, or +deleted, the matching sections re-fetch and re-render. For a Home with 4 +result-list sections, editing a single Swimmer somewhere else in the realm +can fire re-fetches across every section whose query might be affected — +even though only one section's data actually changed. With the host's +autosave on each keystroke, this can make unrelated edit forms feel sluggish +because the Home tab is consuming CPU on every reindex. + +There is no snapshot/live toggle to reach for — the surface is live by +default. The lever you _do_ have is `@mode`: `'hover'` (default) hydrates +each result so it can respond to hover; `'none'` renders the prerendered +HTML with no per-result interactivity, which is cheaper for dense read-only +sections. + +```gts +{{!-- ✅ Default: hover-hydrated results. --}} +<@context.searchResultsComponent @query={{this.q}} as |results|> + {{#each results.entries key='id' as |entry|}}{{/each}} + + +{{!-- ✅ Cheaper for dense, read-only sections — no per-result hover hydration. --}} +<@context.searchResultsComponent @query={{this.q}} @mode='none' as |results|> + {{#each results.entries key='id' as |entry|}}{{/each}} + +``` + +Keep the number of live sections on a single Home modest, and prefer +`@mode='none'` for sections the user only reads. + +**Why `@context.searchResultsComponent` (display) instead of `getCards` +(instances):** + +| Use case | Pick | +| ----------------------------------------------------------------- | -------------------------------------------- | +| Showing the cards as themselves (fitted/embedded HTML) | `@context.searchResultsComponent` | +| Reading model values to compute aggregates (counts, sums, charts) | `getCards` | +| Both — list and aggregate | `getCards`, then render with `<@fields ...>` | + +The home almost always wants the first. The host pre-renders each result on +the realm side, so the home doesn't pay the cost of loading every model into +memory. For a realm with hundreds of instances, this is the difference +between snappy and unusable. + +**Critical — apply the chrome contract:** + +The home's outermost element (`.sg` in the example) MUST leave decoration to +the host's CardContainer. No `border-radius`, no `border`, no `box-shadow`, +no opaque `background` (`var(--paper)` is fine — the paper is the brand +surface, not chrome), no `overflow`. Brand-specific outer treatment goes on +the Theme card as `--radius`, `--background`, `--border`. See the +`boxel-delegated-render-control` skill. + +**Critical — no plural-field wrapper for search-results output:** + +`@context.searchResultsComponent` does NOT wrap its yielded entries in +`.plural-field / .containsMany-field / .linksToMany-field` — that wrapper +only appears for `<@fields.plural @format='...' />` direct rendering. With +the search-results surface, you own the `