@mindlogic-ai/logician-ui 4.0.0-alpha.21 → 4.0.0-alpha.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/dist/components/ErrorFallback/ErrorFallback.d.ts.map +1 -1
  2. package/dist/components/ErrorFallback/ErrorFallback.js +13 -2
  3. package/dist/components/ErrorFallback/ErrorFallback.js.map +1 -1
  4. package/dist/components/ErrorFallback/ErrorFallback.mjs +13 -2
  5. package/dist/components/ErrorFallback/ErrorFallback.mjs.map +1 -1
  6. package/dist/components/FileInput/FileInput.d.ts.map +1 -1
  7. package/dist/components/FileInput/FileInput.js +7 -1
  8. package/dist/components/FileInput/FileInput.js.map +1 -1
  9. package/dist/components/FileInput/FileInput.mjs +7 -1
  10. package/dist/components/FileInput/FileInput.mjs.map +1 -1
  11. package/dist/components/Markdown/Markdown.d.ts.map +1 -1
  12. package/dist/components/Markdown/Markdown.js +6 -1
  13. package/dist/components/Markdown/Markdown.js.map +1 -1
  14. package/dist/components/Markdown/Markdown.mjs +6 -1
  15. package/dist/components/Markdown/Markdown.mjs.map +1 -1
  16. package/dist/components/Pagination/Pagination.d.ts.map +1 -1
  17. package/dist/components/Pagination/Pagination.js +6 -1
  18. package/dist/components/Pagination/Pagination.js.map +1 -1
  19. package/dist/components/Pagination/Pagination.mjs +6 -1
  20. package/dist/components/Pagination/Pagination.mjs.map +1 -1
  21. package/dist/components/Radio/RadioControl.d.ts.map +1 -1
  22. package/dist/components/Radio/RadioControl.js +14 -1
  23. package/dist/components/Radio/RadioControl.js.map +1 -1
  24. package/dist/components/Radio/RadioControl.mjs +15 -2
  25. package/dist/components/Radio/RadioControl.mjs.map +1 -1
  26. package/dist/components/Select/ComboboxField.d.ts +1 -1
  27. package/dist/components/Select/ComboboxField.d.ts.map +1 -1
  28. package/dist/components/Select/ComboboxField.js +2 -2
  29. package/dist/components/Select/ComboboxField.js.map +1 -1
  30. package/dist/components/Select/ComboboxField.mjs +2 -2
  31. package/dist/components/Select/ComboboxField.mjs.map +1 -1
  32. package/dist/components/Select/Select.types.d.ts +16 -0
  33. package/dist/components/Select/Select.types.d.ts.map +1 -1
  34. package/dist/components/Select/SelectField.d.ts +1 -1
  35. package/dist/components/Select/SelectField.d.ts.map +1 -1
  36. package/dist/components/Select/SelectField.js +2 -2
  37. package/dist/components/Select/SelectField.js.map +1 -1
  38. package/dist/components/Select/SelectField.mjs +2 -2
  39. package/dist/components/Select/SelectField.mjs.map +1 -1
  40. package/dist/components/Slider/Slider.d.ts.map +1 -1
  41. package/dist/components/Slider/Slider.js +2 -2
  42. package/dist/components/Slider/Slider.js.map +1 -1
  43. package/dist/components/Slider/Slider.mjs +3 -3
  44. package/dist/components/Slider/Slider.mjs.map +1 -1
  45. package/dist/components/Slider/Slider.types.d.ts +15 -0
  46. package/dist/components/Slider/Slider.types.d.ts.map +1 -1
  47. package/dist/components/Typography/Link.d.ts +25 -0
  48. package/dist/components/Typography/Link.d.ts.map +1 -1
  49. package/dist/components/Typography/Link.js +44 -7
  50. package/dist/components/Typography/Link.js.map +1 -1
  51. package/dist/components/Typography/Link.mjs +44 -8
  52. package/dist/components/Typography/Link.mjs.map +1 -1
  53. package/dist/components/Typography/Subtitle.d.ts +17 -0
  54. package/dist/components/Typography/Subtitle.d.ts.map +1 -1
  55. package/dist/components/Typography/Subtitle.js +18 -1
  56. package/dist/components/Typography/Subtitle.js.map +1 -1
  57. package/dist/components/Typography/Subtitle.mjs +18 -1
  58. package/dist/components/Typography/Subtitle.mjs.map +1 -1
  59. package/dist/components/Typography/index.d.ts +1 -1
  60. package/dist/components/Typography/index.d.ts.map +1 -1
  61. package/dist/components/Workflow/canvas/NodePalette/NodePalette.d.ts.map +1 -1
  62. package/dist/components/Workflow/canvas/NodePalette/NodePalette.js +19 -2
  63. package/dist/components/Workflow/canvas/NodePalette/NodePalette.js.map +1 -1
  64. package/dist/components/Workflow/canvas/NodePalette/NodePalette.mjs +19 -2
  65. package/dist/components/Workflow/canvas/NodePalette/NodePalette.mjs.map +1 -1
  66. package/dist/theme/colors.d.ts +145 -37
  67. package/dist/theme/colors.d.ts.map +1 -1
  68. package/dist/theme/colors.js +98 -68
  69. package/dist/theme/colors.js.map +1 -1
  70. package/dist/theme/colors.mjs +98 -68
  71. package/dist/theme/colors.mjs.map +1 -1
  72. package/package.json +2 -1
  73. package/src/components/ErrorFallback/ErrorFallback.tsx +30 -3
  74. package/src/components/FileInput/FileInput.tsx +11 -2
  75. package/src/components/Markdown/Markdown.tsx +6 -1
  76. package/src/components/Pagination/Pagination.tsx +5 -0
  77. package/src/components/Radio/RadioControl.tsx +20 -6
  78. package/src/components/Select/ComboboxField.tsx +11 -1
  79. package/src/components/Select/Select.types.ts +16 -0
  80. package/src/components/Select/SelectField.tsx +14 -1
  81. package/src/components/Slider/Slider.tsx +16 -1
  82. package/src/components/Slider/Slider.types.ts +15 -0
  83. package/src/components/Typography/Link.tsx +44 -7
  84. package/src/components/Typography/Subtitle.tsx +17 -1
  85. package/src/components/Typography/index.ts +1 -1
  86. package/src/components/Workflow/canvas/NodePalette/NodePalette.tsx +12 -2
  87. package/src/components/a11y.kwcag.test.tsx +412 -0
  88. package/src/theme/colors.ts +99 -69
@@ -15,7 +15,14 @@ import { SliderControl } from './SliderControl';
15
15
  */
16
16
  export const Slider = forwardRef(
17
17
  (
18
- { value, defaultValue, onValueChange, children, ...rest }: SliderProps,
18
+ {
19
+ value,
20
+ defaultValue,
21
+ onValueChange,
22
+ ariaLabel,
23
+ children,
24
+ ...rest
25
+ }: SliderProps,
19
26
  ref?: ForwardedRef<HTMLDivElement>
20
27
  ) => {
21
28
  // Check if children already contains SliderControl
@@ -42,6 +49,14 @@ export const Slider = forwardRef(
42
49
  onValueChange={onValueChange}
43
50
  {...rest}
44
51
  >
52
+ {/* The label part is what each thumb's `aria-labelledby` points at, and
53
+ the reference is emitted whether or not the part is rendered — so a
54
+ slider composed without one is not merely unlabelled, it is unnamed
55
+ (KWCAG 5.3.4.1 레이블 제공). Hidden, because these call sites carry
56
+ their label as an adjacent icon or time display. */}
57
+ {ariaLabel && (
58
+ <ChakraSlider.Label srOnly>{ariaLabel}</ChakraSlider.Label>
59
+ )}
45
60
  {wrappedChildren}
46
61
  </ChakraSlider.Root>
47
62
  );
@@ -11,5 +11,20 @@ export interface SliderProps extends Omit<
11
11
  defaultValue?: number[];
12
12
  /** Callback when slider value changes */
13
13
  onValueChange?: Slider.RootProps['onValueChange'];
14
+ /**
15
+ * Accessible name for the slider, when no visible `Slider.Label` is composed
16
+ * in — a seek bar next to a time display, a volume control beside a mute
17
+ * button.
18
+ *
19
+ * Without it the thumb has **no accessible name**: Zag points each thumb's
20
+ * `aria-labelledby` at a label part, and a composition that does not render
21
+ * one leaves that reference dangling — the control announces as a bare
22
+ * "slider" (KWCAG 2.1 5.3.4.1 레이블 제공, axe `aria-input-field-name`).
23
+ *
24
+ * Rendered as a visually hidden label rather than an `aria-label` array so it
25
+ * works the same whether the slider has one thumb or several, and so the name
26
+ * is in the server-rendered markup.
27
+ */
28
+ ariaLabel?: string;
14
29
  children?: ReactNode;
15
30
  }
@@ -5,17 +5,54 @@ export interface LinkCustomProps extends Omit<LinkProps, 'variant'> {
5
5
  variant?: 'error';
6
6
  }
7
7
 
8
+ /**
9
+ * The ramp steps the link paints, exported so a test can assert BOTH halves at
10
+ * once: that the component still picks these tokens, and that these tokens
11
+ * still clear the contrast bar. Asserting only the second half is a test that
12
+ * passes while the component points somewhere else entirely — which is how the
13
+ * dark hover regressed with a green suite.
14
+ */
15
+ export const LINK_RAMP = {
16
+ default: { base: 'primary.main', _dark: 'primary.dark' },
17
+ error: { base: 'danger.main', _dark: 'danger.dark' },
18
+ defaultHover: { base: 'primary.dark', _dark: 'primary.darker' },
19
+ errorHover: { base: 'danger.dark', _dark: 'danger.darker' },
20
+ } as const;
21
+
8
22
  export const Link = forwardRef<HTMLAnchorElement, LinkCustomProps>(
9
23
  ({ color, variant, ...rest }, ref) => {
10
- const defaultColor = 'primary.main';
11
- const errorColor = 'danger.main';
12
- const defaultHoverColor = 'primary.dark';
13
- const errorHoverColor = 'danger.dark';
24
+ // Theme-aware, because `.main` is tuned against a WHITE page. On the dark
25
+ // canvas (#181A20) `primary.main` measures 4.19:1 and `danger.main` 4.26:1
26
+ // — both under the 4.5:1 that KWCAG 2.1 5.1.3.3 (명도 대비) asks of body
27
+ // text, and every link in a consuming app inherits it. The `.dark` end of
28
+ // each ramp is the far side from the PAGE rather than a fixed lightness, so
29
+ // in dark mode it resolves lighter: 6.68:1 and 6.38:1.
30
+ const defaultColor = LINK_RAMP.default;
31
+ const errorColor = LINK_RAMP.error;
32
+ // The hover has to move AWAY from the page in BOTH modes, and `.main` is
33
+ // the wrong end of the ramp to move toward in dark. Mirroring the light
34
+ // step ("one darker") instead of reflecting it sent the dark hover back
35
+ // toward the background: resting 6.68:1 → hovering **4.19:1**, under the
36
+ // same 4.5:1 the resting colour above was just fixed to clear. KWCAG
37
+ // 5.3.3 applies to text in every state a user can put it in, and hover is
38
+ // a state a mouse user is IN while reading the link.
39
+ //
40
+ // `.darker` is the step past `.dark` at both ends, so one token is correct
41
+ // in both modes with no special-casing: light 11.98:1 (unchanged from
42
+ // before), dark 10.68:1. Error the same: 10.83:1 and 10.34:1.
43
+ //
44
+ // No axe rule would have caught this — it measures the resting state only.
45
+ const defaultHoverColor = LINK_RAMP.defaultHover;
46
+ const errorHoverColor = LINK_RAMP.errorHover;
14
47
 
48
+ // `color ?? defaultColor`, not a `typeof color === 'string'` narrowing. The
49
+ // old form silently DISCARDED any non-string value, so a call site passing
50
+ // a per-theme `{ base, _dark }` — the documented Chakra way to fix exactly
51
+ // the contrast problem above — got the default back and no error, in the
52
+ // types or at runtime. A prop the type accepts and the component throws
53
+ // away is worse than one it rejects.
15
54
  const linkColor =
16
- variant === 'error'
17
- ? errorColor
18
- : (typeof color === 'string' ? color : undefined) || defaultColor;
55
+ variant === 'error' ? errorColor : (color ?? defaultColor);
19
56
 
20
57
  const hoverColor =
21
58
  variant === 'error' ? errorHoverColor : defaultHoverColor;
@@ -1,6 +1,23 @@
1
1
  import { forwardRef } from 'react';
2
2
  import { Text, TextProps } from '@chakra-ui/react';
3
3
 
4
+ /**
5
+ * Supporting text under a heading — a subtitle by role in the page, not by
6
+ * document structure.
7
+ *
8
+ * Renders a paragraph. It used to render `<h6>`, which made every call site a
9
+ * level-6 heading in the document outline: a sentence of helper copy under an
10
+ * `<h1>` announced as a heading to a screen reader, and a jump from level 1 to
11
+ * level 6 in the heading list (KWCAG 2.1 5.2.4.2 제목 제공 / axe `heading-order`).
12
+ * A component chosen for its type scale should not decide the outline.
13
+ *
14
+ * A subtitle that genuinely IS the next heading level still says so at the call
15
+ * site, which is where that is known:
16
+ *
17
+ * ```tsx
18
+ * <Subtitle as="h2">{sectionTitle}</Subtitle>
19
+ * ```
20
+ */
4
21
  export const Subtitle = forwardRef<HTMLParagraphElement, TextProps>(
5
22
  (props, ref) => {
6
23
  const { fontSize, ...rest } = props;
@@ -8,7 +25,6 @@ export const Subtitle = forwardRef<HTMLParagraphElement, TextProps>(
8
25
  return (
9
26
  <Text
10
27
  ref={ref}
11
- as="h6"
12
28
  textStyle={fontSize ? undefined : 'subtitle'}
13
29
  fontSize={fontSize}
14
30
  color="fg.muted"
@@ -5,7 +5,7 @@ export { H2 } from './H2';
5
5
  export { H3 } from './H3';
6
6
  export { H4 } from './H4';
7
7
  export { H5 } from './H5';
8
- export { Link } from './Link';
8
+ export { Link, LINK_RAMP } from './Link';
9
9
  export { Overline } from './Overline';
10
10
  export { Subtext } from './Subtext';
11
11
  export { Subtitle } from './Subtitle';
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { DragEvent } from 'react';
3
+ import { DragEvent, useId } from 'react';
4
4
  import { Box, HStack, Stack } from '@chakra-ui/react';
5
5
 
6
6
  import { Card } from '@/components/Card';
@@ -95,6 +95,8 @@ function PaletteItem({ def }: { def: NodeTypeDef }) {
95
95
  export function NodePalette({ onClose }: { onClose: () => void }) {
96
96
  const { nodeTypes, readOnly } = useWorkflow();
97
97
  const translate = useWorkflowTranslate();
98
+ // Names the region below with the same words the header shows.
99
+ const titleId = useId();
98
100
  if (readOnly) return null;
99
101
 
100
102
  // Group by category, preserving registration order within each group.
@@ -115,6 +117,14 @@ export function NodePalette({ onClose }: { onClose: () => void }) {
115
117
 
116
118
  return (
117
119
  <FloatingCard
120
+ // A named region, so the palette is something a screen-reader user can
121
+ // navigate TO. Its title used to be a `Subtitle`, which rendered `<h6>`
122
+ // and put a level-6 heading under the host page's `<h1>` (axe
123
+ // `heading-order`); `Subtitle` is a paragraph now, and a labelled region
124
+ // is what this panel actually is — a landmark, not an outline entry
125
+ // (KWCAG 2.1 5.2.4.1 반복 영역 건너뛰기 / 5.2.4.2 제목 제공).
126
+ role="region"
127
+ aria-labelledby={titleId}
118
128
  width={PALETTE_WIDTH}
119
129
  mt={4}
120
130
  mb={4}
@@ -140,7 +150,7 @@ export function NodePalette({ onClose }: { onClose: () => void }) {
140
150
  borderBottom="1px solid"
141
151
  borderColor="slate.200"
142
152
  >
143
- <Subtitle color="slate.1300">
153
+ <Subtitle id={titleId} color="slate.1300">
144
154
  {translate('workflow_palette_title')}
145
155
  </Subtitle>
146
156
  <IconButton
@@ -0,0 +1,412 @@
1
+ import { render } from '@testing-library/react';
2
+ import axe from 'axe-core';
3
+ import { beforeAll, describe, expect, it } from 'vitest';
4
+
5
+ import { FileInput } from '@/components/FileInput';
6
+ import { LogicianProvider } from '@/components/LogicianProvider';
7
+ import { baseMarkdownComponents } from '@/components/Markdown/Markdown';
8
+ import { Radio, RadioGroup } from '@/components/Radio';
9
+ import { SelectField } from '@/components/Select';
10
+ import {
11
+ Slider,
12
+ SliderFilledTrack,
13
+ SliderThumb,
14
+ SliderTrack,
15
+ } from '@/components/Slider';
16
+ import { Link, LINK_RAMP, Subtitle } from '@/components/Typography';
17
+ import { colors } from '@/theme/colors';
18
+
19
+ /**
20
+ * Regressions for the accessibility defects a KWCAG 2.1 (한국형 웹 콘텐츠 접근성
21
+ * 지침) audit found in these components.
22
+ *
23
+ * Each one shipped for a while, and each was invisible in review: the screen
24
+ * looked right, and only the accessibility tree or the markup said otherwise.
25
+ * That is exactly the kind of defect a test has to hold, because the next person
26
+ * to touch the component will not see it either.
27
+ *
28
+ * The Korean checkpoint ids are here on purpose — a 웹 접근성 품질인증 evaluator
29
+ * works from those numbers, and a failing test should say which one it is about.
30
+ */
31
+
32
+ beforeAll(() => {
33
+ // Zag's slider observes its thumb to size the track. jsdom ships no
34
+ // `ResizeObserver`, so without this the observe call throws ASYNCHRONOUSLY,
35
+ // after the test that triggered it has already passed — which vitest reports
36
+ // as an unhandled error and exits non-zero on. A green test list and a red
37
+ // run is the worst way to find that out, so it is stubbed here rather than
38
+ // left to whoever next reads the CI log.
39
+ window.ResizeObserver = class {
40
+ observe() {}
41
+ unobserve() {}
42
+ disconnect() {}
43
+ } as unknown as typeof window.ResizeObserver;
44
+
45
+ // Chakra's color-mode and responsive machinery calls it on mount; jsdom has no
46
+ // implementation.
47
+ window.matchMedia = ((query: string) => ({
48
+ matches: false,
49
+ media: query,
50
+ onchange: null,
51
+ addListener: () => {},
52
+ removeListener: () => {},
53
+ addEventListener: () => {},
54
+ removeEventListener: () => {},
55
+ dispatchEvent: () => false,
56
+ })) as unknown as typeof window.matchMedia;
57
+ });
58
+
59
+ const withProvider = (ui: React.ReactNode) =>
60
+ render(<LogicianProvider>{ui}</LogicianProvider>);
61
+
62
+ /** The text a screen reader would announce for `element`, via `aria-labelledby`. */
63
+ const nameFromLabelledBy = (element: Element | null): string =>
64
+ (element?.getAttribute('aria-labelledby') ?? '')
65
+ .split(/\s+/)
66
+ .filter(Boolean)
67
+ .map((id) => document.getElementById(id)?.textContent ?? '')
68
+ .join(' ')
69
+ .trim();
70
+
71
+ describe('KWCAG 5.4.1.1 마크업 오류 방지', () => {
72
+ it('라디오 하나가 같은 id 를 두 번 내보내지 않는다', () => {
73
+ const { container } = withProvider(
74
+ <RadioGroup defaultValue="a">
75
+ <Radio value="a">
76
+ <Radio.Control />
77
+ <Radio.Text>A</Radio.Text>
78
+ </Radio>
79
+ <Radio value="b">
80
+ <Radio.Control />
81
+ <Radio.Text>B</Radio.Text>
82
+ </Radio>
83
+ </RadioGroup>
84
+ );
85
+
86
+ const ids = [...container.querySelectorAll('[id]')].map((el) => el.id);
87
+ const duplicates = ids.filter((id, i) => ids.indexOf(id) !== i);
88
+
89
+ // The control and the indicator inside it used to share Chakra's generated
90
+ // `ids.itemControl`, so every radio on a page carried a duplicate.
91
+ expect(duplicates).toEqual([]);
92
+ });
93
+ });
94
+
95
+ describe('KWCAG 5.3.4.1 레이블 제공', () => {
96
+ it('보이는 레이블이 없어도 셀렉트에 이름이 있다', () => {
97
+ const { container } = withProvider(
98
+ <SelectField
99
+ options={[{ label: 'One', value: '1' }]}
100
+ ariaLabel="분류 선택"
101
+ />
102
+ );
103
+
104
+ // The trigger's `aria-labelledby` is emitted whether or not the label part
105
+ // is rendered; without the part it pointed at nothing and the control
106
+ // announced as a bare "button".
107
+ expect(
108
+ nameFromLabelledBy(container.querySelector('[data-part="trigger"]'))
109
+ ).toBe('분류 선택');
110
+ });
111
+
112
+ it('보이는 레이블이 없어도 슬라이더에 이름이 있다', () => {
113
+ const { container } = withProvider(
114
+ <Slider ariaLabel="탐색" value={[10]} min={0} max={100}>
115
+ <SliderTrack>
116
+ <SliderFilledTrack />
117
+ </SliderTrack>
118
+ <SliderThumb />
119
+ </Slider>
120
+ );
121
+
122
+ expect(
123
+ nameFromLabelledBy(container.querySelector('[data-part="thumb"]'))
124
+ ).toBe('탐색');
125
+ });
126
+ });
127
+
128
+ describe('KWCAG 5.2.4.2 제목 제공', () => {
129
+ it('Subtitle 은 제목이 아니라 문단이다', () => {
130
+ const { container } = withProvider(<Subtitle>보조 설명</Subtitle>);
131
+
132
+ // It rendered `<h6>`, which put every call site into the document outline —
133
+ // a paragraph of helper copy announced as a level-6 heading, and a jump from
134
+ // whatever level came before it.
135
+ expect(container.querySelector('h6')).toBeNull();
136
+ expect(container.querySelector('p')?.textContent).toBe('보조 설명');
137
+ });
138
+
139
+ it('제목이 필요하면 호출부가 말할 수 있다', () => {
140
+ const { container } = withProvider(<Subtitle as="h2">절 제목</Subtitle>);
141
+
142
+ expect(container.querySelector('h2')?.textContent).toBe('절 제목');
143
+ });
144
+
145
+ it('마크다운의 ###### 은 여전히 제목이다', () => {
146
+ // `Subtitle` 의 기본 태그를 문단으로 바꾸면서 같이 끌려간 자리. 마크다운
147
+ // 매퍼의 `h6` 는 **글쓴이가 실제로 쓴 제목**이므로, 여기까지 문단이 되면
148
+ // 고치려던 결함이 방향만 바꿔 다시 생긴다 — 제목 목록에서 사라지는 쪽으로.
149
+ const H6 = baseMarkdownComponents.h6 as React.ComponentType<{
150
+ children: React.ReactNode;
151
+ }>;
152
+ const { container } = withProvider(<H6>여섯 번째 제목</H6>);
153
+
154
+ expect(container.querySelector('h6')?.textContent).toBe('여섯 번째 제목');
155
+ });
156
+ });
157
+
158
+ describe('KWCAG 5.4.2.1 웹 애플리케이션 접근성 준수', () => {
159
+ it('파일 입력이 버튼 안에 컨트롤을 넣지 않는다', () => {
160
+ const { container } = withProvider(<FileInput onChange={() => {}} />);
161
+
162
+ // `role="button"` on the wrapper made the real `<input>` an interactive
163
+ // element inside another (axe `nested-interactive`), and `role="group"` on a
164
+ // `<label>` is not an allowed pairing at all (`aria-allowed-role`).
165
+ expect(container.querySelector('[role="button"]')).toBeNull();
166
+ expect(container.querySelector('label[role]')).toBeNull();
167
+ });
168
+
169
+ it('호버 시 드러나는 오버레이는 group 클래스로 동작한다', () => {
170
+ const { container } = withProvider(<FileInput onChange={() => {}} />);
171
+
172
+ // `_groupHover` compiles to `.group:hover &`, so the CLASS is what makes it
173
+ // fire — the old `role="group"` did nothing for hover or for assistive tech.
174
+ expect(container.querySelector('label')?.className).toContain('group');
175
+ });
176
+ });
177
+
178
+ /**
179
+ * An axe pass over the components above, so the NEXT defect of this shape does
180
+ * not need an audit downstream to find it.
181
+ *
182
+ * Everything in this file until now is a regression test: each one names a
183
+ * defect that already shipped. This runs the same engine the certification
184
+ * audit runs, in the design system, on the compositions most likely to be
185
+ * wrong — so a component that acquires a nameless control, a role that is not
186
+ * allowed on its element, or an interactive element inside another fails here
187
+ * rather than in an app that consumes it.
188
+ *
189
+ * **jsdom has no layout**, so this is deliberately not the whole rule set:
190
+ * `color-contrast` needs painted pixels and reports nothing here, and rules
191
+ * about visibility or size have the same problem. What jsdom does carry
192
+ * perfectly is structure and ARIA — which is precisely where all nine defects
193
+ * in this branch lived. The contrast half belongs to a browser, and FactChat's
194
+ * `storybook-a11y.yml` runs it there in both colour modes.
195
+ */
196
+ const STRUCTURAL_AXE_RULES = [
197
+ 'aria-allowed-attr',
198
+ 'aria-allowed-role',
199
+ 'aria-required-attr',
200
+ 'aria-required-children',
201
+ 'aria-required-parent',
202
+ 'aria-valid-attr-value',
203
+ 'button-name',
204
+ 'duplicate-id',
205
+ 'label',
206
+ 'link-name',
207
+ 'nested-interactive',
208
+ 'aria-input-field-name',
209
+ 'aria-toggle-field-name',
210
+ ];
211
+
212
+ /** Runs axe over a container and returns one line per violating node. */
213
+ const axeViolations = async (container: HTMLElement): Promise<string[]> => {
214
+ const results = await axe.run(container, {
215
+ runOnly: { type: 'rule', values: STRUCTURAL_AXE_RULES },
216
+ // The default reporter walks the whole document for a summary this does
217
+ // not use, and jsdom makes that slow for no gain.
218
+ resultTypes: ['violations'],
219
+ });
220
+ return results.violations.flatMap((v) =>
221
+ v.nodes.map((n) => `${v.id}: ${n.html.replace(/\s+/g, ' ').slice(0, 120)}`)
222
+ );
223
+ };
224
+
225
+ describe('KWCAG 5.1.3.3 텍스트 콘텐츠의 명도 대비', () => {
226
+ it('링크 색은 테마에 따라 갈린다', () => {
227
+ // `.main` is tuned against a white page: on the dark canvas `primary.main`
228
+ // is 4.19:1, under the 4.5:1 body text needs. Every link in a consuming app
229
+ // inherits this default, so it is the single highest-reach instance of that
230
+ // defect in the package.
231
+ const { container } = withProvider(<Link href="#">링크</Link>);
232
+ const cls = container.querySelector('a')?.className ?? '';
233
+
234
+ // Emotion compiles the conditional into a class; a flat string value would
235
+ // not produce a `_dark` variant at all.
236
+ expect(cls).not.toBe('');
237
+ });
238
+
239
+ it('호출부가 준 색을 버리지 않는다', () => {
240
+ // The old implementation narrowed with `typeof color === 'string'` and
241
+ // dropped anything else — so a per-theme `{ base, _dark }`, which is the
242
+ // documented way to fix a contrast problem, was silently replaced by the
243
+ // default. Accepted by the type, discarded at runtime, no error either way.
244
+ const custom = { base: 'danger.main', _dark: 'danger.dark' };
245
+ const { container } = withProvider(
246
+ <Link href="#" color={custom}>
247
+ 링크
248
+ </Link>
249
+ );
250
+ const withCustom = container.querySelector('a')?.className ?? '';
251
+
252
+ const { container: plain } = withProvider(<Link href="#">링크</Link>);
253
+ const withDefault = plain.querySelector('a')?.className ?? '';
254
+
255
+ expect(withCustom).not.toBe(withDefault);
256
+ });
257
+ });
258
+
259
+ describe('axe — 구조·ARIA 규칙', () => {
260
+ it.each([
261
+ [
262
+ '이름이 있는 셀렉트',
263
+ <SelectField
264
+ key="select"
265
+ options={[{ label: 'One', value: '1' }]}
266
+ ariaLabel="분류 선택"
267
+ />,
268
+ ],
269
+ [
270
+ '이름이 있는 슬라이더',
271
+ <Slider key="slider" ariaLabel="탐색" value={[10]} min={0} max={100}>
272
+ <SliderTrack>
273
+ <SliderFilledTrack />
274
+ </SliderTrack>
275
+ <SliderThumb />
276
+ </Slider>,
277
+ ],
278
+ [
279
+ '라디오 그룹',
280
+ <RadioGroup key="radio" defaultValue="a">
281
+ <Radio value="a">
282
+ <Radio.Control />
283
+ <Radio.Text>A</Radio.Text>
284
+ </Radio>
285
+ <Radio value="b">
286
+ <Radio.Control />
287
+ <Radio.Text>B</Radio.Text>
288
+ </Radio>
289
+ </RadioGroup>,
290
+ ],
291
+ ['파일 입력', <FileInput key="file" onChange={() => {}} />],
292
+ ])('%s', async (_name, ui) => {
293
+ const { container } = withProvider(ui);
294
+ expect(await axeViolations(container)).toEqual([]);
295
+ });
296
+ });
297
+
298
+ /**
299
+ * The link ramp's HOVER states, measured rather than rendered.
300
+ *
301
+ * The resting colour has a test above; the hover did not, and that is exactly
302
+ * where the defect got in. `defaultHoverColor` was written as "one step darker"
303
+ * — correct against a white page, and backwards on a dark one, where darker
304
+ * means *toward* the background. The dark hover landed on 4.19:1 while the
305
+ * resting colour it replaced had just been fixed to clear 4.5:1.
306
+ *
307
+ * No scanner catches this: axe measures the resting state only, and jsdom
308
+ * cannot resolve an emotion conditional into a colour. So this asserts the
309
+ * palette arithmetic directly, against the raw scale — which also holds if
310
+ * someone re-pegs `blue.100` or `rose.100` later for an unrelated reason.
311
+ *
312
+ * KWCAG 2.2 5.3.3 텍스트 콘텐츠의 명도 대비 applies to text in every state a
313
+ * user can put it in, and hover is a state a mouse user is IN while reading.
314
+ */
315
+ describe('Link 색 대비 — 링크 램프의 hover 상태', () => {
316
+ const relativeLuminance = (hex: string) => {
317
+ const n = hex.replace('#', '');
318
+ const channels = [0, 2, 4].map(
319
+ (i) => parseInt(n.slice(i, i + 2), 16) / 255
320
+ );
321
+ const [r, g, b] = channels.map((c) =>
322
+ c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
323
+ );
324
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
325
+ };
326
+ const contrast = (a: string, b: string) => {
327
+ const [hi, lo] = [relativeLuminance(a), relativeLuminance(b)].sort(
328
+ (x, y) => y - x
329
+ );
330
+ return (hi + 0.05) / (lo + 0.05);
331
+ };
332
+
333
+ /** The canvas each mode's text is read against (`bg.surface`). */
334
+ const LIGHT_CANVAS = '#FFFFFF';
335
+ const DARK_CANVAS = '#181A20';
336
+ const AA_BODY_TEXT = 4.5;
337
+
338
+ /**
339
+ * Semantic token → raw hex, per mode. Only the steps this ramp uses; kept
340
+ * literal so the test breaks loudly if the semantic layer is re-pointed
341
+ * rather than silently measuring the wrong colour.
342
+ */
343
+ const RESOLVED: Record<string, { base: string; _dark: string }> = {
344
+ 'primary.main': {
345
+ base: colors.blue[500].value,
346
+ _dark: colors.blue[300].value,
347
+ },
348
+ 'primary.dark': {
349
+ base: colors.blue[700].value,
350
+ _dark: colors.blue[200].value,
351
+ },
352
+ 'primary.darker': {
353
+ base: colors.blue[900].value,
354
+ _dark: colors.blue[100].value,
355
+ },
356
+ 'danger.main': {
357
+ base: colors.rose[500].value,
358
+ _dark: colors.rose[300].value,
359
+ },
360
+ 'danger.dark': {
361
+ base: colors.rose[700].value,
362
+ _dark: colors.rose[200].value,
363
+ },
364
+ 'danger.darker': {
365
+ base: colors.rose[900].value,
366
+ _dark: colors.rose[100].value,
367
+ },
368
+ };
369
+
370
+ const resolve = (token: string, mode: 'base' | '_dark') => {
371
+ const entry = RESOLVED[token];
372
+ if (!entry) throw new Error(`토큰 ${token} 의 원색이 이 표에 없다`);
373
+ return entry[mode];
374
+ };
375
+
376
+ it.each([
377
+ ['기본 링크', LINK_RAMP.default],
378
+ ['기본 링크 hover', LINK_RAMP.defaultHover],
379
+ ['오류 링크', LINK_RAMP.error],
380
+ ['오류 링크 hover', LINK_RAMP.errorHover],
381
+ ])('%s 가 라이트·다크 양쪽에서 4.5:1 을 넘는다', (_what, ramp) => {
382
+ // Reads the tokens the COMPONENT actually uses. A version of this test that
383
+ // hardcoded the expected steps passed just fine with the hover pointing at
384
+ // `primary.main` — measuring the right arithmetic about the wrong colour.
385
+ expect(
386
+ contrast(resolve(ramp.base, 'base'), LIGHT_CANVAS)
387
+ ).toBeGreaterThanOrEqual(AA_BODY_TEXT);
388
+ expect(
389
+ contrast(resolve(ramp._dark, '_dark'), DARK_CANVAS)
390
+ ).toBeGreaterThanOrEqual(AA_BODY_TEXT);
391
+ });
392
+
393
+ it('hover 가 배경 반대쪽으로 간다 — 양쪽 모드에서', () => {
394
+ // The invariant the defect broke: hovering must make the link stand out
395
+ // MORE. "One step darker" is correct on a white page and backwards on a
396
+ // dark one, where darker means toward the background.
397
+ for (const [mode, canvas] of [
398
+ ['base', LIGHT_CANVAS],
399
+ ['_dark', DARK_CANVAS],
400
+ ] as const) {
401
+ const resting = contrast(resolve(LINK_RAMP.default[mode], mode), canvas);
402
+ const hover = contrast(
403
+ resolve(LINK_RAMP.defaultHover[mode], mode),
404
+ canvas
405
+ );
406
+ expect(
407
+ hover,
408
+ `${mode} 에서 hover 가 resting 보다 흐리다`
409
+ ).toBeGreaterThan(resting);
410
+ }
411
+ });
412
+ });