feat: add stable presentation demo footer rail
This commit is contained in:
@@ -0,0 +1,89 @@
|
|||||||
|
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { DemoChromePresentation } from "./presentation-demo-chrome.js";
|
||||||
|
import { PresentationDemoRail } from "./PresentationDemoRail.js";
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
const readyAction: DemoChromePresentation = {
|
||||||
|
kind: "action",
|
||||||
|
mode: "live",
|
||||||
|
label: "Run prepared workflow",
|
||||||
|
status: {
|
||||||
|
kind: "ready",
|
||||||
|
target: "http://127.0.0.1:8765/rpc",
|
||||||
|
label: "Live target ready",
|
||||||
|
detail: "127.0.0.1:8765",
|
||||||
|
},
|
||||||
|
canRun: true,
|
||||||
|
canRetry: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("PresentationDemoRail", () => {
|
||||||
|
it("renders no content when demo chrome is hidden", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<PresentationDemoRail presentation={{ kind: "hidden" }} retryHealth={vi.fn()} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the target badge and runs a healthy live action", () => {
|
||||||
|
const runPreparedWorkflow = vi.fn(async () => {});
|
||||||
|
render(
|
||||||
|
<PresentationDemoRail
|
||||||
|
presentation={readyAction}
|
||||||
|
runPreparedWorkflow={runPreparedWorkflow}
|
||||||
|
retryHealth={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const rail = screen.getByTestId("presentation-demo-rail");
|
||||||
|
expect(within(rail).getByLabelText("presentation evidence mode")).toBeInTheDocument();
|
||||||
|
fireEvent.click(within(rail).getByRole("button", { name: "Run prepared workflow" }));
|
||||||
|
expect(runPreparedWorkflow).toHaveBeenCalledWith("live");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses replay action and retry when live health has failed", () => {
|
||||||
|
const retryHealth = vi.fn();
|
||||||
|
render(
|
||||||
|
<PresentationDemoRail
|
||||||
|
presentation={{
|
||||||
|
...readyAction,
|
||||||
|
mode: "replay",
|
||||||
|
label: "Play replay walkthrough",
|
||||||
|
status: {
|
||||||
|
kind: "failed",
|
||||||
|
target: "http://127.0.0.1:8765/rpc",
|
||||||
|
label: "Replay fallback",
|
||||||
|
detail: "connection refused",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
retryHealth={retryHealth}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const rail = screen.getByTestId("presentation-demo-rail");
|
||||||
|
expect(within(rail).getByRole("button", { name: "Play replay walkthrough" })).toBeInTheDocument();
|
||||||
|
fireEvent.click(within(rail).getByRole("button", { name: "Retry live service" }));
|
||||||
|
expect(retryHealth).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["running", "Running workflow..."],
|
||||||
|
["paused", "Run paused - review required"],
|
||||||
|
["resuming", "Resuming workflow..."],
|
||||||
|
["completed", "Run complete"],
|
||||||
|
] as const)("renders %s as status content without a disabled run button", (kind, label) => {
|
||||||
|
render(
|
||||||
|
<PresentationDemoRail
|
||||||
|
presentation={{ kind, label } as DemoChromePresentation}
|
||||||
|
retryHealth={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const rail = screen.getByTestId("presentation-demo-rail");
|
||||||
|
expect(within(rail).getByRole("status")).toHaveTextContent(label);
|
||||||
|
expect(within(rail).queryByRole("button")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import type { JSX } from "react";
|
||||||
|
import type { TimelineAgentMode } from "../demo/agent/timelineAgent.js";
|
||||||
|
import type { DemoChromePresentation } from "./presentation-demo-chrome.js";
|
||||||
|
import { PresentationTruthBadge } from "./PresentationTruthBadge.js";
|
||||||
|
|
||||||
|
type PresentationDemoRailProps = {
|
||||||
|
readonly presentation: DemoChromePresentation;
|
||||||
|
readonly runPreparedWorkflow?: ((mode: TimelineAgentMode) => Promise<void>) | undefined;
|
||||||
|
readonly retryHealth: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PresentationDemoRail = ({
|
||||||
|
presentation,
|
||||||
|
runPreparedWorkflow,
|
||||||
|
retryHealth,
|
||||||
|
}: PresentationDemoRailProps): JSX.Element | null => {
|
||||||
|
if (presentation.kind === "hidden") return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="presentation-demo-rail"
|
||||||
|
data-demo-rail={presentation.kind}
|
||||||
|
data-testid="presentation-demo-rail"
|
||||||
|
>
|
||||||
|
{presentation.kind === "action" ? (
|
||||||
|
<>
|
||||||
|
<PresentationTruthBadge status={presentation.status} />
|
||||||
|
<div className="presentation-demo-rail__actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="presentation-demo-rail__primary"
|
||||||
|
onClick={() => void runPreparedWorkflow?.(presentation.mode)}
|
||||||
|
disabled={!presentation.canRun || runPreparedWorkflow === undefined}
|
||||||
|
>
|
||||||
|
{presentation.label}
|
||||||
|
</button>
|
||||||
|
{presentation.canRetry ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="presentation-demo-rail__retry"
|
||||||
|
onClick={retryHealth}
|
||||||
|
>
|
||||||
|
Retry live service
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="presentation-demo-rail__status" role="status" aria-live="polite">
|
||||||
|
{presentation.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { cleanup, render, screen, within } from "@testing-library/react";
|
import { cleanup, render, screen, within } from "@testing-library/react";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { EvidenceRecord } from "../app/state.js";
|
import type { EvidenceRecord } from "../app/state.js";
|
||||||
import type { PresentationTargetHealth } from "./presentation-target-status.js";
|
import type { DemoChromePresentation } from "./presentation-demo-chrome.js";
|
||||||
import { PresentationFooter } from "./PresentationFooter.js";
|
import { PresentationFooter } from "./PresentationFooter.js";
|
||||||
|
|
||||||
afterEach(() => cleanup());
|
afterEach(() => cleanup());
|
||||||
@@ -17,10 +17,8 @@ describe("PresentationFooter", () => {
|
|||||||
response: { result: { status: "completed" } },
|
response: { result: { status: "completed" } },
|
||||||
durationMs: 34,
|
durationMs: 34,
|
||||||
};
|
};
|
||||||
const replayHealth: PresentationTargetHealth = {
|
const hiddenRail: DemoChromePresentation = {
|
||||||
kind: "replay",
|
kind: "hidden",
|
||||||
label: "Replay evidence",
|
|
||||||
detail: "reviewed recording",
|
|
||||||
};
|
};
|
||||||
render(
|
render(
|
||||||
<PresentationFooter
|
<PresentationFooter
|
||||||
@@ -31,7 +29,8 @@ describe("PresentationFooter", () => {
|
|||||||
focusPath: [],
|
focusPath: [],
|
||||||
}}
|
}}
|
||||||
evidence={[evidence]}
|
evidence={[evidence]}
|
||||||
targetStatus={replayHealth}
|
demoRail={hiddenRail}
|
||||||
|
retryHealth={vi.fn()}
|
||||||
showEvidenceReceipt
|
showEvidenceReceipt
|
||||||
inspectEvidence={vi.fn()}
|
inspectEvidence={vi.fn()}
|
||||||
/>,
|
/>,
|
||||||
@@ -52,11 +51,8 @@ describe("PresentationFooter", () => {
|
|||||||
focusPath: [],
|
focusPath: [],
|
||||||
}}
|
}}
|
||||||
evidence={[]}
|
evidence={[]}
|
||||||
targetStatus={{
|
demoRail={{ kind: "hidden" }}
|
||||||
kind: "replay",
|
retryHealth={vi.fn()}
|
||||||
label: "Replay evidence",
|
|
||||||
detail: "reviewed recording",
|
|
||||||
}}
|
|
||||||
showEvidenceReceipt={false}
|
showEvidenceReceipt={false}
|
||||||
inspectEvidence={vi.fn()}
|
inspectEvidence={vi.fn()}
|
||||||
/>,
|
/>,
|
||||||
@@ -65,4 +61,32 @@ describe("PresentationFooter", () => {
|
|||||||
const footer = screen.getByRole("contentinfo", { name: /presentation footer/i });
|
const footer = screen.getByRole("contentinfo", { name: /presentation footer/i });
|
||||||
expect(within(footer).queryByLabelText("presentation evidence mode")).not.toBeInTheDocument();
|
expect(within(footer).queryByLabelText("presentation evidence mode")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders exactly one healthy demo rail for a demo location", () => {
|
||||||
|
render(
|
||||||
|
<PresentationFooter
|
||||||
|
location={{ kind: "main", sceneId: "agent-handoff", beatId: "request", focusPath: [] }}
|
||||||
|
evidence={[]}
|
||||||
|
demoRail={{
|
||||||
|
kind: "action",
|
||||||
|
mode: "live",
|
||||||
|
label: "Run prepared workflow",
|
||||||
|
status: {
|
||||||
|
kind: "ready",
|
||||||
|
target: "http://127.0.0.1:8765/rpc",
|
||||||
|
label: "Live target ready",
|
||||||
|
detail: "127.0.0.1:8765",
|
||||||
|
},
|
||||||
|
canRun: true,
|
||||||
|
canRetry: true,
|
||||||
|
}}
|
||||||
|
retryHealth={vi.fn()}
|
||||||
|
showEvidenceReceipt={false}
|
||||||
|
inspectEvidence={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getAllByTestId("presentation-demo-rail")).toHaveLength(1);
|
||||||
|
expect(screen.getByText("Live target ready")).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import type { EvidenceRecord } from "../app/state.js";
|
import type { EvidenceRecord } from "../app/state.js";
|
||||||
|
import type { TimelineAgentMode } from "../demo/agent/timelineAgent.js";
|
||||||
import { SceneProgress } from "./SceneProgress.js";
|
import { SceneProgress } from "./SceneProgress.js";
|
||||||
import { EvidenceReceipt } from "./evidence/EvidenceReceipt.js";
|
import { EvidenceReceipt } from "./evidence/EvidenceReceipt.js";
|
||||||
import { PresentationTruthBadge } from "./PresentationTruthBadge.js";
|
import type { DemoChromePresentation } from "./presentation-demo-chrome.js";
|
||||||
import type { PresentationTargetHealth } from "./presentation-target-status.js";
|
import { PresentationDemoRail } from "./PresentationDemoRail.js";
|
||||||
import { isDemoChromeScene } from "./presentation-demo-chrome.js";
|
|
||||||
import type { MainLocation } from "./storyboard.js";
|
import type { MainLocation } from "./storyboard.js";
|
||||||
|
|
||||||
type PresentationFooterProps = {
|
type PresentationFooterProps = {
|
||||||
readonly location: MainLocation;
|
readonly location: MainLocation;
|
||||||
readonly evidence: readonly EvidenceRecord[];
|
readonly evidence: readonly EvidenceRecord[];
|
||||||
readonly targetStatus: PresentationTargetHealth;
|
readonly demoRail: DemoChromePresentation;
|
||||||
|
readonly runPreparedWorkflow?: ((mode: TimelineAgentMode) => Promise<void>) | undefined;
|
||||||
|
readonly retryHealth: () => void;
|
||||||
readonly showEvidenceReceipt: boolean;
|
readonly showEvidenceReceipt: boolean;
|
||||||
readonly inspectEvidence: () => void;
|
readonly inspectEvidence: () => void;
|
||||||
};
|
};
|
||||||
@@ -17,13 +19,19 @@ type PresentationFooterProps = {
|
|||||||
export const PresentationFooter = ({
|
export const PresentationFooter = ({
|
||||||
location,
|
location,
|
||||||
evidence,
|
evidence,
|
||||||
targetStatus,
|
demoRail,
|
||||||
|
runPreparedWorkflow,
|
||||||
|
retryHealth,
|
||||||
showEvidenceReceipt,
|
showEvidenceReceipt,
|
||||||
inspectEvidence,
|
inspectEvidence,
|
||||||
}: PresentationFooterProps) => (
|
}: PresentationFooterProps) => (
|
||||||
<footer className="presentation-footer" aria-label="presentation footer">
|
<footer className="presentation-footer" aria-label="presentation footer">
|
||||||
<SceneProgress location={location} />
|
<SceneProgress location={location} />
|
||||||
{isDemoChromeScene(location.sceneId) && <PresentationTruthBadge status={targetStatus} />}
|
<PresentationDemoRail
|
||||||
|
presentation={demoRail}
|
||||||
|
runPreparedWorkflow={runPreparedWorkflow}
|
||||||
|
retryHealth={retryHealth}
|
||||||
|
/>
|
||||||
<EvidenceReceipt
|
<EvidenceReceipt
|
||||||
records={evidence}
|
records={evidence}
|
||||||
visible={showEvidenceReceipt}
|
visible={showEvidenceReceipt}
|
||||||
|
|||||||
@@ -271,13 +271,13 @@ describe("PresentationRoute", () => {
|
|||||||
expect(screen.getByRole("button", { name: /inspect evidence/i })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: /inspect evidence/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the Scene 8 composer without a workflow run action", async () => {
|
it("renders the Scene 8 composer with one stable footer workflow action", async () => {
|
||||||
window.location.hash = "#scene/agent-handoff/request";
|
window.location.hash = "#scene/agent-handoff/request";
|
||||||
const { PresentationRoute } = await import("./PresentationRoute.js");
|
const { PresentationRoute } = await import("./PresentationRoute.js");
|
||||||
render(<PresentationRoute />);
|
render(<PresentationRoute />);
|
||||||
|
|
||||||
expect(await screen.findByRole("textbox", { name: /authoring request/i })).toBeInTheDocument();
|
expect(await screen.findByRole("textbox", { name: /authoring request/i })).toBeInTheDocument();
|
||||||
expect(screen.queryByRole("button", { name: "Run prepared workflow" })).not.toBeInTheDocument();
|
expect(screen.getAllByRole("button", { name: "Run prepared workflow" })).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses stored target for live presentation mode", async () => {
|
it("uses stored target for live presentation mode", async () => {
|
||||||
@@ -286,7 +286,7 @@ describe("PresentationRoute", () => {
|
|||||||
const { PresentationRoute } = await import("./PresentationRoute.js");
|
const { PresentationRoute } = await import("./PresentationRoute.js");
|
||||||
render(<PresentationRoute />);
|
render(<PresentationRoute />);
|
||||||
|
|
||||||
expect(await screen.findAllByRole("button", { name: /run prepared workflow/i })).toHaveLength(2);
|
expect(await screen.findAllByRole("button", { name: /run prepared workflow/i })).toHaveLength(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("exposes an explicit live launch on the Scene 10 operation beat", async () => {
|
it("exposes an explicit live launch on the Scene 10 operation beat", async () => {
|
||||||
@@ -353,7 +353,7 @@ describe("PresentationRoute", () => {
|
|||||||
|
|
||||||
expect(await screen.findByText(/Live target is ready/i)).toBeInTheDocument();
|
expect(await screen.findByText(/Live target is ready/i)).toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: "Send" })).toBeEnabled();
|
expect(screen.getByRole("button", { name: "Send" })).toBeEnabled();
|
||||||
expect(screen.queryByRole("button", { name: "Run prepared workflow" })).not.toBeInTheDocument();
|
expect(screen.getAllByRole("button", { name: "Run prepared workflow" })).toHaveLength(1);
|
||||||
expect(mockedCallOperation).toHaveBeenCalledWith("workflow.health", "http://127.0.0.1:8765/rpc", {});
|
expect(mockedCallOperation).toHaveBeenCalledWith("workflow.health", "http://127.0.0.1:8765/rpc", {});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { PresentationFooter } from "./PresentationFooter.js";
|
|||||||
import type { PresentationState } from "./presentation-state.js";
|
import type { PresentationState } from "./presentation-state.js";
|
||||||
import { compositionForState } from "./presentation-state.js";
|
import { compositionForState } from "./presentation-state.js";
|
||||||
import type { PresentationTargetHealth } from "./presentation-target-status.js";
|
import type { PresentationTargetHealth } from "./presentation-target-status.js";
|
||||||
|
import { demoChromeFor } from "./presentation-demo-chrome.js";
|
||||||
import type { DemoTimelineController } from "../demo/useDemoTimeline.js";
|
import type { DemoTimelineController } from "../demo/useDemoTimeline.js";
|
||||||
import { findScene, type MainLocation } from "./storyboard.js";
|
import { findScene, type MainLocation } from "./storyboard.js";
|
||||||
|
|
||||||
@@ -58,6 +59,20 @@ export const PresentationStage = ({
|
|||||||
const composition = compositionForState(state);
|
const composition = compositionForState(state);
|
||||||
|
|
||||||
const isMainScene = state.location.kind === "main";
|
const isMainScene = state.location.kind === "main";
|
||||||
|
// Keep the footer projection pure and derive it from the same state the scene consumes.
|
||||||
|
const demoRail = isMainScene
|
||||||
|
? demoChromeFor({
|
||||||
|
sceneId: state.location.sceneId,
|
||||||
|
phase: demo.state.phase,
|
||||||
|
mode: demo.state.mode,
|
||||||
|
inFlight: demo.inFlight,
|
||||||
|
approvalState: approvalActions?.state ?? "ready",
|
||||||
|
targetStatus,
|
||||||
|
liveTargetReady,
|
||||||
|
canRun: timelineAgent?.canRun ?? false,
|
||||||
|
canRunLive: timelineAgent?.canRunLive ?? false,
|
||||||
|
})
|
||||||
|
: { kind: "hidden" as const };
|
||||||
const activeSceneView = isMainScene
|
const activeSceneView = isMainScene
|
||||||
? findScene(state.location.sceneId)?.view ?? "unknown"
|
? findScene(state.location.sceneId)?.view ?? "unknown"
|
||||||
: "discussion";
|
: "discussion";
|
||||||
@@ -104,7 +119,9 @@ export const PresentationStage = ({
|
|||||||
<PresentationFooter
|
<PresentationFooter
|
||||||
location={state.location}
|
location={state.location}
|
||||||
evidence={evidence}
|
evidence={evidence}
|
||||||
targetStatus={targetStatus}
|
demoRail={demoRail}
|
||||||
|
runPreparedWorkflow={timelineAgent?.runPreparedWorkflow}
|
||||||
|
retryHealth={retryHealth}
|
||||||
showEvidenceReceipt={composition.evidencePresentation !== "hidden"}
|
showEvidenceReceipt={composition.evidencePresentation !== "hidden"}
|
||||||
inspectEvidence={openEvidence}
|
inspectEvidence={openEvidence}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1028,6 +1028,75 @@
|
|||||||
border-color: color-mix(in oklch, var(--accent-cyan) 65%, var(--stage-line));
|
border-color: color-mix(in oklch, var(--accent-cyan) 65%, var(--stage-line));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.presentation-demo-rail {
|
||||||
|
min-width: min(22rem, 42vw);
|
||||||
|
min-height: 1.9rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presentation-demo-rail__actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presentation-demo-rail button {
|
||||||
|
border: 1px solid var(--stage-line);
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
padding: 0.34rem 0.55rem;
|
||||||
|
background: var(--stage-surface);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font: 600 0.68rem/1 var(--font-interface);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presentation-demo-rail__primary {
|
||||||
|
border-color: color-mix(in oklch, var(--accent-cyan) 65%, var(--stage-line)) !important;
|
||||||
|
background: color-mix(in oklch, var(--accent-cyan) 14%, var(--stage-surface)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presentation-demo-rail button:hover:not(:disabled) {
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.presentation-demo-rail button:focus-visible {
|
||||||
|
outline: 2px solid var(--accent-cyan);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presentation-demo-rail button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presentation-demo-rail__status {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font: 600 0.68rem/1 var(--font-mono);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.presentation-demo-rail {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
justify-content: flex-start;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presentation-demo-rail .presentation-truth-badge {
|
||||||
|
max-width: 40%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presentation-demo-rail__actions {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.evidence-receipt:focus-visible,
|
.evidence-receipt:focus-visible,
|
||||||
.evidence-inspector button:focus-visible,
|
.evidence-inspector button:focus-visible,
|
||||||
.evidence-inspector select:focus-visible {
|
.evidence-inspector select:focus-visible {
|
||||||
|
|||||||
Reference in New Issue
Block a user