This repository has no description
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806// Local theme library editor. Theme application is handled by the page theme runtime.(() => { 'use strict';
const api = typeof browser !== 'undefined' ? browser : chrome; const model = window.BskyPlusThemes; if (!model || document.querySelector('[data-bskyplus-theme-editor]')) return;
const { GROUPS, BUILTIN_THEMES, isBuiltinTheme, availableBuiltinThemes, STORAGE_KEY, createTheme, validateTheme, normalizeLibrary, parseWitchSkyUri, witchSkyThemes, exportTheme, contrastRatio, contrastIssues, shiftHue, } = model; const HEX = /^#[0-9a-f]{6}$/i; const MAX_IMPORT_BYTES = 256 * 1024; const LIVE_PREVIEW_DELAY_MS = 100; const fieldsByKey = new Map(GROUPS.flatMap(group => group.fields.map(field => [field.key, { ...field, group: group.name }]))); let library = normalizeLibrary(null); let draft = null; let selectedId = null; let dirty = false; let queue = Promise.resolve(); let notice = ''; let storageUnavailable = false; let busy = false; let previewEnabled = true; let previewActivated = false; let hueBaseColors = null; let highlightedContrastPair = null; let contrastHighlightTimer; let previewTimer = 0;
function el(tag, attributes = {}, ...children) { const node = document.createElement(tag); for (const [name, value] of Object.entries(attributes)) { if (value === null || value === undefined) continue; if (name === 'className') node.className = value; else if (name === 'text') node.textContent = value; else if (name.startsWith('on')) node.addEventListener(name.slice(2).toLowerCase(), value); else node.setAttribute(name, value); } node.append(...children.filter(Boolean)); return node; }
const sectionAnimations = new WeakMap();
function sectionOpen(details) { return sectionAnimations.get(details)?.pending ?? details.open; }
function setSectionOpen(details, expanded) { const controller = sectionAnimations.get(details); if (controller) controller.setOpen(expanded); else details.open = expanded; }
function animateSection(details) { const summary = details.querySelector(':scope > summary'); const content = el('div', { className: 'bskyplus-theme-section-content' }); content.append(...[...details.children].filter(child => child !== summary)); const reveal = el('div', { className: 'bskyplus-theme-section-reveal' }, content); details.append(reveal); let timer; let frame; const dispose = () => { clearTimeout(timer); cancelAnimationFrame(frame); }; const controller = { pending: null, setOpen, dispose }; sectionAnimations.set(details, controller);
const sync = expanded => { details.dataset.themeExpanded = String(expanded); summary.setAttribute('aria-expanded', String(expanded)); content.inert = !expanded; }; const finish = () => { if (controller.pending === null) return; const expanded = controller.pending; controller.pending = null; clearTimeout(timer); details.open = expanded; }; function setOpen(expanded) { if (sectionOpen(details) === expanded && details.open === expanded) return; dispose(); controller.pending = expanded; if (!expanded && content.contains(document.activeElement)) summary.focus(); const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches; const wasClosed = !details.open; if (expanded && !details.open) { details.open = true; } content.inert = !expanded; summary.setAttribute('aria-expanded', String(expanded)); const start = () => { sync(expanded); if (reducedMotion) finish(); else timer = setTimeout(finish, 340); }; // Native details hides its contents completely. Paint the zero-height // wrapper first so opening has a rendered starting point to animate from. if (expanded && wasClosed && !reducedMotion) { reveal.style.transition = 'none'; details.dataset.themeExpanded = 'false'; void reveal.offsetHeight; reveal.style.removeProperty('transition'); frame = requestAnimationFrame(() => { void reveal.offsetHeight; start(); }); } else start(); } summary.addEventListener('click', event => { event.preventDefault(); setOpen(!sectionOpen(details)); }); reveal.addEventListener('transitionend', event => { if (event.target === reveal && event.propertyName === 'grid-template-rows') finish(); }); // Keep direct changes to the native details state in sync as well. details.addEventListener('toggle', () => { if (!details.open) { dispose(); controller.pending = null; } if (controller.pending === null) sync(details.open); }); sync(details.open); }
function makeDraft(base = 'dark') { return createTheme('Untitled theme', base); }
function begin(theme) { draft = theme ? validateTheme(theme) : makeDraft(); selectedId = library.themes.some(saved => saved.id === theme?.id) || availableBuiltinThemes(library).some(saved => saved.id === theme?.id) ? theme.id : null; dirty = false; notice = ''; render(); }
function setDirty(live = false) { dirty = true; previewActivated = true; updateDirty(); if (live) { if (!previewTimer) previewTimer = setTimeout(() => { previewTimer = 0; updatePreview(); }, LIVE_PREVIEW_DELAY_MS); } else { if (previewTimer) clearTimeout(previewTimer); previewTimer = 0; updatePreview(); } }
function updateDirty() { const save = editor.querySelector('[data-theme-save]'); if (save) save.textContent = dirty || !selectedId ? 'Save and apply' : 'Apply'; const status = editor.querySelector('[data-theme-status]'); if (status) status.textContent = notice || (dirty ? 'Unsaved changes' : !selectedId ? 'New theme' : selectedId === library.activeId ? 'Active theme · saved locally' : 'Saved locally'); editor.querySelectorAll('[data-theme-write], [data-theme-import], [data-theme-witchsky-import]').forEach(button => { button.disabled = busy || storageUnavailable || (button.hasAttribute('data-theme-save-as') && (!dirty || !selectedId)) || (button.classList.contains('bskyplus-theme-delete') && !selectedId); }); editor.querySelectorAll('.bskyplus-theme-controls input, .bskyplus-theme-preview-toggle input, [data-theme-select], .bskyplus-theme-hue button, .bskyplus-theme-group input').forEach(control => { control.disabled = busy; }); }
function setBusy(next) { busy = next; updateDirty(); }
function colorValue(key) { return draft?.colors[key] || '#000000'; }
function updatePreview() { if (!draft) return; const colors = draft.colors; const textRatio = contrastRatio(colors.text, colors.background).toFixed(1); const buttonRatio = contrastRatio(colors.onPrimary, colors.primary).toFixed(1); const hint = editor.querySelector('[data-theme-contrast]'); if (hint) hint.textContent = `Text ${textRatio}:1 · Button ${buttonRatio}:1`; const warning = editor.querySelector('[data-theme-contrast-warning]'); if (warning) { const issues = contrastIssues(colors); warning.hidden = !issues.length; warning.querySelector('summary').textContent = issues.length + ' low-contrast combinations'; warning.querySelector('ul').replaceChildren(...contrastRecommendations(issues)); if (highlightedContrastPair && !issues.some(issue => issue.foreground === highlightedContrastPair.foreground && issue.background === highlightedContrastPair.background)) { clearContrastHighlight(); } } updatePagePreview(); }
function clearContrastHighlight() { clearTimeout(contrastHighlightTimer); highlightedContrastPair = null; editor.querySelectorAll('.bskyplus-theme-field-highlight').forEach(field => field.classList.remove('bskyplus-theme-field-highlight')); }
function showContrastSettings(issue) { const fields = [issue.foreground, issue.background].map(key => editor.querySelector(`[data-theme-field="${key}"]`)); if (fields.some(field => !field)) return; clearContrastHighlight(); highlightedContrastPair = { foreground: issue.foreground, background: issue.background }; for (const field of fields) { setSectionOpen(field.closest('[data-theme-group]'), true); // Restart the fade even when the same pair is selected again. void field.offsetWidth; field.classList.add('bskyplus-theme-field-highlight'); } contrastHighlightTimer = setTimeout(clearContrastHighlight, 3000); requestAnimationFrame(() => { const field = fields[0]; field.querySelector('[data-theme-hex]').focus({ preventScroll: true }); field.scrollIntoView({ block: 'center', behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'instant' : 'smooth' }); }); }
function contrastRecommendations(issues, dialog = null) { // Keep advice consistent when one text color fails on several surfaces. const directions = new Map(); for (const { foreground } of issues) { if (directions.has(foreground)) continue; const backgrounds = issues.filter(pair => pair.foreground === foreground) .map(pair => draft.colors[pair.background]); const worstContrast = color => Math.min(...backgrounds.map(background => contrastRatio(color, background))); directions.set(foreground, worstContrast('#000000') > worstContrast('#FFFFFF') ? 'Darken' : 'Lighten'); } return issues.map(issue => { const field = fieldsByKey.get(issue.foreground); const backgroundField = fieldsByKey.get(issue.background); return el('li', { className: 'bskyplus-theme-contrast-item' }, el('span', { text: issue.label + ': ' + issue.ratio.toFixed(1) + ':1' }), el('span', { className: 'bskyplus-theme-contrast-advice', text: directions.get(issue.foreground) + ' “' + field.label + '” in ' + field.group + ', or adjust “' + backgroundField.label + '” in ' + backgroundField.group + '. Aim for above 4:1.' }), el('button', { type: 'button', className: 'bskyplus-theme-contrast-edit', 'data-theme-contrast-edit': field.key, 'data-theme-contrast-background': issue.background, text: 'Edit both colors', onclick: () => { dialog?.close(); showContrastSettings(issue); } }) ); }); }
function updatePagePreview() { let theme = null; if (previewEnabled && previewActivated && draft) { try { theme = validateTheme(draft); } catch { return; /* Keep the last valid page styling while a field is invalid. */ } } parent.postMessage({ type: 'BSKYPLUS_THEME_PREVIEW', theme }, 'https://bsky.app'); }
function confirmAction({ title, message, confirmLabel, cancelLabel = 'Cancel' }) { if (editor.querySelector('dialog')) return Promise.resolve(false); return new Promise(resolve => { const dialog = el('dialog', { className: 'bskyplus-theme-dialog', 'data-theme-confirm': '', 'aria-labelledby': 'bskyplusThemeConfirmTitle', }); dialog.append(el('form', { method: 'dialog' }, el('h2', { id: 'bskyplusThemeConfirmTitle', text: title }), el('p', { text: message }), el('div', { className: 'bskyplus-theme-actions' }, el('button', { type: 'submit', value: 'cancel', className: 'secondary-btn', 'data-theme-confirm-cancel': '', text: cancelLabel }), el('button', { type: 'submit', value: 'confirm', className: 'preset-btn', 'data-theme-confirm-accept': '', text: confirmLabel }) ) )); dialog.addEventListener('close', () => { const confirmed = dialog.returnValue === 'confirm'; dialog.remove(); resolve(confirmed); }, { once: true }); editor.append(dialog); dialog.showModal(); dialog.querySelector('[data-theme-confirm-cancel]').focus(); }); }
function confirmDiscard() { if (!dirty) return true; return confirmAction({ title: 'Discard unsaved theme changes?', message: 'Your edits to this theme will be lost.', confirmLabel: 'Discard changes', cancelLabel: 'Keep editing', }); }
function withStorage(task) { queue = queue.then(task, task); return queue; }
async function readLibrary() { const stored = await api.storage.local.get(STORAGE_KEY); library = normalizeLibrary(stored[STORAGE_KEY]); const active = library.themes.find(theme => theme.id === library.activeId) || availableBuiltinThemes(library).find(theme => theme.id === library.activeId); begin(active || library.themes[0] || null); }
async function saveLibrary(change) { await withStorage(async () => { const stored = await api.storage.local.get(STORAGE_KEY); const latest = normalizeLibrary(stored[STORAGE_KEY]); const nextLibrary = change(latest); await api.storage.local.set({ [STORAGE_KEY]: nextLibrary }); library = normalizeLibrary(nextLibrary); }); }
function openSaveDialog(copyName = null, issues = contrastIssues(draft.colors)) { if (editor.querySelector('dialog')) return; const copying = copyName !== null; const nameInput = copying ? el('input', { type: 'text', className: 'bskyplus-theme-name', id: 'bskyplusThemeCopyName', value: copyName || draft.name + ' copy', maxlength: '80', required: '', }) : null; const dialog = el('dialog', { className: 'bskyplus-theme-dialog', 'aria-labelledby': 'bskyplusThemeDialogTitle' }); const form = el('form', { onsubmit: event => { event.preventDefault(); if (nameInput && !nameInput.value.trim()) { nameInput.setCustomValidity('Enter a theme name.'); nameInput.reportValidity(); return; } const name = nameInput ? nameInput.value.trim() : null; dialog.close(); saveDraft(name, true); } }, el('h2', { id: 'bskyplusThemeDialogTitle', text: copying ? 'Save theme as…' : 'Low contrast warning' }), copying ? el('label', { className: 'bskyplus-theme-name-field', for: 'bskyplusThemeCopyName' }, el('span', { text: 'New theme name' }), nameInput) : null, issues.length ? el('p', { text: issues.length + ' text or indicator combinations have contrast below 3.0:1 and may be hard to read. Aim for above 4:1. You can still save this theme.' }) : null, issues.length ? el('ul', {}, ...contrastRecommendations(issues, dialog)) : null, el('div', { className: 'bskyplus-theme-actions' }, el('button', { type: 'button', className: 'secondary-btn', text: 'Cancel', onclick: () => dialog.close() }), el('button', { type: 'submit', className: 'preset-btn', text: issues.length ? 'Save anyway' : 'Save' }) ) ); nameInput?.addEventListener('input', () => nameInput.setCustomValidity('')); dialog.append(form); dialog.addEventListener('close', () => dialog.remove()); editor.append(dialog); dialog.showModal(); if (nameInput) { nameInput.focus(); nameInput.select(); } }
async function saveDraft(copyName = null, allowLowContrast = false) { if (!draft) return; if (copyName === null && !dirty && isBuiltinTheme(selectedId)) { setBusy(true); try { await saveLibrary(latest => ({ ...latest, activeId: selectedId })); notice = 'Saved and applied'; render(); } catch { notice = 'Could not update the active theme.'; updateDirty(); } finally { setBusy(false); } return; } let theme; try { theme = validateTheme({ ...draft, id: copyName === null && !isBuiltinTheme(draft.id) ? draft.id : createTheme().id, name: copyName === null ? draft.name.trim() || 'Untitled theme' : copyName, }); } catch (error) { notice = error.message || 'Fix the theme values before saving.'; updateDirty(); return; } const issues = contrastIssues(theme.colors); if (issues.length && !allowLowContrast) { openSaveDialog(copyName, issues); return; } setBusy(true); try { await saveLibrary(latest => { const themes = latest.themes.filter(item => item.id !== theme.id); if (themes.length >= 100) throw new Error('You can save up to 100 themes. Delete one before saving another.'); themes.push(theme); return { ...latest, themes, activeId: theme.id }; }); draft = theme; selectedId = theme.id; dirty = false; previewActivated = true; notice = 'Saved and applied'; render(); } catch (error) { notice = error.message?.startsWith('You can') ? error.message : 'Could not save the theme. Try again.'; dirty = true; updateDirty(); } finally { setBusy(false); } }
async function selectTheme(id) { if (busy) return; if (id === selectedId && !dirty) { previewActivated = true; updatePagePreview(); return; } if (!(await confirmDiscard())) return; const theme = library.themes.find(item => item.id === id) || availableBuiltinThemes(library).find(item => item.id === id); previewActivated = true; begin(theme || null); editor.querySelector(`[data-theme-select="${id}"]`)?.focus({ preventScroll: true }); }
async function importTheme(event) { const input = event.currentTarget; const file = input.files?.[0]; input.value = ''; if (!file) return; if (file.size > MAX_IMPORT_BYTES) { notice = 'Theme files must be 256 KB or smaller.'; updateDirty(); return; }
setBusy(true); try { let theme = validateTheme(JSON.parse(await file.text())); if (!(await confirmDiscard())) return; const importedAsCopy = isBuiltinTheme(theme.id) || library.themes.some(saved => saved.id === theme.id); if (importedAsCopy) theme = { ...theme, id: createTheme(theme.name, theme.base).id }; draft = theme; selectedId = null; dirty = true; previewActivated = true; notice = importedAsCopy ? 'Imported as a copy · review, then save' : 'Imported theme · review, then save'; render(); } catch (error) { notice = error instanceof SyntaxError ? 'The selected file is not valid JSON.' : (error.message || 'Could not import the theme.'); updateDirty(); } finally { setBusy(false); } }
async function fetchJson(url, message) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 10000); try { const response = await fetch(url, { signal: controller.signal, credentials: 'omit', cache: 'no-store', referrerPolicy: 'no-referrer' }); if (!response.ok) throw new Error(message); const text = await response.text(); if (text.length > MAX_IMPORT_BYTES) throw new Error('The WitchSky response is too large.'); return JSON.parse(text); } catch (error) { if (error.name === 'AbortError') throw new Error('The WitchSky request timed out.'); if (error instanceof SyntaxError) throw new Error('WitchSky returned an invalid response.'); throw error; } finally { clearTimeout(timeout); } }
async function importWitchSky(input) { let target; try { target = parseWitchSkyUri(input.value); } catch (error) { notice = error.message; updateDirty(); return; } let permissionRequest; if (target.didDocumentUrl && api.permissions && typeof api.permissions.request === 'function') { const origin = new URL(target.didDocumentUrl).origin; try { permissionRequest = api.permissions.request({ origins: [origin + '/*'] }); } catch { notice = 'Could not request access to the did:web repository. Check the browser permission settings and try again.'; updateDirty(); return; } try { if (!(await permissionRequest)) { notice = 'Access to the did:web repository was not granted.'; updateDirty(); return; } } catch { notice = 'Could not request access to the did:web repository. Check the browser permission settings and try again.'; updateDirty(); return; } } if (!(await confirmDiscard())) return;
setBusy(true); try { const did = await fetchJson(target.didDocumentUrl || 'https://plc.directory/' + encodeURIComponent(target.repo), 'Could not resolve the WitchSky theme repository.'); if (target.didDocumentUrl && did.id !== target.repo) throw new Error('The did:web document does not match the WitchSky repository.'); const service = did.service?.find(item => item?.id === '#atproto_pds' || item?.type === 'AtprotoPersonalDataServer'); if (typeof service?.serviceEndpoint !== 'string') throw new Error('The WitchSky repository does not publish a PDS endpoint.'); const endpoint = new URL(service?.serviceEndpoint); if (endpoint.protocol !== 'https:' || endpoint.username || endpoint.password || endpoint.hash) { throw new Error('The WitchSky repository has an invalid PDS endpoint.'); } const recordUrl = new URL('/xrpc/com.atproto.repo.getRecord', endpoint.origin); recordUrl.searchParams.set('repo', target.repo); recordUrl.searchParams.set('collection', 'app.witchsky.theme.colors'); recordUrl.searchParams.set('rkey', target.rkey); const response = await fetchJson(recordUrl, 'Could not load that WitchSky theme record.'); if (response.uri !== target.uri) throw new Error('WitchSky returned a different theme record.'); const themes = witchSkyThemes(response.value); await saveLibrary(latest => { if (latest.themes.length + themes.length > 100) throw new Error('You can save up to 100 themes. Delete some before importing this palette.'); return { ...latest, themes: [...latest.themes, ...themes] }; }); draft = themes[0]; selectedId = themes[0].id; dirty = false; previewActivated = true; input.value = ''; notice = `Imported ${themes.length} WitchSky ${themes.length === 1 ? 'theme' : 'themes'}`; render(); } catch (error) { notice = error.message || 'Could not import the WitchSky theme.'; updateDirty(); } finally { setBusy(false); } }
function resetHueBaseline() { hueBaseColors = { ...draft.colors }; const slider = editor.querySelector('[data-theme-hue]'); if (slider) slider.value = '0'; const output = editor.querySelector('[data-theme-hue-value]'); if (output) output.textContent = '0°'; }
function updateHue(degrees) { // Always use the original palette so dragging back to zero is lossless. draft.colors = shiftHue(hueBaseColors, degrees); editor.querySelector('[data-theme-hue]').value = String(degrees); editor.querySelector('[data-theme-hue-value]').textContent = (degrees > 0 ? '+' : '') + degrees + '°'; for (const [key, color] of Object.entries(draft.colors)) { editor.querySelector(`[data-theme-picker="${key}"]`).value = color; const hex = editor.querySelector(`[data-theme-hex="${key}"]`); hex.value = color; hex.removeAttribute('aria-invalid'); } notice = ''; setDirty(); }
function blurHueSliderAfterPointer(slider, pointerId) { const release = event => { if (event.pointerId !== pointerId) return; window.removeEventListener('pointerup', release, true); window.removeEventListener('pointercancel', release, true); slider.blur(); }; window.addEventListener('pointerup', release, true); window.addEventListener('pointercancel', release, true); }
function updateColor(key, value, live = false) { if (!HEX.test(value)) return; draft.colors[key] = value.toLowerCase(); const picker = editor.querySelector(`[data-theme-picker="${key}"]`); const hex = editor.querySelector(`[data-theme-hex="${key}"]`); if (picker && picker.value !== value) picker.value = value; if (hex && hex.value !== value) hex.value = value; hex?.removeAttribute('aria-invalid'); resetHueBaseline(); setDirty(live); }
function field(field) { const value = colorValue(field.key); const picker = el('input', { type: 'color', value, 'aria-label': `${field.label} color`, 'data-theme-picker': field.key, oninput: event => updateColor(field.key, event.target.value, true), onchange: event => updateColor(field.key, event.target.value) }); const hex = el('input', { type: 'text', value, inputmode: 'text', autocomplete: 'off', spellcheck: 'false', maxlength: '7', 'aria-label': `${field.label} hex value`, 'data-theme-hex': field.key, oninput: event => { const next = event.target.value.trim(); if (HEX.test(next)) updateColor(field.key, next); else event.target.setAttribute('aria-invalid', 'true'); }, onchange: event => { if (!HEX.test(event.target.value.trim())) { event.target.value = colorValue(field.key); event.target.removeAttribute('aria-invalid'); } } }); return el('label', { className: 'bskyplus-theme-field', 'data-theme-field': field.key }, el('span', { className: 'bskyplus-theme-field-copy' }, el('span', { className: 'bskyplus-theme-field-label', text: field.label }), el('span', { className: 'bskyplus-theme-field-description', text: field.description }) ), el('span', { className: 'bskyplus-theme-color-inputs' }, picker, hex) ); }
function themeCard(theme) { const selected = theme.id === selectedId; const active = theme.id === library.activeId; const cardPreview = el('div', { className: 'bskyplus-theme-preview bskyplus-theme-card-preview', onclick: () => selectTheme(theme.id) }, el('div', { className: 'bskyplus-theme-skeleton', 'aria-hidden': 'true' }, el('div', { className: 'bskyplus-theme-preview-bar' }, el('span', { className: 'bskyplus-theme-skeleton-line' }), el('span', { className: 'bskyplus-theme-skeleton-line bskyplus-theme-skeleton-link' }) ), el('div', { className: 'bskyplus-theme-preview-post' }, el('div', { className: 'bskyplus-theme-preview-avatar' }), el('div', { className: 'bskyplus-theme-skeleton-copy' }, el('span', { className: 'bskyplus-theme-skeleton-line' }), el('span', { className: 'bskyplus-theme-skeleton-line bskyplus-theme-skeleton-short' }), el('div', { className: 'bskyplus-theme-skeleton-reactions' }, ...['textSubtle', 'like', 'positive', 'negative'].map(color => el('span', { style: `background: var(--bskyplus-preview-${color})` })) ) ) ) ), el('button', { type: 'button', className: 'bskyplus-theme-preview-button', text: 'Select theme', 'data-theme-select': theme.id, 'aria-label': 'Select theme: ' + theme.name, 'aria-pressed': String(selected), }) ); for (const [key, value] of Object.entries(theme.colors)) { cardPreview.style.setProperty(`--bskyplus-preview-${key}`, value); } return el('article', { className: 'bskyplus-theme-card', 'data-theme-card': theme.id, 'data-theme-selected': String(selected) }, el('div', { className: 'bskyplus-theme-card-heading' }, el('span', { className: 'bskyplus-theme-card-name', text: theme.name }), selected || active || isBuiltinTheme(theme.id) ? el('span', { className: 'bskyplus-theme-card-status', text: selected ? active ? 'Active · editing' : isBuiltinTheme(theme.id) ? 'Included · editing' : 'Editing' : active ? 'Active' : 'Included' }) : null ), cardPreview ); }
function render() { if (previewTimer) clearTimeout(previewTimer); previewTimer = 0; clearContrastHighlight(); hueBaseColors = { ...draft.colors }; const groupStates = new Map([...editor.querySelectorAll('[data-theme-group]')] .map(group => [group.dataset.themeGroup, sectionOpen(group)])); const previousLibrary = editor.querySelector('[data-theme-library]'); const libraryOpen = previousLibrary ? sectionOpen(previousLibrary) : true; editor.querySelectorAll('details').forEach(details => sectionAnimations.get(details)?.dispose()); editor.replaceChildren(); const themeLibrary = el('details', { className: 'bskyplus-theme-library', 'data-theme-library': '', open: libraryOpen ? '' : null }, el('summary', { text: 'Select a theme (' + (library.themes.length + availableBuiltinThemes(library).length) + ')' }), el('div', { className: 'bskyplus-theme-library-grid' }, ...availableBuiltinThemes(library).map(themeCard), ...library.themes.map(themeCard)) ); const name = el('input', { type: 'text', className: 'bskyplus-theme-name', value: draft.name, maxlength: '80', 'aria-label': 'Theme name', placeholder: 'Theme name', oninput: event => { draft.name = event.target.value; setDirty(); } }); const previewToggle = el('label', { className: 'bskyplus-theme-preview-toggle' }, el('input', { type: 'checkbox', className: 'switch', checked: previewEnabled ? '' : null, onchange: event => { previewEnabled = event.target.checked; if (previewEnabled) previewActivated = true; updatePagePreview(); } }), el('span', { text: 'Preview changes on this page' }) ); const controls = el('div', { className: 'bskyplus-theme-controls' }, el('label', { className: 'bskyplus-theme-name-field' }, el('span', { text: 'Theme name' }), name) ); const witchSkyInput = el('input', { type: 'url', className: 'bskyplus-theme-witchsky-uri', 'data-theme-witchsky-uri': '', placeholder: 'at://did:plc:… or at://did:web:…/app.witchsky.theme.colors/…', 'aria-label': 'WitchSky theme AT URI', spellcheck: 'false', autocomplete: 'off', }); const witchSkyImport = el('button', { type: 'button', className: 'secondary-btn', 'data-theme-witchsky-import': '', text: 'Import WitchSky palette', onclick: () => importWitchSky(witchSkyInput), }); controls.append(el('div', { className: 'bskyplus-theme-witchsky' }, witchSkyInput, witchSkyImport)); controls.append(el('div', { className: 'bskyplus-theme-hue' }, el('div', { className: 'bskyplus-theme-hue-header' }, el('label', { for: 'bskyplusThemeHue', text: 'Hue shift' }), el('output', { for: 'bskyplusThemeHue', 'data-theme-hue-value': '', text: '0°' }), el('button', { type: 'button', className: 'secondary-btn', text: 'Reset hue', onclick: () => updateHue(0) }) ), el('input', { type: 'range', id: 'bskyplusThemeHue', 'data-theme-hue': '', className: 'range-slider', min: '-180', max: '180', step: '1', value: '0', 'aria-describedby': 'bskyplusThemeHueHint', oninput: event => updateHue(Number(event.target.value)), onpointerdown: event => blurHueSliderAfterPointer(event.currentTarget, event.pointerId) }), el('p', { id: 'bskyplusThemeHueHint', className: 'bskyplus-theme-field-description', text: 'Shift all colors together, keeping saturation and lightness. Black, white, and gray stay unchanged.' }) )); const groups = el('div', { className: 'bskyplus-theme-groups' }, ...GROUPS.map((group, index) => { const open = groupStates.has(group.name) ? groupStates.get(group.name) : index === 0; return el('details', { className: 'bskyplus-theme-group', 'data-theme-group': group.name, open: open ? '' : null }, el('summary', { text: group.name }), el('div', { className: 'bskyplus-theme-group-fields' }, ...group.fields.map(field)) ); })); const save = el('button', { type: 'button', className: 'preset-btn bskyplus-theme-apply', 'data-theme-write': '', 'data-theme-save': '', text: 'Save and apply', onclick: () => saveDraft() }); const saveAs = el('button', { type: 'button', className: 'secondary-btn', 'data-theme-write': '', 'data-theme-save-as': '', text: 'Save as…', onclick: () => openSaveDialog('') }); const defaultButton = el('button', { type: 'button', className: 'secondary-btn', text: 'Use Bluesky default', onclick: async () => { setBusy(true); try { await saveLibrary(latest => ({ ...latest, activeId: null })); previewEnabled = false; notice = 'Using Bluesky default'; render(); } catch { notice = 'Could not update the active theme.'; updateDirty(); } finally { setBusy(false); } }}); defaultButton.setAttribute('data-theme-write', ''); defaultButton.setAttribute('data-theme-default', ''); const deleteButton = el('button', { type: 'button', className: 'secondary-btn bskyplus-theme-delete', 'data-theme-write': '', text: 'Delete', disabled: selectedId ? null : '', onclick: async () => { if (!selectedId || !(await confirmAction({ title: `Delete “${draft.name}”?`, message: 'This theme will be removed from your library.', confirmLabel: 'Delete theme', }))) return; setBusy(true); try { const deletedId = selectedId; const deletingBuiltin = isBuiltinTheme(deletedId); await saveLibrary(latest => deletingBuiltin ? { ...latest, deletedBuiltinIds: [...(latest.deletedBuiltinIds || []), deletedId], activeId: latest.activeId === deletedId ? null : latest.activeId } : { ...latest, themes: latest.themes.filter(theme => theme.id !== deletedId), activeId: latest.activeId === deletedId ? null : latest.activeId }); if (deletingBuiltin) { previewActivated = false; begin(null); } else begin(library.themes[0] || null); } catch { notice = 'Could not delete the theme.'; updateDirty(); } finally { setBusy(false); } }}); const exportButton = el('button', { type: 'button', className: 'secondary-btn', text: 'Export JSON', onclick: () => { let theme; try { theme = validateTheme(draft); } catch (error) { notice = error.message || 'Fix the theme values before exporting.'; updateDirty(); return; } const url = URL.createObjectURL(new Blob([exportTheme(theme)], { type: 'application/json' })); const anchor = el('a', { href: url, download: `${theme.name.trim().replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '') || 'bskyplus-theme'}.json` }); document.body.append(anchor); anchor.click(); anchor.remove(); setTimeout(() => URL.revokeObjectURL(url), 0); }}); const importInput = el('input', { type: 'file', accept: '.json,application/json', hidden: '', 'data-theme-import-input': '', onchange: importTheme }); const importButton = el('button', { type: 'button', className: 'secondary-btn', 'data-theme-import': '', text: 'Import JSON', onclick: () => importInput.click() }); const actions = el('div', { className: 'bskyplus-theme-actions' }, save, saveAs, defaultButton, deleteButton, importInput, importButton, exportButton); const status = el('p', { className: 'bskyplus-theme-status', 'data-theme-status': '', role: 'status' }); if (storageUnavailable) notice = 'Saved themes are unavailable. Editing and export still work.'; const contrastWarning = el('details', { className: 'bskyplus-theme-contrast-warning', 'data-theme-contrast-warning': '', hidden: '' }, el('summary'), el('p', { text: 'Below 3.0:1 can be hard to read. Aim for above 4:1.' }), el('ul')); editor.append(previewToggle, themeLibrary, controls, el('p', { className: 'bskyplus-theme-contrast', 'data-theme-contrast': '' }), contrastWarning, groups, actions, status); editor.querySelectorAll('details').forEach(animateSection); updatePreview(); updateDirty(); }
const editor = el('section', { className: 'section bskyplus-theme-editor', 'data-bskyplus-theme-editor': '' }); const firstSection = document.querySelector('.section'); firstSection?.before(editor); if (!editor.isConnected) document.body.prepend(editor);
api.storage.onChanged.addListener((changes, area) => { if (area !== 'local' || !changes[STORAGE_KEY]) return; if (dirty) { notice = 'Saved themes changed in another Bsky+ window.'; updateDirty(); return; } library = normalizeLibrary(changes[STORAGE_KEY].newValue); const availableBuiltins = availableBuiltinThemes(library); if (isBuiltinTheme(selectedId) && !availableBuiltins.some(theme => theme.id === selectedId)) { previewActivated = false; begin(null); return; } const selected = library.themes.find(theme => theme.id === selectedId) || availableBuiltins.find(theme => theme.id === selectedId) || library.themes.find(theme => theme.id === library.activeId) || availableBuiltins.find(theme => theme.id === library.activeId); begin(selected || null); });
readLibrary().catch(() => { storageUnavailable = true; notice = 'Saved themes are unavailable. Editing and export still work.'; draft = makeDraft(); render(); });})();