From 122f485403164ed120fbb58db50394bfa8475d0e Mon Sep 17 00:00:00 2001 From: Burcu Noyan Date: Fri, 24 Jul 2026 17:56:41 -0400 Subject: [PATCH 1/2] Record boxel-ui component-selection and field-nesting rules Five conventions, all found while fixing the boxel.ai hero meta strip (CS-12316), where a spacing ticket turned into a structural one. Authored into both trees per the double-authoring invariant in README: skills/boxel-ui-guidelines/references/* and Skill/boxel-ui-guidelines.md. - Don't neutralize a component, pick the variant. 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. + +**Gotcha — an `` with no `href` renders as *disabled*.** `Button` treats `a.boxel-button:not([href])`, `[href='']`, and `.disabled-link` as a disabled link: `opacity: 0.5`, disabled color, `pointer-events: none`. So `@as='anchor'` with a conditionally-empty `@href` silently produces a faded, disabled-looking element — and that fade often *looks* like a nice de-emphasis, so it survives review as if it were designed. + +Never leave it implicit. Decide which case you're in, because they want different markup: + +**Case 1 — the link is meant to exist but isn't available yet** (unpublished URL, gated resource, "coming soon"). A disabled link is exactly what this is, so say so. Pass `@disabled` explicitly, and add `aria-disabled` yourself — `Button`'s anchor branch only suppresses the `href`, it sets no `disabled` attribute and no `aria-disabled`, so without it the state is visual-only: + +```gts + +``` + +Note `@disabled={{true}}` on an anchor produces DOM identical to just omitting `href` — its whole value is that the template now states the intent instead of leaving a reader to infer it. + +**Case 2 — the field is optional and some items are plain labels.** Nothing is disabled; there is no action that could become available. Render a non-anchor element and state the de-emphasis directly, so the appearance isn't coupled to a control state that may get restyled later: + +```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. + +Ask which case the *data model* intends, not which looks better — they render nearly identically, so the only real difference is what the code claims is true. + +### 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. + +**Gotcha — an `` with no `href` renders as *disabled*.** `Button` treats `a.boxel-button:not([href])`, `[href='']`, and `.disabled-link` as a disabled link: `opacity: 0.5`, disabled color, `pointer-events: none`. So `@as='anchor'` with a conditionally-empty `@href` silently produces a faded, disabled-looking element — and that fade often *looks* like a nice de-emphasis, so it survives review as if it were designed. + +Never leave it implicit. Decide which case you're in, because they want different markup: + +**Case 1 — the link is meant to exist but isn't available yet** (unpublished URL, gated resource, "coming soon"). A disabled link is exactly what this is, so say so. Pass `@disabled` explicitly, and add `aria-disabled` yourself — `Button`'s anchor branch only suppresses the `href`, it sets no `disabled` attribute and no `aria-disabled`, so without it the state is visual-only: + +```gts + +``` + +Note `@disabled={{true}}` on an anchor produces DOM identical to just omitting `href` — its whole value is that the template now states the intent instead of leaving a reader to infer it. + +**Case 2 — the field is optional and some items are plain labels.** Nothing is disabled; there is no action that could become available. Render a non-anchor element and state the de-emphasis directly, so the appearance isn't coupled to a control state that may get restyled later: + +```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. + +Ask which case the *data model* intends, not which looks better — they render nearly identically, so the only real difference is what the code claims is true. + ### 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. From 007953dce42a1b91191eab063a7c60bb250f9ae5 Mon Sep 17 00:00:00 2001 From: Burcu Noyan Date: Fri, 24 Jul 2026 18:12:55 -0400 Subject: [PATCH 2/2] Make the optional-href guidance outlive the boxel-ui fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gotcha was written around boxel-ui's current internals — "Button sets no aria-disabled, add it yourself", "@disabled on an anchor is identical DOM". CS-12305 will falsify all of that, and the worst of it is the instruction: it would keep teaching a workaround for a fixed bug. Restructured so the durable part leads and the perishable part is fenced: - The rule is now "an optional @href is a decision, not a detail" — Case 1 (link exists, unavailable) vs Case 2 (optional field, plain label), decided by what the data model intends. That holds at any boxel-ui version. - The mechanism moved into a dated, ticket-linked version note that tells the reader to delete it, and itself, once CS-12305 lands. - Added why this matters more after the fix, not less: today a Case 2 item written as Case 1 is silent to assistive tech; afterwards it actively announces a plain label as an unavailable link. - Checklist item reworded off the current behaviour too. CS-12305 now lists the three files to clean up when that bullet ships, so the note can't rot unnoticed. Co-Authored-By: Claude Opus 5 --- Skill/boxel-ui-guidelines.md | 21 ++++++++++--------- .../references/checklist.md | 2 +- .../references/use-boxel-ui-components.md | 19 +++++++++-------- 3 files changed, 22 insertions(+), 20 deletions(-) diff --git a/Skill/boxel-ui-guidelines.md b/Skill/boxel-ui-guidelines.md index 9931208..f6c9a7d 100644 --- a/Skill/boxel-ui-guidelines.md +++ b/Skill/boxel-ui-guidelines.md @@ -753,11 +753,13 @@ Each cancelling declaration is invisible coupling to the component's current int **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. -**Gotcha — an `` with no `href` renders as *disabled*.** `Button` treats `a.boxel-button:not([href])`, `[href='']`, and `.disabled-link` as a disabled link: `opacity: 0.5`, disabled color, `pointer-events: none`. So `@as='anchor'` with a conditionally-empty `@href` silently produces a faded, disabled-looking element — and that fade often *looks* like a nice de-emphasis, so it survives review as if it were designed. +### An optional `@href` is a decision, not a detail -Never leave it implicit. Decide which case you're in, because they want different markup: +`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. -**Case 1 — the link is meant to exist but isn't available yet** (unpublished URL, gated resource, "coming soon"). A disabled link is exactly what this is, so say so. Pass `@disabled` explicitly, and add `aria-disabled` yourself — `Button`'s anchor branch only suppresses the `href`, it sets no `disabled` attribute and no `aria-disabled`, so without it the state is visual-only: +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 ``` -Note `@disabled={{true}}` on an anchor produces DOM identical to just omitting `href` — its whole value is that the template now states the intent instead of leaving a reader to infer it. - -**Case 2 — the field is optional and some items are plain labels.** Nothing is disabled; there is no action that could become available. Render a non-anchor element and state the de-emphasis directly, so the appearance isn't coupled to a control state that may get restyled later: +**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}} @@ -785,9 +784,11 @@ Note `@disabled={{true}}` on an anchor produces DOM identical to just omitting ` {{/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. +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. -Ask which case the *data model* intends, not which looks better — they render nearly identically, so the only real difference is what the code claims is true. +> **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()` @@ -838,7 +839,7 @@ Before finalizing any card template, verify: - [ ] Prefers `<@fields.field />` for all simple field rendering; `@model.x` for conditionals, HTML attributes, context-specific fallback value, and JS getters - [ ] Custom HTML/CSS replaced with existing boxel-ui components wherever possible - [ ] No overrides that cancel a boxel-ui component's own defaults (`padding: 0`, `background: none`, `border: none` on a `Pill`/`Button`) — pick the `@kind`/`@variant`/`@size` that already has no chrome (e.g. `Button @kind='link-muted'`) and keep only genuinely bespoke declarations -- [ ] No `@as='anchor'` with a silently-empty `@href` — `Button` styles a hrefless `` as a *disabled* link (`opacity: 0.5`, disabled color, `pointer-events: none`), so the state is never stated. Either the link is genuinely unavailable (pass `@disabled` **and** `aria-disabled`, which Button's anchor branch does not set) or the item is a plain label (render a non-anchor element, with shared `font-size`/`line-height` so both branches match) +- [ ] An optional `@href` on `Button @as='anchor'` is resolved deliberately, not left to imply itself — either the link genuinely exists but is unavailable (pass `@disabled`) or the item is a plain label (render a non-anchor element, with shared `font-size`/`line-height` so both branches match). A hrefless `` is not a link and is styled as disabled either way, so silence here misstates the item - [ ] Primitive grays (`--boxel-100`…`--boxel-700`) not used as text color — they don't flip with dark mode and go illegible; muted text is `var(--muted-foreground)`, relative de-emphasis is `opacity` or `color-mix(… currentColor …)` - [ ] `:deep()` / `display: contents` used only on host-generated field DOM — a wrapper FieldDef you own (especially a `containsMany` of wrappers each holding one item, or anything needing a cross-scope selector into a child's `