mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
improvement(ui): align terminal with the workflow design system, fix row hover states (#6534)
* improvement(ui): align terminal with the workflow design system, fix row hover states Terminal: - derive log-row block tiles the way the canvas does (role accent for core blocks and subflows, provider colour only for role-less integrations) - compose rows from chipGeometryClass, chipContentLabelClass and disclosureChevronClass instead of re-deriving the pill - align the output tree's greys with the log rows; share ROW_STYLES.nested and BADGE_STYLE instead of duplicating the literals - neutralise value-type badges so red is the only colour in the tree - unify the row/separator gutter; normalise icon sizes to size-[14px] - drop dead flattenEntryTree and the RunningBadge re-exports Hover model (chipVariants, PopoverItem, Combobox, docs sidebar, terminal): - hover paints --surface-hover, one step below the --surface-active a selected row keeps, so a hovered row no longer impersonates the selected one - an active row holds its surface through hover instead of brightening Deploy modal: - move the footer actions onto the Chip family, primary action as Chip variant='primary' to match every other modal footer * improvement(emcn): extract the row-state surface pair, simplify the terminal rows Review follow-ups from /simplify and /cleanup: - add chipHoverSurfaceClass / chipActiveSurfaceClass to chip-chrome as the one home for the two-surface row model, and route chipVariants, PopoverItem, Combobox, the docs sidebar, the landing preview and queued messages through them instead of restating the literals - terminal ROW_STYLES now renders chipVariants rather than re-deriving its output, and the four rows share content/label/status classes resolved once - structured-output composes chipGeometryClass with an h-auto override rather than restating four of its literals - getEntryAccentType collapses to one expression, dropping the SYNTHETIC_BLOCK types coupling - collapse the chip compound variants to two array-matched entries - deploy modal: hoist the shared loader adornment, size it with chipContentIconClass so it matches every other chip icon - landing preview drops its --c-active/--c-hover inline aliases for the tokens - trim the rationale to one canonical copy with cross-references, and convert the block comments on declarations to TSDoc * improvement(workflow): align canvas controls with the canvas surface and icon scale - floating controls sit on --surface-2, the surface the block cards use, rather than --surface-1 (the sidebar/panel surface) - undo/redo/fit glyphs drop 16px -> the platform's 14px default, and the mode dropdown's own 12px icons come up to match; the control had three icon sizes - inactive buttons hover to --surface-hover instead of --surface-5, which was the active mode button's resting fill, so hovering one looked selected - inner radius goes concentric with the 4px padding (rounded-sm inside rounded-lg) * improvement(workflow): give the canvas-mode chevron the same treatment as its siblings It was the only control in the cluster with no hover fill and a different rest colour (--text-muted against the others' --text-secondary), so it read fainter and behaved differently under the pointer. It is also a disclosure chevron, so it now uses disclosureChevronClass instead of a hand-rolled duration-100 copy, and a real 20px box instead of the !p-1.5 override plus -m-1 hit-area hack. * test(terminal): lock the log-row accent rule against the block toolbar getEntryAccentType encodes a cross-surface rule — a block must be accented the same way in the terminal as in the block toolbar — and nothing enforced it. The table covers every branch: core blocks mapped and unmapped, role-bearing and role-less integrations and triggers, the config-less subflows, and the synthesized error/validation/cancelled rows that must keep their status fill. Verified failing: reverting the guard to an unconditional return reds two of the four cases. * fix(workflow): make notifications track panel and terminal resize live The toast stack insets by --panel-width / --terminal-height, but a resize drag writes those to the resized subtree only (.panel-container / .terminal-container) rather than to :root, because a custom-property write on :root recalculates the whole document (~150x slower). The stack is portalled to <body>, so it shares no ancestor with either and kept reading the stale :root value — it held its pre-drag position and jumped once the drag committed, while the canvas controls, which are laid out inside the shrinking canvas, tracked the drag in realtime. useDragResize now accepts several target subtrees and writes each one, so the scoped recalc is preserved and every consumer follows the drag frame by frame. The stack is found through a new data-toast-viewport attribute. Also drops the canvas controls from bottom-4 to bottom-2: the toast clears the terminal by 8px (it anchors from the viewport, and the terminal is inset by CONTENT_WINDOW_GAP), where the controls measure from the canvas floor and so sat at twice the gap. * improvement(workflow): inset the canvas controls 8px off both edges The toast stack clears the terminal and the panel by 8px — it anchors from the viewport at --terminal-height/--panel-width + 16px, and both are themselves inset by CONTENT_WINDOW_GAP (8px). The controls measure from the canvas floor and wall instead, so their 16px read as twice the gap on both axes. * improvement(workflow): lift the canvas controls and toasts to a 12px clearance 8px sat them too close to the terminal. 12 is on the same 4px grid as the surrounding spacing, where 10 would have been the only off-grid value in the area. Both surfaces clear the terminal and the panel by the same amount, so they read as one row; the toast's literals move into named insets rather than staying bare numbers in a style object. * refactor(hooks): split the drag's resize target from its other var consumers getTarget briefly accepted a list, which made the first entry both the resized element and the drag's liveness reference. A toast auto-dismisses after 5s, so had one ever led that list, its mid-drag unmount would have read as the drag target detaching and skipped the final recompute on release. The co-consumers now come through getExtraTargets, which is written but never consulted for liveness, and can come and go freely. * fix(emcn): stop the combobox cursor diverging from what Enter commits The option rows painted --surface-active from CSS :hover as well as from isHighlighted. CSS :hover tracks the pointer continuously while highlightedIndex only advances on mouseenter, so once the list scrolled under a stationary pointer the row that looked selected was not the one Enter would commit — Enter reads filteredOptions[highlightedIndex]. isHighlighted is now the single source of truth for the cursor, so paint and commit cannot disagree. The row under a stationary pointer may lag a scroll until the mouse moves, but it lags in agreement with what Enter will do, which is the invariant worth keeping. Disabled options also stop painting on hover, matching the mouseenter guard that already refused to highlight them. The 'All' row keeps its own hover: it clears the highlight rather than taking it, so it has no isHighlighted paint to fall back on. * fix(toast): derive the workflow inset from the shell's actual padding WORKFLOW_INSET_PX baked in the 8px the workspace shell normally insets the panel and terminal by, so the stack's 20px resolved to a 12px clearance — matching the canvas controls. But the shell drops to p-0 on the desktop title-bar shell with a collapsed sidebar, and there the stack would have sat 20px out while the controls, laid out inside the shell, stayed at 12. The stack now adds --workspace-content-gap (published on :root, zeroed by the same condition that zeroes the padding) to a flat 12, so the two surfaces hold the same clearance in both configurations. Before this PR they matched in the p-0 case at 16px each, so this closes a divergence the PR would otherwise have introduced.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 (`<div role='button'>`, `<DropdownMenuTrigger asChild>` 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 },
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
>
|
||||
<span className='flex-1 truncate text-[var(--text-primary)]'>
|
||||
@@ -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'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 `<body>` 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 `<body>`, 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,
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user