diff --git a/Skill/boxel-ui-guidelines.md b/Skill/boxel-ui-guidelines.md
index 58c20b6..f6c9a7d 100644
--- a/Skill/boxel-ui-guidelines.md
+++ b/Skill/boxel-ui-guidelines.md
@@ -181,6 +181,8 @@ var(--boxel-caption-font-weight)
var(--boxel-caption-line-height)
```
+**Take the whole role group, don't assemble one.** When text needs a size *and* a matching line-height, use the four tokens of its semantic role rather than reaching into the primitive ladder and hand-writing the pair — `font-size: var(--boxel-font-size-xs); line-height: calc(15 / 11);` should be `var(--boxel-caption-font-size)` + `var(--boxel-caption-line-height)`. The role group stays internally consistent and re-scales with the theme; a hand-computed `calc()` line-height silently stops matching the moment the theme's type scale changes.
+
#### Low-level typography tokens
Note: The font-family, font-sizes, spacing, radius will be recalculated based on the linked card in cardInfo.theme. Below values are defaults.
@@ -239,6 +241,8 @@ var(--boxel-transition) /* 0.2s ease */
Do NOT use these for brand or theme colors — they are hardcoded and not theme-aware. Prefer semantic variables above. These exist only as low-level primitives:
+**Concrete failure mode — the grays as text color.** `color: var(--boxel-500)` for "muted" text looks correct in light mode and goes **illegible in dark mode**: the gray stays put while the surface flips dark, collapsing contrast. This is the most common way the primitives leak in, because a mid-gray reads as a safe, neutral choice. Muted text is always `var(--muted-foreground)` — it is defined per theme precisely so it moves with the surface. If you need de-emphasis *relative to whatever color is inherited*, derive it (`opacity`, or `color-mix(in oklab, currentColor 60%, transparent)`) rather than naming a fixed gray.
+
```css
/* Grays */
var(--boxel-100) through var(--boxel-700)
@@ -572,6 +576,38 @@ The base `field-component` provides named containers automatically — you do no
For isolated templates, the parent does not provide a named container — declare `container-type: inline-size` with a name on your own root element and use that name in `@container` rules.
+### Override tokens in the query, not the rules
+
+When a value changes at a breakpoint, declare it once as a custom property on the composition root and have the `@container` block reassign only the property. Do not re-declare the rule that consumes it.
+
+**Wrong** — the same value lives in two rule blocks per breakpoint, and every consuming rule has to be repeated:
+```css
+.meta-strip { gap: 1.25rem 2.5rem; }
+@container hero (inline-size <= 500px) {
+ .meta-strip { gap: 1.125rem 1.5rem; } /* duplicated selector + property */
+}
+```
+
+**Right** — the breakpoint block is a short list of value changes:
+```css
+.hero-inner {
+ --meta-strip-gap: 1.25rem 2.5rem;
+ --meta-strip-margin-top: 4.5rem;
+}
+.meta-strip {
+ gap: var(--meta-strip-gap);
+ margin-top: var(--meta-strip-margin-top);
+}
+@container hero (inline-size <= 500px) {
+ .hero-inner {
+ --meta-strip-gap: 1.125rem 1.5rem;
+ --meta-strip-margin-top: 3.5rem;
+ }
+}
+```
+
+This keeps each responsive value in one place, makes the breakpoint block readable as "what changes at this size," and scales without duplicating selectors as breakpoints accumulate. Declare the defaults on the root per the fallback rule above — bare `var()` reads below, no inline fallbacks.
+
**Named containers are safer in nested situations.** An anonymous `@container` matches the nearest ancestor with any `container-type`, which could be an unintended intermediate container. `@container fitted-card (...)` skips anonymous containers and always resolves to the nearest ancestor with that specific name — so nested fitted cards each correctly target their own wrapper.
## Prevent Content Overflow
@@ -649,7 +685,7 @@ import {
- `DateRangePicker` — date range selection
**Buttons & Actions:**
-- `Button` — primary action button (use `@kind` for primary/secondary/muted/destructive/text-only; use `@size` for `auto, base, extra-small, small, tall, touch)
+- `Button` — primary action button. `@kind` for primary/secondary/muted/destructive/text-only/primary-dark **and the chromeless link kinds `link`/`link-primary`/`link-muted`** (no background, no border, no min-height — the right choice for text that should read as a link, not a control). `@size` for `auto, base, extra-small, small, tall, touch`. `@as` picks the rendered element: `'button'` (default), `'anchor'` (+ `@href`), or `'link-to'` (+ `@route`/`@models`/`@query`).
- `IconButton` — icon-only button (use `@variant` for primary/secondary/muted/destructive/text-only, `@size` for `auto, base, extra-small, small, tall, touch)
- `ContextButton` — contextual action button (`@icon` for add, edit, close, delete, context-menu, context-menu-vertical; `@variant` for highlight, highlight-icon, ghost, destructive, destructive-icon)
- `CopyButton` — copy-to-clipboard
@@ -680,6 +716,91 @@ import {
- `ColorPalette` / `ColorPicker` — color selection
- `DragAndDrop` — drag-and-drop interface
+### Don't neutralize a component — pick the variant
+
+If styling a boxel-ui component requires cancelling its own defaults, you picked the wrong component or the wrong variant. The tell is a `
+```
+
+**Right** — a variant that already has no chrome, leaving only genuinely bespoke declarations:
+```gts
+
+
+```
+
+Each cancelling declaration is invisible coupling to the component's current internals: it rots silently when the component changes, and it hides the fact that a purpose-built variant exists. Read the component's API first and look through `@kind` / `@variant` / `@size` before writing a single override.
+
+**Component args are not portable between components.** `@as` and `@href` are `Button`'s args. `Pill` has no `@as` — it takes `@tag` (a raw HTML tag name) and receives `href` as a plain attribute through `...attributes`. Never carry one component's arg names to another; check the signature.
+
+### An optional `@href` is a decision, not a detail
+
+`Button @as='anchor'` renders an ``, and an `` with no `href` is not a link — it isn't focusable and reads as generic text. `Button` also *styles* that state as disabled (`a.boxel-button:not([href])`, `[href='']`, `.disabled-link` → `opacity: 0.5`, disabled colour, `pointer-events: none`), so a conditionally-empty `@href` produces a faded element that looks deliberate and passes review as if it were designed.
+
+So whenever a url-ish field is optional, decide which of these the **data model** intends. They look nearly identical on screen; the difference is what the markup claims is true.
+
+**Case 1 — the link is meant to exist but isn't available yet.** Unpublished URL, gated resource, "coming soon". A disabled link is precisely what this is, so say so with `@disabled` rather than letting an absent `href` imply it:
+
+```gts
+
+```
+
+**Case 2 — the field is optional and some items are plain labels.** Nothing is disabled; no action could ever become available. Render a non-anchor element and state the de-emphasis directly, so the appearance isn't coupled to a control state:
+
+```gts
+{{#if @model.url.length}}
+
+{{else}}
+ <@fields.label />
+{{/if}}
+```
+
+When you branch like this the non-component element does **not** inherit the component's `@size` metrics — declare shared `font-size`/`line-height` on the class both branches carry, or the two render at different sizes.
+
+Getting this wrong is not cosmetic. Once `Button` announces its disabled links to assistive tech (below), a Case 2 item written as Case 1 stops being merely silent and starts telling screen-reader users that a plain label is an unavailable link.
+
+> **Version note (2026-07, [CS-12305](https://linear.app/cardstack/issue/CS-12305/upstream-portable-homepage-modules-to-boxel-ui)).** `Button`'s anchor branch currently suppresses the `href` and nothing else — it sets no `disabled` attribute and no `aria-disabled` — so the disabled state is **visual-only for every caller**, and `@disabled={{true}}` on an anchor yields DOM identical to just omitting `href`. Until CS-12305 lands, Case 1 should also pass `aria-disabled={{unless @model.url.length 'true'}}` by hand. Delete that argument once `Button` sets it itself, and delete this note with it.
+
+### Collapse wrapper FieldDefs instead of flattening them with `:deep()`
+
+`:deep()` and `display: contents` are for **host-generated** DOM you cannot remove. If the wrapper is a FieldDef *you* introduced, delete it instead. Two signals it isn't a real grouping level:
+
+- The instance data shows a `containsMany` of wrapper fields that each hold exactly **one** item. That's not a group, it's indirection.
+- You are reaching **across a scoped-style boundary** — a selector in the parent's `
+```
+
+**Right** — a variant that already has no chrome, leaving only genuinely bespoke declarations:
+```gts
+
+
+```
+
+Each cancelling declaration is invisible coupling to the component's current internals: it rots silently when the component changes, and it hides the fact that a purpose-built variant exists. Read the component's API first (see the top of this file) and look through `@kind` / `@variant` / `@size` before writing a single override.
+
+**Component args are not portable between components.** `@as` and `@href` are `Button`'s args. `Pill` has no `@as` — it takes `@tag` (a raw HTML tag name) and receives `href` as a plain attribute through `...attributes`. Never carry one component's arg names to another; check the signature.
+
+### An optional `@href` is a decision, not a detail
+
+`Button @as='anchor'` renders an ``, and an `` with no `href` is not a link — it isn't focusable and reads as generic text. `Button` also *styles* that state as disabled (`a.boxel-button:not([href])`, `[href='']`, `.disabled-link` → `opacity: 0.5`, disabled colour, `pointer-events: none`), so a conditionally-empty `@href` produces a faded element that looks deliberate and passes review as if it were designed.
+
+So whenever a url-ish field is optional, decide which of these the **data model** intends. They look nearly identical on screen; the difference is what the markup claims is true.
+
+**Case 1 — the link is meant to exist but isn't available yet.** Unpublished URL, gated resource, "coming soon". A disabled link is precisely what this is, so say so with `@disabled` rather than letting an absent `href` imply it:
+
+```gts
+
+```
+
+**Case 2 — the field is optional and some items are plain labels.** Nothing is disabled; no action could ever become available. Render a non-anchor element and state the de-emphasis directly, so the appearance isn't coupled to a control state:
+
+```gts
+{{#if @model.url.length}}
+
+{{else}}
+ <@fields.label />
+{{/if}}
+```
+
+When you branch like this the non-component element does **not** inherit the component's `@size` metrics — declare shared `font-size`/`line-height` on the class both branches carry, or the two render at different sizes.
+
+Getting this wrong is not cosmetic. Once `Button` announces its disabled links to assistive tech (below), a Case 2 item written as Case 1 stops being merely silent and starts telling screen-reader users that a plain label is an unavailable link.
+
+> **Version note (2026-07, [CS-12305](https://linear.app/cardstack/issue/CS-12305/upstream-portable-homepage-modules-to-boxel-ui)).** `Button`'s anchor branch currently suppresses the `href` and nothing else — it sets no `disabled` attribute and no `aria-disabled` — so the disabled state is **visual-only for every caller**, and `@disabled={{true}}` on an anchor yields DOM identical to just omitting `href`. Until CS-12305 lands, Case 1 should also pass `aria-disabled={{unless @model.url.length 'true'}}` by hand. Delete that argument once `Button` sets it itself, and delete this note with it.
+
### Drag/drop quality bar
For kanban/status/deal/task boards:
diff --git a/skills/boxel-ui-guidelines/references/use-container-queries-not-viewport-units.md b/skills/boxel-ui-guidelines/references/use-container-queries-not-viewport-units.md
index 7cb1799..6d05095 100644
--- a/skills/boxel-ui-guidelines/references/use-container-queries-not-viewport-units.md
+++ b/skills/boxel-ui-guidelines/references/use-container-queries-not-viewport-units.md
@@ -23,4 +23,36 @@ The host-provided named containers:
For isolated templates, the parent does not provide a named container — declare `container-type: inline-size` with a name on your own root element and use that name in `@container` rules.
+### Override tokens in the query, not the rules
+
+When a value changes at a breakpoint, declare it once as a custom property on the composition root and have the `@container` block reassign only the property. Do not re-declare the rule that consumes it.
+
+**Wrong** — the same value lives in two rule blocks per breakpoint, and every consuming rule has to be repeated:
+```css
+.meta-strip { gap: 1.25rem 2.5rem; }
+@container hero (inline-size <= 500px) {
+ .meta-strip { gap: 1.125rem 1.5rem; } /* duplicated selector + property */
+}
+```
+
+**Right** — the breakpoint block is a short list of value changes:
+```css
+.hero-inner {
+ --meta-strip-gap: 1.25rem 2.5rem;
+ --meta-strip-margin-top: 4.5rem;
+}
+.meta-strip {
+ gap: var(--meta-strip-gap);
+ margin-top: var(--meta-strip-margin-top);
+}
+@container hero (inline-size <= 500px) {
+ .hero-inner {
+ --meta-strip-gap: 1.125rem 1.5rem;
+ --meta-strip-margin-top: 3.5rem;
+ }
+}
+```
+
+This keeps each responsive value in one place, makes the breakpoint block readable as "what changes at this size," and scales without duplicating selectors as breakpoints accumulate. Declare the defaults on the root per the fallback rule in `use-boxel-design-tokens-for-theming.md` — bare `var()` reads below, no inline fallbacks.
+
**Named containers are safer in nested situations.** An anonymous `@container` matches the nearest ancestor with any `container-type`, which could be an unintended intermediate container. `@container fitted-card (...)` skips anonymous containers and always resolves to the nearest ancestor with that specific name — so nested fitted cards each correctly target their own wrapper.