diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8d1a59e..45f9be5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -67,7 +67,10 @@ jobs:
- run: pnpm install --frozen-lockfile
- name: Install Playwright browsers
- run: pnpm dlx playwright install --with-deps chromium
+ # Use the project-pinned Playwright (pnpm exec), not `pnpm dlx` which
+ # fetches the latest Playwright and installs mismatched browser
+ # revisions, leaving the pinned version's binary missing.
+ run: pnpm exec playwright install --with-deps chromium
- name: Run tests
run: pnpm test
diff --git a/src/EpButton.ts b/src/EpButton.ts
index 4c592c6..f67c004 100644
--- a/src/EpButton.ts
+++ b/src/EpButton.ts
@@ -20,6 +20,10 @@ export class EpButton extends LitElement {
line-height: 1.5;
border: none;
outline: none;
+ /* Reset the UA button background so it doesn't leak through variants
+ that set their own (default/ghost/icon were showing the browser's
+ light ButtonFace, which looked broken in dark themes). */
+ background: transparent;
display: inline-flex;
width: 100%;
height: 100%;
@@ -51,21 +55,21 @@ export class EpButton extends LitElement {
background: var(--bg-soft-color, #f2f3f4);
}
- /* Primary */
+ /* Primary — matches Etherpad colibris .btn-primary: primary-coloured
+ background with bg-coloured (white) text. */
:host([variant="primary"]) button {
- background: var(--text-color, #586a69);
- color: var(--primary-color, #64d29b);
+ background: var(--primary-color, #64d29b);
+ color: var(--bg-color, #ffffff);
border: none;
- transition: .2s background-color 0.15s ease, border-color 0.15s ease, opacity 0.15s ease;
+ transition: filter 0.15s ease, opacity 0.15s ease;
}
- :host([variant="primary"]) button:active {
- box-shadow: var(--primary-button-active,inset 0 1px 12px rgba(0, 0, 0, 0.9));
- background: var(--primary-button-active, #444);
+ :host([variant="primary"]) button:hover {
+ filter: brightness(0.94);
}
- :host([variant="primary"]) button:hover {
- background: var(--dark-color, #4a5d5c);
+ :host([variant="primary"]) button:active {
+ filter: brightness(0.88);
}
/* Ghost */
diff --git a/src/EpChatMessage.ts b/src/EpChatMessage.ts
index 2ca1da8..ad382f1 100644
--- a/src/EpChatMessage.ts
+++ b/src/EpChatMessage.ts
@@ -4,44 +4,39 @@ import { customElement, property } from 'lit/decorators.js';
@customElement('ep-chat-message')
export class EpChatMessage extends LitElement {
static styles = css`
+ /* Matches Etherpad colibris chat: a plain message line (#chattext p),
+ padding 4px 10px, bold author, muted inline time, then the text. No
+ bubbles — Etherpad does not style own vs other messages differently. */
:host {
--ep-font: var(--main-font-family, Quicksand, Cantarell, "Open Sans", "Helvetica Neue", sans-serif);
display: block;
font-family: var(--ep-font);
font-size: 14px;
+ line-height: 1.5;
color: var(--text-color, #485365);
- padding: 6px 10px;
+ padding: 4px 10px;
+ word-wrap: break-word;
}
:host(:first-child) { padding-top: 10px; }
:host(:last-child) { padding-bottom: 10px; }
- .header {
- display: flex;
- align-items: baseline;
- gap: 8px;
- margin-bottom: 2px;
- }
-
.author {
- font-weight: 700;
- font-size: 13px;
+ font-weight: bold;
}
.time {
font-size: 11px;
color: var(--text-soft-color, #576273);
+ margin: 0 4px 0 6px;
}
- .body {
- line-height: 1.5;
- word-wrap: break-word;
- }
+ .body { display: inline; }
+ /* authorColors mode: Etherpad tints the whole message with the author
+ colour. Opt-in via the own flag to keep the default view plain. */
:host([own]) {
background: var(--bg-soft-color, #f2f3f4);
- border-radius: 4px;
- margin: 2px 0;
}
`;
@@ -52,13 +47,7 @@ export class EpChatMessage extends LitElement {
render() {
return html`
-
-
-
-
+ ${this.author}${this.time ? html`${this.time}` : html``}
`;
}
}
diff --git a/src/EpCheckbox.ts b/src/EpCheckbox.ts
index 421162c..9279fde 100644
--- a/src/EpCheckbox.ts
+++ b/src/EpCheckbox.ts
@@ -22,11 +22,17 @@ export class EpCheckbox extends LitElement {
pointer-events: none;
}
+ /* Etherpad colibris toggle: a light, outlined track (bg-soft fill with a
+ text-soft border) that switches to a primary-coloured border when
+ checked. */
.track {
position: relative;
- background: var(--middle-color, #d2d2d2);
+ box-sizing: border-box;
+ background: var(--bg-soft-color, #f2f3f4);
+ border: 2px solid var(--text-soft-color, #576273);
border-radius: 10px;
- transition: background 0.2s ease;
+ opacity: 0.7;
+ transition: border-color 0.2s ease, opacity 0.2s ease;
flex-shrink: 0;
}
@@ -41,20 +47,25 @@ export class EpCheckbox extends LitElement {
}
:host([checked]) .track {
- background: var(--text-color, #64d29b);
+ background: transparent;
+ border-color: var(--primary-color, #64d29b);
+ opacity: 1;
}
-
+
.thumb {
position: absolute;
top: 50%;
width: 16px;
height: 16px;
border-radius: 50%;
- background: white;
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
- transition: transform 0.2s ease;
+ background: var(--text-soft-color, #576273);
+ transition: transform 0.2s ease, background 0.2s ease;
transform: translateY(-50%);
}
+
+ :host([checked]) .thumb {
+ background: var(--primary-color, #64d29b);
+ }
:host([variant="default"]) .thumb {
left: 2px;
diff --git a/src/EpColorPicker.ts b/src/EpColorPicker.ts
index 8b17812..4db768f 100644
--- a/src/EpColorPicker.ts
+++ b/src/EpColorPicker.ts
@@ -27,9 +27,12 @@ const isLightColor = (color: string): boolean => {
}
};
+// Etherpad's author colour palette (the pastel tints from
+// AuthorManager.getColorPalette) — the same swatches Etherpad offers when
+// picking your author colour.
const DEFAULT_COLORS = [
- 'black', 'red', 'green', 'blue', 'yellow', 'orange',
- 'purple', 'pink', 'brown', 'gray', 'white', 'cyan',
+ '#ffc7c7', '#fff1c7', '#e3ffc7', '#c7ffd5', '#c7ffff', '#c7d5ff', '#e3c7ff', '#ffc7f1',
+ '#ffa8a8', '#ffe699', '#cfff9e', '#99ffb3', '#a3ffff', '#99b3ff', '#cc99ff', '#ff99e5',
];
@customElement('ep-color-picker')
diff --git a/src/EpEditor.ts b/src/EpEditor.ts
index 7729f37..94d0cdc 100644
--- a/src/EpEditor.ts
+++ b/src/EpEditor.ts
@@ -1,4 +1,4 @@
-import { LitElement, html, css } from 'lit';
+import { LitElement, html } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { AceEditor } from './editor/AceEditor.js';
import type AttributePool from './editor/AttributePool.js';
@@ -16,60 +16,14 @@ import type AttributePool from './editor/AttributePool.js';
*/
@customElement('ep-editor')
export class EpEditor extends LitElement {
- static styles = css`
- :host {
- display: block;
- position: relative;
- min-height: 100px;
- }
-
- .ep-editor-container {
- width: 100%;
- height: 100%;
- min-height: inherit;
- overflow: auto;
- font-family: var(--ep-editor-font, monospace);
- font-size: var(--ep-editor-font-size, 14px);
- line-height: var(--ep-editor-line-height, 1.6);
- color: var(--ep-editor-color, #333);
- background: var(--ep-editor-bg, #fff);
- padding: var(--ep-editor-padding, 8px 12px);
- box-sizing: border-box;
- outline: none;
- white-space: pre-wrap;
- word-wrap: break-word;
- }
-
- .ep-editor-container:focus {
- outline: none;
- }
-
- .ep-editor-container .list-bullet1 { list-style-type: disc; }
- .ep-editor-container .list-bullet2 { list-style-type: circle; }
- .ep-editor-container .list-bullet3 { list-style-type: square; }
- .ep-editor-container .list-bullet4 { list-style-type: disc; }
- .ep-editor-container .list-number1 { list-style-type: decimal; }
- .ep-editor-container .list-number2 { list-style-type: lower-alpha; }
- .ep-editor-container .list-number3 { list-style-type: lower-roman; }
- .ep-editor-container .list-number4 { list-style-type: decimal; }
-
- .ep-editor-container ul, .ep-editor-container ol {
- padding-left: 1.5em;
- margin: 0;
- }
-
- .ep-editor-container .tag\\:b, .ep-editor-container b { font-weight: bold; }
- .ep-editor-container .tag\\:i, .ep-editor-container i { font-style: italic; }
- .ep-editor-container .tag\\:u, .ep-editor-container u { text-decoration: underline; }
- .ep-editor-container .tag\\:s, .ep-editor-container s { text-decoration: line-through; }
-
- .ep-editor-container a { color: var(--ep-editor-link-color, #0366d6); text-decoration: underline; }
-
- :host([readonly]) .ep-editor-container {
- opacity: 0.85;
- cursor: default;
- }
- `;
+ // Render into the light DOM, NOT a shadow root. Etherpad's Ace engine reads
+ // and restores the caret via the document Selection API, which does not work
+ // reliably across a shadow boundary (the caret retargets to the host and
+ // every keystroke inserts at offset 0, reversing the text). In light DOM the
+ // engine uses the standard, well-tested selection path.
+ protected createRenderRoot() {
+ return this;
+ }
/** Initial text content for the editor. */
@property({ type: String }) content = '';
@@ -95,7 +49,7 @@ export class EpEditor extends LitElement {
// ── Lifecycle ──────────────────────────────────────────────
protected firstUpdated() {
- const container = this.shadowRoot!.querySelector('.ep-editor-container') as HTMLElement;
+ const container = this.renderRoot.querySelector('.ep-editor-container') as HTMLElement;
if (!container) return;
this._editor = new AceEditor(container);
@@ -294,6 +248,41 @@ export class EpEditor extends LitElement {
protected render() {
return html`
+
= {
success: `
`,
- error: `
`,
+ error: `
`,
info: `
`,
};
@@ -61,7 +61,7 @@ export class EpToastItem extends LitElement {
}
:host([type="success"]) .toast { border-left-color: var(--primary-color, #64d29b); }
- :host([type="error"]) .toast { border-left-color: #d9534f; }
+ :host([type="error"]) .toast { border-left-color: #d1242f; }
:host([type="info"]) .toast { border-left-color: var(--dark-color, #576273); }
.icon { flex-shrink: 0; width: 16px; height: 16px; margin-top: 2px; }
diff --git a/src/editor/AceEditor.ts b/src/editor/AceEditor.ts
index 50015a9..c7fd1b0 100644
--- a/src/editor/AceEditor.ts
+++ b/src/editor/AceEditor.ts
@@ -2068,13 +2068,38 @@ export class AceEditor {
return this.targetDoc.getSelection();
}
+ // Obtain the live selection range. Inside a shadow root, getRangeAt(0)
+ // retargets the range to the shadow host (losing the real caret, which then
+ // reads as offset 0 — every keystroke would insert at the document start).
+ // getComposedRanges({shadowRoots}) returns a range that descends into the
+ // shadow tree, so we use it whenever the editor lives in a shadow root.
+ private getSelectionRange(browserSelection: any): {
+ startContainer: Node; startOffset: number; endContainer: Node; endOffset: number;
+ } | null {
+ if (this.rootNode instanceof ShadowRoot &&
+ typeof browserSelection.getComposedRanges === 'function') {
+ const ranges = browserSelection.getComposedRanges({ shadowRoots: [this.rootNode] });
+ if (ranges && ranges.length > 0) {
+ const r = ranges[0];
+ if (r.startContainer) return r;
+ }
+ }
+ if (browserSelection.rangeCount === 0) return null;
+ return browserSelection.getRangeAt(0);
+ }
+
private getSelection(): any {
const browserSelection = this.getDocSelection();
- if (!browserSelection || browserSelection.type === 'None' ||
- browserSelection.rangeCount === 0) {
+ if (!browserSelection) return null;
+ // In shadow DOM the legacy Selection reports type 'None' / rangeCount 0
+ // even when the caret is in the shadow tree, but getComposedRanges still
+ // resolves it — so only apply these guards outside a shadow root.
+ if (!(this.rootNode instanceof ShadowRoot) &&
+ (browserSelection.type === 'None' || browserSelection.rangeCount === 0)) {
return null;
}
- const range = browserSelection.getRangeAt(0);
+ const range = this.getSelectionRange(browserSelection);
+ if (!range) return null;
const isInBody = (n: Node | null): boolean => {
while (n) {