From 29791a36c6c223625e670d9b1e15829a92aa6aec Mon Sep 17 00:00:00 2001 From: xiexian <3468714225@qq.com> Date: Thu, 1 Oct 2026 18:22:15 +0800 Subject: [PATCH] docs(studio): add current theme author guide and starter --- .github/CODEOWNERS | 1 + docs/PLUGIN_PACKAGES.md | 3 + docs/THEME_AUTHOR_GUIDE.md | 135 +++++++++++++ docs/THEME_PACK.md | 3 + docs/themes/paper-dawn/theme.json | 21 +-- docs/themes/reasonix-plugin.json | 9 + .../serve/theme_author_example_test.go | 178 ++++++++++++++++++ 7 files changed, 332 insertions(+), 18 deletions(-) create mode 100644 docs/THEME_AUTHOR_GUIDE.md create mode 100644 docs/themes/reasonix-plugin.json create mode 100644 internal/frontend/serve/theme_author_example_test.go diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 3c03cce13b..a0de0e7807 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -10,6 +10,7 @@ # User-facing guides and references. /docs/ @SivanCola @esengine +/docs/THEME_AUTHOR_GUIDE.md @esengine @SivanCola /sdk/ @SivanCola @esengine /README.md @SivanCola @esengine /README.zh-CN.md @SivanCola @esengine diff --git a/docs/PLUGIN_PACKAGES.md b/docs/PLUGIN_PACKAGES.md index 5dea176cba..8a00948ea0 100644 --- a/docs/PLUGIN_PACKAGES.md +++ b/docs/PLUGIN_PACKAGES.md @@ -27,6 +27,9 @@ OpenAPI inputs, a reference checker, and a source-linked guide format. The [stdio MCP example](../examples/mcp-line-counter-kit/README.md) bundles a local Go server with build, connection, tool-call, and lifecycle checks. +The [Studio theme author guide](THEME_AUTHOR_GUIDE.md) uses a copyable pure-theme +package, the current token vocabulary, and Appearance activation and cleanup. + ## CLI Mode Use `reasonix plugin` when installing or managing plugin packages from a diff --git a/docs/THEME_AUTHOR_GUIDE.md b/docs/THEME_AUTHOR_GUIDE.md new file mode 100644 index 0000000000..17224b82d3 --- /dev/null +++ b/docs/THEME_AUTHOR_GUIDE.md @@ -0,0 +1,135 @@ +--- +status: active +owner: @esengine +backup: @SivanCola +reviewed: 2026-10-01 +--- + +# Studio theme author guide + +Studio reads a directory containing `theme.json` with `schemaVersion: 1`. +The [Paper Dawn package](themes/reasonix-plugin.json) is a local, token-only +starter with light and dark palettes. Its palette is released as CC0-1.0. +It contributes one theme and no skills, hooks, commands, MCP servers or runtime. + +## Install and try the starter + +From this repository's root, with the Studio `reasonix` CLI on your PATH: + +```bash +reasonix plugin install ./docs/themes --dry-run +reasonix plugin install ./docs/themes --yes +reasonix plugin doctor paper-dawn-kit +reasonix plugin show paper-dawn-kit +``` + +Review the preview before installing: it should contain one plugin action with +`themeCount: 1` and no execution capabilities. Installation copies the package; +moving the source afterwards does not break the installed theme. + +Open **Settings → Appearance** in Studio and choose **Paper Dawn** from the theme +choices. Installing a theme does not select it. Try both light and dark modes, +read ordinary and muted text, and check a dialog and a code block. + +The package's theme identity is `plugin:paper-dawn-kit:paper-dawn`: the plugin +name and the contributed theme directory name determine it. The `id` inside +`theme.json` does not override that plugin identity. + +## Make your own package + +Copy `docs/themes` into a local working folder; keep this layout: + +```text +my-palette/ + reasonix-plugin.json + paper-dawn/ + theme.json +``` + +Change the plugin `name`, `version` and `description` in +`reasonix-plugin.json`, then edit the theme's `name`, `author`, `description` +and palette. If you rename `paper-dawn`, update `contributes.themes` to match. +The manifest uses an explicit relative path; all contributions stay inside +the package root. + +For a live local development copy: + +```bash +cd /absolute/path/to/my-palette +reasonix plugin install . --link --yes +``` + +A link source must be inside the current workspace or your home directory. +A linked package reads your working folder. Keep it in place, and keep the +theme directory name stable. + +After edits, reopen Studio's Appearance settings +and reselect the theme to reread its tokens. For a copied installation, preview +and apply the replacement instead: + +```bash +reasonix plugin install /absolute/path/to/my-palette --replace --dry-run +reasonix plugin install /absolute/path/to/my-palette --replace --yes +``` + +Use your package's manifest name in management commands. Share an immutable +repository commit using the [community author guide](MARKET_AUTHOR_GUIDE.md) +after checking the package and its licence; local installation does not publish it. + +## Current token vocabulary + +Each scheme is a map under `tokens.light` or `tokens.dark`. Both schemes must +contain at least one valid token, or the pack will not load. Omitted tokens +retain Studio defaults or derive from another declared surface. + +The authoritative vocabulary is the kernel's [`Tokens` map](../internal/ext/theme/tokens.go), with value validation in `validToken`, `isColour`, `isLength` and `isFontStack` in that same file. Use those definitions when choosing token names and values; this guide does not maintain a second token table. + +The frontend maps the vocabulary to CSS variables in [`theme.ts`](../desktop/frontend-next/src/ui/theme.ts). The kernel's [`TestThemeTokenVocabularyMatchesTheFrontend`](../internal/ext/theme/tokens_test.go) checks that both sides agree. + +Status colours such as `ok`, `warn` and `err` belong to Studio and cannot be +recoloured by a pack. `sidebar` and `chat` are not current token names. +An unknown token or invalid value is dropped, while valid tokens still load; +the theme choice shows the resulting warnings. + +User contrast settings can adjust a theme's text colours. Check readability +with the contrast settings and both colour schemes instead of assuming the +JSON colour is always the final text colour. + +The retired desktop's `baseStyle`, `recipes`, `taskBackground` and +`schemaVersion: 2` are not the Studio authoring contract. +[Theme Pack V2](THEME_PACK.md) remains the legacy reference. + +## Optional local images + +Keep image files next to `theme.json`. Studio recognises an optional +`background.png`, `.jpg`, `.jpeg` or `.webp`, and likewise a `preview` image. + +The reader selects those conventional filenames; a `background.image` field +alone does not locate an arbitrary file. +Keep each image at most 8 MiB so the asset endpoint can serve it. + +The current `background` block controls `focusX`, `focusY`, `safeArea` +(`left`, `center`, `right`), `homeOpacity`, `taskOpacity` and +`overlayStrength`. Numeric placement and opacity values are bounded to 0–1. + +Use a low task opacity so the image does not compete with a transcript. +Check your image licence and avoid including workspace screenshots or secrets. + +## Disable and clean up + +```bash +reasonix plugin disable paper-dawn-kit +reasonix plugin enable paper-dawn-kit +reasonix plugin remove paper-dawn-kit --yes +``` + +Restart Studio after external CLI changes, then reopen Appearance settings. +Disabling hides the contributed +theme and retains the copied files; re-enabling restores the same identity. +Removing a copied package deletes its installed copy. Removing a linked +package retains the source working folder. + +Studio retains the selected theme identity when its plugin becomes unavailable +and renders its default appearance until the theme is available again. Choose +the default theme in Appearance settings to clear that selection explicitly. +Themes do not enter model prompts or add tools to a conversation. diff --git a/docs/THEME_PACK.md b/docs/THEME_PACK.md index f9a479fa57..fa913aa41f 100644 --- a/docs/THEME_PACK.md +++ b/docs/THEME_PACK.md @@ -8,6 +8,9 @@ archives. What Reasonix Studio reads instead: - plugin themes are packs of that shape, listed as `plugin::` (see `docs/PLUGIN_PACKAGES.md`). +For the current format, a directly installable starter, and its management +lifecycle, use the [Studio theme author guide](THEME_AUTHOR_GUIDE.md). + Native theme packs for the Reasonix desktop app. Packs are controlled skins: semantic color tokens, density/corner recipes, and optional local images for the home and task/workspace scenes. They **cannot** run CSS, JavaScript, fonts, diff --git a/docs/themes/paper-dawn/theme.json b/docs/themes/paper-dawn/theme.json index 83706fcd53..e3133e17e2 100644 --- a/docs/themes/paper-dawn/theme.json +++ b/docs/themes/paper-dawn/theme.json @@ -3,43 +3,28 @@ "id": "paper-dawn", "name": "Paper Dawn", "author": "Reasonix", - "description": "Template theme — solid tokens only, no background image. Royalty-free starter.", + "description": "Studio theme template with light and dark palettes, no background image. CC0-1.0 starter.", "license": "CC0-1.0", - "baseStyle": "graphite", "tokens": { "light": { "bg": "#f7f4ef", "panel": "#ffffff", - "sidebar": "#f3efe8", - "chat": "#fbfaf7", "fg": "#1c1917", "fgDim": "#57534e", "fgFaint": "#a8a29e", "border": "#e7e5e4", "accent": "#c2410c", - "accentFg": "#fff7ed", - "ok": "#15803d", - "warn": "#b45309", - "err": "#b91c1c" + "accentFg": "#fff7ed" }, "dark": { "bg": "#0c0b0a", "panel": "#171412", - "sidebar": "#141210", - "chat": "#0c0b0a", "fg": "#f5f5f4", "fgDim": "#a8a29e", "fgFaint": "#78716c", "border": "#292524", "accent": "#fb923c", - "accentFg": "#0c0b0a", - "ok": "#4ade80", - "warn": "#fbbf24", - "err": "#f87171" + "accentFg": "#0c0b0a" } - }, - "recipes": { - "density": "comfortable", - "corners": "soft" } } diff --git a/docs/themes/reasonix-plugin.json b/docs/themes/reasonix-plugin.json new file mode 100644 index 0000000000..fc1870ed5b --- /dev/null +++ b/docs/themes/reasonix-plugin.json @@ -0,0 +1,9 @@ +{ + "apiVersion": "reasonix.io/plugin/v2", + "name": "paper-dawn-kit", + "version": "1.0.0", + "description": "A local Studio theme template with light and dark palettes.", + "contributes": { + "themes": ["paper-dawn/theme.json"] + } +} diff --git a/internal/frontend/serve/theme_author_example_test.go b/internal/frontend/serve/theme_author_example_test.go new file mode 100644 index 0000000000..05121ad0d2 --- /dev/null +++ b/internal/frontend/serve/theme_author_example_test.go @@ -0,0 +1,178 @@ +package serve + +import ( + "encoding/json" + "net/http" + "os" + "path/filepath" + "reflect" + "testing" + + "reasonix/internal/base/testenv" + "reasonix/internal/contract/config" + "reasonix/internal/ext/installsource" + "reasonix/internal/ext/pluginpkg" + "reasonix/internal/ext/theme" +) + +func TestStudioThemeAuthorExampleLifecycle(t *testing.T) { + home := testenv.TempDir(t) + reasonixHome := filepath.Join(home, ".reasonix") + t.Setenv("REASONIX_HOME", reasonixHome) + t.Setenv("REASONIX_STATE_HOME", reasonixHome) + source := filepath.Join(testenv.TempDir(t), "theme-example") + if err := os.CopyFS(source, os.DirFS(filepath.Join("..", "..", "..", "docs", "themes"))); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(filepath.Join(source, "paper-dawn", "theme.json")) + if err != nil { + t.Fatal(err) + } + var authored struct { + Name string `json:"name"` + Tokens map[string]map[string]string `json:"tokens"` + } + if err := json.Unmarshal(raw, &authored); err != nil { + t.Fatal(err) + } + installer := installsource.NewTool(installsource.Options{ + ProjectRoot: testenv.TempDir(t), HomeDir: home, RequireApprovedPlan: true, + }) + type installResult struct { + OK bool `json:"ok"` + Status string `json:"status"` + Applied bool `json:"applied"` + PlanID string `json:"planId"` + Actions []struct { + Target string `json:"target"` + ThemeCount int `json:"themeCount"` + SkillCount int `json:"skillCount"` + AgentCount int `json:"agentCount"` + CommandCount int `json:"commandCount"` + PromptCount int `json:"promptCount"` + HookCount int `json:"hookCount"` + ToolCount int `json:"toolCount"` + Runtime any `json:"runtime"` + } `json:"actions"` + } + run := func(args map[string]any) installResult { + t.Helper() + input, err := json.Marshal(args) + if err != nil { + t.Fatal(err) + } + output, err := installer.Execute(t.Context(), input) + if err != nil { + t.Fatal(err) + } + var result installResult + if err := json.Unmarshal([]byte(output), &result); err != nil { + t.Fatalf("install response %q: %v", output, err) + } + if !result.OK { + t.Fatalf("install response: %s", output) + } + return result + } + const id = "plugin:paper-dawn-kit:paper-dawn" + srv := themeServer(t) + check := func(stage string, present, active bool) { + t.Helper() + t.Run(stage, func(t *testing.T) { + response, err := http.Get(srv.URL + "/themes") + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("theme listing = %d", response.StatusCode) + } + var rows []themeView + if err := json.NewDecoder(response.Body).Decode(&rows); err != nil { + t.Fatal(err) + } + found := false + for _, row := range rows { + if row.ID != id { + continue + } + found = true + if row.Name != authored.Name || row.Active != active || len(row.Warnings) != 0 || !reflect.DeepEqual(row.Tokens, authored.Tokens) { + t.Fatalf("theme row = %+v, want active=%t and all authored tokens without warnings", row, active) + } + if row.Background != nil || row.Sky != nil || row.HasPreview { + t.Fatalf("token-only example has image or sky: %+v", row) + } + } + if found != present { + t.Fatalf("theme present = %t, want %t", found, present) + } + if _, err := theme.Load(id); (err == nil) != present { + t.Fatalf("theme load error = %v, want present=%t", err, present) + } + }) + } + check("not-installed", false, false) + preview := run(map[string]any{"source": source, "kind": "plugin", "apply": false}) + if preview.Status != "planned" || preview.Applied || preview.PlanID == "" || len(preview.Actions) != 1 { + t.Fatalf("preview = %+v", preview) + } + action := preview.Actions[0] + if action.ThemeCount != 1 || action.SkillCount != 0 || action.AgentCount != 0 || action.CommandCount != 0 || action.PromptCount != 0 || action.HookCount != 0 || action.ToolCount != 0 || action.Runtime != nil { + t.Fatalf("pure-theme preview = %+v", action) + } + check("preview", false, false) + installed := run(map[string]any{"source": source, "kind": "plugin", "apply": true, "planId": preview.PlanID}) + if installed.Status != "done" || !installed.Applied || len(installed.Actions) != 1 { + t.Fatalf("installed = %+v", installed) + } + installedRoot := installed.Actions[0].Target + if installedRoot == "" { + t.Fatal("install has no copied target") + } + if err := os.RemoveAll(source); err != nil { + t.Fatal(err) + } + check("copied-source-removed", true, false) + activate := func(themeID string, status int) { + t.Helper() + response := postJSON(t, srv.URL+"/themes", map[string]string{"id": themeID}) + defer response.Body.Close() + if response.StatusCode != status { + t.Fatalf("activate %q = %d, want %d", themeID, response.StatusCode, status) + } + } + activate(id, http.StatusNoContent) + check("active", true, true) + if err := pluginpkg.SetEnabled(reasonixHome, "paper-dawn-kit", false); err != nil { + t.Fatal(err) + } + check("disabled", false, false) + activate(id, http.StatusUnprocessableEntity) + if cfg, err := config.Load(); err != nil || cfg.Desktop.ThemePack != id { + t.Fatalf("disabled selection was not retained: config=%+v err=%v", cfg, err) + } + if _, err := os.Stat(filepath.Join(installedRoot, "paper-dawn", "theme.json")); err != nil { + t.Fatalf("disable removed copied content: %v", err) + } + if err := pluginpkg.SetEnabled(reasonixHome, "paper-dawn-kit", true); err != nil { + t.Fatal(err) + } + check("re-enabled-active", true, true) + removed := run(map[string]any{"op": "uninstall", "name": "paper-dawn-kit", "kind": "plugin"}) + if removed.Status != "done" || !removed.Applied { + t.Fatalf("uninstall = %+v", removed) + } + check("removed", false, false) + if _, err := os.Stat(installedRoot); !os.IsNotExist(err) { + t.Fatalf("uninstall retained copied content: %v", err) + } + if cfg, err := config.Load(); err != nil || cfg.Desktop.ThemePack != id { + t.Fatalf("removed selection was not retained: config=%+v err=%v", cfg, err) + } + activate("", http.StatusNoContent) + if cfg, err := config.Load(); err != nil || cfg.Desktop.ThemePack != "" { + t.Fatalf("default appearance was not restored: config=%+v err=%v", cfg, err) + } + check("default", false, false) +}