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 = ` + +
+ +
Connected
+
+
+

Link code

+
pre
+
quote
+
HeadCell
+

Remote edit

+
+
+
+ `; + element.querySelector('[data-remote-edit]')!.append( + await window.renderInkspanCursorProbe({ name: 'Remote', color: '#abcdef' }), + ); + }); + expect(rejectedExternalRequests).toEqual([]); +}); + +test('preserves state and structural cues in forced colors', async ({ + browserName, + page, +}, testInfo) => { + const tabKey = + 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), + ).toBe(true); + + const focusStart = page.getByRole('button', { + name: 'Start keyboard journey', + }); + const plainButton = page.getByRole('button', { name: 'Plain' }); + await focusStart.focus(); + await page.keyboard.press(tabKey); + await expect(plainButton).toBeFocused(); + + 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 quote = get('.cwl-editor__content blockquote'); + + 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', + quoteColor: getComputedStyle(quote).color, + canvasTextColor: editorStyle.color, + 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, + highlightBackground: activeStyle.backgroundColor, + }; + }); + + await testInfo.attach('forced-colors-content', { + body: JSON.stringify(evidence, null, 2), + contentType: 'application/json', + }); + await page.screenshot({ + path: testInfo.outputPath('forced-colors-content.png'), + 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', + 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, + editorColorAdjustment: evidence.supportsColorAdjustment ? 'auto' : '', + activeColorAdjustment: evidence.supportsColorAdjustment ? 'none' : '', + disabledColorAdjustment: evidence.supportsColorAdjustment ? 'auto' : '', + labelColorAdjustment: evidence.supportsColorAdjustment ? 'none' : '', + }); + + const activeButton = page.getByRole('button', { name: 'Active' }); + await page.keyboard.press(tabKey); + await expect(activeButton).toBeFocused(); + + const editable = page.locator('.cwl-editor__content'); + await page.keyboard.press(tabKey); + await expect(editable).toBeFocused(); + const editableFocusEvidence = await editable.evaluate((element) => { + const style = getComputedStyle(element); + return { + outlineStyle: style.outlineStyle, + outlineWidth: style.outlineWidth, + }; + }); + expect(editableFocusEvidence).toEqual({ + outlineStyle: 'solid', + outlineWidth: '2px', + }); + await page.screenshot({ + path: testInfo.outputPath('forced-colors-editor-focus.png'), + fullPage: true, + }); +}); + +for (const forcedColors of ['none', 'active'] as const) { + test(`keeps every real toolbar control visible at 320px with forced colors ${forcedColors}`, async ({ + browserName, + page, + }, testInfo) => { + await page.setViewportSize({ width: 320, height: 900 }); + await page.emulateMedia({ forcedColors }); + await page.goto('/tests/browser/input-harness.html?toolbar=1'); + await expect(page.locator('.ProseMirror')).toHaveAttribute('contenteditable', 'true'); + const toolbar = page.getByRole('toolbar', { name: 'Formatting' }); + await expect(toolbar.getByRole('button', { name: 'Insert inline (base64) image' })).toBeVisible(); + const clippedControls = await toolbar.evaluate((element) => { + const toolbarBounds = element.getBoundingClientRect(); + return Array.from(element.querySelectorAll('button')).flatMap((button) => { + const bounds = button.getBoundingClientRect(); + const visible = bounds.width > 0 && bounds.height > 0 + && bounds.left >= toolbarBounds.left && bounds.right <= toolbarBounds.right + && bounds.top >= toolbarBounds.top && bounds.bottom <= toolbarBounds.bottom; + return visible ? [] : [button.getAttribute('aria-label')]; + }); + }); + await page.screenshot({ + path: testInfo.outputPath(`real-toolbar-320-${forcedColors}.png`), + fullPage: true, + }); + expect(clippedControls).toEqual([]); + if (forcedColors === 'active') { + const colors = await page.locator('.cwl-editor').evaluate((element) => ({ + canvasText: getComputedStyle(element).color, + placeholder: getComputedStyle( + element.querySelector('.is-editor-empty:first-child')!, '::before', + ).color, + })); + await testInfo.attach('actual-editor-placeholder-colors', { + body: JSON.stringify(colors, null, 2), + contentType: 'application/json', + }); + expect(colors.placeholder).toBe(colors.canvasText); + } + await page.keyboard.press( + browserName === 'webkit' && process.platform === 'darwin' ? 'Alt+Tab' : 'Tab', + ); + await expect(toolbar.getByRole('button', { name: 'Bold (Ctrl/Cmd+B)', exact: true })).toBeFocused(); + await page.screenshot({ + path: testInfo.outputPath(`real-toolbar-320-${forcedColors}-focus.png`), + fullPage: true, + }); + await page.keyboard.press('Enter'); + await expect(toolbar.getByRole('button', { name: 'Bold (Ctrl/Cmd+B)', exact: true })) + .toHaveAttribute('aria-pressed', 'true'); + const readButtonPaint = (element: Element) => { + const style = getComputedStyle(element); + return { + color: style.color, + background: style.backgroundColor, + transitionDuration: style.transitionDuration, + 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 }); + const initialPaint = await bold.evaluate(readButtonPaint); + await page.screenshot({ + path: testInfo.outputPath(`real-toolbar-320-${forcedColors}-active-mark.png`), + fullPage: true, + }); + await page.screenshot({ + path: testInfo.outputPath(`real-toolbar-320-${forcedColors}-settled-mark.png`), + animations: 'disabled', + fullPage: true, + }); + await testInfo.attach('actual-active-button-paint', { + body: JSON.stringify({ initialPaint, settledPaint: await bold.evaluate(readButtonPaint) }, null, 2), + contentType: 'application/json', + }); + if (forcedColors === 'active') { + expect(initialPaint.transitionDuration).toBe('0s'); + 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(); + const hoverPaint = await italic.evaluate(readButtonPaint); + await page.screenshot({ + path: testInfo.outputPath('real-toolbar-320-forced-hover.png'), + fullPage: true, + }); + expect(hoverPaint.transitionDuration).toBe('0s'); + expect(hoverPaint.forcedColorAdjust).toBe(hoverPaint.supportsColorAdjustment ? 'none' : ''); + expect(hoverPaint.color).not.toBe(hoverPaint.background); + } + }); +}