Fix the settings tabs, and space a form from what follows it
**The Audio tab rendered nothing.** The tabs are radios plus sibling selectors, and the CSS named every tab twice -- once to highlight its label, once to show its panel. A tab added without also adding those two rules gets a label that selects nothing, which is not something anyone catches in review; it looks like a blank page. Replaced with rules that derive what they can. The active label is `input:checked + .tabs__tab`, which needs to know nothing at all. The panel is matched by position -- CSS cannot compare a radio's id with a panel's data-tab -- so the Nth radio shows the Nth panel. Both lists render in the same order and a conditional tab drops out of both at once, so they cannot drift. There is a test asserting the two orders match, including with Audio absent. **A card following a form sat flush against Save.** The "Try it" panel on the search page read as another field of the settings form. The gap belongs to the form rather than to its action row: the action row is always its form's last child, so a bottom margin there has nothing to push away from. Adds `.form-actions` and a bottom margin on a form that is a direct child of an admin page. Also says plainly in the dictation settings that a server hosting one model ignores the model field, so `whisper-1` there is a label rather than a selection. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -72,23 +72,64 @@
|
|||||||
|
|
||||||
.tabs__panel { display: none; }
|
.tabs__panel { display: none; }
|
||||||
|
|
||||||
/* Each radio activates its own label and its own panel. Written out because
|
/* The active tab's label. Each radio is immediately followed by its own label,
|
||||||
CSS has no way to derive one from the other. */
|
so this needs to know nothing about how many tabs there are or what they are
|
||||||
#tab-account:checked ~ label[for="tab-account"],
|
called. */
|
||||||
#tab-models:checked ~ label[for="tab-models"],
|
.tabs__bar input:checked + .tabs__tab {
|
||||||
#tab-appearance:checked ~ label[for="tab-appearance"],
|
|
||||||
#tab-security:checked ~ label[for="tab-security"] {
|
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
border-bottom-color: var(--accent);
|
border-bottom-color: var(--accent);
|
||||||
}
|
}
|
||||||
.tabs__bar:has(#tab-account:checked) ~ .tabs__body [data-tab="tab-account"],
|
|
||||||
.tabs__bar:has(#tab-models:checked) ~ .tabs__body [data-tab="tab-models"],
|
/*
|
||||||
.tabs__bar:has(#tab-appearance:checked) ~ .tabs__body [data-tab="tab-appearance"],
|
The active panel, matched by position.
|
||||||
.tabs__bar:has(#tab-security:checked) ~ .tabs__body [data-tab="tab-security"] {
|
|
||||||
|
CSS cannot compare a radio's id with a panel's data-tab, so this used to name
|
||||||
|
every tab twice -- and a tab added without also adding its two rules here
|
||||||
|
rendered a label that selected nothing. That is not a failure anyone spots in
|
||||||
|
review; it looks like a blank page.
|
||||||
|
|
||||||
|
Position is derivable, so it is used instead: the Nth radio shows the Nth
|
||||||
|
panel. Both lists are rendered in the same order, and a conditional tab drops
|
||||||
|
out of both at once, so they cannot drift apart. :nth-of-type counts by
|
||||||
|
element name, which is why the panels are <section> and the alerts above them
|
||||||
|
are <div> -- the alerts are not counted.
|
||||||
|
|
||||||
|
Enumerated to eight, comfortably more than exist. A ninth tab needs one line.
|
||||||
|
*/
|
||||||
|
.tabs__bar:has(input:nth-of-type(1):checked) ~ .tabs__body .tabs__panel:nth-of-type(1),
|
||||||
|
.tabs__bar:has(input:nth-of-type(2):checked) ~ .tabs__body .tabs__panel:nth-of-type(2),
|
||||||
|
.tabs__bar:has(input:nth-of-type(3):checked) ~ .tabs__body .tabs__panel:nth-of-type(3),
|
||||||
|
.tabs__bar:has(input:nth-of-type(4):checked) ~ .tabs__body .tabs__panel:nth-of-type(4),
|
||||||
|
.tabs__bar:has(input:nth-of-type(5):checked) ~ .tabs__body .tabs__panel:nth-of-type(5),
|
||||||
|
.tabs__bar:has(input:nth-of-type(6):checked) ~ .tabs__body .tabs__panel:nth-of-type(6),
|
||||||
|
.tabs__bar:has(input:nth-of-type(7):checked) ~ .tabs__body .tabs__panel:nth-of-type(7),
|
||||||
|
.tabs__bar:has(input:nth-of-type(8):checked) ~ .tabs__body .tabs__panel:nth-of-type(8) {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
.tabs__tab:has(:focus-visible) { outline: 2px solid var(--accent); outline-offset: -2px; }
|
.tabs__tab:has(:focus-visible) { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||||
|
|
||||||
|
/*
|
||||||
|
A form's action row, and the space after the form it closes.
|
||||||
|
|
||||||
|
Not a plain .btn-row: a settings form is a stack of cards, and Save has to
|
||||||
|
read as belonging to the cards above it rather than to whatever comes next.
|
||||||
|
The gap that matters is the one *after* the form -- a card following it (the
|
||||||
|
"Try it" panel on the search page, say) otherwise sits flush against Save and
|
||||||
|
looks like another field of the same form. That margin therefore belongs to
|
||||||
|
the form, not to the action row: the action row is always its form's last
|
||||||
|
child, so a bottom margin there would have nothing to push away from.
|
||||||
|
*/
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: var(--sp-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-page > form { margin-bottom: var(--sp-8); }
|
||||||
|
.admin-page > form:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
/* --- Cards ----------------------------------------------------------------- */
|
/* --- Cards ----------------------------------------------------------------- */
|
||||||
.card {
|
.card {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
|
|||||||
@@ -61,8 +61,9 @@
|
|||||||
<input class="input" id="stt-model" name="stt_model"
|
<input class="input" id="stt-model" name="stt_model"
|
||||||
value="{{ values.stt_model }}" placeholder="whisper-1">
|
value="{{ values.stt_model }}" placeholder="whisper-1">
|
||||||
<p class="field__hint">
|
<p class="field__hint">
|
||||||
Sent even to servers that only host one; a router in front of several
|
A server hosting one model <strong>ignores this</strong> and uses
|
||||||
needs it.
|
whatever it was started with — <code>whisper-1</code> is then just a
|
||||||
|
label. It only selects anything on a server that hosts several.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -179,6 +180,6 @@
|
|||||||
<div id="audio-test-tts"></div>
|
<div id="audio-test-tts"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="btn-row"><button class="btn btn--primary" type="submit">Save settings</button></div>
|
<div class="form-actions"><button class="btn btn--primary" type="submit">Save settings</button></div>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -84,6 +84,6 @@
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="btn-row"><button class="btn btn--primary" type="submit">Save settings</button></div>
|
<div class="form-actions"><button class="btn btn--primary" type="submit">Save settings</button></div>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -128,7 +128,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="btn-row"><button class="btn btn--primary" type="submit">Save settings</button></div>
|
<div class="form-actions"><button class="btn btn--primary" type="submit">Save settings</button></div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
|
|||||||
@@ -298,6 +298,43 @@ def test_the_audio_tab_appears_only_once_audio_is_configured(
|
|||||||
assert "tab-audio" in client.get("/settings").text
|
assert "tab-audio" in client.get("/settings").text
|
||||||
|
|
||||||
|
|
||||||
|
def _tab_lists(html: str) -> tuple[list[str], list[str]]:
|
||||||
|
import re
|
||||||
|
|
||||||
|
return (
|
||||||
|
re.findall(r'<input[^>]*name="settings-tab"[^>]*id="(tab-[a-z]+)"', html),
|
||||||
|
re.findall(r'<section class="tabs__panel" data-tab="(tab-[a-z]+)"', html),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_settings_tab_has_a_panel_in_the_same_position(
|
||||||
|
client: TestClient, db, registered
|
||||||
|
):
|
||||||
|
"""The tabs are radios plus sibling selectors, and CSS cannot compare a
|
||||||
|
radio's id with a panel's data-tab -- the link is positional. A tab whose
|
||||||
|
panel is somewhere else in the order silently shows the wrong one, and a tab
|
||||||
|
with no panel shows a blank page. Neither is visible in review."""
|
||||||
|
settings_store.update(
|
||||||
|
db,
|
||||||
|
{"tts_enabled": True, "tts_base_url": "http://tts", "stt_enabled": True},
|
||||||
|
key=settings_store.AUDIO,
|
||||||
|
)
|
||||||
|
tabs, panels = _tab_lists(client.get("/settings").text)
|
||||||
|
|
||||||
|
assert tabs, "no tabs were found; the markup must have changed"
|
||||||
|
assert tabs == panels
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_positions_still_line_up_when_a_conditional_tab_is_absent(
|
||||||
|
client: TestClient, registered
|
||||||
|
):
|
||||||
|
"""Audio only appears once it is configured. It has to drop out of both
|
||||||
|
lists at once or everything after it shifts by one."""
|
||||||
|
tabs, panels = _tab_lists(client.get("/settings").text)
|
||||||
|
assert "tab-audio" not in tabs
|
||||||
|
assert tabs == panels
|
||||||
|
|
||||||
|
|
||||||
def test_saving_audio_preferences(client: TestClient, db, registered):
|
def test_saving_audio_preferences(client: TestClient, db, registered):
|
||||||
client.post(
|
client.post(
|
||||||
"/api/preferences/audio",
|
"/api/preferences/audio",
|
||||||
|
|||||||
Reference in New Issue
Block a user