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, + ); + }); +}); diff --git a/src/forcedColorsStyles.test.ts b/src/forcedColorsStyles.test.ts new file mode 100644 index 00000000..8d19f082 --- /dev/null +++ b/src/forcedColorsStyles.test.ts @@ -0,0 +1,109 @@ +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 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 = + 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 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); + for (const selector of baseStateSelectors) { + expect(beforeForcedColorsStyles).toContain(selector); + expect(afterForcedColorsStyles).not.toContain(selector); + } + }); + + 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, + ); + }); +}); diff --git a/src/styles.css b/src/styles.css index 0970a931..a406bb02 100644 --- a/src/styles.css +++ b/src/styles.css @@ -106,12 +106,6 @@ cursor: not-allowed; } -@media (forced-colors: active) { - .cwl-tb-btn:focus-visible { - outline-color: CanvasText; - } -} - .cwl-editor__surface { overflow-y: auto; max-height: 70vh; @@ -129,6 +123,11 @@ outline: none; } +.cwl-editor__content:focus-visible { + outline: 2px solid var(--cwl-accent); + outline-offset: -2px; +} + .cwl-editor__content > * + * { margin-top: 0.75em; } @@ -260,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; 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..b5c5d52d --- /dev/null +++ b/tests/browser/specs/forced-colors.browser.spec.ts @@ -0,0 +1,150 @@ +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, + }); + + 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', + }); +});