diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index 4a682ff56a..0940a45039 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -6539,7 +6539,7 @@ describe('Link accessibility - Focus styles', () => { const styleTag = document.querySelector('[data-superdoc-link-styles]'); expect(styleTag).toBeTruthy(); expect(styleTag?.textContent).toContain(':focus-visible'); - expect(styleTag?.textContent).toContain('sr-only'); + expect(styleTag?.textContent).toContain('superdoc-sr-only'); }); it('should not inject styles twice', () => { @@ -6954,7 +6954,7 @@ describe('Link accessibility - Tooltip aria-describedby', () => { // Look for the description element in the mount, not document const descElem = mount.querySelector(`#${describedBy}`); expect(descElem?.textContent).toBe('Visit our homepage for more information'); - expect(descElem?.className).toContain('sr-only'); + expect(descElem?.className).toContain('superdoc-sr-only'); }); it('should maintain title attribute for visual tooltip', () => { diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 191c35b3df..ee92278766 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -4047,7 +4047,7 @@ export class DomPainter { const descId = `link-desc-${linkId}`; const descElem = this.doc.createElement('span'); descElem.id = descId; - descElem.className = 'sr-only'; // Screen reader only class + descElem.className = 'superdoc-sr-only'; // Screen reader only class descElem.textContent = tooltip; // Insert description element after the link diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index c711b85f18..0b936c1ff4 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -185,7 +185,7 @@ const LINK_AND_TOC_STYLES = ` } /* Screen reader only content (WCAG SC 1.3.1) */ -.sr-only { +.superdoc-sr-only { position: absolute; width: 1px; height: 1px; diff --git a/packages/super-editor/src/assets/styles/elements/ai.css b/packages/super-editor/src/assets/styles/elements/ai.css index 282815d057..b4cabe4eeb 100644 --- a/packages/super-editor/src/assets/styles/elements/ai.css +++ b/packages/super-editor/src/assets/styles/elements/ai.css @@ -1,16 +1,16 @@ /* Custom toolbar styling */ /* AI button icon styling with gradient */ -.toolbar-icon__icon--ai { +.super-editor .toolbar-icon__icon--ai { position: relative; z-index: 1; } -.toolbar-icon__icon--ai svg { +.super-editor .toolbar-icon__icon--ai svg { fill: transparent; } -.toolbar-icon__icon--ai::before { +.super-editor .toolbar-icon__icon--ai::before { content: ''; position: absolute; top: 0; @@ -33,12 +33,12 @@ transition: filter 0.2s ease; } -.toolbar-icon__icon--ai:hover::before { +.super-editor .toolbar-icon__icon--ai:hover::before { filter: brightness(1.3); } /* AI text appear animation */ -@keyframes aiTextAppear { +@keyframes superdoc-aiTextAppear { from { opacity: 0; transform: translateY(5px); @@ -49,27 +49,27 @@ } } -.sd-ai-text-appear { +.super-editor .sd-ai-text-appear { display: inline; opacity: 0; - animation: aiTextAppear 0.7s ease-out forwards; + animation: superdoc-aiTextAppear 0.7s ease-out forwards; animation-fill-mode: both; will-change: opacity, transform; /* Ensure each mark is treated as a separate animation context */ contain: content; } -.sd-ai-loader { +.super-editor .sd-ai-loader { display: flex; justify-content: flex-start; } -.sd-ai-loader > img { +.super-editor .sd-ai-loader > img { width: fit-content; height: 40px; } -@keyframes ai-pulse { +@keyframes superdoc-ai-pulse { 0% { background-color: rgba(99, 102, 241, 0.1); } @@ -81,6 +81,6 @@ } } -.sd-ai-highlight-pulse { - animation: ai-pulse 1.5s ease-in-out infinite; +.super-editor .sd-ai-highlight-pulse { + animation: superdoc-ai-pulse 1.5s ease-in-out infinite; } diff --git a/packages/super-editor/src/assets/styles/elements/page-number.css b/packages/super-editor/src/assets/styles/elements/page-number.css index ffacbb5e1d..7ae846bd60 100644 --- a/packages/super-editor/src/assets/styles/elements/page-number.css +++ b/packages/super-editor/src/assets/styles/elements/page-number.css @@ -1,20 +1,20 @@ -.sd-editor-auto-page-number, -.sd-editor-auto-total-pages { +.super-editor .sd-editor-auto-page-number, +.super-editor .sd-editor-auto-total-pages { transition: all 250ms ease; border-bottom: 1px solid #9a9a9a; cursor: not-allowed; } -.sd-editor-auto-page-number:hover, -.sd-editor-auto-total-pages:hover { +.super-editor .sd-editor-auto-page-number:hover, +.super-editor .sd-editor-auto-total-pages:hover { border-bottom-color: #4f4f4f; } -.sd-editor-auto-page-number-content { +.super-editor .sd-editor-auto-page-number-content { pointer-events: none; } -.ProseMirror.view-mode { +.super-editor .ProseMirror.view-mode { .sd-editor-auto-page-number, .sd-editor-auto-total-pages { border: none; diff --git a/packages/super-editor/src/assets/styles/elements/prosemirror.css b/packages/super-editor/src/assets/styles/elements/prosemirror.css index af738f2d72..064e21bf61 100644 --- a/packages/super-editor/src/assets/styles/elements/prosemirror.css +++ b/packages/super-editor/src/assets/styles/elements/prosemirror.css @@ -1,14 +1,17 @@ /** * Basic ProseMirror styles. * https://github.com/ProseMirror/prosemirror-view/blob/master/style/prosemirror.css + * + * All selectors are scoped under .sd-editor-scoped to prevent bleeding into host apps. + * See: SD-1850 */ -.ProseMirror { +.sd-editor-scoped .ProseMirror { position: relative; /* We use "all: revert" to isolate the editor content from external stylesheets. This makes the "contenteditable" not editable on Safari devices. So we need to keep this. */ -webkit-user-modify: read-write-plaintext-only; } -.ProseMirror { +.sd-editor-scoped .ProseMirror { word-wrap: break-word; white-space: pre-wrap; white-space: break-spaces; @@ -18,35 +21,35 @@ z-index: 0; /* Needed to place images behind text with lower z-index */ } -.ProseMirror pre { +.sd-editor-scoped .ProseMirror pre { white-space: pre-wrap; } -.ProseMirror ol, -.ProseMirror ul { +.sd-editor-scoped .ProseMirror ol, +.sd-editor-scoped .ProseMirror ul { margin-block-start: 0; margin-block-end: 0; margin-inline-start: 0; margin-inline-end: 0; } -.ProseMirror ol, -.ProseMirror ul { +.sd-editor-scoped .ProseMirror ol, +.sd-editor-scoped .ProseMirror ul { padding-inline-start: 0; padding-left: 0; list-style: none; } -.ProseMirror li::marker { +.sd-editor-scoped .ProseMirror li::marker { content: none; } -.ProseMirror li::marker { +.sd-editor-scoped .ProseMirror li::marker { padding: 0; margin: 0; } -.ProseMirror li > p { +.sd-editor-scoped .ProseMirror li > p { margin: 0; padding: 0; display: inline-block; @@ -56,44 +59,44 @@ * Hide marker for indented lists. * If a list-item contains a list but doesn't contain a "p" tag with text. */ -.ProseMirror ol { +.sd-editor-scoped .ProseMirror ol { margin: 0; } -.ProseMirror li:has(> ul:first-child, > ol:first-child):not(:has(> p)) { +.sd-editor-scoped .ProseMirror li:has(> ul:first-child, > ol:first-child):not(:has(> p)) { list-style-type: none; } -.ProseMirror li:has(> ul:first-child, > ol:first-child):not(:has(> p))::marker { +.sd-editor-scoped .ProseMirror li:has(> ul:first-child, > ol:first-child):not(:has(> p))::marker { content: ''; } -.ProseMirror-hideselection *::selection { +.sd-editor-scoped .ProseMirror-hideselection *::selection { background: transparent; } -.ProseMirror-hideselection *::-moz-selection { +.sd-editor-scoped .ProseMirror-hideselection *::-moz-selection { background: transparent; } -.ProseMirror-hideselection * { +.sd-editor-scoped .ProseMirror-hideselection * { caret-color: transparent; } /* See https://github.com/ProseMirror/prosemirror/issues/1421#issuecomment-1759320191 */ -.ProseMirror [draggable][contenteditable='false'] { +.sd-editor-scoped .ProseMirror [draggable][contenteditable='false'] { user-select: text; } -.ProseMirror-selectednode { +.sd-editor-scoped .ProseMirror-selectednode { outline: 2px solid #8cf; } /* Make sure li selections wrap around markers */ -li.ProseMirror-selectednode { +.sd-editor-scoped li.ProseMirror-selectednode { outline: none; } -li.ProseMirror-selectednode:after { +.sd-editor-scoped li.ProseMirror-selectednode:after { content: ''; position: absolute; left: -32px; @@ -104,24 +107,24 @@ li.ProseMirror-selectednode:after { pointer-events: none; } -.ProseMirror img { +.sd-editor-scoped .ProseMirror img { height: auto; max-width: 100%; } /* Protect against generic img rules */ -img.ProseMirror-separator { +.sd-editor-scoped img.ProseMirror-separator { display: inline !important; border: none !important; margin: 0 !important; } -.ProseMirror .sd-editor-tab { +.sd-editor-scoped .ProseMirror .sd-editor-tab { display: inline-block; vertical-align: text-bottom; } -.ProseMirror u .sd-editor-tab { +.sd-editor-scoped .ProseMirror u .sd-editor-tab { white-space: pre; border-bottom: 1px solid #000; margin-bottom: 1.5px; @@ -132,12 +135,12 @@ Tables https://github.com/ProseMirror/prosemirror-tables/blob/master/style/tables.css https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html */ -.ProseMirror.resize-cursor { +.sd-editor-scoped .ProseMirror.resize-cursor { cursor: ew-resize; cursor: col-resize; } -.ProseMirror .tableWrapper { +.sd-editor-scoped .ProseMirror .tableWrapper { --table-border-width: 1px; --offset: 2px; @@ -151,7 +154,7 @@ https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html width: calc(100% + (var(--table-border-width) + var(--offset))); } -.ProseMirror table { +.sd-editor-scoped .ProseMirror table { border-collapse: collapse; border-spacing: 0; table-layout: auto; @@ -159,12 +162,12 @@ https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html /* width: 100%; */ } -.ProseMirror tr { +.sd-editor-scoped .ProseMirror tr { position: relative; } -.ProseMirror td, -.ProseMirror th { +.sd-editor-scoped .ProseMirror td, +.sd-editor-scoped .ProseMirror th { min-width: 0; position: relative; vertical-align: top; @@ -172,24 +175,24 @@ https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html overflow-wrap: anywhere; } -.ProseMirror td[data-placeholder], -.ProseMirror th[data-placeholder] { +.sd-editor-scoped .ProseMirror td[data-placeholder], +.sd-editor-scoped .ProseMirror th[data-placeholder] { padding: 0 !important; border: 0 !important; background: transparent !important; } -.ProseMirror td[data-placeholder] > *, -.ProseMirror th[data-placeholder] > * { +.sd-editor-scoped .ProseMirror td[data-placeholder] > *, +.sd-editor-scoped .ProseMirror th[data-placeholder] > * { display: none !important; } -.ProseMirror th { +.sd-editor-scoped .ProseMirror th { font-weight: bold; text-align: left; } -.ProseMirror table .column-resize-handle { +.sd-editor-scoped .ProseMirror table .column-resize-handle { position: absolute; right: -2px; top: 0; @@ -200,7 +203,7 @@ https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html pointer-events: none; } -.ProseMirror table .selectedCell:after { +.sd-editor-scoped .ProseMirror table .selectedCell:after { position: absolute; content: ''; left: 0; @@ -214,24 +217,24 @@ https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html /* Tables - end */ /* Track changes */ -.ProseMirror .track-insert-dec, -.ProseMirror .track-delete-dec, -.ProseMirror .track-format-dec { +.sd-editor-scoped .ProseMirror .track-insert-dec, +.sd-editor-scoped .ProseMirror .track-delete-dec, +.sd-editor-scoped .ProseMirror .track-format-dec { pointer-events: none; } -.ProseMirror .track-insert-dec.hidden, -.ProseMirror .track-delete-dec.hidden { +.sd-editor-scoped .ProseMirror .track-insert-dec.hidden, +.sd-editor-scoped .ProseMirror .track-delete-dec.hidden { display: none; } -.ProseMirror .track-insert-dec.highlighted { +.sd-editor-scoped .ProseMirror .track-insert-dec.highlighted { border-top: 1px dashed var(--sd-track-insert-border, #00853d); border-bottom: 1px dashed var(--sd-track-insert-border, #00853d); background-color: var(--sd-track-insert-bg, #399c7222); } -.ProseMirror .track-delete-dec.highlighted { +.sd-editor-scoped .ProseMirror .track-delete-dec.highlighted { border-top: 1px dashed var(--sd-track-delete-border, #cb0e47); border-bottom: 1px dashed var(--sd-track-delete-border, #cb0e47); background-color: var(--sd-track-delete-bg, #cb0e4722); @@ -239,21 +242,21 @@ https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html text-decoration-thickness: 2px !important; } -.ProseMirror .track-format-dec.highlighted { +.sd-editor-scoped .ProseMirror .track-format-dec.highlighted { border-bottom: 2px solid var(--sd-track-format-border, gold); } -.ProseMirror .track-delete-widget { +.sd-editor-scoped .ProseMirror .track-delete-widget { visibility: hidden; } /* Track changes - end */ /* Collaboration cursors */ -.ProseMirror > .ProseMirror-yjs-cursor:first-child { +.sd-editor-scoped .ProseMirror > .ProseMirror-yjs-cursor:first-child { margin-top: 16px; } -.ProseMirror-yjs-cursor { +.sd-editor-scoped .ProseMirror-yjs-cursor { position: relative; margin-left: -1px; margin-right: -1px; @@ -264,7 +267,7 @@ https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html pointer-events: none; } -.ProseMirror-yjs-cursor > div { +.sd-editor-scoped .ProseMirror-yjs-cursor > div { position: absolute; top: -1.05em; left: -1px; @@ -323,7 +326,7 @@ https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html /* Presentation Editor Remote Cursors - end */ /* Footnotes */ -.sd-footnote-ref { +.sd-editor-scoped .sd-footnote-ref { font-size: 0.75em; line-height: 1; vertical-align: super; @@ -331,13 +334,13 @@ https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html } /* Image placeholder */ -.ProseMirror placeholder { +.sd-editor-scoped .ProseMirror placeholder { display: inline; border: 1px solid #ccc; color: #ccc; } -.ProseMirror placeholder:after { +.sd-editor-scoped .ProseMirror placeholder:after { content: '☁'; font-size: 200%; line-height: 0.1; @@ -345,41 +348,41 @@ https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html } /* Gapcursor */ -.ProseMirror-gapcursor { +.sd-editor-scoped .ProseMirror-gapcursor { display: none; pointer-events: none; position: absolute; margin: 0; } -.ProseMirror-gapcursor:after { +.sd-editor-scoped .ProseMirror-gapcursor:after { content: ''; display: block; position: absolute; top: -2px; width: 20px; border-top: 1px solid black; - animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite; + animation: superdoc-cursor-blink 1.1s steps(2, start) infinite; } -@keyframes ProseMirror-cursor-blink { +@keyframes superdoc-cursor-blink { to { visibility: hidden; } } -.ProseMirror-focused .ProseMirror-gapcursor { +.sd-editor-scoped .ProseMirror-focused .ProseMirror-gapcursor { display: block; } -.ProseMirror div[data-type='contentBlock'] { +.sd-editor-scoped .ProseMirror div[data-type='contentBlock'] { position: absolute; outline: none; user-select: none; z-index: -1; } -.ProseMirror div[data-horizontal-rule='true'] { +.sd-editor-scoped .ProseMirror div[data-horizontal-rule='true'] { position: relative; z-index: auto; display: inline-block; @@ -388,23 +391,23 @@ https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html align-self: flex-end; } -.sd-editor-dropcap { +.sd-editor-scoped .sd-editor-dropcap { float: left; display: flex; align-items: baseline; margin-top: -5px; } -.ProseMirror-search-match { +.sd-editor-scoped .ProseMirror-search-match { background-color: #ffff0054; } -.ProseMirror-active-search-match { +.sd-editor-scoped .ProseMirror-active-search-match { background-color: #ff6a0054; } -.ProseMirror span.sd-custom-selection::selection { +.sd-editor-scoped .ProseMirror span.sd-custom-selection::selection { background: transparent; } -.sd-custom-selection { +.sd-editor-scoped .sd-custom-selection { background-color: #d9d9d9; border-radius: 0.1em; } diff --git a/packages/super-editor/src/assets/styles/extensions/document-section.css b/packages/super-editor/src/assets/styles/extensions/document-section.css index 1e55c9d160..6511e0c3e0 100644 --- a/packages/super-editor/src/assets/styles/extensions/document-section.css +++ b/packages/super-editor/src/assets/styles/extensions/document-section.css @@ -1,10 +1,10 @@ -.sd-document-section-block { +.super-editor .sd-document-section-block { background-color: #fafafa; border: 1px solid #ababab; border-radius: 4px; position: relative; } -.sd-document-section-block-info { +.super-editor .sd-document-section-block-info { position: absolute; top: -19px; left: -1px; @@ -22,27 +22,27 @@ background-color: #fafafa; } -.sd-document-section-block:hover { +.super-editor .sd-document-section-block:hover { border-radius: 0 4px 4px 4px; } -.sd-document-section-block:hover .sd-document-section-block-info { +.super-editor .sd-document-section-block:hover .sd-document-section-block-info { display: flex; align-items: center; } -.sd-document-section-block-info span { +.super-editor .sd-document-section-block-info span { max-width: 100%; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } -.ProseMirror.view-mode .sd-document-section-block { +.super-editor .ProseMirror.view-mode .sd-document-section-block { background: none; border: none; } -.ProseMirror.view-mode .sd-document-section-block-info { +.super-editor .ProseMirror.view-mode .sd-document-section-block-info { display: none; } diff --git a/packages/super-editor/src/assets/styles/extensions/noderesizer.css b/packages/super-editor/src/assets/styles/extensions/noderesizer.css index 3f9990925a..c256c40d74 100644 --- a/packages/super-editor/src/assets/styles/extensions/noderesizer.css +++ b/packages/super-editor/src/assets/styles/extensions/noderesizer.css @@ -1,12 +1,12 @@ /* Resize handles container */ -.sd-editor-resize-container { +.sd-editor-resize-container.sd-editor-scoped { position: absolute; pointer-events: none; z-index: 11; } /* Resize handles */ -.sd-editor-resize-handle { +.sd-editor-scoped .sd-editor-resize-handle { position: absolute; width: 12px; height: 12px; @@ -18,53 +18,53 @@ transition: all 0.1s ease; } -.sd-editor-resize-handle:hover { +.sd-editor-scoped .sd-editor-resize-handle:hover { background-color: #228be6; transform: scale(1.1); box-shadow: 0 0 6px rgba(0, 0, 0, 0.4); } /* Handle positions */ -.sd-editor-resize-handle-nw { +.sd-editor-scoped .sd-editor-resize-handle-nw { top: -6px; left: -6px; cursor: nwse-resize; } -.sd-editor-resize-handle-ne { +.sd-editor-scoped .sd-editor-resize-handle-ne { top: -6px; right: -6px; cursor: nesw-resize; } -.sd-editor-resize-handle-sw { +.sd-editor-scoped .sd-editor-resize-handle-sw { bottom: -6px; left: -6px; cursor: nesw-resize; } -.sd-editor-resize-handle-se { +.sd-editor-scoped .sd-editor-resize-handle-se { bottom: -6px; right: -6px; cursor: nwse-resize; } /* Hide handles when editor loses focus */ -.ProseMirror:not(.ProseMirror-focused) .sd-editor-resize-container { +.sd-editor-scoped .ProseMirror:not(.ProseMirror-focused) .sd-editor-resize-container { display: none; } /* Smooth transitions for resizing */ -.sd-editor-resizable-wrapper * { +.sd-editor-scoped .sd-editor-resizable-wrapper * { transition: none; } -.sd-editor-resizable-wrapper *:not([style*='width']) { +.sd-editor-scoped .sd-editor-resizable-wrapper *:not([style*='width']) { transition: all 0.2s ease; } /* Resize feedback indicator */ -.sd-editor-resizable-wrapper::after { +.sd-editor-scoped .sd-editor-resizable-wrapper::after { content: 'Drag corners to resize'; position: absolute; bottom: -25px; @@ -82,6 +82,6 @@ z-index: 12; } -.sd-editor-resizable-wrapper:hover::after { +.sd-editor-scoped .sd-editor-resizable-wrapper:hover::after { opacity: 1; } diff --git a/packages/super-editor/src/assets/styles/extensions/structured-content.css b/packages/super-editor/src/assets/styles/extensions/structured-content.css index 4fbd438271..8d80d5d434 100644 --- a/packages/super-editor/src/assets/styles/extensions/structured-content.css +++ b/packages/super-editor/src/assets/styles/extensions/structured-content.css @@ -1,5 +1,5 @@ -.sd-structured-content, -.sd-structured-content-block { +.super-editor .sd-structured-content, +.super-editor .sd-structured-content-block { padding: 1px; box-sizing: border-box; border-radius: 4px; @@ -7,24 +7,24 @@ position: relative; } -.sd-structured-content:has(img), -.sd-structured-content:has(img) .sd-structured-content__content { +.super-editor .sd-structured-content:has(img), +.super-editor .sd-structured-content:has(img) .sd-structured-content__content { display: inline-block; } /* Hover (not selected): light grey background, no handle */ -.sd-structured-content:not(.ProseMirror-selectednode):hover, -.sd-structured-content-block:not(.ProseMirror-selectednode):hover { +.super-editor .sd-structured-content:not(.ProseMirror-selectednode):hover, +.super-editor .sd-structured-content-block:not(.ProseMirror-selectednode):hover { background-color: #f2f2f2; } /* Selected: border + handle visible */ -.sd-structured-content.ProseMirror-selectednode, -.sd-structured-content-block.ProseMirror-selectednode { +.super-editor .sd-structured-content.ProseMirror-selectednode, +.super-editor .sd-structured-content-block.ProseMirror-selectednode { border-color: #629be7; } -.sd-structured-content-draggable { +.super-editor .sd-structured-content-draggable { font-size: 10px; align-items: center; justify-content: center; @@ -46,7 +46,7 @@ display: none; } -.sd-structured-content-draggable span { +.super-editor .sd-structured-content-draggable span { max-width: 100%; overflow: hidden; white-space: nowrap; @@ -54,18 +54,18 @@ } /* Handle only visible when selected */ -.sd-structured-content.ProseMirror-selectednode .sd-structured-content-draggable, -.sd-structured-content-block.ProseMirror-selectednode .sd-structured-content-draggable { +.super-editor .sd-structured-content.ProseMirror-selectednode .sd-structured-content-draggable, +.super-editor .sd-structured-content-block.ProseMirror-selectednode .sd-structured-content-draggable { display: inline-flex; } -.ProseMirror.view-mode .sd-structured-content, -.ProseMirror.view-mode .sd-structured-content-block { +.super-editor .ProseMirror.view-mode .sd-structured-content, +.super-editor .ProseMirror.view-mode .sd-structured-content-block { padding: 0; border: none; } -.ProseMirror.view-mode .sd-structured-content-draggable { +.super-editor .ProseMirror.view-mode .sd-structured-content-draggable { display: none; } diff --git a/packages/super-editor/src/assets/styles/layout/global.css b/packages/super-editor/src/assets/styles/layout/global.css index 503c5885fe..ba6b428a27 100644 --- a/packages/super-editor/src/assets/styles/layout/global.css +++ b/packages/super-editor/src/assets/styles/layout/global.css @@ -11,10 +11,6 @@ outline: none; } -a { - text-decoration: auto; -} - .super-editor a { color: initial; text-decoration: auto; diff --git a/packages/super-editor/src/tests/css-no-bleed.test.js b/packages/super-editor/src/tests/css-no-bleed.test.js new file mode 100644 index 0000000000..a79b41af3d --- /dev/null +++ b/packages/super-editor/src/tests/css-no-bleed.test.js @@ -0,0 +1,263 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync, readdirSync } from 'fs'; +import { resolve, join } from 'path'; + +/** + * CSS Bleed Prevention Tests (SD-1850) + * + * Structural lint tests that parse CSS files and verify no selectors + * can bleed into host applications. This prevents regressions if someone + * adds an unscoped selector in the future. + */ + +const SUPER_EDITOR_STYLES_DIR = resolve(__dirname, '../assets/styles'); + +/** + * Recursively find all .css files in a directory. + */ +function findCssFiles(dir, prefix = '') { + const results = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const rel = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + results.push(...findCssFiles(join(dir, entry.name), rel)); + } else if (entry.name.endsWith('.css')) { + results.push(rel); + } + } + return results; +} + +/** + * Parse top-level CSS selectors from a CSS file string. + * Returns an array of selector strings (only top-level, not inside @media/@keyframes). + */ +function extractTopLevelSelectors(cssText) { + const selectors = []; + let depth = 0; + let current = ''; + let inComment = false; + let inAtRule = false; + + for (let i = 0; i < cssText.length; i++) { + const char = cssText[i]; + const next = cssText[i + 1]; + + // Track comments + if (!inComment && char === '/' && next === '*') { + inComment = true; + i++; + continue; + } + if (inComment && char === '*' && next === '/') { + inComment = false; + i++; + continue; + } + if (inComment) continue; + + // Track @ rules (keyframes, media, etc.) + if (depth === 0 && char === '@') { + inAtRule = true; + current = '@'; + continue; + } + + if (char === '{') { + if (depth === 0) { + const trimmed = current.trim(); + if (trimmed && !inAtRule) { + // Split comma-separated selectors + const parts = trimmed.split(',').map((s) => s.trim()); + selectors.push(...parts); + } + current = ''; + } + depth++; + continue; + } + + if (char === '}') { + depth--; + if (depth === 0) { + inAtRule = false; + current = ''; + } + continue; + } + + if (depth === 0) { + current += char; + } + } + + return selectors; +} + +/** + * Bare HTML element selectors that should NEVER appear at top-level without a parent scope. + * These are the most dangerous: they affect all elements of that type on the page. + */ +const BARE_ELEMENT_PATTERN = + /^(a|p|div|span|table|tr|td|th|ul|ol|li|h[1-6]|img|pre|blockquote|code|input|button|select|textarea|form|label|section|article|nav|header|footer|main|aside)\s*(\{|,|:|\[|>|\+|~|$)/; + +/** + * Check if a selector is scoped under a SuperDoc parent class. + */ +function isScopedSelector(selector) { + // Already scoped under .super-editor, .superdoc, .sd-, .presentation-editor, .tippy-box + const scopedPrefixes = ['.super-editor', '.superdoc', '.sd-', '.presentation-editor', '.tippy-box']; + + return scopedPrefixes.some((prefix) => selector.startsWith(prefix)); +} + +describe('CSS Bleed Prevention (SD-1850)', () => { + it('should not have bare HTML element selectors at top level', () => { + const cssFiles = findCssFiles(SUPER_EDITOR_STYLES_DIR); + const violations = []; + + for (const file of cssFiles) { + const fullPath = join(SUPER_EDITOR_STYLES_DIR, file); + const cssText = readFileSync(fullPath, 'utf8'); + const selectors = extractTopLevelSelectors(cssText); + + for (const selector of selectors) { + if (BARE_ELEMENT_PATTERN.test(selector)) { + violations.push({ file, selector }); + } + } + } + + if (violations.length > 0) { + const message = violations.map((v) => ` ${v.file}: "${v.selector}"`).join('\n'); + expect.fail( + `Found bare HTML element selectors that will bleed into host apps:\n${message}\n\nScope them under .super-editor (e.g., ".super-editor a { ... }")`, + ); + } + }); + + it('should scope all .ProseMirror selectors under .super-editor', () => { + const cssFiles = findCssFiles(SUPER_EDITOR_STYLES_DIR); + const violations = []; + + for (const file of cssFiles) { + const fullPath = join(SUPER_EDITOR_STYLES_DIR, file); + const cssText = readFileSync(fullPath, 'utf8'); + const selectors = extractTopLevelSelectors(cssText); + + for (const selector of selectors) { + // Check for .ProseMirror selectors not scoped under .super-editor + const isProseMirrorSelector = + selector.startsWith('.ProseMirror') || + selector.startsWith('li.ProseMirror') || + selector.startsWith('img.ProseMirror'); + + if (isProseMirrorSelector && !selector.startsWith('.super-editor')) { + // Allow .ProseMirror inside already-scoped selectors (e.g., .superdoc-field .ProseMirror) + if (!isScopedSelector(selector)) { + violations.push({ file, selector }); + } + } + } + } + + if (violations.length > 0) { + const message = violations.map((v) => ` ${v.file}: "${v.selector}"`).join('\n'); + expect.fail( + `Found .ProseMirror selectors not scoped under .super-editor:\n${message}\n\nPrefix with ".super-editor" (e.g., ".super-editor .ProseMirror { ... }")`, + ); + } + }); + + it('should not have generic utility class names at top level', () => { + const cssFiles = findCssFiles(SUPER_EDITOR_STYLES_DIR); + const violations = []; + + // Generic class names that are commonly used by frameworks (Bootstrap, Tailwind, etc.) + const genericClassNames = ['.sr-only', '.visually-hidden', '.hidden', '.clearfix', '.container', '.row', '.col']; + + for (const file of cssFiles) { + const fullPath = join(SUPER_EDITOR_STYLES_DIR, file); + const cssText = readFileSync(fullPath, 'utf8'); + const selectors = extractTopLevelSelectors(cssText); + + for (const selector of selectors) { + for (const generic of genericClassNames) { + if ( + selector === generic || + selector.startsWith(generic + ' ') || + selector.startsWith(generic + ':') || + selector.startsWith(generic + '.') + ) { + violations.push({ file, selector, generic }); + } + } + } + } + + if (violations.length > 0) { + const message = violations.map((v) => ` ${v.file}: "${v.selector}" (conflicts with "${v.generic}")`).join('\n'); + expect.fail( + `Found generic utility class names that will conflict with host app frameworks:\n${message}\n\nPrefix with "superdoc-" or "sd-" namespace`, + ); + } + }); + + it('extractTopLevelSelectors should parse basic CSS correctly', () => { + const css = ` + .foo { color: red; } + .bar, .baz { color: blue; } + @keyframes spin { to { transform: rotate(360deg); } } + .qux { color: green; } + `; + const selectors = extractTopLevelSelectors(css); + expect(selectors).toContain('.foo'); + expect(selectors).toContain('.bar'); + expect(selectors).toContain('.baz'); + expect(selectors).toContain('.qux'); + // @keyframes should not produce selectors + expect(selectors).not.toContain('@keyframes spin'); + }); + + it('extractTopLevelSelectors should ignore nested selectors', () => { + const css = ` + .parent { + .child { color: red; } + } + a { color: blue; } + `; + const selectors = extractTopLevelSelectors(css); + expect(selectors).toContain('.parent'); + expect(selectors).toContain('a'); + // .child is nested, should not appear + expect(selectors).not.toContain('.child'); + }); + + it("should namespace all @keyframes names with superdoc- or sd-", () => { + const cssFiles = findCssFiles(SUPER_EDITOR_STYLES_DIR); + const violations = []; + + for (const file of cssFiles) { + const fullPath = join(SUPER_EDITOR_STYLES_DIR, file); + const cssText = readFileSync(fullPath, "utf8"); + const keyframePattern = /@keyframes\s+([\w-]+)/g; + let match; + + while ((match = keyframePattern.exec(cssText)) !== null) { + const name = match[1]; + if (!name.startsWith("superdoc-") && !name.startsWith("sd-")) { + violations.push({ file, keyframe: name }); + } + } + } + + if (violations.length > 0) { + const message = violations + .map((v) => ` ${v.file}: @keyframes ${v.keyframe}`) + .join("\n"); + expect.fail( + `Found @keyframes names not prefixed with "superdoc-" or "sd-":\n${message}\n\nRename to start with "superdoc-" or "sd-" to avoid collisions with host apps`, + ); + } + }); +}); diff --git a/packages/super-editor/src/tests/css-scoping-regressions.test.js b/packages/super-editor/src/tests/css-scoping-regressions.test.js index 4a3cdd7ad2..c61180e206 100644 --- a/packages/super-editor/src/tests/css-scoping-regressions.test.js +++ b/packages/super-editor/src/tests/css-scoping-regressions.test.js @@ -177,4 +177,43 @@ describe('CSS Scoping Regressions', () => { host.remove(); } }); + + it('noderesizer stylesheet should define overlay selectors that do not require .super-editor ancestry', () => { + const cssText = readFileSync(NODE_RESIZER_CSS_PATH, 'utf8'); + const selectors = extractTopLevelSelectors(cssText); + + const overlayContainerSelectors = selectors.filter( + (selector) => + selector.includes('.sd-editor-resize-container') && !selector.includes(':hover') && !selector.includes('::'), + ); + const overlayHandleSelectors = selectors.filter( + (selector) => + selector.includes('.sd-editor-resize-handle') && !selector.includes(':hover') && !selector.includes('::'), + ); + + expect(overlayContainerSelectors.length).toBeGreaterThan(0); + expect(overlayHandleSelectors.length).toBeGreaterThan(0); + expect(overlayContainerSelectors.some((selector) => !selector.includes('.super-editor'))).toBe(true); + expect(overlayHandleSelectors.some((selector) => !selector.includes('.super-editor'))).toBe(true); + }); + + it('prosemirror stylesheet should define core selectors that do not require .super-editor ancestry', () => { + const cssText = readFileSync(PROSEMIRROR_CSS_PATH, 'utf8'); + const selectors = extractTopLevelSelectors(cssText); + + const proseMirrorRootSelectors = selectors.filter(targetsProseMirrorRoot); + const proseMirrorListSelectors = selectors.filter( + (selector) => selector.includes('.ProseMirror ol') || selector.includes('.ProseMirror ul'), + ); + const proseMirrorSelectedNodeSelectors = selectors.filter((selector) => + selector.includes('.ProseMirror-selectednode'), + ); + + expect(proseMirrorRootSelectors.length).toBeGreaterThan(0); + expect(proseMirrorListSelectors.length).toBeGreaterThan(0); + expect(proseMirrorSelectedNodeSelectors.length).toBeGreaterThan(0); + expect(proseMirrorRootSelectors.some((selector) => !selector.includes('.super-editor'))).toBe(true); + expect(proseMirrorListSelectors.some((selector) => !selector.includes('.super-editor'))).toBe(true); + expect(proseMirrorSelectedNodeSelectors.some((selector) => !selector.includes('.super-editor'))).toBe(true); + }); });