From 210821328a3b95ce3c93dc7e57ea00d2f971596c Mon Sep 17 00:00:00 2001 From: Nathnael Dereje Date: Fri, 12 Jun 2026 15:28:19 +0200 Subject: [PATCH 1/2] chore: Add test page --- pages/table/divider-contrast.page.tsx | 522 ++++++++++++++++++++++++++ src/table/header-cell/styles.scss | 10 +- src/table/resizer/styles.scss | 6 +- 3 files changed, 534 insertions(+), 4 deletions(-) create mode 100644 pages/table/divider-contrast.page.tsx diff --git a/pages/table/divider-contrast.page.tsx b/pages/table/divider-contrast.page.tsx new file mode 100644 index 0000000000..9ac975148a --- /dev/null +++ b/pages/table/divider-contrast.page.tsx @@ -0,0 +1,522 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useContext, useState } from 'react'; + +import { Mode } from '@cloudscape-design/global-styles'; + +import { + Box, + CollectionPreferences, + CollectionPreferencesProps, + ColumnLayout, + FormField, + Header, + Input, + Link, + Select, + SpaceBetween, + StatusIndicator, + Table, + TableProps, + Toggle, +} from '~components'; + +import AppContext, { AppContextType } from '../app/app-context'; +import { SimplePage } from '../app/templates'; + +// --------------------------------------------------------------------------- +// Token reference values (read from design-tokens visual-refresh). +// Hard-coded so the prototype can show the contrast number for "the real +// token" even when the user has overridden it. If tokens change, update here. +// --------------------------------------------------------------------------- +// Resolved from style-dictionary/visual-refresh + style-dictionary/core/color-palette.ts: +// colorBorderDividerDefault → colorNeutral350 (light) / colorNeutral650 (dark) +// colorBorderDividerInteractiveDefault → colorNeutral500 (light) / colorNeutral300 (dark) +// colorBackgroundTableHeader → colorBackgroundContainerHeader +// → colorWhite (light) / colorNeutral850 (dark) +// neutral{N} maps to colorGrey{N} in core/color-palette.ts. +const TOKEN_VALUES = { + light: { + 'color-border-divider-default': '#c6c6cd', // colorGrey350 + 'color-border-divider-interactive-default': '#8c8c94', // colorGrey500 + 'color-background-table-header': '#ffffff', // colorWhite + }, + dark: { + 'color-border-divider-default': '#424650', // colorGrey650 + 'color-border-divider-interactive-default': '#dedee3', // colorGrey300 + 'color-background-table-header': '#161d26', // colorGrey850 + }, +}; + +// --------------------------------------------------------------------------- +// Sample data +// --------------------------------------------------------------------------- +interface Instance { + id: string; + name: string; + type: string; + az: string; + state: string; + cpu: number; + memory: number; + netIn: number; + netOut: number; + cost: number; +} + +const allInstances: Instance[] = Array.from({ length: 12 }, (_, i) => ({ + id: `i-${String(i + 1).padStart(3, '0')}`, + name: `instance-${i + 1}`, + type: ['t3.medium', 't3.large', 'r5.xlarge'][i % 3], + az: ['us-east-1a', 'us-east-1b'][i % 2], + state: ['running', 'stopped'][i % 2], + cpu: +(40 + ((i * 7) % 60)).toFixed(1), + memory: +(30 + ((i * 11) % 70)).toFixed(1), + netIn: 1000 + i * 437, + netOut: 800 + i * 311, + cost: +(20 + i * 13.7).toFixed(2), +})); + +const columnDefinitions: TableProps.ColumnDefinition[] = [ + { id: 'id', header: 'Instance ID', cell: item => {item.id}, isRowHeader: true }, + { id: 'name', header: 'Name', cell: item => item.name }, + { id: 'type', header: 'Type', cell: item => item.type }, + { id: 'az', header: 'AZ', cell: item => item.az }, + { id: 'state', header: 'State', cell: item => item.state }, + { id: 'cpu', header: 'CPU (%)', cell: item => `${item.cpu}%` }, + { id: 'memory', header: 'Memory (%)', cell: item => `${item.memory}%` }, + { id: 'netIn', header: 'Network in', cell: item => item.netIn.toLocaleString() }, + { id: 'netOut', header: 'Network out', cell: item => item.netOut.toLocaleString() }, + { id: 'cost', header: 'Cost ($)', cell: item => `$${item.cost}` }, +]; + +const groupDefinitions: TableProps.GroupDefinition[] = [ + { id: 'config', header: 'Configuration' }, + { id: 'performance', header: 'Performance' }, + { id: 'network', header: 'Network' }, + { id: 'metrics', header: 'Metrics' }, +]; + +type DepthPreset = 'depth-2' | 'depth-3' | 'flat'; + +const columnDisplayPresets: Record = { + flat: columnDefinitions.map(c => ({ id: c.id!, visible: true })), + 'depth-2': [ + { id: 'id', visible: true }, + { id: 'name', visible: true }, + { + type: 'group', + id: 'config', + visible: true, + children: [ + { id: 'type', visible: true }, + { id: 'az', visible: true }, + { id: 'state', visible: true }, + ], + }, + { + type: 'group', + id: 'performance', + visible: true, + children: [ + { id: 'cpu', visible: true }, + { id: 'memory', visible: true }, + ], + }, + { + type: 'group', + id: 'network', + visible: true, + children: [ + { id: 'netIn', visible: true }, + { id: 'netOut', visible: true }, + ], + }, + { id: 'cost', visible: true }, + ], + 'depth-3': [ + { id: 'id', visible: true }, + { id: 'name', visible: true }, + { + type: 'group', + id: 'config', + visible: true, + children: [ + { id: 'type', visible: true }, + { id: 'az', visible: true }, + { id: 'state', visible: true }, + ], + }, + { + type: 'group', + id: 'metrics', + visible: true, + children: [ + { + type: 'group', + id: 'performance', + visible: true, + children: [ + { id: 'cpu', visible: true }, + { id: 'memory', visible: true }, + ], + }, + { + type: 'group', + id: 'network', + visible: true, + children: [ + { id: 'netIn', visible: true }, + { id: 'netOut', visible: true }, + ], + }, + ], + }, + { id: 'cost', visible: true }, + ], +}; + +const depthOptions = [ + { value: 'depth-3', label: 'Depth 3 (nested groups)' }, + { value: 'depth-2', label: 'Depth 2 (single-level groups)' }, + { value: 'flat', label: 'Flat (no groups)' }, +]; + +// --------------------------------------------------------------------------- +// Color presets (the actual question on the table). +// --------------------------------------------------------------------------- +type TokenPreset = 'default' | 'interactive'; + +const presetOptions = [ + { value: 'default', label: 'colorBorderDividerDefault' }, + { value: 'interactive', label: 'colorBorderDividerInteractiveDefault' }, +]; + +// --------------------------------------------------------------------------- +// Contrast helpers (WCAG 2.x relative luminance). +// --------------------------------------------------------------------------- +function hexToRgb(hex: string): [number, number, number] | null { + const clean = hex.trim().replace('#', ''); + if (!/^[0-9a-fA-F]{6}$/.test(clean)) { + return null; + } + return [parseInt(clean.slice(0, 2), 16), parseInt(clean.slice(2, 4), 16), parseInt(clean.slice(4, 6), 16)]; +} + +function relativeLuminance([r, g, b]: [number, number, number]): number { + const channel = (v: number) => { + const s = v / 255; + return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); + }; + return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); +} + +function contrastRatio(fg: string, bg: string): number | null { + const fgRgb = hexToRgb(fg); + const bgRgb = hexToRgb(bg); + if (!fgRgb || !bgRgb) { + return null; + } + const [l1, l2] = [relativeLuminance(fgRgb), relativeLuminance(bgRgb)].sort((a, b) => b - a); + return (l1 + 0.05) / (l2 + 0.05); +} + +function ContrastIndicator({ ratio }: { ratio: number | null }) { + if (ratio === null) { + return Invalid hex; + } + const passes = ratio >= 3; + return ( + + {ratio.toFixed(2)}:1 {passes ? '— passes 3:1' : '— below 3:1'} + + ); +} + +// --------------------------------------------------------------------------- +// Resolve the hex value for a token preset in the given mode. The Select acts +// as a "load preset" action — the editable Input is always the source of +// truth for what gets applied to the table. +// --------------------------------------------------------------------------- +function presetHex(preset: 'default' | 'interactive', mode: 'light' | 'dark'): string { + const tokenName = preset === 'default' ? 'color-border-divider-default' : 'color-border-divider-interactive-default'; + return TOKEN_VALUES[mode][tokenName]; +} + +// Reverse lookup: which preset (if any) matches `hex` in the active mode? +function matchingPresetOption(hex: string, mode: 'light' | 'dark') { + const normalized = hex.trim().toLowerCase(); + return presetOptions.find(opt => presetHex(opt.value as TokenPreset, mode).toLowerCase() === normalized) ?? null; +} + +// --------------------------------------------------------------------------- +// Page +// --------------------------------------------------------------------------- +type DemoContext = React.Context< + AppContextType<{ + depth: DepthPreset; + horizontalHex: string; + verticalHex: string; + resizable: boolean; + stickyHeader: boolean; + firstSticky: number; + lastSticky: number; + }> +>; + +export default function DividerContrastPage() { + const { + mode, + setMode, + urlParams: { + depth = 'depth-3' as DepthPreset, + // Initialised from the current-mode interactive-default token so the + // initial render matches production. Edit freely or use the preset + // dropdown to overwrite with another token's hex. + horizontalHex = TOKEN_VALUES.light['color-border-divider-interactive-default'], + verticalHex = TOKEN_VALUES.light['color-border-divider-interactive-default'], + resizable = true, + stickyHeader = true, + firstSticky = 1, + lastSticky = 1, + }, + setUrlParams, + } = useContext(AppContext as DemoContext); + + const [columnDisplay, setColumnDisplay] = useState(columnDisplayPresets[depth]); + + // CollectionPreferences-driven settings. Initial sticky values come from the URL + // so deep-linking still works; further changes flow through the preferences modal. + const [tablePrefs, setTablePrefs] = useState({ + wrapLines: false, + stripedRows: false, + contentDensity: 'comfortable', + stickyColumns: { first: firstSticky, last: lastSticky }, + }); + + const modeKey: 'light' | 'dark' = mode === Mode.Dark ? 'dark' : 'light'; + const horizontalColor = horizontalHex; + const verticalColor = verticalHex; + const headerBg = TOKEN_VALUES[modeKey]['color-background-table-header']; + + const horizontalRatio = contrastRatio(horizontalColor, headerBg); + const verticalRatio = contrastRatio(verticalColor, headerBg); + + // The override drives two CSS custom properties consumed inside the + // table source (header-cell + resizer SCSS). The properties have the + // production tokens as fallbacks, so this prototype is invisible when + // these styles aren't set. + const overrideStyle = { + '--awsui-table-divider-horizontal-prototype': horizontalColor, + '--awsui-table-divider-vertical-prototype': verticalColor, + } as React.CSSProperties; + + const tableItems = allInstances; + + // Effective sticky-column counts: the preferences value wins, with a URL-param fallback. + const effectiveFirst = tablePrefs.stickyColumns?.first ?? firstSticky; + const effectiveLast = tablePrefs.stickyColumns?.last ?? lastSticky; + + return ( + + + + + setUrlParams({ horizontalHex: detail.value })} + /> + + {horizontalColor} on {headerBg} —{' '} + + + + + + + + setUrlParams({ verticalHex: detail.value })} + /> + + {verticalColor} on {headerBg} —{' '} + + + + + + + Layout & sticky + + + setMode(detail.selectedOption.value === 'dark' ? Mode.Dark : Mode.Light)} + ariaLabel="Color mode" + /> + + + setUrlParams({ resizable: detail.checked })}> + Resizable + + setUrlParams({ stickyHeader: detail.checked })}> + Sticky header + + + + + } + > +
+ Instances} + empty={No instances} + preferences={ + { + if (detail.contentDisplay) { + setColumnDisplay([...detail.contentDisplay]); + } + setTablePrefs({ + wrapLines: detail.wrapLines ?? false, + stripedRows: detail.stripedRows ?? false, + contentDensity: detail.contentDensity ?? 'comfortable', + stickyColumns: detail.stickyColumns ?? { first: 0, last: 0 }, + }); + if (detail.stickyColumns) { + setUrlParams({ + firstSticky: detail.stickyColumns.first ?? 0, + lastSticky: detail.stickyColumns.last ?? 0, + }); + } + }} + wrapLinesPreference={{ + label: 'Wrap lines', + description: 'Show all the text on multiple lines instead of truncating it.', + }} + stripedRowsPreference={{ + label: 'Striped rows', + description: 'Add alternating shaded rows for readability.', + }} + contentDensityPreference={{ + label: 'Compact mode', + description: 'Reduce vertical spacing between rows.', + }} + stickyColumnsPreference={{ + firstColumns: { + title: 'Stick first columns', + description: 'Keep leading columns visible while horizontally scrolling.', + options: [ + { label: 'None', value: 0 }, + { label: 'First column', value: 1 }, + { label: 'First two columns', value: 2 }, + ], + }, + lastColumns: { + title: 'Stick last columns', + description: 'Keep trailing columns visible while horizontally scrolling.', + options: [ + { label: 'None', value: 0 }, + { label: 'Last column', value: 1 }, + { label: 'Last two columns', value: 2 }, + { label: 'Last three columns', value: 3 }, + ], + }, + }} + contentDisplayPreference={{ + title: 'Column preferences', + description: 'Reorder, show, and hide columns. Drag a column to a different group.', + options: [ + { id: 'id', label: 'Instance ID', alwaysVisible: true }, + { id: 'name', label: 'Name' }, + { id: 'type', label: 'Type' }, + { id: 'az', label: 'AZ' }, + { id: 'state', label: 'State' }, + { id: 'cpu', label: 'CPU (%)' }, + { id: 'memory', label: 'Memory (%)' }, + { id: 'netIn', label: 'Network in' }, + { id: 'netOut', label: 'Network out' }, + { id: 'cost', label: 'Cost ($)' }, + ], + groups: + depth === 'flat' + ? undefined + : [ + { id: 'config', label: 'Configuration' }, + { id: 'performance', label: 'Performance' }, + { id: 'network', label: 'Network' }, + { id: 'metrics', label: 'Metrics' }, + ], + }} + /> + } + /> + + + ); +} diff --git a/src/table/header-cell/styles.scss b/src/table/header-cell/styles.scss index b55faad5f9..b77e8d9307 100644 --- a/src/table/header-cell/styles.scss +++ b/src/table/header-cell/styles.scss @@ -53,7 +53,10 @@ $cell-horizontal-padding: awsui.$space-scaled-l; position: relative; text-align: start; box-sizing: border-box; - border-block-end: awsui.$border-divider-list-width solid awsui.$color-border-divider-interactive-default; + /* stylelint-disable custom-property-pattern -- prototype-only override; remove with the prototype page */ + border-block-end: awsui.$border-divider-list-width solid + var(--awsui-table-divider-horizontal-prototype, #{awsui.$color-border-divider-interactive-default}); + /* stylelint-enable custom-property-pattern */ background: awsui.$color-background-table-header; color: awsui.$color-text-column-header; font-weight: awsui.$font-weight-heading-s; @@ -71,7 +74,10 @@ $cell-horizontal-padding: awsui.$space-scaled-l; } &-sticky { - border-block-end: awsui.$border-table-sticky-width solid awsui.$color-border-divider-interactive-default; + /* stylelint-disable custom-property-pattern -- prototype-only override; remove with the prototype page */ + border-block-end: awsui.$border-table-sticky-width solid + var(--awsui-table-divider-horizontal-prototype, #{awsui.$color-border-divider-interactive-default}); + /* stylelint-enable custom-property-pattern */ } &-stuck:not(.header-cell-variant-full-page) { border-block-end-color: transparent; diff --git a/src/table/resizer/styles.scss b/src/table/resizer/styles.scss index f17ed1053e..44848ac02f 100644 --- a/src/table/resizer/styles.scss +++ b/src/table/resizer/styles.scss @@ -46,9 +46,11 @@ th:not([data-rightmost]) > .divider, max-block-size: calc(100% - #{$block-gap}); margin-block: auto; margin-inline: auto; - border-inline-start: awsui.$border-divider-list-width solid awsui.$color-border-divider-interactive-default; + /* stylelint-disable custom-property-pattern -- prototype-only override; remove with the prototype page */ + border-inline-start: awsui.$border-divider-list-width solid + var(--awsui-table-divider-vertical-prototype, #{awsui.$color-border-divider-interactive-default}); + /* stylelint-enable custom-property-pattern */ box-sizing: border-box; - // Position variants for grouped column headers. // All Column dividers maintain the same bottom gap ($block-gap / 2) as the default. &.divider-position-top { From 0ba0a5b4227c1d3bf54d47638a81776bad82bf02 Mon Sep 17 00:00:00 2001 From: Nathnael Dereje Date: Fri, 12 Jun 2026 22:26:34 +0200 Subject: [PATCH 2/2] chore: Second proposal --- pages/table/divider-contrast.page.tsx | 28 ++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/pages/table/divider-contrast.page.tsx b/pages/table/divider-contrast.page.tsx index 9ac975148a..28e0fef99c 100644 --- a/pages/table/divider-contrast.page.tsx +++ b/pages/table/divider-contrast.page.tsx @@ -31,19 +31,24 @@ import { SimplePage } from '../app/templates'; // --------------------------------------------------------------------------- // Resolved from style-dictionary/visual-refresh + style-dictionary/core/color-palette.ts: // colorBorderDividerDefault → colorNeutral350 (light) / colorNeutral650 (dark) -// colorBorderDividerInteractiveDefault → colorNeutral500 (light) / colorNeutral300 (dark) +// colorBorderDividerInteractiveDefault → colorNeutral500 (light) / colorNeutral300 (dark) [current] // colorBackgroundTableHeader → colorBackgroundContainerHeader // → colorWhite (light) / colorNeutral850 (dark) // neutral{N} maps to colorGrey{N} in core/color-palette.ts. +// +// "interactive-proposed" is the value under design review: +// light = colorNeutral500 (unchanged), dark = colorNeutral600 — muted but still ≥ 3:1. const TOKEN_VALUES = { light: { 'color-border-divider-default': '#c6c6cd', // colorGrey350 - 'color-border-divider-interactive-default': '#8c8c94', // colorGrey500 + 'color-border-divider-interactive-default': '#8c8c94', // colorGrey500 (current) + 'color-border-divider-interactive-proposed': '#8c8c94', // colorGrey500 (unchanged in light) 'color-background-table-header': '#ffffff', // colorWhite }, dark: { 'color-border-divider-default': '#424650', // colorGrey650 - 'color-border-divider-interactive-default': '#dedee3', // colorGrey300 + 'color-border-divider-interactive-default': '#dedee3', // colorGrey300 (current) + 'color-border-divider-interactive-proposed': '#656871', // colorGrey600 (proposed) 'color-background-table-header': '#161d26', // colorGrey850 }, }; @@ -185,11 +190,15 @@ const depthOptions = [ // --------------------------------------------------------------------------- // Color presets (the actual question on the table). // --------------------------------------------------------------------------- -type TokenPreset = 'default' | 'interactive'; +type TokenPreset = 'default' | 'interactive' | 'interactive-proposed'; const presetOptions = [ { value: 'default', label: 'colorBorderDividerDefault' }, - { value: 'interactive', label: 'colorBorderDividerInteractiveDefault' }, + { value: 'interactive', label: 'colorBorderDividerInteractiveDefault (current)' }, + { + value: 'interactive-proposed', + label: 'colorBorderDividerInteractiveDefault (proposed: dark → grey600)', + }, ]; // --------------------------------------------------------------------------- @@ -238,8 +247,13 @@ function ContrastIndicator({ ratio }: { ratio: number | null }) { // as a "load preset" action — the editable Input is always the source of // truth for what gets applied to the table. // --------------------------------------------------------------------------- -function presetHex(preset: 'default' | 'interactive', mode: 'light' | 'dark'): string { - const tokenName = preset === 'default' ? 'color-border-divider-default' : 'color-border-divider-interactive-default'; +function presetHex(preset: TokenPreset, mode: 'light' | 'dark'): string { + const tokenName = + preset === 'default' + ? 'color-border-divider-default' + : preset === 'interactive' + ? 'color-border-divider-interactive-default' + : 'color-border-divider-interactive-proposed'; return TOKEN_VALUES[mode][tokenName]; }