Skip to content
Open
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
90 changes: 90 additions & 0 deletions packages/host/app/components/adorn/adorn-context.gts
Original file line number Diff line number Diff line change
@@ -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 `<AdornContext>`, 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 `<AdornLabel>` / `<AdornSelectChip>` directly inside
// each item.
//
// Usage:
//
// <div class='outer-container'>
// <AdornContext>
// {{#each cards as |card|}}
// <div class={{cn 'my-card adorn-stroke' selected=card.selected}}>
// <AdornLabel><:text>{{card.typeName}}</:text></AdornLabel>
// <AdornSelectChip @selected={{card.selected}} />
// </div>
// {{/each}}
// </AdornContext>
// </div>
interface AdornContextSignature {
Element: HTMLDivElement;
Blocks: {
default: [];
};
}

const AdornContext: TemplateOnlyComponent<AdornContextSignature> = <template>
<div class='adorn-context' ...attributes>
{{yield}}
</div>
<style scoped>
/* `display: contents` so the wrapper is not visually
represented; the CSS variables and `:deep()` rules below still
attach to this element and cascade / match against descendants
normally. The element is also the boundary that
trackLabelOverflow reads (via `cardEl.closest('.adorn-context')`)
so its `getBoundingClientRect()` defines where the label may
extend. */
.adorn-context {
display: contents;

/* Token definitions live with the context, not in the global
stylesheet. --boxel-teal is the light accent shipped by
boxel-ui; the darker accent is exclusive to the Adorn
treatment and used when both hovered and selected. */
--adorn-accent-light: var(--boxel-teal);
--adorn-accent: #00da9f;
}
/* Stroke utility. The consumer applies `.adorn-stroke` to
whichever descendant should carry the outline (typically the
card-like element itself), then drives `.selected` and either
the `:hover` pseudo-class or a `.hovered` class. */
.adorn-context :deep(.adorn-stroke:hover:not(.selected)),
.adorn-context :deep(.adorn-stroke.hovered:not(.selected)) {
box-shadow: 0 0 0 2px var(--adorn-accent-light);
}
.adorn-context :deep(.adorn-stroke.selected) {
box-shadow: 0 0 0 4px var(--adorn-accent-light);
}
.adorn-context :deep(.adorn-stroke.selected:hover),
.adorn-context :deep(.adorn-stroke.selected.hovered) {
box-shadow: 0 0 0 4px var(--adorn-accent);
}
</style>
</template>;

export default AdornContext;
119 changes: 119 additions & 0 deletions packages/host/app/components/adorn/adorn-label.gts
Original file line number Diff line number Diff line change
@@ -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<AdornLabelSignature> = <template>
<div class='adorn-label {{if @compact "compact"}}' ...attributes>
{{#if (has-block 'icon')}}
<span class='adorn-label-icon-slot'>{{yield to='icon'}}</span>
{{/if}}
<span class='adorn-label-text'>{{yield to='text'}}</span>
{{#if (has-block 'dropdown')}}
<span class='adorn-label-dropdown'>{{yield to='dropdown'}}</span>
{{/if}}
</div>
<style scoped>
.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));
}
/* Mirrored polygon when the label flips below the card so the
slope still points toward the card edge. */
.adorn-label[data-side='bottom'] {
clip-path: polygon(0 100%, calc(100% - 13px) 100%, 100% 0, 0 0);
}
.adorn-label.compact {
padding: 2px 10px 2px 5px;
font-size: 9px;
gap: 4px;
}

.adorn-label-icon-slot {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 14px;
height: 14px;
color: #0a2e1c;
}
.adorn-label.compact .adorn-label-icon-slot {
width: 11px;
height: 11px;
}
/* Cascade the slot's size to whatever the consumer puts inside
(typically an SVG icon), so they don't have to size it
themselves. */
.adorn-label-icon-slot > * {
width: 100%;
height: 100%;
}

.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;
}

/* In-tab menu slot. Inline-flex so the menu trigger and its
portal-origin element 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;
}
</style>
</template>;

export default AdornLabel;
83 changes: 83 additions & 0 deletions packages/host/app/components/adorn/adorn-select-chip.gts
Original file line number Diff line number Diff line change
@@ -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<AdornSelectChipSignature> =
<template>
<span class='adorn-select-chip {{if @compact "compact"}}' ...attributes>
{{#if @selected}}
<svg
class='adorn-select-icon'
viewBox='0 0 14 14'
fill='none'
aria-hidden='true'
>
<circle cx='7' cy='7' r='7' fill='#0a2e1c' />
<path
d='M3.5 7.5L5.5 9.5L10.5 4.5'
stroke='currentColor'
stroke-width='1.5'
stroke-linecap='round'
stroke-linejoin='round'
/>
</svg>
{{else}}
<svg
class='adorn-select-icon'
viewBox='-1 -1 16 16'
fill='none'
aria-hidden='true'
>
<circle cx='7' cy='7' r='6.5' stroke='#0a2e1c' stroke-width='1.5' />
</svg>
{{/if}}
</span>
<style scoped>
.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-chip.compact {
width: 16px;
height: 16px;
padding: 2px;
}
.adorn-select-icon {
display: block;
width: 14px;
height: 14px;
}
.adorn-select-chip.compact .adorn-select-icon {
width: 12px;
height: 12px;
}
</style>
</template>;

export default AdornSelectChip;
Loading
Loading