From 2a71466670cadcce9f2c531ea54dfdbd82e8454e Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Sun, 12 Jul 2026 13:01:45 +0300 Subject: [PATCH] Refine client panel layout and bump Harbor versions --- docs/design/responsive-layout.md | 21 ++++++ src/shared/versions.js | 4 +- src/web/styles.css | 79 ++++++++++++++++++--- test/web/responsive-layout-contract.test.js | 62 ++++++++++++++++ 4 files changed, 154 insertions(+), 12 deletions(-) create mode 100644 docs/design/responsive-layout.md create mode 100644 test/web/responsive-layout-contract.test.js diff --git a/docs/design/responsive-layout.md b/docs/design/responsive-layout.md new file mode 100644 index 0000000..cc3811f --- /dev/null +++ b/docs/design/responsive-layout.md @@ -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. diff --git a/src/shared/versions.js b/src/shared/versions.js index af38c95..340de35 100644 --- a/src/shared/versions.js +++ b/src/shared/versions.js @@ -1,6 +1,6 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.7.8', - gatewayClient: '0.7.8', + macClient: '0.7.9', + gatewayClient: '0.7.9', gatewayBackend: '0.7.0', }); diff --git a/src/web/styles.css b/src/web/styles.css index c6a926b..dfd3510 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -1630,14 +1630,20 @@ p { .client-panel { position: relative; display: grid; + grid-template-columns: minmax(0, 1fr) minmax(240px, 280px) minmax(0, 1fr); place-items: center; + width: 100%; min-height: 520px; padding: 24px 0; } +.client-panel.is-setup { + grid-template-columns: minmax(0, 1fr); +} + .client-panel.is-setup .client-form { - left: 50%; - transform: translateX(-50%); + grid-column: 1; + justify-self: center; } .client-power-section p { @@ -1647,6 +1653,7 @@ p { .client-power-section { position: relative; + grid-column: 2; display: grid; justify-items: center; gap: 18px; @@ -1922,7 +1929,7 @@ p { } .client-duration-toggle { - width: 290px; + width: min(290px, 100%); min-height: 24px; display: grid; place-items: center; @@ -1999,14 +2006,20 @@ p { } .client-form { - position: absolute; - left: calc(50% + 130px); + position: static; + grid-column: 3; + align-self: center; + justify-self: start; 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; gap: 36px; - transform: translateX(0); - 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); + transition: opacity 650ms cubic-bezier(0.16, 1, 0.3, 1), filter 650ms cubic-bezier(0.16, 1, 0.3, 1); } .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) { .client-local-rule { grid-template-columns: 24px 104px minmax(0, 1fr) 28px; @@ -2955,7 +3014,7 @@ p { grid-template-columns: 1fr; gap: 26px; min-height: 0; - padding: 20px 6px; + padding: 96px 6px 72px; } .client-power-section { @@ -2972,7 +3031,7 @@ p { } .harbor-brand { - transform: translate(-50%, calc(-50% - 28vh)) scale(2.15); + transform: translateX(-50%) scale(1.35); } .client-instructions-toggle { diff --git a/test/web/responsive-layout-contract.test.js b/test/web/responsive-layout-contract.test.js new file mode 100644 index 0000000..9b7f5b8 --- /dev/null +++ b/test/web/responsive-layout-contract.test.js @@ -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/); +});