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 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`` - : 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 @@
${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`` : menuSlot()}
+
${action}${!url && here && !here.local ? html`` : ''}
${connectRow(s)} `; @@ -1069,7 +1068,7 @@ return html`
T3 Connect${badge(view.badge)}
${view.text}
-
${view.action ? html`` : ''}${menu.length ? html`` : menuSlot()}
+
${view.action ? html`` : ''}${menu.length ? html`` : ''}
`; }; @@ -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`${menuSlot()}`; + actions = html``; } else if (p.state === 'failed') { title = html`${title}Failed`; status = statusLine({ dot: 'danger', text: p.error || 'The tunnel failed' }); - actions = html`${menuSlot()}`; + actions = html``; } 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`${menuSlot()}`; + actions = html``; } return html`
@@ -1341,7 +1340,7 @@ ${keyView.value ? html`` : html``} - ${source === 'env' ? menuSlot() : html``} + ${source === 'env' ? '' : html``}
`; }; @@ -1437,8 +1436,8 @@ ${s.setupKeySource ? keyRow(s.setupKeySource) : ''} ${viaT3(s) ? html`
Signed in through T3 CodeThis 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`
Lock this browserEnds this browser’s session here. Coming back takes the setup key.
${menuSlot()}
`} -
Turn the console offSet 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`
Lock this browserEnds this browser’s session here. Coming back takes the setup key.
`} +
Turn the console offSet 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`` : ''}${menuSlot()}
+
${view.available || view.error ? html`` : ''}
Release channel${view.channel === 'nightly' ? 'New features first. Nightly builds can be less reliable.' : 'Published releases, for everyday work.'}
-
${['stable', 'nightly'].map((channel) => html``)}
${menuSlot()}
+
${['stable', 'nightly'].map((channel) => html``)}
${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`` : ''} - ${menuSlot()} +
`; }; @@ -1584,7 +1583,7 @@
${row.mode && !row.error ? html`` : ''} - ${menuSlot()} +
`; }; 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) => `
${title}
${text}
`; const section = (id, title, row) => `

${title}

${row}
`; const provider = (update) => `
Provider updatesAuto${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; +}).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;