Animate vertical transitions between client drawers
Build and Deploy Gateway / build-and-push (push) Successful in 22s
Build and Deploy Gateway / deploy (push) Successful in 6s

This commit is contained in:
2026-08-14 15:23:00 +03:00
parent 019930924d
commit 32217f4d17
17 changed files with 260 additions and 93 deletions
@@ -0,0 +1,85 @@
---
name: design-harbor-device-ecosystem
description: [TODO: Complete and informative explanation of what the skill does and when to use it. Include WHEN to use this skill - specific scenarios, file types, or tasks that trigger it.]
---
# Design Harbor Device Ecosystem
## Overview
[TODO: 1-2 sentences explaining what this skill enables]
## Structuring This Skill
[TODO: Choose the structure that best fits this skill's purpose. Common patterns:
**1. Workflow-Based** (best for sequential processes)
- Works well when there are clear step-by-step procedures
- Example: DOCX skill with "Workflow Decision Tree" -> "Reading" -> "Creating" -> "Editing"
- Structure: ## Overview -> ## Workflow Decision Tree -> ## Step 1 -> ## Step 2...
**2. Task-Based** (best for tool collections)
- Works well when the skill offers different operations/capabilities
- Example: PDF skill with "Quick Start" -> "Merge PDFs" -> "Split PDFs" -> "Extract Text"
- Structure: ## Overview -> ## Quick Start -> ## Task Category 1 -> ## Task Category 2...
**3. Reference/Guidelines** (best for standards or specifications)
- Works well for brand guidelines, coding standards, or requirements
- Example: Brand styling with "Brand Guidelines" -> "Colors" -> "Typography" -> "Features"
- Structure: ## Overview -> ## Guidelines -> ## Specifications -> ## Usage...
**4. Capabilities-Based** (best for integrated systems)
- Works well when the skill provides multiple interrelated features
- Example: Product Management with "Core Capabilities" -> numbered capability list
- Structure: ## Overview -> ## Core Capabilities -> ### 1. Feature -> ### 2. Feature...
Patterns can be mixed and matched as needed. Most skills combine patterns (e.g., start with task-based, add workflow for complex operations).
Delete this entire "Structuring This Skill" section when done - it's just guidance.]
## [TODO: Replace with the first main section based on chosen structure]
[TODO: Add content here. See examples in existing skills:
- Code samples for technical skills
- Decision trees for complex workflows
- Concrete examples with realistic user requests
- References to scripts/templates/references as needed]
## Resources (optional)
Create only the resource directories this skill actually needs. Delete this section if no resources are required.
### scripts/
Executable code (Python/Bash/etc.) that can be run directly to perform specific operations.
**Examples from other skills:**
- PDF skill: `fill_fillable_fields.py`, `extract_form_field_info.py` - utilities for PDF manipulation
- DOCX skill: `document.py`, `utilities.py` - Python modules for document processing
**Appropriate for:** Python scripts, shell scripts, or any executable code that performs automation, data processing, or specific operations.
**Note:** Scripts may be executed without loading into context, but can still be read by Codex for patching or environment adjustments.
### references/
Documentation and reference material intended to be loaded into context to inform Codex's process and thinking.
**Examples from other skills:**
- Product management: `communication.md`, `context_building.md` - detailed workflow guides
- BigQuery: API reference documentation and query examples
- Finance: Schema documentation, company policies
**Appropriate for:** In-depth documentation, API references, database schemas, comprehensive guides, or any detailed information that Codex should reference while working.
### assets/
Files not intended to be loaded into context, but rather used within the output Codex produces.
**Examples from other skills:**
- Brand styling: PowerPoint template files (.pptx), logo files
- Frontend builder: HTML/React boilerplate project directories
- Typography: Font files (.ttf, .woff2)
**Appropriate for:** Templates, boilerplate code, document templates, images, icons, fonts, or any files meant to be copied or used in the final output.
---
**Not every skill requires all three types of resources.**
@@ -0,0 +1,4 @@
interface:
display_name: "Design Harbor Device Ecosystem"
short_description: "Design trusted Harbor device integration."
default_prompt: "Use -harbor-device-ecosystem to design Harbor Gateway discovery, pairing, and connected-device contracts."
+2 -2
View File
@@ -1,6 +1,6 @@
export const HARBOR_VERSIONS = Object.freeze({ export const HARBOR_VERSIONS = Object.freeze({
macClient: '0.25.9', macClient: '0.25.10',
gatewayClient: '0.26.8', gatewayClient: '0.26.9',
gatewayBackend: '0.26.5', gatewayBackend: '0.26.5',
}); });
+125 -36
View File
@@ -5,6 +5,7 @@ import React, {
useState, useState,
type CSSProperties, type CSSProperties,
} from 'react'; } from 'react';
import { flushSync } from 'react-dom';
import { import {
copyText, copyText,
localProxyUrls, localProxyUrls,
@@ -63,6 +64,10 @@ const VERSION_PARTS = [
['hotfix', 'Hotfix'], ['hotfix', 'Hotfix'],
] as const; ] as const;
const DRAWER_SWITCH_MS = 620;
const DRAWER_ORDER = ['subscription', 'instructions', 'devices', 'diagnostics', 'routing'] as const;
type DrawerKey = typeof DRAWER_ORDER[number];
interface UiError { interface UiError {
context?: string; context?: string;
profileId?: string; profileId?: string;
@@ -442,6 +447,10 @@ export function ClientOverviewPage({
const [copyAnnouncement, setCopyAnnouncement] = useState<CopyAnnouncement>({ text: '', cycle: 0 }); const [copyAnnouncement, setCopyAnnouncement] = useState<CopyAnnouncement>({ text: '', cycle: 0 });
const copyTimersRef = useRef<Partial<Record<CopyKind, ReturnType<typeof setTimeout>>>>({}); const copyTimersRef = useRef<Partial<Record<CopyKind, ReturnType<typeof setTimeout>>>>({});
const copyAttemptsRef = useRef<Partial<Record<CopyKind, object>>>({}); const copyAttemptsRef = useRef<Partial<Record<CopyKind, object>>>({});
const drawerSwitchRef = useRef<{
target: DrawerKey;
finish: () => void;
} | null>(null);
const gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1'; const gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1';
const controlHost = window.location.host || `${gatewayAddress}:3456`; const controlHost = window.location.host || `${gatewayAddress}:3456`;
const proxyUrls = localProxyUrls(state?.clientRuntime?.proxyPort, gatewayAddress); const proxyUrls = localProxyUrls(state?.clientRuntime?.proxyPort, gatewayAddress);
@@ -523,6 +532,41 @@ export function ClientOverviewPage({
controlHost, controlHost,
}); });
const diagnosticsAvailable = hasSubscription; const diagnosticsAvailable = hasSubscription;
const drawerControls = {
subscription: {
isOpen: subscriptionFeature.open,
panelRef: subscriptionFeature.panelRef,
show: subscriptionFeature.toggle,
close: subscriptionFeature.close,
},
instructions: {
isOpen: instructionsFeature.isOpen,
panelRef: instructionsFeature.panelRef,
show: instructionsFeature.toggle,
close: instructionsFeature.close,
},
devices: {
isOpen: devicesFeature.isOpen,
panelRef: devicesFeature.panelRef,
show: devicesFeature.toggle,
close: devicesFeature.close,
},
diagnostics: {
isOpen: diagnosticsFeature.isOpen,
panelRef: diagnosticsFeature.panelRef,
show: diagnosticsFeature.toggle,
close: diagnosticsFeature.close,
},
routing: {
isOpen: routingFeature.isOpen,
panelRef: routingFeature.panelRef,
show: routingFeature.open,
close: routingFeature.forceClose,
},
};
const drawerOrder = isGateway
? DRAWER_ORDER
: DRAWER_ORDER.filter((drawer) => drawer !== 'devices');
useEffect(() => { useEffect(() => {
setNow(Date.now()); setNow(Date.now());
@@ -598,12 +642,77 @@ export function ClientOverviewPage({
}, 800); }, 800);
} }
function openRouting() { function switchDrawer(target: DrawerKey) {
subscriptionFeature.close(); const activeSwitch = drawerSwitchRef.current;
instructionsFeature.close(); const current = activeSwitch?.target
devicesFeature.close(); || drawerOrder.find((drawer) => drawerControls[drawer].isOpen)
diagnosticsFeature.close(); || null;
routingFeature.open(); activeSwitch?.finish();
if (current === target) {
drawerControls[target].close();
return;
}
if (current === 'routing' && routingFeature.dirty) {
routingFeature.requestClose();
return;
}
if (!current) {
drawerControls[target].show();
return;
}
const fromControl = drawerControls[current];
const toControl = drawerControls[target];
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reducedMotion) {
flushSync(() => {
fromControl.close();
toControl.show();
});
return;
}
flushSync(() => toControl.show());
const from = fromControl.panelRef.current;
const to = toControl.panelRef.current;
if (!from || !to || typeof from.animate !== 'function' || typeof to.animate !== 'function') {
fromControl.close();
return;
}
from.inert = true;
from.setAttribute('aria-hidden', 'true');
const direction = DRAWER_ORDER.indexOf(target) > DRAWER_ORDER.indexOf(current) ? -1 : 1;
const options: KeyframeAnimationOptions = {
duration: DRAWER_SWITCH_MS,
easing: 'cubic-bezier(0.16, 1, 0.3, 1)',
fill: 'both',
};
const outgoing = from.animate([
{ transform: 'translateY(0)', opacity: 1 },
{ transform: `translateY(${direction * 100}%)`, opacity: 1 },
], options);
const incoming = to.animate([
{ transform: `translateY(${-direction * 100}%)`, opacity: 1 },
{ transform: 'translateY(0)', opacity: 1 },
], options);
let finished = false;
const cancel = () => {
from.inert = false;
from.removeAttribute('aria-hidden');
outgoing.cancel();
incoming.cancel();
};
const finish = () => {
if (finished) return;
finished = true;
flushSync(() => fromControl.close());
cancel();
if (drawerSwitchRef.current?.target === target) drawerSwitchRef.current = null;
};
incoming.addEventListener('finish', finish, { once: true });
drawerSwitchRef.current = { target, finish };
} }
const mainIdentity = gatewayDirect const mainIdentity = gatewayDirect
@@ -629,53 +738,33 @@ export function ClientOverviewPage({
<div className="client-live-region" role="status" aria-live="polite" aria-atomic="true"> <div className="client-live-region" role="status" aria-live="polite" aria-atomic="true">
<span key={copyAnnouncement.cycle}>{copyAnnouncement.text}</span> <span key={copyAnnouncement.cycle}>{copyAnnouncement.text}</span>
</div> </div>
{hasSubscription && <nav className="client-secondary-menu" aria-label="Дополнительные меню"> {hasSubscription && <nav
className="client-secondary-menu"
aria-label="Дополнительные меню"
onPointerDown={(event) => event.stopPropagation()}
>
<SubscriptionToggle <SubscriptionToggle
feature={subscriptionFeature} feature={subscriptionFeature}
onToggle={() => { onToggle={() => switchDrawer('subscription')}
if (routingFeature.isOpen && !routingFeature.requestClose()) return;
instructionsFeature.close();
devicesFeature.close();
diagnosticsFeature.close();
subscriptionFeature.toggle();
}}
/> />
<InstructionsToggle <InstructionsToggle
feature={instructionsFeature} feature={instructionsFeature}
onToggle={() => { onToggle={() => switchDrawer('instructions')}
if (routingFeature.isOpen && !routingFeature.requestClose()) return;
subscriptionFeature.close();
devicesFeature.close();
diagnosticsFeature.close();
instructionsFeature.toggle();
}}
/> />
{isGateway && <DevicesToggle {isGateway && <DevicesToggle
feature={devicesFeature} feature={devicesFeature}
onToggle={() => { onToggle={() => switchDrawer('devices')}
if (routingFeature.isOpen && !routingFeature.requestClose()) return;
subscriptionFeature.close();
instructionsFeature.close();
diagnosticsFeature.close();
devicesFeature.toggle();
}}
/>} />}
<DiagnosticsToggle <DiagnosticsToggle
feature={diagnosticsFeature} feature={diagnosticsFeature}
onToggle={() => { onToggle={() => switchDrawer('diagnostics')}
if (routingFeature.isOpen && !routingFeature.requestClose()) return;
subscriptionFeature.close();
instructionsFeature.close();
devicesFeature.close();
diagnosticsFeature.toggle();
}}
/> />
<RoutingToggle <RoutingToggle
feature={routingFeature} feature={routingFeature}
gatewayDirect={gatewayDirect} gatewayDirect={gatewayDirect}
isGateway={isGateway} isGateway={isGateway}
hasSubscription={hasSubscription} hasSubscription={hasSubscription}
onOpen={openRouting} onOpen={() => switchDrawer('routing')}
/> />
</nav>} </nav>}
<main className={`client-panel${showPower ? '' : ' is-setup'}${!isGateway && hasSubscription ? ' has-subscription' : ''}${isGateway && hasSubscription ? ' is-gateway-home' : ''}`}> <main className={`client-panel${showPower ? '' : ' is-setup'}${!isGateway && hasSubscription ? ' has-subscription' : ''}${isGateway && hasSubscription ? ' is-gateway-home' : ''}`}>
+2 -6
View File
@@ -1,7 +1,3 @@
.client-instructions.client-devices {
width: min(580px, 100vw);
}
.client-devices-header { .client-devices-header {
margin-bottom: 28px; margin-bottom: 28px;
} }
@@ -128,8 +124,8 @@
transform: translate(0, 2px); transform: translate(0, 2px);
} }
.client-devices-reset-wrap:hover > .client-tooltip, .client-devices-reset-wrap.client-tooltip-anchor:hover > .client-tooltip,
.client-devices-reset-wrap:has(> :focus-visible) > .client-tooltip { .client-devices-reset-wrap.client-tooltip-anchor:has(> :focus-visible) > .client-tooltip {
transform: translate(0, 0); transform: translate(0, 0);
} }
-4
View File
@@ -1,7 +1,3 @@
.client-instructions.client-diagnostics {
width: min(580px, 100vw);
}
.client-diagnostics-header { .client-diagnostics-header {
margin-bottom: 12px; margin-bottom: 12px;
} }
-4
View File
@@ -1,7 +1,3 @@
.client-instructions {
width: min(470px, 100vw);
}
.client-instruction-list { .client-instruction-list {
display: grid; display: grid;
gap: 15px; gap: 15px;
-4
View File
@@ -1,7 +1,3 @@
.client-local-rules {
width: min(480px, 100vw);
}
.client-local-rules-header { .client-local-rules-header {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
+1
View File
@@ -434,6 +434,7 @@
.client-server-scroll { .client-server-scroll {
max-height: 330px; max-height: 330px;
overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain; overscroll-behavior: contain;
padding-right: 5px; padding-right: 5px;
+1 -8
View File
@@ -38,13 +38,6 @@
padding: 32px 30px 76px; padding: 32px 30px 76px;
} }
@media (min-width: 921px) {
.client-subscription-drawer {
right: 64px;
width: min(480px, calc(100vw - 64px));
}
}
.client-profiles-header { .client-profiles-header {
min-height: 44px; min-height: 44px;
display: flex; display: flex;
@@ -303,7 +296,7 @@
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 0 32px;
border: 0; border: 0;
background: transparent; background: transparent;
color: var(--client-text); color: var(--client-text);
+9 -4
View File
@@ -183,6 +183,8 @@
position: fixed; position: fixed;
inset: 0 0 0 auto; inset: 0 0 0 auto;
z-index: 50; z-index: 50;
width: min(580px, 100vw);
overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
background: color-mix(in oklch, var(--client-bg) 99%, var(--client-panel)); background: color-mix(in oklch, var(--client-bg) 99%, var(--client-panel));
color: var(--client-text); color: var(--client-text);
@@ -200,6 +202,13 @@
transition-delay: 0s; transition-delay: 0s;
} }
@media (min-width: 921px) {
.client-drawer {
right: 64px;
width: min(580px, calc(100vw - 64px));
}
}
.client-drawer-sheet { .client-drawer-sheet {
position: relative; position: relative;
min-height: 100%; min-height: 100%;
@@ -484,10 +493,6 @@
transition: 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-subscription-drawer {
width: min(480px, 100vw);
}
.client-confirmation-popup { .client-confirmation-popup {
position: fixed; position: fixed;
inset: 0; inset: 0;
+2 -1
View File
@@ -139,7 +139,7 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.match(panel, /\{!compact && <TrafficChart/); assert.match(panel, /\{!compact && <TrafficChart/);
assert.doesNotMatch(panel, /Закрепите устройство, чтобы изменить маршрут|Сначала верните маршрут через Gateway/); assert.doesNotMatch(panel, /Закрепите устройство, чтобы изменить маршрут|Сначала верните маршрут через Gateway/);
assert.match(panel, /maxLength=\{64\}[\s\S]*autoFocus/); assert.match(panel, /maxLength=\{64\}[\s\S]*autoFocus/);
assert.match(styles, /\.client-devices \{\s*width: min\(580px, 100vw\)/); assert.match(styles, /\.client-drawer \{[\s\S]*width: min\(580px, 100vw\)/);
assert.match(styles, /\.client-device \{[\s\S]*--client-device-chart-height: 34px;[\s\S]*grid-template-columns: 34px minmax\(0, 1fr\) 112px 34px;[\s\S]*grid-template-rows: 34px var\(--client-device-chart-height\);[\s\S]*padding: 10px 8px/); assert.match(styles, /\.client-device \{[\s\S]*--client-device-chart-height: 34px;[\s\S]*grid-template-columns: 34px minmax\(0, 1fr\) 112px 34px;[\s\S]*grid-template-rows: 34px var\(--client-device-chart-height\);[\s\S]*padding: 10px 8px/);
assert.match(styles, /\.client-device\.is-pinned \{[\s\S]*--client-device-chart-height: 72px/); assert.match(styles, /\.client-device\.is-pinned \{[\s\S]*--client-device-chart-height: 72px/);
assert.match(styles, /\.client-device\.is-deprioritized \{[\s\S]*grid-template-rows: 34px;[\s\S]*padding-block: 6px/); assert.match(styles, /\.client-device\.is-deprioritized \{[\s\S]*grid-template-rows: 34px;[\s\S]*padding-block: 6px/);
@@ -207,6 +207,7 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.match(styles, /\.client-devices-kicker \{[^}]*flex-wrap: nowrap/); assert.match(styles, /\.client-devices-kicker \{[^}]*flex-wrap: nowrap/);
assert.match(styles, /\.client-devices-reset-wrap \{[^}]*margin-left: auto/); assert.match(styles, /\.client-devices-reset-wrap \{[^}]*margin-left: auto/);
assert.match(styles, /\.client-devices-reset-wrap > \.client-tooltip \{[^}]*white-space: nowrap/); assert.match(styles, /\.client-devices-reset-wrap > \.client-tooltip \{[^}]*white-space: nowrap/);
assert.match(styles, /\.client-devices-reset-wrap\.client-tooltip-anchor:hover > \.client-tooltip,[\s\S]*transform: translate\(0, 0\)/);
assert.match(styles, /\.client-devices-reset \{[\s\S]*oklch\(0\.68 0\.15 28\)[\s\S]*\.client-devices-reset:hover:not\(:disabled\) svg[\s\S]*rotate\(-360deg\)/); assert.match(styles, /\.client-devices-reset \{[\s\S]*oklch\(0\.68 0\.15 28\)[\s\S]*\.client-devices-reset:hover:not\(:disabled\) svg[\s\S]*rotate\(-360deg\)/);
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-device-policy svg[\s\S]*\.client-device-alias-trigger[\s\S]*\.client-device-identity-details[\s\S]*\.client-device-identity-copy[\s\S]*\.client-device-traffic-value > span[\s\S]*\.client-device-traffic-breakdown[\s\S]*\.client-device-traffic-lines[\s\S]*\.client-text-morph-value[\s\S]*transition: none;[\s\S]*animation: none/); assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-device-policy svg[\s\S]*\.client-device-alias-trigger[\s\S]*\.client-device-identity-details[\s\S]*\.client-device-identity-copy[\s\S]*\.client-device-traffic-value > span[\s\S]*\.client-device-traffic-breakdown[\s\S]*\.client-device-traffic-lines[\s\S]*\.client-text-morph-value[\s\S]*transition: none;[\s\S]*animation: none/);
assert.match(styles, /\.client-drawer \{[\s\S]*z-index: 50;[\s\S]*box-shadow:/); assert.match(styles, /\.client-drawer \{[\s\S]*z-index: 50;[\s\S]*box-shadow:/);
+1 -1
View File
@@ -37,7 +37,7 @@ test('device controller preserves Gateway-only polling, monotonic publication an
assert.match(feature, /resetOpen[\s\S]*if \(resetOpen\) return/); assert.match(feature, /resetOpen[\s\S]*if \(resetOpen\) return/);
assert.match(feature, /resetDeviceTraffic\(snapshot\.revision\)[\s\S]*STATE_CONFLICT[\s\S]*resetDeviceTraffic\(latest\.revision\)/); assert.match(feature, /resetDeviceTraffic\(snapshot\.revision\)[\s\S]*STATE_CONFLICT[\s\S]*resetDeviceTraffic\(latest\.revision\)/);
assert.match(panel, /<ConfirmationDialog[\s\S]*title="Сбросить статистику\?"[\s\S]*confirmLabel="Сбросить"[\s\S]*busy=\{resetting\}/); assert.match(panel, /<ConfirmationDialog[\s\S]*title="Сбросить статистику\?"[\s\S]*confirmLabel="Сбросить"[\s\S]*busy=\{resetting\}/);
assert.match(page, /<DevicesToggle[\s\S]*devicesFeature\.toggle\(\)/); assert.match(page, /<DevicesToggle[\s\S]*onToggle=\{\(\) => switchDrawer\('devices'\)\}/);
assert.match(page, /<GatewayTrafficSummary feature=\{devicesFeature\} now=\{now\}/); assert.match(page, /<GatewayTrafficSummary feature=\{devicesFeature\} now=\{now\}/);
}); });
+13 -9
View File
@@ -76,6 +76,7 @@ test('server rows scroll inside the subscription drawer without moving the main
const grid = rule('.client-server-grid'); const grid = rule('.client-server-grid');
assert.match(scroll, /overflow-y:\s*auto/); assert.match(scroll, /overflow-y:\s*auto/);
assert.match(scroll, /overflow-x:\s*hidden/);
assert.match(scroll, /scrollbar-width:\s*none/); assert.match(scroll, /scrollbar-width:\s*none/);
assert.match(styles, /\.client-server-scroll::-webkit-scrollbar\s*\{[\s\S]*display:\s*none/); assert.match(styles, /\.client-server-scroll::-webkit-scrollbar\s*\{[\s\S]*display:\s*none/);
assert.match(simpleScroll, /max-height:\s*352px/); assert.match(simpleScroll, /max-height:\s*352px/);
@@ -83,6 +84,7 @@ test('server rows scroll inside the subscription drawer without moving the main
assert.match(simpleScroll, /scrollbar-width:\s*thin/); assert.match(simpleScroll, /scrollbar-width:\s*thin/);
assert.match(grid, /width:\s*min\(100%, 220px\)/); assert.match(grid, /width:\s*min\(100%, 220px\)/);
assert.match(rule('.client-drawer'), /overflow-y:\s*auto/); assert.match(rule('.client-drawer'), /overflow-y:\s*auto/);
assert.match(rule('.client-drawer'), /overflow-x:\s*hidden/);
assert.match(subscription, /client-profile-list[\s\S]*feature\.profiles\.map\(\(profile\) => <ProfileGroup/); assert.match(subscription, /client-profile-list[\s\S]*feature\.profiles\.map\(\(profile\) => <ProfileGroup/);
assert.match(rule('.client-server-toolbar-title'), /grid-column:\s*2/); assert.match(rule('.client-server-toolbar-title'), /grid-column:\s*2/);
assert.match(rule('.client-server-mode-toggle'), /grid-row:\s*2/); assert.match(rule('.client-server-mode-toggle'), /grid-row:\s*2/);
@@ -110,10 +112,7 @@ test('tablet and mobile regions use normal flow with viewport-safe widths', () =
assert.match(mobile, /\.client-secondary-menu \{[\s\S]*right:\s*8px/); assert.match(mobile, /\.client-secondary-menu \{[\s\S]*right:\s*8px/);
assert.match(mobile, /\.client-power-control > \.harbor-brand \{[\s\S]*scale\(1\.35\)/); assert.match(mobile, /\.client-power-control > \.harbor-brand \{[\s\S]*scale\(1\.35\)/);
assert.match(mobile, /\.client-server-check \{[\s\S]*width:\s*44px/); assert.match(mobile, /\.client-server-check \{[\s\S]*width:\s*44px/);
assert.match(styles, /\.client-instructions\s*\{[\s\S]*width:\s*min\(470px, 100vw\)/); assert.match(rule('.client-drawer'), /width:\s*min\(580px, 100vw\)/);
assert.match(styles, /\.client-local-rules\s*\{[\s\S]*width:\s*min\(480px, 100vw\)/);
assert.match(styles, /\.client-instructions\.client-diagnostics\s*\{[\s\S]*width:\s*min\(580px, 100vw\)/);
assert.match(styles, /\.client-subscription-drawer\s*\{[\s\S]*width:\s*min\(480px, 100vw\)/);
assert.match(styles, /\.client-confirmation-dialog\s*\{[\s\S]*width:\s*min\(430px, calc\(100vw - 48px\)\)/); assert.match(styles, /\.client-confirmation-dialog\s*\{[\s\S]*width:\s*min\(430px, calc\(100vw - 48px\)\)/);
for (const viewport of [320, 390, 768]) { for (const viewport of [320, 390, 768]) {
@@ -128,11 +127,11 @@ test('tablet and mobile regions use normal flow with viewport-safe widths', () =
assert.ok(Math.min(360, desktopOuterColumn) <= desktopOuterColumn, 'details fit the 1440px desktop grid'); assert.ok(Math.min(360, desktopOuterColumn) <= desktopOuterColumn, 'details fit the 1440px desktop grid');
}); });
test('secondary menus share one right rail and both drawers open from the right', () => { test('secondary menus share one right rail and switch equal drawers as a vertical tape', () => {
const disabledRulesLabel = rule('.client-local-rules-toggle:disabled span'); const disabledRulesLabel = rule('.client-local-rules-toggle:disabled span');
const zIndex = (selector) => Number(/z-index:\s*(\d+)/.exec(rule(selector))?.[1]); const zIndex = (selector) => Number(/z-index:\s*(\d+)/.exec(rule(selector))?.[1]);
assert.match(component, /<nav className="client-secondary-menu" aria-label="Дополнительные меню">/); assert.match(component, /<nav[\s\S]*className="client-secondary-menu"[\s\S]*aria-label="Дополнительные меню"[\s\S]*onPointerDown=\{\(event\) => event\.stopPropagation\(\)\}/);
assert.match(component, /<SubscriptionToggle[\s\S]*<InstructionsToggle[\s\S]*<DevicesToggle/); assert.match(component, /<SubscriptionToggle[\s\S]*<InstructionsToggle[\s\S]*<DevicesToggle/);
assert.match(subscription, /controls="client-subscription-drawer"/); assert.match(subscription, /controls="client-subscription-drawer"/);
assert.match(component, /<InstructionsToggle[\s\S]*<RoutingToggle/); assert.match(component, /<InstructionsToggle[\s\S]*<RoutingToggle/);
@@ -160,6 +159,7 @@ test('secondary menus share one right rail and both drawers open from the right'
assert.match(styles, /@keyframes client-rail-info-refill[\s\S]*stroke-dashoffset: 1[\s\S]*stroke-dashoffset: 0/); assert.match(styles, /@keyframes client-rail-info-refill[\s\S]*stroke-dashoffset: 1[\s\S]*stroke-dashoffset: 0/);
assert.doesNotMatch(styles, /client-secondary-icon-motion|\.client-local-rules-toggle\.is-open svg\s*\{[^}]*rotate/); assert.doesNotMatch(styles, /client-secondary-icon-motion|\.client-local-rules-toggle\.is-open svg\s*\{[^}]*rotate/);
assert.match(rule('.client-drawer'), /inset:\s*0 0 0 auto/); assert.match(rule('.client-drawer'), /inset:\s*0 0 0 auto/);
assert.match(rule('.client-drawer'), /width:\s*min\(580px, 100vw\)/);
assert.match(rule('.client-drawer'), /transform:\s*translateX\(104%\)/); assert.match(rule('.client-drawer'), /transform:\s*translateX\(104%\)/);
assert.match(rule('.client-drawer'), /z-index:\s*50/); assert.match(rule('.client-drawer'), /z-index:\s*50/);
assert.match(rule('.client-drawer'), /box-shadow:/); assert.match(rule('.client-drawer'), /box-shadow:/);
@@ -167,12 +167,16 @@ test('secondary menus share one right rail and both drawers open from the right'
['.client-confirmation-popup', '.client-secondary-menu', '.client-drawer', '.harbor-versions'].map(zIndex), ['.client-confirmation-popup', '.client-secondary-menu', '.client-drawer', '.harbor-versions'].map(zIndex),
[100, 60, 50, 40], [100, 60, 50, 40],
); );
assert.match(rule('.client-instructions'), /width:\s*min\(470px, 100vw\)/); assert.match(styles, /@media \(min-width: 921px\) \{[\s\S]*\.client-drawer \{[\s\S]*right:\s*64px;[\s\S]*width:\s*min\(580px, calc\(100vw - 64px\)\)/);
assert.match(rule('.client-local-rules'), /width:\s*min\(480px, 100vw\)/);
assert.match(instructions, /<Drawer[\s\S]*className="client-instructions"/); assert.match(instructions, /<Drawer[\s\S]*className="client-instructions"/);
assert.match(routing, /<Drawer[\s\S]*className="client-local-rules"/); assert.match(routing, /<Drawer[\s\S]*className="client-local-rules"/);
assert.match(subscription, /<Drawer[\s\S]*className="client-subscription-drawer"/); assert.match(subscription, /<Drawer[\s\S]*className="client-subscription-drawer"/);
assert.match(component, /subscriptionFeature\.close\(\)[\s\S]*devicesFeature\.close\(\)[\s\S]*diagnosticsFeature\.close\(\)[\s\S]*instructionsFeature\.toggle\(\)/); assert.match(component, /const DRAWER_ORDER = \['subscription', 'instructions', 'devices', 'diagnostics', 'routing'\]/);
assert.match(component, /function switchDrawer\(target: DrawerKey\)[\s\S]*from\.inert = true[\s\S]*translateY\(\$\{direction \* 100\}%\)[\s\S]*translateY\(\$\{-direction \* 100\}%\)/);
assert.match(component, /const cancel = \(\) => \{[\s\S]*from\.inert = false;[\s\S]*from\.removeAttribute\('aria-hidden'\)/);
assert.match(component, /DRAWER_SWITCH_MS = 620[\s\S]*prefers-reduced-motion: reduce[\s\S]*flushSync/);
assert.match(component, /current === 'routing' && routingFeature\.dirty[\s\S]*routingFeature\.requestClose\(\)/);
assert.doesNotMatch(component, /instructionsFeature\.close\(\)[\s\S]{0,180}devicesFeature\.close\(\)[\s\S]{0,180}subscriptionFeature\.toggle\(\)/);
}); });
test('connectivity diagnostics render stable compact tables before the first run', () => { test('connectivity diagnostics render stable compact tables before the first run', () => {
+2 -2
View File
@@ -37,8 +37,8 @@ test('routing lifecycle and Page orchestration keep the existing guards and bloc
assert.match(feature, /matchMedia\('\(prefers-reduced-motion: reduce\)'\)[\s\S]*removing: true/); assert.match(feature, /matchMedia\('\(prefers-reduced-motion: reduce\)'\)[\s\S]*removing: true/);
assert.match(feature, /document\.startViewTransition\(update\)/); assert.match(feature, /document\.startViewTransition\(update\)/);
assert.match(feature, /operationBlocked\(operations, 'routeRules'\) \|\| rules\.some/); assert.match(feature, /operationBlocked\(operations, 'routeRules'\) \|\| rules\.some/);
assert.match(page, /routingFeature\.isOpen && !routingFeature\.requestClose\(\)/); assert.match(page, /current === 'routing' && routingFeature\.dirty[\s\S]*routingFeature\.requestClose\(\)/);
assert.match(page, /function openRouting\(\) \{[\s\S]*subscriptionFeature\.close\(\)[\s\S]*instructionsFeature\.close\(\)[\s\S]*devicesFeature\.close\(\)[\s\S]*diagnosticsFeature\.close\(\)[\s\S]*routingFeature\.open\(\)/); assert.match(page, /routing:[\s\S]*show: routingFeature\.open,[\s\S]*close: routingFeature\.forceClose/);
assert.match(page, /routingSlot=\{<RoutingPendingStatus[\s\S]*blocked=\{connectionBlocked\}/); assert.match(page, /routingSlot=\{<RoutingPendingStatus[\s\S]*blocked=\{connectionBlocked\}/);
assert.match(page, /<RoutingPanel[\s\S]*InlineError[\s\S]*InlineProgress/); assert.match(page, /<RoutingPanel[\s\S]*InlineError[\s\S]*InlineProgress/);
}); });
+10 -10
View File
@@ -37,24 +37,24 @@ 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: 822, cascadeEdges: 818,
customProperties: 103, customProperties: 103,
declarations: 3302, declarations: 3300,
important: 0, important: 0,
keyframes: 56, keyframes: 56,
media: 13, media: 13,
rules: 952, rules: 947,
variableReferences: 808, variableReferences: 808,
}, },
hashes: { hashes: {
cascadeEdges: '183435ac9241f0507dd2d82eda73e71eaaee8f8dcbdc0747b83cf8e17e2ce9f7', cascadeEdges: 'e5115e193b09067562309034ecfed8dd35d7435562b581036da00190dfd95650',
customProperties: 'ef70fb1d9b61b177f1939f811babe1116bee631de26f5eac0aa0d0009ca5a1fb', customProperties: 'ef70fb1d9b61b177f1939f811babe1116bee631de26f5eac0aa0d0009ca5a1fb',
declarations: '07ab8e4c442a88368e6727f0665b29a2d27a882d64c38647250a0f4f0c069f6e', declarations: '3e6d3c24e0df278909fd0200470193378866819c1a62a6d0404e74297cab45d7',
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945', duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
duplicateSelectors: '0fa66e370695261a2a5c6a189752c07d518d80d68d3e79a3ba4bb34407893c0e', duplicateSelectors: '0fa66e370695261a2a5c6a189752c07d518d80d68d3e79a3ba4bb34407893c0e',
keyframes: 'd4378751f36eccc87923c12540315462055032a5d34978a0f45ecada0a5cc1ce', keyframes: 'd4378751f36eccc87923c12540315462055032a5d34978a0f45ecada0a5cc1ce',
ruleDeclarationSequences: '864dd1f50b04e6e07e30be6e4aa3b72d02d5425dbecf74a28afa6280ce9dfd73', ruleDeclarationSequences: 'ef07abd92d371c865e70dc8e709a396ec46056164035594a8cddda6b0f61a629',
selectors: 'fa5bb2e06dcbb72802e49d67e2312504f27e87e855427498526ea24f143f84c8', selectors: '085df6f266f22b99fc429c089b3142f12fd6f74bfb9b86233c55bf8baa92469c',
variableReferences: '664c093241c4076ca05a9dda06ae88d4e7024c926c4636109790201df8212df4', variableReferences: '664c093241c4076ca05a9dda06ae88d4e7024c926c4636109790201df8212df4',
witnesses: 'ac771c94e7f2c409c3d317de610c2dae5722eb1d6f0b6626a4e1aa986c83fd94', witnesses: 'ac771c94e7f2c409c3d317de610c2dae5722eb1d6f0b6626a4e1aa986c83fd94',
}, },
@@ -405,8 +405,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-DbgBuebh.css']); assert.deepEqual(assets, ['index-DhqvaTQ4.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, 127802); assert.equal(built.byteLength, 127639);
assert.equal(sha256(built), '19995a47de1ae634f29b26fb4e484f6d9fb048519cc42ad0a186d11904e2bfb6'); assert.equal(sha256(built), 'c5e9ec300671c6660fb23bfaa725a53b165dd08e387362cfac3fbcf709a8d9e9');
}); });
@@ -99,8 +99,8 @@ test('feature keeps exact slots, truthy closes and subscription DOM order', () =
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"/);
assert.match(page, /<SubscriptionPanel[\s\S]*statusSlot=\{<>[\s\S]*InlineError[\s\S]*renderServerPicker=[\s\S]*<ServerPicker/); assert.match(page, /<SubscriptionPanel[\s\S]*statusSlot=\{<>[\s\S]*InlineError[\s\S]*renderServerPicker=[\s\S]*<ServerPicker/);
assert.doesNotMatch(page, /<InlineProgress[^>]*context="subscription"/); assert.doesNotMatch(page, /<InlineProgress[^>]*context="subscription"/);
assert.match(page, /<SubscriptionToggle[\s\S]*subscriptionFeature\.toggle\(\)/); assert.match(page, /<SubscriptionToggle[\s\S]*onToggle=\{\(\) => switchDrawer\('subscription'\)\}/);
assert.match(page, /subscriptionFeature\.close\(\)[\s\S]*devicesFeature\.close\(\)[\s\S]*diagnosticsFeature\.close\(\)/); assert.match(page, /subscription: \{[\s\S]*show: subscriptionFeature\.toggle,[\s\S]*close: subscriptionFeature\.close/);
assert.doesNotMatch(feature, /setInterval|copyText|client-live-region/); assert.doesNotMatch(feature, /setInterval|copyText|client-live-region/);
}); });
@@ -129,6 +129,7 @@ test('profiles render as flat accordion groups with scoped controls', () => {
assert.match(styles, /\.client-profile-body/); assert.match(styles, /\.client-profile-body/);
assert.match(styles, /\.client-profile-body \{[\s\S]*grid-template-rows: 0fr[\s\S]*\.client-profile-body\.is-open \{[\s\S]*grid-template-rows: 1fr/); assert.match(styles, /\.client-profile-body \{[\s\S]*grid-template-rows: 0fr[\s\S]*\.client-profile-body\.is-open \{[\s\S]*grid-template-rows: 1fr/);
assert.match(styles, /\.client-profile-body-inner \{[\s\S]*padding: 0 0 4px 24px/); assert.match(styles, /\.client-profile-body-inner \{[\s\S]*padding: 0 0 4px 24px/);
assert.match(styles, /\.client-profile-selected-server \{[\s\S]*padding: 0 8px 0 32px/);
assert.match(styles, /\.client-profile-group\.is-expired \.client-profile-title small \{[\s\S]*color: oklch\(0\.68 0\.14 72\)/); assert.match(styles, /\.client-profile-group\.is-expired \.client-profile-title small \{[\s\S]*color: oklch\(0\.68 0\.14 72\)/);
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-profile-body \*/); assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-profile-body \*/);
assert.match(styles, /\.client-profile-refresh\.is-refreshing/); assert.match(styles, /\.client-profile-refresh\.is-refreshing/);