-
-
Notifications
You must be signed in to change notification settings - Fork 3k
feat(skin): add margin skin (11 themes; light/dark per theme; switchable from Settings) #7880
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| # margin G�� Etherpad skin | ||
|
|
||
| A standalone drop-in skin with six themes and an orthogonal Light/Dark toggle: | ||
|
|
||
| | Theme | Light | Dark | Natural mode | | ||
| | --- | --- | --- | --- | | ||
| | `colibris` | G�� | G�� | light (no dark palette) | | ||
| | `editorial` | G�� | G�� | light | | ||
| | `brutalist` | G�� | G�� | light | | ||
| | `paper` | G�� | G�� | light | | ||
| | `crt` | G�� | G�� | dark | | ||
| | `industrial` | G�� | G�� | dark | | ||
|
|
||
| The current `data-theme` and `data-mode` attributes live on `<html>`. Mode is paired with theme in CSS via `[data-theme="X"][data-mode="light|dark"]`. | ||
|
|
||
| No external dependency on colibris G�� 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 + mode on load (defaulting to `colibris` + the theme's natural mode), the Google Fonts stylesheet is `@import`-ed from `pad.css` / `index.css`, and a **Theme** dropdown plus a **Dark mode** checkbox are injected into both the User Settings and Pad-wide Settings columns of the Settings popup. | ||
|
|
||
| ## Switch themes at runtime | ||
|
|
||
| The Settings popup (gear icon in the toolbar) has: | ||
| - a **Theme** dropdown with the six themes, | ||
| - a **Dark mode** checkbox (orthogonal G�� flips lightG��dark for any theme that has a dark palette). | ||
|
|
||
| Choices persist in `localStorage` under `marginTheme` + `marginMode` and propagate across the pad and the lobby. | ||
|
|
||
| Programmatically, from DevTools: | ||
|
|
||
| ```js | ||
| document.documentElement.dataset.theme = 'crt'; | ||
| document.documentElement.dataset.mode = 'dark'; | ||
| ``` | ||
|
|
||
| ## Folder layout | ||
|
|
||
| ``` | ||
| margin/ | ||
| G��G�� index.css lobby / pad-list themes | ||
| G��G�� index.js lobby JS (early theme bootstrap) | ||
| G��G�� pad.css pad themes + component imports | ||
| G��G�� pad.js pad JS hooks (theme bootstrap, Settings dropdown, | ||
| G�� iframe theme propagation) | ||
| G��G�� timeslider.css version timeline | ||
| G��G�� timeslider.js timeslider JS | ||
| G��G�� src/ | ||
| G�� G��G�� general.css, layout.css, pad-editor.css, pad-variants.css | ||
| G�� G��G�� components/ toolbar, chat, popups, users, gritter, scrollbars, GǪ | ||
| G�� G��G�� plugins/ comments, color picker, tables, GǪ | ||
| G��G�� README.md | ||
| ``` | ||
|
|
||
| The `src/` partials are vendored from upstream colibris so this skin is fully self-contained G�� themes layer on top via `data-theme="GǪ"` overrides in `pad.css` and `index.css`, and inherit the same CSS-variable contract (`--primary-color`, `--bg-color`, `--main-font-family`, `--editor-horizontal-padding`, GǪ) that colibris exposes. | ||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,173 @@ | ||
| 'use strict'; | ||
|
|
||
| // Apply the user's saved theme + light/dark mode as early as possible so the | ||
| // lobby paints in the same theme as the last pad they visited. The controls | ||
| // that write these localStorage keys live in the pad's Settings popup | ||
| // (see pad.js). | ||
| const MARGIN_THEME_KEY = 'marginTheme'; | ||
| const MARGIN_MODE_KEY = 'marginMode'; | ||
| const MARGIN_THEME_DEFAULT = 'colibris'; | ||
| const MARGIN_MODE_DEFAULTS = { | ||
| colibris: 'light', editorial: 'light', brutalist: 'light', | ||
| paper: 'light', crt: 'dark', industrial: 'dark', | ||
| }; | ||
| try { | ||
| const theme = localStorage.getItem(MARGIN_THEME_KEY) || MARGIN_THEME_DEFAULT; | ||
| const mode = localStorage.getItem(MARGIN_MODE_KEY) || MARGIN_MODE_DEFAULTS[theme] || 'light'; | ||
| document.documentElement.setAttribute('data-theme', theme); | ||
| document.documentElement.setAttribute('data-mode', mode); | ||
| } catch (_) { | ||
| document.documentElement.setAttribute('data-theme', MARGIN_THEME_DEFAULT); | ||
| document.documentElement.setAttribute('data-mode', MARGIN_MODE_DEFAULTS[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"]'); | ||
| // 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 = []; | ||
| 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( | ||
| (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 = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-right w-4 h-4 text-gray-400"><path d="M5 12h14"></path><path d="m12 5 7 7-7 7"></path></svg>'; | ||
| const clockIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-clock w-3 h-3"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>'; | ||
| const personalIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-users w-3 h-3"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M22 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>'; | ||
| recentPadListData.forEach((pad) => { | ||
| const li = document.createElement('li'); | ||
|
|
||
|
|
||
| li.style.cursor = 'pointer'; | ||
|
|
||
| li.className = 'recent-pad'; | ||
| // 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; | ||
|
Comment on lines
+104
to
+108
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 1. Recent pad links break margin/pad.js stores the pad name directly from window.location.pathname (already percent-encoded), but margin/index.js builds recent-pad URLs using encodeURIComponent(pad.name), causing double-encoding (e.g., my%20pad → my%2520pad) and broken navigation from the lobby’s recent list. Agent Prompt
|
||
| const link = document.createElement('a'); | ||
| link.style.textDecoration = 'none'; | ||
|
|
||
| link.href = padPath; | ||
| link.innerText = pad.name; | ||
|
Comment on lines
+104
to
+113
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 2. Recent pad link broken margin/pad.js stores the pad name directly from window.location.pathname (percent-encoded), but margin/index.js applies encodeURIComponent() again when building the recent-pad URL, which double-encodes and breaks navigation for pad names containing spaces or non-ASCII characters. Users will click a recent pad and be sent to a different (wrong) pad URL (e.g., %20 becomes %2520). Agent Prompt
|
||
| 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; | ||
|
|
||
|
Comment on lines
+60
to
+147
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 3. Invalid timestamp crashes lobby margin/index.js filters recentPads entries only by name and then unconditionally formats `new Date(pad.timestamp) via toLocaleDateString(), which throws if timestamp` is missing or invalid. A single malformed entry can abort recent-pad rendering and break the lobby’s recent pads UI. Agent Prompt
|
||
| 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); | ||
| }); | ||
| } | ||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
1. Non-utf8 skin files
🐞 Bug☼ ReliabilityAgent Prompt
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools