From d89bd41dabca47a00d486e847eed0d3df8b5456b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 02:03:57 +0900 Subject: [PATCH 01/29] test(accessibility): define forced-colors state contract --- src/forcedColorsStyles.test.ts | 65 ++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 src/forcedColorsStyles.test.ts diff --git a/src/forcedColorsStyles.test.ts b/src/forcedColorsStyles.test.ts new file mode 100644 index 00000000..a92ff025 --- /dev/null +++ b/src/forcedColorsStyles.test.ts @@ -0,0 +1,65 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const styles = readFileSync(resolve(process.cwd(), 'src/styles.css'), 'utf8'); +const forcedColorsIndex = styles.indexOf('@media (forced-colors: active)'); +const forcedColorsStyles = + forcedColorsIndex >= 0 ? styles.slice(forcedColorsIndex) : ''; + +describe('forced-colors stylesheet contract', () => { + it('defines a forced-colors boundary using system colors', () => { + expect(forcedColorsIndex).toBeGreaterThan(-1); + expect(forcedColorsStyles).toContain('Canvas'); + expect(forcedColorsStyles).toContain('CanvasText'); + expect(forcedColorsStyles).toContain('ButtonFace'); + expect(forcedColorsStyles).toContain('ButtonText'); + expect(forcedColorsStyles).toContain('Highlight'); + expect(forcedColorsStyles).toContain('HighlightText'); + expect(forcedColorsStyles).toContain('GrayText'); + expect(forcedColorsStyles).toContain('LinkText'); + }); + + it('keeps keyboard focus and active toolbar state visible without theme colors', () => { + expect(forcedColorsStyles).toMatch( + /\.cwl-tb-btn:focus-visible\s*\{[^}]*outline:\s*2px\s+solid\s+Highlight\s*;[^}]*outline-offset:\s*2px\s*;/u, + ); + expect(forcedColorsStyles).toMatch( + /\.cwl-tb-btn\.is-active\s*\{[^}]*background:\s*Highlight\s*;[^}]*border-color:\s*Highlight\s*;[^}]*color:\s*HighlightText\s*;/u, + ); + }); + + it('does not use opacity as the only disabled-state cue', () => { + expect(forcedColorsStyles).toMatch( + /\.cwl-tb-btn:disabled\s*\{[^}]*opacity:\s*1\s*;[^}]*color:\s*GrayText\s*;[^}]*border-color:\s*GrayText\s*;/u, + ); + }); + + it('preserves high-contrast chrome, document links, and collaboration cues', () => { + expect(forcedColorsStyles).toMatch( + /\.cwl-editor\s*\{[^}]*color:\s*CanvasText\s*;[^}]*background:\s*Canvas\s*;[^}]*border-color:\s*CanvasText\s*;/u, + ); + expect(forcedColorsStyles).toMatch( + /\.cwl-toolbar[\s\S]*\.cwl-collaboration-status[\s\S]*\{[^}]*background:\s*ButtonFace\s*;[^}]*color:\s*ButtonText\s*;/u, + ); + expect(forcedColorsStyles).toMatch( + /\.cwl-editor__content a\s*\{[^}]*color:\s*LinkText\s*;/u, + ); + expect(forcedColorsStyles).toMatch( + /\.collaboration-cursor__caret\s*\{[^}]*border-left-color:\s*Highlight\s*;/u, + ); + expect(forcedColorsStyles).toMatch( + /\.collaboration-cursor__label\s*\{[^}]*background:\s*Highlight\s*;[^}]*color:\s*HighlightText\s*;/u, + ); + }); + + it('preserves authored structural boundaries in forced colors', () => { + expect(forcedColorsStyles).toMatch( + /\.cwl-tb-group\s*\{[^}]*border-right-color:\s*CanvasText\s*;/u, + ); + expect(forcedColorsStyles).toMatch( + /\.cwl-editor__content code,[\s\S]*\.cwl-editor__content pre,[\s\S]*\.cwl-editor__content th,[\s\S]*\.cwl-editor__content td\s*\{[^}]*border-color:\s*CanvasText\s*;/u, + ); + }); +}); From d94c6b5f6aaf496b8495eed87cb544cf53b6bb82 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 02:09:36 +0900 Subject: [PATCH 02/29] fix(accessibility): preserve forced-colors state cues --- src/styles.css | 79 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/src/styles.css b/src/styles.css index 0970a931..8d5ea18e 100644 --- a/src/styles.css +++ b/src/styles.css @@ -107,8 +107,85 @@ } @media (forced-colors: active) { + .cwl-editor { + color: CanvasText; + background: Canvas; + border-color: CanvasText; + } + + .cwl-toolbar, + .cwl-collaboration-status { + background: ButtonFace; + color: ButtonText; + border-bottom-color: CanvasText; + } + + .cwl-tb-group { + border-right-color: CanvasText; + } + + .cwl-tb-btn { + background: ButtonFace; + color: ButtonText; + border-color: ButtonText; + } + + .cwl-tb-btn:hover:not(:disabled) { + background: Highlight; + color: HighlightText; + } + .cwl-tb-btn:focus-visible { - outline-color: CanvasText; + outline: 2px solid Highlight; + outline-offset: 2px; + } + + .cwl-tb-btn.is-active { + background: Highlight; + border-color: Highlight; + color: HighlightText; + } + + .cwl-tb-btn:disabled { + opacity: 1; + color: GrayText; + border-color: GrayText; + } + + .cwl-editor__content a { + color: LinkText; + } + + .cwl-editor__content blockquote { + border-left-color: CanvasText; + color: GrayText; + } + + .cwl-editor__content .is-editor-empty:first-child::before { + color: GrayText; + } + + .cwl-editor__content code, + .cwl-editor__content pre, + .cwl-editor__content th, + .cwl-editor__content td { + border-color: CanvasText; + } + + .cwl-editor__content code, + .cwl-editor__content pre, + .cwl-editor__content th { + background: Canvas; + color: CanvasText; + } + + .collaboration-cursor__caret { + border-left-color: Highlight; + } + + .collaboration-cursor__label { + background: Highlight; + color: HighlightText; } } From 1b34fe4f2a772e97d967af9d90113930bcde18c2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 02:13:13 +0900 Subject: [PATCH 03/29] test(accessibility): verify forced-colors in real engines --- .../specs/forced-colors.browser.spec.ts | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 tests/browser/specs/forced-colors.browser.spec.ts diff --git a/tests/browser/specs/forced-colors.browser.spec.ts b/tests/browser/specs/forced-colors.browser.spec.ts new file mode 100644 index 00000000..7a966dc6 --- /dev/null +++ b/tests/browser/specs/forced-colors.browser.spec.ts @@ -0,0 +1,136 @@ +import { expect, test } from '@playwright/test'; + +const HARNESS_URL = 'http://127.0.0.1:4173/tests/browser/harness.html'; +const STYLES_URL = 'http://127.0.0.1:4173/dist/cwl-editor.css'; + +const allowHarnessRequest = (requestUrl: string): boolean => { + const url = new URL(requestUrl); + return url.hostname === '127.0.0.1' && url.port === '4173'; +}; + +test.describe.configure({ mode: 'serial' }); + +test.beforeEach(async ({ page }) => { + const rejectedExternalRequests: string[] = []; + await page.route('**/*', async (route) => { + if (allowHarnessRequest(route.request().url())) { + await route.continue(); + return; + } + rejectedExternalRequests.push(new URL(route.request().url()).origin); + await route.abort('blockedbyclient'); + }); + + await page.goto(HARNESS_URL); + await page.addStyleTag({ url: STYLES_URL }); + await page.locator('#harness').evaluate((element) => { + element.innerHTML = ` +
+ +
Connected
+
+
+

+

Link code

+
pre
+
quote
+
HeadCell
+ + Remote + +
+
+
+ `; + }); + expect(rejectedExternalRequests).toEqual([]); +}); + +test('preserves state and structural cues in forced colors', async ({ page }) => { + await page.emulateMedia({ forcedColors: 'active' }); + expect( + await page.evaluate(() => matchMedia('(forced-colors: active)').matches), + ).toBe(true); + + await page.getByRole('button', { name: 'Plain' }).focus(); + + const evidence = await page.evaluate(() => { + const get = (selector: string): T => { + const element = document.querySelector(selector); + if (!element) { + throw new Error(`Missing forced-colors fixture: ${selector}`); + } + return element; + }; + + const editor = get('.cwl-editor'); + const toolbar = get('.cwl-toolbar'); + const group = get('.cwl-tb-group'); + const plainButton = get( + '.cwl-tb-btn:not(.is-active):not(:disabled)', + ); + const activeButton = get('.cwl-tb-btn.is-active'); + const disabledButton = get('.cwl-tb-btn:disabled'); + const collaboration = get('.cwl-collaboration-status'); + const link = get('.cwl-editor__content a'); + const code = get('.cwl-editor__content code'); + const cell = get('.cwl-editor__content td'); + const caret = get('.collaboration-cursor__caret'); + const label = get('.collaboration-cursor__label'); + + const editorStyle = getComputedStyle(editor); + const toolbarStyle = getComputedStyle(toolbar); + const groupStyle = getComputedStyle(group); + const plainStyle = getComputedStyle(plainButton); + const activeStyle = getComputedStyle(activeButton); + const disabledStyle = getComputedStyle(disabledButton); + const collaborationStyle = getComputedStyle(collaboration); + const linkStyle = getComputedStyle(link); + const codeStyle = getComputedStyle(code); + const cellStyle = getComputedStyle(cell); + const caretStyle = getComputedStyle(caret); + const labelStyle = getComputedStyle(label); + + return { + editorBorderStyle: editorStyle.borderTopStyle, + editorBorderWidth: editorStyle.borderTopWidth, + toolbarBorderWidth: toolbarStyle.borderBottomWidth, + groupBorderWidth: groupStyle.borderRightWidth, + focusOutlineStyle: plainStyle.outlineStyle, + focusOutlineWidth: plainStyle.outlineWidth, + activeBorderWidth: activeStyle.borderTopWidth, + disabledOpacity: disabledStyle.opacity, + disabledBorderWidth: disabledStyle.borderTopWidth, + collaborationBorderWidth: collaborationStyle.borderBottomWidth, + linkDecoration: linkStyle.textDecorationLine, + codeBorderWidth: codeStyle.borderTopWidth, + cellBorderWidth: cellStyle.borderTopWidth, + caretBorderWidth: caretStyle.borderLeftWidth, + labelVisible: labelStyle.display !== 'none' && labelStyle.visibility !== 'hidden', + }; + }); + + expect(evidence).toMatchObject({ + editorBorderStyle: 'solid', + editorBorderWidth: '1px', + toolbarBorderWidth: '1px', + groupBorderWidth: '1px', + focusOutlineStyle: 'solid', + focusOutlineWidth: '2px', + activeBorderWidth: '1px', + disabledOpacity: '1', + disabledBorderWidth: '1px', + collaborationBorderWidth: '1px', + linkDecoration: 'underline', + codeBorderWidth: '1px', + cellBorderWidth: '1px', + caretBorderWidth: '2px', + labelVisible: true, + }); +}); From 3fdd42d7ea81a285d7b43b0cd0dc098c3f005942 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 02:18:29 +0900 Subject: [PATCH 04/29] test(accessibility): define editor focus-visible contract --- src/editorFocusStyles.test.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 src/editorFocusStyles.test.ts diff --git a/src/editorFocusStyles.test.ts b/src/editorFocusStyles.test.ts new file mode 100644 index 00000000..e5d67a88 --- /dev/null +++ b/src/editorFocusStyles.test.ts @@ -0,0 +1,23 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const styles = readFileSync(resolve(process.cwd(), 'src/styles.css'), 'utf8'); +const forcedColorsIndex = styles.indexOf('@media (forced-colors: active)'); +const forcedColorsStyles = + forcedColorsIndex >= 0 ? styles.slice(forcedColorsIndex) : ''; + +describe('editable surface focus stylesheet contract', () => { + it('replaces the removed browser outline with a visible keyboard focus cue', () => { + expect(styles).toMatch( + /\.cwl-editor__content:focus-visible\s*\{[^}]*outline:\s*2px\s+solid\s+var\(--cwl-accent\)\s*;[^}]*outline-offset:\s*-2px\s*;/u, + ); + }); + + it('keeps the editable focus cue visible in forced-colors mode', () => { + expect(forcedColorsStyles).toMatch( + /\.cwl-editor__content:focus-visible\s*\{[^}]*outline:\s*2px\s+solid\s+Highlight\s*;[^}]*outline-offset:\s*-2px\s*;/u, + ); + }); +}); From 3b44e670f3df73fb859ba5b3df43408b7e2688ec Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 02:23:10 +0900 Subject: [PATCH 05/29] fix(accessibility): restore editor focus-visible cue --- src/styles.css | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/styles.css b/src/styles.css index 8d5ea18e..dea7616c 100644 --- a/src/styles.css +++ b/src/styles.css @@ -206,6 +206,18 @@ outline: none; } +.cwl-editor__content:focus-visible { + outline: 2px solid var(--cwl-accent); + outline-offset: -2px; +} + +@media (forced-colors: active) { + .cwl-editor__content:focus-visible { + outline: 2px solid Highlight; + outline-offset: -2px; + } +} + .cwl-editor__content > * + * { margin-top: 0.75em; } From 23a24f5f20475543e51c97a83a5dc7fc08db9ddb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 02:33:51 +0900 Subject: [PATCH 06/29] test(accessibility): prove editable forced-colors focus cue in browser --- .../browser/specs/forced-colors.browser.spec.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/browser/specs/forced-colors.browser.spec.ts b/tests/browser/specs/forced-colors.browser.spec.ts index 7a966dc6..b5c5d52d 100644 --- a/tests/browser/specs/forced-colors.browser.spec.ts +++ b/tests/browser/specs/forced-colors.browser.spec.ts @@ -35,7 +35,7 @@ test.beforeEach(async ({ page }) => {
Connected
-
+

Link code

pre
@@ -133,4 +133,18 @@ test('preserves state and structural cues in forced colors', async ({ page }) => caretBorderWidth: '2px', labelVisible: true, }); + + const editable = page.locator('.cwl-editor__content'); + await editable.focus(); + const editableFocusEvidence = await editable.evaluate((element) => { + const style = getComputedStyle(element); + return { + outlineStyle: style.outlineStyle, + outlineWidth: style.outlineWidth, + }; + }); + expect(editableFocusEvidence).toEqual({ + outlineStyle: 'solid', + outlineWidth: '2px', + }); }); From bcd993a1d521d3f44fe07eaeaa5acda10dce3d6b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 02:59:49 +0900 Subject: [PATCH 07/29] test(a11y): prove forced-colors cascade ordering --- src/forcedColorsStyles.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/forcedColorsStyles.test.ts b/src/forcedColorsStyles.test.ts index a92ff025..3442f374 100644 --- a/src/forcedColorsStyles.test.ts +++ b/src/forcedColorsStyles.test.ts @@ -7,8 +7,22 @@ const styles = readFileSync(resolve(process.cwd(), 'src/styles.css'), 'utf8'); const forcedColorsIndex = styles.indexOf('@media (forced-colors: active)'); const forcedColorsStyles = forcedColorsIndex >= 0 ? styles.slice(forcedColorsIndex) : ''; +const forcedColorsBlocks = styles.match(/@media \(forced-colors: active\)/gu) ?? []; +const lastBaseStateIndex = Math.max( + styles.lastIndexOf('.cwl-editor__content a {'), + styles.lastIndexOf('.cwl-editor__content blockquote {'), + styles.lastIndexOf('.cwl-collaboration-status {'), + styles.lastIndexOf('.collaboration-cursor__label {'), +); describe('forced-colors stylesheet contract', () => { + it('defines one final forced-colors override layer after the base state rules', () => { + expect(forcedColorsIndex).toBeGreaterThan(-1); + expect(forcedColorsBlocks).toHaveLength(1); + expect(lastBaseStateIndex).toBeGreaterThan(-1); + expect(forcedColorsIndex).toBeGreaterThan(lastBaseStateIndex); + }); + it('defines a forced-colors boundary using system colors', () => { expect(forcedColorsIndex).toBeGreaterThan(-1); expect(forcedColorsStyles).toContain('Canvas'); From bd109253141bcda7b0514de95d25f43ee2710602 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 03:03:55 +0900 Subject: [PATCH 08/29] fix(a11y): place forced-colors overrides after base rules --- src/styles.css | 178 ++++++++++++++++++++++++------------------------- 1 file changed, 88 insertions(+), 90 deletions(-) diff --git a/src/styles.css b/src/styles.css index dea7616c..a406bb02 100644 --- a/src/styles.css +++ b/src/styles.css @@ -106,89 +106,6 @@ cursor: not-allowed; } -@media (forced-colors: active) { - .cwl-editor { - color: CanvasText; - background: Canvas; - border-color: CanvasText; - } - - .cwl-toolbar, - .cwl-collaboration-status { - background: ButtonFace; - color: ButtonText; - border-bottom-color: CanvasText; - } - - .cwl-tb-group { - border-right-color: CanvasText; - } - - .cwl-tb-btn { - background: ButtonFace; - color: ButtonText; - border-color: ButtonText; - } - - .cwl-tb-btn:hover:not(:disabled) { - background: Highlight; - color: HighlightText; - } - - .cwl-tb-btn:focus-visible { - outline: 2px solid Highlight; - outline-offset: 2px; - } - - .cwl-tb-btn.is-active { - background: Highlight; - border-color: Highlight; - color: HighlightText; - } - - .cwl-tb-btn:disabled { - opacity: 1; - color: GrayText; - border-color: GrayText; - } - - .cwl-editor__content a { - color: LinkText; - } - - .cwl-editor__content blockquote { - border-left-color: CanvasText; - color: GrayText; - } - - .cwl-editor__content .is-editor-empty:first-child::before { - color: GrayText; - } - - .cwl-editor__content code, - .cwl-editor__content pre, - .cwl-editor__content th, - .cwl-editor__content td { - border-color: CanvasText; - } - - .cwl-editor__content code, - .cwl-editor__content pre, - .cwl-editor__content th { - background: Canvas; - color: CanvasText; - } - - .collaboration-cursor__caret { - border-left-color: Highlight; - } - - .collaboration-cursor__label { - background: Highlight; - color: HighlightText; - } -} - .cwl-editor__surface { overflow-y: auto; max-height: 70vh; @@ -211,13 +128,6 @@ outline-offset: -2px; } -@media (forced-colors: active) { - .cwl-editor__content:focus-visible { - outline: 2px solid Highlight; - outline-offset: -2px; - } -} - .cwl-editor__content > * + * { margin-top: 0.75em; } @@ -349,6 +259,94 @@ padding-top: calc(16px + 1.6em); } +@media (forced-colors: active) { + .cwl-editor { + color: CanvasText; + background: Canvas; + border-color: CanvasText; + } + + .cwl-toolbar, + .cwl-collaboration-status { + background: ButtonFace; + color: ButtonText; + border-bottom-color: CanvasText; + } + + .cwl-tb-group { + border-right-color: CanvasText; + } + + .cwl-tb-btn { + background: ButtonFace; + color: ButtonText; + border-color: ButtonText; + } + + .cwl-tb-btn:hover:not(:disabled) { + background: Highlight; + color: HighlightText; + } + + .cwl-tb-btn:focus-visible { + outline: 2px solid Highlight; + outline-offset: 2px; + } + + .cwl-tb-btn.is-active { + background: Highlight; + border-color: Highlight; + color: HighlightText; + } + + .cwl-tb-btn:disabled { + opacity: 1; + color: GrayText; + border-color: GrayText; + } + + .cwl-editor__content:focus-visible { + outline: 2px solid Highlight; + outline-offset: -2px; + } + + .cwl-editor__content a { + color: LinkText; + } + + .cwl-editor__content blockquote { + border-left-color: CanvasText; + color: GrayText; + } + + .cwl-editor__content .is-editor-empty:first-child::before { + color: GrayText; + } + + .cwl-editor__content code, + .cwl-editor__content pre, + .cwl-editor__content th, + .cwl-editor__content td { + border-color: CanvasText; + } + + .cwl-editor__content code, + .cwl-editor__content pre, + .cwl-editor__content th { + background: Canvas; + color: CanvasText; + } + + .collaboration-cursor__caret { + border-left-color: Highlight; + } + + .collaboration-cursor__label { + background: Highlight; + color: HighlightText; + } +} + @media print { .cwl-editor { --cwl-fg: #000000; From 940c2e1341ad46c66f1cdc4cfab3603fd7dfc1aa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 03:08:24 +0900 Subject: [PATCH 09/29] test(a11y): scope forced-colors order before print media --- src/forcedColorsStyles.test.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/forcedColorsStyles.test.ts b/src/forcedColorsStyles.test.ts index 3442f374..809bbf06 100644 --- a/src/forcedColorsStyles.test.ts +++ b/src/forcedColorsStyles.test.ts @@ -4,19 +4,23 @@ import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; const styles = readFileSync(resolve(process.cwd(), 'src/styles.css'), 'utf8'); -const forcedColorsIndex = styles.indexOf('@media (forced-colors: active)'); +const printIndex = styles.indexOf('@media print'); +const screenStyles = printIndex >= 0 ? styles.slice(0, printIndex) : styles; +const forcedColorsIndex = screenStyles.indexOf('@media (forced-colors: active)'); const forcedColorsStyles = - forcedColorsIndex >= 0 ? styles.slice(forcedColorsIndex) : ''; -const forcedColorsBlocks = styles.match(/@media \(forced-colors: active\)/gu) ?? []; + forcedColorsIndex >= 0 ? screenStyles.slice(forcedColorsIndex) : ''; +const forcedColorsBlocks = + screenStyles.match(/@media \(forced-colors: active\)/gu) ?? []; const lastBaseStateIndex = Math.max( - styles.lastIndexOf('.cwl-editor__content a {'), - styles.lastIndexOf('.cwl-editor__content blockquote {'), - styles.lastIndexOf('.cwl-collaboration-status {'), - styles.lastIndexOf('.collaboration-cursor__label {'), + screenStyles.lastIndexOf('.cwl-editor__content a {'), + screenStyles.lastIndexOf('.cwl-editor__content blockquote {'), + screenStyles.lastIndexOf('.cwl-collaboration-status {'), + screenStyles.lastIndexOf('.collaboration-cursor__label {'), ); describe('forced-colors stylesheet contract', () => { - it('defines one final forced-colors override layer after the base state rules', () => { + it('defines one final screen forced-colors override layer after base state rules', () => { + expect(printIndex).toBeGreaterThan(-1); expect(forcedColorsIndex).toBeGreaterThan(-1); expect(forcedColorsBlocks).toHaveLength(1); expect(lastBaseStateIndex).toBeGreaterThan(-1); From 862db1d4ec2f910fcc65dbd3aabdfcd49877d98d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 03:11:20 +0900 Subject: [PATCH 10/29] test(a11y): distinguish media overrides from later screen rules --- src/forcedColorsStyles.test.ts | 44 +++++++++++++++++++++++++++------- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/src/forcedColorsStyles.test.ts b/src/forcedColorsStyles.test.ts index 809bbf06..8d19f082 100644 --- a/src/forcedColorsStyles.test.ts +++ b/src/forcedColorsStyles.test.ts @@ -7,24 +7,50 @@ const styles = readFileSync(resolve(process.cwd(), 'src/styles.css'), 'utf8'); const printIndex = styles.indexOf('@media print'); const screenStyles = printIndex >= 0 ? styles.slice(0, printIndex) : styles; const forcedColorsIndex = screenStyles.indexOf('@media (forced-colors: active)'); + +const findCssBlockEnd = (source: string, startIndex: number): number => { + const openingBrace = source.indexOf('{', startIndex); + if (openingBrace < 0) return -1; + + let depth = 0; + for (let index = openingBrace; index < source.length; index += 1) { + if (source[index] === '{') depth += 1; + if (source[index] !== '}') continue; + depth -= 1; + if (depth === 0) return index + 1; + } + return -1; +}; + +const forcedColorsEnd = + forcedColorsIndex >= 0 ? findCssBlockEnd(screenStyles, forcedColorsIndex) : -1; const forcedColorsStyles = - forcedColorsIndex >= 0 ? screenStyles.slice(forcedColorsIndex) : ''; + forcedColorsEnd > forcedColorsIndex + ? screenStyles.slice(forcedColorsIndex, forcedColorsEnd) + : ''; +const beforeForcedColorsStyles = + forcedColorsIndex >= 0 ? screenStyles.slice(0, forcedColorsIndex) : screenStyles; +const afterForcedColorsStyles = + forcedColorsEnd >= 0 ? screenStyles.slice(forcedColorsEnd) : screenStyles; const forcedColorsBlocks = screenStyles.match(/@media \(forced-colors: active\)/gu) ?? []; -const lastBaseStateIndex = Math.max( - screenStyles.lastIndexOf('.cwl-editor__content a {'), - screenStyles.lastIndexOf('.cwl-editor__content blockquote {'), - screenStyles.lastIndexOf('.cwl-collaboration-status {'), - screenStyles.lastIndexOf('.collaboration-cursor__label {'), -); +const baseStateSelectors = [ + '.cwl-editor__content a {', + '.cwl-editor__content blockquote {', + '.cwl-collaboration-status {', + '.collaboration-cursor__label {', +]; describe('forced-colors stylesheet contract', () => { it('defines one final screen forced-colors override layer after base state rules', () => { expect(printIndex).toBeGreaterThan(-1); expect(forcedColorsIndex).toBeGreaterThan(-1); + expect(forcedColorsEnd).toBeGreaterThan(forcedColorsIndex); expect(forcedColorsBlocks).toHaveLength(1); - expect(lastBaseStateIndex).toBeGreaterThan(-1); - expect(forcedColorsIndex).toBeGreaterThan(lastBaseStateIndex); + for (const selector of baseStateSelectors) { + expect(beforeForcedColorsStyles).toContain(selector); + expect(afterForcedColorsStyles).not.toContain(selector); + } }); it('defines a forced-colors boundary using system colors', () => { From f522d1ff51dc2b74bc4de1999f8ffe659c6e3b84 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 10:32:07 +0900 Subject: [PATCH 11/29] fix(a11y): unify forced-colors layer into one cascade-effective block - Single screen @media (forced-colors: active) layer after base state rules: editor content focus = CanvasText outline-color (guaranteed canvas contrast), toolbar focus/active = Highlight family, structure = CanvasText - editorFocusStyles contract asserts the unified cascade; designTokens and forcedColors contracts both satisfied (17 tests pass) --- src/editorFocusStyles.test.ts | 18 ++++++------------ src/styles.css | 9 ++++----- 2 files changed, 10 insertions(+), 17 deletions(-) diff --git a/src/editorFocusStyles.test.ts b/src/editorFocusStyles.test.ts index 194821a5..d18894bb 100644 --- a/src/editorFocusStyles.test.ts +++ b/src/editorFocusStyles.test.ts @@ -24,25 +24,19 @@ describe('editable surface focus stylesheet contract', () => { ); expect(ordinaryFocusRule).not.toBeNull(); - // The forced-colors boundary must cascade after the theme-colored rule so - // the system-color override is effective, not dead code. + // The single screen forced-colors layer must cascade after the + // theme-colored rule so the system-color override is effective. expect(forcedColorsIndex).toBeGreaterThan( ordinaryFocusRule?.index ?? Number.MAX_SAFE_INTEGER, ); + // Editor content keeps guaranteed canvas contrast under forced colors. expect(forcedColorsStyles).toMatch( - /\.cwl-editor__content:focus-visible\s*\{[^}]*outline:\s*2px\s+solid\s+Highlight\s*;[^}]*outline-offset:\s*-2px\s*;/u, - ); - }); - - it('uses one consistent system color for every focus indicator in forced-colors mode', () => { - expect(forcedColorsStyles).toMatch( - /\.cwl-tb-btn:focus-visible\s*\{[^}]*outline:\s*2px\s+solid\s+Highlight\s*;[^}]*outline-offset:\s*2px\s*;/u, + /\.cwl-editor__content:focus-visible\s*\{[^}]*outline-color:\s*CanvasText\s*;/u, ); - // No competing partial override may resurrect a second focus color after - // the comprehensive forced-colors contract. + // No competing shorthand may resurrect a second editor-content focus color. expect(forcedColorsStyles).not.toMatch( - /\.cwl-editor__content:focus-visible\s*\{[^}]*outline-color:\s*CanvasText\s*;/u, + /\.cwl-editor__content:focus-visible\s*\{[^}]*outline:\s*2px\s+solid\s+(?:Highlight|var\(--cwl-accent\))\s*;/u, ); }); }); diff --git a/src/styles.css b/src/styles.css index 318e081f..93851d13 100644 --- a/src/styles.css +++ b/src/styles.css @@ -262,6 +262,10 @@ border-color: CanvasText; } + .cwl-editor__content:focus-visible { + outline-color: CanvasText; + } + .cwl-toolbar, .cwl-collaboration-status { background: ButtonFace; @@ -301,11 +305,6 @@ border-color: GrayText; } - .cwl-editor__content:focus-visible { - outline: 2px solid Highlight; - outline-offset: -2px; - } - .cwl-editor__content a { color: LinkText; } From 48fffe12bc596dbf401facc0b05aef99cc060f1c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 22:02:58 -0700 Subject: [PATCH 12/29] test(accessibility): drive forced-colors focus by keyboard --- tests/browser/specs/forced-colors.browser.spec.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/browser/specs/forced-colors.browser.spec.ts b/tests/browser/specs/forced-colors.browser.spec.ts index b5c5d52d..9d4b589e 100644 --- a/tests/browser/specs/forced-colors.browser.spec.ts +++ b/tests/browser/specs/forced-colors.browser.spec.ts @@ -58,7 +58,9 @@ test('preserves state and structural cues in forced colors', async ({ page }) => await page.evaluate(() => matchMedia('(forced-colors: active)').matches), ).toBe(true); - await page.getByRole('button', { name: 'Plain' }).focus(); + const plainButton = page.getByRole('button', { name: 'Plain' }); + await page.keyboard.press('Tab'); + await expect(plainButton).toBeFocused(); const evidence = await page.evaluate(() => { const get = (selector: string): T => { @@ -134,8 +136,13 @@ test('preserves state and structural cues in forced colors', async ({ page }) => labelVisible: true, }); + const activeButton = page.getByRole('button', { name: 'Active' }); + await page.keyboard.press('Tab'); + await expect(activeButton).toBeFocused(); + const editable = page.locator('.cwl-editor__content'); - await editable.focus(); + await page.keyboard.press('Tab'); + await expect(editable).toBeFocused(); const editableFocusEvidence = await editable.evaluate((element) => { const style = getComputedStyle(element); return { From a10b7c9136941bfc49fd5e3170f0ee705ba500ad Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 14:46:07 +0900 Subject: [PATCH 13/29] test(accessibility): run forced-colors browser coverage --- tests/browser/playwright.config.ts | 2 +- tests/browser/specs/forced-colors.browser.spec.ts | 13 +++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/tests/browser/playwright.config.ts b/tests/browser/playwright.config.ts index d7d4c2a2..d3ffec9b 100644 --- a/tests/browser/playwright.config.ts +++ b/tests/browser/playwright.config.ts @@ -2,7 +2,7 @@ import { defineConfig, devices } from '@playwright/test'; const HARNESS_ORIGIN = 'http://127.0.0.1:4173'; const HARNESS_URL = `${HARNESS_ORIGIN}/tests/browser/harness.html`; -const ENGINE_BROWSER_SPECS = /(?:clipboard|focus|print)\.browser\.spec\.ts/u; +const ENGINE_BROWSER_SPECS = /(?:clipboard|focus|forced-colors|print)\.browser\.spec\.ts/u; export default defineConfig({ testDir: './specs', diff --git a/tests/browser/specs/forced-colors.browser.spec.ts b/tests/browser/specs/forced-colors.browser.spec.ts index 9d4b589e..974ad0d7 100644 --- a/tests/browser/specs/forced-colors.browser.spec.ts +++ b/tests/browser/specs/forced-colors.browser.spec.ts @@ -32,6 +32,7 @@ test.beforeEach(async ({ page }) => { +
Connected
@@ -53,15 +54,15 @@ test.beforeEach(async ({ page }) => { }); test('preserves state and structural cues in forced colors', async ({ page }) => { + const plainButton = page.getByRole('button', { name: 'Plain' }); + await plainButton.focus(); + await expect(plainButton).toBeFocused(); + await page.emulateMedia({ forcedColors: 'active' }); expect( await page.evaluate(() => matchMedia('(forced-colors: active)').matches), ).toBe(true); - const plainButton = page.getByRole('button', { name: 'Plain' }); - await page.keyboard.press('Tab'); - await expect(plainButton).toBeFocused(); - const evidence = await page.evaluate(() => { const get = (selector: string): T => { const element = document.querySelector(selector); @@ -137,11 +138,11 @@ test('preserves state and structural cues in forced colors', async ({ page }) => }); const activeButton = page.getByRole('button', { name: 'Active' }); - await page.keyboard.press('Tab'); + await activeButton.focus(); await expect(activeButton).toBeFocused(); const editable = page.locator('.cwl-editor__content'); - await page.keyboard.press('Tab'); + await editable.focus(); await expect(editable).toBeFocused(); const editableFocusEvidence = await editable.evaluate((element) => { const style = getComputedStyle(element); From eb3618c521029a84eb3ebd2bedd0b89991cc64b2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 15:06:12 +0900 Subject: [PATCH 14/29] fix(accessibility): respect browser harness ownership --- tests/browser/playwright.config.ts | 2 +- tests/browser/specs/forced-colors.browser.spec.ts | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/browser/playwright.config.ts b/tests/browser/playwright.config.ts index d3ffec9b..d7d4c2a2 100644 --- a/tests/browser/playwright.config.ts +++ b/tests/browser/playwright.config.ts @@ -2,7 +2,7 @@ import { defineConfig, devices } from '@playwright/test'; const HARNESS_ORIGIN = 'http://127.0.0.1:4173'; const HARNESS_URL = `${HARNESS_ORIGIN}/tests/browser/harness.html`; -const ENGINE_BROWSER_SPECS = /(?:clipboard|focus|forced-colors|print)\.browser\.spec\.ts/u; +const ENGINE_BROWSER_SPECS = /(?:clipboard|focus|print)\.browser\.spec\.ts/u; export default defineConfig({ testDir: './specs', diff --git a/tests/browser/specs/forced-colors.browser.spec.ts b/tests/browser/specs/forced-colors.browser.spec.ts index 974ad0d7..5d85b13f 100644 --- a/tests/browser/specs/forced-colors.browser.spec.ts +++ b/tests/browser/specs/forced-colors.browser.spec.ts @@ -54,15 +54,15 @@ test.beforeEach(async ({ page }) => { }); test('preserves state and structural cues in forced colors', async ({ page }) => { - const plainButton = page.getByRole('button', { name: 'Plain' }); - await plainButton.focus(); - await expect(plainButton).toBeFocused(); - await page.emulateMedia({ forcedColors: 'active' }); expect( await page.evaluate(() => matchMedia('(forced-colors: active)').matches), ).toBe(true); + const plainButton = page.getByRole('button', { name: 'Plain' }); + await page.keyboard.press('Tab'); + await expect(plainButton).toBeFocused(); + const evidence = await page.evaluate(() => { const get = (selector: string): T => { const element = document.querySelector(selector); @@ -138,11 +138,11 @@ test('preserves state and structural cues in forced colors', async ({ page }) => }); const activeButton = page.getByRole('button', { name: 'Active' }); - await activeButton.focus(); + await page.keyboard.press('Tab'); await expect(activeButton).toBeFocused(); const editable = page.locator('.cwl-editor__content'); - await editable.focus(); + await page.keyboard.press('Tab'); await expect(editable).toBeFocused(); const editableFocusEvidence = await editable.evaluate((element) => { const style = getComputedStyle(element); From 3378ac41888f621c6b6e18e8fb5e70d06da746f2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 05:56:24 +0900 Subject: [PATCH 15/29] test(accessibility): use keyboard forced-colors journey Replace programmatic target focus with Tab navigation so the browser evidence exercises the actual focus-visible interaction path. Signed-off-by: Seongho Bae Commit-Message-Assisted-by: Claude (via Claude Code) --- tests/browser/specs/forced-colors.browser.spec.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/browser/specs/forced-colors.browser.spec.ts b/tests/browser/specs/forced-colors.browser.spec.ts index d2159ade..984b053b 100644 --- a/tests/browser/specs/forced-colors.browser.spec.ts +++ b/tests/browser/specs/forced-colors.browser.spec.ts @@ -25,6 +25,7 @@ test.beforeEach(async ({ page }) => { await page.addStyleTag({ url: STYLES_URL }); await page.locator('#harness').evaluate((element) => { element.innerHTML = ` +
`; + element.querySelector('[data-remote-edit]')!.append( + await window.renderInkspanCursorProbe({ name: 'Remote', color: '#abcdef' }), + ); }); expect(rejectedExternalRequests).toEqual([]); }); @@ -61,6 +62,9 @@ test('preserves state and structural cues in forced colors', async ({ browserName === 'webkit' && process.platform === 'darwin' ? 'Alt+Tab' : 'Tab'; + const label = page.locator('.collaboration-cursor__label'); + await expect(label).toHaveCSS('background-color', 'rgb(171, 205, 239)'); + await expect(label).toHaveCSS('color', 'rgb(0, 0, 0)'); await page.emulateMedia({ forcedColors: 'active' }); expect( await page.evaluate(() => matchMedia('(forced-colors: active)').matches), @@ -134,6 +138,10 @@ test('preserves state and structural cues in forced colors', async ({ activeColorAdjustment: activeStyle.forcedColorAdjust, disabledColorAdjustment: disabledStyle.forcedColorAdjust, labelColorAdjustment: labelStyle.forcedColorAdjust, + labelColor: labelStyle.color, + labelBackground: labelStyle.backgroundColor, + highlightText: activeStyle.color, + highlightBackground: activeStyle.backgroundColor, }; }); @@ -146,6 +154,8 @@ test('preserves state and structural cues in forced colors', async ({ fullPage: true, }); expect(evidence.quoteColor).toBe(evidence.canvasTextColor); + expect(evidence.labelColor).toBe(evidence.highlightText); + expect(evidence.labelBackground).toBe(evidence.highlightBackground); expect(evidence).toMatchObject({ editorBorderStyle: 'solid', diff --git a/tests/browser/vite.config.ts b/tests/browser/vite.config.ts index f0bb0d92..91adbfdc 100644 --- a/tests/browser/vite.config.ts +++ b/tests/browser/vite.config.ts @@ -16,6 +16,9 @@ export default defineConfig({ ? resolve(dirname(packageEntry), 'cwl-editor.css') : resolve(repositoryRoot, 'dist/cwl-editor.css'), 'inkspan-browser-under-test': packageEntry, + 'inkspan-collaboration-under-test': configuredPackageEntry + ? resolve(dirname(packageEntry), 'cwl-collaboration.js') + : resolve(repositoryRoot, 'src/collaboration/index.ts'), 'inkspan-autosave-under-test': configuredPackageEntry ? resolve(dirname(packageEntry), 'cwl-autosave.js') : resolve(repositoryRoot, 'src/autosave/package.ts'), From 7a95e5a042d3ef382a5f778a1d50a0a270a4e353 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 20:38:31 +0900 Subject: [PATCH 28/29] fix: preserve readable system highlight labels Signed-off-by: Seongho Bae --- docs/PRD.md | 2 +- docs/TRD.md | 4 ++ .../forced-colors-readable-content.md | 51 ++++++++++++++++++- docs/product-technical-gap-baseline.md | 2 +- src/forcedColorsStyles.test.ts | 7 +-- src/styles.css | 4 +- 6 files changed, 62 insertions(+), 8 deletions(-) diff --git a/docs/PRD.md b/docs/PRD.md index da49c849..a6c778f1 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -93,7 +93,7 @@ The product promise is: **author, convert, collaborate, and prove document chang - Native controls, focus behavior, keyboard parity, truthful `aria-keyshortcuts` metadata, non-color status semantics, and host-facing lifecycle state support WCAG-oriented embedding. - Toolbar shortcut metadata must reflect repository-level shipped behavior, including host/editor bindings such as link editing, rather than only extension-local defaults. - Active PR / Proposed: at a 320 CSS-pixel viewport, every toolbar control must remain fully visible in normal and forced colors. A page without horizontal scrolling is insufficient evidence if controls are clipped inside the editor; see the [toolbar reflow record](doctoring/toolbar-reflow.md). -- Active PR / Proposed: empty-editor guidance and quoted content use readable document text colors in forced-colors mode, while disabled controls retain a distinct inactive appearance; see the [visual inspection record](doctoring/forced-colors-readable-content.md). +- Active PR / Proposed: empty-editor guidance and quoted content use readable document text colors in forced-colors mode, while disabled controls retain a distinct inactive appearance. Selected and hovered controls must keep their labels readable immediately after interaction; see the [visual inspection record](doctoring/forced-colors-readable-content.md). - Application-visible saving/conflict/recovery messages must be derivable from programmatic state without Inkspan prescribing untranslated user-facing copy. - Export/print surfaces must not rely on color alone or inaccessible interaction-only state where the corresponding product surface exists. diff --git a/docs/TRD.md b/docs/TRD.md index 5797318d..e197f7c6 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -119,6 +119,10 @@ Active PR / Proposed toolbar reflow lets controls within an oversized group wrap Active PR / Proposed forced-colors presentation uses `CanvasText` for readable placeholder guidance and blockquote content, retaining `GrayText` only for disabled toolbar controls. Browser checks observe the placeholder pseudo-element and quoted text against the selected document text color and preserve keyboard-focus screenshots. The [visual inspection record](doctoring/forced-colors-readable-content.md) distinguishes stylesheet/browser observations from physical OS palettes or whole-product WCAG conformance. +Within that forced-colors layer, toolbar color transitions are disabled to avoid temporarily interpolating away from system colors. Only hovered/active toolbar buttons and collaboration cursor labels use `forced-color-adjust: none`, paired with the user's `Highlight`/`HighlightText` colors, so automatic text backplates cannot conceal their labels. Generic controls, disabled unselected buttons and authored document content retain automatic adjustment; no hard-coded palette or editor-wide opt-out is introduced. + +The two forced-mode cursor-label color declarations override the renderer's normal inline colors. The browser fixture uses the selected source/package collaboration renderer and checks normal colors before the forced system pair; normal mode and cursor input sanitization remain unchanged. + Toolbar shortcut metadata is implemented on protected `main`. Shipped keyboard behavior, focus behavior, native controls, `aria-pressed`, `aria-keyshortcuts`, programmatic save/conflict state, and visible shortcut documentation must agree. Repository-level keyboard behavior outranks extension-local defaults when determining metadata. Status must not depend on color alone. Inkspan exposes machine state sufficient for host WCAG-oriented messaging while leaving localization and application-specific live-region policy to the host. Accessible editor placeholder semantics are implemented on protected `main`. Standalone and collaborative textbox surfaces expose the same normalized non-blank host-supplied visual placeholder through `aria-placeholder`; whitespace-only guidance is omitted, and placeholder updates do not replace the current TipTap editor or host-owned Yjs binding. `aria-labelledby`/`aria-label` remain the accessible-name authority and placeholder guidance never grants editability. diff --git a/docs/doctoring/forced-colors-readable-content.md b/docs/doctoring/forced-colors-readable-content.md index e8ac2a9b..30cc8434 100644 --- a/docs/doctoring/forced-colors-readable-content.md +++ b/docs/doctoring/forced-colors-readable-content.md @@ -32,10 +32,55 @@ needed. CONTRACTS and ADR ownership remain unchanged. Keeping disabled-text colors on readable content was rejected because it gave the wrong semantic cue and produced faint guidance in the inspected rendering. -Hard-coded black/white colors or opting out of forced colors were rejected +Hard-coded black/white colors or globally opting out of forced colors were rejected because they can conflict with the user's selected palette. Text identity and structural cues distinguish placeholders/quotes without borrowing disabled state. +## Follow-up finding: selected labels disappear + +The nine-case prebuilt source-browser run at +`83783803d76c47694f4022ac7cb760f5e83815bf` passed its semantic and geometry +assertions, but direct screenshot inspection found Chromium's real pressed +Bold button blank. The smaller fixture also showed blank active/remote labels. +This visual failure prevents UI acceptance despite the passing test count. + +At diagnostic head `a9f2775669a5346132ed5f4994dbb6e13e898271`, the real +button's initial foreground and background both computed to white during its +120 ms color transition. After animation completion, the background used the +system highlight color but a white text backplate still obscured the label. +The strengthened contract at `f6dbf5257a47b7ee05747306e924914292616348` +failed four stylesheet checks and all three selected real-engine cases. + +Disabling only toolbar transitions at +`7f6edc7770104cdbd9d7e09aface53f9bbb26b92` removed the interpolated white +background but retained the blank Chromium label. Its screenshot, paint values +and failed check are preserved as an incomplete alternative, not a fix. + +The repair retains that forced-mode-only transition removal and sets +`forced-color-adjust: none` only on the three existing system-highlight pairs: +hovered buttons, active buttons and collaboration cursor labels. Each still uses +the user's `Highlight` and `HighlightText`; none uses a hard-coded palette. +The editor, document text and generic controls retain automatic adjustment. +This narrow override corrects the observed backplate conflict under the CSS +Color Adjustment guidance; a whole-editor opt-out would be unnecessarily broad. + +The actual collaboration renderer also supplies inline foreground/background +colors. At `0c29f3fed7c12a18ad032eb73273af99d7e5788a`, using that renderer +instead of hand-written cursor markup exposed another three-engine failure: +the narrow adjustment exception retained the author's colors. The two system +color declarations therefore use `!important` inside the forced-colors layer +to override those normal inline values. The browser fixture imports the selected +source or packaged collaboration entrypoint lazily, verifies its normal colors, +then requires the forced label to match the active system-highlight pair. +This does not change the renderer, its sanitization, or normal-mode colors. + +Regression evidence must include immediate and settled selected-label screenshots, +hovered labels, keyboard focus and disabled-state cues. CSS/computed-style checks +alone cannot prove painted text is readable. The diagnostic prebuilt configuration +keeps normal test/assertion/startup deadlines, retries and browser projects but +runs the build separately after the standard command exceeded its startup limit. +It is not a passing standard startup run, packed-release proof or a latency claim. + ## Runnable verification and limits ```sh @@ -64,3 +109,7 @@ https://www.w3.org/TR/css-color-4/#css-system-colors World Wide Web Consortium. (n.d.). *Understanding Success Criterion 1.4.3: Contrast (Minimum)*. Web Accessibility Initiative. Retrieved September 6, 2026, from https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html + +World Wide Web Consortium. (2025, December 16). *CSS Color Adjustment Module +Level 1* (Candidate Recommendation Snapshot, Section 3.2). +https://www.w3.org/TR/2025/CR-css-color-adjust-1-20251216/#forced-color-adjust-prop diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 0cd0d6fb..d0fa59e8 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -157,7 +157,7 @@ runtime boundary: [toolbar reflow](doctoring/toolbar-reflow.md) records the reproducible clipping failure and the Active PR / Proposed repair; - direct visual inspection of readable guidance and document content in forced - colors, alongside disabled-state and keyboard-focus cues; the + colors, alongside immediate selected/hovered labels, disabled-state and keyboard-focus cues; the [readable-content record](doctoring/forced-colors-readable-content.md) records the Active PR / Proposed correction without claiming whole-product conformance; - clear rollback and incident paths for ambiguous publication or persistence diff --git a/src/forcedColorsStyles.test.ts b/src/forcedColorsStyles.test.ts index be7451b7..37e7642d 100644 --- a/src/forcedColorsStyles.test.ts +++ b/src/forcedColorsStyles.test.ts @@ -86,8 +86,9 @@ describe('forced-colors stylesheet contract', () => { expect(blockStart).toBeGreaterThan(-1); const block = forcedColorsStyles.slice(blockStart, findCssBlockEnd(forcedColorsStyles, blockStart)); expect(block).toMatch(/forced-color-adjust:\s*none\s*;/u); - expect(block).toMatch(/[;{]\s*background:\s*Highlight\s*;/u); - expect(block).toMatch(/[;{]\s*color:\s*HighlightText\s*;/u); + const priority = selector === '.collaboration-cursor__label' ? ' !important' : ''; + expect(block).toContain(`background: Highlight${priority};`); + expect(block).toContain(`color: HighlightText${priority};`); }); it('does not use opacity as the only disabled-state cue', () => { @@ -121,7 +122,7 @@ describe('forced-colors stylesheet contract', () => { /\.collaboration-cursor__caret\s*\{[^}]*border-left-color:\s*Highlight\s*;/u, ); expect(forcedColorsStyles).toMatch( - /\.collaboration-cursor__label\s*\{[^}]*background:\s*Highlight\s*;[^}]*color:\s*HighlightText\s*;/u, + /\.collaboration-cursor__label\s*\{[^}]*background:\s*Highlight\s*!important\s*;[^}]*color:\s*HighlightText\s*!important\s*;/u, ); }); diff --git a/src/styles.css b/src/styles.css index d7ccfc7b..9b0ceda8 100644 --- a/src/styles.css +++ b/src/styles.css @@ -341,8 +341,8 @@ } .collaboration-cursor__label { - background: Highlight; - color: HighlightText; + background: Highlight !important; + color: HighlightText !important; forced-color-adjust: none; } } From 85e64444366927f93260b00ac9215f0f7f23b24a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 20:40:03 +0900 Subject: [PATCH 29/29] test: record forced-color adjustment capability per engine Signed-off-by: Seongho Bae --- .../forced-colors-readable-content.md | 7 ++++++ .../specs/forced-colors.browser.spec.ts | 24 ++++++++++--------- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/docs/doctoring/forced-colors-readable-content.md b/docs/doctoring/forced-colors-readable-content.md index 30cc8434..7aea76c1 100644 --- a/docs/doctoring/forced-colors-readable-content.md +++ b/docs/doctoring/forced-colors-readable-content.md @@ -101,6 +101,13 @@ claim physical-device/OS coverage from emulation, or treat these narrow checks as whole-product WCAG certification. Final run outcomes belong in dated PR evidence. Failed/partial runs stay retained rather than being normalized away. +The inspected WebKit build emulates the media query but does not expose the +`forced-color-adjust` property. Record its live `CSS.supports` result and empty +computed property rather than claiming support. All color, geometry, focus, +interaction and screenshot checks still run in that engine; no test is skipped +for this capability difference. Chromium and Firefox also report their actual +capabilities rather than receiving browser-name-based exceptions. + ## Standards basis World Wide Web Consortium. (2026). *CSS Color Module Level 4: CSS system colors*. diff --git a/tests/browser/specs/forced-colors.browser.spec.ts b/tests/browser/specs/forced-colors.browser.spec.ts index bcd35064..680f3bb1 100644 --- a/tests/browser/specs/forced-colors.browser.spec.ts +++ b/tests/browser/specs/forced-colors.browser.spec.ts @@ -134,10 +134,11 @@ test('preserves state and structural cues in forced colors', async ({ labelVisible: labelStyle.display !== 'none' && labelStyle.visibility !== 'hidden', quoteColor: getComputedStyle(quote).color, canvasTextColor: editorStyle.color, - editorColorAdjustment: editorStyle.forcedColorAdjust, - activeColorAdjustment: activeStyle.forcedColorAdjust, - disabledColorAdjustment: disabledStyle.forcedColorAdjust, - labelColorAdjustment: labelStyle.forcedColorAdjust, + supportsColorAdjustment: CSS.supports('forced-color-adjust', 'none'), + editorColorAdjustment: editorStyle.getPropertyValue('forced-color-adjust'), + activeColorAdjustment: activeStyle.getPropertyValue('forced-color-adjust'), + disabledColorAdjustment: disabledStyle.getPropertyValue('forced-color-adjust'), + labelColorAdjustment: labelStyle.getPropertyValue('forced-color-adjust'), labelColor: labelStyle.color, labelBackground: labelStyle.backgroundColor, highlightText: activeStyle.color, @@ -173,10 +174,10 @@ test('preserves state and structural cues in forced colors', async ({ cellBorderWidth: '1px', caretBorderWidth: '2px', labelVisible: true, - editorColorAdjustment: 'auto', - activeColorAdjustment: 'none', - disabledColorAdjustment: 'auto', - labelColorAdjustment: 'none', + editorColorAdjustment: evidence.supportsColorAdjustment ? 'auto' : '', + activeColorAdjustment: evidence.supportsColorAdjustment ? 'none' : '', + disabledColorAdjustment: evidence.supportsColorAdjustment ? 'auto' : '', + labelColorAdjustment: evidence.supportsColorAdjustment ? 'none' : '', }); const activeButton = page.getByRole('button', { name: 'Active' }); @@ -259,7 +260,8 @@ for (const forcedColors of ['none', 'active'] as const) { color: style.color, background: style.backgroundColor, transitionDuration: style.transitionDuration, - forcedColorAdjust: style.forcedColorAdjust, + supportsColorAdjustment: CSS.supports('forced-color-adjust', 'none'), + forcedColorAdjust: style.getPropertyValue('forced-color-adjust'), }; }; const bold = toolbar.getByRole('button', { name: 'Bold (Ctrl/Cmd+B)', exact: true }); @@ -279,7 +281,7 @@ for (const forcedColors of ['none', 'active'] as const) { }); if (forcedColors === 'active') { expect(initialPaint.transitionDuration).toBe('0s'); - expect(initialPaint.forcedColorAdjust).toBe('none'); + expect(initialPaint.forcedColorAdjust).toBe(initialPaint.supportsColorAdjustment ? 'none' : ''); expect(initialPaint.color).not.toBe(initialPaint.background); const italic = toolbar.getByRole('button', { name: 'Italic (Ctrl/Cmd+I)', exact: true }); await italic.hover(); @@ -289,7 +291,7 @@ for (const forcedColors of ['none', 'active'] as const) { fullPage: true, }); expect(hoverPaint.transitionDuration).toBe('0s'); - expect(hoverPaint.forcedColorAdjust).toBe('none'); + expect(hoverPaint.forcedColorAdjust).toBe(hoverPaint.supportsColorAdjustment ? 'none' : ''); expect(hoverPaint.color).not.toBe(hoverPaint.background); } });