Compare commits

...

8 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
80 changed files with 4483 additions and 291 deletions

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,32 +1,37 @@
project_root := justfile_directory()
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 local production proxy image locally.
build:
cd '{{project_root}}' && docker buildx bake -f '{{proxy_bake}}' prod
cd '{{project_root}}' && docker buildx bake -f '{{proxy_bake}}' prod
# Start the local production stack in the background using the current image.
up:
docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate
docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate
# Build first, then start the local production stack in the background.
start: build
docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate
start: build up
# Rebuild the local production proxy image locally.
rebuild:
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
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
docker compose -f '{{local_compose}}' down --remove-orphans --volumes
# Follow logs for the local production stack.
logs:
docker compose -f '{{local_compose}}' logs -f
docker compose -f '{{local_compose}}' logs -f
# Restart the local production stack.
restart:
docker compose -f '{{local_compose}}' 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

@@ -9,8 +9,8 @@ This project is still in an early scaffold stage, so the goal is to keep changes
### Project structure
- `Frontend/` — SolidStart frontend workspace
- `Backend/` — backend placeholder
- `Proxy/` — proxy placeholder
- `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
@@ -39,7 +39,8 @@ Main local development flow:
just local dev
```
This command builds the frontend development image and starts the local development stack.
This command builds the frontend and backend development images, then starts the
local development stack for Postgres, Valkey, frontend, and backend services.
### Local environment
@@ -49,7 +50,16 @@ Local development uses:
Env/.env.local
```
If local environment values are missing, create or update that file before starting the stack.
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

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.

View File

@@ -66,6 +66,14 @@
- [ ] 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

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

@@ -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

@@ -15,6 +15,7 @@
--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);
@@ -26,7 +27,9 @@
min-height: 0;
display: flex;
position: relative;
z-index: 1;
z-index: 6;
isolation: isolate;
overflow: visible;
background: var(--color-surface);
}
@@ -92,9 +95,10 @@
.sidebarDock {
position: absolute;
right: var(--space-1);
bottom: var(--space-3);
left: calc(var(--space-1) - (var(--rail-width) * 0.9));
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;

View File

@@ -1,10 +1,10 @@
// Path: Frontend/src/components/shell/AppShell/AppShell.tsx
import { createSignal, onMount, type JSX } from "solid-js";
import { getDocumentTheme, setTheme, type Theme } from "../../../helpers/theme";
import { getDocumentTheme, setTheme, type Theme } from "../../../theme/runtime";
import { WorkspaceHome } from "../../workspace-home/WorkspaceHome/WorkspaceHome";
import { LeftRail } from "../LeftRail/LeftRail";
import { ProfileDock } from "../ProfileDock/ProfileDock";
import { ServerDock } from "../ServerDock/ServerDock";
import { TopBar } from "../TopBar/TopBar";
import { WorkspaceSidebar } from "../WorkspaceSidebar/WorkspaceSidebar";
import styles from "./AppShell.module.scss";
@@ -26,24 +26,28 @@ export const AppShell = (): JSX.Element => {
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 class={styles.sidebarDock}>
<ProfileDock />
</div>
</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

@@ -1,14 +1,17 @@
.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);
overflow: hidden;
padding: var(--space-3) var(--space-2) calc(var(--space-3) + var(--rail-dock-clearance));
overflow: visible;
}
.topCluster,
@@ -20,6 +23,14 @@
gap: var(--space-2);
}
.bottomCluster {
margin-top: auto;
}
.topCluster {
gap: var(--space-3);
}
.items {
width: 100%;
min-height: 0;
@@ -28,22 +39,101 @@
flex-direction: column;
align-items: center;
gap: var(--space-2);
overflow-y: auto;
overscroll-behavior: contain;
overflow: visible;
padding-block: var(--space-1);
}
.logo {
width: var(--rail-workspace-size);
height: var(--rail-workspace-size);
.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;
justify-content: center;
border-radius: var(--radius-lg);
background: var(--color-accent);
color: var(--color-accent-contrast);
font-weight: 700;
letter-spacing: -0.02em;
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 {
@@ -55,13 +145,33 @@
@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);
box-shadow: var(--shadow-soft);
border-radius: var(--radius-md);
box-shadow: none;
}
.addButton {

View File

@@ -2,38 +2,66 @@
import { For, type JSX } from "solid-js";
import { Plus } from "../../../lib/icons";
import { railItems } from "../data/shell.data";
import { railItems, type RailItem } from "../data/shell.data";
import styles from "./LeftRail.module.scss";
export const LeftRail = (): JSX.Element => {
type RailEntryProps = {
item: RailItem;
label: string;
abbreviation: string;
personal?: boolean;
};
const RailEntry = (props: RailEntryProps): JSX.Element => {
return (
<aside class={styles.rail} aria-label="Workspace rail">
<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}>
<div class={styles.logo} aria-hidden="true">
M
</div>
{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={railItems}>
{(item): JSX.Element => (
<button
type="button"
classList={{
[styles.workspaceButton]: true,
[styles.workspaceButtonActive]: !!item.active,
}}
title={item.label}
aria-label={item.label}
>
{item.abbreviation}
</button>
)}
<For 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 workspace" title="Create workspace">
<button type="button" class={styles.addButton} aria-label="Create server" title="Create server">
<Plus size={16} strokeWidth={2} />
</button>
</div>

View File

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

View File

@@ -0,0 +1,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

@@ -1,18 +1,17 @@
.panel {
--profile-dock-avatar-size: var(--control-size-md);
--profile-dock-action-min-height: var(--space-8);
--profile-dock-border: color-mix(in srgb, var(--color-border-strong) 75%, transparent);
--profile-dock-surface: color-mix(in srgb, var(--color-surface) 94%, transparent);
--profile-dock-status-ring: 0 0 0 3px color-mix(in srgb, var(--color-success) 18%, transparent);
--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(--profile-dock-border);
border: 1px solid var(--server-dock-border);
border-radius: calc(var(--radius-xl) + var(--space-1));
background: var(--profile-dock-surface);
background: var(--server-dock-surface);
box-shadow:
0 20px 48px color-mix(in srgb, black 16%, transparent),
var(--shadow-strong);
@@ -26,14 +25,14 @@
align-items: center;
}
.avatar {
width: var(--profile-dock-avatar-size);
height: var(--profile-dock-avatar-size);
.glyph {
width: var(--server-dock-glyph-size);
height: var(--server-dock-glyph-size);
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--color-accent-soft);
border-radius: var(--radius-lg);
background: color-mix(in srgb, var(--color-accent-soft) 84%, transparent);
color: var(--color-accent-strong);
@include text-label;
}
@@ -48,20 +47,25 @@
@include text-label;
}
.status {
.status,
.subtitle {
@include text-caption;
display: inline-flex;
align-items: center;
gap: var(--space-2);
color: var(--color-text-muted);
}
.status {
display: inline-flex;
align-items: center;
gap: var(--space-1);
}
.statusDot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
width: 0.45rem;
height: 0.45rem;
flex: 0 0 auto;
border-radius: 999px;
background: var(--color-success);
box-shadow: var(--profile-dock-status-ring);
box-shadow: 0 0 0 0.1rem color-mix(in srgb, var(--color-success) 18%, transparent);
}
.actions {
@@ -71,7 +75,7 @@
}
.action {
min-height: var(--profile-dock-action-min-height);
min-height: var(--server-dock-action-min-height);
display: inline-flex;
align-items: center;
justify-content: center;

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

@@ -1,8 +1,8 @@
.topBar {
--topbar-control-size: var(--control-size-md);
--topbar-control-size: 2.5rem;
min-height: 4rem;
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4) var(--space-3);
@@ -12,6 +12,7 @@
.identity {
min-width: 0;
display: grid;
justify-items: start;
gap: 0;
}
@@ -22,55 +23,50 @@
letter-spacing: 0.08em;
}
.title {
@include text-title;
display: flex;
align-items: center;
gap: var(--space-2);
strong {
font: inherit;
font-weight: var(--font-weight-title);
}
}
.context {
color: var(--color-text-muted);
}
.controls,
.actions {
display: flex;
align-items: center;
}
.controls {
gap: var(--space-1);
}
.actionButton,
.themeButton {
height: var(--topbar-control-size);
display: inline-flex;
align-items: center;
justify-content: center;
@include interactive-frame();
.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);
}
.themeButton {
width: auto;
padding-inline: var(--space-2);
gap: var(--space-1);
.actionButton:hover {
background: color-mix(in srgb, var(--color-text) 8%, transparent);
color: var(--color-text);
}
.actionButton,
.themeButton {
@include interactive-frame-hover();
}
.themeLabel {
@include text-label;
.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) {
@@ -82,4 +78,8 @@
.actions {
display: none;
}
.controls {
gap: 0;
}
}

View File

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

@@ -1,6 +1,7 @@
.sidebar {
--sidebar-nav-item-min-height: var(--control-size-lg);
--sidebar-dock-clearance: 8rem;
position: relative;
min-width: 0;
min-height: 0;
display: grid;
@@ -8,28 +9,17 @@
gap: var(--space-4);
padding: var(--space-4);
overflow: hidden;
border-top-left-radius: inherit;
isolation: isolate;
}
.header {
display: grid;
gap: 0.2rem;
gap: var(--space-2);
}
.eyebrow {
@include text-caption;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.title {
@include text-title;
}
.meta {
@include text-caption;
color: var(--color-text-muted);
max-width: 28ch;
.headerDrawerOpen {
z-index: 4;
}
.section {
@@ -37,6 +27,17 @@
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 {
@@ -78,7 +79,7 @@
border-color: var(--color-border);
background: var(--color-surface);
color: var(--color-text);
box-shadow: var(--shadow-soft);
box-shadow: inset 0 1px 0 color-mix(in srgb, white 4%, transparent);
}
.icon {

View File

@@ -1,23 +1,42 @@
// Path: Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx
import { For, Show, type JSX } from "solid-js";
import { workspaceSidebarItems } from "../data/shell.data";
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="Workspace navigation">
<div class={styles.header}>
<span class={styles.eyebrow}>Workspace</span>
<h2 class={styles.title}>Product Operations</h2>
<p class={styles.meta}>A barebone shell for Mokus first real workspace layout.</p>
<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 class={styles.section}>
<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={workspaceSidebarItems}>
<For each={serverSidebarItems}>
{(item): JSX.Element => {
const Icon = item.icon;

View File

@@ -1,7 +1,7 @@
// Path: Frontend/src/components/shell/data/shell.data.ts
import type { Component } from "solid-js";
import { Bell, Folder, Home, LayoutGrid, Plus, Search, Settings, User } from "../../../lib/icons";
import { Bell, Folder, Home, LayoutGrid, Search, Settings, User } from "../../../lib/icons";
type ShellIconProps = {
class?: string;
@@ -15,6 +15,48 @@ 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;
};
@@ -32,13 +74,58 @@ export type TopBarAction = {
icon: ShellIcon;
};
export const railItems: readonly RailItem[] = [
{ id: "personal", label: "Personal", abbreviation: "P" },
{ id: "moku", label: "Moku", abbreviation: "M", active: true },
{ id: "labs", label: "Labs", abbreviation: "L" },
const personalDockActions: readonly ServerDockAction[] = [
{ id: "account", label: "Account", icon: User },
{ id: "settings", label: "Settings", icon: Settings },
] as const;
export const workspaceSidebarItems: readonly SidebarItem[] = [
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" },
@@ -47,7 +134,5 @@ export const workspaceSidebarItems: readonly SidebarItem[] = [
export const topBarActions: readonly TopBarAction[] = [
{ id: "search", label: "Search", icon: Search },
{ id: "create", label: "Create", icon: Plus },
{ id: "inbox", label: "Inbox", icon: Bell },
{ id: "profile", label: "Profile", icon: User },
] as const;

View File

@@ -1,6 +1,7 @@
// 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 = {
@@ -11,18 +12,18 @@ type ShellCheckpointCard = {
const shellCheckpointCards: readonly ShellCheckpointCard[] = [
{
title: "App shell",
copy: "Top bar, left rail, workspace sidebar, and content viewport are now split into modular components.",
title: "Server shell",
copy: "Top bar, server rail, sidebar, and content viewport are now split into modular components.",
meta: "Layout foundation",
},
{
title: "Workspace context",
copy: "The shell already has clear places for org context, workspace switching, and future surface navigation.",
meta: "Navigation foundation",
title: "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 workspace home content, auth state, and early primitives without redesigning the whole frame.",
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",
},
];
@@ -31,10 +32,10 @@ export const WorkspaceHome = (): JSX.Element => {
return (
<main class={styles.viewport}>
<section class={styles.hero}>
<span class={styles.eyebrow}>Workspace home</span>
<h1 class={styles.title}>Moku is ready for its first real shell.</h1>
<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 real frontend surfaces on top of a real backend core.
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>

View File

@@ -1,8 +1,9 @@
// Path: Frontend/src/entry-client.tsx
// @refresh reload
import type { JSX } from "solid-js";
import { mount, StartClient } from "@solidjs/start/client";
import type { JSX } from "solid-js";
import { initializeThemeRuntime } from "./theme/runtime";
const getAppRoot = (): HTMLElement => {
const appRoot = document.getElementById("app");
@@ -18,4 +19,6 @@ const mountApp = (): void => {
mount((): JSX.Element => <StartClient />, getAppRoot());
};
void initializeThemeRuntime();
mountApp();

View File

@@ -3,19 +3,21 @@
// @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)});
}
})();
`;
@@ -28,7 +30,7 @@ type DocumentRenderProps = {
const renderDocument = ({ assets, children, scripts }: DocumentRenderProps): JSX.Element => {
return (
<html lang="en" data-theme="light">
<html lang="en" data-theme={DEFAULT_THEME}>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />

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): void => {
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem(THEME_STORAGE_KEY, theme);
};

View File

@@ -5,7 +5,9 @@ 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

@@ -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

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