From 26fa227576e8be2fcbd179a0a7f665a2c6d15060 Mon Sep 17 00:00:00 2001 From: Kenny Lin Date: Wed, 8 Jul 2026 10:22:52 -0400 Subject: [PATCH 1/7] added z-index soft scale --- packages/eslint-plugin-gamut/src/index.tsx | 2 + .../src/no-raw-z-index.test.ts | 55 +++++ .../eslint-plugin-gamut/src/no-raw-z-index.ts | 62 +++++ .../eslint-plugin-gamut/src/recommended.ts | 1 + .../__snapshots__/theme.test.ts.snap | 222 +++++++++++++++++- packages/gamut-styles/src/themes/core.ts | 3 + .../gamut-styles/src/variables/elements.ts | 9 +- packages/gamut-styles/src/variables/index.ts | 1 + .../gamut-styles/src/variables/zIndices.ts | 43 ++++ packages/gamut-styles/src/variance/config.ts | 3 + packages/gamut/src/Anchor/index.tsx | 9 +- .../gamut/src/BarChart/layout/GridLines.tsx | 4 +- packages/gamut/src/BodyPortal/index.tsx | 16 +- packages/gamut/src/Box/props.ts | 4 +- packages/gamut/src/Button/shared/styles.ts | 3 +- packages/gamut/src/DataList/EmptyRows.tsx | 4 +- .../Calendar/CalendarWrapper.tsx | 3 +- .../Calendar/utils/elements.tsx | 9 +- packages/gamut/src/Flyout/index.tsx | 3 +- .../Form/SelectDropdown/SelectDropdown.tsx | 3 + .../gamut/src/Form/SelectDropdown/styles.ts | 8 +- packages/gamut/src/List/TableHeader.tsx | 9 +- packages/gamut/src/List/elements.tsx | 9 +- packages/gamut/src/Menu/elements.tsx | 7 +- packages/gamut/src/Overlay/index.tsx | 9 +- .../src/PatternBackdrop/PatternBackdrop.tsx | 11 +- packages/gamut/src/Popover/elements.tsx | 6 +- packages/gamut/src/Popover/styles/base.ts | 4 +- packages/gamut/src/Popover/styles/variants.ts | 4 +- .../src/PopoverContainer/PopoverContainer.tsx | 9 +- packages/gamut/src/Tabs/TabButton.tsx | 6 +- packages/gamut/src/Tabs/Tabs.tsx | 6 +- packages/gamut/src/Tabs/styles.tsx | 4 +- .../gamut/src/Tip/PreviewTip/elements.tsx | 4 +- packages/gamut/src/Tip/shared/InlineTip.tsx | 4 +- packages/gamut/src/Tip/shared/elements.tsx | 4 +- packages/gamut/src/Toaster/index.tsx | 4 +- packages/gamut/src/Typography/Text.tsx | 5 +- 38 files changed, 499 insertions(+), 73 deletions(-) create mode 100644 packages/eslint-plugin-gamut/src/no-raw-z-index.test.ts create mode 100644 packages/eslint-plugin-gamut/src/no-raw-z-index.ts create mode 100644 packages/gamut-styles/src/variables/zIndices.ts diff --git a/packages/eslint-plugin-gamut/src/index.tsx b/packages/eslint-plugin-gamut/src/index.tsx index 9459f37a2a0..f4346fbc28d 100644 --- a/packages/eslint-plugin-gamut/src/index.tsx +++ b/packages/eslint-plugin-gamut/src/index.tsx @@ -2,6 +2,7 @@ import gamutImportPaths from './gamut-import-paths'; import noCssStandalone from './no-css-standalone'; import noInlineStyle from './no-inline-style'; import noKbdElement from './no-kbd-element'; +import noRawZIndex from './no-raw-z-index'; import preferThemed from './prefer-themed'; import recommended from './recommended'; @@ -10,6 +11,7 @@ const rules = { 'no-css-standalone': noCssStandalone, 'no-inline-style': noInlineStyle, 'no-kbd-element': noKbdElement, + 'no-raw-z-index': noRawZIndex, 'prefer-themed': preferThemed, }; diff --git a/packages/eslint-plugin-gamut/src/no-raw-z-index.test.ts b/packages/eslint-plugin-gamut/src/no-raw-z-index.test.ts new file mode 100644 index 00000000000..75da9221e7b --- /dev/null +++ b/packages/eslint-plugin-gamut/src/no-raw-z-index.test.ts @@ -0,0 +1,55 @@ +import { ESLintUtils } from '@typescript-eslint/utils'; + +import rule from './no-raw-z-index'; + +const ruleTester = new ESLintUtils.RuleTester({ + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, +}); + +ruleTester.run('no-raw-z-index', rule, { + valid: [ + // Semantic tokens are the expected usage. + `const styles = { zIndex: zIndices.modal };`, + `;`, + // Arithmetic on a token is allowed (e.g. Tip's shadow). + `const styles = { zIndex: zIndices.foreground - 2 };`, + `;`, + // Variables / non-literal expressions are not flagged. + `;`, + `const styles = { zIndex };`, + // Unrelated properties. + `const styles = { padding: 0 };`, + `;`, + ], + invalid: [ + { + code: `const styles = { zIndex: 1 };`, + errors: [{ messageId: 'noRawZIndex' }], + }, + { + code: `const styles = { zIndex: 0 };`, + errors: [{ messageId: 'noRawZIndex' }], + }, + { + code: `const styles = { zIndex: -1 };`, + errors: [{ messageId: 'noRawZIndex' }], + }, + { + code: `const styles = { 'z-index': 100 };`, + errors: [{ messageId: 'noRawZIndex' }], + }, + { + code: `;`, + errors: [{ messageId: 'noRawZIndex' }], + }, + { + code: `;`, + errors: [{ messageId: 'noRawZIndex' }], + }, + ], +}); diff --git a/packages/eslint-plugin-gamut/src/no-raw-z-index.ts b/packages/eslint-plugin-gamut/src/no-raw-z-index.ts new file mode 100644 index 00000000000..eadf3bca810 --- /dev/null +++ b/packages/eslint-plugin-gamut/src/no-raw-z-index.ts @@ -0,0 +1,62 @@ +import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils'; + +import { createRule } from './createRule'; + +/** + * True for a numeric literal, including a negated one like `-1`. + */ +const isNumericLiteral = (node: TSESTree.Node | null | undefined): boolean => { + if (!node) return false; + if (node.type === AST_NODE_TYPES.Literal && typeof node.value === 'number') { + return true; + } + return ( + node.type === AST_NODE_TYPES.UnaryExpression && + (node.operator === '-' || node.operator === '+') && + isNumericLiteral(node.argument) + ); +}; + +const isZIndexKey = (key: TSESTree.Node): boolean => + (key.type === AST_NODE_TYPES.Identifier && key.name === 'zIndex') || + (key.type === AST_NODE_TYPES.Literal && key.value === 'zIndex') || + (key.type === AST_NODE_TYPES.Literal && key.value === 'z-index'); + +export default createRule({ + create(context) { + return { + // Style objects: `{ zIndex: 1 }` / `{ 'z-index': 1 }` + Property(node) { + if (isZIndexKey(node.key) && isNumericLiteral(node.value)) { + context.report({ messageId: 'noRawZIndex', node: node.value }); + } + }, + // JSX props: `` + JSXAttribute(node) { + if ( + node.name.type === AST_NODE_TYPES.JSXIdentifier && + node.name.name === 'zIndex' && + node.value?.type === AST_NODE_TYPES.JSXExpressionContainer && + isNumericLiteral(node.value.expression as TSESTree.Node) + ) { + context.report({ messageId: 'noRawZIndex', node: node.value }); + } + }, + }; + }, + defaultOptions: [], + meta: { + docs: { + description: + 'Disallow raw numeric z-index values; use a semantic token from the `zIndices` scale.', + recommended: 'error', + }, + messages: { + noRawZIndex: + 'Use a semantic token from the `zIndices` scale (e.g. `zIndices.modal`) instead of a raw z-index number. For a deliberate in-between value, disable this rule inline with a justifying comment.', + }, + type: 'suggestion', + schema: [], + }, + name: 'no-raw-z-index', +}); diff --git a/packages/eslint-plugin-gamut/src/recommended.ts b/packages/eslint-plugin-gamut/src/recommended.ts index 970bb7700ff..a1d71b635c9 100644 --- a/packages/eslint-plugin-gamut/src/recommended.ts +++ b/packages/eslint-plugin-gamut/src/recommended.ts @@ -2,6 +2,7 @@ export default { rules: { 'gamut/no-css-standalone': 'error', 'gamut/no-inline-style': 'error', + 'gamut/no-raw-z-index': 'error', 'gamut/prefer-themed': 'off', 'gamut/gamut-import-paths': 'error', }, diff --git a/packages/gamut-styles/src/themes/__tests__/__snapshots__/theme.test.ts.snap b/packages/gamut-styles/src/themes/__tests__/__snapshots__/theme.test.ts.snap index 2be2c419212..061f863b25a 100644 --- a/packages/gamut-styles/src/themes/__tests__/__snapshots__/theme.test.ts.snap +++ b/packages/gamut-styles/src/themes/__tests__/__snapshots__/theme.test.ts.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`themes admin - theme shape 1`] = ` { @@ -79,7 +79,7 @@ exports[`themes admin - theme shape 1`] = ` "base": "4rem", "md": "5rem", }, - "headerZ": 15, + "headerZ": 400, }, "modes": { "dark": { @@ -151,6 +151,20 @@ exports[`themes admin - theme shape 1`] = ` "text-secondary": "rgba(16,22,47,0.75)", }, }, + "zIndices": { + "appBar": 400, + "base": 0, + "flyout": 500, + "foreground": 1, + "modal": 600, + "popover": 700, + "portal": 200, + "stickyHeader": 100, + "toaster": 800, + "tooltip": 900, + "underlay": -1, + "widget": 300, + }, }, "_variables": { "mode": { @@ -257,7 +271,19 @@ exports[`themes admin - theme shape 1`] = ` "--color-yellow-500": "#FFD300", "--color-yellow-900": "#211B00", "--elements-headerHeight": "4rem", - "--elements-headerZ": 15, + "--elements-headerZ": 400, + "--zIndices-appBar": 400, + "--zIndices-base": 0, + "--zIndices-flyout": 500, + "--zIndices-foreground": 1, + "--zIndices-modal": 600, + "--zIndices-popover": 700, + "--zIndices-portal": 200, + "--zIndices-stickyHeader": 100, + "--zIndices-toaster": 800, + "--zIndices-tooltip": 900, + "--zIndices-underlay": -1, + "--zIndices-widget": 300, "@media only screen and (min-width: 1024px)": { "--elements-headerHeight": "5rem", }, @@ -513,6 +539,20 @@ monospace", "8": "0.5rem", "96": "6rem", }, + "zIndices": { + "appBar": "var(--zIndices-appBar)", + "base": "var(--zIndices-base)", + "flyout": "var(--zIndices-flyout)", + "foreground": "var(--zIndices-foreground)", + "modal": "var(--zIndices-modal)", + "popover": "var(--zIndices-popover)", + "portal": "var(--zIndices-portal)", + "stickyHeader": "var(--zIndices-stickyHeader)", + "toaster": "var(--zIndices-toaster)", + "tooltip": "var(--zIndices-tooltip)", + "underlay": "var(--zIndices-underlay)", + "widget": "var(--zIndices-widget)", + }, } `; @@ -595,7 +635,7 @@ exports[`themes core - theme shape 1`] = ` "base": "4rem", "md": "5rem", }, - "headerZ": 15, + "headerZ": 400, }, "modes": { "dark": { @@ -667,6 +707,20 @@ exports[`themes core - theme shape 1`] = ` "text-secondary": "rgba(16,22,47,0.75)", }, }, + "zIndices": { + "appBar": 400, + "base": 0, + "flyout": 500, + "foreground": 1, + "modal": 600, + "popover": 700, + "portal": 200, + "stickyHeader": 100, + "toaster": 800, + "tooltip": 900, + "underlay": -1, + "widget": 300, + }, }, "_variables": { "mode": { @@ -773,7 +827,19 @@ exports[`themes core - theme shape 1`] = ` "--color-yellow-500": "#FFD300", "--color-yellow-900": "#211B00", "--elements-headerHeight": "4rem", - "--elements-headerZ": 15, + "--elements-headerZ": 400, + "--zIndices-appBar": 400, + "--zIndices-base": 0, + "--zIndices-flyout": 500, + "--zIndices-foreground": 1, + "--zIndices-modal": 600, + "--zIndices-popover": 700, + "--zIndices-portal": 200, + "--zIndices-stickyHeader": 100, + "--zIndices-toaster": 800, + "--zIndices-tooltip": 900, + "--zIndices-underlay": -1, + "--zIndices-widget": 300, "@media only screen and (min-width: 1024px)": { "--elements-headerHeight": "5rem", }, @@ -1029,6 +1095,20 @@ monospace", "8": "0.5rem", "96": "6rem", }, + "zIndices": { + "appBar": "var(--zIndices-appBar)", + "base": "var(--zIndices-base)", + "flyout": "var(--zIndices-flyout)", + "foreground": "var(--zIndices-foreground)", + "modal": "var(--zIndices-modal)", + "popover": "var(--zIndices-popover)", + "portal": "var(--zIndices-portal)", + "stickyHeader": "var(--zIndices-stickyHeader)", + "toaster": "var(--zIndices-toaster)", + "tooltip": "var(--zIndices-tooltip)", + "underlay": "var(--zIndices-underlay)", + "widget": "var(--zIndices-widget)", + }, } `; @@ -1114,7 +1194,7 @@ exports[`themes lxStudio - theme shape 1`] = ` "base": "4rem", "md": "5rem", }, - "headerZ": 15, + "headerZ": 400, }, "modes": { "dark": { @@ -1186,6 +1266,20 @@ exports[`themes lxStudio - theme shape 1`] = ` "text-secondary": "rgba(16,22,47,0.75)", }, }, + "zIndices": { + "appBar": 400, + "base": 0, + "flyout": 500, + "foreground": 1, + "modal": 600, + "popover": 700, + "portal": 200, + "stickyHeader": 100, + "toaster": 800, + "tooltip": 900, + "underlay": -1, + "widget": 300, + }, }, "_variables": { "mode": { @@ -1295,7 +1389,19 @@ exports[`themes lxStudio - theme shape 1`] = ` "--color-yellow-500": "#FFD300", "--color-yellow-900": "#211B00", "--elements-headerHeight": "4rem", - "--elements-headerZ": 15, + "--elements-headerZ": 400, + "--zIndices-appBar": 400, + "--zIndices-base": 0, + "--zIndices-flyout": 500, + "--zIndices-foreground": 1, + "--zIndices-modal": 600, + "--zIndices-popover": 700, + "--zIndices-portal": 200, + "--zIndices-stickyHeader": 100, + "--zIndices-toaster": 800, + "--zIndices-tooltip": 900, + "--zIndices-underlay": -1, + "--zIndices-widget": 300, "@media only screen and (min-width: 1024px)": { "--elements-headerHeight": "5rem", }, @@ -1556,6 +1662,20 @@ monospace", "8": "0.5rem", "96": "6rem", }, + "zIndices": { + "appBar": "var(--zIndices-appBar)", + "base": "var(--zIndices-base)", + "flyout": "var(--zIndices-flyout)", + "foreground": "var(--zIndices-foreground)", + "modal": "var(--zIndices-modal)", + "popover": "var(--zIndices-popover)", + "portal": "var(--zIndices-portal)", + "stickyHeader": "var(--zIndices-stickyHeader)", + "toaster": "var(--zIndices-toaster)", + "tooltip": "var(--zIndices-tooltip)", + "underlay": "var(--zIndices-underlay)", + "widget": "var(--zIndices-widget)", + }, } `; @@ -1654,7 +1774,7 @@ exports[`themes percipio - theme shape 1`] = ` "base": "4rem", "md": "5rem", }, - "headerZ": 15, + "headerZ": 400, }, "modes": { "dark": { @@ -1726,6 +1846,20 @@ exports[`themes percipio - theme shape 1`] = ` "text-secondary": "rgba(34, 35, 37, 0.75)", }, }, + "zIndices": { + "appBar": 400, + "base": 0, + "flyout": 500, + "foreground": 1, + "modal": 600, + "popover": 700, + "portal": 200, + "stickyHeader": 100, + "toaster": 800, + "tooltip": 900, + "underlay": -1, + "widget": 300, + }, }, "_variables": { "mode": { @@ -1848,7 +1982,19 @@ exports[`themes percipio - theme shape 1`] = ` "--color-yellow-500": "#FFD300", "--color-yellow-900": "#211B00", "--elements-headerHeight": "4rem", - "--elements-headerZ": 15, + "--elements-headerZ": 400, + "--zIndices-appBar": 400, + "--zIndices-base": 0, + "--zIndices-flyout": 500, + "--zIndices-foreground": 1, + "--zIndices-modal": 600, + "--zIndices-popover": 700, + "--zIndices-portal": 200, + "--zIndices-stickyHeader": 100, + "--zIndices-toaster": 800, + "--zIndices-tooltip": 900, + "--zIndices-underlay": -1, + "--zIndices-widget": 300, "@media only screen and (min-width: 1024px)": { "--elements-headerHeight": "5rem", }, @@ -2120,6 +2266,20 @@ sans-serif", "8": "0.5rem", "96": "6rem", }, + "zIndices": { + "appBar": "var(--zIndices-appBar)", + "base": "var(--zIndices-base)", + "flyout": "var(--zIndices-flyout)", + "foreground": "var(--zIndices-foreground)", + "modal": "var(--zIndices-modal)", + "popover": "var(--zIndices-popover)", + "portal": "var(--zIndices-portal)", + "stickyHeader": "var(--zIndices-stickyHeader)", + "toaster": "var(--zIndices-toaster)", + "tooltip": "var(--zIndices-tooltip)", + "underlay": "var(--zIndices-underlay)", + "widget": "var(--zIndices-widget)", + }, } `; @@ -2222,7 +2382,7 @@ exports[`themes platform - theme shape 1`] = ` "base": "4rem", "md": "5rem", }, - "headerZ": 15, + "headerZ": 400, }, "modes": { "dark": { @@ -2346,6 +2506,20 @@ exports[`themes platform - theme shape 1`] = ` "text-secondary": "rgba(16,22,47,0.75)", }, }, + "zIndices": { + "appBar": 400, + "base": 0, + "flyout": 500, + "foreground": 1, + "modal": 600, + "popover": 700, + "portal": 200, + "stickyHeader": 100, + "toaster": 800, + "tooltip": 900, + "underlay": -1, + "widget": 300, + }, }, "_variables": { "mode": { @@ -2498,7 +2672,19 @@ exports[`themes platform - theme shape 1`] = ` "--color-yellow-500": "#FFD300", "--color-yellow-900": "#211B00", "--elements-headerHeight": "4rem", - "--elements-headerZ": 15, + "--elements-headerZ": 400, + "--zIndices-appBar": 400, + "--zIndices-base": 0, + "--zIndices-flyout": 500, + "--zIndices-foreground": 1, + "--zIndices-modal": 600, + "--zIndices-popover": 700, + "--zIndices-portal": 200, + "--zIndices-stickyHeader": 100, + "--zIndices-toaster": 800, + "--zIndices-tooltip": 900, + "--zIndices-underlay": -1, + "--zIndices-widget": 300, "@media only screen and (min-width: 1024px)": { "--elements-headerHeight": "5rem", }, @@ -2852,5 +3038,19 @@ monospace", "8": "0.5rem", "96": "6rem", }, + "zIndices": { + "appBar": "var(--zIndices-appBar)", + "base": "var(--zIndices-base)", + "flyout": "var(--zIndices-flyout)", + "foreground": "var(--zIndices-foreground)", + "modal": "var(--zIndices-modal)", + "popover": "var(--zIndices-popover)", + "portal": "var(--zIndices-portal)", + "stickyHeader": "var(--zIndices-stickyHeader)", + "toaster": "var(--zIndices-toaster)", + "tooltip": "var(--zIndices-tooltip)", + "underlay": "var(--zIndices-underlay)", + "widget": "var(--zIndices-widget)", + }, } `; diff --git a/packages/gamut-styles/src/themes/core.ts b/packages/gamut-styles/src/themes/core.ts index 3a6da844330..eb4457cdbea 100644 --- a/packages/gamut-styles/src/themes/core.ts +++ b/packages/gamut-styles/src/themes/core.ts @@ -11,6 +11,7 @@ import { lineHeight, mediaQueries, spacing, + zIndices, } from '../variables'; /** @@ -28,6 +29,7 @@ export const coreTheme = createTheme({ fontWeight, spacing, elements, + zIndices, }) .addColors(corePalette) .addColorModes('light', { @@ -141,6 +143,7 @@ export const coreTheme = createTheme({ 2: `2px solid ${colors['border-primary']}`, })) .createScaleVariables('elements') + .createScaleVariables('zIndices') .addName('core') .build(); diff --git a/packages/gamut-styles/src/variables/elements.ts b/packages/gamut-styles/src/variables/elements.ts index 25bc743bd83..387199e0f28 100644 --- a/packages/gamut-styles/src/variables/elements.ts +++ b/packages/gamut-styles/src/variables/elements.ts @@ -1,9 +1,12 @@ +import { zIndices } from './zIndices'; + export const elements = { headerHeight: { base: '4rem', md: '5rem' }, /** - * Semi-arbitrary z-index for global page headers. - * @remarks PLEASE talk to web platform before adding new z-index constants! + * z-index for global page headers. Aliases the `appBar` token from the `zIndices` + * scale so consumers still reading `elements.headerZ` stay in sync with the scale. + * Prefer `zIndex="appBar"` in new code. */ - headerZ: 15, + headerZ: zIndices.appBar, } as const; diff --git a/packages/gamut-styles/src/variables/index.ts b/packages/gamut-styles/src/variables/index.ts index d4f074462ef..06ad877133d 100644 --- a/packages/gamut-styles/src/variables/index.ts +++ b/packages/gamut-styles/src/variables/index.ts @@ -5,3 +5,4 @@ export * from './responsive'; export * from './spacing'; export * from './timing'; export * from './typography'; +export * from './zIndices'; diff --git a/packages/gamut-styles/src/variables/zIndices.ts b/packages/gamut-styles/src/variables/zIndices.ts new file mode 100644 index 00000000000..24fcadd51d6 --- /dev/null +++ b/packages/gamut-styles/src/variables/zIndices.ts @@ -0,0 +1,43 @@ +/** + * Semantic z-index scale. A single scale covers both in-flow layers (low values) and + * portal layers (≥100). Every z-index in Gamut should reference a token here rather than a + * magic number. + * + * The `zIndex` prop is intentionally left numeric/unscaled, and this object is numeric, so + * tokens are used as `zIndex={zIndices.modal}`. That preserves the escape hatch (a raw + * in-between number, e.g. `zIndex={605}`) and arithmetic on tokens (e.g. `zIndices.foreground - 2`). + * + * Portal-band values are spaced by 100 so in-between escape-hatch numbers are available. + * `portal` (200) is the floor of the portal band and the default for `BodyPortal`. + * + * @remarks PLEASE talk to web platform before adding new z-index tokens. + */ +export const zIndices = { + /** Decorative layer behind content (underlines, backdrops, shadows). */ + underlay: -1, + /** Ground layer — establishes a local stacking context without lifting above siblings. */ + base: 0, + /** Content lifted just above a sibling underlay/base. */ + foreground: 1, + /** + * In-flow sticky content headers (e.g. a sticky table `thead`). Below the portal band, + * so every overlay and the app nav cover it. Not a portal layer. + */ + stickyHeader: 100, + /** Portal floor / `BodyPortal` default — un-tokenized portal content lands here. */ + portal: 200, + /** Persistent floating page furniture at rest (e.g. an AI chat launcher, help bubble). */ + widget: 300, + /** Global app header / nav bar. Aliased by the legacy `elements.headerZ` constant. */ + appBar: 400, + /** Portaled side panel (the `Flyout` component = `Drawer` inside `Overlay`). */ + flyout: 500, + /** `Overlay`, `Modal`, and `Dialog` (they share one portal primitive). */ + modal: 600, + /** Portal-mode `Popover` and the portaled `SelectDropdown` menu — above modal. */ + popover: 700, + /** Toasts / notifications — visible above modals. */ + toaster: 800, + /** Floating tooltips (`FloatingTip`) — highest, so they are never clipped. */ + tooltip: 900, +} as const; diff --git a/packages/gamut-styles/src/variance/config.ts b/packages/gamut-styles/src/variance/config.ts index ad2b6e3fc23..1cacdaef3f1 100644 --- a/packages/gamut-styles/src/variance/config.ts +++ b/packages/gamut-styles/src/variance/config.ts @@ -328,6 +328,9 @@ export const positioning = { resolveProperty: getPropertyMode, transform: transformSize, }, + // Intentionally unscaled: scaled variance props are token-only and reject raw numbers, + // which would break the numeric escape hatch and Tip's z-index arithmetic. Use the numeric + // `zIndices` token object instead (e.g. zIndex={zIndices.modal}). zIndex: { property: 'zIndex' }, opacity: { property: 'opacity' }, } as const; diff --git a/packages/gamut/src/Anchor/index.tsx b/packages/gamut/src/Anchor/index.tsx index 287ff0e6d37..b65906656eb 100644 --- a/packages/gamut/src/Anchor/index.tsx +++ b/packages/gamut/src/Anchor/index.tsx @@ -1,4 +1,9 @@ -import { styledOptions, system, variant } from '@codecademy/gamut-styles'; +import { + styledOptions, + system, + variant, + zIndices, +} from '@codecademy/gamut-styles'; import { StyleProps, variance } from '@codecademy/variance'; import styled from '@emotion/styled'; import { ComponentProps, forwardRef, HTMLProps, RefObject } from 'react'; @@ -21,7 +26,7 @@ const outlineFocusVisible = { border: 2, borderColor: 'primary', opacity: 0, - zIndex: 0, + zIndex: zIndices.base, }, [ButtonSelectors.OUTLINE_FOCUS_VISIBLE]: { diff --git a/packages/gamut/src/BarChart/layout/GridLines.tsx b/packages/gamut/src/BarChart/layout/GridLines.tsx index 14db738f132..bf1c9718c98 100644 --- a/packages/gamut/src/BarChart/layout/GridLines.tsx +++ b/packages/gamut/src/BarChart/layout/GridLines.tsx @@ -1,4 +1,4 @@ -import { css } from '@codecademy/gamut-styles'; +import { css, zIndices } from '@codecademy/gamut-styles'; import styled from '@emotion/styled'; import React, { useMemo } from 'react'; @@ -12,7 +12,7 @@ const GridLineWrapper = styled(Box)( inset: 0, pointerEvents: 'none', position: 'absolute', - zIndex: 0, + zIndex: zIndices.base, }) ); diff --git a/packages/gamut/src/BodyPortal/index.tsx b/packages/gamut/src/BodyPortal/index.tsx index 5da951b486b..34309f27739 100644 --- a/packages/gamut/src/BodyPortal/index.tsx +++ b/packages/gamut/src/BodyPortal/index.tsx @@ -1,4 +1,9 @@ -import { ColorMode, system, useCurrentMode } from '@codecademy/gamut-styles'; +import { + ColorMode, + system, + useCurrentMode, + zIndices, +} from '@codecademy/gamut-styles'; import styled from '@emotion/styled'; import { useState } from 'react'; import * as React from 'react'; @@ -19,16 +24,17 @@ const PortalWrapper = styled interface BodyPortalProps { /** - * TEMPORARY: a stopgap solution to avoid zIndex conflicts - - * will be reworked with: GM-624 - * previously, zIndex was set to 1 in the CSS function + * Stacking layer for the portaled content. Pass a `zIndices` token + * (e.g. `zIndices.modal`) or a raw number as an escape hatch. Defaults to + * `zIndices.portal` — the floor of the portal band — so un-tokenized portal content + * stays above local page content instead of silently landing at a low value. */ zIndex?: number; } export const BodyPortal: React.FC> = ({ children, - zIndex = 1, + zIndex = zIndices.portal, }) => { const [ready, setReady] = useState(false); const mode = useCurrentMode(); diff --git a/packages/gamut/src/Box/props.ts b/packages/gamut/src/Box/props.ts index 536ff575392..0d53c0a39a9 100644 --- a/packages/gamut/src/Box/props.ts +++ b/packages/gamut/src/Box/props.ts @@ -1,4 +1,4 @@ -import { system } from '@codecademy/gamut-styles'; +import { system, zIndices } from '@codecademy/gamut-styles'; import { StyleProps, variance } from '@codecademy/variance'; import { WithChildrenProp } from '../utils'; @@ -24,7 +24,7 @@ export const sharedStates = system.states({ }, context: { position: 'relative', - zIndex: 1, + zIndex: zIndices.foreground, }, 'no-select': { WebkitTouchCallout: 'none', diff --git a/packages/gamut/src/Button/shared/styles.ts b/packages/gamut/src/Button/shared/styles.ts index 9bcf0cbed8f..534aa43b7de 100644 --- a/packages/gamut/src/Button/shared/styles.ts +++ b/packages/gamut/src/Button/shared/styles.ts @@ -4,6 +4,7 @@ import { styledOptions, system, transitionConcat, + zIndices, } from '@codecademy/gamut-styles'; import { CSSObject, ThemeProps, variance } from '@codecademy/variance'; import styled from '@emotion/styled'; @@ -67,7 +68,7 @@ export const buttonStyles = system.css({ border: 2, inset: -5, opacity: 0, - zIndex: 0, + zIndex: zIndices.base, }, [ButtonSelectors.OUTLINE_FOCUS_VISIBLE]: { opacity: 1, diff --git a/packages/gamut/src/DataList/EmptyRows.tsx b/packages/gamut/src/DataList/EmptyRows.tsx index 3684664b57e..811fb5fb99a 100644 --- a/packages/gamut/src/DataList/EmptyRows.tsx +++ b/packages/gamut/src/DataList/EmptyRows.tsx @@ -1,3 +1,5 @@ +import { zIndices } from '@codecademy/gamut-styles'; + import { FlexBox } from '../Box'; import { FillButton } from '../Button'; import { Text } from '../Typography'; @@ -15,7 +17,7 @@ export const EmptyRows = () => { position="sticky" top="calc(50% - 66px)" width="320px" - zIndex={1} + zIndex={zIndices.foreground} > diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarWrapper.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarWrapper.tsx index 425348fa6dd..29e2b477461 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarWrapper.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarWrapper.tsx @@ -1,4 +1,5 @@ import { CheckerDense } from '@codecademy/gamut-patterns'; +import { zIndices } from '@codecademy/gamut-styles'; import * as React from 'react'; import { Box } from '../../../Box'; @@ -12,7 +13,7 @@ export const CalendarWrapper: React.FC = ({ children }) => ( border={1} borderRadius="sm" position="relative" - zIndex={1} + zIndex={zIndices.foreground} > {children} diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/elements.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/elements.tsx index b5a7354e8d7..6a992bb7969 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/elements.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/elements.tsx @@ -1,4 +1,9 @@ -import { css, states, transitionConcat } from '@codecademy/gamut-styles'; +import { + css, + states, + transitionConcat, + zIndices, +} from '@codecademy/gamut-styles'; import { StyleProps } from '@codecademy/variance'; import styled from '@emotion/styled'; @@ -119,7 +124,7 @@ export const DateCell = styled.td( borderRadius: 'lg', border: 2, opacity: 0, - zIndex: 0, + zIndex: zIndices.base, }, '&:focus-visible::before': { opacity: 1, diff --git a/packages/gamut/src/Flyout/index.tsx b/packages/gamut/src/Flyout/index.tsx index 8bd38fc0ccf..adfee0e0c4c 100644 --- a/packages/gamut/src/Flyout/index.tsx +++ b/packages/gamut/src/Flyout/index.tsx @@ -1,5 +1,5 @@ import { MiniDeleteIcon } from '@codecademy/gamut-icons'; -import { Background, Colors } from '@codecademy/gamut-styles'; +import { Background, Colors, zIndices } from '@codecademy/gamut-styles'; import * as React from 'react'; import { FlexBox } from '../Box'; @@ -52,6 +52,7 @@ export const Flyout: React.FC = ({ escapeCloses isOpen={expanded} shroud + zIndex={zIndices.flyout} onRequestClose={onClose} > diff --git a/packages/gamut/src/Form/SelectDropdown/SelectDropdown.tsx b/packages/gamut/src/Form/SelectDropdown/SelectDropdown.tsx index f31839eaa46..603b626cbfe 100644 --- a/packages/gamut/src/Form/SelectDropdown/SelectDropdown.tsx +++ b/packages/gamut/src/Form/SelectDropdown/SelectDropdown.tsx @@ -276,6 +276,9 @@ export const SelectDropdown: React.FC = ({ isOptionDisabled={(option) => option.disabled} isSearchable={isSearchable} menuAlignment={menuAlignment} + menuPortalTarget={ + typeof document !== 'undefined' ? document.body : undefined + } name={name} options={selectOptions} placeholder={placeholder} diff --git a/packages/gamut/src/Form/SelectDropdown/styles.ts b/packages/gamut/src/Form/SelectDropdown/styles.ts index ba9178c113c..281e1e87f8c 100644 --- a/packages/gamut/src/Form/SelectDropdown/styles.ts +++ b/packages/gamut/src/Form/SelectDropdown/styles.ts @@ -3,6 +3,7 @@ import { states, theme as GamutTheme, variant, + zIndices, } from '@codecademy/gamut-styles'; import { StylesConfig } from 'react-select'; @@ -25,7 +26,6 @@ import { BaseSelectComponentProps } from './types/styles'; const selectDropdownStyles = css({ ...formBaseFieldStylesObject, display: 'flex', - zIndex: 3, }); const selectFocusStyles = { @@ -164,6 +164,12 @@ export const getMemoizedStyles = ( : {}), }; }, + menuPortal: (provided) => ({ + ...provided, + // The menu is portaled to the body, so it stacks at the page root as a popover — + // above sticky headers and modal content. A raw `zIndex` prop overrides as an escape hatch. + zIndex: zIndex ?? zIndices.popover, + }), menuList: (provided, state: BaseSelectComponentProps) => { const sizeInteger = state.selectProps.size === 'small' ? 2 : 3; const maxHeight = `${ diff --git a/packages/gamut/src/List/TableHeader.tsx b/packages/gamut/src/List/TableHeader.tsx index 60810da9c91..e7c64cfe86b 100644 --- a/packages/gamut/src/List/TableHeader.tsx +++ b/packages/gamut/src/List/TableHeader.tsx @@ -1,3 +1,4 @@ +import { zIndices } from '@codecademy/gamut-styles'; import { ComponentProps, forwardRef } from 'react'; import { Box } from '../Box'; @@ -12,7 +13,13 @@ export const TableHeader = forwardRef( ({ children, ...rest }, ref) => { const { spacing, scrollable, variant } = useListContext(); return ( - + ( flexDirection: { _: 'row', c_base: 'column', c_sm: 'row' }, top: 0, bg: 'background-current', - zIndex: 2, + zIndex: zIndices.stickyHeader, fontFamily: 'accent', pb: { _: 0, c_base: 8, c_sm: 0 }, }), @@ -468,7 +469,7 @@ export const StickyHeaderColWrapper = styled.th( height: '100%', top: 0, left: 0, - zIndex: -1, + zIndex: zIndices.underlay, }, '&:after': { content: '""', @@ -482,7 +483,7 @@ export const StickyHeaderColWrapper = styled.th( height: '100%', top: 0, left: 0, - zIndex: -1, + zIndex: zIndices.underlay, }, // p: 0 removes the browser's default padding of 1px p: 0, @@ -490,7 +491,7 @@ export const StickyHeaderColWrapper = styled.th( flexShrink: 0, position: 'sticky', left: 0, - zIndex: 1, + zIndex: zIndices.foreground, bg: { _: 'inherit', c_base: 'transparent', c_sm: 'inherit' }, '&:not(:first-of-type)': { left: { _: 16, c_base: 0, c_sm: 16 }, diff --git a/packages/gamut/src/Menu/elements.tsx b/packages/gamut/src/Menu/elements.tsx index f8eea4af20d..0a36aed2f91 100644 --- a/packages/gamut/src/Menu/elements.tsx +++ b/packages/gamut/src/Menu/elements.tsx @@ -2,6 +2,7 @@ import { styledOptions, system, transitionConcat, + zIndices, } from '@codecademy/gamut-styles'; import { StyleProps, variance } from '@codecademy/variance'; import styled from '@emotion/styled'; @@ -88,7 +89,7 @@ const interactiveVariants = system.variant({ alignItems: 'center', cursor: 'pointer', width: 1, - zIndex: 1, + zIndex: zIndices.foreground, px: 24, py: 12, position: 'relative', @@ -108,7 +109,7 @@ const interactiveVariants = system.variant({ border: 2, borderColor: 'primary', opacity: 0, - zIndex: -1, + zIndex: zIndices.underlay, }, [MenuItemSelectors.OUTLINE_FOCUS_VISIBLE]: { opacity: 1, @@ -224,7 +225,7 @@ const StyledListLink = styled('a', styledOptions<'a'>())( export const ListLink = forwardRef< HTMLAnchorElement, ComponentProps ->(({ zIndex = 1, ...rest }, ref) => ( +>(({ zIndex = zIndices.foreground, ...rest }, ref) => ( )); diff --git a/packages/gamut/src/Overlay/index.tsx b/packages/gamut/src/Overlay/index.tsx index 9ddda4854d0..e4512961341 100644 --- a/packages/gamut/src/Overlay/index.tsx +++ b/packages/gamut/src/Overlay/index.tsx @@ -1,4 +1,4 @@ -import { states } from '@codecademy/gamut-styles'; +import { states, zIndices } from '@codecademy/gamut-styles'; import styled from '@emotion/styled'; import { useCallback } from 'react'; import * as React from 'react'; @@ -34,8 +34,9 @@ export type OverlayProps = { /** Whether the overlay allows scroll */ allowScroll?: boolean; /** - * z-index for the Overlay. Defaults to 3 to appear above common UI elements - * like headers . Can be overridden when needed for custom stacking orders. + * Stacking layer for the Overlay. Pass a `zIndices` token or a raw number (escape + * hatch). Defaults to `zIndices.modal`; a portaled side panel should pass + * `zIndices.flyout` to sit below modals. */ zIndex?: number; }; @@ -61,7 +62,7 @@ export const Overlay: React.FC = ({ onRequestClose, isOpen, allowScroll = false, - zIndex = 3, + zIndex = zIndices.modal, }) => { const handleOutsideClick = useCallback(() => { if (clickOutsideCloses) { diff --git a/packages/gamut/src/PatternBackdrop/PatternBackdrop.tsx b/packages/gamut/src/PatternBackdrop/PatternBackdrop.tsx index c3d6bcd8b15..3bf50b9e0be 100644 --- a/packages/gamut/src/PatternBackdrop/PatternBackdrop.tsx +++ b/packages/gamut/src/PatternBackdrop/PatternBackdrop.tsx @@ -1,5 +1,5 @@ import { CheckerDense } from '@codecademy/gamut-patterns'; -import { styledOptions, system } from '@codecademy/gamut-styles'; +import { styledOptions, system, zIndices } from '@codecademy/gamut-styles'; import { StyleProps, variance } from '@codecademy/variance'; import styled from '@emotion/styled'; import { ComponentProps, forwardRef } from 'react'; @@ -13,7 +13,7 @@ const PatternBackdropBody = styled('div', styledOptions)< >( system.css({ position: 'relative', - zIndex: 1, + zIndex: zIndices.foreground, bg: 'background', border: 1, maxWidth: 1, @@ -29,7 +29,12 @@ type PatternBackdropProps = ComponentProps; */ export const PatternBackdrop = forwardRef( ({ children, ...rest }, ref) => ( - + {isOpen && ( - + ) : ( - + ); export type PopoverContainerProps = Pick; diff --git a/packages/gamut/src/Popover/styles/base.ts b/packages/gamut/src/Popover/styles/base.ts index d3aae9f9c2c..59fcf3c5545 100644 --- a/packages/gamut/src/Popover/styles/base.ts +++ b/packages/gamut/src/Popover/styles/base.ts @@ -1,4 +1,4 @@ -import { states, variant } from '@codecademy/gamut-styles'; +import { states, variant, zIndices } from '@codecademy/gamut-styles'; import { toolTipBodyCss } from '../../Tip/shared/styles/styles'; @@ -31,7 +31,7 @@ export const popoverStates = states({ export const raisedDivVariants = variant({ base: { - zIndex: 1, + zIndex: zIndices.foreground, }, defaultVariant: 'primary', variants: { diff --git a/packages/gamut/src/Popover/styles/variants.ts b/packages/gamut/src/Popover/styles/variants.ts index 1ebf78d59e7..ba22712decf 100644 --- a/packages/gamut/src/Popover/styles/variants.ts +++ b/packages/gamut/src/Popover/styles/variants.ts @@ -1,4 +1,4 @@ -import { states, variant } from '@codecademy/gamut-styles'; +import { states, variant, zIndices } from '@codecademy/gamut-styles'; import { createVariantsFromAlignments } from '../../Tip/shared/styles/createVariantsUtils'; import { tooltipArrowHeight } from '../../Tip/shared/styles/styles'; @@ -47,7 +47,7 @@ const beakVariantStyles = createVariantsFromAlignments( export const beakVariants = variant({ base: { background: 'transparent', - zIndex: 1, + zIndex: zIndices.foreground, position: 'fixed', }, prop: 'beak', diff --git a/packages/gamut/src/PopoverContainer/PopoverContainer.tsx b/packages/gamut/src/PopoverContainer/PopoverContainer.tsx index c93f1965bc8..0259a1e9272 100644 --- a/packages/gamut/src/PopoverContainer/PopoverContainer.tsx +++ b/packages/gamut/src/PopoverContainer/PopoverContainer.tsx @@ -1,4 +1,9 @@ -import { elementDir, system, useElementDir } from '@codecademy/gamut-styles'; +import { + elementDir, + system, + useElementDir, + zIndices, +} from '@codecademy/gamut-styles'; import { variance } from '@codecademy/variance'; import styled from '@emotion/styled'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; @@ -282,5 +287,5 @@ export const PopoverContainer: React.FC = ({ if (inline) return content; - return {content}; + return {content}; }; diff --git a/packages/gamut/src/Tabs/TabButton.tsx b/packages/gamut/src/Tabs/TabButton.tsx index 0cbaf3f2582..fc150c6560a 100644 --- a/packages/gamut/src/Tabs/TabButton.tsx +++ b/packages/gamut/src/Tabs/TabButton.tsx @@ -1,4 +1,4 @@ -import { states, variant } from '@codecademy/gamut-styles'; +import { states, variant, zIndices } from '@codecademy/gamut-styles'; import { StyleProps } from '@codecademy/variance'; import styled from '@emotion/styled'; @@ -29,7 +29,7 @@ const focusVisibleStyles = { borderColor: 'primary', position: 'absolute', inset: 0, - zIndex: 0, + zIndex: zIndices.base, borderRadiusTop: 'md', borderRadiusBottom: 'none', }, @@ -48,7 +48,7 @@ export const tabVariants = variant({ color: 'text', font: 'inherit', cursor: 'pointer', - zIndex: 1, + zIndex: zIndices.foreground, [TabSelectors.DISABLED]: { opacity: 0.25, cursor: 'not-allowed', diff --git a/packages/gamut/src/Tabs/Tabs.tsx b/packages/gamut/src/Tabs/Tabs.tsx index 3194df06756..13332160986 100644 --- a/packages/gamut/src/Tabs/Tabs.tsx +++ b/packages/gamut/src/Tabs/Tabs.tsx @@ -1,4 +1,4 @@ -import { Background } from '@codecademy/gamut-styles'; +import { Background, zIndices } from '@codecademy/gamut-styles'; import { StyleProps } from '@codecademy/variance'; import styled from '@emotion/styled'; import * as React from 'react'; @@ -25,10 +25,10 @@ export const Tabs: React.FC = (props) => { {/* currently only supporting dark mode for the LE variant. */} {props.variant === 'block' ? ( - + ) : ( - + )} ); diff --git a/packages/gamut/src/Tabs/styles.tsx b/packages/gamut/src/Tabs/styles.tsx index 68d2579f9fb..e445e6c8686 100644 --- a/packages/gamut/src/Tabs/styles.tsx +++ b/packages/gamut/src/Tabs/styles.tsx @@ -1,4 +1,4 @@ -import { system, variant } from '@codecademy/gamut-styles'; +import { system, variant, zIndices } from '@codecademy/gamut-styles'; export const tabContainerVariants = variant({ base: { @@ -15,7 +15,7 @@ export const tabContainerVariants = variant({ bg: 'text', position: 'absolute', bottom: 0, - zIndex: 0, + zIndex: zIndices.base, width: '100%', }, }, diff --git a/packages/gamut/src/Tip/PreviewTip/elements.tsx b/packages/gamut/src/Tip/PreviewTip/elements.tsx index 6f42308c3c4..21cde41ef91 100644 --- a/packages/gamut/src/Tip/PreviewTip/elements.tsx +++ b/packages/gamut/src/Tip/PreviewTip/elements.tsx @@ -1,5 +1,5 @@ import { CheckerDense } from '@codecademy/gamut-patterns'; -import { css, variant } from '@codecademy/gamut-styles'; +import { css, variant, zIndices } from '@codecademy/gamut-styles'; import styled from '@emotion/styled'; import { useMemo } from 'react'; @@ -147,7 +147,7 @@ export const PreviewTipShadow: React.FC = ({ return ( diff --git a/packages/gamut/src/Tip/shared/InlineTip.tsx b/packages/gamut/src/Tip/shared/InlineTip.tsx index 871cfaac1ac..b59608e1459 100644 --- a/packages/gamut/src/Tip/shared/InlineTip.tsx +++ b/packages/gamut/src/Tip/shared/InlineTip.tsx @@ -1,3 +1,5 @@ +import { zIndices } from '@codecademy/gamut-styles'; + import { InfoTipContainer } from '../InfoTip/styles'; import { PreviewTipContents, PreviewTipShadow } from '../PreviewTip/elements'; import { ToolTipContainer } from '../ToolTip/elements'; @@ -55,7 +57,7 @@ export const InlineTip: React.FC = ({ const tipBody = ( = ({ colorMode = 'light', }) => { return ( - + diff --git a/packages/gamut/src/Typography/Text.tsx b/packages/gamut/src/Typography/Text.tsx index 173661b8632..a05bf9d53d0 100644 --- a/packages/gamut/src/Typography/Text.tsx +++ b/packages/gamut/src/Typography/Text.tsx @@ -3,6 +3,7 @@ import { styledOptions, system, variant, + zIndices, } from '@codecademy/gamut-styles'; import { StyleProps, variance } from '@codecademy/variance'; import styled from '@emotion/styled'; @@ -76,7 +77,7 @@ const textStates = states({ fontWeight: 'bold', minWidth: '0.4rem', position: 'relative', - zIndex: 1, + zIndex: zIndices.foreground, // the text is more legible against the background color with text smoothing MozOsxFontSmoothing: 'grayscale', WebkitFontSmoothing: 'antialiased', @@ -90,7 +91,7 @@ const textStates = states({ position: 'absolute', top: '50%', width: 'calc(100% + 0.4rem)', - zIndex: -1, + zIndex: zIndices.underlay, }, }, screenreader: { From c01bd808cbfa4625c569c8f3c300f9161cc6175c Mon Sep 17 00:00:00 2001 From: Kenny Lin Date: Wed, 8 Jul 2026 12:21:06 -0400 Subject: [PATCH 2/7] update eslint messages and desc --- packages/eslint-plugin-gamut/src/no-raw-z-index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/eslint-plugin-gamut/src/no-raw-z-index.ts b/packages/eslint-plugin-gamut/src/no-raw-z-index.ts index eadf3bca810..3a0ca1e55eb 100644 --- a/packages/eslint-plugin-gamut/src/no-raw-z-index.ts +++ b/packages/eslint-plugin-gamut/src/no-raw-z-index.ts @@ -48,12 +48,12 @@ export default createRule({ meta: { docs: { description: - 'Disallow raw numeric z-index values; use a semantic token from the `zIndices` scale.', + 'Discourage raw numeric z-index values which can lead to z-index stacking issues and encourage usage of semantic tokens from the `zIndices` scale.', recommended: 'error', }, messages: { noRawZIndex: - 'Use a semantic token from the `zIndices` scale (e.g. `zIndices.modal`) instead of a raw z-index number. For a deliberate in-between value, disable this rule inline with a justifying comment.', + 'Semantic tokens from the `zIndices` scale (e.g. `zIndices.modal`) are recommendedinstead of a raw z-index number. For a deliberate in-between value, disable this rule inline with a justifying comment.', }, type: 'suggestion', schema: [], From 96635b09a194797d939325afddbfa2b605557a42 Mon Sep 17 00:00:00 2001 From: Kenny Lin Date: Wed, 8 Jul 2026 14:27:49 -0400 Subject: [PATCH 3/7] updates to zIndex scale --- .../__snapshots__/theme.test.ts.snap | 35 +++++----------- .../gamut-styles/src/variables/zIndices.ts | 12 +++--- packages/gamut/src/List/TableHeader.tsx | 2 +- packages/gamut/src/List/elements.tsx | 2 +- .../DataList/DataList.stories.tsx | 40 +++++++++---------- 5 files changed, 38 insertions(+), 53 deletions(-) diff --git a/packages/gamut-styles/src/themes/__tests__/__snapshots__/theme.test.ts.snap b/packages/gamut-styles/src/themes/__tests__/__snapshots__/theme.test.ts.snap index 061f863b25a..2769c9de387 100644 --- a/packages/gamut-styles/src/themes/__tests__/__snapshots__/theme.test.ts.snap +++ b/packages/gamut-styles/src/themes/__tests__/__snapshots__/theme.test.ts.snap @@ -155,11 +155,10 @@ exports[`themes admin - theme shape 1`] = ` "appBar": 400, "base": 0, "flyout": 500, - "foreground": 1, + "foreground": 100, "modal": 600, "popover": 700, "portal": 200, - "stickyHeader": 100, "toaster": 800, "tooltip": 900, "underlay": -1, @@ -275,11 +274,10 @@ exports[`themes admin - theme shape 1`] = ` "--zIndices-appBar": 400, "--zIndices-base": 0, "--zIndices-flyout": 500, - "--zIndices-foreground": 1, + "--zIndices-foreground": 100, "--zIndices-modal": 600, "--zIndices-popover": 700, "--zIndices-portal": 200, - "--zIndices-stickyHeader": 100, "--zIndices-toaster": 800, "--zIndices-tooltip": 900, "--zIndices-underlay": -1, @@ -547,7 +545,6 @@ monospace", "modal": "var(--zIndices-modal)", "popover": "var(--zIndices-popover)", "portal": "var(--zIndices-portal)", - "stickyHeader": "var(--zIndices-stickyHeader)", "toaster": "var(--zIndices-toaster)", "tooltip": "var(--zIndices-tooltip)", "underlay": "var(--zIndices-underlay)", @@ -711,11 +708,10 @@ exports[`themes core - theme shape 1`] = ` "appBar": 400, "base": 0, "flyout": 500, - "foreground": 1, + "foreground": 100, "modal": 600, "popover": 700, "portal": 200, - "stickyHeader": 100, "toaster": 800, "tooltip": 900, "underlay": -1, @@ -831,11 +827,10 @@ exports[`themes core - theme shape 1`] = ` "--zIndices-appBar": 400, "--zIndices-base": 0, "--zIndices-flyout": 500, - "--zIndices-foreground": 1, + "--zIndices-foreground": 100, "--zIndices-modal": 600, "--zIndices-popover": 700, "--zIndices-portal": 200, - "--zIndices-stickyHeader": 100, "--zIndices-toaster": 800, "--zIndices-tooltip": 900, "--zIndices-underlay": -1, @@ -1103,7 +1098,6 @@ monospace", "modal": "var(--zIndices-modal)", "popover": "var(--zIndices-popover)", "portal": "var(--zIndices-portal)", - "stickyHeader": "var(--zIndices-stickyHeader)", "toaster": "var(--zIndices-toaster)", "tooltip": "var(--zIndices-tooltip)", "underlay": "var(--zIndices-underlay)", @@ -1270,11 +1264,10 @@ exports[`themes lxStudio - theme shape 1`] = ` "appBar": 400, "base": 0, "flyout": 500, - "foreground": 1, + "foreground": 100, "modal": 600, "popover": 700, "portal": 200, - "stickyHeader": 100, "toaster": 800, "tooltip": 900, "underlay": -1, @@ -1393,11 +1386,10 @@ exports[`themes lxStudio - theme shape 1`] = ` "--zIndices-appBar": 400, "--zIndices-base": 0, "--zIndices-flyout": 500, - "--zIndices-foreground": 1, + "--zIndices-foreground": 100, "--zIndices-modal": 600, "--zIndices-popover": 700, "--zIndices-portal": 200, - "--zIndices-stickyHeader": 100, "--zIndices-toaster": 800, "--zIndices-tooltip": 900, "--zIndices-underlay": -1, @@ -1670,7 +1662,6 @@ monospace", "modal": "var(--zIndices-modal)", "popover": "var(--zIndices-popover)", "portal": "var(--zIndices-portal)", - "stickyHeader": "var(--zIndices-stickyHeader)", "toaster": "var(--zIndices-toaster)", "tooltip": "var(--zIndices-tooltip)", "underlay": "var(--zIndices-underlay)", @@ -1850,11 +1841,10 @@ exports[`themes percipio - theme shape 1`] = ` "appBar": 400, "base": 0, "flyout": 500, - "foreground": 1, + "foreground": 100, "modal": 600, "popover": 700, "portal": 200, - "stickyHeader": 100, "toaster": 800, "tooltip": 900, "underlay": -1, @@ -1986,11 +1976,10 @@ exports[`themes percipio - theme shape 1`] = ` "--zIndices-appBar": 400, "--zIndices-base": 0, "--zIndices-flyout": 500, - "--zIndices-foreground": 1, + "--zIndices-foreground": 100, "--zIndices-modal": 600, "--zIndices-popover": 700, "--zIndices-portal": 200, - "--zIndices-stickyHeader": 100, "--zIndices-toaster": 800, "--zIndices-tooltip": 900, "--zIndices-underlay": -1, @@ -2274,7 +2263,6 @@ sans-serif", "modal": "var(--zIndices-modal)", "popover": "var(--zIndices-popover)", "portal": "var(--zIndices-portal)", - "stickyHeader": "var(--zIndices-stickyHeader)", "toaster": "var(--zIndices-toaster)", "tooltip": "var(--zIndices-tooltip)", "underlay": "var(--zIndices-underlay)", @@ -2510,11 +2498,10 @@ exports[`themes platform - theme shape 1`] = ` "appBar": 400, "base": 0, "flyout": 500, - "foreground": 1, + "foreground": 100, "modal": 600, "popover": 700, "portal": 200, - "stickyHeader": 100, "toaster": 800, "tooltip": 900, "underlay": -1, @@ -2676,11 +2663,10 @@ exports[`themes platform - theme shape 1`] = ` "--zIndices-appBar": 400, "--zIndices-base": 0, "--zIndices-flyout": 500, - "--zIndices-foreground": 1, + "--zIndices-foreground": 100, "--zIndices-modal": 600, "--zIndices-popover": 700, "--zIndices-portal": 200, - "--zIndices-stickyHeader": 100, "--zIndices-toaster": 800, "--zIndices-tooltip": 900, "--zIndices-underlay": -1, @@ -3046,7 +3032,6 @@ monospace", "modal": "var(--zIndices-modal)", "popover": "var(--zIndices-popover)", "portal": "var(--zIndices-portal)", - "stickyHeader": "var(--zIndices-stickyHeader)", "toaster": "var(--zIndices-toaster)", "tooltip": "var(--zIndices-tooltip)", "underlay": "var(--zIndices-underlay)", diff --git a/packages/gamut-styles/src/variables/zIndices.ts b/packages/gamut-styles/src/variables/zIndices.ts index 24fcadd51d6..873d947a8ac 100644 --- a/packages/gamut-styles/src/variables/zIndices.ts +++ b/packages/gamut-styles/src/variables/zIndices.ts @@ -14,16 +14,16 @@ */ export const zIndices = { /** Decorative layer behind content (underlines, backdrops, shadows). */ - underlay: -1, + underlay: -100, /** Ground layer — establishes a local stacking context without lifting above siblings. */ base: 0, - /** Content lifted just above a sibling underlay/base. */ - foreground: 1, /** - * In-flow sticky content headers (e.g. a sticky table `thead`). Below the portal band, - * so every overlay and the app nav cover it. Not a portal layer. + * The raised in-flow layer: an element in front of what sits/scrolls behind it, but below + * all portal overlays. Covers content lifted above an `underlay` (e.g. text over its + * underline) and sticky content headers (e.g. a sticky table `thead`). Set to 100 so it + * clears common ad-hoc low z-index values. */ - stickyHeader: 100, + foreground: 100, /** Portal floor / `BodyPortal` default — un-tokenized portal content lands here. */ portal: 200, /** Persistent floating page furniture at rest (e.g. an AI chat launcher, help bubble). */ diff --git a/packages/gamut/src/List/TableHeader.tsx b/packages/gamut/src/List/TableHeader.tsx index e7c64cfe86b..63add861255 100644 --- a/packages/gamut/src/List/TableHeader.tsx +++ b/packages/gamut/src/List/TableHeader.tsx @@ -18,7 +18,7 @@ export const TableHeader = forwardRef( display="block" position="sticky" top={0} - zIndex={zIndices.stickyHeader} + zIndex={zIndices.foreground} > ( flexDirection: { _: 'row', c_base: 'column', c_sm: 'row' }, top: 0, bg: 'background-current', - zIndex: zIndices.stickyHeader, + zIndex: zIndices.foreground, fontFamily: 'accent', pb: { _: 0, c_base: 8, c_sm: 0 }, }), diff --git a/packages/styleguide/src/lib/Organisms/Lists & Tables/DataList/DataList.stories.tsx b/packages/styleguide/src/lib/Organisms/Lists & Tables/DataList/DataList.stories.tsx index 27d0fbde385..f065ace0a80 100644 --- a/packages/styleguide/src/lib/Organisms/Lists & Tables/DataList/DataList.stories.tsx +++ b/packages/styleguide/src/lib/Organisms/Lists & Tables/DataList/DataList.stories.tsx @@ -101,26 +101,26 @@ const meta: Meta = { header: false, spacing: 'condensed', onRowSelect: () => {}, - onRowExpand: () => {}, - expandedContent: ({ row }) => ( - - - - ), + // onRowExpand: () => {}, + // expandedContent: ({ row }) => ( + // + // + // + // ), }, }; From 4d0c25cad40253ff00cc4e162070eee705329ebc Mon Sep 17 00:00:00 2001 From: Kenny Lin Date: Wed, 8 Jul 2026 15:52:16 -0400 Subject: [PATCH 4/7] added a story --- .../styleguide/src/lib/Foundations/ZIndex.mdx | 75 ++++++++++++++++ .../src/lib/Foundations/shared/elements.tsx | 86 +++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 packages/styleguide/src/lib/Foundations/ZIndex.mdx diff --git a/packages/styleguide/src/lib/Foundations/ZIndex.mdx b/packages/styleguide/src/lib/Foundations/ZIndex.mdx new file mode 100644 index 00000000000..d597f7f639f --- /dev/null +++ b/packages/styleguide/src/lib/Foundations/ZIndex.mdx @@ -0,0 +1,75 @@ +import { Meta } from '@storybook/addon-docs/blocks'; + +import { ComponentHeader, LinkTo, TokenTable } from '~styleguide/blocks'; + +import * as TABLES from './shared/elements'; + +export const parameters = { + title: 'Z-Index', + subtitle: + 'A single semantic scale for coordinating stacking order across Gamut.', + status: 'static', +}; + + + + + +Gamut exposes one semantic z-index scale, `zIndices`, so stacking order is coordinated by name +instead of by scattered magic numbers. Import it from `@codecademy/gamut-styles` and pass a token to +the `zIndex` prop: + +```tsx +import { zIndices } from '@codecademy/gamut-styles'; + +; +``` + +## The scale + +Values run in two bands. **In-flow** tokens (below `0`–`100`) order elements relative to their +siblings inside a normal stacking context. **Portal** tokens (`200`+) order elements that render +into a portal at `document.body` and compete at the page root. Portal values are spaced by 100. + + + +- **`underlay` → `foreground`** are in-flow: a decorative layer behind content, a neutral ground + that establishes a stacking context, and content raised in front (including sticky headers). +- **`portal` → `tooltip`** are the portal layers, ordered so the things that must never be clipped + (tooltips, toasts, popovers) sit above dialogs, which sit above side panels and the app bar. +- `portal` is the floor of the portal band and the default for `BodyPortal`, so portaled content + that omits a specific token still lands above page content. + +## Why it's a _soft_ scale + +Unlike hard token scales such as borderRadii or spacing — +where the prop is bound to the scale and **only** accepts token values — the `zIndex` prop is left +**numeric**. `zIndices` is just a numeric object you reference by name; the prop still accepts a raw +number. This is deliberate: + +- **Escape hatch.** Stacking occasionally needs a one-off value between two layers (e.g. a confirm + dialog nested inside a modal). You can pass a raw in-between number without leaving the system: + + ```tsx + ; // just above modal + ; // deliberate one-off + ``` + +- **Arithmetic.** Some components derive a relative value from a base (e.g. a tooltip's shadow sits + a step behind its body: `zIndices.foreground - 2`). A token-only string prop can't express that. + +Binding `zIndex` to the scale as a hard token would break both of the above (variance scaled props +reject raw numbers), so it stays soft. To keep it from sliding back into magic numbers, the +`gamut/no-raw-z-index` lint rule flags bare numeric z-index values and steers you to a token; a +genuine escape hatch uses an inline `eslint-disable` with a short justification. + +## Guidance + +- **Reach for a token first.** `zIndices.modal`, `zIndices.foreground`, etc. — the name documents + intent far better than a number. +- **In-flow vs. portal.** Use the in-flow tokens for stacking within a component; only portaled + content belongs on the `portal`+ band. +- **Escape hatch sparingly.** A raw number is fine for a genuine one-off, but prefer + `token ± n` so the relationship to the scale stays visible, and leave a comment. +- **Third-party widgets** (e.g. injected marketing/chat scripts) set their own z-index and are out + of Gamut's control; `tooltip` is the highest Gamut layer. diff --git a/packages/styleguide/src/lib/Foundations/shared/elements.tsx b/packages/styleguide/src/lib/Foundations/shared/elements.tsx index 1ec160156af..e1a838776cb 100644 --- a/packages/styleguide/src/lib/Foundations/shared/elements.tsx +++ b/packages/styleguide/src/lib/Foundations/shared/elements.tsx @@ -6,6 +6,7 @@ import { lxStudioColors, theme, trueColors, + zIndices as zIndicesTokens, } from '@codecademy/gamut-styles'; // eslint-disable-next-line gamut/import-paths import * as ALL_PROPS from '@codecademy/gamut-styles/src/variance/config'; @@ -438,6 +439,91 @@ export const borderRadii = { ], }; +// Representative components that use each token, linked to their Storybook stories. +// Story ids are the folder path under `src/lib` (Storybook auto-title). +const zIndexExamples: Record = { + underlay: [ + { label: 'Text', id: 'Typography/Text' }, + { label: 'Menu', id: 'Molecules/Menu' }, + ], + base: [ + { label: 'Tabs', id: 'Molecules/Tabs' }, + { label: 'Anchor', id: 'Typography/Anchor' }, + ], + foreground: [ + { label: 'Text', id: 'Typography/Text' }, + { label: 'Tabs', id: 'Molecules/Tabs' }, + { label: 'DataList', id: 'Organisms/Lists & Tables/DataList' }, + ], + flyout: [{ label: 'Flyout', id: 'Molecules/Flyout' }], + modal: [ + { label: 'Modal', id: 'Molecules/Modals/Modal' }, + { label: 'Dialog', id: 'Molecules/Modals/Dialog' }, + ], + popover: [ + { label: 'Popover', id: 'Molecules/Popover' }, + { label: 'SelectDropdown', id: 'Atoms/FormInputs/SelectDropdown' }, + ], + toaster: [{ label: 'Toaster', id: 'Molecules/Toasts/Toaster' }], + tooltip: [{ label: 'ToolTip', id: 'Molecules/Tips/ToolTip' }], +}; + +// Tokens with no single component example get a short note instead of links. +const zIndexNotes: Record = { + portal: 'BodyPortal default', + widget: 'Floating launchers (none in Gamut yet)', + appBar: 'App header / nav (app-owned)', +}; + +export const zIndices = { + // Object insertion order runs low → high (underlay … tooltip). + rows: Object.entries(zIndicesTokens).map(([id, value]) => ({ + id, + value, + })), + columns: [ + { ...PROP_COLUMN, name: 'Token' }, + { + ...PATH_COLUMN, + render: ({ id }: any) => zIndices.{id}, + }, + VALUE_COLUMN, + { + key: 'band', + name: 'Band', + size: 'lg', + render: ({ value }: any) => ( + {value >= zIndicesTokens.portal ? 'portal' : 'in-flow'} + ), + }, + { + key: 'usedBy', + name: 'Used by', + size: 'lg', + render: ({ id }: any) => { + const examples = zIndexExamples[id]; + if (!examples?.length) { + return {zIndexNotes[id] ?? '—'}; + } + return ( + <> + {examples.flatMap((example, i) => { + const link = ( + + {example.label} + + ); + return i === 0 + ? [link] + : [, , link]; + })} + + ); + }, + }, + ], +}; + export const LightModeTable = () => ( From 32cfd25581c1f433f7b4947d548ffa544e118fcf Mon Sep 17 00:00:00 2001 From: Kenny Lin Date: Wed, 8 Jul 2026 15:56:55 -0400 Subject: [PATCH 5/7] linting errors --- .../src/lib/Foundations/shared/elements.tsx | 2 +- .../DataList/DataList.stories.tsx | 40 +++++++++---------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/packages/styleguide/src/lib/Foundations/shared/elements.tsx b/packages/styleguide/src/lib/Foundations/shared/elements.tsx index e1a838776cb..a74f1e51a82 100644 --- a/packages/styleguide/src/lib/Foundations/shared/elements.tsx +++ b/packages/styleguide/src/lib/Foundations/shared/elements.tsx @@ -509,7 +509,7 @@ export const zIndices = { <> {examples.flatMap((example, i) => { const link = ( - + {example.label} ); diff --git a/packages/styleguide/src/lib/Organisms/Lists & Tables/DataList/DataList.stories.tsx b/packages/styleguide/src/lib/Organisms/Lists & Tables/DataList/DataList.stories.tsx index f065ace0a80..27d0fbde385 100644 --- a/packages/styleguide/src/lib/Organisms/Lists & Tables/DataList/DataList.stories.tsx +++ b/packages/styleguide/src/lib/Organisms/Lists & Tables/DataList/DataList.stories.tsx @@ -101,26 +101,26 @@ const meta: Meta = { header: false, spacing: 'condensed', onRowSelect: () => {}, - // onRowExpand: () => {}, - // expandedContent: ({ row }) => ( - // - // - // - // ), + onRowExpand: () => {}, + expandedContent: ({ row }) => ( + + + + ), }, }; From 83c108a23581c1eb33b059a1baa025e5cd52516f Mon Sep 17 00:00:00 2001 From: Kenny Lin Date: Wed, 8 Jul 2026 16:01:16 -0400 Subject: [PATCH 6/7] updated snapshots --- .../__snapshots__/theme.test.ts.snap | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/gamut-styles/src/themes/__tests__/__snapshots__/theme.test.ts.snap b/packages/gamut-styles/src/themes/__tests__/__snapshots__/theme.test.ts.snap index 2769c9de387..c04ce5490db 100644 --- a/packages/gamut-styles/src/themes/__tests__/__snapshots__/theme.test.ts.snap +++ b/packages/gamut-styles/src/themes/__tests__/__snapshots__/theme.test.ts.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing +// Jest Snapshot v1, https://goo.gl/fbAQLP exports[`themes admin - theme shape 1`] = ` { @@ -161,7 +161,7 @@ exports[`themes admin - theme shape 1`] = ` "portal": 200, "toaster": 800, "tooltip": 900, - "underlay": -1, + "underlay": -100, "widget": 300, }, }, @@ -280,7 +280,7 @@ exports[`themes admin - theme shape 1`] = ` "--zIndices-portal": 200, "--zIndices-toaster": 800, "--zIndices-tooltip": 900, - "--zIndices-underlay": -1, + "--zIndices-underlay": -100, "--zIndices-widget": 300, "@media only screen and (min-width: 1024px)": { "--elements-headerHeight": "5rem", @@ -714,7 +714,7 @@ exports[`themes core - theme shape 1`] = ` "portal": 200, "toaster": 800, "tooltip": 900, - "underlay": -1, + "underlay": -100, "widget": 300, }, }, @@ -833,7 +833,7 @@ exports[`themes core - theme shape 1`] = ` "--zIndices-portal": 200, "--zIndices-toaster": 800, "--zIndices-tooltip": 900, - "--zIndices-underlay": -1, + "--zIndices-underlay": -100, "--zIndices-widget": 300, "@media only screen and (min-width: 1024px)": { "--elements-headerHeight": "5rem", @@ -1270,7 +1270,7 @@ exports[`themes lxStudio - theme shape 1`] = ` "portal": 200, "toaster": 800, "tooltip": 900, - "underlay": -1, + "underlay": -100, "widget": 300, }, }, @@ -1392,7 +1392,7 @@ exports[`themes lxStudio - theme shape 1`] = ` "--zIndices-portal": 200, "--zIndices-toaster": 800, "--zIndices-tooltip": 900, - "--zIndices-underlay": -1, + "--zIndices-underlay": -100, "--zIndices-widget": 300, "@media only screen and (min-width: 1024px)": { "--elements-headerHeight": "5rem", @@ -1847,7 +1847,7 @@ exports[`themes percipio - theme shape 1`] = ` "portal": 200, "toaster": 800, "tooltip": 900, - "underlay": -1, + "underlay": -100, "widget": 300, }, }, @@ -1982,7 +1982,7 @@ exports[`themes percipio - theme shape 1`] = ` "--zIndices-portal": 200, "--zIndices-toaster": 800, "--zIndices-tooltip": 900, - "--zIndices-underlay": -1, + "--zIndices-underlay": -100, "--zIndices-widget": 300, "@media only screen and (min-width: 1024px)": { "--elements-headerHeight": "5rem", @@ -2504,7 +2504,7 @@ exports[`themes platform - theme shape 1`] = ` "portal": 200, "toaster": 800, "tooltip": 900, - "underlay": -1, + "underlay": -100, "widget": 300, }, }, @@ -2669,7 +2669,7 @@ exports[`themes platform - theme shape 1`] = ` "--zIndices-portal": 200, "--zIndices-toaster": 800, "--zIndices-tooltip": 900, - "--zIndices-underlay": -1, + "--zIndices-underlay": -100, "--zIndices-widget": 300, "@media only screen and (min-width: 1024px)": { "--elements-headerHeight": "5rem", From 715f4f02410e18900234cc251c46d62a783409a6 Mon Sep 17 00:00:00 2001 From: Kenny Lin Date: Wed, 8 Jul 2026 16:18:13 -0400 Subject: [PATCH 7/7] add version plan --- .../version-plan-1783541829799.md | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .nx/version-plans/version-plan-1783541829799.md diff --git a/.nx/version-plans/version-plan-1783541829799.md b/.nx/version-plans/version-plan-1783541829799.md new file mode 100644 index 00000000000..a4717424084 --- /dev/null +++ b/.nx/version-plans/version-plan-1783541829799.md @@ -0,0 +1,34 @@ +--- +eslint-plugin-gamut: major +gamut-styles: minor +gamut: major +gamut-kit: major +--- + +Add a semantic z-index scale (`zIndices`) and migrate Gamut off ad-hoc z-index values. + +**gamut-styles** + +- Add the `zIndices` token scale (exported object + theme scale with `--zIndices-*` CSS variables): + `underlay (-100)`, `base (0)`, `foreground (100)`, `portal (200)`, `widget (300)`, `appBar (400)`, + `flyout (500)`, `modal (600)`, `popover (700)`, `toaster (800)`, `tooltip (900)`. +- It is a _soft_ scale: the `zIndex` prop stays numeric, so tokens are used as `zIndex={zIndices.modal}` + and raw-number escape hatches / arithmetic (`zIndices.foreground - 2`) still work. +- `elements.headerZ` now aliases `zIndices.appBar` (was `15`). Prefer `zIndices.appBar` going forward. + +**gamut** + +- ⚠️ Behavior change — component stacking now uses the scale, which retunes several z-index values: + `Overlay`/`Modal`/`Dialog` → `modal (600)`, `Flyout` → `flyout (500)`, `Toaster` → `toaster (800)` + (fixes toasts previously stacking atPortal`default →`portal (200)`, sticky +`TableHeader`→`foreground (100)`, and many in-flow values → `underlay`/`base`/`foreground`. +- `SelectDropdown`'s options menu now portals to `document.body` (a true popover at `zIndices.popover`), + so it renders above sticky headers and modal content instead of being clipped. +- No public API is removed or retyped; the `zIndex` prop still accepts numbers. + +**eslint-plugin-gamut** + +- Add rule `gamut/no-raw-z-index`, enabled as `error` in the recommended config. It flags raw numeric + z-index literals in style objects and JSX props and steers to `zIndices` tokens; token references and + arithmetic are allowed, and deliberate escape hatches use an inline `eslint-disable`. Major because + the new error-level recommended rule can fail existing lint in consumers until raw z-index is migrated.