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..1a01555d4f7 --- /dev/null +++ b/packages/host/app/components/adorn/adorn-context.gts @@ -0,0 +1,90 @@ +import type { TemplateOnlyComponent } from '@ember/component/template-only'; + +// AdornContext: the entry point for the Adorn visual treatment. +// 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 +// 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 that drive hover from JS can opt in too). +// - Marks the bounding region for dynamic label positioning. The +// overflow-tracking machinery walks up from the hovered card to +// the closest ``, then uses the **parent** of the +// context as the boundary — AdornContext itself renders as +// `display: contents`, so its own rect is empty, but its parent +// is the visible container the consumer mounted it inside of. +// +// Mount AdornContext as a child of whatever visible element should +// bound label growth (operator-mode mounts it inside the stack +// item's content area; the search results pane mounts it inside the +// search-sheet content; the card chooser inside the catalog modal). +// Then render `` / `` directly inside +// each item. +// +// Usage: +// +//
+// +// {{#each cards as |card|}} +//
+// <:text>{{card.typeName}} +// +//
+// {{/each}} +//
+//
+interface AdornContextSignature { + Element: HTMLDivElement; + Blocks: { + default: []; + }; +} + +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 new file mode 100644 index 00000000000..3b145ed96c6 --- /dev/null +++ b/packages/host/app/components/adorn/adorn-label.gts @@ -0,0 +1,119 @@ +import type { TemplateOnlyComponent } from '@ember/component/template-only'; + +// 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. +// +// 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: { + 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 new file mode 100644 index 00000000000..b432d8c77d5 --- /dev/null +++ b/packages/host/app/components/adorn/adorn-select-chip.gts @@ -0,0 +1,83 @@ +import type { TemplateOnlyComponent } from '@ember/component/template-only'; + +// 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. +// +// `@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 2fb8d3b1905..a1f918ee7fc 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,6 +44,10 @@ import { getMenuItems, } from '@cardstack/runtime-common'; +import AdornContext from '@cardstack/host/components/adorn/adorn-context'; +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 { @@ -65,14 +70,25 @@ import type { StackItemRenderedCardForOverlayActions } from './stack-item'; import type { CardDefOrId } from './stack-item'; import type StoreService from '../../services/store'; -// The label's outward growth should be bounded by the visible frame -// of the operator-mode stack item — that's the box that defines the -// "page" the card is rendered on, and it keeps the label out of the -// chrome around it (sidebar, dialog title bar). Within that frame -// the label is free to extend across sibling cards / columns when -// the hovered card is near an edge. +// The label's outward growth is bounded by the visible outer +// container of adorn-decorated items. We find that container by +// walking up to the closest `` marker, then taking +// its parent element: AdornContext itself renders as +// `display: contents` so it doesn't disrupt the consumer's layout, +// which means its own bounding rect is empty — but its parent is +// the element the consumer mounted the context inside of, and +// that's the visual region we want. function findAdornLabelBoundary(cardEl: HTMLElement): HTMLElement | null { - return cardEl.closest('.stack-item-content'); + let marker = cardEl.closest('.adorn-context'); + return marker?.parentElement ?? null; +} + +// 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 { @@ -91,235 +107,129 @@ export default class OperatorModeOverlays extends Overlays { } @@ -409,6 +294,65 @@ 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(); + + // Single ResizeObserver shared across all overlay elements in this + // component. Each `trackCompact` modifier instance registers its + // overlay element via `observe()` and unregisters via `unobserve()` + // on teardown — so the per-overlay overhead is one WeakMap entry + // and one entry in the observer's observation set, no per-overlay + // ResizeObserver instance. + private overlayToCard = new WeakMap(); + private compactObserver = new ResizeObserver((entries) => { + for (let entry of entries) { + let overlay = entry.target as HTMLElement; + let cardEl = this.overlayToCard.get(overlay); + if (!cardEl) continue; + let { width, height } = entry.contentRect; + let isCompact = shouldRenderCompact(width, height); + if (isCompact && !this.compactCards.has(cardEl)) { + this.compactCards.add(cardEl); + } else if (!isCompact && this.compactCards.has(cardEl)) { + this.compactCards.delete(cardEl); + } + } + }); + + private trackCompact = modifier( + (overlay: HTMLElement, [cardEl]: [HTMLElement | undefined]) => { + if (!cardEl) { + return undefined; + } + this.overlayToCard.set(overlay, cardEl); + this.compactObserver.observe(overlay); + // Seed the initial state; the observer won't have fired yet. + let { width, height } = overlay.getBoundingClientRect(); + if (shouldRenderCompact(width, height)) { + this.compactCards.add(cardEl); + } + return () => { + this.compactObserver.unobserve(overlay); + this.overlayToCard.delete(overlay); + this.compactCards.delete(cardEl); + }; + }, + ); + + willDestroy() { + super.willDestroy?.(); + this.compactObserver.disconnect(); + } + + @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