Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions packages/widget/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,29 @@ Widget root, a prop the Layout's composition supplies that names the
difference, or a Layout-specific component. See
[ADR 0013](../../docs/adr/0013-layouts-are-application-composition.md).

`app/layouts/layout-app-container.tsx` selects the Layout attribute and page
transition. The shared shell receives that resolved presentation; it does not
select a Layout or own Layout-specific transition presets.

## Routes and navigation

The Route Registry (`services/navigation/widget-routes.ts`) defines every
absolute Widget path once: its pattern, typed params, the Layouts that mount
it, how to build it, and how to match a pathname against it. Route trees mount
registry patterns, navigation accepts only registry-built `WidgetPath`s, and
pathname parsing uses registry matching; only relative redirects stay literal.
Building a route that some Layout does not mount requires naming the Layout. A
Transaction Flow's Review, Steps, and Complete form one flow group per mount.
The owning Feature or application composition binds the navigation-state schema
to its route or flow group. Presentation changes carried by navigation apply
only after the router accepts it, so blocked navigation cannot remount the
current page. The `navigation` Feature adapts the registry to React.

Navigation that completes a domain operation, such as a handoff with its
navigation or a deep link, runs in that Effect operation through the
`WidgetNavigation` service. Navigation that only answers a user's UI action,
such as a tab press or a back button, stays in React.

## Dependency direction

Dependencies point toward narrow interfaces and stable lower-level concepts:
Expand Down Expand Up @@ -246,8 +269,9 @@ and Effect Atom registry.
memoize by hand), ad hoc Atom runtimes, direct Effect runtime execution,
native `Date`, global `fetch`, throws in Effect generators, wildcard Module
exports, Resources that read an API ResourceSource without
`makePresentableResource`, Layout reads outside application composition and
Widget Configuration, Layout-named identifiers in Domain, Resource, service, and
`makePresentableResource`, literal absolute Widget paths outside the Route
Registry, Layout reads outside application composition and Widget
Configuration, Layout-named identifiers in Domain, Resource, service, and
Feature `model/` and `state/` code, and Layout-named files outside `ui/` and
`app/layouts/`.
- Architecture rules live in these tools, not in tests that read source text.
Expand Down
6 changes: 3 additions & 3 deletions packages/widget/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { SKAtomRegistryProvider } from "./app/composition/providers/atom-runtime
import { WidgetConfigurationUnavailable } from "./app/composition/widget-configuration-unavailable";
import { CompactRoutes } from "./app/layouts/compact/compact-routes";
import { DashboardRoutes } from "./app/layouts/dashboard/dashboard-routes";
import { LayoutAppContainer } from "./app/layouts/layout-app-container";
import { applicationRoutes } from "./app/routes/application-routes";
import { useApplicationRouteEffects } from "./app/routes/react/use-application-route-effects";
import { applicationRouterAtom } from "./app/runtime/application-router";
Expand All @@ -22,7 +23,6 @@ import {
useWidgetConfig,
} from "./features/widget-configuration/index";
import {
AppContainer,
NetworkNotSupported,
NoEnabledYields,
} from "./features/widget-shell/composition";
Expand Down Expand Up @@ -73,9 +73,9 @@ const SKAppRouter = () => {

return (
<ApplicationRouteContentProvider value={<Root />}>
<AppContainer layout={layout}>
<LayoutAppContainer layout={layout}>
<RouterProvider router={router} />
</AppContainer>
</LayoutAppContainer>
</ApplicationRouteContentProvider>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,23 @@
import { useAtomMount } from "@effect/atom-react";
import { useEffect } from "react";
import { useLocation } from "react-router";
import {
mountAnimationCompletionAtom,
useMountAnimation,
} from "../../../../features/mount-animation/index";
import { useWidgetRouteMatch } from "../../../../features/navigation/index";
import { widgetRoutes } from "../../../../services/navigation/widget-routes";

export const MountAnimationEffects = () => {
const location = useLocation();
const onEarn = useWidgetRouteMatch(widgetRoutes.earn) !== null;
const { dispatch } = useMountAnimation();

useAtomMount(mountAnimationCompletionAtom);

useEffect(() => {
if (location.pathname !== "/") {
if (!onEarn) {
dispatch({ type: "all" });
}
}, [location.pathname, dispatch]);
}, [onEarn, dispatch]);

return null;
};
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import { useState } from "react";
import { I18nextProvider } from "react-i18next";
import {
AppContainer,
WidgetUnavailableWithoutConfiguration,
} from "../../features/widget-shell/composition";
import { WidgetUnavailableWithoutConfiguration } from "../../features/widget-shell/composition";
import type { RejectedConfigurationPresentation } from "../../services/config/widget-config-boundary";
import { createWidgetI18nInstance } from "../../services/translation/widget-translation";
import { LayoutAppContainer } from "../layouts/layout-app-container";
import { ThemeStyles } from "./providers/theme-wrapper";

/**
Expand All @@ -25,9 +23,9 @@ export const WidgetConfigurationUnavailable = ({
return (
<I18nextProvider i18n={i18n}>
<ThemeStyles theme={theme} variant={variant}>
<AppContainer layout={layout}>
<LayoutAppContainer layout={layout}>
<WidgetUnavailableWithoutConfiguration />
</AppContainer>
</LayoutAppContainer>
</ThemeStyles>
</I18nextProvider>
);
Expand Down
152 changes: 78 additions & 74 deletions packages/widget/src/app/layouts/compact/compact-routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
createActivityActionRoutes,
} from "../../../features/activity/composition";
import { AnimatedEarnPage } from "../../../features/earn/composition";
import { WidgetNavigate } from "../../../features/navigation/views";
import { AnimatedPositionsPage } from "../../../features/portfolio/composition";
import { usePortfolioPendingActionsCount } from "../../../features/portfolio/index";
import { CompactPositionDetailsPage } from "../../../features/position-details/composition";
Expand All @@ -24,12 +25,87 @@ import {
} from "../../../features/widget-shell/views";
import { createYieldFlowRoutes } from "../../../features/yield-transaction-flow/composition";
import { getYieldFlowRouteGroup } from "../../../features/yield-transaction-flow/index";
import { widgetRoutes } from "../../../services/navigation/widget-routes";

const CompactDetails = () => {
const pendingActionsCount = usePortfolioPendingActionsCount();
return <Details pendingActionsCount={pendingActionsCount} />;
};

/** The Compact Layout's route tree. */
export const createCompactRoutes = () => {
const earnPath = widgetRoutes.earn.build();

return (
<Route element={<CompactLayout />}>
{/* Home + Tabs */}
<Route element={<CompactDetails />}>
<Route index element={<AnimatedEarnPage />} />
<Route
path={widgetRoutes.positions.pattern}
element={<AnimatedPositionsPage />}
/>
<Route path={widgetRoutes.activity.pattern}>
<Route index element={<AnimatedActivityPage />} />
<Route
element={
<WalletScopeRouteGuard
fallbackPath={widgetRoutes.activity.build()}
/>
}
>
{createActivityActionRoutes({
selectsDefaultAction: false,
showsBackButton: true,
})}
</Route>
</Route>
</Route>

<Route element={<WalletScopeRouteGuard fallbackPath={earnPath} />}>
{/* Stake flow */}
{createYieldFlowRoutes({ mount: "Earn" })}

{/* Position details hub + flow mounts */}
<Route path={widgetRoutes.positionDetails.pattern}>
<Route index element={<CompactPositionDetailsPage />} />
<Route
path={widgetRoutes.positionSelectValidator.pattern}
element={<CompactPositionDetailsPage />}
/>

{/* Legacy / accidental stake prefix → hub (no form) */}
<Route path={widgetRoutes.positionStake.pattern}>
<Route index element={<Navigate replace to=".." />} />
<Route path="*" element={<Navigate replace to="../.." />} />
</Route>

{/* Unstaking */}
<Route path={widgetRoutes.positionUnstake.pattern}>
<Route index element={<Navigate replace to=".." />} />
{createYieldFlowRoutes({ mount: "PositionExit" })}
<Route path="*" element={<Navigate replace to="../.." />} />
</Route>

{/* Pending Actions */}
<Route path={widgetRoutes.positionPendingAction.pattern}>
<Route index element={<Navigate replace to=".." />} />
{createYieldFlowRoutes({ mount: "PositionManage" })}
<Route path="*" element={<Navigate replace to="../.." />} />
</Route>

<Route
path="*"
element={<Navigate replace relative="path" to=".." />}
/>
</Route>
</Route>

<Route path="*" element={<WidgetNavigate replace to={earnPath} />} />
</Route>
);
};

export const CompactRoutes = () => {
const location = useLocation();
// Review, Steps, and Complete of one flow mount share a key, so the flow
Expand All @@ -46,7 +122,7 @@ export const CompactRoutes = () => {
if (yieldFlowRouteGroup !== null) {
return `flow:${yieldFlowRouteGroup}`;
}
if (detailsMatch) return "/";
if (detailsMatch) return "tabs";
return location.key;
};
const key = resolveRouteKey();
Expand All @@ -62,79 +138,7 @@ export const CompactRoutes = () => {
<motion.div layout="position" className={container}>
<AnimatePresence>
<Routes location={location} key={key}>
<Route element={<CompactLayout />}>
{/* Home + Tabs */}
<Route element={<CompactDetails />}>
<Route index element={<AnimatedEarnPage />} />
<Route
path="positions"
element={<AnimatedPositionsPage />}
/>
<Route path="activity">
<Route index element={<AnimatedActivityPage />} />
<Route
element={
<WalletScopeRouteGuard fallbackPath="/activity" />
}
>
{createActivityActionRoutes({
selectsDefaultAction: false,
showsBackButton: true,
})}
</Route>
</Route>
</Route>

<Route element={<WalletScopeRouteGuard fallbackPath="/" />}>
{/* Stake flow */}
{createYieldFlowRoutes({ journey: "Enter" })}

{/* Position details hub + flow mounts */}
<Route path="positions/:integrationId/:balanceId">
<Route index element={<CompactPositionDetailsPage />} />
<Route
path="select-validator/:pendingActionType"
element={<CompactPositionDetailsPage />}
/>

{/* Legacy / accidental stake prefix → hub (no form) */}
<Route path="stake">
<Route index element={<Navigate replace to=".." />} />
<Route
path="*"
element={<Navigate replace to="../.." />}
/>
</Route>

{/* Unstaking */}
<Route path="unstake">
<Route index element={<Navigate replace to=".." />} />
{createYieldFlowRoutes({ journey: "Exit" })}
<Route
path="*"
element={<Navigate replace to="../.." />}
/>
</Route>

{/* Pending Actions */}
<Route path="pending-action">
<Route index element={<Navigate replace to=".." />} />
{createYieldFlowRoutes({ journey: "Manage" })}
<Route
path="*"
element={<Navigate replace to="../.." />}
/>
</Route>

<Route
path="*"
element={<Navigate replace relative="path" to=".." />}
/>
</Route>
</Route>

<Route path="*" element={<Navigate to="/" replace />} />
</Route>
{createCompactRoutes()}
</Routes>
</AnimatePresence>
</motion.div>
Expand Down
10 changes: 10 additions & 0 deletions packages/widget/src/app/layouts/dashboard/dashboard-earn-route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Schema } from "effect";
import { YieldCategory } from "../../../domain/earn/contract";
import { widgetRoutes } from "../../../services/navigation/widget-routes";

/** A category press only changes the page after its navigation is accepted. */
export const dashboardEarnRoute = widgetRoutes.earn.withState(
Schema.Struct({
yieldCategory: Schema.Literals(Object.values(YieldCategory)),
})
);
Loading
Loading