Compare commits

...

11 Commits

Author SHA1 Message Date
MangoPig
fcf96590bb Merge branch 'Features/Frontend/Context-Menu' 2026-06-18 11:17:23 +01:00
MangoPig
eeba19bbb6 Feat: Add workspace context actions 2026-06-18 11:16:54 +01:00
MangoPig
dea9e7e6ff Merge branch 'Features/Frontend/Responsiveness' 2026-06-17 10:52:39 +01:00
MangoPig
85bf971547 Feat: Add responsive workspace shell 2026-06-17 10:52:14 +01:00
MangoPig
5d86a5124b Merge branch 'Features/Frontend/CollapsibleShell' into tmp/collapsible-shell-clean-merge 2026-06-17 05:42:48 +01:00
MangoPig
7fdc5f2d22 Feat: Add collapsible shell 2026-06-17 05:37:29 +01:00
MangoPig
630b3778db Merge branch 'Features/Frontend/Notifications' 2026-06-16 17:00:51 +01:00
MangoPig
248a0b1828 Feat: Add notifications menu 2026-06-16 17:00:51 +01:00
MangoPig
fd429bdcdd Merge branch 'Features/Frontend/ProfileMenu' 2026-06-16 16:39:41 +01:00
MangoPig
bbebccfcf3 Feat: Add profile menu 2026-06-16 16:38:26 +01:00
MangoPig
fd67af7101 Merge branch 'Features/Server-Shell' 2026-06-16 13:11:59 +01:00
41 changed files with 3729 additions and 138 deletions

2
.gitignore vendored
View File

@@ -25,3 +25,5 @@ pnpm-debug.log*
# Go build output
tmp/
bin/
.cgcignore

View File

@@ -15,7 +15,6 @@ type Config struct {
LogLevel string
WebPort string
APIPort string
WorkerPort string
PostgresURL string
ValkeyURL string
ShutdownTimeout time.Duration
@@ -28,7 +27,6 @@ func Load() *Config {
LogLevel: getEnv("LOG_LEVEL", "debug"),
WebPort: getEnv("BACKEND_WEB_PORT", "8080"),
APIPort: getEnv("BACKEND_API_PORT", "8081"),
WorkerPort: getEnv("BACKEND_WORKER_PORT", "8082"),
PostgresURL: getEnv("DATABASE_URL", "postgres://moku:moku_dev_password@localhost:5432/moku?sslmode=disable"),
ValkeyURL: getEnv("VALKEY_URL", "redis://localhost:6379/0"),
ShutdownTimeout: getDurationEnv("BACKEND_SHUTDOWN_TIMEOUT", 10*time.Second),
@@ -43,8 +41,6 @@ func (c *Config) Address(serviceName string) string {
port = c.WebPort
case "api":
port = c.APIPort
case "worker":
port = c.WorkerPort
default:
port = c.WebPort
}

View File

@@ -6,7 +6,6 @@ LOG_LEVEL=debug
BACKEND_WEB_PORT=8080
BACKEND_API_PORT=8081
BACKEND_WORKER_PORT=8082
BACKEND_SHUTDOWN_TIMEOUT=10s
DATABASE_URL=postgres://moku:moku_dev_password@localhost:5432/moku?sslmode=disable

View File

@@ -8,8 +8,10 @@
}
.body {
--shell-dock-clearance: calc(var(--space-12) + var(--space-12) + var(--space-8));
--rail-width: 4.75rem;
--sidebar-width: 16.75rem;
--mobile-bottom-nav-clearance: 0rem;
--shell-top-left-radius: calc(var(--radius-xl) + var(--space-1));
--shell-frame-border: color-mix(in srgb, var(--color-border-strong) 44%, transparent);
--shell-divider-border: color-mix(in srgb, var(--color-border-strong) 34%, transparent);
@@ -23,6 +25,14 @@
background: var(--color-surface);
}
.bodyRailCollapsed {
--rail-width: 0rem;
}
.bodySidebarCollapsed {
--sidebar-width: 0rem;
}
.railColumn {
min-height: 0;
display: flex;
@@ -93,11 +103,21 @@
border-top-right-radius: 0;
}
.mobileWorkspaceView {
min-width: 0;
min-height: 0;
height: 100%;
display: grid;
box-sizing: border-box;
overflow: hidden;
background: var(--workspace-panel-surface);
}
.sidebarDock {
position: absolute;
bottom: var(--space-3);
left: calc(var(--space-1) + (var(--rail-width) * 0.1));
width: calc(var(--sidebar-width) + (var(--rail-width) * 0.9) - var(--space-2));
width: max(12rem, calc(var(--sidebar-width) + (var(--rail-width) * 0.9) - var(--space-2)));
right: auto;
z-index: calc(var(--z-modal) + 1);
pointer-events: none;
@@ -112,6 +132,14 @@
--rail-width: 5rem;
--sidebar-width: 17.25rem;
}
.bodyRailCollapsed {
--rail-width: 0rem;
}
.bodySidebarCollapsed {
--sidebar-width: 0rem;
}
}
@include respond-down(tablet) {
@@ -119,21 +147,102 @@
--rail-width: 4.5rem;
--sidebar-width: 13.25rem;
}
.bodyRailCollapsed {
--rail-width: 0rem;
}
.bodySidebarCollapsed {
--sidebar-width: 0rem;
}
}
.bodyRailCollapsed .railColumn {
overflow: hidden;
}
.bodySidebarCollapsed .railColumn {
--rail-dock-clearance: 0rem;
}
.bodySidebarCollapsed:not(.bodyRailCollapsed) .railColumn {
--rail-bottom-offset: var(--space-3);
}
.bodySidebarCollapsed .sidebarColumn {
--sidebar-dock-clearance: 0rem;
display: none;
overflow: hidden;
border-left-width: 0;
border-top-width: 0;
}
.bodySidebarCollapsed .workspaceRegion {
grid-template-columns: minmax(0, 1fr);
}
.bodySidebarCollapsed .workspaceMain {
border-left-color: transparent;
}
.bodySidebarCollapsed:not(.bodyRailCollapsed) .workspaceMain {
border-top-width: 1px;
border-top-color: var(--shell-frame-border);
border-left-color: var(--shell-frame-border);
border-top-left-radius: var(--shell-top-left-radius);
box-shadow: none;
}
.bodySidebarCollapsed:not(.bodyRailCollapsed) .workspaceRegion::before {
display: none;
}
.bodySidebarCollapsed .sidebarDock {
display: none;
}
@include respond-down(mobile) {
.body {
grid-template-columns: 4.5rem minmax(0, 1fr);
--rail-width: 4.5rem;
grid-template-columns: minmax(0, 1fr);
--rail-width: 0rem;
--sidebar-width: 0rem;
--mobile-bottom-nav-clearance: calc(var(--space-12) + var(--space-10) + env(safe-area-inset-bottom, 0px));
}
.railColumn {
position: sticky;
top: 0;
display: none;
}
.workspaceRegion,
.workspaceRegion {
display: grid;
grid-template-columns: minmax(0, 1fr);
border-top-left-radius: 0;
}
.workspaceRegion::before {
background: var(--workspace-panel-surface);
border-left-width: 0;
border-right-width: 0;
border-top-left-radius: 0;
}
.sidebarColumn,
.sidebarDock {
display: none;
}
.workspaceMain {
border-left-width: 0;
border-top-right-radius: 0;
border-top-left-radius: 0;
box-sizing: border-box;
padding-bottom: var(--mobile-bottom-nav-clearance);
}
.mobileWorkspaceView {
height: 100%;
max-height: none;
padding-bottom: 0;
background: var(--workspace-panel-surface);
}
}

View File

@@ -1,19 +1,52 @@
// Path: Frontend/src/components/shell/AppShell/AppShell.tsx
import { createSignal, onMount, type JSX } from "solid-js";
import { createSignal, onCleanup, onMount, Show, type JSX } from "solid-js";
import { getDocumentTheme, setTheme, type Theme } from "../../../theme/runtime";
import { WorkspaceHome } from "../../workspace-home/WorkspaceHome/WorkspaceHome";
import { LeftRail } from "../LeftRail/LeftRail";
import { MobileBottomNav } from "../MobileBottomNav/MobileBottomNav";
import { MobileWorkspaceBrowser } from "../MobileWorkspaceBrowser/MobileWorkspaceBrowser";
import { ServerDock } from "../ServerDock/ServerDock";
import { NotificationsMenu } from "../TopBar/NotificationsMenu";
import { ProfileMenu } from "../TopBar/ProfileMenu";
import { TopBar } from "../TopBar/TopBar";
import { WorkspaceSidebar } from "../WorkspaceSidebar/WorkspaceSidebar";
import styles from "./AppShell.module.scss";
type MobileWorkspaceView = "notifications" | "profile" | null;
const MOBILE_VIEWPORT_QUERY = "(max-width: 48rem)";
export const AppShell = (): JSX.Element => {
const [themeState, setThemeState] = createSignal<Theme>("light");
const [isRailCollapsed, setIsRailCollapsed] = createSignal(false);
const [isSidebarCollapsed, setIsSidebarCollapsed] = createSignal(false);
const [isMobileViewport, setIsMobileViewport] = createSignal(false);
const [isMobileWorkspaceBrowserOpen, setIsMobileWorkspaceBrowserOpen] = createSignal(false);
const [activeMobileWorkspaceView, setActiveMobileWorkspaceView] = createSignal<MobileWorkspaceView>(null);
onMount((): void => {
setThemeState(getDocumentTheme());
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
return;
}
const mediaQuery = window.matchMedia(MOBILE_VIEWPORT_QUERY);
const syncMobileViewport = (): void => {
setIsMobileViewport(mediaQuery.matches);
if (!mediaQuery.matches) {
setIsMobileWorkspaceBrowserOpen(false);
setActiveMobileWorkspaceView(null);
}
};
syncMobileViewport();
mediaQuery.addEventListener("change", syncMobileViewport);
onCleanup(() => {
mediaQuery.removeEventListener("change", syncMobileViewport);
});
});
const toggleTheme = (): void => {
@@ -23,24 +56,86 @@ export const AppShell = (): JSX.Element => {
setThemeState(next);
};
const openMobileWorkspaceView = (view: Exclude<MobileWorkspaceView, null>): void => {
setIsMobileWorkspaceBrowserOpen(false);
setActiveMobileWorkspaceView((current) => (current === view ? null : view));
};
const toggleMobileWorkspaceBrowser = (): void => {
setActiveMobileWorkspaceView(null);
setIsMobileWorkspaceBrowserOpen((open) => !open);
};
const toggleMobileNotifications = (): void => {
openMobileWorkspaceView("notifications");
};
const toggleMobileProfile = (): void => {
openMobileWorkspaceView("profile");
};
const closeMobileWorkspaceView = (): void => {
setActiveMobileWorkspaceView(null);
};
return (
<div class={styles.shell}>
<TopBar theme={themeState()} onToggleTheme={toggleTheme} />
<TopBar
theme={themeState()}
onToggleTheme={toggleTheme}
isMobileViewport={isMobileViewport()}
isNotificationsOpen={activeMobileWorkspaceView() === "notifications"}
isProfileOpen={activeMobileWorkspaceView() === "profile"}
onToggleNotifications={toggleMobileNotifications}
onToggleProfile={toggleMobileProfile}
/>
<div class={styles.body}>
<div
classList={{
[styles.body]: true,
[styles.bodyRailCollapsed]: isRailCollapsed(),
[styles.bodySidebarCollapsed]: isSidebarCollapsed(),
}}
>
{/* Left server rail */}
<div class={styles.railColumn}>
<LeftRail />
<LeftRail collapsed={isRailCollapsed()} />
</div>
{/* Sidebar + main workspace frame */}
<div class={styles.workspaceRegion}>
<div class={styles.sidebarColumn}>
<WorkspaceSidebar />
<WorkspaceSidebar
collapsed={isSidebarCollapsed()}
railCollapsed={isRailCollapsed()}
onToggleRailCollapse={(): void => {
setIsRailCollapsed((collapsed) => !collapsed);
}}
/>
</div>
<div class={styles.workspaceMain}>
<WorkspaceHome />
{/* On mobile, top-bar menus become full workspace views instead of popovers. */}
<Show
when={isMobileViewport() && activeMobileWorkspaceView() !== null}
fallback={
<WorkspaceHome
sidebarCollapsed={isSidebarCollapsed()}
onToggleSidebarCollapse={(): void => {
setIsSidebarCollapsed((collapsed) => !collapsed);
}}
/>
}
>
<div class={styles.mobileWorkspaceView}>
<Show when={activeMobileWorkspaceView() === "notifications"}>
<NotificationsMenu id="mobile-workspace-notifications" onSelect={closeMobileWorkspaceView} variant="workspace" />
</Show>
<Show when={activeMobileWorkspaceView() === "profile"}>
<ProfileMenu id="mobile-workspace-profile" onSelect={closeMobileWorkspaceView} variant="workspace" />
</Show>
</div>
</Show>
</div>
</div>
@@ -49,6 +144,19 @@ export const AppShell = (): JSX.Element => {
<ServerDock />
</div>
</div>
<MobileBottomNav
isBrowseOpen={isMobileWorkspaceBrowserOpen()}
onBrowseToggle={(): void => {
toggleMobileWorkspaceBrowser();
}}
/>
<MobileWorkspaceBrowser
open={isMobileWorkspaceBrowserOpen()}
onClose={(): void => {
setIsMobileWorkspaceBrowserOpen(false);
}}
/>
</div>
);
};

View File

@@ -7,7 +7,7 @@
min-width: 0;
padding: 0;
display: inline-flex;
align-items: center;
align-items: flex-end;
justify-content: flex-start;
gap: var(--space-2);
border: 0;
@@ -43,14 +43,25 @@
box-shadow: 0 0 0 0.12rem color-mix(in srgb, var(--color-accent-soft) 14%, transparent);
}
.copy {
min-width: 0;
display: inline-flex;
align-items: baseline;
gap: var(--space-1);
}
.value {
@include text-title;
color: var(--color-text);
font-weight: var(--font-weight-title);
line-height: 1;
}
.meta {
@include text-caption;
color: var(--color-text-muted);
line-height: 1;
padding-left: var(--space-1);
}
.icon {
@@ -80,7 +91,7 @@
.menuSection {
display: grid;
gap: 0.15rem;
gap: calc(var(--space-1) / 2);
}
.menuSectionLabel {
@@ -171,14 +182,39 @@
}
.submenuIndicator {
width: 0.35rem;
height: 0.35rem;
width: calc(var(--space-1) + (var(--space-1) / 2));
height: calc(var(--space-1) + (var(--space-1) / 2));
flex: 0 0 auto;
border-radius: 999px;
background: var(--color-accent-soft);
}
@include respond-down(mobile) {
.selector {
align-items: flex-end;
gap: var(--space-1);
}
.copy {
gap: var(--space-2);
}
.value {
line-height: 0.95;
}
.meta {
font-size: 0.68rem;
line-height: 1;
letter-spacing: 0.01em;
padding-bottom: calc(var(--space-1) / 2);
}
.icon {
align-self: flex-end;
margin-bottom: calc(var(--space-1) / 2);
}
.menu {
min-width: min(16rem, calc(100vw - (var(--space-4) * 2)));
}

View File

@@ -53,8 +53,10 @@ export const DepartmentSelector = (): JSX.Element => {
aria-expanded={isOpen()}
onClick={() => setIsOpen((open) => !open)}
>
<strong class={styles.value}>{selectedDepartment().name}</strong>
<span class={styles.meta}>{selectedTeamName()} team</span>
<span class={styles.copy}>
<strong class={styles.value}>{selectedDepartment().name}</strong>
<span class={styles.meta}>{selectedTeamName()}</span>
</span>
<ChevronDown classList={{ [styles.icon]: true, [styles.iconOpen]: isOpen() }} size={16} strokeWidth={2} />
</button>

View File

@@ -1,7 +1,5 @@
.rail {
--rail-workspace-size: var(--control-size-lg);
--rail-action-size: var(--control-size-md);
--rail-dock-clearance: 8rem;
position: relative;
z-index: 3;
flex: 1;
@@ -10,12 +8,19 @@
flex-direction: column;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-2) calc(var(--space-3) + var(--rail-dock-clearance));
padding: var(--space-3) var(--space-2) calc(var(--space-3) + var(--rail-dock-clearance, var(--shell-dock-clearance)));
overflow: visible;
}
.topCluster,
.bottomCluster {
.railCollapsed {
--rail-workspace-size: calc(var(--control-size-md) + 0.1rem);
justify-content: flex-start;
gap: 0;
padding-top: var(--space-4);
padding-inline: var(--space-1);
}
.topCluster {
width: 100%;
display: flex;
flex-direction: column;
@@ -23,14 +28,18 @@
gap: var(--space-2);
}
.bottomCluster {
margin-top: auto;
}
.topCluster {
gap: var(--space-3);
}
.railCollapsed .topCluster {
gap: var(--space-3);
}
.railCollapsed .topCluster {
align-items: center;
}
.items {
width: 100%;
min-height: 0;
@@ -173,20 +182,3 @@
border-radius: var(--radius-md);
box-shadow: none;
}
.addButton {
width: var(--rail-action-size);
height: var(--rail-action-size);
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px dashed var(--color-border-strong);
border-radius: var(--radius-pill);
background: transparent;
color: var(--color-text-muted);
&:hover {
background: var(--color-surface-hover);
color: var(--color-text);
}
}

View File

@@ -1,7 +1,6 @@
// Path: Frontend/src/components/shell/LeftRail/LeftRail.tsx
import { For, type JSX } from "solid-js";
import { Plus } from "../../../lib/icons";
import { For, Show, type JSX } from "solid-js";
import { railItems, type RailItem } from "../data/shell.data";
import styles from "./LeftRail.module.scss";
@@ -42,29 +41,41 @@ const RailEntry = (props: RailEntryProps): JSX.Element => {
);
};
export const LeftRail = (): JSX.Element => {
type LeftRailProps = {
collapsed: boolean;
};
export const LeftRail = (props: LeftRailProps): JSX.Element => {
const personalItem = railItems.find((item) => item.kind === "personal");
const organizationItems = railItems.filter((item) => item.kind === "organization");
return (
<aside class={styles.rail} aria-label="Server rail">
<aside
classList={{
[styles.rail]: true,
[styles.railCollapsed]: props.collapsed,
}}
aria-label="Server rail"
>
<div class={styles.topCluster}>
{personalItem ? <RailEntry item={personalItem} label={personalItem.label} abbreviation="M" personal /> : null}
<Show when={!props.collapsed && personalItem}>
{(item): JSX.Element => (
<RailEntry item={item()} label={item().label} abbreviation={item().abbreviation} personal />
)}
</Show>
<div class={styles.sectionDivider} aria-hidden="true" />
<Show when={!props.collapsed}>
<div class={styles.sectionDivider} aria-hidden="true" />
</Show>
</div>
<div class={styles.items}>
<For each={organizationItems}>
{(item): JSX.Element => <RailEntry item={item} label={item.label} abbreviation={item.abbreviation} />}
</For>
</div>
<div class={styles.bottomCluster}>
<button type="button" class={styles.addButton} aria-label="Create server" title="Create server">
<Plus size={16} strokeWidth={2} />
</button>
</div>
<Show when={!props.collapsed}>
<div class={styles.items}>
<For each={organizationItems}>
{(item): JSX.Element => <RailEntry item={item} label={item.label} abbreviation={item.abbreviation} />}
</For>
</div>
</Show>
</aside>
);
};

View File

@@ -0,0 +1,108 @@
.mobileNav {
display: none;
}
@include respond-down(mobile) {
.mobileNav {
--mobile-nav-button-gap: var(--space-1);
--mobile-nav-button-padding-inline: var(--space-2);
--mobile-nav-button-padding-top: calc(var(--space-2) + (var(--space-1) / 2));
--mobile-nav-button-padding-bottom: var(--space-2);
position: fixed;
right: 0;
bottom: 0;
left: 0;
display: grid;
gap: var(--space-2);
padding: var(--space-2) var(--space-3) calc(var(--space-2) + env(safe-area-inset-bottom, 0px));
background:
linear-gradient(to top, color-mix(in srgb, var(--color-canvas) 98%, transparent), color-mix(in srgb, var(--color-canvas) 92%, transparent));
border-top: 1px solid color-mix(in srgb, var(--color-border-strong) 40%, transparent);
backdrop-filter: blur(var(--blur-overlay));
z-index: var(--z-sticky);
}
.contextBar {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
min-width: 0;
color: var(--color-text-muted);
}
.contextServer,
.contextProject {
@include text-caption;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.contextServer {
max-width: 45vw;
}
.contextProject {
max-width: 30vw;
}
.contextDivider {
@include text-caption;
color: var(--color-text-subtle);
}
.navGrid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-2);
}
.navButton {
min-width: 0;
display: grid;
justify-items: center;
gap: var(--mobile-nav-button-gap);
padding: var(--mobile-nav-button-padding-top) var(--mobile-nav-button-padding-inline) var(--mobile-nav-button-padding-bottom);
border: 1px solid transparent;
border-radius: var(--radius-xl);
background: transparent;
color: var(--color-text-muted);
transition:
background 160ms var(--easing-standard),
color 160ms var(--easing-standard),
border-color 160ms var(--easing-standard),
transform 180ms var(--easing-standard);
}
.navButton:hover,
.navButton:focus-visible {
color: var(--color-text);
background: color-mix(in srgb, var(--color-surface) 82%, transparent);
border-color: color-mix(in srgb, var(--color-border-strong) 28%, transparent);
}
.navButton:active {
transform: translateY(calc(var(--space-1) / 2));
}
.navButtonActive {
color: var(--color-text);
background: color-mix(in srgb, var(--color-surface) 90%, transparent);
border-color: color-mix(in srgb, var(--color-border-strong) 34%, transparent);
box-shadow: var(--shadow-soft);
}
.iconWrap {
display: inline-flex;
align-items: center;
justify-content: center;
}
.label {
@include text-caption;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}

View File

@@ -0,0 +1,63 @@
// Path: Frontend/src/components/shell/MobileBottomNav/MobileBottomNav.tsx
import { For, type JSX } from "solid-js";
import { activeProject, activeServer, mobileBottomNavItems, type MobileBottomNavItem } from "../data/shell.data";
import styles from "./MobileBottomNav.module.scss";
type MobileBottomNavProps = {
isBrowseOpen: boolean;
onBrowseToggle: VoidFunction;
};
const MobileNavEntry = (props: {
item: MobileBottomNavItem;
isActive: boolean;
onSelect?: VoidFunction;
}): JSX.Element => {
const Icon = props.item.icon;
return (
<button
type="button"
onClick={() => props.onSelect?.()}
classList={{
[styles.navButton]: true,
[styles.navButtonActive]: props.isActive,
}}
aria-current={props.isActive ? "page" : undefined}
aria-expanded={props.item.id === "browse" ? props.isActive : undefined}
aria-label={props.item.label}
title={props.item.label}
>
<span class={styles.iconWrap} aria-hidden="true">
<Icon size={18} strokeWidth={2} />
</span>
<span class={styles.label}>{props.item.label}</span>
</button>
);
};
export const MobileBottomNav = (props: MobileBottomNavProps): JSX.Element => {
return (
<nav class={styles.mobileNav} aria-label="Mobile workspace navigation">
<div class={styles.contextBar}>
<span class={styles.contextServer}>{activeServer.name}</span>
<span class={styles.contextDivider}>/</span>
<span class={styles.contextProject}>{activeProject.name}</span>
</div>
<div class={styles.navGrid}>
<For each={mobileBottomNavItems}>
{(item): JSX.Element => (
<MobileNavEntry
item={item}
isActive={item.id === "browse" ? props.isBrowseOpen : (item.active ?? false) && !props.isBrowseOpen}
onSelect={item.id === "browse" ? props.onBrowseToggle : undefined}
/>
)}
</For>
</div>
</nav>
);
};

View File

@@ -0,0 +1,190 @@
.browserLayer {
display: none;
}
@include respond-down(mobile) {
.browserLayer {
position: fixed;
inset: 0;
display: grid;
z-index: calc(var(--z-popover, 20) + 2);
background: color-mix(in srgb, var(--color-canvas) 96%, black 4%);
}
.sheet {
min-height: 100dvh;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
background:
linear-gradient(180deg, color-mix(in srgb, var(--color-surface) 84%, transparent) 0%, transparent 8rem),
color-mix(in srgb, var(--color-canvas) 97%, black 3%);
overflow: clip;
}
.sheetHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: calc(var(--space-5) + env(safe-area-inset-top, 0px)) var(--space-4) var(--space-4);
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 84%, transparent);
}
.headerActions {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.brandBlock {
min-width: 0;
display: grid;
gap: var(--space-1);
}
.sectionLabel {
@include text-caption;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.brandEyebrow {
@include text-caption;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.brandTitle {
@include text-title;
color: var(--color-text);
font-weight: var(--font-weight-semibold);
}
.brandContext {
@include text-caption;
color: var(--color-text-subtle);
}
.closeButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--control-size-md);
height: var(--control-size-md);
padding: 0;
border: 1px solid color-mix(in srgb, var(--color-border) 92%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--color-surface) 92%, transparent);
color: var(--color-text-subtle);
}
.createButton {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
min-height: var(--control-size-md);
padding: 0 var(--space-3);
border: 1px solid color-mix(in srgb, var(--color-border-strong, var(--color-border)) 82%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--color-surface) 94%, var(--color-canvas) 6%);
color: var(--color-text);
box-shadow: var(--shadow-soft);
font: inherit;
font-weight: var(--font-weight-semibold);
}
.sheetBody {
min-height: 0;
display: grid;
gap: var(--space-5);
padding: var(--space-5) var(--space-5) calc(var(--space-12) + var(--space-10) + env(safe-area-inset-bottom, 0px));
overflow: auto;
}
.sectionBlock {
display: grid;
gap: var(--space-2);
}
.treeList,
.treeListItem {
list-style: none;
margin: 0;
padding: 0;
}
.treeList {
display: grid;
gap: 0;
border-top: 1px solid color-mix(in srgb, var(--color-border) 92%, transparent);
}
.treeListNested {
display: grid;
gap: 0;
}
.treeRow {
min-width: 0;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
min-height: calc(var(--control-size-lg) + var(--space-3));
padding: var(--space-4) var(--space-2) var(--space-4) calc(var(--space-2) + (var(--tree-depth, 0) * var(--space-4)));
border: 0;
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 92%, transparent);
border-radius: 0;
background: transparent;
color: var(--color-text);
text-align: left;
}
.treeRowActive {
color: var(--color-text);
background: color-mix(in srgb, var(--color-surface) 72%, transparent);
}
.treeRowBranch {
font-weight: var(--font-weight-semibold);
}
.treeRowLead,
.treeRowTrail {
min-width: 0;
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.treeRowTrail {
flex: 0 0 auto;
color: var(--color-text-muted);
}
.treeLabel {
@include text-label;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.treeMeta {
@include text-caption;
min-width: 1rem;
text-align: right;
}
.treeChevron {
color: var(--color-text-subtle);
}
.treeListNested > .treeListItem > .treeRow {
min-height: calc(var(--control-size-lg) + var(--space-1));
}
}

View File

@@ -0,0 +1,236 @@
import { For, Show, createSignal, type JSX } from "solid-js";
import { ChevronRight, Plus, X } from "../../../lib/icons";
import { createLongPressGesture } from "../createLongPressGesture";
import {
activeProject,
activeServer,
createWorkspaceStaticTarget,
createWorkspaceSurfaceTarget,
createWorkspaceTreeTarget,
workspaceStaticItems,
workspaceTree,
type SidebarItem,
type WorkspaceContextMenuAction,
type WorkspaceContextMenuTarget,
type WorkspaceStaticItem,
type WorkspaceTreeNode,
} from "../data/shell.data";
import { WorkspaceMobileActionSheet } from "../WorkspaceMobileActionSheet/WorkspaceMobileActionSheet";
import styles from "./MobileWorkspaceBrowser.module.scss";
type MobileWorkspaceBrowserProps = {
open: boolean;
onClose: VoidFunction;
};
const TreeRow = (props: { node: WorkspaceTreeNode; depth?: number }): JSX.Element => {
const depth = props.depth ?? 0;
const Icon = props.node.icon;
const hasChildren = (props.node.children?.length ?? 0) > 0;
return (
<button
classList={{
[styles.treeRow]: true,
[styles.treeRowActive]: props.node.active ?? false,
[styles.treeRowBranch]: hasChildren,
}}
type="button"
style={{ "--tree-depth": `${depth}` }}
>
<span class={styles.treeRowLead}>
<Icon size={16} strokeWidth={2} />
<span class={styles.treeLabel}>{props.node.label}</span>
</span>
<span class={styles.treeRowTrail}>
<Show when={props.node.meta}>
<span class={styles.treeMeta}>{props.node.meta}</span>
</Show>
<Show when={hasChildren}>
<ChevronRight size={14} strokeWidth={2} class={styles.treeChevron} />
</Show>
</span>
</button>
);
};
const StaticRow = (props: { item: SidebarItem }): JSX.Element => {
const Icon = props.item.icon;
return (
<button classList={{ [styles.treeRow]: true, [styles.treeRowActive]: props.item.active ?? false }} type="button" style={{ "--tree-depth": "0" }}>
<span class={styles.treeRowLead}>
<Icon size={16} strokeWidth={2} />
<span class={styles.treeLabel}>{props.item.label}</span>
</span>
<span class={styles.treeRowTrail}>
<Show when={props.item.meta}>
<span class={styles.treeMeta}>{props.item.meta}</span>
</Show>
<ChevronRight size={14} strokeWidth={2} class={styles.treeChevron} />
</span>
</button>
);
};
const WorkspaceStaticRow = (props: {
item: WorkspaceStaticItem;
onOpenActionSheet: (target: WorkspaceContextMenuTarget) => void;
}): JSX.Element => {
const target = createWorkspaceStaticTarget(props.item);
const longPress = createLongPressGesture({
onLongPress: () => {
props.onOpenActionSheet(target);
},
});
return (
<li
class={styles.treeListItem}
onContextMenu={(event): void => {
event.preventDefault();
props.onOpenActionSheet(target);
}}
{...longPress}
>
<StaticRow item={props.item} />
</li>
);
};
const WorkspaceTreeBranch = (props: {
nodes: readonly WorkspaceTreeNode[];
depth?: number;
onOpenActionSheet: (target: WorkspaceContextMenuTarget) => void;
}): JSX.Element => {
const depth = props.depth ?? 0;
return (
<For each={props.nodes}>
{(node): JSX.Element => {
const target = createWorkspaceTreeTarget(node);
const longPress = createLongPressGesture({
onLongPress: () => {
props.onOpenActionSheet(target);
},
});
return (
<li
class={styles.treeListItem}
onContextMenu={(event): void => {
event.preventDefault();
props.onOpenActionSheet(target);
}}
{...longPress}
>
<TreeRow node={node} depth={depth} />
<Show when={node.children?.length}>
<ul class={styles.treeListNested}>
<WorkspaceTreeBranch nodes={node.children ?? []} depth={depth + 1} onOpenActionSheet={props.onOpenActionSheet} />
</ul>
</Show>
</li>
);
}}
</For>
);
};
export const MobileWorkspaceBrowser = (props: MobileWorkspaceBrowserProps): JSX.Element => {
const [actionSheetTarget, setActionSheetTarget] = createSignal<WorkspaceContextMenuTarget | null>(null);
const sectionNodes = workspaceTree.filter((node) => (node.children?.length ?? 0) > 0);
const looseNodes = workspaceTree.filter((node) => (node.children?.length ?? 0) === 0);
const workspaceTarget = createWorkspaceSurfaceTarget(activeProject);
const openActionSheet = (target: WorkspaceContextMenuTarget): void => {
setActionSheetTarget(target);
};
const closeActionSheet = (): void => {
setActionSheetTarget(null);
};
const openWorkspaceActionSheet = (): void => {
openActionSheet(workspaceTarget);
};
const handleActionSelect = (_action: WorkspaceContextMenuAction, _target: WorkspaceContextMenuTarget): void => {
// Mobile first pass only establishes the action-sheet IA and long-press behavior.
};
const workspaceLongPress = createLongPressGesture({
onLongPress: openWorkspaceActionSheet,
});
return (
<Show when={props.open}>
<div class={styles.browserLayer}>
<section class={styles.sheet} aria-label="Mobile workspace browser">
<header class={styles.sheetHeader}>
<div
class={styles.brandBlock}
onContextMenu={(event): void => {
event.preventDefault();
openWorkspaceActionSheet();
}}
{...workspaceLongPress}
>
{/* Long-pressing the browser header exposes workspace-level actions on mobile. */}
<span class={styles.brandEyebrow}>Moku Work</span>
<strong class={styles.brandTitle}>{activeProject.name}</strong>
<span class={styles.brandContext}>{activeServer.name}</span>
</div>
<div class={styles.headerActions}>
<button
class={styles.createButton}
type="button"
aria-label="Create"
onClick={openWorkspaceActionSheet}
>
<Plus size={16} strokeWidth={2.25} />
<span>Create</span>
</button>
<button class={styles.closeButton} type="button" aria-label="Close workspace browser" onClick={props.onClose}>
<X size={18} strokeWidth={2} />
</button>
</div>
</header>
<div class={styles.sheetBody}>
<section class={styles.sectionBlock}>
<span class={styles.sectionLabel}>Workspace</span>
<ul class={styles.treeList}>
<For each={workspaceStaticItems}>
{(item): JSX.Element => <WorkspaceStaticRow item={item} onOpenActionSheet={openActionSheet} />}
</For>
</ul>
</section>
<section class={styles.sectionBlock}>
<span class={styles.sectionLabel}>Items</span>
<ul class={styles.treeList}>
<WorkspaceTreeBranch nodes={sectionNodes} onOpenActionSheet={openActionSheet} />
</ul>
</section>
<Show when={looseNodes.length > 0}>
<section class={styles.sectionBlock}>
<span class={styles.sectionLabel}>More</span>
<ul class={styles.treeList}>
<WorkspaceTreeBranch nodes={looseNodes} onOpenActionSheet={openActionSheet} />
</ul>
</section>
</Show>
</div>
</section>
<WorkspaceMobileActionSheet
target={actionSheetTarget()}
onClose={closeActionSheet}
onSelect={handleActionSelect}
/>
</div>
</Show>
);
};

View File

@@ -2,7 +2,11 @@
display: grid;
--project-drawer-gap: var(--space-3);
--project-drawer-top: calc(var(--space-4) + var(--control-size-lg));
--project-drawer-bottom: calc(var(--sidebar-dock-clearance) + var(--project-drawer-gap));
--project-drawer-bottom: calc(var(--sidebar-dock-clearance, var(--shell-dock-clearance)) + var(--project-drawer-gap));
}
.rootCompact {
justify-items: center;
}
.trigger {
@@ -39,6 +43,29 @@
box-shadow: var(--shadow-soft);
}
.triggerCompact {
width: var(--control-size-xl);
min-height: var(--control-size-xl);
grid-template-columns: auto;
justify-items: center;
gap: 0.15rem;
padding: var(--space-2) 0;
border-radius: var(--radius-xl);
}
.triggerCompact .triggerLead {
width: var(--control-size-md);
height: var(--control-size-md);
}
.triggerCompact .triggerIcon {
transform: rotate(0deg);
}
.triggerCompact .triggerIconOpen {
transform: rotate(180deg);
}
.triggerLead {
width: var(--control-size-md);
height: var(--control-size-md);
@@ -99,6 +126,13 @@
pointer-events: auto;
}
.rootCompact .scrim,
.rootCompact .drawer {
left: 0;
right: auto;
width: min(18rem, calc(100vw - 6rem));
}
.drawer {
position: absolute;
inset: calc(var(--project-drawer-top) + var(--project-drawer-gap)) var(--space-4)

View File

@@ -6,6 +6,7 @@ import { activeProject, projectItems } from "../data/shell.data";
import styles from "./ProjectSelector.module.scss";
type ProjectSelectorProps = {
compact?: boolean;
isOpen: boolean;
onToggle: () => void;
onClose: () => void;
@@ -68,7 +69,10 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
return (
<div
class={styles.root}
classList={{
[styles.root]: true,
[styles.rootCompact]: !!props.compact,
}}
style={{
"--project-drawer-top": `${drawerTop()}px`,
}}
@@ -79,20 +83,23 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
ref={triggerRef}
classList={{
[styles.trigger]: true,
[styles.triggerCompact]: !!props.compact,
[styles.triggerOpen]: props.isOpen,
}}
aria-label="Open project drawer"
aria-label={`Open left workspace sidebar menu for ${selectedProject().name}`}
aria-expanded={props.isOpen}
title="Open project drawer"
title={selectedProject().name}
onClick={toggleOpen}
>
<span class={styles.triggerLead} aria-hidden="true">
<Folder size={18} strokeWidth={2} />
</span>
<span class={styles.triggerCopy}>
<span class={styles.eyebrow}>Projects</span>
<span class={styles.value}>{selectedProject().name}</span>
</span>
{!props.compact ? (
<span class={styles.triggerCopy}>
<span class={styles.eyebrow}>Projects</span>
<span class={styles.value}>{selectedProject().name}</span>
</span>
) : null}
<ChevronDown
classList={{
[styles.triggerIcon]: true,

View File

@@ -0,0 +1,66 @@
.button {
display: inline-flex;
justify-content: center;
align-items: center;
width: 2.75rem;
height: 2.75rem;
aspect-ratio: 1;
padding: 0;
border: 0;
border-radius: 999px;
flex-shrink: 0;
cursor: pointer;
background: transparent;
color: var(--color-text-muted);
transition:
background-color 220ms var(--easing-standard),
color 220ms var(--easing-standard),
transform 180ms var(--easing-standard);
}
.button:hover {
background: color-mix(in srgb, var(--color-text) 8%, transparent);
color: var(--color-text);
}
.buttonOpen {
background: color-mix(in srgb, var(--color-surface) 92%, transparent);
color: var(--color-text);
}
.button:focus-visible {
outline: none;
background: color-mix(in srgb, var(--color-accent-strong) 12%, transparent);
box-shadow: 0 0 0 0.12rem color-mix(in srgb, var(--color-accent-strong) 28%, transparent);
color: var(--color-text);
}
.iconWrap {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
}
.badge {
@include text-caption;
position: absolute;
top: -0.45rem;
right: -0.7rem;
min-width: 1rem;
height: 1rem;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 0.24rem;
border: 1px solid color-mix(in srgb, var(--color-surface) 68%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--color-primary-3) 84%, black 16%);
color: white;
font-weight: var(--font-weight-semibold);
line-height: 1;
box-shadow: 0 6px 14px color-mix(in srgb, black 18%, transparent);
pointer-events: none;
}

View File

@@ -0,0 +1,36 @@
import type { JSX } from "solid-js";
import { Bell } from "../../../lib/icons";
import { unreadNotificationCount } from "../data/shell.data";
import styles from "./NotificationsButton.module.scss";
type NotificationsButtonProps = {
isOpen: boolean;
menuId: string;
onToggle: () => void;
};
export const NotificationsButton = (props: NotificationsButtonProps): JSX.Element => {
const hasUnread = unreadNotificationCount > 0;
const unreadLabel = hasUnread ? `, ${unreadNotificationCount} unread` : "";
return (
<button
classList={{
[styles.button]: true,
[styles.buttonOpen]: props.isOpen,
}}
type="button"
aria-label={`${props.isOpen ? "Close" : "Open"} notifications${unreadLabel}`}
title={`${props.isOpen ? "Close" : "Open"} notifications`}
aria-haspopup="menu"
aria-controls={props.menuId}
aria-expanded={props.isOpen}
onClick={props.onToggle}
>
<span class={styles.iconWrap} aria-hidden="true">
<Bell size={18} strokeWidth={2} />
{hasUnread ? <span class={styles.badge}>{unreadNotificationCount}</span> : null}
</span>
</button>
);
};

View File

@@ -0,0 +1,271 @@
.menu {
position: absolute;
top: calc(100% + var(--space-2));
right: 0;
width: min(24rem, calc(100vw - (var(--space-4) * 2)));
display: grid;
gap: var(--space-3);
padding: var(--space-3);
border: 1px solid var(--color-border-strong);
border-radius: calc(var(--radius-lg) + (var(--space-1) / 2));
background: color-mix(in srgb, var(--color-surface-muted) 96%, transparent);
box-shadow: var(--shadow-strong);
backdrop-filter: blur(var(--blur-overlay));
z-index: var(--z-dropdown);
}
.menu.menuWorkspace {
position: static;
top: auto;
right: auto;
left: auto;
bottom: auto;
width: 100%;
max-width: none;
height: 100%;
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
gap: var(--space-4);
padding: var(--space-4);
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
backdrop-filter: none;
z-index: auto;
overflow: hidden;
}
.header,
.footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.header {
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--color-border);
}
.headerCopy {
min-width: 0;
display: grid;
gap: calc(var(--space-1) / 2);
}
.title {
@include text-label;
color: var(--color-text);
}
.subtitle,
.sectionLabel,
.itemMeta,
.itemTime {
@include text-caption;
color: var(--color-text-muted);
}
.headerAction,
.footerAction {
@include text-caption;
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: 0;
border: 0;
background: transparent;
color: var(--color-text-muted);
transition: color 160ms var(--easing-standard);
}
.headerAction:hover,
.headerAction:focus-visible,
.footerAction:hover,
.footerAction:focus-visible {
outline: none;
color: var(--color-text);
}
.listWrap {
display: grid;
gap: var(--space-3);
max-height: min(24rem, 60vh);
overflow-y: auto;
padding-right: var(--space-1);
margin-right: calc(var(--space-1) * -1);
}
.stateCard {
display: grid;
justify-items: start;
gap: var(--space-2);
padding: var(--space-3);
border: 1px solid color-mix(in srgb, var(--color-border) 18%, transparent);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--color-surface) 92%, transparent);
}
.stateIcon {
width: 2.2rem;
height: 2.2rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
background: color-mix(in srgb, var(--color-surface-muted) 88%, transparent);
color: var(--color-text);
}
.stateTitle {
@include text-label;
color: var(--color-text);
}
.stateCopy {
@include text-caption;
color: var(--color-text-muted);
}
.section {
display: grid;
gap: var(--space-2);
}
.section + .section {
padding-top: var(--space-3);
border-top: 1px solid var(--color-border);
}
.sectionLabel {
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-subtle);
}
.list {
display: grid;
gap: var(--space-1);
}
.item {
width: 100%;
min-width: 0;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: start;
gap: var(--space-2);
padding: var(--space-2);
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text);
text-align: left;
transition:
background-color 160ms var(--easing-standard),
border-color 160ms var(--easing-standard),
color 160ms var(--easing-standard);
}
.item:hover {
background: color-mix(in srgb, var(--color-surface) 88%, transparent);
border-color: color-mix(in srgb, var(--color-border) 20%, transparent);
}
.item:focus-visible {
outline: none;
background: color-mix(in srgb, var(--color-surface) 92%, transparent);
border-color: color-mix(in srgb, var(--color-accent-soft) 52%, transparent);
box-shadow: 0 0 0 0.12rem color-mix(in srgb, var(--color-accent-soft) 14%, transparent);
}
.itemUnread {
background: color-mix(in srgb, var(--color-surface) 90%, transparent);
border-color: color-mix(in srgb, var(--color-border) 16%, transparent);
}
.itemMarker,
.itemMarkerMuted {
width: 0.5rem;
height: 0.5rem;
margin-top: 0.45rem;
border-radius: 999px;
flex-shrink: 0;
background: color-mix(in srgb, var(--color-primary-2) 78%, white 22%);
}
.itemMarkerMuted {
background: color-mix(in srgb, var(--color-text-subtle) 36%, transparent);
}
.itemBody {
min-width: 0;
display: grid;
gap: calc(var(--space-1) / 2);
}
.itemTitle {
@include text-label;
color: var(--color-text);
}
.itemTime {
padding-top: calc(var(--space-1) / 4);
white-space: nowrap;
color: var(--color-text-subtle);
}
.footer {
padding-top: var(--space-3);
border-top: 1px solid var(--color-border);
justify-content: space-between;
flex-wrap: wrap;
}
.menu.menuWorkspace .listWrap {
min-height: 0;
max-height: none;
height: 100%;
padding-right: 0;
margin-right: 0;
}
.menu.menuWorkspace .header,
.menu.menuWorkspace .footer {
padding-left: 0;
padding-right: 0;
}
@include respond-down(mobile) {
.menu.menuWorkspace {
height: 100%;
min-height: 0;
padding: var(--space-5);
}
.menu.menuWorkspace .listWrap {
min-height: 0;
max-height: none;
}
.menu.menuWorkspace .item {
grid-template-columns: auto minmax(0, 1fr);
}
.menu.menuWorkspace .itemTime {
grid-column: 2;
padding-top: 0;
}
.menu.menuWorkspace .footer {
align-items: flex-start;
flex-direction: column;
}
.menu.menuWorkspace .footerAction {
padding: var(--space-1) 0;
}
}

View File

@@ -0,0 +1,123 @@
import { For, Show, type JSX } from "solid-js";
import { Bell, Settings } from "../../../lib/icons";
import { notificationItems, unreadNotificationCount } from "../data/shell.data";
import styles from "./NotificationsMenu.module.scss";
type NotificationsMenuProps = {
id: string;
menuRef?: (element: HTMLDivElement) => void;
onSelect: () => void;
variant?: "popover" | "workspace";
};
export const NotificationsMenu = (props: NotificationsMenuProps): JSX.Element => {
const unreadItems = notificationItems.filter((item) => item.unread);
const earlierItems = notificationItems.filter((item) => !item.unread);
const hasNotifications = notificationItems.length > 0;
const isCaughtUp = unreadItems.length === 0 && hasNotifications;
const variant = props.variant ?? "popover";
return (
<div
id={props.id}
classList={{
[styles.menu]: true,
[styles.menuWorkspace]: variant === "workspace",
}}
role="menu"
aria-label="Notifications"
ref={props.menuRef}
>
<div class={styles.header}>
<div class={styles.headerCopy}>
<strong class={styles.title}>Notifications</strong>
<span class={styles.subtitle}>
{unreadNotificationCount > 0
? `You have ${unreadNotificationCount} unread`
: "Youre all caught up"}
</span>
</div>
<Show when={unreadNotificationCount > 0}>
<button type="button" role="menuitem" class={styles.headerAction} onClick={props.onSelect}>
Mark all read
</button>
</Show>
</div>
<div class={styles.listWrap}>
<Show when={!hasNotifications}>
<div class={styles.stateCard}>
<span class={styles.stateIcon} aria-hidden="true">
<Bell size={18} strokeWidth={2} />
</span>
<strong class={styles.stateTitle}>No notifications yet</strong>
<span class={styles.stateCopy}>When activity starts across your workspace, itll show up here.</span>
</div>
</Show>
<Show when={isCaughtUp}>
<div class={styles.stateCard}>
<span class={styles.stateIcon} aria-hidden="true">
<Bell size={18} strokeWidth={2} />
</span>
<strong class={styles.stateTitle}>Youre all caught up</strong>
<span class={styles.stateCopy}>No unread notifications right now. Earlier activity is still available below.</span>
</div>
</Show>
<Show when={unreadItems.length > 0}>
<section class={styles.section} aria-label="Unread notifications">
<span class={styles.sectionLabel}>Unread</span>
<div class={styles.list}>
<For each={unreadItems}>
{(item): JSX.Element => (
<button type="button" role="menuitem" classList={{ [styles.item]: true, [styles.itemUnread]: true }} onClick={props.onSelect}>
<span class={styles.itemMarker} aria-hidden="true" />
<div class={styles.itemBody}>
<span class={styles.itemTitle}>{item.title}</span>
<span class={styles.itemMeta}>{item.contextLabel}</span>
</div>
<span class={styles.itemTime}>{item.timeLabel}</span>
</button>
)}
</For>
</div>
</section>
</Show>
<Show when={earlierItems.length > 0}>
<section class={styles.section} aria-label="Earlier notifications">
<span class={styles.sectionLabel}>Earlier</span>
<div class={styles.list}>
<For each={earlierItems}>
{(item): JSX.Element => (
<button type="button" role="menuitem" class={styles.item} onClick={props.onSelect}>
<span class={styles.itemMarkerMuted} aria-hidden="true" />
<div class={styles.itemBody}>
<span class={styles.itemTitle}>{item.title}</span>
<span class={styles.itemMeta}>{item.contextLabel}</span>
</div>
<span class={styles.itemTime}>{item.timeLabel}</span>
</button>
)}
</For>
</div>
</section>
</Show>
</div>
<div class={styles.footer}>
<button type="button" role="menuitem" class={styles.footerAction} onClick={props.onSelect}>
<Settings size={16} strokeWidth={2} />
<span>Notification settings</span>
</button>
<button type="button" role="menuitem" class={styles.footerAction} onClick={props.onSelect}>
<Bell size={16} strokeWidth={2} />
<span>View all notifications</span>
</button>
</div>
</div>
);
};

View File

@@ -0,0 +1,5 @@
.root {
position: relative;
display: inline-flex;
align-items: center;
}

View File

@@ -0,0 +1,38 @@
import { createUniqueId, Show, type JSX } from "solid-js";
import { NotificationsButton } from "./NotificationsButton";
import { NotificationsMenu } from "./NotificationsMenu";
import { createDesktopMenuController } from "./createDesktopMenuController";
import styles from "./NotificationsNav.module.scss";
type NotificationsNavProps = {
isMobileViewport: boolean;
isMobileWorkspaceOpen: boolean;
onToggleMobileWorkspace: VoidFunction;
};
export const NotificationsNav = (props: NotificationsNavProps): JSX.Element => {
const menuId = createUniqueId();
return (
<Show
when={props.isMobileViewport}
fallback={<DesktopNotificationsNav />}
>
<div class={styles.root}>
<NotificationsButton isOpen={props.isMobileWorkspaceOpen} menuId={menuId} onToggle={props.onToggleMobileWorkspace} />
</div>
</Show>
);
};
const DesktopNotificationsNav = (): JSX.Element => {
const controller = createDesktopMenuController();
const menuId = createUniqueId();
return (
<div class={styles.root} ref={controller.setRootRef}>
<NotificationsButton isOpen={controller.isOpen()} menuId={menuId} onToggle={controller.toggleMenu} />
{controller.isOpen() ? <NotificationsMenu id={menuId} menuRef={controller.setMenuRef} onSelect={controller.closeMenu} /> : null}
</div>
);
};

View File

@@ -0,0 +1,200 @@
.menu {
position: absolute;
top: calc(100% + var(--space-2));
right: 0;
width: min(21rem, calc(100vw - (var(--space-4) * 2)));
display: grid;
gap: var(--space-3);
padding: var(--space-3);
border: 1px solid var(--color-border-strong);
border-radius: calc(var(--radius-lg) + (var(--space-1) / 2));
background: color-mix(in srgb, var(--color-surface-muted) 96%, transparent);
box-shadow: var(--shadow-strong);
backdrop-filter: blur(var(--blur-overlay));
z-index: var(--z-dropdown);
}
.menu.menuWorkspace {
position: static;
top: auto;
right: auto;
left: auto;
bottom: auto;
width: 100%;
max-width: none;
height: 100%;
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: var(--space-4);
padding: var(--space-4);
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
backdrop-filter: none;
z-index: auto;
overflow: hidden;
}
.sections {
display: grid;
gap: var(--space-3);
}
.summary {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: var(--space-3);
align-items: center;
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--color-border);
}
.avatar {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
align-self: center;
margin-right: var(--space-1);
}
.avatarRing {
position: absolute;
inset: 0;
border-radius: 999px;
background:
conic-gradient(
from 0deg,
transparent 0deg 24deg,
var(--color-primary-1) 24deg 118deg,
transparent 118deg 144deg,
var(--color-primary-2) 144deg 238deg,
transparent 238deg 264deg,
var(--color-primary-3) 264deg 356deg,
transparent 356deg 360deg
);
mask: radial-gradient(circle, transparent 64%, black 67%);
-webkit-mask: radial-gradient(circle, transparent 64%, black 67%);
}
.avatarCore {
@include text-label;
position: relative;
z-index: 1;
display: inline-flex;
align-items: center;
justify-content: center;
width: 78%;
height: 78%;
border-radius: 999px;
background: var(--color-surface);
color: var(--color-text);
font-weight: var(--font-weight-semibold);
}
.summaryCopy {
min-width: 0;
display: grid;
gap: calc(var(--space-1) / 2);
}
.name,
.itemLabel {
@include text-label;
}
.name {
color: var(--color-text);
}
.email,
.role,
.context {
@include text-caption;
color: var(--color-text-muted);
}
.context {
margin-top: var(--space-1);
color: var(--color-text-subtle);
}
.section {
display: grid;
gap: var(--space-1);
}
.section + .section {
padding-top: var(--space-3);
border-top: 1px solid var(--color-border);
}
.item {
width: 100%;
min-width: 0;
min-height: calc(var(--control-size-lg) + (var(--space-1) / 2));
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: var(--space-2);
padding: var(--space-2);
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text);
text-align: left;
transition:
background-color 160ms var(--easing-standard),
border-color 160ms var(--easing-standard),
color 160ms var(--easing-standard);
}
.item:hover {
background: color-mix(in srgb, var(--color-surface) 88%, transparent);
border-color: color-mix(in srgb, var(--color-border) 20%, transparent);
}
.item:focus-visible {
outline: none;
background: color-mix(in srgb, var(--color-surface) 92%, transparent);
border-color: color-mix(in srgb, var(--color-accent-soft) 52%, transparent);
box-shadow: 0 0 0 0.12rem color-mix(in srgb, var(--color-accent-soft) 14%, transparent);
}
.itemDanger {
color: color-mix(in srgb, var(--color-primary-3) 74%, var(--color-text) 26%);
}
.itemDanger:hover,
.itemDanger:focus-visible {
background: color-mix(in srgb, var(--color-primary-3) 8%, transparent);
border-color: color-mix(in srgb, var(--color-primary-3) 16%, transparent);
}
.itemIcon {
width: calc(var(--control-size-lg) - var(--space-2));
height: calc(var(--control-size-lg) - var(--space-2));
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--color-surface) 92%, transparent);
color: currentColor;
}
.menu.menuWorkspace .sections {
min-height: 0;
height: 100%;
align-content: start;
overflow-y: auto;
}
.menu.menuWorkspace .summary,
.menu.menuWorkspace .sections {
padding-left: 0;
padding-right: 0;
}

View File

@@ -0,0 +1,75 @@
import { For, type JSX } from "solid-js";
import { User } from "../../../lib/icons";
import { activeUserProfile, profileMenuSections } from "../data/shell.data";
import styles from "./ProfileMenu.module.scss";
type ProfileMenuProps = {
id: string;
menuRef?: (element: HTMLDivElement) => void;
onSelect: () => void;
variant?: "popover" | "workspace";
};
export const ProfileMenu = (props: ProfileMenuProps): JSX.Element => {
const variant = props.variant ?? "popover";
return (
<div
id={props.id}
classList={{
[styles.menu]: true,
[styles.menuWorkspace]: variant === "workspace",
}}
role="menu"
aria-label="Profile menu"
ref={props.menuRef}
>
<div class={styles.summary}>
<div class={styles.avatar} aria-hidden="true">
<span class={styles.avatarRing} />
<span class={styles.avatarCore}>
<User size={16} strokeWidth={2} />
</span>
</div>
<div class={styles.summaryCopy}>
<strong class={styles.name}>{activeUserProfile.name}</strong>
<span class={styles.email}>{activeUserProfile.email}</span>
<span class={styles.role}>{activeUserProfile.roleLabel}</span>
<span class={styles.context}>{activeUserProfile.contextLabel}</span>
</div>
</div>
<div class={styles.sections}>
<For each={profileMenuSections}>
{(section): JSX.Element => (
<div class={styles.section}>
<For each={section.items}>
{(item): JSX.Element => {
const Icon = item.icon;
return (
<button
type="button"
role="menuitem"
classList={{
[styles.item]: true,
[styles.itemDanger]: item.tone === "danger",
}}
onClick={props.onSelect}
>
<span class={styles.itemIcon} aria-hidden="true">
<Icon size={16} strokeWidth={2} />
</span>
<span class={styles.itemLabel}>{item.label}</span>
</button>
);
}}
</For>
</div>
)}
</For>
</div>
</div>
);
};

View File

@@ -4,13 +4,19 @@ import { For, type JSX } from "solid-js";
import type { Theme } from "../../../theme/runtime";
import { topBarActions } from "../data/shell.data";
import { DepartmentSelector } from "../DepartmentSelector/DepartmentSelector";
import { NotificationsNav } from "./NotificationsNav";
import { ThemeToggle } from "./ThemeToggle";
import { UserNavButton } from "./UserNavButton";
import { UserNav } from "./UserNav";
import styles from "./TopBar.module.scss";
type TopBarProps = {
theme: Theme;
onToggleTheme: VoidFunction;
isMobileViewport: boolean;
isNotificationsOpen: boolean;
isProfileOpen: boolean;
onToggleNotifications: VoidFunction;
onToggleProfile: VoidFunction;
};
export const TopBar = (props: TopBarProps): JSX.Element => {
@@ -36,8 +42,17 @@ export const TopBar = (props: TopBarProps): JSX.Element => {
</For>
</div>
<NotificationsNav
isMobileViewport={props.isMobileViewport}
isMobileWorkspaceOpen={props.isNotificationsOpen}
onToggleMobileWorkspace={props.onToggleNotifications}
/>
<ThemeToggle theme={props.theme} onToggle={props.onToggleTheme} />
<UserNavButton />
<UserNav
isMobileViewport={props.isMobileViewport}
isMobileWorkspaceOpen={props.isProfileOpen}
onToggleMobileWorkspace={props.onToggleProfile}
/>
</div>
</header>
);

View File

@@ -0,0 +1,5 @@
.root {
position: relative;
display: inline-flex;
align-items: center;
}

View File

@@ -0,0 +1,38 @@
import { createUniqueId, Show, type JSX } from "solid-js";
import { ProfileMenu } from "./ProfileMenu";
import { UserNavButton } from "./UserNavButton";
import { createDesktopMenuController } from "./createDesktopMenuController";
import styles from "./UserNav.module.scss";
type UserNavProps = {
isMobileViewport: boolean;
isMobileWorkspaceOpen: boolean;
onToggleMobileWorkspace: VoidFunction;
};
export const UserNav = (props: UserNavProps): JSX.Element => {
const menuId = createUniqueId();
return (
<Show
when={props.isMobileViewport}
fallback={<DesktopUserNav />}
>
<div class={styles.root}>
<UserNavButton isOpen={props.isMobileWorkspaceOpen} menuId={menuId} onToggle={props.onToggleMobileWorkspace} />
</div>
</Show>
);
};
const DesktopUserNav = (): JSX.Element => {
const controller = createDesktopMenuController();
const menuId = createUniqueId();
return (
<div class={styles.root} ref={controller.setRootRef}>
<UserNavButton isOpen={controller.isOpen()} menuId={menuId} onToggle={controller.toggleMenu} />
{controller.isOpen() ? <ProfileMenu id={menuId} menuRef={controller.setMenuRef} onSelect={controller.closeMenu} /> : null}
</div>
);
};

View File

@@ -23,7 +23,12 @@
color: var(--color-text);
}
.userButton:hover .spinContainer {
.userButtonOpen {
color: var(--color-text);
}
.userButton:hover .spinContainer,
.userButtonOpen .spinContainer {
animation-play-state: running;
opacity: 1;
}

View File

@@ -4,9 +4,27 @@ import type { JSX } from "solid-js";
import { User } from "../../../lib/icons";
import styles from "./UserNavButton.module.scss";
export const UserNavButton = (): JSX.Element => {
type UserNavButtonProps = {
isOpen: boolean;
menuId: string;
onToggle: () => void;
};
export const UserNavButton = (props: UserNavButtonProps): JSX.Element => {
return (
<button class={styles.userButton} type="button" aria-label="Open profile" title="Open profile">
<button
classList={{
[styles.userButton]: true,
[styles.userButtonOpen]: props.isOpen,
}}
type="button"
aria-label={props.isOpen ? "Close profile menu" : "Open profile menu"}
title={props.isOpen ? "Close profile menu" : "Open profile menu"}
aria-haspopup="menu"
aria-controls={props.menuId}
aria-expanded={props.isOpen}
onClick={props.onToggle}
>
<span class={styles.spinContainer} aria-hidden="true">
<span class={styles.spinRing} />
</span>

View File

@@ -0,0 +1,72 @@
import { createEffect, createSignal, onCleanup } from "solid-js";
type DesktopMenuController = {
isOpen: () => boolean;
rootRef: HTMLDivElement | undefined;
menuRef: HTMLDivElement | undefined;
setRootRef: (element: HTMLDivElement) => void;
setMenuRef: (element: HTMLDivElement) => void;
closeMenu: VoidFunction;
toggleMenu: VoidFunction;
};
// Shared desktop popover behavior for top-bar menus.
export const createDesktopMenuController = (): DesktopMenuController => {
const [isOpen, setIsOpen] = createSignal(false);
let rootRef: HTMLDivElement | undefined;
let menuRef: HTMLDivElement | undefined;
const closeMenu = (): void => {
setIsOpen(false);
};
const toggleMenu = (): void => {
setIsOpen((open) => !open);
};
createEffect(() => {
if (!isOpen()) return;
const handlePointerDown = (event: PointerEvent): void => {
if (!rootRef) return;
const target = event.target;
if (target instanceof Node && !rootRef.contains(target)) {
closeMenu();
}
};
const handleKeyDown = (event: KeyboardEvent): void => {
if (event.key === "Escape") {
closeMenu();
}
};
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
menuRef?.querySelector<HTMLButtonElement>("[role='menuitem']")?.focus();
onCleanup(() => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
});
});
return {
isOpen,
get rootRef() {
return rootRef;
},
get menuRef() {
return menuRef;
},
setRootRef: (element: HTMLDivElement): void => {
rootRef = element;
},
setMenuRef: (element: HTMLDivElement): void => {
menuRef = element;
},
closeMenu,
toggleMenu,
};
};

View File

@@ -0,0 +1,194 @@
.menu {
--context-menu-width: 13.5rem;
position: fixed;
width: min(var(--context-menu-width), calc(100vw - (var(--space-4) * 2)));
display: grid;
gap: 0;
padding: var(--space-1);
border: 1px solid color-mix(in srgb, var(--color-border-strong) 56%, transparent);
border-radius: calc(var(--radius-md) + (var(--space-1) / 2));
background: var(--color-surface);
box-shadow: var(--shadow-soft);
z-index: 2147483647;
user-select: none;
}
.header {
display: grid;
gap: calc(var(--space-1) / 2);
padding: var(--space-2) calc(var(--space-2) + (var(--space-1) / 2));
}
.eyebrow {
@include text-caption;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.title {
@include text-label;
color: var(--color-text);
font-weight: var(--font-weight-title);
}
.sectionList {
display: grid;
gap: 0;
}
.sectionListCompact {
gap: calc(var(--space-1) / 2);
}
.section {
display: grid;
gap: 0;
}
.section:first-child {
padding-top: calc(var(--space-1) / 2);
}
.section + .section {
margin-top: calc(var(--space-1) / 2);
padding-top: var(--space-2);
border-top: 1px solid color-mix(in srgb, var(--color-border) 78%, transparent);
}
.sectionLabel {
@include text-caption;
color: var(--color-text-subtle);
padding: 0 var(--space-2);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.actionList {
display: grid;
gap: 0;
}
.actionItem {
position: relative;
}
.action {
width: 100%;
min-height: calc(var(--control-size-md) - var(--space-2));
display: inline-flex;
align-items: center;
justify-content: flex-start;
gap: var(--space-2);
padding: var(--space-2) calc(var(--space-2) + (var(--space-1) / 2));
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text);
text-align: left;
font-size: var(--font-size-label);
line-height: var(--line-height-label);
font-weight: var(--font-weight-label);
transition:
background var(--motion-duration-fast) var(--motion-ease-standard),
border-color var(--motion-duration-fast) var(--motion-ease-standard),
color var(--motion-duration-fast) var(--motion-ease-standard);
}
.actionCreate {
border-color: transparent;
background: transparent;
font-weight: var(--font-weight-title);
box-shadow: none;
}
.actionCreate:hover,
.actionCreate:focus-visible,
.actionCreate.actionSubmenuOpen {
background: color-mix(in srgb, var(--color-surface-hover) 82%, var(--color-surface));
border-color: color-mix(in srgb, var(--color-border) 72%, transparent);
}
.actionCreateIcon {
width: calc(var(--control-size-md) - var(--space-3));
height: calc(var(--control-size-md) - var(--space-3));
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--color-surface-hover) 72%, var(--color-surface));
color: var(--color-text);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-border) 74%, transparent);
}
.actionLabel {
min-width: 0;
}
.actionMeta {
margin-left: auto;
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: var(--space-2);
padding-left: var(--space-3);
}
.actionShortcut {
color: var(--color-text-muted);
font-size: var(--font-size-caption);
line-height: var(--line-height-caption);
font-weight: var(--font-weight-caption);
white-space: nowrap;
}
.actionChevron {
color: var(--color-text-subtle);
flex: 0 0 auto;
}
.actionSubmenuOpen {
background: color-mix(in srgb, var(--color-surface-hover) 82%, var(--color-surface));
border-color: color-mix(in srgb, var(--color-border-strong) 72%, transparent);
}
.action:hover,
.action:focus-visible {
background: color-mix(in srgb, var(--color-surface-hover) 78%, var(--color-surface));
border-color: color-mix(in srgb, var(--color-border) 72%, transparent);
}
.actionDanger {
color: var(--color-danger-text, var(--color-text));
}
.actionDanger:hover,
.actionDanger:focus-visible {
background: color-mix(in srgb, var(--color-danger-soft, var(--color-surface-hover)) 72%, transparent);
border-color: color-mix(in srgb, var(--color-danger-border, var(--color-border)) 72%, transparent);
color: var(--color-danger-text, var(--color-text));
}
.submenu {
position: absolute;
top: calc(var(--space-1) * -1);
left: calc(100% + var(--space-2));
width: min(var(--context-menu-width), calc(100vw - (var(--space-4) * 2)));
padding: var(--space-1);
border: 1px solid color-mix(in srgb, var(--color-border-strong) 56%, transparent);
border-radius: calc(var(--radius-md) + (var(--space-1) / 2));
background: var(--color-surface);
box-shadow: var(--shadow-soft);
z-index: 2147483647;
}
.submenuList {
display: grid;
gap: var(--space-1);
}
@include respond-down(mobile) {
.menu {
display: none;
}
}

View File

@@ -0,0 +1,231 @@
import { For, Show, createEffect, createMemo, createSignal, onMount, type JSX } from "solid-js";
import { Portal } from "solid-js/web";
import { ChevronRight, Plus } from "../../../lib/icons";
import {
getWorkspaceContextMenuEyebrow,
getWorkspaceContextMenuSections,
type WorkspaceContextMenuAction,
type WorkspaceContextMenuShortcut,
type WorkspaceContextMenuTarget,
} from "../data/shell.data";
import styles from "./WorkspaceContextMenu.module.scss";
type ShortcutPlatform = "mac" | "windows";
type NavigatorWithUserAgentData = Navigator & {
userAgentData?: {
platform?: string;
};
};
type WorkspaceContextMenuPosition = {
x: number;
y: number;
};
type WorkspaceContextMenuProps = {
target: WorkspaceContextMenuTarget | null;
position: WorkspaceContextMenuPosition | null;
onClose: VoidFunction;
onSelect: (action: WorkspaceContextMenuAction, target: WorkspaceContextMenuTarget) => void;
menuRef: (element: HTMLDivElement) => void;
};
const getShortcutPlatform = (): ShortcutPlatform => {
if (typeof navigator === "undefined") {
return "mac";
}
const navigatorWithUserAgentData = navigator as NavigatorWithUserAgentData;
const platform =
typeof navigatorWithUserAgentData.userAgentData?.platform === "string"
? navigatorWithUserAgentData.userAgentData.platform
: navigator.platform;
return /mac|iphone|ipad|ipod/i.test(platform) ? "mac" : "windows";
};
const formatShortcut = (shortcut: WorkspaceContextMenuShortcut, platform: ShortcutPlatform): string => {
const keyLabel = (() => {
switch (shortcut.key) {
case "enter":
return platform === "mac" ? "↩" : "Enter";
case "delete":
return platform === "mac" ? "⌫" : "Del";
default:
return shortcut.key.toUpperCase();
}
})();
const modifierLabels =
shortcut.modifiers?.map((modifier) => {
switch (modifier) {
case "meta":
return platform === "mac" ? "⌘" : "Ctrl";
case "alt":
return platform === "mac" ? "⌥" : "Alt";
case "shift":
return platform === "mac" ? "⇧" : "Shift";
}
}) ?? [];
if (platform === "mac") {
return `${modifierLabels.join("")}${keyLabel}`;
}
return [...modifierLabels, keyLabel].join("+");
};
export const WorkspaceContextMenu = (props: WorkspaceContextMenuProps): JSX.Element => {
const [activeSubmenuActionId, setActiveSubmenuActionId] = createSignal<string | null>(null);
const [shortcutPlatform, setShortcutPlatform] = createSignal<ShortcutPlatform>("mac");
const sections = createMemo(() => (props.target ? getWorkspaceContextMenuSections(props.target) : []));
const handleActionSelect = (action: WorkspaceContextMenuAction, target: WorkspaceContextMenuTarget): void => {
props.onSelect(action, target);
props.onClose();
};
const menuState = createMemo<{
target: WorkspaceContextMenuTarget;
position: WorkspaceContextMenuPosition;
} | null>(() =>
props.target && props.position
? {
target: props.target,
position: props.position,
}
: null,
);
const showHeader = (): boolean => props.target?.kind !== "workspace";
const sectionHasLabel = createMemo(() => sections().some((section) => Boolean(section.label)));
onMount(() => {
setShortcutPlatform(getShortcutPlatform());
});
createEffect(() => {
void props.target;
setActiveSubmenuActionId(null);
});
return (
<Show when={menuState()}>
{(resolvedMenuState): JSX.Element => {
const target = resolvedMenuState().target;
const position = resolvedMenuState().position;
return (
<Portal>
<div
ref={props.menuRef}
class={styles.menu}
role="menu"
aria-label={`${target.label} context menu`}
style={{
left: `${position.x}px`,
top: `${position.y}px`,
}}
>
<Show when={target.kind !== "workspace"}>
<header class={styles.header}>
<span class={styles.eyebrow}>{getWorkspaceContextMenuEyebrow(target)}</span>
<strong class={styles.title}>{target.label}</strong>
</header>
</Show>
<div classList={{ [styles.sectionList]: true, [styles.sectionListCompact]: !sectionHasLabel() }}>
<For each={sections()}>
{(section): JSX.Element => (
<section class={styles.section}>
<Show when={section.label}>
<span class={styles.sectionLabel}>{section.label}</span>
</Show>
<div class={styles.actionList}>
<For each={section.items}>
{(action): JSX.Element => {
const isSubmenuOpen = () => activeSubmenuActionId() === action.id;
return (
<div
class={styles.actionItem}
onMouseEnter={() => {
setActiveSubmenuActionId(action.children ? action.id : null);
}}
>
<button
type="button"
role="menuitem"
classList={{
[styles.action]: true,
[styles.actionCreate]: action.id === "create",
[styles.actionDanger]: action.tone === "danger",
[styles.actionSubmenuOpen]: isSubmenuOpen(),
}}
onClick={() => {
if (action.children) {
setActiveSubmenuActionId(isSubmenuOpen() ? null : action.id);
return;
}
handleActionSelect(action, target);
}}
>
<Show when={action.id === "create"}>
<span class={styles.actionCreateIcon} aria-hidden="true">
<Plus size={14} strokeWidth={2.25} />
</span>
</Show>
<span class={styles.actionLabel}>{action.label}</span>
<div class={styles.actionMeta}>
<Show when={action.shortcut}>
<span class={styles.actionShortcut}>{formatShortcut(action.shortcut!, shortcutPlatform())}</span>
</Show>
<Show when={action.children}>
<ChevronRight class={styles.actionChevron} size={16} strokeWidth={2} />
</Show>
</div>
</button>
<Show when={action.children && isSubmenuOpen()}>
<div class={styles.submenu} role="menu" aria-label={`${action.label} submenu`}>
<div class={styles.submenuList}>
<For each={action.children ?? []}>
{(childAction): JSX.Element => (
<button
type="button"
role="menuitem"
classList={{
[styles.action]: true,
[styles.actionDanger]: childAction.tone === "danger",
}}
onClick={() => handleActionSelect(childAction, target)}
>
<span class={styles.actionLabel}>{childAction.label}</span>
<div class={styles.actionMeta}>
<Show when={childAction.shortcut}>
<span class={styles.actionShortcut}>{formatShortcut(childAction.shortcut!, shortcutPlatform())}</span>
</Show>
</div>
</button>
)}
</For>
</div>
</div>
</Show>
</div>
);
}}
</For>
</div>
</section>
)}
</For>
</div>
</div>
</Portal>
);
}}
</Show>
);
};

View File

@@ -0,0 +1,134 @@
import { createEffect, createSignal, onCleanup } from "solid-js";
import type { WorkspaceContextMenuTarget } from "../data/shell.data";
type WorkspaceContextMenuState = {
target: WorkspaceContextMenuTarget;
x: number;
y: number;
};
const readRootPixelToken = (name: string, fallback: number): number => {
if (typeof window === "undefined") {
return fallback;
}
const value = window.getComputedStyle(document.documentElement).getPropertyValue(name).trim();
const parsed = Number.parseFloat(value);
if (!Number.isFinite(parsed)) {
return fallback;
}
if (value.endsWith("px")) {
return parsed;
}
return parsed * 16;
};
const clampMenuPosition = (value: number, min: number, max: number): number => {
if (max <= min) {
return min;
}
return Math.min(Math.max(value, min), max);
};
export const createWorkspaceContextMenuController = () => {
const [menuState, setMenuState] = createSignal<WorkspaceContextMenuState | null>(null);
let menuRef: HTMLDivElement | undefined;
const closeMenu = (): void => {
setMenuState(null);
};
const repositionMenu = (): void => {
if (typeof window === "undefined" || !menuRef) {
return;
}
const current = menuState();
if (!current) {
return;
}
const viewportPadding = readRootPixelToken("--space-4", 16);
const rect = menuRef.getBoundingClientRect();
const nextX = clampMenuPosition(current.x, viewportPadding, window.innerWidth - rect.width - viewportPadding);
const nextY = clampMenuPosition(current.y, viewportPadding, window.innerHeight - rect.height - viewportPadding);
if (nextX === current.x && nextY === current.y) {
return;
}
setMenuState({ ...current, x: nextX, y: nextY });
};
const openMenu = (event: MouseEvent, target: WorkspaceContextMenuTarget): void => {
event.preventDefault();
setMenuState({ target, x: event.clientX, y: event.clientY });
};
const openMenuFromElement = (element: HTMLElement, target: WorkspaceContextMenuTarget): void => {
const rect = element.getBoundingClientRect();
setMenuState({
target,
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
});
};
createEffect(() => {
if (!menuState()) {
return;
}
if (typeof window === "undefined") {
return;
}
const frame = window.requestAnimationFrame(() => {
repositionMenu();
});
const handlePointerDown = (event: PointerEvent): void => {
if (!menuRef?.contains(event.target as Node)) {
closeMenu();
}
};
const handleKeyDown = (event: KeyboardEvent): void => {
if (event.key === "Escape") {
closeMenu();
}
};
const handleViewportChange = (): void => {
closeMenu();
};
document.addEventListener("pointerdown", handlePointerDown);
window.addEventListener("resize", handleViewportChange);
window.addEventListener("scroll", handleViewportChange, true);
window.addEventListener("keydown", handleKeyDown);
onCleanup(() => {
window.cancelAnimationFrame(frame);
document.removeEventListener("pointerdown", handlePointerDown);
window.removeEventListener("resize", handleViewportChange);
window.removeEventListener("scroll", handleViewportChange, true);
window.removeEventListener("keydown", handleKeyDown);
});
});
return {
menuState,
openMenu,
openMenuFromElement,
closeMenu,
setMenuRef: (element: HTMLDivElement): void => {
menuRef = element;
},
};
};

View File

@@ -0,0 +1,146 @@
.layer {
display: none;
}
@include respond-down(mobile) {
.layer {
position: fixed;
inset: 0;
display: block;
z-index: var(--z-modal);
}
.backdrop {
position: absolute;
inset: 0;
border: 0;
background: color-mix(in srgb, black 52%, transparent);
}
.sheet {
position: absolute;
right: 0;
bottom: 0;
left: 0;
max-height: calc(100dvh - (var(--space-12) * 2));
display: grid;
gap: var(--space-3);
padding: var(--space-3) var(--space-3) calc(var(--space-4) + env(safe-area-inset-bottom, 0px));
border-top: 1px solid color-mix(in srgb, var(--color-border) 88%, transparent);
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
background: var(--color-surface);
box-shadow: var(--shadow-strong);
overflow: auto;
}
.handle {
width: var(--space-10);
height: var(--space-1);
margin: 0 auto;
border-radius: var(--radius-pill);
background: color-mix(in srgb, var(--color-text-muted) 24%, transparent);
}
.header {
display: flex;
align-items: start;
justify-content: space-between;
gap: var(--space-3);
padding-bottom: var(--space-3);
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 88%, transparent);
}
.headerCopy {
min-width: 0;
display: grid;
gap: calc(var(--space-1) / 2);
}
.eyebrow {
@include text-caption;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.title {
@include text-title;
color: var(--color-text);
font-weight: var(--font-weight-semibold);
}
.closeButton {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--control-size-md);
height: var(--control-size-md);
padding: 0;
border: 1px solid color-mix(in srgb, var(--color-border) 88%, transparent);
border-radius: var(--radius-pill);
background: color-mix(in srgb, var(--color-surface) 84%, var(--color-surface-elevated, var(--color-surface)) 16%);
color: var(--color-text-subtle);
}
.sectionList {
display: grid;
gap: var(--space-3);
}
.section {
display: grid;
gap: var(--space-2);
}
.section + .section {
padding-top: var(--space-3);
border-top: 1px solid color-mix(in srgb, var(--color-border) 92%, transparent);
}
.sectionLabel {
@include text-caption;
padding-inline: var(--space-1);
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.actionList {
display: grid;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--color-border) 88%, transparent);
border-radius: var(--radius-lg);
background: color-mix(in srgb, var(--color-surface) 88%, var(--color-surface-elevated, var(--color-surface)) 12%);
}
.action {
min-width: 0;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
min-height: calc(var(--control-size-lg) + var(--space-2));
padding: 0 var(--space-3);
border: 0;
background: transparent;
color: var(--color-text);
text-align: left;
}
.action:active {
background: color-mix(in srgb, var(--color-text) 6%, transparent);
}
.action + .action {
border-top: 1px solid color-mix(in srgb, var(--color-border) 92%, transparent);
}
.actionLabel {
@include text-label;
}
.actionDanger {
color: var(--color-danger-text, var(--color-text));
}
}

View File

@@ -0,0 +1,131 @@
import { For, Show, createMemo, type JSX } from "solid-js";
import { Portal } from "solid-js/web";
import { X } from "../../../lib/icons";
import {
getWorkspaceContextMenuEyebrow,
getWorkspaceContextMenuSections,
type WorkspaceContextMenuAction,
type WorkspaceContextMenuSection,
type WorkspaceContextMenuTarget,
} from "../data/shell.data";
import styles from "./WorkspaceMobileActionSheet.module.scss";
type WorkspaceMobileActionSheetProps = {
target: WorkspaceContextMenuTarget | null;
onClose: VoidFunction;
onSelect: (action: WorkspaceContextMenuAction, target: WorkspaceContextMenuTarget) => void;
};
type FlattenedActionSection = {
id: string;
label?: string;
items: readonly WorkspaceContextMenuAction[];
};
const flattenMobileSections = (
sections: readonly WorkspaceContextMenuSection[],
): readonly FlattenedActionSection[] => {
// Mobile uses a flat action-sheet model, so desktop flyout groups become
// standalone labeled sections instead of nested menus.
return sections.flatMap((section) => {
const directActions = section.items.filter((action) => !action.children?.length);
const nestedSections = section.items
.filter((action) => action.children?.length)
.map((action) => ({
id: `${section.id}-${action.id}`,
label: action.label,
items: action.children ?? [],
}));
const flattenedSections: FlattenedActionSection[] = [];
if (directActions.length > 0) {
flattenedSections.push({
id: section.id,
label: section.label,
items: directActions,
});
}
return [...flattenedSections, ...nestedSections];
});
};
export const WorkspaceMobileActionSheet = (props: WorkspaceMobileActionSheetProps): JSX.Element => {
const sheetState = createMemo(() => {
if (!props.target) {
return null;
}
return {
target: props.target,
sections: flattenMobileSections(getWorkspaceContextMenuSections(props.target)),
};
});
const handleActionSelect = (action: WorkspaceContextMenuAction, target: WorkspaceContextMenuTarget): void => {
props.onSelect(action, target);
props.onClose();
};
return (
<Show when={sheetState()}>
{(sheetState): JSX.Element => {
const target = sheetState().target;
const sections = sheetState().sections;
return (
<Portal>
<div class={styles.layer}>
<button class={styles.backdrop} type="button" aria-label="Close action sheet" onClick={props.onClose} />
<section class={styles.sheet} aria-label={`${target.label} actions`}>
<div class={styles.handle} aria-hidden="true" />
<header class={styles.header}>
<div class={styles.headerCopy}>
<span class={styles.eyebrow}>{getWorkspaceContextMenuEyebrow(target)}</span>
<strong class={styles.title}>{target.label}</strong>
</div>
<button class={styles.closeButton} type="button" aria-label="Close action sheet" onClick={props.onClose}>
<X size={18} strokeWidth={2} />
</button>
</header>
<div class={styles.sectionList}>
<For each={sections}>
{(section): JSX.Element => (
<section class={styles.section}>
<Show when={section.label}>
<span class={styles.sectionLabel}>{section.label}</span>
</Show>
<div class={styles.actionList}>
<For each={section.items}>
{(action): JSX.Element => (
<button
type="button"
classList={{
[styles.action]: true,
[styles.actionDanger]: action.tone === "danger",
}}
onClick={() => handleActionSelect(action, target)}
>
<span class={styles.actionLabel}>{action.label}</span>
</button>
)}
</For>
</div>
</section>
)}
</For>
</div>
</section>
</div>
</Portal>
);
}}
</Show>
);
};

View File

@@ -1,6 +1,5 @@
.sidebar {
--sidebar-nav-item-min-height: var(--control-size-lg);
--sidebar-dock-clearance: 8rem;
position: relative;
min-width: 0;
min-height: 0;
@@ -15,13 +14,60 @@
.header {
display: grid;
gap: var(--space-3);
}
.headerActions {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: var(--space-2);
justify-items: stretch;
}
.headerControls {
display: grid;
grid-template-columns: minmax(0, 1fr);
align-items: start;
}
.headerDrawerOpen {
z-index: 4;
}
.headerActionButton {
width: 100%;
min-height: var(--control-size-md);
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid color-mix(in srgb, var(--color-border-strong) 44%, transparent);
border-radius: var(--radius-pill);
background: color-mix(in srgb, var(--color-surface) 94%, transparent);
color: var(--color-text-muted);
box-shadow: var(--shadow-soft);
transition:
background 160ms var(--easing-standard),
color 160ms var(--easing-standard),
border-color 160ms var(--easing-standard),
transform 180ms var(--easing-standard);
}
.headerActionButton:hover,
.headerActionButton:focus-visible {
background: var(--color-surface-hover);
border-color: var(--color-border);
color: var(--color-text);
}
.headerActionButton:hover {
transform: translateY(-1px);
}
.headerCollapseButton {
background: color-mix(in srgb, var(--color-accent-soft) 58%, transparent);
color: var(--color-accent-strong);
}
.section {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
@@ -45,7 +91,7 @@
overflow-y: auto;
overscroll-behavior: contain;
padding-right: var(--space-1);
padding-bottom: calc(var(--space-4) + var(--sidebar-dock-clearance));
padding-bottom: calc(var(--space-4) + var(--sidebar-dock-clearance, var(--shell-dock-clearance)));
margin-right: calc(var(--space-1) * -1);
}
@@ -61,6 +107,22 @@
padding: 0;
}
.treeSectionLabel {
@include text-caption;
margin: var(--space-3) 0 var(--space-2);
padding: 0 var(--space-3);
color: var(--color-text-subtle);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.treeList {
list-style: none;
display: grid;
gap: var(--space-1);
padding: 0;
}
.navItem {
width: 100%;
min-width: 0;
@@ -75,6 +137,45 @@
@include interactive-frame-hover(var(--color-surface-hover), transparent, var(--color-text));
}
.treeItem {
width: 100%;
min-width: 0;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-2);
min-height: calc(var(--control-size-lg) - var(--space-2));
padding: var(--space-2) var(--space-3);
padding-left: calc(var(--space-3) + (var(--tree-depth, 0) * var(--space-4)));
border: 1px solid transparent;
border-radius: var(--radius-lg);
background: transparent;
color: var(--color-text-muted);
text-align: left;
transition:
background 160ms var(--easing-standard),
color 160ms var(--easing-standard),
border-color 160ms var(--easing-standard),
transform 180ms var(--easing-standard);
}
.treeItem:hover,
.treeItem:focus-visible {
background: var(--color-surface-hover);
color: var(--color-text);
}
.treeItemFolder {
color: var(--color-text);
}
.treeItemActive {
border-color: var(--color-border);
background: var(--color-surface);
color: var(--color-text);
box-shadow: inset 0 1px 0 color-mix(in srgb, white 4%, transparent);
}
.navItemActive {
border-color: var(--color-border);
background: var(--color-surface);
@@ -97,6 +198,52 @@
color: var(--color-text-muted);
}
.sidebarCollapsed {
gap: var(--space-3);
padding: var(--space-3) var(--space-2);
}
.sidebarCollapsed .headerActions {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.sidebarCollapsed .headerControls {
justify-items: center;
}
.sidebarCollapsed .header {
justify-items: center;
}
.sidebarCollapsed .navScroller {
padding-right: 0;
margin-right: 0;
}
.sidebarCollapsed .navItem {
grid-template-columns: auto;
justify-content: center;
gap: 0;
min-height: calc(var(--control-size-lg) - var(--space-1));
padding: var(--space-2);
border-radius: var(--radius-md);
}
.sidebarCollapsed .label,
.sidebarCollapsed .itemMeta,
.sidebarCollapsed .treeSectionLabel,
.sidebarCollapsed .treeList {
display: none;
}
.sidebarCollapsed .icon {
opacity: 1;
}
.sidebarCollapsed .section {
gap: var(--space-3);
}
@include respond-down(mobile) {
.sidebar {
display: none;

View File

@@ -1,67 +1,261 @@
// Path: Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx
import { For, Show, createSignal, type JSX } from "solid-js";
import { For, Show, createMemo, createSignal, type JSX } from "solid-js";
import { ChevronLeft, ChevronRight } from "../../../lib/icons";
import { ProjectSelector } from "../ProjectSelector/ProjectSelector";
import { serverSidebarItems } from "../data/shell.data";
import {
activeProject,
createWorkspaceStaticTarget,
createWorkspaceSurfaceTarget,
createWorkspaceTreeTarget,
workspaceSidebarHeaderActions,
workspaceStaticItems,
workspaceTree,
type WorkspaceContextMenuAction,
type WorkspaceContextMenuTarget,
type WorkspaceStaticItem,
type WorkspaceTreeNode,
} from "../data/shell.data";
import { WorkspaceContextMenu } from "../WorkspaceContextMenu/WorkspaceContextMenu";
import { createWorkspaceContextMenuController } from "../WorkspaceContextMenu/createWorkspaceContextMenuController";
import styles from "./WorkspaceSidebar.module.scss";
export const WorkspaceSidebar = (): JSX.Element => {
const [isProjectDrawerOpen, setIsProjectDrawerOpen] = createSignal(false);
type WorkspaceSidebarProps = {
collapsed: boolean;
railCollapsed: boolean;
onToggleRailCollapse: () => void;
};
const isContextMenuKeyboardTrigger = (event: KeyboardEvent): boolean => event.key === "ContextMenu" || (event.shiftKey && event.key === "F10");
const WorkspaceHomeEntry = (props: {
item: WorkspaceStaticItem;
onOpenContextMenu: (event: MouseEvent, target: WorkspaceContextMenuTarget) => void;
onOpenContextMenuFromKeyboard: (element: HTMLElement, target: WorkspaceContextMenuTarget) => void;
}): JSX.Element => {
const Icon = props.item.icon;
const target = createWorkspaceStaticTarget(props.item);
return (
<aside class={styles.sidebar} aria-label="Server navigation">
<div
<li>
<button
type="button"
classList={{
[styles.header]: true,
[styles.headerDrawerOpen]: isProjectDrawerOpen(),
[styles.navItem]: true,
[styles.navItemActive]: !!props.item.active,
}}
>
<ProjectSelector
isOpen={isProjectDrawerOpen()}
onToggle={(): void => {
setIsProjectDrawerOpen(true);
}}
onClose={(): void => {
setIsProjectDrawerOpen(false);
}}
/>
</div>
aria-current={props.item.active ? "page" : undefined}
aria-label={props.item.label}
title={props.item.label}
onContextMenu={(event): void => {
event.stopPropagation();
props.onOpenContextMenu(event, target);
}}
onKeyDown={(event): void => {
if (!isContextMenuKeyboardTrigger(event)) {
return;
}
<div
classList={{
[styles.section]: true,
[styles.sectionHidden]: isProjectDrawerOpen(),
event.preventDefault();
props.onOpenContextMenuFromKeyboard(event.currentTarget, target);
}}
>
<span class={styles.sectionLabel}>Navigation</span>
<div class={styles.navScroller}>
<ul class={styles.navList} role="list">
<For each={serverSidebarItems}>
{(item): JSX.Element => {
const Icon = item.icon;
<Icon class={styles.icon} size={18} strokeWidth={2} />
<span class={styles.label}>{props.item.label}</span>
<Show when={props.item.meta}>
<span class={styles.itemMeta}>{props.item.meta}</span>
</Show>
</button>
</li>
);
};
const WorkspaceTreeBranch = (props: {
nodes: readonly WorkspaceTreeNode[];
depth?: number;
onOpenContextMenu: (event: MouseEvent, target: WorkspaceContextMenuTarget) => void;
onOpenContextMenuFromKeyboard: (element: HTMLElement, target: WorkspaceContextMenuTarget) => void;
}): JSX.Element => {
const depth = () => props.depth ?? 0;
return (
<ul class={styles.treeList} role="list">
<For each={props.nodes}>
{(node): JSX.Element => {
const Icon = node.icon;
const target = createWorkspaceTreeTarget(node);
return (
<li>
<button
type="button"
classList={{
[styles.treeItem]: true,
[styles.treeItemActive]: !!node.active,
[styles.treeItemFolder]: node.kind === "folder",
}}
style={{ "--tree-depth": String(depth()) }}
aria-current={node.active ? "page" : undefined}
aria-label={node.label}
title={node.label}
onContextMenu={(event): void => {
event.stopPropagation();
props.onOpenContextMenu(event, target);
}}
onKeyDown={(event): void => {
if (!isContextMenuKeyboardTrigger(event)) {
return;
}
event.preventDefault();
props.onOpenContextMenuFromKeyboard(event.currentTarget, target);
}}
>
<Icon class={styles.icon} size={18} strokeWidth={2} />
<span class={styles.label}>{node.label}</span>
<Show when={node.meta}>
<span class={styles.itemMeta}>{node.meta}</span>
</Show>
</button>
<Show when={node.children?.length}>
<WorkspaceTreeBranch
nodes={node.children ?? []}
depth={depth() + 1}
onOpenContextMenu={props.onOpenContextMenu}
onOpenContextMenuFromKeyboard={props.onOpenContextMenuFromKeyboard}
/>
</Show>
</li>
);
}}
</For>
</ul>
);
};
export const WorkspaceSidebar = (props: WorkspaceSidebarProps): JSX.Element => {
const [isProjectDrawerOpen, setIsProjectDrawerOpen] = createSignal(false);
const contextMenu = createWorkspaceContextMenuController();
const railToggleLabel = (): string => (props.railCollapsed ? "Expand server rail" : "Collapse server rail");
const sidebarContextMenuTarget = createWorkspaceSurfaceTarget(activeProject);
const contextMenuTarget = createMemo(() => contextMenu.menuState()?.target ?? null);
const contextMenuPosition = createMemo(() => {
const state = contextMenu.menuState();
return state
? {
x: state.x,
y: state.y,
}
: null;
});
const handleContextActionSelect = (_action: WorkspaceContextMenuAction, _target: WorkspaceContextMenuTarget): void => {
// Initial implementation only establishes the menu IA and placement.
};
return (
<>
<aside
classList={{
[styles.sidebar]: true,
[styles.sidebarCollapsed]: props.collapsed,
}}
aria-label="Left workspace sidebar"
onContextMenu={(event): void => {
contextMenu.openMenu(event, sidebarContextMenuTarget);
}}
>
<div
classList={{
[styles.header]: true,
[styles.headerDrawerOpen]: isProjectDrawerOpen(),
}}
>
<div class={styles.headerActions}>
<button
type="button"
classList={{
[styles.headerActionButton]: true,
[styles.headerCollapseButton]: true,
}}
aria-label={railToggleLabel()}
title={railToggleLabel()}
onClick={props.onToggleRailCollapse}
>
{props.railCollapsed ? <ChevronRight size={16} strokeWidth={2} /> : <ChevronLeft size={16} strokeWidth={2} />}
</button>
<For each={workspaceSidebarHeaderActions}>
{(action): JSX.Element => {
const Icon = action.icon;
return (
<li>
<button
type="button"
classList={{
[styles.navItem]: true,
[styles.navItemActive]: !!item.active,
}}
>
<Icon class={styles.icon} size={18} strokeWidth={2} />
<span class={styles.label}>{item.label}</span>
<Show when={item.meta}>
<span class={styles.itemMeta}>{item.meta}</span>
</Show>
</button>
</li>
<button type="button" class={styles.headerActionButton} aria-label={action.label} title={action.label}>
<Icon size={16} strokeWidth={2} />
</button>
);
}}
</For>
</ul>
</div>
<div class={styles.headerControls}>
<ProjectSelector
compact={props.collapsed}
isOpen={isProjectDrawerOpen()}
onToggle={(): void => {
setIsProjectDrawerOpen(true);
}}
onClose={(): void => {
setIsProjectDrawerOpen(false);
}}
/>
</div>
</div>
</div>
</aside>
<div
classList={{
[styles.section]: true,
[styles.sectionHidden]: isProjectDrawerOpen(),
}}
>
<Show when={!props.collapsed}>
<span class={styles.sectionLabel}>Workspace</span>
</Show>
<div class={styles.navScroller}>
<ul class={styles.navList} role="list">
<For each={workspaceStaticItems}>
{(item): JSX.Element => (
<WorkspaceHomeEntry
item={item}
onOpenContextMenu={contextMenu.openMenu}
onOpenContextMenuFromKeyboard={contextMenu.openMenuFromElement}
/>
)}
</For>
</ul>
<Show when={!props.collapsed}>
<div class={styles.treeSectionLabel}>Items</div>
</Show>
<WorkspaceTreeBranch
nodes={workspaceTree}
onOpenContextMenu={contextMenu.openMenu}
onOpenContextMenuFromKeyboard={contextMenu.openMenuFromElement}
/>
</div>
</div>
</aside>
<WorkspaceContextMenu
target={contextMenuTarget()}
position={contextMenuPosition()}
menuRef={contextMenu.setMenuRef}
onClose={contextMenu.closeMenu}
onSelect={handleContextActionSelect}
/>
</>
);
};

View File

@@ -0,0 +1,73 @@
import type { JSX } from "solid-js";
type PointerHandler = NonNullable<JSX.DOMAttributes<Element>["onPointerDown"]>;
type LongPressGestureOptions = {
onLongPress: () => void;
delay?: number;
movementThreshold?: number;
};
type LongPressGestureHandlers = {
onPointerDown: PointerHandler;
onPointerMove: PointerHandler;
onPointerUp: PointerHandler;
onPointerCancel: PointerHandler;
onPointerLeave: PointerHandler;
};
export const createLongPressGesture = (options: LongPressGestureOptions): LongPressGestureHandlers => {
let timeoutId: number | undefined;
let originX = 0;
let originY = 0;
const delay = options.delay ?? 420;
const movementThreshold = options.movementThreshold ?? 10;
const clearPendingLongPress = (): void => {
if (typeof timeoutId === "number") {
window.clearTimeout(timeoutId);
timeoutId = undefined;
}
};
const onPointerDown: PointerHandler = (event): void => {
// Mobile long-press should only respond to the primary touch/pen pointer.
if (event.pointerType === "mouse" || !event.isPrimary) {
return;
}
originX = event.clientX;
originY = event.clientY;
clearPendingLongPress();
timeoutId = window.setTimeout(() => {
timeoutId = undefined;
options.onLongPress();
}, delay);
};
const onPointerMove: PointerHandler = (event): void => {
if (typeof timeoutId !== "number") {
return;
}
const deltaX = Math.abs(event.clientX - originX);
const deltaY = Math.abs(event.clientY - originY);
if (deltaX > movementThreshold || deltaY > movementThreshold) {
clearPendingLongPress();
}
};
const onPointerUp: PointerHandler = (): void => {
clearPendingLongPress();
};
return {
onPointerDown,
onPointerMove,
onPointerUp,
onPointerCancel: onPointerUp,
onPointerLeave: onPointerUp,
};
};

View File

@@ -1,7 +1,21 @@
// Path: Frontend/src/components/shell/data/shell.data.ts
import type { Component } from "solid-js";
import { Bell, Folder, Home, LayoutGrid, Search, Settings, User } from "../../../lib/icons";
import {
Bell,
CircleHelp,
FileText,
Folder,
Home,
Keyboard,
LayoutGrid,
LogOut,
Repeat,
Search,
Settings,
Shield,
User,
} from "../../../lib/icons";
type ShellIconProps = {
class?: string;
@@ -68,12 +82,131 @@ export type SidebarItem = {
meta?: string;
};
export type WorkspaceStaticKind = "workspace" | "home" | "settings";
export type WorkspaceStaticItem = SidebarItem & {
contextKind: WorkspaceStaticKind;
};
export type WorkspaceTreeNode = {
id: string;
label: string;
kind: "folder" | "board" | "doc";
icon: ShellIcon;
active?: boolean;
meta?: string;
children?: readonly WorkspaceTreeNode[];
};
export type SidebarHeaderAction = {
id: string;
label: string;
icon: ShellIcon;
};
export type TopBarAction = {
id: string;
label: string;
icon: ShellIcon;
};
export type MobileBottomNavItem = {
id: string;
label: string;
icon: ShellIcon;
active?: boolean;
};
export type WorkspaceContextMenuTarget = {
id: string;
label: string;
kind: WorkspaceStaticKind | WorkspaceTreeNode["kind"];
};
export type WorkspaceContextMenuAction = {
id: string;
label: string;
tone?: "default" | "danger";
shortcut?: WorkspaceContextMenuShortcut;
children?: readonly WorkspaceContextMenuAction[];
};
export type WorkspaceContextMenuShortcutModifier = "meta" | "alt" | "shift";
export type WorkspaceContextMenuShortcutKey = "b" | "c" | "d" | "delete" | "enter" | "f" | "m" | "r";
export type WorkspaceContextMenuShortcut = {
modifiers?: readonly WorkspaceContextMenuShortcutModifier[];
key: WorkspaceContextMenuShortcutKey;
};
export type WorkspaceContextMenuSection = {
id: string;
label?: string;
items: readonly WorkspaceContextMenuAction[];
};
export const getWorkspaceContextMenuEyebrow = (target: WorkspaceContextMenuTarget): string => {
switch (target.kind) {
case "workspace":
case "home":
return "Workspace";
case "settings":
return "Configuration";
case "folder":
return "Folder";
case "board":
return "Board";
case "doc":
return "Doc";
}
};
export const createWorkspaceSurfaceTarget = (workspace: ActiveProject): WorkspaceContextMenuTarget => ({
id: `workspace-${workspace.id}`,
label: workspace.name,
kind: "workspace",
});
export const createWorkspaceStaticTarget = (item: WorkspaceStaticItem): WorkspaceContextMenuTarget => ({
id: item.id,
label: item.label,
kind: item.contextKind,
});
export const createWorkspaceTreeTarget = (node: WorkspaceTreeNode): WorkspaceContextMenuTarget => ({
id: node.id,
label: node.label,
kind: node.kind,
});
export type NotificationItem = {
id: string;
title: string;
contextLabel: string;
timeLabel: string;
unread?: boolean;
};
export type ProfileMenuAction = {
id: string;
label: string;
icon: ShellIcon;
tone?: "default" | "danger";
};
export type ProfileMenuSection = {
id: string;
items: readonly ProfileMenuAction[];
};
export type ActiveUserProfile = {
name: string;
email: string;
roleLabel: string;
contextLabel: string;
};
const personalDockActions: readonly ServerDockAction[] = [
{ id: "account", label: "Account", icon: User },
{ id: "settings", label: "Settings", icon: Settings },
@@ -125,14 +258,245 @@ export const departmentItems: readonly DepartmentItem[] = [
] as const;
// Sidebar and topbar scaffold data
export const serverSidebarItems: readonly SidebarItem[] = [
{ id: "home", label: "Home", icon: Home, active: true },
{ id: "boards", label: "Boards", icon: LayoutGrid, meta: "0" },
{ id: "docs", label: "Docs", icon: Folder, meta: "0" },
{ id: "settings", label: "Settings", icon: Settings },
// These static entries stay pinned in both desktop and mobile workspace navigation.
export const workspaceStaticItems: readonly WorkspaceStaticItem[] = [
{ id: "home", label: "Home", icon: Home, active: true, contextKind: "home" },
{ id: "workspace-settings", label: "Settings", icon: Settings, contextKind: "settings" },
] as const;
// Freeform workspace tree scaffold: folders, boards, and docs are first-class siblings.
export const workspaceTree: readonly WorkspaceTreeNode[] = [
{
id: "product-workspace",
label: "Product",
kind: "folder",
icon: Folder,
children: [
{ id: "roadmap-board", label: "Roadmap", kind: "board", icon: LayoutGrid, active: true },
{ id: "launch-brief", label: "Launch Brief", kind: "doc", icon: FileText },
{
id: "research-folder",
label: "Research",
kind: "folder",
icon: Folder,
children: [
{ id: "interviews-doc", label: "Interviews", kind: "doc", icon: FileText },
{ id: "signals-board", label: "Signals", kind: "board", icon: LayoutGrid, meta: "2" },
],
},
],
},
{
id: "design-folder",
label: "Design",
kind: "folder",
icon: Folder,
children: [
{ id: "system-doc", label: "Design System", kind: "doc", icon: FileText },
{ id: "review-board", label: "Review Queue", kind: "board", icon: LayoutGrid },
],
},
{ id: "general-notes", label: "General Notes", kind: "doc", icon: FileText },
] as const;
export const workspaceSidebarHeaderActions: readonly SidebarHeaderAction[] = [
{ id: "search-workspace", label: "Search workspace", icon: Search },
] as const;
export const mobileBottomNavItems: readonly MobileBottomNavItem[] = [
{ id: "home", label: "Home", icon: Home, active: true },
{ id: "search", label: "Search", icon: Search },
{ id: "browse", label: "Browse", icon: Folder },
] as const;
// Initial context-menu IA scaffold. Behavior wiring can evolve later, but the
// target kinds and action grouping should stay shared across workspace surfaces.
export const getWorkspaceContextMenuSections = (
target: WorkspaceContextMenuTarget,
): readonly WorkspaceContextMenuSection[] => {
const createActions = [
{ id: "new-folder", label: "New folder", shortcut: { modifiers: ["alt"], key: "f" } },
{ id: "new-board", label: "New board", shortcut: { modifiers: ["alt"], key: "b" } },
{ id: "new-doc", label: "New doc", shortcut: { modifiers: ["alt"], key: "d" } },
] as const;
const createSubmenuAction = {
id: "create",
label: "Create",
children: createActions,
} as const;
switch (target.kind) {
case "workspace":
return [
{
id: "create",
label: undefined,
items: [createSubmenuAction],
},
{
id: "workspace",
label: undefined,
items: [
{ id: "rename-workspace", label: "Rename workspace", shortcut: { key: "enter" } },
{ id: "copy-workspace-link", label: "Copy link", shortcut: { modifiers: ["meta"], key: "c" } },
],
},
] as const;
case "home":
return [
{
id: "create",
label: undefined,
items: [createSubmenuAction],
},
{
id: "workspace",
label: undefined,
items: [{ id: "open-home", label: "Open home", shortcut: { key: "enter" } }],
},
] as const;
case "settings":
return [
{
id: "settings",
label: undefined,
items: [
{ id: "open-settings", label: "Open settings", shortcut: { key: "enter" } },
{ id: "copy-settings-link", label: "Copy link", shortcut: { modifiers: ["meta"], key: "c" } },
],
},
] as const;
case "folder":
return [
{
id: "open",
items: [
{ id: "open-folder", label: "Open folder", shortcut: { key: "enter" } },
{ id: "rename-folder", label: "Rename", shortcut: { modifiers: ["meta"], key: "r" } },
],
},
{
id: "create",
label: undefined,
items: [createSubmenuAction],
},
{
id: "organize",
label: undefined,
items: [
{ id: "duplicate-folder", label: "Duplicate", shortcut: { modifiers: ["meta"], key: "d" } },
{ id: "move-folder", label: "Move", shortcut: { modifiers: ["meta"], key: "m" } },
{ id: "delete-folder", label: "Delete", shortcut: { modifiers: ["meta"], key: "delete" }, tone: "danger" },
],
},
] as const;
case "board":
return [
{
id: "board",
items: [
{ id: "open-board", label: "Open board", shortcut: { key: "enter" } },
{ id: "rename-board", label: "Rename", shortcut: { modifiers: ["meta"], key: "r" } },
],
},
{
id: "organize",
label: undefined,
items: [
{ id: "duplicate-board", label: "Duplicate", shortcut: { modifiers: ["meta"], key: "d" } },
{ id: "move-board", label: "Move", shortcut: { modifiers: ["meta"], key: "m" } },
{ id: "delete-board", label: "Delete", shortcut: { modifiers: ["meta"], key: "delete" }, tone: "danger" },
],
},
] as const;
case "doc":
return [
{
id: "doc",
items: [
{ id: "open-doc", label: "Open doc", shortcut: { key: "enter" } },
{ id: "rename-doc", label: "Rename", shortcut: { modifiers: ["meta"], key: "r" } },
],
},
{
id: "organize",
label: undefined,
items: [
{ id: "duplicate-doc", label: "Duplicate", shortcut: { modifiers: ["meta"], key: "d" } },
{ id: "move-doc", label: "Move", shortcut: { modifiers: ["meta"], key: "m" } },
{ id: "delete-doc", label: "Delete", shortcut: { modifiers: ["meta"], key: "delete" }, tone: "danger" },
],
},
] as const;
}
};
export const topBarActions: readonly TopBarAction[] = [
{ id: "search", label: "Search", icon: Search },
{ id: "inbox", label: "Inbox", icon: Bell },
] as const;
export const notificationItems: readonly NotificationItem[] = [
{
id: "comment-design-systems",
title: "New comment on Design Systems",
contextLabel: "Product • Review thread updated",
timeLabel: "2m ago",
unread: true,
},
{
id: "sprint-platform",
title: "Sprint updated in Platform",
contextLabel: "Engineering • Scope changed",
timeLabel: "15m ago",
unread: true,
},
{
id: "member-joined",
title: "New member joined Operations",
contextLabel: "Organization Name • Access granted",
timeLabel: "1h ago",
},
{
id: "daily-summary",
title: "Daily summary is ready",
contextLabel: "General • 8 updates across boards",
timeLabel: "Today, 8:00 AM",
},
] as const;
export const unreadNotificationCount = notificationItems.filter((item) => item.unread).length;
export const activeUserProfile: ActiveUserProfile = {
name: "Demo Account",
email: "demo@moku.work",
roleLabel: "Founder · Product",
contextLabel: "Organization Name • Design Systems",
};
export const profileMenuSections: readonly ProfileMenuSection[] = [
{
id: "account",
items: [
{ id: "profile", label: "Profile", icon: User },
{ id: "account-settings", label: "Account Settings", icon: Settings },
{ id: "notifications", label: "Notifications", icon: Bell },
{ id: "security", label: "Security", icon: Shield },
],
},
{
id: "preferences",
items: [
{ id: "keyboard-shortcuts", label: "Keyboard Shortcuts", icon: Keyboard },
{ id: "theme-preferences", label: "Theme Preferences", icon: Settings },
{ id: "help-support", label: "Help & Support", icon: CircleHelp },
],
},
{
id: "session",
items: [
{ id: "switch-account", label: "Switch Account", icon: Repeat },
{ id: "sign-out", label: "Sign Out", icon: LogOut, tone: "danger" },
],
},
] as const;

View File

@@ -9,6 +9,71 @@
padding: var(--space-5) var(--space-6);
}
.workspaceTopBar {
width: 100%;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-2);
min-height: calc(var(--control-size-md) - var(--space-3));
padding: 0;
}
.workspaceTopBarStart,
.workspaceTopBarEnd {
min-width: calc(var(--control-size-md) - 0.5rem);
display: inline-flex;
align-items: center;
}
.workspaceTopBarEnd {
justify-content: flex-end;
}
.workspaceTopBarCenter {
min-width: 0;
display: flex;
justify-content: center;
}
.workspaceBreadcrumb {
@include text-caption;
min-width: 0;
color: var(--color-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.workspaceCollapseButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: calc(var(--control-size-md) - 0.5rem);
height: calc(var(--control-size-md) - 0.5rem);
border: 1px solid color-mix(in srgb, var(--color-border-strong) 44%, transparent);
border-radius: var(--radius-pill);
background: color-mix(in srgb, var(--color-surface) 94%, transparent);
color: var(--color-text-muted);
box-shadow: var(--shadow-soft);
transition:
background 160ms var(--easing-standard),
color 160ms var(--easing-standard),
border-color 160ms var(--easing-standard),
transform 180ms var(--easing-standard);
}
.workspaceCollapseButton:hover,
.workspaceCollapseButton:focus-visible {
background: var(--color-surface-hover);
border-color: var(--color-border);
color: var(--color-text);
}
.workspaceCollapseButton:hover {
transform: translateY(-1px);
}
.hero {
display: grid;
gap: var(--space-3);
@@ -74,6 +139,20 @@
@include respond-down(mobile) {
.viewport {
gap: var(--space-4);
padding: var(--space-4);
padding: var(--space-4) var(--space-4) calc(var(--space-8) + env(safe-area-inset-bottom, 0px));
}
.workspaceTopBar {
grid-template-columns: minmax(0, 1fr);
}
.workspaceTopBarStart,
.workspaceTopBarEnd,
.workspaceCollapseButton {
display: none;
}
.workspaceTopBarCenter {
justify-content: flex-start;
}
}

View File

@@ -1,7 +1,8 @@
// Path: Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx
import { For, type JSX } from "solid-js";
import { activeServer } from "../../shell/data/shell.data";
import { ChevronLeft, ChevronRight } from "../../../lib/icons";
import { activeProject, activeServer } from "../../shell/data/shell.data";
import styles from "./WorkspaceHome.module.scss";
type ShellCheckpointCard = {
@@ -28,9 +29,37 @@ const shellCheckpointCards: readonly ShellCheckpointCard[] = [
},
];
export const WorkspaceHome = (): JSX.Element => {
type WorkspaceHomeProps = {
sidebarCollapsed: boolean;
onToggleSidebarCollapse: () => void;
};
export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
const sidebarToggleLabel = (): string => (props.sidebarCollapsed ? "Expand left workspace sidebar" : "Collapse left workspace sidebar");
const breadcrumb = (): string => `${activeServer.name} / ${activeProject.name} / Home`;
return (
<main class={styles.viewport}>
<div class={styles.workspaceTopBar}>
<div class={styles.workspaceTopBarStart}>
<button
type="button"
class={styles.workspaceCollapseButton}
aria-label={sidebarToggleLabel()}
title={sidebarToggleLabel()}
onClick={props.onToggleSidebarCollapse}
>
{props.sidebarCollapsed ? <ChevronRight size={16} strokeWidth={2} /> : <ChevronLeft size={16} strokeWidth={2} />}
</button>
</div>
<div class={styles.workspaceTopBarCenter}>
<span class={styles.workspaceBreadcrumb}>{breadcrumb()}</span>
</div>
<div class={styles.workspaceTopBarEnd} aria-hidden="true" />
</div>
<section class={styles.hero}>
<span class={styles.eyebrow}>Server home</span>
<h1 class={styles.title}>{activeServer.name} is ready for its first real shell.</h1>

View File

@@ -1,13 +1,22 @@
// Path: Frontend/src/lib/icons/index.ts
export { default as Bell } from "lucide-solid/icons/bell";
export { default as CircleHelp } from "lucide-solid/icons/circle-help";
export { default as ChevronDown } from "lucide-solid/icons/chevron-down";
export { default as ChevronLeft } from "lucide-solid/icons/chevron-left";
export { default as ChevronRight } from "lucide-solid/icons/chevron-right";
export { default as FileText } from "lucide-solid/icons/file-text";
export { default as Folder } from "lucide-solid/icons/folder";
export { default as Home } from "lucide-solid/icons/house";
export { default as Keyboard } from "lucide-solid/icons/keyboard";
export { default as LayoutGrid } from "lucide-solid/icons/layout-grid";
export { default as LogOut } from "lucide-solid/icons/log-out";
export { default as Moon } from "lucide-solid/icons/moon";
export { default as Plus } from "lucide-solid/icons/plus";
export { default as Repeat } from "lucide-solid/icons/repeat";
export { default as Search } from "lucide-solid/icons/search";
export { default as Settings } from "lucide-solid/icons/settings";
export { default as Shield } from "lucide-solid/icons/shield";
export { default as Sun } from "lucide-solid/icons/sun";
export { default as User } from "lucide-solid/icons/user";
export { default as X } from "lucide-solid/icons/x";