diff --git a/packages/widget/src/App.tsx b/packages/widget/src/App.tsx
index 8bc4be7c1..2cb70ce03 100644
--- a/packages/widget/src/App.tsx
+++ b/packages/widget/src/App.tsx
@@ -7,12 +7,11 @@ import { createRef, useImperativeHandle, useState } from "react";
import ReactDOM from "react-dom/client";
import { createMemoryRouter, RouterProvider } from "react-router";
import { preloadImages } from "./assets/images";
-import { Box } from "./components/atoms/box";
import { Dashboard } from "./Dashboard";
import { Providers } from "./providers";
+import { AppContainerProvider } from "./providers/app-container";
import { SettingsContextProvider, useSettings } from "./providers/settings";
import type { SettingsProps, VariantProps } from "./providers/settings/types";
-import { appContainer } from "./style.css";
import { useLoadErrorTranslations } from "./translation";
import { Widget } from "./Widget";
@@ -46,13 +45,11 @@ export const SKApp = (props: SKAppProps) => {
return (
-
-
+
);
};
diff --git a/packages/widget/src/components/atoms/virtual-list/index.tsx b/packages/widget/src/components/atoms/virtual-list/index.tsx
index 7bbce42e6..a13a6cce2 100644
--- a/packages/widget/src/components/atoms/virtual-list/index.tsx
+++ b/packages/widget/src/components/atoms/virtual-list/index.tsx
@@ -46,7 +46,7 @@ type VirtualGroupListProps = {
estimateSize: VirtualizerOptions["estimateSize"];
} & InfiniteScrollProps;
-export const VirtualList = ({
+export const VirtualList = ({
data,
itemContent,
className,
diff --git a/packages/widget/src/hooks/use-split-collapsed.ts b/packages/widget/src/hooks/use-split-collapsed.ts
new file mode 100644
index 000000000..ae906ed62
--- /dev/null
+++ b/packages/widget/src/hooks/use-split-collapsed.ts
@@ -0,0 +1,13 @@
+import { useAppContainerWidth } from "../providers/app-container";
+import { isSplitCollapsedWidth } from "../styles/tokens/breakpoints";
+
+/**
+ * JS counterpart of `splitExpandedContainerQuery`. Both must resolve against
+ * the app container width so the layout stays consistent when the dashboard is
+ * embedded in a host that is narrower than the viewport.
+ */
+export const useSplitCollapsed = () => {
+ const appContainerWidth = useAppContainerWidth();
+
+ return appContainerWidth !== null && isSplitCollapsedWidth(appContainerWidth);
+};
diff --git a/packages/widget/src/pages-dashboard/common/components/split-view/index.tsx b/packages/widget/src/pages-dashboard/common/components/split-view/index.tsx
new file mode 100644
index 000000000..194859aba
--- /dev/null
+++ b/packages/widget/src/pages-dashboard/common/components/split-view/index.tsx
@@ -0,0 +1,90 @@
+import clsx from "clsx";
+import { type ReactNode, useState } from "react";
+import { Box } from "../../../../components/atoms/box";
+import { CaretLeftIcon } from "../../../../components/atoms/icons/caret-left";
+import { useSplitCollapsed } from "../../../../hooks/use-split-collapsed";
+import { VerticalDivider } from "../divider";
+import * as styles from "./styles.css";
+
+type Side = "primary" | "secondary";
+
+type SplitViewProps = {
+ primary: ReactNode;
+ secondary: ReactNode;
+ primaryBarLabel: string;
+ secondaryBarLabel: string;
+};
+
+export const SplitView = ({
+ primary,
+ secondary,
+ primaryBarLabel,
+ secondaryBarLabel,
+}: SplitViewProps) => {
+ const isCollapsed = useSplitCollapsed();
+ const [activeSide, setActiveSide] = useState("primary");
+
+ if (!primary || !secondary) {
+ return {primary || secondary};
+ }
+
+ const showPrimary = !isCollapsed || activeSide === "primary";
+ const showSecondary = !isCollapsed || activeSide === "secondary";
+ const revealLabel =
+ activeSide === "primary" ? secondaryBarLabel : primaryBarLabel;
+
+ const primaryClass = !isCollapsed
+ ? styles.panelWrapContents
+ : showPrimary
+ ? styles.panelWrapActiveFromLeft
+ : styles.panelWrapHidden;
+
+ const secondaryClass = !isCollapsed
+ ? styles.panelWrapContents
+ : showSecondary
+ ? styles.panelWrapActiveFromRight
+ : styles.panelWrapHidden;
+
+ return (
+
+ {primary}
+
+ {isCollapsed ? (
+
+ setActiveSide((side) =>
+ side === "primary" ? "secondary" : "primary"
+ )
+ }
+ >
+
+
+
+
+
+ {revealLabel}
+
+
+ ) : (
+
+ )}
+
+ {secondary}
+
+ );
+};
diff --git a/packages/widget/src/pages-dashboard/common/components/split-view/styles.css.ts b/packages/widget/src/pages-dashboard/common/components/split-view/styles.css.ts
new file mode 100644
index 000000000..26b2aa272
--- /dev/null
+++ b/packages/widget/src/pages-dashboard/common/components/split-view/styles.css.ts
@@ -0,0 +1,122 @@
+import { keyframes, style } from "@vanilla-extract/css";
+import { atoms } from "../../../../styles/theme/atoms.css";
+import { vars } from "../../../../styles/theme/contract.css";
+import { OUTLET_PADDING } from "../styles.css";
+
+export const container = style({
+ alignItems: "stretch",
+ display: "flex",
+ flexDirection: "row",
+ gap: "24px",
+ justifyContent: "center",
+ minWidth: 0,
+ width: "100%",
+});
+
+export const panelWrapContents = style({
+ display: "contents",
+});
+
+export const panelWrapHidden = style({
+ display: "none",
+});
+
+const reduceMotion = "(prefers-reduced-motion: reduce)";
+
+const slideInFromLeft = keyframes({
+ from: { opacity: 0, transform: "translateX(-16px)" },
+ to: { opacity: 1, transform: "translateX(0)" },
+});
+
+const slideInFromRight = keyframes({
+ from: { opacity: 0, transform: "translateX(16px)" },
+ to: { opacity: 1, transform: "translateX(0)" },
+});
+
+const panelWrapActiveBase = style({
+ display: "flex",
+ flex: 1,
+ flexDirection: "row",
+ minWidth: 0,
+});
+
+export const panelWrapActiveFromLeft = style([
+ panelWrapActiveBase,
+ {
+ animation: `${slideInFromLeft} 260ms ease`,
+ "@media": {
+ [reduceMotion]: { animation: "none" },
+ },
+ },
+]);
+
+export const panelWrapActiveFromRight = style([
+ panelWrapActiveBase,
+ {
+ animation: `${slideInFromRight} 260ms ease`,
+ "@media": {
+ [reduceMotion]: { animation: "none" },
+ },
+ },
+]);
+
+export const bar = style([
+ atoms({
+ background: "stakeSectionBackground",
+ }),
+ {
+ alignItems: "center",
+ alignSelf: "stretch",
+ border: 0,
+ borderRadius: "10px",
+ cursor: "pointer",
+ display: "flex",
+ flexDirection: "column",
+ flexShrink: 0,
+ font: "inherit",
+ gap: "8px",
+ justifyContent: "center",
+ padding: "12px 0",
+ transition: "background 150ms ease",
+ width: "30px",
+ selectors: {
+ "&:hover": {
+ background: vars.color.backgroundMuted,
+ },
+ },
+ },
+]);
+
+/**
+ * Bleed the bar outward into the dashboard outlet padding, leaving an 8px gap
+ * to the card edge on whichever side it currently lives.
+ */
+export const barBleedRight = style({
+ marginRight: `calc(8px - ${OUTLET_PADDING})`,
+});
+
+export const barBleedLeft = style({
+ marginLeft: `calc(8px - ${OUTLET_PADDING})`,
+});
+
+export const barIcon = style({
+ alignItems: "center",
+ display: "flex",
+ justifyContent: "center",
+ transition: "transform 200ms ease",
+ "@media": {
+ [reduceMotion]: { transition: "none" },
+ },
+});
+
+export const barIconFlipped = style({
+ transform: "rotate(180deg)",
+});
+
+export const barLabel = style({
+ fontSize: "13px",
+ fontWeight: 600,
+ letterSpacing: "0.02em",
+ whiteSpace: "nowrap",
+ writingMode: "vertical-rl",
+});
diff --git a/packages/widget/src/pages-dashboard/common/components/styles.css.ts b/packages/widget/src/pages-dashboard/common/components/styles.css.ts
index bf83fa854..386c1d9b5 100644
--- a/packages/widget/src/pages-dashboard/common/components/styles.css.ts
+++ b/packages/widget/src/pages-dashboard/common/components/styles.css.ts
@@ -11,7 +11,9 @@ export const wrapper = recipe({
borderWidth: "1px",
borderStyle: "solid",
boxShadow: "0px 15px 40px 0px #0000000D",
- width: "1000px",
+ maxWidth: "1000px",
+ width: "100%",
+ minWidth: "100%",
},
],
variants: {
@@ -59,16 +61,6 @@ export const outletWrapper = recipe({
},
});
-export const tabPageContainer = recipe({
- base: {
- display: "flex",
- flexDirection: "row",
- gap: "24px",
- alignItems: "stretch",
- justifyContent: "center",
- },
-});
-
export const tabPageDivider = recipe({
base: {
alignSelf: "stretch",
diff --git a/packages/widget/src/pages-dashboard/common/components/tab-page-container.tsx b/packages/widget/src/pages-dashboard/common/components/tab-page-container.tsx
deleted file mode 100644
index d0e4d84ea..000000000
--- a/packages/widget/src/pages-dashboard/common/components/tab-page-container.tsx
+++ /dev/null
@@ -1,13 +0,0 @@
-import clsx from "clsx";
-import { Box } from "../../../components/atoms/box";
-import { tabPageContainer } from "./styles.css";
-
-export const TabPageContainer = ({
- children,
- className,
-}: {
- children: React.ReactNode;
- className?: string;
-}) => {
- return {children};
-};
diff --git a/packages/widget/src/pages-dashboard/overview/index.tsx b/packages/widget/src/pages-dashboard/overview/index.tsx
index 85a4edac7..b91fceba6 100644
--- a/packages/widget/src/pages-dashboard/overview/index.tsx
+++ b/packages/widget/src/pages-dashboard/overview/index.tsx
@@ -1,38 +1,43 @@
+import { useTranslation } from "react-i18next";
import { Outlet } from "react-router";
import { Box } from "../../components/atoms/box";
import { AnimationPage } from "../../navigation/containers/animation-page";
import { BackButtonProvider } from "../common/components/back-button";
-import { VerticalDivider } from "../common/components/divider";
-import { TabPageContainer } from "../common/components/tab-page-container";
+import { SplitView } from "../common/components/split-view";
import { EarnDetails } from "./earn-details";
import { earnDetailsWrapper } from "./earn-details/styles.css";
import { overviewPageContainer } from "./styles.css";
export const OverviewPage = () => {
+ const { t } = useTranslation();
+
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+ }
+ secondary={
+
+
+
+ }
+ />
);
diff --git a/packages/widget/src/pages-dashboard/overview/styles.css.ts b/packages/widget/src/pages-dashboard/overview/styles.css.ts
index 7b03c1d32..86cce6ae8 100644
--- a/packages/widget/src/pages-dashboard/overview/styles.css.ts
+++ b/packages/widget/src/pages-dashboard/overview/styles.css.ts
@@ -1,5 +1,10 @@
import { style } from "@vanilla-extract/css";
+import { splitExpandedContainerQuery } from "../../styles/tokens/breakpoints";
export const overviewPageContainer = style({
- maxWidth: "380px",
+ "@container": {
+ [splitExpandedContainerQuery]: {
+ maxWidth: "380px",
+ },
+ },
});
diff --git a/packages/widget/src/pages-dashboard/position-details/index.tsx b/packages/widget/src/pages-dashboard/position-details/index.tsx
index 12d622481..df734cf19 100644
--- a/packages/widget/src/pages-dashboard/position-details/index.tsx
+++ b/packages/widget/src/pages-dashboard/position-details/index.tsx
@@ -9,8 +9,7 @@ import {
BackButton,
BackButtonProvider,
} from "../common/components/back-button";
-import { VerticalDivider } from "../common/components/divider";
-import { TabPageContainer } from "../common/components/tab-page-container";
+import { SplitView } from "../common/components/split-view";
import {
positionDetailsActionsHasContent,
positionDetailsStakeHasContent,
@@ -53,6 +52,7 @@ const PositionBreadcrumb = ({
};
const PositionDetailsPageComponent = () => {
+ const { t } = useTranslation();
const positionDetails = usePositionDetails();
const shouldShowActions =
positionDetailsActionsHasContent(positionDetails) ||
@@ -64,45 +64,48 @@ const PositionDetailsPageComponent = () => {
return (
-
- {shouldShowActions ? (
+
+
+
+
+
+
+
+ ) : null
+ }
+ secondary={
-
+ {shouldShowActions ? null : (
+
+ )}
-
-
-
+
- ) : null}
-
- {shouldShowActions ? : null}
-
-
- {shouldShowActions ? null : (
-
- )}
-
-
-
-
+ }
+ />
);
};
diff --git a/packages/widget/src/pages-dashboard/position-details/styles.css.ts b/packages/widget/src/pages-dashboard/position-details/styles.css.ts
index b4d05886f..503c43740 100644
--- a/packages/widget/src/pages-dashboard/position-details/styles.css.ts
+++ b/packages/widget/src/pages-dashboard/position-details/styles.css.ts
@@ -1,5 +1,6 @@
import { style } from "@vanilla-extract/css";
import { atoms } from "../../styles/theme/atoms.css";
+import { splitExpandedContainerQuery } from "../../styles/tokens/breakpoints";
export const posistionDetailsInfoContainer = style([
atoms({
@@ -8,12 +9,20 @@ export const posistionDetailsInfoContainer = style([
width: "0",
}),
{
- maxWidth: "600px",
+ "@container": {
+ [splitExpandedContainerQuery]: {
+ maxWidth: "600px",
+ },
+ },
},
]);
export const positionDetailsActionsContainer = style({
- maxWidth: "380px",
+ "@container": {
+ [splitExpandedContainerQuery]: {
+ maxWidth: "380px",
+ },
+ },
});
export const breadcrumb = style([
diff --git a/packages/widget/src/providers/app-container/index.tsx b/packages/widget/src/providers/app-container/index.tsx
new file mode 100644
index 000000000..731612d01
--- /dev/null
+++ b/packages/widget/src/providers/app-container/index.tsx
@@ -0,0 +1,62 @@
+import type { PropsWithChildren } from "react";
+import { createContext, useContext, useLayoutEffect, useState } from "react";
+import { Box } from "../../components/atoms/box";
+import { appContainer } from "../../style.css";
+
+type AppContainerVariant = "widget" | "dashboard";
+
+/**
+ * Inline size of the app container, or `null` while it is not measurable yet.
+ * Layout decisions taken in JS must use this instead of the window size, since
+ * the app can be embedded in a host that gives it less room than the viewport.
+ */
+const AppContainerWidthContext = createContext(
+ undefined
+);
+
+export const AppContainerProvider = ({
+ variant,
+ children,
+}: PropsWithChildren<{ variant: AppContainerVariant }>) => {
+ const [element, setElement] = useState(null);
+ const [width, setWidth] = useState(null);
+
+ useLayoutEffect(() => {
+ if (!element || typeof ResizeObserver === "undefined") {
+ setWidth(null);
+ return;
+ }
+
+ setWidth(element.getBoundingClientRect().width);
+
+ const observer = new ResizeObserver(([entry]) => {
+ if (entry) setWidth(entry.contentRect.width);
+ });
+
+ observer.observe(element);
+
+ return () => observer.disconnect();
+ }, [element]);
+
+ return (
+
+
+ {children}
+
+
+ );
+};
+
+export const useAppContainerWidth = () => {
+ const value = useContext(AppContainerWidthContext);
+
+ if (value === undefined) {
+ throw new Error("AppContainerProvider not found in the tree");
+ }
+
+ return value;
+};
diff --git a/packages/widget/src/style.css.ts b/packages/widget/src/style.css.ts
index 42be2717b..ec3fb09d6 100644
--- a/packages/widget/src/style.css.ts
+++ b/packages/widget/src/style.css.ts
@@ -1,10 +1,12 @@
-import { createContainer, style } from "@vanilla-extract/css";
+import { style } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";
import { atoms } from "./styles/theme/atoms.css";
import { minContainerWidth } from "./styles/tokens/breakpoints";
+import {
+ appContainerName,
+ widgetContainerName,
+} from "./styles/tokens/containers.css";
-const appContainerName = createContainer();
-const widgetContainerName = createContainer();
const widgetContainerMaxWidth = 400;
export const animationContainer = style([
diff --git a/packages/widget/src/styles/theme/global.css.ts b/packages/widget/src/styles/theme/global.css.ts
index 23b2b73a6..1416e9379 100644
--- a/packages/widget/src/styles/theme/global.css.ts
+++ b/packages/widget/src/styles/theme/global.css.ts
@@ -1,6 +1,6 @@
import { globalStyle, layer } from "@vanilla-extract/css";
import { vars } from "./contract.css";
-import { rootSelector } from "./ids";
+import { dashboardLayoutSelector, rootSelector } from "./ids";
const reset = layer("reset");
@@ -12,6 +12,13 @@ globalStyle(rootSelector, {
},
});
+globalStyle(`${dashboardLayoutSelector} ${rootSelector}`, {
+ marginLeft: "auto",
+ marginRight: "auto",
+ maxWidth: "1000px",
+ width: "100%",
+});
+
// Resets
globalStyle(`${rootSelector} *`, {
"@layer": {
diff --git a/packages/widget/src/styles/theme/ids.ts b/packages/widget/src/styles/theme/ids.ts
index 51b7c1590..c5eee43b1 100644
--- a/packages/widget/src/styles/theme/ids.ts
+++ b/packages/widget/src/styles/theme/ids.ts
@@ -1,2 +1,5 @@
export const id = "stakekit";
export const rootSelector = `[data-rk="${id}"]`;
+
+const layoutAttribute = "data-sk-layout";
+export const dashboardLayoutSelector = `[${layoutAttribute}="dashboard"]`;
diff --git a/packages/widget/src/styles/tokens/breakpoints.ts b/packages/widget/src/styles/tokens/breakpoints.ts
index 124690d41..db3840df3 100644
--- a/packages/widget/src/styles/tokens/breakpoints.ts
+++ b/packages/widget/src/styles/tokens/breakpoints.ts
@@ -1,3 +1,5 @@
+import { appContainerName } from "./containers.css";
+
export const breakpoints = {
mobile: 0,
tablet: 520,
@@ -6,11 +8,24 @@ export const breakpoints = {
export type Breakpoint = keyof typeof breakpoints;
-export const minMediaQuery = (breakpoint: Breakpoint) =>
- `screen and (min-width: ${breakpoints[breakpoint]}px)`;
+const SPLIT_COLLAPSE_BREAKPOINT = 800;
+
+const toPx = (breakpoint: Breakpoint | number) =>
+ typeof breakpoint === "number" ? breakpoint : breakpoints[breakpoint];
+
+export const minMediaQuery = (breakpoint: Breakpoint | number) =>
+ `screen and (min-width: ${toPx(breakpoint)}px)`;
export const minContainerWidth = (
containerName: string,
breakpoint: Breakpoint | number
-) =>
- `${containerName} (min-width: ${typeof breakpoint === "number" ? breakpoint : breakpoints[breakpoint]}px)`;
+) => `${containerName} (min-width: ${toPx(breakpoint)}px)`;
+
+
+export const splitExpandedContainerQuery = minContainerWidth(
+ appContainerName,
+ SPLIT_COLLAPSE_BREAKPOINT + 1
+);
+
+export const isSplitCollapsedWidth = (appContainerWidth: number) =>
+ appContainerWidth <= SPLIT_COLLAPSE_BREAKPOINT;
diff --git a/packages/widget/src/styles/tokens/containers.css.ts b/packages/widget/src/styles/tokens/containers.css.ts
new file mode 100644
index 000000000..085b0bd72
--- /dev/null
+++ b/packages/widget/src/styles/tokens/containers.css.ts
@@ -0,0 +1,5 @@
+import { createContainer } from "@vanilla-extract/css";
+
+export const appContainerName = createContainer();
+
+export const widgetContainerName = createContainer();
diff --git a/packages/widget/src/translation/English/translations.json b/packages/widget/src/translation/English/translations.json
index cac12c715..a0cf90cd8 100644
--- a/packages/widget/src/translation/English/translations.json
+++ b/packages/widget/src/translation/English/translations.json
@@ -239,6 +239,11 @@
}
},
"dashboard": {
+ "split_view": {
+ "earn": "Earn",
+ "details": "Details",
+ "actions": "Actions"
+ },
"details": {
"tabs": {
"earn": "Earn",
diff --git a/packages/widget/src/translation/French/translations.json b/packages/widget/src/translation/French/translations.json
index a508d49a0..5be0f9ee0 100644
--- a/packages/widget/src/translation/French/translations.json
+++ b/packages/widget/src/translation/French/translations.json
@@ -682,6 +682,11 @@
},
"chain_modal_disclaimer": "Alimenté par Yield.xyz",
"dashboard": {
+ "split_view": {
+ "earn": "Earn",
+ "details": "Détails",
+ "actions": "Actions"
+ },
"details": {
"tabs": {
"earn": "Earn",