From 1bbf03b0c284f77180091d8d50f6ff9717d52c5b Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Thu, 28 May 2026 18:27:26 -0400 Subject: [PATCH 1/6] Extract AdornLabel, AdornSelectChip, and shared Adorn CSS tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Adorn UI call sites (operator-mode card overlays in this branch + search-results / card-chooser cards in PR #5009 / #5010) had copy-pasted the visual specification — the teal hover/selection outline, the flag-tab type label, and the selection chip — into their own component files. Pull the shared parts into reusable primitives so the visual contract has a single source. - New `` component renders the teal flag tab. Yields the default block; consumers populate it with `.adorn-label-icon` / `.adorn-label-text` / `.adorn-label-dropdown` children and own positioning. Background reads from `--adorn-label-bg` so consumers can shift to the darker accent when the underlying card is selected. Mirrors its clip-path vertically when the consumer sets `data-side="bottom"` (used by the operator-mode flip-below path). - New `` component renders the 20×20 selection chip with the appropriate SVG icon based on the `@selected` arg. The operator-mode caller wraps it in a button for interactivity; the card-chooser caller will mount it as a decorative element. - `--adorn-accent-light` / `--adorn-accent` color tokens and a new `.adorn-stroke` utility class live in the global `app.css`. Apply `.adorn-stroke` to any card-like element to get the standard 2px hover / 4px selected / darker-on-both outline; the utility reads both `:hover` and a `.hovered` class so consumers that drive hover via JS (e.g. operator-mode overlays, which have `pointer-events: none` themselves) can opt in too. Refactor `operator-mode-overlays.gts` to use the primitives. The trackLabelOverflow modifier, boundary detection, menu, and compact- mode container-query overrides stay in place; the CSS shrinks to just the operator-mode-specific bits (selected→darker variable override, menu trigger sizing, select-button positioning). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../host/app/components/adorn/adorn-label.gts | 79 ++++++++ .../components/adorn/adorn-select-chip.gts | 68 +++++++ .../operator-mode/operator-mode-overlays.gts | 177 ++++-------------- packages/host/app/styles/app.css | 26 +++ 4 files changed, 206 insertions(+), 144 deletions(-) create mode 100644 packages/host/app/components/adorn/adorn-label.gts create mode 100644 packages/host/app/components/adorn/adorn-select-chip.gts diff --git a/packages/host/app/components/adorn/adorn-label.gts b/packages/host/app/components/adorn/adorn-label.gts new file mode 100644 index 00000000000..78d1407de83 --- /dev/null +++ b/packages/host/app/components/adorn/adorn-label.gts @@ -0,0 +1,79 @@ +import type { TemplateOnlyComponent } from '@ember/component/template-only'; + +// AdornLabel: the teal "flag tab" type-label used by the Adorn hover +// treatment. The component renders only the outer shaped chip; the +// caller yields the inner content (typically `.adorn-label-icon`, +// `.adorn-label-text`, and an optional `.adorn-label-dropdown` for an +// in-tab menu) and is responsible for positioning the label and for +// driving the `data-side` attribute when flipping below the card. +// +// Background defaults to `var(--adorn-accent-light)` but can be +// overridden by setting `--adorn-label-bg` on any ancestor (operator- +// mode uses this to switch to the darker accent when the underlying +// card is selected). +interface AdornLabelSignature { + Element: HTMLDivElement; + Blocks: { + default: []; + }; +} + +const AdornLabel: TemplateOnlyComponent = ; + +export default AdornLabel; diff --git a/packages/host/app/components/adorn/adorn-select-chip.gts b/packages/host/app/components/adorn/adorn-select-chip.gts new file mode 100644 index 00000000000..d0c70d37f57 --- /dev/null +++ b/packages/host/app/components/adorn/adorn-select-chip.gts @@ -0,0 +1,68 @@ +import type { TemplateOnlyComponent } from '@ember/component/template-only'; + +// AdornSelectChip: the small teal rounded-square selection chip shown +// at the bottom-right corner of an Adorn-treated card. Renders an +// unfilled circle outline by default and a filled-with-check icon +// when `@selected` is true. Positioning + interactivity are the +// caller's responsibility: operator-mode wraps the chip in a button +// so it can be clicked to toggle selection, while purely-decorative +// callers can mount it as-is. +interface AdornSelectChipSignature { + Args: { + selected?: boolean; + }; + Element: HTMLSpanElement; +} + +const AdornSelectChip: TemplateOnlyComponent = + ; + +export default AdornSelectChip; diff --git a/packages/host/app/components/operator-mode/operator-mode-overlays.gts b/packages/host/app/components/operator-mode/operator-mode-overlays.gts index 2fb8d3b1905..e7a4f3daad4 100644 --- a/packages/host/app/components/operator-mode/operator-mode-overlays.gts +++ b/packages/host/app/components/operator-mode/operator-mode-overlays.gts @@ -43,6 +43,9 @@ import { getMenuItems, } from '@cardstack/runtime-common'; +import AdornLabel from '@cardstack/host/components/adorn/adorn-label'; +import AdornSelectChip from '@cardstack/host/components/adorn/adorn-select-chip'; + import { removeFileExtension } from '@cardstack/host/utils/card-search/types'; import type { @@ -102,7 +105,7 @@ export default class OperatorModeOverlays extends Overlays { {{#if (or isSelected isHovered)}}
{{this.getCardTypeName cardDefOrId renderedCard}} - {{! Wrap BoxelDropdown so the trigger and its - portal-origin element count as a single flex item - of the label. Otherwise the label grows by one - flex-gap as soon as the menu opens (the open-state - wormhole-origin becomes a flex item where the - closed-state placeholder was display none), and - the label would shift on every menu open/close. }} -
+
{{/if}} - {{! Selection indicator — bottom-right rounded square chip }} + {{! Selection indicator — bottom-right rounded square chip. + Wrap AdornSelectChip in a button so it can be clicked + to toggle selection. }} {{#if (this.isButtonDisplayed 'select' renderedCard)}} {{/if}} @@ -230,12 +196,6 @@ export default class OperatorModeOverlays extends Overlays { {{/each}} ; export default AdornLabel; diff --git a/packages/host/app/components/adorn/adorn-select-chip.gts b/packages/host/app/components/adorn/adorn-select-chip.gts index d0c70d37f57..645a8db0561 100644 --- a/packages/host/app/components/adorn/adorn-select-chip.gts +++ b/packages/host/app/components/adorn/adorn-select-chip.gts @@ -5,8 +5,11 @@ import type { TemplateOnlyComponent } from '@ember/component/template-only'; // unfilled circle outline by default and a filled-with-check icon // when `@selected` is true. Positioning + interactivity are the // caller's responsibility: operator-mode wraps the chip in a button -// so it can be clicked to toggle selection, while purely-decorative -// callers can mount it as-is. +// so it can be clicked to toggle selection; purely-decorative callers +// (e.g. the card chooser) mount it as-is. +// +// The visual styling for `.adorn-select-chip` and `.adorn-select-icon` +// lives in `app/styles/app.css`. interface AdornSelectChipSignature { Args: { selected?: boolean; @@ -44,25 +47,6 @@ const AdornSelectChip: TemplateOnlyComponent = {{/if}} - ; export default AdornSelectChip; diff --git a/packages/host/app/components/operator-mode/operator-mode-overlays.gts b/packages/host/app/components/operator-mode/operator-mode-overlays.gts index e7a4f3daad4..7ea334a05d5 100644 --- a/packages/host/app/components/operator-mode/operator-mode-overlays.gts +++ b/packages/host/app/components/operator-mode/operator-mode-overlays.gts @@ -249,20 +249,11 @@ export default class OperatorModeOverlays extends Overlays { display: none; } - /* Compact mode for small atom-format cards. Override sizes on - AdornLabel/AdornSelectChip via :global because those classes - live on elements rendered by the AdornLabel/AdornSelectChip - components and aren't visible to this stylesheet's scope. */ + /* Compact mode — when the actions-overlay is small (atom-format + cards), shrink the operator-mode-specific bits. The shared + AdornLabel / AdornSelectChip compact-mode sizing lives in + app.css and applies via the same @container query. */ @container actions-overlay (aspect-ratio > 2.0) and (height <= 57px) { - :global(.adorn-label) { - padding: 2px 10px 2px 5px; - font-size: 9px; - gap: 4px; - } - :global(.adorn-label-icon) { - width: 11px; - height: 11px; - } .adorn-label-menu { width: 14px; height: 14px; @@ -273,15 +264,6 @@ export default class OperatorModeOverlays extends Overlays { bottom: 2px; right: 2px; } - :global(.adorn-select-chip) { - width: 16px; - height: 16px; - padding: 2px; - } - :global(.adorn-select-icon) { - width: 12px; - height: 12px; - } } diff --git a/packages/host/app/styles/app.css b/packages/host/app/styles/app.css index b490eb674be..29af0c430e0 100644 --- a/packages/host/app/styles/app.css +++ b/packages/host/app/styles/app.css @@ -41,6 +41,111 @@ box-shadow: 0 0 0 4px var(--adorn-accent); } +/* Adorn type-label flag tab. renders the outer div with + class .adorn-label and yields its default block; consumers place + `.adorn-label-icon`, `.adorn-label-text`, and an optional + `.adorn-label-dropdown` (wraps an in-tab menu) inside. + Positioning is the consumer's responsibility. Background reads + `--adorn-label-bg` so any ancestor can override (operator-mode + uses this to switch to the darker accent when the underlying + card is selected). When the label flips below the card the + consumer writes `data-side='bottom'` on the label and the + clip-path mirrors vertically. */ +.adorn-label { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 12px 3px 7px; + background: var(--adorn-label-bg, var(--adorn-accent-light)); + color: #0a2e1c; + font: 700 10px/1 var(--boxel-font-family, inherit); + letter-spacing: 0.5px; + text-transform: uppercase; + white-space: nowrap; + overflow: hidden; + border-radius: 5px 0 0 5px; + clip-path: polygon(0 0, calc(100% - 13px) 0, 100% 100%, 0 100%); + z-index: 1; + filter: drop-shadow(0 5px 8px rgba(0, 0, 0, 0.2)); +} +.adorn-label[data-side='bottom'] { + clip-path: polygon(0 100%, calc(100% - 13px) 100%, 100% 0, 0 0); +} +.adorn-label-icon { + width: 14px; + height: 14px; + flex-shrink: 0; + color: #0a2e1c; +} +.adorn-label-text { + /* `min-width: 0` lets the flex item shrink below its min-content + size when the label is capped by a max-width; without it, + text-overflow:ellipsis can't kick in. */ + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} +/* Wrap any in-tab menu (e.g. BoxelDropdown) in + `` so its trigger and the + portal-origin element it ships count as a single flex item of + the label. Otherwise the label's natural width grows by one + flex-gap when the menu opens (the open-state wormhole-origin + becomes a flex item where the closed-state placeholder was + display:none), shifting the label on every menu open/close. */ +.adorn-label-dropdown { + display: inline-flex; + align-items: center; +} + +/* Adorn selection chip — small teal rounded-square shown at the + card's bottom-right corner. renders the chip + itself; positioning + interactivity (if any) are the consumer's + responsibility. */ +.adorn-select-chip { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + padding: 3px; + border-radius: 5px; + background: var(--adorn-label-bg, var(--adorn-accent-light)); + color: var(--adorn-accent-light); + z-index: 1; +} +.adorn-select-icon { + display: block; + width: 14px; + height: 14px; +} + +/* Compact mode — when an Adorn-treated card is rendered into a small + container (atom-format cards in operator-mode), shrink the label + and chip. The `actions-overlay` container is named by operator- + mode, so this rule only fires inside that context; other Adorn + consumers (search results, card chooser) keep the full-size + primitives. */ +@container actions-overlay (aspect-ratio > 2.0) and (height <= 57px) { + .adorn-label { + padding: 2px 10px 2px 5px; + font-size: 9px; + gap: 4px; + } + .adorn-label-icon { + width: 11px; + height: 11px; + } + .adorn-select-chip { + width: 16px; + height: 16px; + padding: 2px; + } + .adorn-select-icon { + width: 12px; + height: 12px; + } +} + .error { color: var(--boxel-error-100); } From 070e88474b1bda1f9cd1df0f7737928012ca6e5a Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Thu, 28 May 2026 19:45:50 -0400 Subject: [PATCH 3/6] =?UTF-8?q?Encapsulate=20Adorn=20primitives=20?= =?UTF-8?q?=E2=80=94=20no=20:global,=20no=20global=20Adorn=20styles=20in?= =?UTF-8?q?=20app.css?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous extraction relied on `:global(...)` rules inside the AdornLabel / AdornSelectChip scoped CSS to reach yielded children, and on global rules in app.css that any element with the right class name picked up. Both break encapsulation. Restructure so each Adorn primitive owns its own scoped CSS: - New `` component is the entry point. Wraps consumer markup in a `display: contents` div that declares the `--adorn-accent-light` / `--adorn-accent` tokens (cascade only; no globals), and exposes the hover/selection box-shadow utility via `:deep()` rules on any descendant with the `.adorn-stroke` class. The :deep is owned by AdornContext, not by consumers. AdornContext yields `{ Label, SelectChip }` — component references curried with `@compact` so the consumer doesn't have to re-pass it. - `` now uses named blocks (`<:icon>`, `<:text>`, `<:dropdown>`) so every inner element (`.adorn-label-icon-slot`, `.adorn-label-text`, `.adorn-label-dropdown`) is rendered by the component, not by a consumer. Scoped CSS targets them directly, no :global needed. The icon slot cascades its size to its yielded contents via `> *`. - `` is a simple span; its CSS is fully scoped. - Both primitives accept a `@compact` arg and apply a `.compact` modifier class. No more @container queries inside the primitives. - Operator-mode detects compact mode in JS (ResizeObserver on the actions-overlay overlay; checks the aspect-ratio > 2 / height <= 57px threshold the previous @container query used) and passes the result to AdornContext as `@compact`. The same boolean toggles a `.compact` class on the overlay for the operator-mode-specific elements (menu trigger, select-button positioning) that aren't Adorn primitives. - app.css no longer carries any Adorn rules. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../app/components/adorn/adorn-context.gts | 95 ++++++ .../host/app/components/adorn/adorn-label.gts | 120 +++++++- .../components/adorn/adorn-select-chip.gts | 51 +++- .../operator-mode/operator-mode-overlays.gts | 276 ++++++++++-------- packages/host/app/styles/app.css | 131 --------- 5 files changed, 401 insertions(+), 272 deletions(-) create mode 100644 packages/host/app/components/adorn/adorn-context.gts diff --git a/packages/host/app/components/adorn/adorn-context.gts b/packages/host/app/components/adorn/adorn-context.gts new file mode 100644 index 00000000000..33b00e44445 --- /dev/null +++ b/packages/host/app/components/adorn/adorn-context.gts @@ -0,0 +1,95 @@ +import type { TemplateOnlyComponent } from '@ember/component/template-only'; +import { hash } from '@ember/helper'; + +import AdornLabel, { type AdornLabelSignature } from './adorn-label'; +import AdornSelectChip, { + type AdornSelectChipSignature, +} from './adorn-select-chip'; + +import type { ComponentLike } from '@glint/template'; + +// AdornContext: the entry point for the Adorn visual treatment. +// Wraps the consumer's markup in a transparent (display:contents) +// container that: +// +// - Declares the Adorn color tokens (`--adorn-accent-light`, +// `--adorn-accent`) so descendant Adorn primitives pick them up +// by inheritance without polluting the global stylesheet. +// - Provides the hover / selection outline rules. Any descendant +// that carries the `.adorn-stroke` class gets the standard 2px +// teal hover, 4px selected, darker teal selected+hover treatment +// (the rules respond to both `:hover` and an explicit `.hovered` +// class so consumers driving hover from JS can opt in too). +// - Yields `{ Label, SelectChip }` — component references already +// curried with `@compact`, so the consumer can render them +// without re-passing the context's compactness. +// +// Usage: +// +// +//
+// +// <:text>{{cardTypeName}} +// +// +//
+//
+interface AdornContextSignature { + Args: { + compact?: boolean; + }; + Blocks: { + default: [ + { + Label: ComponentLike; + SelectChip: ComponentLike; + }, + ]; + }; +} + +const AdornContext: TemplateOnlyComponent = ; + +export default AdornContext; diff --git a/packages/host/app/components/adorn/adorn-label.gts b/packages/host/app/components/adorn/adorn-label.gts index 57c818c1d96..3b145ed96c6 100644 --- a/packages/host/app/components/adorn/adorn-label.gts +++ b/packages/host/app/components/adorn/adorn-label.gts @@ -1,27 +1,119 @@ import type { TemplateOnlyComponent } from '@ember/component/template-only'; -// AdornLabel: the teal "flag tab" type-label used by the Adorn hover -// treatment. Renders only the outer shaped chip; the caller yields -// the inner content (typically `.adorn-label-icon`, -// `.adorn-label-text`, and an optional `.adorn-label-dropdown` for -// an in-tab menu) and is responsible for positioning the label and -// for driving the `data-side` attribute when flipping below the card. +// AdornLabel: the teal flag-tab type label. Renders an outer div +// shaped like a flag (sloped right edge, rounded left corners), with +// named-block slots for an optional icon, the required type-name +// text, and an optional in-tab dropdown menu. // -// The visual styling (flag clip-path, colors, drop-shadow, the inner -// `.adorn-label-*` content-class rules) lives in `app/styles/app.css` -// alongside the other Adorn primitives, so consumers can place -// children with those class names and the rules apply. -interface AdornLabelSignature { +// All inner content classes (`.adorn-label-icon-slot`, +// `.adorn-label-text`, `.adorn-label-dropdown`) are rendered by this +// component, so the scoped CSS below applies without needing +// `:global()`. The slot wrappers cascade their size to whatever +// content the consumer yields (typically an SVG icon, a string of +// text, and a BoxelDropdown). +// +// `@compact` switches to a smaller variant used inside narrow +// containers (e.g. operator-mode's atom-format cards). +// +// `data-side="bottom"` mirrors the clip-path vertically — used by +// operator-mode when there isn't room above the card and the label +// flips below. +// +// Positioning is the consumer's responsibility. Background reads +// `--adorn-label-bg` so an ancestor can swap to the darker accent +// when the underlying card is selected. +export interface AdornLabelSignature { + Args: { + compact?: boolean; + }; Element: HTMLDivElement; Blocks: { - default: []; + icon?: []; + text: []; + dropdown?: []; }; } const AdornLabel: TemplateOnlyComponent = ; export default AdornLabel; diff --git a/packages/host/app/components/adorn/adorn-select-chip.gts b/packages/host/app/components/adorn/adorn-select-chip.gts index 645a8db0561..b432d8c77d5 100644 --- a/packages/host/app/components/adorn/adorn-select-chip.gts +++ b/packages/host/app/components/adorn/adorn-select-chip.gts @@ -1,25 +1,28 @@ import type { TemplateOnlyComponent } from '@ember/component/template-only'; -// AdornSelectChip: the small teal rounded-square selection chip shown -// at the bottom-right corner of an Adorn-treated card. Renders an +// AdornSelectChip: small teal rounded-square selection chip shown at +// the bottom-right corner of an Adorn-treated card. Renders an // unfilled circle outline by default and a filled-with-check icon -// when `@selected` is true. Positioning + interactivity are the -// caller's responsibility: operator-mode wraps the chip in a button -// so it can be clicked to toggle selection; purely-decorative callers -// (e.g. the card chooser) mount it as-is. +// when `@selected` is true. // -// The visual styling for `.adorn-select-chip` and `.adorn-select-icon` -// lives in `app/styles/app.css`. -interface AdornSelectChipSignature { +// `@compact` shrinks the chip + icon for narrow containers (atom- +// format cards in operator-mode). +// +// Positioning and interactivity are the consumer's responsibility: +// operator-mode wraps the chip in a button so it can be clicked to +// toggle selection; purely-decorative consumers (search results, +// card chooser) mount it as-is. +export interface AdornSelectChipSignature { Args: { selected?: boolean; + compact?: boolean; }; Element: HTMLSpanElement; } const AdornSelectChip: TemplateOnlyComponent = ; export default AdornSelectChip; diff --git a/packages/host/app/components/operator-mode/operator-mode-overlays.gts b/packages/host/app/components/operator-mode/operator-mode-overlays.gts index 7ea334a05d5..88e5906e779 100644 --- a/packages/host/app/components/operator-mode/operator-mode-overlays.gts +++ b/packages/host/app/components/operator-mode/operator-mode-overlays.gts @@ -8,6 +8,7 @@ import { autoUpdate } from '@floating-ui/dom'; import { modifier } from 'ember-modifier'; import { consume } from 'ember-provide-consume-context'; import { velcro } from 'ember-velcro'; +import { TrackedSet } from 'tracked-built-ins'; import type { BoxelDropdownAPI } from '@cardstack/boxel-ui/components'; import { @@ -43,8 +44,7 @@ import { getMenuItems, } from '@cardstack/runtime-common'; -import AdornLabel from '@cardstack/host/components/adorn/adorn-label'; -import AdornSelectChip from '@cardstack/host/components/adorn/adorn-select-chip'; +import AdornContext from '@cardstack/host/components/adorn/adorn-context'; import { removeFileExtension } from '@cardstack/host/utils/card-search/types'; @@ -78,6 +78,14 @@ function findAdornLabelBoundary(cardEl: HTMLElement): HTMLElement | null { return cardEl.closest('.stack-item-content'); } +// Adorn's `@compact` variant shrinks the label and selection chip +// for narrow atom-format cards. The threshold mirrors what the +// previous CSS @container query used: cards that are wider than 2:1 +// and no taller than 57px. +function shouldRenderCompact(width: number, height: number): boolean { + return height > 0 && height <= 57 && width / height > 2.0; +} + export default class OperatorModeOverlays extends Overlays { overlayClassName = 'actions-overlay'; @service declare private store: StoreService; @@ -100,115 +108,117 @@ export default class OperatorModeOverlays extends Overlays { (this.getCardId renderedCard.cardDefOrId) (this.isSelected renderedCard.cardDefOrId) (this.isHovered renderedCard) - as |cardDefOrId cardId isSelected isHovered| + (this.isCompact renderedCard.element) + as |cardDefOrId cardId isSelected isHovered isCompact| }} {{#if (or isSelected isHovered)}} -
- {{! Type-label tab — hover only. trackLabelOverflow - positions the label inline so it stays inside the - containing card's footprint, flipping below the card - when there isn't room above and truncating with an - ellipsis when there isn't room sideways. }} - {{#if isHovered}} - - {{#let - (this.getCardTypeIcon cardDefOrId renderedCard) - as |TypeIcon| - }} - {{#if TypeIcon}} - - {{/if}} - {{/let}} - - {{this.getCardTypeName cardDefOrId renderedCard}} - - - - <:trigger as |bindings|> - - - <:content as |dd|> - - - - - - {{/if}} - - {{! Selection indicator — bottom-right rounded square chip. - Wrap AdornSelectChip in a button so it can be clicked - to toggle selection. }} - {{#if (this.isButtonDisplayed 'select' renderedCard)}} - - {{/if}} -
+ +
+ {{! Type-label tab — hover only. trackLabelOverflow + positions the label so it stays inside the + containing card's footprint, flips below the card + when there isn't room above, and truncates with an + ellipsis when it can't fit sideways. }} + {{#if isHovered}} + + <:icon> + {{#let + (this.getCardTypeIcon cardDefOrId renderedCard) + as |TypeIcon| + }} + {{#if TypeIcon}} + + {{/if}} + {{/let}} + + <:text> + {{this.getCardTypeName cardDefOrId renderedCard}} + + <:dropdown> + + <:trigger as |bindings|> + + + <:content as |dd|> + + + + + + {{/if}} + + {{! Selection indicator — wrap the chip in a button so + it can be clicked to toggle selection. }} + {{#if (this.isButtonDisplayed 'select' renderedCard)}} + + {{/if}} +
+
{{/if}} {{/let}} {{/each}} @@ -280,6 +285,43 @@ export default class OperatorModeOverlays extends Overlays { return 100; } + // Tracks which rendered cards are currently small enough to warrant + // the Adorn compact treatment (narrow atom-format cards). The set + // is updated by `trackCompact` as cards resize; `isCompact` reads + // from it on each render so the template can pass `@compact` to + // AdornContext and toggle a `.compact` class on the overlay. + private compactCards = new TrackedSet(); + + private trackCompact = modifier( + (overlay: HTMLElement, [cardEl]: [HTMLElement | undefined]) => { + if (!cardEl) { + return undefined; + } + let check = () => { + let { width, height } = overlay.getBoundingClientRect(); + if (shouldRenderCompact(width, height)) { + if (!this.compactCards.has(cardEl)) { + this.compactCards.add(cardEl); + } + } else if (this.compactCards.has(cardEl)) { + this.compactCards.delete(cardEl); + } + }; + let observer = new ResizeObserver(check); + observer.observe(overlay); + check(); + return () => { + observer.disconnect(); + this.compactCards.delete(cardEl); + }; + }, + ); + + @action + private isCompact(cardEl: HTMLElement | undefined): boolean { + return cardEl ? this.compactCards.has(cardEl) : false; + } + // Positions the type-label tab manually inside the containing // card's footprint, so its slope stays anchored to the hovered // card and long type-names get truncated with an ellipsis when diff --git a/packages/host/app/styles/app.css b/packages/host/app/styles/app.css index 29af0c430e0..7074e5a8537 100644 --- a/packages/host/app/styles/app.css +++ b/packages/host/app/styles/app.css @@ -13,137 +13,6 @@ --host-search-sheet-z-index: 300; --host-workspace-chooser-z-index: 200; - - /* Adorn palette — the teal-accented hover / selection visual used - by the workspace cards-grid overlays, the search sheet, and the - card chooser. --boxel-teal (#00ffba) is the light accent shipped - by boxel-ui; the darker accent is exclusive to the Adorn treatment - and is used when both hovered and selected. */ - --adorn-accent-light: var(--boxel-teal); - --adorn-accent: #00da9f; -} - -/* Adorn stroke utility. Apply this class to any card-like element - to get the Adorn outline: 2px teal stroke on hover, 4px on - selection, darker teal when both. Hover is read from both the - `:hover` pseudo-class and an explicit `.hovered` class so consumers - that track hover via JS (e.g. when the element itself has - `pointer-events: none`) can drive the state too. */ -.adorn-stroke:hover:not(.selected), -.adorn-stroke.hovered:not(.selected) { - box-shadow: 0 0 0 2px var(--adorn-accent-light); -} -.adorn-stroke.selected { - box-shadow: 0 0 0 4px var(--adorn-accent-light); -} -.adorn-stroke.selected:hover, -.adorn-stroke.selected.hovered { - box-shadow: 0 0 0 4px var(--adorn-accent); -} - -/* Adorn type-label flag tab. renders the outer div with - class .adorn-label and yields its default block; consumers place - `.adorn-label-icon`, `.adorn-label-text`, and an optional - `.adorn-label-dropdown` (wraps an in-tab menu) inside. - Positioning is the consumer's responsibility. Background reads - `--adorn-label-bg` so any ancestor can override (operator-mode - uses this to switch to the darker accent when the underlying - card is selected). When the label flips below the card the - consumer writes `data-side='bottom'` on the label and the - clip-path mirrors vertically. */ -.adorn-label { - display: inline-flex; - align-items: center; - gap: 5px; - padding: 3px 12px 3px 7px; - background: var(--adorn-label-bg, var(--adorn-accent-light)); - color: #0a2e1c; - font: 700 10px/1 var(--boxel-font-family, inherit); - letter-spacing: 0.5px; - text-transform: uppercase; - white-space: nowrap; - overflow: hidden; - border-radius: 5px 0 0 5px; - clip-path: polygon(0 0, calc(100% - 13px) 0, 100% 100%, 0 100%); - z-index: 1; - filter: drop-shadow(0 5px 8px rgba(0, 0, 0, 0.2)); -} -.adorn-label[data-side='bottom'] { - clip-path: polygon(0 100%, calc(100% - 13px) 100%, 100% 0, 0 0); -} -.adorn-label-icon { - width: 14px; - height: 14px; - flex-shrink: 0; - color: #0a2e1c; -} -.adorn-label-text { - /* `min-width: 0` lets the flex item shrink below its min-content - size when the label is capped by a max-width; without it, - text-overflow:ellipsis can't kick in. */ - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; -} -/* Wrap any in-tab menu (e.g. BoxelDropdown) in - `` so its trigger and the - portal-origin element it ships count as a single flex item of - the label. Otherwise the label's natural width grows by one - flex-gap when the menu opens (the open-state wormhole-origin - becomes a flex item where the closed-state placeholder was - display:none), shifting the label on every menu open/close. */ -.adorn-label-dropdown { - display: inline-flex; - align-items: center; -} - -/* Adorn selection chip — small teal rounded-square shown at the - card's bottom-right corner. renders the chip - itself; positioning + interactivity (if any) are the consumer's - responsibility. */ -.adorn-select-chip { - display: inline-flex; - align-items: center; - justify-content: center; - width: 20px; - height: 20px; - padding: 3px; - border-radius: 5px; - background: var(--adorn-label-bg, var(--adorn-accent-light)); - color: var(--adorn-accent-light); - z-index: 1; -} -.adorn-select-icon { - display: block; - width: 14px; - height: 14px; -} - -/* Compact mode — when an Adorn-treated card is rendered into a small - container (atom-format cards in operator-mode), shrink the label - and chip. The `actions-overlay` container is named by operator- - mode, so this rule only fires inside that context; other Adorn - consumers (search results, card chooser) keep the full-size - primitives. */ -@container actions-overlay (aspect-ratio > 2.0) and (height <= 57px) { - .adorn-label { - padding: 2px 10px 2px 5px; - font-size: 9px; - gap: 4px; - } - .adorn-label-icon { - width: 11px; - height: 11px; - } - .adorn-select-chip { - width: 16px; - height: 16px; - padding: 2px; - } - .adorn-select-icon { - width: 12px; - height: 12px; - } } .error { From 147a251c7de906f826a9efadd2d16d7d11ef0299 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Thu, 28 May 2026 20:11:36 -0400 Subject: [PATCH 4/6] AdornContext: align with the outer container, simplify the API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AdornContext now wraps the outer container of adorn-decorated items (operator-mode wraps its entire overlay each-loop in one AdornContext rather than wrapping each card individually) and stands in as the positioning boundary that trackLabelOverflow reads. - AdornContext stays layout-transparent (`display: contents`); it just publishes the Adorn tokens, the `:deep(.adorn-stroke)` outline utility, and a stable `.adorn-context` class for trackLabelOverflow to anchor against. The boundary lookup in findAdornLabelBoundary now finds the closest `.adorn-context` instead of `.stack-item-content`, which means any caller can decide where the label growth region begins by mounting the context. - AdornContext no longer yields curried `Label` / `SelectChip`. Consumers import `` / `` directly and pass `@compact` per item — operator-mode's compactness is per-card anyway, so the list-level curry was over-fitted. - Operator-mode-overlays wraps its `{{#each}}` in a single AdornContext and passes the per-card `@compact` directly to the primitive components. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../app/components/adorn/adorn-context.gts | 70 ++++++++----------- .../operator-mode/operator-mode-overlays.gts | 61 ++++++++-------- 2 files changed, 62 insertions(+), 69 deletions(-) diff --git a/packages/host/app/components/adorn/adorn-context.gts b/packages/host/app/components/adorn/adorn-context.gts index 33b00e44445..712223a332c 100644 --- a/packages/host/app/components/adorn/adorn-context.gts +++ b/packages/host/app/components/adorn/adorn-context.gts @@ -1,16 +1,10 @@ import type { TemplateOnlyComponent } from '@ember/component/template-only'; -import { hash } from '@ember/helper'; - -import AdornLabel, { type AdornLabelSignature } from './adorn-label'; -import AdornSelectChip, { - type AdornSelectChipSignature, -} from './adorn-select-chip'; - -import type { ComponentLike } from '@glint/template'; // AdornContext: the entry point for the Adorn visual treatment. -// Wraps the consumer's markup in a transparent (display:contents) -// container that: +// Wraps the consumer's outer container of Adorn-decorated items +// (the operator-mode overlay row, the search-results list, the card- +// chooser grid) in a layout-transparent (`display: contents`) div +// that: // // - Declares the Adorn color tokens (`--adorn-accent-light`, // `--adorn-accent`) so descendant Adorn primitives pick them up @@ -19,49 +13,45 @@ import type { ComponentLike } from '@glint/template'; // that carries the `.adorn-stroke` class gets the standard 2px // teal hover, 4px selected, darker teal selected+hover treatment // (the rules respond to both `:hover` and an explicit `.hovered` -// class so consumers driving hover from JS can opt in too). -// - Yields `{ Label, SelectChip }` — component references already -// curried with `@compact`, so the consumer can render them -// without re-passing the context's compactness. +// class so consumers that drive hover from JS can opt in too). +// - Marks the bounding region for dynamic label positioning. The +// `` component's overflow-tracking modifier finds +// the closest enclosing `.adorn-context` and uses that +// element's bounding rect as the boundary the label must stay +// inside. +// +// Consumers wrap a list of adornable items once and render +// `` / `` directly inside each item. // // Usage: // -// -//
-// -// <:text>{{cardTypeName}} -// -// -//
+// +// {{#each cards as |card|}} +//
+// <:text>{{card.typeName}} +// +//
+// {{/each}} //
interface AdornContextSignature { - Args: { - compact?: boolean; - }; + Element: HTMLDivElement; Blocks: { - default: [ - { - Label: ComponentLike; - SelectChip: ComponentLike; - }, - ]; + default: []; }; } const AdornContext: TemplateOnlyComponent =