Feat: Build out server shell

This commit is contained in:
MangoPig
2026-06-16 13:11:14 +01:00
parent 35586729ba
commit 829d7b3d8f
28 changed files with 1990 additions and 149 deletions
@@ -0,0 +1,200 @@
.root {
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));
}
.trigger {
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) calc(var(--space-2) + 0.2rem);
border: 1px solid color-mix(in srgb, var(--color-border-strong) 44%, transparent);
border-radius: calc(var(--radius-lg) + var(--space-1));
background: color-mix(in srgb, var(--color-surface) 96%, transparent);
box-shadow: var(--shadow-soft);
text-align: left;
position: relative;
z-index: 5;
transition:
border-color var(--duration-fast) var(--easing-standard),
background var(--duration-fast) var(--easing-standard),
box-shadow var(--duration-fast) var(--easing-standard),
transform 180ms var(--easing-standard);
}
.trigger:hover {
background: var(--color-surface-hover);
border-color: var(--color-border);
}
.triggerOpen {
border-color: color-mix(in srgb, var(--color-border-strong) 22%, transparent);
background: color-mix(in srgb, var(--color-surface) 92%, transparent);
box-shadow: var(--shadow-soft);
}
.triggerLead {
width: var(--control-size-md);
height: var(--control-size-md);
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--color-accent-soft) 82%, transparent);
color: var(--color-accent-strong);
}
.triggerCopy {
min-width: 0;
display: grid;
gap: 0.12rem;
}
.eyebrow,
.projectItemDescription {
@include text-caption;
color: var(--color-text-muted);
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.08em;
}
.value,
.projectItemName {
@include text-label;
}
.triggerIcon {
color: var(--color-text-muted);
transform: rotate(-90deg);
transition: transform var(--duration-fast) var(--easing-standard);
}
.triggerIconOpen {
transform: rotate(0deg);
}
.scrim {
position: absolute;
inset: calc(var(--project-drawer-top) + var(--project-drawer-gap)) var(--space-4)
var(--project-drawer-bottom) var(--space-4);
z-index: 2;
opacity: 0;
pointer-events: none;
background: color-mix(in srgb, black 8%, transparent);
border-radius: var(--radius-lg);
transition: opacity 260ms var(--easing-standard);
}
.scrimOpen {
opacity: 1;
pointer-events: auto;
}
.drawer {
position: absolute;
inset: calc(var(--project-drawer-top) + var(--project-drawer-gap)) var(--space-4)
var(--project-drawer-bottom) var(--space-4);
z-index: 3;
display: grid;
overflow: hidden;
border-radius: var(--radius-lg);
opacity: 0;
pointer-events: none;
transform: translateX(calc(-1 * (var(--space-5) + 12%)));
will-change: transform, opacity;
transition:
opacity 240ms var(--easing-standard),
transform 360ms cubic-bezier(0.16, 1, 0.3, 1);
}
.drawerOpen {
opacity: 1;
pointer-events: auto;
transform: translateX(0);
}
.drawer::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
border: 1px solid color-mix(in srgb, var(--color-border-strong) 52%, transparent);
background: var(--color-surface-muted);
box-shadow:
14px 0 30px color-mix(in srgb, black 7%, transparent),
inset -1px 0 0 color-mix(in srgb, white 4%, transparent);
pointer-events: none;
}
.drawerBody {
position: relative;
z-index: 1;
min-height: 0;
display: grid;
align-content: start;
gap: var(--space-3);
padding: var(--space-4);
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: none;
}
.drawerBody::-webkit-scrollbar {
width: 0;
}
.projectList {
list-style: none;
display: grid;
gap: 0.2rem;
padding: 0;
}
.projectItem {
width: 100%;
min-width: 0;
min-height: calc(var(--control-size-md) + var(--space-2));
padding: var(--space-2) var(--space-3);
border: 1px solid transparent;
border-radius: var(--radius-sm);
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);
text-align: left;
}
.projectItem:hover {
background: color-mix(in srgb, var(--color-surface-hover) 82%, transparent);
color: var(--color-text);
border-color: color-mix(in srgb, var(--color-border) 22%, transparent);
}
.projectItemActive {
border-color: color-mix(in srgb, var(--color-border) 28%, transparent);
background: color-mix(in srgb, var(--color-surface) 82%, transparent);
color: var(--color-text);
box-shadow: none;
}
.projectItemCopy {
min-width: 0;
display: grid;
gap: 0.05rem;
}
.projectItemDescription {
color: color-mix(in srgb, var(--color-text-muted) 84%, transparent);
}
@@ -0,0 +1,156 @@
// Path: Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx
import { For, createSignal, onCleanup, onMount, type JSX } from "solid-js";
import { ChevronDown, Folder } from "../../../lib/icons";
import { activeProject, projectItems } from "../data/shell.data";
import styles from "./ProjectSelector.module.scss";
type ProjectSelectorProps = {
isOpen: boolean;
onToggle: () => void;
onClose: () => void;
};
const defaultProject = projectItems.find((item) => item.id === activeProject.id) ?? projectItems[0];
export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
const [selectedProject, setSelectedProject] = createSignal({ id: defaultProject.id, name: defaultProject.name });
const [drawerTop, setDrawerTop] = createSignal<number>(0);
let triggerRef: HTMLButtonElement | undefined;
onMount(() => {
if (!triggerRef) {
return;
}
const updateDrawerTop = (): void => {
if (!triggerRef) {
return;
}
setDrawerTop(triggerRef.offsetTop + triggerRef.offsetHeight);
};
updateDrawerTop();
const observer = new ResizeObserver(() => {
updateDrawerTop();
});
observer.observe(triggerRef);
window.addEventListener("resize", updateDrawerTop);
onCleanup(() => {
observer.disconnect();
window.removeEventListener("resize", updateDrawerTop);
});
});
const toggleOpen = (): void => {
if (!props.isOpen) {
props.onToggle();
return;
}
props.onClose();
};
const selectProject = (projectId: string): void => {
const nextProject = projectItems.find((item): boolean => item.id === projectId);
if (!nextProject) {
return;
}
setSelectedProject({ id: nextProject.id, name: nextProject.name });
props.onClose();
};
return (
<div
class={styles.root}
style={{
"--project-drawer-top": `${drawerTop()}px`,
}}
>
{/* Project trigger */}
<button
type="button"
ref={triggerRef}
classList={{
[styles.trigger]: true,
[styles.triggerOpen]: props.isOpen,
}}
aria-label="Open project drawer"
aria-expanded={props.isOpen}
title="Open project drawer"
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>
<ChevronDown
classList={{
[styles.triggerIcon]: true,
[styles.triggerIconOpen]: props.isOpen,
}}
size={16}
strokeWidth={2}
/>
</button>
{/* Outside-click scrim */}
<button
type="button"
classList={{
[styles.scrim]: true,
[styles.scrimOpen]: props.isOpen,
}}
aria-hidden={!props.isOpen}
tabIndex={props.isOpen ? 0 : -1}
onClick={props.onClose}
/>
{/* Slide-out project list */}
<div
classList={{
[styles.drawer]: true,
[styles.drawerOpen]: props.isOpen,
}}
aria-hidden={!props.isOpen}
>
<div class={styles.drawerBody}>
<ul class={styles.projectList} role="list">
<For each={projectItems}>
{(item): JSX.Element => {
const isSelected = (): boolean => selectedProject().id === item.id;
return (
<li>
<button
type="button"
classList={{
[styles.projectItem]: true,
[styles.projectItemActive]: isSelected(),
}}
onClick={(): void => selectProject(item.id)}
>
<span class={styles.projectItemCopy}>
<span class={styles.projectItemName}>{item.name}</span>
<span class={styles.projectItemDescription}>{item.description}</span>
</span>
</button>
</li>
);
}}
</For>
</ul>
</div>
</div>
</div>
);
};