Skip to content
Merged
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
1 change: 1 addition & 0 deletions Skill/dev-core-concept.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ Boxel is a composable card-based system where information lives in self-containe
Needs own identity? → CardDef with linksTo
Referenced from multiple places? → CardDef with linksTo
Referencing a file (image, doc, etc.)? → FileDef subtype with linksTo
Have a URL that points at a card or realm file? → linksTo/linksToMany — NEVER StringField/UrlField
Just compound data? → FieldDef with contains
```

Expand Down
2 changes: 1 addition & 1 deletion Skill/dev-quick-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ import ColorField from '@cardstack/base/color';
import EmailField from '@cardstack/base/email';
import PercentageField from '@cardstack/base/percentage';
import PhoneNumberField from '@cardstack/base/phone-number';
import UrlField from '@cardstack/base/url';
import UrlField from '@cardstack/base/url'; // external URLs only — a URL pointing at a card or realm file is a linksTo, never a UrlField
import AddressField from '@cardstack/base/address';

// ⚠️ EXTENDING BASE FIELDS: To customize a base field, import it and extend:
Expand Down
1 change: 1 addition & 0 deletions index.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ Every skill lives in `skills/` and auto-activates on its description triggers
- **🚨 `linksToMany` JSON uses indexed top-level keys** (`"activityFeed.0": { "links": { "self": "../foo" } }`), never an array under `links.self` (Cardinal Rule 13). The array shape is rejected with `"instance ... is not a card resource document"`.
- **🚨 `linksTo` fields never appear in `attributes` — not even as `null`** (Cardinal Rule 14). A link lives under `relationships`, keyed by its field path (nested fields use dotted keys: `"cardInfo.theme"`); an empty link is `{ "links": { "self": null } }` or the key is omitted. A `null` attribute writes successfully — then every read of the instance fails with `linkTo field ... cannot deserialize non-relationship value null` until the raw JSON is repaired.
- **🚨 Any function a template *calls* must be an arrow-function property, never a class method** (Cardinal Rule 15). Glimmer invokes `(this.isActive note)` / `{{fn this.method}}` unbound, so a class method throws during render and **freezes the whole application** until reload; `{{on}}` handlers mask the same mistake by merely breaking one handler. Getters are safe — the template reads them off `this`. Details: [`boxel-workspace-cardinal-rules/SKILL.md`](skills/boxel-workspace-cardinal-rules/SKILL.md) #11.
- **A URL pointing at a realm resource is a link, never a string** (Cardinal Rule 16). If a field's value is the URL of a card instance or a realm file, model it as `linksTo` / `linksToMany` (a `FileDef` subtype for files) — never as `StringField`/`UrlField`. A string-typed realm URL bypasses the index (no invalidation, no broken-link detection, no traversal) and rots silently when the target moves. The complement of Cardinal Rule 12: external URLs never in relationships, realm URLs never in string attributes. Two carve-outs where a string is correct: a `FileDef`'s own `id`/`url`/`sourceUrl` descriptor fields, and a `hostRoutingRules` public nav path. Details: [`base-field-catalog.md`](skills/boxel/references/base-field-catalog.md) "Realm-resource URLs — always a relationship, never a string".
- **🚨 Building a kit is a sequential checklist with verification gates — lint is NOT the gate.** The seven gates, in order: (1) Stage-0 planning (thunk-by-default `() => Class` for kit-internal links; per-format content matrices), (2) import audit (base fields are **default** exports; never `ImageDef` from `@cardstack/base/image`), (3) CDN-verify every icon, (4) push per-file — no atomic batches on fresh realms (this is about `boxel realm push` from a shell; it is not a reason to write files one per turn — see below), (5) module-load probe (`get-card-type-schema` must return `status: ready`), (6) typed-search count gate (`boxel search` is the truth source, not lint), (7) render smoke test per CardDef. Exact commands and failure signatures: [`indexing-operations.md`](skills/boxel-environment/references/indexing-operations.md).
- **Query traps that silently return zero rows** (Cardinal Rules 5–7): `filter: { type: ref }` to select all cards of a type — never a bare `{ on: ref }` (`on` only scopes predicates); custom sort fields require `on: ref`; build refs with `codeRef()` and import the `realmURL` Symbol from `@cardstack/runtime-common` (never `Symbol.for('realmURL')`). Details: [`query-systems.md`](skills/boxel/references/query-systems.md).
- **Format choice = who owns the cell size, not what the cell looks like.** `@format='embedded'` lets the child decide its height — use for lists, feeds, roster rows. `@format='fitted'` makes the child fill a parent-controlled box — use for uniform tile grids (portraits, calendar cells). Picking fitted for a list with short content leaves empty boxes below each row. The fix is the format choice, upstream of any CSS. See "Picking the format" in [`delegated-render-control.md`](skills/boxel-ui-guidelines/references/delegated-render-control.md).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ The relationship link points at a real ImageDef instance in the realm — never
## Gotchas

- **NEVER cross-mix.** Don't put an external URL into `relationships.heroImage.links.self`. That's the realm-bricking shape. The relationship is for in-realm card identifiers only.
- **Cross-mixing is wrong in the other direction too.** Don't put a realm resource URL (a card instance or realm file the realm serves) into the `UrlField` side. A string-typed realm URL bypasses the index — no invalidation, no broken-link detection, no traversal — and rots silently when the target moves. In-realm targets always go through the `linksTo` side; the `UrlField` side is for external URLs only.
- **Use `UrlField`, not `StringField`, and not `MaybeBase64Field`.** `UrlField` (from `@cardstack/base/url`) extends `StringField` with URL-shape validation in edit mode. The base `cardInfo` field uses `MaybeBase64Field` for historical reasons (it also accepts inline base64) — don't follow that lead in new code; `UrlField` is the canonical choice for an external HTTP(S) URL.
- **Empty string vs null.** When clearing the URL, set the JSON value to `null` not `""` — empty strings can confuse downstream consumers expecting truthiness.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export class SiteConfig extends CardDef {
```

**Gotchas:**
- `pageUrl` as `UrlField` is correct here and is not a violation of the realm-resource-URL rule (`boxel` Cardinal Rule 16): the value is a curated public path routed via `hostRoutingRules` (`/about`, `/pricing`), not a card identifier. Decoupling the public URL from the card id is the point of the routing mechanism — do not rewrite it as `linksTo` to a page card.
- For production realm files, keep `PageConfig`, `SiteConfig`, and each page shell in separate `.gts` files. The example co-locates them only to show the pattern in one place.
- Preserve `cardInfo.theme` as the override in computed `cardTheme`: `this.cardInfo?.theme ?? this.site?.brandGuide ?? null`.
- Sort nav entries in the rendering component, not in the JSON instance. Use `showInNav` + `navOrder`.
Expand Down
18 changes: 17 additions & 1 deletion skills/boxel-workspace-cardinal-rules/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ 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.<field>.links.self`
## 2. Never put an external URL in `relationships.<field>.links.self` — and never a realm URL in a string field

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
Expand All @@ -38,6 +38,22 @@ 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.

**The rule cuts both ways.** If a field's value is the URL of a card instance or a
realm file — an absolute realm URL, a relative path like `../Theme/foo`, or any URL
a realm serves — model it as `linksTo` / `linksToMany` (a `FileDef` subtype for
files), never as a `StringField` or `UrlField` attribute. The string version writes
fine, indexes fine, and even renders as a clickable link — then rots silently: the
index never invalidates the referrer when the target changes, broken-link
diagnostics can't see it, `<@fields.X />` can't render the target, and queries can't
traverse it. When the target moves or is deleted, nothing reports the dangling
reference. Two carve-outs where a string is correct: a `FileDef` subtype's own
`id`/`url`/`sourceUrl` descriptor fields hold the realm file URL as strings by
design, and a curated public path routed via `hostRoutingRules` (a nav target like
`/about`) is a routed path, not a resource identifier. Everything else `UrlField`
holds should be an external (non-realm) URL. Details:
`boxel/references/base-field-catalog.md` "Realm-resource URLs — always a
relationship, never a string".

## 3. `linksToMany` JSON uses indexed top-level keys, never an array

Correct:
Expand Down
2 changes: 2 additions & 0 deletions skills/boxel/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ You are generating idiomatic Boxel: **Card Definitions** in `.gts` (Glimmer Type
| 13 | **🚨 `linksToMany` JSON shape uses INDEXED KEYS, never an array under `links.self`.** Each linked item in a `linksToMany` field gets its own top-level relationship key with an indexed suffix. Correct: `"activityFeed.0": { "links": { "self": "..." } }`, `"activityFeed.1": { "links": { "self": "..." } }`. WRONG (and the host rejects with "instance ... is not a card resource document"): `"activityFeed": { "links": { "self": ["...", "..."] } }`. The array-inside-`self` shape is intuitive but not valid Boxel JSON:API — `links.self` is a single string per JSON:API spec, and Boxel's encoding of "many" is indexed top-level keys. See `references/core-patterns.md` "JSON:API instance shapes". |
| 14 | **🚨 `linksTo` fields never appear in `attributes` — not even as `null`.** A `linksTo` field is serialized under `relationships`, keyed by its field path — for a linksTo nested inside a contained field, a dotted key: `"cardInfo.theme": { "links": { "self": "../Theme/foo" } }`. An empty link is `{ "links": { "self": null } }`, or omit the key entirely. Writing `"cardInfo": { "theme": null }` (or any value for the link) into `attributes` passes lint and writes successfully — then every read of the instance throws `linkTo field 'theme' cannot deserialize non-relationship value null` until the raw JSON is repaired by hand. |
| 15 | **🚨 Any function a template *calls* must be an arrow-function property, never a class method.** Glimmer invokes template-called functions (`{{if (this.isActive note) ...}}`, `{{fn this.method}}`) without binding `this`; a class body is always strict mode, so `this` is `undefined` and the first property access throws **during render**, which poisons Ember's renderer — the whole application freezes until reload. Lint passes, and `{{on}}` handlers mask the same mistake (there it merely breaks one handler). Write `isActive = (note: string) => this.activeNotes.has(note);`, never `isActive(note: string) { ... }`. Getters are safe (the template reads them off `this`); `@action` methods also bind, but arrow properties are the convention. See `boxel-workspace-cardinal-rules/SKILL.md` #11. |
| 16 | **A URL that points at a Boxel realm resource is a link, never a string.** If a field's value is the URL of a card instance or a realm file — an absolute realm URL (`https://<realm>/Person/jane`), a relative path (`../Theme/foo`), or any URL a realm serves — model it as `linksTo` / `linksToMany` (a `FileDef` subtype for files), **NEVER** as `contains(StringField)` or `contains(UrlField)`. A string-typed realm URL is invisible to the index: no invalidation when the target changes, no broken-link detection, no `<@fields.X />` rendering of the target, no query traversal — the reference silently goes stale when the target moves or is deleted. This is the exact complement of Rule 12: external URLs never go in relationships, and realm-resource URLs never go in string attributes. Two carve-outs: a `FileDef` subtype's own `id`/`url`/`sourceUrl` descriptor fields are strings that hold the realm file URL by design — populate them as strings, never convert them to relationships; and a curated public path routed via `hostRoutingRules` (a nav target like `/about`) is a routed path, not a resource identifier — `UrlField` is correct there. See `references/base-field-catalog.md` "Realm-resource URLs — always a relationship, never a string". |

> **Rules 5–7 are the silent-zero-rows traps.** No error is thrown; the response is just empty, every time. Memorize them before writing any query. Full reference: [`references/query-systems.md`](references/query-systems.md).
>
Expand All @@ -41,6 +42,7 @@ You are generating idiomatic Boxel: **Card Definitions** in `.gts` (Glimmer Type

```
Needs own identity / referenced from multiple places? → CardDef + linksTo
URL pointing at a card or realm file? → linksTo / linksToMany — never StringField/UrlField (Rule 16)

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.

Medium — the Skill/ tree (the in-app assistant) does not get this line, so the two harnesses drift.

index.md's maintainer note: "Skill guidance lives in two hand-maintained trees that nothing syncs: skills/ … and Skill/ … A convention change must be authored into both, or the two harnesses drift."

This decision tree has a hand-maintained twin at Skill/dev-core-concept.md:41-44:

Needs own identity? → CardDef with linksTo
Referenced from multiple places? → CardDef with linksTo
Referencing a file (image, doc, etc.)? → FileDef subtype with linksTo
Just compound data? → FieldDef with contains

Skill/dev-core-concept.json points instructionsSource at ./dev-core-concept.md (a local copy), not at skills/, so nothing propagates. Skill/dev-quick-reference.md:62 (the UrlField import listing) and Skill/dev-file-def.md are the other two mirror sites.

Concrete misbehavior: the in-app assistant picking a field type consults Skill/dev-core-concept.md's tree, finds no rule about realm URLs, and emits @field author = contains(UrlField) — the exact bug this PR exists to prevent. Note the new workspace rule 12 does reach the assistant (Skill/boxel-workspace-cardinal-rules.json links straight at ../skills/boxel-workspace-cardinal-rules/SKILL.md), so this is a partial-coverage gap rather than a total one — but the field-selection guidance the assistant actually reads is the half that's missing.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Claude Code 🤖] Fixed in 25caa2b. The decision tree in Skill/dev-core-concept.md now carries the rule line, and the UrlField import in Skill/dev-quick-reference.md carries an external-URLs-only note. Skill/dev-file-def.md already teaches both sides (linksTo to reference a FileDef; descriptor fields as strings), so it needed no change.

Image / document / file asset? → FileDef subtype + linksTo (see boxel-file-def)
Generated/uploaded media payload? → Write bytes with WriteBinaryFileCommand, then linksTo FileDef/ImageDef/PngDef. Never StringField data URI.
Compound data only AND list of ~1–3 items? → FieldDef + containsMany
Expand Down
26 changes: 25 additions & 1 deletion skills/boxel/references/base-field-catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Every field listed here is importable from a stable specifier and ready to use w
| `BigIntegerField` | `'@cardstack/base/big-integer'` | For values beyond `Number.MAX_SAFE_INTEGER`. |
| `TextAreaField` | `'@cardstack/base/text-area'` | Multi-line plain text (sub-page). For paragraphs that aren't markdown. |
| `EmailField` | `'@cardstack/base/email'` | Validates as `user@domain`. Renders as `mailto:` link. |
| `UrlField` | `'@cardstack/base/url'` | Validates URL shape. Renders as `<a>` in non-edit modes. |
| `UrlField` | `'@cardstack/base/url'` | Validates URL shape. Renders as `<a>` in non-edit modes. **External URLs only** — a URL that points at a realm resource (a card instance or realm file) must be a `linksTo` / `linksToMany` field instead, never a string. |

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.

Low — the "Details" target for a general rule is a bullet buried inside an image-specific section.

index.md:92 and the glossary bullet both send the reader here for the details of Rule 16, but the substantive paragraph landed at line 88, inside "Image fields — the URL/ImageDef pair pattern", under a bullet list headed "The contract:". The rule itself is not image-specific — the motivating example in the PR body is @field author.

Concrete misbehavior: a model deciding how to model author, owner, or relatedDoc follows the index.md link (which carries no anchor, unlike the Rule 12 bullet just above it which names its section), scans this file's headings, sees only an image-pair-pattern section, and concludes the guidance doesn't apply to a non-image pointer. The two decision-guide lines added at 215 and 236 are the only non-image touchpoints and they carry no rationale.

Suggest a short standalone subsection (sibling to "🔴 DateField vs DateTimeField — the schema-vs-value contract") with the general statement, and have the image-section bullet cross-reference it — then give index.md an anchored link.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Claude Code 🤖] Fixed in 25caa2b. The general statement now lives in its own subsection in base-field-catalog.md ("Realm-resource URLs — always a relationship, never a string", sibling to the DateField section) with the decay list and both carve-outs; the image-section contract bullet cross-references it, and the index.md and glossary bullets name the section in their pointers.

| `PhoneNumberField` | `'@cardstack/base/phone-number'` | Country code + national number. Compound field. |
| `EthereumAddressField` | `'@cardstack/base/ethereum-address'` | Web3 address with checksum validation. |
| `ColorField` | `'@cardstack/base/color'` | Renders a color swatch + picker in edit mode. |
Expand Down Expand Up @@ -85,9 +85,28 @@ For a `linksToMany(ImageDef)` gallery, the URL twin is `containsMany(UrlField)`:
- `relationships.<field>.links.self` is for card identifiers — relative paths (`"../Theme/foo"`) or absolute realm URLs only.
- External URLs (Unsplash, S3, CDN, any `https://` URL pointing at non-card content) go in `attributes.<field>URL` on the URL-twin field.
- Uploaded card-side images go in the linked ImageDef as a normal `linksTo` relationship.
- The rule cuts both ways: a URL that points at a **realm resource** never goes in a `UrlField`/`StringField` attribute — see "Realm-resource URLs — always a relationship, never a string" below.

**Future direction (not implemented yet):** a single compound `Image` FieldDef that wraps either a URL or an ImageDef link and exposes a unified `.src` accessor. Until then, use the pair-of-fields approach above.

### 🔗 Realm-resource URLs — always a relationship, never a string

The general rule, not image-specific: if a field's value is the URL of a **realm resource** — a card instance or a realm file, whether as an absolute realm URL, a relative path, or any URL a realm serves — model it as `linksTo` / `linksToMany` (a `FileDef` subtype for files), never as `contains(StringField)` or `contains(UrlField)`. This applies to any pointer field: `author`, `owner`, `relatedDoc`, `parentProject`, and so on.

Both versions pass lint, write, index, and render a clickable link. The string version then decays with no error:

- The index only tracks relationship links as dependencies — nothing re-indexes the referrer when the target changes, and no `brokenLinks` diagnostic fires when the target is deleted.
- The template cannot render the target card (`<@fields.author @format='embedded' />` needs a relationship).
- Queries cannot traverse it (`author.name` filters need a relationship).
- Relationship links are relative paths that survive a realm copy or rename; the string holds an absolute URL that keeps pointing at the old realm.

**Two carve-outs — string is correct there:**

- A `FileDef` subtype's own `id` / `url` / `sourceUrl` descriptor fields are strings that hold the realm file URL by design. Populate them as strings (`new ImageDef({ id: fileIdentifier, url: fileIdentifier, sourceUrl: fileIdentifier, ... })`) — never try to convert them into relationships. The relationship lives one level up: the card's field pointing at the `ImageDef` is the `linksTo`.
- A curated public path routed via `hostRoutingRules` (a nav target like `/about` or `/pricing` in a site config) is a routed path, not a resource identifier — decoupling the public URL from the card id is the point of the routing mechanism. `UrlField` is correct there. See the `build-site-config-with-theme` and `link-host-mode-paths` patterns.

Everything else `UrlField` holds should be an external (non-realm) URL.

### 🔴 DateField vs DateTimeField — the schema-vs-value contract

The most common silent failure pattern in Boxel card families: declaring `contains(DateField)` in the .gts but writing an ISO datetime (`"2026-06-13T15:30:00Z"`) in the JSON instance, OR declaring `contains(DateTimeField)` and writing only `"2026-06-13"`.
Expand Down Expand Up @@ -211,6 +230,7 @@ These extend `FileDef` and must be used with `linksTo`, never `contains`. See `b
Need text?
├── Single line, generic → StringField
├── Email/URL/phone → EmailField / UrlField / PhoneNumberField
│ (UrlField = external URLs only; a realm resource URL is a linksTo, see below)
├── Multi-line plain → TextAreaField
└── Markdown
├── Stored on the card → MarkdownField (or RichMarkdownField for editor chrome)
Expand All @@ -231,6 +251,10 @@ Need a date?
Need an image / file?
└── Always linksTo(ImageDef) or specific subtype. NEVER contains(ImageDef).

Need to reference another card or a realm file (even if you have it as a URL)?
└── Always linksTo / linksToMany. NEVER StringField or UrlField holding the URL.
(Carve-outs: a FileDef's own id/url/sourceUrl fields; hostRoutingRules nav paths — see the section above.)

Need bounded choices?
└── enumField(StringField, { options: [...] })

Expand Down
Loading