diff --git a/.changeset/tasty-news-own.md b/.changeset/tasty-news-own.md
new file mode 100644
index 00000000..b3ec2452
--- /dev/null
+++ b/.changeset/tasty-news-own.md
@@ -0,0 +1,6 @@
+---
+"@easypost/easy-ui-icons": minor
+"@easypost/easy-ui": minor
+---
+
+feat: ForgeLayout
diff --git a/documentation/specs/ForgeLayout.md b/documentation/specs/ForgeLayout.md
new file mode 100644
index 00000000..c94bec70
--- /dev/null
+++ b/documentation/specs/ForgeLayout.md
@@ -0,0 +1,120 @@
+# `ForgeLayout` Component Specification
+
+## Overview
+
+`ForgeLayout` defines the header, nav, and main content areas of a Forge product page.
+
+### Prior Art
+
+- [Primer ``](https://primer.style/design/components/page-layout/react)
+- [Paste `](https://paste.twilio.design/components/sidebar-navigation)
+
+---
+
+## Design
+
+`ForgeLayout` will be a compound component consisting of `ForgeLayout`, `ForgeLayout.Nav`, `ForgeLayout.Header`, and `ForgeLayout.Content`.
+
+`ForgeLayout` is highly composable. Subcomponents within a `ForgeLayout` can be replaced as needed. Subcomponents are lightweight wrappers with built-in styles and constraints.
+
+`ForgeLayout` is concerned only with presentational structure. It is meant to be wrapped by an app layout that may include app-specific business logic and configuration.
+
+`ForgeLayout` can be in an `expanded` or `collapsed` navigational state by using the `navState` prop. When `expanded`, the navigation is present, along with any relevant header controls. When `collapsed`, the navigation is hidden, and the relevant controls are presented in the header.
+
+`ForgeLayout` is aware of a global `mode` prop. When passed `test`, the shell is decorated with a color to indicate a non-production environment. The `mode` can be changed with the `ModeSwitcher` control.
+
+### API
+
+```tsx
+import { ForgeLayout } from "@easypost/easy-ui/ForgeLayout";
+
+function App() {
+ return (
+
+
+
+ Item 1
+
+ Title>}>
+
+ Item 2
+
+
+ Item 3
+
+
+ Title>}>
+
+ Item 4
+
+
+ Item 5
+
+
+
+
+
+
+ {}}>
+ Back
+
+
+ Breadcrumb
+ Breadcrumb
+
+
+
+
+
+ {}} />
+
+
+ }
+ >
+
+ Action 1:1
+ Action 1:2
+
+
+
+
+ Action 2:1
+ Action 2:2
+
+
+
+
+
+ Page Content
+
+ );
+}
+```
+
+---
+
+## Behavior
+
+### Accessibility
+
+- `ForgeLayout.Header` will render as `header`
+- `ForgeLayout.Content` will render as `main`
+- `ForgeLayout.Nav` will be rendered as `nav` with associated `aria-label`
+- `ForgeLayout.NavLink` will render as ``
+- Selected nav links will be decorated as `aria-current="page"`
+
+### Dependencies
+
+- `Text`
+- `useLink`
+- Will use `EasyUIProvider`'s navigation hooks to support client-side links. See [client side routing](https://react-spectrum.adobe.com/react-aria/routing.html#routerprovider). This was added as part of `NexusLayout`.
diff --git a/easy-ui-icons/src/AccountTree.json b/easy-ui-icons/src/AccountTree.json
new file mode 100644
index 00000000..ec970532
--- /dev/null
+++ b/easy-ui-icons/src/AccountTree.json
@@ -0,0 +1,5 @@
+{
+ "name": "account_tree",
+ "style": "outlined",
+ "source": "@material-symbols/svg-300"
+}
diff --git a/easy-ui-icons/src/DoorOpen.json b/easy-ui-icons/src/DoorOpen.json
new file mode 100644
index 00000000..e7d05831
--- /dev/null
+++ b/easy-ui-icons/src/DoorOpen.json
@@ -0,0 +1,5 @@
+{
+ "name": "door_open",
+ "style": "outlined",
+ "source": "@material-symbols/svg-300"
+}
diff --git a/easy-ui-icons/src/Key.json b/easy-ui-icons/src/Key.json
new file mode 100644
index 00000000..458b0a1b
--- /dev/null
+++ b/easy-ui-icons/src/Key.json
@@ -0,0 +1,5 @@
+{
+ "name": "key",
+ "style": "outlined",
+ "source": "@material-symbols/svg-300"
+}
diff --git a/easy-ui-icons/src/Shield.json b/easy-ui-icons/src/Shield.json
new file mode 100644
index 00000000..55ca9f9d
--- /dev/null
+++ b/easy-ui-icons/src/Shield.json
@@ -0,0 +1,5 @@
+{
+ "name": "shield",
+ "style": "outlined",
+ "source": "@material-symbols/svg-300"
+}
diff --git a/easy-ui-icons/src/ViewList.json b/easy-ui-icons/src/ViewList.json
new file mode 100644
index 00000000..05aa795d
--- /dev/null
+++ b/easy-ui-icons/src/ViewList.json
@@ -0,0 +1,5 @@
+{
+ "name": "view_list",
+ "style": "outlined",
+ "source": "@material-symbols/svg-300"
+}
diff --git a/easy-ui-icons/src/Widgets.json b/easy-ui-icons/src/Widgets.json
new file mode 100644
index 00000000..12784695
--- /dev/null
+++ b/easy-ui-icons/src/Widgets.json
@@ -0,0 +1,5 @@
+{
+ "name": "widgets",
+ "style": "outlined",
+ "source": "@material-symbols/svg-300"
+}
diff --git a/easy-ui-react/src/ForgeLayout/ForgeLayout.mdx b/easy-ui-react/src/ForgeLayout/ForgeLayout.mdx
new file mode 100644
index 00000000..893005b5
--- /dev/null
+++ b/easy-ui-react/src/ForgeLayout/ForgeLayout.mdx
@@ -0,0 +1,40 @@
+import React from "react";
+import { Canvas, Meta, ArgTypes, Controls } from "@storybook/blocks";
+import { ForgeLayout } from "./ForgeLayout";
+import * as ForgeLayoutStories from "./ForgeLayout.stories";
+
+
+
+# ForgeLayout
+
+`ForgeLayout` defines the header, main content, and multipage content areas of a Nexus product page.
+
+
+
+`ForgeLayout` is a compound component consisting of `ForgeLayout`, `ForgeLayout.Nav`, `ForgeLayout.Header`, and `ForgeLayout.Content`.
+
+`ForgeLayout` also provides components for building navigation with `ForgeLayout.Nav`, `ForgeLayout.NavLink`, and `ForgeLayout.NavSection`.
+
+`ForgeLayout` is highly composable. Subcomponents within a `ForgeLayout` can be replaced as needed. Subcomponents are simple lightweight wrappers with built-in styles and constraints.
+
+## Test Mode
+
+`ForgeLayout` supports an obvious visual indicator for test mode with the `mode="test"` prop.
+
+
+
+
+
+## Collapsed Navigation
+
+`ForgeLayout` supports a distraction-free content mode with the `navState="collapsed"` prop.
+
+
+
+
+
+## Properties
+
+### ForgeLayout
+
+
diff --git a/easy-ui-react/src/ForgeLayout/ForgeLayout.module.scss b/easy-ui-react/src/ForgeLayout/ForgeLayout.module.scss
new file mode 100644
index 00000000..7697b155
--- /dev/null
+++ b/easy-ui-react/src/ForgeLayout/ForgeLayout.module.scss
@@ -0,0 +1,100 @@
+@use "../styles/common" as *;
+@use "../styles/unstyled";
+
+.ForgeLayout {
+ @include component-token("forge-layout", "header-height", 56px);
+ @include component-token("forge-layout", "shell-gutter", 20px);
+ @include component-token("forge-layout", "menu-border-color", transparent);
+ @include component-token(
+ "forge-layout",
+ "header-border-color",
+ design-token("color.neutral.300")
+ );
+ @include component-token("forge-layout", "header-border-width", 1px);
+
+ display: flex;
+ flex-direction: row;
+ align-items: flex-start;
+ padding-left: component-token("forge-layout", "shell-gutter");
+ padding-right: component-token("forge-layout", "shell-gutter");
+ gap: component-token("forge-layout", "shell-gutter");
+ min-height: 100svh;
+ background-color: design-token("color.neutral.025");
+ position: relative;
+}
+
+.modeTest {
+ @include component-token(
+ "forge-layout",
+ "menu-border-color",
+ design-token("color.warning.600")
+ );
+ @include component-token(
+ "forge-layout",
+ "header-border-color",
+ design-token("color.warning.600")
+ );
+ @include component-token("forge-layout", "header-border-width", 2px);
+}
+
+.backgroundDecoration01 {
+ background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzA5IiBoZWlnaHQ9IjI5NSIgdmlld0JveD0iMCAwIDMwOSAyOTUiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIGQ9Ik00NC4xMTc1IDIyOC41NjdDMzQuNjg2OSAyMjYuOTU3IDI3LjcwMjEgMjE4LjkxOCAyNy40MjQgMjA5LjM1N0wyNS41MDk2IDE0My41NzNDMjUuMzM2NSAxMzcuNTU5IDI3Ljg2OTkgMTMxLjc4IDMyLjQxMTIgMTI3LjgzNEw2MS41MjM5IDEwMi41MzRDNjYuMDY1MiA5OC41ODc5IDcyLjEzOTggOTYuODg0NSA3OC4wNzQ4IDk3Ljg5NzhMMTA2LjE4NCAxMDIuNjk4TDExNi4zNjYgMTU0LjMzNkMxMTcuNzQ0IDE2MS4zMzQgMTIyLjczMyAxNjcuMDc2IDEyOS40NzMgMTY5LjQxN0wxNjEuMTU5IDE4MC40MzdMMTYxLjU1MiAxOTMuOTY3QzE2MS43MjkgMTk5Ljk4MiAxNTkuMTk1IDIwNS43NjEgMTU0LjY1NCAyMDkuNzA3TDEyNS41NDEgMjM1LjAwN0MxMjAuOTk3IDIzOC45NTIgMTE0LjkyMiAyNDAuNjU1IDEwOC45OTEgMjM5LjY0M0w0NC4xMTc1IDIyOC41NjdaIiBmaWxsPSIjRkI1QzU5Ii8+CjxwYXRoIGQ9Ik0yMDQuNjczIDE4NC44ODZDMTk5LjI0NCAxODkuNjAzIDE5MS43MDUgMTkxLjA1NSAxODQuOTEgMTg4LjY5NkwxNjEuMTU5IDE4MC40MzdMMTU5LjYzOCAxMjguMTgzQzE1OS4zNiAxMTguNjIyIDE1Mi4zNzkgMTEwLjU4NSAxNDIuOTQ4IDEwOC45NzRMMTA2LjE4NCAxMDIuNjk4TDEwNS4wMTMgOTYuNzUzM0MxMDMuNjIyIDg5LjY5NDIgMTA2LjExMiA4Mi40MzI2IDExMS41NDYgNzcuNzEzM0wxNTYuMDY0IDM5LjAyNzVDMTYxLjQ5MyAzNC4zMTA1IDE2OS4wMzIgMzIuODU3NyAxNzUuODI3IDM1LjIxNzZMMjMxLjI2NCA1NC40OTU3QzIzOC4wMDQgNTYuODM2OSAyNDIuOTkyIDYyLjU3OTMgMjQ0LjM3IDY5LjU3NzFMMjUyLjUzNSAxMTAuOTc4TDI0Ni40MzEgMTA2LjAwOEMyNDIuNjQzIDEwMi45MjQgMjM3LjE4OSAxMDMuMDE0IDIzMy41MDMgMTA2LjIxNUwyMDguOTM5IDEyNy41NjFDMjA1LjI1MyAxMzAuNzYyIDIwNC40MDggMTM2LjE1MiAyMDYuOTMyIDE0MC4zMjlMMjIwLjI5MyAxNjIuNDQ2QzIyMC44ODkgMTYzLjQzMiAyMjEuNjUzIDE2NC4zMSAyMjIuNTQ3IDE2NS4wMzlMMjI1LjEwOCAxNjcuMTI3TDIwNC42NzMgMTg0Ljg4NloiIGZpbGw9IiMxNjRERkYiLz4KPHBhdGggZD0iTTE2MS4xNTkgMTgwLjQzN0wxMjkuNDczIDE2OS40MTdDMTIyLjczMyAxNjcuMDc2IDExNy43NDQgMTYxLjMzNCAxMTYuMzY3IDE1NC4zMzZMMTA2LjE4NCAxMDIuNjk4TDE0Mi45NDggMTA4Ljk3NEMxNTIuMzc5IDExMC41ODQgMTU5LjM2IDExOC42MjIgMTU5LjYzOCAxMjguMTgzTDE2MS4xNTkgMTgwLjQzN1oiIGZpbGw9IiNGRURFREUiLz4KPHBhdGggZD0iTTI1NS41MDYgMTgxLjE0NkMyNTEuODIgMTg0LjM0OCAyNDYuMzYzIDE4NC40MzYgMjQyLjU3OCAxODEuMzUzTDIyNS4xMDggMTY3LjEyN0wyNDkuMTkxIDE0Ni4yQzI1NC42MjQgMTQxLjQ4IDI1Ny4xMTUgMTM0LjIxOSAyNTUuNzI0IDEyNy4xNkwyNTIuNTM1IDExMC45NzhMMjY2LjQ2MiAxMjIuMzIyQzI2Ny4zNTYgMTIzLjA1MSAyNjguMTIgMTIzLjkyOSAyNjguNzE2IDEyNC45MTZMMjgyLjA3NiAxNDcuMDMyQzI4NC42MDEgMTUxLjIwOSAyODMuNzUzIDE1Ni41OTggMjgwLjA2NyAxNTkuNzk5TDI1NS41MDYgMTgxLjE0NlYxODEuMTQ2WiIgZmlsbD0iIzAwRTVBRSIvPgo8cGF0aCBkPSJNMjI1LjEwOCAxNjcuMTI3TDIyMi41NDcgMTY1LjAzOUMyMjEuNjUzIDE2NC4zMSAyMjAuODg5IDE2My40MzIgMjIwLjI5MyAxNjIuNDQ1TDIwNi45MzIgMTQwLjMyOUMyMDQuNDA4IDEzNi4xNTIgMjA1LjI1MyAxMzAuNzYyIDIwOC45MzkgMTI3LjU2MUwyMzMuNTAzIDEwNi4yMTVDMjM3LjE4OSAxMDMuMDE0IDI0Mi42NDMgMTAyLjkyNCAyNDYuNDMxIDEwNi4wMDhMMjUyLjUzNSAxMTAuOTc4TDI1NS43MjQgMTI3LjE2QzI1Ny4xMTUgMTM0LjIxOSAyNTQuNjI0IDE0MS40OCAyNDkuMTkxIDE0Ni4yTDIyNS4xMDggMTY3LjEyN1YxNjcuMTI3WiIgZmlsbD0iI0ZGRjlGNSIvPgo8L3N2Zz4K"),
+ url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzEyIiBoZWlnaHQ9IjIyMyIgdmlld0JveD0iMCAwIDMxMiAyMjMiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIGQ9Ik0yNzAuNDE1IDEwLjI3MTNDMjc0LjU0NCAxMS4zOSAyNzcuNzcxIDE0LjYxMzYgMjc4Ljg5IDE4Ljc0NjJMMjg4LjA5OCA1Mi43MjlDMjg5LjIzMSA1Ni44OTMgMjg4LjA0MSA2MS4zNDYgMjg0Ljk5IDY0LjM5OUwyNzMuOTUxIDc1LjQzN0wyNTYuMDQyIDUzLjU3MkMyNTEuNDcgNDcuOTg0OSAyNDQuNjI4IDQ0Ljc0NyAyMzcuNDA2IDQ0Ljc0ODdMMTk3LjY4OCA0NC43NDQ3TDE5Ni42MzUgNDAuODU2N0MxOTUuNTA2IDM2LjY4OTkgMTk2LjY5MiAzMi4yMzk2IDE5OS43NDMgMjkuMTg2NkwyMjQuNzYzIDQuMTcwNDFDMjI3LjgxNCAxLjExNzQxIDIzMi4yNjYgLTAuMDY3MjkxOSAyMzYuNDMgMS4wNjA5MUwyNzAuNDE1IDEwLjI3MTNaIiBmaWxsPSIjRkI1QzU5Ii8+CjxwYXRoIGQ9Ik0zMDYuMDg0IDExNC42NThDMzEzLjM1NSAxMjMuNTM1IDMxMy4zNTUgMTM2LjMxMiAzMDYuMDgyIDE0NS4xOUwyNTYuMDQ1IDIwNi4yNzRDMjUxLjQ3MSAyMTEuODYxIDI0NC42MjggMjE1LjEwMSAyMzcuNDA4IDIxNS4wOTdMMTkxLjEzMSAyMTUuMDk5QzE4My45MTMgMjE1LjA5OCAxNzcuMDcxIDIxMS44NjEgMTcyLjQ5NSAyMDYuMjc2TDE1NS41ODIgMTg1LjYyOUMxNTYuODc1IDE4My43NTUgMTU4LjA5MyAxODEuODIgMTU5LjIzNSAxNzkuODI0QzE3NC4yODkgMTUzLjU3NyAxNzQuMjg4IDEyMS4zMTggMTU5LjIzNSA5NS4wNzJDMTU2LjQ0IDkwLjE5OCAxNTMuMjA2IDg1LjY3MSAxNDkuNTk2IDgxLjUyNkwxNzIuNDkzIDUzLjU3M0MxNzcuMDY2IDQ3Ljk4NjcgMTgzLjkwNSA0NC43NDg3IDE5MS4xMjcgNDQuNzQ2OUwxOTcuNjg0IDQ0Ljc0NjZMMjA1LjgzOSA3NC44NDFDMjA2Ljk2MiA3OC45NzIgMjEwLjE4NiA4Mi4xOTcgMjE0LjMxNiA4My4zMkwyNDguMyA5Mi41MjdDMjUyLjQ2OSA5My42NTYgMjU2LjkxNiA5Mi40NyAyNTkuOTY5IDg5LjQyMUwyNzMuOTQ3IDc1LjQzOUwzMDYuMDggMTE0LjY2TDMwNi4wODQgMTE0LjY1OFoiIGZpbGw9IiMxNjRERkYiLz4KPHBhdGggZD0iTTI1Ni4wNDIgNTMuNTcyTDI3My45NTEgNzUuNDM3TDI1OS45NzIgODkuNDE5QzI1Ni45MTkgOTIuNDY4IDI1Mi40NzMgOTMuNjU1IDI0OC4zMDMgOTIuNTI1TDIxNC4zMiA4My4zMThDMjEwLjE4OSA4Mi4xOTYgMjA2Ljk2NiA3OC45NyAyMDUuODQzIDc0Ljg0TDE5Ny42ODggNDQuNzQ0OUwyMzcuNDA2IDQ0Ljc0OUMyNDQuNjI4IDQ0Ljc0NzIgMjUxLjQ3IDQ3Ljk4NTEgMjU2LjA0MiA1My41NzJaIiBmaWxsPSIjRkVERURFIi8+CjxwYXRoIGQ9Ik0xNTkuMjM2IDk1LjA2N0MxNzQuMjkyIDEyMS4zMTYgMTc0LjI5MyAxNTMuNTc1IDE1OS4yMzkgMTc5LjgyMkMxNTguMDk2IDE4MS44MTggMTU2Ljg3OCAxODMuNzUzIDE1NS41ODYgMTg1LjYyN0wxMjIuNDYxIDE0NS4xODZDMTE1LjE5IDEzNi4zMDkgMTE1LjE5IDEyMy41MzIgMTIyLjQ2MyAxMTQuNjUzTDE0OS42MDMgODEuNTIzQzE1My4yMTMgODUuNjY4IDE1Ni40NDcgOTAuMTk0IDE1OS4yNCA5NS4wNjVMMTU5LjIzNiA5NS4wNjdaIiBmaWxsPSIjQ0NGQUVGIi8+CjxwYXRoIGQ9Ik0xNDkuNTk5IDgxLjUyNUwxMjIuNDU5IDExNC42NTZDMTE1LjE4NiAxMjMuNTM0IDExNS4xODYgMTM2LjMxMSAxMjIuNDU3IDE0NS4xODhMMTU1LjU4MiAxODUuNjI5QzEzOS43ODkgMjA4LjY2MiAxMTMuNTY5IDIyMi42MjMgODUuMzU3IDIyMi42MjJMODUuMzUzIDIyMi42MjRDNTQuODM2NiAyMjIuNjIzIDI2LjY1MzIgMjA2LjI5NyAxMS40NjgyIDE3OS44MjRDMy45NDI0IDE2Ni43MDMgMC4xNzczMDIgMTUyLjA3NiAwLjE3OTkwMiAxMzcuNDQ1QzAuMTc4ODAyIDEyMi44MTYgMy45NDAyIDEwOC4xOTEgMTEuNDY5MiA5NS4wNjZDMTkuMDgxOCA4MS43OTQgMjkuOTYwMyA3MS4wNzIgNDIuNzY1NiA2My42NzlDNTUuNTcwOCA1Ni4yODYgNzAuMTM2IDUyLjI2OCA4NS4zNTMgNTIuMjdDMTEwLjI1NSA1Mi4yNjkgMTMzLjYwMyA2My4xMzYgMTQ5LjU5OSA4MS41MjVaIiBmaWxsPSIjMDBFNUFFIi8+Cjwvc3ZnPgo=");
+ background-repeat: no-repeat;
+ background-position:
+ -40px -90px,
+ calc(100% + 24px) calc(100% + 24px);
+}
+
+.body {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+}
+
+.header {
+ display: flex;
+ flex-wrap: nowrap;
+ align-items: center;
+ justify-content: space-between;
+ min-width: 0;
+ border-bottom: 2px solid transparent;
+ z-index: calc(#{design-token("z-index.nav")} + 1);
+}
+
+.content {
+ padding-top: component-token("forge-layout", "shell-gutter");
+ padding-bottom: component-token("forge-layout", "shell-gutter");
+}
+
+.controls {
+ display: flex;
+ align-items: center;
+ gap: design-token("space.2");
+}
+
+.fauxContainer {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+
+ display: flex;
+ flex-direction: column;
+ pointer-events: none;
+}
+
+.fauxHeader {
+ background-color: design-token("color.neutral.025");
+ border-bottom: component-token("forge-layout", "header-border-width") solid
+ component-token("forge-layout", "header-border-color");
+ z-index: design-token("z-index.nav");
+}
+
+.fauxHeader,
+.header {
+ position: sticky;
+ top: 0;
+ height: component-token("forge-layout", "header-height");
+}
diff --git a/easy-ui-react/src/ForgeLayout/ForgeLayout.stories.tsx b/easy-ui-react/src/ForgeLayout/ForgeLayout.stories.tsx
new file mode 100644
index 00000000..8a287ec7
--- /dev/null
+++ b/easy-ui-react/src/ForgeLayout/ForgeLayout.stories.tsx
@@ -0,0 +1,157 @@
+import AccountBalanceIcon from "@easypost/easy-ui-icons/AccountBalance";
+import AccountCircleIcon from "@easypost/easy-ui-icons/AccountCircle";
+import AccountTreeIcon from "@easypost/easy-ui-icons/AccountTree";
+import DoorOpenIcon from "@easypost/easy-ui-icons/DoorOpen";
+import GroupsIcon from "@easypost/easy-ui-icons/Groups";
+import HomeIcon from "@easypost/easy-ui-icons/Home";
+import KeyIcon from "@easypost/easy-ui-icons/Key";
+import LocalShippingIcon from "@easypost/easy-ui-icons/LocalShipping";
+import RadarIcon from "@easypost/easy-ui-icons/Radar";
+import SettingsIcon from "@easypost/easy-ui-icons/Settings";
+import ShieldIcon from "@easypost/easy-ui-icons/Shield";
+import SupportIcon from "@easypost/easy-ui-icons/Support";
+import ViewListIcon from "@easypost/easy-ui-icons/ViewList";
+import WebhookIcon from "@easypost/easy-ui-icons/Webhook";
+import WidgetsIcon from "@easypost/easy-ui-icons/Widgets";
+import { action } from "@storybook/addon-actions";
+import { Meta, StoryObj } from "@storybook/react";
+import React from "react";
+import { Menu } from "../Menu";
+import { ForgeLayout, ForgeLayoutProps } from "./ForgeLayout";
+import { Card } from "../Card";
+
+type Story = StoryObj;
+
+const Template = (args: Partial) => {
+ return (
+
+
+
+ Dashboard
+
+ Management>}>
+
+ Insurance
+
+
+ Sub Accounts
+
+
+ Carriers
+
+
+ Wallet
+
+
+ Branding
+
+
+ Members
+
+
+ Account Settings
+
+
+ Development>}>
+
+ Logs
+
+
+ API Keys
+
+
+ Webhooks
+
+
+
+
+
+
+ Controls when collapsed
+
+
+ Controls when expanded
+
+
+ }
+ >
+
+ Action 1:1
+ Action 1:2
+
+
+
+
+ Action 2:1
+ Action 2:2
+
+
+
+
+
+
+
+
+ Page Content
+
+
+
+
+ );
+};
+
+const meta: Meta = {
+ title: "Components/ProductLayout/ForgeLayout",
+ component: ForgeLayout,
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+};
+
+export default meta;
+
+export const Default: Story = {
+ render: Template.bind({}),
+};
+
+export const TestMode: Story = {
+ render: Template.bind({}),
+ args: {
+ mode: "test",
+ },
+ parameters: {
+ controls: {
+ include: ["mode"],
+ },
+ },
+};
+
+export const Collapsed: Story = {
+ render: Template.bind({}),
+ args: {
+ navState: "collapsed",
+ },
+ parameters: {
+ controls: {
+ include: ["navState"],
+ },
+ },
+};
diff --git a/easy-ui-react/src/ForgeLayout/ForgeLayout.test.tsx b/easy-ui-react/src/ForgeLayout/ForgeLayout.test.tsx
new file mode 100644
index 00000000..4b906a04
--- /dev/null
+++ b/easy-ui-react/src/ForgeLayout/ForgeLayout.test.tsx
@@ -0,0 +1,169 @@
+import { screen } from "@testing-library/react";
+import React, { ReactNode } from "react";
+import { vi } from "vitest";
+import { Menu } from "../Menu";
+import {
+ mockGetComputedStyle,
+ mockIntersectionObserver,
+ render,
+ userClick,
+} from "../utilities/test";
+import { ForgeLayout, Mode, NavState } from "./ForgeLayout";
+
+describe("", () => {
+ let restoreGetComputedStyle: () => void;
+ let restoreIntersectionObserver: () => void;
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ restoreIntersectionObserver = mockIntersectionObserver();
+ restoreGetComputedStyle = mockGetComputedStyle();
+ });
+
+ afterEach(() => {
+ restoreGetComputedStyle();
+ restoreIntersectionObserver();
+ vi.useRealTimers();
+ });
+
+ it("should render a forge layout", async () => {
+ const handleMenuAction1 = vi.fn();
+
+ const { user } = render(
+ createForgeLayout({
+ selectedHref: "/1",
+ onMenuAction1: handleMenuAction1,
+ }),
+ );
+
+ expect(screen.getByText("Content")).toBeInTheDocument();
+ expect(screen.getByRole("main")).toBeInTheDocument();
+ expect(screen.getByRole("banner")).toBeInTheDocument();
+ expect(
+ screen.getByRole("navigation", { name: "Main" }),
+ ).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "Nav Link 1" })).toHaveAttribute(
+ "aria-current",
+ "page",
+ );
+ expect(screen.getByRole("link", { name: "Action 3" })).toBeInTheDocument();
+
+ await userClick(
+ user,
+ screen.getByRole("button", { name: "Menu Action 1" }),
+ );
+ await userClick(
+ user,
+ screen.getByRole("menuitem", { name: "Menu Action 1:1" }),
+ );
+
+ expect(handleMenuAction1).toBeCalled();
+ });
+
+ it("should render collapsed state", async () => {
+ render(
+ createForgeLayout({
+ navState: "collapsed",
+ selectedHref: "/1",
+ }),
+ );
+ expect(
+ screen.queryByRole("navigation", { name: "Main" }),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByText("Controls when expanded"),
+ ).not.toBeInTheDocument();
+ expect(screen.queryByText("Controls when collapsed")).toBeInTheDocument();
+ });
+
+ it("should render test mode", async () => {
+ render(
+ createForgeLayout({
+ mode: "test",
+ selectedHref: "/1",
+ }),
+ );
+ expect(screen.getByTestId("ForgeLayout")).toHaveAttribute(
+ "class",
+ expect.stringContaining("modeTest"),
+ );
+ });
+});
+
+function createForgeLayout(
+ props: {
+ mode?: Mode;
+ navState?: NavState;
+ content?: ReactNode;
+ selectedHref?: string;
+ onMenuAction1?: () => void;
+ onMenuAction2?: () => void;
+ } = {},
+) {
+ const {
+ mode = "production",
+ navState = "expanded",
+ content = Content
,
+ selectedHref = "/1",
+ onMenuAction1 = vi.fn(),
+ onMenuAction2 = vi.fn(),
+ } = props;
+ return (
+
+
+
+ Nav Link 1
+
+ Nav Section Title>}>
+
+ Nav Link 2
+
+
+ Nav Link 3
+
+
+
+
+
+ Controls when collapsed
+
+
+ Controls when expanded
+
+
+ }
+ >
+
+ Menu Action 1:1
+ Menu Action 1:2
+
+
+
+
+ Menu Action 2:1
+ Menu Action 2:2
+
+
+
+
+
+ {content}
+
+ );
+}
+
+function Icon() {
+ return (
+
+ );
+}
diff --git a/easy-ui-react/src/ForgeLayout/ForgeLayout.tsx b/easy-ui-react/src/ForgeLayout/ForgeLayout.tsx
new file mode 100644
index 00000000..78631b26
--- /dev/null
+++ b/easy-ui-react/src/ForgeLayout/ForgeLayout.tsx
@@ -0,0 +1,273 @@
+import React, { ReactNode, useContext, useMemo } from "react";
+import { classNames, variationName } from "../utilities/css";
+import {
+ ForgeLayoutActionBadge,
+ ForgeLayoutActions,
+ ForgeLayoutLinkAction,
+ ForgeLayoutMenuAction,
+} from "./ForgeLayoutActions";
+import {
+ ForgeLayoutNav,
+ ForgeLayoutNavLink,
+ ForgeLayoutNavSection,
+ useForgeLayoutNav,
+} from "./ForgeLayoutNav";
+
+import styles from "./ForgeLayout.module.scss";
+
+export type Mode = "test" | "production";
+
+export type NavState = "expanded" | "collapsed";
+
+export type ForgeLayoutProps = {
+ /** Layout children. */
+ children: ReactNode;
+
+ /**
+ * Provides obvious visual indicator for non-production modes.
+ *
+ * @default production
+ */
+ mode?: Mode;
+
+ /**
+ * Display state of the nav menu.
+ *
+ * @default expanded
+ */
+ navState?: NavState;
+
+ /**
+ * Background decoration for layout.
+ *
+ * @default 01
+ */
+ backgroundDecoration?: "01";
+};
+
+export type ForgeLayoutHeaderProps = {
+ /** Header children. */
+ children: ReactNode;
+};
+
+export type ForgeLayoutContentProps = {
+ /** Content children. */
+ children: ReactNode;
+};
+
+export type ForgeLayoutControlsProps = {
+ /** Controls children. */
+ children: ReactNode;
+
+ /**
+ * Display state of the nav menu for when these controls show.
+ *
+ * @default expanded
+ */
+ visibleWhenNavStateIs?: NavState;
+};
+
+export type ForgeLayoutContextType = {
+ mode?: Mode;
+ navState?: NavState;
+};
+
+const ForgeLayoutContext = React.createContext(
+ null,
+);
+
+export const useForgeLayout = () => {
+ const context = useContext(ForgeLayoutContext);
+ if (!context) {
+ throw new Error("useForgeLayout must be used within a ForgeLayout");
+ }
+ return context;
+};
+
+/**
+ * `ForgeLayout` defines the header, nav, and main content areas of a Forge product page.
+ *
+ * @example
+ * ```tsx
+ *
+ *
+ *
+ * Item 1
+ *
+ * Title>}>
+ *
+ * Item 2
+ *
+ *
+ * Item 3
+ *
+ *
+ * Title>}>
+ *
+ * Item 4
+ *
+ *
+ * Item 5
+ *
+ *
+ *
+ *
+ *
+ *
+ * {}}>
+ * Back
+ *
+ *
+ * Breadcrumb
+ * Breadcrumb
+ *
+ *
+ *
+ *
+ *
+ * {}} />
+ *
+ *
+ * }
+ * >
+ *
+ * Action 1:1
+ * Action 1:2
+ *
+ *
+ *
+ *
+ * Action 2:1
+ * Action 2:2
+ *
+ *
+ *
+ *
+ *
+ * Page Content
+ *
+ * ```
+ */
+export function ForgeLayout(props: ForgeLayoutProps) {
+ const {
+ backgroundDecoration = "01",
+ mode = "production",
+ navState = "expanded",
+ children,
+ } = props;
+ const className = classNames(
+ styles.ForgeLayout,
+ styles[variationName("backgroundDecoration", backgroundDecoration)],
+ styles[variationName("mode", mode)],
+ styles[variationName("navState", navState)],
+ );
+ const context = useMemo(() => {
+ return { mode, navState };
+ }, [mode, navState]);
+ return (
+
+
+
+ );
+}
+
+function ForgeLayoutHeader(props: ForgeLayoutHeaderProps) {
+ const { children } = props;
+ return ;
+}
+
+function ForgeLayoutControls(props: ForgeLayoutControlsProps) {
+ const { navState } = useForgeLayout();
+ const { children, visibleWhenNavStateIs = "expanded" } = props;
+
+ if (navState !== visibleWhenNavStateIs) {
+ return null;
+ }
+
+ return {children}
;
+}
+
+function ForgeLayoutBody(props: ForgeLayoutContentProps) {
+ const { children } = props;
+ return {children}
;
+}
+
+function ForgeLayoutContent(props: ForgeLayoutContentProps) {
+ const { children } = props;
+ return {children};
+}
+
+/**
+ * Represents the primary nav of a ``.
+ */
+ForgeLayout.Nav = ForgeLayoutNav;
+
+/**
+ * Represents a section in the primary nav of a ``.
+ */
+ForgeLayout.NavSection = ForgeLayoutNavSection;
+
+/**
+ * Represents a primary nav link of a ``.
+ */
+ForgeLayout.NavLink = ForgeLayoutNavLink;
+
+/**
+ * Represents a body that holds the header and main content in a ``.
+ */
+ForgeLayout.Body = ForgeLayoutBody;
+
+/**
+ * Represents the header of a ``.
+ */
+ForgeLayout.Header = ForgeLayoutHeader;
+
+/**
+ * Represents the controls of a ``.
+ */
+ForgeLayout.Controls = ForgeLayoutControls;
+
+/**
+ * Represents the secondary actions of a ``.
+ */
+ForgeLayout.Actions = ForgeLayoutActions;
+
+/**
+ * Represents an action badge in a ``.
+ */
+ForgeLayout.ActionBadge = ForgeLayoutActionBadge;
+
+/**
+ * Represents a secondary menu action of a ``.
+ */
+ForgeLayout.MenuAction = ForgeLayoutMenuAction;
+
+/**
+ * Represents a secondary link action of a ``.
+ */
+ForgeLayout.LinkAction = ForgeLayoutLinkAction;
+
+/**
+ * Represents the main content of a ``.
+ */
+ForgeLayout.Content = ForgeLayoutContent;
+
+/**
+ * Helper hook for retrieving nav state. Useful for custom nav links.
+ */
+ForgeLayout.useForgeLayoutNav = useForgeLayoutNav;
diff --git a/easy-ui-react/src/ForgeLayout/ForgeLayoutActions.module.scss b/easy-ui-react/src/ForgeLayout/ForgeLayoutActions.module.scss
new file mode 100644
index 00000000..2b3113d8
--- /dev/null
+++ b/easy-ui-react/src/ForgeLayout/ForgeLayoutActions.module.scss
@@ -0,0 +1,51 @@
+@use "../styles/common" as *;
+@use "../styles/unstyled";
+
+.button {
+ @include unstyled.link;
+ @include unstyled.button;
+ cursor: pointer;
+
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+
+ border-radius: design-token("shape.border_radius.lg");
+ padding: design-token("space.0.5");
+ margin: calc(#{design-token("space.0.5")} * -1);
+ outline: none;
+}
+
+.focused {
+ @include native-focus-ring;
+}
+
+.hovered {
+ background-color: design-token("color.neutral.050");
+}
+
+.selected,
+.open {
+ background-color: design-token("color.primary.700");
+ color: design-token("color.neutral.000");
+}
+
+.badgeContainer {
+ position: absolute;
+ right: design-token("space.0.5");
+ bottom: design-token("space.0.5");
+ display: inline-flex;
+}
+
+.badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+
+ background-color: design-token("color.positive.500");
+ border-radius: 100%;
+ color: design-token("color.primary.800");
+ height: design-token("space.1.5");
+ width: design-token("space.1.5");
+}
diff --git a/easy-ui-react/src/ForgeLayout/ForgeLayoutActions.tsx b/easy-ui-react/src/ForgeLayout/ForgeLayoutActions.tsx
new file mode 100644
index 00000000..6b1c21a1
--- /dev/null
+++ b/easy-ui-react/src/ForgeLayout/ForgeLayoutActions.tsx
@@ -0,0 +1,165 @@
+import React, {
+ ComponentProps,
+ forwardRef,
+ ReactNode,
+ useCallback,
+ useRef,
+ useState,
+} from "react";
+import {
+ AriaLinkOptions,
+ mergeProps,
+ PressHookProps,
+ useFocusRing,
+ useHover,
+ useLink,
+ usePress,
+} from "react-aria";
+import { HorizontalStack } from "../HorizontalStack";
+import { Icon } from "../Icon";
+import { Menu } from "../Menu";
+import { Text } from "../Text";
+import { IconSymbol } from "../types";
+import { classNames } from "../utilities/css";
+
+import styles from "./ForgeLayoutActions.module.scss";
+
+export type ForgeLayoutActionsProps = {
+ /** Actions children. */
+ children: ReactNode;
+};
+
+export type ForgeLayoutActionBadgeProps = {
+ /** Badge children. */
+ children?: ReactNode;
+};
+
+export type ForgeLayoutMenuActionProps = {
+ /** Optional custom accessibility label describing the menu action. */
+ accessibilityLabel?: string;
+
+ /** Icon symbol for the action. */
+ iconSymbol: IconSymbol;
+
+ /** Badge for the action. */
+ renderBadge?: () => ReactNode;
+
+ /** Render the menu overlay. */
+ children: ReactNode;
+};
+
+export type ForgeLayoutLinkActionProps = {
+ /** Optional custom accessibility label describing the menu action. */
+ accessibilityLabel?: string;
+
+ /** Action link icon symbol. */
+ iconSymbol: IconSymbol;
+
+ /** Whether or not action link is selected. */
+ isSelected?: boolean;
+
+ /** Badge for the action. */
+ renderBadge?: () => ReactNode;
+} & AriaLinkOptions;
+
+export function ForgeLayoutActions(props: ForgeLayoutActionsProps) {
+ const { children } = props;
+ return (
+
+ {children}
+
+ );
+}
+
+export function ForgeLayoutMenuAction(props: ForgeLayoutMenuActionProps) {
+ const {
+ accessibilityLabel = "Actions",
+ iconSymbol,
+ children,
+ renderBadge,
+ } = props;
+ const [isOpen, setIsOpen] = useState(false);
+ const handleOpenChange = useCallback((isOpen: boolean) => {
+ setIsOpen(isOpen);
+ }, []);
+ const { focusProps, isFocusVisible } = useFocusRing({});
+ const { hoverProps, isHovered } = useHover({});
+ const className = classNames(
+ styles.button,
+ isFocusVisible && styles.focused,
+ isHovered && styles.hovered,
+ isOpen && styles.open,
+ );
+ return (
+
+ );
+}
+
+export function ForgeLayoutLinkAction(props: ForgeLayoutLinkActionProps) {
+ const {
+ accessibilityLabel = "Actions",
+ iconSymbol,
+ renderBadge,
+ isSelected,
+ } = props;
+ const ref = useRef(null);
+ const { linkProps } = useLink(props, ref);
+ const { focusProps, isFocusVisible } = useFocusRing(props);
+ const { hoverProps, isHovered } = useHover(props);
+ const className = classNames(
+ styles.button,
+ isFocusVisible && styles.focused,
+ isHovered && styles.hovered,
+ isSelected && styles.selected,
+ );
+ return (
+
+ {accessibilityLabel}
+
+ {renderBadge && (
+ {renderBadge()}
+ )}
+
+ );
+}
+
+export function ForgeLayoutActionBadge(props: ForgeLayoutActionBadgeProps) {
+ const { children } = props;
+ return {children}
;
+}
+
+/** TODO: Figure out how to work with UnstyledButton instead */
+export const PressableButton = forwardRef<
+ HTMLButtonElement,
+ ComponentProps<"button"> & PressHookProps
+>((props, ref) => {
+ const { pressProps } = usePress(props);
+ return (
+
+ );
+});
+
+PressableButton.displayName = "PressableButton";
diff --git a/easy-ui-react/src/ForgeLayout/ForgeLayoutNav.module.scss b/easy-ui-react/src/ForgeLayout/ForgeLayoutNav.module.scss
new file mode 100644
index 00000000..05124931
--- /dev/null
+++ b/easy-ui-react/src/ForgeLayout/ForgeLayoutNav.module.scss
@@ -0,0 +1,35 @@
+@use "../styles/common" as *;
+
+.nav {
+ position: sticky;
+ top: 0;
+ background: design-token("color.neutral.000");
+ box-shadow: design-token("shadow.level.2");
+ border: 2px solid component-token("forge-layout", "menu-border-color");
+ border-top: 0;
+ border-bottom-right-radius: design-token("shape.border_radius.lg");
+ border-bottom-left-radius: design-token("shape.border_radius.lg");
+ padding: design-token("space.2");
+ z-index: calc(#{design-token("z-index.nav")} + 1);
+}
+
+.link {
+ display: inline-flex;
+ align-items: center;
+ text-decoration: none;
+ color: design-token("color.primary.700");
+ gap: design-token("space.1");
+ outline: none;
+}
+
+.focused {
+ @include native-focus-ring;
+}
+
+.hovered {
+ color: design-token("color.primary.600");
+}
+
+.selected {
+ color: design-token("color.primary.500");
+}
diff --git a/easy-ui-react/src/ForgeLayout/ForgeLayoutNav.tsx b/easy-ui-react/src/ForgeLayout/ForgeLayoutNav.tsx
new file mode 100644
index 00000000..e0a7f3b0
--- /dev/null
+++ b/easy-ui-react/src/ForgeLayout/ForgeLayoutNav.tsx
@@ -0,0 +1,154 @@
+import React, { ReactNode, useContext, useMemo, useRef } from "react";
+import {
+ AriaLinkOptions,
+ mergeProps,
+ useFocusRing,
+ useHover,
+ useLink,
+} from "react-aria";
+import { Icon } from "../Icon";
+import { Text } from "../Text";
+import { VerticalStack } from "../VerticalStack";
+import { IconSymbol } from "../types";
+import { classNames } from "../utilities/css";
+
+import styles from "./ForgeLayoutNav.module.scss";
+import { useForgeLayout } from "./ForgeLayout";
+
+export type ForgeLayoutNavProps = {
+ /**
+ * Sidebar nav title.
+ *
+ * @default Main
+ */
+ title?: ReactNode;
+
+ /** Selected href of sidebar nav link. */
+ selectedHref?: AriaLinkOptions["href"];
+
+ /** Multipage container children. */
+ children: ReactNode;
+};
+
+export type ForgeLayoutNavSectionProps = {
+ /** Sidebar nav section title. */
+ title: ReactNode;
+
+ /** Sidebar nav section children. */
+ children: ReactNode;
+};
+
+export type ForgeLayoutNavLinkProps = {
+ /** Nav link icon symbol. */
+ iconSymbol: IconSymbol;
+
+ /** Nav link children. */
+ children: ReactNode;
+} & AriaLinkOptions;
+
+export type ForgeLayoutNavContextType = {
+ selectedHref?: ForgeLayoutNavProps["selectedHref"];
+};
+
+const ForgeLayoutNavContext =
+ React.createContext(null);
+
+export const useForgeLayoutNav = () => {
+ const context = useContext(ForgeLayoutNavContext);
+ if (!context) {
+ throw new Error("useForgeLayoutNav must be used within a ForgeLayoutNav");
+ }
+ return context;
+};
+
+export function ForgeLayoutNav(props: ForgeLayoutNavProps) {
+ const { navState = "expanded" } = useForgeLayout();
+ const { selectedHref, title = "Main", children } = props;
+ const context = useMemo(() => {
+ return { selectedHref };
+ }, [selectedHref]);
+
+ if (navState === "collapsed") {
+ return null;
+ }
+
+ return (
+
+
+
+
+ {children}
+
+
+
+ );
+}
+
+export function ForgeLayoutNavSection(props: ForgeLayoutNavSectionProps) {
+ const { title, children } = props;
+ return (
+
+
+ {title}
+
+ {children}
+
+ );
+}
+
+export function ForgeLayoutNavLink(props: ForgeLayoutNavLinkProps) {
+ const { href, iconSymbol, children } = props;
+ const { selectedHref } = useForgeLayoutNav();
+
+ const ref = useRef(null);
+ const { linkProps } = useLink(props, ref);
+ const { focusProps, isFocusVisible } = useFocusRing(props);
+ const { hoverProps, isHovered } = useHover(props);
+ const isSelected = href === selectedHref;
+ const className = classNames(
+ styles.link,
+ isFocusVisible && styles.focused,
+ isHovered && styles.hovered,
+ isSelected && styles.selected,
+ );
+ return (
+
+
+ {children}
+
+ );
+}
+
+function Logo() {
+ return (
+
+ );
+}
diff --git a/easy-ui-react/src/ForgeLayout/index.ts b/easy-ui-react/src/ForgeLayout/index.ts
new file mode 100644
index 00000000..ef50f218
--- /dev/null
+++ b/easy-ui-react/src/ForgeLayout/index.ts
@@ -0,0 +1 @@
+export * from "./ForgeLayout";