Files
espresso_frame/server/app/templates/frame_config.html
T
tfaour 5f4f8f2ea7
Build and push server image / test (push) Successful in 44s
Build and push server image / build-and-push (push) Successful in 3m33s
Build and push server image / deploy (push) Failing after 1m27s
Add a curated theme system for "modern" style widgets, inspired by Tesserae
Frame.theme (7 presets in app/theme_tokens.py) drives font family,
corner radius, drop shadow, and an accent hue for every modern-style
widget's header/accent region. Rich accent colors (not just the 6 flat
panel inks) are approximated via denser Bayer stippling confined to just
that region (html_render.ordered_dither_regions), so icon/text content
elsewhere stays exactly as crisp as it is today -- verified directly
against real Chromium renders, both in unit tests and via run-server.
"classic" is a byte-identical no-visual-change default: weather's header
keeps its original fixed blue gradient, tasks/calendar keep their flat
THEME_* ink.

Themes are purely stylistic -- battery's charge-level color, calendar/
tasks' per-owner event chips, and text's own per-widget font choice are
never touched.
2026-07-31 10:34:28 +00:00

294 lines
16 KiB
HTML

{% extends "app_base.html" %}
{% block title %}{{ frame.name or "Frame" }} · Configuration{% endblock %}
{% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %}
{% block device_status %}{% include "_device_status_bar.html" %}{% endblock %}
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
{% block content %}
<div id="control-banner" class="control-banner" style="display: none;">
<span id="control-holder"></span>
<button type="button" id="take-control" class="btn-inline">Take control</button>
</div>
<div class="layout">
<div class="main-col">
<section class="card">
<h2 class="card-title">Display settings</h2>
<form id="config-form">
<label>Orientation
<select id="orientation">
<option value="landscape" {% if frame.orientation == "landscape" %}selected{% endif %}>Landscape</option>
<option value="portrait" {% if frame.orientation == "portrait" %}selected{% endif %}>Portrait</option>
<option value="landscape_flipped" {% if frame.orientation == "landscape_flipped" %}selected{% endif %}>Landscape (flipped)</option>
<option value="portrait_flipped" {% if frame.orientation == "portrait_flipped" %}selected{% endif %}>Portrait (flipped)</option>
</select>
</label>
<label>Refresh interval (minutes)
<input type="number" id="refresh_interval_minutes" min="1" max="1440"
value="{{ (frame.refresh_interval_s // 60) or 60 }}" required>
</label>
<div class="checkbox-row">
<input type="checkbox" id="quiet_hours_enabled" {% if frame.quiet_hours_enabled %}checked{% endif %}>
<label for="quiet_hours_enabled">Quiet hours (don't wake overnight)</label>
</div>
<label>Quiet hours start
<input type="time" id="quiet_hours_start" value="{{ frame.quiet_hours_start }}">
</label>
<label>Quiet hours end
<input type="time" id="quiet_hours_end" value="{{ frame.quiet_hours_end }}">
</label>
<label>Timezone
<select id="timezone">
{% for tz in timezones %}
<option value="{{ tz }}" {% if tz == frame.timezone %}selected{% endif %}>{{ tz }}</option>
{% endfor %}
</select>
</label>
<p class="sub" style="margin-top: 8px;">Quiet hours times are
interpreted in this timezone. The device may still wake once right
at the start of quiet hours -- it can't know ahead of time -- but
goes right back to sleep until they end.</p>
<button type="submit">Save</button>
</form>
</section>
<section class="card">
<h2 class="card-title">Hold actions</h2>
<p class="sub">Holding NEXT or BACK past this duration runs a
frame-wide action instead of each widget's normal short-press
binding (set per-widget in that widget's own config dialog).
Not scoped to any widget -- e.g. cycling through your saved
layouts.</p>
<form id="hold-config-form">
<label>Hold duration (seconds)
<input type="number" id="hold_duration_s" min="3" max="10"
value="{{ (frame.hold_duration_ms // 1000) or 3 }}" required>
</label>
<label>NEXT held action
<select id="next_hold_action">
<option value="" {% if not frame.next_hold_action %}selected{% endif %}>(none)</option>
{% for action, label in global_action_labels.items() %}
<option value="{{ action }}" {% if frame.next_hold_action == action %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label>BACK held action
<select id="back_hold_action">
<option value="" {% if not frame.back_hold_action %}selected{% endif %}>(none)</option>
{% for action, label in global_action_labels.items() %}
<option value="{{ action }}" {% if frame.back_hold_action == action %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<button type="submit">Save</button>
</form>
</section>
</div>
<div class="side-col">
<section class="card">
<h2 class="card-title">Firmware update</h2>
<p class="sub" id="firmware-board">
{% if frame.device_board_variant %}Detected board: {{ frame.device_board_variant }}
{% else %}Board not detected yet -- the frame reports it on its next check-in.{% endif %}
</p>
<p class="sub" id="firmware-available">
{% if frame.firmware_available_version %}Uploaded: v{{ frame.firmware_available_version }} -- the frame
updates itself on its next wake if it's running something else.{% else %}No firmware uploaded yet.{% endif %}
</p>
<input type="file" id="firmware-file" accept=".bin">
<button type="button" class="secondary" id="firmware-upload">Upload firmware</button>
<div id="firmware-repo-display" style="margin-top: 16px; {% if not frame.firmware_update_repo_url %}display: none;{% endif %}">
<p class="sub">Gitea repo: <code id="firmware-repo-text">{{ frame.firmware_update_repo_url }}</code>
<button type="button" class="secondary btn-inline" id="firmware-repo-edit-btn">Edit</button>
</p>
</div>
<label id="firmware-repo-edit" style="{% if frame.firmware_update_repo_url %}display: none;{% endif %}">Gitea repo URL
<input type="text" id="firmware_update_repo_url" placeholder="https://git.example.com/owner/repo"
value="{{ frame.firmware_update_repo_url }}">
</label>
<div class="checkbox-row">
<input type="checkbox" id="firmware_auto_update" {% if frame.firmware_auto_update %}checked{% endif %}>
<label for="firmware_auto_update">Automatically apply updates</label>
</div>
<p class="sub" style="margin-top: 4px;">While on, this frame installs
whatever the repo above publishes next, with nobody reviewing it
first -- only point it at a repo you trust.</p>
<button type="button" class="secondary" id="firmware-settings-save">Save</button>
<p class="sub" id="firmware-gitea-status" style="display: none; margin-top: 10px;"></p>
<button type="button" class="secondary" id="firmware-check-now">Check now</button>
<button type="button" id="firmware-update-btn" style="display: none;">Update frame</button>
</section>
<section class="card">
<h2 class="card-title">Battery alerts</h2>
<label>Email me when battery drops below (%)
<input type="number" id="battery_alert_threshold_pct" min="0" max="100"
value="{% if frame.battery_alert_threshold_pct >= 0 %}{{ frame.battery_alert_threshold_pct }}{% endif %}"
placeholder="disabled">
</label>
<p class="sub" style="margin-top: 8px;">Sent once per discharge cycle
to the frame owner's email (set in Settings) -- clear the field to
disable. Needs SMTP configured by an admin.</p>
<button type="button" class="secondary" id="battery-alert-save">Save</button>
</section>
<details class="card">
<summary class="card-title">Advanced configuration</summary>
<p class="sub">Color-quantization values used for every widget
<strong>except photos</strong> (which has its own separate
settings below) -- approximations by default, since exact
primaries aren't published. Tune them by comparing a rendered
widget against the physical panel; different panel units can
vary enough to be worth calibrating per frame.</p>
<div class="palette-table-wrap">
<table class="palette-table">
<thead><tr><th></th><th>Color</th><th>Hex</th><th>R</th><th>G</th><th>B</th></tr></thead>
<tbody>
{% set current_palette = frame.palette_rgb or default_palette_rgb %}
{% set current_hex = palette_to_hex(current_palette) %}
{% for label in palette_labels %}
<tr>
<td><span class="palette-swatch-preview" data-index="{{ loop.index0 }}"
style="background: {{ current_hex[loop.index0] }};"></span></td>
<td>{{ label }}</td>
<td><input type="text" class="palette-hex" id="palette_{{ loop.index0 }}" data-index="{{ loop.index0 }}"
value="{{ current_hex[loop.index0] }}" maxlength="7" pattern="#[0-9a-fA-F]{6}"
spellcheck="false" autocomplete="off"></td>
<td><input type="number" class="palette-rgb palette-r" data-index="{{ loop.index0 }}"
min="0" max="255" value="{{ current_palette[loop.index0][0] }}"></td>
<td><input type="number" class="palette-rgb palette-g" data-index="{{ loop.index0 }}"
min="0" max="255" value="{{ current_palette[loop.index0][1] }}"></td>
<td><input type="number" class="palette-rgb palette-b" data-index="{{ loop.index0 }}"
min="0" max="255" value="{{ current_palette[loop.index0][2] }}"></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<h2 class="card-title" style="margin-top: 20px;">Image adjustments</h2>
<label>Color enhancement <span class="slider-value" id="color_boost_value">{{ "%.2f" | format(frame.color_boost) }}</span>
<input type="range" id="color_boost" min="0" max="2" step="0.05" value="{{ frame.color_boost }}">
</label>
<label>Contrast <span class="slider-value" id="contrast_boost_value">{{ "%.2f" | format(frame.contrast_boost) }}</span>
<input type="range" id="contrast_boost" min="0" max="2" step="0.05" value="{{ frame.contrast_boost }}">
</label>
<label>Dithering strength <span class="slider-value" id="dither_strength_value">{{ "%.2f" | format(frame.dither_strength) }}</span>
<input type="range" id="dither_strength" min="0" max="1" step="0.05" value="{{ frame.dither_strength }}">
</label>
<p class="sub" style="margin-top: 8px;">1.00 is unchanged for color/
contrast. Dithering strength trades noise texture for smoother
gradients as it goes down; 0 is a flat, un-dithered quantization.
Use the preview below to compare.</p>
<button type="button" class="secondary" id="palette-save" style="margin-top: 16px;">Save</button>
<button type="button" class="secondary" id="palette-reset">Reset to defaults</button>
<button type="button" class="secondary" id="palette-load-calibrated">Load calibrated Spectra 6 preset</button>
<p class="sub" style="margin-top: 8px;">Experimental: a community-measured
starting point (not this specific panel) -- fills the table above,
doesn't save by itself. Real Spectra 6 ink is duller than the
idealized defaults; this may or may not match your actual unit.
Compare against the physical panel before keeping it.</p>
</details>
<details class="card">
<summary class="card-title">Theme</summary>
<p class="sub">A curated visual style for widgets using the
"modern" (experimental) render style -- font, corner radius,
shadow, and header accent. Widgets rendered in the classic
style are unaffected. Photos are unaffected too (see Photos
configuration below).</p>
<label>Theme
<select id="theme-select">
{% for key, t in themes.items() %}
<option value="{{ key }}" {% if frame.theme == key %}selected{% endif %}>{{ t.label }}</option>
{% endfor %}
</select>
</label>
<button type="button" class="secondary" id="theme-save" style="margin-top: 16px;">Save</button>
</details>
<details class="card">
<summary class="card-title">Photos configuration</summary>
<p class="sub">Palette and dithering used <strong>only</strong> by
the photos widget, independent of Advanced configuration above --
lets you tune the rest of this frame's widgets (e.g. a "modern"
HTML-rendered look) without changing what looks best for actual
photographs, or vice versa.</p>
<div class="palette-table-wrap">
<table class="palette-table">
<thead><tr><th></th><th>Color</th><th>Hex</th><th>R</th><th>G</th><th>B</th></tr></thead>
<tbody>
{% set current_photo_palette = frame.photo_palette_rgb or default_palette_rgb %}
{% set current_photo_hex = palette_to_hex(current_photo_palette) %}
{% for label in palette_labels %}
<tr>
<td><span class="photo-palette-swatch-preview" data-index="{{ loop.index0 }}"
style="background: {{ current_photo_hex[loop.index0] }};"></span></td>
<td>{{ label }}</td>
<td><input type="text" class="photo-palette-hex" id="photo_palette_{{ loop.index0 }}" data-index="{{ loop.index0 }}"
value="{{ current_photo_hex[loop.index0] }}" maxlength="7" pattern="#[0-9a-fA-F]{6}"
spellcheck="false" autocomplete="off"></td>
<td><input type="number" class="photo-palette-rgb photo-palette-r" data-index="{{ loop.index0 }}"
min="0" max="255" value="{{ current_photo_palette[loop.index0][0] }}"></td>
<td><input type="number" class="photo-palette-rgb photo-palette-g" data-index="{{ loop.index0 }}"
min="0" max="255" value="{{ current_photo_palette[loop.index0][1] }}"></td>
<td><input type="number" class="photo-palette-rgb photo-palette-b" data-index="{{ loop.index0 }}"
min="0" max="255" value="{{ current_photo_palette[loop.index0][2] }}"></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<label>Dithering strength <span class="slider-value" id="photo_dither_strength_value">{{ "%.2f" | format(frame.photo_dither_strength) }}</span>
<input type="range" id="photo_dither_strength" min="0" max="1" step="0.05" value="{{ frame.photo_dither_strength }}">
</label>
<button type="button" class="secondary" id="photo-palette-save" style="margin-top: 16px;">Save</button>
<button type="button" class="secondary" id="photo-palette-reset">Reset to defaults</button>
</details>
<section class="card">
<h2 class="card-title">Preview</h2>
{% if photo_widget_id %}
<p class="sub">The current photo (from this frame's photo widget), and
exactly how it renders on the panel with this frame's saved settings
above.</p>
<div class="preview-compare">
<div class="preview-pane">
<p class="sub">Now displaying</p>
<img class="preview-img" id="preview-original" alt="Original photo">
</div>
<div class="preview-pane">
<p class="sub">How it will look on the frame</p>
<img class="preview-img" id="preview-rendered" alt="Rendered preview">
</div>
</div>
<button type="button" class="secondary" id="preview-refresh">Refresh preview</button>
{% else %}
<p class="sub">This frame doesn't have a photo widget to preview
against yet -- add one from the Layout tab.</p>
{% endif %}
</section>
</div>
</div>
<div id="result"></div>
{% endblock %}
{% block scripts %}
<script>
window.FRAME_BASE_API = window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};
window.DEFAULT_PALETTE_HEX = {{ palette_to_hex(default_palette_rgb) | tojson }};
window.CALIBRATED_SPECTRA6_HEX = {{ calibrated_spectra6_hex | tojson }};
window.PHOTO_WIDGET_PREVIEW_API = {{ (("/api/frames/" ~ frame.id ~ "/widgets/" ~ photo_widget_id) | tojson) if photo_widget_id else "null" }};
</script>
<script src="/static/device_status_bar.js"></script>
<script src="/static/frame_header.js"></script>
<script src="/static/frame_config.js"></script>
{% endblock %}