('*')].some((el) =>
diff --git a/apps/sim/hooks/use-drag-resize.ts b/apps/sim/hooks/use-drag-resize.ts
index 622196ea1e2..89928c14266 100644
--- a/apps/sim/hooks/use-drag-resize.ts
+++ b/apps/sim/hooks/use-drag-resize.ts
@@ -9,14 +9,30 @@ interface UseDragResizeOptions {
*/
cssVar: string
/**
- * Returns the element that consumes {@link cssVar} (or an ancestor of every
- * consumer). During the drag the variable is written here — a style recalc
- * scoped to that subtree — instead of on `:root`, where on a large document
- * every custom-property write recalculates the whole tree (~150x slower).
- * Captured once on drag start; a `null` return falls back to
- * `document.documentElement`.
+ * Returns the element the drag resizes, which is also the subtree
+ * {@link cssVar} is written to during it — a style recalc scoped to that
+ * subtree, instead of `:root`, where on a large document every
+ * custom-property write recalculates the whole tree (~150x slower). Captured
+ * once on drag start; a `null` return falls back to
+ * `document.documentElement`. This element is also the drag's liveness
+ * reference: once it detaches, the release stops recomputing from layout.
*/
getTarget: () => HTMLElement | null
+ /**
+ * Other subtrees that read {@link cssVar} but are not what the drag resizes —
+ * the toast stack insets by `--panel-width`/`--terminal-height` yet is
+ * portalled to ``, so it shares no ancestor with either. Each is
+ * written alongside the primary, which keeps the recalc scoped AND keeps
+ * these consumers tracking the drag; one left off here reads the stale
+ * `:root` value and only catches up when the drag commits.
+ *
+ * Deliberately separate from {@link getTarget} rather than one list: these
+ * come and go independently of the drag (a toast auto-dismisses mid-drag),
+ * so they must never become the liveness reference. Absent (`null`) or
+ * duplicate elements are ignored, and writing to one that detaches mid-drag
+ * is harmless.
+ */
+ getExtraTargets?: () => (HTMLElement | null)[]
/**
* Maps a pointer position to the clamped target dimension, or `null` to
* ignore the move. Runs at most once per animation frame (before the write,
@@ -91,6 +107,8 @@ export function useDragResize(options: UseDragResizeOptions) {
const pointerId = e.pointerId
const { cssVar } = optionsRef.current
const target = optionsRef.current.getTarget() ?? document.documentElement
+ const extras = optionsRef.current.getExtraTargets?.() ?? []
+ const targets = [...new Set([target, ...extras.filter((el) => el !== null)])]
document.body.style.cursor = optionsRef.current.cursor
document.body.style.userSelect = 'none'
handle.setPointerCapture?.(pointerId)
@@ -100,7 +118,7 @@ export function useDragResize(options: UseDragResizeOptions) {
let lastApplied: number | null = null
const applyValue = (value: number) => {
- target.style.setProperty(cssVar, `${value}px`)
+ for (const el of targets) el.style.setProperty(cssVar, `${value}px`)
lastApplied = value
optionsRef.current.onApply?.(value)
}
@@ -144,7 +162,9 @@ export function useDragResize(options: UseDragResizeOptions) {
}
if (lastApplied !== null) {
optionsRef.current.commit(lastApplied)
- if (target !== document.documentElement) target.style.removeProperty(cssVar)
+ for (const el of targets) {
+ if (el !== document.documentElement) el.style.removeProperty(cssVar)
+ }
}
optionsRef.current.onEnd?.()
}
diff --git a/packages/emcn/src/components/chip/chip-chrome.ts b/packages/emcn/src/components/chip/chip-chrome.ts
index 8c158a7eb13..fa8d4754819 100644
--- a/packages/emcn/src/components/chip/chip-chrome.ts
+++ b/packages/emcn/src/components/chip/chip-chrome.ts
@@ -46,6 +46,7 @@ export const chipFieldTextClass =
* Like every token in this module, never re-derive the literal; import it.
*/
export const chipContentGap = 'gap-1.5'
+
/**
* Chip pill geometry — height, centering, gap, radius, padding, text size — with
* NO interactivity (no `cursor-pointer`, no hover). `chipVariants` composes this
@@ -58,6 +59,20 @@ export const chipGeometryClass = `h-[30px] items-center ${chipContentGap} rounde
export const chipContentIconClass = 'size-[16px] flex-shrink-0 text-[var(--text-icon)]'
/** Chip-content label (non-inverse): truncating `--text-body` at `text-sm`. Inverse chip variants override the color to `currentColor`. */
export const chipContentLabelClass = 'min-w-0 truncate text-[var(--text-body)] text-sm'
+
+/**
+ * The two row surfaces. Mutually exclusive — a row paints one OR the other,
+ * never both, so a selected row holds its surface through hover.
+ *
+ * Hover used to be `--surface-active` (a hovered row looked selected, so lists
+ * appeared to have two selections) and active used to brighten to `--surface-6`
+ * on hover (read as the selection changing under the cursor). Do not reintroduce
+ * either. `chipVariants` wires this for pills; hand-rolled rows import these
+ * rather than restating the literals.
+ */
+export const chipHoverSurfaceClass = 'hover-hover:bg-[var(--surface-hover)]'
+/** @see {@link chipHoverSurfaceClass} — the selected half of the same pair. */
+export const chipActiveSurfaceClass = 'bg-[var(--surface-active)]'
/**
* The disclosure chevron that rotates to expand or collapse a sidebar section or a
* tree row: 14px at `--text-icon`, animating on the same 150ms curve the section
diff --git a/packages/emcn/src/components/chip/chip.tsx b/packages/emcn/src/components/chip/chip.tsx
index 06a2a3064dd..aa80a5d225a 100644
--- a/packages/emcn/src/components/chip/chip.tsx
+++ b/packages/emcn/src/components/chip/chip.tsx
@@ -11,10 +11,12 @@ import { cva, type VariantProps } from 'class-variance-authority'
import Link, { type LinkProps } from 'next/link'
import { cn } from '../../lib/cn'
import {
+ chipActiveSurfaceClass,
chipContentIconClass,
chipContentLabelClass,
chipFilledFillTokens,
chipGeometryClass,
+ chipHoverSurfaceClass,
chipPrimaryFillTokens,
} from './chip-chrome'
@@ -27,16 +29,16 @@ import {
* - `chipVariants({...})` → any other element (``, `` inner, etc.)
*
* @remarks
- * The implicit **default** variant is the bare pill — transparent, `--surface-active` on hover. Omit `variant`
+ * The implicit **default** variant is the bare pill — transparent, `--surface-hover` on hover. Omit `variant`
* to get it (shadcn-style); never write `variant='default'`. Named variants:
- * `filled` (`--surface-5` light / `--surface-4` dark fill, `--surface-active` hover) — a borderless surface reserved for
+ * `filled` (`--surface-5` light / `--surface-4` dark fill, `--surface-hover` hover) — a borderless surface reserved for
* chip FIELDS/TRIGGERS ({@link ChipInput}/{@link ChipDropdown}/{@link ChipSelect}/{@link ChipDatePicker}), **never `Chip`
* itself**; those triggers add the `--border-1` outline themselves via `TRIGGER_BORDER_CLASS`;
* `primary` (inverse surface), `destructive` (error-token surface), `border-shadow` (raised card-like surface),
* `border` (the `border-shadow` shadow ring on a transparent surface — an outline drawn purely via box-shadow,
* no CSS border, no fill).
- * `active` renders the default/filled chip in its selected state — `--surface-active` at rest, one surface darker
- * (`--surface-6`) on hover. `fullWidth` swaps `inline-flex` for block-level `flex`.
+ * `active` renders the default/filled chip in its selected state — `--surface-active`, held through hover.
+ * `fullWidth` swaps `inline-flex` for block-level `flex`.
*
* The chip carries NO outer margin — spacing between chips belongs to the parent, as a `gap`. It used to ship a
* default `mx-0.5` "cluster margin" with a `flush` prop to switch it off, which meant a chip's visual box was not
@@ -44,10 +46,12 @@ import {
* could never close past the margins. Do not reintroduce it.
*
* The default/filled hover lives in `active`-keyed compound variants (not the base variant string) so the
- * rest/hover classes are mutually exclusive — a chip renders exactly ONE `hover-hover:bg-*`. This keeps raw
+ * rest/hover classes are mutually exclusive — a chip renders AT MOST ONE `hover-hover:bg-*`. This keeps raw
* `chipVariants({...})` consumers identical to `cn(chipVariants({...}))` ones; folding the non-active hover back
* into the variant string would emit two conflicting hover classes that only `cn`'s tailwind-merge resolves,
* silently diverging raw consumers (e.g. an active row that darkens with `Chip` but not with raw `chipVariants`).
+ * The two surfaces themselves, and why an active chip takes no hover class at all, are documented on
+ * {@link chipHoverSurfaceClass}.
*/
const chipVariants = cva(
`group cursor-pointer ${chipGeometryClass} transition-colors disabled:cursor-not-allowed disabled:opacity-60`,
@@ -61,33 +65,14 @@ const chipVariants = cva(
'bg-[var(--text-error)] text-white hover-hover:text-white hover-hover:brightness-106',
'border-shadow':
'bg-[var(--surface-2)] shadow-[0_0_0_1px_rgba(28,40,64,0.08),0_1px_3px_0_rgba(28,40,64,0.1)] hover-hover:bg-[var(--surface-3)] dark:shadow-[0_0_0_1px_var(--border-1),0_1px_3px_0_rgba(0,0,0,0.3)] dark:hover-hover:bg-[var(--surface-4)]',
- border:
- 'shadow-[0_0_0_1px_rgba(28,40,64,0.08),0_1px_3px_0_rgba(28,40,64,0.1)] hover-hover:bg-[var(--surface-active)] dark:shadow-[0_0_0_1px_var(--border-1),0_1px_3px_0_rgba(0,0,0,0.3)]',
+ border: `shadow-[0_0_0_1px_rgba(28,40,64,0.08),0_1px_3px_0_rgba(28,40,64,0.1)] ${chipHoverSurfaceClass} dark:shadow-[0_0_0_1px_var(--border-1),0_1px_3px_0_rgba(0,0,0,0.3)]`,
},
active: { true: '', false: '' },
fullWidth: { true: 'flex', false: 'inline-flex' },
},
compoundVariants: [
- {
- variant: 'default',
- active: false,
- className: 'hover-hover:bg-[var(--surface-active)]',
- },
- {
- variant: 'default',
- active: true,
- className: 'bg-[var(--surface-active)] hover-hover:bg-[var(--surface-6)]',
- },
- {
- variant: 'filled',
- active: false,
- className: 'hover-hover:bg-[var(--surface-active)]',
- },
- {
- variant: 'filled',
- active: true,
- className: 'bg-[var(--surface-active)] hover-hover:bg-[var(--surface-6)]',
- },
+ { variant: ['default', 'filled'], active: false, className: chipHoverSurfaceClass },
+ { variant: ['default', 'filled'], active: true, className: chipActiveSurfaceClass },
],
defaultVariants: { variant: 'default', active: false, fullWidth: false },
}
diff --git a/packages/emcn/src/components/combobox/combobox.tsx b/packages/emcn/src/components/combobox/combobox.tsx
index 41ccc3b7772..391e1028a1f 100644
--- a/packages/emcn/src/components/combobox/combobox.tsx
+++ b/packages/emcn/src/components/combobox/combobox.tsx
@@ -17,6 +17,7 @@ import {
import { cva, type VariantProps } from 'class-variance-authority'
import { Check, ChevronDown, Loader, Search } from '../../icons'
import { cn } from '../../lib/cn'
+import { chipActiveSurfaceClass, chipHoverSurfaceClass } from '../chip/chip-chrome'
import { Input } from '../input/input'
import { Popover, PopoverAnchor, PopoverContent, PopoverScrollArea } from '../popover/popover'
@@ -841,8 +842,17 @@ const Combobox = memo(
className={cn(
'relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-1.5 font-sans',
size === 'sm' ? 'py-[5px] text-caption' : 'py-1.5 text-sm',
- 'hover-hover:bg-[var(--surface-active)]',
- (isHighlighted || isSelected) && 'bg-[var(--surface-active)]',
+ /*
+ No CSS `:hover` here — `isHighlighted` is the
+ single source of truth for the cursor, because
+ it is also what Enter commits. A `:hover` class
+ tracks the pointer continuously while
+ `highlightedIndex` only moves on `mouseenter`,
+ so after the list scrolls under a stationary
+ pointer the two disagree and the row that looks
+ selected is not the one Enter would choose.
+ */
+ (isHighlighted || isSelected) && chipActiveSurfaceClass,
option.disabled && 'cursor-not-allowed opacity-50'
)}
>
@@ -881,8 +891,10 @@ const Combobox = memo(
className={cn(
'relative flex cursor-pointer select-none items-center rounded-sm px-1.5 font-sans',
size === 'sm' ? 'py-[5px] text-caption' : 'py-1.5 text-sm',
- 'hover-hover:bg-[var(--surface-active)]',
- !multiSelectValues?.length && 'bg-[var(--surface-active)]'
+ // Clears the highlight rather than taking it, so unlike option rows it hovers.
+ !multiSelectValues?.length
+ ? chipActiveSurfaceClass
+ : chipHoverSurfaceClass
)}
>
@@ -915,8 +927,8 @@ const Combobox = memo(
className={cn(
'relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-1.5 font-sans',
size === 'sm' ? 'py-[5px] text-caption' : 'py-1.5 text-sm',
- 'hover-hover:bg-[var(--surface-active)]',
- (isHighlighted || isSelected) && 'bg-[var(--surface-active)]',
+ // See above: `isHighlighted` alone, so paint matches what Enter commits.
+ (isHighlighted || isSelected) && chipActiveSurfaceClass,
option.disabled && 'cursor-not-allowed opacity-50'
)}
>
diff --git a/packages/emcn/src/components/index.ts b/packages/emcn/src/components/index.ts
index d4455a84348..de3ce13c392 100644
--- a/packages/emcn/src/components/index.ts
+++ b/packages/emcn/src/components/index.ts
@@ -19,6 +19,7 @@ export {
export { ChipChevronDown } from './chip/chip-chevron'
export {
cellIconNodeClass,
+ chipActiveSurfaceClass,
chipBorderShadowRing,
chipContentGap,
chipContentIconClass,
@@ -28,6 +29,7 @@ export {
chipFilledFillTokens,
chipFilledSurfaceTokens,
chipGeometryClass,
+ chipHoverSurfaceClass,
chipIconSlotClass,
chipPrimaryFillTokens,
disclosureChevronClass,
diff --git a/packages/emcn/src/components/popover/popover.tsx b/packages/emcn/src/components/popover/popover.tsx
index f3406494c0e..6ecd0d529a0 100644
--- a/packages/emcn/src/components/popover/popover.tsx
+++ b/packages/emcn/src/components/popover/popover.tsx
@@ -55,6 +55,7 @@ import * as PopoverPrimitive from '@radix-ui/react-popover'
import { createPortal } from 'react-dom'
import { Check, ChevronLeft, ChevronRight, Search } from '../../icons'
import { cn } from '../../lib/cn'
+import { chipActiveSurfaceClass, chipHoverSurfaceClass } from '../chip/chip-chrome'
type PopoverSize = 'sm' | 'md'
type PopoverColorScheme = 'default' | 'inverted'
@@ -115,8 +116,13 @@ const STYLES = {
/** Interactive state styles: default, and inverted (dark bg in light mode) */
states: {
default: {
- active: 'bg-[var(--surface-active)]',
- hover: 'hover-hover:bg-[var(--surface-active)]',
+ /**
+ * The shared row-state pair — see {@link chipHoverSurfaceClass}.
+ * `getItemStateClasses` returns active OR hover and never both, which is
+ * what holds a checked item's surface through hover.
+ */
+ active: chipActiveSurfaceClass,
+ hover: chipHoverSurfaceClass,
},
inverted: {
active:
diff --git a/packages/emcn/src/components/toast/toast.tsx b/packages/emcn/src/components/toast/toast.tsx
index 4798688cfc0..23bb9226c0a 100644
--- a/packages/emcn/src/components/toast/toast.tsx
+++ b/packages/emcn/src/components/toast/toast.tsx
@@ -33,6 +33,23 @@ const AUTO_DISMISS_MS = 5000
/** Card width; tracks the workflow-panel inset on narrow viewports. */
const TOAST_WIDTH = 'min(100vw - 2rem, 280px)'
+/** Gap from the viewport edge on an ordinary page. */
+const VIEWPORT_INSET_PX = 16
+/**
+ * Gap the stack keeps from the workflow panel and terminal it sits against —
+ * the same one the canvas controls keep, so the two floating surfaces read as
+ * one row.
+ *
+ * `--panel-width` / `--terminal-height` measure the element, not its distance
+ * from the viewport, and the stack is portalled to `` so it anchors from
+ * the viewport. `--workspace-content-gap` adds back whatever padding the
+ * workspace shell insets those elements by — normally 8px, but 0 on the desktop
+ * shell with a collapsed sidebar. Hardcoding the sum would silently hold the
+ * stack 8px further out in that configuration while the controls, which are laid
+ * out inside the shell, stayed put.
+ */
+const WORKFLOW_INSET_PX = 12
+
/** Most toasts kept alive at once; older arrivals are evicted. */
const STACK_LIMIT = 3
/** Per-depth lift and shrink that make collapsed cards peek above the front one. */
@@ -590,14 +607,27 @@ export function ToastProvider({ children }: { children?: ReactNode }) {
aria-live='polite'
aria-label='Notifications'
data-native-surface-overlay=''
+ /*
+ * The stack is portalled to ``, so it shares no ancestor
+ * with the panel or terminal it insets by. A resize drag writes
+ * `--panel-width` / `--terminal-height` to each consuming
+ * subtree rather than to `:root`; this attribute is how it
+ * finds this one, and without it the stack would hold the
+ * pre-drag position until the drag commits.
+ */
+ data-toast-viewport=''
className='fixed z-[var(--z-toast)] m-0 list-none p-0'
exit={{
opacity: 0,
transition: reduceMotion ? { duration: 0 } : { duration: 0.2, ease: 'easeIn' },
}}
style={{
- right: isWorkflowPage ? 'calc(var(--panel-width) + 16px)' : '16px',
- bottom: isWorkflowPage ? 'calc(var(--terminal-height) + 16px)' : '16px',
+ right: isWorkflowPage
+ ? `calc(var(--panel-width) + var(--workspace-content-gap, 0px) + ${WORKFLOW_INSET_PX}px)`
+ : `${VIEWPORT_INSET_PX}px`,
+ bottom: isWorkflowPage
+ ? `calc(var(--terminal-height) + var(--workspace-content-gap, 0px) + ${WORKFLOW_INSET_PX}px)`
+ : `${VIEWPORT_INSET_PX}px`,
width: TOAST_WIDTH,
height: containerHeight,
}}