Skip to content

Adorn hover bar: anchor left when it fits, clamp to right corner radius when it doesn't#4999

Merged
lukemelia merged 21 commits into
mainfrom
cs-11280-adorn-hover-bar-overhangs-on-the-right-for-long-card-type
May 29, 2026
Merged

Adorn hover bar: anchor left when it fits, clamp to right corner radius when it doesn't#4999
lukemelia merged 21 commits into
mainfrom
cs-11280-adorn-hover-bar-overhangs-on-the-right-for-long-card-type

Conversation

@lukemelia
Copy link
Copy Markdown
Contributor

@lukemelia lukemelia commented May 27, 2026

Summary

The Adorn hover type-label tab now uses a two-phase anchor:

  • While the label's natural width fits, it hugs the card's top-left corner with a 4px bleed that lines up with the selection stroke.
  • When it doesn't fit, its right edge pins to the start of the card's top-right corner radius and the extra width spills off the card's left edge — so long card-type names never collide with the rounded corner.
  • The label stays inside the visible stack-item frame: it flips below the card when there isn't room above, and truncates with an ellipsis when it can't fit at natural width without escaping the panel into surrounding chrome (operator-mode sidebar, dialog title bar). Within the panel, intra-panel overlap with sibling cards is allowed.

Resolves CS-11280.

Implementation

  • A trackLabelOverflow ember-modifier on the label computes position directly from the rendered card's rect and the visible stack-item frame's rect, then writes left, top, max-width, and a data-side attribute on the label inline.
  • The boundary is .stack-item-content — the visible top-level frame for the panel the card is rendered on. Sibling cards / columns within that frame don't constrain the label; the chrome around it does.
  • Anchor decision: short labels use left = card.left − 4. Long labels (natural width > card.width − corner-radius + 4) anchor their right edge to card.right − (corner-radius − 4) and grow leftward, with a 4px hysteresis so sub-pixel wobble can't flip the placement on every layout pass.
  • Width: when the label's natural width fits the boundary, max-width: max-content so the browser sizes to the true intrinsic width (writing back the integer-rounded scrollWidth would shave a sub-pixel remainder and trip text-overflow: ellipsis even with room to spare). Only when the label actually has to clamp against the boundary do we write a measured pixel value, which is the case where ellipsis is the desired outcome.
  • Side: prefer above; flip below when there's not enough room above the card inside the boundary. The [data-side="bottom"] attribute drives a CSS rule that mirrors the clip-path polygon vertically so the slope still points toward the card.
  • The label is wrapped around BoxelDropdown with an inline-flex <span> so the dropdown's trigger and its portal-origin counts as a single flex item of the label — without it 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 is display: none), shifting the label on every menu open.
  • autoUpdate from @floating-ui/dom re-fires the position update on scroll, resize, and ancestor mutations; the placement math is direct (floating-ui's flip + shift + size middleware aren't a clean fit for the right-anchored-with-truncation pattern, and limitShift doesn't actually cap shift distance the way it reads).

Tests

  • Integration test overlay-menu-items-test.gts asserts both anchor phases (short → left-anchored at card.left − 4, long → right edge clamped to the corner-radius point and overflowing past card.left) and that the label stays inside the containing-card frame either way.

Demo card

  • packages/experiments-realm/adorn-overflow-demo.gts renders a 3-column kanban with deliberately long task-card type names so the long-label / flip-below / boundary-clamp behavior can be reproduced by hand.

Test plan

  • Integration tests pass.
  • Manually verified on a card with a long type name that the tab grows rightward up to the corner radius and then spills off the left as the name lengthens further.
  • Manually verified that opening the 3-dot menu does not shift the label horizontally.
  • Manually verified that a label near the top of a panel flips below the card instead of intruding on the panel chrome.
Screen.Recording.2026-05-28.at.11.03.23.AM.mov

🤖 Generated with Claude Code

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 27, 2026

Preview deployments

Host Test Results

    1 files  ±0      1 suites  ±0   1h 48m 43s ⏱️ +5s
2 841 tests ±0  2 826 ✅ ±0  15 💤 ±0  0 ❌ ±0 
2 860 runs  ±0  2 845 ✅ ±0  15 💤 ±0  0 ❌ ±0 

Results for commit a01d6a3. ± Comparison against earlier commit 9da71e1.

Realm Server Test Results

    1 files  ±0      1 suites  ±0   12m 9s ⏱️ -9s
1 515 tests ±0  1 514 ✅ ±0  1 💤 ±0  0 ❌ ±0 
1 606 runs  ±0  1 605 ✅ ±0  1 💤 ±0  0 ❌ ±0 

Results for commit a01d6a3. ± Comparison against earlier commit 9da71e1.

The type-label tab was anchored to the card's top-left corner, so long
card type names overflowed off the right edge. Anchor it to the top-right
corner instead so any overhang happens off the left edge of the card.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@lukemelia lukemelia force-pushed the cs-11280-adorn-hover-bar-overhangs-on-the-right-for-long-card-type branch from d3ae0a2 to 31b3636 Compare May 28, 2026 14:32
…n it doesn't

The previous fix flipped the tab to a right anchor so long names spilled
off the left edge instead of the right. Designers wanted a two-phase
behavior: the tab should hug the card's top-left corner while it fits
and only switch to anchoring against the start of the card's top-right
corner radius (with the extra width spilling off the left) once a left
anchor would put its right edge into the rounded corner.

CSS positioning alone can't express this because alignment direction
and overflow direction are tied. The fix:

- The velcro offset middleware now also exposes the card's
  border-top-right-radius as the --card-corner-radius CSS variable on
  the overlay, so chrome anchored above the card has a single source
  of truth for where the rounded corner begins.
- A ResizeObserver-based modifier on the label computes the right
  positioning in JS: when the label's natural width fits the available
  space (overlay clientWidth - corner radius + 4px stroke bleed), it
  sets left to -4px; otherwise it writes an integer-pixel left value
  that pins the right edge to the corner-radius point and lets the
  extra width spill off the card's left edge. The integer-pixel inline
  left avoids the dropdown-click jitter that a CSS right: calc(...)
  anchor would inherit from sub-pixel overlay-width recomputations
  velcro's autoUpdate performs on DOM mutations.
- The integration test now asserts the new two-phase behavior:
  left-anchored when the label fits, right-edge clamped to the corner
  radius (and overflowing past the overlay's left edge) when it
  doesn't.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@lukemelia lukemelia changed the title Right-justify Adorn hover bar so long names overflow off the left edge Adorn hover bar: anchor left when it fits, clamp to corner radius when it doesn't May 28, 2026
@lukemelia lukemelia requested a review from Copilot May 28, 2026 15:24
@lukemelia lukemelia changed the title Adorn hover bar: anchor left when it fits, clamp to corner radius when it doesn't Adorn hover bar: anchor left when it fits, clamp to right corner radius when it doesn't May 28, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Updates the Adorn hover type-label tab to use a two-phase anchor: it hugs the card's top-left corner when its content fits, and pins its right edge to the start of the top-right corner radius (overflowing leftward) when it doesn't. The decision is made in JS using a ResizeObserver and integer pixel measurements with hysteresis to avoid sub-pixel flicker, and the velcro overlay now exposes the card's top-right border radius as a --card-corner-radius CSS variable so the label can compute its anchor.

Changes:

  • Surface --card-corner-radius (from the reference card's border-top-right-radius) on the floating overlay element in the velcro offset middleware.
  • Add a trackLabelOverflow ember-modifier on the type-label tab that toggles a data-overflow attribute and writes an integer-pixel inline left based on overlay width, corner radius, and label scrollWidth, with 4px hysteresis.
  • Add an integration test asserting both the fitting (left-anchored) and overflowing (right-clamped-to-radius) phases.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.

File Description
packages/host/app/components/operator-mode/overlays.gts Exposes --card-corner-radius from the reference card via the velcro middleware.
packages/host/app/components/operator-mode/operator-mode-overlays.gts Adds the trackLabelOverflow modifier and updates the adorn-label comments to reflect JS-driven positioning.
packages/host/tests/integration/components/overlay-menu-items-test.gts Adds integration test covering both anchor phases of the hover type-label tab.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@lukemelia lukemelia marked this pull request as ready for review May 28, 2026 15:35
@lukemelia lukemelia requested review from a team and backspace May 28, 2026 15:35
@habdelra
Copy link
Copy Markdown
Contributor

habdelra commented May 28, 2026

is there a scenario where the tile you are adorning is already on the left side of the isolated view, in that case wouldn't the text get cropped? it feels like you are relying on the geometry of cards grid specifically to prevent that from happening--but that's really just one particular way of rendering tiles such that you always have space on the left. Perhaps something like the kanban board view is not so generous with its left side. its not too hard to conceive of a card that has no space on the left for the adorn title to overflow.

lukemelia and others added 13 commits May 28, 2026 12:10
…pping

The Adorn hover-label tab grows leftward off a card's left edge once its
content would otherwise push its right edge into the card's top-right
corner radius. That spill assumes the card has room to its left. A
narrow kanban column with overflow:hidden (typical for vertically
scrolling columns) clips the leftward growth at the column boundary, so
the type-name truncates before it's fully readable.

This card renders a 3-column kanban with narrow overflow:hidden columns
and a child card class whose displayName is deliberately long, so the
clipping is immediately reproducible by hovering any task.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The six ProjectInitiativeTask instance JSONs reference the class by
name in adoptsFrom, so it has to be a named export of the module, not
a private declaration inside it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ide the containing card

Previously the label used its own custom measurement + inline-left logic
to pick between a left anchor and a right-at-corner-radius anchor.
That kept it on top of the card but couldn't keep it inside the card's
container — a long type-name in a tightly-bounded layout (kanban column,
dialog content area, top edge of a stack item) spilled into chrome that
should not be overlapped.

Drive position with @floating-ui/dom instead, anchored to the rendered
card element:

- The placement decision (left-anchored vs right-anchored at the
  corner-radius point) is unchanged — short labels still hug the top-
  left corner, long ones still pin their right edge to the corner-
  radius point and grow leftward. A 4px hysteresis band still keeps
  sub-pixel wobble from flipping the decision.
- `flip` switches the label below the card when there's no room above.
  A custom middleware writes a `data-side` attribute on the label;
  CSS mirrors the clip-path vertically so the slope keeps pointing
  toward the card.
- `shift` slides the label horizontally to stay inside the boundary.
- `size` caps `max-width` to whatever's still available after shifting;
  text-overflow:ellipsis truncates the type-name rather than letting
  the label spill into surrounding chrome.

The boundary is the closest enclosing rendered-card wrapper
(`[data-boxel-card-id]`) — i.e. the card this card is embedded in —
falling back to `.stack-item-content` for top-level cards. Sibling
content within the same containing card is fair game (the long-name
label may visually overlap a sibling embedded card); operator-mode
chrome around the containing card is not.

Drop the `--card-corner-radius` CSS variable that the overlay's offset
middleware was writing — floating-ui no longer needs it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…red card

Without a limit, shift was happily dragging a long-name label across
multiple sibling columns to fit it inside the containing card, leaving
the tab's slope pointing at a card the cursor wasn't on. Use
limitShift with a 16px budget so the label can still nudge a bit when
it's right at the boundary, but beyond that the size middleware
truncates with an ellipsis and the slope stays anchored above the
hovered card.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…irectly

floating-ui's flip+shift+size middlewares aren't a clean fit for the
right-anchored-with-truncation pattern this label needs:

- limitShift({ offset }) doesn't cap shift distance — its `offset`
  is a minimum reference/floating overlap, so for a long label and a
  narrow card the resulting bounds let shift drag the label all the
  way across the boundary.
- Even if shift were tightly limited, the size middleware reports
  the full boundary width as availableWidth whenever shift.enabled.x
  is true, so the label still wouldn't truncate.

Compute the placement directly: read the card and boundary rects,
pick top vs bottom from the available vertical space, pick
left-anchored vs right-anchored from whether the natural label
width fits the card's interior, then clamp the un-anchored edge
against the boundary and write `max-width` so CSS
text-overflow:ellipsis truncates the type-name. autoUpdate from
floating-ui is still useful as the re-fire trigger on scroll /
resize / ancestor mutations.

Net effect on the kanban demo: hovering a BACKLOG card now keeps
the label's slope above that card, and the type-name truncates
with an ellipsis rather than spilling across into IN PROGRESS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…e card

In the !shouldOverflow branch (label fits within the card's interior
by construction), we were still doing
  anchorRightX = min(boundaryRect.right - 4, anchorLeftX + labelWidth)
which would shave a few pixels off whenever the boundary's right edge
sat just past the card's, triggering text-overflow:ellipsis on labels
that obviously had room to spare.

When the label fits the card, the label fits the card — no need to
also clamp against the boundary on that side. Use the natural width
and let it render in full. The overflow case still clamps against the
boundary so the ellipsis kicks in when the label would actually spill
outside the containing card.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`scrollWidth` is integer-rounded, so a label whose natural width is
141.5px reads back as 141 and writing `max-width: 141px` shaves the
sub-pixel remainder off — enough to trigger `text-overflow: ellipsis`
on a short label (GARDEN ITEM, ISSUE, THEME) that obviously has room
to spare.

In the !shouldOverflow branch the browser already knows the natural
width better than we can measure it, so let it pick: write
`max-width: max-content` and the label sizes to its true intrinsic
width without any rounding. The overflow branch still uses a measured
pixel value because that's where ellipsis-on-purpose lives.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous boundary was the nearest enclosing rendered-card wrapper
(`[data-boxel-card-id]`), which for a card embedded inside another
card just barely wider than itself ends up clamping the label against
the parent-card's left edge — triggering the ellipsis on a label that
had plenty of empty space available a few pixels further left, in a
sibling card or column.

Switch to bounding by `.stack-item-content` — the visible frame of
the operator-mode stack item, which is the same panel the card is
rendered on. Within that frame the label is free to extend across
sibling cards / columns when the hovered card sits near an edge (the
overlap case is the one we previously accepted as fine), and the
frame still keeps the label out of the chrome around it (sidebar,
dialog title bar) which was the original motivation for bounding.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Print the values trackLabelOverflow is working with on each update —
scrollWidth, card rect, boundary rect + element, computed available
width, shouldOverflow decision, and the max-width that gets applied —
so we can see which input is wrong when the label ellipsizes a label
that obviously had room to spare. To be reverted once the underlying
issue is understood.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
So dev-tools console output is copy-paste friendly instead of
collapsed {…} objects.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…fits in boundary

Logged diagnostics on a long-name hover showed scrollWidth=204,
unclamped anchorLeft=1381, boundary.left=953 — i.e. the label's
natural width fit fully inside the boundary. But my overflow branch
unconditionally wrote `max-width: 204px` (the integer-rounded
scrollWidth), shaving the sub-pixel remainder and tripping
text-overflow:ellipsis on a label that obviously had room to spare.

Split the overflow branch: when the natural label width fits within
the boundary (unclamped anchorLeft >= boundary.left + padding), use
max-content so the browser picks the true intrinsic width. Only when
the label actually has to be clamped against the boundary do we write
a measured pixel value — that's the case where ellipsis is the
desired outcome.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The chip lost its border-radius along the way when the clip-path's
in-polygon bevels (from the earlier anti-aliasing-seam fix) were
inadvertently kept while the rounded look was actually desired.
Restore `border-radius: 5px 0 0 5px` so the left corners are properly
rounded.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Resolved overlay-related conflicts in favor of HEAD:

- `packages/host/app/components/operator-mode/operator-mode-overlays.gts`:
  main carried PR #5000's clip-path-with-4px-bevels approach (no
  border-radius, to eliminate an anti-aliasing seam). HEAD restores
  `border-radius: 5px 0 0 5px` per the design intent on this branch.
  Kept HEAD's CSS — restored radius + simpler slope-only clip-path.

- `packages/host/tests/integration/components/overlay-menu-items-test.gts`:
  main's seam-guard test asserts `borderTopLeftRadius === '0px'`,
  which is the opposite of HEAD's design intent. Kept HEAD's
  positioning test (anchors-left-while-fits / clamps-to-corner-radius-
  on-overflow / stays-inside-containing-card) and dropped the
  seam-guard test, since the rounded corners are intentional.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@lukemelia
Copy link
Copy Markdown
Contributor Author

@habdelra thanks for pushing me on that. I've totally revised it to use floating-ui with middleware to handle these scenarios gracefully. Here's an updated video:

Screen.Recording.2026-05-28.at.4.16.20.PM.mov

@lukemelia lukemelia requested a review from habdelra May 28, 2026 20:23
@backspace
Copy link
Copy Markdown
Contributor

  • Position is written as an inline left value (rather than right: calc(...)) so it doesn't shift when velcro's autoUpdate re-fires on DOM mutations — for example, when the 3-dot menu dropdown opens.

Is this true? Or am I misunderstanding?

CleanShot 2026-05-28 at 16 02 54

lukemelia and others added 2 commits May 28, 2026 18:02
…the label

Diagnostic logs on a long-name hover showed scrollWidth going from
192 to 197 exactly when the menu opened — a 5px jump that lines up
exactly with the label's `gap: 5px` flex setting. BoxelDropdown
yields two siblings into the label: the trigger and its content
placeholder. When the menu is closed, the content placeholder has
`display: none` and is not a flex item, so no gap is added. When the
menu opens, the placeholder is replaced by the wormhole-origin div
(non-`display: none`), which becomes a flex item, and the label's
flex layout adds a 5px gap between the trigger and it.

Wrap BoxelDropdown in an inline-flex `<span>` so the trigger and the
portal-origin live inside their own flex container — they're one
flex item of the label, not two, and the label's natural width
doesn't change when the menu opens.

Avoid backticks inside the template's `{{! ... }}` comment — content-
tag's lexer reads them as a template-literal opening and errors with
"Invalid count value: -1".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The trackLabelOverflow positioning bug is fixed (the BoxelDropdown
wrapper kept the label's natural width stable across menu open /
close); remove the [adorn-debug] log + warn lines that were guiding
the diagnosis. Behavior unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@lukemelia
Copy link
Copy Markdown
Contributor Author

@backspace the description was out-of-date (now updated) but the behavior you noted was a re-regression (now fixed). Thanks!

lukemelia and others added 2 commits May 28, 2026 22:55
The integration test for the hover type-label tab waited on
`label.getBoundingClientRect().width > 0` before measuring, but the
label has its natural content width as soon as it's inserted into the
DOM — trackLabelOverflow's initial CSS defaults (`position: fixed;
top: 0; left: 0; max-width: max-content`) already give it a non-zero
width before the JS positioning fires. So the wait could resolve
while the label was still at viewport (0, 0), and `labelRect.left`
read 0 instead of `cardRect.left - 4`.

Wait on `label.style.left.endsWith('px')` instead: the modifier sets
`style.left = '0'` (no unit) synchronously at setup, then writes
`Npx` once `update()` has computed the position, so the px suffix is
a reliable signal that the positioner has landed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The label was `position: fixed` with `left`/`top` written in
viewport coordinates. That broke under any ancestor that creates a
fixed-positioning containing block — most notably the test runner's
`#ember-testing { transform: scale(0.5) }` wrapper, which made the
inline left be interpreted in `#ember-testing`'s scaled coordinate
space (so the label rendered ~hundreds of px off from where the
modifier expected). The fitting-case assertion in
overlay-menu-items-test exposed this.

Switch to `position: absolute` so the offset parent is the
velcro'd `.actions-overlay` div (which already tracks the card),
and convert the viewport anchor into the offset parent's local
coordinate space by reading `parentRect / offsetWidth` for the
scale. Production (scale = 1) is unchanged; the test environment
now writes the correct local-space left/top.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@backspace backspace left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pending lint fix of course

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@lukemelia lukemelia merged commit 09bb1fc into main May 29, 2026
76 of 77 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants