From bc27384ce25ce64a029dec3fe3626f69d6d97fbd Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Wed, 2 Sep 2026 15:27:45 -0300 Subject: [PATCH 1/3] fix(a11y): darken placeholder text to clear WCAG AA Placeholders across inputs, textareas and the value editor all resolve to --color-text-tertiary. In light mode that was Neutrals/300 (#9da4ae), which is 2.26:1 on surface-default, well under the 4.5:1 AA threshold. Repoint the light value to Neutrals/500 (#656d7b), measured at 5.22:1. Dark mode already passed and is unchanged. Also switch the react-select placeholder onto the same token: it was the one placeholder still reading the raw SCSS variable, fixed for both themes. That leaves $input-placeholder-color(-dark) unused, so remove them. Closes #8441 Co-Authored-By: Claude Opus 5 (1M context) --- frontend/common/theme/tokens.json | 2 +- frontend/common/theme/tokens.ts | 2 +- frontend/documentation/TokenReference.generated.stories.tsx | 2 +- frontend/web/styles/3rdParty/_react-select.scss | 2 +- frontend/web/styles/_tokens.scss | 2 +- frontend/web/styles/_variables.scss | 2 -- 6 files changed, 5 insertions(+), 7 deletions(-) diff --git a/frontend/common/theme/tokens.json b/frontend/common/theme/tokens.json index 28ecd0fcdb6a..d75701d1ab1f 100644 --- a/frontend/common/theme/tokens.json +++ b/frontend/common/theme/tokens.json @@ -103,7 +103,7 @@ "text": { "default": { "cssVar": "--color-text-default", "light": "#1a2634", "dark": "#ffffff" }, "secondary": { "cssVar": "--color-text-secondary", "light": "#656d7b", "dark": "#9da4ae" }, - "tertiary": { "cssVar": "--color-text-tertiary", "light": "#9da4ae", "dark": "rgba(255, 255, 255, 0.48)" }, + "tertiary": { "cssVar": "--color-text-tertiary", "light": "#656d7b", "dark": "rgba(255, 255, 255, 0.48)", "description": "Placeholders and hint text. In light mode, dark enough to clear 4.5:1 on surface-default." }, "disabled": { "cssVar": "--color-text-disabled", "light": "#9da4ae", "dark": "rgba(255, 255, 255, 0.32)" }, "action": { "cssVar": "--color-text-action", "light": "#6837fc", "dark": "#906af6" }, "danger": { "cssVar": "--color-text-danger", "light": "#bb1720", "dark": "#ef4d56", "description": "In light mode, darker than border/icon danger to clear 4.5:1 on the tint." }, diff --git a/frontend/common/theme/tokens.ts b/frontend/common/theme/tokens.ts index 5caa021f0f54..025e5418cc4c 100644 --- a/frontend/common/theme/tokens.ts +++ b/frontend/common/theme/tokens.ts @@ -206,7 +206,7 @@ export const colorTextDisabled = 'var(--color-text-disabled, #9da4ae)' export const colorTextInfo = 'var(--color-text-info, #0aaddf)' export const colorTextSecondary = 'var(--color-text-secondary, #656d7b)' export const colorTextSuccess = 'var(--color-text-success, #13787b)' -export const colorTextTertiary = 'var(--color-text-tertiary, #9da4ae)' +export const colorTextTertiary = 'var(--color-text-tertiary, #656d7b)' export const colorTextWarning = 'var(--color-text-warning, #9f5208)' // Chart diff --git a/frontend/documentation/TokenReference.generated.stories.tsx b/frontend/documentation/TokenReference.generated.stories.tsx index f0ecbc20aafb..727391c331fc 100644 --- a/frontend/documentation/TokenReference.generated.stories.tsx +++ b/frontend/documentation/TokenReference.generated.stories.tsx @@ -189,7 +189,7 @@ export const AllTokens: StoryObj = { --color-text-tertiary - var(--slate-300) + var(--slate-500) diff --git a/frontend/web/styles/3rdParty/_react-select.scss b/frontend/web/styles/3rdParty/_react-select.scss index 32be0f3ebe86..46bd41ca85cb 100644 --- a/frontend/web/styles/3rdParty/_react-select.scss +++ b/frontend/web/styles/3rdParty/_react-select.scss @@ -48,7 +48,7 @@ overflow: hidden; text-overflow: ellipsis; font-weight: normal; - color: $input-placeholder-color; + color: var(--color-text-tertiary); } &__indicator { color: $text-icon-light-grey; diff --git a/frontend/web/styles/_tokens.scss b/frontend/web/styles/_tokens.scss index 9fa39a8b7ff3..ce37f7bf5dfa 100644 --- a/frontend/web/styles/_tokens.scss +++ b/frontend/web/styles/_tokens.scss @@ -112,7 +112,7 @@ --color-text-info: var(--blue-500); --color-text-secondary: var(--slate-500); --color-text-success: var(--green-600); - --color-text-tertiary: var(--slate-300); + --color-text-tertiary: var(--slate-500); --color-text-warning: var(--orange-800); // Code diff --git a/frontend/web/styles/_variables.scss b/frontend/web/styles/_variables.scss index 46efbd7ef2da..975490656f04 100644 --- a/frontend/web/styles/_variables.scss +++ b/frontend/web/styles/_variables.scss @@ -246,7 +246,6 @@ $input-border-color: $basic-alpha-16; $input-border-color-dark: $bg-dark400; $input-focus-border-color-dark: $primary; $input-hover-border-color-dark: $white-alpha-8; -$input-placeholder-color: $text-icon-light-grey; $input-padding: 12px 12px 12px 16px; $input-padding-sm: 8px 12px 8px 14px; $input-padding-xsm: 6px 8px; @@ -256,7 +255,6 @@ $textarea-height-sm: 100px; $textarea-height-xsm: 86px; $textarea-height-lg: 128px; $input-border-highlight-color: $primary400; -$input-placeholder-color-dark: $text-icon-light-grey; //Switch $switch-height: 24px; From 237d79fd53cee4d5ac4bfdfbe86c4ca3636c501e Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Wed, 2 Sep 2026 15:28:03 -0300 Subject: [PATCH 2/3] feat(value-editor): move copy inside the input Copy was the last item of the .txt/.json/.xml/.toml/.yaml row that floats above the editor, which conflated picking a format with copying the value. It now sits inside the input, right aligned, per the design. While moving it, make it a real control: BareButton with an aria-label instead of a , so it is focusable and reachable by keyboard and screen reader. Its colour comes from --color-icon-action on hover rather than a hardcoded purple. Copy stayed hidden on disabled editors and under onlyOneLang only because it lived in the row those hide. That behaviour is preserved here so the change stays visual, but it is worth revisiting: three of the eight call sites are permanently disabled read-only values, which is where copy is most useful. Drop the ConfigProvider wrapper. ValueEditor reads none of isLoading, error, getValue or hasFeature, and on the E2E path they were spread onto the textarea as unknown DOM attributes. It also crashed the component outside app boot, which is what the new story surfaced. Swap the validation icons from ionicons to our own Icon. Storybook stubs IonIcon as a grey circle, so the new story was showing a placeholder where a warning triangle belongs. Both ids stay: saveFeatureWithValidation reads the error one off the DOM. Add a Storybook story covering the empty, filled, multiline, JSON, invalid JSON, code-medium, disabled and single-language states, snapshotted by Chromatic in both themes. Closes #8442 Co-Authored-By: Claude Opus 5 (1M context) --- frontend/.storybook/preview.js | 1 + .../components/ValueEditor.stories.tsx | 94 +++++++++++++++++++ frontend/web/components/ValueEditor.js | 53 ++++++----- frontend/web/styles/3rdParty/_hljs.scss | 20 +++- 4 files changed, 141 insertions(+), 27 deletions(-) create mode 100644 frontend/documentation/components/ValueEditor.stories.tsx diff --git a/frontend/.storybook/preview.js b/frontend/.storybook/preview.js index bfae1036295c..091b5b5d3612 100644 --- a/frontend/.storybook/preview.js +++ b/frontend/.storybook/preview.js @@ -61,6 +61,7 @@ window.Row = Row window.FormGroup = FormGroup // isMobile is set at app boot; stub it so components that read it render in Storybook. window.isMobile = false +window.toast = (message) => console.log('[toast]', message) /** @type { import('storybook').Preview } */ const preview = { diff --git a/frontend/documentation/components/ValueEditor.stories.tsx b/frontend/documentation/components/ValueEditor.stories.tsx new file mode 100644 index 000000000000..f58af691f279 --- /dev/null +++ b/frontend/documentation/components/ValueEditor.stories.tsx @@ -0,0 +1,94 @@ +import React, { useState } from 'react' +import type { Meta, StoryObj } from 'storybook' + +import ValueEditor from 'components/ValueEditor' +import FieldLabel from 'components/base/forms/FieldLabel' +import Constants from 'common/constants' + +const meta: Meta = { + parameters: { chromatic: { disableSnapshot: false } }, + title: 'Components/Forms/ValueEditor', +} +export default meta + +type Story = StoryObj + +const DEFAULT_TOOLTIP = Constants.strings.REMOTE_CONFIG_DESCRIPTION + +const Interactive = ({ + initialValue = '', + label, + tooltip = DEFAULT_TOOLTIP, + ...props +}: Record) => { + const [value, setValue] = useState(initialValue) + return ( +
+ {label && {label}} + +
+ ) +} + +export const Default: Story = { + render: () => , +} + +export const WithValue: Story = { + render: () => , +} + +export const Multiline: Story = { + render: () => ( + + ), +} + +export const Json: Story = { + render: () => ( + + ), +} + +export const InvalidJson: Story = { + render: () => ( + + ), +} + +export const CodeMedium: Story = { + render: () => ( + + ), +} + +export const Disabled: Story = { + render: () => ( + + ), +} + +export const OnlyOneLang: Story = { + render: () => ( + '} + /> + ), +} diff --git a/frontend/web/components/ValueEditor.js b/frontend/web/components/ValueEditor.js index 0ae271633957..038eb1656d31 100644 --- a/frontend/web/components/ValueEditor.js +++ b/frontend/web/components/ValueEditor.js @@ -1,11 +1,9 @@ import React, { Component } from 'react' import cx from 'classnames' import Highlight from './Highlight' -import ConfigProvider from 'common/providers/ConfigProvider' import { Clipboard } from 'polyfill-react-native' import Icon from './icons/Icon' -import { IonIcon } from '@ionic/react' -import { checkmarkCircle, warning } from 'ionicons/icons' +import BareButton from './base/forms/BareButton' import toml from 'toml' import yaml from 'yaml' @@ -95,23 +93,24 @@ class Validation extends Component { this.props.language === 'ini' ? 'toml' : this.props.language return this.state.error ? ( + > + +
} > {`${displayLanguage} validation error, please check your value.
Error: ${this.state.error}`} ) : ( - + > + + ) } } @@ -141,8 +140,17 @@ class ValueEditor extends Component { /> ) + copyValue = () => { + const res = Clipboard.setString(this.props.value) + toast( + res ? 'Clipboard set' : 'Could not set clipboard :(', + res ? '' : 'danger', + ) + } + render() { const { ...rest } = this.props + const showCopy = !this.props.onlyOneLang && !this.props.disabled return (
.yaml {this.state.language === 'yaml' && this.renderValidation()} - { - const res = Clipboard.setString(this.props.value) - toast( - res ? 'Clipboard set' : 'Could not set clipboard :(', - res ? '' : 'danger', - ) - }} - className={cx('txt primary')} - > - - copy - )} + {showCopy && ( + + + + )} + {E2E ? (