Compare commits

..
Author SHA1 Message Date
ChuckBuildsandClaude Opus 5 f8d700e5d9 test(vegas): cover the width cap still working when asked for
Defaulting the cap off must not quietly remove it. Asserts that an
explicit ratio is honoured and validates, alongside the existing check
that omitting it means uncapped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
2026-08-07 16:12:07 -04:00
ChuckBuildsandClaude Opus 5 e0d62a9e54 fix(vegas): stop capping plugin width by default
Vegas plugins read as "cut off early" or "starting in the middle". That
was the per-plugin width budget, not the scroll engine:
overflow_mode=rotate is designed to resume mid-content on each
appearance, so the symptom was the feature working as specified.

Measured over a 17-plugin fleet on a 512px panel, the cap was a bad
trade. Only four plugins were ever wide enough to hit the 3.0 default --
leaderboard 11,518px, news 10,021px, odds-ticker 4,643px, hockey
1,508px. Weather is 650px and flights 512px; the cap never touched them
or the other eleven. So it bought nothing on thirteen plugins while
costing two visible faults on four: content entering mid-item (a news
ticker started at column 6027 of its own strip), and a final rotation
window of whatever happened to be left -- 348px of an 1,840px stocks
ticker, seven seconds of panel time.

Default max_plugin_width_ratio to 0 (uncapped), so every plugin
contributes all of its content and is always entered at its beginning.
The cap remains available, and vegas_max_width_screens still caps an
individual plugin -- which is where the knob belongs, since a genuinely
long ticker is a property of that plugin rather than of the fleet.

Verified on a live 512px device: 327 budget crops in the preceding six
hours, none after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
2026-08-07 16:05:28 -04:00
ChuckandGitHub fc25a70d75 fix(web): make the update button work on branches without tracking (#443)
Reported from a pi whose checkout sat on a local branch:

    git pull failed (returncode=1): There is no tracking information for
    the current branch. Please specify which branch you want to rebase
    against.

The Tools tab reported that as "Update failed; check logs for details",
which tells the user nothing they can act on, and the underlying git
message never reached the UI at all.

A branch with no upstream is easy to end up on — checking one out by
name, restoring a backup, or following a guide that names a branch — and
until now it left the update button permanently broken with no way out
except SSH.

resolve_pull_command() now decides how to pull:
  - upstream set                  -> git pull --rebase, as before
  - no upstream, origin/<branch>  -> git pull --rebase origin <branch>,
    then attach tracking so the next update is a plain pull
  - no upstream, no remote branch -> an error naming the branch and
    pointing at Switch branch
  - detached HEAD                 -> says so, rather than failing obscurely

That resolution happens BEFORE the stash. Previously the handler stashed
local changes and then discovered it could not pull, putting the user's
work away for an update that was never going to run.

Failures now surface git's own message instead of "check logs".

Adds a branch picker to the Tools tab, backed by GET
/system/git-branches (local + remote-only) and a checkout_branch action.
Switching attaches tracking, so Pull Latest works afterwards. Branch
names are validated against a strict pattern before reaching a subprocess
argument list.

Local edits block a checkout, as they should. Rather than a truncated
one-line error, the response carries git's full list of blocking files
and a can_retry_with_stash flag; the UI then offers "Stash and switch" as
an explicit choice. Stashing is never done unasked — putting someone's
edits away without consent is worse than refusing the switch.

Verified on the pi that produced the report: on its untracked 'audit'
branch the update now returns the actionable message, git-info reports
upstream='' and can_pull=false, and an injected branch name is rejected.
27 tests build real git repositories and cover each path, including the
stash route that could not be exercised safely on the device.
2026-08-07 13:27:43 -04:00
003312f4ff feat(web): let schemas label enum dropdown options (#442)
* feat(web): let schemas label enum dropdown options

An enum property renders as a dropdown whose option text is derived from
the value — underscores replaced, title case applied. That works when the
value reads as its own label and fails when it does not: "vs" renders as
"Vs", and "abbrev" tells the user nothing about the "Sep 19" it produces.
Schemas had no way to say otherwise, so the label was whatever the config
key happened to look like.

Enum dropdowns now take their option text from x-options.labels when the
schema supplies it. This is not a new convention: the checkbox-group
widget has read x-options.labels since it was written, with the same
humanised fallback. This extends it to plain enums and to array-table
columns.

Display only — the option value, and so the saved config, is unchanged.
The map may be partial; unlabelled values keep the humanised fallback, so
every existing schema renders exactly as before. Older cores ignore
x-options entirely, which means a plugin can ship labels without
requiring users to upgrade first.

Array-table columns get the same lookup but keep the raw value as their
fallback rather than the humanised one. Those columns hold values such as
ticker symbols, where "aapl" -> "Aapl" would be wrong, and they were not
being humanised before this change.

Verified against the running web service: with labels the hockey plugin's
date dropdown reads "Sep 19 / 9/19 / 19 Sep / 19/9 / Fri Sep 19"; with
the pre-change template and the same schema it falls back to
"Abbrev / Numeric / Day First / ...", confirming the degradation path.

* fix(web): label enum options in dynamically added table rows, and test the
shipped template

Both points from the CodeRabbit review on #442.

array-table.js built enum <option> elements with o.textContent = opt, so a
row added with "Add row" showed the raw value while the server-rendered
rows above it showed the schema's label — the same column reading two
different ways until the page was reloaded. Both option-building sites now
go through a shared enumOptionLabel(), which mirrors the template exactly:
x-options or x_options, labels map, raw value as the fallback.

The tests rendered a copy of the template expression, so they could pass
while production drifted. They now extract the live enum <select> block
out of plugin_config.html and render that, and assert on the full
value -> label map rather than substring presence.

Mutation-checked, since a guard that cannot fail is not a guard:
  - remove the labels lookup      -> 5 of 9 fail
  - change only the fallback to   -> 2 of 9 fail
    option|upper (keeping the
    enum_labels.get call intact)
  - revert the JS to raw values   -> 1 of 9 fails
The middle case is the one the review called out as able to slip through.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-07 13:27:20 -04:00
9 changed files with 245 additions and 15 deletions
+1 -1
View File
@@ -149,7 +149,7 @@
"min_plugin_width": 8,
"lead_in_width": 0,
"plugins_per_cycle": 6,
"max_plugin_width_ratio": 3.0,
"max_plugin_width_ratio": 0.0,
"overflow_mode": "rotate",
"dynamic_duration_enabled": true,
"min_cycle_duration": 60,
+1 -1
View File
@@ -127,7 +127,7 @@ Read by `src/vegas_mode/config.py` (`VegasScrollConfig.from_config`). See
| `min_plugin_width` | int, `8` |
| `lead_in_width` | int, `0` |
| `plugins_per_cycle` | int, `6` |
| `max_plugin_width_ratio` | float, `3.0` |
| `max_plugin_width_ratio` | float, `0.0` |
| `overflow_mode` | string, `"rotate"` |
| `dynamic_duration_enabled` | bool, `true` |
| `min_cycle_duration` | int, `60` |
+41
View File
@@ -206,6 +206,47 @@ To use an existing widget in your plugin's `config_schema.json`, simply add the
The widget will be automatically rendered when the plugin configuration form is loaded.
## Labelling Enum Options (`x-options.labels`)
A plain `enum` renders as a dropdown whose option text is the value with
underscores replaced and title case applied — `day_first` becomes "Day First".
That is fine for values that read as their own label, and wrong for values that
do not: `vs` becomes "Vs", and `abbrev` says nothing about the `Sep 19` it
actually produces.
Supply `x-options.labels` to set the visible text. This is the same convention
the `checkbox-group` widget uses:
```json
{
"date_format": {
"type": "string",
"enum": ["abbrev", "numeric", "day_first"],
"default": "abbrev",
"x-options": {
"labels": {
"abbrev": "Sep 19",
"numeric": "9/19",
"day_first": "19 Sep"
}
}
}
}
```
Labels are **display only** — the stored value is still the enum value, so
adding them never changes a saved config. The map may be partial: any value
without a label keeps the humanised fallback. Older cores that predate this
support ignore `x-options` and render the fallback for every option, so a
plugin can ship labels without requiring a core upgrade.
Array-table columns (`x-widget: array-table`) accept the same
`x-options.labels` on a column definition, but their fallback is the **raw
value** rather than the humanised one, because those columns hold values such
as ticker symbols where `aapl` → "Aapl" would be wrong. Rows added in the
browser use the labels too (`array-table.js`), so a column reads the same
before and after a page reload.
## Marking Fields as Advanced (`x-advanced`)
Add `"x-advanced": true` to any top-level, non-object property to move it out
+17 -5
View File
@@ -104,10 +104,22 @@ class VegasModeConfig:
overflow_mode: str = "rotate"
# Cap on one plugin's share of a cycle, as a multiple of display width.
# A single ticker returning 7,000px would otherwise hold the panel for over
# two minutes. Overflow is deferred to later cycles rather than discarded.
# 0 disables the cap.
max_plugin_width_ratio: float = 3.0
# 0 (the default) disables the cap, so every plugin contributes all of its
# content and is always entered at its beginning.
#
# Capping was the default until it proved to cost more than it bought.
# Measured over a 17-plugin fleet on a 512px panel, only four plugins were
# ever wide enough to hit a 3.0 cap; for those four it produced two visible
# faults. Content resumed mid-item on each appearance (a news ticker entered
# at column 6027 of its own strip), and the final window of a rotation was
# whatever happened to be left — 348px of a 1840px stocks ticker, seven
# seconds of panel time. Both read as the display being broken rather than
# as deferral working.
#
# A wide plugin does hold the panel for a long time uncapped: set the cap
# per plugin with vegas_max_width_screens where that matters, rather than
# globally where it mostly hurts plugins that were never the problem.
max_plugin_width_ratio: float = 0.0
# Plugin management
plugin_order: List[str] = field(default_factory=list)
@@ -159,7 +171,7 @@ class VegasModeConfig:
lead_in_width=int(vegas_config.get('lead_in_width', 0)),
plugins_per_cycle=int(vegas_config.get('plugins_per_cycle', 6)),
max_plugin_width_ratio=float(
vegas_config.get('max_plugin_width_ratio', 3.0)),
vegas_config.get('max_plugin_width_ratio', 0.0)),
overflow_mode=str(vegas_config.get('overflow_mode', 'rotate')),
plugin_order=list(vegas_config.get('plugin_order', [])),
excluded_plugins=set(vegas_config.get('excluded_plugins', [])),
+134
View File
@@ -0,0 +1,134 @@
"""Guard: enum dropdowns in the plugin config form honour x-options.labels.
The form derives an option's visible text from its value — underscores
replaced, title case applied ("day_first" -> "Day First"). That cannot
express every label a schema needs: "vs" reads as "Vs", and "abbrev" says
nothing about the "Sep 19" it produces. Schemas can supply x-options.labels
instead, the same convention the checkbox-group widget already uses.
These tests extract the enum <select> block *out of the shipped template*
and render that, so they exercise the production expression rather than a
copy of it. If the fallback or the lookup changes, these tests render the
changed code and fail — a duplicated fragment here would silently keep
passing.
"""
import re
from pathlib import Path
import pytest
from jinja2 import DictLoader, Environment
PROJECT_ROOT = Path(__file__).resolve().parent.parent
CONFIG_FORM = (PROJECT_ROOT / 'web_interface' / 'templates' / 'v3' / 'partials'
/ 'plugin_config.html')
ARRAY_TABLE_JS = (PROJECT_ROOT / 'web_interface' / 'static' / 'v3' / 'js'
/ 'widgets' / 'array-table.js')
# The enum branch: from the `{% set enum_labels %}` line through `</select>`.
ENUM_BLOCK_RE = re.compile(
r"(\{%\s*set enum_labels\s*=.*?</select>)", re.S
)
def _shipped_enum_block() -> str:
"""Return the live enum <select> block lifted from plugin_config.html."""
source = CONFIG_FORM.read_text(encoding='utf-8')
match = ENUM_BLOCK_RE.search(source)
assert match, (
'could not find the enum <select> block in plugin_config.html — the '
'template changed shape and this guard needs updating'
)
return match.group(1)
def _render(prop: dict, value=None) -> str:
"""Render the shipped enum block with a minimal fixture."""
env = Environment(loader=DictLoader({'f': _shipped_enum_block()}),
autoescape=True)
return env.get_template('f').render(
prop=prop, value=value, field_id='fid', full_key='k'
)
def _option_labels(html: str) -> dict:
"""Map each rendered option's value to its visible text."""
return {
value: text.strip()
for value, text in re.findall(
r'<option value="([^"]*)"[^>]*>(.*?)</option>', html, re.S
)
}
def test_labels_are_used_when_supplied() -> None:
html = _render({
'enum': ['vs', 'date_time'],
'x-options': {'labels': {'vs': 'VS', 'date_time': 'Date and time'}},
})
assert _option_labels(html) == {'vs': 'VS', 'date_time': 'Date and time'}
def test_unlabelled_values_keep_the_humanised_fallback() -> None:
"""Schemas without labels must render exactly as they did before."""
html = _render({'enum': ['day_first', 'weekday']})
assert _option_labels(html) == {'day_first': 'Day First', 'weekday': 'Weekday'}
def test_partial_labels_fall_back_per_value() -> None:
"""A labels map covering some values leaves the rest humanised."""
html = _render({'enum': ['vs', 'day_first'],
'x-options': {'labels': {'vs': 'VS'}}})
assert _option_labels(html) == {'vs': 'VS', 'day_first': 'Day First'}
def test_option_values_are_unchanged_by_labelling() -> None:
"""Labels are display-only: the submitted value stays the enum value."""
html = _render({'enum': ['abbrev'],
'x-options': {'labels': {'abbrev': 'Sep 19'}}})
assert _option_labels(html) == {'abbrev': 'Sep 19'}
def test_selected_option_still_tracks_the_current_value() -> None:
"""Labelling must not disturb which option is marked selected."""
html = _render({'enum': ['abbrev', 'numeric'],
'x-options': {'labels': {'abbrev': 'Sep 19'}}},
value='numeric')
selected = re.search(r'<option value="([^"]+)"[^>]*selected', html)
assert selected and selected.group(1) == 'numeric'
@pytest.mark.parametrize('key', ['x-options', 'x_options'])
def test_both_option_key_spellings_work(key: str) -> None:
"""The template accepts either spelling, as its other widgets do."""
html = _render({'enum': ['vs'], key: {'labels': {'vs': 'VS'}}})
assert _option_labels(html) == {'vs': 'VS'}
def test_table_column_enum_falls_back_to_the_raw_value() -> None:
"""Array-table columns must not title-case values that were never labelled.
Those columns hold values such as ticker symbols, where "aapl" -> "Aapl"
would be wrong, so their fallback stays the raw value.
"""
source = CONFIG_FORM.read_text(encoding='utf-8')
assert 'col_labels.get(opt, opt)' in source, (
'array-table column options must fall back to the raw value, not the '
'humanised one'
)
def test_dynamically_added_table_rows_use_the_same_labels() -> None:
"""Rows added client-side must label options like the server-rendered ones.
array-table.js builds new rows in the browser; if it printed the raw value
a column would read differently before and after a page reload.
"""
js = ARRAY_TABLE_JS.read_text(encoding='utf-8')
assert 'function enumOptionLabel' in js, (
'array-table.js lost its enum label helper'
)
raw_option_text = re.findall(r'o\.textContent\s*=\s*opt\s*;', js)
assert not raw_option_text, (
'array-table.js renders an enum option as its raw value; it must go '
'through enumOptionLabel() so dynamic rows match server-rendered ones'
)
+14
View File
@@ -781,6 +781,20 @@ class TestNewConfigKeys:
assert cfg.render_width_pct == 100
assert cfg.min_content_separation == 24
def test_width_cap_is_off_by_default(self):
# Capping made wide plugins resume mid-content on every appearance and
# emit runt final windows; it is now opt-in per plugin instead.
assert VegasModeConfig().max_plugin_width_ratio == 0.0
assert VegasModeConfig.from_config({}).max_plugin_width_ratio == 0.0
def test_width_cap_is_still_available_when_asked_for(self):
# Defaulting the cap off must not remove it: a user who sets a ratio
# still gets one, and 0 still means uncapped.
cfg = VegasModeConfig.from_config(
{'display': {'vegas_scroll': {'max_plugin_width_ratio': 3.0}}})
assert cfg.max_plugin_width_ratio == 3.0
assert cfg.validate() == []
@pytest.mark.parametrize('overrides,bad_key', [
({'render_width_pct': 5}, 'render_width_pct'),
({'render_width_pct': 101}, 'render_width_pct'),
@@ -160,6 +160,24 @@
// ─── Cell rendering ─────────────────────────────────────────────────────
/**
* Visible text for one enum option.
*
* Mirrors the server-rendered table in plugin_config.html: a schema may
* supply x-options.labels, and anything unlabelled falls back to the raw
* value. Rows added here must match rows rendered by the template, or the
* same column would read differently before and after a page reload.
*
* @param {Object} colDef column (or property) schema
* @param {*} opt the enum value
* @returns {string} label to display
*/
function enumOptionLabel(colDef, opt) {
const options = (colDef && (colDef['x-options'] || colDef['x_options'])) || {};
const labels = options.labels || {};
return Object.prototype.hasOwnProperty.call(labels, opt) ? labels[opt] : opt;
}
/**
* Create one <td> for a display column.
*/
@@ -219,7 +237,7 @@
if (opt === null) return;
const o = document.createElement('option');
o.value = opt;
o.textContent = opt;
o.textContent = enumOptionLabel(colDef, opt);
if (String(colValue) === String(opt)) o.selected = true;
sel.appendChild(o);
});
@@ -646,7 +664,7 @@
enumVals.forEach(opt => {
if (opt === null) return;
const o = document.createElement('option');
o.value = opt; o.textContent = opt;
o.value = opt; o.textContent = enumOptionLabel(schema, opt);
if (String(currentVal) === String(opt)) o.selected = true;
sel.appendChild(o);
});
@@ -556,11 +556,11 @@
</div>
<div class="form-group" id="setting-display-vegas_max_plugin_width_ratio" data-setting-key="display.vegas_scroll.max_plugin_width_ratio">
<label for="vegas_max_plugin_width_ratio" class="block text-sm font-medium text-gray-700">Max Plugin Width (screens){{ ui.help_tip('Caps how much of one cycle a single plugin may occupy, measured in screen widths (020).\nDefault: 3. A long ticker such as a news feed or leaderboard is trimmed to this and the remainder shown on later cycles, so one plugin cannot hold the display for minutes. Set 0 for no limit.', 'Max Plugin Width') }}</label>
<label for="vegas_max_plugin_width_ratio" class="block text-sm font-medium text-gray-700">Max Plugin Width (screens){{ ui.help_tip('Caps how much of one cycle a single plugin may occupy, measured in screen widths (020).\nDefault: 0 (no limit) — every plugin shows all of its content and always starts at the beginning.\nSet a limit to stop one long ticker holding the display for minutes: it is cut to this width and the remainder shown on later cycles. The trade-off is that such a plugin then resumes mid-content on each appearance instead of starting fresh.', 'Max Plugin Width') }}</label>
<input type="number"
id="vegas_max_plugin_width_ratio"
name="vegas_max_plugin_width_ratio"
value="{{ main_config.display.get('vegas_scroll', {}).get('max_plugin_width_ratio', 3.0) }}"
value="{{ main_config.display.get('vegas_scroll', {}).get('max_plugin_width_ratio', 0.0) }}"
min="0"
max="20"
step="0.5"
@@ -121,14 +121,21 @@
</label>
{% endif %}
{# Enum dropdown #}
{# Enum dropdown. Option text comes from x-options.labels when the
schema supplies it -- the same convention the checkbox-group
widget already uses -- because humanising the raw value cannot
express every label: "vs" reads as "Vs", and "abbrev" says
nothing about the "Sep 19" it produces. Values without a label
fall back to the humanised form, so existing schemas render
exactly as before. #}
{% elif prop.enum %}
{% set enum_labels = (prop.get('x-options') or prop.get('x_options') or {}).get('labels') or {} %}
<select id="{{ field_id }}"
name="{{ full_key }}"
class="form-select w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 bg-white text-black">
{% for option in prop.enum %}
<option value="{{ option }}" {% if value == option %}selected{% endif %}>
{{ option|replace('_', ' ')|title }}
{{ enum_labels.get(option, option|replace('_', ' ')|title) }}
</option>
{% endfor %}
</select>
@@ -569,10 +576,14 @@
class="block w-20 px-2 py-1 border border-gray-300 rounded text-sm text-center"
{% if col_def.get('description') %}title="{{ col_def.get('description') }}"{% endif %}>
{% elif col_enum %}
{# Labels are opt-in here and the fallback stays the raw
value: table columns hold things like ticker symbols,
which must not be title-cased behind the user's back. #}
{% set col_labels = (col_def.get('x-options') or col_def.get('x_options') or {}).get('labels') or {} %}
<select name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
class="block w-full px-2 py-1 border border-gray-300 rounded text-sm bg-white">
{% for opt in col_enum %}{% if opt is not none %}
<option value="{{ opt }}" {% if col_value == opt or (col_value is none and col_def.get('default') == opt) %}selected{% endif %}>{{ opt }}</option>
<option value="{{ opt }}" {% if col_value == opt or (col_value is none and col_def.get('default') == opt) %}selected{% endif %}>{{ col_labels.get(opt, opt) }}</option>
{% endif %}{% endfor %}
</select>
{% elif col_xwidget == 'date-picker' %}