Improve startup responsiveness and admin prompt motion
CI / Windows baseline (push) Has been cancelled
CI / Windows baseline (push) Has been cancelled
This commit is contained in:
+84
-22
@@ -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}
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user