From 7e41625128aaf304e8611efaf07e165f1d9f616c Mon Sep 17 00:00:00 2001 From: John McLear Date: Mon, 11 May 2026 11:31:02 +0100 Subject: [PATCH 1/4] feat(skin): add margin skin (themes via data-theme, switchable from Settings) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A standalone drop-in skin alongside colibris / no-skin, with eleven themes — one neutral default (colibris) and five named themes each in light and dark mode: editorial, brutalist, paper, crt, industrial. Theme is chosen via a Theme dropdown injected into the Settings popup (both User Settings and Pad-wide Settings columns) and persists in localStorage under `marginTheme`. The skin applies the saved theme on load — in the pad via pad.js, in the lobby via index.js — so no edits to src/templates/* are required. The skin uses the same CSS-variable contract colibris exposes (`--primary-color`, `--bg-color`, `--main-font-family`, `--editor-horizontal-padding`, …) and reuses the colibris component partials, vendored into `margin/src/` so the skin is fully self-contained. Google Fonts powering the type stacks (Newsreader, Space Mono, Lora, IBM Plex Mono/Sans, VT323, Instrument Serif) are loaded via `@import` from `pad.css` / `index.css` so the skin does not require core template changes. --- src/static/skins/margin/README.md | 53 +++ src/static/skins/margin/index.css | 43 ++ src/static/skins/margin/index.js | 150 +++++++ src/static/skins/margin/pad.css | 415 ++++++++++++++++++ src/static/skins/margin/pad.js | 176 ++++++++ .../skins/margin/src/components/buttons.css | 74 ++++ .../skins/margin/src/components/chat.css | 91 ++++ .../skins/margin/src/components/form.css | 122 +++++ .../skins/margin/src/components/gritter.css | 82 ++++ .../margin/src/components/import-export.css | 5 + .../skins/margin/src/components/popup.css | 177 ++++++++ .../margin/src/components/scrollbars.css | 41 ++ .../skins/margin/src/components/sidediv.css | 31 ++ .../src/components/table-of-content.css | 21 + .../skins/margin/src/components/toolbar.css | 154 +++++++ .../skins/margin/src/components/users.css | 65 +++ src/static/skins/margin/src/general.css | 11 + src/static/skins/margin/src/layout.css | 48 ++ src/static/skins/margin/src/pad-editor.css | 48 ++ src/static/skins/margin/src/pad-variants.css | 228 ++++++++++ .../skins/margin/src/plugins/author_hover.css | 10 + .../margin/src/plugins/brightcolorpicker.css | 14 + .../skins/margin/src/plugins/comments.css | 112 +++++ .../skins/margin/src/plugins/font_color.css | 41 ++ .../margin/src/plugins/set_title_on_pad.css | 7 + .../skins/margin/src/plugins/tables2.css | 239 ++++++++++ src/static/skins/margin/timeslider.css | 108 +++++ src/static/skins/margin/timeslider.js | 4 + 28 files changed, 2570 insertions(+) create mode 100644 src/static/skins/margin/README.md create mode 100644 src/static/skins/margin/index.css create mode 100644 src/static/skins/margin/index.js create mode 100644 src/static/skins/margin/pad.css create mode 100644 src/static/skins/margin/pad.js create mode 100644 src/static/skins/margin/src/components/buttons.css create mode 100644 src/static/skins/margin/src/components/chat.css create mode 100644 src/static/skins/margin/src/components/form.css create mode 100644 src/static/skins/margin/src/components/gritter.css create mode 100644 src/static/skins/margin/src/components/import-export.css create mode 100644 src/static/skins/margin/src/components/popup.css create mode 100644 src/static/skins/margin/src/components/scrollbars.css create mode 100644 src/static/skins/margin/src/components/sidediv.css create mode 100644 src/static/skins/margin/src/components/table-of-content.css create mode 100644 src/static/skins/margin/src/components/toolbar.css create mode 100644 src/static/skins/margin/src/components/users.css create mode 100644 src/static/skins/margin/src/general.css create mode 100644 src/static/skins/margin/src/layout.css create mode 100644 src/static/skins/margin/src/pad-editor.css create mode 100644 src/static/skins/margin/src/pad-variants.css create mode 100644 src/static/skins/margin/src/plugins/author_hover.css create mode 100644 src/static/skins/margin/src/plugins/brightcolorpicker.css create mode 100644 src/static/skins/margin/src/plugins/comments.css create mode 100644 src/static/skins/margin/src/plugins/font_color.css create mode 100644 src/static/skins/margin/src/plugins/set_title_on_pad.css create mode 100644 src/static/skins/margin/src/plugins/tables2.css create mode 100644 src/static/skins/margin/timeslider.css create mode 100644 src/static/skins/margin/timeslider.js diff --git a/src/static/skins/margin/README.md b/src/static/skins/margin/README.md new file mode 100644 index 00000000000..247c0c42947 --- /dev/null +++ b/src/static/skins/margin/README.md @@ -0,0 +1,53 @@ +# margin — Etherpad skin + +A standalone drop-in skin with eleven themes — one neutral default and five named themes each available in light and dark mode: + +| Default | Light | Dark | +| --- | --- | --- | +| `colibris` | `editorial` | `editorial-dark` | +| | `brutalist` | `brutalist-dark` | +| | `paper` | `paper-dark` | +| | `crt-light` | `crt` | +| | `industrial-light` | `industrial` | + +No external dependency on colibris — all component partials are vendored under `src/`. + +## Install + +1. Copy this `margin/` folder into `src/static/skins/`. +2. In `settings.json`, set: + ```json + "skinName": "margin" + ``` + +No template edits are required. The skin applies the user's saved theme on load (defaulting to `colibris`), the Google Fonts stylesheet is `@import`-ed from `pad.css` / `index.css`, and a **Theme** dropdown is injected into the User Settings and Pad-wide Settings popups. + +## Switch themes at runtime + +The Settings popup (the gear icon in the toolbar) has a **Theme** dropdown in both User Settings and Pad-wide Settings columns. Selecting a theme persists the choice in `localStorage` under the `marginTheme` key and reflects across the pad and the lobby. + +Programmatically, from DevTools: + +```js +document.documentElement.dataset.theme = 'crt' +``` + +## Folder layout + +``` +margin/ +├─ index.css lobby / pad-list themes +├─ index.js lobby JS (early theme bootstrap) +├─ pad.css pad themes + component imports +├─ pad.js pad JS hooks (theme bootstrap, Settings dropdown, +│ iframe theme propagation) +├─ timeslider.css version timeline +├─ timeslider.js timeslider JS +├─ src/ +│ ├─ general.css, layout.css, pad-editor.css, pad-variants.css +│ ├─ components/ toolbar, chat, popups, users, gritter, scrollbars, … +│ └─ plugins/ comments, color picker, tables, … +└─ README.md +``` + +The `src/` partials are vendored from upstream colibris so this skin is fully self-contained — themes layer on top via `data-theme="…"` overrides in `pad.css` and `index.css`, and inherit the same CSS-variable contract (`--primary-color`, `--bg-color`, `--main-font-family`, `--editor-horizontal-padding`, …) that colibris exposes. diff --git a/src/static/skins/margin/index.css b/src/static/skins/margin/index.css new file mode 100644 index 00000000000..080f36058eb --- /dev/null +++ b/src/static/skins/margin/index.css @@ -0,0 +1,43 @@ +/* margin / index.css — lobby + pad-list page + * Styles the home / pad-list using the same data-theme system as pad.css. + * Drop alongside pad.css in src/static/skins/margin/. */ + +@import url("https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300..700;1,6..72,300..700&family=Instrument+Serif:ital@0;1&family=Lora:ital,wght@0,400..700;1,400..700&family=Space+Mono:ital,wght@0,400;0,700;1,400;1,700&family=VT323&family=IBM+Plex+Mono:ital,wght@0,400;0,500;0,600;1,400&family=IBM+Plex+Sans:ital,wght@0,400;0,500;0,600;0,700&display=swap"); + +/* Theme definitions are mirrored from pad.css so the lobby themes too. + * Both light and dark variants of each named theme are defined so the + * Settings popup's Theme dropdown can apply consistently across pages. */ +[data-theme="colibris"] { --m-bg:#f2f3f4; --m-fg:#485365; --m-soft:#576273; --m-accent:#64d29b; --m-panel:#fff; --m-rule:#dadada; --m-radius:3px; --m-shadow:0 2px 8px rgba(68,68,68,.08); --m-font:Quicksand, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; --m-ui-style:normal; --m-ui-case:none; } +[data-theme="editorial"] { --m-bg:#f5f0e8; --m-fg:#1c1916; --m-soft:#5a534a; --m-accent:#a8442b; --m-panel:#fbf8f2; --m-rule:rgba(28,25,22,.12); --m-radius:2px; --m-shadow:0 12px 36px -18px rgba(28,25,22,.20); --m-font:"Newsreader",Georgia,serif; --m-ui-style:italic; --m-ui-case:none; } +[data-theme="editorial-dark"] { color-scheme:dark; --m-bg:#16130f; --m-fg:#f0eadd; --m-soft:#a8a098; --m-accent:#d27047; --m-panel:#1c1916; --m-rule:rgba(240,234,221,.12); --m-radius:2px; --m-shadow:0 12px 36px -18px rgba(0,0,0,.6); --m-font:"Newsreader",Georgia,serif; --m-ui-style:italic; --m-ui-case:none; } +[data-theme="brutalist"] { --m-bg:#f3f3f0; --m-fg:#000; --m-soft:#222; --m-accent:#ff3b00; --m-panel:#fff; --m-rule:#000; --m-radius:0; --m-shadow:4px 4px 0 #000; --m-font:"Space Mono",ui-monospace,monospace; --m-ui-style:normal; --m-ui-case:uppercase; } +[data-theme="brutalist-dark"] { color-scheme:dark; --m-bg:#0c0c09; --m-fg:#fff; --m-soft:#ddd; --m-accent:#ff3b00; --m-panel:#000; --m-rule:#fff; --m-radius:0; --m-shadow:4px 4px 0 #fff; --m-font:"Space Mono",ui-monospace,monospace; --m-ui-style:normal; --m-ui-case:uppercase; } +[data-theme="paper"] { --m-bg:#f6f1e8; --m-fg:#2a2520; --m-soft:#6b6259; --m-accent:#b87333; --m-panel:#fbf6ec; --m-rule:rgba(42,37,32,.08); --m-radius:12px; --m-shadow:0 14px 40px -16px rgba(42,37,32,.18); --m-font:"Lora",Georgia,serif; --m-ui-style:normal; --m-ui-case:none; } +[data-theme="paper-dark"] { color-scheme:dark; --m-bg:#231e19; --m-fg:#efe7d4; --m-soft:#a89e8d; --m-accent:#d99560; --m-panel:#2a2520; --m-rule:rgba(239,231,212,.10); --m-radius:12px; --m-shadow:0 14px 40px -16px rgba(0,0,0,.6); --m-font:"Lora",Georgia,serif; --m-ui-style:normal; --m-ui-case:none; } +[data-theme="crt-light"] { --m-bg:#edf6ee; --m-fg:#04200d; --m-soft:#1c4a2b; --m-accent:#006b3f; --m-panel:#e9f5ed; --m-rule:rgba(0,107,63,.20); --m-radius:0; --m-shadow:0 0 0 1px rgba(0,107,63,.25); --m-font:"IBM Plex Mono",ui-monospace,monospace; --m-ui-style:normal; --m-ui-case:uppercase; } +[data-theme="crt"] { color-scheme:dark; --m-bg:#04140a; --m-fg:#7fffae; --m-soft:#4ed188; --m-accent:#ffb84d; --m-panel:#08200f; --m-rule:rgba(127,255,174,.20); --m-radius:0; --m-shadow:0 0 24px rgba(127,255,174,.10); --m-font:"IBM Plex Mono",ui-monospace,monospace; --m-ui-style:normal; --m-ui-case:uppercase; } +[data-theme="industrial-light"] { --m-bg:#f0f1f4; --m-fg:#14171c; --m-soft:#525965; --m-accent:#cc9900; --m-panel:#f5f6f8; --m-rule:rgba(20,23,28,.08); --m-radius:3px; --m-shadow:0 12px 32px rgba(20,23,28,.10); --m-font:"IBM Plex Sans",ui-sans-serif,system-ui,sans-serif; --m-ui-style:normal; --m-ui-case:uppercase; } +[data-theme="industrial"] { color-scheme:dark; --m-bg:#0d0f12; --m-fg:#e6e8eb; --m-soft:#9aa0a8; --m-accent:#ffcc00; --m-panel:#14171c; --m-rule:rgba(255,255,255,.08); --m-radius:3px; --m-shadow:0 12px 32px rgba(0,0,0,.4); --m-font:"IBM Plex Sans",ui-sans-serif,system-ui,sans-serif; --m-ui-style:normal; --m-ui-case:uppercase; } + +[data-theme] body, [data-theme] .body { background: var(--m-bg) !important; color: var(--m-fg); font-family: var(--m-font); } +[data-theme] body nav { border-bottom: 1px solid var(--m-rule); } +[data-theme] .logo-box { background: var(--m-accent); border-radius: var(--m-radius); } +[data-theme] #wrapper, [data-theme] .pad-datalist { + background: var(--m-panel); border: 1px solid var(--m-rule); + border-radius: var(--m-radius); box-shadow: var(--m-shadow); +} +[data-theme] .mission-statement h2 { color: var(--m-fg); text-transform: var(--m-ui-case); font-style: var(--m-ui-style); } +[data-theme] .mission-statement p { color: var(--m-soft); } +[data-theme] #padname { background: var(--m-panel); color: var(--m-fg); border-color: var(--m-rule); border-radius: var(--m-radius); } +[data-theme] #go2Name [type="submit"], [data-theme] #transferSessionButton { + background: var(--m-accent); border-radius: var(--m-radius); + text-transform: var(--m-ui-case); +} +[data-theme] #button { background: var(--m-panel); color: var(--m-fg); border: 1px solid var(--m-rule); border-radius: var(--m-radius); } +[data-theme] .pad-datalist h2 { border-bottom-color: var(--m-rule); color: var(--m-fg); } +[data-theme] .recent-pad:hover a { color: var(--m-accent); } + +[data-theme="brutalist"] #wrapper, [data-theme="brutalist"] .pad-datalist, +[data-theme="brutalist"] #button, [data-theme="brutalist"] #go2Name [type="submit"] { + border: 2px solid #000 !important; box-shadow: 4px 4px 0 #000; +} diff --git a/src/static/skins/margin/index.js b/src/static/skins/margin/index.js new file mode 100644 index 00000000000..0a7120722a9 --- /dev/null +++ b/src/static/skins/margin/index.js @@ -0,0 +1,150 @@ +'use strict'; + +// Apply the user's saved theme as early as possible so the lobby paints in +// the same theme as the last pad they visited. The dropdown that writes +// this localStorage key lives in the pad's Settings popup (see pad.js). +const MARGIN_THEME_KEY = 'marginTheme'; +const MARGIN_THEME_DEFAULT = 'colibris'; +try { + const saved = localStorage.getItem(MARGIN_THEME_KEY); + document.documentElement.setAttribute('data-theme', saved || MARGIN_THEME_DEFAULT); +} catch (_) { + document.documentElement.setAttribute('data-theme', MARGIN_THEME_DEFAULT); +} + +window.addEventListener('pageshow', (event) => { + if (event.persisted) { + if (document.readyState === 'complete' || document.readyState === 'interactive') { + window.customStart(); + } else { + window.addEventListener('DOMContentLoaded', window.customStart, {once: true}); + } + } +}); + +window.customStart = () => { + const recentPadList = document.getElementById('recent-pads'); + if (recentPadList) { + recentPadList.replaceChildren(); + } + // define your javascript here + // jquery is available - except index.js + // you can load extra scripts with $.getScript http://api.jquery.com/jQuery.getScript/ + const divHoldingPlaceHolderLabel = document + .querySelector('[data-l10n-id="index.placeholderPadEnter"]'); + + const observer = new MutationObserver(() => { + document.querySelector('#go2Name input') + .setAttribute('placeholder', divHoldingPlaceHolderLabel.textContent); + }); + + observer + .observe(divHoldingPlaceHolderLabel, {childList: true, subtree: true, characterData: true}); + + + const recentPadListHeading = document.querySelector('[data-l10n-id="index.recentPads"]'); + const recentPadsFromLocalStorage = localStorage.getItem('recentPads'); + let recentPadListData = []; + if (recentPadsFromLocalStorage != null) { + recentPadListData = JSON.parse(recentPadsFromLocalStorage); + } + + // Remove duplicates based on pad name and sort by timestamp + recentPadListData = recentPadListData.filter( + (pad, index, self) => index === self.findIndex((p) => p.name === pad.name) + ).sort((a, b) => new Date(a.timestamp) > new Date(b.timestamp) ? -1 : 1); + + if (recentPadList && recentPadListData.length === 0) { + const parentStyle = recentPadList.parentElement.style; + recentPadListHeading.setAttribute('data-l10n-id', 'index.recentPadsEmpty'); + parentStyle.display = 'flex'; + parentStyle.justifyContent = 'center'; + parentStyle.alignItems = 'center'; + parentStyle.maxHeight = '100%'; + recentPadList.remove(); + } else if (recentPadList) { + /** + * @typedef {Object} Pad + * @property {string} name + */ + + /** + * @param {Pad} pad + */ + + const arrowIcon = ''; + const clockIcon = ''; + const personalIcon = ''; + recentPadListData.forEach((pad) => { + const li = document.createElement('li'); + + + li.style.cursor = 'pointer'; + + li.className = 'recent-pad'; + const padPath = `${window.location.href}p/${pad.name}`; + const link = document.createElement('a'); + link.style.textDecoration = 'none'; + + link.href = padPath; + link.innerText = pad.name; + li.appendChild(link); + + + const arrowIconElement = document.createElement('span'); + arrowIconElement.className = 'recent-pad-arrow'; + arrowIconElement.innerHTML = arrowIcon; + li.appendChild(arrowIconElement); + + const nextRow = document.createElement('div'); + + nextRow.style.display = 'flex'; + nextRow.style.gap = '10px'; + nextRow.style.marginTop = '10px'; + + const clockIconElement = document.createElement('span'); + clockIconElement.className = 'recent-pad-clock'; + clockIconElement.innerHTML = clockIcon; + + nextRow.appendChild(clockIconElement); + + const time = new Date(pad.timestamp); + const userLocale = navigator.language || 'en-US'; + + const formattedTime = time.toLocaleDateString(userLocale, { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); + const timeElement = document.createElement('span'); + timeElement.className = 'recent-pad-time'; + timeElement.innerText = formattedTime; + + nextRow.appendChild(timeElement); + + const personalIconElement = document.createElement('span'); + personalIconElement.className = 'recent-pad-personal'; + personalIconElement.innerHTML = personalIcon; + + personalIconElement.style.marginLeft = '5px'; + + const members = document.createElement('span'); + members.className = 'recent-pad-members'; + members.innerText = pad.members; + + + nextRow.appendChild(personalIconElement); + nextRow.appendChild(members); + li.appendChild(nextRow); + + li.addEventListener('click', () => { + window.location.href = padPath; + }); + + // https://v0.dev/chat/etherpad-design-clone-qZnwOrVRXxH + recentPadList.appendChild(li); + }); + } +}; diff --git a/src/static/skins/margin/pad.css b/src/static/skins/margin/pad.css new file mode 100644 index 00000000000..dd420000d56 --- /dev/null +++ b/src/static/skins/margin/pad.css @@ -0,0 +1,415 @@ +/* ════════════════════════════════════════════════════════════════════════ + * margin / pad.css — a standalone Etherpad skin with 5 themes. + * + * Drop the entire `margin/` folder into src/static/skins/, then in + * settings.json set: + * + * "skinName": "margin" + * + * Choose a theme by setting data-theme on in src/templates/pad.html: + * + * + * + * Themes: editorial · brutalist · paper · crt · industrial + * + * Switch live in devtools: document.documentElement.dataset.theme='crt' + * + * This file inherits the same variable contract colibris uses + * (--primary-color, --text-color, --bg-color, --main-font-family, …) so + * Etherpad's built-in components pick up the theme automatically. + * ════════════════════════════════════════════════════════════════════════ */ + +/* Google Fonts powering the five themed type stacks (Newsreader/Editorial, + * Space Mono/Brutalist, Lora/Paper, IBM Plex Mono+VT323/CRT, IBM Plex + * Sans/Industrial, plus Instrument Serif for display H1/H2). */ +@import url("https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300..700;1,6..72,300..700&family=Instrument+Serif:ital@0;1&family=Lora:ital,wght@0,400..700;1,400..700&family=Space+Mono:ital,wght@0,400;0,700;1,400;1,700&family=VT323&family=IBM+Plex+Mono:ital,wght@0,400;0,500;0,600;1,400&family=IBM+Plex+Sans:ital,wght@0,400;0,500;0,600;0,700&display=swap"); + +/* Component partials — vendored from colibris so this skin is standalone. */ +@import url("src/general.css"); +@import url("src/layout.css"); +@import url("src/pad-editor.css"); +@import url("src/components/scrollbars.css"); +@import url("src/components/buttons.css"); +@import url("src/components/popup.css"); +@import url("src/components/chat.css"); +@import url("src/components/sidediv.css"); +@import url("src/components/gritter.css"); +@import url("src/components/table-of-content.css"); +@import url("src/components/toolbar.css"); +@import url("src/components/users.css"); +@import url("src/components/form.css"); +@import url("src/components/import-export.css"); +@import url("src/plugins/brightcolorpicker.css"); +@import url("src/plugins/font_color.css"); +@import url("src/plugins/tables2.css"); +@import url("src/plugins/set_title_on_pad.css"); +@import url("src/plugins/author_hover.css"); +@import url("src/plugins/comments.css"); + +/* Default theme if data-theme isn't set */ +:root { color-scheme: light; } +html:not([data-theme]) { /* fallback */ } + +/* Mirror colibris's narrow-viewport behavior: the per-theme blocks below + * each set --editor-horizontal-padding (40-64px) so the editor frame has + * room to breathe on desktop. Below 1000px (matching the layout.css media + * query that drops `max-width: 900px` on the iframe), zero them so the + * editor surface fills the available container width. */ +@media (max-width: 1000px) { + [data-theme] { + --editor-horizontal-padding: 0px !important; + --editor-vertical-padding: 0px !important; + } +} + +/* ─── 1. EDITORIAL ───────────────────────────────────────────────────── */ +[data-theme="editorial"] { + --super-dark-color:#1c1916; --dark-color:#5a534a; + --primary-color:#a8442b; --middle-color:rgba(28,25,22,.18); + --light-color:#f0eadd; --super-light-color:#fbf8f2; + --text-color:#1c1916; --text-soft-color:#5a534a; + --border-color:rgba(28,25,22,.18); + --bg-soft-color:#f0eadd; --bg-color:#fbf8f2; + --toolbar-border:1px solid rgba(28,25,22,.12); + --main-font-family:"Newsreader",Georgia,serif; + --editor-horizontal-padding:64px; --editor-vertical-padding:40px; + --m-bg:#f5f0e8; --m-rule:rgba(28,25,22,.12); + --m-radius:2px; --m-shadow:0 12px 36px -18px rgba(28,25,22,.20); + --m-ui-style:italic; --m-ui-case:none; --m-ui-track:.04em; +} + +/* ─── 2. BRUTALIST ──────────────────────────────────────────────────── */ +[data-theme="brutalist"] { + --super-dark-color:#000; --dark-color:#222; + --primary-color:#ff3b00; --middle-color:#000; + --light-color:#f3f3f0; --super-light-color:#fff; + --text-color:#000; --text-soft-color:#222; + --border-color:#000; + --bg-soft-color:#f3f3f0; --bg-color:#fff; + --toolbar-border:2px solid #000; + --main-font-family:"Space Mono",ui-monospace,monospace; + --editor-horizontal-padding:48px; --editor-vertical-padding:32px; + --m-bg:#f3f3f0; --m-rule:#000; + --m-radius:0; --m-shadow:4px 4px 0 #000; + --m-ui-style:normal; --m-ui-case:uppercase; --m-ui-track:.06em; +} + +/* ─── 3. PAPER ──────────────────────────────────────────────────────── */ +[data-theme="paper"] { + --super-dark-color:#2a2520; --dark-color:#6b6259; + --primary-color:#b87333; --middle-color:rgba(42,37,32,.18); + --light-color:#efe7d4; --super-light-color:#fbf6ec; + --text-color:#2a2520; --text-soft-color:#6b6259; + --border-color:rgba(42,37,32,.18); + --bg-soft-color:#efe7d4; --bg-color:#fbf6ec; + --toolbar-border:1px solid rgba(42,37,32,.08); + --main-font-family:"Lora",Georgia,serif; + --editor-horizontal-padding:56px; --editor-vertical-padding:40px; + --m-bg:#f6f1e8; --m-rule:rgba(42,37,32,.08); + --m-radius:10px; --m-shadow:0 14px 40px -16px rgba(42,37,32,.18); + --m-ui-style:normal; --m-ui-case:none; --m-ui-track:0; + --m-display-font:"Instrument Serif",Georgia,serif; +} + +/* ─── 4. CRT TERMINAL ───────────────────────────────────────────────── */ +[data-theme="crt"] { + color-scheme: dark; + --super-dark-color:#7fffae; --dark-color:#4ed188; + --primary-color:#ffb84d; --middle-color:rgba(127,255,174,.45); + --light-color:#0c2a14; --super-light-color:#08200f; + --text-color:#7fffae; --text-soft-color:#4ed188; + --border-color:rgba(127,255,174,.45); + --bg-soft-color:#0c2a14; --bg-color:#08200f; + --toolbar-border:1px solid rgba(127,255,174,.45); + --main-font-family:"IBM Plex Mono",ui-monospace,monospace; + --editor-horizontal-padding:48px; --editor-vertical-padding:32px; + --m-bg:#04140a; --m-rule:rgba(127,255,174,.20); + --m-radius:0; --m-shadow:0 0 0 1px rgba(127,255,174,.25),0 0 24px rgba(127,255,174,.10); + --m-ui-style:normal; --m-ui-case:uppercase; --m-ui-track:.10em; + --m-display-font:"VT323",ui-monospace,monospace; +} + +/* ─── 5. INDUSTRIAL ─────────────────────────────────────────────────── */ +[data-theme="industrial"] { + color-scheme: dark; + --super-dark-color:#e6e8eb; --dark-color:#9aa0a8; + --primary-color:#ffcc00; --middle-color:rgba(255,255,255,.18); + --light-color:#1c2027; --super-light-color:#14171c; + --text-color:#e6e8eb; --text-soft-color:#9aa0a8; + --border-color:rgba(255,255,255,.18); + --bg-soft-color:#1c2027; --bg-color:#14171c; + --toolbar-border:1px solid rgba(255,255,255,.08); + --main-font-family:"IBM Plex Sans",ui-sans-serif,system-ui,sans-serif; + --editor-horizontal-padding:56px; --editor-vertical-padding:36px; + --m-bg:#0d0f12; --m-rule:rgba(255,255,255,.08); + --m-radius:3px; --m-shadow:0 12px 32px rgba(0,0,0,.4); + --m-ui-style:normal; --m-ui-case:uppercase; --m-ui-track:.08em; +} + +/* ─── 0. COLIBRIS (default, mirrors colibris/pad.css) ──────────────── */ +[data-theme="colibris"] { + --super-dark-color:#485365; --dark-color:#576273; + --primary-color:#64d29b; --middle-color:#dadada; + --light-color:#f2f3f4; --super-light-color:#ffffff; + --text-color:#485365; --text-soft-color:#576273; + --border-color:#dadada; + --bg-soft-color:#f2f3f4; --bg-color:#ffffff; + --toolbar-border:none; + --main-font-family:Quicksand, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; + --editor-horizontal-padding:40px; --editor-vertical-padding:25px; + --m-bg:#f2f3f4; --m-rule:#dadada; + --m-radius:3px; --m-shadow:0 2px 8px rgba(68,68,68,.08); + --m-ui-style:normal; --m-ui-case:none; --m-ui-track:0; +} + +/* ─── 1b. EDITORIAL — DARK ─────────────────────────────────────────── */ +[data-theme="editorial-dark"] { + color-scheme:dark; + --super-dark-color:#f0eadd; --dark-color:#a8a098; + --primary-color:#d27047; --middle-color:rgba(240,234,221,.18); + --light-color:#221e1a; --super-light-color:#1c1916; + --text-color:#f0eadd; --text-soft-color:#a8a098; + --border-color:rgba(240,234,221,.18); + --bg-soft-color:#221e1a; --bg-color:#1c1916; + --toolbar-border:1px solid rgba(240,234,221,.12); + --main-font-family:"Newsreader",Georgia,serif; + --editor-horizontal-padding:64px; --editor-vertical-padding:40px; + --m-bg:#16130f; --m-rule:rgba(240,234,221,.12); + --m-radius:2px; --m-shadow:0 12px 36px -18px rgba(0,0,0,.6); + --m-ui-style:italic; --m-ui-case:none; --m-ui-track:.04em; +} + +/* ─── 2b. BRUTALIST — DARK ─────────────────────────────────────────── */ +[data-theme="brutalist-dark"] { + color-scheme:dark; + --super-dark-color:#fff; --dark-color:#ddd; + --primary-color:#ff3b00; --middle-color:#fff; + --light-color:#111; --super-light-color:#000; + --text-color:#fff; --text-soft-color:#ddd; + --border-color:#fff; + --bg-soft-color:#111; --bg-color:#000; + --toolbar-border:2px solid #fff; + --main-font-family:"Space Mono",ui-monospace,monospace; + --editor-horizontal-padding:48px; --editor-vertical-padding:32px; + --m-bg:#0c0c09; --m-rule:#fff; + --m-radius:0; --m-shadow:4px 4px 0 #fff; + --m-ui-style:normal; --m-ui-case:uppercase; --m-ui-track:.06em; +} + +/* ─── 3b. PAPER — DARK ─────────────────────────────────────────────── */ +[data-theme="paper-dark"] { + color-scheme:dark; + --super-dark-color:#efe7d4; --dark-color:#a89e8d; + --primary-color:#d99560; --middle-color:rgba(239,231,212,.18); + --light-color:#332c25; --super-light-color:#2a2520; + --text-color:#efe7d4; --text-soft-color:#a89e8d; + --border-color:rgba(239,231,212,.18); + --bg-soft-color:#332c25; --bg-color:#2a2520; + --toolbar-border:1px solid rgba(239,231,212,.10); + --main-font-family:"Lora",Georgia,serif; + --editor-horizontal-padding:56px; --editor-vertical-padding:40px; + --m-bg:#231e19; --m-rule:rgba(239,231,212,.10); + --m-radius:10px; --m-shadow:0 14px 40px -16px rgba(0,0,0,.6); + --m-ui-style:normal; --m-ui-case:none; --m-ui-track:0; + --m-display-font:"Instrument Serif",Georgia,serif; +} + +/* ─── 4b. CRT — LIGHT (paper-terminal, less iconic but readable) ──── */ +[data-theme="crt-light"] { + --super-dark-color:#04200d; --dark-color:#1c4a2b; + --primary-color:#006b3f; --middle-color:rgba(0,107,63,.30); + --light-color:#dfeee2; --super-light-color:#e9f5ed; + --text-color:#04200d; --text-soft-color:#1c4a2b; + --border-color:rgba(0,107,63,.30); + --bg-soft-color:#dfeee2; --bg-color:#e9f5ed; + --toolbar-border:1px solid rgba(0,107,63,.30); + --main-font-family:"IBM Plex Mono",ui-monospace,monospace; + --editor-horizontal-padding:48px; --editor-vertical-padding:32px; + --m-bg:#edf6ee; --m-rule:rgba(0,107,63,.20); + --m-radius:0; --m-shadow:0 0 0 1px rgba(0,107,63,.25); + --m-ui-style:normal; --m-ui-case:uppercase; --m-ui-track:.10em; + --m-display-font:"VT323",ui-monospace,monospace; +} + +/* ─── 5b. INDUSTRIAL — LIGHT ──────────────────────────────────────── */ +[data-theme="industrial-light"] { + --super-dark-color:#14171c; --dark-color:#525965; + --primary-color:#cc9900; --middle-color:rgba(20,23,28,.18); + --light-color:#e6e8eb; --super-light-color:#f5f6f8; + --text-color:#14171c; --text-soft-color:#525965; + --border-color:rgba(20,23,28,.18); + --bg-soft-color:#e6e8eb; --bg-color:#f5f6f8; + --toolbar-border:1px solid rgba(20,23,28,.08); + --main-font-family:"IBM Plex Sans",ui-sans-serif,system-ui,sans-serif; + --editor-horizontal-padding:56px; --editor-vertical-padding:36px; + --m-bg:#f0f1f4; --m-rule:rgba(20,23,28,.08); + --m-radius:3px; --m-shadow:0 12px 32px rgba(20,23,28,.10); + --m-ui-style:normal; --m-ui-case:uppercase; --m-ui-track:.08em; +} + +/* ════════════════════════════════════════════════════════════════════ + * Cross-theme overrides (apply to anything under [data-theme]) + * ════════════════════════════════════════════════════════════════════ */ +[data-theme] body { background: var(--m-bg); color: var(--text-color); } +[data-theme] #editorcontainerbox, +[data-theme] #editorcontainer, +[data-theme] #padeditor, +[data-theme] #outerdocbody, +[data-theme] iframe[name="ace_outer"], +[data-theme] iframe[name="ace_inner"] { background: var(--m-bg) !important; } + +/* Editor frame outline — gives the pad surface a visible edge against + * the page background when both share dark/light tones. */ +[data-theme] iframe[name="ace_outer"] { + border: 1px solid var(--border-color) !important; + border-radius: 4px; + box-shadow: 0 1px 0 rgba(0,0,0,.04); +} +[data-theme^="brutalist"] iframe[name="ace_outer"] { border-radius: 0; border-width: 2px !important; box-shadow: var(--m-shadow); border-color: var(--m-rule) !important; } +[data-theme="crt"] iframe[name="ace_outer"] { box-shadow: 0 0 0 1px var(--border-color), 0 0 18px rgba(127,255,174,.18) inset; } +[data-theme="crt-light"] iframe[name="ace_outer"] { box-shadow: 0 0 0 1px var(--border-color); } + +/* Show-users / chat / share buttons in the menu_right have hard-coded + * background colors in upstream — force them to follow the theme. + * No left-borders: keep the right-side icons visually flush like the left. */ +[data-theme] .toolbar .menu_right li a, +[data-theme] #chaticon, +[data-theme] #chaticon a { + background: transparent !important; + color: var(--text-color) !important; + border-left: 0 !important; +} +[data-theme] #chaticon .chatlabel { color: var(--text-color) !important; } +[data-theme] .toolbar ul li[data-key=showusers] > a { + background: var(--primary-color) !important; + color: var(--bg-color) !important; +} + +/* Normalize and dispatches change via $().trigger('change'), + // which only fires jQuery-bound handlers — not native addEventListener. Bind + // through jQuery so the widget actually drives applyMarginTheme. + $(select).on('change', () => { + applyMarginTheme(select.value); + // Mirror to the sibling select (user-settings ↔ pad-settings). + $('.margin-theme-row select').each(function () { + if (this !== select) this.value = select.value; + }); + if ($.fn.niceSelect) $('.margin-theme-row select').niceSelect('update'); + }); + row.appendChild(label); + row.appendChild(select); + return row; +}; + +const injectThemeSelector = () => { + // Two .dropdowns-container blocks exist: user settings + pad-wide settings. + const containers = [ + {host: '#user-settings-section .dropdowns-container', id: 'margin-theme-user'}, + {host: '#pad-settings-section .dropdowns-container', id: 'margin-theme-pad'}, + ]; + let injectedAny = false; + let allDone = true; + let injectedThisCall = false; + containers.forEach(({host, id}) => { + if (document.getElementById(id)) { injectedAny = true; return; } + const target = document.querySelector(host); + if (!target) { allDone = false; return; } + target.appendChild(buildThemeRow(id)); + injectedAny = true; + injectedThisCall = true; + }); + // Etherpad runs $('select').niceSelect() once at pad-init (pad_editbar.ts), + // so a freshly appended is still native chrome. Wrap ours now so @@ -102,19 +165,23 @@ const injectThemeSelector = () => { return injectedAny && allDone; }; -// Propagate data-theme from the host page into the editor iframes so -// [data-theme=...] rules in pad-editor.css apply inside them. +// Propagate data-theme + data-mode from the host page into the editor iframes +// so [data-theme="X"][data-mode="Y"] rules in pad-editor.css apply inside. const propagateTheme = () => { try { const theme = document.documentElement.getAttribute('data-theme'); + const mode = document.documentElement.getAttribute('data-mode'); if (!theme) return; const setOn = (doc) => { if (!doc) return; - if (doc.documentElement) doc.documentElement.setAttribute('data-theme', theme); - const outerBody = doc.getElementById && doc.getElementById('outerdocbody'); - if (outerBody) outerBody.setAttribute('data-theme', theme); - const innerBody = doc.getElementById && doc.getElementById('innerdocbody'); - if (innerBody) innerBody.setAttribute('data-theme', theme); + const apply = (el) => { + if (!el) return; + el.setAttribute('data-theme', theme); + if (mode) el.setAttribute('data-mode', mode); + }; + apply(doc.documentElement); + apply(doc.getElementById && doc.getElementById('outerdocbody')); + apply(doc.getElementById && doc.getElementById('innerdocbody')); }; const outer = document.querySelector('iframe[name="ace_outer"]'); if (!outer) return; @@ -143,9 +210,9 @@ window.customStart = () => { propagateTheme(); if (++attempts > 40) clearInterval(tick); // ~10s }, 250); - // Also re-apply if theme is changed at runtime + // Also re-apply if theme/mode is changed at runtime new MutationObserver(propagateTheme).observe(document.documentElement, { - attributes: true, attributeFilter: ['data-theme'], + attributes: true, attributeFilter: ['data-theme', 'data-mode'], }); const pathSegments = window.location.pathname.split('/'); From 6a7242bedb7b3c6613bd1d02cb5a458ebf9af3f2 Mon Sep 17 00:00:00 2001 From: John McLear Date: Mon, 11 May 2026 11:44:15 +0100 Subject: [PATCH 3/4] chore(margin): use protocol-relative URL for Google Fonts @import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Qodo feedback on PR #7721: use //fonts.googleapis.com/… rather than hardcoded https:// so the resource works under both HTTP and HTTPS without protocol-specific embedding (PR Compliance ID 7). --- src/static/skins/margin/index.css | 2 +- src/static/skins/margin/pad.css | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/static/skins/margin/index.css b/src/static/skins/margin/index.css index e03af2fa639..c67f4ea18f4 100644 --- a/src/static/skins/margin/index.css +++ b/src/static/skins/margin/index.css @@ -2,7 +2,7 @@ * Styles the home / pad-list using the same data-theme system as pad.css. * Drop alongside pad.css in src/static/skins/margin/. */ -@import url("https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300..700;1,6..72,300..700&family=Instrument+Serif:ital@0;1&family=Lora:ital,wght@0,400..700;1,400..700&family=Space+Mono:ital,wght@0,400;0,700;1,400;1,700&family=VT323&family=IBM+Plex+Mono:ital,wght@0,400;0,500;0,600;1,400&family=IBM+Plex+Sans:ital,wght@0,400;0,500;0,600;0,700&display=swap"); +@import url("//fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300..700;1,6..72,300..700&family=Instrument+Serif:ital@0;1&family=Lora:ital,wght@0,400..700;1,400..700&family=Space+Mono:ital,wght@0,400;0,700;1,400;1,700&family=VT323&family=IBM+Plex+Mono:ital,wght@0,400;0,500;0,600;1,400&family=IBM+Plex+Sans:ital,wght@0,400;0,500;0,600;0,700&display=swap"); /* Theme definitions are mirrored from pad.css so the lobby themes too. * Both light and dark variants of each named theme are defined so the diff --git a/src/static/skins/margin/pad.css b/src/static/skins/margin/pad.css index 6055d942bc0..307d8cd63b3 100644 --- a/src/static/skins/margin/pad.css +++ b/src/static/skins/margin/pad.css @@ -22,7 +22,7 @@ /* Google Fonts powering the five themed type stacks (Newsreader/Editorial, * Space Mono/Brutalist, Lora/Paper, IBM Plex Mono+VT323/CRT, IBM Plex * Sans/Industrial, plus Instrument Serif for display H1/H2). */ -@import url("https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300..700;1,6..72,300..700&family=Instrument+Serif:ital@0;1&family=Lora:ital,wght@0,400..700;1,400..700&family=Space+Mono:ital,wght@0,400;0,700;1,400;1,700&family=VT323&family=IBM+Plex+Mono:ital,wght@0,400;0,500;0,600;1,400&family=IBM+Plex+Sans:ital,wght@0,400;0,500;0,600;0,700&display=swap"); +@import url("//fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300..700;1,6..72,300..700&family=Instrument+Serif:ital@0;1&family=Lora:ital,wght@0,400..700;1,400..700&family=Space+Mono:ital,wght@0,400;0,700;1,400;1,700&family=VT323&family=IBM+Plex+Mono:ital,wght@0,400;0,500;0,600;1,400&family=IBM+Plex+Sans:ital,wght@0,400;0,500;0,600;0,700&display=swap"); /* Component partials — vendored from colibris so this skin is standalone. */ @import url("src/general.css"); From 08a25c9c1b92a59f799b7c63b3d340c55d777457 Mon Sep 17 00:00:00 2001 From: John McLear Date: Mon, 11 May 2026 15:08:34 +0100 Subject: [PATCH 4/4] fix(margin): address Qodo review (URL build, storage guards, timeslider theme) - Build recent-pad links with new URL() + encodeURIComponent so a trailing slash, query, hash, or special chars in pad names no longer produce a broken link. - Wrap recentPads read/parse and writes in try/catch on both the lobby and pad scripts; an exception used to abort customStart() and break recent pads / settings injection in private mode or when the entry was corrupted. - Bootstrap data-theme + data-mode on the timeslider page (and inherit from the parent doc when running inside #history-frame), since margin/pad.css scopes its theme tokens under [data-theme="..."]. History was unthemed. --- src/static/skins/margin/index.js | 24 +++++++++++++---- src/static/skins/margin/pad.js | 24 ++++++++++++----- src/static/skins/margin/timeslider.js | 38 +++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 12 deletions(-) diff --git a/src/static/skins/margin/index.js b/src/static/skins/margin/index.js index c542fcebcb9..6932571adab 100644 --- a/src/static/skins/margin/index.js +++ b/src/static/skins/margin/index.js @@ -52,11 +52,21 @@ window.customStart = () => { const recentPadListHeading = document.querySelector('[data-l10n-id="index.recentPads"]'); - const recentPadsFromLocalStorage = localStorage.getItem('recentPads'); + // localStorage may be unavailable (private mode, disabled cookies) and the + // stored value may be malformed if another tab corrupted it. Either case + // would throw out of customStart() and break the rest of the lobby init, + // so swallow both and fall back to an empty list. let recentPadListData = []; - if (recentPadsFromLocalStorage != null) { - recentPadListData = JSON.parse(recentPadsFromLocalStorage); - } + try { + const recentPadsFromLocalStorage = localStorage.getItem('recentPads'); + if (recentPadsFromLocalStorage != null) { + const parsed = JSON.parse(recentPadsFromLocalStorage); + if (Array.isArray(parsed)) { + recentPadListData = parsed.filter( + (p) => p && typeof p === 'object' && typeof p.name === 'string'); + } + } + } catch (_) { /* private mode / corrupted entry */ } // Remove duplicates based on pad name and sort by timestamp recentPadListData = recentPadListData.filter( @@ -91,7 +101,11 @@ window.customStart = () => { li.style.cursor = 'pointer'; li.className = 'recent-pad'; - const padPath = `${window.location.href}p/${pad.name}`; + // Use new URL() so a trailing slash, query string, or hash on + // window.location.href doesn't produce a broken link, and so pad + // names with characters that need encoding still resolve. + const padPath = new URL(`p/${encodeURIComponent(pad.name)}`, + window.location.href).href; const link = document.createElement('a'); link.style.textDecoration = 'none'; diff --git a/src/static/skins/margin/pad.js b/src/static/skins/margin/pad.js index 281eaae9cc3..a47790452d3 100644 --- a/src/static/skins/margin/pad.js +++ b/src/static/skins/margin/pad.js @@ -217,11 +217,20 @@ window.customStart = () => { const pathSegments = window.location.pathname.split('/'); const padName = pathSegments[pathSegments.length - 1]; - const recentPads = localStorage.getItem('recentPads'); - if (recentPads == null) { - localStorage.setItem('recentPads', JSON.stringify([])); - } - const recentPadsList = JSON.parse(localStorage.getItem('recentPads')); + // localStorage access and JSON.parse can both throw (private mode, + // restricted storage, corrupted value). Treat any failure as "no recent + // pads yet" rather than aborting customStart(). + let recentPadsList = []; + try { + const raw = localStorage.getItem('recentPads'); + if (raw != null) { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + recentPadsList = parsed.filter( + (p) => p && typeof p === 'object' && typeof p.name === 'string'); + } + } + } catch (_) { /* private mode / corrupted entry */ } if (!recentPadsList.some((pad) => pad.name === padName)) { if (recentPadsList.length >= MAX_PADS_IN_HISTORY) { recentPadsList.shift(); // Remove the oldest pad if we have more than 10 @@ -231,13 +240,14 @@ window.customStart = () => { timestamp: new Date().toISOString(), // Store the timestamp for sorting members: 1, }); - localStorage.setItem('recentPads', JSON.stringify(recentPadsList)); } else { // Update the timestamp if the pad already exists const existingPad = recentPadsList.find((pad) => pad.name === padName); if (existingPad) { existingPad.timestamp = new Date().toISOString(); } - localStorage.setItem('recentPads', JSON.stringify(recentPadsList)); } + try { + localStorage.setItem('recentPads', JSON.stringify(recentPadsList)); + } catch (_) { /* quota / private mode — skip silently */ } }; diff --git a/src/static/skins/margin/timeslider.js b/src/static/skins/margin/timeslider.js index 5fa8ae3b2f7..75c4a3114bd 100644 --- a/src/static/skins/margin/timeslider.js +++ b/src/static/skins/margin/timeslider.js @@ -1,4 +1,42 @@ 'use strict'; +// The timeslider page loads margin's pad.css, whose theme tokens live under +// [data-theme="..."][data-mode="..."]. Without this bootstrap the history +// view (and the in-place #history-frame iframe spun up by pad_mode.ts) would +// render with no theme applied — visually broken vs the pad it came from. +// Keep this list in sync with MARGIN_THEMES in pad.js. +const MARGIN_THEME_VALUES = [ + 'colibris', 'editorial', 'brutalist', 'paper', 'crt', 'industrial', +]; +const MARGIN_THEME_DEFAULT = 'colibris'; +const MARGIN_MODE_DEFAULTS = { + colibris: 'light', editorial: 'light', brutalist: 'light', + paper: 'light', crt: 'dark', industrial: 'dark', +}; + +const readStorage = (key) => { + try { return localStorage.getItem(key); } catch (_) { return null; } +}; + +// When opened as the in-place history iframe (#history-frame) prefer the +// parent doc's live attribute over localStorage — that way history mirrors +// whatever the pad currently shows even if the user toggled theme after +// localStorage was last written. +const readFromParent = (attr) => { + try { + if (window.parent && window.parent !== window) { + return window.parent.document.documentElement.getAttribute(attr); + } + } catch (_) { /* cross-origin — ignore */ } + return null; +}; + +let theme = readFromParent('data-theme') || readStorage('marginTheme'); +if (!MARGIN_THEME_VALUES.includes(theme)) theme = MARGIN_THEME_DEFAULT; +let mode = readFromParent('data-mode') || readStorage('marginMode'); +if (mode !== 'light' && mode !== 'dark') mode = MARGIN_MODE_DEFAULTS[theme] || 'light'; +document.documentElement.setAttribute('data-theme', theme); +document.documentElement.setAttribute('data-mode', mode); + window.customStart = () => { };