aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-07-15 05:10:40 -0400
committerhistoria <[not public]>2026-07-15 05:10:40 -0400
commit84dd8c7557d8b9d92cd8d339afc28a0fd9a3a896 (patch)
tree60c47059d6776e7b91fb516ebdb04a0932c9aa59
parentbeb188ce050fb76cdddc0bbeedee7ec608e47995 (diff)
downloadthehouseoficarus-84dd8c7557d8b9d92cd8d339afc28a0fd9a3a896.tar.gz
feat(admin): color scheme test page added
-rw-r--r--cmd/thoi/main.go4
-rw-r--r--internal/admin/api_colors.go430
-rw-r--r--internal/admin/api_colors_test.go159
-rw-r--r--internal/admin/server.go8
-rw-r--r--internal/admin/static/admin.css37
-rw-r--r--internal/admin/static/coloreditor.js570
-rw-r--r--internal/admin/templates/colors.html37
-rw-r--r--internal/admin/templates/layout.html2
8 files changed, 1244 insertions, 3 deletions
diff --git a/cmd/thoi/main.go b/cmd/thoi/main.go
index e5f44f7..4608536 100644
--- a/cmd/thoi/main.go
+++ b/cmd/thoi/main.go
@@ -77,7 +77,7 @@ func main() {
g.SetHub(server.Hub())
if cfg.AdminHTTP.Enabled {
- adminSrv, err := admin.NewServer(cfg, false, g.AccountStore, g.World, g.ItemStore, g.ObjectStore, g.MobStore, dataDir, g.CourseStore.ReloadAll, g.RewriteRoomIDs)
+ adminSrv, err := admin.NewServer(cfg, false, g.AccountStore, g.World, g.ItemStore, g.ObjectStore, g.MobStore, dataDir, g.CourseStore.ReloadAll, g.RewriteRoomIDs, *configPath)
if err != nil {
log.Fatalf("failed to start admin HTTP server: %v", err)
}
@@ -90,7 +90,7 @@ func main() {
}
if cfg.AdminHTTPS.Enabled {
- adminSrv, err := admin.NewServer(cfg, true, g.AccountStore, g.World, g.ItemStore, g.ObjectStore, g.MobStore, dataDir, g.CourseStore.ReloadAll, g.RewriteRoomIDs)
+ adminSrv, err := admin.NewServer(cfg, true, g.AccountStore, g.World, g.ItemStore, g.ObjectStore, g.MobStore, dataDir, g.CourseStore.ReloadAll, g.RewriteRoomIDs, *configPath)
if err != nil {
log.Fatalf("failed to start admin HTTPS server: %v", err)
}
diff --git a/internal/admin/api_colors.go b/internal/admin/api_colors.go
new file mode 100644
index 0000000..78e5f98
--- /dev/null
+++ b/internal/admin/api_colors.go
@@ -0,0 +1,430 @@
+package admin
+
+import (
+ "net/http"
+ "os"
+ "sort"
+ "strings"
+
+ "gopkg.in/yaml.v3"
+
+ "thehouseoficarus/internal/color"
+ "thehouseoficarus/internal/config"
+)
+
+// colorCategory describes one editable color category: its config key, a
+// human-friendly label, and a UI grouping.
+type colorCategory struct {
+ Name string `json:"name"`
+ Label string `json:"label"`
+ Group string `json:"group"`
+}
+
+// colorSegment is one piece of preview text tagged with the category whose
+// color spec should paint it. A nil/empty category means plain (uncolored).
+type colorSegment struct {
+ T string `json:"t"` // text
+ C string `json:"c"` // category name, "" = plain
+}
+
+// colorLine groups segments into one rendered line.
+type colorLine struct {
+ Segments []colorSegment `json:"segments"`
+}
+
+// colorSection is a titled block of example output.
+type colorSection struct {
+ Title string `json:"title"`
+ Lines []colorLine `json:"lines"`
+}
+
+// colorCategories is the curated, grouped, ordered master list mirroring the
+// keys produced by config.DefaultColors(). Editing here is the single source
+// of truth for the admin color themer's left-side list.
+var colorCategories = []colorCategory{
+ // Room / look
+ {Name: "room_name", Label: "Room Name", Group: "Room"},
+ {Name: "room_number", Label: "Room Number", Group: "Room"},
+ {Name: "room_desc", Label: "Room Description", Group: "Room"},
+ {Name: "exit_direction", Label: "Exit Direction", Group: "Room"},
+ {Name: "exit_name", Label: "Exit Name", Group: "Room"},
+ {Name: "mob", Label: "Mob", Group: "Room"},
+ {Name: "protected_mob", Label: "Protected Mob", Group: "Room"},
+ {Name: "player_name", Label: "Player Name", Group: "Room"},
+ {Name: "item", Label: "Item", Group: "Room"},
+ {Name: "dim", Label: "Dim / Brackets", Group: "Room"},
+
+ // Movement
+ {Name: "direction", Label: "Move Direction", Group: "Movement"},
+
+ // Combat
+ {Name: "damage_dealt", Label: "Damage Dealt", Group: "Combat"},
+ {Name: "damage_taken", Label: "Damage Taken", Group: "Combat"},
+ {Name: "damage", Label: "Damage (alt key)", Group: "Combat"},
+ {Name: "miss", Label: "Miss", Group: "Combat"},
+ {Name: "death", Label: "Death", Group: "Combat"},
+ {Name: "victory", Label: "Victory", Group: "Combat"},
+ {Name: "warning", Label: "Warning", Group: "Combat"},
+ {Name: "error", Label: "Error", Group: "Combat"},
+ {Name: "science_mod", Label: "Science Mod", Group: "Combat"},
+
+ // XP / level
+ {Name: "xp", Label: "XP Drop", Group: "XP / Level"},
+ {Name: "level_up", Label: "Level Up", Group: "XP / Level"},
+
+ // Pickups / economy
+ {Name: "credits_pickup", Label: "Credits Pickup", Group: "Pickups / Economy"},
+ {Name: "chips_pickup", Label: "Chips Pickup", Group: "Pickups / Economy"},
+
+ // Chat / dialog
+ {Name: "say", Label: "Say", Group: "Chat / Dialog"},
+ {Name: "global", Label: "Global", Group: "Chat / Dialog"},
+ {Name: "broadcast", Label: "Broadcast", Group: "Chat / Dialog"},
+ {Name: "dialog", Label: "Dialog", Group: "Chat / Dialog"},
+ {Name: "dialog_options", Label: "Dialog Options", Group: "Chat / Dialog"},
+
+ // Drops / consume / fire
+ {Name: "drop_message", Label: "Drop Message", Group: "Drops / Consume / Fire"},
+ {Name: "eat_food", Label: "Eat Food", Group: "Drops / Consume / Fire"},
+ {Name: "fire", Label: "Fire", Group: "Drops / Consume / Fire"},
+
+ // Tick / status
+ {Name: "visual_tick", Label: "Visual Tick", Group: "Tick / Status"},
+ {Name: "visual_first_tick", Label: "Visual First Tick", Group: "Tick / Status"},
+
+ // Tech / farm
+ {Name: "battery", Label: "Battery", Group: "Tech / Farm"},
+ {Name: "tech_depleted", Label: "Tech Depleted", Group: "Tech / Farm"},
+ {Name: "farm_grow", Label: "Farm Grow", Group: "Tech / Farm"},
+ {Name: "farm_disease", Label: "Farm Disease", Group: "Tech / Farm"},
+
+ // Assassin / map / safespot
+ {Name: "assassin_task", Label: "Assassin Task", Group: "Assassin / Map / Safespot"},
+ {Name: "map_at", Label: "Map At (you)", Group: "Assassin / Map / Safespot"},
+ {Name: "map_blocked", Label: "Map Blocked", Group: "Assassin / Map / Safespot"},
+ {Name: "map_course", Label: "Map Course", Group: "Assassin / Map / Safespot"},
+ {Name: "safespot_alert", Label: "Safespot Alert", Group: "Assassin / Map / Safespot"},
+
+ // System
+ {Name: "sequence", Label: "Sequence (trigger)", Group: "System"},
+}
+
+// knownColor returns true if name is one of the editable categories.
+func knownColor(name string) bool {
+ for _, c := range colorCategories {
+ if c.Name == name {
+ return true
+ }
+ }
+ return false
+}
+
+// seg is a small constructor helper for the inline preview builder.
+func seg(t, cat string) colorSegment {
+ return colorSegment{T: t, C: cat}
+}
+
+// ln builds a line from a sequence of segments.
+func ln(segs ...colorSegment) colorLine {
+ return colorLine{Segments: segs}
+}
+
+// txt builds a plain (uncolored) line.
+func txt(s string) colorLine {
+ return colorLine{Segments: []colorSegment{{T: s}}}
+}
+
+// buildPreview constructs the example output covering every color category.
+// Text patterns match real in-game output (room looks, combat, pickups, chat,
+// etc.) so admins see exactly what each color key paints.
+func buildPreview() []colorSection {
+ var sections []colorSection
+
+ // --- A full room "look" ----------------------------------------------
+ sections = append(sections, colorSection{
+ Title: "Room Look",
+ Lines: []colorLine{
+ ln(seg("[", "dim"), seg("*", "map_at"), seg("] ", "dim"),
+ seg("Test Room", "room_name"),
+ seg(" (", ""), seg("intro", "room_desc"), seg(", ", ""),
+ seg("#2001", "room_number"), seg(")", "")),
+ txt(""),
+ ln(seg("It's a test room", "room_desc")),
+ txt(""),
+ ln(seg("A tree is here.", "room_desc")),
+ txt(""),
+ ln(seg("A sentry ", "protected_mob"), seg("(level 4) watches the gate.", "")),
+ ln(seg("A man ", "mob"), seg("(level 5) scribbles something in a small notebook.", "")),
+ txt(""),
+ ln(seg("Icarus", "player_name"), seg(" is here.", "")),
+ txt(""),
+ txt("On the ground:"),
+ ln(seg(" bones ", "item"), seg("(reserved for admin 65t)", "dim")),
+ ln(seg(" bronze axe", "item")),
+ ln(seg(" bucket of water", "item")),
+ ln(seg(" 10 x chips ", "item"), seg("(reserved for admin 65t)", "dim")),
+ ln(seg(" pot of flour", "item")),
+ txt(""),
+ txt("Exits:"),
+ ln(seg(" ", ""), seg("south", "exit_direction"), seg(" - ", ""), seg("Test Room", "exit_name")),
+ ln(seg(" ", ""), seg("east", "exit_direction"), seg(" - ", ""), seg("Room ", "exit_name"), seg("#1005", "room_number")),
+ ln(seg(" ", ""), seg("west", "exit_direction"), seg(" - ", ""), seg("Inside Transport Shuttle", "exit_name")),
+ txt(""),
+ ln(seg(">", "dim")),
+ },
+ })
+
+ // --- Movement ---------------------------------------------------------
+ sections = append(sections, colorSection{
+ Title: "Movement",
+ Lines: []colorLine{
+ ln(seg("You walk ", ""), seg("south", "direction"), seg(" to ", ""),
+ seg("Test Room", "room_name"), seg(" (", ""), seg("#2001", "room_number"), seg(")", "")),
+ ln(seg("WARNING: that area is exposed to ", ""), seg("radiation", "warning"), seg(".", "")),
+ },
+ })
+
+ // --- Combat rounds (matches real output) ------------------------------
+ sections = append(sections, colorSection{
+ Title: "Combat (two encounters)",
+ Lines: []colorLine{
+ txt(""),
+ ln(seg("> attack man", "dim")),
+ txt(""),
+ ln(seg("You attack ", ""), seg("the man", "mob"), seg("! Style: accurate (+3 atk)", "")),
+ ln(seg("You hit ", ""), seg("the man", "mob"), seg(" for ", ""),
+ seg("\u25B8 6 \u25C2 damage. [\u2588\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591] [ 1/7]", "damage_dealt"),
+ seg(" (+18xp acc, +6xp hp)", "xp")),
+ ln(seg("The man", "mob"), seg(" misses you.", "miss")),
+ ln(seg("You hit ", ""), seg("the man", "mob"), seg(" for ", ""),
+ seg("\u25B8 6 \u25C2 damage. [\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591] [ 0/7]", "damage_dealt"),
+ seg(" (+18xp acc, +6xp hp)", "xp")),
+ ln(seg("You have defeated ", ""), seg("the man", "mob"), seg("!", "victory")),
+ ln(seg("*** You are now level 12 melee! ***", "level_up")),
+ txt(""),
+ ln(seg("A scout", "mob"), seg(" hits you for ", ""),
+ seg("\u25B8 5 \u25C2 damage. [\u2588\u2588\u2588\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591] [22/40]", "damage_taken")),
+ ln(seg("A scout's attack is extra effective! Equip ", ""),
+ seg("plated vest", "warning"), seg(" for protection.", "")),
+ ln(seg("You attack ", ""), seg("a scout", "mob"), seg(" with ", ""), seg("laser cutter", "science_mod"), seg("!", "")),
+ ln(seg("You miss ", ""), seg("a scout", "mob"), seg(".", "miss")),
+ ln(seg("Oh dear, you are dead!", "death")),
+ ln(seg("Error: command not recognized.", "error")),
+ txt(""),
+ ln(seg("(", ""), seg("damage", "damage"), seg(" is a config-only alt key, not emitted by the engine)", "")),
+ },
+ })
+
+ // --- Pickups / economy ------------------------------------------------
+ sections = append(sections, colorSection{
+ Title: "Pickups & Economy",
+ Lines: []colorLine{
+ ln(seg("You pick up ", ""), seg("50 credits. (total: 1234)", "credits_pickup")),
+ ln(seg("You steal ", ""), seg("25 credits", "credits_pickup"), seg(".", "")),
+ ln(seg("Score \u2502 ", ""), seg("12 chips", "chips_pickup")),
+ },
+ })
+
+ // --- Chat / dialog ----------------------------------------------------
+ sections = append(sections, colorSection{
+ Title: "Chat & Dialog",
+ Lines: []colorLine{
+ ln(seg("You say: ", ""), seg("hello there", "say")),
+ ln(seg("[Global] ", "global"), seg("Icarus: ", "player_name"), seg("anyone up for a raid?", "global")),
+ ln(seg("Icarus", "player_name"), seg(" has slain ", ""),
+ seg("a scout", "broadcast"), seg(" (level 5)!", "broadcast")),
+ txt(""),
+ ln(seg("Guard says: ", ""), seg("\u201cHalt. State your business.\u201d", "dialog")),
+ ln(seg("[enter to continue]", "dialog_options")),
+ ln(seg(" 1. ", "dialog_options"), seg("I'm looking for work.", "dialog")),
+ ln(seg(" 2. ", "dialog_options"), seg("Just passing through.", "dialog")),
+ },
+ })
+
+ // --- Drops / consume / fire ------------------------------------------
+ sections = append(sections, colorSection{
+ Title: "Drops, Consume & Fire",
+ Lines: []colorLine{
+ ln(seg("The man drops: ", "drop_message"), seg("bones", "item")),
+ ln(seg("The man drops: ", "drop_message"),
+ seg("10", "credits_pickup"), seg(" x ", ""), seg("chips", "item")),
+ ln(seg("You eat the bread. It restores some energy.", "eat_food")),
+ ln(seg("You manage to get a fire going!", "fire")),
+ },
+ })
+
+ // --- Tick / tech / farm ----------------------------------------------
+ sections = append(sections, colorSection{
+ Title: "Tick, Tech & Farm",
+ Lines: []colorLine{
+ ln(seg("tick 1", "visual_first_tick")),
+ ln(seg("tick 2", "visual_tick")),
+ ln(seg("tick 3", "visual_tick")),
+ ln(seg("Your battery is depleted! All tech has been disabled.", "tech_depleted")),
+ ln(seg("You connect to the charging station. Your battery is fully recharged.", "battery")),
+ ln(seg("Your marigold has grown to stage 2/4.", "farm_grow")),
+ ln(seg("Your marigold has become diseased!", "farm_disease")),
+ },
+ })
+
+ // --- Assassin / safespot ---------------------------------------------
+ sections = append(sections, colorSection{
+ Title: "Assassin & Safespot",
+ Lines: []colorLine{
+ ln(seg("Your target: 15 slugs. Get to work.", "assassin_task")),
+ ln(seg("*** Assassin task complete! ***", "assassin_task"),
+ seg(" (+50xp asm)", "xp")),
+ ln(seg(" Assassin task: 12 of 15 slugs remaining.", "assassin_task")),
+ ln(seg("Your safespot has been compromised!", "safespot_alert")),
+ ln(seg("Your divine power prevents death.", "miss")),
+ },
+ })
+
+ // --- Map (mini map side panel) ---------------------------------------
+ sections = append(sections, colorSection{
+ Title: "Mini Map",
+ Lines: []colorLine{
+ ln(seg("\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557", "dim")),
+ ln(seg("\u2551", "dim"), seg("\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591", "map_blocked"), seg("\u2551", "dim")),
+ ln(seg("\u2551", "dim"), seg("\u2591\u2591", "map_blocked"), seg("@", "map_at"), seg("\u2591\u2591", "map_blocked"), seg("\u2502", "map_course"), seg("\u2591\u2591\u2591\u2591", "map_blocked"), seg("\u2551", "dim")),
+ ln(seg("\u2551", "dim"), seg("\u2591\u2591\u2591\u2591\u2591", "map_blocked"), seg("\u2502", "map_course"), seg("\u2591\u2591\u2591\u2591", "map_blocked"), seg("\u2551", "dim")),
+ ln(seg("\u2551", "dim"), seg("\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591\u2591", "map_blocked"), seg("\u2551", "dim")),
+ ln(seg("\u255a\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255d", "dim")),
+ ln(seg(" ", ""), seg("Room #2002", "room_number")),
+ },
+ })
+
+ // --- Trigger sequence / system ---------------------------------------
+ sections = append(sections, colorSection{
+ Title: "Triggers & System",
+ Lines: []colorLine{
+ ln(seg("The ancient console hums to life as you approach.", "sequence")),
+ ln(seg(" A holographic display flickers. \u201cWelcome, traveler.\u201d", "sequence")),
+ },
+ })
+
+ return sections
+}
+
+func (s *AdminServer) handleColors(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case http.MethodGet:
+ s.getColors(w, r)
+ case http.MethodPost:
+ s.saveColors(w, r)
+ default:
+ writeJSONError(w, "method not allowed", http.StatusMethodNotAllowed)
+ }
+}
+
+func (s *AdminServer) getColors(w http.ResponseWriter, _ *http.Request) {
+ live := make(map[string]string, len(colorCategories))
+ for _, c := range colorCategories {
+ if v, ok := s.cfg.DefaultColors[c.Name]; ok {
+ live[c.Name] = v
+ } else {
+ live[c.Name] = ""
+ }
+ }
+ defaults := config.DefaultColors()
+ def := make(map[string]string, len(colorCategories))
+ for _, c := range colorCategories {
+ if v, ok := defaults[c.Name]; ok {
+ def[c.Name] = v
+ } else {
+ def[c.Name] = ""
+ }
+ }
+ writeJSON(w, map[string]any{
+ "colors": live,
+ "default_colors": def,
+ "categories": colorCategories,
+ "preview": buildPreview(),
+ })
+}
+
+func (s *AdminServer) saveColors(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Colors map[string]string `json:"colors"`
+ }
+ if err := readJSON(r, &body); err != nil {
+ writeJSONError(w, "invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ // Validate every supplied key/spec against the known config grammar.
+ for name, val := range body.Colors {
+ if !knownColor(name) {
+ writeJSONError(w, "unknown color category: "+name, http.StatusBadRequest)
+ return
+ }
+ // "off" or empty is explicitly allowed (NoColor). Anything else must
+ // parse to a non-empty ColorSpec whose only unknown we tolerate is none.
+ spec := color.Parse(val)
+ if strings.TrimSpace(val) != "" && val != "off" && spec.Empty() {
+ writeJSONError(w, "invalid color spec for "+name+": "+val, http.StatusBadRequest)
+ return
+ }
+ }
+
+ cur := s.cfg.DefaultColors
+ next := make(map[string]string, len(cur)+len(body.Colors))
+ for k, v := range cur {
+ next[k] = v
+ }
+ for k, v := range body.Colors {
+ next[k] = v
+ }
+ s.cfg.DefaultColors = next
+
+ if err := s.writeConfigFile(); err != nil {
+ writeJSONError(w, "save failed: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ writeJSON(w, map[string]any{"ok": true})
+}
+
+func (s *AdminServer) handleColorsReset(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ writeJSONError(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ s.cfg.DefaultColors = config.DefaultColors()
+ if err := s.writeConfigFile(); err != nil {
+ writeJSONError(w, "save failed: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ // Return the freshly reset live values for the client to reload.
+ live := make(map[string]string, len(colorCategories))
+ for _, c := range colorCategories {
+ if v, ok := s.cfg.DefaultColors[c.Name]; ok {
+ live[c.Name] = v
+ } else {
+ live[c.Name] = ""
+ }
+ }
+ writeJSON(w, map[string]any{"ok": true, "colors": live})
+}
+
+// writeConfigFile marshals the entire live *config.Config back to config.yaml,
+// overwriting whatever was on disk. A full re-marshal drops the file's manual
+// comments but produces a valid, reloadable file with the edited colors.
+func (s *AdminServer) writeConfigFile() error {
+ _ = backupFile(s.configPath) // best-effort snapshot for safety
+ out, err := yaml.Marshal(s.cfg)
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(s.configPath, out, 0644)
+}
+
+// sortedKeys is a small helper retained in case future code wants a stable
+// iteration order over the category names.
+func sortedKeys(m map[string]string) []string {
+ keys := make([]string, 0, len(m))
+ for k := range m {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ return keys
+}
diff --git a/internal/admin/api_colors_test.go b/internal/admin/api_colors_test.go
new file mode 100644
index 0000000..d1e5e9a
--- /dev/null
+++ b/internal/admin/api_colors_test.go
@@ -0,0 +1,159 @@
+package admin
+
+import (
+ "bytes"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "thehouseoficarus/internal/config"
+)
+
+func newColorsTestServer(t *testing.T) (*AdminServer, string) {
+ t.Helper()
+ dir := t.TempDir()
+ path := filepath.Join(dir, "config.yaml")
+ if err := os.WriteFile(path, []byte("tick_length: 600\ndefault_colors:\n room_name: \"74 bold\"\n"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ loaded, err := config.Load(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return &AdminServer{cfg: loaded, configPath: path}, path
+}
+
+func TestColorsGet(t *testing.T) {
+ s, _ := newColorsTestServer(t)
+ req := httptest.NewRequest(http.MethodGet, "/api/colors", nil)
+ w := httptest.NewRecorder()
+ s.getColors(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("status %d", w.Code)
+ }
+ var out map[string]any
+ if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
+ t.Fatal(err)
+ }
+ cats, _ := out["categories"].([]any)
+ if len(cats) != len(colorCategories) {
+ t.Fatalf("categories count: got %d want %d", len(cats), len(colorCategories))
+ }
+ preview, _ := out["preview"].([]any)
+ if len(preview) < 8 {
+ t.Fatalf("preview sections too few: %d", len(preview))
+ }
+}
+
+func TestColorsSaveAndPersist(t *testing.T) {
+ s, path := newColorsTestServer(t)
+ before := s.cfg.DefaultColors["room_name"]
+
+ body := map[string]any{"colors": map[string]string{"room_name": "FF bold"}}
+ b, _ := json.Marshal(body)
+ req := httptest.NewRequest(http.MethodPost, "/api/colors", bytes.NewReader(b))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ s.saveColors(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("save status %d: %s", w.Code, w.Body.String())
+ }
+ if s.cfg.DefaultColors["room_name"] == before {
+ t.Fatalf("live map not updated")
+ }
+ if s.cfg.DefaultColors["room_name"] != "FF bold" {
+ t.Fatalf("live value = %q", s.cfg.DefaultColors["room_name"])
+ }
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.Contains(raw, []byte("FF bold")) {
+ t.Fatalf("config.yaml not persisted with new value; got:\n%s", string(raw))
+ }
+}
+
+func TestColorsSaveRejectsBadSpec(t *testing.T) {
+ s, _ := newColorsTestServer(t)
+ body := map[string]any{"colors": map[string]string{"room_name": "zzzzz"}}
+ b, _ := json.Marshal(body)
+ req := httptest.NewRequest(http.MethodPost, "/api/colors", bytes.NewReader(b))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ s.saveColors(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
+ }
+}
+
+func TestColorsSaveRejectsUnknownKey(t *testing.T) {
+ s, _ := newColorsTestServer(t)
+ body := map[string]any{"colors": map[string]string{"nope": "FF"}}
+ b, _ := json.Marshal(body)
+ req := httptest.NewRequest(http.MethodPost, "/api/colors", bytes.NewReader(b))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ s.saveColors(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400, got %d", w.Code)
+ }
+}
+
+func TestColorsOffAllowed(t *testing.T) {
+ s, _ := newColorsTestServer(t)
+ body := map[string]any{"colors": map[string]string{"mob": "off"}}
+ b, _ := json.Marshal(body)
+ req := httptest.NewRequest(http.MethodPost, "/api/colors", bytes.NewReader(b))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ s.saveColors(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200 for 'off', got %d: %s", w.Code, w.Body.String())
+ }
+}
+
+func TestColorsReset(t *testing.T) {
+ s, _ := newColorsTestServer(t)
+ s.cfg.DefaultColors["room_name"] = "FF"
+ req := httptest.NewRequest(http.MethodPost, "/api/colors/reset", nil)
+ w := httptest.NewRecorder()
+ s.handleColorsReset(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("status %d: %s", w.Code, w.Body.String())
+ }
+ if s.cfg.DefaultColors["room_name"] != "74 bold" {
+ t.Fatalf("reset did not restore builtin; got %q", s.cfg.DefaultColors["room_name"])
+ }
+}
+
+func TestColorsPreviewExhaustive(t *testing.T) {
+ s, _ := newColorsTestServer(t)
+ req := httptest.NewRequest(http.MethodGet, "/api/colors", nil)
+ w := httptest.NewRecorder()
+ s.getColors(w, req)
+ var out map[string]any
+ if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
+ t.Fatal(err)
+ }
+ seen := map[string]bool{}
+ for _, sec := range out["preview"].([]any) {
+ for _, ln := range sec.(map[string]any)["lines"].([]any) {
+ for _, seg := range ln.(map[string]any)["segments"].([]any) {
+ c := seg.(map[string]any)["c"].(string)
+ if c != "" {
+ seen[c] = true
+ }
+ }
+ }
+ }
+ for _, cat := range colorCategories {
+ if !seen[cat.Name] {
+ t.Errorf("preview missing category: %s", cat.Name)
+ }
+ }
+ _ = io.Discard
+} \ No newline at end of file
diff --git a/internal/admin/server.go b/internal/admin/server.go
index 9983a0b..842f640 100644
--- a/internal/admin/server.go
+++ b/internal/admin/server.go
@@ -55,9 +55,12 @@ type AdminServer struct {
// admin web GUI can trigger the same migration as the swapid command
// without the admin package depending on game.
rewriteRoomIDs func(idMap map[int]int)
+ // configPath is the on-disk config.yaml path, used by the color themer
+ // to persist edited default_colors back to the file the game loaded.
+ configPath string
}
-func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStore, w *world.World, is *item.ItemStore, os *object.ObjectStore, ms *world.MobStore, dataDir string, reloadCourses func(), rewriteRoomIDs func(map[int]int)) (*AdminServer, error) {
+func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStore, w *world.World, is *item.ItemStore, os *object.ObjectStore, ms *world.MobStore, dataDir string, reloadCourses func(), rewriteRoomIDs func(map[int]int), configPath string) (*AdminServer, error) {
secret := make([]byte, 32)
if _, err := rand.Read(secret); err != nil {
return nil, fmt.Errorf("cookie secret: %w", err)
@@ -102,6 +105,7 @@ func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStor
cookieSecret: secret,
reloadCourses: reloadCourses,
rewriteRoomIDs: rewriteRoomIDs,
+ configPath: configPath,
}
mux := http.NewServeMux()
@@ -185,6 +189,8 @@ func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStor
apiMux.HandleFunc("/api/rooms/resolve-duplicate", s.handleResolveDuplicate)
apiMux.HandleFunc("/api/triggers", s.handleTriggers)
apiMux.HandleFunc("/api/triggers/", s.handleTriggerByID)
+ apiMux.HandleFunc("/api/colors", s.handleColors)
+ apiMux.HandleFunc("/api/colors/reset", s.handleColorsReset)
mux.Handle("/api/", s.authMiddleware(apiMux))
diff --git a/internal/admin/static/admin.css b/internal/admin/static/admin.css
index aeec09d..06a640c 100644
--- a/internal/admin/static/admin.css
+++ b/internal/admin/static/admin.css
@@ -491,3 +491,40 @@ details.obj-card summary.obj-card-header::-webkit-details-marker{display:none}
.obj-card[data-card="trigger_action"] .ie-inter-header .ie-trig-dn{display:none}
input:focus-visible,select:focus-visible,textarea:focus-visible,button:focus-visible{outline:2px solid rgba(100,160,255,.5);outline-offset:1px}
.sr-no-results{padding:6px 8px;font-size:11px;color:#666;text-align:center;cursor:default;pointer-events:none}
+
+/* ── Color themer ── */
+.color-themer{display:flex;flex:1;min-height:0}
+.color-list{width:340px;display:flex;flex-direction:column;padding-bottom:0}
+.color-list .search-bar{flex-shrink:0}
+#colorRows{flex:1;overflow-y:auto;padding-right:4px}
+.color-group{font-size:10px;text-transform:uppercase;letter-spacing:.5px;color:#777;margin:10px 0 4px 0;padding-bottom:2px;border-bottom:1px solid var(--border)}
+.color-row{display:flex;align-items:center;gap:6px;padding:4px 4px;border-radius:3px;margin-bottom:1px}
+.color-row:hover{background:rgba(15,52,96,.3)}
+.color-row.dirty{background:rgba(158,99,7,.12)}
+.color-row .color-swatch{width:22px;height:22px;flex-shrink:0;border-radius:3px;border:1px solid #555;cursor:pointer}
+.color-row .color-swatch.nocolor{background:repeating-linear-gradient(45deg,#333,#333 3px,#222 3px,#222 6px)!important;border-color:#444}
+.color-row .color-label{font-size:12px;flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer}
+.color-row .color-flags{display:flex;flex-direction:column;gap:1px;font-size:9px;color:#aaa}
+.color-row .color-flags label{display:flex;align-items:center;gap:2px;cursor:pointer}
+.color-row .color-flags input[type=checkbox]{margin:0}
+.color-row .color-adv{width:78px;padding:2px 4px;font-size:10px;background:var(--input-bg);color:var(--text);border:1px solid var(--input-border);border-radius:3px;font-family:monospace}
+.color-row .color-revert{flex-shrink:0;padding:0 4px;font-size:11px;line-height:1;background:none;border:none;color:#888;cursor:pointer}
+.color-row .color-revert:hover{color:#fff}
+.color-row .color-revert:disabled{opacity:.3;cursor:default}
+.color-list-actions{flex-shrink:0;display:flex;gap:4px;padding:8px 0 4px 0;border-top:1px solid var(--border);margin-top:4px}
+.color-save-bar{flex-shrink:0;display:flex;align-items:center;gap:8px;padding:8px 0 12px 0}
+.color-save-status{font-size:11px;color:#888}
+.color-save-status.ok{color:var(--success)}
+.color-save-status.err{color:var(--danger)}
+
+.color-preview-main{display:flex;flex-direction:column;padding-bottom:8px}
+.color-preview-toolbar{display:flex;align-items:center;gap:8px;flex-shrink:0;margin-bottom:8px}
+.color-preview-toolbar select{padding:3px 6px;background:var(--input-bg);color:var(--text);border:1px solid var(--input-border);border-radius:3px;font-family:monospace}
+.color-preview-hint{font-size:11px;color:#888}
+.color-preview-scroll{flex:1;overflow-y:auto;overflow-x:auto;border:1px solid var(--border);border-radius:4px}
+.color-preview-pre{margin:0;padding:8px;font-family:ui-monospace,"Cascadia Mono","Cascadia Code","JetBrains Mono","Fira Code",Menlo,Consolas,"DejaVu Sans Mono","Courier New",monospace;font-size:12px;line-height:1;white-space:pre-wrap;color:#e0e0e0;background:#000}
+.cp-title{color:#9ab;display:block;margin:8px 0 3px 0;font-size:11px;text-transform:uppercase;letter-spacing:.5px;font-weight:bold}
+.cp-title:first-child{margin-top:0}
+/* Picker popup tweaks for the themer (richer than bindColorField's) */
+#__cp_popover .cp-grid .cp-swatch{width:14px;height:14px}
+#__cp_popover .cp-swatch.selected{outline:2px solid #fff;outline-offset:1px}
diff --git a/internal/admin/static/coloreditor.js b/internal/admin/static/coloreditor.js
new file mode 100644
index 0000000..dc219f6
--- /dev/null
+++ b/internal/admin/static/coloreditor.js
@@ -0,0 +1,570 @@
+// Color themer: left-side category editor + right-side live example preview.
+// Self-contained picker that does NOT clobber bold/dim/bg tokens (unlike the
+// generic bindColorField), so a row's full color spec stays editable in parts.
+
+(function () {
+ 'use strict';
+
+ var STATE = {
+ categories: [], // [{name,label,group}]
+ categoriesById: {}, // name -> cat
+ saved: {}, // name -> spec string (last persisted)
+ staged: {}, // name -> spec string (unsaved edits)
+ defaults: {}, // name -> spec string (builtins, for reset)
+ preview: [], // [{title, lines:[{segments:[{t,c}]}]}]
+ dirty: {} // name -> true
+ };
+
+ function $i(id) { return document.getElementById(id); }
+ function el(tag, cls, txt) {
+ var e = document.createElement(tag);
+ if (cls) e.className = cls;
+ if (txt != null) e.textContent = txt;
+ return e;
+ }
+
+ // ── Spec parse/serialize (JS port of internal/color.Parse) ──────────────
+ function parseSpec(v) {
+ v = (v || '').trim();
+ var s = { off: false, fg: -1, bg: -1, bold: false, dim: false, underline: false, gradient: null };
+ if (v === '' || v === 'off') { s.off = true; return s; }
+ var toks = v.split(/\s+/);
+ for (var i = 0; i < toks.length; i++) {
+ var t = toks[i];
+ if (!t) continue;
+ if (t === 'bold') { s.bold = true; continue; }
+ if (t === 'dim') { s.dim = true; continue; }
+ if (t === 'underline') { s.underline = true; continue; }
+ if (t.indexOf('bg:') === 0) {
+ var h = t.slice(3).replace(/[^0-9A-Fa-f]/g, '').substring(0, 2);
+ if (h.length === 2) { var n = parseInt(h, 16); if (n >= 0 && n <= 255) s.bg = n; }
+ continue;
+ }
+ if (t.indexOf('g:') === 0) {
+ var parts = t.slice(2).split(',').map(function (p) { return p.trim(); });
+ var stops = [];
+ for (var j = 0; j < parts.length; j++) {
+ var gh = parts[j].replace(/[^0-9A-Fa-f]/g, '').substring(0, 2);
+ if (gh.length === 2) stops.push(parseInt(gh, 16));
+ }
+ if (stops.length >= 2) s.gradient = stops;
+ continue;
+ }
+ var hx = t.replace(/[^0-9A-Fa-f]/g, '').substring(0, 2);
+ if (hx.length === 2) {
+ var fi = parseInt(hx, 16);
+ if (fi >= 0 && fi <= 255) s.fg = fi;
+ }
+ }
+ return s;
+ }
+
+ function serializeSpec(s) {
+ if (!s || s.off) return s && s.off ? 'off' : '';
+ var parts = [];
+ if (s.fg >= 0) parts.push(('0' + s.fg.toString(16)).slice(-2).toUpperCase());
+ if (s.bold) parts.push('bold');
+ if (s.dim) parts.push('dim');
+ if (s.underline) parts.push('underline');
+ if (s.bg >= 0) parts.push('bg:' + ('0' + s.bg.toString(16)).slice(-2).toUpperCase());
+ if (s.gradient && s.gradient.length >= 2) {
+ parts.push('g:' + s.gradient.map(function (g) { return ('0' + g.toString(16)).slice(-2).toUpperCase(); }).join(','));
+ }
+ return parts.join(' ');
+ }
+
+ // Current effective spec for a category (staged overrides saved).
+ function effectiveSpec(name) {
+ if (STATE.dirty[name]) return STATE.staged[name];
+ return STATE.saved[name] || STATE.defaults[name] || '';
+ }
+
+ // ── 16-color ANSI projection (mirrors nearestANSI in color.go) ──────────
+ var ANSI16 = [
+ [0, 0, 0], [170, 0, 0], [0, 170, 0], [170, 170, 0],
+ [0, 0, 170], [170, 0, 170], [0, 170, 170], [170, 170, 170],
+ [85, 85, 85], [255, 85, 85], [85, 255, 85], [255, 255, 85],
+ [85, 85, 255], [255, 85, 255], [85, 255, 255], [255, 255, 255]
+ ];
+ function rgbForCss(hex) {
+ if (!hex) return [0, 0, 0];
+ hex = hex.replace('#', '');
+ return [parseInt(hex.substring(0, 2), 16), parseInt(hex.substring(2, 4), 16), parseInt(hex.substring(4, 6), 16)];
+ }
+ function nearestAnsi16(idx) {
+ var rgb = (window.xtermToRGB ? xtermToRGB(idx) : [0, 0, 0]);
+ var best = 0, bestD = Infinity;
+ for (var i = 0; i < 16; i++) {
+ var dr = rgb[0] - ANSI16[i][0], dg = rgb[1] - ANSI16[i][1], db = rgb[2] - ANSI16[i][2];
+ var d = dr * dr + dg * dg + db * db;
+ if (d < bestD) { bestD = d; best = i; }
+ }
+ var c = ANSI16[best];
+ return '#' + ('0' + c[0].toString(16)).slice(-2) + ('0' + c[1].toString(16)).slice(-2) + ('0' + c[2].toString(16)).slice(-2);
+ }
+ function nearest256OfRgb(r, g, b) {
+ if (window.cssToXtermIdx) {
+ var hex = ('0' + (r & 255).toString(16)).slice(-2) + ('0' + (g & 255).toString(16)).slice(-2) + ('0' + (b & 255).toString(16)).slice(-2);
+ return cssToXtermIdx(hex);
+ }
+ return 0;
+ }
+
+ // ── Left-side row builder ──────────────────────────────────────────────
+ function buildRows() {
+ var host = $i('colorRows');
+ host.innerHTML = '';
+ var byGroup = {};
+ var order = [];
+ STATE.categories.forEach(function (c) {
+ if (!byGroup[c.group]) { byGroup[c.group] = []; order.push(c.group); }
+ byGroup[c.group].push(c);
+ });
+ order.forEach(function (g) {
+ host.appendChild(el('div', 'color-group', g));
+ byGroup[g].forEach(function (c) { host.appendChild(buildRow(c)); });
+ });
+ }
+
+ function rowSwatchColor(spec) {
+ if (spec.off || spec.fg < 0) return null; // no color → striped swatch
+ return window.xtermToCss ? xtermToCss(spec.fg) : '#808080';
+ }
+
+ function buildRow(cat) {
+ var row = el('div', 'color-row');
+ row.dataset.name = cat.name;
+
+ var savedSpec = parseSpec(effectiveSpec(cat.name));
+ row.dataset.fg = savedSpec.fg >= 0 ? savedSpec.fg : '-1';
+
+ var sw = el('div', 'color-swatch');
+ var sc = rowSwatchColor(savedSpec);
+ if (sc) sw.style.background = sc; else sw.classList.add('nocolor');
+ sw.title = 'Click to pick foreground color';
+
+ var lbl = el('div', 'color-label', cat.label);
+
+ var flags = el('div', 'color-flags');
+ function mkFlag(name, label) {
+ var lab = el('label');
+ var cb = el('input'); cb.type = 'checkbox'; cb.dataset.flag = name;
+ if (savedSpec[name]) cb.checked = true;
+ lab.appendChild(cb); lab.appendChild(el('span', null, label));
+ return lab;
+ }
+ flags.appendChild(mkFlag('bold', 'B'));
+ flags.appendChild(mkFlag('dim', 'D'));
+ flags.appendChild(mkFlag('underline', 'U'));
+
+ var adv = el('input', 'color-adv');
+ adv.type = 'text'; adv.placeholder = 'bg:/g:';
+ adv.value = extractAdvanced(savedSpec);
+ adv.title = 'Advanced tokens: bg:HH or g:HH,HH,...';
+ adv.addEventListener('input', function () { commitRow(row, cat); });
+
+ var rbtn = el('button', 'color-revert', '\u21BA');
+ rbtn.title = 'Revert this row to the saved value';
+
+ row.appendChild(sw);
+ row.appendChild(lbl);
+ row.appendChild(flags);
+ row.appendChild(adv);
+ row.appendChild(rbtn);
+
+ // wire events
+ flags.addEventListener('change', function () { commitRow(row, cat); });
+ sw.addEventListener('click', function (e) { e.stopPropagation(); openPicker(row, cat, sw); });
+ lbl.addEventListener('click', function (e) { e.stopPropagation(); openPicker(row, cat, sw); });
+ rbtn.addEventListener('click', function (e) {
+ e.stopPropagation();
+ revertRow(row, cat);
+ });
+
+ refreshRow(row);
+ return row;
+ }
+
+ // Read the row's current UI into a spec string, mark dirty, refresh.
+ function commitRow(row, cat) {
+ var s = { off: false, fg: -1, bg: -1, bold: false, dim: false, underline: false, gradient: null };
+ s.fg = parseInt(row.dataset.fg != null ? row.dataset.fg : '-1', 10);
+ if (isNaN(s.fg)) s.fg = -1;
+ var flags = row.querySelectorAll('.color-flags input');
+ for (var i = 0; i < flags.length; i++) {
+ var f = flags[i].dataset.flag;
+ if (f === 'bold') s.bold = flags[i].checked;
+ if (f === 'dim') s.dim = flags[i].checked;
+ if (f === 'underline') s.underline = flags[i].checked;
+ }
+ var adv = row.querySelector('.color-adv');
+ var advStr = adv.value.trim();
+ if (advStr) {
+ var toks = advStr.split(/\s+/);
+ for (var j = 0; j < toks.length; j++) {
+ var t = toks[j];
+ if (t.indexOf('bg:') === 0) {
+ var h = t.slice(3).replace(/[^0-9A-Fa-f]/g, '').substring(0, 2);
+ if (h.length === 2) s.bg = parseInt(h, 16);
+ } else if (t.indexOf('g:') === 0) {
+ var parts = t.slice(2).split(',').map(function (p) { return p.trim(); });
+ var stops = [];
+ for (var k = 0; k < parts.length; k++) {
+ var gh = parts[k].replace(/[^0-9A-Fa-f]/g, '').substring(0, 2);
+ if (gh.length === 2) stops.push(parseInt(gh, 16));
+ }
+ if (stops.length >= 2) s.gradient = stops;
+ } else if (t === 'off') {
+ s.off = true;
+ }
+ }
+ }
+ var specStr = serializeSpec(s);
+ STATE.staged[cat.name] = specStr;
+ STATE.dirty[cat.name] = (specStr !== (STATE.saved[cat.name] || ''));
+ refreshRow(row);
+ refreshRevertButton(row, cat);
+ renderColorPreview();
+ }
+
+ function extractAdvanced(spec) {
+ if (!spec || spec.off) return '';
+ var parts = [];
+ if (spec.bg >= 0) parts.push('bg:' + ('0' + spec.bg.toString(16)).slice(-2).toUpperCase());
+ if (spec.gradient && spec.gradient.length >= 2) {
+ parts.push('g:' + spec.gradient.map(function (g) { return ('0' + g.toString(16)).slice(-2).toUpperCase(); }).join(','));
+ }
+ return parts.join(' ');
+ }
+
+ function refreshRow(row) {
+ row.classList.toggle('dirty', !!STATE.dirty[row.dataset.name]);
+ var spec = parseSpec(effectiveSpec(row.dataset.name));
+ var sw = row.querySelector('.color-swatch');
+ var sc = rowSwatchColor(spec);
+ sw.classList.toggle('nocolor', !sc);
+ sw.style.background = sc || '';
+ }
+
+ function refreshRevertButton(row, cat) {
+ var btn = row.querySelector('.color-revert');
+ btn.disabled = !STATE.dirty[cat.name];
+ }
+
+ function revertRow(row, cat) {
+ delete STATE.staged[cat.name];
+ delete STATE.dirty[cat.name];
+ var spec = parseSpec(STATE.saved[cat.name] || STATE.defaults[cat.name] || '');
+ row.dataset.fg = spec.fg >= 0 ? spec.fg : '-1';
+ row.querySelectorAll('.color-flags input').forEach(function (cb) {
+ var f = cb.dataset.flag; cb.checked = !!spec[f];
+ });
+ row.querySelector('.color-adv').value = extractAdvanced(spec);
+ refreshRow(row);
+ refreshRevertButton(row, cat);
+ renderColorPreview();
+ }
+
+ // ── Picker popover (per-row, does not touch bold/dim/bg) ────────────────
+ var popover = null, boundRow = null, boundCat = null, boundSwatch = null;
+
+ function ensurePicker() {
+ if (popover) return;
+ popover = el('div');
+ popover.id = '__ct_popover';
+ popover.style.cssText = 'display:none;position:fixed;z-index:300;background:var(--panel);border:1px solid var(--border);border-radius:6px;padding:8px;box-shadow:0 4px 20px rgba(0,0,0,.6);max-width:340px';
+ var head = el('div'); head.style.cssText = 'display:flex;align-items:center;gap:6px;margin-bottom:6px';
+ head.appendChild(el('span', 'color-swatch'));
+ var hexIn = el('input'); hexIn.type = 'text'; hexIn.maxLength = 2;
+ hexIn.style.cssText = 'width:50px;padding:3px 6px;font-size:11px;background:var(--input-bg);color:var(--text);border:1px solid var(--input-border);border-radius:3px;font-family:monospace;text-transform:uppercase';
+ var offB = el('button', 'btn btn-sm', 'Off');
+ var xB = el('button', 'btn btn-sm', '\u00d7'); xB.title = 'close';
+ head.appendChild(hexIn); head.appendChild(offB); head.appendChild(xB);
+ popover.appendChild(head);
+
+ var grid = el('div', 'cp-grid');
+ for (var i = 0; i < 256; i++) {
+ var sw = el('div', 'cp-swatch');
+ sw.className = 'cp-swatch';
+ (function (idx, ee) {
+ ee.style.background = window.xtermToCss ? xtermToCss(idx) : '#000';
+ ee.dataset.idx = idx;
+ ee.title = ('0' + idx.toString(16)).slice(-2).toUpperCase() + ' (' + idx + ')';
+ })(i, sw);
+ grid.appendChild(sw);
+ }
+ popover.appendChild(grid);
+
+ document.body.appendChild(popover);
+
+ grid.addEventListener('click', function (e) {
+ var s = e.target.closest('.cp-swatch'); if (!s) return;
+ var idx = parseInt(s.dataset.idx, 10);
+ setRowFg(boundRow, boundCat, idx);
+ hexIn.value = ('0' + idx.toString(16)).slice(-2).toUpperCase();
+ highlightGrid(idx);
+ popover.querySelector('.color-swatch').style.background = window.xtermToCss ? xtermToCss(idx) : '#000';
+ });
+ hexIn.addEventListener('input', function () {
+ var v = this.value.toUpperCase().replace(/[^0-9A-F]/g, '').substring(0, 2);
+ this.value = v;
+ if (v.length === 2) {
+ var idx = parseInt(v, 16);
+ setRowFg(boundRow, boundCat, idx);
+ highlightGrid(idx);
+ popover.querySelector('.color-swatch').style.background = window.xtermToCss ? xtermToCss(idx) : '#000';
+ }
+ });
+ offB.addEventListener('click', function () {
+ setRowFg(boundRow, boundCat, -1);
+ boundRow.querySelector('.color-swatch').classList.add('nocolor');
+ boundRow.querySelector('.color-swatch').style.background = '';
+ hidePicker();
+ });
+ xB.addEventListener('click', hidePicker);
+
+ document.addEventListener('click', function (e) {
+ if (!popover || popover.style.display === 'none') return;
+ if (!popover.contains(e.target) && e.target !== boundSwatch) hidePicker();
+ });
+ }
+
+ function highlightGrid(idx) {
+ var all = popover.querySelectorAll('.cp-swatch');
+ for (var i = 0; i < all.length; i++) all[i].classList.remove('selected');
+ var sel = popover.querySelector('.cp-swatch[data-idx="' + idx + '"]');
+ if (sel) sel.classList.add('selected');
+ }
+
+ function setRowFg(row, cat, idx) {
+ row.dataset.fg = idx;
+ commitRow(row, cat);
+ var sw = row.querySelector('.color-swatch');
+ if (idx < 0) { sw.classList.add('nocolor'); sw.style.background = ''; }
+ else { sw.classList.remove('nocolor'); sw.style.background = window.xtermToCss ? xtermToCss(idx) : '#808080'; }
+ }
+
+ function openPicker(row, cat, swatch) {
+ ensurePicker();
+ boundRow = row; boundCat = cat; boundSwatch = swatch;
+ var spec = parseSpec(effectiveSpec(cat.name));
+ var fg = spec.fg >= 0 ? spec.fg : 0;
+ var hexIn = popover.querySelector('input[type=text]');
+ hexIn.value = ('0' + fg.toString(16)).slice(-2).toUpperCase();
+ popover.querySelector('.color-swatch').style.background = window.xtermToCss ? xtermToCss(fg) : '#000';
+ highlightGrid(fg);
+ var rect = swatch.getBoundingClientRect();
+ popover.style.display = 'block';
+ var pw = popover.offsetWidth;
+ var left = rect.left;
+ if (left + pw > window.innerWidth - 10) left = Math.max(10, window.innerWidth - pw - 10);
+ popover.style.left = left + 'px';
+ popover.style.top = (rect.bottom + 4) + 'px';
+ hexIn.focus(); hexIn.select();
+ }
+
+ function hidePicker() { if (popover) popover.style.display = 'none'; }
+
+ // ── Preview rendering ──────────────────────────────────────────────────
+ function renderColorPreview() {
+ var pre = $i('colorPreview');
+ if (!pre) return;
+ var mode = ($i('colorMode') || {}).value || 'xterm256';
+ pre.innerHTML = '';
+ STATE.preview.forEach(function (sec, idx) {
+ var title = el('span', 'cp-title', sec.title);
+ pre.appendChild(title);
+ sec.lines.forEach(function (line) {
+ var lineEl = el('div');
+ lineEl.style.minHeight = '1em';
+ line.segments.forEach(function (seg) {
+ lineEl.appendChild(renderSegment(seg, mode));
+ });
+ pre.appendChild(lineEl);
+ });
+ });
+ }
+
+ function renderSegment(seg, mode) {
+ var span = el('span');
+ span.textContent = seg.t;
+ if (!seg.c || mode === 'none') return span;
+ var spec = parseSpec(effectiveSpec(seg.c));
+ if (spec.off) return span;
+
+ if (spec.bg >= 0) {
+ var bgCss = mode === 'ansi16' ? nearestAnsi16(spec.bg) : (window.xtermToCss ? xtermToCss(spec.bg) : '#808080');
+ span.style.backgroundColor = bgCss;
+ // pick a readable fg when the spec has none
+ var rgb = rgbForCss(bgCss);
+ span.style.color = (rgb[0] + rgb[1] + rgb[2] > 384) ? '#000' : '#fff';
+ }
+
+ if (spec.gradient && spec.gradient.length >= 2 && mode === 'xterm256') {
+ // per-character gradient ramp
+ var stops = spec.gradient;
+ var chars = Array.from(seg.t);
+ var txt = document.createDocumentFragment();
+ chars.forEach(function (ch, i) {
+ var t = chars.length > 1 ? i / (chars.length - 1) : 0;
+ var idx = interpolateGradient(stops, t);
+ var s = el('span');
+ s.textContent = ch;
+ if (spec.bold) s.style.fontWeight = 'bold';
+ if (spec.dim) s.style.opacity = '0.55';
+ if (spec.underline) s.style.textDecoration = 'underline';
+ s.style.color = window.xtermToCss ? xtermToCss(idx) : '#808080';
+ if (spec.bg >= 0) { s.style.backgroundColor = span.style.backgroundColor; }
+ txt.appendChild(s);
+ });
+ return txt;
+ }
+
+ if (spec.fg >= 0) {
+ var fgCss;
+ if (mode === 'ansi16') fgCss = nearestAnsi16(spec.fg);
+ else fgCss = window.xtermToCss ? xtermToCss(spec.fg) : '#808080';
+ span.style.color = fgCss;
+ }
+ if (spec.bold) span.style.fontWeight = 'bold';
+ if (spec.dim) span.style.opacity = '0.55';
+ if (spec.underline) span.style.textDecoration = 'underline';
+ return span;
+ }
+
+ function interpolateGradient(stops, t) {
+ if (t <= 0) return stops[0];
+ if (t >= 1) return stops[stops.length - 1];
+ var seg2 = t * (stops.length - 1);
+ var i = Math.floor(seg2);
+ if (i >= stops.length - 1) i = stops.length - 2;
+ var frac = seg2 - i;
+ var a = window.xtermToRGB ? xtermToRGB(stops[i]) : [0, 0, 0];
+ var b = window.xtermToRGB ? xtermToRGB(stops[i + 1]) : [0, 0, 0];
+ var r = Math.round(a[0] + frac * (b[0] - a[0]));
+ var g = Math.round(a[1] + frac * (b[1] - a[1]));
+ var bb = Math.round(a[2] + frac * (b[2] - a[2]));
+ return nearest256OfRgb(r, g, bb);
+ }
+
+ // ── Save / reset / export / import ─────────────────────────────────────
+ function colorSave() {
+ var status = $i('colorSaveStatus');
+ status.textContent = 'Saving...'; status.className = 'color-save-status';
+ var payload = {};
+ Object.keys(STATE.dirty).forEach(function (k) {
+ if (STATE.dirty[k]) payload[k] = effectiveSpec(k);
+ });
+ fetch('/api/colors', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ colors: payload })
+ }).then(function (r) { return r.json(); }).then(function (j) {
+ if (j.ok) {
+ // committed values become the new saved baseline
+ Object.keys(STATE.dirty).forEach(function (k) { if (STATE.dirty[k]) STATE.saved[k] = effectiveSpec(k); });
+ STATE.dirty = {};
+ document.querySelectorAll('.color-row.dirty').forEach(function (row) {
+ row.classList.remove('dirty');
+ var btn = row.querySelector('.color-revert'); if (btn) btn.disabled = true;
+ });
+ status.textContent = 'Saved'; status.className = 'color-save-status ok';
+ } else {
+ status.textContent = 'Error: ' + (j.error || 'unknown'); status.className = 'color-save-status err';
+ }
+ }).catch(function (e) {
+ status.textContent = 'Error: ' + e; status.className = 'color-save-status err';
+ });
+ }
+
+ function colorResetAll() {
+ if (!confirm('Reset ALL colors to the built-in defaults? This saves immediately.')) return;
+ fetch('/api/colors/reset', { method: 'POST' }).then(function (r) { return r.json(); }).then(function (j) {
+ if (!j.ok) { alert('Reset failed: ' + (j.error || 'unknown')); return; }
+ Object.keys(j.colors || {}).forEach(function (k) {
+ STATE.saved[k] = j.colors[k];
+ STATE.staged[k] = j.colors[k];
+ delete STATE.dirty[k];
+ });
+ buildRows();
+ renderColorPreview();
+ }).catch(function (e) { alert('Reset failed: ' + e); });
+ }
+
+ function colorExport() {
+ var out = {};
+ STATE.categories.forEach(function (c) { out[c.name] = effectiveSpec(c.name); });
+ var json = JSON.stringify(out, null, 2);
+ var ta = el('textarea'); ta.value = json; ta.style.position = 'fixed'; ta.style.opacity = '0';
+ document.body.appendChild(ta); ta.select();
+ try { document.execCommand('copy'); } catch (e) {}
+ document.body.removeChild(ta);
+ var status = $i('colorSaveStatus');
+ status.textContent = 'Theme JSON copied to clipboard'; status.className = 'color-save-status ok';
+ setTimeout(function () { if (status.textContent === 'Theme JSON copied to clipboard') status.textContent = ''; }, 2500);
+ }
+
+ function colorImportPrompt() {
+ var txt = prompt('Paste exported theme JSON (a {category: spec} object):');
+ if (!txt) return;
+ var obj;
+ try { obj = JSON.parse(txt); } catch (e) { alert('Invalid JSON: ' + e); return; }
+ var applied = 0;
+ Object.keys(obj).forEach(function (k) {
+ if (!STATE.categoriesById[k]) return;
+ var spec = parseSpec(obj[k]);
+ STATE.staged[k] = serializeSpec(spec);
+ STATE.dirty[k] = (STATE.staged[k] !== (STATE.saved[k] || ''));
+ applied++;
+ });
+ if (applied === 0) { alert('No known categories found in the JSON.'); return; }
+ buildRows();
+ renderColorPreview();
+ var status = $i('colorSaveStatus');
+ status.textContent = 'Imported ' + applied + ' colors (press Save to persist).';
+ status.className = 'color-save-status';
+ }
+
+ function filterColorRows(q) {
+ q = (q || '').toLowerCase();
+ document.querySelectorAll('#colorRows .color-row').forEach(function (row) {
+ var name = row.dataset.name;
+ var label = (STATE.categoriesById[name] || {}).label || '';
+ row.style.display = (!q || name.indexOf(q) >= 0 || label.toLowerCase().indexOf(q) >= 0) ? '' : 'none';
+ });
+ document.querySelectorAll('#colorRows .color-group').forEach(function (g) {
+ // hide group header if all its rows are hidden — naive: check sibling visibility
+ var any = false, n = g.nextElementSibling;
+ while (n && !n.classList.contains('color-group')) {
+ if (n.classList.contains('color-row') && n.style.display !== 'none') { any = true; break; }
+ n = n.nextElementSibling;
+ }
+ g.style.display = any || !q ? '' : 'none';
+ });
+ }
+
+ // ── Init ───────────────────────────────────────────────────────────────
+ window.initColorEditor = function () {
+ fetch('/api/colors').then(function (r) { return r.json(); }).then(function (data) {
+ STATE.categories = data.categories || [];
+ STATE.categoriesById = {};
+ STATE.categories.forEach(function (c) { STATE.categoriesById[c.name] = c; });
+ STATE.saved = data.colors || {};
+ STATE.defaults = data.default_colors || {};
+ STATE.preview = data.preview || [];
+ STATE.staged = {}; STATE.dirty = {};
+ Object.keys(STATE.saved).forEach(function (k) { STATE.staged[k] = STATE.saved[k]; });
+ buildRows();
+ renderColorPreview();
+ }).catch(function (e) {
+ $i('colorRows').textContent = 'Failed to load colors: ' + e;
+ });
+ };
+
+ // expose for inline onclick handlers
+ window.colorSave = colorSave;
+ window.colorResetAll = colorResetAll;
+ window.colorExport = colorExport;
+ window.colorImportPrompt = colorImportPrompt;
+ window.filterColorRows = filterColorRows;
+ window.renderColorPreview = renderColorPreview;
+})(); \ No newline at end of file
diff --git a/internal/admin/templates/colors.html b/internal/admin/templates/colors.html
new file mode 100644
index 0000000..a559dc3
--- /dev/null
+++ b/internal/admin/templates/colors.html
@@ -0,0 +1,37 @@
+{{define "body-colors"}}
+<div class="color-themer">
+ <div class="editor-list color-list" id="colorList">
+ <div class="search-bar"><input type="search" id="colorFilter" placeholder="Filter categories..." oninput="filterColorRows(this.value)"></div>
+ <div id="colorRows"></div>
+ <div class="color-list-actions">
+ <button class="btn btn-sm" id="colorExportBtn" onclick="colorExport()">Export JSON</button>
+ <button class="btn btn-sm" id="colorImportBtn" onclick="colorImportPrompt()">Import JSON</button>
+ <button class="btn btn-sm" id="colorResetBtn" onclick="colorResetAll()">Reset all to defaults</button>
+ </div>
+ <div class="color-save-bar">
+ <button class="btn btn-primary" id="colorSaveBtn" onclick="colorSave()">Save Colors</button>
+ <span id="colorSaveStatus" class="color-save-status"></span>
+ </div>
+ </div>
+ <div class="editor-main color-preview-main">
+ <div class="color-preview-toolbar">
+ <label for="colorMode">Color mode:</label>
+ <select id="colorMode" onchange="renderColorPreview()">
+ <option value="xterm256">xterm256 (full color)</option>
+ <option value="ansi16">ansi16 (16-color nearest)</option>
+ <option value="none">none (uncolored)</option>
+ </select>
+ <span class="color-preview-hint">Right side updates live as you edit colors.</span>
+ </div>
+ <div class="color-preview-scroll">
+ <pre id="colorPreview" class="color-preview-pre"></pre>
+ </div>
+ </div>
+</div>
+<input type="hidden" id="colorImportInput">
+<script src="/static/coloreditor.js"></script>
+<script>
+initColorEditor();
+</script>
+{{end}}
+</content> \ No newline at end of file
diff --git a/internal/admin/templates/layout.html b/internal/admin/templates/layout.html
index 02f8a01..f3bf9fd 100644
--- a/internal/admin/templates/layout.html
+++ b/internal/admin/templates/layout.html
@@ -20,6 +20,7 @@
<a href="/editor/techs" class="{{if eq .Page "techs"}}active{{end}}">Techs</a>
<a href="/editor/modules" class="{{if eq .Page "modules"}}active{{end}}">Modules</a>
<a href="/editor/triggers" class="{{if eq .Page "triggers"}}active{{end}}">Triggers</a>
+ <a href="/editor/colors" class="{{if eq .Page "colors"}}active{{end}}">Color</a>
<a href="/editor/characters" class="{{if eq .Page "characters"}}active{{end}}">Characters</a>
<a href="/editor/dashboard" class="{{if eq .Page "dashboard"}}active{{end}}">Dashboard</a>
<a href="/editor/files" class="{{if eq .Page "files"}}active{{end}}">Files</a>
@@ -40,6 +41,7 @@
{{else if eq .Page "techs"}}{{template "body-techs" .}}
{{else if eq .Page "modules"}}{{template "body-modules" .}}
{{else if eq .Page "triggers"}}{{template "body-triggers" .}}
+{{else if eq .Page "colors"}}{{template "body-colors" .}}
{{else if eq .Page "characters"}}{{template "body-characters" .}}
{{else if eq .Page "dashboard"}}{{template "body-dashboard" .}}
{{else if eq .Page "files"}}{{template "body-files" .}}