diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..c0d6db3
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "tools/ui-design-code"]
+ path = tools/ui-design-code
+ url = ../ui-design-code
diff --git a/README.md b/README.md
index 32db7fa..731380c 100644
--- a/README.md
+++ b/README.md
@@ -3,3 +3,18 @@
Architecture documentation was moved to the Project Bible:
- `bible/README.md`
+
+## UI design-code integration
+
+`core` now serves UI theme assets from `ui-design-code` at runtime:
+
+- CSS: `/ui-design/static/css/theme.css`
+- JS: `/ui-design/static/js/app.js`
+
+Path resolution order for `ui-design-code`:
+
+1. `UI_DESIGN_CODE_DIR` env var
+2. `./tools/ui-design-code`
+3. `../ui-design-code`
+
+This lets UI updates in `ui-design-code` appear in `core` without copying files.
diff --git a/internal/api/ui.go b/internal/api/ui.go
index 888424f..a7f537f 100644
--- a/internal/api/ui.go
+++ b/internal/api/ui.go
@@ -161,6 +161,7 @@ type uiHandlers struct {
func RegisterUIRoutes(mux *http.ServeMux, deps UIDependencies) {
h := uiHandlers{deps: deps}
+ registerUIDesignRoutes(mux)
mux.HandleFunc("/", h.handleStart)
mux.HandleFunc("/ui", h.handleIndex)
mux.HandleFunc("/ui/", h.handleIndex)
diff --git a/internal/api/ui_design_assets.go b/internal/api/ui_design_assets.go
new file mode 100644
index 0000000..3f1f068
--- /dev/null
+++ b/internal/api/ui_design_assets.go
@@ -0,0 +1,134 @@
+package api
+
+import (
+ "errors"
+ "fmt"
+ "log"
+ "net/http"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+)
+
+type uiDesignAssets struct {
+ RootPath string
+ CSSPath string
+ JSPath string
+}
+
+func registerUIDesignRoutes(mux *http.ServeMux) {
+ assets, err := resolveUIDesignAssets()
+ if err != nil {
+ log.Printf("ui design assets: %v", err)
+ return
+ }
+ log.Printf("ui design assets: using %s", assets.RootPath)
+
+ mux.HandleFunc("/ui-design/static/css/theme.css", func(w http.ResponseWriter, r *http.Request) {
+ serveUIDesignFile(w, r, "/ui-design/static/css/theme.css", assets.CSSPath, "text/css; charset=utf-8")
+ })
+ if assets.JSPath != "" {
+ mux.HandleFunc("/ui-design/static/js/app.js", func(w http.ResponseWriter, r *http.Request) {
+ serveUIDesignFile(w, r, "/ui-design/static/js/app.js", assets.JSPath, "application/javascript; charset=utf-8")
+ })
+ }
+}
+
+func serveUIDesignFile(w http.ResponseWriter, r *http.Request, routePath, filePath, contentType string) {
+ if r.URL.Path != routePath {
+ http.NotFound(w, r)
+ return
+ }
+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
+ w.WriteHeader(http.StatusMethodNotAllowed)
+ return
+ }
+ if _, err := os.Stat(filePath); err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Cache-Control", "no-store")
+ w.Header().Set("Content-Type", contentType)
+ http.ServeFile(w, r, filePath)
+}
+
+func resolveUIDesignAssets() (uiDesignAssets, error) {
+ roots := candidateUIDesignRoots()
+ if len(roots) == 0 {
+ return uiDesignAssets{}, errors.New("no ui-design-code path candidates")
+ }
+
+ cssSuffixes := []string{
+ filepath.Join("demo", "web", "static", "css", "app.css"),
+ filepath.Join("kit", "patterns", "theme-vapor", "static", "vapor.css"),
+ filepath.Join("kit", "patterns", "theme-aqua-legacy", "demo-aqua-freeze.css"),
+ }
+ jsSuffixes := []string{
+ filepath.Join("demo", "web", "static", "js", "app.js"),
+ }
+
+ for _, root := range roots {
+ cssPath := firstExistingFile(root, cssSuffixes)
+ if cssPath == "" {
+ continue
+ }
+ jsPath := firstExistingFile(root, jsSuffixes)
+ return uiDesignAssets{
+ RootPath: root,
+ CSSPath: cssPath,
+ JSPath: jsPath,
+ }, nil
+ }
+
+ return uiDesignAssets{}, fmt.Errorf("ui-design-code not found in candidates: %s", strings.Join(roots, ", "))
+}
+
+func candidateUIDesignRoots() []string {
+ seen := make(map[string]struct{})
+ var roots []string
+
+ push := func(path string) {
+ if strings.TrimSpace(path) == "" {
+ return
+ }
+ abs, err := filepath.Abs(path)
+ if err != nil {
+ return
+ }
+ abs = filepath.Clean(abs)
+ if _, ok := seen[abs]; ok {
+ return
+ }
+ info, err := os.Stat(abs)
+ if err != nil || !info.IsDir() {
+ return
+ }
+ seen[abs] = struct{}{}
+ roots = append(roots, abs)
+ }
+
+ push(os.Getenv("UI_DESIGN_CODE_DIR"))
+ push(filepath.Join("tools", "ui-design-code"))
+ push(filepath.Join("..", "ui-design-code"))
+
+ if _, file, _, ok := runtime.Caller(0); ok {
+ apiDir := filepath.Dir(file)
+ coreRoot := filepath.Clean(filepath.Join(apiDir, "..", ".."))
+ push(filepath.Join(coreRoot, "tools", "ui-design-code"))
+ push(filepath.Join(coreRoot, "..", "ui-design-code"))
+ }
+
+ return roots
+}
+
+func firstExistingFile(root string, suffixes []string) string {
+ for _, suffix := range suffixes {
+ path := filepath.Join(root, suffix)
+ info, err := os.Stat(path)
+ if err == nil && !info.IsDir() {
+ return path
+ }
+ }
+ return ""
+}
diff --git a/internal/api/ui_shared.tmpl b/internal/api/ui_shared.tmpl
index 540b2af..0fa6a5d 100644
--- a/internal/api/ui_shared.tmpl
+++ b/internal/api/ui_shared.tmpl
@@ -822,6 +822,8 @@
}
}
+
+