Refine subscription panel layout and feedback
Build and Deploy Gateway / build-and-push (push) Successful in 19s
Build and Deploy Gateway / deploy (push) Successful in 7s

This commit is contained in:
2026-08-11 07:47:28 +03:00
parent 5cad3e9061
commit 9a8191dc91
7 changed files with 56 additions and 134 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
export const HARBOR_VERSIONS = Object.freeze({ export const HARBOR_VERSIONS = Object.freeze({
macClient: '0.23.3', macClient: '0.23.4',
gatewayClient: '0.24.3', gatewayClient: '0.24.4',
gatewayBackend: '0.24.0', gatewayBackend: '0.24.0',
}); });
@@ -46,18 +46,6 @@ interface SubscriptionFeatureOptions {
onDismissError: () => void; onDismissError: () => void;
} }
function operationText(key: OperationKey) {
return ({
profileAdd: 'Добавляем подписку…',
profileRename: 'Переименовываем подписку…',
profileSelect: 'Сохраняем выбор сервера…',
profileActivate: 'Переключаем подписку…',
profileRefresh: 'Обновляем подписку…',
profileDelete: 'Удаляем подписку…',
serverApply: 'Применяем сервер…',
} as Partial<Record<OperationKey, string>>)[key] || '';
}
function profileServer(profile: ProfileSnapshot | undefined, serverId: string) { function profileServer(profile: ProfileSnapshot | undefined, serverId: string) {
return profile?.servers.find((server) => server.id === serverId) || null; return profile?.servers.find((server) => server.id === serverId) || null;
} }
@@ -99,11 +87,6 @@ export function useSubscriptionFeature({
: isSubscriptionUrlValid(normalizedUrl) ? 'valid' : 'invalid'; : isSubscriptionUrlValid(normalizedUrl) ? 'valid' : 'invalid';
const deleteProfile = profiles.find((profile) => profile.id === deleteId) || null; const deleteProfile = profiles.find((profile) => profile.id === deleteId) || null;
const deleteStopsVpn = connected && selection.appliedProfileId === deleteId; const deleteStopsVpn = connected && selection.appliedProfileId === deleteId;
const activeOperation = (Object.entries(operations) as Array<[
OperationKey,
OperationRegistrySnapshot[OperationKey],
]>).find(([, operation]) => operation?.status === 'running');
useEffect(() => { useEffect(() => {
const ids = new Set(profiles.map((profile) => profile.id)); const ids = new Set(profiles.map((profile) => profile.id));
setExpanded((current) => current.filter((id) => ids.has(id))); setExpanded((current) => current.filter((id) => ids.has(id)));
@@ -220,7 +203,6 @@ export function useSubscriptionFeature({
addError, addError,
deleteProfile, deleteProfile,
deleteStopsVpn, deleteStopsVpn,
activeOperation,
refreshingIds, refreshingIds,
revealVersions, revealVersions,
panelRef, panelRef,
@@ -301,9 +283,7 @@ function AddProfileForm({ feature }: { feature: SubscriptionFeatureController })
onChange={(event) => feature.setUrl(event.target.value)} onChange={(event) => feature.setUrl(event.target.value)}
/> />
</label> </label>
<div className="client-profile-form-status" role="status"> {message && <div className="client-profile-form-status" role="status">{message}</div>}
{message || '\u00a0'}
</div>
<div className="client-profile-form-actions"> <div className="client-profile-form-actions">
{feature.profiles.length > 0 && <button type="button" onClick={feature.cancelAdd}>Отмена</button>} {feature.profiles.length > 0 && <button type="button" onClick={feature.cancelAdd}>Отмена</button>}
<button <button
@@ -482,9 +462,6 @@ export function SubscriptionPanel({
onClick={feature.close} onClick={feature.close}
>×</button> >×</button>
</header> </header>
<div className={`client-profiles-operation${feature.activeOperation ? ' is-active' : ''}`} role="status">
{feature.activeOperation ? operationText(feature.activeOperation[0]) : '\u00a0'}
</div>
{statusSlot} {statusSlot}
<div className="client-profile-list"> <div className="client-profile-list">
{feature.profiles.map((profile) => <ProfileGroup {feature.profiles.map((profile) => <ProfileGroup
+4 -4
View File
@@ -618,17 +618,16 @@
.client-profile-body .client-server-row { .client-profile-body .client-server-row {
grid-template-columns: minmax(0, 1fr) 64px; grid-template-columns: minmax(0, 1fr) 64px;
min-height: 52px; min-height: 44px;
border-bottom: 1px solid var(--client-border);
} }
.client-profile-body .client-server-row.is-selected { .client-profile-body .client-server-row.is-selected {
background: color-mix(in oklch, var(--client-accent) 5%, transparent); background: color-mix(in oklch, var(--client-accent) 2%, transparent);
} }
.client-profile-body .client-server-row .client-server { .client-profile-body .client-server-row .client-server {
grid-column: 1; grid-column: 1;
min-height: 51px; min-height: 44px;
display: grid; display: grid;
grid-template-columns: 18px minmax(0, 1fr) auto; grid-template-columns: 18px minmax(0, 1fr) auto;
place-items: center start; place-items: center start;
@@ -636,6 +635,7 @@
padding: 0 8px; padding: 0 8px;
border: 0; border: 0;
text-align: left; text-align: left;
animation: none;
} }
.client-profile-body .client-server-row .client-server::before { .client-profile-body .client-server-row .client-server::before {
+30 -86
View File
@@ -60,54 +60,17 @@
letter-spacing: 0.02em; letter-spacing: 0.02em;
} }
.client-profiles-operation {
min-height: 22px;
display: flex;
align-items: center;
margin-top: 8px;
color: var(--client-muted);
font-size: 9px;
gap: 8px;
}
.client-profiles-operation.is-active::before {
content: '';
width: 7px;
height: 7px;
border: 1px solid currentColor;
border-radius: 50%;
}
.client-profile-list { .client-profile-list {
margin-top: 8px; margin-top: 2px;
border-top: 1px solid var(--client-border);
} }
.client-profile-group { .client-profile-group {
position: relative; position: relative;
border-bottom: 1px solid var(--client-border); padding: 2px 0;
animation: client-profile-group-in 520ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.client-profile-group.is-active {
background: color-mix(in oklch, var(--client-accent) 3%, transparent);
}
.client-profile-group.is-active .client-profile-title strong,
.client-profile-group.is-active .client-profile-selected-server {
color: var(--client-accent);
}
@keyframes client-profile-group-in {
from {
opacity: 0;
filter: blur(5px);
transform: translateY(8px);
}
} }
.client-profile-header { .client-profile-header {
min-height: 72px; min-height: 54px;
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto 36px 36px; grid-template-columns: minmax(0, 1fr) auto 36px 36px;
align-items: center; align-items: center;
@@ -116,11 +79,11 @@
.client-profile-disclosure { .client-profile-disclosure {
min-width: 0; min-width: 0;
min-height: 56px; min-height: 48px;
display: grid; display: grid;
grid-template-columns: 18px minmax(0, 1fr); grid-template-columns: 18px minmax(0, 1fr);
align-items: center; align-items: center;
padding: 6px 0; padding: 4px 0 4px 8px;
border: 0; border: 0;
background: transparent; background: transparent;
color: var(--client-text); color: var(--client-text);
@@ -143,7 +106,7 @@
.client-profile-title { .client-profile-title {
min-width: 0; min-width: 0;
display: grid; display: grid;
gap: 4px; gap: 2px;
} }
.client-profile-title > span { .client-profile-title > span {
@@ -172,10 +135,7 @@
} }
.client-profile-title em { .client-profile-title em {
padding: 3px 6px; padding: 0;
border: 1px solid color-mix(in oklch, var(--client-accent) 30%, transparent);
border-radius: 3px;
background: color-mix(in oklch, var(--client-accent) 8%, transparent);
color: var(--client-accent); color: var(--client-accent);
font-size: 7px; font-size: 7px;
font-style: normal; font-style: normal;
@@ -225,11 +185,19 @@
.client-profile-refresh:hover:not(:disabled), .client-profile-refresh:hover:not(:disabled),
.client-profile-refresh:focus-visible, .client-profile-refresh:focus-visible,
.client-profile-delete:hover:not(:disabled), .client-profile-delete:hover:not(:disabled) {
.client-profile-delete:focus-visible {
color: var(--client-accent); color: var(--client-accent);
} }
.client-profile-delete,
.client-profile-delete:focus-visible {
color: oklch(0.62 0.16 28);
}
.client-profile-delete:hover:not(:disabled) {
color: oklch(0.7 0.18 28);
}
.client-profile-refresh:hover:not(:disabled) svg, .client-profile-refresh:hover:not(:disabled) svg,
.client-profile-refresh:focus-visible:not(.is-refreshing) svg { .client-profile-refresh:focus-visible:not(.is-refreshing) svg {
transform: rotate(90deg); transform: rotate(90deg);
@@ -255,21 +223,11 @@
} }
.client-profile-body { .client-profile-body {
max-height: 3200px;
padding: 0 0 4px; padding: 0 0 4px;
overflow: hidden;
opacity: 1;
visibility: visible;
transition: max-height 560ms cubic-bezier(0.16, 1, 0.3, 1), opacity 260ms ease, padding 420ms ease, visibility 0s;
} }
.client-profile-body[aria-hidden='true'] { .client-profile-body[aria-hidden='true'] {
max-height: 0; display: none;
padding-top: 0;
padding-bottom: 0;
opacity: 0;
visibility: hidden;
transition: max-height 420ms cubic-bezier(0.16, 1, 0.3, 1), opacity 180ms ease, padding 320ms ease, visibility 0s 420ms;
} }
.client-profile-body .client-servers { .client-profile-body .client-servers {
@@ -279,14 +237,13 @@
.client-profile-selected-server { .client-profile-selected-server {
width: 100%; width: 100%;
min-height: 52px; min-height: 44px;
display: grid; display: grid;
grid-template-columns: 18px minmax(0, 1fr) auto; grid-template-columns: 18px minmax(0, 1fr) auto;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 0 8px; padding: 0 8px;
border: 0; border: 0;
border-top: 1px solid var(--client-border);
background: transparent; background: transparent;
color: var(--client-text); color: var(--client-text);
font: inherit; font: inherit;
@@ -319,7 +276,7 @@
.client-profile-selected-server:hover, .client-profile-selected-server:hover,
.client-profile-selected-server:focus-visible { .client-profile-selected-server:focus-visible {
background: color-mix(in oklch, var(--client-accent) 5%, transparent); background: color-mix(in oklch, var(--client-accent) 2%, transparent);
} }
.client-profile-selected-server:focus-visible { .client-profile-selected-server:focus-visible {
@@ -341,17 +298,7 @@
} }
.client-subscription-sheet > .client-profile-add { .client-subscription-sheet > .client-profile-add {
margin-top: 12px; margin-top: 8px;
border-bottom: 0;
animation: client-profile-add-in 520ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes client-profile-add-in {
from {
opacity: 0;
filter: blur(5px);
transform: translateY(8px);
}
} }
.client-profile-activate { .client-profile-activate {
@@ -365,13 +312,12 @@
.client-profile-add-trigger { .client-profile-add-trigger {
width: 100%; width: 100%;
margin-top: 24px; margin-top: 10px;
text-align: left; text-align: left;
} }
.client-profile-server-hint, .client-profile-server-hint,
.client-profile-local-status, .client-profile-local-status {
.client-profile-form-status {
min-height: 18px; min-height: 18px;
margin: 0; margin: 0;
color: var(--client-muted); color: var(--client-muted);
@@ -388,8 +334,7 @@
.client-profile-add { .client-profile-add {
display: grid; display: grid;
gap: 10px; gap: 10px;
padding: 16px 0 20px; padding: 10px 0 4px;
border-bottom: 1px solid var(--client-border);
} }
.client-profile-add label { .client-profile-add label {
@@ -405,8 +350,9 @@
.client-profile-add input { .client-profile-add input {
min-width: 0; min-width: 0;
height: 40px; height: 40px;
padding: 0 10px; padding: 0 2px;
border: 1px solid var(--client-border); border: 0;
border-bottom: 1px solid color-mix(in oklch, var(--client-border) 72%, transparent);
border-radius: 0; border-radius: 0;
outline: 0; outline: 0;
background: transparent; background: transparent;
@@ -415,11 +361,11 @@
} }
.client-profile-add input:focus { .client-profile-add input:focus {
border: 1px solid var(--client-accent); border-bottom-color: var(--client-accent);
} }
.client-profile-add input[aria-invalid='true'] { .client-profile-add input[aria-invalid='true'] {
border: 1px solid oklch(0.68 0.15 28); border-bottom-color: oklch(0.68 0.15 28);
} }
.client-profile-form-actions { .client-profile-form-actions {
@@ -487,12 +433,10 @@
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.client-profile-chevron, .client-profile-chevron,
.client-profile-body,
.client-profile-refresh, .client-profile-refresh,
.client-profile-refresh svg, .client-profile-refresh svg,
.client-profile-delete-lid, .client-profile-delete-lid,
.client-profile-group, .client-profile-group {
.client-subscription-sheet > .client-profile-add {
transition: none; transition: none;
animation: none; animation: none;
} }
+1 -1
View File
@@ -133,7 +133,7 @@ test('server picker starts simple and reveals advanced controls on demand', () =
assert.match(picker, /\.slice\(0, SIMPLE_SERVER_LIMIT\)/); assert.match(picker, /\.slice\(0, SIMPLE_SERVER_LIMIT\)/);
assert.match(picker, /\{selected && <ServerRow/); assert.match(picker, /\{selected && <ServerRow/);
assert.match(styles, /\.client-profile-body \.client-server-row \{[\s\S]*?grid-template-columns: minmax\(0, 1fr\) 64px;/); assert.match(styles, /\.client-profile-body \.client-server-row \{[\s\S]*?grid-template-columns: minmax\(0, 1fr\) 64px;/);
assert.match(styles, /\.client-profile-body \.client-server-row\.is-selected \{[\s\S]*?background: color-mix\(in oklch, var\(--client-accent\) 5%, transparent\);/); assert.match(styles, /\.client-profile-body \.client-server-row\.is-selected \{[\s\S]*?background: color-mix\(in oklch, var\(--client-accent\) 2%, transparent\);/);
assert.match(styles, /\.client-profile-body \.client-server-check-label \{[\s\S]*?display: inline;/); assert.match(styles, /\.client-profile-body \.client-server-check-label \{[\s\S]*?display: inline;/);
assert.doesNotMatch(styles, /\.client-profile-body \.client-server-check::after/); assert.doesNotMatch(styles, /\.client-profile-body \.client-server-check::after/);
}); });
+16 -16
View File
@@ -36,26 +36,26 @@ const expectedImports = [
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex'); const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
const acceptedLedger = { const acceptedLedger = {
counts: { counts: {
cascadeEdges: 792, cascadeEdges: 790,
customProperties: 31, customProperties: 31,
declarations: 2977, declarations: 2941,
important: 0, important: 0,
keyframes: 51, keyframes: 49,
media: 13, media: 13,
rules: 910, rules: 906,
variableReferences: 338, variableReferences: 328,
}, },
hashes: { hashes: {
cascadeEdges: 'f83d896540885ddaf09e55551a2f1ba5b587dcebfc285cc305efe80136154d93', cascadeEdges: '1f4fd4cc495ac8d430f0d3891a7d63db34ee44313b4f865b58de7c587378b261',
customProperties: 'c7dd331e4bad898c450568999d8c9c6837e275a79c365c7680e143026fde4545', customProperties: 'c7dd331e4bad898c450568999d8c9c6837e275a79c365c7680e143026fde4545',
declarations: '14728723e4a95d6031b60eb39c0864da0ccc061e24798091cd093b6354a5d906', declarations: 'c17ab95f3bccd3fca26b2707f07e5298deda0f6343e80d77dbc5df8e97ef0ecb',
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945', duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
duplicateSelectors: 'c0ec5c96e48cd2ceed32aa5050383bcf29c3c0b8f1b440ed6883435e3e9b964d', duplicateSelectors: 'c0ec5c96e48cd2ceed32aa5050383bcf29c3c0b8f1b440ed6883435e3e9b964d',
keyframes: '9e87e997d0b776462ac3e495c7ced584871a5d9512226e5008eb84b4d311530a', keyframes: 'bde9c628dc86cb8a4299d5397eb7a81c5fbc9481136a073130991d3182d0799d',
ruleDeclarationSequences: 'eac60ada2de39eff87713f71f5ca533cd831ee7d400d27eaa09562c29dfbfadd', ruleDeclarationSequences: 'a0e34d9639e87e77a2a095503196f4472a95ac875e398908f184b64b5bf5feba',
selectors: 'e79f61996bcd2a806828406c0a626e87d60112f1b24264d60b8670f9b8f831af', selectors: '7051c489e7dac8bd04f3cdeb44bda8c069793cff4232ea730d40a19545ea0475',
variableReferences: '5c56197ab72968fc007f333c0cc58f90b6ec90465d6273bc757b7c77503dd26e', variableReferences: '37d0cec8ffda35b4b3823322c0e21802ad0d3252258deade0e2124bdc6eb6ca0',
witnesses: '7066589e7b69a5b3c728ff70430a8d6d3c6c6d562cea91377244c0754e18b8da', witnesses: '1b0e3d6fb35dde7acd7b3597f290c2e8b2f25ca569e74b047457121967affc6f',
}, },
}; };
@@ -110,7 +110,7 @@ test('tokens, shared primitives, and feature styles have one explicit owner', ()
test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => { test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => {
const witnesses = readStyleWitnesses(root); const witnesses = readStyleWitnesses(root);
assert.equal(witnesses.length, 735); assert.equal(witnesses.length, 734);
assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0); assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0);
const ledger = createStyleLedger(readStyleSource(root), { witnesses }); const ledger = createStyleLedger(readStyleSource(root), { witnesses });
assert.deepEqual(ledger.counts, acceptedLedger.counts); assert.deepEqual(ledger.counts, acceptedLedger.counts);
@@ -306,8 +306,8 @@ test('main owns one public stylesheet and the regrouped production CSS is determ
assert.equal((main.match(/import ['"][^'"]+\.css['"]/g) || []).length, 1); assert.equal((main.match(/import ['"][^'"]+\.css['"]/g) || []).length, 1);
const assets = fs.readdirSync(path.join(root, 'dist/assets')).filter((file) => file.endsWith('.css')); const assets = fs.readdirSync(path.join(root, 'dist/assets')).filter((file) => file.endsWith('.css'));
assert.deepEqual(assets, ['index-pEpt1ReD.css']); assert.deepEqual(assets, ['index-hHmAphe7.css']);
const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0])); const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0]));
assert.equal(built.byteLength, 111837); assert.equal(built.byteLength, 110356);
assert.equal(sha256(built), 'f797af224ecae5e0435c7fb165cc158a237d11654c48a8cb287b32b2230e6b0c'); assert.equal(sha256(built), '67a5df63d5ee0624c0e58f7fc9751c279a0c009c37abc8ef77567892c93da931');
}); });
@@ -87,7 +87,8 @@ test('validation rejection parser preserves structured errors and normalizes non
test('feature keeps exact slots, truthy closes and subscription DOM order', () => { test('feature keeps exact slots, truthy closes and subscription DOM order', () => {
assert.match(feature, /const host = new URL\(normalizedUrl\)\.hostname;[\s\S]*if \(!await onAdd\(label, normalizedUrl\)\) return;[\s\S]*resetAdd\(\)/); assert.match(feature, /const host = new URL\(normalizedUrl\)\.hostname;[\s\S]*if \(!await onAdd\(label, normalizedUrl\)\) return;[\s\S]*resetAdd\(\)/);
assert.match(feature, /if \(!await onForget\(deleteProfile\.id, deleteStopsVpn \? 'stop-and-delete' : 'delete'\)\) return;[\s\S]*setDeleteId\(''\)/); assert.match(feature, /if \(!await onForget\(deleteProfile\.id, deleteStopsVpn \? 'stop-and-delete' : 'delete'\)\) return;[\s\S]*setDeleteId\(''\)/);
assert.match(feature, /client-profiles-operation[\s\S]*\{statusSlot\}[\s\S]*client-profile-list[\s\S]*feature\.adding[\s\S]*AddProfileForm[\s\S]*client-profile-add-trigger/); assert.match(feature, /\{statusSlot\}[\s\S]*client-profile-list[\s\S]*feature\.adding[\s\S]*AddProfileForm[\s\S]*client-profile-add-trigger/);
assert.doesNotMatch(feature, /client-profiles-operation|activeOperation|operationText/);
assert.doesNotMatch(feature, /client-profiles-current|aria-label="Добавить подписку"/); assert.doesNotMatch(feature, /client-profiles-current|aria-label="Добавить подписку"/);
assert.match(feature, /const drawerOpen = feature\.open && feature\.profiles\.length > 0/); assert.match(feature, /const drawerOpen = feature\.open && feature\.profiles\.length > 0/);
assert.match(feature, /feature\.profiles\.length === 0 && <div className="client-form client-subscription-first-run"/); assert.match(feature, /feature\.profiles\.length === 0 && <div className="client-form client-subscription-first-run"/);