Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions src/static/skins/margin/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# margin — Etherpad skin

A standalone drop-in skin with six themes and an orthogonal Light/Dark toggle:

| Theme | Light | Dark | Natural mode |
| --- | --- | --- | --- |
| `colibris` | ✓ | — | light (no dark palette) |
| `editorial` | ✓ | ✓ | light |
| `brutalist` | ✓ | ✓ | light |
| `paper` | ✓ | ✓ | light |
| `crt` | ✓ | ✓ | dark |
| `industrial` | ✓ | ✓ | 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 — 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 — flips light↔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/
├─ 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.
43 changes: 43 additions & 0 deletions src/static/skins/margin/index.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

173 changes: 173 additions & 0 deletions src/static/skins/margin/index.js
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;
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);
});
}
};
Loading
Loading