VulpesOS/host/gaia-theme.js
2026-10-22 20:42:26 +02:00

268 lines
13 KiB
JavaScript

// Shared appearance preference; external browser pages are not injected.
(() => {
const key = 'vulpes.theme';
const system = matchMedia('(prefers-color-scheme: dark)');
let mode = 'light', select;
function apply(value) {
mode = ['light', 'dark', 'system'].includes(value) ? value : 'light';
document.documentElement.dataset.vulpesTheme =
mode === 'system' ? (system.matches ? 'dark' : 'light') : mode;
if (select) select.value = mode;
}
system.addEventListener('change', () => apply(mode));
addEventListener('vulpes-service-event', ({detail}) => {
if (detail.type === 'settingchange' && detail.data.key === key) apply(detail.data.value);
});
VulpesCompat.call('appearance.current', {}).then(apply).catch(console.error);
if (location.hostname !== 'settings.localhost') return;
function label() {
const lang = document.documentElement.lang || navigator.language;
const labels = lang.startsWith('fr') ? ['Apparence', 'Clair', 'Sombre', 'Selon le système'] :
lang.startsWith('el') ? ['Εμφάνιση', 'Φωτεινό', 'Σκοτεινό', 'Σύμφωνα με το σύστημα'] :
['Appearance', 'Light', 'Dark', 'Follow system'];
const title = document.getElementById('vulpes-theme-label');
if (title && title.textContent !== labels[0]) title.textContent = labels[0];
if (select) [...select.options].forEach((option, i) => {
if (option.textContent !== labels[i + 1]) option.textContent = labels[i + 1];
});
}
function insert() {
const list = document.querySelector('#display > div > ul');
if (!list || select?.isConnected) return;
const row = document.createElement('li'), title = document.createElement('p');
row.id = 'vulpes-theme-row';
title.id = 'vulpes-theme-label';
const wrapper = document.createElement('span');
wrapper.className = 'button icon icon-dialog';
select = document.createElement('select');
select.id = 'vulpes-theme';
select.setAttribute('aria-labelledby', title.id);
for (const value of ['light', 'dark', 'system']) {
const option = document.createElement('option');
option.value = value; select.append(option);
}
select.value = mode;
select.onchange = async () => {
select.disabled = true;
try { await navigator.mozSettings.createLock().set({[key]:select.value}); }
catch (error) { select.value = mode; console.error(error); }
finally { select.disabled = false; }
};
wrapper.append(select); row.append(title, wrapper); list.append(row); label();
}
addEventListener('localized', label);
new MutationObserver(() => { insert(); label(); }).observe(document.documentElement,
{childList:true, subtree:true, attributes:true, attributeFilter:['lang']});
insert();
})();
// Compact is a user preference, with a keypad-device default supplied by the host.
(() => {
const key = 'vulpes.layout';
let mode = 'auto', compactDefault = false, select;
function apply(value) {
mode = ['auto', 'compact', 'standard'].includes(value) ? value : 'auto';
const compact = mode === 'compact' || (mode === 'auto' && compactDefault);
document.documentElement.dataset.vulpesLayout = compact ? 'compact' : 'standard';
if (select) select.value = mode;
// Gaia caches grid coordinates; switching layout also invalidates that cache.
requestAnimationFrame(() => {
const apps = window.appWindow?.apps;
if (!apps?.icons) return;
if (compact) {
window.appWindow.panels.scrollLeft = 0;
window.appWindow.updatePanelIndicator();
}
apps._iconSize = 0;
apps.icons.synchronise();
apps.refreshGridSize();
});
}
addEventListener('vulpes-service-event', ({detail}) => {
if (detail.type === 'settingchange' && detail.data.key === key) apply(detail.data.value);
});
VulpesCompat.call('layout.current', {}).then(value => {
compactDefault = value.compactDefault;
document.documentElement.dataset.vulpesMotion = value.reducedMotion ? 'reduced' : 'standard';
if (value.reducedMotion) {
// Preserve animationend callbacks used by Gaia, and leave busy indicators running.
const shorten = animations => {
for (const animation of animations) {
const timing = animation.effect?.getTiming();
if (timing && Number.isFinite(timing.iterations) && animation.playState !== 'finished')
animation.effect.updateTiming({duration:1, delay:0, endDelay:0});
}
};
document.addEventListener('animationstart', event => shorten(event.target.getAnimations()), true);
shorten(document.getAnimations());
}
apply(value.mode);
}).catch(console.error);
if (location.hostname !== 'settings.localhost') return;
function label() {
const fr = (document.documentElement.lang || navigator.language).startsWith('fr');
const labels = fr ? ['Mode compact', 'Automatique', 'Activé', 'Désactivé'] :
['Compact mode', 'Automatic', 'On', 'Off'];
const title = document.getElementById('vulpes-layout-label');
if (title && title.textContent !== labels[0]) title.textContent = labels[0];
if (select) [...select.options].forEach((option, i) => {
if (option.textContent !== labels[i + 1]) option.textContent = labels[i + 1];
});
}
function insert() {
const list = document.querySelector('#display > div > ul');
if (!list || select?.isConnected) return;
const row = document.createElement('li'), title = document.createElement('p');
row.id = 'vulpes-layout-row'; title.id = 'vulpes-layout-label';
const wrapper = document.createElement('span');
wrapper.className = 'button icon icon-dialog';
select = document.createElement('select'); select.id = 'vulpes-layout';
select.setAttribute('aria-labelledby', title.id);
for (const value of ['auto', 'compact', 'standard']) {
const option = document.createElement('option'); option.value = value; select.append(option);
}
select.value = mode;
select.onchange = async () => {
select.disabled = true;
try { await navigator.mozSettings.createLock().set({[key]:select.value}); }
catch (error) { select.value = mode; console.error(error); }
finally { select.disabled = false; }
};
wrapper.append(select); row.append(title, wrapper); list.append(row); label();
}
addEventListener('localized', label);
new MutationObserver(() => { insert(); label(); }).observe(document.documentElement,
{childList:true, subtree:true, attributes:true, attributeFilter:['lang']});
insert();
})();
// One readable application name above the compact grid, including keypad focus.
(() => {
if (location.hostname !== 'homescreen.localhost') return;
let heading, selected, grid;
const nameObserver = new MutationObserver(updateName);
function updateName() {
if (!heading || !selected) return;
const name = selected._subtitle?.textContent || selected.getAttribute('aria-label') || '';
if (heading.textContent !== name) heading.textContent = name;
}
function choose(icon) {
if (!icon || !grid?.contains(icon)) return;
if (selected !== icon) {
selected?.removeAttribute('data-vulpes-compact-selected');
selected?.removeAttribute('data-vulpes-keypad-focus');
selected = icon;
selected.setAttribute('data-vulpes-compact-selected', '');
nameObserver.disconnect();
if (selected.shadowRoot) nameObserver.observe(selected.shadowRoot,
{childList:true, subtree:true, characterData:true});
}
updateName();
}
function refresh() {
const focus = grid?.querySelector('gaia-app-icon[data-vulpes-keypad-focus]');
choose(focus || (selected?.isConnected && selected) || grid?.querySelector('gaia-app-icon'));
}
function init() {
grid = document.getElementById('apps');
if (!grid) return;
heading = document.createElement('div');
heading.id = 'vulpes-compact-app-name';
heading.setAttribute('aria-live', 'polite');
heading.setAttribute('aria-atomic', 'true');
document.body.insertBefore(heading, document.getElementById('panels'));
new MutationObserver(refresh).observe(grid,
{childList:true, subtree:true, attributes:true, attributeFilter:['data-vulpes-keypad-focus']});
for (const type of ['focus', 'pointerdown', 'pointerover']) grid.addEventListener(type, event => {
choose(event.target.closest('gaia-app-icon'));
}, true);
addEventListener('localized', updateName);
refresh();
}
if (document.readyState === 'loading') addEventListener('DOMContentLoaded', init, {once:true});
else init();
})();
// Cover-screen preferences live in the shared settings store, like appearance.
(() => {
if (location.hostname !== 'settings.localhost') return;
VulpesCompat.call('layout.current', {}).then(async capabilities => {
if (!capabilities.coverScreen) return;
const defaults = {'vulpes.cover.title':'VULPES', 'vulpes.cover.titleColor':'#33a6ff',
'vulpes.cover.batteryColor':'#33a6ff'};
const values = {...defaults}, controls = {};
const colors = ['#33a6ff', '#ffffff', '#4ade80', '#fbbf24', '#f472b6',
'#ef4444', '#f97316', '#a855f7', '#22d3ee', '#14b8a6', '#9ca3af'];
const result = await navigator.mozSettings.createLock().get('*');
for (const key of Object.keys(defaults)) if (typeof result[key] === 'string') values[key] = result[key];
let row, title, previewTitle, previewBattery;
function labels() {
const fr = (document.documentElement.lang || navigator.language).startsWith('fr');
if (!row) return;
title.textContent = fr ? 'Écran extérieur' : 'Cover screen';
const texts = fr ? ['Texte', 'Couleur du texte', 'Couleur de la batterie'] :
['Text', 'Text colour', 'Battery colour'];
const names = fr ? ['Bleu', 'Blanc', 'Vert', 'Jaune', 'Rose', 'Rouge', 'Orange', 'Violet', 'Cyan', 'Turquoise', 'Gris'] :
['Blue', 'White', 'Green', 'Yellow', 'Pink', 'Red', 'Orange', 'Purple', 'Cyan', 'Turquoise', 'Grey'];
Object.keys(defaults).forEach((key, index) => {
const control = controls[key];
control.previousElementSibling.textContent = texts[index];
if (control.options) [...control.options].forEach((option, i) => option.textContent = names[i]);
});
previewBattery.textContent = fr ? 'Batterie · aperçu' : 'Battery · preview';
}
function preview() {
previewTitle.textContent = controls['vulpes.cover.title'].value.trim() || 'VULPES';
previewTitle.style.color = controls['vulpes.cover.titleColor'].value;
previewBattery.style.color = controls['vulpes.cover.batteryColor'].value;
}
function insert() {
const list = document.querySelector('#display > div > ul');
if (!list || row?.isConnected) return;
row = document.createElement('li'); row.id = 'vulpes-cover-settings';
title = document.createElement('h2'); row.append(title);
for (const key of Object.keys(defaults)) {
const label = document.createElement('label'), caption = document.createElement('span');
const control = document.createElement(key.endsWith('.title') ? 'input' : 'select');
control.id = key.replaceAll('.', '-'); label.htmlFor = control.id;
if (control.localName === 'input') {control.type = 'text'; control.maxLength = 24;}
else for (const color of colors) {
const option = document.createElement('option'); option.value = color; control.append(option);
}
control.value = values[key];
if (control.localName === 'select' && !colors.includes(values[key])) control.value = defaults[key];
controls[key] = control;
let pending, writes = Promise.resolve();
const save = () => {
clearTimeout(pending);
preview();
const value = control.value.trim() || defaults[key];
writes = writes.then(async () => {
await navigator.mozSettings.createLock().set({[key]:value});
values[key] = value;
}).catch(console.error);
return writes;
};
control.oninput = () => {
preview();
// Keypad edits emit input; closing the lid need not blur the field.
if (control.localName === 'input') {
clearTimeout(pending); pending = setTimeout(save, 250);
}
};
control.onchange = save;
control.onblur = save;
label.append(caption, control); row.append(label);
}
const sample = document.createElement('div'); sample.className = 'vulpes-cover-preview';
previewTitle = document.createElement('div'); previewBattery = document.createElement('div');
sample.append(previewTitle, previewBattery); row.append(sample); list.append(row); labels(); preview();
}
new MutationObserver(insert).observe(document.documentElement, {childList:true, subtree:true});
addEventListener('localized', labels); insert();
}).catch(console.error);
})();