Compare commits

...

29 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
MangoPig
829d7b3d8f Feat: Build out server shell 2026-06-16 13:11:14 +01:00
MangoPig
35586729ba Merge branch 'Features/Server' 2026-06-16 13:06:16 +01:00
MangoPig
7d57792a82 Feat: Replace profile dock with server dock 2026-06-16 13:05:31 +01:00
MangoPig
f41dbc43fa Merge branch 'Features/Backend/Scaffolding' 2026-06-16 07:35:22 +01:00
MangoPig
76c24782c8 Feat: Backend scaffolding and local dev stack 2026-06-16 07:34:34 +01:00
MangoPig
4ebee9e695 Merge branch 'Features/Frontend/Adoptive-Theme' 2026-06-15 10:56:01 +01:00
MangoPig
a5ca826a6e Feat: Adoptive theme foundation 2026-06-15 10:54:55 +01:00
MangoPig
ecd214652a Merge branch 'Features/Frontend/Web-Loader' 2026-06-15 07:00:44 +01:00
MangoPig
99538e30c8 Feat: Web loader 2026-06-15 06:59:57 +01:00
MangoPig
90de5ca868 Merge branch 'Features/Frontend/Local-Prod-Proxy' 2026-06-15 05:09:39 +01:00
MangoPig
354dbc849b Feat: Local prod proxy 2026-06-15 05:09:13 +01:00
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
MangoPig
282e6b604d Feat: Documentations
Fix
2026-06-14 03:07:51 +01:00
130 changed files with 9926 additions and 210 deletions

13
.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
.DS_Store
.git
.gitignore
**/.pnpm-store
**/.output
**/dist
**/node_modules
Commands
Docker
Documentation
Env

6
.gitignore vendored
View File

@@ -21,3 +21,9 @@ pnpm-debug.log*
# OS / editor files
.DS_Store
.idea/
# Go build output
tmp/
bin/
.cgcignore

52
Backend/.air.api.toml Normal file
View File

@@ -0,0 +1,52 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp/dev"
[build]
args_bin = []
bin = "./tmp/dev/bin/api"
cmd = "go build -o ./tmp/dev/bin/api ./cmd/api"
delay = 1000
exclude_dir = ["tmp", "vendor", "testdata"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "sql"]
include_file = []
kill_delay = "0s"
log = "tmp/dev/logs/build-errors.api.log"
poll = false
poll_interval = 0
post_cmd = []
pre_cmd = []
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = true
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
silent = false
time = true
[misc]
clean_on_exit = false
[proxy]
app_port = 0
enabled = false
proxy_port = 0
[screen]
clear_on_rebuild = true
keep_scroll = true

52
Backend/.air.web.toml Normal file
View File

@@ -0,0 +1,52 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp/dev"
[build]
args_bin = []
bin = "./tmp/dev/bin/web"
cmd = "go build -o ./tmp/dev/bin/web ./cmd/web"
delay = 1000
exclude_dir = ["tmp", "vendor", "testdata"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "sql"]
include_file = []
kill_delay = "0s"
log = "tmp/dev/logs/build-errors.web.log"
poll = false
poll_interval = 0
post_cmd = []
pre_cmd = []
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = true
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
silent = false
time = true
[misc]
clean_on_exit = false
[proxy]
app_port = 0
enabled = false
proxy_port = 0
[screen]
clear_on_rebuild = true
keep_scroll = true

52
Backend/.air.worker.toml Normal file
View File

@@ -0,0 +1,52 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp/dev"
[build]
args_bin = []
bin = "./tmp/dev/bin/worker"
cmd = "go build -o ./tmp/dev/bin/worker ./cmd/worker"
delay = 1000
exclude_dir = ["tmp", "vendor", "testdata"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "sql"]
include_file = []
kill_delay = "0s"
log = "tmp/dev/logs/build-errors.worker.log"
poll = false
poll_interval = 0
post_cmd = []
pre_cmd = []
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = true
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
silent = false
time = true
[misc]
clean_on_exit = false
[proxy]
app_port = 0
enabled = false
proxy_port = 0
[screen]
clear_on_rebuild = true
keep_scroll = true

4
Backend/.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
.git
.gitignore
tmp
testdata

View File

36
Backend/Dockerfile Normal file
View File

@@ -0,0 +1,36 @@
# syntax=docker/dockerfile:1.7
FROM golang:1.25.7-alpine AS base
WORKDIR /app
RUN apk add --no-cache ca-certificates curl git tzdata && update-ca-certificates
COPY go.mod go.sum ./
RUN go mod download
FROM base AS development
RUN curl -fsSL https://raw.githubusercontent.com/air-verse/air/master/install.sh | sh -s -- -b /usr/local/bin
COPY . .
CMD ["air", "-c", ".air.web.toml"]
FROM base AS builder
ARG SERVICE_NAME=web
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/app ./cmd/${SERVICE_NAME}
FROM alpine:3.22 AS runtime
RUN apk add --no-cache ca-certificates tzdata && update-ca-certificates
WORKDIR /app
COPY --from=builder /out/app /usr/local/bin/app
CMD ["/usr/local/bin/app"]

35
Backend/cmd/api/main.go Normal file
View File

@@ -0,0 +1,35 @@
package main
import (
"log"
"moku-backend/internal/bootstrap"
"moku-backend/internal/httpx"
"moku-backend/internal/process"
)
func main() {
app, err := bootstrap.New("api")
if err != nil {
log.Fatalf("bootstrap api service: %v", err)
}
defer func() {
if closeErr := app.Close(); closeErr != nil {
app.Logger.Error("close api service", "error", closeErr)
}
}()
handler := httpx.NewRouter(httpx.RouterConfig{
ServiceName: app.ServiceName,
Config: app.Config,
Logger: app.Logger,
BuildInfo: app.BuildInfo,
Database: app.Database,
Cache: app.Cache,
})
if err := process.RunHTTPServer(app.ServiceName, app.Config.Address(app.ServiceName), handler, app.Logger, app.Config.ShutdownTimeout); err != nil {
app.Logger.Error("api service stopped", "error", err)
log.Fatal(err)
}
}

View File

@@ -0,0 +1,49 @@
package main
import (
"fmt"
"log"
"os"
"moku-backend/internal/config"
"moku-backend/internal/database"
)
func main() {
cfg := config.Load()
db, err := database.NewPostgres(cfg.PostgresURL)
if err != nil {
log.Fatalf("connect database for migrations: %v", err)
}
defer db.Close()
command := "up"
if len(os.Args) > 1 {
command = os.Args[1]
}
switch command {
case "up":
if err := db.MigrateUp(); err != nil {
log.Fatalf("run migrations: %v", err)
}
fmt.Println("migrations applied")
case "down":
if err := db.MigrateDown(); err != nil {
log.Fatalf("roll back migration: %v", err)
}
fmt.Println("latest migration rolled back")
case "reset":
if err := db.MigrateReset(); err != nil {
log.Fatalf("reset migrations: %v", err)
}
fmt.Println("migrations reset")
case "status":
if err := db.MigrateStatus(); err != nil {
log.Fatalf("show migration status: %v", err)
}
default:
log.Fatalf("unsupported migrate command %q (supported: up, down, reset, status)", command)
}
}

35
Backend/cmd/web/main.go Normal file
View File

@@ -0,0 +1,35 @@
package main
import (
"log"
"moku-backend/internal/bootstrap"
"moku-backend/internal/httpx"
"moku-backend/internal/process"
)
func main() {
app, err := bootstrap.New("web")
if err != nil {
log.Fatalf("bootstrap web service: %v", err)
}
defer func() {
if closeErr := app.Close(); closeErr != nil {
app.Logger.Error("close web service", "error", closeErr)
}
}()
handler := httpx.NewRouter(httpx.RouterConfig{
ServiceName: app.ServiceName,
Config: app.Config,
Logger: app.Logger,
BuildInfo: app.BuildInfo,
Database: app.Database,
Cache: app.Cache,
})
if err := process.RunHTTPServer(app.ServiceName, app.Config.Address(app.ServiceName), handler, app.Logger, app.Config.ShutdownTimeout); err != nil {
app.Logger.Error("web service stopped", "error", err)
log.Fatal(err)
}
}

View File

@@ -0,0 +1,27 @@
package main
import (
"log"
"moku-backend/internal/bootstrap"
"moku-backend/internal/process"
)
func main() {
app, err := bootstrap.New("worker")
if err != nil {
log.Fatalf("bootstrap worker service: %v", err)
}
defer func() {
if closeErr := app.Close(); closeErr != nil {
app.Logger.Error("close worker service", "error", closeErr)
}
}()
app.Logger.Info("worker ready", "service", app.ServiceName, "environment", app.Config.Environment)
if err := process.WaitForShutdown(app.ServiceName, app.Logger); err != nil {
app.Logger.Error("worker stopped", "error", err)
log.Fatal(err)
}
}

6
Backend/db/embed.go Normal file
View File

@@ -0,0 +1,6 @@
package db
import "embed"
//go:embed migrations/*.sql
var Migrations embed.FS

View File

@@ -0,0 +1,26 @@
-- +goose Up
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE TABLE IF NOT EXISTS organizations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS workspaces (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
name TEXT NOT NULL,
slug TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (organization_id, slug)
);
CREATE INDEX IF NOT EXISTS idx_workspaces_organization_id ON workspaces (organization_id);
-- +goose Down
DROP TABLE IF EXISTS workspaces;
DROP TABLE IF EXISTS organizations;

View File

@@ -0,0 +1,15 @@
-- name: ListOrganizations :many
SELECT id, name, slug, created_at, updated_at
FROM organizations
ORDER BY created_at DESC;
-- name: CreateOrganization :one
INSERT INTO organizations (name, slug)
VALUES ($1, $2)
RETURNING id, name, slug, created_at, updated_at;
-- name: ListWorkspacesByOrganization :many
SELECT id, organization_id, name, slug, created_at, updated_at
FROM workspaces
WHERE organization_id = $1
ORDER BY created_at DESC;

36
Backend/docker-bake.hcl Normal file
View File

@@ -0,0 +1,36 @@
variable "REGISTRY" {
default = "registry.example.com"
}
variable "TAG" {
default = "latest"
}
target "_app" {
context = "."
dockerfile = "Dockerfile"
}
target "dev" {
inherits = ["_app"]
target = "development"
tags = ["moku/work-backend:dev"]
}
target "dev-image" {
inherits = ["_app"]
target = "development"
tags = ["${REGISTRY}/moku/work-backend:dev-${TAG}"]
}
group "local" {
targets = ["dev"]
}
group "registry" {
targets = ["dev-image"]
}
group "default" {
targets = ["dev"]
}

24
Backend/go.mod Normal file
View File

@@ -0,0 +1,24 @@
module moku-backend
go 1.25.7
require (
github.com/go-chi/chi/v5 v5.3.0
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.10.0
github.com/pressly/goose/v3 v3.27.1
github.com/redis/go-redis/v9 v9.20.1
)
require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.36.0 // indirect
)

70
Backend/go.sum Normal file
View File

@@ -0,0 +1,70 @@
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
github.com/redis/go-redis/v9 v9.20.1 h1:sfCU6A8P3dXbKyWes02uxA2baehGux9dZHfEKtsTB1w=
github.com/redis/go-redis/v9 v9.20.1/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0=
modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U=
modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=

View File

@@ -0,0 +1,85 @@
// Path: Backend/internal/bootstrap/bootstrap.go
package bootstrap
import (
"log/slog"
"os"
"strings"
"moku-backend/internal/buildinfo"
"moku-backend/internal/cache"
"moku-backend/internal/config"
"moku-backend/internal/database"
)
type App struct {
ServiceName string
Config *config.Config
Logger *slog.Logger
BuildInfo buildinfo.Info
Database *database.DB
Cache *cache.Client
}
func New(serviceName string) (*App, error) {
cfg := config.Load()
logger := newLogger(cfg)
db, err := database.NewPostgres(cfg.PostgresURL)
if err != nil {
return nil, err
}
valkey, err := cache.NewValkey(cfg.ValkeyURL)
if err != nil {
db.Close()
return nil, err
}
return &App{
ServiceName: serviceName,
Config: cfg,
Logger: logger.With("service", serviceName),
BuildInfo: buildinfo.Current(),
Database: db,
Cache: valkey,
}, nil
}
func (a *App) Close() error {
var closeErr error
if a.Cache != nil {
if err := a.Cache.Close(); err != nil {
closeErr = err
}
}
if a.Database != nil {
a.Database.Close()
}
return closeErr
}
func newLogger(cfg *config.Config) *slog.Logger {
level := slog.LevelInfo
switch strings.ToLower(cfg.LogLevel) {
case "debug":
level = slog.LevelDebug
case "warn":
level = slog.LevelWarn
case "error":
level = slog.LevelError
}
options := &slog.HandlerOptions{Level: level}
if cfg.IsDevelopment() {
return slog.New(slog.NewTextHandler(os.Stdout, options))
}
return slog.New(slog.NewJSONHandler(os.Stdout, options))
}

View File

@@ -0,0 +1,23 @@
// Path: Backend/internal/buildinfo/buildinfo.go
package buildinfo
var (
Version = "dev"
Commit = "unknown"
BuiltAt = "unknown"
)
type Info struct {
Version string `json:"version"`
Commit string `json:"commit"`
BuiltAt string `json:"builtAt"`
}
func Current() Info {
return Info{
Version: Version,
Commit: Commit,
BuiltAt: BuiltAt,
}
}

40
Backend/internal/cache/valkey.go vendored Normal file
View File

@@ -0,0 +1,40 @@
// Path: Backend/internal/cache/valkey.go
package cache
import (
"context"
"github.com/redis/go-redis/v9"
)
type Client struct {
Redis *redis.Client
}
func NewValkey(valkeyURL string) (*Client, error) {
options, err := redis.ParseURL(valkeyURL)
if err != nil {
return nil, err
}
client := redis.NewClient(options)
return &Client{Redis: client}, nil
}
func (c *Client) Health(ctx context.Context) error {
if c == nil || c.Redis == nil {
return nil
}
return c.Redis.Ping(ctx).Err()
}
func (c *Client) Close() error {
if c == nil || c.Redis == nil {
return nil
}
return c.Redis.Close()
}

View File

@@ -0,0 +1,75 @@
// Path: Backend/internal/config/config.go
package config
import (
"fmt"
"os"
"strings"
"time"
)
type Config struct {
AppName string
Environment string
LogLevel string
WebPort string
APIPort string
PostgresURL string
ValkeyURL string
ShutdownTimeout time.Duration
}
func Load() *Config {
return &Config{
AppName: getEnv("APP_NAME", "moku"),
Environment: getEnv("GO_ENV", "development"),
LogLevel: getEnv("LOG_LEVEL", "debug"),
WebPort: getEnv("BACKEND_WEB_PORT", "8080"),
APIPort: getEnv("BACKEND_API_PORT", "8081"),
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),
}
}
func (c *Config) Address(serviceName string) string {
var port string
switch strings.ToLower(serviceName) {
case "web":
port = c.WebPort
case "api":
port = c.APIPort
default:
port = c.WebPort
}
return fmt.Sprintf(":%s", port)
}
func (c *Config) IsDevelopment() bool {
return strings.EqualFold(c.Environment, "development")
}
func getEnv(key, fallback string) string {
if value, exists := os.LookupEnv(key); exists {
return value
}
return fallback
}
func getDurationEnv(key string, fallback time.Duration) time.Duration {
value, exists := os.LookupEnv(key)
if !exists {
return fallback
}
parsed, err := time.ParseDuration(value)
if err != nil {
return fallback
}
return parsed
}

View File

@@ -0,0 +1,95 @@
// Path: Backend/internal/database/postgres.go
package database
import (
"context"
"database/sql"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/jackc/pgx/v5/stdlib"
"github.com/pressly/goose/v3"
"moku-backend/db"
)
type DB struct {
Pool *pgxpool.Pool
}
func NewPostgres(databaseURL string) (*DB, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
config, err := pgxpool.ParseConfig(databaseURL)
if err != nil {
return nil, err
}
config.MaxConns = 25
config.MinConns = 5
config.MaxConnLifetime = time.Hour
config.MaxConnIdleTime = 30 * time.Minute
pool, err := pgxpool.NewWithConfig(ctx, config)
if err != nil {
return nil, err
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, err
}
return &DB{Pool: pool}, nil
}
func (d *DB) MigrateUp() error {
return d.runGoose(goose.Up)
}
func (d *DB) MigrateDown() error {
return d.runGoose(goose.Down)
}
func (d *DB) MigrateReset() error {
return d.runGoose(goose.Reset)
}
func (d *DB) MigrateStatus() error {
return d.runGoose(goose.Status)
}
func (d *DB) runGoose(command func(*sql.DB, string, ...goose.OptionsFunc) error) error {
if d == nil || d.Pool == nil {
return nil
}
sqlDB := stdlib.OpenDBFromPool(d.Pool)
defer sqlDB.Close()
goose.SetBaseFS(db.Migrations)
if err := goose.SetDialect("postgres"); err != nil {
return err
}
return command(sqlDB, "migrations")
}
func (d *DB) Health(ctx context.Context) error {
if d == nil || d.Pool == nil {
return nil
}
return d.Pool.Ping(ctx)
}
func (d *DB) Close() {
if d == nil || d.Pool == nil {
return
}
d.Pool.Close()
}

View File

@@ -0,0 +1,53 @@
// Path: Backend/internal/httpx/api_routes.go
package httpx
import (
"net/http"
"github.com/go-chi/chi/v5"
)
type apiRoutes struct {
cfg RouterConfig
}
func newAPIRoutes(cfg RouterConfig) routeRegistrar {
return apiRoutes{cfg: cfg}
}
func (routes apiRoutes) Register(router chi.Router) {
router.Route("/v1", func(apiRouter chi.Router) {
apiRouter.Get("/", routes.handleIndex)
apiRouter.Get("/organizations", routes.handleOrganizations)
apiRouter.Get("/workspaces", routes.handleWorkspaces)
})
}
func (routes apiRoutes) handleIndex(w http.ResponseWriter, _ *http.Request) {
WriteJSON(w, http.StatusOK, map[string]any{
"service": routes.cfg.ServiceName,
"version": "v1",
"status": "scaffolded",
})
}
func (routes apiRoutes) handleOrganizations(w http.ResponseWriter, _ *http.Request) {
WriteJSON(w, http.StatusOK, map[string]any{
"data": []any{},
"meta": map[string]any{
"resource": "organizations",
"count": 0,
},
})
}
func (routes apiRoutes) handleWorkspaces(w http.ResponseWriter, _ *http.Request) {
WriteJSON(w, http.StatusOK, map[string]any{
"data": []any{},
"meta": map[string]any{
"resource": "workspaces",
"count": 0,
},
})
}

View File

@@ -0,0 +1,80 @@
// Path: Backend/internal/httpx/middleware.go
package httpx
import (
"context"
"log/slog"
"net/http"
"time"
"github.com/google/uuid"
)
type contextKey string
const requestIDContextKey contextKey = "requestID"
type statusRecorder struct {
http.ResponseWriter
statusCode int
}
func (r *statusRecorder) WriteHeader(statusCode int) {
r.statusCode = statusCode
r.ResponseWriter.WriteHeader(statusCode)
}
func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestID := uuid.NewString()
ctx := context.WithValue(r.Context(), requestIDContextKey, requestID)
w.Header().Set("X-Request-ID", requestID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func Recoverer(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if recovered := recover(); recovered != nil {
requestID := RequestIDFromContext(r.Context())
logger.Error("panic recovered", "request_id", requestID, "panic", recovered, "path", r.URL.Path)
WriteError(w, http.StatusInternalServerError, requestID, "internal_error", "An unexpected error occurred.")
}
}()
next.ServeHTTP(w, r)
})
}
}
func RequestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
startedAt := time.Now()
recorder := &statusRecorder{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(recorder, r)
logger.Info(
"http request",
"request_id", RequestIDFromContext(r.Context()),
"method", r.Method,
"path", r.URL.Path,
"status", recorder.statusCode,
"duration", time.Since(startedAt).String(),
)
})
}
}
func RequestIDFromContext(ctx context.Context) string {
requestID, ok := ctx.Value(requestIDContextKey).(string)
if !ok {
return ""
}
return requestID
}

View File

@@ -0,0 +1,33 @@
// Path: Backend/internal/httpx/response.go
package httpx
import (
"encoding/json"
"net/http"
)
type ErrorResponse struct {
Error string `json:"error"`
Message string `json:"message"`
RequestID string `json:"requestId,omitempty"`
}
func WriteJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
if payload == nil {
return
}
_ = json.NewEncoder(w).Encode(payload)
}
func WriteError(w http.ResponseWriter, status int, requestID, code, message string) {
WriteJSON(w, status, ErrorResponse{
Error: code,
Message: message,
RequestID: requestID,
})
}

View File

@@ -0,0 +1,70 @@
// Path: Backend/internal/httpx/router.go
package httpx
import (
"log/slog"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
chimiddleware "github.com/go-chi/chi/v5/middleware"
"moku-backend/internal/buildinfo"
"moku-backend/internal/cache"
"moku-backend/internal/config"
"moku-backend/internal/database"
)
type RouterConfig struct {
ServiceName string
Config *config.Config
Logger *slog.Logger
BuildInfo buildinfo.Info
Database *database.DB
Cache *cache.Client
}
type routeRegistrar interface {
Register(chi.Router)
}
func NewRouter(cfg RouterConfig) http.Handler {
router := chi.NewRouter()
registerMiddleware(router, cfg)
registerRoutes(router, routesForService(cfg)...)
router.NotFound(notFoundHandler)
return router
}
func registerMiddleware(router chi.Router, cfg RouterConfig) {
router.Use(chimiddleware.RealIP)
router.Use(RequestID)
router.Use(Recoverer(cfg.Logger))
router.Use(RequestLogger(cfg.Logger))
}
func registerRoutes(router chi.Router, registrars ...routeRegistrar) {
for _, registrar := range registrars {
registrar.Register(router)
}
}
func routesForService(cfg RouterConfig) []routeRegistrar {
registrars := []routeRegistrar{newSharedRoutes(cfg)}
switch strings.ToLower(cfg.ServiceName) {
case "web":
registrars = append(registrars, newWebRoutes(cfg))
case "api":
registrars = append(registrars, newAPIRoutes(cfg))
}
return registrars
}
func notFoundHandler(w http.ResponseWriter, r *http.Request) {
WriteError(w, http.StatusNotFound, RequestIDFromContext(r.Context()), "not_found", "The requested endpoint does not exist.")
}

View File

@@ -0,0 +1,69 @@
// Path: Backend/internal/httpx/shared_routes.go
package httpx
import (
"context"
"net/http"
"time"
"github.com/go-chi/chi/v5"
)
type sharedRoutes struct {
cfg RouterConfig
}
func newSharedRoutes(cfg RouterConfig) routeRegistrar {
return sharedRoutes{cfg: cfg}
}
func (routes sharedRoutes) Register(router chi.Router) {
router.Get("/", routes.handleIndex)
router.Get("/health", routes.handleHealth)
router.Get("/version", routes.handleVersion)
}
func (routes sharedRoutes) handleIndex(w http.ResponseWriter, _ *http.Request) {
WriteJSON(w, http.StatusOK, map[string]string{
"service": routes.cfg.ServiceName,
"status": "ok",
})
}
func (routes sharedRoutes) handleHealth(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
databaseStatus := "ok"
if err := routes.cfg.Database.Health(ctx); err != nil {
databaseStatus = err.Error()
}
cacheStatus := "ok"
if err := routes.cfg.Cache.Health(ctx); err != nil {
cacheStatus = err.Error()
}
statusCode := http.StatusOK
if databaseStatus != "ok" || cacheStatus != "ok" {
statusCode = http.StatusServiceUnavailable
}
WriteJSON(w, statusCode, map[string]any{
"service": routes.cfg.ServiceName,
"status": map[string]string{
"database": databaseStatus,
"cache": cacheStatus,
},
})
}
func (routes sharedRoutes) handleVersion(w http.ResponseWriter, _ *http.Request) {
WriteJSON(w, http.StatusOK, map[string]any{
"service": routes.cfg.ServiceName,
"app": routes.cfg.Config.AppName,
"environment": routes.cfg.Config.Environment,
"build": routes.cfg.BuildInfo,
})
}

View File

@@ -0,0 +1,51 @@
// Path: Backend/internal/httpx/web_routes.go
package httpx
import (
"net/http"
"github.com/go-chi/chi/v5"
)
type webRoutes struct {
cfg RouterConfig
}
func newWebRoutes(cfg RouterConfig) routeRegistrar {
return webRoutes{cfg: cfg}
}
func (routes webRoutes) Register(router chi.Router) {
router.Get("/session", routes.handleSession)
router.Get("/bootstrap", routes.handleBootstrap)
router.Get("/me", routes.handleCurrentUser)
}
func (routes webRoutes) handleSession(w http.ResponseWriter, _ *http.Request) {
WriteJSON(w, http.StatusOK, map[string]any{
"service": routes.cfg.ServiceName,
"session": map[string]any{
"authenticated": false,
"mode": "cookie",
},
})
}
func (routes webRoutes) handleBootstrap(w http.ResponseWriter, _ *http.Request) {
WriteJSON(w, http.StatusOK, map[string]any{
"service": routes.cfg.ServiceName,
"app": map[string]any{
"name": routes.cfg.Config.AppName,
"environment": routes.cfg.Config.Environment,
},
"features": map[string]bool{
"auth": false,
"workspaces": false,
},
})
}
func (routes webRoutes) handleCurrentUser(w http.ResponseWriter, r *http.Request) {
WriteError(w, http.StatusNotImplemented, RequestIDFromContext(r.Context()), "not_implemented", "The current user endpoint is scaffolded but not implemented yet.")
}

View File

@@ -0,0 +1,65 @@
// Path: Backend/internal/process/process.go
package process
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func RunHTTPServer(serviceName, address string, handler http.Handler, logger *slog.Logger, shutdownTimeout time.Duration) error {
server := &http.Server{
Addr: address,
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
}
serverErr := make(chan error, 1)
go func() {
logger.Info("http server starting", "service", serviceName, "address", address)
serverErr <- server.ListenAndServe()
}()
signalCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
select {
case err := <-serverErr:
if err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
case <-signalCtx.Done():
logger.Info("shutdown requested", "service", serviceName)
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("shutdown %s server: %w", serviceName, err)
}
logger.Info("http server stopped", "service", serviceName)
return nil
}
func WaitForShutdown(serviceName string, logger *slog.Logger) error {
signalCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
logger.Info("process running", "service", serviceName)
<-signalCtx.Done()
logger.Info("shutdown requested", "service", serviceName)
return nil
}

12
Backend/sqlc.yaml Normal file
View File

@@ -0,0 +1,12 @@
version: "2"
sql:
- engine: "postgresql"
schema: "db/migrations"
queries: "db/queries"
gen:
go:
package: "sqlc"
out: "internal/sqlc"
sql_package: "pgx/v5"
emit_json_tags: true
emit_empty_slices: true

View File

@@ -0,0 +1,26 @@
project_root := justfile_directory()
backend_dir := project_root + "/Backend"
# Apply embedded database migrations.
migrate-up:
cd '{{backend_dir}}' && go run ./cmd/migrate up
# Roll back the most recent embedded database migration.
migrate-down:
cd '{{backend_dir}}' && go run ./cmd/migrate down
# Reset all embedded database migrations and reapply from scratch.
migrate-reset:
cd '{{backend_dir}}' && go run ./cmd/migrate reset
# Show the embedded database migration status.
migrate-status:
cd '{{backend_dir}}' && go run ./cmd/migrate status
# Format backend Go source files.
fmt:
cd '{{backend_dir}}' && gofmt -w ./cmd ./db ./internal
# Run backend test suite.
test:
cd '{{backend_dir}}' && go test ./...

View File

@@ -0,0 +1,14 @@
project_root := justfile_directory()
local_compose := project_root + "/Docker/docker-compose.local.dev.yaml"
frontend_dir := project_root + "/Frontend"
node_modules_volume := "moku_work_frontend_node_modules"
# Recreate the frontend node_modules Docker volume.
node_modules:
docker compose -f '{{local_compose}}' rm -sf frontend >/dev/null 2>&1 || true
docker volume rm -f '{{node_modules_volume}}' >/dev/null 2>&1 || true
docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate frontend
# Run the frontend TypeScript check.
tsc:
cd '{{frontend_dir}}' && pnpm typecheck

View File

@@ -0,0 +1,39 @@
stack_runner := justfile_directory() + "/Commands/Local/Dev/scripts/dev-stack.sh"
mod frontend
mod backend
# Build the combined local development stack assets.
build:
bash '{{stack_runner}}' build
# Start the full local development stack in the background.
up:
bash '{{stack_runner}}' up
# Build first, then start the full local development stack in the background.
start:
bash '{{stack_runner}}' start
# Alias for the main full local development flow.
dev: up
# Stop and remove the local development stack.
down:
bash '{{stack_runner}}' down
# Rebuild the full local development stack.
rebuild:
bash '{{stack_runner}}' rebuild
# Follow logs for the full local development stack.
logs:
bash '{{stack_runner}}' logs
# Restart the full local development stack.
restart:
bash '{{stack_runner}}' restart
# Stop the local development stack and remove local images, volumes, and backend dev state.
clean:
bash '{{stack_runner}}' clean

View File

@@ -0,0 +1,91 @@
#!/usr/bin/env bash
# Path: Commands/Local/Dev/scripts/backend-stack.sh
set -euo pipefail
action=${1:-up}
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
project_root=$(cd -- "$script_dir/../../../.." && pwd)
backend_dir="$project_root/Backend"
backend_bake="$backend_dir/docker-bake.hcl"
env_dir="$project_root/Env"
compose_file="$project_root/Docker/docker-compose.local.dev.yaml"
runtime_dir="$backend_dir/tmp/dev"
backend_image="moku/work-backend:dev"
backend_go_pkg_volume="moku_work_backend_go_pkg"
backend_go_build_volume="moku_work_backend_go_build"
services=(web api worker)
source "$script_dir/docker.sh"
source "$script_dir/env.sh"
build_backend() {
cd "$backend_dir"
docker buildx bake -f "$backend_bake" dev
}
up_backend() {
docker compose -f "$compose_file" up -d --remove-orphans --force-recreate "${services[@]}"
}
down_backend() {
docker compose -f "$compose_file" stop "${services[@]}" >/dev/null 2>&1 || true
docker compose -f "$compose_file" rm -f "${services[@]}" >/dev/null 2>&1 || true
}
restart_backend() {
docker compose -f "$compose_file" restart "${services[@]}"
}
follow_logs() {
docker compose -f "$compose_file" logs -f "${services[@]}"
}
clean_runtime() {
rm -rf "$runtime_dir"
}
case "$action" in
check)
ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.'
;;
build)
ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.'
build_backend
;;
up)
ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.'
ensure_local_env_file "$env_dir"
up_backend
;;
down)
ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.'
ensure_local_env_file "$env_dir"
down_backend
;;
restart)
ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.'
ensure_local_env_file "$env_dir"
restart_backend
;;
logs)
ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.'
ensure_local_env_file "$env_dir"
follow_logs
;;
clean)
ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.'
ensure_local_env_file "$env_dir"
down_backend
remove_docker_image_if_present "$backend_image"
remove_docker_volume_if_present "$backend_go_pkg_volume"
remove_docker_volume_if_present "$backend_go_build_volume"
clean_runtime
;;
*)
printf 'Unsupported backend stack action: %s\n' "$action" >&2
exit 1
;;
esac

View File

@@ -0,0 +1,102 @@
#!/usr/bin/env bash
# Path: Commands/Local/Dev/scripts/dev-stack.sh
set -euo pipefail
action=${1:-up}
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
project_root=$(cd -- "$script_dir/../../../.." && pwd)
frontend_dir="$project_root/Frontend"
frontend_bake="$frontend_dir/docker-bake.hcl"
backend_dir="$project_root/Backend"
backend_bake="$backend_dir/docker-bake.hcl"
env_dir="$project_root/Env"
compose_file="$project_root/Docker/docker-compose.local.dev.yaml"
frontend_image="moku/work-frontend:dev"
backend_image="moku/work-backend:dev"
frontend_volume="moku_work_frontend_node_modules"
backend_go_pkg_volume="moku_work_backend_go_pkg"
backend_go_build_volume="moku_work_backend_go_build"
backend_runtime_dir="$backend_dir/tmp/dev"
source "$script_dir/docker.sh"
source "$script_dir/env.sh"
build_frontend() {
cd "$frontend_dir"
docker buildx bake -f "$frontend_bake" dev
}
build_backend() {
cd "$backend_dir"
docker buildx bake -f "$backend_bake" dev
}
build_images() {
build_frontend
build_backend
}
up_stack() {
docker compose -f "$compose_file" up -d --remove-orphans --force-recreate
}
down_stack() {
docker compose -f "$compose_file" down --remove-orphans --volumes
}
follow_logs() {
docker compose -f "$compose_file" logs -f
}
clean_stack() {
docker compose -f "$compose_file" down --remove-orphans --volumes >/dev/null 2>&1 || true
remove_docker_image_if_present "$frontend_image"
remove_docker_image_if_present "$backend_image"
remove_docker_volume_if_present "$frontend_volume"
remove_docker_volume_if_present "$backend_go_pkg_volume"
remove_docker_volume_if_present "$backend_go_build_volume"
rm -rf "$backend_runtime_dir"
}
start_stack() {
build_images
up_stack
}
case "$action" in
build)
ensure_docker 'docker is required for the local development stack. Install Docker first.'
build_images
;;
up|start|rebuild)
ensure_docker 'docker is required for the local development stack. Install Docker first.'
ensure_local_env_file "$env_dir"
start_stack
;;
down)
ensure_docker 'docker is required for the local development stack. Install Docker first.'
ensure_local_env_file "$env_dir"
down_stack
;;
restart)
ensure_docker 'docker is required for the local development stack. Install Docker first.'
ensure_local_env_file "$env_dir"
docker compose -f "$compose_file" restart
;;
logs)
ensure_docker 'docker is required for the local development stack. Install Docker first.'
ensure_local_env_file "$env_dir"
follow_logs
;;
clean)
ensure_docker 'docker is required for the local development stack. Install Docker first.'
ensure_local_env_file "$env_dir"
clean_stack
;;
*)
printf 'Unsupported dev stack action: %s\n' "$action" >&2
exit 1
;;
esac

View File

@@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -euo pipefail
ensure_docker() {
local error_message=${1:-docker is required. Install Docker first.}
if ! command -v docker >/dev/null 2>&1; then
printf '%s\n' "$error_message" >&2
exit 1
fi
}
remove_docker_image_if_present() {
docker image rm -f "$1" >/dev/null 2>&1 || true
}
remove_docker_volume_if_present() {
docker volume rm -f "$1" >/dev/null 2>&1 || true
}

View File

@@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -euo pipefail
ensure_local_env_file() {
local env_dir=$1
local example_env_file="$env_dir/.env.example"
local local_env_file="$env_dir/.env.local"
if [[ -f "$local_env_file" ]]; then
return
fi
if [[ ! -f "$example_env_file" ]]; then
printf 'Missing env template: %s\n' "$example_env_file" >&2
exit 1
fi
cp "$example_env_file" "$local_env_file"
printf 'Created %s from %s\n' "$local_env_file" "$example_env_file"
}

View File

@@ -1,36 +0,0 @@
project_root := justfile_directory()
frontend_dir := project_root + "/Frontend"
frontend_bake := project_root + "/Frontend/docker-bake.hcl"
local_compose := project_root + "/Docker/docker-compose.local.dev.yaml"
# Build the Frontend development image.
build:
cd '{{frontend_dir}}' && docker buildx bake -f '{{frontend_bake}}' dev
# Start the local development stack in the background using the current image.
up:
docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate
# Build first, then start the local development stack in the background.
start: build
docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate
# Alias for the main local development flow.
dev: start
# Stop and remove the local development stack.
down:
docker compose -f '{{local_compose}}' down --remove-orphans --volumes
# Rebuild the Frontend development image and recreate the stack.
rebuild:
cd '{{frontend_dir}}' && docker buildx bake -f '{{frontend_bake}}' dev
docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate
# Follow logs for the local development stack.
logs:
docker compose -f '{{local_compose}}' logs -f
# Restart the local development stack.
restart:
docker compose -f '{{local_compose}}' restart

View File

@@ -1,2 +1,2 @@
mod dev
mod dev "Dev"
mod prod

View File

@@ -1,11 +1,37 @@
project_root := justfile_directory()
frontend_dir := project_root + "/Frontend"
frontend_bake := project_root + "/Frontend/docker-bake.hcl"
proxy_bake := project_root + "/Proxy/docker-bake.hcl"
local_compose := project_root + "/Docker/docker-compose.local.prod.yaml"
proxy_image := "moku/work-proxy:local-prod"
# Build the Frontend production image locally.
# Build the local production proxy image locally.
build:
cd '{{frontend_dir}}' && docker buildx bake -f '{{frontend_bake}}' prod
cd '{{project_root}}' && docker buildx bake -f '{{proxy_bake}}' prod
# Rebuild the Frontend production image locally.
# Start the local production stack in the background using the current image.
up:
docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate
# Build first, then start the local production stack in the background.
start: build up
# Rebuild the local production proxy image locally.
rebuild:
cd '{{frontend_dir}}' && docker buildx bake -f '{{frontend_bake}}' --set '*.no-cache=true' prod
cd '{{project_root}}' && docker buildx bake -f '{{proxy_bake}}' --set '*.no-cache=true' prod
docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate
# Stop and remove the local production stack.
down:
docker compose -f '{{local_compose}}' down --remove-orphans --volumes
# Follow logs for the local production stack.
logs:
docker compose -f '{{local_compose}}' logs -f
# Restart the local production stack.
restart:
docker compose -f '{{local_compose}}' restart
# Stop the local production stack and remove local images.
clean:
docker compose -f '{{local_compose}}' down --remove-orphans --volumes
docker image rm -f '{{proxy_image}}' >/dev/null 2>&1 || true

View File

View File

@@ -1,15 +1,95 @@
x-backend-service: &backend-service
image: moku/work-backend:dev
restart: unless-stopped
env_file:
- ../Env/.env.local
environment:
DATABASE_URL: postgres://moku:moku_dev_password@postgres:5432/moku?sslmode=disable
VALKEY_URL: redis://valkey:6379/0
depends_on:
postgres:
condition: service_healthy
valkey:
condition: service_healthy
volumes:
- ../Backend:/app
- moku_work_backend_go_pkg:/go/pkg/mod
- moku_work_backend_go_build:/root/.cache/go-build
services:
frontend:
image: moku/work-frontend:dev
container_name: moku-work-frontend
restart: unless-stopped
env_file:
- ../Env/.env.local
ports:
- "3333:3333"
volumes:
- ../Frontend:/app
- moku_work_frontend_node_modules:/app/node_modules
postgres:
image: postgres:17-alpine
container_name: moku-work-postgres
restart: unless-stopped
environment:
POSTGRES_DB: moku
POSTGRES_USER: moku
POSTGRES_PASSWORD: moku_dev_password
ports:
- "5432:5432"
volumes:
- moku_work_postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U moku -d moku"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
valkey:
image: valkey/valkey:8-alpine
container_name: moku-work-valkey
restart: unless-stopped
ports:
- "6379:6379"
volumes:
- moku_work_valkey_data:/data
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
start_period: 5s
frontend:
image: moku/work-frontend:dev
container_name: moku-work-frontend
restart: unless-stopped
env_file:
- ../Env/.env.local
depends_on:
postgres:
condition: service_healthy
valkey:
condition: service_healthy
ports:
- "3333:3333"
volumes:
- ../Frontend:/app
- moku_work_frontend_node_modules:/app/node_modules
web:
<<: *backend-service
container_name: moku-work-backend-web
command: ["air", "-c", ".air.web.toml"]
ports:
- "8080:8080"
api:
<<: *backend-service
container_name: moku-work-backend-api
command: ["air", "-c", ".air.api.toml"]
ports:
- "8081:8081"
worker:
<<: *backend-service
container_name: moku-work-backend-worker
command: ["air", "-c", ".air.worker.toml"]
volumes:
moku_work_frontend_node_modules:
moku_work_postgres_data:
moku_work_valkey_data:
moku_work_frontend_node_modules:
moku_work_backend_go_pkg:
moku_work_backend_go_build:

View File

@@ -1 +1,7 @@
# Reserved for a future local production compose stack.
services:
proxy:
image: moku/work-proxy:local-prod
container_name: moku-work-proxy-local
restart: unless-stopped
ports:
- "8080:80"

171
Documentation/CONTRIBUTING Normal file
View File

@@ -0,0 +1,171 @@
# Contributing
Thanks for contributing to Moku Work.
This project is still in an early scaffold stage, so the goal is to keep changes easy to understand, easy to review, and easy to undo.
## Getting Started
### Project structure
- `Frontend/` — SolidStart frontend workspace
- `Backend/` — Go backend services (`web`, `api`, `worker`)
- `Proxy/` — local production proxy/runtime assets
- `Docker/` — local Docker Compose files
- `Env/` — local environment files
- `Commands/` — Just command modules and entrypoints
### Prerequisites
Before working on the project, make sure you have:
- `just`
- Docker with `docker compose`
- Docker Buildx
- Node.js `>=22`
- `pnpm@9`
### Local development
List available commands:
```bash
just --list --list-submodules
```
Main local development flow:
```bash
just local dev
```
This command builds the frontend and backend development images, then starts the
local development stack for Postgres, Valkey, frontend, and backend services.
### Local environment
Local development uses:
```bash
Env/.env.local
```
The template lives at:
```bash
Env/.env.example
```
When you start the local Docker stack, the dev scripts will create `Env/.env.local`
from `Env/.env.example` automatically if it does not already exist.
If you need custom local values, edit `Env/.env.local` after it is created.
## Commit Naming Convention
Use short, clear commit messages that describe one logical change.
Preferred format:
```text
Type: Short description
```
Examples:
```text
Feat: Add login page scaffold
Fix: Resolve mobile sidebar overflow
Refactor: Split theme helpers from app entry
Docs: Add local development notes
Chore: Update frontend dependencies
```
Recommended commit types:
- `Feat:` — new feature or visible behavior
- `Fix:` — bug fix
- `Refactor:` — internal code change without behavior change
- `Docs:` — documentation only
- `Chore:` — maintenance, tooling, dependency, or housekeeping work
- `Style:` — formatting or styling-only cleanup
- `Test:` — tests added or updated
### Commit message rules
- Keep the subject line short and specific
- Start with a capitalized type
- Describe what changed, not the entire session
- One commit should usually be explainable in one sentence
Good:
```text
Feat: Add base dashboard layout
Fix: Correct broken theme toggle import
Docs: Document branch naming workflow
```
Bad:
```text
stuff
more updates
big rewrite
misc fixes
```
## Branch Naming Convention
Use branch names that make the purpose of the work obvious at a glance.
Preferred format:
```text
type/short-description
```
Examples:
```text
feature/login-page
fix/mobile-sidebar-overflow
refactor/theme-helpers
docs/contributing-update
chore/frontend-dependency-updates
rewrite/v2-foundation
```
Recommended branch types:
- `feature/` — new feature work
- `fix/` — bug fixes
- `refactor/` — structural code changes without intended behavior changes
- `docs/` — documentation work
- `chore/` — maintenance or tooling updates
- `rewrite/` — large rebuilds or architecture overhauls
### Branch naming rules
- Use lowercase words
- Separate words with hyphens
- Keep the name short but specific
- Name the branch after the outcome, not the time spent on it
- For larger efforts, prefer one clear branch name over vague labels like `misc` or `updates`
Good:
```text
feature/auth-shell
fix/docker-local-env-loading
rewrite/new-routing-foundation
```
Bad:
```text
new-stuff
work-branch
test
misc-updates
```

View File

@@ -0,0 +1,177 @@
{
"schemaVersion": "1.0.0",
"id": "moku-styling-sample",
"name": "Moku Styling Sample",
"description": "Sample theme showing the background, surface, border, accent, and primary tokens documented in Documentation/STYLING.md.",
"author": "Moku Work",
"tokens": {
"shared": {
"palette": {
"gray": {
"0": "hsl(210 20% 99%)",
"50": "hsl(220 20% 97%)",
"100": "hsl(220 16% 93%)",
"200": "hsl(220 13% 87%)",
"300": "hsl(220 11% 75%)",
"400": "hsl(220 9% 58%)",
"500": "hsl(220 10% 45%)",
"600": "hsl(220 14% 34%)",
"700": "hsl(220 18% 24%)",
"800": "hsl(220 22% 16%)",
"900": "hsl(220 28% 10%)"
},
"blue": {
"400": "hsl(218 88% 61%)",
"500": "hsl(221 83% 53%)",
"600": "hsl(224 76% 48%)"
},
"green": {
"500": "hsl(154 60% 40%)"
},
"red": {
"500": "hsl(0 72% 54%)"
},
"amber": {
"500": "hsl(36 100% 50%)"
}
},
"space": {
"1": "0.25rem",
"2": "0.5rem",
"3": "0.75rem",
"4": "1rem",
"5": "1.25rem",
"6": "1.5rem",
"8": "2rem",
"10": "2.5rem",
"12": "3rem"
},
"radius": {
"sm": "0.375rem",
"md": "0.625rem",
"lg": "0.875rem",
"xl": "1.25rem",
"pill": "999px"
},
"size": {
"controlMd": "2.25rem",
"controlLg": "2.5rem",
"contentWidthWide": "72rem",
"blurOverlay": "18px"
},
"shadow": {
"soft": "0 12px 32px hsl(220 30% 10% / 0.08)",
"strong": "0 20px 48px hsl(220 30% 10% / 0.16)"
},
"zIndex": {
"base": "1",
"dropdown": "100",
"sticky": "200",
"overlay": "400",
"modal": "500",
"toast": "600"
},
"motion": {
"durationFast": "140ms",
"durationBase": "220ms",
"durationSlow": "320ms",
"easeStandard": "cubic-bezier(0.2, 0.8, 0.2, 1)"
},
"typography": {
"fontFamily": {
"sans": "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Helvetica Neue\", Arial, sans-serif",
"heading": "\"Avenir Next\", \"Segoe UI\", \"Helvetica Neue\", Arial, sans-serif",
"display": "\"Avenir Next\", \"Segoe UI\", \"Helvetica Neue\", Arial, sans-serif",
"serif": "ui-serif, Georgia, Cambria, \"Times New Roman\", Times, serif",
"mono": "ui-monospace, \"SF Mono\", \"SFMono-Regular\", Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace"
},
"fontSize": {
"caption": "0.75rem",
"label": "0.875rem",
"body": "1rem",
"title": "clamp(1.125rem, 1.05rem + 0.3vw, 1.25rem)",
"heading": "clamp(1.5rem, 1.2rem + 1vw, 2.125rem)",
"display": "clamp(2.25rem, 1.7rem + 2.2vw, 3.75rem)"
},
"lineHeight": {
"caption": "1.4",
"label": "1.35",
"body": "1.55",
"title": "1.3",
"heading": "1.15",
"display": "1.05"
},
"fontWeight": {
"caption": "500",
"label": "600",
"body": "400",
"title": "600",
"heading": "600",
"display": "700"
},
"letterSpacing": {
"caption": "0.01em",
"label": "0.005em",
"body": "0",
"title": "-0.01em",
"heading": "-0.02em",
"display": "-0.03em"
}
}
},
"modes": {
"light": {
"colorScheme": "light",
"colors": {
"canvas": "var(--gray-50)",
"surface": "hsl(0 0% 100% / 0.9)",
"surfaceMuted": "var(--gray-0)",
"surfaceHover": "var(--gray-100)",
"border": "hsl(220 15% 85% / 0.9)",
"borderStrong": "hsl(220 12% 70% / 0.9)",
"text": "var(--gray-800)",
"textMuted": "var(--gray-500)",
"accent": "var(--blue-500)",
"accentStrong": "var(--blue-600)",
"accentSoft": "hsl(218 88% 61% / 0.12)",
"accentContrast": "hsl(0 0% 100%)",
"primaryOne": "var(--blue-500)",
"primaryTwo": "hsl(271 72% 60%)",
"primaryThree": "hsl(192 76% 48%)",
"success": "var(--green-500)",
"danger": "var(--red-500)",
"warning": "var(--amber-500)",
"focusRing": "hsl(221 83% 53% / 0.55)"
}
},
"dark": {
"colorScheme": "dark",
"colors": {
"canvas": "var(--gray-900)",
"surface": "hsl(220 23% 14% / 0.92)",
"surfaceMuted": "hsl(220 22% 12% / 0.96)",
"surfaceHover": "hsl(220 18% 20% / 0.96)",
"border": "hsl(220 12% 26% / 0.9)",
"borderStrong": "hsl(220 12% 38% / 0.9)",
"text": "hsl(210 20% 96%)",
"textMuted": "hsl(220 12% 70%)",
"accent": "hsl(217 91% 67%)",
"accentStrong": "hsl(218 88% 61%)",
"accentSoft": "hsl(217 91% 67% / 0.18)",
"accentContrast": "hsl(220 28% 10%)",
"primaryOne": "hsl(217 91% 67%)",
"primaryTwo": "hsl(272 80% 70%)",
"primaryThree": "hsl(190 84% 62%)",
"success": "hsl(154 55% 48%)",
"danger": "hsl(0 72% 62%)",
"warning": "hsl(36 100% 60%)",
"focusRing": "hsl(217 91% 67% / 0.65)"
},
"shadow": {
"soft": "0 16px 40px hsl(220 40% 3% / 0.45)",
"strong": "0 24px 60px hsl(220 40% 3% / 0.55)"
}
}
}
}
}

327
Documentation/STYLING.md Normal file
View File

@@ -0,0 +1,327 @@
# Styling Reference
This document explains which theme tokens control the main backgrounds, surfaces,
borders, and shell gradients in Moku Work.
It is focused on the current frontend shell scaffold so future visual tuning can
be done intentionally instead of by guesswork.
## Source Of Truth
There are two places to look when changing styling tokens:
- Runtime theme payload:
- `Frontend/public/themes/moku-default.json`
- SCSS fallback defaults:
- `Frontend/src/styles/themes/_light.scss`
- `Frontend/src/styles/themes/_dark.scss`
- Full sample theme file:
- `Documentation/STYLING-THEME-SAMPLE.json`
If you want to change the actual themed values used by the app, update the theme
JSON first. The SCSS files act as fallback/default variables.
---
## Core Surface Tokens
These are the main tokens currently driving shell backgrounds and card surfaces:
- `--color-canvas`
- outer page background
- `--color-surface`
- standard panel/card/topbar surface
- `--color-surface-muted`
- quieter secondary panels and dropdown surfaces
- `--color-surface-hover`
- hover state for solid surface rows
- `--color-border`
- standard card/panel border
- `--color-border-strong`
- stronger shell edges, separators, and emphasized borders
- `--color-text`
- primary text color
- `--color-text-muted`
- secondary/meta text color
- `--color-accent`
- primary accent lane
- `--color-accent-strong`
- stronger accent emphasis
- `--color-accent-soft`
- soft accent wash for subtle fills
There are also ring-only multi-primary tokens currently used for the top-right
profile ring:
- `--color-primary-1`
- `--color-primary-2`
- `--color-primary-3`
---
## Light Mode Values
Current light-mode surface values:
```text
--color-canvas: var(--gray-50)
--color-surface: hsl(0 0% 100% / 0.9)
--color-surface-muted: var(--gray-0)
--color-surface-hover: var(--gray-100)
--color-border: hsl(220 15% 85% / 0.9)
--color-border-strong: hsl(220 12% 70% / 0.9)
--color-text: var(--gray-800)
--color-text-muted: var(--gray-500)
--color-accent: var(--blue-500)
--color-accent-strong: var(--blue-600)
--color-accent-soft: hsl(218 88% 61% / 0.12)
--color-primary-1: var(--blue-500)
--color-primary-2: hsl(271 72% 60%)
--color-primary-3: hsl(192 76% 48%)
```
## Dark Mode Values
Current dark-mode surface values:
```text
--color-canvas: var(--gray-900)
--color-surface: hsl(220 23% 14% / 0.92)
--color-surface-muted: hsl(220 22% 12% / 0.96)
--color-surface-hover: hsl(220 18% 20% / 0.96)
--color-border: hsl(220 12% 26% / 0.9)
--color-border-strong: hsl(220 12% 38% / 0.9)
--color-text: hsl(210 20% 96%)
--color-text-muted: hsl(220 12% 70%)
--color-accent: hsl(217 91% 67%)
--color-accent-strong: hsl(218 88% 61%)
--color-accent-soft: hsl(217 91% 67% / 0.18)
--color-primary-1: hsl(217 91% 67%)
--color-primary-2: hsl(272 80% 70%)
--color-primary-3: hsl(190 84% 62%)
```
---
## What Controls What
### 1. App Background
File:
- `Frontend/src/components/shell/AppShell/AppShell.module.scss`
The outer app frame uses:
```scss
background: var(--color-canvas);
color: var(--color-text);
```
So if you want to change the overall page backdrop, change `--color-canvas`.
### 2. Main Shell Split Background
File:
- `Frontend/src/components/shell/AppShell/AppShell.module.scss`
The shell derives two important internal surface blends:
```text
--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))
```
It also derives frame/separator borders:
```text
--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)
```
Surface usage inside the shell:
- `.body``var(--color-surface)`
- `.railColumn``var(--color-surface)`
- `.sidebarColumn``var(--sidebar-panel-surface)`
- `.workspaceMain``var(--workspace-panel-surface)`
The major left/right shell background is drawn by `workspaceRegion::before` using a
horizontal gradient from sidebar surface to workspace surface.
### 3. Standard Content Cards
File:
- `Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.module.scss`
Cards currently use:
```text
background: var(--color-surface)
border: 1px solid var(--color-border)
box-shadow: var(--shadow-soft)
```
If cards feel too flat or too strong, start by adjusting:
- `--color-surface`
- `--color-border`
### 4. Top Bar
File:
- `Frontend/src/components/shell/TopBar/TopBar.module.scss`
The top bar itself uses:
```text
background: var(--color-surface)
```
Hover/focus states for top-right controls use transparent mixes based on:
- `--color-text`
- `--color-accent-strong`
So the bar is mostly controlled by `--color-surface`, while the interactive polish
comes from text/accent tokens.
### 5. Server Dock
File:
- `Frontend/src/components/shell/ServerDock/ServerDock.module.scss`
The dock uses two derived tokens:
```text
--server-dock-border: color-mix(in srgb, var(--color-border-strong) 75%, transparent)
--server-dock-surface: color-mix(in srgb, var(--color-surface) 94%, transparent)
```
That means the dock is visually tied most strongly to:
- `--color-surface`
- `--color-border-strong`
The server glyph fill uses a soft accent wash derived from:
- `--color-accent-soft`
### 6. Project Drawer
File:
- `Frontend/src/components/shell/ProjectSelector/ProjectSelector.module.scss`
Important layers:
- scrim:
- `color-mix(in srgb, black 8%, transparent)`
- drawer panel surface:
- defined in `.drawer::before`
- vertical gradient from `--color-surface` to `--color-surface-muted`
- current-project summary block:
- `color-mix(in srgb, var(--color-surface) 72%, transparent)`
- menu row hover:
- based on `--color-surface-hover`
- menu row active:
- based on `--color-surface`
So the drawers look is mainly shaped by:
- `--color-surface`
- `--color-surface-muted`
- `--color-surface-hover`
- `--color-border-strong`
### 7. Department Selector Dropdown
File:
- `Frontend/src/components/shell/DepartmentSelector/DepartmentSelector.module.scss`
The department dropdown is intentionally solid, not blurred.
It uses:
```text
.menu background: var(--color-surface-muted)
.menu border: 1px solid var(--color-border-strong)
.menuItem background: var(--color-surface-muted)
.submenuItem background: var(--color-surface-muted)
hover/active rows: var(--color-surface)
```
If the department menu feels too heavy or too subtle, start by adjusting:
- `--color-surface-muted`
- `--color-surface`
- `--color-border-strong`
---
## Quick Tuning Guide
If you want to change the overall visual mood quickly, these are the highest-leverage tokens:
### Make the app feel lighter / airier
Adjust:
- `--color-canvas`
- `--color-surface`
- `--color-surface-muted`
### Make shells/cards feel more separated
Adjust:
- `--color-border`
- `--color-border-strong`
- `--color-surface-muted`
### Make accent washes more or less noticeable
Adjust:
- `--color-accent-soft`
### Change the visual personality of the profile ring
Adjust:
- `--color-primary-1`
- `--color-primary-2`
- `--color-primary-3`
---
## Practical Rule Of Thumb
Use this mental model:
```text
canvas = app/page background
surface = primary panel or card
surface-muted = quieter secondary panel
surface-hover = solid hover state
border = normal edge
border-strong = stronger shell edge or divider
accent-soft = subtle tinted wash
primary-1/2/3 = decorative multi-color accents
```
If you are unsure where to start, tune these in this order:
1. `--color-canvas`
2. `--color-surface`
3. `--color-surface-muted`
4. `--color-border`
5. `--color-border-strong`
6. `--color-accent-soft`
That usually gives the biggest visual shift with the fewest unintended side effects.

90
Documentation/TODO.md Normal file
View File

@@ -0,0 +1,90 @@
# ToDo
## Version 1.0.0 Roadmap
### Version 0.1.0
**Goal:** Barebone frontend with a real backend core.
#### Architecture
- [ ] Project-Structure
- [ ] Stack-Decisions
- [ ] Proxy
- [ ] Local-Prod-NGINX-Proxy
- [ ] Static-Frontend-Serving
- [ ] First-Request-Web-Loader
- [ ] Bootstrap-Document
- [ ] Route-Intent-Handoff
- [ ] Tiny-First-Paint-Budget
- [ ] Dev-and-Prod-Builds
- [x] Local-Dev-Just-Commands
- [x] Local-Dev-Docker-Compose
- [ ] Local-Prod-Just-Commands
- [ ] Local-Prod-Docker-Compose
- [ ] Frontend-Production-Dockerfile
- [ ] Frontend-docker-bake
#### Backend
- [ ] Auth
- [ ] Session-Flow
- [ ] Login-Logout-Foundation
- [ ] Authentication
- [ ] User
- [ ] Base-Model
- [ ] Base-Workspace
- [ ] Folders-and-Subfolders
- [ ] Boards
- [ ] Dashboard
- [ ] Organization
- [ ] Base-Model
- [ ] Access-Rules-and-Membership
- [ ] Workspace
- [ ] Folders-and-Subfolders
- [ ] API
#### Frontend
- [x] Foundation
- [x] Typography
- [x] Icons
- [ ] App Shell
- [ ] Primitives
- [ ] Button
- [ ] IconButton
- [ ] Input
- [ ] Surface
- [ ] Nav-Bar
- [ ] Workspace-Switching
- [ ] Workspace-Home
### Version 0.2.0
**Goal:** First real work surface.
- [ ] Table
- [ ] CVA
- [ ] Storyboard
- [ ] Theme-System
- [ ] Theme-Registry
- [ ] Built-In-Theme-Presets
- [ ] Active-Theme-Persistence
- [ ] Theme-Switcher
- [ ] Theme-JSON-Upload
- [ ] Theme-JSON-Import-Validation
- [ ] Community-Theme-Readiness
### Version 0.3.0
**Goal:** Documents and system hardening.
- [ ] Document
- [ ] Accessibility-Rules
- [ ] Motion-Foundation
### Version 0.4.0
- [ ] Gantt-Board
- [ ] Calendar
- [ ] Timeline

12
Env/.env.example Normal file
View File

@@ -0,0 +1,12 @@
ALLOWED_HOSTS=
APP_NAME=moku
GO_ENV=development
LOG_LEVEL=debug
BACKEND_WEB_PORT=8080
BACKEND_API_PORT=8081
BACKEND_SHUTDOWN_TIMEOUT=10s
DATABASE_URL=postgres://moku:moku_dev_password@localhost:5432/moku?sslmode=disable
VALKEY_URL=redis://localhost:6379/0

View File

@@ -1,5 +1,8 @@
# syntax=docker/dockerfile:1.7
# Frontend development image only.
# Production static serving is owned by Proxy/Local/Dockerfile.
FROM node:22-alpine AS base
WORKDIR /app
@@ -16,27 +19,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

@@ -17,30 +17,18 @@ target "dev" {
tags = ["moku/work-frontend:dev"]
}
target "prod" {
inherits = ["_app"]
target = "production"
tags = ["moku/work-frontend:prod"]
}
target "dev-image" {
inherits = ["_app"]
target = "development"
tags = ["${REGISTRY}/moku/work-frontend:dev-${TAG}"]
}
target "prod-image" {
inherits = ["_app"]
target = "production"
tags = ["${REGISTRY}/moku/work-frontend:prod-${TAG}"]
}
group "local" {
targets = ["dev", "prod"]
targets = ["dev"]
}
group "registry" {
targets = ["dev-image", "prod-image"]
targets = ["dev-image"]
}
group "default" {

View File

@@ -11,19 +11,22 @@
"scripts": {
"dev": "vite dev",
"build": "vite build",
"start": "vite start",
"build:static": "pnpm build && node ./scripts/render-static-index.mjs",
"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

@@ -0,0 +1,176 @@
{
"schemaVersion": "1.0.0",
"id": "moku-default",
"name": "Moku Default",
"description": "Baseline Moku shell tokens for built-in light and dark modes.",
"tokens": {
"shared": {
"palette": {
"gray": {
"0": "hsl(210 20% 99%)",
"50": "hsl(220 20% 97%)",
"100": "hsl(220 16% 93%)",
"200": "hsl(220 13% 87%)",
"300": "hsl(220 11% 75%)",
"400": "hsl(220 9% 58%)",
"500": "hsl(220 10% 45%)",
"600": "hsl(220 14% 34%)",
"700": "hsl(220 18% 24%)",
"800": "hsl(220 22% 16%)",
"900": "hsl(220 28% 10%)"
},
"blue": {
"400": "hsl(218 88% 61%)",
"500": "hsl(221 83% 53%)",
"600": "hsl(224 76% 48%)"
},
"green": {
"500": "hsl(154 60% 40%)"
},
"red": {
"500": "hsl(0 72% 54%)"
},
"amber": {
"500": "hsl(36 100% 50%)"
}
},
"space": {
"1": "0.25rem",
"2": "0.5rem",
"3": "0.75rem",
"4": "1rem",
"5": "1.25rem",
"6": "1.5rem",
"8": "2rem",
"10": "2.5rem",
"12": "3rem"
},
"radius": {
"sm": "0.375rem",
"md": "0.625rem",
"lg": "0.875rem",
"xl": "1.25rem",
"pill": "999px"
},
"size": {
"controlMd": "2.25rem",
"controlLg": "2.5rem",
"contentWidthWide": "72rem",
"blurOverlay": "18px"
},
"shadow": {
"soft": "0 12px 32px hsl(220 30% 10% / 0.08)",
"strong": "0 20px 48px hsl(220 30% 10% / 0.16)"
},
"zIndex": {
"base": "1",
"dropdown": "100",
"sticky": "200",
"overlay": "400",
"modal": "500",
"toast": "600"
},
"motion": {
"durationFast": "140ms",
"durationBase": "220ms",
"durationSlow": "320ms",
"easeStandard": "cubic-bezier(0.2, 0.8, 0.2, 1)"
},
"typography": {
"fontFamily": {
"sans": "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Helvetica Neue\", Arial, sans-serif",
"heading": "\"Avenir Next\", \"Segoe UI\", \"Helvetica Neue\", Arial, sans-serif",
"display": "\"Avenir Next\", \"Segoe UI\", \"Helvetica Neue\", Arial, sans-serif",
"serif": "ui-serif, Georgia, Cambria, \"Times New Roman\", Times, serif",
"mono": "ui-monospace, \"SF Mono\", \"SFMono-Regular\", Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace"
},
"fontSize": {
"caption": "0.75rem",
"label": "0.875rem",
"body": "1rem",
"title": "clamp(1.125rem, 1.05rem + 0.3vw, 1.25rem)",
"heading": "clamp(1.5rem, 1.2rem + 1vw, 2.125rem)",
"display": "clamp(2.25rem, 1.7rem + 2.2vw, 3.75rem)"
},
"lineHeight": {
"caption": "1.4",
"label": "1.35",
"body": "1.55",
"title": "1.3",
"heading": "1.15",
"display": "1.05"
},
"fontWeight": {
"caption": "500",
"label": "600",
"body": "400",
"title": "600",
"heading": "600",
"display": "700"
},
"letterSpacing": {
"caption": "0.01em",
"label": "0.005em",
"body": "0",
"title": "-0.01em",
"heading": "-0.02em",
"display": "-0.03em"
}
}
},
"modes": {
"light": {
"colorScheme": "light",
"colors": {
"canvas": "var(--gray-50)",
"surface": "hsl(0 0% 100% / 0.9)",
"surfaceMuted": "var(--gray-0)",
"surfaceHover": "var(--gray-100)",
"border": "hsl(220 15% 85% / 0.9)",
"borderStrong": "hsl(220 12% 70% / 0.9)",
"text": "var(--gray-800)",
"textMuted": "var(--gray-500)",
"accent": "var(--blue-500)",
"accentStrong": "var(--blue-600)",
"accentSoft": "hsl(218 88% 61% / 0.12)",
"accentContrast": "hsl(0 0% 100%)",
"primaryOne": "var(--blue-500)",
"primaryTwo": "hsl(271 72% 60%)",
"primaryThree": "hsl(192 76% 48%)",
"success": "var(--green-500)",
"danger": "var(--red-500)",
"warning": "var(--amber-500)",
"focusRing": "hsl(221 83% 53% / 0.55)"
}
},
"dark": {
"colorScheme": "dark",
"colors": {
"canvas": "var(--gray-900)",
"surface": "hsl(220 23% 14% / 0.92)",
"surfaceMuted": "hsl(220 22% 12% / 0.96)",
"surfaceHover": "hsl(220 18% 20% / 0.96)",
"border": "hsl(220 12% 26% / 0.9)",
"borderStrong": "hsl(220 12% 38% / 0.9)",
"text": "hsl(210 20% 96%)",
"textMuted": "hsl(220 12% 70%)",
"accent": "hsl(217 91% 67%)",
"accentStrong": "hsl(218 88% 61%)",
"accentSoft": "hsl(217 91% 67% / 0.18)",
"accentContrast": "hsl(220 28% 10%)",
"primaryOne": "hsl(217 91% 67%)",
"primaryTwo": "hsl(272 80% 70%)",
"primaryThree": "hsl(190 84% 62%)",
"success": "hsl(154 55% 48%)",
"danger": "hsl(0 72% 62%)",
"warning": "hsl(36 100% 60%)",
"focusRing": "hsl(217 91% 67% / 0.65)"
},
"shadow": {
"soft": "0 16px 40px hsl(220 40% 3% / 0.45)",
"strong": "0 24px 60px hsl(220 40% 3% / 0.55)"
}
}
}
}
}

View File

@@ -0,0 +1,177 @@
{
"schemaVersion": "1.0.0",
"id": "moku-midnight",
"name": "Moku Midnight",
"description": "A warm, low-light Moku theme inspired by the mood and palette direction of refact0r's Midnight Discord theme, adapted to Moku's token schema.",
"author": "Moku Work",
"tokens": {
"shared": {
"palette": {
"gray": {
"0": "#f9f5d7",
"50": "#fbf1c7",
"100": "#ebdbb2",
"200": "#d5c4a1",
"300": "#bdae93",
"400": "#a89984",
"500": "#928374",
"600": "#7c6f64",
"700": "#665c54",
"800": "#3c3836",
"900": "#282828"
},
"blue": {
"400": "hsl(167 24% 68%)",
"500": "#7caea3",
"600": "hsl(167 24% 48%)"
},
"green": {
"500": "#a8b665"
},
"red": {
"500": "#ea6962"
},
"amber": {
"500": "#d8a656"
}
},
"space": {
"1": "0.25rem",
"2": "0.5rem",
"3": "0.75rem",
"4": "1rem",
"5": "1.25rem",
"6": "1.5rem",
"8": "2rem",
"10": "2.5rem",
"12": "3rem"
},
"radius": {
"sm": "0.375rem",
"md": "0.625rem",
"lg": "0.875rem",
"xl": "1.25rem",
"pill": "999px"
},
"size": {
"controlMd": "2.25rem",
"controlLg": "2.5rem",
"contentWidthWide": "72rem",
"blurOverlay": "18px"
},
"shadow": {
"soft": "0 12px 28px hsl(28 16% 12% / 0.08)",
"strong": "0 18px 40px hsl(28 18% 10% / 0.14)"
},
"zIndex": {
"base": "1",
"dropdown": "100",
"sticky": "200",
"overlay": "400",
"modal": "500",
"toast": "600"
},
"motion": {
"durationFast": "140ms",
"durationBase": "220ms",
"durationSlow": "320ms",
"easeStandard": "cubic-bezier(0.2, 0.8, 0.2, 1)"
},
"typography": {
"fontFamily": {
"sans": "Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Helvetica Neue\", Arial, sans-serif",
"heading": "Inter, \"Avenir Next\", \"Segoe UI\", \"Helvetica Neue\", Arial, sans-serif",
"display": "Inter, \"Avenir Next\", \"Segoe UI\", \"Helvetica Neue\", Arial, sans-serif",
"serif": "ui-serif, Georgia, Cambria, \"Times New Roman\", Times, serif",
"mono": "ui-monospace, \"SF Mono\", \"SFMono-Regular\", Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace"
},
"fontSize": {
"caption": "0.75rem",
"label": "0.875rem",
"body": "1rem",
"title": "clamp(1.125rem, 1.05rem + 0.3vw, 1.25rem)",
"heading": "clamp(1.5rem, 1.2rem + 1vw, 2.125rem)",
"display": "clamp(2.25rem, 1.7rem + 2.2vw, 3.75rem)"
},
"lineHeight": {
"caption": "1.4",
"label": "1.35",
"body": "1.55",
"title": "1.3",
"heading": "1.15",
"display": "1.05"
},
"fontWeight": {
"caption": "500",
"label": "600",
"body": "400",
"title": "600",
"heading": "600",
"display": "700"
},
"letterSpacing": {
"caption": "0.01em",
"label": "0.005em",
"body": "0",
"title": "-0.01em",
"heading": "-0.02em",
"display": "-0.03em"
}
}
},
"modes": {
"light": {
"colorScheme": "light",
"colors": {
"canvas": "hsl(38 24% 97%)",
"surface": "hsl(36 22% 99% / 0.94)",
"surfaceMuted": "hsl(36 20% 96%)",
"surfaceHover": "hsl(34 18% 93%)",
"border": "hsl(30 14% 76% / 0.72)",
"borderStrong": "hsl(28 16% 60% / 0.82)",
"text": "hsl(22 16% 22%)",
"textMuted": "hsl(28 10% 42%)",
"accent": "#d3869b",
"accentStrong": "hsl(344 47% 56%)",
"accentSoft": "hsl(344 47% 70% / 0.12)",
"accentContrast": "var(--gray-0)",
"primaryOne": "#7caea3",
"primaryTwo": "#d3869b",
"primaryThree": "#d8a656",
"success": "#a8b665",
"danger": "#ea6962",
"warning": "#d8a656",
"focusRing": "hsl(344 47% 56% / 0.28)"
}
},
"dark": {
"colorScheme": "dark",
"colors": {
"canvas": "var(--gray-900)",
"surface": "hsl(20 8% 16% / 0.94)",
"surfaceMuted": "var(--gray-800)",
"surfaceHover": "hsl(22 9% 24% / 0.96)",
"border": "hsl(20 10% 30% / 0.72)",
"borderStrong": "hsl(30 14% 55% / 0.62)",
"text": "#d4be98",
"textMuted": "#a79a83",
"accent": "#d3869b",
"accentStrong": "hsl(344 47% 63%)",
"accentSoft": "hsl(344 47% 63% / 0.18)",
"accentContrast": "var(--gray-900)",
"primaryOne": "#7caea3",
"primaryTwo": "#d3869b",
"primaryThree": "#d8a656",
"success": "#a8b665",
"danger": "#ea6962",
"warning": "#d8a656",
"focusRing": "hsl(344 47% 63% / 0.45)"
},
"shadow": {
"soft": "0 14px 32px hsl(20 16% 3% / 0.28)",
"strong": "0 20px 48px hsl(20 16% 2% / 0.38)"
}
}
}
}
}

View File

@@ -0,0 +1,49 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
const manifestPath = resolve("dist/client/.vite/manifest.json");
const outputPath = resolve("dist/client/index.html");
const themeBootstrapScript = `
(() => {
try {
const storageKey = "theme";
const stored = localStorage.getItem(storageKey);
const theme = stored === "light" || stored === "dark"
? stored
: (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
document.documentElement.setAttribute("data-theme", theme);
} catch {
document.documentElement.setAttribute("data-theme", "light");
}
})();
`.trim();
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
const entry = manifest["src/entry-client.tsx"];
if (!entry?.file) {
throw new Error("Could not find src/entry-client.tsx in the client manifest.");
}
const cssLinks = Array.isArray(entry.css) ? entry.css.map((href) => ` <link rel="stylesheet" href="/${href}">`).join("\n") : "";
const html = `<!doctype html>
<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>${themeBootstrapScript}</script>
${cssLinks}
</head>
<body>
<div id="app"></div>
<script type="module" src="/${entry.file}"></script>
</body>
</html>
`;
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, html, "utf8");

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,248 @@
.shell {
height: 100dvh;
min-height: 100dvh;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
background: var(--color-canvas);
color: var(--color-text);
}
.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);
--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));
position: relative;
min-height: 0;
display: grid;
grid-template-columns: var(--rail-width) minmax(0, 1fr);
overflow: hidden;
background: var(--color-surface);
}
.bodyRailCollapsed {
--rail-width: 0rem;
}
.bodySidebarCollapsed {
--sidebar-width: 0rem;
}
.railColumn {
min-height: 0;
display: flex;
position: relative;
z-index: 6;
isolation: isolate;
overflow: visible;
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;
}
.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: 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;
> * {
pointer-events: auto;
}
}
@include respond-up(mobile) {
.body {
--rail-width: 5rem;
--sidebar-width: 17.25rem;
}
.bodyRailCollapsed {
--rail-width: 0rem;
}
.bodySidebarCollapsed {
--sidebar-width: 0rem;
}
}
@include respond-down(tablet) {
.body {
--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: 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 {
display: none;
}
.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

@@ -0,0 +1,162 @@
// Path: Frontend/src/components/shell/AppShell/AppShell.tsx
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 => {
const next: Theme = themeState() === "dark" ? "light" : "dark";
setTheme(next);
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}
isMobileViewport={isMobileViewport()}
isNotificationsOpen={activeMobileWorkspaceView() === "notifications"}
isProfileOpen={activeMobileWorkspaceView() === "profile"}
onToggleNotifications={toggleMobileNotifications}
onToggleProfile={toggleMobileProfile}
/>
<div
classList={{
[styles.body]: true,
[styles.bodyRailCollapsed]: isRailCollapsed(),
[styles.bodySidebarCollapsed]: isSidebarCollapsed(),
}}
>
{/* Left server rail */}
<div class={styles.railColumn}>
<LeftRail collapsed={isRailCollapsed()} />
</div>
{/* Sidebar + main workspace frame */}
<div class={styles.workspaceRegion}>
<div class={styles.sidebarColumn}>
<WorkspaceSidebar
collapsed={isSidebarCollapsed()}
railCollapsed={isRailCollapsed()}
onToggleRailCollapse={(): void => {
setIsRailCollapsed((collapsed) => !collapsed);
}}
/>
</div>
<div class={styles.workspaceMain}>
{/* 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>
{/* Floating server dock overlay */}
<div class={styles.sidebarDock}>
<ServerDock />
</div>
</div>
<MobileBottomNav
isBrowseOpen={isMobileWorkspaceBrowserOpen()}
onBrowseToggle={(): void => {
toggleMobileWorkspaceBrowser();
}}
/>
<MobileWorkspaceBrowser
open={isMobileWorkspaceBrowserOpen()}
onClose={(): void => {
setIsMobileWorkspaceBrowserOpen(false);
}}
/>
</div>
);
};

View File

@@ -0,0 +1,221 @@
.root {
position: relative;
min-width: 0;
}
.selector {
min-width: 0;
padding: 0;
display: inline-flex;
align-items: flex-end;
justify-content: flex-start;
gap: var(--space-2);
border: 0;
background: transparent;
color: var(--color-text);
text-align: left;
transition:
background-color 180ms var(--easing-standard),
color 180ms var(--easing-standard);
}
.selectorOpen {
.meta,
.icon {
color: var(--color-text-subtle);
}
}
.selector:hover {
.value {
color: var(--color-text);
}
.meta,
.icon {
color: var(--color-text-subtle);
}
}
.selector:focus-visible {
outline: none;
border-radius: var(--radius-sm);
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 {
flex: 0 0 auto;
color: var(--color-text-muted);
transition: transform 180ms var(--easing-standard), color 180ms var(--easing-standard);
}
.iconOpen {
transform: rotate(180deg);
}
.menu {
position: absolute;
top: calc(100% + var(--space-2));
left: 0;
min-width: min(18rem, calc(100vw - (var(--space-4) * 2)));
display: grid;
gap: var(--space-2);
padding: var(--space-2);
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-md);
background: var(--color-surface-muted);
box-shadow: 0 16px 32px color-mix(in srgb, black 18%, transparent);
z-index: 20;
}
.menuSection {
display: grid;
gap: calc(var(--space-1) / 2);
}
.menuSectionLabel {
@include text-caption;
display: block;
color: var(--color-text-muted);
letter-spacing: 0.05em;
text-transform: uppercase;
padding-inline: var(--space-1);
margin-bottom: var(--space-2);
}
.menuDivider {
height: 1px;
background: var(--color-border);
}
.menuItem {
min-width: 0;
min-height: 2.75rem;
display: flex;
align-items: center;
width: 100%;
padding: var(--space-2) var(--space-2);
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: var(--color-surface-muted);
color: var(--color-text);
text-align: left;
transition:
background-color 160ms var(--easing-standard),
border-color 160ms var(--easing-standard);
}
.menuItem:hover {
border-color: var(--color-border);
background: var(--color-surface);
}
.menuItemActive {
border-color: var(--color-accent-soft);
background: var(--color-surface);
}
.menuItemCopy {
min-width: 0;
display: grid;
gap: 0;
}
.menuItemValue {
@include text-label;
color: var(--color-text);
font-weight: var(--font-weight-semibold);
}
.menuItemMeta {
@include text-caption;
color: var(--color-text-muted);
}
.submenuItem {
min-width: 0;
min-height: 2.5rem;
display: flex;
align-items: center;
width: 100%;
gap: var(--space-2);
padding: var(--space-2) var(--space-2) var(--space-2) var(--space-3);
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: var(--color-surface-muted);
color: var(--color-text);
text-align: left;
transition:
background-color 160ms var(--easing-standard),
border-color 160ms var(--easing-standard);
}
.submenuItem:hover {
border-color: var(--color-border);
background: var(--color-surface);
}
.submenuItemActive {
border-color: var(--color-accent-soft);
background: var(--color-surface);
}
.submenuIndicator {
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

@@ -0,0 +1,113 @@
import { For, createSignal, onCleanup, onMount, type JSX } from "solid-js";
import { ChevronDown } from "../../../lib/icons";
import { activeDepartment, departmentItems, type DepartmentItem } from "../data/shell.data";
import styles from "./DepartmentSelector.module.scss";
const defaultDepartment = departmentItems.find((item) => item.id === activeDepartment.id) ?? departmentItems[0];
const defaultTeamName = departmentItems
.find((item) => item.id === activeDepartment.id)
?.teams.find((teamName) => teamName === activeDepartment.teamName)
?? defaultDepartment?.teams[0]
?? "";
export const DepartmentSelector = (): JSX.Element => {
const [isOpen, setIsOpen] = createSignal(false);
const [selectedDepartment, setSelectedDepartment] = createSignal<DepartmentItem>(defaultDepartment);
const [selectedTeamName, setSelectedTeamName] = createSignal(defaultTeamName);
let rootRef: HTMLDivElement | undefined;
onMount(() => {
const handlePointerDown = (event: PointerEvent): void => {
if (!isOpen()) return;
if (!rootRef) return;
const target = event.target;
if (target instanceof Node && !rootRef.contains(target)) {
setIsOpen(false);
}
};
document.addEventListener("pointerdown", handlePointerDown);
onCleanup(() => document.removeEventListener("pointerdown", handlePointerDown));
});
const selectDepartment = (item: DepartmentItem): void => {
setSelectedDepartment(item);
setSelectedTeamName(item.teams[0] ?? "");
};
const selectTeam = (teamName: string): void => {
setSelectedTeamName(teamName);
setIsOpen(false);
};
return (
<div class={styles.root} ref={rootRef}>
<button
classList={{ [styles.selector]: true, [styles.selectorOpen]: isOpen() }}
type="button"
aria-label="Select department"
title="Select department"
aria-haspopup="menu"
aria-expanded={isOpen()}
onClick={() => setIsOpen((open) => !open)}
>
<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>
{isOpen() ? (
<div class={styles.menu} role="menu" aria-label="Department selector menu">
<div class={styles.menuSection}>
<span class={styles.menuSectionLabel}>Departments</span>
<For each={departmentItems}>
{(item): JSX.Element => (
<button
classList={{ [styles.menuItem]: true, [styles.menuItemActive]: item.id === selectedDepartment().id }}
type="button"
role="menuitemradio"
aria-checked={item.id === selectedDepartment().id}
onClick={() => selectDepartment(item)}
>
<div class={styles.menuItemCopy}>
<strong class={styles.menuItemValue}>{item.name}</strong>
<span class={styles.menuItemMeta}>{item.teams.length} teams</span>
</div>
</button>
)}
</For>
</div>
<div class={styles.menuDivider} aria-hidden="true" />
<div class={styles.menuSection}>
<span class={styles.menuSectionLabel}>Teams in {selectedDepartment().name}</span>
<For each={selectedDepartment().teams}>
{(teamName): JSX.Element => (
<button
classList={{ [styles.submenuItem]: true, [styles.submenuItemActive]: teamName === selectedTeamName() }}
type="button"
role="menuitemradio"
aria-checked={teamName === selectedTeamName()}
onClick={() => selectTeam(teamName)}
>
<span class={styles.submenuIndicator} aria-hidden="true" />
<div class={styles.menuItemCopy}>
<strong class={styles.menuItemValue}>{teamName}</strong>
</div>
</button>
)}
</For>
</div>
</div>
) : null}
</div>
);
};

View File

@@ -0,0 +1,184 @@
.rail {
--rail-workspace-size: var(--control-size-lg);
position: relative;
z-index: 3;
flex: 1;
min-height: 0;
display: flex;
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, var(--shell-dock-clearance)));
overflow: visible;
}
.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;
align-items: center;
gap: var(--space-2);
}
.topCluster {
gap: var(--space-3);
}
.railCollapsed .topCluster {
gap: var(--space-3);
}
.railCollapsed .topCluster {
align-items: center;
}
.items {
width: 100%;
min-height: 0;
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
overflow: visible;
padding-block: var(--space-1);
}
.itemSlot {
position: relative;
width: 100%;
display: flex;
justify-content: center;
overflow: visible;
}
.itemSlot:hover,
.itemSlot:focus-within,
.itemSlotActive {
z-index: 12;
}
.activeIndicator {
position: absolute;
left: calc(50% - (var(--rail-workspace-size) / 2) - var(--space-2));
top: 50%;
width: 0.26rem;
height: 0.55rem;
border-radius: var(--radius-pill);
background: hsl(0 0% 100% / 0.94);
transform: translateY(-50%) scaleY(0.72);
transform-origin: center;
opacity: 0;
z-index: 2;
transition:
opacity 140ms var(--easing-standard),
height 180ms var(--easing-standard),
transform 180ms var(--easing-standard);
}
.itemSlot:hover .activeIndicator {
opacity: 1;
height: 1.1rem;
transform: translateY(-50%) scaleY(1);
}
.itemSlotActive .activeIndicator {
opacity: 1;
height: 2.1rem;
transform: translateY(-50%) scaleY(1);
}
.hoverLabel {
position: absolute;
left: calc(100% + var(--space-3));
top: 50%;
z-index: 8;
display: inline-flex;
align-items: center;
min-height: 2rem;
padding: 0 var(--space-3);
border: 1px solid color-mix(in srgb, var(--color-border-strong) 52%, transparent);
border-radius: var(--radius-md);
background: var(--color-surface-muted);
color: var(--color-text);
white-space: nowrap;
box-shadow: 0 12px 28px color-mix(in srgb, black 16%, transparent);
@include text-label;
pointer-events: none;
opacity: 0;
transform: translateY(-50%) translateX(calc(var(--space-2) * -1));
transition:
opacity 140ms var(--easing-standard),
transform 180ms cubic-bezier(0.22, 1, 0.36, 1);
}
.hoverLabel::before {
content: "";
position: absolute;
top: 50%;
left: calc(var(--space-2) * -1);
width: 0.7rem;
height: 0.7rem;
border-left: 1px solid color-mix(in srgb, var(--color-border-strong) 52%, transparent);
border-bottom: 1px solid color-mix(in srgb, var(--color-border-strong) 52%, transparent);
background: var(--color-surface-muted);
transform: translateY(-50%) rotate(45deg);
}
.sectionDivider {
width: calc(var(--rail-workspace-size) - var(--space-2));
height: 1px;
border-radius: var(--radius-pill);
background: color-mix(in srgb, var(--color-border-strong) 58%, transparent);
}
.itemSlot:hover .hoverLabel {
opacity: 1;
transform: translateY(-50%) translateX(0);
}
.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();
transition:
border-radius 180ms var(--easing-standard),
background 180ms var(--easing-standard),
color 180ms var(--easing-standard),
transform 180ms var(--easing-standard);
}
.personalButton {
background: var(--color-accent);
border-color: transparent;
color: var(--color-accent-contrast);
font-weight: 700;
letter-spacing: -0.02em;
}
.itemSlot:hover .workspaceButton,
.itemSlot:focus-within .workspaceButton {
border-radius: var(--radius-md);
transform: translateY(-1px);
}
.workspaceButtonActive {
background: var(--color-accent);
border-color: transparent;
color: var(--color-accent-contrast);
border-radius: var(--radius-md);
box-shadow: none;
}

View File

@@ -0,0 +1,81 @@
// Path: Frontend/src/components/shell/LeftRail/LeftRail.tsx
import { For, Show, type JSX } from "solid-js";
import { railItems, type RailItem } from "../data/shell.data";
import styles from "./LeftRail.module.scss";
type RailEntryProps = {
item: RailItem;
label: string;
abbreviation: string;
personal?: boolean;
};
const RailEntry = (props: RailEntryProps): JSX.Element => {
return (
<div
classList={{
[styles.itemSlot]: true,
[styles.itemSlotActive]: !!props.item.active,
}}
>
<span class={styles.activeIndicator} aria-hidden="true" />
<button
type="button"
classList={{
[styles.workspaceButton]: true,
[styles.workspaceButtonActive]: !!props.item.active,
[styles.personalButton]: !!props.personal,
}}
aria-label={props.label}
title={props.label}
>
{props.abbreviation}
</button>
<span class={styles.hoverLabel} role="presentation">
{props.label}
</span>
</div>
);
};
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
classList={{
[styles.rail]: true,
[styles.railCollapsed]: props.collapsed,
}}
aria-label="Server rail"
>
<div class={styles.topCluster}>
<Show when={!props.collapsed && personalItem}>
{(item): JSX.Element => (
<RailEntry item={item()} label={item().label} abbreviation={item().abbreviation} personal />
)}
</Show>
<Show when={!props.collapsed}>
<div class={styles.sectionDivider} aria-hidden="true" />
</Show>
</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

@@ -0,0 +1,234 @@
.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(--shell-dock-clearance)) + var(--project-drawer-gap));
}
.rootCompact {
justify-items: center;
}
.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);
}
.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);
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;
}
.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)
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);
}

View File

@@ -0,0 +1,163 @@
// 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 = {
compact?: boolean;
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
classList={{
[styles.root]: true,
[styles.rootCompact]: !!props.compact,
}}
style={{
"--project-drawer-top": `${drawerTop()}px`,
}}
>
{/* Project trigger */}
<button
type="button"
ref={triggerRef}
classList={{
[styles.trigger]: true,
[styles.triggerCompact]: !!props.compact,
[styles.triggerOpen]: props.isOpen,
}}
aria-label={`Open left workspace sidebar menu for ${selectedProject().name}`}
aria-expanded={props.isOpen}
title={selectedProject().name}
onClick={toggleOpen}
>
<span class={styles.triggerLead} aria-hidden="true">
<Folder size={18} strokeWidth={2} />
</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,
[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>
);
};

View File

@@ -0,0 +1,89 @@
.panel {
--server-dock-glyph-size: var(--control-size-md);
--server-dock-action-min-height: var(--space-8);
--server-dock-border: color-mix(in srgb, var(--color-border-strong) 75%, transparent);
--server-dock-surface: color-mix(in srgb, var(--color-surface) 94%, 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(--server-dock-border);
border-radius: calc(var(--radius-xl) + var(--space-1));
background: var(--server-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;
}
.glyph {
width: var(--server-dock-glyph-size);
height: var(--server-dock-glyph-size);
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-lg);
background: color-mix(in srgb, var(--color-accent-soft) 84%, transparent);
color: var(--color-accent-strong);
@include text-label;
}
.copy {
min-width: 0;
display: grid;
gap: 0.15rem;
}
.name {
@include text-label;
}
.status,
.subtitle {
@include text-caption;
color: var(--color-text-muted);
}
.status {
display: inline-flex;
align-items: center;
gap: var(--space-1);
}
.statusDot {
width: 0.45rem;
height: 0.45rem;
flex: 0 0 auto;
border-radius: 999px;
background: var(--color-success);
box-shadow: 0 0 0 0.1rem color-mix(in srgb, var(--color-success) 18%, transparent);
}
.actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-2);
}
.action {
min-height: var(--server-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,46 @@
// Path: Frontend/src/components/shell/ServerDock/ServerDock.tsx
import { For, Show, type JSX } from "solid-js";
import { activeServer } from "../data/shell.data";
import styles from "./ServerDock.module.scss";
export const ServerDock = (): JSX.Element => {
return (
<section class={styles.panel} aria-label="Server dock">
<div class={styles.identity}>
<div class={styles.glyph} aria-hidden="true">
{activeServer.abbreviation}
</div>
<div class={styles.copy}>
<span class={styles.name}>{activeServer.name}</span>
<Show
when={activeServer.kind === "organization"}
fallback={<span class={styles.subtitle}>{activeServer.subtitle}</span>}
>
<span class={styles.status}>
<span class={styles.statusDot} aria-hidden="true" />
<span>{activeServer.connectedLabel}</span>
</span>
</Show>
</div>
</div>
<Show when={activeServer.dockActions.length > 0}>
<div class={styles.actions}>
<For each={activeServer.dockActions}>
{(item): JSX.Element => {
const Icon = item.icon;
return (
<button type="button" class={styles.action} aria-label={item.label} title={item.label}>
<Icon size={16} strokeWidth={2} />
<span class={styles.actionLabel}>{item.label}</span>
</button>
);
}}
</For>
</div>
</Show>
</section>
);
};

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

@@ -0,0 +1,68 @@
.toggleButton {
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 500ms ease,
color 220ms var(--easing-standard),
transform 180ms var(--easing-standard);
}
.toggleButton:hover {
background: color-mix(in srgb, var(--color-text) 8%, transparent);
color: var(--color-text);
}
.toggleButton: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);
}
.iconContainer {
position: relative;
width: 1.375rem;
height: 1.375rem;
}
.iconLayer {
position: absolute;
inset: 0;
display: inline-flex;
align-items: center;
justify-content: center;
transition:
transform 1000ms ease,
opacity 500ms ease;
}
.moonLayer {
transform: rotate(90deg);
opacity: 0;
:global([data-theme="dark"]) & {
transform: rotate(0deg);
opacity: 1;
}
}
.sunLayer {
transform: rotate(0deg);
opacity: 1;
:global([data-theme="dark"]) & {
transform: rotate(-90deg);
opacity: 0;
}
}

View File

@@ -0,0 +1,32 @@
// Path: Frontend/src/components/shell/TopBar/ThemeToggle.tsx
import type { JSX } from "solid-js";
import type { Theme } from "../../../theme/runtime";
import { Moon, Sun } from "../../../lib/icons";
import styles from "./ThemeToggle.module.scss";
type ThemeToggleProps = {
theme: Theme;
onToggle: VoidFunction;
};
export const ThemeToggle = (props: ThemeToggleProps): JSX.Element => {
return (
<button
class={styles.toggleButton}
type="button"
onClick={props.onToggle}
aria-label={`Switch to ${props.theme === "dark" ? "light" : "dark"} theme`}
title={`Switch to ${props.theme === "dark" ? "light" : "dark"} theme`}
>
<span class={styles.iconContainer} aria-hidden="true">
<span class={`${styles.iconLayer} ${styles.moonLayer}`}>
<Moon size={18} strokeWidth={2} />
</span>
<span class={`${styles.iconLayer} ${styles.sunLayer}`}>
<Sun size={18} strokeWidth={2} />
</span>
</span>
</button>
);
};

View File

@@ -0,0 +1,85 @@
.topBar {
--topbar-control-size: 2.5rem;
min-height: 4rem;
display: grid;
grid-template-columns: minmax(0, 1fr) 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;
justify-items: start;
gap: 0;
}
.eyebrow {
@include text-caption;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.controls,
.actions {
display: flex;
align-items: center;
}
.controls {
gap: var(--space-1);
}
.actions {
gap: var(--space-1);
}
.actionButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--topbar-control-size);
height: var(--topbar-control-size);
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);
}
.actionButton:hover {
background: color-mix(in srgb, var(--color-text) 8%, transparent);
color: var(--color-text);
}
.actionButton: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);
}
@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;
}
.controls {
gap: 0;
}
}

View File

@@ -0,0 +1,59 @@
// Path: Frontend/src/components/shell/TopBar/TopBar.tsx
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 { 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 => {
return (
<header class={styles.topBar}>
<div class={styles.identity}>
<span class={styles.eyebrow}>Moku Work</span>
<DepartmentSelector />
</div>
<div class={styles.controls}>
<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>
<NotificationsNav
isMobileViewport={props.isMobileViewport}
isMobileWorkspaceOpen={props.isNotificationsOpen}
onToggleMobileWorkspace={props.onToggleNotifications}
/>
<ThemeToggle theme={props.theme} onToggle={props.onToggleTheme} />
<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

@@ -0,0 +1,105 @@
.userButton {
position: relative;
display: inline-flex;
justify-content: center;
align-items: center;
width: 2.75rem;
height: 2.75rem;
margin-left: var(--space-1);
padding: 0;
border: 0;
border-radius: 999px;
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
overflow: hidden;
transition:
transform 180ms var(--easing-standard),
color 220ms var(--easing-standard);
}
.userButton:hover {
transform: scale(1.05);
color: var(--color-text);
}
.userButtonOpen {
color: var(--color-text);
}
.userButton:hover .spinContainer,
.userButtonOpen .spinContainer {
animation-play-state: running;
opacity: 1;
}
.userButton:focus-visible {
outline: none;
color: var(--color-text);
box-shadow: 0 0 0 0.12rem color-mix(in srgb, var(--color-accent-strong) 28%, transparent);
}
.spinContainer {
position: absolute;
inset: 0;
display: inline-flex;
align-items: center;
justify-content: center;
opacity: 0.72;
transition: opacity 220ms var(--easing-standard);
animation: spin-reverse 1.5s ease-in-out infinite reverse;
animation-play-state: paused;
pointer-events: none;
}
.spinRing {
width: 100%;
height: 100%;
border-radius: 999px;
background:
conic-gradient(
from 0deg,
transparent 0deg 28deg,
var(--color-primary-1) 28deg 118deg,
transparent 118deg 148deg,
var(--color-primary-2) 148deg 238deg,
transparent 238deg 268deg,
var(--color-primary-3) 268deg 358deg,
transparent 358deg 360deg
);
mask: radial-gradient(circle, transparent 63%, black 66%);
-webkit-mask: radial-gradient(circle, transparent 63%, black 66%);
animation: spin-forward 14s linear infinite;
}
.userCore {
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-muted);
}
@keyframes spin-forward {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes spin-reverse {
from {
transform: rotate(0deg);
}
to {
transform: rotate(-360deg);
}
}

View File

@@ -0,0 +1,37 @@
// Path: Frontend/src/components/shell/TopBar/UserNavButton.tsx
import type { JSX } from "solid-js";
import { User } from "../../../lib/icons";
import styles from "./UserNavButton.module.scss";
type UserNavButtonProps = {
isOpen: boolean;
menuId: string;
onToggle: () => void;
};
export const UserNavButton = (props: UserNavButtonProps): JSX.Element => {
return (
<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>
<span class={styles.userCore} aria-hidden="true">
<User size={16} strokeWidth={2.2} />
</span>
</button>
);
};

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

@@ -0,0 +1,251 @@
.sidebar {
--sidebar-nav-item-min-height: var(--control-size-lg);
position: relative;
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;
border-top-left-radius: inherit;
isolation: isolate;
}
.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);
gap: var(--space-2);
min-height: 0;
position: relative;
z-index: 1;
transition:
opacity 180ms var(--easing-standard),
transform 220ms var(--easing-standard);
}
.sectionHidden {
opacity: 0;
pointer-events: none;
transform: translateX(var(--space-3));
}
.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, var(--shell-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;
}
.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;
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));
}
.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);
color: var(--color-text);
box-shadow: inset 0 1px 0 color-mix(in srgb, white 4%, transparent);
}
.icon {
color: inherit;
opacity: 0.85;
}
.label {
@include text-label;
min-width: 0;
}
.itemMeta {
@include text-caption;
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

@@ -0,0 +1,261 @@
// Path: Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx
import { For, Show, createMemo, createSignal, type JSX } from "solid-js";
import { ChevronLeft, ChevronRight } from "../../../lib/icons";
import { ProjectSelector } from "../ProjectSelector/ProjectSelector";
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";
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 (
<li>
<button
type="button"
classList={{
[styles.navItem]: true,
[styles.navItemActive]: !!props.item.active,
}}
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;
}
event.preventDefault();
props.onOpenContextMenuFromKeyboard(event.currentTarget, target);
}}
>
<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 (
<button type="button" class={styles.headerActionButton} aria-label={action.label} title={action.label}>
<Icon size={16} strokeWidth={2} />
</button>
);
}}
</For>
</div>
<div class={styles.headerControls}>
<ProjectSelector
compact={props.collapsed}
isOpen={isProjectDrawerOpen()}
onToggle={(): void => {
setIsProjectDrawerOpen(true);
}}
onClose={(): void => {
setIsProjectDrawerOpen(false);
}}
/>
</div>
</div>
<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

@@ -0,0 +1,502 @@
// Path: Frontend/src/components/shell/data/shell.data.ts
import type { Component } from "solid-js";
import {
Bell,
CircleHelp,
FileText,
Folder,
Home,
Keyboard,
LayoutGrid,
LogOut,
Repeat,
Search,
Settings,
Shield,
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;
kind: "personal" | "organization";
active?: boolean;
};
export type ServerDockAction = {
id: string;
label: string;
icon: ShellIcon;
};
export type ActiveServer = {
id: string;
name: string;
abbreviation: string;
kind: "personal" | "organization";
connectedLabel?: string;
subtitle?: string;
dockActions: readonly ServerDockAction[];
};
export type ActiveProject = {
id: string;
name: string;
};
export type ActiveDepartment = {
id: string;
name: string;
teamName: string;
};
export type DepartmentItem = {
id: string;
name: string;
teams: readonly string[];
active?: boolean;
};
export type ProjectItem = {
id: string;
name: string;
description: string;
active?: boolean;
};
export type SidebarItem = {
id: string;
label: string;
icon: ShellIcon;
active?: boolean;
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 },
] as const;
const organizationAdminDockActions: readonly ServerDockAction[] = [
{ id: "members", label: "Members", icon: User },
{ id: "server", label: "Server", icon: Settings },
] as const;
// Server shell scaffold data
export const railItems: readonly RailItem[] = [
{ id: "personal-server", label: "Personal Server Name", abbreviation: "P", kind: "personal" },
{ id: "organization-server", label: "Organization Name", abbreviation: "O", kind: "organization", active: true },
{ id: "design-review", label: "Design Review", abbreviation: "D", kind: "organization" },
] as const;
export const activeServer: ActiveServer = {
id: "organization-server",
name: "Organization Name",
abbreviation: "O",
kind: "organization",
connectedLabel: "12 connected",
dockActions: organizationAdminDockActions,
};
// Workspace framing scaffold data
export const activeProject: ActiveProject = {
id: "general",
name: "General",
};
export const activeDepartment: ActiveDepartment = {
id: "product",
name: "Product",
teamName: "Design Systems",
};
export const projectItems: readonly ProjectItem[] = [
{ id: "general", name: "General", description: "Default shared project", active: true },
{ id: "operations", name: "Operations", description: "Cross-team planning and delivery" },
{ id: "hiring", name: "Hiring", description: "Candidate pipeline and interview loops" },
] as const;
export const departmentItems: readonly DepartmentItem[] = [
{ id: "product", name: "Product", teams: ["Design Systems", "Research Ops"], active: true },
{ id: "engineering", name: "Engineering", teams: ["Platform", "Realtime Collaboration"] },
{ id: "operations", name: "Operations", teams: ["Shared Services", "People Ops"] },
] as const;
// Sidebar and topbar scaffold data
// 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 },
] 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

@@ -0,0 +1,158 @@
.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);
}
.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);
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) 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

@@ -0,0 +1,84 @@
// Path: Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx
import { For, type JSX } from "solid-js";
import { ChevronLeft, ChevronRight } from "../../../lib/icons";
import { activeProject, activeServer } from "../../shell/data/shell.data";
import styles from "./WorkspaceHome.module.scss";
type ShellCheckpointCard = {
title: string;
copy: string;
meta: string;
};
const shellCheckpointCards: readonly ShellCheckpointCard[] = [
{
title: "Server shell",
copy: "Top bar, server rail, sidebar, and content viewport are now split into modular components.",
meta: "Layout foundation",
},
{
title: "Presence foundation",
copy: "The dock now distinguishes personal and organization servers, leaving clear space for future presence and server-aware controls.",
meta: "Server foundation",
},
{
title: "Next build target",
copy: "You can now plug in auth state, server onboarding, and live presence without redesigning the whole frame.",
meta: "Ready for v0.1.0 work",
},
];
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>
<p class={styles.description}>
This is the barebone app frame for v0.1.0 enough structure to start building a real self-hosted server experience on top of the 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,24 @@
// Path: Frontend/src/entry-client.tsx
// @refresh reload
import { mount, StartClient } from "@solidjs/start/client";
import type { JSX } from "solid-js";
import { initializeThemeRuntime } from "./theme/runtime";
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());
};
void initializeThemeRuntime();
mountApp();

Some files were not shown because too many files have changed in this diff Show More