From 8b3d7d9959d0c01c63aa2da1cad5a7756ea31985 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 03:21:51 +0900 Subject: [PATCH 01/21] test(reliability): expose unbounded accessibility metadata --- ...itorAccessibility.resourceBoundary.test.ts | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 src/components/editorAccessibility.resourceBoundary.test.ts diff --git a/src/components/editorAccessibility.resourceBoundary.test.ts b/src/components/editorAccessibility.resourceBoundary.test.ts new file mode 100644 index 00000000..28e96784 --- /dev/null +++ b/src/components/editorAccessibility.resourceBoundary.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { + buildEditorAccessibilityAttributes, + type EditorAccessibilityOptions, +} from './editorAccessibility.js'; + +const ACCESSIBILITY_METADATA_MAX_CODE_UNITS = 65_536; +const INVALID_ACCESSIBILITY_METADATA_MESSAGE = + 'Accessibility metadata must be a string within the supported length.'; + +function attributesWithAriaLabel(value: unknown): Record { + return buildEditorAccessibilityAttributes({ + defaultLabel: 'Editor', + editable: true, + ariaLabel: value as EditorAccessibilityOptions['ariaLabel'], + }); +} + +describe('editor accessibility metadata resource boundary', () => { + it('rejects non-string runtime metadata through one stable error contract', () => { + expect(() => attributesWithAriaLabel(42)).toThrowError( + new RangeError(INVALID_ACCESSIBILITY_METADATA_MESSAGE), + ); + }); + + it('rejects oversized metadata without reflecting its payload', () => { + const privateMarker = 'private-accessibility-marker'; + const value = `${privateMarker}${'x'.repeat(ACCESSIBILITY_METADATA_MAX_CODE_UNITS)}`; + let failure: unknown; + + try { + attributesWithAriaLabel(value); + } catch (error) { + failure = error; + } + + expect(failure).toEqual( + new RangeError(INVALID_ACCESSIBILITY_METADATA_MESSAGE), + ); + expect(String(failure)).not.toContain(privateMarker); + }); + + it('accepts metadata exactly at the local ceiling', () => { + const value = 'x'.repeat(ACCESSIBILITY_METADATA_MAX_CODE_UNITS); + + expect(attributesWithAriaLabel(value)['aria-label']).toBe(value); + }); + + it('keeps blank optional metadata omitted after bounded normalization', () => { + expect( + buildEditorAccessibilityAttributes({ + defaultLabel: 'Editor', + editable: true, + placeholder: ' ', + languageTag: ' ', + ariaLabelledBy: ' ', + ariaDescribedBy: ' ', + ariaErrorMessage: ' ', + }), + ).toEqual({ + class: 'cwl-editor__content', + role: 'textbox', + 'aria-multiline': 'true', + 'aria-readonly': 'false', + 'aria-label': 'Editor', + }); + }); +}); From 3b44239180a11f7060e20cd363f722f1e0091650 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 04:01:43 +0900 Subject: [PATCH 02/21] fix(reliability): bound accessibility metadata normalization --- src/components/editorAccessibility.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/components/editorAccessibility.ts b/src/components/editorAccessibility.ts index f2cc1779..d10aff4f 100644 --- a/src/components/editorAccessibility.ts +++ b/src/components/editorAccessibility.ts @@ -1,5 +1,9 @@ import type { EditorTextDirection } from '../types.js'; +const ACCESSIBILITY_METADATA_MAX_CODE_UNITS = 65_536; +const INVALID_ACCESSIBILITY_METADATA_MESSAGE = + 'Accessibility metadata must be a string within the supported length.'; + /** Values accepted by the WAI-ARIA `aria-invalid` state on a textbox. */ export type EditorAriaInvalid = boolean | 'grammar' | 'spelling'; @@ -29,11 +33,19 @@ export interface EditorAccessibilityOptions { editable: boolean; } -/** Normalize an optional host-supplied accessibility string. */ +/** Normalize optional host metadata after enforcing Inkspan's local size boundary. */ function normalizedAccessibilityValue( value: string | undefined, ): string | undefined { - const normalized = value?.trim(); + if (value === undefined) return undefined; + if ( + typeof value !== 'string' || + value.length > ACCESSIBILITY_METADATA_MAX_CODE_UNITS + ) { + throw new RangeError(INVALID_ACCESSIBILITY_METADATA_MESSAGE); + } + + const normalized = value.trim(); return normalized ? normalized : undefined; } From 75d0008c52a14c7d340f453e761616d4b9b1c772 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 05:29:55 +0900 Subject: [PATCH 03/21] test(accessibility): reject malformed editor language tags --- ...itorAccessibility.resourceBoundary.test.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/components/editorAccessibility.resourceBoundary.test.ts b/src/components/editorAccessibility.resourceBoundary.test.ts index 28e96784..786fe766 100644 --- a/src/components/editorAccessibility.resourceBoundary.test.ts +++ b/src/components/editorAccessibility.resourceBoundary.test.ts @@ -7,6 +7,8 @@ import { const ACCESSIBILITY_METADATA_MAX_CODE_UNITS = 65_536; const INVALID_ACCESSIBILITY_METADATA_MESSAGE = 'Accessibility metadata must be a string within the supported length.'; +const INVALID_LANGUAGE_TAG_MESSAGE = + 'Accessibility language tag must be valid BCP 47 metadata.'; function attributesWithAriaLabel(value: unknown): Record { return buildEditorAccessibilityAttributes({ @@ -65,4 +67,32 @@ describe('editor accessibility metadata resource boundary', () => { 'aria-label': 'Editor', }); }); + + it('rejects malformed editor language tags without reflecting the payload', () => { + const privateMarker = 'private-invalid-language-marker'; + let failure: unknown; + + try { + buildEditorAccessibilityAttributes({ + defaultLabel: 'Editor', + editable: true, + languageTag: `${privateMarker} not-a-tag`, + }); + } catch (error) { + failure = error; + } + + expect(failure).toEqual(new RangeError(INVALID_LANGUAGE_TAG_MESSAGE)); + expect(String(failure)).not.toContain(privateMarker); + }); + + it('validates but does not canonicalize accepted language tag spelling', () => { + expect( + buildEditorAccessibilityAttributes({ + defaultLabel: 'Editor', + editable: true, + languageTag: ' EN-us ', + }).lang, + ).toBe('EN-us'); + }); }); From d762601a137d5f5f5ddec8a46aaf9d27f8491091 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 05:34:11 +0900 Subject: [PATCH 04/21] fix(accessibility): validate editor language tags --- src/components/editorAccessibility.ts | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/components/editorAccessibility.ts b/src/components/editorAccessibility.ts index d10aff4f..a180c2cd 100644 --- a/src/components/editorAccessibility.ts +++ b/src/components/editorAccessibility.ts @@ -3,6 +3,8 @@ import type { EditorTextDirection } from '../types.js'; const ACCESSIBILITY_METADATA_MAX_CODE_UNITS = 65_536; const INVALID_ACCESSIBILITY_METADATA_MESSAGE = 'Accessibility metadata must be a string within the supported length.'; +const INVALID_LANGUAGE_TAG_MESSAGE = + 'Accessibility language tag must be valid BCP 47 metadata.'; /** Values accepted by the WAI-ARIA `aria-invalid` state on a textbox. */ export type EditorAriaInvalid = boolean | 'grammar' | 'spelling'; @@ -49,6 +51,20 @@ function normalizedAccessibilityValue( return normalized ? normalized : undefined; } +/** Validate one non-blank editor language tag without changing caller spelling. */ +function normalizedEditorLanguageTag( + value: string | undefined, +): string | undefined { + const normalized = normalizedAccessibilityValue(value); + if (normalized === undefined) return undefined; + try { + Intl.getCanonicalLocales(normalized); + } catch { + throw new RangeError(INVALID_LANGUAGE_TAG_MESSAGE); + } + return normalized; +} + /** * Normalize the shared visual and semantic empty-editor guidance. * @@ -67,14 +83,15 @@ export function normalizeEditorPlaceholder( * * A non-blank `aria-labelledby` reference takes precedence over the fallback * string label. Optional placeholder, language, and ID-reference values are - * omitted when blank. Placeholder guidance remains supplemental and never - * replaces the accessible name. + * omitted when blank. Non-blank language metadata must be a syntactically valid + * BCP 47 tag, while its trimmed caller spelling is preserved. Placeholder + * guidance remains supplemental and never replaces the accessible name. */ export function buildEditorAccessibilityAttributes( options: EditorAccessibilityOptions, ): Record { const placeholder = normalizeEditorPlaceholder(options.placeholder); - const languageTag = normalizedAccessibilityValue(options.languageTag); + const languageTag = normalizedEditorLanguageTag(options.languageTag); const labelledBy = normalizedAccessibilityValue(options.ariaLabelledBy); const describedBy = normalizedAccessibilityValue(options.ariaDescribedBy); const errorMessage = normalizedAccessibilityValue(options.ariaErrorMessage); From 64e46d780cd25bbcc9b821470b641f48820b7e63 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 05:40:58 +0900 Subject: [PATCH 05/21] test(accessibility): preserve RFC 5646 language-tag forms --- .../editorAccessibility.resourceBoundary.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/components/editorAccessibility.resourceBoundary.test.ts b/src/components/editorAccessibility.resourceBoundary.test.ts index 786fe766..f2b243d5 100644 --- a/src/components/editorAccessibility.resourceBoundary.test.ts +++ b/src/components/editorAccessibility.resourceBoundary.test.ts @@ -95,4 +95,17 @@ describe('editor accessibility metadata resource boundary', () => { }).lang, ).toBe('EN-us'); }); + + it.each(['x-private', 'i-klingon', 'zh-cmn-Hans-CN'])( + 'preserves well-formed RFC 5646 language tag %s', + (languageTag) => { + expect( + buildEditorAccessibilityAttributes({ + defaultLabel: 'Editor', + editable: true, + languageTag: ` ${languageTag} `, + }).lang, + ).toBe(languageTag); + }, + ); }); From 6ba8555ee3145255b7598a515389d70cfee15d9a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 05:44:56 +0900 Subject: [PATCH 06/21] fix(accessibility): validate full RFC 5646 tag syntax --- src/components/editorAccessibility.ts | 52 +++++++++++++++++++++++---- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/src/components/editorAccessibility.ts b/src/components/editorAccessibility.ts index a180c2cd..f5c7912c 100644 --- a/src/components/editorAccessibility.ts +++ b/src/components/editorAccessibility.ts @@ -6,6 +6,37 @@ const INVALID_ACCESSIBILITY_METADATA_MESSAGE = const INVALID_LANGUAGE_TAG_MESSAGE = 'Accessibility language tag must be valid BCP 47 metadata.'; +const RFC_5646_GRANDFATHERED_TAGS = new Set([ + 'art-lojban', + 'cel-gaulish', + 'en-gb-oed', + 'i-ami', + 'i-bnn', + 'i-default', + 'i-enochian', + 'i-hak', + 'i-klingon', + 'i-lux', + 'i-mingo', + 'i-navajo', + 'i-pwn', + 'i-tao', + 'i-tay', + 'i-tsu', + 'no-bok', + 'no-nyn', + 'sgn-be-fr', + 'sgn-be-nl', + 'sgn-ch-de', + 'zh-guoyu', + 'zh-hakka', + 'zh-min', + 'zh-min-nan', + 'zh-xiang', +]); +const RFC_5646_PRIVATE_USE_TAG = /^[xX](?:-[A-Za-z0-9]{1,8})+$/; +const RFC_5646_LANGTAG = /^(?:[A-Za-z]{2,3}(?:-[A-Za-z]{3}){0,3}|[A-Za-z]{4}|[A-Za-z]{5,8})(?:-[A-Za-z]{4})?(?:-(?:[A-Za-z]{2}|[0-9]{3}))?(?:-(?:[A-Za-z0-9]{5,8}|[0-9][A-Za-z0-9]{3}))*(?:-[0-9A-WY-Za-wy-z](?:-[A-Za-z0-9]{2,8})+)*(?:-[xX](?:-[A-Za-z0-9]{1,8})+)?$/; + /** Values accepted by the WAI-ARIA `aria-invalid` state on a textbox. */ export type EditorAriaInvalid = boolean | 'grammar' | 'spelling'; @@ -51,15 +82,22 @@ function normalizedAccessibilityValue( return normalized ? normalized : undefined; } +/** Check the complete RFC 5646 well-formed tag grammar without registry lookup. */ +function isWellFormedLanguageTag(value: string): boolean { + return ( + RFC_5646_GRANDFATHERED_TAGS.has(value.toLowerCase()) || + RFC_5646_PRIVATE_USE_TAG.test(value) || + RFC_5646_LANGTAG.test(value) + ); +} + /** Validate one non-blank editor language tag without changing caller spelling. */ function normalizedEditorLanguageTag( value: string | undefined, ): string | undefined { const normalized = normalizedAccessibilityValue(value); if (normalized === undefined) return undefined; - try { - Intl.getCanonicalLocales(normalized); - } catch { + if (!isWellFormedLanguageTag(normalized)) { throw new RangeError(INVALID_LANGUAGE_TAG_MESSAGE); } return normalized; @@ -83,9 +121,11 @@ export function normalizeEditorPlaceholder( * * A non-blank `aria-labelledby` reference takes precedence over the fallback * string label. Optional placeholder, language, and ID-reference values are - * omitted when blank. Non-blank language metadata must be a syntactically valid - * BCP 47 tag, while its trimmed caller spelling is preserved. Placeholder - * guidance remains supplemental and never replaces the accessible name. + * omitted when blank. Non-blank language metadata must be well-formed under the + * complete RFC 5646 syntax, including private-use and grandfathered tags; IANA + * registry-content validity remains a host policy concern. The trimmed caller + * spelling is preserved. Placeholder guidance remains supplemental and never + * replaces the accessible name. */ export function buildEditorAccessibilityAttributes( options: EditorAccessibilityOptions, From 7af12272b790ec20eb1e354be855f2ecb6ff8418 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 06:01:40 +0900 Subject: [PATCH 07/21] test(a11y): reject duplicate RFC 5646 subtags --- .../editorAccessibility.resourceBoundary.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/components/editorAccessibility.resourceBoundary.test.ts b/src/components/editorAccessibility.resourceBoundary.test.ts index f2b243d5..1744417b 100644 --- a/src/components/editorAccessibility.resourceBoundary.test.ts +++ b/src/components/editorAccessibility.resourceBoundary.test.ts @@ -108,4 +108,17 @@ describe('editor accessibility metadata resource boundary', () => { ).toBe(languageTag); }, ); + + it.each(['de-DE-1901-1901', 'en-a-bbb-a-ccc'])( + 'rejects RFC 5646 tag with repeated variant or extension singleton: %s', + (languageTag) => { + expect(() => + buildEditorAccessibilityAttributes({ + defaultLabel: 'Editor', + editable: true, + languageTag, + }), + ).toThrowError(new RangeError(INVALID_LANGUAGE_TAG_MESSAGE)); + }, + ); }); From e4a7c04ff240bcf015937e5d2b06a8663ed01d80 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 06:03:43 +0900 Subject: [PATCH 08/21] fix(a11y): enforce RFC 5646 subtag uniqueness --- src/components/editorAccessibility.ts | 38 +++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/src/components/editorAccessibility.ts b/src/components/editorAccessibility.ts index f5c7912c..fd420efe 100644 --- a/src/components/editorAccessibility.ts +++ b/src/components/editorAccessibility.ts @@ -36,6 +36,8 @@ const RFC_5646_GRANDFATHERED_TAGS = new Set([ ]); const RFC_5646_PRIVATE_USE_TAG = /^[xX](?:-[A-Za-z0-9]{1,8})+$/; const RFC_5646_LANGTAG = /^(?:[A-Za-z]{2,3}(?:-[A-Za-z]{3}){0,3}|[A-Za-z]{4}|[A-Za-z]{5,8})(?:-[A-Za-z]{4})?(?:-(?:[A-Za-z]{2}|[0-9]{3}))?(?:-(?:[A-Za-z0-9]{5,8}|[0-9][A-Za-z0-9]{3}))*(?:-[0-9A-WY-Za-wy-z](?:-[A-Za-z0-9]{2,8})+)*(?:-[xX](?:-[A-Za-z0-9]{1,8})+)?$/; +const RFC_5646_VARIANT = /^(?:[A-Za-z0-9]{5,8}|[0-9][A-Za-z0-9]{3})$/; +const RFC_5646_EXTENSION_SINGLETON = /^[0-9A-WY-Za-wy-z]$/; /** Values accepted by the WAI-ARIA `aria-invalid` state on a textbox. */ export type EditorAriaInvalid = boolean | 'grammar' | 'spelling'; @@ -82,12 +84,38 @@ function normalizedAccessibilityValue( return normalized ? normalized : undefined; } +/** Enforce RFC 5646's case-insensitive uniqueness rules beyond its ABNF shape. */ +function hasUniqueLanguageTagSubtags(value: string): boolean { + const variants = new Set(); + const extensionSingletons = new Set(); + let readingExtensions = false; + + for (const subtag of value.split('-').slice(1)) { + const normalized = subtag.toLowerCase(); + if (normalized === 'x') break; + + if (RFC_5646_EXTENSION_SINGLETON.test(subtag)) { + if (extensionSingletons.has(normalized)) return false; + extensionSingletons.add(normalized); + readingExtensions = true; + continue; + } + + if (!readingExtensions && RFC_5646_VARIANT.test(subtag)) { + if (variants.has(normalized)) return false; + variants.add(normalized); + } + } + + return true; +} + /** Check the complete RFC 5646 well-formed tag grammar without registry lookup. */ function isWellFormedLanguageTag(value: string): boolean { return ( RFC_5646_GRANDFATHERED_TAGS.has(value.toLowerCase()) || RFC_5646_PRIVATE_USE_TAG.test(value) || - RFC_5646_LANGTAG.test(value) + (RFC_5646_LANGTAG.test(value) && hasUniqueLanguageTagSubtags(value)) ); } @@ -122,10 +150,10 @@ export function normalizeEditorPlaceholder( * A non-blank `aria-labelledby` reference takes precedence over the fallback * string label. Optional placeholder, language, and ID-reference values are * omitted when blank. Non-blank language metadata must be well-formed under the - * complete RFC 5646 syntax, including private-use and grandfathered tags; IANA - * registry-content validity remains a host policy concern. The trimmed caller - * spelling is preserved. Placeholder guidance remains supplemental and never - * replaces the accessible name. + * complete RFC 5646 syntax and uniqueness rules, including private-use and + * grandfathered tags; IANA registry-content validity remains a host policy + * concern. The trimmed caller spelling is preserved. Placeholder guidance + * remains supplemental and never replaces the accessible name. */ export function buildEditorAccessibilityAttributes( options: EditorAccessibilityOptions, From 210ca6af5df46aa2693bc50b1952fc72338e5251 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 06:08:56 +0900 Subject: [PATCH 09/21] test(a11y): cover RFC 5646 private-use suffix --- ...itorAccessibility.resourceBoundary.test.ts | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/src/components/editorAccessibility.resourceBoundary.test.ts b/src/components/editorAccessibility.resourceBoundary.test.ts index 1744417b..3c5a5912 100644 --- a/src/components/editorAccessibility.resourceBoundary.test.ts +++ b/src/components/editorAccessibility.resourceBoundary.test.ts @@ -96,18 +96,20 @@ describe('editor accessibility metadata resource boundary', () => { ).toBe('EN-us'); }); - it.each(['x-private', 'i-klingon', 'zh-cmn-Hans-CN'])( - 'preserves well-formed RFC 5646 language tag %s', - (languageTag) => { - expect( - buildEditorAccessibilityAttributes({ - defaultLabel: 'Editor', - editable: true, - languageTag: ` ${languageTag} `, - }).lang, - ).toBe(languageTag); - }, - ); + it.each([ + 'x-private', + 'i-klingon', + 'zh-cmn-Hans-CN', + 'en-US-x-private', + ])('preserves well-formed RFC 5646 language tag %s', (languageTag) => { + expect( + buildEditorAccessibilityAttributes({ + defaultLabel: 'Editor', + editable: true, + languageTag: ` ${languageTag} `, + }).lang, + ).toBe(languageTag); + }); it.each(['de-DE-1901-1901', 'en-a-bbb-a-ccc'])( 'rejects RFC 5646 tag with repeated variant or extension singleton: %s', From c9eb5272cbb7e2d7d502a2130e545c88ea72c9e1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 06:16:09 +0900 Subject: [PATCH 10/21] test(a11y): consolidate runtime accessibility contracts --- .../editorAccessibilityRuntime.test.ts | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 src/components/editorAccessibilityRuntime.test.ts diff --git a/src/components/editorAccessibilityRuntime.test.ts b/src/components/editorAccessibilityRuntime.test.ts new file mode 100644 index 00000000..b00b5e09 --- /dev/null +++ b/src/components/editorAccessibilityRuntime.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; +import { buildEditorAccessibilityAttributes } from './editorAccessibility.js'; + +describe('editor accessibility runtime contracts', () => { + it.each(['ltr', 'rtl', 'auto'] as const)( + 'preserves the valid %s text direction', + (textDirection) => { + expect( + buildEditorAccessibilityAttributes({ + defaultLabel: 'Editor', + editable: true, + textDirection, + }).dir, + ).toBe(textDirection); + }, + ); + + it('rejects a runtime text direction outside the public enumerated states', () => { + expect(() => + buildEditorAccessibilityAttributes({ + defaultLabel: 'Editor', + editable: true, + textDirection: 'sideways' as never, + }), + ).toThrowError( + new RangeError('Editor text direction must be ltr, rtl, or auto.'), + ); + }); + + it.each([false, true, 'grammar', 'spelling'] as const)( + 'preserves the valid %s aria-invalid state', + (ariaInvalid) => { + expect( + buildEditorAccessibilityAttributes({ + defaultLabel: 'Editor', + editable: true, + ariaInvalid, + })['aria-invalid'], + ).toBe(String(ariaInvalid)); + }, + ); + + it('rejects a runtime aria-invalid value outside the public states', () => { + expect(() => + buildEditorAccessibilityAttributes({ + defaultLabel: 'Editor', + editable: true, + ariaInvalid: 'unknown' as never, + }), + ).toThrowError( + new RangeError( + 'Editor aria-invalid must be false, true, grammar, or spelling.', + ), + ); + }); + + it.each([false, true] as const)( + 'preserves the valid %s aria-required state', + (ariaRequired) => { + expect( + buildEditorAccessibilityAttributes({ + defaultLabel: 'Editor', + editable: true, + ariaRequired, + })['aria-required'], + ).toBe(String(ariaRequired)); + }, + ); + + it('rejects a runtime aria-required value outside the public states', () => { + expect(() => + buildEditorAccessibilityAttributes({ + defaultLabel: 'Editor', + editable: true, + ariaRequired: 'maybe' as never, + }), + ).toThrowError( + new RangeError('Editor aria-required must be false or true.'), + ); + }); +}); From 04595d126f61c4cbc00906a4749d50f28bcd6310 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 06:19:55 +0900 Subject: [PATCH 11/21] fix(a11y): consolidate accessibility runtime validation --- src/components/editorAccessibility.ts | 48 +++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/src/components/editorAccessibility.ts b/src/components/editorAccessibility.ts index fd420efe..99769cd2 100644 --- a/src/components/editorAccessibility.ts +++ b/src/components/editorAccessibility.ts @@ -131,6 +131,44 @@ function normalizedEditorLanguageTag( return normalized; } +/** Reject runtime direction values outside Inkspan's public finite contract. */ +function validateEditorTextDirection( + value: EditorTextDirection | undefined, +): void { + if ( + value !== undefined && + value !== 'ltr' && + value !== 'rtl' && + value !== 'auto' + ) { + throw new RangeError('Editor text direction must be ltr, rtl, or auto.'); + } +} + +/** Reject runtime `aria-invalid` values outside Inkspan's finite contract. */ +function validateEditorAriaInvalid( + value: EditorAriaInvalid | undefined, +): void { + if ( + value !== undefined && + value !== false && + value !== true && + value !== 'grammar' && + value !== 'spelling' + ) { + throw new RangeError( + 'Editor aria-invalid must be false, true, grammar, or spelling.', + ); + } +} + +/** Reject runtime `aria-required` values outside Inkspan's boolean contract. */ +function validateEditorAriaRequired(value: boolean | undefined): void { + if (value !== undefined && value !== false && value !== true) { + throw new RangeError('Editor aria-required must be false or true.'); + } +} + /** * Normalize the shared visual and semantic empty-editor guidance. * @@ -152,12 +190,18 @@ export function normalizeEditorPlaceholder( * omitted when blank. Non-blank language metadata must be well-formed under the * complete RFC 5646 syntax and uniqueness rules, including private-use and * grandfathered tags; IANA registry-content validity remains a host policy - * concern. The trimmed caller spelling is preserved. Placeholder guidance - * remains supplemental and never replaces the accessible name. + * concern. Runtime direction and ARIA state values are checked against Inkspan's + * finite public contracts before attribute emission. The trimmed caller spelling + * of accepted language tags is preserved. Placeholder guidance remains + * supplemental and never replaces the accessible name. */ export function buildEditorAccessibilityAttributes( options: EditorAccessibilityOptions, ): Record { + validateEditorTextDirection(options.textDirection); + validateEditorAriaInvalid(options.ariaInvalid); + validateEditorAriaRequired(options.ariaRequired); + const placeholder = normalizeEditorPlaceholder(options.placeholder); const languageTag = normalizedEditorLanguageTag(options.languageTag); const labelledBy = normalizedAccessibilityValue(options.ariaLabelledBy); From 0551ae31809dc37e146f1bba60bf5d13daf04b60 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 06:32:49 +0900 Subject: [PATCH 12/21] test(accessibility): reject invalid extlang chains --- .../editorAccessibility.resourceBoundary.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/components/editorAccessibility.resourceBoundary.test.ts b/src/components/editorAccessibility.resourceBoundary.test.ts index 3c5a5912..8ced8c14 100644 --- a/src/components/editorAccessibility.resourceBoundary.test.ts +++ b/src/components/editorAccessibility.resourceBoundary.test.ts @@ -111,6 +111,19 @@ describe('editor accessibility metadata resource boundary', () => { ).toBe(languageTag); }); + it.each(['zh-cmn-hak', 'zh-cmn-hak-yue'])( + 'rejects RFC 5646 tag with a permanently invalid extra extlang: %s', + (languageTag) => { + expect(() => + buildEditorAccessibilityAttributes({ + defaultLabel: 'Editor', + editable: true, + languageTag, + }), + ).toThrowError(new RangeError(INVALID_LANGUAGE_TAG_MESSAGE)); + }, + ); + it.each(['de-DE-1901-1901', 'en-a-bbb-a-ccc'])( 'rejects RFC 5646 tag with repeated variant or extension singleton: %s', (languageTag) => { From f21e923fad39e1790dba5b9dada9d75b2ea2cbcc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 06:37:14 +0900 Subject: [PATCH 13/21] fix(accessibility): reject invalid extlang chains --- src/components/editorAccessibility.ts | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/components/editorAccessibility.ts b/src/components/editorAccessibility.ts index 99769cd2..d8d250cc 100644 --- a/src/components/editorAccessibility.ts +++ b/src/components/editorAccessibility.ts @@ -35,7 +35,7 @@ const RFC_5646_GRANDFATHERED_TAGS = new Set([ 'zh-xiang', ]); const RFC_5646_PRIVATE_USE_TAG = /^[xX](?:-[A-Za-z0-9]{1,8})+$/; -const RFC_5646_LANGTAG = /^(?:[A-Za-z]{2,3}(?:-[A-Za-z]{3}){0,3}|[A-Za-z]{4}|[A-Za-z]{5,8})(?:-[A-Za-z]{4})?(?:-(?:[A-Za-z]{2}|[0-9]{3}))?(?:-(?:[A-Za-z0-9]{5,8}|[0-9][A-Za-z0-9]{3}))*(?:-[0-9A-WY-Za-wy-z](?:-[A-Za-z0-9]{2,8})+)*(?:-[xX](?:-[A-Za-z0-9]{1,8})+)?$/; +const RFC_5646_LANGTAG = /^(?:[A-Za-z]{2,3}(?:-[A-Za-z]{3})?|[A-Za-z]{4}|[A-Za-z]{5,8})(?:-[A-Za-z]{4})?(?:-(?:[A-Za-z]{2}|[0-9]{3}))?(?:-(?:[A-Za-z0-9]{5,8}|[0-9][A-Za-z0-9]{3}))*(?:-[0-9A-WY-Za-wy-z](?:-[A-Za-z0-9]{2,8})+)*(?:-[xX](?:-[A-Za-z0-9]{1,8})+)?$/; const RFC_5646_VARIANT = /^(?:[A-Za-z0-9]{5,8}|[0-9][A-Za-z0-9]{3})$/; const RFC_5646_EXTENSION_SINGLETON = /^[0-9A-WY-Za-wy-z]$/; @@ -84,7 +84,7 @@ function normalizedAccessibilityValue( return normalized ? normalized : undefined; } -/** Enforce RFC 5646's case-insensitive uniqueness rules beyond its ABNF shape. */ +/** Enforce RFC 5646's locally decidable uniqueness rules beyond its ABNF shape. */ function hasUniqueLanguageTagSubtags(value: string): boolean { const variants = new Set(); const extensionSingletons = new Set(); @@ -110,7 +110,7 @@ function hasUniqueLanguageTagSubtags(value: string): boolean { return true; } -/** Check the complete RFC 5646 well-formed tag grammar without registry lookup. */ +/** Check locally decidable RFC 5646 validity without IANA registry lookup. */ function isWellFormedLanguageTag(value: string): boolean { return ( RFC_5646_GRANDFATHERED_TAGS.has(value.toLowerCase()) || @@ -187,13 +187,14 @@ export function normalizeEditorPlaceholder( * * A non-blank `aria-labelledby` reference takes precedence over the fallback * string label. Optional placeholder, language, and ID-reference values are - * omitted when blank. Non-blank language metadata must be well-formed under the - * complete RFC 5646 syntax and uniqueness rules, including private-use and - * grandfathered tags; IANA registry-content validity remains a host policy - * concern. Runtime direction and ARIA state values are checked against Inkspan's - * finite public contracts before attribute emission. The trimmed caller spelling - * of accepted language tags is preserved. Placeholder guidance remains - * supplemental and never replaces the accessible name. + * omitted when blank. Non-blank language metadata must satisfy RFC 5646 rules + * that Inkspan can decide locally, including private-use, grandfathered, + * extlang-position, variant-uniqueness, and extension-uniqueness constraints; + * IANA registry-content validity remains a host policy concern. Runtime direction + * and ARIA state values are checked against Inkspan's finite public contracts + * before attribute emission. The trimmed caller spelling of accepted language + * tags is preserved. Placeholder guidance remains supplemental and never replaces + * the accessible name. */ export function buildEditorAccessibilityAttributes( options: EditorAccessibilityOptions, From cae7477724bbfac65f072f0102dd4c2b01e5093b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 17:04:10 -0700 Subject: [PATCH 14/21] test(a11y): reject non-boolean editor accessibility state --- .../editorAccessibilityRuntime.test.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/components/editorAccessibilityRuntime.test.ts b/src/components/editorAccessibilityRuntime.test.ts index b00b5e09..d32386bc 100644 --- a/src/components/editorAccessibilityRuntime.test.ts +++ b/src/components/editorAccessibilityRuntime.test.ts @@ -2,6 +2,32 @@ import { describe, expect, it } from 'vitest'; import { buildEditorAccessibilityAttributes } from './editorAccessibility.js'; describe('editor accessibility runtime contracts', () => { + it.each([ + [true, 'false'], + [false, 'true'], + ] as const)( + 'preserves editable=%s as aria-readonly=%s', + (editable, ariaReadonly) => { + expect( + buildEditorAccessibilityAttributes({ + defaultLabel: 'Editor', + editable, + })['aria-readonly'], + ).toBe(ariaReadonly); + }, + ); + + it('rejects a non-boolean runtime editable value before deriving aria-readonly', () => { + expect(() => + buildEditorAccessibilityAttributes({ + defaultLabel: 'Editor', + editable: 'false' as unknown as boolean, + }), + ).toThrowError( + new RangeError('Editor editable state must be false or true.'), + ); + }); + it.each(['ltr', 'rtl', 'auto'] as const)( 'preserves the valid %s text direction', (textDirection) => { From f5e51c3ce210eb312b84ef9ad87ca12cab0efad2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 17:05:54 -0700 Subject: [PATCH 15/21] fix(a11y): validate editor accessibility editability --- src/components/editorAccessibility.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/components/editorAccessibility.ts b/src/components/editorAccessibility.ts index d8d250cc..f2b9b133 100644 --- a/src/components/editorAccessibility.ts +++ b/src/components/editorAccessibility.ts @@ -131,6 +131,13 @@ function normalizedEditorLanguageTag( return normalized; } +/** Reject runtime editability values outside Inkspan's boolean contract. */ +function validateEditorEditable(value: boolean): void { + if (value !== false && value !== true) { + throw new RangeError('Editor editable state must be false or true.'); + } +} + /** Reject runtime direction values outside Inkspan's public finite contract. */ function validateEditorTextDirection( value: EditorTextDirection | undefined, @@ -190,15 +197,16 @@ export function normalizeEditorPlaceholder( * omitted when blank. Non-blank language metadata must satisfy RFC 5646 rules * that Inkspan can decide locally, including private-use, grandfathered, * extlang-position, variant-uniqueness, and extension-uniqueness constraints; - * IANA registry-content validity remains a host policy concern. Runtime direction - * and ARIA state values are checked against Inkspan's finite public contracts - * before attribute emission. The trimmed caller spelling of accepted language - * tags is preserved. Placeholder guidance remains supplemental and never replaces - * the accessible name. + * IANA registry-content validity remains a host policy concern. Runtime editable, + * direction, and ARIA state values are checked against Inkspan's finite public + * contracts before attribute emission. The trimmed caller spelling of accepted + * language tags is preserved. Placeholder guidance remains supplemental and never + * replaces the accessible name. */ export function buildEditorAccessibilityAttributes( options: EditorAccessibilityOptions, ): Record { + validateEditorEditable(options.editable); validateEditorTextDirection(options.textDirection); validateEditorAriaInvalid(options.ariaInvalid); validateEditorAriaRequired(options.ariaRequired); From 9fb8889cc6573c9395a1fd2ffd0fefeee518ae56 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 17:07:31 -0700 Subject: [PATCH 16/21] chore(a11y): restore canonical metadata test scope --- .../editorAccessibilityRuntime.test.ts | 26 ------------------- 1 file changed, 26 deletions(-) diff --git a/src/components/editorAccessibilityRuntime.test.ts b/src/components/editorAccessibilityRuntime.test.ts index d32386bc..b00b5e09 100644 --- a/src/components/editorAccessibilityRuntime.test.ts +++ b/src/components/editorAccessibilityRuntime.test.ts @@ -2,32 +2,6 @@ import { describe, expect, it } from 'vitest'; import { buildEditorAccessibilityAttributes } from './editorAccessibility.js'; describe('editor accessibility runtime contracts', () => { - it.each([ - [true, 'false'], - [false, 'true'], - ] as const)( - 'preserves editable=%s as aria-readonly=%s', - (editable, ariaReadonly) => { - expect( - buildEditorAccessibilityAttributes({ - defaultLabel: 'Editor', - editable, - })['aria-readonly'], - ).toBe(ariaReadonly); - }, - ); - - it('rejects a non-boolean runtime editable value before deriving aria-readonly', () => { - expect(() => - buildEditorAccessibilityAttributes({ - defaultLabel: 'Editor', - editable: 'false' as unknown as boolean, - }), - ).toThrowError( - new RangeError('Editor editable state must be false or true.'), - ); - }); - it.each(['ltr', 'rtl', 'auto'] as const)( 'preserves the valid %s text direction', (textDirection) => { From 2cc4b5f3774c5b9c8dcadb81eac77427cbf3f8f8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 17:07:58 -0700 Subject: [PATCH 17/21] chore(a11y): restore canonical accessibility ownership --- src/components/editorAccessibility.ts | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/src/components/editorAccessibility.ts b/src/components/editorAccessibility.ts index f2b9b133..d8d250cc 100644 --- a/src/components/editorAccessibility.ts +++ b/src/components/editorAccessibility.ts @@ -131,13 +131,6 @@ function normalizedEditorLanguageTag( return normalized; } -/** Reject runtime editability values outside Inkspan's boolean contract. */ -function validateEditorEditable(value: boolean): void { - if (value !== false && value !== true) { - throw new RangeError('Editor editable state must be false or true.'); - } -} - /** Reject runtime direction values outside Inkspan's public finite contract. */ function validateEditorTextDirection( value: EditorTextDirection | undefined, @@ -197,16 +190,15 @@ export function normalizeEditorPlaceholder( * omitted when blank. Non-blank language metadata must satisfy RFC 5646 rules * that Inkspan can decide locally, including private-use, grandfathered, * extlang-position, variant-uniqueness, and extension-uniqueness constraints; - * IANA registry-content validity remains a host policy concern. Runtime editable, - * direction, and ARIA state values are checked against Inkspan's finite public - * contracts before attribute emission. The trimmed caller spelling of accepted - * language tags is preserved. Placeholder guidance remains supplemental and never - * replaces the accessible name. + * IANA registry-content validity remains a host policy concern. Runtime direction + * and ARIA state values are checked against Inkspan's finite public contracts + * before attribute emission. The trimmed caller spelling of accepted language + * tags is preserved. Placeholder guidance remains supplemental and never replaces + * the accessible name. */ export function buildEditorAccessibilityAttributes( options: EditorAccessibilityOptions, ): Record { - validateEditorEditable(options.editable); validateEditorTextDirection(options.textDirection); validateEditorAriaInvalid(options.ariaInvalid); validateEditorAriaRequired(options.ariaRequired); From f6aa305d62f9f6715a4730eb77f4ab8c07418248 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 18:39:04 -0700 Subject: [PATCH 18/21] test(accessibility): cover required fallback label boundary --- ...itorAccessibility.resourceBoundary.test.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/components/editorAccessibility.resourceBoundary.test.ts b/src/components/editorAccessibility.resourceBoundary.test.ts index 8ced8c14..ca76e62e 100644 --- a/src/components/editorAccessibility.resourceBoundary.test.ts +++ b/src/components/editorAccessibility.resourceBoundary.test.ts @@ -18,6 +18,13 @@ function attributesWithAriaLabel(value: unknown): Record { }); } +function attributesWithDefaultLabel(value: unknown): Record { + return buildEditorAccessibilityAttributes({ + defaultLabel: value as EditorAccessibilityOptions['defaultLabel'], + editable: true, + }); +} + describe('editor accessibility metadata resource boundary', () => { it('rejects non-string runtime metadata through one stable error contract', () => { expect(() => attributesWithAriaLabel(42)).toThrowError( @@ -48,6 +55,29 @@ describe('editor accessibility metadata resource boundary', () => { expect(attributesWithAriaLabel(value)['aria-label']).toBe(value); }); + it('rejects non-string required fallback labels through the stable metadata error contract', () => { + expect(() => attributesWithDefaultLabel(42)).toThrowError( + new RangeError(INVALID_ACCESSIBILITY_METADATA_MESSAGE), + ); + }); + + it('rejects oversized required fallback labels without reflecting their payload', () => { + const privateMarker = 'private-default-label-marker'; + const value = `${privateMarker}${'x'.repeat(ACCESSIBILITY_METADATA_MAX_CODE_UNITS)}`; + let failure: unknown; + + try { + attributesWithDefaultLabel(value); + } catch (error) { + failure = error; + } + + expect(failure).toEqual( + new RangeError(INVALID_ACCESSIBILITY_METADATA_MESSAGE), + ); + expect(String(failure)).not.toContain(privateMarker); + }); + it('keeps blank optional metadata omitted after bounded normalization', () => { expect( buildEditorAccessibilityAttributes({ From a9fc72cb18f2ef3fc1a1daf238f119a8b1c9a8df Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 18:39:41 -0700 Subject: [PATCH 19/21] fix(accessibility): bound required fallback label metadata --- src/components/editorAccessibility.ts | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/src/components/editorAccessibility.ts b/src/components/editorAccessibility.ts index d8d250cc..c1742b18 100644 --- a/src/components/editorAccessibility.ts +++ b/src/components/editorAccessibility.ts @@ -68,19 +68,24 @@ export interface EditorAccessibilityOptions { editable: boolean; } -/** Normalize optional host metadata after enforcing Inkspan's local size boundary. */ -function normalizedAccessibilityValue( - value: string | undefined, -): string | undefined { - if (value === undefined) return undefined; +/** Enforce Inkspan's local type and resource boundary for accessibility text. */ +function validatedAccessibilityValue(value: string): string { if ( typeof value !== 'string' || value.length > ACCESSIBILITY_METADATA_MAX_CODE_UNITS ) { throw new RangeError(INVALID_ACCESSIBILITY_METADATA_MESSAGE); } + return value; +} + +/** Normalize optional host metadata after enforcing Inkspan's local size boundary. */ +function normalizedAccessibilityValue( + value: string | undefined, +): string | undefined { + if (value === undefined) return undefined; - const normalized = value.trim(); + const normalized = validatedAccessibilityValue(value).trim(); return normalized ? normalized : undefined; } @@ -186,8 +191,9 @@ export function normalizeEditorPlaceholder( * collaborative editor surfaces. * * A non-blank `aria-labelledby` reference takes precedence over the fallback - * string label. Optional placeholder, language, and ID-reference values are - * omitted when blank. Non-blank language metadata must satisfy RFC 5646 rules + * string label. Required and optional accessibility strings are bounded before + * attribute emission; optional placeholder, language, and ID-reference values + * are omitted when blank. Non-blank language metadata must satisfy RFC 5646 rules * that Inkspan can decide locally, including private-use, grandfathered, * extlang-position, variant-uniqueness, and extension-uniqueness constraints; * IANA registry-content validity remains a host policy concern. Runtime direction @@ -203,6 +209,7 @@ export function buildEditorAccessibilityAttributes( validateEditorAriaInvalid(options.ariaInvalid); validateEditorAriaRequired(options.ariaRequired); + const defaultLabel = validatedAccessibilityValue(options.defaultLabel); const placeholder = normalizeEditorPlaceholder(options.placeholder); const languageTag = normalizedEditorLanguageTag(options.languageTag); const labelledBy = normalizedAccessibilityValue(options.ariaLabelledBy); @@ -222,7 +229,7 @@ export function buildEditorAccessibilityAttributes( if (labelledBy) { attributes['aria-labelledby'] = labelledBy; } else { - attributes['aria-label'] = explicitLabel ?? options.defaultLabel; + attributes['aria-label'] = explicitLabel ?? defaultLabel; } if (describedBy) attributes['aria-describedby'] = describedBy; if (errorMessage) attributes['aria-errormessage'] = errorMessage; From d950d610cd2a81ae0d414b247b27323bd3ca83fe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 18:41:59 -0700 Subject: [PATCH 20/21] test(accessibility): expose finite-state getter TOCTOU --- .../editorAccessibilityRuntime.test.ts | 63 ++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/src/components/editorAccessibilityRuntime.test.ts b/src/components/editorAccessibilityRuntime.test.ts index b00b5e09..80ca2173 100644 --- a/src/components/editorAccessibilityRuntime.test.ts +++ b/src/components/editorAccessibilityRuntime.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { buildEditorAccessibilityAttributes } from './editorAccessibility.js'; +import { + buildEditorAccessibilityAttributes, + type EditorAccessibilityOptions, +} from './editorAccessibility.js'; describe('editor accessibility runtime contracts', () => { it.each(['ltr', 'rtl', 'auto'] as const)( @@ -27,6 +30,24 @@ describe('editor accessibility runtime contracts', () => { ); }); + it('snapshots text direction once before validation and emission', () => { + const options: EditorAccessibilityOptions = { + defaultLabel: 'Editor', + editable: true, + }; + let reads = 0; + Object.defineProperty(options, 'textDirection', { + enumerable: true, + get() { + reads += 1; + return reads === 1 ? 'ltr' : 'sideways'; + }, + }); + + expect(buildEditorAccessibilityAttributes(options).dir).toBe('ltr'); + expect(reads).toBe(1); + }); + it.each([false, true, 'grammar', 'spelling'] as const)( 'preserves the valid %s aria-invalid state', (ariaInvalid) => { @@ -54,6 +75,26 @@ describe('editor accessibility runtime contracts', () => { ); }); + it('snapshots aria-invalid once before validation and emission', () => { + const options: EditorAccessibilityOptions = { + defaultLabel: 'Editor', + editable: true, + }; + let reads = 0; + Object.defineProperty(options, 'ariaInvalid', { + enumerable: true, + get() { + reads += 1; + return reads === 1 ? false : 'unknown'; + }, + }); + + expect(buildEditorAccessibilityAttributes(options)['aria-invalid']).toBe( + 'false', + ); + expect(reads).toBe(1); + }); + it.each([false, true] as const)( 'preserves the valid %s aria-required state', (ariaRequired) => { @@ -78,4 +119,24 @@ describe('editor accessibility runtime contracts', () => { new RangeError('Editor aria-required must be false or true.'), ); }); + + it('snapshots aria-required once before validation and emission', () => { + const options: EditorAccessibilityOptions = { + defaultLabel: 'Editor', + editable: true, + }; + let reads = 0; + Object.defineProperty(options, 'ariaRequired', { + enumerable: true, + get() { + reads += 1; + return reads === 1 ? false : 'maybe'; + }, + }); + + expect(buildEditorAccessibilityAttributes(options)['aria-required']).toBe( + 'false', + ); + expect(reads).toBe(1); + }); }); From 59d82f8b65cfad5230fa9e11afb97867293a43f5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 18:43:47 -0700 Subject: [PATCH 21/21] fix(accessibility): snapshot finite runtime metadata --- src/components/editorAccessibility.ts | 28 +++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/src/components/editorAccessibility.ts b/src/components/editorAccessibility.ts index c1742b18..9599f299 100644 --- a/src/components/editorAccessibility.ts +++ b/src/components/editorAccessibility.ts @@ -197,17 +197,21 @@ export function normalizeEditorPlaceholder( * that Inkspan can decide locally, including private-use, grandfathered, * extlang-position, variant-uniqueness, and extension-uniqueness constraints; * IANA registry-content validity remains a host policy concern. Runtime direction - * and ARIA state values are checked against Inkspan's finite public contracts - * before attribute emission. The trimmed caller spelling of accepted language - * tags is preserved. Placeholder guidance remains supplemental and never replaces - * the accessible name. + * and ARIA state values are each captured once and checked against Inkspan's finite + * public contracts before the same captured value is emitted. The trimmed caller + * spelling of accepted language tags is preserved. Placeholder guidance remains + * supplemental and never replaces the accessible name. */ export function buildEditorAccessibilityAttributes( options: EditorAccessibilityOptions, ): Record { - validateEditorTextDirection(options.textDirection); - validateEditorAriaInvalid(options.ariaInvalid); - validateEditorAriaRequired(options.ariaRequired); + const textDirection = options.textDirection; + const ariaInvalid = options.ariaInvalid; + const ariaRequired = options.ariaRequired; + + validateEditorTextDirection(textDirection); + validateEditorAriaInvalid(ariaInvalid); + validateEditorAriaRequired(ariaRequired); const defaultLabel = validatedAccessibilityValue(options.defaultLabel); const placeholder = normalizeEditorPlaceholder(options.placeholder); @@ -225,7 +229,7 @@ export function buildEditorAccessibilityAttributes( if (placeholder) attributes['aria-placeholder'] = placeholder; if (languageTag) attributes.lang = languageTag; - if (options.textDirection) attributes.dir = options.textDirection; + if (textDirection) attributes.dir = textDirection; if (labelledBy) { attributes['aria-labelledby'] = labelledBy; } else { @@ -233,11 +237,11 @@ export function buildEditorAccessibilityAttributes( } if (describedBy) attributes['aria-describedby'] = describedBy; if (errorMessage) attributes['aria-errormessage'] = errorMessage; - if (options.ariaInvalid !== undefined) { - attributes['aria-invalid'] = String(options.ariaInvalid); + if (ariaInvalid !== undefined) { + attributes['aria-invalid'] = String(ariaInvalid); } - if (options.ariaRequired !== undefined) { - attributes['aria-required'] = String(options.ariaRequired); + if (ariaRequired !== undefined) { + attributes['aria-required'] = String(ariaRequired); } return attributes;