Files
yellowjacket/frontend/src/components/config-page/config-field.ts
T
logan 2100f0022f fix(settings): raise every Settings control to the touch floor
#56 named 44px and #195 took the page header there. Settings is the
other half of #186 and much the larger one: swept on the reference
device (TLP301, 424x439) with all eleven config-sections expanded,
**120 controls** were under the floor -- not the 93 the issue's table
implies, and config-field is eight of them.

The bulk is behind the disclosures, which is why nobody had counted it:

    36  .column-arrow-btn          16x14   <- smallest in the app
    29  .column-toggle             16x16
    26  shortcut-capture button    80x25
     8  download format checkbox   16x16
     7  config-field select        335x30
     6  wa-input / wa-button       204x20, 185x21

**The density argument, measured rather than guessed, and it is
smaller than it looks.** The rows were already near the floor --
.column-item is 335x36 and .shortcut-row 335x37; it is the controls
*inside* them that were 14-25px. So a control grows into the row it
already occupies and the row goes 36 to 44. Measured after: the two
column lists went 373->447 and 690->850, +234px over the whole page.
Half a screen of extra scroll on a page that already scrolls, against
36 targets of 16x14.

**Settings is cheaper than the header was, and for a stated reason.**
There is no overflow fit on this page, so the header's "only width is
contested" rule does not bind at all and nothing here needs padding
with a negative margin. Height is a min-size, and the two square
controls can simply be square.

Three shapes, because one rule does not fit three kinds of control:

**A native checkbox is targeted through its label.** It cannot grow
its hit area without growing its paint, and a 44px checkbox is not
what anyone wants -- so .column-label is a real <label for> now and
the column's *name* is the target, 70x44 rather than 16x16. That is
the argument config-field already makes one file over ("a real label
association also makes the label text a click target, which is
behaviour, not annotation"), and here it is the whole fix. The
download formats already had the label; they only needed the height.

**The arrows take padding, which is invisible.** They carry
background: none and a transparent border, so 16x14 -> 44x44 changes
nothing anyone can see until hover -- #186's Direction exactly.

**Web Awesome's controls come from the library's own API.** Their
height is decided inside somebody else's shadow root, and
--wa-form-control-height is the variable that decides it. A custom
property inherits through a shadow boundary, so a :host declaration
reaches them; styles/wa-touch-floor.css.ts is that, once, adopted
rather than written at :root in index.css -- a :root rule would be
invisible to the component tier, which renders a component and no page
stylesheet.

**Two controls no sweep can see are fixed by name**, and they are the
trap this issue keeps setting. config-field's toggle has an <input>
that is opacity: 0; width: 0; height: 0, so a walk of every input
skips it as a zero-sized node -- what a finger hits is the <label>,
which measured **34x19**, smaller than anything in either of #186's
tables and absent from both. It is 44x44 with the pill still painted
at 2.5em x 1.4em and negative inline margins keeping it flush with the
inputs above. And shortcut-capture's reset button renders only for a
shortcut somebody has rebound, so a sweep of a fresh install never
meets it.

Verified on the device, same method as the sweep that filed it:
120 controls under the floor before, 42 after. All 42 are accounted
for -- 37 are checkboxes whose labels measure 70x44 and 57x44, four
are wa-input's inner input at 204x**42**, which is the control
measured *inside* its own 1px border (part=base is 238x44), and one is
the skip link, which #186 already ruled out as keyboard-only.

The e2e suite passes, top-bar-fit and header-action-overflow included
-- but that is **chromium**, which is half an answer, and saying so is
the whole of what #195's second commit was about. What can be argued
rather than run: library-filter is the only thing here in a container
that measures itself, and its width did not change. The fit measures
inline size.

Two page-header screenshots are refreshed because they are this
issue's own debris -- #195's taller sort control, merged last session,
with its references never re-recorded. app-sidebar's and
now-playing's are deliberately left: they are unrelated drift, and
blessing an unrelated screenshot is how the sidebar reference came to
still list a destination #27 retired. That is #196.
2026-08-21 22:55:54 -04:00

483 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property } from 'lit/decorators.js';
/**
* The id every field's control carries, so its `<label>` can name it.
*
* The label was a *sibling* of the control with no `for`, which names
* nothing — so every select, toggle and text field in Settings computed
* an empty accessible name. Measured on the expanded Settings page:
* **24 of 93 controls unnamed**, six of them here and the rest the
* column toggles in `config-page`. `a11y.6` is not wrong about this;
* it says in the same line that it scanned every `<button>`, and none
* of these is one.
*
* A fixed id is safe, and only because each `config-field` is its own
* shadow root — the whole page renders a dozen elements with
* `id="control"` and each `for` resolves within its own root. It is
* preferred over `aria-label` for what it buys beyond the name: a real
* label association also makes the label text a click target for the
* control, which is behaviour, not annotation.
*/
const CONTROL_ID = 'control';
/**
* Schema describing a single config field.
*
* The `type` controls which input widget is rendered:
* - `text` plain text input
* - `select` dropdown with `options`
* - `color` colour picker swatch
* - `directory` readonly text + browse button
* - `number` numeric input
* - `toggle` on/off switch
*/
export interface ConfigFieldSchema {
key: string;
label: string;
description?: string;
type:
| 'text'
| 'select'
| 'color'
| 'directory'
| 'number'
| 'toggle';
options?: { value: string; label: string }[];
disabled?: boolean;
}
export interface ConfigFieldChangeEvent {
key: string;
value: unknown;
}
@customElement('config-field')
export class ConfigField extends LitElement {
static override styles = css`
:host {
display: block;
margin-bottom: 1em;
color-scheme: inherit;
}
.field {
display: flex;
flex-direction: column;
gap: 0.35em;
}
label {
font-weight: 600;
font-size: 0.85em;
color: var(--yj-text-primary, #fff);
}
.description {
font-size: 0.75em;
color: var(--yj-text-tertiary, #888);
margin: 0;
}
.input-row {
display: flex;
align-items: center;
gap: 0.5em;
}
/* Every control here meets the app's 44px touch floor (#186).
This is the shape every row in Settings uses, so it is the
one rule that covers the most controls -- and it is the
*cheapest* place to reach the floor, because there is no
overflow fit on this page. The page header's had one (#69),
which is why that pass had to grow padding and hand the
width back with a negative margin; here the control is a
block in a column and a taller box costs nothing but the
height it takes.
Measured on the reference device before this: the select
335x30, the text and number inputs the same, the browse
button 30 tall, the colour swatch 33x33 and the toggle
**34x19**. */
input[type='text'],
input[type='number'] {
background: var(--yj-bg-elevated, #343a40);
color: var(--yj-text-primary, #fff);
border: 1px solid var(--yj-border-subtle, #333);
border-radius: 4px;
padding: 0.4em 0.6em;
font-size: 0.85em;
font-family: inherit;
min-width: 0;
min-block-size: 44px;
flex: 1;
}
input:focus {
outline: 1px solid var(--yj-accent, #ffd43b);
border-color: var(--yj-accent, #ffd43b);
}
input[readonly] {
opacity: 0.8;
cursor: default;
}
select {
background: var(--yj-bg-elevated, #343a40);
color: var(--yj-text-primary, #fff);
border: 1px solid var(--yj-border-subtle, #333);
border-radius: 4px;
padding: 0.4em 0.6em;
font-size: 0.85em;
font-family: inherit;
cursor: pointer;
min-block-size: 44px;
flex: 1;
}
select:focus {
outline: 1px solid var(--yj-accent, #ffd43b);
border-color: var(--yj-accent, #ffd43b);
}
select option {
background: var(--yj-bg-elevated, #343a40);
color: var(--yj-text-primary, #fff);
}
button {
background: var(--yj-info, #4263eb);
color: var(--yj-info-fg, #fff);
border: none;
border-radius: 4px;
padding: 0.4em 0.8em;
font-size: 0.85em;
cursor: pointer;
white-space: nowrap;
min-block-size: 44px;
}
button:hover {
background: var(--yj-info-hover, #3b5bdb);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Colour picker */
.color-wrapper {
display: flex;
align-items: center;
gap: 0.75em;
}
input[type='color'] {
/* border-box, or the 2px border makes this 48 and the
assertion below reads as passing by four pixels of
border rather than by the rule. */
box-sizing: border-box;
width: 44px;
height: 44px;
border: 2px solid var(--yj-border, #444);
border-radius: 4px;
padding: 0;
cursor: pointer;
background: none;
}
input[type='color']::-webkit-color-swatch-wrapper {
padding: 2px;
}
input[type='color']::-webkit-color-swatch {
border: none;
border-radius: 2px;
}
.color-hex {
font-family: monospace;
font-size: 0.85em;
color: var(--yj-text-secondary, #b3b3b3);
}
/* Toggle */
.toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
min-block-size: 44px;
}
/* The toggle is the one control here whose target and paint
must differ, and it is also the one no sweep can see.
Its <input> is opacity: 0; width: 0; height: 0, so a
walk of every input on the page skips it as a zero-sized
node -- the thing a finger actually hits is this <label>,
which measured **34x19**. That is smaller than anything in
#186's original table and it is absent from it for exactly
that reason.
A 44px pill is not what a switch should look like, so the
box is 44px and the paint is not: .toggle-slider is a
2.5em x 1.4em child centred in it rather than an absolute
fill. The negative inline margins hand the extra width back
to the layout, so the pill stays flush with the right edge
of the inputs in the rows above it -- the header pass's
shape, used here for alignment rather than for a fit. */
.toggle-switch {
display: grid;
place-items: center;
inline-size: 44px;
block-size: 44px;
margin-inline: calc((2.5em - 44px) / 2);
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: relative;
cursor: pointer;
inline-size: 2.5em;
block-size: 1.4em;
background: var(--yj-bg-overlay, #495057);
border-radius: 1em;
transition: background 0.2s;
}
.toggle-slider::before {
content: '';
position: absolute;
height: 1em;
width: 1em;
left: 0.2em;
bottom: 0.2em;
background: white;
border-radius: 50%;
transition: transform 0.2s;
}
.toggle-switch input:checked + .toggle-slider {
background: var(--yj-accent, #ffd43b);
}
.toggle-switch input:checked + .toggle-slider::before {
transform: translateX(1.1em);
}
`;
@property({ attribute: false })
schema!: ConfigFieldSchema;
@property({ attribute: false })
value: unknown = '';
override render() {
if (!this.schema) return nothing;
return html`
<div class="field">
${this.schema.type === 'toggle'
? this.renderToggle()
: html`
<label for=${CONTROL_ID}>
${this.schema.label}
</label>
${this.renderInput()}
`}
${this.schema.description
? html`<p class="description">
${this.schema.description}
</p>`
: nothing}
</div>
`;
}
private renderInput() {
switch (this.schema.type) {
case 'text':
return this.renderText();
case 'number':
return this.renderNumber();
case 'select':
return this.renderSelect();
case 'color':
return this.renderColor();
case 'directory':
return this.renderDirectory();
default:
return html`<p>Unsupported field type</p>`;
}
}
private renderText() {
return html`
<input
id=${CONTROL_ID}
type="text"
.value=${String(this.value ?? '')}
?disabled=${this.schema.disabled}
@change=${this.onTextChange}
/>
`;
}
private renderNumber() {
return html`
<input
id=${CONTROL_ID}
type="number"
.value=${String(this.value ?? '')}
?disabled=${this.schema.disabled}
@change=${this.onTextChange}
/>
`;
}
private renderSelect() {
const current = String(this.value ?? '');
return html`
<select
id=${CONTROL_ID}
?disabled=${this.schema.disabled}
@change=${this.onSelectChange}
>
${(this.schema.options ?? []).map(
(opt) => html`
<option
value=${opt.value}
?selected=${opt.value === current}
>
${opt.label}
</option>
`,
)}
</select>
`;
}
private renderColor() {
const hex = String(this.value ?? '#ffffff');
return html`
<div class="color-wrapper">
<input
id=${CONTROL_ID}
type="color"
.value=${hex}
?disabled=${this.schema.disabled}
@input=${this.onColorInput}
/>
<span class="color-hex">${hex}</span>
</div>
`;
}
private renderDirectory() {
return html`
<div class="input-row">
<input
id=${CONTROL_ID}
type="text"
.value=${String(this.value ?? '')}
readonly
/>
<button
aria-label="Browse for ${this.schema.label}"
?disabled=${this.schema.disabled}
@click=${this.onBrowseClick}
>
Browse
</button>
</div>
`;
}
private renderToggle() {
const checked = Boolean(this.value);
return html`
<div class="toggle-row">
<label for=${CONTROL_ID}>${this.schema.label}</label>
<label class="toggle-switch">
<input
id=${CONTROL_ID}
type="checkbox"
?checked=${checked}
?disabled=${this.schema.disabled}
@change=${this.onToggleChange}
/>
<span class="toggle-slider"></span>
</label>
</div>
`;
}
// ===================================================================
// EVENT DISPATCHERS
// ===================================================================
private emitChange(value: unknown): void {
this.dispatchEvent(
new CustomEvent<ConfigFieldChangeEvent>(
'config-change',
{
detail: {
key: this.schema.key,
value,
},
bubbles: true,
composed: true,
},
),
);
}
private onTextChange = (e: Event) => {
const input = e.target as HTMLInputElement;
this.emitChange(input.value);
};
private onSelectChange = (e: Event) => {
const select = e.target as HTMLSelectElement;
this.emitChange(select.value);
};
private onColorInput = (e: Event) => {
const input = e.target as HTMLInputElement;
this.value = input.value;
this.emitChange(input.value);
};
private onBrowseClick = () => {
this.dispatchEvent(
new CustomEvent('config-browse', {
detail: { key: this.schema.key },
bubbles: true,
composed: true,
}),
);
};
private onToggleChange = (e: Event) => {
const input = e.target as HTMLInputElement;
this.emitChange(input.checked);
};
}
declare global {
interface HTMLElementTagNameMap {
'config-field': ConfigField;
}
}