diff --git a/docs/superpowers/handoffs/2026-07-27-dts-editor-agnostic-help.md b/docs/superpowers/handoffs/2026-07-27-dts-editor-agnostic-help.md new file mode 100644 index 000000000..1e6bab287 --- /dev/null +++ b/docs/superpowers/handoffs/2026-07-27-dts-editor-agnostic-help.md @@ -0,0 +1,84 @@ +# Editor Handoff — Platform-Agnostic (help) Templates & Readonly Visibility + +**For:** the DTS template editor (`~/dev/poracle-embed-visualizer`). +**From:** PoracleNG processor, branch `fix/dts-agnostic-platform-save` (PR #176). +**Date:** 2026-07-27. + +## The bug this closes +Loading a fallback **help** template (e.g. `help/fort`) in the editor and saving it to edit later produced a **duplicate** in the editable-templates list: the readonly fallback **plus** a second `discord` copy. Root cause was two-sided: + +- **Server** rejected `platform=""` on save, forcing help to be saved with a concrete platform. +- **Editor** coerces an empty platform to `"discord"` on load, so the agnostic help fallback (`platform=""`) becomes a `discord`-specific entry that can't shadow the `""` fallback (the override key includes platform). + +The **server half is shipped** (see "Server contract" below). This doc is the **editor half**. + +## Key fact: `help` is the ONLY platform-agnostic type +Every other DTS type — `monster`, `monsterNoIv`, `raid`, `egg`, `quest`, `questSummary`, `invasion`, `incident`, `lure`, `nest`, `gym`, `fort-update`, `maxbattle`, `showcase`, `monsterChanged`, `weatherchange`, `rsvpChanges`, `buttonResponse` — is **platform-specific** and MUST carry a concrete platform (`discord`/`telegram`). Only `help` is agnostic: its bundled fallbacks ship with `platform=""` (from `fallbacks/dts/help/*.json`) and a single entry serves all platforms. + +Hardcode this to match the server: + +```js +const AGNOSTIC_TYPES = new Set(['help']); +const isAgnostic = (type) => AGNOSTIC_TYPES.has(type); +``` + +(The server's equivalent is `dts.IsPlatformAgnosticType`, currently `{help}`. There is no API to query the set — keep this client list in sync if the server ever adds more agnostic types.) + +## Editor changes + +### 1. Stop coercing empty platform to `"discord"` for agnostic types +`src/hooks/useDts.js:8`: + +```js +.map((e) => ({ ...e, id: String(e.id ?? '1'), platform: e.platform || 'discord', language: e.language ?? '' })); +``` + +`e.platform || 'discord'` is what turns the fallback's `""` into `"discord"`. Preserve `""` for agnostic types: + +```js +.map((e) => ({ + ...e, + id: String(e.id ?? '1'), + platform: isAgnostic(e.type) ? (e.platform ?? '') : (e.platform || 'discord'), + language: e.language ?? '', +})); +``` + +Apply the same to the other coercion sites: `useDts.js:180` (`platform: template.platform || 'discord'`) and the new-template default at `useDts.js:15` (`platform: 'discord'` — a **new** help template should default to `""`, not `discord`). + +### 2. Show agnostic entries regardless of the platform tab +The list filters by `t.platform === filters.platform` (`useDts.js:32, 41, 47, 70, 81, 142`). An agnostic help entry (`platform=""`) fails `"" === "discord"` and would vanish from every platform tab. Include agnostic entries in any tab: + +```js +const platformMatches = (t) => isAgnostic(t.type) || t.platform === filters.platform; +``` + +(Or give `help` its own platform-neutral view — but "show in every tab" is the least surprising.) + +### 3. Save agnostic templates with `platform=""` +When saving a help template, POST `platform: ""` (not `"discord"`). With change #1 preserving `""` through state, this happens naturally as long as the save path doesn't re-inject a platform. Server-side the file is then written as `config/dts/help-fort.json` (no platform segment). + +Also fix the **download** filename at `src/App.jsx:319`: + +```js +a.download = `${entry.type}-${entry.id || 'default'}-${entry.platform || 'discord'}.json`; +``` + +Omit the platform segment when empty so an agnostic download is `help-fort.json` (mirrors the server's `entryFilename`). + +### 4. Show readonly (fallback) entries in the list, clearly marked +`GET /api/dts/templates` returns **`readonly: true`** on every bundled/fallback entry. **Surface these in the template list — don't hide them** — with a clear badge such as **"read-only (fallback)"** (and, ideally, an affordance to "copy / override"). This makes it obvious which rows are: + +- editable in place (the user's own `config/dts/` entries, `readonly` absent/false), vs +- read-only fallbacks the user can copy to create an override. + +When the user saves an override of a readonly fallback, the server **drops the readonly entry** from the returned list (the user's copy shadows it), so the row naturally flips from "read-only" to editable — no duplicate. Rendering the `readonly` flag is what makes that transition legible to the user. + +## Server contract (already shipped — PR #176) +- `POST /api/dts/templates` now **accepts `platform=""` for agnostic types** (`help`). Non-agnostic types still return **400** without a platform (unchanged). +- A saved `(help, , "")` override shares the fallback's key and **shadows** it: the editable list then shows exactly **one** `help/` entry (the user's), with the readonly fallback dropped. +- Saved agnostic files are named `help-.json` (no platform segment). +- `GET /api/dts/templates` continues to return `readonly: true` on fallback entries — use it for the badge in change #4. + +## Migration note (existing duplicate) +The duplicate currently visible on the running instance comes from an already-saved `config/dts/help-fort-discord.json` (platform `discord`) written before this fix. Delete it (editor delete button or `rm`) to clear the current duplicate; the fix only prevents **new** ones. diff --git a/processor/internal/api/huma_dts_writes.go b/processor/internal/api/huma_dts_writes.go index 17f6db823..a35730617 100644 --- a/processor/internal/api/huma_dts_writes.go +++ b/processor/internal/api/huma_dts_writes.go @@ -256,7 +256,11 @@ func RegisterDTSSaveTemplates(api huma.API, ts dtsSaveWriter) { } for i, entry := range entries { - if entry.Type == "" || entry.Platform == "" { + // Platform is required except for platform-agnostic types (e.g. + // help), whose fallbacks carry an empty platform. Allowing "" for + // those lets an override keep the fallback's key and shadow it, + // instead of surfacing as a duplicate alongside the fallback. + if entry.Type == "" || (entry.Platform == "" && !dts.IsPlatformAgnosticType(entry.Type)) { msg := fmt.Sprintf("entry %d missing required fields (type=%q, platform=%q, id=%q)", i, entry.Type, entry.Platform, entry.ID) log.Warnf("dts save: %s", msg) return nil, huma.Error400BadRequest(msg) diff --git a/processor/internal/api/huma_dts_writes_test.go b/processor/internal/api/huma_dts_writes_test.go index 8965c6702..294c316fa 100644 --- a/processor/internal/api/huma_dts_writes_test.go +++ b/processor/internal/api/huma_dts_writes_test.go @@ -206,6 +206,19 @@ func TestHumaDTSSaveTemplates_MissingFields400(t *testing.T) { } } +func TestHumaDTSSaveTemplates_AgnosticHelpEmptyPlatformOK(t *testing.T) { + r, api := newFeaturesTestAPI(t) + RegisterDTSSaveTemplates(api, &stubDTSWriter{}) + + // help is platform-agnostic: platform="" must be accepted (not 400) so an + // override can shadow the "" fallback instead of duplicating it. + body := []byte(`[{"type":"help","id":"fort","platform":"","template":"x"}]`) + w := postJSON(t, r, "/api/dts/templates", body) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 for agnostic help with empty platform, got %d body=%s", w.Code, w.Body.String()) + } +} + func TestHumaDTSSaveTemplates_Readonly403(t *testing.T) { r, api := newFeaturesTestAPI(t) ts := &stubDTSWriter{saveErr: errors.New("cannot overwrite readonly entry")} diff --git a/processor/internal/dts/editor_test.go b/processor/internal/dts/editor_test.go index 3ad17f2ec..256fc9cfd 100644 --- a/processor/internal/dts/editor_test.go +++ b/processor/internal/dts/editor_test.go @@ -53,6 +53,68 @@ func TestFilteredEntriesDedupesOverride(t *testing.T) { } } +// TestSaveEntryAgnosticHelpOverrideShadowsFallback covers the "duplicate +// help/fort in the editor list" bug: a platform-agnostic help fallback +// (platform="") saved as an override must keep platform="" so its entryKey +// matches the fallback and it shadows it — rather than surfacing as a second +// entry. Previously the editor was forced to save help with platform="discord" +// (the save API rejected ""), producing (help,fort,"") + (help,fort,discord). +func TestSaveEntryAgnosticHelpOverrideShadowsFallback(t *testing.T) { + entries := []DTSEntry{ + {Type: "help", ID: "fort", Platform: "", Language: "", Template: "fallback text", Readonly: true}, + } + ts, tmp := newTestStore(t, entries) + + // Save an agnostic override (platform="" preserved, as the fixed editor sends). + err := ts.SaveEntry(DTSEntry{Type: "help", ID: "fort", Platform: "", Language: "", Template: "my edited text"}) + if err != nil { + t.Fatalf("SaveEntry: %v", err) + } + + // The editor list for help/fort must show exactly ONE entry — the user's. + got := ts.FilteredEntries("help", "", "", "fort") + if len(got) != 1 { + t.Fatalf("expected 1 help/fort entry after override, got %d (%+v)", len(got), got) + } + if got[0].Readonly { + t.Errorf("help/fort entry is readonly; want the user override to shadow the fallback") + } + if got[0].Template != "my edited text" { + t.Errorf("template = %v, want the saved override text", got[0].Template) + } + + // The override is written with a clean, platform-less filename. + if _, err := os.Stat(filepath.Join(tmp, "dts", "help-fort.json")); err != nil { + t.Errorf("expected config/dts/help-fort.json to exist: %v", err) + } +} + +func TestEntryFilenameEmptyPlatform(t *testing.T) { + agnostic := entryFilename(&DTSEntry{Type: "help", ID: "fort", Platform: ""}) + if agnostic != "help-fort.json" { + t.Errorf("agnostic filename = %q, want %q", agnostic, "help-fort.json") + } + withPlatform := entryFilename(&DTSEntry{Type: "monster", ID: "1", Platform: "discord"}) + if withPlatform != "monster-1-discord.json" { + t.Errorf("platform filename = %q, want %q", withPlatform, "monster-1-discord.json") + } + withLang := entryFilename(&DTSEntry{Type: "help", ID: "fort", Platform: "", Language: "de"}) + if withLang != "help-fort-de.json" { + t.Errorf("agnostic+lang filename = %q, want %q", withLang, "help-fort-de.json") + } +} + +func TestIsPlatformAgnosticType(t *testing.T) { + if !IsPlatformAgnosticType("help") { + t.Error(`IsPlatformAgnosticType("help") = false, want true`) + } + for _, notAgnostic := range []string{"monster", "raid", "quest", ""} { + if IsPlatformAgnosticType(notAgnostic) { + t.Errorf("IsPlatformAgnosticType(%q) = true, want false", notAgnostic) + } + } +} + func TestGetEntryPrefersLast(t *testing.T) { entries := []DTSEntry{ {Type: "monster", ID: "1", Platform: "discord", Language: "", Template: "old"}, diff --git a/processor/internal/dts/templates.go b/processor/internal/dts/templates.go index 9dc8a418d..d41a47660 100644 --- a/processor/internal/dts/templates.go +++ b/processor/internal/dts/templates.go @@ -1057,6 +1057,22 @@ func entryKey(e *DTSEntry) string { return e.Type + "|" + e.Platform + "|" + e.Language + "|" + strings.ToLower(e.ID.String()) } +// platformAgnosticTypes are DTS template types whose bundled fallbacks carry +// an empty platform ("") — they render the same regardless of destination +// platform, so a user override should stay platform-agnostic too. Keeping an +// override agnostic makes its entryKey match the fallback's, so the override +// cleanly shadows the fallback instead of appearing as a second entry. Today +// only "help" (per-command help text, loaded from fallbacks/dts/help/*.json) +// is agnostic; add future agnostic types here. +var platformAgnosticTypes = map[string]bool{"help": true} + +// IsPlatformAgnosticType reports whether a DTS type is platform-agnostic and +// may therefore be saved with an empty platform. Non-agnostic types (monster, +// raid, …) still require a concrete platform on save. +func IsPlatformAgnosticType(dtsType string) bool { + return platformAgnosticTypes[dtsType] +} + // dedupEntriesPreferLast returns a slice containing each entry keyed by // entryKey, keeping only the last occurrence. Load order is // fallback → config/dts.json → config/dts/*.json, so the "last" entry is @@ -1098,13 +1114,18 @@ func dedupEntriesPreferLast(entries []DTSEntry) []DTSEntry { } // entryFilename generates a filename for saving an entry to config/dts/. -// Format: {type}-{id}-{platform}[-{lang}].json +// Format: {type}-{id}[-{platform}][-{lang}].json. The platform segment is +// omitted for platform-agnostic entries (empty platform) so they get a clean +// "help-fort.json" rather than a trailing-dash "help-fort-.json". func entryFilename(e *DTSEntry) string { id := strings.ToLower(e.ID.String()) if id == "" { id = "default" } - name := e.Type + "-" + id + "-" + e.Platform + name := e.Type + "-" + id + if e.Platform != "" { + name += "-" + e.Platform + } if e.Language != "" { name += "-" + e.Language }