Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 22 additions & 13 deletions src/editorFocusStyles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,30 +4,39 @@ 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 visibility', () => {
it('preserves a visible keyboard focus indicator on the textbox surface', () => {
expect(styles).not.toMatch(
/\.cwl-editor__content:focus\s*\{[^}]*outline:\s*none\s*;/u,
);
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,
);
describe('editable surface focus stylesheet contract', () => {
it('replaces the removed browser outline with a visible keyboard focus cue', () => {
const ordinaryRule = /\.cwl-editor__content:focus-visible\s*\{[^}]*outline:\s*2px\s+solid\s+var\(--cwl-accent\)\s*;[^}]*outline-offset:\s*-2px\s*;/u;

expect(styles).toMatch(ordinaryRule);
// Exactly one ordinary rule keeps the forced-colors override authoritative.
expect(styles.match(new RegExp(ordinaryRule.source, 'gu'))?.length).toBe(1);
});

it('keeps the editable focus indicator visible in forced-colors mode', () => {
it('keeps the editable focus cue visible in forced-colors mode', () => {
const ordinaryFocusRule =
/\.cwl-editor__content:focus-visible\s*\{[^}]*outline:\s*2px\s+solid\s+var\(--cwl-accent\)\s*;[^}]*\}/u.exec(
/\.cwl-editor__content:focus-visible\s*\{[^}]*outline:\s*2px\s+solid\s+var\(--cwl-accent\)\s*;[^}]*outline-offset:\s*-2px\s*;/u.exec(
styles,
);
expect(ordinaryFocusRule).not.toBeNull();

const forcedColorsIndex = styles.indexOf('@media (forced-colors: active)');
expect(forcedColorsIndex).toBeGreaterThan(ordinaryFocusRule?.index ?? Number.MAX_SAFE_INTEGER);
const forcedColorsStyles = styles.slice(forcedColorsIndex);
// 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-color:\s*CanvasText\s*;/u,
);
// No competing shorthand may resurrect a second editor-content focus color.
expect(forcedColorsStyles).not.toMatch(
/\.cwl-editor__content:focus-visible\s*\{[^}]*outline:\s*2px\s+solid\s+(?:Highlight|var\(--cwl-accent\))\s*;/u,
);
});
});
109 changes: 109 additions & 0 deletions src/forcedColorsStyles.test.ts
Original file line number Diff line number Diff line change
@@ -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,
);
});
});
94 changes: 87 additions & 7 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -124,13 +124,6 @@
outline-offset: -2px;
}

@media (forced-colors: active) {
.cwl-tb-btn:focus-visible,
.cwl-editor__content:focus-visible {
outline-color: CanvasText;
}
}

.cwl-editor__content > * + * {
margin-top: 0.75em;
}
Expand Down Expand Up @@ -262,6 +255,93 @@
padding-top: calc(16px + 1.6em);
}

@media (forced-colors: active) {
.cwl-editor {
color: CanvasText;
background: Canvas;
border-color: CanvasText;
}

.cwl-editor__content:focus-visible {
outline-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;
}
}

@media print {
.cwl-editor {
--cwl-fg: #000000;
Expand Down
Loading
Loading