Compare commits

...

18 Commits

Author SHA1 Message Date
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
109 changed files with 6335 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

4
.gitignore vendored
View File

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

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,79 @@
// 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
WorkerPort 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"),
WorkerPort: getEnv("BACKEND_WORKER_PORT", "8082"),
PostgresURL: getEnv("DATABASE_URL", "postgres://moku:moku_dev_password@localhost:5432/moku?sslmode=disable"),
ValkeyURL: getEnv("VALKEY_URL", "redis://localhost:6379/0"),
ShutdownTimeout: getDurationEnv("BACKEND_SHUTDOWN_TIMEOUT", 10*time.Second),
}
}
func (c *Config) Address(serviceName string) string {
var port string
switch strings.ToLower(serviceName) {
case "web":
port = c.WebPort
case "api":
port = c.APIPort
case "worker":
port = c.WorkerPort
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

13
Env/.env.example Normal file
View File

@@ -0,0 +1,13 @@
ALLOWED_HOSTS=
APP_NAME=moku
GO_ENV=development
LOG_LEVEL=debug
BACKEND_WEB_PORT=8080
BACKEND_API_PORT=8081
BACKEND_WORKER_PORT=8082
BACKEND_SHUTDOWN_TIMEOUT=10s
DATABASE_URL=postgres://moku:moku_dev_password@localhost:5432/moku?sslmode=disable
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,139 @@
.shell {
height: 100dvh;
min-height: 100dvh;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
background: var(--color-canvas);
color: var(--color-text);
}
.body {
--rail-width: 4.75rem;
--sidebar-width: 16.75rem;
--shell-top-left-radius: calc(var(--radius-xl) + var(--space-1));
--shell-frame-border: color-mix(in srgb, var(--color-border-strong) 44%, transparent);
--shell-divider-border: color-mix(in srgb, var(--color-border-strong) 34%, transparent);
--sidebar-panel-surface: color-mix(in srgb, var(--color-surface-muted) 92%, transparent);
--workspace-panel-surface: color-mix(in srgb, var(--color-canvas) 94%, var(--color-surface));
position: relative;
min-height: 0;
display: grid;
grid-template-columns: var(--rail-width) minmax(0, 1fr);
overflow: hidden;
background: var(--color-surface);
}
.railColumn {
min-height: 0;
display: flex;
position: relative;
z-index: 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;
}
.sidebarDock {
position: absolute;
bottom: var(--space-3);
left: calc(var(--space-1) + (var(--rail-width) * 0.1));
width: calc(var(--sidebar-width) + (var(--rail-width) * 0.9) - var(--space-2));
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;
}
}
@include respond-down(tablet) {
.body {
--rail-width: 4.5rem;
--sidebar-width: 13.25rem;
}
}
@include respond-down(mobile) {
.body {
grid-template-columns: 4.5rem minmax(0, 1fr);
--rail-width: 4.5rem;
}
.railColumn {
position: sticky;
top: 0;
}
.workspaceRegion,
.sidebarDock {
display: none;
}
}

View File

@@ -0,0 +1,54 @@
// Path: Frontend/src/components/shell/AppShell/AppShell.tsx
import { createSignal, onMount, 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 { ServerDock } from "../ServerDock/ServerDock";
import { TopBar } from "../TopBar/TopBar";
import { WorkspaceSidebar } from "../WorkspaceSidebar/WorkspaceSidebar";
import styles from "./AppShell.module.scss";
export const AppShell = (): JSX.Element => {
const [themeState, setThemeState] = createSignal<Theme>("light");
onMount((): void => {
setThemeState(getDocumentTheme());
});
const toggleTheme = (): void => {
const next: Theme = themeState() === "dark" ? "light" : "dark";
setTheme(next);
setThemeState(next);
};
return (
<div class={styles.shell}>
<TopBar theme={themeState()} onToggleTheme={toggleTheme} />
<div class={styles.body}>
{/* Left server rail */}
<div class={styles.railColumn}>
<LeftRail />
</div>
{/* Sidebar + main workspace frame */}
<div class={styles.workspaceRegion}>
<div class={styles.sidebarColumn}>
<WorkspaceSidebar />
</div>
<div class={styles.workspaceMain}>
<WorkspaceHome />
</div>
</div>
{/* Floating server dock overlay */}
<div class={styles.sidebarDock}>
<ServerDock />
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,185 @@
.root {
position: relative;
min-width: 0;
}
.selector {
min-width: 0;
padding: 0;
display: inline-flex;
align-items: center;
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);
}
.value {
@include text-title;
color: var(--color-text);
font-weight: var(--font-weight-title);
}
.meta {
color: var(--color-text-muted);
}
.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: 0.15rem;
}
.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: 0.35rem;
height: 0.35rem;
flex: 0 0 auto;
border-radius: 999px;
background: var(--color-accent-soft);
}
@include respond-down(mobile) {
.menu {
min-width: min(16rem, calc(100vw - (var(--space-4) * 2)));
}
}

View File

@@ -0,0 +1,111 @@
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)}
>
<strong class={styles.value}>{selectedDepartment().name}</strong>
<span class={styles.meta}>{selectedTeamName()} team</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,192 @@
.rail {
--rail-workspace-size: var(--control-size-lg);
--rail-action-size: var(--control-size-md);
--rail-dock-clearance: 8rem;
position: relative;
z-index: 3;
flex: 1;
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));
overflow: visible;
}
.topCluster,
.bottomCluster {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
}
.bottomCluster {
margin-top: auto;
}
.topCluster {
gap: var(--space-3);
}
.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;
}
.addButton {
width: var(--rail-action-size);
height: var(--rail-action-size);
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px dashed var(--color-border-strong);
border-radius: var(--radius-pill);
background: transparent;
color: var(--color-text-muted);
&:hover {
background: var(--color-surface-hover);
color: var(--color-text);
}
}

View File

@@ -0,0 +1,70 @@
// Path: Frontend/src/components/shell/LeftRail/LeftRail.tsx
import { For, type JSX } from "solid-js";
import { Plus } from "../../../lib/icons";
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>
);
};
export const LeftRail = (): JSX.Element => {
const personalItem = railItems.find((item) => item.kind === "personal");
const organizationItems = railItems.filter((item) => item.kind === "organization");
return (
<aside class={styles.rail} aria-label="Server rail">
<div class={styles.topCluster}>
{personalItem ? <RailEntry item={personalItem} label={personalItem.label} abbreviation="M" personal /> : null}
<div class={styles.sectionDivider} aria-hidden="true" />
</div>
<div class={styles.items}>
<For each={organizationItems}>
{(item): JSX.Element => <RailEntry item={item} label={item.label} abbreviation={item.abbreviation} />}
</For>
</div>
<div class={styles.bottomCluster}>
<button type="button" class={styles.addButton} aria-label="Create server" title="Create server">
<Plus size={16} strokeWidth={2} />
</button>
</div>
</aside>
);
};

View File

@@ -0,0 +1,200 @@
.root {
display: grid;
--project-drawer-gap: var(--space-3);
--project-drawer-top: calc(var(--space-4) + var(--control-size-lg));
--project-drawer-bottom: calc(var(--sidebar-dock-clearance) + var(--project-drawer-gap));
}
.trigger {
width: 100%;
min-width: 0;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-2);
min-height: calc(var(--control-size-lg) + var(--space-2));
padding: var(--space-2) var(--space-3) calc(var(--space-2) + 0.2rem);
border: 1px solid color-mix(in srgb, var(--color-border-strong) 44%, transparent);
border-radius: calc(var(--radius-lg) + var(--space-1));
background: color-mix(in srgb, var(--color-surface) 96%, transparent);
box-shadow: var(--shadow-soft);
text-align: left;
position: relative;
z-index: 5;
transition:
border-color var(--duration-fast) var(--easing-standard),
background var(--duration-fast) var(--easing-standard),
box-shadow var(--duration-fast) var(--easing-standard),
transform 180ms var(--easing-standard);
}
.trigger:hover {
background: var(--color-surface-hover);
border-color: var(--color-border);
}
.triggerOpen {
border-color: color-mix(in srgb, var(--color-border-strong) 22%, transparent);
background: color-mix(in srgb, var(--color-surface) 92%, transparent);
box-shadow: var(--shadow-soft);
}
.triggerLead {
width: var(--control-size-md);
height: var(--control-size-md);
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--color-accent-soft) 82%, transparent);
color: var(--color-accent-strong);
}
.triggerCopy {
min-width: 0;
display: grid;
gap: 0.12rem;
}
.eyebrow,
.projectItemDescription {
@include text-caption;
color: var(--color-text-muted);
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.08em;
}
.value,
.projectItemName {
@include text-label;
}
.triggerIcon {
color: var(--color-text-muted);
transform: rotate(-90deg);
transition: transform var(--duration-fast) var(--easing-standard);
}
.triggerIconOpen {
transform: rotate(0deg);
}
.scrim {
position: absolute;
inset: calc(var(--project-drawer-top) + var(--project-drawer-gap)) var(--space-4)
var(--project-drawer-bottom) var(--space-4);
z-index: 2;
opacity: 0;
pointer-events: none;
background: color-mix(in srgb, black 8%, transparent);
border-radius: var(--radius-lg);
transition: opacity 260ms var(--easing-standard);
}
.scrimOpen {
opacity: 1;
pointer-events: auto;
}
.drawer {
position: absolute;
inset: calc(var(--project-drawer-top) + var(--project-drawer-gap)) var(--space-4)
var(--project-drawer-bottom) var(--space-4);
z-index: 3;
display: grid;
overflow: hidden;
border-radius: var(--radius-lg);
opacity: 0;
pointer-events: none;
transform: translateX(calc(-1 * (var(--space-5) + 12%)));
will-change: transform, opacity;
transition:
opacity 240ms var(--easing-standard),
transform 360ms cubic-bezier(0.16, 1, 0.3, 1);
}
.drawerOpen {
opacity: 1;
pointer-events: auto;
transform: translateX(0);
}
.drawer::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
border: 1px solid color-mix(in srgb, var(--color-border-strong) 52%, transparent);
background: var(--color-surface-muted);
box-shadow:
14px 0 30px color-mix(in srgb, black 7%, transparent),
inset -1px 0 0 color-mix(in srgb, white 4%, transparent);
pointer-events: none;
}
.drawerBody {
position: relative;
z-index: 1;
min-height: 0;
display: grid;
align-content: start;
gap: var(--space-3);
padding: var(--space-4);
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: none;
}
.drawerBody::-webkit-scrollbar {
width: 0;
}
.projectList {
list-style: none;
display: grid;
gap: 0.2rem;
padding: 0;
}
.projectItem {
width: 100%;
min-width: 0;
min-height: calc(var(--control-size-md) + var(--space-2));
padding: var(--space-2) var(--space-3);
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text-muted);
transition:
background 160ms var(--easing-standard),
color 160ms var(--easing-standard),
border-color 160ms var(--easing-standard),
transform 180ms var(--easing-standard);
text-align: left;
}
.projectItem:hover {
background: color-mix(in srgb, var(--color-surface-hover) 82%, transparent);
color: var(--color-text);
border-color: color-mix(in srgb, var(--color-border) 22%, transparent);
}
.projectItemActive {
border-color: color-mix(in srgb, var(--color-border) 28%, transparent);
background: color-mix(in srgb, var(--color-surface) 82%, transparent);
color: var(--color-text);
box-shadow: none;
}
.projectItemCopy {
min-width: 0;
display: grid;
gap: 0.05rem;
}
.projectItemDescription {
color: color-mix(in srgb, var(--color-text-muted) 84%, transparent);
}

View File

@@ -0,0 +1,156 @@
// Path: Frontend/src/components/shell/ProjectSelector/ProjectSelector.tsx
import { For, createSignal, onCleanup, onMount, type JSX } from "solid-js";
import { ChevronDown, Folder } from "../../../lib/icons";
import { activeProject, projectItems } from "../data/shell.data";
import styles from "./ProjectSelector.module.scss";
type ProjectSelectorProps = {
isOpen: boolean;
onToggle: () => void;
onClose: () => void;
};
const defaultProject = projectItems.find((item) => item.id === activeProject.id) ?? projectItems[0];
export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
const [selectedProject, setSelectedProject] = createSignal({ id: defaultProject.id, name: defaultProject.name });
const [drawerTop, setDrawerTop] = createSignal<number>(0);
let triggerRef: HTMLButtonElement | undefined;
onMount(() => {
if (!triggerRef) {
return;
}
const updateDrawerTop = (): void => {
if (!triggerRef) {
return;
}
setDrawerTop(triggerRef.offsetTop + triggerRef.offsetHeight);
};
updateDrawerTop();
const observer = new ResizeObserver(() => {
updateDrawerTop();
});
observer.observe(triggerRef);
window.addEventListener("resize", updateDrawerTop);
onCleanup(() => {
observer.disconnect();
window.removeEventListener("resize", updateDrawerTop);
});
});
const toggleOpen = (): void => {
if (!props.isOpen) {
props.onToggle();
return;
}
props.onClose();
};
const selectProject = (projectId: string): void => {
const nextProject = projectItems.find((item): boolean => item.id === projectId);
if (!nextProject) {
return;
}
setSelectedProject({ id: nextProject.id, name: nextProject.name });
props.onClose();
};
return (
<div
class={styles.root}
style={{
"--project-drawer-top": `${drawerTop()}px`,
}}
>
{/* Project trigger */}
<button
type="button"
ref={triggerRef}
classList={{
[styles.trigger]: true,
[styles.triggerOpen]: props.isOpen,
}}
aria-label="Open project drawer"
aria-expanded={props.isOpen}
title="Open project drawer"
onClick={toggleOpen}
>
<span class={styles.triggerLead} aria-hidden="true">
<Folder size={18} strokeWidth={2} />
</span>
<span class={styles.triggerCopy}>
<span class={styles.eyebrow}>Projects</span>
<span class={styles.value}>{selectedProject().name}</span>
</span>
<ChevronDown
classList={{
[styles.triggerIcon]: true,
[styles.triggerIconOpen]: props.isOpen,
}}
size={16}
strokeWidth={2}
/>
</button>
{/* Outside-click scrim */}
<button
type="button"
classList={{
[styles.scrim]: true,
[styles.scrimOpen]: props.isOpen,
}}
aria-hidden={!props.isOpen}
tabIndex={props.isOpen ? 0 : -1}
onClick={props.onClose}
/>
{/* Slide-out project list */}
<div
classList={{
[styles.drawer]: true,
[styles.drawerOpen]: props.isOpen,
}}
aria-hidden={!props.isOpen}
>
<div class={styles.drawerBody}>
<ul class={styles.projectList} role="list">
<For each={projectItems}>
{(item): JSX.Element => {
const isSelected = (): boolean => selectedProject().id === item.id;
return (
<li>
<button
type="button"
classList={{
[styles.projectItem]: true,
[styles.projectItemActive]: isSelected(),
}}
onClick={(): void => selectProject(item.id)}
>
<span class={styles.projectItemCopy}>
<span class={styles.projectItemName}>{item.name}</span>
<span class={styles.projectItemDescription}>{item.description}</span>
</span>
</button>
</li>
);
}}
</For>
</ul>
</div>
</div>
</div>
);
};

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,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,44 @@
// 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 { ThemeToggle } from "./ThemeToggle";
import { UserNavButton } from "./UserNavButton";
import styles from "./TopBar.module.scss";
type TopBarProps = {
theme: Theme;
onToggleTheme: VoidFunction;
};
export const TopBar = (props: TopBarProps): JSX.Element => {
return (
<header class={styles.topBar}>
<div class={styles.identity}>
<span class={styles.eyebrow}>Moku Work</span>
<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>
<ThemeToggle theme={props.theme} onToggle={props.onToggleTheme} />
<UserNavButton />
</div>
</header>
);
};

View File

@@ -0,0 +1,100 @@
.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);
}
.userButton:hover .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,19 @@
// 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";
export const UserNavButton = (): JSX.Element => {
return (
<button class={styles.userButton} type="button" aria-label="Open profile" title="Open profile">
<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,104 @@
.sidebar {
--sidebar-nav-item-min-height: var(--control-size-lg);
--sidebar-dock-clearance: 8rem;
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-2);
}
.headerDrawerOpen {
z-index: 4;
}
.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));
margin-right: calc(var(--space-1) * -1);
}
.sectionLabel {
@include text-label;
color: var(--color-text-muted);
}
.navList {
list-style: none;
display: grid;
gap: var(--space-1);
padding: 0;
}
.navItem {
width: 100%;
min-width: 0;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-2);
min-height: var(--sidebar-nav-item-min-height);
padding: var(--space-2) var(--space-3);
@include interactive-frame(transparent, transparent, var(--color-text-muted), var(--radius-lg));
text-align: left;
@include interactive-frame-hover(var(--color-surface-hover), transparent, var(--color-text));
}
.navItemActive {
border-color: var(--color-border);
background: var(--color-surface);
color: var(--color-text);
box-shadow: 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);
}
@include respond-down(mobile) {
.sidebar {
display: none;
}
}

View File

@@ -0,0 +1,67 @@
// Path: Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx
import { For, Show, createSignal, type JSX } from "solid-js";
import { ProjectSelector } from "../ProjectSelector/ProjectSelector";
import { serverSidebarItems } from "../data/shell.data";
import styles from "./WorkspaceSidebar.module.scss";
export const WorkspaceSidebar = (): JSX.Element => {
const [isProjectDrawerOpen, setIsProjectDrawerOpen] = createSignal(false);
return (
<aside class={styles.sidebar} aria-label="Server navigation">
<div
classList={{
[styles.header]: true,
[styles.headerDrawerOpen]: isProjectDrawerOpen(),
}}
>
<ProjectSelector
isOpen={isProjectDrawerOpen()}
onToggle={(): void => {
setIsProjectDrawerOpen(true);
}}
onClose={(): void => {
setIsProjectDrawerOpen(false);
}}
/>
</div>
<div
classList={{
[styles.section]: true,
[styles.sectionHidden]: isProjectDrawerOpen(),
}}
>
<span class={styles.sectionLabel}>Navigation</span>
<div class={styles.navScroller}>
<ul class={styles.navList} role="list">
<For each={serverSidebarItems}>
{(item): JSX.Element => {
const Icon = item.icon;
return (
<li>
<button
type="button"
classList={{
[styles.navItem]: true,
[styles.navItemActive]: !!item.active,
}}
>
<Icon class={styles.icon} size={18} strokeWidth={2} />
<span class={styles.label}>{item.label}</span>
<Show when={item.meta}>
<span class={styles.itemMeta}>{item.meta}</span>
</Show>
</button>
</li>
);
}}
</For>
</ul>
</div>
</div>
</aside>
);
};

View File

@@ -0,0 +1,138 @@
// Path: Frontend/src/components/shell/data/shell.data.ts
import type { Component } from "solid-js";
import { Bell, Folder, Home, LayoutGrid, Search, Settings, User } from "../../../lib/icons";
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 TopBarAction = {
id: string;
label: string;
icon: ShellIcon;
};
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
export const serverSidebarItems: readonly SidebarItem[] = [
{ id: "home", label: "Home", icon: Home, active: true },
{ id: "boards", label: "Boards", icon: LayoutGrid, meta: "0" },
{ id: "docs", label: "Docs", icon: Folder, meta: "0" },
{ id: "settings", label: "Settings", icon: Settings },
] as const;
export const topBarActions: readonly TopBarAction[] = [
{ id: "search", label: "Search", icon: Search },
{ id: "inbox", label: "Inbox", icon: Bell },
] as const;

View File

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

View File

@@ -0,0 +1,55 @@
// Path: Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx
import { For, type JSX } from "solid-js";
import { 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",
},
];
export const WorkspaceHome = (): JSX.Element => {
return (
<main class={styles.viewport}>
<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();

View File

@@ -1,40 +1,53 @@
// Path: Frontend/src/entry-server.tsx
// @refresh reload
import type { JSX } from "solid-js";
import { createHandler, StartServer } from "@solidjs/start/server";
import { DEFAULT_THEME, THEME_STORAGE_KEY } from "./theme/runtime";
import { THEME_MODE_NAMES } from "./theme/schema";
const themeBootstrapScript = `
(() => {
try {
const storageKey = "theme";
const storageKey = ${JSON.stringify(THEME_STORAGE_KEY)};
const stored = localStorage.getItem(storageKey);
const theme = stored === "light" || stored === "dark"
const theme = stored === ${JSON.stringify(THEME_MODE_NAMES[0])} || stored === ${JSON.stringify(THEME_MODE_NAMES[1])}
? stored
: (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
: (window.matchMedia("(prefers-color-scheme: dark)").matches ? ${JSON.stringify(THEME_MODE_NAMES[1])} : ${JSON.stringify(DEFAULT_THEME)});
document.documentElement.setAttribute("data-theme", theme);
} catch {
document.documentElement.setAttribute("data-theme", "light");
document.documentElement.setAttribute("data-theme", ${JSON.stringify(DEFAULT_THEME)});
}
})();
`;
export default createHandler(() => (
<StartServer
document={({ assets, children, scripts }) => (
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
<script innerHTML={themeBootstrapScript} />
{assets}
</head>
<body>
<div id="app">{children}</div>
{scripts}
</body>
</html>
)}
/>
));
type DocumentRenderProps = {
assets?: JSX.Element;
children?: JSX.Element;
scripts?: JSX.Element;
};
const renderDocument = ({ assets, children, scripts }: DocumentRenderProps): JSX.Element => {
return (
<html lang="en" data-theme={DEFAULT_THEME}>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
<script innerHTML={themeBootstrapScript} />
{assets}
</head>
<body>
<div id="app">{children}</div>
{scripts}
</body>
</html>
);
};
const serverHandler = createHandler((): JSX.Element => {
return <StartServer document={renderDocument} />;
});
export default serverHandler;

View File

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

View File

@@ -1,22 +0,0 @@
// Path: Frontend/src/helpers/theme.ts
export type Theme = "light" | "dark";
export const THEME_STORAGE_KEY = "theme";
export const resolvePreferredTheme = (): Theme => {
const stored = localStorage.getItem(THEME_STORAGE_KEY);
if (stored === "light" || stored === "dark") {
return stored;
}
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
};
export const getDocumentTheme = (): Theme => (document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light");
export const setTheme = (theme: Theme) => {
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem(THEME_STORAGE_KEY, theme);
};

View File

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

View File

@@ -17,6 +17,9 @@
--color-accent-strong: hsl(218 88% 61%);
--color-accent-soft: hsl(217 91% 67% / 0.18);
--color-accent-contrast: hsl(220 28% 10%);
--color-primary-1: hsl(217 91% 67%);
--color-primary-2: hsl(272 80% 70%);
--color-primary-3: hsl(190 84% 62%);
--color-success: hsl(154 55% 48%);
--color-danger: hsl(0 72% 62%);

View File

@@ -18,6 +18,9 @@
--color-accent-strong: var(--blue-600);
--color-accent-soft: hsl(218 88% 61% / 0.12);
--color-accent-contrast: hsl(0 0% 100%);
--color-primary-1: var(--blue-500);
--color-primary-2: hsl(271 72% 60%);
--color-primary-3: hsl(192 76% 48%);
--color-success: var(--green-500);
--color-danger: var(--red-500);

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,32 @@
// Path: Frontend/src/theme/presets.ts
import type { ThemeDefinition } from "./schema";
export const themePresetMetas = [
{
id: "moku-default",
name: "Moku Default",
description: "The baseline Moku theme preset, matching the original shell styling tokens.",
path: "/themes/moku-default.json",
},
{
id: "moku-midnight",
name: "Moku Midnight",
description: "The active warm, low-light Moku theme preset inspired by the Midnight Discord palette direction.",
path: "/themes/moku-midnight.json",
},
] as const satisfies readonly (Pick<ThemeDefinition, "id" | "name" | "description"> & { path: string })[];
export const defaultThemePresetPath = "/themes/moku-midnight.json";
export const defaultThemePresetMeta = {
id: "moku-midnight",
name: "Moku Midnight",
description: "The active warm, low-light Moku theme preset inspired by the Midnight Discord palette direction.",
} satisfies Pick<ThemeDefinition, "id" | "name" | "description">;
export const resolveThemePresetPath = (presetId: string): string | null => {
const match = themePresetMetas.find((preset) => preset.id === presetId);
return match?.path ?? null;
};

View File

@@ -0,0 +1,159 @@
// Path: Frontend/src/theme/runtime.ts
import { defaultThemePresetMeta, defaultThemePresetPath, resolveThemePresetPath } from "./presets";
import { createCssVariableMap, isThemeModeName, validateThemeDefinition, type ThemeDefinition, type ThemeModeName } from "./schema";
export type Theme = ThemeModeName;
export const THEME_STORAGE_KEY = "theme";
export const THEME_PRESET_STORAGE_KEY = "theme-preset";
export const DEFAULT_THEME: Theme = "light";
let activeThemeDefinition: ThemeDefinition | null = null;
let themeInitializationPromise: Promise<ThemeDefinition | null> | null = null;
const canUseDom = (): boolean => typeof document !== "undefined";
const canUseStorage = (): boolean => typeof localStorage !== "undefined";
const canUseMatchMedia = (): boolean => typeof window !== "undefined" && typeof window.matchMedia === "function";
const getRootElement = (): HTMLElement | null => {
return canUseDom() ? document.documentElement : null;
};
const persistThemePreset = (themeDefinition: ThemeDefinition): void => {
if (!canUseStorage()) {
return;
}
localStorage.setItem(THEME_PRESET_STORAGE_KEY, themeDefinition.id);
};
const setDocumentThemeMode = (theme: Theme): void => {
const rootElement = getRootElement();
rootElement?.setAttribute("data-theme", theme);
if (rootElement) {
rootElement.style.colorScheme = theme;
}
if (canUseStorage()) {
localStorage.setItem(THEME_STORAGE_KEY, theme);
}
};
const applyThemeVariables = (themeDefinition: ThemeDefinition, theme: Theme): void => {
const rootElement = getRootElement();
if (!rootElement) {
return;
}
const variableMap = createCssVariableMap(themeDefinition, theme);
for (const [name, value] of Object.entries(variableMap)) {
rootElement.style.setProperty(name, value);
}
};
export const resolvePreferredTheme = (): Theme => {
const stored = canUseStorage() ? localStorage.getItem(THEME_STORAGE_KEY) : null;
if (isThemeModeName(stored)) {
return stored;
}
if (canUseMatchMedia()) {
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : DEFAULT_THEME;
}
return DEFAULT_THEME;
};
export const getDocumentTheme = (): Theme => {
const theme = getRootElement()?.getAttribute("data-theme");
return isThemeModeName(theme) ? theme : resolvePreferredTheme();
};
export const applyThemeDefinition = (themeDefinition: ThemeDefinition, theme: Theme): void => {
activeThemeDefinition = themeDefinition;
persistThemePreset(themeDefinition);
setDocumentThemeMode(theme);
applyThemeVariables(themeDefinition, theme);
};
export const resolvePreferredThemePresetId = (): string => {
if (!canUseStorage()) {
return defaultThemePresetMeta.id;
}
const stored = localStorage.getItem(THEME_PRESET_STORAGE_KEY);
if (stored && resolveThemePresetPath(stored)) {
return stored;
}
return defaultThemePresetMeta.id;
};
export const resolvePreferredThemePresetPath = (): string => {
const presetId = resolvePreferredThemePresetId();
return resolveThemePresetPath(presetId) ?? defaultThemePresetPath;
};
export const initializeThemeRuntime = async (): Promise<ThemeDefinition | null> => {
if (typeof window === "undefined") {
return null;
}
if (activeThemeDefinition) {
applyThemeDefinition(activeThemeDefinition, getDocumentTheme());
return activeThemeDefinition;
}
if (!themeInitializationPromise) {
themeInitializationPromise = (async (): Promise<ThemeDefinition | null> => {
try {
const response = await fetch(resolvePreferredThemePresetPath(), {
headers: {
Accept: "application/json",
},
});
if (!response.ok) {
throw new Error(`Theme preset request failed with status ${response.status}.`);
}
const candidate = (await response.json()) as unknown;
const result = validateThemeDefinition(candidate);
if (!result.success) {
throw new Error(result.errors.join(" "));
}
applyThemeDefinition(result.data, getDocumentTheme());
return result.data;
} catch (error) {
console.error("Failed to initialize theme runtime.", error);
return null;
} finally {
themeInitializationPromise = null;
}
})();
}
return themeInitializationPromise;
};
export const setTheme = (theme: Theme): void => {
setDocumentThemeMode(theme);
if (activeThemeDefinition) {
applyThemeVariables(activeThemeDefinition, theme);
}
};

View File

@@ -0,0 +1,345 @@
// Path: Frontend/src/theme/schema.ts
export const THEME_SCHEMA_VERSION = "1.0.0";
export const THEME_MODE_NAMES = ["light", "dark"] as const;
export const THEME_PALETTE_KEYS = {
gray: ["0", "50", "100", "200", "300", "400", "500", "600", "700", "800", "900"],
blue: ["400", "500", "600"],
green: ["500"],
red: ["500"],
amber: ["500"],
} as const;
export const THEME_SPACE_KEYS = ["1", "2", "3", "4", "5", "6", "8", "10", "12"] as const;
export const THEME_RADIUS_KEYS = ["sm", "md", "lg", "xl", "pill"] as const;
export const THEME_SIZE_KEYS = ["controlMd", "controlLg", "contentWidthWide", "blurOverlay"] as const;
export const THEME_SHADOW_KEYS = ["soft", "strong"] as const;
export const THEME_Z_INDEX_KEYS = ["base", "dropdown", "sticky", "overlay", "modal", "toast"] as const;
export const THEME_MOTION_KEYS = ["durationFast", "durationBase", "durationSlow", "easeStandard"] as const;
export const THEME_TYPE_SCALE_KEYS = ["caption", "label", "body", "title", "heading", "display"] as const;
export const THEME_FONT_FAMILY_KEYS = ["sans", "heading", "display", "serif", "mono"] as const;
export const THEME_MODE_COLOR_KEYS = ["canvas", "surface", "surfaceMuted", "surfaceHover", "border", "borderStrong", "text", "textMuted", "accent", "accentStrong", "accentSoft", "accentContrast", "primaryOne", "primaryTwo", "primaryThree", "success", "danger", "warning", "focusRing"] as const;
export type ThemeModeName = (typeof THEME_MODE_NAMES)[number];
type ThemeTokenMap<TKey extends string> = Record<TKey, string>;
export type ThemeSharedTokens = {
palette: {
gray: ThemeTokenMap<(typeof THEME_PALETTE_KEYS.gray)[number]>;
blue: ThemeTokenMap<(typeof THEME_PALETTE_KEYS.blue)[number]>;
green: ThemeTokenMap<(typeof THEME_PALETTE_KEYS.green)[number]>;
red: ThemeTokenMap<(typeof THEME_PALETTE_KEYS.red)[number]>;
amber: ThemeTokenMap<(typeof THEME_PALETTE_KEYS.amber)[number]>;
};
space: ThemeTokenMap<(typeof THEME_SPACE_KEYS)[number]>;
radius: ThemeTokenMap<(typeof THEME_RADIUS_KEYS)[number]>;
size: ThemeTokenMap<(typeof THEME_SIZE_KEYS)[number]>;
shadow: ThemeTokenMap<(typeof THEME_SHADOW_KEYS)[number]>;
zIndex: ThemeTokenMap<(typeof THEME_Z_INDEX_KEYS)[number]>;
motion: ThemeTokenMap<(typeof THEME_MOTION_KEYS)[number]>;
typography: {
fontFamily: ThemeTokenMap<(typeof THEME_FONT_FAMILY_KEYS)[number]>;
fontSize: ThemeTokenMap<(typeof THEME_TYPE_SCALE_KEYS)[number]>;
lineHeight: ThemeTokenMap<(typeof THEME_TYPE_SCALE_KEYS)[number]>;
fontWeight: ThemeTokenMap<(typeof THEME_TYPE_SCALE_KEYS)[number]>;
letterSpacing: ThemeTokenMap<(typeof THEME_TYPE_SCALE_KEYS)[number]>;
};
};
export type ThemeModeTokens = {
colorScheme: ThemeModeName;
colors: ThemeTokenMap<(typeof THEME_MODE_COLOR_KEYS)[number]>;
shadow?: Partial<ThemeTokenMap<(typeof THEME_SHADOW_KEYS)[number]>>;
};
export type ThemeDefinition = {
schemaVersion: typeof THEME_SCHEMA_VERSION;
id: string;
name: string;
description?: string;
author?: string;
tokens: {
shared: ThemeSharedTokens;
modes: Record<ThemeModeName, ThemeModeTokens>;
};
};
export type ThemeValidationResult =
| {
success: true;
data: ThemeDefinition;
}
| {
success: false;
errors: string[];
};
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
const isNonEmptyString = (value: unknown): value is string => typeof value === "string" && value.trim().length > 0;
const getNestedRecord = (value: unknown, path: string, errors: string[]): Record<string, unknown> | null => {
if (!isRecord(value)) {
errors.push(`${path} must be an object.`);
return null;
}
return value;
};
const validateRequiredStringMap = (record: Record<string, unknown>, path: string, requiredKeys: readonly string[], errors: string[]): void => {
for (const key of requiredKeys) {
if (!isNonEmptyString(record[key])) {
errors.push(`${path}.${key} must be a non-empty string token value.`);
}
}
};
export const isThemeModeName = (value: unknown): value is ThemeModeName => typeof value === "string" && THEME_MODE_NAMES.includes(value as ThemeModeName);
export const validateThemeDefinition = (candidate: unknown): ThemeValidationResult => {
const errors: string[] = [];
const root = getNestedRecord(candidate, "theme", errors);
if (!root) {
return { success: false, errors };
}
if (root.schemaVersion !== THEME_SCHEMA_VERSION) {
errors.push(`theme.schemaVersion must be ${THEME_SCHEMA_VERSION}.`);
}
if (!isNonEmptyString(root.id)) {
errors.push("theme.id must be a non-empty string.");
}
if (!isNonEmptyString(root.name)) {
errors.push("theme.name must be a non-empty string.");
}
if (root.description !== undefined && !isNonEmptyString(root.description)) {
errors.push("theme.description must be a non-empty string when provided.");
}
if (root.author !== undefined && !isNonEmptyString(root.author)) {
errors.push("theme.author must be a non-empty string when provided.");
}
const tokens = getNestedRecord(root.tokens, "theme.tokens", errors);
const shared = tokens ? getNestedRecord(tokens.shared, "theme.tokens.shared", errors) : null;
const modes = tokens ? getNestedRecord(tokens.modes, "theme.tokens.modes", errors) : null;
if (shared) {
const palette = getNestedRecord(shared.palette, "theme.tokens.shared.palette", errors);
const space = getNestedRecord(shared.space, "theme.tokens.shared.space", errors);
const radius = getNestedRecord(shared.radius, "theme.tokens.shared.radius", errors);
const size = getNestedRecord(shared.size, "theme.tokens.shared.size", errors);
const shadow = getNestedRecord(shared.shadow, "theme.tokens.shared.shadow", errors);
const zIndex = getNestedRecord(shared.zIndex, "theme.tokens.shared.zIndex", errors);
const motion = getNestedRecord(shared.motion, "theme.tokens.shared.motion", errors);
const typography = getNestedRecord(shared.typography, "theme.tokens.shared.typography", errors);
if (palette) {
for (const [paletteName, keys] of Object.entries(THEME_PALETTE_KEYS)) {
const paletteScale = getNestedRecord(palette[paletteName], `theme.tokens.shared.palette.${paletteName}`, errors);
if (paletteScale) {
validateRequiredStringMap(paletteScale, `theme.tokens.shared.palette.${paletteName}`, keys, errors);
}
}
}
if (space) {
validateRequiredStringMap(space, "theme.tokens.shared.space", THEME_SPACE_KEYS, errors);
}
if (radius) {
validateRequiredStringMap(radius, "theme.tokens.shared.radius", THEME_RADIUS_KEYS, errors);
}
if (size) {
validateRequiredStringMap(size, "theme.tokens.shared.size", THEME_SIZE_KEYS, errors);
}
if (shadow) {
validateRequiredStringMap(shadow, "theme.tokens.shared.shadow", THEME_SHADOW_KEYS, errors);
}
if (zIndex) {
validateRequiredStringMap(zIndex, "theme.tokens.shared.zIndex", THEME_Z_INDEX_KEYS, errors);
}
if (motion) {
validateRequiredStringMap(motion, "theme.tokens.shared.motion", THEME_MOTION_KEYS, errors);
}
if (typography) {
const fontFamily = getNestedRecord(typography.fontFamily, "theme.tokens.shared.typography.fontFamily", errors);
const fontSize = getNestedRecord(typography.fontSize, "theme.tokens.shared.typography.fontSize", errors);
const lineHeight = getNestedRecord(typography.lineHeight, "theme.tokens.shared.typography.lineHeight", errors);
const fontWeight = getNestedRecord(typography.fontWeight, "theme.tokens.shared.typography.fontWeight", errors);
const letterSpacing = getNestedRecord(typography.letterSpacing, "theme.tokens.shared.typography.letterSpacing", errors);
if (fontFamily) {
validateRequiredStringMap(fontFamily, "theme.tokens.shared.typography.fontFamily", THEME_FONT_FAMILY_KEYS, errors);
}
if (fontSize) {
validateRequiredStringMap(fontSize, "theme.tokens.shared.typography.fontSize", THEME_TYPE_SCALE_KEYS, errors);
}
if (lineHeight) {
validateRequiredStringMap(lineHeight, "theme.tokens.shared.typography.lineHeight", THEME_TYPE_SCALE_KEYS, errors);
}
if (fontWeight) {
validateRequiredStringMap(fontWeight, "theme.tokens.shared.typography.fontWeight", THEME_TYPE_SCALE_KEYS, errors);
}
if (letterSpacing) {
validateRequiredStringMap(letterSpacing, "theme.tokens.shared.typography.letterSpacing", THEME_TYPE_SCALE_KEYS, errors);
}
}
}
if (modes) {
for (const modeName of THEME_MODE_NAMES) {
const mode = getNestedRecord(modes[modeName], `theme.tokens.modes.${modeName}`, errors);
if (!mode) {
continue;
}
if (mode.colorScheme !== modeName) {
errors.push(`theme.tokens.modes.${modeName}.colorScheme must be ${modeName}.`);
}
const colors = getNestedRecord(mode.colors, `theme.tokens.modes.${modeName}.colors`, errors);
if (colors) {
validateRequiredStringMap(colors, `theme.tokens.modes.${modeName}.colors`, THEME_MODE_COLOR_KEYS, errors);
}
if (mode.shadow !== undefined) {
const modeShadow = getNestedRecord(mode.shadow, `theme.tokens.modes.${modeName}.shadow`, errors);
if (modeShadow) {
for (const key of THEME_SHADOW_KEYS) {
if (modeShadow[key] !== undefined && !isNonEmptyString(modeShadow[key])) {
errors.push(`theme.tokens.modes.${modeName}.shadow.${key} must be a non-empty string token value when provided.`);
}
}
}
}
}
}
if (errors.length > 0) {
return { success: false, errors };
}
return { success: true, data: candidate as ThemeDefinition };
};
export const createCssVariableMap = (theme: ThemeDefinition, mode: ThemeModeName): Record<string, string> => {
const shared = theme.tokens.shared;
const modeTokens = theme.tokens.modes[mode];
return {
"--gray-0": shared.palette.gray["0"],
"--gray-50": shared.palette.gray["50"],
"--gray-100": shared.palette.gray["100"],
"--gray-200": shared.palette.gray["200"],
"--gray-300": shared.palette.gray["300"],
"--gray-400": shared.palette.gray["400"],
"--gray-500": shared.palette.gray["500"],
"--gray-600": shared.palette.gray["600"],
"--gray-700": shared.palette.gray["700"],
"--gray-800": shared.palette.gray["800"],
"--gray-900": shared.palette.gray["900"],
"--blue-400": shared.palette.blue["400"],
"--blue-500": shared.palette.blue["500"],
"--blue-600": shared.palette.blue["600"],
"--green-500": shared.palette.green["500"],
"--red-500": shared.palette.red["500"],
"--amber-500": shared.palette.amber["500"],
"--space-1": shared.space["1"],
"--space-2": shared.space["2"],
"--space-3": shared.space["3"],
"--space-4": shared.space["4"],
"--space-5": shared.space["5"],
"--space-6": shared.space["6"],
"--space-8": shared.space["8"],
"--space-10": shared.space["10"],
"--space-12": shared.space["12"],
"--radius-sm": shared.radius.sm,
"--radius-md": shared.radius.md,
"--radius-lg": shared.radius.lg,
"--radius-xl": shared.radius.xl,
"--radius-pill": shared.radius.pill,
"--control-size-md": shared.size.controlMd,
"--control-size-lg": shared.size.controlLg,
"--content-width-wide": shared.size.contentWidthWide,
"--blur-overlay": shared.size.blurOverlay,
"--shadow-soft": modeTokens.shadow?.soft ?? shared.shadow.soft,
"--shadow-strong": modeTokens.shadow?.strong ?? shared.shadow.strong,
"--z-base": shared.zIndex.base,
"--z-dropdown": shared.zIndex.dropdown,
"--z-sticky": shared.zIndex.sticky,
"--z-overlay": shared.zIndex.overlay,
"--z-modal": shared.zIndex.modal,
"--z-toast": shared.zIndex.toast,
"--motion-duration-fast": shared.motion.durationFast,
"--motion-duration-base": shared.motion.durationBase,
"--motion-duration-slow": shared.motion.durationSlow,
"--motion-ease-standard": shared.motion.easeStandard,
"--font-family-sans": shared.typography.fontFamily.sans,
"--font-family-heading": shared.typography.fontFamily.heading,
"--font-family-display": shared.typography.fontFamily.display,
"--font-family-serif": shared.typography.fontFamily.serif,
"--font-family-mono": shared.typography.fontFamily.mono,
"--font-size-caption": shared.typography.fontSize.caption,
"--font-size-label": shared.typography.fontSize.label,
"--font-size-body": shared.typography.fontSize.body,
"--font-size-title": shared.typography.fontSize.title,
"--font-size-heading": shared.typography.fontSize.heading,
"--font-size-display": shared.typography.fontSize.display,
"--line-height-caption": shared.typography.lineHeight.caption,
"--line-height-label": shared.typography.lineHeight.label,
"--line-height-body": shared.typography.lineHeight.body,
"--line-height-title": shared.typography.lineHeight.title,
"--line-height-heading": shared.typography.lineHeight.heading,
"--line-height-display": shared.typography.lineHeight.display,
"--font-weight-caption": shared.typography.fontWeight.caption,
"--font-weight-label": shared.typography.fontWeight.label,
"--font-weight-body": shared.typography.fontWeight.body,
"--font-weight-title": shared.typography.fontWeight.title,
"--font-weight-heading": shared.typography.fontWeight.heading,
"--font-weight-display": shared.typography.fontWeight.display,
"--letter-spacing-caption": shared.typography.letterSpacing.caption,
"--letter-spacing-label": shared.typography.letterSpacing.label,
"--letter-spacing-body": shared.typography.letterSpacing.body,
"--letter-spacing-title": shared.typography.letterSpacing.title,
"--letter-spacing-heading": shared.typography.letterSpacing.heading,
"--letter-spacing-display": shared.typography.letterSpacing.display,
"--color-canvas": modeTokens.colors.canvas,
"--color-surface": modeTokens.colors.surface,
"--color-surface-muted": modeTokens.colors.surfaceMuted,
"--color-surface-hover": modeTokens.colors.surfaceHover,
"--color-border": modeTokens.colors.border,
"--color-border-strong": modeTokens.colors.borderStrong,
"--color-text": modeTokens.colors.text,
"--color-text-muted": modeTokens.colors.textMuted,
"--color-accent": modeTokens.colors.accent,
"--color-accent-strong": modeTokens.colors.accentStrong,
"--color-accent-soft": modeTokens.colors.accentSoft,
"--color-accent-contrast": modeTokens.colors.accentContrast,
"--color-primary-1": modeTokens.colors.primaryOne,
"--color-primary-2": modeTokens.colors.primaryTwo,
"--color-primary-3": modeTokens.colors.primaryThree,
"--color-success": modeTokens.colors.success,
"--color-danger": modeTokens.colors.danger,
"--color-warning": modeTokens.colors.warning,
"--color-focus-ring": modeTokens.colors.focusRing,
};
};

View File

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

View File

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

View File

@@ -4,4 +4,4 @@ mod local "Commands/Local"
[default]
help:
just --list --list-submodules
just --list --list-submodules

35
Loader/README.md Normal file
View File

@@ -0,0 +1,35 @@
# Loader
Tiny first-request bootstrap document for Moku.
## Purpose
The loader is intended to be the smallest possible first paint shown before the
real application is handed off.
Long term responsibilities:
- capture original route intent
- collect lightweight client capability hints
- make a tiny boot decision
- hand off to login or app
## Route intent contract
The loader writes bootstrap state to `sessionStorage`:
- `moku.loader.intent` — the intended route path
- `moku.loader.meta` — JSON metadata for the current bootstrap event
The loader also writes a short-lived cookie:
- `moku_loader_seen=1`
The proxy uses that cookie to decide whether to keep serving the loader or to
serve the real app document on the next request.
## Current handoff behavior
For now, the loader simply hands off to the originally requested route. That
keeps the first implementation tiny while leaving a clear place to add auth or
perf-tier decisions later.

44
Loader/index.html Normal file
View File

@@ -0,0 +1,44 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark" />
<title>Moku Loader</title>
<link rel="stylesheet" href="/__loader/styles.css" />
<script type="module" src="/__loader/scripts/main.js"></script>
</head>
<body>
<main class="loader-shell" aria-busy="true" aria-live="polite">
<section class="loader-panel" aria-label="Application bootstrap">
<p class="loader-eyebrow">Moku bootstrap</p>
<h1 class="loader-title">Starting Moku</h1>
<p class="loader-copy">Capturing route intent and preparing the lightest possible handoff.</p>
<div class="loader-indicators" aria-hidden="true">
<span class="loader-dot"></span>
<span class="loader-dot"></span>
<span class="loader-dot"></span>
</div>
<dl class="loader-meta">
<div class="loader-meta-row">
<dt>Status</dt>
<dd id="loader-status">Initializing loader</dd>
</div>
<div class="loader-meta-row">
<dt>Intent</dt>
<dd id="loader-intent">Waiting for capture</dd>
</div>
</dl>
</section>
</main>
<noscript>
<section class="loader-noscript">
<strong>JavaScript is required.</strong>
<span>Moku's bootstrap loader needs JavaScript to hand off to the app.</span>
</section>
</noscript>
</body>
</html>

9
Loader/scripts/config.js Normal file
View File

@@ -0,0 +1,9 @@
// Path: Loader/scripts/config.js
export const LOADER_INTENT_STORAGE_KEY = "moku.loader.intent";
export const LOADER_META_STORAGE_KEY = "moku.loader.meta";
export const LOADER_SEEN_COOKIE_NAME = "moku_loader_seen";
export const LOADER_DEFAULT_INTENT = "/";
export const LOADER_ROOT_PREFIX = "/__loader";
export const LOADER_HANDOFF_DELAY_MS = 720;
export const LOADER_SEEN_COOKIE_MAX_AGE_SECONDS = 1800;

25
Loader/scripts/dom.js Normal file
View File

@@ -0,0 +1,25 @@
// Path: Loader/scripts/dom.js
export const getStatusElement = () => {
return document.getElementById("loader-status");
};
export const getIntentElement = () => {
return document.getElementById("loader-intent");
};
export const setStatus = (value) => {
const statusElement = getStatusElement();
if (statusElement instanceof HTMLElement) {
statusElement.textContent = value;
}
};
export const setIntentPreview = (value) => {
const intentElement = getIntentElement();
if (intentElement instanceof HTMLElement) {
intentElement.textContent = value;
}
};

13
Loader/scripts/handoff.js Normal file
View File

@@ -0,0 +1,13 @@
// Path: Loader/scripts/handoff.js
import { LOADER_HANDOFF_DELAY_MS } from "./config.js";
export const scheduleHandoff = (target, onBeforeHandoff) => {
window.setTimeout(() => {
if (typeof onBeforeHandoff === "function") {
onBeforeHandoff();
}
window.location.assign(target);
}, LOADER_HANDOFF_DELAY_MS);
};

19
Loader/scripts/hints.js Normal file
View File

@@ -0,0 +1,19 @@
// Path: Loader/scripts/hints.js
export const collectClientHints = () => {
const navigatorConnection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
return {
capturedAt: new Date().toISOString(),
language: navigator.language,
languages: Array.isArray(navigator.languages) ? navigator.languages : [],
hardwareConcurrency: navigator.hardwareConcurrency ?? null,
deviceMemory: navigator.deviceMemory ?? null,
networkType: navigatorConnection?.effectiveType ?? null,
saveData: navigatorConnection?.saveData ?? null,
viewport: {
width: window.innerWidth,
height: window.innerHeight,
},
};
};

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