From 821486af023a181d8225f0d46b5b54d00a87e6f9 Mon Sep 17 00:00:00 2001 From: Andrei Kirkouski Date: Mon, 22 Jun 2026 12:23:37 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(facade):=20tanstack=20requirement=20?= =?UTF-8?q?=E2=86=92=20themeMode-style=20passthrough=20to=20AppConfig.tans?= =?UTF-8?q?tack?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose a `tanstack` JSON requirement (default "{}") that the config handler decodes and returns as top-level `tanstack` in /api/public/facade/config, so the Web Host can apply TanStack Query defaults globally and per role-based category (content/lists). Mirrors the api_routes/axios_defaults JSON-requirement pattern. - config_handler.lua: read tanstack via non_empty_map_or_nil(get_req_json_any), add `tanstack` to the returned config table - _index.yaml: tanstack ns.requirement → wippy.facade:tanstack .default - config_handler_test.lua: tanstack in REQ_NAMES + defaults; default + decode tests - README: tanstack row in the optional JSON parameters table Stacked on the theme_mode facade branch (config_handler already returns themeMode). --- src/facade/README.md | 1 + src/facade/_index.yaml | 9 +++++++++ src/facade/config_handler.lua | 4 ++++ src/facade/config_handler_test.lua | 28 ++++++++++++++++++++++++++++ 4 files changed, 42 insertions(+) diff --git a/src/facade/README.md b/src/facade/README.md index 25bfb96..6b409b7 100644 --- a/src/facade/README.md +++ b/src/facade/README.md @@ -109,6 +109,7 @@ These accept JSON strings for complex configuration: | `allow_additional_tags` | `{}` | `hostConfig.allowAdditionalTags` | HTML sanitizer tag whitelist (e.g. `{"w-chart":["data","type"]}`) | | `chat` | `{}` | `hostConfig.chat` | Chat config (e.g. `{"convertPasteToFile":{"enabled":true,"minFileSize":1024,"allowHtml":false}}`) | | `axios_defaults` | `{}` | `axiosDefaults` | HTTP client defaults (e.g. `{"timeout":30000}`) — top-level, not under hostConfig | +| `tanstack` | `{}` | `tanstack` | TanStack Query defaults — top-level, not under hostConfig. `{ default?, content?, lists? }`: `default` applies to all queries, `content` to single-resource renders, `lists` to navigation/index queries. Host default is `refetchOnWindowFocus:false` (e.g. `{"lists":{"refetchOnWindowFocus":true}}`) | | `extra_scripts` | `[]` | `extraScripts` | External `` — in the `` of any page that should share the theme (the host shell already does). It applies the stored theme before paint and exposes `window.wippyThemePersist` (`read`, `write`, `apply`). The Web Host emits a `themeChanged` event the shell uses to persist changes. > **Boolean parsing:** `show_admin` defaults to `true` (any value except `"false"` is truthy). All other boolean flags default to `false` (only `"true"` is truthy). @@ -372,9 +377,9 @@ Returns an empty body (200 OK) when no variables are configured. Response has `C ```json { - "facade_url": "https://web-host.wippy.ai/webcomponents-1.0.40", + "facade_url": "https://web-host.wippy.ai/webcomponents-1.0.41", "iframe_origin": "https://web-host.wippy.ai", - "iframe_url": "https://web-host.wippy.ai/webcomponents-1.0.40/iframe.html?waitForCustomConfig", + "iframe_url": "https://web-host.wippy.ai/webcomponents-1.0.41/iframe.html?waitForCustomConfig", "login_path": "/login.html", "login_redirect_param": null, "env": { diff --git a/src/facade/_index.yaml b/src/facade/_index.yaml index c221d1e..a0088fb 100644 --- a/src/facade/_index.yaml +++ b/src/facade/_index.yaml @@ -17,16 +17,20 @@ entries: targets: - entry: wippy.facade:static path: .meta.server + - entry: wippy.facade:web_router + path: .meta.server - name: router kind: ns.requirement meta: - description: Public API router for config and CSS variable endpoints + description: Public API router for config, CSS variable, and theme-persist endpoints targets: - entry: wippy.facade:config_endpoint path: .meta.router - entry: wippy.facade:css_vars_endpoint path: .meta.router + - entry: wippy.facade:theme_persist_endpoint + path: .meta.router - name: fe_facade_url kind: ns.requirement @@ -35,7 +39,7 @@ entries: targets: - entry: wippy.facade:fe_facade_url path: .default - default: https://web-host.wippy.ai/webcomponents-1.0.40 + default: https://web-host.wippy.ai/webcomponents-1.0.41 - name: fe_entry_path kind: ns.requirement @@ -163,6 +167,36 @@ entries: path: .default default: auto + - name: theme_persist + kind: ns.requirement + meta: + description: | + Where the user's chosen theme mode is persisted across reloads. + "none" (default) keeps the current behavior (theme always comes from + theme_mode). "cookie" stores it in a cookie — the Jet shell reads it + server-side and renders the w-theme-* class before paint (no flash). + "localStorage" stores it client-side (a brief flash is unavoidable; the + server can't read it). The store name is theme_storage_key. The facade + serves a ready-made script at /facade/theme-persist.js that applies and + writes the value; the host shell and login/non-host pages just include it. + targets: + - entry: wippy.facade:theme_persist + path: .default + default: none + + - name: theme_storage_key + kind: ns.requirement + meta: + description: | + Cookie / localStorage key used to persist the theme mode when + theme_persist is "cookie" or "localStorage". Returned in /facade/config + as themeStorageKey and baked into /facade/theme-persist.js so non-Wippy + pages share the same key. + targets: + - entry: wippy.facade:theme_storage_key + path: .default + default: "@wippy-theme-mode" + # Auth - name: login_path kind: ns.requirement @@ -380,10 +414,14 @@ entries: directory: ./public/ mode: "0755" + # Serves facade static assets (@wippy-fe/loading.js) and is the SPA fallback + # for history-mode deep links. The Jet shell endpoints below match only the + # exact "/" and "/index.html" (Go 1.22 "{$}" = exact match), a strict subset + # of this catch-all, so they win for those paths without shadowing assets. - name: static kind: http.static meta: - comment: Serves facade index.html + comment: Serves facade assets (@wippy-fe/*) + SPA fallback server: server fs: public_files directory: / @@ -392,6 +430,62 @@ entries: index: index.html spa: true + # Jet-rendered shell (index.html) — server-side theme cookie application + # Root router for the Jet shell. Endpoints use exact-match paths ("/{$}" and + # "/index.html") so they only intercept those two routes; the /api/public API + # router and the "/" static catch-all handle everything else. + - name: web_router + kind: http.router + meta: + comment: Root router for the Jet-rendered facade shell + server: server + prefix: / + + - name: templates + kind: template.set + meta: + comment: Facade Jet template set (shell) + + - name: index + kind: template.jet + meta: + content_type: text/html + comment: Facade shell template — conditionally bakes the theme class onto + source: file://index.jet + set: wippy.facade:templates + + - name: index_handler + kind: function.lua + meta: + comment: Renders the facade shell; applies the persisted theme from the request cookie (cookie mode) + source: file://index_handler.lua + method: handler + modules: + - http + - registry + - templates + security: + actor: + id: wippy.facade:index_handler + policies: + - wippy.facade:config_read_runtime + + - name: index_endpoint + kind: http.endpoint + meta: + router: web_router + method: GET + path: /{$} + func: index_handler + + - name: index_html_endpoint + kind: http.endpoint + meta: + router: web_router + method: GET + path: /index.html + func: index_handler + # Shared helpers: file:// resolution and CSS variable generation - name: theming_helpers kind: library.lua @@ -460,6 +554,31 @@ entries: path: /facade/variables.css func: css_vars_handler + # Theme-persistence script endpoint + - name: theme_persist_handler + kind: function.lua + meta: + comment: Serves /facade/theme-persist.js — early-apply + window.wippyThemePersist with key/mode baked in + source: file://theme_persist_handler.lua + method: handler + modules: + - http + - registry + - json + security: + actor: + id: wippy.facade:theme_persist_handler + policies: + - wippy.facade:config_read_runtime + + - name: theme_persist_endpoint + kind: http.endpoint + meta: + router: router + method: GET + path: /facade/theme-persist.js + func: theme_persist_handler + - name: config_read_runtime kind: security.policy meta: diff --git a/src/facade/config_handler.lua b/src/facade/config_handler.lua index adf0ee8..c523d05 100644 --- a/src/facade/config_handler.lua +++ b/src/facade/config_handler.lua @@ -192,6 +192,18 @@ local function handler() theme_mode = "auto" end + -- Persistence of the chosen theme mode. Clamp to the valid enum (bad value → + -- "none" = no persistence). theme_storage_key falls back to the documented + -- default so a blank requirement still produces a usable key. + local theme_persist = get_req("theme_persist") + if theme_persist ~= "cookie" and theme_persist ~= "localStorage" then + theme_persist = "none" + end + local theme_storage_key = get_req("theme_storage_key") + if theme_storage_key == "" then + theme_storage_key = "@wippy-theme-mode" + end + local config = { facade_url = facade_url, iframe_origin = iframe_origin, @@ -208,6 +220,8 @@ local function handler() }, routePrefix = non_empty_or_nil(api_url), themeMode = theme_mode, + themePersist = theme_persist, + themeStorageKey = theme_storage_key, apiRoutes = api_routes, axiosDefaults = axios_defaults, tanstack = tanstack, diff --git a/src/facade/config_handler_test.lua b/src/facade/config_handler_test.lua index afc0ed4..aeb705a 100644 --- a/src/facade/config_handler_test.lua +++ b/src/facade/config_handler_test.lua @@ -17,6 +17,7 @@ local REQ_NAMES: {string} = { "api_routes", "additional_nav_items", "state_cache", "allow_additional_tags", "chat", "axios_defaults", "extra_scripts", "host_config_layout", "theme_mode", + "theme_persist", "theme_storage_key", "tanstack", } @@ -56,6 +57,8 @@ local function setup_registry(overrides: {[string]: string}?) extra_scripts = "[]", host_config_layout = "{}", theme_mode = "auto", + theme_persist = "none", + theme_storage_key = "@wippy-theme-mode", tanstack = "{}", } @@ -93,6 +96,23 @@ local function derive_ws_url(api_url: string): string return api_url:gsub("^https://", "wss://"):gsub("^http://", "ws://") end +-- Mirrors the clamp in config_handler.lua: only "cookie"/"localStorage" are +-- valid persistence targets; anything else (typo/misconfig) → "none". +local function clamp_theme_persist(value: string): string + if value ~= "cookie" and value ~= "localStorage" then + return "none" + end + return value +end + +-- Mirrors the storage-key fallback: blank requirement → documented default. +local function clamp_theme_storage_key(value: string): string + if value == "" then + return "@wippy-theme-mode" + end + return value +end + local function define_tests() test.describe("config handler", function() test.describe("ws url derivation", function() @@ -113,6 +133,28 @@ local function define_tests() end) end) + test.describe("theme persistence clamping", function() + test.it("keeps cookie and localStorage as-is", function() + test.eq(clamp_theme_persist("cookie"), "cookie") + test.eq(clamp_theme_persist("localStorage"), "localStorage") + end) + + test.it("clamps unknown / typo values to none", function() + test.eq(clamp_theme_persist("none"), "none") + test.eq(clamp_theme_persist(""), "none") + test.eq(clamp_theme_persist("localstorage"), "none") + test.eq(clamp_theme_persist("session"), "none") + end) + + test.it("storage key falls back to default when blank", function() + test.eq(clamp_theme_storage_key(""), "@wippy-theme-mode") + end) + + test.it("storage key keeps a custom value", function() + test.eq(clamp_theme_storage_key("my.theme"), "my.theme") + end) + end) + test.describe("iframe URL construction", function() test.it("builds iframe URL from facade_url and entry_path", function() local facade_url = "https://front.wippy.ai" @@ -123,7 +165,7 @@ local function define_tests() end) test.it("extracts iframe origin from facade URL", function() - local facade_url = "https://web-host.wippy.ai/webcomponents-1.0.40" + local facade_url = "https://web-host.wippy.ai/webcomponents-1.0.41" local origin = facade_url:match("^(https?://[^/]+)") test.eq(origin, "https://web-host.wippy.ai") @@ -347,6 +389,8 @@ local function define_tests() }, routePrefix = "http://localhost:8085", themeMode = "auto", + themePersist = "cookie", + themeStorageKey = "@wippy-theme-mode", theming = { global = { customCSS = "@import url('https://fonts.example.com');", @@ -388,6 +432,8 @@ local function define_tests() test.is_false(decoded.hostConfig.allowSelectModel) test.eq(decoded.theming.host.i18n.app.title, "Wippy") test.eq(decoded.themeMode, "auto") + test.eq(decoded.themePersist, "cookie") + test.eq(decoded.themeStorageKey, "@wippy-theme-mode") test.eq(decoded.login_path, "/login.html") end) end) diff --git a/src/facade/index.jet b/src/facade/index.jet new file mode 100644 index 0000000..aeab7ab --- /dev/null +++ b/src/facade/index.jet @@ -0,0 +1,294 @@ + + + + + + + + + Wippy + + + + +
+
+ +
+ + + diff --git a/src/facade/index_handler.lua b/src/facade/index_handler.lua new file mode 100644 index 0000000..53a26bd --- /dev/null +++ b/src/facade/index_handler.lua @@ -0,0 +1,86 @@ +local http = require("http") +local registry = require("registry") +local templates = require("templates") + +local NS = "wippy.facade:" +local TEMPLATE_SET = NS .. "templates" + +local function get_req(name: string): string + local entry, _ = registry.get(NS .. name) + if entry and entry.data then + return entry.data.default or "" + end + return "" +end + +-- Pull a single cookie value out of the raw Cookie header +-- ("a=1; @wippy-theme-mode=dark; b=2" → "dark"). Plain string match — no +-- pattern metachars from `name` reach gsub (we compare with ==). +local function cookie_value(header: string?, name: string): string? + if not header or header == "" then + return nil + end + for pair in header:gmatch("[^;]+") do + local k, v = pair:match("^%s*(.-)%s*=%s*(.*)$") + if k == name then + return v + end + end + return nil +end + +-- Renders the facade shell (index.jet). The only server-side dynamic part is the +-- theme: in "cookie" persistence mode the stored mode is read from the request +-- cookie and the matching w-theme-* class is baked onto so the very first +-- paint is already themed (no flash). "localStorage"/"none" render no class — the +-- served theme-persist.js handles those client-side. +local function handler() + local req = http.request() + local res = http.response() + if not res then + return nil, "no HTTP context" + end + + local persist = get_req("theme_persist") + local key = get_req("theme_storage_key") + if key == "" then + key = "@wippy-theme-mode" + end + + local has_theme = false + local theme_class = "" + local color_scheme = "" + if persist == "cookie" and req then + local stored = cookie_value(req:header("Cookie"), key) + if stored == "dark" then + has_theme = true + theme_class = "w-theme-dark" + color_scheme = "dark" + elseif stored == "light" then + has_theme = true + theme_class = "w-theme-light" + color_scheme = "light" + end + end + + local set = templates.get(TEMPLATE_SET) + local html, err = set:render("index", { + hasTheme = has_theme, + themeClass = theme_class, + colorScheme = color_scheme, + }) + if err then + res:set_status(http.STATUS.INTERNAL_SERVER_ERROR) + res:set_content_type("text/html") + res:write("WippyFailed to render shell.") + return nil, err + end + + res:set_content_type("text/html") + -- The shell embeds a per-user theme from a cookie; never cache across users. + res:set_header("Cache-Control", "no-store") + res:set_status(http.STATUS.OK) + res:write(html) +end + +return { handler = handler } diff --git a/src/facade/public/index.html b/src/facade/public/index.html index 3e4357c..7462282 100644 --- a/src/facade/public/index.html +++ b/src/facade/public/index.html @@ -4,6 +4,11 @@ + + Wippy