diff --git a/.claude/rules/emcn-components.md b/.claude/rules/emcn-components.md index 5ca3d2a8dcb..c5548d327df 100644 --- a/.claude/rules/emcn-components.md +++ b/.claude/rules/emcn-components.md @@ -11,7 +11,7 @@ Import components, `cn`, and tokens from the `@sim/emcn` barrel; icons come from Never hand-roll the chip pill from raw class strings (they go stale). Compose from the canonical sources: -- **Surface, typography + content tokens:** `chip/chip-chrome.ts` — `chipFilledSurfaceTokens`, `chipFieldSurfaceClass`, `chipFieldTextClass` (text fields and the dropdown search box build on these), plus the chip-content chrome `chipContentGap`, `chipGeometryClass`, `chipContentIconClass`, `chipContentLabelClass`, and `cellIconNodeClass` (non-chip surfaces that must visually match chip content, e.g. resource table cells). All are re-exported from the `@sim/emcn` barrel — no subpath import needed. +- **Surface, typography + content tokens:** `chip/chip-chrome.ts` — `chipFilledSurfaceTokens`, `chipFieldSurfaceClass`, `chipFieldTextClass` (text fields and the dropdown search box build on these), plus the chip-content chrome `chipContentGap`, `chipGeometryClass`, `chipContentIconClass`, `chipContentLabelClass`, `cellIconNodeClass` (non-chip surfaces that must visually match chip content, e.g. resource table cells), and the row-state pair `chipHoverSurfaceClass` / `chipActiveSurfaceClass` (hover vs. selected — mutually exclusive, so a selected row holds its surface through hover; every hand-rolled row imports these rather than restating the literals). All are re-exported from the `@sim/emcn` barrel — no subpath import needed. - **Pill geometry:** `chip/chip.tsx` — `chipVariants` (30px tall, `rounded-lg`, `px-2`, icon↔text `gap-1.5`). Every pill-shaped trigger (`ChipDropdown`, `ChipSelect`, `ChipSwitch`) reuses it for visual parity. Canonical look: normal font-weight (never `font-medium`/`font-semibold`), value text `--text-body`, icons `--text-icon` at `size-[14px]`, placeholder `--text-muted`, `transition-colors`, **no focus ring** (the caret marks focus). Filled surface is `--surface-5` light / `--surface-4` dark with a `--border-1` border. diff --git a/apps/docs/app/global.css b/apps/docs/app/global.css index e2c10fb937c..6373ec36d75 100644 --- a/apps/docs/app/global.css +++ b/apps/docs/app/global.css @@ -518,19 +518,13 @@ html #nd-sidebar button[data-active="true"] { color: var(--text-body) !important; } -/* Hover state — the app's nav Chip hovers to --surface-active. These rules must - carry !important to beat fumadocs' own sidebar styles, which also means they - override the Tailwind hover utilities on the items; keep the two in step. */ +/* Hover state — emcn's two-surface row model (see `chipHoverSurfaceClass`). + These rules must carry !important to beat fumadocs' own sidebar styles, which + also means they override the Tailwind hover utilities on the items; keep the + two in step. */ html #nd-sidebar a:not(:has(span.font-mono)):hover:not([data-active="true"]), html #nd-sidebar button:hover:not([data-active="true"]) { - background-color: var(--surface-active) !important; -} - -/* An active item darkens one surface on hover, matching the app's active Chip */ -html #nd-sidebar a[data-active="true"]:not(:has(span.font-mono)):hover, -html #nd-sidebar button[data-active="true"]:hover { - background-color: var(--surface-6) !important; - color: var(--text-body) !important; + background-color: var(--surface-hover) !important; } /* Hide search, platform, and collapse button from sidebar completely */ diff --git a/apps/docs/components/docs-layout/sidebar-components.tsx b/apps/docs/components/docs-layout/sidebar-components.tsx index 6a603fc856e..c3968b021f2 100644 --- a/apps/docs/components/docs-layout/sidebar-components.tsx +++ b/apps/docs/components/docs-layout/sidebar-components.tsx @@ -1,6 +1,7 @@ 'use client' import { type ReactNode, useState } from 'react' +import { chipActiveSurfaceClass, chipHoverSurfaceClass } from '@sim/emcn' import { ChevronRight } from '@sim/emcn/icons' import type { Folder, Item, Separator } from 'fumadocs-core/page-tree' import { useSidebar } from 'fumadocs-ui/components/sidebar/base' @@ -43,7 +44,7 @@ function isActive(url: string, pathname: string, nested = true): boolean { /** * Rows mirror the app sidebar's chip pill: 30px tall, `rounded-lg`, `px-2`, 14px * at normal weight, `--text-body` at rest AND when active — only the background - * moves, to `--surface-active`, then `--surface-6` when an active row is hovered. + * moves, on the two-surface model — see emcn's `chipHoverSurfaceClass`. * * Height, horizontal padding, weight and color are additionally pinned in * `global.css` (`html #nd-sidebar a…`), which needs `!important` to beat @@ -52,20 +53,25 @@ function isActive(url: string, pathname: string, nested = true): boolean { * the stylesheet is what actually lands on desktop. */ const ITEM_BASE = - 'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-[var(--text-body)] text-sm transition-colors hover:bg-[var(--surface-active)]' -const ITEM_ACTIVE_MOBILE = 'bg-[var(--surface-active)]' + 'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-[var(--text-body)] text-sm transition-colors' +const ITEM_ACTIVE_MOBILE = chipActiveSurfaceClass const ITEM_DESKTOP = 'lg:mb-[0.0625rem] lg:block lg:rounded-lg lg:px-2 lg:font-normal lg:text-sm lg:leading-tight' const ITEM_TEXT = 'lg:text-[var(--text-body)]' -const ITEM_HOVER = 'lg:hover:bg-[var(--surface-active)]' -const ITEM_ACTIVE = - 'lg:bg-[var(--surface-active)] lg:font-normal lg:text-[var(--text-body)] lg:hover:bg-[var(--surface-6)]' +/** + * Unprefixed, and applied only to inactive rows — an unconditional hover in + * `ITEM_BASE` would fade the current page under the pointer below `lg`. + */ +const ITEM_HOVER = chipHoverSurfaceClass +const ITEM_ACTIVE = 'lg:bg-[var(--surface-active)] lg:font-normal lg:text-[var(--text-body)]' const FOLDER_TEXT = 'lg:text-[var(--text-body)] lg:font-normal' -const FOLDER_HOVER = 'lg:hover:bg-[var(--surface-active)]' -const FOLDER_ACTIVE = - 'lg:bg-[var(--surface-active)] lg:text-[var(--text-body)] lg:hover:bg-[var(--surface-6)]' +const FOLDER_HOVER = chipHoverSurfaceClass +const FOLDER_ACTIVE = 'lg:bg-[var(--surface-active)] lg:text-[var(--text-body)]' + +const itemClass = (active: boolean) => + cn(ITEM_BASE, ITEM_DESKTOP, ITEM_TEXT, active ? cn(ITEM_ACTIVE_MOBILE, ITEM_ACTIVE) : ITEM_HOVER) export function SidebarItem({ item }: { item: Item }) { const pathname = usePathname() @@ -73,19 +79,7 @@ export function SidebarItem({ item }: { item: Item }) { const active = isActive(item.url, pathname, false) return ( - + {item.name} ) @@ -119,14 +113,7 @@ export function SidebarFolder({ item, children }: { item: Folder; children: Reac href={item.index.url} prefetch={prefetch} data-active={active} - className={cn( - ITEM_BASE, - active && ITEM_ACTIVE_MOBILE, - ITEM_DESKTOP, - ITEM_TEXT, - !active && ITEM_HOVER, - active && ITEM_ACTIVE - )} + className={itemClass(active)} > {item.name} @@ -144,12 +131,10 @@ export function SidebarFolder({ item, children }: { item: Folder; children: Reac data-active={active} className={cn( 'flex flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors', - 'text-[var(--text-body)] hover:bg-[var(--surface-active)]', - active && ITEM_ACTIVE_MOBILE, + 'text-[var(--text-body)]', 'lg:block lg:flex-1 lg:rounded-lg lg:px-2 lg:text-sm lg:leading-tight', FOLDER_TEXT, - !active && FOLDER_HOVER, - active && FOLDER_ACTIVE + active ? cn(ITEM_ACTIVE_MOBILE, FOLDER_ACTIVE) : FOLDER_HOVER )} > {item.name} @@ -158,8 +143,8 @@ export function SidebarFolder({ item, children }: { item: Folder; children: Reac +
- - +
)} @@ -642,18 +642,13 @@ export function DeployModal({
{chatExists && ( - + )} - +
)} @@ -676,9 +671,8 @@ export function DeployModal({
- + - + {mcpToolSaveDisabledReason && ( @@ -882,16 +876,23 @@ function GeneralFooter({
) const deployActionLoading = isSubmitting || isDeploymentSettling + const deployLoader = deployActionLoading ? ( + + ) : null if (!isDeployed) { return ( {status}
- +
) @@ -901,14 +902,18 @@ function GeneralFooter({ {status}
- + {(needsRedeployment || isDeploymentSettling) && ( - + )}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks/use-panel-resize.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks/use-panel-resize.ts index 5f20d08e113..fbecf8be6f7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks/use-panel-resize.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks/use-panel-resize.ts @@ -18,6 +18,15 @@ function getPanelContainer(): HTMLElement | null { return document.querySelector('.panel-container') } +/** + * The toast stack also insets its right edge by `--panel-width`, but is + * portalled to `` and so shares no ancestor with the panel. See + * `use-terminal-resize.ts` for why this is written alongside the primary. + */ +function getToastViewport(): (HTMLElement | null)[] { + return [document.querySelector('[data-toast-viewport]')] +} + /** * Handles panel drag-resize with zero React renders during the drag. The * `--panel-width` variable is written to `.panel-container` (a scoped style @@ -34,6 +43,7 @@ export function usePanelResize() { cursor: 'ew-resize', cssVar: '--panel-width', getTarget: getPanelContainer, + getExtraTargets: getToastViewport, compute: computePanelWidth, commit: setPanelWidth, }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/entry-block-tile/entry-block-tile.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/entry-block-tile/entry-block-tile.tsx new file mode 100644 index 00000000000..b0ffdf95f69 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/entry-block-tile/entry-block-tile.tsx @@ -0,0 +1,35 @@ +'use client' + +import { memo } from 'react' +import { chipIconSlotClass, cn } from '@sim/emcn' +import { WorkflowTypeIcon } from '@sim/workflow-renderer' +import { + getBlockColor, + getBlockIcon, + getEntryAccentType, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils' +import { getTileIconColorClass } from '@/blocks/icon-color' + +export interface EntryBlockTileProps { + blockType: string +} + +/** A log row's block tile. @see getEntryAccentType */ +export const EntryBlockTile = memo(function EntryBlockTile({ blockType }: EntryBlockTileProps) { + const BlockIcon = getBlockIcon(blockType) + const bgColor = getBlockColor(blockType) + const accentType = getEntryAccentType(blockType) + + if (BlockIcon && accentType) { + return + } + + return ( +
+ {BlockIcon && } +
+ ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/entry-block-tile/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/entry-block-tile/index.ts new file mode 100644 index 00000000000..6617a4df600 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/entry-block-tile/index.ts @@ -0,0 +1 @@ +export { EntryBlockTile, type EntryBlockTileProps } from './entry-block-tile' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/filter-popover/filter-popover.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/filter-popover/filter-popover.tsx index 3ba40426fd0..e3553338095 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/filter-popover/filter-popover.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/filter-popover/filter-popover.tsx @@ -13,11 +13,11 @@ import { } from '@sim/emcn' import { ListFilter } from '@sim/emcn/icons' import clsx from 'clsx' +import { EntryBlockTile } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/entry-block-tile' import type { BlockInfo, TerminalFilters, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types' -import { getBlockIcon } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils' /** * Props for the FilterPopover component @@ -94,7 +94,6 @@ export const FilterPopover = memo(function FilterPopover({ Blocks {uniqueBlocks.map((block) => { - const BlockIcon = getBlockIcon(block.blockType) const isSelected = filters.blockIds.has(block.blockId) return ( @@ -104,7 +103,7 @@ export const FilterPopover = memo(function FilterPopover({ showCheck={isSelected} onClick={() => toggleBlock(block.blockId)} > - {BlockIcon && } + {block.blockName} ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/index.ts index b230b8196ad..2fdcd094f91 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/index.ts @@ -1,5 +1,6 @@ +export { EntryBlockTile, type EntryBlockTileProps } from './entry-block-tile' export { FilterPopover, type FilterPopoverProps } from './filter-popover' export { LogRowContextMenu, type LogRowContextMenuProps } from './log-row-context-menu' export { OutputPanel, type OutputPanelProps } from './output-panel' -export { RunningBadge, StatusDisplay, type StatusDisplayProps } from './status-display' +export { StatusDisplay, type StatusDisplayProps } from './status-display' export { ToggleButton, type ToggleButtonProps } from './toggle-button' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/structured-output.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/structured-output.tsx index 6a4c917bd39..9ac4335730b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/structured-output.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/components/structured-output.tsx @@ -11,7 +11,14 @@ import { useRef, useState, } from 'react' -import { Badge, ChevronDown, cn } from '@sim/emcn' +import { + Badge, + ChevronDown, + chipGeometryClass, + chipHoverSurfaceClass, + cn, + disclosureChevronClass, +} from '@sim/emcn' import { useVirtualizer } from '@tanstack/react-virtual' import { isUserFileDisplayMetadata } from '@/lib/core/utils/user-file' import { @@ -19,9 +26,12 @@ import { type LargeArrayManifest, } from '@/lib/execution/payloads/large-array-manifest-metadata' import { isLargeValueRef, type LargeValueRef } from '@/lib/execution/payloads/large-value-ref' +import { + BADGE_STYLE, + ROW_STYLES, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types' type ValueType = 'null' | 'undefined' | 'array' | 'string' | 'number' | 'boolean' | 'object' -type BadgeVariant = 'green' | 'blue' | 'orange' | 'purple' | 'gray' | 'red' interface NodeEntry { key: string @@ -51,28 +61,33 @@ const CONFIG = { VIRTUALIZATION_THRESHOLD: 200, } as const -const BADGE_VARIANTS: Record = { - string: 'green', - number: 'blue', - boolean: 'orange', - array: 'purple', - null: 'gray', - undefined: 'gray', - object: 'gray', -} as const +/** + * One neutral tag for every value type. Per-type hues re-encoded a fact the tag + * text and the value already state, and left red — the only signal that matters + * — competing with decoration. Red stays; nothing else is coloured. + */ +const VALUE_TYPE_BADGE_VARIANT = 'gray-secondary' as const /** - * Styling constants matching the original non-virtualized implementation. + * Styling constants for the output tree — the same chip as a log row. + * + * `row` overrides the pill's fixed `h-[30px]` with `h-auto min-h-[30px]`: a + * wrapped value grows past one line. */ const STYLES = { - row: 'group flex min-h-[30px] cursor-pointer items-center gap-2 rounded-lg px-2 -mx-2 hover-hover:bg-[var(--surface-active)]', - chevron: 'size-[14px] flex-shrink-0 text-[var(--text-muted)] transition-transform duration-100', - keyName: 'text-sm text-[var(--text-primary)]', - badge: 'rounded-sm px-1 py-[0px] text-xs', - summary: 'text-sm text-[var(--text-secondary)]', - indent: 'mt-0.5 ml-[3px] flex min-w-0 flex-col gap-0.5 border-[var(--border)] border-l pl-[9px]', - value: 'min-w-0 py-0.5 text-sm text-[var(--text-primary)]', - emptyValue: 'py-0.5 text-sm text-[var(--text-secondary)]', + row: cn( + 'group flex cursor-pointer transition-colors', + chipGeometryClass, + '-mx-2 h-auto min-h-[30px]', + chipHoverSurfaceClass + ), + chevron: disclosureChevronClass, + keyName: 'text-sm text-[var(--text-body)]', + badge: BADGE_STYLE, + summary: 'text-sm text-[var(--text-muted)]', + indent: ROW_STYLES.nested, + value: 'min-w-0 py-0.5 text-sm text-[var(--text-body)]', + emptyValue: 'py-0.5 text-sm text-[var(--text-muted)]', matchHighlight: 'bg-yellow-200/60 dark:bg-yellow-500/40', currentMatchHighlight: 'bg-orange-400', } as const @@ -399,7 +414,7 @@ const StructuredNode = memo(function StructuredNode({ [displayValue, isPrimitiveValue] ) - const badgeVariant = isError ? 'red' : BADGE_VARIANTS[type] + const badgeVariant = isError ? 'red' : VALUE_TYPE_BADGE_VARIANT const valueText = isPrimitiveValue ? formatPrimitive(displayValue) : '' const matchIndices = searchContext?.pathToMatchIndices.get(path) ?? EMPTY_MATCH_INDICES @@ -654,7 +669,7 @@ function VirtualizedRow({ const paddingLeft = CONFIG.BASE_PADDING + row.depth * CONFIG.INDENT_PER_LEVEL if (row.type === 'header') { - const badgeVariant = row.isError ? 'red' : BADGE_VARIANTS[row.valueType] + const badgeVariant = row.isError ? 'red' : VALUE_TYPE_BADGE_VARIANT return (
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx index 5382e0fdd89..8582f06c58e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/output-panel/output-panel.tsx @@ -346,7 +346,7 @@ export const OutputPanel = React.memo(function OutputPanel({ aria-label='Close search' className='!p-1.5 -m-1.5' > - + @@ -362,7 +362,7 @@ export const OutputPanel = React.memo(function OutputPanel({ aria-label='Search in output' className='!p-1.5 -m-1.5' > - + @@ -380,7 +380,7 @@ export const OutputPanel = React.memo(function OutputPanel({ aria-label='Component Playground' className='!p-1.5 -m-1.5' > - + @@ -399,9 +399,9 @@ export const OutputPanel = React.memo(function OutputPanel({ className='!p-1.5 -m-1.5' > {showCopySuccess ? ( - + ) : ( - + )} @@ -419,7 +419,7 @@ export const OutputPanel = React.memo(function OutputPanel({ aria-label='Export console CSV' className='!p-1.5 -m-1.5' > - + @@ -434,7 +434,7 @@ export const OutputPanel = React.memo(function OutputPanel({ aria-label='Clear console' className='!p-1.5 -m-1.5' > - + @@ -451,7 +451,7 @@ export const OutputPanel = React.memo(function OutputPanel({ aria-label='Terminal options' className='!p-1.5 -m-1.5' > - + - +
)} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/status-display/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/status-display/index.ts index 0bc435a9a77..3078939f128 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/status-display/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/status-display/index.ts @@ -1 +1 @@ -export { RunningBadge, StatusDisplay, type StatusDisplayProps } from './status-display' +export { StatusDisplay, type StatusDisplayProps } from './status-display' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/status-display/status-display.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/status-display/status-display.tsx index 8388edc2acb..8c896389a08 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/status-display/status-display.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/status-display/status-display.tsx @@ -7,7 +7,7 @@ import { BADGE_STYLE } from '@/app/workspace/[workspaceId]/w/[workflowId]/compon /** * Running badge component - displays a consistent "Running" indicator */ -export const RunningBadge = memo(function RunningBadge() { +const RunningBadge = memo(function RunningBadge() { return ( Running diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-terminal-resize.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-terminal-resize.ts index 750e0c17358..85da4747696 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-terminal-resize.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-terminal-resize.ts @@ -18,6 +18,17 @@ function getTerminalContainer(): HTMLElement | null { return document.querySelector('.terminal-container') } +/** + * The toast stack also insets its bottom by `--terminal-height`, but is + * portalled to `` and so shares no ancestor with the terminal. Writing it + * alongside keeps the notifications tracking the drag frame by frame instead of + * holding their pre-drag position until it commits. Usually absent — the stack + * only mounts while a toast is showing — in which case nothing extra is written. + */ +function getToastViewport(): (HTMLElement | null)[] { + return [document.querySelector('[data-toast-viewport]')] +} + /** * Updates the store height mid-drag only when it crosses the expanded * threshold, so `isExpanded` subscribers (header chevron, auto-open logic) @@ -50,6 +61,7 @@ export function useTerminalResize() { cursor: 'ns-resize', cssVar: '--terminal-height', getTarget: getTerminalContainer, + getExtraTargets: getToastViewport, compute: computeTerminalHeight, commit: setTerminalHeight, onApply: syncExpandedThreshold, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx index 803e5024a4d..adbffa83d00 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx @@ -5,6 +5,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Button, ChevronDown, + disclosureChevronClass, handleKeyboardActivation, Popover, PopoverContent, @@ -22,6 +23,7 @@ import { sendMothershipMessage } from '@/lib/mothership/events' import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils' import { + EntryBlockTile, LogRowContextMenu, OutputPanel, StatusDisplay, @@ -39,8 +41,6 @@ import { type ExecutionGroup, flattenBlockEntriesOnly, flattenVisibleExecutionRows, - getBlockColor, - getBlockIcon, groupEntriesByExecution, isEventFromEditableElement, type NavigableBlockEntry, @@ -48,7 +48,6 @@ import { type VisibleTerminalRow, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' -import { getTileIconColorClass } from '@/blocks/icon-color' import { OUTPUT_PANEL_WIDTH, TERMINAL_HEIGHT } from '@/stores/constants' import type { ConsoleEntry } from '@/stores/terminal' import { @@ -70,6 +69,16 @@ const MIN_OUTPUT_PANEL_WIDTH_PX = OUTPUT_PANEL_WIDTH.MIN const MAX_TREE_DEPTH = 50 +/** + * Gutter every log row and run separator is inset by. + * + * One value on both edges: a row's selected fill and the separator above it are + * the two widest things in the list, and a separator that stops short of the + * fill it sits against reads as a ragged right edge. `pl-[10px]` on the row and + * `mx-[4px]` on the separator used to disagree by exactly those 4px. + */ +const LOG_ROW_GUTTER_CLASS = 'px-[10px]' + function hasMatchInTree( nodes: EntryNode[], predicate: (e: ConsoleEntry) => boolean, @@ -96,22 +105,16 @@ const BlockRow = memo(function BlockRow({ isSelected: boolean onSelect: (entry: ConsoleEntry) => void }) { - const BlockIcon = getBlockIcon(entry.blockType) const hasError = Boolean(entry.error) const isRunning = Boolean(entry.isRunning) const isCanceled = Boolean(entry.isCanceled) - const bgColor = getBlockColor(entry.blockType) return (
{ e.stopPropagation() onSelect(entry) @@ -120,25 +123,13 @@ const BlockRow = memo(function BlockRow({ handleKeyboardActivation(event, () => onSelect(entry), { stopPropagation: true }) } > -
-
- {BlockIcon && ( - - )} -
- +
+ + {entry.blockName}
- + { e.stopPropagation() onToggle() }} onKeyDown={(event) => handleKeyboardActivation(event, onToggle, { stopPropagation: true })} > -
- +
+ {iterationLabel} {hasChildren && ( - + )}
- + 0 @@ -287,7 +261,7 @@ const SubflowNodeRow = memo(function SubflowNodeRow({
{ e.stopPropagation() onToggleNode(nodeId) @@ -296,38 +270,14 @@ const SubflowNodeRow = memo(function SubflowNodeRow({ handleKeyboardActivation(event, () => onToggleNode(nodeId), { stopPropagation: true }) } > -
-
- {BlockIcon && ( - - )} -
- - {displayName} - +
+ + {displayName} {hasChildren && ( - + )}
- + 0 @@ -402,11 +350,7 @@ const WorkflowNodeRow = memo(function WorkflowNodeRow({
{ e.stopPropagation() if (!isSelected) onSelectEntry(entry) @@ -423,38 +367,16 @@ const WorkflowNodeRow = memo(function WorkflowNodeRow({ ) } > -
-
- {BlockIcon && ( - - )} -
- +
+ + {entry.blockName} {hasChildren && ( - + )}
- + -
+
+
) } return ( -
-
+
+
{sortDirection === 'desc' ? ( - + ) : ( - + )} @@ -1350,7 +1272,7 @@ export const Terminal = memo(function Terminal() { aria-label='Component Playground' className='!p-1.5 -m-1.5' > - + @@ -1370,7 +1292,7 @@ export const Terminal = memo(function Terminal() { aria-label='Export console CSV' className='!p-1.5 -m-1.5' > - + @@ -1385,7 +1307,7 @@ export const Terminal = memo(function Terminal() { aria-label='Clear console' className='!p-1.5 -m-1.5' > - + @@ -1405,7 +1327,7 @@ export const Terminal = memo(function Terminal() { aria-label='Terminal options' className='!p-1.5 -m-1.5' > - + ({ getBlock: vi.fn().mockReturnValue(null), @@ -17,11 +17,13 @@ vi.mock('@/stores/constants', () => ({ TERMINAL_BLOCK_COLUMN_WIDTH: { MIN: 120, DEFAULT: 200, MAX: 400 }, })) +import { getBlock } from '@/blocks' import type { ConsoleEntry } from '@/stores/terminal' import { buildEntryTree, type EntryNode, flattenVisibleExecutionRows, + getEntryAccentType, groupEntriesByExecution, } from './utils' @@ -856,3 +858,49 @@ describe('flattenVisibleExecutionRows', () => { expect(rowsExpanded[1].depth).toBe(1) }) }) + +describe('getEntryAccentType', () => { + const mockedGetBlock = vi.mocked(getBlock) + + afterEach(() => { + mockedGetBlock.mockReturnValue(null as never) + }) + + function withCategory(category: string) { + mockedGetBlock.mockReturnValue({ category } as never) + } + + it('accents a core block by its type, mapped or not', () => { + withCategory('blocks') + expect(getEntryAccentType('agent')).toBe('agent') + /* + * An unmapped core block still takes the accent path and lands on `neutral`, + * which is exactly what the block toolbar does for a newly added one — the + * two surfaces must not disagree while the role map catches up. + */ + expect(getEntryAccentType('brand_new_core_block')).toBe('brand_new_core_block') + }) + + it('accents a non-core block only when it carries a canvas role', () => { + withCategory('tools') + expect(getEntryAccentType('table')).toBe('table') + // A role-less integration keeps its own provider colour instead. + expect(getEntryAccentType('gmail')).toBeUndefined() + + withCategory('triggers') + expect(getEntryAccentType('schedule')).toBe('schedule') + expect(getEntryAccentType('some_vendor_trigger')).toBeUndefined() + }) + + it('accents subflows, which carry a role but no registry config', () => { + expect(getEntryAccentType('loop')).toBe('loop') + expect(getEntryAccentType('parallel')).toBe('parallel') + expect(getEntryAccentType('workflow')).toBe('workflow') + }) + + it('leaves the terminal-synthesized run rows on their own status fill', () => { + expect(getEntryAccentType('error')).toBeUndefined() + expect(getEntryAccentType('validation')).toBeUndefined() + expect(getEntryAccentType('cancelled')).toBeUndefined() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.ts index bd7ae34a07e..d1b5aa1f07d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.ts @@ -1,5 +1,6 @@ import type React from 'react' import { Ban, CircleX, Repeat, Split, TriangleAlert, Workflow } from '@sim/emcn/icons' +import { hasWorkflowTypeRole } from '@sim/workflow-renderer' import { getBlock } from '@/blocks' import { isWorkflowBlockType } from '@/executor/constants' import { TERMINAL_BLOCK_COLUMN_WIDTH } from '@/stores/constants' @@ -93,6 +94,23 @@ export function getBlockColor(blockType: string): string { return '#6b7280' } +/** + * The type a log row's tile takes its accent from, or `undefined` when the row + * must fall back to the block's own provider colour. + * + * Same rule the block toolbar applies, so a block is accented identically + * wherever it is listed: a core block always takes the canvas role accent (an + * unmapped one lands on `neutral`, exactly as it does in the toolbar), and + * anything else — integrations, triggers, subflows — takes one only if it has a + * role. That second clause is what leaves the terminal's synthesized + * `error`/`validation`/`cancelled` rows on their own status fill: they carry no + * config and no role, so they fall through to the provider-colour branch. + */ +export function getEntryAccentType(blockType: string): string | undefined { + const isCoreBlock = getBlock(blockType)?.category === 'blocks' + return isCoreBlock || hasWorkflowTypeRole(blockType) ? blockType : undefined +} + /** * Determines if a keyboard event originated from a text-editable element */ @@ -645,20 +663,6 @@ export function groupEntriesByExecution(entries: ConsoleEntry[]): ExecutionGroup return result } -/** - * Flattens entry tree into display order for keyboard navigation - */ -export function flattenEntryTree(nodes: EntryNode[]): ConsoleEntry[] { - const result: ConsoleEntry[] = [] - for (const node of nodes) { - result.push(node.entry) - if (node.children.length > 0) { - result.push(...flattenEntryTree(node.children)) - } - } - return result -} - /** * Block entry with parent tracking for navigation */ diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls.tsx index a593180796f..c934e7c22ea 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls.tsx @@ -5,6 +5,9 @@ import { Button, ChevronDown, Cursor, + chipHoverSurfaceClass, + cn, + disclosureChevronClass, Hand, Popover, PopoverAnchor, @@ -89,7 +92,15 @@ export const WorkflowControls = memo(function WorkflowControls() { return ( <>
{/* Canvas Mode Selector */} @@ -98,7 +109,7 @@ export const WorkflowControls = memo(function WorkflowControls() {
- -
@@ -122,7 +136,7 @@ export const WorkflowControls = memo(function WorkflowControls() { setIsCanvasModeOpen(false) }} > - + Mover - + Pointer @@ -143,11 +157,11 @@ export const WorkflowControls = memo(function WorkflowControls() { @@ -159,11 +173,11 @@ export const WorkflowControls = memo(function WorkflowControls() { @@ -177,10 +191,10 @@ export const WorkflowControls = memo(function WorkflowControls() { diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.test.tsx index 267e66b9275..c420e8910ab 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.test.tsx @@ -122,9 +122,11 @@ function row(name: string): HTMLElement { /** * Whether a row is painted with the persistent active fill. * - * Matches an exact class token, never a substring: the inactive chip carries - * `hover-hover:bg-[var(--surface-active)]`, which *contains* the active class, so a - * substring check reports every row as marked. + * Matches an exact class token, never a substring. The inactive chip now hovers to + * `--surface-hover`, so it no longer carries the active class as a substring — but + * keep the token match: hover and active are one token apart by design, and a + * substring check would silently start reporting every row as marked if they ever + * converge again. */ function isMarked(name: string): boolean { return [...row(name).querySelectorAll('*')].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, }}