Improve startup responsiveness and admin prompt motion
CI / Windows baseline (push) Has been cancelled

This commit is contained in:
2026-07-22 17:50:12 +03:00
parent 90ec4ca086
commit 9c987df6e9
11 changed files with 597 additions and 183 deletions
+84 -22
View File
@@ -220,6 +220,11 @@ export function App() {
const [serviceVisualState, setServiceVisualState] =
useState<ServiceVisualState>(null);
const serviceVisualTimerRef = useRef<number | null>(null);
const [isAdminPromptOpen, setIsAdminPromptOpen] = useState(false);
const [isAdminPromptHintVisible, setIsAdminPromptHintVisible] =
useState(false);
const adminPromptSeenRef = useRef(false);
const adminPromptTimerRef = useRef<number | null>(null);
const proxyfier = useMemo(
() => components.find((component) => component.id === "proxyfier"),
@@ -278,11 +283,32 @@ export function App() {
void refresh();
}, []);
useEffect(() => {
if (!hasAdminPrompt || adminPromptSeenRef.current) return undefined;
adminPromptSeenRef.current = true;
setIsAdminPromptHintVisible(true);
adminPromptTimerRef.current = window.setTimeout(() => {
setIsAdminPromptHintVisible(false);
adminPromptTimerRef.current = null;
}, 4600);
return () => {
if (adminPromptTimerRef.current !== null) {
window.clearTimeout(adminPromptTimerRef.current);
adminPromptTimerRef.current = null;
}
};
}, [hasAdminPrompt]);
useEffect(() => {
return () => {
if (serviceVisualTimerRef.current !== null) {
window.clearTimeout(serviceVisualTimerRef.current);
}
if (adminPromptTimerRef.current !== null) {
window.clearTimeout(adminPromptTimerRef.current);
}
};
}, []);
@@ -1017,29 +1043,64 @@ export function App() {
}, 700);
}
function toggleAdminPrompt() {
if (adminPromptTimerRef.current !== null) {
window.clearTimeout(adminPromptTimerRef.current);
adminPromptTimerRef.current = null;
}
setIsAdminPromptHintVisible(false);
setIsAdminPromptOpen((open) => !open);
}
function renderAdminPrompt() {
if (!hasAdminPrompt) return null;
return (
<aside className="admin-prompt" aria-label="Права администратора">
<span className="admin-prompt-icon" aria-hidden="true">
<ShieldAlert size={15} strokeWidth={1.9} />
</span>
<div className="admin-prompt-copy">
<strong>Права администратора</strong>
<span>{adminStatus?.message}</span>
</div>
<Button
<aside
className={`admin-prompt ${isAdminPromptOpen ? "is-expanded" : "is-collapsed"} ${isAdminPromptHintVisible ? "has-hint" : ""}`}
aria-label="Права администратора"
>
<button
type="button"
variant="primary"
size="sm"
className="admin-prompt-action"
onClick={() => void restartApplicationAsAdmin()}
loading={isRestartingAsAdmin}
loadingLabel="Открываю UAC"
className="admin-prompt-toggle"
onClick={toggleAdminPrompt}
aria-expanded={isAdminPromptOpen}
aria-controls="admin-prompt-details"
aria-label={
isAdminPromptOpen
? "Свернуть информацию о правах администратора"
: "Показать, зачем нужны права администратора"
}
>
Перезапустить с правами
</Button>
<ShieldAlert size={18} strokeWidth={1.9} aria-hidden="true" />
</button>
{isAdminPromptHintVisible && !isAdminPromptOpen ? (
<span className="admin-prompt-hint" role="status">
Для удобной работы нужны права администратора.
</span>
) : null}
<div
className="admin-prompt-details"
id="admin-prompt-details"
aria-hidden={!isAdminPromptOpen}
>
<div className="admin-prompt-copy">
<strong>Нужны права администратора</strong>
<span>Для управления ProxiFyre и правилами Windows.</span>
</div>
<Button
type="button"
variant="primary"
size="sm"
className="admin-prompt-action"
onClick={() => void restartApplicationAsAdmin()}
loading={isRestartingAsAdmin}
loadingLabel="Открываю UAC"
tabIndex={isAdminPromptOpen ? undefined : -1}
>
Перезапустить
</Button>
</div>
</aside>
);
}
@@ -1690,6 +1751,11 @@ export function App() {
align={isVertical || index >= segments.length - 2 ? "end" : "start"}
aria-label={`${segment.label}: ${segment.value}. ${segment.details.join(". ")}`}
key={segment.id}
style={
isVertical
? ({ animationDelay: `${index * 280}ms` } as CSSProperties)
: undefined
}
>
<span className="route-chain-dot" aria-hidden="true" />
<span>{segment.label}</span>
@@ -1713,11 +1779,7 @@ export function App() {
return (
<main
className={[
"simple-shell",
hasUnappliedChanges ? "has-change-dock" : "",
hasAdminPrompt ? "has-admin-prompt" : "",
]
className={["simple-shell", hasUnappliedChanges ? "has-change-dock" : ""]
.filter(Boolean)
.join(" ")}
style={shellStyle}
+51 -3
View File
@@ -9,6 +9,7 @@ import {
noticeFromConfigurationApply,
pingSummary,
routeChainSegments,
summaryRouteChainSegments,
} from "./viewModel";
const runningProxiFyre: ComponentStatus = {
@@ -39,15 +40,62 @@ describe("App view helpers", () => {
"apps",
"proxifyre",
"endpoint",
"exit",
]);
expect(segments[2]).toMatchObject({
value: "proxy.example.test:1080",
tone: "ok",
});
expect(segments[3].details).toContain(
"Local sing-box не нужен для этого маршрута.",
expect(segments[2].details).toContain(
"Трафик пойдет через внешний SOCKS5.",
);
expect(segments[1].details).toEqual([
"Служба запущена и готова к маршрутизации.",
]);
});
it("keeps three summary route slots while components are being detected", () => {
const input = {
routeMode: "external" as const,
proxyInput: "",
proxyfier: undefined,
singbox: undefined,
singBoxStatus: null,
selectedServer: null,
appCount: 0,
isDetectingComponents: true,
};
expect(
summaryRouteChainSegments(input, "idle").map(({ id }) => id),
).toEqual(["apps", "proxifyre", "endpoint"]);
});
it("keeps three summary route slots when traffic bypasses ProxiFyre", () => {
const stoppedProxiFyre: ComponentStatus = {
...runningProxiFyre,
state: "stopped",
running: false,
};
const segments = summaryRouteChainSegments(
{
routeMode: "external",
proxyInput: "proxy.example.test:1080",
proxyfier: stoppedProxiFyre,
singbox: undefined,
singBoxStatus: null,
selectedServer: null,
appCount: 2,
isDetectingComponents: false,
},
"direct",
);
expect(segments.map(({ id }) => id)).toEqual([
"apps",
"proxifyre",
"endpoint",
]);
expect(segments[2]).toMatchObject({ label: "Интернет", value: "напрямую" });
});
it("summarizes the fastest successful ping", () => {
+27 -77
View File
@@ -396,47 +396,20 @@ export function summaryRouteChainSegments(
input: RouteChainInput,
flow: SummaryRouteFlow,
): RouteChainSegment[] {
if (flow === "proxy") return routeChainSegments(input);
const appSegment: RouteChainSegment = {
id: "apps",
label: "Приложения",
value: appCountCompact(input.appCount),
tone: input.appCount > 0 ? "ok" : "warning",
details: [
input.appCount > 0
? appCountText(input.appCount)
: "Добавь хотя бы одно приложение на вкладке ProxiFyre.",
],
};
if (flow === "idle") {
return [
appSegment,
{
id: "idle",
label: "Трафик",
value: input.isDetectingComponents ? "проверяю" : "ожидает приложения",
tone: input.isDetectingComponents ? "checking" : "warning",
details: input.isDetectingComponents
? ["Проверяю ProxiFyre и текущий маршрут."]
: [
"Пока нет выбранных приложений, ProxyWarden ничего не маршрутизирует.",
],
},
];
}
const configuredSegments = routeChainSegments(input);
if (flow !== "direct") return configuredSegments;
return [
appSegment,
configuredSegments[0],
configuredSegments[1],
{
id: "direct",
id: "endpoint",
label: "Интернет",
value: "напрямую",
tone: "warning",
details: [
proxyfierBypassReason(input.proxyfier),
"Пакеты идут обычным системным маршрутом без SOCKS5.",
"Пакеты идут обычным системным маршрутом.",
],
},
];
@@ -449,9 +422,6 @@ export function routeChainSegments(
input.routeMode === "external" && input.proxyInput.trim()
? safeProxyError(input.proxyInput)
: null;
const localServer = input.singBoxStatus?.config.selectedServerTag
? displayServerTag(input.singBoxStatus.config.selectedServerTag)
: "сервер не выбран";
const endpoint =
input.routeMode === "local-singbox"
? localSingBoxAddress(input.singBoxStatus)
@@ -467,15 +437,6 @@ export function routeChainSegments(
: input.singbox?.installed
? "warning"
: "warning";
const exitTone: StatusTone =
input.routeMode === "external"
? proxyValidation || !input.proxyInput.trim()
? "warning"
: "ok"
: input.singbox?.running && input.selectedServer
? "ok"
: "warning";
return [
{
id: "apps",
@@ -484,8 +445,8 @@ export function routeChainSegments(
tone: input.appCount > 0 ? "ok" : "warning",
details: [
input.appCount > 0
? appCountText(input.appCount)
: "Добавь хотя бы одно приложение на вкладке ProxiFyre.",
? "Эти приложения будут идти через выбранный маршрут."
: "Добавь приложение на вкладке ProxiFyre.",
],
},
{
@@ -497,8 +458,7 @@ export function routeChainSegments(
),
tone: componentChainTone(input.proxyfier, input.isDetectingComponents),
details: [
proxyfierTitle(input.proxyfier, input.isDetectingComponents),
proxyfierDetails(input.proxyfier, input.isDetectingComponents),
routeProxyfierDetails(input.proxyfier, input.isDetectingComponents),
],
},
{
@@ -511,35 +471,15 @@ export function routeChainSegments(
tone: endpointTone,
details:
input.routeMode === "local-singbox"
? singBoxDetailLines(
input.singbox,
input.singBoxStatus,
null,
input.singBoxStatus?.config.selectedServerTag,
)
? input.singbox?.running && input.selectedServer
? ["Трафик пойдет через выбранный VPN-сервер."]
: input.singbox?.installed
? ["Local sing-box установлен, но пока не запущен."]
: ["Установи Local sing-box для локального маршрута."]
: [
`Endpoint: ${endpoint}`,
proxyValidation ?? "Формат внешнего SOCKS5 корректен.",
"Local sing-box не участвует во внешнем маршруте.",
],
},
{
id: "exit",
label: input.routeMode === "local-singbox" ? "VPN сервер" : "Выход",
value:
input.routeMode === "local-singbox" ? localServer : "внешний SOCKS5",
tone: exitTone,
details:
input.routeMode === "local-singbox"
? [
input.selectedServer
? serverLabel(input.selectedServer)
: "Сервер Local sing-box не выбран.",
"Применение создаст sing-box config и обновит ProxiFyre.",
]
: [
"Выбранные приложения идут через внешний SOCKS5.",
"Local sing-box не нужен для этого маршрута.",
proxyValidation
? `Проверь адрес SOCKS5: ${proxyValidation}`
: "Трафик пойдет через внешний SOCKS5.",
],
},
];
@@ -850,6 +790,16 @@ export function proxyfierCompactStatus(
return `служба ${serviceStatusLabel(component.serviceStatus)}`;
}
export function routeProxyfierDetails(
component: ComponentStatus | undefined,
checking: boolean,
) {
if (checking) return "Проверяю клиент и службу.";
if (!component?.installed) return "Клиент ProxiFyre не найден.";
if (component.running) return "Служба запущена и готова к маршрутизации.";
return "Служба ProxiFyre остановлена.";
}
export function proxyfierBypassReason(component: ComponentStatus | undefined) {
if (!component?.installed) {
return "ProxiFyre не найден, поэтому выбранные приложения не перехватываются.";
+345 -74
View File
@@ -2,7 +2,6 @@
:root {
--app-footer-height: 54px;
--app-change-dock-height: 0px;
--app-admin-prompt-height: 0px;
--change-row-count: 1;
--app-header-row-height: 0px;
--app-tab-height: 46px;
@@ -815,10 +814,7 @@ button:disabled {
),
var(--surface-canvas);
padding: var(--app-header-height) 0
calc(
var(--app-footer-height) + var(--app-change-dock-height) +
var(--app-admin-prompt-height)
);
calc(var(--app-footer-height) + var(--app-change-dock-height));
}
.simple-shell.has-change-dock {
@@ -829,10 +825,6 @@ button:disabled {
);
}
.simple-shell.has-admin-prompt {
--app-admin-prompt-height: 56px;
}
.simple-panel {
display: grid;
grid-template-rows: minmax(0, 1fr);
@@ -840,7 +832,7 @@ button:disabled {
min-height: 0;
height: calc(
100vh - var(--app-header-height) - var(--app-footer-height) -
var(--app-change-dock-height) - var(--app-admin-prompt-height)
var(--app-change-dock-height)
);
width: 100%;
border: 0;
@@ -974,34 +966,162 @@ button:disabled {
}
.admin-prompt {
--admin-prompt-width: min(520px, calc(100vw - 28px));
--admin-prompt-expand-ease: cubic-bezier(0.72, 0, 1, 1);
--admin-prompt-collapse-ease: cubic-bezier(0, 0, 0.28, 1);
position: fixed;
right: 0;
bottom: 0;
left: 0;
z-index: 40;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
height: var(--app-admin-prompt-height);
min-width: 0;
border-top: 1px solid rgba(245, 158, 11, 0.38);
background: #17150f;
box-shadow: inset 0 1px rgba(255, 255, 255, 0.03);
right: 14px;
bottom: calc(var(--app-footer-height) + var(--app-change-dock-height) + 12px);
z-index: 50;
width: var(--admin-prompt-width);
height: 44px;
min-height: 44px;
margin: 0;
padding: 5px 14px;
padding: 0;
pointer-events: none;
}
.admin-prompt-icon {
.admin-prompt::before {
position: absolute;
z-index: 0;
inset: 0;
border: 0;
border-radius: 22px;
background: #17150f;
box-shadow: 0 14px 36px oklch(0.08 0.012 145 / 0.32);
content: "";
opacity: 0;
pointer-events: none;
transform-origin: right center;
filter: blur(5px);
transform: translateX(8px) scaleX(0.06);
will-change: filter, opacity, transform;
transition:
opacity 260ms var(--admin-prompt-collapse-ease) 140ms,
filter 320ms var(--admin-prompt-collapse-ease),
transform 420ms var(--admin-prompt-collapse-ease);
}
.admin-prompt.is-expanded {
pointer-events: auto;
}
.admin-prompt.is-expanded::before {
opacity: 1;
filter: blur(0);
transform: translateX(0) scaleX(1);
transition:
opacity 160ms linear,
filter 420ms var(--admin-prompt-expand-ease),
transform 560ms var(--admin-prompt-expand-ease);
}
.admin-prompt::after {
position: absolute;
z-index: 0;
top: 5px;
right: 44px;
bottom: 5px;
width: 140px;
border-radius: 17px;
background: linear-gradient(
90deg,
transparent,
oklch(0.78 0.12 82 / 0.11),
transparent
);
content: "";
opacity: 0;
pointer-events: none;
filter: blur(8px);
transform: translateX(70%) scaleX(0.25);
}
.admin-prompt.is-expanded::after {
animation: admin-prompt-light-pass 560ms var(--admin-prompt-expand-ease) both;
}
.admin-prompt-toggle {
position: absolute;
z-index: 2;
top: 0;
left: 0;
display: grid;
place-items: center;
width: 26px;
min-width: 26px;
height: 26px;
border: 1px solid rgba(245, 158, 11, 0.28);
border-radius: 4px;
background: rgba(15, 23, 42, 0.42);
color: #fcd34d;
width: 44px;
height: 44px;
border: 1px solid oklch(0.71 0.12 72 / 0.34);
border-radius: 50%;
background: color-mix(in oklch, var(--surface-raised) 90%, transparent);
box-shadow: 0 8px 24px oklch(0.08 0.012 145 / 0.28);
color: oklch(0.86 0.14 86);
cursor: pointer;
pointer-events: auto;
transition:
background-color var(--motion-fast) var(--ease-out),
border-color var(--motion-fast) var(--ease-out),
color var(--motion-fast) var(--ease-out),
filter var(--motion-fast) var(--ease-out),
transform 360ms var(--ease-out);
transform: translateX(calc(var(--admin-prompt-width) - 44px));
}
.admin-prompt-toggle:hover {
border-color: oklch(0.78 0.14 80 / 0.7);
background: color-mix(in oklch, var(--surface-raised) 100%, transparent);
filter: drop-shadow(0 0 12px oklch(0.71 0.12 72 / 0.35));
transform: translateX(calc(var(--admin-prompt-width) - 44px)) translateY(-1px);
}
.admin-prompt-toggle:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 3px;
}
.admin-prompt-details {
position: absolute;
z-index: 1;
top: 0;
bottom: 0;
left: 8px;
right: 52px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
min-width: 0;
padding: 0 0 0 8px;
clip-path: inset(0 0 0 100%);
opacity: 0;
pointer-events: none;
filter: blur(4px);
transform: translateX(18px);
will-change: clip-path, filter, opacity, transform;
transition:
clip-path 420ms var(--admin-prompt-collapse-ease),
opacity 220ms var(--admin-prompt-collapse-ease) 100ms,
filter 320ms var(--admin-prompt-collapse-ease),
transform 420ms var(--admin-prompt-collapse-ease);
}
.admin-prompt.is-expanded .admin-prompt-toggle {
border-color: oklch(0.78 0.14 80 / 0.6);
background: color-mix(in oklch, var(--surface-raised) 90%, transparent);
box-shadow: 0 8px 24px oklch(0.08 0.012 145 / 0.28);
transform: translateX(calc(var(--admin-prompt-width) - 44px));
}
.admin-prompt.is-expanded .admin-prompt-details {
clip-path: inset(0 0 0 0);
opacity: 1;
pointer-events: auto;
filter: blur(0);
transform: translateX(0);
transition:
clip-path 560ms var(--admin-prompt-expand-ease),
opacity 180ms linear,
filter 420ms var(--admin-prompt-expand-ease),
transform 560ms var(--admin-prompt-expand-ease);
}
.admin-prompt-copy {
@@ -1012,26 +1132,97 @@ button:disabled {
.admin-prompt-copy strong {
color: #f8fafc;
font-size: 12px;
font-size: 11px;
font-weight: 800;
line-height: 1.25;
white-space: nowrap;
white-space: normal;
}
.admin-prompt-copy span {
display: -webkit-box;
display: block;
max-width: 330px;
color: #c7b489;
font-size: 11px;
line-height: 1.25;
overflow: hidden;
white-space: normal;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
font-size: 10px;
line-height: 1.1;
overflow-wrap: anywhere;
}
.admin-prompt-action {
min-width: 190px;
white-space: nowrap;
min-width: 180px;
max-width: 190px;
white-space: normal;
}
.admin-prompt.is-expanded .admin-prompt-toggle:hover {
background: oklch(0.71 0.12 72 / 0.08);
transform: translateX(calc(var(--admin-prompt-width) - 44px)) translateY(-1px);
}
.admin-prompt.is-collapsed .admin-prompt-toggle,
.admin-prompt.has-hint .admin-prompt-toggle {
animation: admin-prompt-pulse 3.6s ease-in-out infinite;
}
.admin-prompt-hint {
position: absolute;
z-index: 1;
top: 0;
right: 52px;
display: grid;
align-items: center;
width: min(310px, calc(100vw - 80px));
height: 44px;
border: 0;
border-radius: 22px;
background: color-mix(in oklch, var(--surface-raised) 92%, transparent);
box-shadow: 0 10px 26px oklch(0.08 0.012 145 / 0.24);
color: #c7b489;
font-size: 10px;
line-height: 1.25;
padding: 0 14px;
pointer-events: none;
animation: admin-prompt-hint-arrive 520ms var(--ease-out) both;
}
@keyframes admin-prompt-hint-arrive {
from {
opacity: 0;
filter: blur(4px);
transform: translateX(10px);
}
to {
opacity: 1;
filter: blur(0);
transform: translateX(0);
}
}
@keyframes admin-prompt-light-pass {
0% {
opacity: 0;
transform: translateX(70%) scaleX(0.25);
}
38% {
opacity: 0.22;
}
100% {
opacity: 0;
transform: translateX(-240%) scaleX(0.9);
}
}
@keyframes admin-prompt-pulse {
0%,
100% {
filter: drop-shadow(0 0 0 oklch(0.71 0.12 72 / 0));
}
50% {
filter: drop-shadow(0 0 12px oklch(0.71 0.12 72 / 0.34));
}
}
.tab-panel-frame {
@@ -2094,6 +2285,7 @@ button.summary-card:hover {
gap: 10px;
width: 100%;
max-width: 288px;
min-height: 194px;
margin-top: 0;
}
@@ -2196,6 +2388,7 @@ button.summary-card:hover {
min-height: 58px;
background: color-mix(in oklch, var(--surface-panel) 94%, transparent);
padding: 9px 10px 9px 9px;
animation: none;
}
.route-chain-segment > :not(.ui-busy-ring) {
@@ -2304,7 +2497,7 @@ button.summary-card:hover {
.changes-dock {
position: fixed;
right: 0;
bottom: calc(var(--app-footer-height) + var(--app-admin-prompt-height));
bottom: var(--app-footer-height);
left: 0;
z-index: 38;
display: grid;
@@ -2740,7 +2933,7 @@ button.summary-card:hover {
.log-dock {
position: fixed;
right: 0;
bottom: var(--app-admin-prompt-height);
bottom: 0;
left: 0;
z-index: 40;
display: grid;
@@ -3220,9 +3413,7 @@ button.summary-card:hover {
.log-toggle,
.log-count,
.log-history,
.log-history-row,
.admin-prompt,
.admin-prompt-icon {
.log-history-row {
border: 0;
box-shadow: none;
}
@@ -4171,6 +4362,7 @@ button.summary-card:hover {
.route-chain--vertical .route-chain-segment {
background: color-mix(in oklch, var(--surface-panel) 34%, transparent);
animation: float-item-in 900ms var(--ease-out) both;
}
.route-chain-segment:hover {
@@ -4340,8 +4532,7 @@ button.summary-card:hover {
}
.changes-dock,
.log-dock,
.admin-prompt {
.log-dock {
background: color-mix(in oklch, var(--surface-raised) 78%, transparent);
box-shadow: 0 -14px 42px oklch(0.08 0.012 145 / 0.2);
backdrop-filter: blur(18px);
@@ -4404,9 +4595,49 @@ button.summary-card:hover {
color: oklch(0.8 0.12 82);
}
.admin-prompt-icon {
background: transparent;
filter: drop-shadow(0 0 12px oklch(0.71 0.12 72 / 0.28));
.admin-prompt-action.ui-button {
justify-self: end;
min-width: 118px;
max-width: none;
min-height: 30px;
border: 0;
border-radius: 999px;
background: oklch(0.71 0.12 72 / 0.1);
box-shadow: none;
color: oklch(0.82 0.1 84);
font-size: 10px;
font-weight: 750;
letter-spacing: 0.01em;
padding: 6px 12px;
text-shadow: none;
transition:
background-color 180ms var(--ease-out),
border-color 180ms var(--ease-out),
box-shadow 180ms var(--ease-out),
color 180ms var(--ease-out),
transform 180ms var(--ease-out);
}
.admin-prompt-action.ui-button:hover:not(:disabled) {
border: 0;
background: oklch(0.71 0.12 72 / 0.15);
box-shadow: none;
color: oklch(0.87 0.1 84);
filter: none;
transform: none;
}
.admin-prompt-action.ui-button:active:not(:disabled) {
background: oklch(0.71 0.12 72 / 0.18);
filter: none;
transform: scale(0.98);
}
.admin-prompt-action.ui-button:focus-visible {
border: 0;
box-shadow:
0 0 0 2px var(--focus-ring),
0 0 0 4px oklch(0.68 0.11 185 / 0.1);
}
.ui-icon-button[data-tooltip]::before,
@@ -4686,15 +4917,45 @@ button.summary-card:hover {
filter: none;
transform: none;
}
.admin-prompt::before,
.admin-prompt::after,
.admin-prompt-toggle,
.admin-prompt-details,
.admin-prompt-action.ui-button {
transition: none;
}
.admin-prompt.is-collapsed .admin-prompt-toggle,
.admin-prompt.has-hint .admin-prompt-toggle,
.admin-prompt::after {
animation: none;
}
.admin-prompt::after {
opacity: 0;
}
.admin-prompt.is-collapsed .admin-prompt-toggle:hover {
filter: none;
transform: translateX(calc(var(--admin-prompt-width) - 44px));
}
.admin-prompt.is-expanded .admin-prompt-toggle:hover {
filter: none;
transform: translateX(calc(var(--admin-prompt-width) - 44px));
}
.admin-prompt-action.ui-button:hover:not(:disabled) {
filter: none;
transform: none;
}
}
@media (max-width: 680px) {
.simple-shell {
padding: var(--app-header-height) 0
calc(
var(--app-footer-height) + var(--app-change-dock-height) +
var(--app-admin-prompt-height)
);
calc(var(--app-footer-height) + var(--app-change-dock-height));
}
.simple-shell.has-change-dock {
@@ -4705,37 +4966,47 @@ button.summary-card:hover {
);
}
.simple-shell.has-admin-prompt {
--app-admin-prompt-height: 92px;
}
.simple-panel {
height: calc(
100vh - var(--app-header-height) - var(--app-footer-height) -
var(--app-change-dock-height) - var(--app-admin-prompt-height)
var(--app-change-dock-height)
);
padding: 12px 12px 16px;
}
.admin-prompt {
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
gap: 8px;
padding: 8px 10px;
--admin-prompt-width: calc(100vw - 20px);
right: 10px;
bottom: calc(
var(--app-footer-height) + var(--app-change-dock-height) + 10px
);
}
.admin-prompt.is-expanded {
width: var(--admin-prompt-width);
}
.admin-prompt-details {
grid-template-columns: 1fr;
width: auto;
padding: 0 0 0 8px;
}
.admin-prompt-hint {
right: 52px;
width: min(280px, calc(100vw - 80px));
}
.admin-prompt-copy span {
display: -webkit-box;
overflow: hidden;
max-width: none;
white-space: normal;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.admin-prompt-action {
grid-column: 1 / -1;
width: 100%;
min-width: 0;
width: fit-content;
justify-self: end;
max-width: none;
min-width: 118px;
}
.app-row {
@@ -5026,7 +5297,7 @@ button.summary-card:hover {
.log-dock {
position: fixed;
right: 0;
bottom: var(--app-admin-prompt-height);
bottom: 0;
left: 0;
grid-template-columns: minmax(0, 1fr) auto;
margin: 0;