Compare commits

16 Commits

Author SHA1 Message Date
MangoPig
7d57792a82 Feat: Replace profile dock with server dock 2026-06-16 13:05:31 +01:00
MangoPig
f41dbc43fa Merge branch 'Features/Backend/Scaffolding' 2026-06-16 07:35:22 +01:00
MangoPig
76c24782c8 Feat: Backend scaffolding and local dev stack 2026-06-16 07:34:34 +01:00
MangoPig
4ebee9e695 Merge branch 'Features/Frontend/Adoptive-Theme' 2026-06-15 10:56:01 +01:00
MangoPig
a5ca826a6e Feat: Adoptive theme foundation 2026-06-15 10:54:55 +01:00
MangoPig
ecd214652a Merge branch 'Features/Frontend/Web-Loader' 2026-06-15 07:00:44 +01:00
MangoPig
99538e30c8 Feat: Web loader 2026-06-15 06:59:57 +01:00
MangoPig
90de5ca868 Merge branch 'Features/Frontend/Local-Prod-Proxy' 2026-06-15 05:09:39 +01:00
MangoPig
354dbc849b Feat: Local prod proxy 2026-06-15 05:09:13 +01:00
MangoPig
ddd25b6eb3 Merge branch 'Features/Frontend/Allowed-Hosts' 2026-06-14 15:23:31 +01:00
MangoPig
cc6243d630 Merge branch 'Features/Frontend/Frontend-Hardening' 2026-06-14 15:23:31 +01:00
MangoPig
9bceb2312d Merge branch 'Features/Frontend/App-Shell' 2026-06-14 15:23:31 +01:00
MangoPig
4c219c0084 Feat: Allowed hosts configuration 2026-06-14 15:23:14 +01:00
MangoPig
a75293fce4 Feat: Frontend hardening 2026-06-14 15:22:19 +01:00
MangoPig
883e8a8bcc Feat: Frontend app shell 2026-06-14 15:21:02 +01:00
MangoPig
282e6b604d Feat: Documentations
Fix
2026-06-14 03:07:51 +01:00
96 changed files with 4494 additions and 210 deletions

13
.dockerignore Normal file
View File

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

4
.gitignore vendored
View File

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

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

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

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

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

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

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

4
Backend/.dockerignore Normal file
View File

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

View File

36
Backend/Dockerfile Normal file
View File

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

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

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

View File

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

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

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

View File

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

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

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

View File

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

View File

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

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

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

24
Backend/go.mod Normal file
View File

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

70
Backend/go.sum Normal file
View File

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

View File

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

View File

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

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

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

View File

@@ -0,0 +1,79 @@
// Path: Backend/internal/config/config.go
package config
import (
"fmt"
"os"
"strings"
"time"
)
type Config struct {
AppName string
Environment string
LogLevel string
WebPort string
APIPort string
WorkerPort string
PostgresURL string
ValkeyURL string
ShutdownTimeout time.Duration
}
func Load() *Config {
return &Config{
AppName: getEnv("APP_NAME", "moku"),
Environment: getEnv("GO_ENV", "development"),
LogLevel: getEnv("LOG_LEVEL", "debug"),
WebPort: getEnv("BACKEND_WEB_PORT", "8080"),
APIPort: getEnv("BACKEND_API_PORT", "8081"),
WorkerPort: getEnv("BACKEND_WORKER_PORT", "8082"),
PostgresURL: getEnv("DATABASE_URL", "postgres://moku:moku_dev_password@localhost:5432/moku?sslmode=disable"),
ValkeyURL: getEnv("VALKEY_URL", "redis://localhost:6379/0"),
ShutdownTimeout: getDurationEnv("BACKEND_SHUTDOWN_TIMEOUT", 10*time.Second),
}
}
func (c *Config) Address(serviceName string) string {
var port string
switch strings.ToLower(serviceName) {
case "web":
port = c.WebPort
case "api":
port = c.APIPort
case "worker":
port = c.WorkerPort
default:
port = c.WebPort
}
return fmt.Sprintf(":%s", port)
}
func (c *Config) IsDevelopment() bool {
return strings.EqualFold(c.Environment, "development")
}
func getEnv(key, fallback string) string {
if value, exists := os.LookupEnv(key); exists {
return value
}
return fallback
}
func getDurationEnv(key string, fallback time.Duration) time.Duration {
value, exists := os.LookupEnv(key)
if !exists {
return fallback
}
parsed, err := time.ParseDuration(value)
if err != nil {
return fallback
}
return parsed
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

12
Backend/sqlc.yaml Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

View File

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

View File

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

171
Documentation/CONTRIBUTING Normal file
View File

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

90
Documentation/TODO.md Normal file
View File

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

13
Env/.env.example Normal file
View File

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

View File

@@ -1,5 +1,8 @@
# syntax=docker/dockerfile:1.7
# Frontend development image only.
# Production static serving is owned by Proxy/Local/Dockerfile.
FROM node:22-alpine AS base
WORKDIR /app
@@ -16,27 +19,11 @@ FROM dependencies AS development
ENV NODE_ENV=development
ENV PORT=3333
ARG ALLOWED_HOSTS
ENV ALLOWED_HOSTS=${ALLOWED_HOSTS}
COPY . .
EXPOSE 3333
CMD ["pnpm", "dev", "--host", "0.0.0.0", "--port", "3333"]
FROM dependencies AS build
ENV NODE_ENV=production
COPY . .
RUN pnpm build
FROM base AS production
ENV NODE_ENV=production
ENV PORT=3333
COPY --from=build /app /app
EXPOSE 3333
CMD ["pnpm", "start", "--host", "0.0.0.0", "--port", "3333"]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,46 @@
// Path: Frontend/src/components/shell/ServerDock/ServerDock.tsx
import { For, Show, type JSX } from "solid-js";
import { activeServer } from "../data/shell.data";
import styles from "./ServerDock.module.scss";
export const ServerDock = (): JSX.Element => {
return (
<section class={styles.panel} aria-label="Server dock">
<div class={styles.identity}>
<div class={styles.glyph} aria-hidden="true">
{activeServer.abbreviation}
</div>
<div class={styles.copy}>
<span class={styles.name}>{activeServer.name}</span>
<Show
when={activeServer.kind === "organization"}
fallback={<span class={styles.subtitle}>{activeServer.subtitle}</span>}
>
<span class={styles.status}>
<span class={styles.statusDot} aria-hidden="true" />
<span>{activeServer.connectedLabel}</span>
</span>
</Show>
</div>
</div>
<Show when={activeServer.dockActions.length > 0}>
<div class={styles.actions}>
<For each={activeServer.dockActions}>
{(item): JSX.Element => {
const Icon = item.icon;
return (
<button type="button" class={styles.action} aria-label={item.label} title={item.label}>
<Icon size={16} strokeWidth={2} />
<span class={styles.actionLabel}>{item.label}</span>
</button>
);
}}
</For>
</div>
</Show>
</section>
);
};

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,48 @@
// Path: Frontend/src/components/shell/WorkspaceSidebar/WorkspaceSidebar.tsx
import { For, Show, type JSX } from "solid-js";
import { workspaceSidebarItems } from "../data/shell.data";
import styles from "./WorkspaceSidebar.module.scss";
export const WorkspaceSidebar = (): JSX.Element => {
return (
<aside class={styles.sidebar} aria-label="Workspace navigation">
<div class={styles.header}>
<span class={styles.eyebrow}>Workspace</span>
<h2 class={styles.title}>Product Operations</h2>
<p class={styles.meta}>A barebone shell for Mokus first real workspace layout.</p>
</div>
<div class={styles.section}>
<span class={styles.sectionLabel}>Navigation</span>
<div class={styles.navScroller}>
<ul class={styles.navList} role="list">
<For each={workspaceSidebarItems}>
{(item): JSX.Element => {
const Icon = item.icon;
return (
<li>
<button
type="button"
classList={{
[styles.navItem]: true,
[styles.navItemActive]: !!item.active,
}}
>
<Icon class={styles.icon} size={18} strokeWidth={2} />
<span class={styles.label}>{item.label}</span>
<Show when={item.meta}>
<span class={styles.itemMeta}>{item.meta}</span>
</Show>
</button>
</li>
);
}}
</For>
</ul>
</div>
</div>
</aside>
);
};

View File

@@ -0,0 +1,83 @@
// Path: Frontend/src/components/shell/data/shell.data.ts
import type { Component } from "solid-js";
import { Bell, Folder, Home, LayoutGrid, Plus, Search, Settings, User } from "../../../lib/icons";
type ShellIconProps = {
class?: string;
size?: number;
strokeWidth?: number;
};
export type ShellIcon = Component<ShellIconProps>;
export type RailItem = {
id: string;
label: string;
abbreviation: string;
active?: boolean;
};
export type 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 SidebarItem = {
id: string;
label: string;
icon: ShellIcon;
active?: boolean;
meta?: string;
};
export type TopBarAction = {
id: string;
label: string;
icon: ShellIcon;
};
const organizationServerActions: readonly ServerDockAction[] = [
{ id: "members", label: "Members", icon: User },
{ id: "server", label: "Server", icon: Settings },
] as const;
export const railItems: readonly RailItem[] = [
{ id: "personal", label: "Personal", abbreviation: "P" },
{ id: "moku", label: "Moku", abbreviation: "M", active: true },
{ id: "labs", label: "Labs", abbreviation: "L" },
] as const;
export const activeServer: ActiveServer = {
id: "organization-server",
name: "Organization Name",
abbreviation: "O",
kind: "organization",
connectedLabel: "12 connected",
dockActions: organizationServerActions,
};
export const workspaceSidebarItems: readonly SidebarItem[] = [
{ id: "home", label: "Home", icon: Home, active: true },
{ id: "boards", label: "Boards", icon: LayoutGrid, meta: "0" },
{ id: "docs", label: "Docs", icon: Folder, meta: "0" },
{ id: "settings", label: "Settings", icon: Settings },
] as const;
export const topBarActions: readonly TopBarAction[] = [
{ id: "search", label: "Search", icon: Search },
{ id: "create", label: "Create", icon: Plus },
{ id: "inbox", label: "Inbox", icon: Bell },
{ id: "profile", label: "Profile", icon: User },
] as const;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,11 @@
// Path: Frontend/src/theme/presets.ts
import type { ThemeDefinition } from "./schema";
export const defaultThemePresetPath = "/themes/moku-default.json";
export const defaultThemePresetMeta = {
id: "moku-default",
name: "Moku Default",
description: "The baseline Moku theme preset, matching the current shell styling tokens.",
} satisfies Pick<ThemeDefinition, "id" | "name" | "description">;

View File

@@ -0,0 +1,129 @@
// Path: Frontend/src/theme/runtime.ts
import { defaultThemePresetPath } from "./presets";
import { createCssVariableMap, isThemeModeName, validateThemeDefinition, type ThemeDefinition, type ThemeModeName } from "./schema";
export type Theme = ThemeModeName;
export const THEME_STORAGE_KEY = "theme";
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 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;
setDocumentThemeMode(theme);
applyThemeVariables(themeDefinition, theme);
};
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(defaultThemePresetPath, {
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,342 @@
// 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", "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-success": modeTokens.colors.success,
"--color-danger": modeTokens.colors.danger,
"--color-warning": modeTokens.colors.warning,
"--color-focus-ring": modeTokens.colors.focusRing,
};
};

View File

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

View File

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

View File

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

35
Loader/README.md Normal file
View File

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

44
Loader/index.html Normal file
View File

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

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

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

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

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

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

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

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

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

32
Loader/scripts/intent.js Normal file
View File

@@ -0,0 +1,32 @@
// Path: Loader/scripts/intent.js
import { LOADER_DEFAULT_INTENT, LOADER_ROOT_PREFIX } from "./config.js";
export const getExplicitIntent = () => {
const params = new URLSearchParams(window.location.search);
const next = params.get("next");
if (typeof next === "string" && next.startsWith("/")) {
return next;
}
return null;
};
export const getCurrentPathIntent = () => {
const current = `${window.location.pathname}${window.location.search}${window.location.hash}`;
if (current === "/" || current === LOADER_ROOT_PREFIX || current === `${LOADER_ROOT_PREFIX}/` || current.startsWith(`${LOADER_ROOT_PREFIX}/`)) {
return LOADER_DEFAULT_INTENT;
}
return current;
};
export const resolveIntent = () => {
return getExplicitIntent() ?? getCurrentPathIntent();
};
export const resolveHandoffTarget = (intent) => {
return intent || LOADER_DEFAULT_INTENT;
};

26
Loader/scripts/main.js Normal file
View File

@@ -0,0 +1,26 @@
// Path: Loader/scripts/main.js
import { setIntentPreview, setStatus } from "./dom.js";
import { scheduleHandoff } from "./handoff.js";
import { resolveHandoffTarget, resolveIntent } from "./intent.js";
import { markLoaderSeen, persistLoaderState } from "./storage.js";
const bootLoader = () => {
const intent = resolveIntent();
const handoffTarget = resolveHandoffTarget(intent);
setStatus("Capturing route intent");
setIntentPreview(intent);
persistLoaderState(intent);
window.setTimeout(() => {
setStatus("Preparing handoff");
}, 180);
scheduleHandoff(handoffTarget, () => {
setStatus("Handing off to application");
markLoaderSeen();
});
};
bootLoader();

24
Loader/scripts/storage.js Normal file
View File

@@ -0,0 +1,24 @@
// Path: Loader/scripts/storage.js
import { LOADER_INTENT_STORAGE_KEY, LOADER_META_STORAGE_KEY, LOADER_SEEN_COOKIE_MAX_AGE_SECONDS, LOADER_SEEN_COOKIE_NAME } from "./config.js";
import { collectClientHints } from "./hints.js";
export const persistLoaderState = (intent) => {
const payload = {
intent,
hints: collectClientHints(),
};
try {
window.sessionStorage.setItem(LOADER_INTENT_STORAGE_KEY, intent);
window.sessionStorage.setItem(LOADER_META_STORAGE_KEY, JSON.stringify(payload));
} catch (error) {
console.warn("[Loader] Unable to persist bootstrap state.", error);
}
return payload;
};
export const markLoaderSeen = () => {
document.cookie = `${LOADER_SEEN_COOKIE_NAME}=1; Path=/; Max-Age=${LOADER_SEEN_COOKIE_MAX_AGE_SECONDS}; SameSite=Lax`;
};

177
Loader/styles.css Normal file
View File

@@ -0,0 +1,177 @@
/* Path: Loader/styles.css */
:root {
color-scheme: dark;
--loader-bg: #07111f;
--loader-panel: rgba(15, 23, 42, 0.92);
--loader-panel-border: rgba(148, 163, 184, 0.18);
--loader-panel-highlight: rgba(255, 255, 255, 0.04);
--loader-text: #f8fafc;
--loader-text-muted: #94a3b8;
--loader-accent: #60a5fa;
--loader-accent-soft: rgba(96, 165, 250, 0.16);
--loader-shadow: 0 24px 64px rgba(2, 6, 23, 0.42);
--loader-radius: 1.25rem;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
min-height: 100%;
}
body {
min-height: 100dvh;
font-family:
Inter,
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
background: radial-gradient(circle at top, rgba(37, 99, 235, 0.18), transparent 36%), linear-gradient(180deg, #091528 0%, var(--loader-bg) 100%);
color: var(--loader-text);
}
.loader-shell {
min-height: 100dvh;
display: grid;
place-items: center;
padding: 1.5rem;
}
.loader-panel {
width: min(100%, 30rem);
padding: 1.5rem;
border: 1px solid var(--loader-panel-border);
border-radius: var(--loader-radius);
background: linear-gradient(180deg, var(--loader-panel-highlight), transparent 16%), var(--loader-panel);
box-shadow: var(--loader-shadow);
backdrop-filter: blur(18px);
}
.loader-eyebrow {
margin: 0 0 0.625rem;
color: var(--loader-text-muted);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.loader-title {
margin: 0;
font-size: clamp(1.75rem, 5vw, 2.25rem);
line-height: 1.05;
}
.loader-copy {
margin: 0.875rem 0 0;
max-width: 30ch;
color: var(--loader-text-muted);
font-size: 0.975rem;
line-height: 1.55;
}
.loader-indicators {
margin-top: 1.25rem;
display: inline-flex;
gap: 0.5rem;
}
.loader-dot {
width: 0.625rem;
height: 0.625rem;
border-radius: 999px;
background: var(--loader-accent-soft);
animation: loader-pulse 1.4s ease-in-out infinite;
}
.loader-dot:nth-child(2) {
animation-delay: 0.18s;
}
.loader-dot:nth-child(3) {
animation-delay: 0.36s;
}
.loader-meta {
margin: 1.5rem 0 0;
padding-top: 1rem;
border-top: 1px solid rgba(148, 163, 184, 0.12);
}
.loader-meta-row {
display: grid;
grid-template-columns: 4.5rem minmax(0, 1fr);
gap: 0.75rem;
align-items: start;
}
.loader-meta-row + .loader-meta-row {
margin-top: 0.625rem;
}
.loader-meta dt {
color: var(--loader-text-muted);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.loader-meta dd {
margin: 0;
min-width: 0;
color: var(--loader-text);
font-size: 0.925rem;
line-height: 1.45;
overflow-wrap: anywhere;
}
.loader-noscript {
position: fixed;
right: 1rem;
bottom: 1rem;
left: 1rem;
display: grid;
gap: 0.375rem;
padding: 0.875rem 1rem;
border: 1px solid rgba(248, 113, 113, 0.28);
border-radius: 0.875rem;
background: rgba(69, 10, 10, 0.92);
color: #fecaca;
}
@keyframes loader-pulse {
0%,
100% {
transform: translateY(0);
background: var(--loader-accent-soft);
}
50% {
transform: translateY(-0.125rem);
background: var(--loader-accent);
}
}
@media (max-width: 47.99rem) {
.loader-shell {
padding: 1rem;
}
.loader-panel {
padding: 1.25rem;
}
.loader-meta-row {
grid-template-columns: 1fr;
gap: 0.25rem;
}
}

99
Proxy/Local/Dockerfile Normal file
View File

@@ -0,0 +1,99 @@
# syntax=docker/dockerfile:1.7
## Frontend
## =========================
ARG NGINX_VERSION=1.29.8
FROM node:22-alpine AS frontend-dependencies
WORKDIR /workspace/Frontend
RUN corepack enable && corepack prepare pnpm@9.0.0 --activate
COPY Frontend/package.json Frontend/pnpm-lock.yaml Frontend/tsconfig.json Frontend/vite.config.ts ./
RUN pnpm install --frozen-lockfile
FROM frontend-dependencies AS frontend-build
ENV NODE_ENV=production
COPY Frontend/ ./
RUN pnpm build:static
## Proxy
## =========================
FROM alpine:3.22 AS precompressed-assets
RUN apk add --no-cache brotli gzip
WORKDIR /workspace
COPY Loader ./html/__loader
COPY --from=frontend-build /workspace/Frontend/dist/client ./html
RUN find ./html -type f \( \
-name '*.html' -o \
-name '*.css' -o \
-name '*.js' -o \
-name '*.json' -o \
-name '*.svg' -o \
-name '*.txt' -o \
-name '*.xml' -o \
-name '*.webmanifest' \
\) -exec gzip -kf -9 {} \; \
&& find ./html -type f \( \
-name '*.html' -o \
-name '*.css' -o \
-name '*.js' -o \
-name '*.json' -o \
-name '*.svg' -o \
-name '*.txt' -o \
-name '*.xml' -o \
-name '*.webmanifest' \
\) -exec brotli -kf -Z {} \;
FROM alpine:3.22 AS brotli-modules
ARG NGINX_VERSION
RUN apk add --no-cache \
alpine-sdk \
cmake \
git \
linux-headers \
openssl-dev \
pcre2-dev \
wget \
zlib-dev
WORKDIR /tmp
RUN wget -O "nginx-${NGINX_VERSION}.tar.gz" "https://nginx.org/download/nginx-${NGINX_VERSION}.tar.gz" \
&& tar -xzf "nginx-${NGINX_VERSION}.tar.gz" \
&& git clone --recurse-submodules https://github.com/google/ngx_brotli.git
WORKDIR /tmp/nginx-${NGINX_VERSION}
RUN cd /tmp/ngx_brotli/deps/brotli \
&& mkdir -p out \
&& cd out \
&& cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=OFF -DCMAKE_C_FLAGS="-fPIC" -DCMAKE_CXX_FLAGS="-fPIC" .. \
&& cmake --build . --config Release -j"$(getconf _NPROCESSORS_ONLN)" \
&& cd /tmp/nginx-${NGINX_VERSION} \
&& ./configure --with-compat --add-dynamic-module=/tmp/ngx_brotli \
&& make modules
FROM nginx:${NGINX_VERSION}-alpine AS production
COPY Proxy/Local/nginx.conf /etc/nginx/nginx.conf
COPY Proxy/Local/default.conf /etc/nginx/conf.d/default.conf
COPY --from=brotli-modules /tmp/nginx-${NGINX_VERSION}/objs/ngx_http_brotli_filter_module.so /usr/lib/nginx/modules/
COPY --from=brotli-modules /tmp/nginx-${NGINX_VERSION}/objs/ngx_http_brotli_static_module.so /usr/lib/nginx/modules/
COPY --from=precompressed-assets /workspace/html /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

39
Proxy/Local/default.conf Normal file
View File

@@ -0,0 +1,39 @@
map $http_cookie $moku_bootstrap_document {
default /__loader/index.html;
"~*(^|; )moku_loader_seen=1($|;)" /index.html;
}
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location = / {
add_header Cache-Control "no-store";
rewrite ^ $moku_bootstrap_document last;
}
location ^~ /__loader/ {
access_log off;
add_header Cache-Control "no-store";
try_files $uri =404;
}
location / {
try_files $uri $uri/ $moku_bootstrap_document;
}
location /favicon.ico {
try_files $uri =404;
access_log off;
log_not_found off;
}
location /_build/ {
access_log off;
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
}

60
Proxy/Local/nginx.conf Normal file
View File

@@ -0,0 +1,60 @@
load_module /usr/lib/nginx/modules/ngx_http_brotli_filter_module.so;
load_module /usr/lib/nginx/modules/ngx_http_brotli_static_module.so;
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
keepalive_timeout 65;
gzip on;
gzip_static on;
gzip_vary on;
gzip_proxied any;
gzip_min_length 1024;
gzip_comp_level 6;
gzip_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/manifest+json
application/xml
application/rss+xml
image/svg+xml;
brotli on;
brotli_static on;
brotli_comp_level 5;
brotli_min_length 1024;
brotli_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/manifest+json
application/xml
application/rss+xml
image/svg+xml;
include /etc/nginx/conf.d/*.conf;
}

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

@@ -0,0 +1,36 @@
variable "REGISTRY" {
default = "registry.example.com"
}
variable "TAG" {
default = "latest"
}
target "_proxy" {
context = "."
dockerfile = "Proxy/Local/Dockerfile"
}
target "prod" {
inherits = ["_proxy"]
target = "production"
tags = ["moku/work-proxy:local-prod"]
}
target "prod-image" {
inherits = ["_proxy"]
target = "production"
tags = ["${REGISTRY}/moku/work-proxy:prod-${TAG}"]
}
group "local" {
targets = ["prod"]
}
group "registry" {
targets = ["prod-image"]
}
group "default" {
targets = ["prod"]
}

View File

@@ -1,27 +1,4 @@
# Moku Base
Empty scaffold for a Monday-style replacement app.
## Structure
- `Backend/` - blank backend placeholder
- `Commands/` - Just submodules and command entrypoints
- `Docker/` - local compose files
- `Env/` - local environment files
- `Frontend/` - SolidStart bare workspace shell
- `Proxy/` - blank proxy placeholder
## Local usage
```bash
just --list --list-submodules
```
Main local flow:
```bash
just local dev
```
# Moku Work
## License