Refine client panel layout and bump Harbor versions
This commit is contained in:
21
docs/design/responsive-layout.md
Normal file
21
docs/design/responsive-layout.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Harbor responsive layout
|
||||||
|
|
||||||
|
The client page has three stable regions: the Harbor brand, the primary power control and the subscription/details form.
|
||||||
|
|
||||||
|
## Desktop
|
||||||
|
|
||||||
|
At widths above 920 px the main panel uses a symmetric three-column grid. Equal outer columns keep the power control on the exact horizontal center axis; the details form occupies the right column. The form has a viewport-relative maximum height and its own vertical scroll for unusually long content, so a large server list cannot move the power control away from the visual center.
|
||||||
|
|
||||||
|
The no-subscription setup state collapses the panel to one column and centers the form. No `left` offset or translated absolute element participates in either layout.
|
||||||
|
|
||||||
|
## Tablet and mobile
|
||||||
|
|
||||||
|
At 920 px and below all main regions use one normal-flow grid column. The brand becomes an absolute header inside the page shell, while reserved top padding prevents it from overlapping the primary content. Errors become normal-flow rows instead of floating over nearby controls. Form width is capped by both the available space and a readable maximum.
|
||||||
|
|
||||||
|
At 560 px and below spacing and drawer padding become more compact. Controls with a preferred fixed size, such as the duration switch, use `min(..., 100%)` so the primary flow remains available at 320 px.
|
||||||
|
|
||||||
|
## Motion and overflow contract
|
||||||
|
|
||||||
|
Layout properties are not animated. State feedback may animate opacity and blur, and the existing `prefers-reduced-motion` rules disable those transitions and animations. Drawers are capped at `100vw`; dialogs and inline content retain viewport-relative width limits.
|
||||||
|
|
||||||
|
Automated source-contract tests protect the symmetric desktop grid, normal-flow narrow layout, viewport-safe control widths and reduced-motion fallback. Release acceptance still includes a rendered check at 390, 768 and 1440 px; TASK-017 will later make that browser matrix automatic in CI.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
export const HARBOR_VERSIONS = Object.freeze({
|
export const HARBOR_VERSIONS = Object.freeze({
|
||||||
macClient: '0.7.8',
|
macClient: '0.7.9',
|
||||||
gatewayClient: '0.7.8',
|
gatewayClient: '0.7.9',
|
||||||
gatewayBackend: '0.7.0',
|
gatewayBackend: '0.7.0',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1630,14 +1630,20 @@ p {
|
|||||||
.client-panel {
|
.client-panel {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(240px, 280px) minmax(0, 1fr);
|
||||||
place-items: center;
|
place-items: center;
|
||||||
|
width: 100%;
|
||||||
min-height: 520px;
|
min-height: 520px;
|
||||||
padding: 24px 0;
|
padding: 24px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-panel.is-setup {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
.client-panel.is-setup .client-form {
|
.client-panel.is-setup .client-form {
|
||||||
left: 50%;
|
grid-column: 1;
|
||||||
transform: translateX(-50%);
|
justify-self: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-power-section p {
|
.client-power-section p {
|
||||||
@@ -1647,6 +1653,7 @@ p {
|
|||||||
|
|
||||||
.client-power-section {
|
.client-power-section {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
grid-column: 2;
|
||||||
display: grid;
|
display: grid;
|
||||||
justify-items: center;
|
justify-items: center;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
@@ -1922,7 +1929,7 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.client-duration-toggle {
|
.client-duration-toggle {
|
||||||
width: 290px;
|
width: min(290px, 100%);
|
||||||
min-height: 24px;
|
min-height: 24px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
@@ -1999,14 +2006,20 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.client-form {
|
.client-form {
|
||||||
position: absolute;
|
position: static;
|
||||||
left: calc(50% + 130px);
|
grid-column: 3;
|
||||||
|
align-self: center;
|
||||||
|
justify-self: start;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
width: min(360px, calc(50% - 154px));
|
width: min(360px, 100%);
|
||||||
|
max-height: min(640px, calc(100vh - 120px));
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(125, 249, 255, 0.34) transparent;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 36px;
|
gap: 36px;
|
||||||
transform: translateX(0);
|
transition: opacity 650ms cubic-bezier(0.16, 1, 0.3, 1), filter 650ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
transition: left 850ms cubic-bezier(0.16, 1, 0.3, 1), transform 850ms cubic-bezier(0.16, 1, 0.3, 1), width 850ms cubic-bezier(0.16, 1, 0.3, 1), opacity 650ms cubic-bezier(0.16, 1, 0.3, 1), filter 650ms cubic-bezier(0.16, 1, 0.3, 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-form-content {
|
.client-form-content {
|
||||||
@@ -2926,6 +2939,52 @@ p {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 920px) {
|
||||||
|
.client-mode .app-main {
|
||||||
|
place-items: start center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-shell {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-panel,
|
||||||
|
.client-panel.is-setup {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
gap: 40px;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 110px 6px 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-power-section,
|
||||||
|
.client-form,
|
||||||
|
.client-panel.is-setup .client-form {
|
||||||
|
grid-column: 1;
|
||||||
|
justify-self: center;
|
||||||
|
width: min(100%, 380px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-form {
|
||||||
|
max-height: none;
|
||||||
|
overflow-y: visible;
|
||||||
|
overscroll-behavior: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.harbor-brand,
|
||||||
|
.client-shell.is-first-run .harbor-brand {
|
||||||
|
position: absolute;
|
||||||
|
top: 28px;
|
||||||
|
transform: translateX(-50%) scale(1.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-inline-error.is-connection,
|
||||||
|
.client-inline-error.is-subscription {
|
||||||
|
position: static;
|
||||||
|
width: min(100%, 360px);
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
.client-local-rule {
|
.client-local-rule {
|
||||||
grid-template-columns: 24px 104px minmax(0, 1fr) 28px;
|
grid-template-columns: 24px 104px minmax(0, 1fr) 28px;
|
||||||
@@ -2955,7 +3014,7 @@ p {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 26px;
|
gap: 26px;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 20px 6px;
|
padding: 96px 6px 72px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-power-section {
|
.client-power-section {
|
||||||
@@ -2972,7 +3031,7 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.harbor-brand {
|
.harbor-brand {
|
||||||
transform: translate(-50%, calc(-50% - 28vh)) scale(2.15);
|
transform: translateX(-50%) scale(1.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-instructions-toggle {
|
.client-instructions-toggle {
|
||||||
|
|||||||
62
test/web/responsive-layout-contract.test.js
Normal file
62
test/web/responsive-layout-contract.test.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
const root = path.resolve(import.meta.dirname, '../..');
|
||||||
|
const styles = fs.readFileSync(path.join(root, 'src/web/styles.css'), 'utf8');
|
||||||
|
|
||||||
|
function rule(selector, source = styles) {
|
||||||
|
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
return new RegExp(`^${escaped}\\s*\\{([\\s\\S]*?)\\n\\}`, 'm').exec(source)?.[1] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
test('desktop layout keeps the power control on a symmetric center axis', () => {
|
||||||
|
const panel = rule('.client-panel');
|
||||||
|
const power = rule('.client-power-section');
|
||||||
|
const form = rule('.client-form');
|
||||||
|
|
||||||
|
assert.match(panel, /grid-template-columns:\s*minmax\(0, 1fr\) minmax\(240px, 280px\) minmax\(0, 1fr\)/);
|
||||||
|
assert.match(power, /grid-column:\s*2/);
|
||||||
|
assert.match(form, /grid-column:\s*3/);
|
||||||
|
assert.match(form, /position:\s*static/);
|
||||||
|
assert.doesNotMatch(form, /\bleft\s*:|translateX|transition:[^;]*(?:left|width|transform)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tablet and mobile regions use normal flow with viewport-safe widths', () => {
|
||||||
|
const responsive = /@media \(max-width: 920px\) \{([\s\S]*?)\n\}\n\n@media \(max-width: 560px\)/.exec(styles)?.[1] || '';
|
||||||
|
|
||||||
|
assert.match(responsive, /grid-template-columns:\s*minmax\(0, 1fr\)/);
|
||||||
|
assert.match(responsive, /\.client-power-section,[\s\S]*\.client-form,[\s\S]*grid-column:\s*1/);
|
||||||
|
assert.match(responsive, /width:\s*min\(100%, 380px\)/);
|
||||||
|
assert.match(responsive, /max-height:\s*none/);
|
||||||
|
assert.match(responsive, /overflow-y:\s*visible/);
|
||||||
|
assert.match(responsive, /\.harbor-brand,[\s\S]*position:\s*absolute/);
|
||||||
|
assert.match(responsive, /\.client-inline-error\.is-connection,[\s\S]*position:\s*static/);
|
||||||
|
|
||||||
|
assert.match(rule('.client-duration-toggle'), /width:\s*min\(290px, 100%\)/);
|
||||||
|
assert.match(styles, /\.client-instructions\s*\{[\s\S]*width:\s*min\(470px, 100vw\)/);
|
||||||
|
assert.match(styles, /\.client-local-rules\s*\{[\s\S]*width:\s*min\(480px, 100vw\)/);
|
||||||
|
assert.match(styles, /\.client-confirmation-dialog\s*\{[\s\S]*width:\s*min\(430px, calc\(100vw - 48px\)\)/);
|
||||||
|
|
||||||
|
for (const viewport of [320, 390, 768]) {
|
||||||
|
const pagePadding = viewport <= 560 ? 28 : 48;
|
||||||
|
const panelPadding = 12;
|
||||||
|
const available = viewport - pagePadding - panelPadding;
|
||||||
|
assert.ok(Math.min(380, available) <= viewport, `form fits ${viewport}px viewport`);
|
||||||
|
assert.ok(Math.min(290, available) <= viewport, `duration control fits ${viewport}px viewport`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const desktopOuterColumn = (1100 - 280) / 2;
|
||||||
|
assert.ok(Math.min(360, desktopOuterColumn) <= desktopOuterColumn, 'details fit the 1440px desktop grid');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('responsive motion has a reduced-motion fallback', () => {
|
||||||
|
const reducedMotion = /@media \(prefers-reduced-motion: reduce\) \{([\s\S]*)\n\}/.exec(styles)?.[1] || '';
|
||||||
|
|
||||||
|
assert.match(reducedMotion, /\.client-power-section/);
|
||||||
|
assert.match(reducedMotion, /\.client-form/);
|
||||||
|
assert.match(reducedMotion, /\.harbor-brand/);
|
||||||
|
assert.match(reducedMotion, /transition:\s*none/);
|
||||||
|
assert.match(reducedMotion, /animation:\s*none/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user