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
+
+
+ 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',
+ });
+});