diff --git a/docker/setup/app.js b/docker/setup/app.js
index ea8dcfd..0dced3e 100644
--- a/docker/setup/app.js
+++ b/docker/setup/app.js
@@ -564,11 +564,10 @@
const variant = opts.plain ? null : a.variant === 'primary' && opts.primary === false ? null : a.variant;
return html`${a.icon ? icon(a.icon) : ''}${a.label} `;
};
- // A row without a menu keeps its place, so every row's one action lines up.
- const menuSlot = () => html``;
+ // Only real controls occupy the action group; its last control uses the row inset.
const menuButton = (row) => row.menu.length
? html`${icon('ellipsis')} `
- : menuSlot();
+ : '';
/** An agent or toolchain row: tile, name and version, one status line, one verb, a menu. */
const resourceRow = (row, opts = {}) => {
@@ -584,7 +583,7 @@
${noticeError ? html`${dot('danger')}${noticeError.text}${detail('notice')} ` : noticeLine(row.notice)}
${row.state === 'running' ? opLine(row) : ''}
-
${actionButton(row, opts)}${opts.menu === false ? menuSlot() : menuButton(row)}
+ ${actionButton(row, opts)}${opts.menu === false ? '' : menuButton(row)}
`;
};
@@ -744,7 +743,7 @@
${icon(item.icon)}
${item.name} ${item.text}
-
${itemAction(item.action)}${menuSlot()}
+
${itemAction(item.action)}
`;
// On a phone each item's one action goes under its words, so they read whole.
const attentionRow = (item) => (item.icon ? itemRow(item) : resourceRow(item, { plain: true, menu: false, wrap: true }));
@@ -1020,7 +1019,7 @@
${url ? html`${url} ${copyButton(url, 'Copy public URL', { iconOnly: true })}${source && source !== 'set here' ? html`${source === 'T3_PUBLIC_URL' ? 'from T3_PUBLIC_URL' : source} ` : ''} `
: html`${dot('warn')}Not set. Pairing links point at it, so they need one${here && !here.local ? html`: this page is open at ${M.hostOf(here.url)}, and T3 Code answers there too` : ''}. `}
- ${action}${!url && here && !here.local ? html`${icon('ellipsis')} ` : menuSlot()}
+ ${action}${!url && here && !here.local ? html`${icon('ellipsis')} ` : ''}
${connectRow(s)}
`;
@@ -1069,7 +1068,7 @@
return html`
${icon('globe')}
T3 Connect ${badge(view.badge)}
${view.text}
-
${view.action ? html`${view.action.icon ? icon(view.action.icon) : ''}${view.action.label} ` : ''}${menu.length ? html`${icon('ellipsis')} ` : menuSlot()}
+
${view.action ? html`${view.action.icon ? icon(view.action.icon) : ''}${view.action.label} ` : ''}${menu.length ? html`${icon('ellipsis')} ` : ''}
`;
};
@@ -1243,16 +1242,16 @@
} else if (p.state === 'starting') {
title = html`${title} Publishing `;
status = html`${joinParts([proc, 'waiting for a public hostname…'])} ${progressBar(null)}`;
- actions = html`Cancel ${menuSlot()}`;
+ actions = html`Cancel `;
} else if (p.state === 'failed') {
title = html`${title}Failed `;
status = statusLine({ dot: 'danger', text: p.error || 'The tunnel failed' });
- actions = html`${icon('refresh-cw')}Retry ${menuSlot()}`;
+ actions = html`${icon('refresh-cw')}Retry `;
} else {
status = p.db
? html`${dot('warn')}${joinParts([proc, 'looks like a database, so Publish asks first'])} `
: html`${proc || where ? joinParts([proc, where]) : p.listening ? 'Listening in the container' : 'Not listening right now'} `;
- actions = html`${p.db ? 'Publish…' : 'Publish'} ${menuSlot()}`;
+ actions = html`${p.db ? 'Publish…' : 'Publish'} `;
}
return html`
@@ -1341,7 +1340,7 @@
${keyView.value
? html`${icon('eye-off')}Hide `
: html`${icon('eye')}Show `}
- ${source === 'env' ? menuSlot() : html`${icon('ellipsis')} `}
+ ${source === 'env' ? '' : html`${icon('ellipsis')} `}
`;
};
@@ -1437,8 +1436,8 @@
${s.setupKeySource ? keyRow(s.setupKeySource) : ''}
${viaT3(s)
? html`${icon('shield-check')} Signed in through T3 Code This browser is paired with T3 Code, which already gives it a terminal here, so the console didn’t ask for the key. Revoking it under Access ends both.
`
- : html`${icon('lock')} Lock this browser Ends this browser’s session here. Coming back takes the setup key.
Lock ${menuSlot()}
`}
- ${icon('circle-stop')} Turn the console off Set T3_SETUP_ENABLED=0 once you’re set up. Pairing then takes t3-pair in a shell.
${copyButton('T3_SETUP_ENABLED=0', 'Copy setting', { ghost: false, size: 'tc-btn--sm' })}${menuSlot()}
+ : html`${icon('lock')} Lock this browser Ends this browser’s session here. Coming back takes the setup key.
Lock
`}
+ ${icon('circle-stop')} Turn the console off Set T3_SETUP_ENABLED=0 once you’re set up. Pairing then takes t3-pair in a shell.
${copyButton('T3_SETUP_ENABLED=0', 'Copy setting', { ghost: false, size: 'tc-btn--sm' })}
`)}`;
};
@@ -1466,11 +1465,11 @@
T3 Code ${view.version || 'unknown version'}${targetVersion ? html` → ${targetVersion}` : ''} ${busy ? html` ${view.busy ? 'Updating' : 'Checking'} ` : ''}
${view.available || view.busy ? '' : view.checkError ? dot('warn') : ''}${state} ${view.checkedAt && !busy ? ' · checked ' + M.relTime(view.checkedAt, now()) : ''}
- ${view.available || view.error ? html`${view.error ? 'Retry update' : 'Update and restart'} ` : ''}${menuSlot()}
+ ${view.available || view.error ? html`${view.error ? 'Retry update' : 'Update and restart'} ` : ''}
Release channel ${view.channel === 'nightly' ? 'New features first. Nightly builds can be less reliable.' : 'Published releases, for everyday work.'}
-
${['stable', 'nightly'].map((channel) => html`${channel === 'stable' ? icon('shield-check') : icon('moon')}${channel === 'stable' ? 'Stable' : 'Nightly'} `)}
${menuSlot()}
+
${['stable', 'nightly'].map((channel) => html`${channel === 'stable' ? icon('shield-check') : icon('moon')}${channel === 'stable' ? 'Stable' : 'Nightly'} `)}
${view.error || view.checkError ? html`
${view.error ? notice('danger', 'circle-alert', 'The last T3 Code update didn’t finish', view.error) : ''}
@@ -1499,7 +1498,7 @@
${label} ${auto.schedule[kind]}${on && auto.lastChecked[kind] ? ' ' + auto.lastChecked[kind] : ''}
${on ? html`Schedule ` : ''}
- ${menuSlot()}
+
`;
};
@@ -1584,7 +1583,7 @@
${row.mode && !row.error ? html`${icon('pencil')}Edit ` : ''}
- ${options.map(([mode, label, off]) => html`${label}${off ? ' (write it first)' : ''} `)} ${menuSlot()}
+ ${options.map(([mode, label, off]) => html`${label}${off ? ' (write it first)' : ''} `)}
`;
};
diff --git a/docker/setup/console.css b/docker/setup/console.css
index 9579950..489c4fa 100644
--- a/docker/setup/console.css
+++ b/docker/setup/console.css
@@ -121,9 +121,6 @@ html.tc-locked { overflow: hidden; }
/* Names and values that must not part at a hyphen. */
.tc-nowrap { white-space: nowrap; }
-/* A row without a menu keeps the menu's place, so actions line up down a list. */
-.tc-row-menu-slot { display: block; width: var(--control-sm); height: 1px; flex: none; }
-.tc-root[data-density="touch"] .tc-row-menu-slot { width: 32px; }
/* A port's tile says whether it is out on the internet. */
.tc-tile--port-state[data-live] { color: var(--info-text); }
@@ -157,7 +154,6 @@ html.tc-locked { overflow: hidden; }
@media (max-width: 639.98px) {
.tc-row:has(> .tc-row-actions--wrap) { grid-template-columns: auto minmax(0, 1fr); }
.tc-row > .tc-row-actions--wrap { grid-column: 2 / -1; justify-content: flex-start; }
- .tc-row-actions--wrap > .tc-row-menu-slot { display: none; }
/* Only rows without a leading tile put actions under the whole main block.
A stale plain modifier must not move an icon row into a single column. */
.tc-row--plain:not(:has(> .tc-tile, > .tc-row-lead)):has(> .tc-row-actions--wrap) { grid-template-columns: minmax(0, 1fr); }
@@ -590,7 +586,6 @@ html.tc-locked { overflow: hidden; }
@media (max-width: 639.98px) {
.tc-runtime-channel { grid-template-columns: minmax(0, 1fr); }
.tc-runtime-channel-actions { width: 100%; }
- .tc-runtime-channel-actions > .tc-row-menu-slot { display: none; }
.tc-runtime-channel .tc-seg { display: flex; flex: 1 1 auto; }
.tc-runtime-channel .tc-seg button { flex: 1; min-height: 32px; justify-content: center; }
}
diff --git a/docker/setup/design/README.md b/docker/setup/design/README.md
index ac362a7..1671992 100644
--- a/docker/setup/design/README.md
+++ b/docker/setup/design/README.md
@@ -48,6 +48,10 @@ Each of these is a bug in the handoff, not a restyle. Keep them when syncing.
The dark input rule sets `background-color` so it preserves `.tc-select`'s
chevron. In forced colours, selects use their native indicator and a system
focus outline, since that mode removes gradients and shadows.
+6. **The enabled switch thumb touched its track's right edge.** Its travel
+ omitted the 2px end inset. Translating by `--_t - 4px` keeps the same 2px
+ clearance above, below, and at the resting end in both states and sizes.
+ `scripts/row-ui-audit.js` measures the rendered thumb in each variant.
## Local additions
diff --git a/docker/setup/design/components.css b/docker/setup/design/components.css
index 51ad5e4..0ddbf6e 100644
--- a/docker/setup/design/components.css
+++ b/docker/setup/design/components.css
@@ -207,7 +207,7 @@
.tc-switch::after { content: ""; position: absolute; top: 2px; left: 2px; width: calc(var(--_t) - 2px); height: calc(var(--_t) - 2px); border-radius: 50%; background: #fff;
box-shadow: 0 1px 2px rgb(0 0 0 / 0.1); transition: translate 150ms; }
.tc-switch[aria-checked="true"] { background: var(--accent); }
-.tc-switch[aria-checked="true"]::after { translate: calc(var(--_t) - 2px) 0; }
+.tc-switch[aria-checked="true"]::after { translate: calc(var(--_t) - 4px) 0; }
/* listbox */
.tc-listbox { list-style: none; margin: 0; padding: 4px var(--space-2); display: flex; flex-direction: column; gap: 1px; max-height: 280px; overflow: auto; }
diff --git a/docs/media/ports-panel.png b/docs/media/ports-panel.png
index 9014d60..e9bc755 100644
Binary files a/docs/media/ports-panel.png and b/docs/media/ports-panel.png differ
diff --git a/docs/media/setup-dark.png b/docs/media/setup-dark.png
index f68d9af..a3f7161 100644
Binary files a/docs/media/setup-dark.png and b/docs/media/setup-dark.png differ
diff --git a/docs/media/setup-light.png b/docs/media/setup-light.png
index 84997b6..c145f50 100644
Binary files a/docs/media/setup-light.png and b/docs/media/setup-light.png differ
diff --git a/scripts/row-ui-audit.js b/scripts/row-ui-audit.js
index 0162014..f928b16 100644
--- a/scripts/row-ui-audit.js
+++ b/scripts/row-ui-audit.js
@@ -17,6 +17,10 @@ const actions = (update) => `${
const main = (title, text) => `
`;
const section = (id, title, row) => `
`;
const provider = (update) => `
${icon} Provider updates Auto ${update ? "3 available" : "Up to date"}
Providers not checked yet · On · check every 3h · update after 30m idle ${actions(update)}
`;
+const switches = (disabled) => [false, true].map((large) => [false, true].map((on) => {
+ const control = `
`;
+ return large ? `
${control} ` : control;
+}).join("")).join("");
const fixtures = [
section("reported-row", "Updates", provider(false)),
section("available-row", "Updates available", provider(true)),
@@ -25,6 +29,7 @@ const fixtures = [
section("connect-row", "With an icon", `
${icon} ${main("T3 Connect", "Connect this server to reach it from your other devices.")}
${button("Connect")}
`),
section("plain-single", "Single action", `
${main("Lock console", "Ends this browser’s session. You will need the setup key to come back.")}${button("Lock")}
`),
section("plain-empty", "Without an action", `
${main("Signed in through T3 Code", "This browser is paired with T3 Code.")}
`),
+ ...[false, true].map((disabled) => section(`switches-${disabled}`, "Switches", `
${main(disabled ? "Disabled" : "Enabled", "Both sizes and states.")}
${switches(disabled)}
`)),
].join("");
const geometry = () => {
@@ -53,6 +58,19 @@ const geometry = () => {
if (r.left < parent.left || r.right > parent.right || r.bottom > parent.bottom) issues.push(`${id}: content outside row`);
}
}
+ for (const control of document.querySelectorAll(".tc-switch")) {
+ const track = control.getBoundingClientRect(), thumb = getComputedStyle(control, "::after");
+ const [x, y = 0] = thumb.translate === "none" ? [0, 0] : thumb.translate.split(/\s+/).map(parseFloat);
+ const width = parseFloat(thumb.width), height = parseFloat(thumb.height);
+ const left = parseFloat(thumb.left) + x, top = parseFloat(thumb.top) + y;
+ const right = track.width - left - width, bottom = track.height - top - height;
+ const end = control.getAttribute("aria-checked") === "true" ? right : left;
+ if (![width, height, left, top, right, bottom].every(Number.isFinite)
+ || Math.min(left, top, right, bottom) < 0
+ || Math.abs(width - height) > 0.5 || Math.abs(top - bottom) > 0.5 || Math.abs(end - top) > 0.5) {
+ issues.push(`${control.getAttribute("aria-label")}: switch thumb insets must balance (left ${left}, right ${right}, top ${top}, bottom ${bottom})`);
+ }
+ }
return issues;
};
@@ -81,7 +99,7 @@ const geometry = () => {
await frame.evaluate(() => document.fonts.ready);
const issues = await frame.evaluate(geometry);
if (issues.length) failures.push({ tag, issues });
- else console.log(`PASS ${tag}: icon/text alignment, action placement, containment (7 row variants)`);
+ else console.log(`PASS ${tag}: icon/text alignment, action placement, containment (9 row variants), balanced switch thumbs (8 variants)`);
await context.close();
}
} finally { await browser.close(); }
diff --git a/scripts/ui-audit.js b/scripts/ui-audit.js
index 71411d2..ab79308 100755
--- a/scripts/ui-audit.js
+++ b/scripts/ui-audit.js
@@ -286,6 +286,18 @@ const audit = () => {
if (Math.abs(box(main).left - left) > 1) {
add("detached-row-text", "text does not follow the leading icon or row padding", main);
}
+ // The final control ends at the row's normal inset. Reserving a menu's
+ // width when there is no menu leaves an unexplained blank strip beside it.
+ // Phone rows that put actions below the text are intentionally left aligned.
+ const actions = row.querySelector(":scope > .tc-row-actions");
+ if (!actions || !visible(actions) || getComputedStyle(actions).justifyContent !== "flex-end") continue;
+ const controls = [...actions.children].filter((el) => visible(el) && el.matches("button, a, input, select, label, [role=group]"));
+ if (!controls.length) continue;
+ const right = Math.max(...controls.map((el) => box(el).right));
+ const inset = box(row).right - parseFloat(style.borderRightWidth) - parseFloat(style.paddingRight);
+ if (Math.abs(right - inset) > 1) {
+ add("trailing-row-space", `final control is ${(inset - right).toFixed(2)}px from the row's right inset`, actions);
+ }
}
return findings;