From da4b84962c13d7932f930aa5175a4600477ee119 Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Wed, 10 Jun 2026 15:44:52 +0100 Subject: [PATCH 1/2] drop type-aware ESLint to stop the lint OOM (#6602) --- frontend/eslint.config.mjs | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 65e97d496..11071641d 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -143,7 +143,9 @@ export default defineConfig( ], }, }, - // Stricter rules that not all sub-folders are conformant to yet + // Stricter rules that not all sub-folders are conformant to yet. + // Keep this non-type-aware: `parserOptions.project`/`projectService` here OOMs + // the lint step (builds the whole TS program); tsc covers type correctness. { files: srcGlobs, ignores: [ @@ -158,15 +160,8 @@ export default defineConfig( "editor/src/core/types/**/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/utils/**/*.{js,mjs,jsx,ts,tsx}", ], - languageOptions: { - parserOptions: { - project: true, - tsconfigRootDir: import.meta.dirname, - }, - }, rules: { "@typescript-eslint/no-explicit-any": "error", - "@typescript-eslint/no-unnecessary-type-assertion": "error", }, }, // Config for browser scripts From 9b877d4f8dbdf7f00c0a059cf54cc5caf2e69037 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Wed, 10 Jun 2026 15:47:47 +0100 Subject: [PATCH 2/2] Move agent section to fab (#6597) --- .../public/locales/en-GB/translation.toml | 4 + .../src/core/components/chat/ChatFAB.tsx | 8 + .../src/core/components/layout/Workbench.tsx | 4 + frontend/editor/src/core/styles/zIndex.ts | 4 + .../proprietary/components/chat/ChatFAB.css | 60 +++ .../proprietary/components/chat/ChatFAB.tsx | 198 +++++++++ frontend/editor/vite.config.ts | 5 + .../src/components/ChatFABWidget.stories.tsx | 385 ++++++++++++++++++ frontend/shared/components/ChatFABButton.css | 144 +++++++ .../components/ChatFABButton.stories.tsx | 37 ++ frontend/shared/components/ChatFABButton.tsx | 66 +++ frontend/shared/components/ChatFABWindow.css | 43 ++ .../components/ChatFABWindow.stories.tsx | 145 +++++++ frontend/shared/components/ChatFABWindow.tsx | 45 ++ frontend/shared/vite-env.d.ts | 1 + 15 files changed, 1149 insertions(+) create mode 100644 frontend/editor/src/core/components/chat/ChatFAB.tsx create mode 100644 frontend/editor/src/proprietary/components/chat/ChatFAB.css create mode 100644 frontend/editor/src/proprietary/components/chat/ChatFAB.tsx create mode 100644 frontend/portal/src/components/ChatFABWidget.stories.tsx create mode 100644 frontend/shared/components/ChatFABButton.css create mode 100644 frontend/shared/components/ChatFABButton.stories.tsx create mode 100644 frontend/shared/components/ChatFABButton.tsx create mode 100644 frontend/shared/components/ChatFABWindow.css create mode 100644 frontend/shared/components/ChatFABWindow.stories.tsx create mode 100644 frontend/shared/components/ChatFABWindow.tsx create mode 100644 frontend/shared/vite-env.d.ts diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index b3f5109ce..4310a4b7a 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -2736,6 +2736,10 @@ unsupported_capability = "Unsupported capability: {{capability}}" summary = "Ran {{count}} tools" unknownTool = "Unknown tool" +[chat.fab] +close = "Close chat" +open = "Open Stirling AI assistant" + [cloudBadge] tooltip = "This operation will use your cloud credits" diff --git a/frontend/editor/src/core/components/chat/ChatFAB.tsx b/frontend/editor/src/core/components/chat/ChatFAB.tsx new file mode 100644 index 000000000..548821369 --- /dev/null +++ b/frontend/editor/src/core/components/chat/ChatFAB.tsx @@ -0,0 +1,8 @@ +/** + * Core stub for the floating action button chat widget. + * The real implementation lives in proprietary/components/chat/ChatFAB.tsx + * and shadows this via the @app/* alias cascade in proprietary builds. + */ +export function ChatFAB() { + return null; +} diff --git a/frontend/editor/src/core/components/layout/Workbench.tsx b/frontend/editor/src/core/components/layout/Workbench.tsx index 9848c86f3..a37d3e164 100644 --- a/frontend/editor/src/core/components/layout/Workbench.tsx +++ b/frontend/editor/src/core/components/layout/Workbench.tsx @@ -17,6 +17,7 @@ import WorkbenchBar from "@app/components/shared/WorkbenchBar"; import LandingPage from "@app/components/shared/LandingPage"; import Footer from "@app/components/shared/Footer"; import DismissAllErrorsButton from "@app/components/shared/DismissAllErrorsButton"; +import { ChatFAB } from "@app/components/chat/ChatFAB"; // Workbench panels are loaded on demand. Viewer pulls in pdfjs-dist and the // full @embedpdf plugin set; FileEditor/PageEditor are only needed once a file @@ -225,6 +226,9 @@ export default function Workbench() { {/* Dismiss All Errors Button */} + {/* Floating AI chat button + panel */} + + {/* Main content area */} { + const wasLoading = prevIsLoadingRef.current; + prevIsLoadingRef.current = isLoading; + if (wasLoading && !isLoading && !isOpenRef.current) { + setHasUnviewedResult(true); + } + }, [isLoading]); + + const overlayRef = useRef(null); + const [rndPos, setRndPos] = useState<{ x: number; y: number } | null>(null); + const [rndSize, setRndSize] = useState({ + width: PANEL_WIDTH_PX, + height: PANEL_HEIGHT_PX, + }); + const [isAnimatingReset, setIsAnimatingReset] = useState(false); + const resetTimerRef = useRef | null>(null); + + const getDefaultPos = () => { + const el = overlayRef.current; + if (!el) return null; + return { + x: el.offsetWidth - PANEL_WIDTH_PX - FAB_GAP_PX, + y: el.offsetHeight - PANEL_HEIGHT_PX - FAB_BOTTOM_OFFSET_PX, + }; + }; + + useLayoutEffect(() => { + const pos = getDefaultPos(); + if (pos) setRndPos(pos); + }, []); + + // Clear the reset timer on unmount to avoid state updates on dead components. + // Also ensure body user-select is restored if we unmount mid-resize. + useEffect(() => { + return () => { + if (resetTimerRef.current !== null) clearTimeout(resetTimerRef.current); + document.body.style.removeProperty("user-select"); + document.body.style.removeProperty("-webkit-user-select"); + }; + }, []); + + const cancelResetAnimation = () => { + if (resetTimerRef.current !== null) { + clearTimeout(resetTimerRef.current); + resetTimerRef.current = null; + } + setIsAnimatingReset(false); + }; + + const handleHeaderDoubleClick = (e: React.MouseEvent) => { + const target = e.target as HTMLElement; + if (target.closest(".chat-panel__header") && !target.closest("button")) { + const pos = getDefaultPos(); + if (!pos) return; + if (resetTimerRef.current !== null) clearTimeout(resetTimerRef.current); + setIsAnimatingReset(true); + setRndPos(pos); + setRndSize({ width: PANEL_WIDTH_PX, height: PANEL_HEIGHT_PX }); + resetTimerRef.current = setTimeout(() => { + setIsAnimatingReset(false); + resetTimerRef.current = null; + }, RESET_MS + 60); + } + }; + + if (!enabled) return null; + + return ( +
+ {/* Trigger button — fades out while panel is open */} + { + setIsOpen(true); + setHasUnviewedResult(false); + }} + aria-label={t("chat.fab.open", "Open Stirling AI assistant")} + aria-expanded={isOpen} + isLoading={isLoading} + showTick={hasUnviewedResult && !isLoading} + /> + + {/* Draggable / resizable panel */} + {rndPos !== null && ( + setRndPos({ x: d.x, y: d.y })} + onResizeStart={() => { + cancelResetAnimation(); + document.body.style.setProperty("user-select", "none"); + document.body.style.setProperty("-webkit-user-select", "none"); + }} + onResizeStop={(_e, _dir, ref, _delta, pos) => { + document.body.style.removeProperty("user-select"); + document.body.style.removeProperty("-webkit-user-select"); + setRndSize({ width: ref.offsetWidth, height: ref.offsetHeight }); + setRndPos(pos); + }} + // Invisible strips centred on the border — cursor change is the affordance. + // zIndex: 1 lifts them above ChatFABWindow's stacking context. + resizeHandleStyles={RESIZE_HANDLES} + style={{ + pointerEvents: isOpen ? "auto" : "none", + transition: isAnimatingReset ? RESET_TRANSITION : undefined, + }} + > + + + setIsOpen(false)} + backLabel={t("chat.fab.close", "Close chat")} + /> + + + + )} +
+ ); +} diff --git a/frontend/editor/vite.config.ts b/frontend/editor/vite.config.ts index 3f51f7ba3..0d5d47915 100644 --- a/frontend/editor/vite.config.ts +++ b/frontend/editor/vite.config.ts @@ -233,6 +233,11 @@ export default defineConfig(async ({ mode }) => { }, }, }, + resolve: { + alias: { + "@shared": path.resolve(__dirname, "../shared"), + }, + }, optimizeDeps: { exclude: ["@embedpdf/pdfium"], }, diff --git a/frontend/portal/src/components/ChatFABWidget.stories.tsx b/frontend/portal/src/components/ChatFABWidget.stories.tsx new file mode 100644 index 000000000..1d49dc8e5 --- /dev/null +++ b/frontend/portal/src/components/ChatFABWidget.stories.tsx @@ -0,0 +1,385 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useRef, useState } from "react"; +import { ChatFABButton } from "@shared/components/ChatFABButton"; +import { ChatFABWindow } from "@shared/components/ChatFABWindow"; + +/** + * Full ChatFAB widget demo — composed from ChatFABButton + ChatFABWindow. + * + * This story simulates the open/close interaction as it looks in the editor + * without needing the full editor context. The real implementation wires + * these same components to ChatContext and react-rnd for dragging. + */ + +interface MockMessage { + id: number; + role: "user" | "assistant"; + text: string; +} + +const INITIAL_MESSAGES: MockMessage[] = [ + { + id: 1, + role: "user", + text: "Merge the three contracts and redact all SSNs", + }, + { + id: 2, + role: "assistant", + text: "I'll merge the PDFs first, then run automatic PII redaction on the combined document.", + }, +]; + +function MockChatContent({ + messages, + onClose, +}: { + messages: MockMessage[]; + onClose: () => void; +}) { + return ( +
+ {/* Header — acts as drag handle in the real implementation */} +
+ Stirling + +
+ + {/* Messages */} +
+ {messages.map((m) => ( +
+ {m.text} +
+ ))} +
+ + {/* Input */} +
+
+ What do you want to do? +
+
+
+ ); +} + +function ChatFABWidgetDemo({ + startOpen = false, + agentLoading = false, + showTick = false, +}: { + startOpen?: boolean; + agentLoading?: boolean; + showTick?: boolean; +}) { + const [open, setOpen] = useState(startOpen); + const [hasUnviewedResult, setHasUnviewedResult] = useState(showTick); + const messages = INITIAL_MESSAGES; + + return ( + /* Simulates the workbench overlay container */ +
+ {/* FAB button */} + + + {/* Chat panel */} +
+ + setOpen(false)} /> + +
+
+ ); +} + +type FlowStep = "idle" | "loading" | "tick" | "open"; + +function ChatFABFullFlowDemo() { + const [step, setStep] = useState("idle"); + const [open, setOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [hasUnviewedResult, setHasUnviewedResult] = useState(false); + const isOpenRef = useRef(open); + isOpenRef.current = open; + + // Simulate agent finishing while FAB is closed → show tick + const simulateAgentRun = () => { + setStep("loading"); + setIsLoading(true); + setTimeout(() => { + setIsLoading(false); + if (!isOpenRef.current) setHasUnviewedResult(true); + setStep("tick"); + }, 2500); + }; + + const handleOpen = () => { + setOpen(true); + setHasUnviewedResult(false); + setStep("open"); + }; + + const handleClose = () => { + setOpen(false); + setStep("idle"); + }; + + return ( +
+ {/* Step guide */} +
+ {(["idle", "loading", "tick", "open"] as FlowStep[]).map((s) => ( +
+ {s === "idle" && "① Idle — plain icon"} + {s === "loading" && "② Loading — animated logo + pulse"} + {s === "tick" && "③ Unread result — tick badge"} + {s === "open" && "④ Viewed — tick cleared"} +
+ ))} + +
+ + {/* FAB button */} + + + {/* Chat panel */} +
+ + + +
+
+ ); +} + +const meta: Meta = { + title: "Editor/ChatFAB/ChatFABWidget", + parameters: { layout: "fullscreen" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Closed: Story = { + render: () => , +}; + +export const Open: Story = { + render: () => , +}; + +/** FAB closed while the agent is running — animated logo + green pulse dot. */ +export const LoadingWhileClosed: Story = { + render: () => , +}; + +export const LoadingWhileOpen: Story = { + render: () => , +}; + +/** + * Agent finished while the panel was closed. + * The tick badge signals an unread result; clicking the FAB clears it. + */ +export const UnreadResult: Story = { + render: () => , +}; + +/** + * Interactive walkthrough of the full notification state machine: + * idle → loading (animated logo) → tick badge (unread) → open (tick clears) → idle. + * Use the "Simulate agent run" button to trigger the transition. + */ +export const FullFlow: Story = { + render: () => , +}; diff --git a/frontend/shared/components/ChatFABButton.css b/frontend/shared/components/ChatFABButton.css new file mode 100644 index 000000000..6b85e4c31 --- /dev/null +++ b/frontend/shared/components/ChatFABButton.css @@ -0,0 +1,144 @@ +.chat-fab-btn { + display: flex; + align-items: center; + justify-content: center; + width: 56px; + height: 56px; + border-radius: 16px; + border: none; + background: var(--color-blue, #3b82f6); + color: #fff; + cursor: pointer; + box-shadow: 0 4px 16px rgba(59, 130, 246, 0.4); + transition: + transform 180ms cubic-bezier(0.32, 0.72, 0, 1), + box-shadow 180ms ease; + position: relative; + outline-offset: 3px; + flex-shrink: 0; +} + +.chat-fab-btn:hover { + transform: scale(1.09); + box-shadow: 0 6px 22px rgba(59, 130, 246, 0.52); +} + +.chat-fab-btn:active { + transform: scale(0.96); + transition-duration: 90ms; +} + +/* Animated logo paths when the agent is actively working */ +.chat-fab-btn--loading svg path:first-child { + animation: chat-fab-logo-right 2s ease-in-out infinite; +} + +.chat-fab-btn--loading svg path:last-child { + animation: chat-fab-logo-left 2s ease-in-out infinite; +} + +@keyframes chat-fab-logo-right { + 0%, + 100% { + transform: translate(0, 0); + opacity: 0.5; + } + 25% { + transform: translate(-1px, -5px); + opacity: 0.45; + } + 50% { + transform: translate(-6px, 0); + opacity: 0.9; + } + 75% { + transform: translate(-1px, 5px); + opacity: 0.55; + } +} + +@keyframes chat-fab-logo-left { + 0%, + 100% { + transform: translate(0, 0); + opacity: 1; + } + 25% { + transform: translate(1px, 5px); + opacity: 0.85; + } + 50% { + transform: translate(6px, 0); + opacity: 0.5; + } + 75% { + transform: translate(1px, -5px); + opacity: 0.85; + } +} + +/* Green pulse dot when the agent is actively working */ +.chat-fab-btn__pulse { + position: absolute; + top: 9px; + right: 9px; + width: 8px; + height: 8px; + border-radius: 50%; + background: #22c55e; + animation: chat-fab-pulse 1.5s ease-in-out infinite; +} + +@keyframes chat-fab-pulse { + 0%, + 100% { + transform: scale(1); + opacity: 1; + } + 50% { + transform: scale(1.35); + opacity: 0.65; + } +} + +/* Tick badge shown when there's an unread result */ +.chat-fab-btn__tick { + position: absolute; + top: 9px; + right: 9px; + width: 16px; + height: 16px; + border-radius: 50%; + background: #22c55e; + display: flex; + align-items: center; + justify-content: center; + animation: chat-fab-tick-in 220ms cubic-bezier(0.32, 0.72, 0, 1) both; +} + +@keyframes chat-fab-tick-in { + from { + transform: scale(0); + opacity: 0; + } + to { + transform: scale(1); + opacity: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + .chat-fab-btn { + transition: none; + } + .chat-fab-btn__pulse { + animation: none; + } + .chat-fab-btn--loading svg path:first-child, + .chat-fab-btn--loading svg path:last-child { + animation: none; + } + .chat-fab-btn__tick { + animation: none; + } +} diff --git a/frontend/shared/components/ChatFABButton.stories.tsx b/frontend/shared/components/ChatFABButton.stories.tsx new file mode 100644 index 000000000..bd7a5bcf8 --- /dev/null +++ b/frontend/shared/components/ChatFABButton.stories.tsx @@ -0,0 +1,37 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ChatFABButton } from "@shared/components/ChatFABButton"; + +const meta: Meta = { + title: "Editor/ChatFAB/ChatFABButton", + component: ChatFABButton, + parameters: { layout: "centered" }, + argTypes: { + isLoading: { control: "boolean" }, + showTick: { control: "boolean" }, + onClick: { action: "clicked" }, + }, +}; +export default meta; +type Story = StoryObj; + +/** Default idle state — no agent running, no unread result. */ +export const Default: Story = {}; + +/** Agent is actively working — the logo paths animate and a green pulse dot appears. */ +export const Loading: Story = { + args: { isLoading: true }, +}; + +/** Agent finished while the panel was closed — tick badge pops in to signal an unread result. */ +export const Tick: Story = { + args: { showTick: true }, +}; + +/** + * If loading restarts while a tick is already showing, the pulse dot is + * suppressed in favour of the tick until the user opens the panel. + * In practice this transition is handled by the parent via `hasUnviewedResult`. + */ +export const TickWhileLoading: Story = { + args: { isLoading: true, showTick: true }, +}; diff --git a/frontend/shared/components/ChatFABButton.tsx b/frontend/shared/components/ChatFABButton.tsx new file mode 100644 index 000000000..8c630a13d --- /dev/null +++ b/frontend/shared/components/ChatFABButton.tsx @@ -0,0 +1,66 @@ +import type { ButtonHTMLAttributes } from "react"; +import "@shared/components/ChatFABButton.css"; + +export interface ChatFABButtonProps extends ButtonHTMLAttributes { + /** Shows a green pulse dot to indicate the agent is actively working. */ + isLoading?: boolean; + /** Shows a green tick badge to indicate an unread result is waiting. */ + showTick?: boolean; +} + +export function ChatFABButton({ + isLoading = false, + showTick = false, + className, + ...rest +}: ChatFABButtonProps) { + const classes = [ + "chat-fab-btn", + isLoading ? "chat-fab-btn--loading" : "", + showTick ? "chat-fab-btn--tick" : "", + className ?? "", + ] + .filter(Boolean) + .join(" "); + + return ( + + ); +} diff --git a/frontend/shared/components/ChatFABWindow.css b/frontend/shared/components/ChatFABWindow.css new file mode 100644 index 000000000..1f59d2444 --- /dev/null +++ b/frontend/shared/components/ChatFABWindow.css @@ -0,0 +1,43 @@ +.chat-fab-window { + position: relative; + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + /* Use Mantine variables in the editor; fall back to shared tokens in storybook */ + background: var(--mantine-color-body, var(--color-surface, #ffffff)); + border-radius: 16px; + border: 1px solid var(--border-subtle, var(--color-border, #e3e8ee)); + box-shadow: + 0 8px 32px rgba(15, 23, 42, 0.14), + 0 2px 8px rgba(15, 23, 42, 0.06); + overflow: hidden; + + /* Closed state: collapsed toward bottom-right origin */ + opacity: 0; + transform: scale(0.9) translateY(14px); + pointer-events: none; + transform-origin: bottom right; + transition: + opacity 200ms ease, + transform 220ms cubic-bezier(0.32, 0.72, 0, 1); +} + +.chat-fab-window--open { + opacity: 1; + transform: scale(1) translateY(0); + pointer-events: auto; +} + +[data-mantine-color-scheme="dark"] .chat-fab-window, +[data-theme="dark"] .chat-fab-window { + box-shadow: + 0 8px 32px rgba(0, 0, 0, 0.38), + 0 2px 8px rgba(0, 0, 0, 0.22); +} + +@media (prefers-reduced-motion: reduce) { + .chat-fab-window { + transition: opacity 100ms ease; + } +} diff --git a/frontend/shared/components/ChatFABWindow.stories.tsx b/frontend/shared/components/ChatFABWindow.stories.tsx new file mode 100644 index 000000000..b2063ce3d --- /dev/null +++ b/frontend/shared/components/ChatFABWindow.stories.tsx @@ -0,0 +1,145 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useState } from "react"; +import { ChatFABWindow } from "@shared/components/ChatFABWindow"; + +const DEMO_MESSAGES = [ + { + id: 1, + role: "user" as const, + text: "Merge the three contracts and redact all SSNs", + }, + { + id: 2, + role: "assistant" as const, + text: "I'll merge the PDFs first, then run redaction on the combined document. Starting now…", + }, + { id: 3, role: "user" as const, text: "Great, also add a watermark" }, +]; + +function MockChat() { + return ( +
+
+ Stirling +
+
+ {DEMO_MESSAGES.map((m) => ( +
+ {m.text} +
+ ))} +
+
+
+ What do you want to do? +
+
+
+ ); +} + +const meta: Meta = { + title: "Editor/ChatFAB/ChatFABWindow", + component: ChatFABWindow, + parameters: { layout: "centered" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], + argTypes: { + open: { control: "boolean" }, + }, +}; +export default meta; +type Story = StoryObj; + +export const Closed: Story = { + args: { open: false, children: }, +}; + +export const Open: Story = { + args: { open: true, children: }, +}; + +export const Toggle: Story = { + render: () => { + const [open, setOpen] = useState(false); + return ( +
+ + + + +
+ ); + }, +}; diff --git a/frontend/shared/components/ChatFABWindow.tsx b/frontend/shared/components/ChatFABWindow.tsx new file mode 100644 index 000000000..c1cafed22 --- /dev/null +++ b/frontend/shared/components/ChatFABWindow.tsx @@ -0,0 +1,45 @@ +import type { CSSProperties, MouseEventHandler, ReactNode } from "react"; +import "@shared/components/ChatFABWindow.css"; + +export interface ChatFABWindowProps { + /** Whether the window is in its expanded/visible state. Controls the CSS transition. */ + open: boolean; + children: ReactNode; + className?: string; + style?: CSSProperties; + onDoubleClick?: MouseEventHandler; +} + +/** + * Visual frame for the floating chat panel. + * + * Renders a card shell (rounded corners, shadow, border) that animates in and + * out via CSS transitions. The caller controls open/closed state; this component + * owns only the appearance and animation — no drag logic, no context. + */ +export function ChatFABWindow({ + open, + children, + className, + style, + onDoubleClick, +}: ChatFABWindowProps) { + const classes = [ + "chat-fab-window", + open ? "chat-fab-window--open" : "", + className ?? "", + ] + .filter(Boolean) + .join(" "); + + return ( +
+ {children} +
+ ); +} diff --git a/frontend/shared/vite-env.d.ts b/frontend/shared/vite-env.d.ts new file mode 100644 index 000000000..ef6d741f6 --- /dev/null +++ b/frontend/shared/vite-env.d.ts @@ -0,0 +1 @@ +declare module "*.css" {}