Skip to content
Open
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 GÇö Etherpad skin

A standalone drop-in skin with six themes and an orthogonal Light/Dark toggle:
Comment on lines +1 to +3
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Non-utf8 skin files 🐞 Bug ☼ Reliability

Several newly added margin skin assets contain invalid UTF-8 byte sequences (visible as mojibake
like "G��" / "Sekund+�r"), which can break tooling that reads these files as UTF-8 and risks serving
garbled text to browsers. This needs re-encoding/cleanup before merge to avoid brittle builds and
hard-to-debug asset issues.
Agent Prompt
### Issue description
Multiple newly added `margin` skin files contain invalid UTF-8 bytes (mojibake sequences like `G��` / `+�`), which breaks UTF-8 decoding in tooling and can result in corrupted comments/text in served assets.

### Issue Context
These files are static assets (CSS/JS/MD) that are expected to be UTF-8 in typical web/build pipelines.

### Fix Focus Areas
- src/static/skins/margin/README.md[1-61]
- src/static/skins/margin/index.css[1-43]
- src/static/skins/margin/pad.css[1-416]
- src/static/skins/margin/pad.js[1-253]
- src/static/skins/margin/timeslider.js[1-42]
- src/static/skins/margin/src/pad-editor.css[1-48]
- src/static/skins/margin/src/components/buttons.css[1-74]

### What to change
- Convert the listed files to valid UTF-8.
- Replace any corrupted punctuation/characters in comments (or remove them) so the repository contains clean UTF-8 sources.
- Re-run whatever formatter/linter/build step is used for static assets to ensure no further encoding regressions.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


| 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.
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;
Comment on lines +104 to +108
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Recent pad links break 🐞 Bug ≡ Correctness

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%20padmy%2520pad) and broken navigation from the lobby’s
recent list.
Agent Prompt
## Issue description
Recent pad links in the lobby can become invalid because pad names are persisted in `recentPads` in an already-encoded form (from `location.pathname`), then encoded again in `index.js` when constructing the URL.

## Issue Context
- `pad.js` stores the last path segment from `location.pathname` as the pad name.
- Browsers keep `location.pathname` percent-encoded.
- `index.js` encodes `pad.name` again via `encodeURIComponent()`.

## Fix Focus Areas
- src/static/skins/margin/pad.js[218-252]
- src/static/skins/margin/index.js[104-114]

## Implementation notes
- When capturing the pad name in `pad.js`, safely decode it before storing (wrap `decodeURIComponent()` in try/catch).
- When rendering the recent list in `index.js`, treat stored names as decoded and apply `encodeURIComponent()` exactly once when building the URL.
- Consider also displaying the decoded name in the UI (so users don’t see `%20`).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

const link = document.createElement('a');
link.style.textDecoration = 'none';

link.href = padPath;
link.innerText = pad.name;
Comment on lines +104 to +113
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Recent pad link broken 🐞 Bug ≡ Correctness

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
### Issue description
Recent pad links can be double-encoded: the stored pad name is derived from `window.location.pathname` (already percent-encoded) and later encoded again with `encodeURIComponent()` when generating the link.

### Issue Context
Core navigation encodes pad names when generating `/p/<pad>` URLs, so the pathname segment can contain `%..` sequences.

### Fix Focus Areas
- src/static/skins/margin/pad.js[218-249]
- src/static/skins/margin/index.js[104-113]

### What to change
Choose ONE consistent representation for `recentPads[].name`:
1) **Store decoded names**: in `pad.js`, set `padName = decodeURIComponent(lastSegment)` before saving. Keep `encodeURIComponent(pad.name)` in `index.js`.

OR
2) **Store encoded names**: keep current storage, but in `index.js` remove the extra encoding and treat `pad.name` as already encoded when building the URL.

Also consider displaying the decoded name to users (so they don’t see `%20` in the list).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

3. Invalid timestamp crashes lobby 🐞 Bug ☼ Reliability

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
### Issue description
`recentPads` entries are only validated for `name`, but `timestamp` is used for sorting and formatting without validation. Invalid timestamps can throw when calling `toLocaleDateString()`.

### Issue Context
The code explicitly tries to tolerate corrupted `localStorage` entries, but it does not validate `timestamp`.

### Fix Focus Areas
- src/static/skins/margin/index.js[60-75]
- src/static/skins/margin/index.js[134-147]

### What to change
- When parsing `recentPads`, additionally validate `timestamp` (and optionally `members`).
  - Example: keep only entries where `typeof p.timestamp === 'string'` and `!Number.isNaN(Date.parse(p.timestamp))`.
- In rendering, guard formatting:
  - If `Date.parse()` is NaN, either skip the date, show a placeholder, or fall back to `''`.
- In the sort comparator, handle invalid dates deterministically (e.g., treat invalid as oldest).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

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