diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index aadebc4d..074aafd8 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -85,5 +85,6 @@ jobs:
test/test_plugin_update_reservation.py \
test/test_template_targets.py \
test/test_widget_scripts.py \
+ test/test_enum_option_labels.py \
test/test_doc_links.py \
test/web_interface/test_cache.py
diff --git a/docs/widget-guide.md b/docs/widget-guide.md
index 7a1ed850..2b6510a9 100644
--- a/docs/widget-guide.md
+++ b/docs/widget-guide.md
@@ -206,6 +206,45 @@ 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.
+
## Marking Fields as Advanced (`x-advanced`)
Add `"x-advanced": true` to any top-level, non-object property to move it out
diff --git a/test/test_enum_option_labels.py b/test/test_enum_option_labels.py
new file mode 100644
index 00000000..c0a56ce6
--- /dev/null
+++ b/test/test_enum_option_labels.py
@@ -0,0 +1,96 @@
+"""Guard: enum dropdowns in the plugin config form honour x-options.labels.
+
+The form humanises a raw enum value into its option text ("day_first" ->
+"Day First"), which 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 render the real Jinja template fragments, so they fail if the
+lookup is dropped or the fallback stops matching the previous behaviour.
+"""
+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')
+
+# The dropdown fragment, lifted from the template so the test exercises the
+# real expression rather than a paraphrase of it.
+SELECT_FRAGMENT = """
+{%- set enum_labels = (prop.get('x-options') or prop.get('x_options') or {}).get('labels') or {} -%}
+{%- for option in prop.enum -%}
+
+{%- endfor -%}
+"""
+
+
+def _render(prop: dict) -> str:
+ env = Environment(loader=DictLoader({'f': SELECT_FRAGMENT}), autoescape=True)
+ return env.get_template('f').render(prop=prop)
+
+
+def test_template_looks_up_enum_labels() -> None:
+ """The shipped template must resolve option text through x-options.labels."""
+ source = CONFIG_FORM.read_text(encoding='utf-8')
+ assert "enum_labels.get(option," in source, (
+ 'plugin_config.html no longer resolves enum option text through '
+ 'x-options.labels; schemas that supply labels would silently show '
+ 'raw values again'
+ )
+
+
+def test_labels_are_used_when_supplied() -> None:
+ prop = {
+ 'enum': ['vs', 'date_time'],
+ 'x-options': {'labels': {'vs': 'VS', 'date_time': 'Date and time'}},
+ }
+ html = _render(prop)
+ assert '>VS<' in html
+ assert '>Date and time<' in html
+
+
+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 '>Day First<' in html
+ assert '>Weekday<' in html
+
+
+def test_partial_labels_fall_back_per_value() -> None:
+ """A labels map covering some values leaves the rest humanised."""
+ prop = {'enum': ['vs', 'day_first'], 'x-options': {'labels': {'vs': 'VS'}}}
+ html = _render(prop)
+ assert '>VS<' in html
+ assert '>Day First<' in html
+
+
+def test_option_values_are_unchanged_by_labelling() -> None:
+ """Labels are display-only: the submitted value stays the enum value."""
+ prop = {'enum': ['abbrev'], 'x-options': {'labels': {'abbrev': 'Sep 19'}}}
+ html = _render(prop)
+ assert 'value="abbrev"' in html
+ assert '>Sep 19<' in html
+
+
+@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 '>VS<' in html
+
+
+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 things like 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'
+ )
diff --git a/web_interface/templates/v3/partials/plugin_config.html b/web_interface/templates/v3/partials/plugin_config.html
index a959ba96..41651fb8 100644
--- a/web_interface/templates/v3/partials/plugin_config.html
+++ b/web_interface/templates/v3/partials/plugin_config.html
@@ -121,14 +121,21 @@
{% 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 {} %}
@@ -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 {} %}
{% elif col_xwidget == 'date-picker' %}