diff --git a/docs/PRD.md b/docs/PRD.md
index d20b19b4..a6c778f1 100644
--- a/docs/PRD.md
+++ b/docs/PRD.md
@@ -92,6 +92,8 @@ 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. 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 80cec07a..e197f7c6 100644
--- a/docs/TRD.md
+++ b/docs/TRD.md
@@ -115,6 +115,14 @@ Identity inspection returns no partial routing object on malformed input. An unk
## Accessibility and interaction semantics
+Active PR / Proposed toolbar reflow lets controls within an oversized group wrap using native CSS while preserving command availability, DOM/keyboard order, theme tokens and print behavior. Real-editor browser checks compare every button's complete bounds with the toolbar at 320 CSS pixels in normal and forced colors; page scroll width or a simplified chrome fixture alone cannot prove that controls are visible. The [toolbar reflow record](doctoring/toolbar-reflow.md) records the causal failure and alternatives. No package/API, document, persistence or host-authority contract changes.
+
+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
new file mode 100644
index 00000000..7aea76c1
--- /dev/null
+++ b/docs/doctoring/forced-colors-readable-content.md
@@ -0,0 +1,122 @@
+# Doctoring record: readable content in forced colors
+
+Date: 2026-09-06
+Status: Active PR / Proposed
+Owner: Inkspan presentation, existing PR #151
+
+## Visual finding and causal check
+
+Direct inspection of the retained three-engine, 320 CSS-pixel editor screenshots
+found unusually faint empty-editor guidance in WebKit's forced-colors rendering.
+The images belong to `dd1a78045807e37e32f87810564be4b1fefd56df`, not a newer
+source generation. Its stylesheet blob
+`265e79cc53f540cdf3de1b4ab10a4139c4444756` matches the test-only integrated
+candidate `f97452a902326d76061b6c770ebf69b68e8cdca0` exactly.
+
+The shared stylesheet assigned `GrayText` to both placeholder guidance and
+blockquote content. W3C defines that system color for disabled text. Neither
+an instruction in an editable document nor authored quoted text is an inactive
+control. The focused regression at
+`8f8ff6e3040d0fd2e436a65090bf3350f571b824` failed both content selectors while
+the six existing stylesheet checks passed. That is a stylesheet-contract RED;
+the earlier screenshots are retained visual evidence, not a fresh browser RED
+or a measured contrast ratio.
+
+## Minimal repair and alternatives
+
+Change the two content declarations to the existing `CanvasText` system color.
+Keep `GrayText`, full opacity and a visible border for disabled toolbar buttons.
+Normal themes, controls, keyboard order, print output, accessible names, document
+serialization and host ownership do not change. No dependency or abstraction is
+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 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
+pnpm exec vitest run src/forcedColorsStyles.test.ts
+pnpm --dir tests/browser exec playwright test --config playwright.config.ts specs/forced-colors.browser.spec.ts --workers=1
+```
+
+The browser regression checks both content selectors against document text,
+including the actual editor's placeholder pseudo-element at 320 CSS pixels.
+Retain and inspect normal/forced-colors toolbar images, actual Tab-focus images
+and the focused editor fixture. Run the complete source and extracted-package
+browser suites with their own exact-head/artifact receipts; the inherited
+stylesheet-provenance checks must prove the selected CSS was loaded.
+
+A selected system palette may intentionally differ between browsers or user
+preferences. Do not infer a numeric contrast ratio from screenshot appearance,
+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*.
+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/doctoring/toolbar-reflow.md b/docs/doctoring/toolbar-reflow.md
new file mode 100644
index 00000000..1d756c35
--- /dev/null
+++ b/docs/doctoring/toolbar-reflow.md
@@ -0,0 +1,37 @@
+# Doctoring record: narrow toolbar control visibility
+
+Date: 2026-09-06
+Status: Active PR / Proposed
+Owner: Inkspan presentation, existing PR #151
+
+## User-visible failure and causal evidence
+
+At 320 CSS pixels, authors could see only part of the image insertion button and could not see the image alternative-text button. The page itself had no horizontal overflow: its scroll width and viewport width both measured 320. The editor's clipping concealed overflow inside the toolbar, so page-width checks and the smaller forced-colors chrome fixture had passed without proving that all real controls were accessible.
+
+The actual public editor is mounted by `tests/browser/input-harness.ts`. `Toolbar` in `src/components/Toolbar.tsx` renders the affected controls in one group; `src/styles.css` allowed the outer toolbar to wrap groups but did not allow a group to wrap its controls. The group's minimum content width exceeded the available space. This is an Inkspan presentation defect, separate from the reference host's previously repaired control-style leakage.
+
+The test-only baseline `4bc3c8fab0c697dba0576686325cf09fe61242b3` adds full-button bounds checks to `tests/browser/specs/forced-colors.browser.spec.ts`. Each of Chromium, Firefox and WebKit reported the same two clipped controls in both normal and forced colors: 12 clipped-control observations across six independent engine/mode cases. The initial incorrectly named test locator failed before reaching geometry and is not counted as product evidence. Screenshots are captured before the assertion so failing states remain inspectable.
+
+## Choice and alternatives
+
+Allow the existing group to wrap with `flex-wrap: wrap`. It adds no JavaScript, dependency, breakpoint, observer or alternative navigation. It preserves every command, the existing control sizes, DOM order, keyboard behavior, theme tokens and host boundary. A narrow group may occupy additional vertical space.
+
+Keeping the page-width-only check was rejected because it reproduced a false pass. Removing clipping from the editor was rejected because controls would escape their surface. Hiding commands or shrinking their targets would trade away functionality or accessibility. A horizontally scrolling group could preserve access, but adds a separate navigation action when native wrapping can keep every control visible. No new layout framework or component abstraction is needed.
+
+## Verification and limits
+
+Run the actual-control regression across the three configured engines:
+
+```sh
+pnpm --dir tests/browser exec playwright test specs/forced-colors.browser.spec.ts --grep 'keeps every real toolbar control'
+```
+
+The optimization metric is the total count of clipped controls across all six engine/mode cases; lower is better, and acceptance requires zero. Keep existing keyboard, touch, composition, print, package, production-coverage and Office checks. Inspect the screenshots as well as geometry. A successful source-harness run is not installed-tarball identity, physical-device/OS high-contrast evidence, general WCAG certification or a performance result. Final exact-head results and artifact identities belong in dated PR evidence, not protected-main claims.
+
+The PRD accessibility requirement, TRD interaction boundary and product-technical gap baseline link this record. CONTRACTS remains unchanged because no public API, document/evidence schema, callback behavior or host ownership moves. The candidate must retain normal protected review and parent-before-child integration; local success does not ship the change. Before integration, a rejected experiment is reversed with an ordinary inverse change while retaining its evidence, never by hiding controls or weakening the visibility check.
+
+## Standards basis
+
+WCAG's Reflow guidance describes preserving information and functionality at a 320 CSS-pixel width and acknowledges exceptions for content that needs a two-dimensional layout. Inkspan does not need to use that exception to hide these controls: wrapping can preserve their availability. This record uses the guidance to choose and test a repair, not to claim whole-product conformance.
+
+World Wide Web Consortium. (n.d.). *Understanding Success Criterion 1.4.10: Reflow*. Web Accessibility Initiative. Retrieved September 6, 2026, from https://www.w3.org/WAI/WCAG22/Understanding/reflow.html
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index 18fd8749..d0fa59e8 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -152,6 +152,14 @@ runtime boundary:
- package provenance, license/SBOM completeness, and reproducible artifacts;
- accessibility evidence across keyboard, forced-colors, print, narrow viewport,
and real browser engines;
+- complete visibility of every actual toolbar control at narrow widths, checked
+ against its containing toolbar rather than inferred from page scroll width;
+ [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 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
outcomes.
diff --git a/src/editorFocusStyles.test.ts b/src/editorFocusStyles.test.ts
index 92badcfb..d18894bb 100644
--- a/src/editorFocusStyles.test.ts
+++ b/src/editorFocusStyles.test.ts
@@ -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,
+ );
});
});
diff --git a/src/forcedColorsStyles.test.ts b/src/forcedColorsStyles.test.ts
new file mode 100644
index 00000000..37e7642d
--- /dev/null
+++ b/src/forcedColorsStyles.test.ts
@@ -0,0 +1,137 @@
+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\s*\{[^}]*transition:\s*none\s*;/u,
+ );
+ 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.each([
+ '.cwl-tb-btn:hover:not(:disabled)',
+ '.cwl-tb-btn.is-active',
+ '.collaboration-cursor__label',
+ ])('preserves the system highlight pair on %s', (selector) => {
+ const blockStart = forcedColorsStyles.indexOf(`${selector} {`);
+ expect(blockStart).toBeGreaterThan(-1);
+ const block = forcedColorsStyles.slice(blockStart, findCssBlockEnd(forcedColorsStyles, blockStart));
+ expect(block).toMatch(/forced-color-adjust:\s*none\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', () => {
+ expect(forcedColorsStyles).toMatch(
+ /\.cwl-tb-btn:disabled\s*\{[^}]*opacity:\s*1\s*;[^}]*color:\s*GrayText\s*;[^}]*border-color:\s*GrayText\s*;/u,
+ );
+ });
+
+ it.each([
+ '.cwl-editor__content blockquote',
+ '.cwl-editor__content .is-editor-empty:first-child::before',
+ ])('uses readable system text for %s instead of disabled text', (selector) => {
+ const blockStart = forcedColorsStyles.indexOf(`${selector} {`);
+ expect(blockStart).toBeGreaterThan(-1);
+ const block = forcedColorsStyles.slice(blockStart, findCssBlockEnd(forcedColorsStyles, blockStart));
+ expect(block).toMatch(/[;{]\s*color:\s*CanvasText\s*;/u);
+ expect(block).not.toContain('GrayText');
+ });
+
+ 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*!important\s*;[^}]*color:\s*HighlightText\s*!important\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 3ba48800..9b0ceda8 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -64,6 +64,7 @@
.cwl-tb-group {
display: flex;
+ flex-wrap: wrap;
gap: 2px;
padding-right: 6px;
margin-right: 4px;
@@ -124,13 +125,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;
}
@@ -262,6 +256,97 @@
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;
+ transition: none;
+ }
+
+ .cwl-tb-btn:hover:not(:disabled) {
+ background: Highlight;
+ color: HighlightText;
+ forced-color-adjust: none;
+ }
+
+ .cwl-tb-btn:focus-visible {
+ outline: 2px solid Highlight;
+ outline-offset: 2px;
+ }
+
+ .cwl-tb-btn.is-active {
+ background: Highlight;
+ border-color: Highlight;
+ color: HighlightText;
+ forced-color-adjust: none;
+ }
+
+ .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: CanvasText;
+ }
+
+ .cwl-editor__content .is-editor-empty:first-child::before {
+ color: CanvasText;
+ }
+
+ .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 !important;
+ color: HighlightText !important;
+ forced-color-adjust: none;
+ }
+}
+
@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..680f3bb1
--- /dev/null
+++ b/tests/browser/specs/forced-colors.browser.spec.ts
@@ -0,0 +1,298 @@
+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(async (element) => {
+ element.innerHTML = `
+
+
+
+