Compare commits

...

6 Commits

Author SHA1 Message Date
MangoPig
ddd25b6eb3 Merge branch 'Features/Frontend/Allowed-Hosts' 2026-06-14 15:23:31 +01:00
MangoPig
cc6243d630 Merge branch 'Features/Frontend/Frontend-Hardening' 2026-06-14 15:23:31 +01:00
MangoPig
9bceb2312d Merge branch 'Features/Frontend/App-Shell' 2026-06-14 15:23:31 +01:00
MangoPig
4c219c0084 Feat: Allowed hosts configuration 2026-06-14 15:23:14 +01:00
MangoPig
a75293fce4 Feat: Frontend hardening 2026-06-14 15:22:19 +01:00
MangoPig
883e8a8bcc Feat: Frontend app shell 2026-06-14 15:21:02 +01:00
27 changed files with 1084 additions and 89 deletions

View File

@@ -16,27 +16,11 @@ FROM dependencies AS development
ENV NODE_ENV=development
ENV PORT=3333
ARG ALLOWED_HOSTS
ENV ALLOWED_HOSTS=${ALLOWED_HOSTS}
COPY . .
EXPOSE 3333
CMD ["pnpm", "dev", "--host", "0.0.0.0", "--port", "3333"]
FROM dependencies AS build
ENV NODE_ENV=production
COPY . .
RUN pnpm build
FROM base AS production
ENV NODE_ENV=production
ENV PORT=3333
COPY --from=build /app /app
EXPOSE 3333
CMD ["pnpm", "start", "--host", "0.0.0.0", "--port", "3333"]

View File

@@ -11,19 +11,21 @@
"scripts": {
"dev": "vite dev",
"build": "vite build",
"start": "vite start",
"typecheck": "tsc --noEmit",
"start": "vite preview",
"preview": "vite preview"
},
"dependencies": {
"lucide-solid": "^0.542.0",
"@solidjs/start": "2.0.0-alpha.2",
"@solidjs/vite-plugin-nitro-2": "^0.1.0",
"lucide-solid": "^0.542.0",
"postcss": "^8.5.15",
"sass": "^1.101.0",
"solid-js": "^1.9.5",
"vite": "^7.0.0"
},
"devDependencies": {
"@types/node": "^25.9.3",
"autoprefixer": "^10.5.0",
"cssnano": "^8.0.2",
"postcss-preset-env": "^11.3.0",

View File

@@ -10,10 +10,10 @@ importers:
dependencies:
'@solidjs/start':
specifier: 2.0.0-alpha.2
version: 2.0.0-alpha.2(crossws@0.4.4(srvx@0.11.8))(vite@7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))
version: 2.0.0-alpha.2(crossws@0.4.4(srvx@0.11.8))(vite@7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))
'@solidjs/vite-plugin-nitro-2':
specifier: ^0.1.0
version: 0.1.0(vite@7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))
version: 0.1.0(vite@7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))
lucide-solid:
specifier: ^0.542.0
version: 0.542.0(solid-js@1.9.11)
@@ -28,8 +28,11 @@ importers:
version: 1.9.11
vite:
specifier: ^7.0.0
version: 7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0)
version: 7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0)
devDependencies:
'@types/node':
specifier: ^25.9.3
version: 25.9.3
autoprefixer:
specifier: ^10.5.0
version: 10.5.0(postcss@8.5.15)
@@ -1262,6 +1265,9 @@ packages:
'@types/micromatch@4.0.10':
resolution: {integrity: sha512-5jOhFDElqr4DKTrTEbnW8DZ4Hz5LRUEmyrGpCMrD/NphYv3nUnaF08xmSLx1rGGnyEs/kFnhiw6dCgcDqMr5PQ==}
'@types/node@25.9.3':
resolution: {integrity: sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==}
'@types/resolve@1.20.2':
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
@@ -3217,6 +3223,9 @@ packages:
unctx@2.5.0:
resolution: {integrity: sha512-p+Rz9x0R7X+CYDkT+Xg8/GhpcShTlU8n+cf9OtOEf7zEQsNcCZO1dPKNRDqvUTaq+P32PMMkxWHwfrxkqfqAYg==}
undici-types@7.24.6:
resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
unenv@2.0.0-rc.24:
resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==}
@@ -4476,13 +4485,13 @@ snapshots:
dependencies:
solid-js: 1.9.11
'@solidjs/start@2.0.0-alpha.2(crossws@0.4.4(srvx@0.11.8))(vite@7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))':
'@solidjs/start@2.0.0-alpha.2(crossws@0.4.4(srvx@0.11.8))(vite@7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))':
dependencies:
'@babel/core': 7.29.0
'@babel/traverse': 7.29.0
'@babel/types': 7.29.0
'@solidjs/meta': 0.29.4(solid-js@1.9.11)
'@tanstack/server-functions-plugin': 1.134.5(vite@7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))
'@tanstack/server-functions-plugin': 1.134.5(vite@7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))
'@types/babel__traverse': 7.28.0
'@types/micromatch': 4.0.10
cookie-es: 2.0.0
@@ -4504,17 +4513,17 @@ snapshots:
source-map-js: 1.2.1
srvx: 0.9.8
terracotta: 1.1.0(solid-js@1.9.11)
vite: 7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0)
vite-plugin-solid: 2.11.10(solid-js@1.9.11)(vite@7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))
vite: 7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0)
vite-plugin-solid: 2.11.10(solid-js@1.9.11)(vite@7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))
transitivePeerDependencies:
- '@testing-library/jest-dom'
- crossws
- supports-color
'@solidjs/vite-plugin-nitro-2@0.1.0(vite@7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))':
'@solidjs/vite-plugin-nitro-2@0.1.0(vite@7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))':
dependencies:
nitropack: 2.13.1
vite: 7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0)
vite: 7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0)
transitivePeerDependencies:
- '@azure/app-configuration'
- '@azure/cosmos'
@@ -4549,7 +4558,7 @@ snapshots:
'@speed-highlight/core@1.2.14': {}
'@tanstack/directive-functions-plugin@1.134.5(vite@7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))':
'@tanstack/directive-functions-plugin@1.134.5(vite@7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))':
dependencies:
'@babel/code-frame': 7.27.1
'@babel/core': 7.29.0
@@ -4559,7 +4568,7 @@ snapshots:
babel-dead-code-elimination: 1.0.12
pathe: 2.0.3
tiny-invariant: 1.3.3
vite: 7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0)
vite: 7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0)
transitivePeerDependencies:
- supports-color
@@ -4576,7 +4585,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@tanstack/server-functions-plugin@1.134.5(vite@7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))':
'@tanstack/server-functions-plugin@1.134.5(vite@7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))':
dependencies:
'@babel/code-frame': 7.27.1
'@babel/core': 7.29.0
@@ -4585,7 +4594,7 @@ snapshots:
'@babel/template': 7.28.6
'@babel/traverse': 7.29.0
'@babel/types': 7.29.0
'@tanstack/directive-functions-plugin': 1.134.5(vite@7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))
'@tanstack/directive-functions-plugin': 1.134.5(vite@7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))
babel-dead-code-elimination: 1.0.12
tiny-invariant: 1.3.3
transitivePeerDependencies:
@@ -4629,6 +4638,10 @@ snapshots:
dependencies:
'@types/braces': 3.0.5
'@types/node@25.9.3':
dependencies:
undici-types: 7.24.6
'@types/resolve@1.20.2': {}
'@types/unist@3.0.3': {}
@@ -6753,6 +6766,8 @@ snapshots:
magic-string: 0.30.21
unplugin: 2.3.11
undici-types@7.24.6: {}
unenv@2.0.0-rc.24:
dependencies:
pathe: 2.0.3
@@ -6876,7 +6891,7 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
vite-plugin-solid@2.11.10(solid-js@1.9.11)(vite@7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0)):
vite-plugin-solid@2.11.10(solid-js@1.9.11)(vite@7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0)):
dependencies:
'@babel/core': 7.29.0
'@types/babel__core': 7.20.5
@@ -6884,12 +6899,12 @@ snapshots:
merge-anything: 5.1.7
solid-js: 1.9.11
solid-refresh: 0.6.3(solid-js@1.9.11)
vite: 7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0)
vitefu: 1.1.2(vite@7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))
vite: 7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0)
vitefu: 1.1.2(vite@7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))
transitivePeerDependencies:
- supports-color
vite@7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0):
vite@7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0):
dependencies:
esbuild: 0.27.3
fdir: 6.5.0(picomatch@4.0.3)
@@ -6898,15 +6913,16 @@ snapshots:
rollup: 4.59.0
tinyglobby: 0.2.15
optionalDependencies:
'@types/node': 25.9.3
fsevents: 2.3.3
jiti: 2.6.1
sass: 1.101.0
sass-embedded: 1.100.0
terser: 5.46.0
vitefu@1.1.2(vite@7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0)):
vitefu@1.1.2(vite@7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0)):
optionalDependencies:
vite: 7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0)
vite: 7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0)
webidl-conversions@3.0.1: {}

View File

@@ -1,7 +1,11 @@
// Path: Frontend/src/app.tsx
import type { JSX } from "solid-js";
import { AppShell } from "./components/shell/AppShell/AppShell";
import "./styles/main.scss";
export default function App() {
return <main />;
}
const App = (): JSX.Element => {
return <AppShell />;
};
export default App;

View File

@@ -0,0 +1,135 @@
.shell {
height: 100dvh;
min-height: 100dvh;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
background: var(--color-canvas);
color: var(--color-text);
}
.body {
--rail-width: 4.75rem;
--sidebar-width: 16.75rem;
--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);
--sidebar-panel-surface: color-mix(in srgb, var(--color-surface-muted) 92%, transparent);
--workspace-panel-surface: color-mix(in srgb, var(--color-canvas) 94%, var(--color-surface));
min-height: 0;
display: grid;
grid-template-columns: var(--rail-width) minmax(0, 1fr);
overflow: hidden;
background: var(--color-surface);
}
.railColumn {
min-height: 0;
display: flex;
position: relative;
z-index: 1;
background: var(--color-surface);
}
.workspaceRegion {
position: relative;
min-width: 0;
min-height: 0;
display: grid;
grid-template-columns: var(--sidebar-width) minmax(0, 1fr);
overflow: visible;
z-index: 1;
isolation: isolate;
border-top-left-radius: var(--shell-top-left-radius);
border-top-right-radius: 0;
}
.workspaceRegion::before {
content: "";
position: absolute;
inset: 0;
background:
linear-gradient(
to right,
var(--sidebar-panel-surface) 0,
var(--sidebar-panel-surface) calc(var(--sidebar-width) - 0.5px),
var(--workspace-panel-surface) calc(var(--sidebar-width) - 0.5px),
var(--workspace-panel-surface) 100%
);
border: 1px solid var(--shell-frame-border);
border-top: 0;
border-top-left-radius: var(--shell-top-left-radius);
border-top-right-radius: 0;
box-shadow: inset 0 1px 0 color-mix(in srgb, white 3%, transparent);
pointer-events: none;
z-index: 0;
}
.sidebarColumn {
position: relative;
min-width: 0;
min-height: 0;
display: grid;
grid-template-rows: minmax(0, 1fr);
overflow: visible;
z-index: 1;
background: var(--sidebar-panel-surface);
border-top: 1px solid var(--shell-frame-border);
border-left: 1px solid var(--shell-frame-border);
border-top-left-radius: var(--shell-top-left-radius);
}
.workspaceMain {
min-width: 0;
min-height: 0;
position: relative;
overflow: hidden;
z-index: 1;
border-top: 1px solid var(--shell-frame-border);
border-left: 1px solid var(--shell-divider-border);
background: var(--workspace-panel-surface);
border-top-right-radius: 0;
}
.sidebarDock {
position: absolute;
right: var(--space-1);
bottom: var(--space-3);
left: calc(var(--space-1) - (var(--rail-width) * 0.9));
z-index: calc(var(--z-modal) + 1);
pointer-events: none;
> * {
pointer-events: auto;
}
}
@include respond-up(mobile) {
.body {
--rail-width: 5rem;
--sidebar-width: 17.25rem;
}
}
@include respond-down(tablet) {
.body {
--rail-width: 4.5rem;
--sidebar-width: 13.25rem;
}
}
@include respond-down(mobile) {
.body {
grid-template-columns: 4.5rem minmax(0, 1fr);
--rail-width: 4.5rem;
}
.railColumn {
position: sticky;
top: 0;
}
.workspaceRegion,
.sidebarDock {
display: none;
}
}

View File

@@ -0,0 +1,50 @@
// Path: Frontend/src/components/shell/AppShell/AppShell.tsx
import { createSignal, onMount, type JSX } from "solid-js";
import { getDocumentTheme, setTheme, type Theme } from "../../../helpers/theme";
import { WorkspaceHome } from "../../workspace-home/WorkspaceHome/WorkspaceHome";
import { LeftRail } from "../LeftRail/LeftRail";
import { ProfileDock } from "../ProfileDock/ProfileDock";
import { TopBar } from "../TopBar/TopBar";
import { WorkspaceSidebar } from "../WorkspaceSidebar/WorkspaceSidebar";
import styles from "./AppShell.module.scss";
export const AppShell = (): JSX.Element => {
const [themeState, setThemeState] = createSignal<Theme>("light");
onMount((): void => {
setThemeState(getDocumentTheme());
});
const toggleTheme = (): void => {
const next: Theme = themeState() === "dark" ? "light" : "dark";
setTheme(next);
setThemeState(next);
};
return (
<div class={styles.shell}>
<TopBar theme={themeState()} onToggleTheme={toggleTheme} />
<div class={styles.body}>
<div class={styles.railColumn}>
<LeftRail />
</div>
<div class={styles.workspaceRegion}>
<div class={styles.sidebarColumn}>
<WorkspaceSidebar />
<div class={styles.sidebarDock}>
<ProfileDock />
</div>
</div>
<div class={styles.workspaceMain}>
<WorkspaceHome />
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,82 @@
.rail {
--rail-workspace-size: var(--control-size-lg);
--rail-action-size: var(--control-size-md);
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-2);
overflow: hidden;
}
.topCluster,
.bottomCluster {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
}
.items {
width: 100%;
min-height: 0;
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
overflow-y: auto;
overscroll-behavior: contain;
padding-block: var(--space-1);
}
.logo {
width: var(--rail-workspace-size);
height: var(--rail-workspace-size);
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-lg);
background: var(--color-accent);
color: var(--color-accent-contrast);
font-weight: 700;
letter-spacing: -0.02em;
}
.workspaceButton {
width: var(--rail-workspace-size);
height: var(--rail-workspace-size);
display: inline-flex;
align-items: center;
justify-content: center;
@include interactive-frame(var(--color-surface-muted), var(--color-border), var(--color-text-muted), var(--radius-lg));
@include text-label;
@include interactive-frame-hover();
}
.workspaceButtonActive {
background: var(--color-accent);
border-color: transparent;
color: var(--color-accent-contrast);
box-shadow: var(--shadow-soft);
}
.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

@@ -0,0 +1,42 @@
// Path: Frontend/src/components/shell/LeftRail/LeftRail.tsx
import { For, type JSX } from "solid-js";
import { Plus } from "../../../lib/icons";
import { railItems } from "../data/shell.data";
import styles from "./LeftRail.module.scss";
export const LeftRail = (): JSX.Element => {
return (
<aside class={styles.rail} aria-label="Workspace rail">
<div class={styles.topCluster}>
<div class={styles.logo} aria-hidden="true">
M
</div>
</div>
<div class={styles.items}>
<For each={railItems}>
{(item): JSX.Element => (
<button
type="button"
classList={{
[styles.workspaceButton]: true,
[styles.workspaceButtonActive]: !!item.active,
}}
title={item.label}
aria-label={item.label}
>
{item.abbreviation}
</button>
)}
</For>
</div>
<div class={styles.bottomCluster}>
<button type="button" class={styles.addButton} aria-label="Create workspace" title="Create workspace">
<Plus size={16} strokeWidth={2} />
</button>
</div>
</aside>
);
};

View File

@@ -0,0 +1,85 @@
.panel {
--profile-dock-avatar-size: var(--control-size-md);
--profile-dock-action-min-height: var(--space-8);
--profile-dock-border: color-mix(in srgb, var(--color-border-strong) 75%, transparent);
--profile-dock-surface: color-mix(in srgb, var(--color-surface) 94%, transparent);
--profile-dock-status-ring: 0 0 0 3px color-mix(in srgb, var(--color-success) 18%, transparent);
position: relative;
z-index: 1;
width: 100%;
display: grid;
gap: var(--space-2);
padding: var(--space-3) var(--space-3) var(--space-2);
border: 1px solid var(--profile-dock-border);
border-radius: calc(var(--radius-xl) + var(--space-1));
background: var(--profile-dock-surface);
box-shadow:
0 20px 48px color-mix(in srgb, black 16%, transparent),
var(--shadow-strong);
backdrop-filter: blur(var(--blur-overlay));
}
.identity {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: var(--space-3);
align-items: center;
}
.avatar {
width: var(--profile-dock-avatar-size);
height: var(--profile-dock-avatar-size);
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--color-accent-soft);
color: var(--color-accent-strong);
@include text-label;
}
.copy {
min-width: 0;
display: grid;
gap: 0.15rem;
}
.name {
@include text-label;
}
.status {
@include text-caption;
display: inline-flex;
align-items: center;
gap: var(--space-2);
color: var(--color-text-muted);
}
.statusDot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--color-success);
box-shadow: var(--profile-dock-status-ring);
}
.actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-2);
}
.action {
min-height: var(--profile-dock-action-min-height);
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-1);
@include interactive-frame(var(--color-surface-muted));
@include interactive-frame-hover();
}
.actionLabel {
@include text-caption;
}

View File

@@ -0,0 +1,35 @@
// Path: Frontend/src/components/shell/ProfileDock/ProfileDock.tsx
import type { JSX } from "solid-js";
import { Settings, User } from "../../../lib/icons";
import styles from "./ProfileDock.module.scss";
export const ProfileDock = (): JSX.Element => {
return (
<section class={styles.panel} aria-label="Profile dock">
<div class={styles.identity}>
<div class={styles.avatar} aria-hidden="true">
R
</div>
<div class={styles.copy}>
<span class={styles.name}>Ronald</span>
<span class={styles.status}>
<span class={styles.statusDot} aria-hidden="true" />
Online in Moku
</span>
</div>
</div>
<div class={styles.actions}>
<button type="button" class={styles.action}>
<User size={16} strokeWidth={2} />
<span class={styles.actionLabel}>Account</span>
</button>
<button type="button" class={styles.action}>
<Settings size={16} strokeWidth={2} />
<span class={styles.actionLabel}>Prefs</span>
</button>
</div>
</section>
);
};

View File

@@ -0,0 +1,85 @@
.topBar {
--topbar-control-size: var(--control-size-md);
min-height: 4rem;
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4) var(--space-3);
background: var(--color-surface);
}
.identity {
min-width: 0;
display: grid;
gap: 0;
}
.eyebrow {
@include text-caption;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.title {
@include text-title;
display: flex;
align-items: center;
gap: var(--space-2);
strong {
font: inherit;
font-weight: var(--font-weight-title);
}
}
.context {
color: var(--color-text-muted);
}
.actions {
display: flex;
align-items: center;
gap: var(--space-1);
}
.actionButton,
.themeButton {
height: var(--topbar-control-size);
display: inline-flex;
align-items: center;
justify-content: center;
@include interactive-frame();
}
.actionButton {
width: var(--topbar-control-size);
}
.themeButton {
width: auto;
padding-inline: var(--space-2);
gap: var(--space-1);
color: var(--color-text);
}
.actionButton,
.themeButton {
@include interactive-frame-hover();
}
.themeLabel {
@include text-label;
}
@include respond-down(mobile) {
.topBar {
grid-template-columns: minmax(0, 1fr) auto;
padding: var(--space-2) var(--space-3) var(--space-3);
}
.actions {
display: none;
}
}

View File

@@ -0,0 +1,45 @@
// Path: Frontend/src/components/shell/TopBar/TopBar.tsx
import { For, type JSX } from "solid-js";
import type { Theme } from "../../../helpers/theme";
import { ChevronDown } from "../../../lib/icons";
import { topBarActions } from "../data/shell.data";
import styles from "./TopBar.module.scss";
type TopBarProps = {
theme: Theme;
onToggleTheme: VoidFunction;
};
export const TopBar = (props: TopBarProps): JSX.Element => {
return (
<header class={styles.topBar}>
<div class={styles.identity}>
<span class={styles.eyebrow}>Moku Work</span>
<div class={styles.title}>
<strong>Workspace Shell</strong>
<span class={styles.context}>Moku / Product</span>
<ChevronDown size={16} strokeWidth={2} />
</div>
</div>
<button class={styles.themeButton} type="button" onClick={props.onToggleTheme}>
<span class={styles.themeLabel}>{props.theme === "dark" ? "Dark" : "Light"}</span>
</button>
<div class={styles.actions}>
<For each={topBarActions}>
{(item): JSX.Element => {
const Icon = item.icon;
return (
<button class={styles.actionButton} type="button" aria-label={item.label} title={item.label}>
<Icon size={18} strokeWidth={2} />
</button>
);
}}
</For>
</div>
</header>
);
};

View File

@@ -0,0 +1,103 @@
.sidebar {
--sidebar-nav-item-min-height: var(--control-size-lg);
--sidebar-dock-clearance: 8rem;
min-width: 0;
min-height: 0;
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
gap: var(--space-4);
padding: var(--space-4);
overflow: hidden;
}
.header {
display: grid;
gap: 0.2rem;
}
.eyebrow {
@include text-caption;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.title {
@include text-title;
}
.meta {
@include text-caption;
color: var(--color-text-muted);
max-width: 28ch;
}
.section {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: var(--space-2);
min-height: 0;
}
.navScroller {
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
padding-right: var(--space-1);
padding-bottom: calc(var(--space-4) + var(--sidebar-dock-clearance));
margin-right: calc(var(--space-1) * -1);
}
.sectionLabel {
@include text-label;
color: var(--color-text-muted);
}
.navList {
list-style: none;
display: grid;
gap: var(--space-1);
padding: 0;
}
.navItem {
width: 100%;
min-width: 0;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-2);
min-height: var(--sidebar-nav-item-min-height);
padding: var(--space-2) var(--space-3);
@include interactive-frame(transparent, transparent, var(--color-text-muted), var(--radius-lg));
text-align: left;
@include interactive-frame-hover(var(--color-surface-hover), transparent, var(--color-text));
}
.navItemActive {
border-color: var(--color-border);
background: var(--color-surface);
color: var(--color-text);
box-shadow: var(--shadow-soft);
}
.icon {
color: inherit;
opacity: 0.85;
}
.label {
@include text-label;
min-width: 0;
}
.itemMeta {
@include text-caption;
color: var(--color-text-muted);
}
@include respond-down(mobile) {
.sidebar {
display: none;
}
}

View File

@@ -0,0 +1,48 @@
// Path: Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx
import { For, Show, type JSX } from "solid-js";
import { workspaceSidebarItems } from "../data/shell.data";
import styles from "./WorkspaceSidebar.module.scss";
export const WorkspaceSidebar = (): JSX.Element => {
return (
<aside class={styles.sidebar} aria-label="Workspace navigation">
<div class={styles.header}>
<span class={styles.eyebrow}>Workspace</span>
<h2 class={styles.title}>Product Operations</h2>
<p class={styles.meta}>A barebone shell for Mokus first real workspace layout.</p>
</div>
<div class={styles.section}>
<span class={styles.sectionLabel}>Navigation</span>
<div class={styles.navScroller}>
<ul class={styles.navList} role="list">
<For each={workspaceSidebarItems}>
{(item): JSX.Element => {
const Icon = item.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>
);
}}
</For>
</ul>
</div>
</div>
</aside>
);
};

View File

@@ -0,0 +1,53 @@
// Path: Frontend/src/components/shell/data/shell.data.ts
import type { Component } from "solid-js";
import { Bell, Folder, Home, LayoutGrid, Plus, Search, Settings, User } from "../../../lib/icons";
type ShellIconProps = {
class?: string;
size?: number;
strokeWidth?: number;
};
export type ShellIcon = Component<ShellIconProps>;
export type RailItem = {
id: string;
label: string;
abbreviation: string;
active?: boolean;
};
export type SidebarItem = {
id: string;
label: string;
icon: ShellIcon;
active?: boolean;
meta?: string;
};
export type TopBarAction = {
id: string;
label: string;
icon: ShellIcon;
};
export const railItems: readonly RailItem[] = [
{ id: "personal", label: "Personal", abbreviation: "P" },
{ id: "moku", label: "Moku", abbreviation: "M", active: true },
{ id: "labs", label: "Labs", abbreviation: "L" },
] as const;
export const workspaceSidebarItems: 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 },
] as const;
export const topBarActions: readonly TopBarAction[] = [
{ id: "search", label: "Search", icon: Search },
{ id: "create", label: "Create", icon: Plus },
{ id: "inbox", label: "Inbox", icon: Bell },
{ id: "profile", label: "Profile", icon: User },
] as const;

View File

@@ -0,0 +1,79 @@
.viewport {
--workspace-content-max-width: var(--content-width-wide);
--workspace-card-min-height: calc(var(--space-12) * 3);
min-width: 0;
min-height: 0;
display: grid;
align-content: start;
gap: var(--space-5);
padding: var(--space-5) var(--space-6);
}
.hero {
display: grid;
gap: var(--space-3);
width: 100%;
max-width: var(--workspace-content-max-width);
}
.eyebrow {
@include text-caption;
color: var(--color-text-muted);
text-transform: uppercase;
}
.title {
@include text-display;
font-family: var(--font-family-display);
max-width: 12ch;
}
.description {
max-width: 64ch;
color: var(--color-text-muted);
}
.grid {
width: 100%;
max-width: var(--workspace-content-max-width);
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-4);
}
.card {
display: grid;
gap: var(--space-2);
min-height: var(--workspace-card-min-height);
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
background: var(--color-surface);
box-shadow: var(--shadow-soft);
}
.cardTitle {
@include text-title;
}
.cardCopy {
color: var(--color-text-muted);
}
.cardMeta {
@include text-caption;
color: var(--color-text-muted);
}
@include respond-down(tablet) {
.grid {
grid-template-columns: 1fr;
}
}
@include respond-down(mobile) {
.viewport {
gap: var(--space-4);
padding: var(--space-4);
}
}

View File

@@ -0,0 +1,54 @@
// Path: Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx
import { For, type JSX } from "solid-js";
import styles from "./WorkspaceHome.module.scss";
type ShellCheckpointCard = {
title: string;
copy: string;
meta: string;
};
const shellCheckpointCards: readonly ShellCheckpointCard[] = [
{
title: "App shell",
copy: "Top bar, left rail, workspace sidebar, and content viewport are now split into modular components.",
meta: "Layout foundation",
},
{
title: "Workspace context",
copy: "The shell already has clear places for org context, workspace switching, and future surface navigation.",
meta: "Navigation foundation",
},
{
title: "Next build target",
copy: "You can now plug in workspace home content, auth state, and early primitives without redesigning the whole frame.",
meta: "Ready for v0.1.0 work",
},
];
export const WorkspaceHome = (): JSX.Element => {
return (
<main class={styles.viewport}>
<section class={styles.hero}>
<span class={styles.eyebrow}>Workspace home</span>
<h1 class={styles.title}>Moku is ready for its first real shell.</h1>
<p class={styles.description}>
This is the barebone app frame for v0.1.0 enough structure to start building real frontend surfaces on top of a real backend core.
</p>
</section>
<section class={styles.grid} aria-label="Shell checkpoints">
<For each={shellCheckpointCards}>
{(card): JSX.Element => (
<article class={styles.card}>
<h2 class={styles.cardTitle}>{card.title}</h2>
<p class={styles.cardCopy}>{card.copy}</p>
<span class={styles.cardMeta}>{card.meta}</span>
</article>
)}
</For>
</section>
</main>
);
};

View File

@@ -1,4 +1,21 @@
// Path: Frontend/src/entry-client.tsx
// @refresh reload
import type { JSX } from "solid-js";
import { mount, StartClient } from "@solidjs/start/client";
mount(() => <StartClient />, document.getElementById("app")!);
const getAppRoot = (): HTMLElement => {
const appRoot = document.getElementById("app");
if (!(appRoot instanceof HTMLElement)) {
throw new Error("App root element '#app' was not found.");
}
return appRoot;
};
const mountApp = (): void => {
mount((): JSX.Element => <StartClient />, getAppRoot());
};
mountApp();

View File

@@ -1,6 +1,7 @@
// Path: Frontend/src/entry-server.tsx
// @refresh reload
import type { JSX } from "solid-js";
import { createHandler, StartServer } from "@solidjs/start/server";
const themeBootstrapScript = `
@@ -19,22 +20,32 @@ const themeBootstrapScript = `
})();
`;
export default createHandler(() => (
<StartServer
document={({ assets, children, scripts }) => (
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
<script innerHTML={themeBootstrapScript} />
{assets}
</head>
<body>
<div id="app">{children}</div>
{scripts}
</body>
</html>
)}
/>
));
type DocumentRenderProps = {
assets?: JSX.Element;
children?: JSX.Element;
scripts?: JSX.Element;
};
const renderDocument = ({ assets, children, scripts }: DocumentRenderProps): JSX.Element => {
return (
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
<script innerHTML={themeBootstrapScript} />
{assets}
</head>
<body>
<div id="app">{children}</div>
{scripts}
</body>
</html>
);
};
const serverHandler = createHandler((): JSX.Element => {
return <StartServer document={renderDocument} />;
});
export default serverHandler;

View File

@@ -1 +1,3 @@
// Path: Frontend/src/global.d.ts
/// <reference types="@solidjs/start/env" />

View File

@@ -16,7 +16,7 @@ export const resolvePreferredTheme = (): Theme => {
export const getDocumentTheme = (): Theme => (document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light");
export const setTheme = (theme: Theme) => {
export const setTheme = (theme: Theme): void => {
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem(THEME_STORAGE_KEY, theme);
};

View File

@@ -1,3 +1,11 @@
// Path: Frontend/src/lib/icons/index.ts
export { Bell, Check, ChevronDown, ChevronLeft, ChevronRight, Folder, FolderOpen, Home, LayoutGrid, MoreHorizontal, Plus, Search, Settings, User, X } from "lucide-solid";
export { default as Bell } from "lucide-solid/icons/bell";
export { default as ChevronDown } from "lucide-solid/icons/chevron-down";
export { default as Folder } from "lucide-solid/icons/folder";
export { default as Home } from "lucide-solid/icons/house";
export { default as LayoutGrid } from "lucide-solid/icons/layout-grid";
export { default as Plus } from "lucide-solid/icons/plus";
export { default as Search } from "lucide-solid/icons/search";
export { default as Settings } from "lucide-solid/icons/settings";
export { default as User } from "lucide-solid/icons/user";

View File

@@ -37,6 +37,11 @@
--radius-xl: 1.25rem;
--radius-pill: 999px;
--control-size-md: 2.25rem;
--control-size-lg: 2.5rem;
--content-width-wide: 72rem;
--blur-overlay: 18px;
--shadow-soft: 0 12px 32px hsl(220 30% 10% / 0.08);
--shadow-strong: 0 20px 48px hsl(220 30% 10% / 0.16);

View File

@@ -1,5 +0,0 @@
/* Path: Frontend/src/styles/tools/_functions.scss */
@function rem($pixels, $base: 16) {
@return ($pixels / $base) * 1rem;
}

View File

@@ -18,6 +18,47 @@
}
}
@mixin respond-down($breakpoint) {
@if $breakpoint == mobile {
@media (max-width: calc($breakpoint-mobile - 0.01rem)) {
@content;
}
} @else if $breakpoint == tablet {
@media (max-width: calc($breakpoint-tablet - 0.01rem)) {
@content;
}
} @else if $breakpoint == desktop {
@media (max-width: calc($breakpoint-desktop - 0.01rem)) {
@content;
}
}
}
@mixin interactive-frame(
$background: var(--color-surface),
$border: var(--color-border),
$color: var(--color-text-muted),
$radius: var(--radius-md)
) {
border: 1px solid $border;
border-radius: $radius;
background: $background;
color: $color;
}
@mixin interactive-frame-hover(
$background: var(--color-surface-hover),
$border: var(--color-border-strong),
$color: var(--color-text)
) {
&:hover {
background: $background;
border-color: $border;
color: $color;
text-decoration: none;
}
}
@mixin text-caption {
font-size: var(--font-size-caption);
font-weight: var(--font-weight-caption);

View File

@@ -1,19 +1,21 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
"allowJs": true,
"strict": true,
"noEmit": true,
"types": ["vite/client"],
"isolatedModules": true,
"paths": {
"~/*": ["./src/*"]
}
}
"compilerOptions": {
"target": "ESNext",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
"allowJs": true,
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["vite/client", "node"],
"isolatedModules": true,
"paths": {
"~/*": ["./src/*"]
}
}
}

View File

@@ -5,13 +5,25 @@ import { defineConfig } from "vite";
import { solidStart } from "@solidjs/start/config";
const extraAllowedHosts = (process.env.ALLOWED_HOSTS ?? "")
.split(",")
.map((host) => host.trim())
.filter(Boolean);
export default defineConfig({
plugins: [solidStart(), nitro()],
server: {
allowedHosts: ["localhost", ...extraAllowedHosts],
},
preview: {
allowedHosts: ["localhost", ...extraAllowedHosts],
},
css: {
preprocessorOptions: {
scss: {
additionalData: `@use "/src/styles/tools/functions" as *;\n@use "/src/styles/tools/breakpoints" as *;\n@use "/src/styles/tools/mixins" as *;\n`,
additionalData: `@use "/src/styles/tools/breakpoints" as *;\n@use "/src/styles/tools/mixins" as *;\n`,
},
},
},
plugins: [solidStart(), nitro()],
});