Roll out "modern" HTML/CSS render style to every widget except photos
Build and push server image / test (push) Successful in 43s
Build and push server image / build-and-push (push) Successful in 3m51s
Build and push server image / deploy (push) Failing after 1m57s

Extends weather's experimental Chromium+Jinja2 render style to battery,
text, tasks, static image, whiteboard, and calendar (all four view
modes -- agenda/today_tomorrow/week/month), and gives the photos widget
its own genuinely independent palette + dithering strength.

Photos: Frame.photo_palette_rgb/photo_dither_strength (mirroring the
existing palette_rgb/dither_strength), with a second "Photos
configuration" card in Advanced Configuration. widgets/photos.py's
render() quantizes itself against these before returning -- no
render_panel changes needed, since photos is the only widget that
genuinely needs a different reference palette and can carry that
itself, the same way modern-style widgets already self-dither via
ordered_dither.

Battery/text/tasks/static image/whiteboard: same render_style pattern
weather established (render_style column, html_render.py build
function, Jinja2 template, dialog toggle). Static image/whiteboard get
their first-ever visual chrome (a rounded-corner shadowed card,
shared framed_image.html.jinja) since classic draws them with zero
frame at all. Fixed the same "preview endpoint bypasses render_style"
bug weather originally shipped with, for tasks/static/whiteboard/
calendar's preview endpoints.

Calendar: own module (app/calendar_html_render.py, mirroring
calendar_render.py's separation from the simpler widgets) covering all
four view modes, not just agenda -- reuses calendar_render's own
private helpers so event colors/times/weather/month-grid math match
classic exactly. Found and fixed two real cross-day layout bugs along
the way: a per-day header height that varied based on whether that
specific day had a weather entry (misaligning where every other day's
event rows started across the week/month grid), and regular-weight
small text being fragile under Bayer ordered dithering (out-of-month
day numbers degraded into unrecognizable speckle) -- fixed by using
bold everywhere and de-emphasizing via size instead of weight/gray,
since gray text has the same dithering fragility this project's PIL
renderers already avoid for exactly this reason.

Migrations 32-38 (Frame's two new columns, then one render_style column
per widget config table). 452 tests passing, including new dispatch/
migration coverage per widget type and a dedicated photos test proving
photo_palette_rgb produces genuinely independent quantization from the
frame's main palette_rgb.
This commit is contained in:
2026-07-31 03:52:19 +00:00
parent c6dad191fb
commit e331f5e5a1
47 changed files with 1662 additions and 116 deletions
+57 -24
View File
@@ -114,7 +114,7 @@ async function loadControl() {
document.getElementById('take-control').addEventListener('click', takeControl);
// ---- Advanced configuration: color palette ----
// ---- Advanced configuration: color palette(s) ----
//
// Hex field and R/G/B number fields are kept in sync live, both
// directions -- editing either updates the other plus the preview
@@ -122,9 +122,15 @@ document.getElementById('take-control').addEventListener('click', takeControl);
// the server already validates as #rrggbb); the R/G/B fields are purely
// an alternate, more precise way to arrive at the same value than
// eyeballing a color-picker swatch.
//
// Parameterized by classPrefix ("palette" for the main one, "photo-
// palette" for the photos-only one added alongside it) rather than
// duplicated wholesale -- there are exactly two real instances of this,
// not a speculative future one, and the two would otherwise be ~90
// near-identical lines apart.
function paletteHexInputs() {
return Array.from(document.querySelectorAll('.palette-hex'))
function paletteHexInputs(classPrefix) {
return Array.from(document.querySelectorAll(`.${classPrefix}-hex`))
.sort((a, b) => Number(a.dataset.index) - Number(b.dataset.index));
}
@@ -140,45 +146,53 @@ function rgbFromHex(hex) {
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function paletteFieldsFor(index) {
function paletteFieldsFor(classPrefix, index) {
const at = (cls) => document.querySelector(`.${cls}[data-index="${index}"]`);
return { hex: at('palette-hex'), r: at('palette-r'), g: at('palette-g'), b: at('palette-b'), swatch: at('palette-swatch-preview') };
return {
hex: at(`${classPrefix}-hex`), r: at(`${classPrefix}-r`), g: at(`${classPrefix}-g`), b: at(`${classPrefix}-b`),
swatch: at(`${classPrefix}-swatch-preview`),
};
}
function syncPaletteFromHex(index) {
const f = paletteFieldsFor(index);
function syncPaletteFromHex(classPrefix, index) {
const f = paletteFieldsFor(classPrefix, index);
const rgb = rgbFromHex(f.hex.value);
if (!rgb) return;
[f.r.value, f.g.value, f.b.value] = rgb;
f.swatch.style.background = f.hex.value;
}
function syncPaletteFromRgb(index) {
const f = paletteFieldsFor(index);
function syncPaletteFromRgb(classPrefix, index) {
const f = paletteFieldsFor(classPrefix, index);
const hex = hexFromRgb(f.r.value, f.g.value, f.b.value);
f.hex.value = hex;
f.swatch.style.background = hex;
}
const palettePickerCount = paletteHexInputs().length;
for (let i = 0; i < palettePickerCount; i++) {
const f = paletteFieldsFor(i);
f.hex.addEventListener('input', () => syncPaletteFromHex(i));
[f.r, f.g, f.b].forEach((el) => el.addEventListener('input', () => syncPaletteFromRgb(i)));
function wirePaletteInputs(classPrefix) {
const count = paletteHexInputs(classPrefix).length;
for (let i = 0; i < count; i++) {
const f = paletteFieldsFor(classPrefix, i);
f.hex.addEventListener('input', () => syncPaletteFromHex(classPrefix, i));
[f.r, f.g, f.b].forEach((el) => el.addEventListener('input', () => syncPaletteFromRgb(classPrefix, i)));
}
}
wirePaletteInputs('palette');
wirePaletteInputs('photo-palette');
// Sliders: live numeric readout next to each, no save until the button
// below is clicked.
['color_boost', 'contrast_boost', 'dither_strength'].forEach((id) => {
['color_boost', 'contrast_boost', 'dither_strength', 'photo_dither_strength'].forEach((id) => {
const input = document.getElementById(id);
const readout = document.getElementById(`${id}_value`);
input.addEventListener('input', () => { readout.textContent = Number(input.value).toFixed(2); });
});
async function savePalette(extra) {
async function savePalette(classPrefix, paletteFormKey, extra) {
const body = new URLSearchParams(extra || {});
for (const input of paletteHexInputs()) {
body.append('palette', input.value);
for (const input of paletteHexInputs(classPrefix)) {
body.append(paletteFormKey, input.value);
}
try {
const resp = await fetch(`${window.FRAME_API}/config`, {
@@ -195,7 +209,7 @@ async function savePalette(extra) {
}
document.getElementById('palette-save').addEventListener('click', () => {
savePalette({
savePalette('palette', 'palette', {
color_boost: document.getElementById('color_boost').value,
contrast_boost: document.getElementById('contrast_boost').value,
dither_strength: document.getElementById('dither_strength').value,
@@ -203,16 +217,16 @@ document.getElementById('palette-save').addEventListener('click', () => {
});
document.getElementById('palette-reset').addEventListener('click', () => {
const inputs = paletteHexInputs();
const inputs = paletteHexInputs('palette');
window.DEFAULT_PALETTE_HEX.forEach((hex, i) => {
inputs[i].value = hex;
syncPaletteFromHex(i);
syncPaletteFromHex('palette', i);
});
['color_boost', 'contrast_boost', 'dither_strength'].forEach((id) => {
document.getElementById(id).value = '1';
document.getElementById(`${id}_value`).textContent = '1.00';
});
savePalette({ palette_reset: 'true', color_boost: '1', contrast_boost: '1', dither_strength: '1' });
savePalette('palette', 'palette', { palette_reset: 'true', color_boost: '1', contrast_boost: '1', dither_strength: '1' });
});
// Fills the table with a community-measured starting point (see the
@@ -220,13 +234,32 @@ document.getElementById('palette-reset').addEventListener('click', () => {
// editing the hex/RGB fields by hand; the user still clicks Save (or
// Reset) to commit or discard it.
document.getElementById('palette-load-calibrated').addEventListener('click', () => {
const inputs = paletteHexInputs();
const inputs = paletteHexInputs('palette');
window.CALIBRATED_SPECTRA6_HEX.forEach((hex, i) => {
inputs[i].value = hex;
syncPaletteFromHex(i);
syncPaletteFromHex('palette', i);
});
});
// ---- Photos configuration: its own separate palette/dithering ----
document.getElementById('photo-palette-save').addEventListener('click', () => {
savePalette('photo-palette', 'photo_palette', {
photo_dither_strength: document.getElementById('photo_dither_strength').value,
});
});
document.getElementById('photo-palette-reset').addEventListener('click', () => {
const inputs = paletteHexInputs('photo-palette');
window.DEFAULT_PALETTE_HEX.forEach((hex, i) => {
inputs[i].value = hex;
syncPaletteFromHex('photo-palette', i);
});
document.getElementById('photo_dither_strength').value = '1';
document.getElementById('photo_dither_strength_value').textContent = '1.00';
savePalette('photo-palette', 'photo_palette', { photo_palette_reset: 'true', photo_dither_strength: '1' });
});
// ---- Preview: current photo vs. how it renders with saved settings ----
// Scoped to this frame's photo widget (window.PHOTO_WIDGET_PREVIEW_API,
// set by the template) rather than window.FRAME_API -- palette/color/