diff --git a/CLAUDE.md b/CLAUDE.md index eef307b6c1eb..eb3056d54389 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -227,6 +227,7 @@ yarn parse-schema # Parse JSON schemas for frontend (connection and - Shadows: `--shadow-xs` through `--shadow-3xl` - Border radius: `--radius-none` through `--radius-full` - **Color Usage**: Full token reference, dark mode guide, and anti-pattern cheat sheet: [`openmetadata-ui/src/main/resources/ui/docs/colors.md`](openmetadata-ui/src/main/resources/ui/docs/colors.md). Always consult this before choosing any color class. +- **Borders — never use `tw:ring-*` to draw an edge.** Rings compile to `box-shadow`, which WebKit does not pixel-snap, so they thin out and can vanish in Safari at non-100% zoom. Use `tw:border-*` where the edge may take layout space, or `tw:outline-1 tw:-outline-offset-1 tw:outline-` where it must not. On focusable elements the `outline` is already the focus ring — draw the border on `::after` via `borderAfter` from `@openmetadata/ui-core-components`. Rules, ring→outline translation table, and gotchas (`outline-hidden` erases outline borders; `transition-shadow` won't animate them): [`colors.md` §2.3.1](openmetadata-ui/src/main/resources/ui/docs/colors.md). - **Legacy**: Ant Design components remain in existing code but should be replaced with `openmetadata-ui-core-components` equivalents when refactoring - Do not add unnecessary spacing between logs and code. - In Java, avoid wildcards imports (e.g., use `import java.util.List;` instead of `import java.util.*;`) diff --git a/openmetadata-ui-core-components/CLAUDE.md b/openmetadata-ui-core-components/CLAUDE.md index a22f51c306d0..9366f5146e39 100644 --- a/openmetadata-ui-core-components/CLAUDE.md +++ b/openmetadata-ui-core-components/CLAUDE.md @@ -100,6 +100,52 @@ Theme colors are defined as CSS custom properties in `src/styles/globals.css` wi - Use `cx()` (from `tailwind-merge`) to merge class names with conflict resolution - Use `isReactComponent()` to type-guard icon props that accept both `FC` and `ReactNode` +### Borders: never use `ring-*` — use `border` or `outline` + +`tw:ring-*` compiles to a `box-shadow`, and **WebKit does not pixel-snap box-shadows** — a +ring used as a border thins out and can vanish entirely in Safari at non-100% zoom. `border` +and `outline` are snapped and never degrade. The library was migrated off rings; do not +reintroduce them. + +| Situation | Use | +|---|---| +| Edge may occupy layout space (static container) | `tw:border tw:border-` | +| Edge must be layout-neutral and the element's `outline` is free | `tw:outline-1 tw:-outline-offset-1 tw:outline-` | +| Element's `outline` is already the focus ring (any focusable control) | `borderAfter` from `@/utils/tailwindClasses` | + +`border` consumes layout, so on content-sized controls it adds 2px of height and makes them +grow on focus (1px → 2px). That is why controls use `outline`. + +```tsx +import { borderAfter } from '@/utils/tailwindClasses'; + +// host needs `tw:relative`; colour via `tw:after:outline-` +cx('tw:relative', borderAfter, 'tw:after:outline-primary', + isDisabled && 'tw:disabled:after:outline-disabled_subtle') +``` + +`borderAfter2` is the 2px variant. Note `::before` is already used by `button.tsx` and +`social-button.tsx` for their inner gradient — hence `::after`. + +Converting a ring: `ring-inset` → `-outline-offset-N`; **no** `ring-inset` → offset `0` (a +non-inset ring draws *outward*). Getting this wrong shifts the edge by 1px. + +Gotchas: +- **`tw:outline-hidden` erases an outline border** — remove it from any element whose border + is an outline. Unlayered LESS (`outline: none`) beats Tailwind utilities and will kill it. +- **`tw:transition-shadow` won't animate an outline** — use + `tw:transition-[outline-color,outline-width]`. Plain `tw:transition` covers `outline-color` + but not `outline-width`. +- **Keep `tw:shadow-*`** — a real drop shadow, not the ring. +- Consumers overriding a border must match where it's drawn: `::after` for + Button/ButtonUtility/Tab, the element for Input/Select/Badge/Card. + +Rings legitimately remain only where `ring-offset-*` fills the gap with a colour +(`color-picker-field`, `icon-picker-field`) — `outline-offset` leaves it transparent. + +Full rationale, measurements, and the anti-pattern table: +[`openmetadata-ui/src/main/resources/ui/docs/colors.md`](../openmetadata-ui/src/main/resources/ui/docs/colors.md) §2.3.1. + ### Button Color Variants The `Button` component supports these `color` values: diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/accordion/accordion.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/accordion/accordion.tsx index eb62af86ea1a..464401001288 100644 --- a/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/accordion/accordion.tsx +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/accordion/accordion.tsx @@ -94,7 +94,7 @@ export const Accordion = ({ {children} @@ -143,10 +143,13 @@ export const AccordionHeader = ({ {...props} className={(state) => cx( - 'tw:flex tw:w-full tw:cursor-pointer tw:items-center tw:justify-between tw:gap-3 tw:px-6 tw:py-4 tw:text-left tw:outline-hidden tw:transition tw:duration-200 tw:ease-in-out', + // `outline-hidden` removed: the outline now draws the focus indicator (it + // replaced a ring, which WebKit does not pixel-snap). + 'tw:flex tw:w-full tw:cursor-pointer tw:items-center tw:justify-between tw:gap-3 tw:px-6 tw:py-4 tw:text-left tw:transition tw:duration-200 tw:ease-in-out', 'tw:text-sm tw:font-semibold tw:text-primary', 'hover:tw:bg-primary_hover', - state.isFocusVisible && 'tw:ring-2 tw:ring-inset tw:ring-brand-300', + state.isFocusVisible && + 'tw:outline-2 tw:-outline-offset-2 tw:outline-brand-300', state.isDisabled && 'tw:cursor-not-allowed tw:text-disabled', typeof className === 'function' ? className(state) : className ) diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/app-navigation/base-components/nav-account-card.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/app-navigation/base-components/nav-account-card.tsx index 4487128874b9..f18b63ccf512 100644 --- a/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/app-navigation/base-components/nav-account-card.tsx +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/app-navigation/base-components/nav-account-card.tsx @@ -82,7 +82,7 @@ const NavAccountCardMenuItem = ({ {shortcut && ( - + {shortcut} )} @@ -137,11 +137,15 @@ export const NavAccountMenu = ({ -
+
{ {(segment) => ( diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/date-picker/date-picker.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/date-picker/date-picker.tsx index 7068a1d2d8be..919be2d3ab19 100644 --- a/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/date-picker/date-picker.tsx +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/date-picker/date-picker.tsx @@ -71,7 +71,9 @@ export const DatePicker = ({ } offset={8} placement="bottom right"> - + {/* outline-[3px] ports the bare `tw:ring` faithfully (3px in Tailwind v4, almost + certainly unintended vs the ring-1 used elsewhere — tracked as a follow-up). */} + {({ close }) => ( <>
diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/date-picker/date-range-picker.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/date-picker/date-range-picker.tsx index f5e530053df5..bb90eaeef939 100644 --- a/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/date-picker/date-range-picker.tsx +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/date-picker/date-range-picker.tsx @@ -151,7 +151,10 @@ export const DateRangePicker = ({ } offset={8} placement="bottom right"> - + {/* `outline-[3px]` ports the bare `tw:ring` faithfully (3px in Tailwind v4 — likely + unintended vs ring-1 elsewhere; tracked as a follow-up). `focus:outline-hidden` + removed: it would suppress this border. */} + {({ close }) => ( <>
diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/file-upload/file-upload.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/file-upload/file-upload.tsx index df94359a556f..20770e9687d2 100644 --- a/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/file-upload/file-upload.tsx +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/file-upload/file-upload.tsx @@ -237,8 +237,10 @@ export const FileUploadDropZone = ({ aria-disabled={isDisabled} aria-labelledby={`${id}-label`} className={cx( - 'tw:relative tw:flex tw:flex-col tw:items-center tw:gap-3 tw:rounded-xl tw:bg-primary tw:px-6 tw:py-4 tw:text-tertiary tw:ring-1 tw:ring-secondary tw:transition tw:duration-100 tw:ease-linear tw:ring-inset', - isDraggingOver && 'tw:ring-2 tw:ring-brand', + // Border drawn with outline, not a ring: WebKit does not pixel-snap box-shadow, so + // rings thin/vanish in Safari when zoomed out. + 'tw:relative tw:flex tw:flex-col tw:items-center tw:gap-3 tw:rounded-xl tw:bg-primary tw:px-6 tw:py-4 tw:text-tertiary tw:outline-1 tw:-outline-offset-1 tw:outline-secondary tw:transition tw:duration-100 tw:ease-linear', + isDraggingOver && 'tw:outline-2 tw:-outline-offset-2 tw:outline-brand', isDisabled ? 'tw:cursor-not-allowed tw:bg-secondary' : 'tw:cursor-pointer', @@ -342,8 +344,10 @@ export const FileListItemProgressBar = ({ return (
  • {isOpen && ( -
    +
    {allowUrl ? ( button]:h-6 tw:[&>button]:rounded-lg tw:[&>button]:outline-none! tw:[&>button]:focus-visible:outline-none! tw:[&>button]:focus-visible:ring-0 tw:[&>button]:focus-visible:ring-offset-0 tw:[&>button>span]:h-6 tw:[&>button>span]:gap-1 tw:[&>button>span]:px-1 tw:[&>button>span]:py-0 tw:[&>button>span>section]:min-w-0 tw:[&>button>span>section]:gap-0 tw:[&>button>span>section>p]:min-w-4 tw:[&>button>span>section>p]:text-center tw:[&>button>span>section>p]:text-xs tw:[&>button>span>section>p]:leading-[18px] tw:[&>button>span>svg]:size-3'; + 'tw:w-[42px] tw:shrink-0 tw:gap-0 tw:[&>button]:h-6 tw:[&>button]:rounded-lg tw:[&>button]:focus-visible:outline-none! tw:[&>button>span]:h-6 tw:[&>button>span]:gap-1 tw:[&>button>span]:px-1 tw:[&>button>span]:py-0 tw:[&>button>span>section]:min-w-0 tw:[&>button>span>section]:gap-0 tw:[&>button>span>section>p]:min-w-4 tw:[&>button>span>section>p]:text-center tw:[&>button>span>section>p]:text-xs tw:[&>button>span>section>p]:leading-[18px] tw:[&>button>span>svg]:size-3'; const compactRowsPerPageItemClassName = 'tw:px-0 tw:[&>div]:h-8 tw:[&>div]:px-2 tw:[&>div]:py-0 tw:[&>div]:outline-none! tw:[&[data-focused]>div]:bg-primary_hover tw:[&[data-selected]>div]:bg-primary tw:[&>div>div]:min-w-0 tw:[&>div>div>span]:text-xs tw:[&>div>div>span]:leading-[18px]'; diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/popover/popover.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/popover/popover.tsx index 89d3dfea5606..175cc63cfc02 100644 --- a/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/popover/popover.tsx +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/popover/popover.tsx @@ -74,7 +74,10 @@ export const Popover = ({ {...popoverProps} className={(state) => cx( - 'tw:origin-(--trigger-anchor-point) tw:rounded-xl tw:bg-primary tw:shadow-lg tw:ring-1 tw:ring-secondary_alt tw:outline-hidden tw:will-change-transform', + // `outline-hidden` removed: the outline now draws this popover's border (it + // replaced a ring, which WebKit does not pixel-snap), so suppressing it would + // erase the border. + 'tw:origin-(--trigger-anchor-point) tw:rounded-xl tw:bg-primary tw:shadow-lg tw:outline-1 tw:outline-secondary_alt tw:will-change-transform', state.isEntering && 'tw:duration-150 tw:ease-out tw:animate-in tw:fade-in tw:placement-left:slide-in-from-right-0.5 tw:placement-right:slide-in-from-left-0.5 tw:placement-top:slide-in-from-bottom-0.5 tw:placement-bottom:slide-in-from-top-0.5', state.isExiting && diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/progress-steps/progress-steps.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/progress-steps/progress-steps.tsx index f104eef4ba23..00a96b8c9394 100644 --- a/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/progress-steps/progress-steps.tsx +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/progress-steps/progress-steps.tsx @@ -127,9 +127,9 @@ const sizes = sortCx({ const circleStatusStyles = sortCx({ complete: 'tw:bg-brand-solid tw:text-fg-white', current: - 'tw:bg-brand-secondary tw:text-fg-brand-primary tw:ring-2 tw:ring-inset tw:ring-bg-brand-solid', + 'tw:bg-brand-secondary tw:text-fg-brand-primary tw:outline-2 tw:-outline-offset-2 tw:outline-bg-brand-solid', incomplete: - 'tw:bg-primary tw:text-fg-quaternary tw:ring-1 tw:ring-inset tw:ring-primary', + 'tw:bg-primary tw:text-fg-quaternary tw:outline-1 tw:-outline-offset-1 tw:outline-primary', }); const titleStatusStyles = sortCx({ diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/slideout-menus/slideout-menu.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/slideout-menus/slideout-menu.tsx index 5e57df2b7ae7..7010d01c2010 100644 --- a/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/slideout-menus/slideout-menu.tsx +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/slideout-menus/slideout-menu.tsx @@ -70,7 +70,9 @@ export const Dialog = (props: DialogProps) => ( role="dialog" {...props} className={cx( - 'tw:relative tw:flex tw:size-full tw:flex-col tw:items-start tw:gap-6 tw:overflow-y-auto tw:bg-primary tw:ring-1 tw:ring-secondary_alt tw:outline-hidden', + // `outline-hidden` removed: the outline now draws this panel's border (it replaced a + // ring, which WebKit does not pixel-snap), so suppressing it would erase the border. + 'tw:relative tw:flex tw:size-full tw:flex-col tw:items-start tw:gap-6 tw:overflow-y-auto tw:bg-primary tw:outline-1 tw:outline-secondary_alt', props.className )} /> diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/table/table.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/table/table.tsx index 7bce2f7f4774..8d32a529e5a5 100644 --- a/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/table/table.tsx +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/table/table.tsx @@ -78,7 +78,7 @@ const TableCardRoot = ({
    {children} @@ -259,7 +259,11 @@ const TableHead = ({ {...props} className={(state) => cx( - 'tw:relative tw:p-0 tw:px-6 tw:py-2 tw:outline-hidden tw:focus-visible:z-1 tw:focus-visible:ring-2 tw:focus-visible:ring-focus-ring tw:focus-visible:ring-offset-bg-primary tw:focus-visible:ring-inset', + // Focus indicator drawn with outline, not a ring (WebKit does not pixel-snap + // box-shadow). `outline-hidden` is gone — it would suppress this indicator. + // `ring-offset-bg-primary` is dropped: it set an offset *colour* while + // --tw-ring-offset-width defaults to 0px, so it never rendered. + 'tw:relative tw:p-0 tw:px-6 tw:py-2 tw:focus-visible:z-1 tw:focus-visible:outline-2 tw:focus-visible:-outline-offset-2 tw:focus-visible:outline-focus-ring', selectionBehavior === 'toggle' && 'tw:nth-2:pl-3', state.allowsSorting && 'tw:cursor-pointer', typeof className === 'function' ? className(state) : className diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/tabs/tabs.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/tabs/tabs.tsx index 6e0f261db98f..73672063719e 100644 --- a/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/tabs/tabs.tsx +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/tabs/tabs.tsx @@ -1,6 +1,7 @@ import type { BadgeColors, Sizes } from '@/components/base/badges/badge-types'; import { Badge } from '@/components/base/badges/badges'; import { cx } from '@/utils/cx'; +import { borderAfter } from '@/utils/tailwindClasses'; import type { ComponentPropsWithRef, ReactNode } from 'react'; import { Fragment, createContext, useContext, useMemo } from 'react'; import type { @@ -69,7 +70,7 @@ const getTabStyles = ({ isHovered && 'tw:text-secondary', isFocusVisible && 'tw:outline-2 tw:-outline-offset-2', isSelected && - 'tw:bg-primary_alt tw:text-secondary tw:shadow-xs tw:ring-1 tw:ring-primary tw:ring-inset' + `tw:bg-primary_alt tw:text-secondary tw:shadow-xs ${borderAfter} tw:after:outline-primary` ), underline: cx( 'tw:rounded-none tw:border-b-2 tw:border-transparent tw:outline-focus-ring', @@ -124,11 +125,11 @@ const getHorizontalStyles = ({ 'button-brand': 'tw:gap-1', 'button-gray': 'tw:gap-1', 'button-border': cx( - 'tw:gap-1 tw:rounded-[10px] tw:bg-secondary_alt tw:p-1 tw:ring-1 tw:ring-secondary tw:ring-inset', + 'tw:gap-1 tw:rounded-[10px] tw:bg-secondary_alt tw:p-1 tw:outline-1 tw:-outline-offset-1 tw:outline-secondary', size === 'md' && 'tw:rounded-xl tw:p-1.5' ), 'button-minimal': - 'tw:gap-0.5 tw:rounded-lg tw:bg-secondary_alt tw:ring-1 tw:ring-inset tw:ring-secondary', + 'tw:gap-0.5 tw:rounded-lg tw:bg-secondary_alt tw:outline-1 tw:-outline-offset-1 tw:outline-secondary', underline: cx('tw:gap-3', fullWidth && 'tw:w-full tw:gap-4'), line: 'tw:gap-2', }); @@ -203,7 +204,9 @@ export const Tab = (props: TabComponentProps) => { {...otherProps} className={(prop) => cx( - 'tw:z-10 tw:flex tw:h-max tw:cursor-pointer tw:items-center tw:justify-center tw:gap-2 tw:rounded-md tw:whitespace-nowrap tw:text-quaternary tw:transition tw:duration-100 tw:ease-linear', + // `tw:relative` anchors the button-minimal selected ::after border — the tab's + // own outline is reserved for the focus ring. + 'tw:relative tw:z-10 tw:flex tw:h-max tw:cursor-pointer tw:items-center tw:justify-center tw:gap-2 tw:rounded-md tw:whitespace-nowrap tw:text-quaternary tw:transition tw:duration-100 tw:ease-linear', 'group-orientation-vertical:tw:justify-start', fullWidth && 'tw:w-full tw:flex-1', sizes[size][type], diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/tree-select/tree-select.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/tree-select/tree-select.tsx index e88812ea2f43..980ed141d368 100644 --- a/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/tree-select/tree-select.tsx +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/tree-select/tree-select.tsx @@ -285,11 +285,15 @@ export const TreeSelect = ({
    ({ {multiple && selectedData.map((node) => (

    {node.label} @@ -370,7 +374,7 @@ export const TreeSelect = ({ {isOpen && (

    ({ {...props} className={(state) => cx( - 'tw:group/tree-item tw:outline-hidden tw:rounded-md', + // `outline-hidden` removed: the outline now draws the focus/drag indicators + // (they replaced rings, which WebKit does not pixel-snap). + 'tw:group/tree-item tw:rounded-md', 'tw:cursor-pointer tw:select-none', state.isDisabled && 'tw:opacity-50 tw:cursor-not-allowed', - state.isFocusVisible && 'tw:ring-2 tw:ring-inset tw:ring-brand-300', - 'data-[dragging]:tw:opacity-50 data-[dragging]:tw:ring-2 data-[dragging]:tw:ring-inset data-[dragging]:tw:ring-brand-300', - 'data-[drop-target]:tw:bg-brand-primary_alt data-[drop-target]:tw:ring-2 data-[drop-target]:tw:ring-inset data-[drop-target]:tw:ring-brand-300', + state.isFocusVisible && + 'tw:outline-2 tw:-outline-offset-2 tw:outline-brand-300', + 'data-[dragging]:tw:opacity-50 data-[dragging]:tw:outline-2 data-[dragging]:tw:-outline-offset-2 data-[dragging]:tw:outline-brand-300', + 'data-[drop-target]:tw:bg-brand-primary_alt data-[drop-target]:tw:outline-2 data-[drop-target]:tw:-outline-offset-2 data-[drop-target]:tw:outline-brand-300', typeof className === 'function' ? className(state) : className ) }> @@ -173,9 +176,10 @@ const TreeExpandButton = ({ className, ...props }: TreeExpandButtonProps) => { className={(state) => cx( 'tw:flex tw:items-center tw:justify-center tw:w-4 tw:h-4 tw:shrink-0', - 'tw:rounded tw:outline-hidden tw:text-fg-quaternary', + // `outline-hidden` removed: the outline now draws the focus indicator. + 'tw:rounded tw:text-fg-quaternary', 'tw:transition-transform tw:duration-200 tw:ease-in-out tw:cursor-pointer', - state.isFocusVisible && 'tw:ring-2 tw:ring-brand-300', + state.isFocusVisible && 'tw:outline-2 tw:outline-brand-300', className ) } diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/autocomplete/autocomplete-item.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/autocomplete/autocomplete-item.tsx index 5160de09f33f..c2f51d2ad28e 100644 --- a/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/autocomplete/autocomplete-item.tsx +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/autocomplete/autocomplete-item.tsx @@ -21,8 +21,10 @@ const sizes = { md: 'tw:p-2.5 tw:pl-2', }; +// `outline-hidden` removed: the outline now draws the focus indicator (it replaced a ring, +// which WebKit does not pixel-snap). const itemWrapperBase = - 'tw:flex tw:cursor-pointer tw:items-center tw:gap-2 tw:rounded-md tw:outline-hidden tw:select-none'; + 'tw:flex tw:cursor-pointer tw:items-center tw:gap-2 tw:rounded-md tw:select-none'; const itemWrapperClass = ( state: ListBoxItemRenderProps, @@ -34,7 +36,8 @@ const itemWrapperClass = ( state.isSelected && 'tw:bg-active', state.isDisabled && 'tw:cursor-not-allowed', state.isFocused && 'tw:bg-primary_hover', - state.isFocusVisible && 'tw:ring-2 tw:ring-focus-ring tw:ring-inset', + state.isFocusVisible && + 'tw:outline-2 tw:-outline-offset-2 tw:outline-focus-ring', sizes[size], extraClass ); diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/autocomplete/autocomplete.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/autocomplete/autocomplete.tsx index 07abe3b2988b..7e391710130d 100644 --- a/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/autocomplete/autocomplete.tsx +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/autocomplete/autocomplete.tsx @@ -313,11 +313,16 @@ const AutocompleteTrigger = ({ {...otherProps} className={({ isFocusWithin, isDisabled }) => cx( - 'tw:relative tw:flex tw:w-full tw:items-center tw:gap-2 tw:rounded-lg tw:bg-primary tw:shadow-xs tw:ring-1 tw:ring-primary tw:outline-hidden tw:transition tw:duration-100 tw:ease-linear tw:ring-inset', + // Border drawn with outline, not a ring (WebKit does not pixel-snap box-shadow, + // so rings thin/vanish in Safari when zoomed out). `outline-hidden` is gone — the + // outline IS the border and focus indicator, as in input.tsx. + 'tw:relative tw:flex tw:w-full tw:items-center tw:gap-2 tw:rounded-lg tw:bg-primary tw:shadow-xs tw:outline-1 tw:-outline-offset-1 tw:outline-primary tw:transition tw:duration-100 tw:ease-linear', isDisabled && 'tw:cursor-not-allowed tw:bg-disabled_subtle', - isInvalid && 'tw:ring-error_subtle', - isFocusWithin && 'tw:ring-2 tw:ring-brand', - isFocusWithin && isInvalid && 'tw:ring-2 tw:ring-error', + isInvalid && 'tw:outline-error_subtle', + isFocusWithin && 'tw:outline-2 tw:-outline-offset-2 tw:outline-brand', + isFocusWithin && + isInvalid && + 'tw:outline-2 tw:-outline-offset-2 tw:outline-error', sizes[size].root ) } diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/avatar/avatar-profile-photo.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/avatar/avatar-profile-photo.tsx index f55e1738c568..888f404ff986 100644 --- a/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/avatar/avatar-profile-photo.tsx +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/avatar/avatar-profile-photo.tsx @@ -77,7 +77,7 @@ export const AvatarProfilePhoto = ({ return (
    @@ -91,7 +91,7 @@ export const AvatarProfilePhoto = ({ return (
    {placeholder || ( @@ -140,7 +140,7 @@ export const AvatarProfilePhoto = ({ return (
    ( = { light: { - root: 'tw:rounded-full tw:ring-1 tw:ring-inset', - addon: 'tw:rounded-full tw:ring-1 tw:ring-inset', + root: 'tw:rounded-full tw:outline-1 tw:-outline-offset-1', + addon: 'tw:rounded-full tw:outline-1 tw:-outline-offset-1', }, modern: { - root: 'tw:rounded-[10px] tw:bg-primary tw:text-secondary tw:shadow-xs tw:ring-1 tw:ring-inset tw:ring-primary tw:hover:bg-secondary', + root: 'tw:rounded-[10px] tw:bg-primary tw:text-secondary tw:shadow-xs tw:outline-1 tw:-outline-offset-1 tw:outline-primary tw:hover:bg-secondary', addon: - 'tw:flex tw:items-center tw:rounded-md tw:bg-primary tw:shadow-xs tw:ring-1 tw:ring-inset tw:ring-primary', + 'tw:flex tw:items-center tw:rounded-md tw:bg-primary tw:shadow-xs tw:outline-1 tw:-outline-offset-1 tw:outline-primary', icon: 'tw:text-utility-gray-500', }, }; @@ -92,28 +92,28 @@ const colorClasses: Record< > = sortCx({ light: { brand: { - root: 'tw:bg-utility-brand-50 tw:text-utility-brand-700 tw:ring-utility-brand-200 tw:hover:bg-utility-brand-100', - addon: 'tw:bg-primary tw:text-current tw:ring-utility-brand-200', + root: 'tw:bg-utility-brand-50 tw:text-utility-brand-700 tw:outline-utility-brand-200 tw:hover:bg-utility-brand-100', + addon: 'tw:bg-primary tw:text-current tw:outline-utility-brand-200', icon: 'tw:text-utility-brand-500', }, gray: { - root: 'tw:bg-utility-gray-50 tw:text-utility-gray-700 tw:ring-utility-gray-200 tw:hover:bg-utility-gray-100', - addon: 'tw:bg-primary tw:text-current tw:ring-utility-gray-200', + root: 'tw:bg-utility-gray-50 tw:text-utility-gray-700 tw:outline-utility-gray-200 tw:hover:bg-utility-gray-100', + addon: 'tw:bg-primary tw:text-current tw:outline-utility-gray-200', icon: 'tw:text-utility-gray-500', }, error: { - root: 'tw:bg-utility-error-50 tw:text-utility-error-700 tw:ring-utility-error-200 tw:hover:bg-utility-error-100', - addon: 'tw:bg-primary tw:text-current tw:ring-utility-error-200', + root: 'tw:bg-utility-error-50 tw:text-utility-error-700 tw:outline-utility-error-200 tw:hover:bg-utility-error-100', + addon: 'tw:bg-primary tw:text-current tw:outline-utility-error-200', icon: 'tw:text-utility-error-500', }, warning: { - root: 'tw:bg-utility-warning-50 tw:text-utility-warning-700 tw:ring-utility-warning-200 tw:hover:bg-utility-warning-100', - addon: 'tw:bg-primary tw:text-current tw:ring-utility-warning-200', + root: 'tw:bg-utility-warning-50 tw:text-utility-warning-700 tw:outline-utility-warning-200 tw:hover:bg-utility-warning-100', + addon: 'tw:bg-primary tw:text-current tw:outline-utility-warning-200', icon: 'tw:text-utility-warning-500', }, success: { - root: 'tw:bg-utility-success-50 tw:text-utility-success-700 tw:ring-utility-success-200 tw:hover:bg-utility-success-100', - addon: 'tw:bg-primary tw:text-current tw:ring-utility-success-200', + root: 'tw:bg-utility-success-50 tw:text-utility-success-700 tw:outline-utility-success-200 tw:hover:bg-utility-success-100', + addon: 'tw:bg-primary tw:text-current tw:outline-utility-success-200', icon: 'tw:text-utility-success-500', }, }, diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/badges/badges.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/badges/badges.tsx index d59991404e1a..bd43fa46a1af 100644 --- a/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/badges/badges.tsx +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/components/base/badges/badges.tsx @@ -17,79 +17,79 @@ export const filledColors: Record< { root: string; addon: string; addonButton: string } > = { gray: { - root: 'tw:bg-utility-gray-50 tw:text-utility-gray-700 tw:ring-utility-gray-200', + root: 'tw:bg-utility-gray-50 tw:text-utility-gray-700 tw:outline-utility-gray-200', addon: 'tw:text-utility-gray-500', addonButton: 'tw:hover:bg-utility-gray-100 tw:text-utility-gray-400 tw:hover:text-utility-gray-500', }, brand: { - root: 'tw:bg-utility-brand-50 tw:text-utility-brand-700 tw:ring-utility-brand-200', + root: 'tw:bg-utility-brand-50 tw:text-utility-brand-700 tw:outline-utility-brand-200', addon: 'tw:text-utility-brand-500', addonButton: 'tw:hover:bg-utility-brand-100 tw:text-utility-brand-400 tw:hover:text-utility-brand-500', }, error: { - root: 'tw:bg-utility-error-50 tw:text-utility-error-700 tw:ring-utility-error-200', + root: 'tw:bg-utility-error-50 tw:text-utility-error-700 tw:outline-utility-error-200', addon: 'tw:text-utility-error-500', addonButton: 'tw:hover:bg-utility-error-100 tw:text-utility-error-400 tw:hover:text-utility-error-500', }, warning: { - root: 'tw:bg-utility-warning-50 tw:text-utility-warning-700 tw:ring-utility-warning-200', + root: 'tw:bg-utility-warning-50 tw:text-utility-warning-700 tw:outline-utility-warning-200', addon: 'tw:text-utility-warning-500', addonButton: 'tw:hover:bg-utility-warning-100 tw:text-utility-warning-400 tw:hover:text-utility-warning-500', }, success: { - root: 'tw:bg-utility-success-50 tw:text-utility-success-700 tw:ring-utility-success-200', + root: 'tw:bg-utility-success-50 tw:text-utility-success-700 tw:outline-utility-success-200', addon: 'tw:text-utility-success-500', addonButton: 'tw:hover:bg-utility-success-100 tw:text-utility-success-400 tw:hover:text-utility-success-500', }, 'gray-blue': { - root: 'tw:bg-utility-gray-blue-50 tw:text-utility-gray-blue-700 tw:ring-utility-gray-blue-200', + root: 'tw:bg-utility-gray-blue-50 tw:text-utility-gray-blue-700 tw:outline-utility-gray-blue-200', addon: 'tw:text-utility-gray-blue-500', addonButton: 'tw:hover:bg-utility-gray-blue-100 tw:text-utility-gray-blue-400 tw:hover:text-utility-gray-blue-500', }, 'blue-light': { - root: 'tw:bg-utility-blue-light-50 tw:text-utility-blue-light-700 tw:ring-utility-blue-light-200', + root: 'tw:bg-utility-blue-light-50 tw:text-utility-blue-light-700 tw:outline-utility-blue-light-200', addon: 'tw:text-utility-blue-light-500', addonButton: 'tw:hover:bg-utility-blue-light-100 tw:text-utility-blue-light-400 tw:hover:text-utility-blue-light-500', }, blue: { - root: 'tw:bg-utility-blue-50 tw:text-utility-blue-700 tw:ring-utility-blue-200', + root: 'tw:bg-utility-blue-50 tw:text-utility-blue-700 tw:outline-utility-blue-200', addon: 'tw:text-utility-blue-500', addonButton: 'tw:hover:bg-utility-blue-100 tw:text-utility-blue-400 tw:hover:text-utility-blue-500', }, indigo: { - root: 'tw:bg-utility-indigo-50 tw:text-utility-indigo-700 tw:ring-utility-indigo-200', + root: 'tw:bg-utility-indigo-50 tw:text-utility-indigo-700 tw:outline-utility-indigo-200', addon: 'tw:text-utility-indigo-500', addonButton: 'tw:hover:bg-utility-indigo-100 tw:text-utility-indigo-400 tw:hover:text-utility-indigo-500', }, purple: { - root: 'tw:bg-utility-purple-50 tw:text-utility-purple-700 tw:ring-utility-purple-200', + root: 'tw:bg-utility-purple-50 tw:text-utility-purple-700 tw:outline-utility-purple-200', addon: 'tw:text-utility-purple-500', addonButton: 'tw:hover:bg-utility-purple-100 tw:text-utility-purple-400 tw:hover:text-utility-purple-500', }, pink: { - root: 'tw:bg-utility-pink-50 tw:text-utility-pink-700 tw:ring-utility-pink-200', + root: 'tw:bg-utility-pink-50 tw:text-utility-pink-700 tw:outline-utility-pink-200', addon: 'tw:text-utility-pink-500', addonButton: 'tw:hover:bg-utility-pink-100 tw:text-utility-pink-400 tw:hover:text-utility-pink-500', }, orange: { - root: 'tw:bg-utility-orange-50 tw:text-utility-orange-700 tw:ring-utility-orange-200', + root: 'tw:bg-utility-orange-50 tw:text-utility-orange-700 tw:outline-utility-orange-200', addon: 'tw:text-utility-orange-500', addonButton: 'tw:hover:bg-utility-orange-100 tw:text-utility-orange-400 tw:hover:text-utility-orange-500', }, 'blue-dark': { - root: 'tw:bg-utility-blue-dark-50 tw:text-utility-gray-700 tw:ring-utility-gray-blue-200', + root: 'tw:bg-utility-blue-dark-50 tw:text-utility-gray-700 tw:outline-utility-gray-blue-200', addon: 'tw:text-utility-gray-700', addonButton: 'tw:hover:bg-utility-blue-dark-100 tw:text-utility-gray-700 tw:hover:text-utility-blue-dark-500', @@ -119,7 +119,7 @@ const withPillTypes = { 'tw:size-max tw:flex tw:items-center tw:whitespace-nowrap tw:rounded-md tw:shadow-xs', styles: { gray: { - root: 'tw:bg-primary tw:text-secondary tw:ring-primary', + root: 'tw:bg-primary tw:text-secondary tw:outline-primary', addon: 'tw:text-gray-500', addonButton: 'tw:hover:bg-utility-gray-100 tw:text-utility-gray-400 tw:hover:text-utility-gray-500', @@ -141,7 +141,7 @@ const withBadgeTypes = { }, [badgeTypes.badgeModern]: { common: - 'tw:size-max tw:flex tw:items-center tw:whitespace-nowrap tw:rounded-md tw:bg-primary tw:text-secondary tw:ring-primary tw:shadow-xs', + 'tw:size-max tw:flex tw:items-center tw:whitespace-nowrap tw:rounded-md tw:bg-primary tw:text-secondary tw:outline-primary tw:shadow-xs', styles: addonOnlyColors, }, }; @@ -195,7 +195,7 @@ export const Badge = (props: BadgeProps) => { colors.common, sizes[type][size], colors.styles[color].root, - bordered && 'tw:ring-1 tw:ring-inset', + bordered && 'tw:outline-1 tw:-outline-offset-1', props.className )} data-testid={props['data-testid']}> @@ -253,7 +253,7 @@ export const BadgeWithDot = ( colors.common, sizes[type][size], colors.styles[color].root, - bordered && 'tw:ring-1 tw:ring-inset', + bordered && 'tw:outline-1 tw:-outline-offset-1', className )}> @@ -352,7 +352,7 @@ export const BadgeWithIcon = ( colors.common, sizes[type][size][icon], colors.styles[color].root, - bordered && 'tw:ring-1 tw:ring-inset', + bordered && 'tw:outline-1 tw:-outline-offset-1', className )}> {IconLeading && ( @@ -418,7 +418,7 @@ export const BadgeWithFlag = ( colors.common, sizes[type][size], colors.styles[color].root, - bordered && 'tw:ring-1 tw:ring-inset' + bordered && 'tw:outline-1 tw:-outline-offset-1' )}> {`${flag}( colors.common, sizes[type][size], colors.styles[color].root, - bordered && 'tw:ring-1 tw:ring-inset' + bordered && 'tw:outline-1 tw:-outline-offset-1' )}> Badge( colors.common, sizes[type][size], colors.styles[color].root, - bordered && 'tw:ring-1 tw:ring-inset' + bordered && 'tw:outline-1 tw:-outline-offset-1' )}> {children} -
    +

    Filter by status

    diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/utils/tailwindClasses.ts b/openmetadata-ui-core-components/src/main/resources/ui/src/utils/tailwindClasses.ts index 5ba092573d5c..9b89e3a7b37f 100644 --- a/openmetadata-ui-core-components/src/main/resources/ui/src/utils/tailwindClasses.ts +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/utils/tailwindClasses.ts @@ -23,3 +23,25 @@ export const fontSizeClass: Record<'xs' | 'sm' | 'md' | 'lg' | 'xl', string> = { lg: 'tw:text-lg', xl: 'tw:text-xl', }; + +/** + * Draws a 1px border on the ::after overlay, for elements whose own `outline` is already + * taken by the focus ring. + * + * Rings are box-shadows, and WebKit does not pixel-snap box-shadows — so a ring used as a + * border thins or disappears in Safari at fractional zoom. Outlines are snapped. An element + * has only one outline, so on focusable elements the border moves to ::after and the + * element's own outline stays free for focus. + * + * Layout-neutral. Requires `tw:relative` on the host. Supply the colour per variant with + * `tw:after:outline-`; state changes use `tw::after:outline-`. + * + * Where the element's outline is NOT already in use, prefer the simpler + * `tw:outline-1 tw:-outline-offset-1 tw:outline-` directly on the element. + */ +export const borderAfter = + 'tw:after:pointer-events-none tw:after:absolute tw:after:inset-0 tw:after:rounded-[inherit] tw:after:outline-1 tw:after:-outline-offset-1'; + +/** 2px variant of {@link borderAfter}, for elements whose border was a `ring-2`. */ +export const borderAfter2 = + 'tw:after:pointer-events-none tw:after:absolute tw:after:inset-0 tw:after:rounded-[inherit] tw:after:outline-2 tw:after:-outline-offset-2'; diff --git a/openmetadata-ui/src/main/resources/ui/docs/colors.md b/openmetadata-ui/src/main/resources/ui/docs/colors.md index 7762fc5f3bd6..c4ebad28dceb 100644 --- a/openmetadata-ui/src/main/resources/ui/docs/colors.md +++ b/openmetadata-ui/src/main/resources/ui/docs/colors.md @@ -176,7 +176,9 @@ Use border tokens for input outlines, card dividers, separators, and table borde | `tw:border-disabled` | `--color-border-disabled` | `#d5d7da` | `#373a41` | Disabled input border | | `tw:border-disabled_subtle` | `--color-border-disabled_subtle` | `#e9eaeb` | `#22262f` | Subtle disabled border | -**Ring and outline tokens** follow the same naming (`tw:ring-primary`, `tw:outline-brand`, etc.) and map to the same values. +**Outline tokens** follow the same naming (`tw:outline-primary`, `tw:outline-brand`, …) and +resolve to the same values. **Ring tokens exist but must not be used to draw an edge** — see +§2.3.1. ```tsx // ✅ Correct @@ -190,6 +192,101 @@ Use border tokens for input outlines, card dividers, separators, and table borde --- +### 2.3.1 Never use `ring-*` to draw a border — use `border` or `outline` + +**Rule: `tw:ring-*` is banned for any visible edge.** Use `tw:border-*` where the edge may +occupy layout space, and `tw:outline-*` where it must not. + +#### Why + +Tailwind's `ring-*` compiles to a `box-shadow`. **WebKit does not pixel-snap box-shadows**, +so a ring used as a border thins out — and at some zoom levels disappears entirely — in +Safari. `border` and `outline` are snapped to whole device pixels and never degrade. + +Measured peak border darkness (42 = full strength) across a 50–150% zoom sweep: + +| | `border` | `ring` | `outline` | +|---|---|---|---| +| WebKit @1x | 42..42 | **0..42** (vanishes) | 42..42 | +| Chromium @1x | 42..42 | 21..42 (dims ~50%) | 42..42 | +| WebKit @2x | 42..42 | 42..42 | 42..42 | + +Chromium degrades rings too — it just never falls far enough to notice, which is why this +reads as a Safari-only bug. Confirmed in real Safari: an inset outline and a real border are +indistinguishable at every zoom; the ring is not. + +#### Which one to use + +| Situation | Use | +|---|---| +| Edge may take layout space (static container, card) | `tw:border tw:border-` | +| Edge must be layout-neutral, element's `outline` is free | `tw:outline-1 tw:-outline-offset-1 tw:outline-` | +| Element's `outline` is already the focus ring | **`::after` overlay** — see below | + +`border` consumes 1px of the box. On controls whose height is content-driven, switching a +ring to a border makes them 2px taller and makes them **grow on focus** (1px → 2px). That is +why controls use `outline`, not `border`. + +#### Translating an existing ring + +| Ring | Equivalent | +|---|---| +| `tw:ring-1 tw:ring-inset tw:ring-X` | `tw:outline-1 tw:-outline-offset-1 tw:outline-X` | +| `tw:ring-1 tw:ring-X` (no `ring-inset`) | `tw:outline-1 tw:outline-X` — offset **0**, since a non-inset ring draws *outward* | +| `tw:ring-2 …` (focus) | `tw:outline-2 tw:-outline-offset-2 tw:outline-X` | +| `tw:ring-0` (suppressor) | `tw:outline-0` — or `tw:after:outline-0` if the target draws its border on `::after` | + +Getting the offset wrong shifts the edge by 1px, so check for `ring-inset` before converting. + +#### When the outline is already taken (focusable elements) + +An element has **exactly one** outline. Buttons, checkboxes, radios, toggles, tags, tabs and +the slider thumb already use theirs for the focus ring, and they must show the border **and** +the focus ring at once. Draw the border on an `::after` overlay instead — import +`borderAfter` from `@openmetadata/ui-core-components`: + +```tsx +import { borderAfter } from '@openmetadata/ui-core-components'; + +// host needs `tw:relative`; supply the colour with `tw:after:outline-` +