From 11f69b61e4261b19056722ae96bd1c713575ff75 Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Wed, 8 Jul 2026 09:41:09 +0300 Subject: [PATCH 01/27] docs(dashboard): add variable text fields implementation plan 21 TDD tasks: pure logic foundations (tokenize, validation, caret, filter, registry, automation vars, preview resolver), React components (VariableEditor, VariableBrowser, DiscordMessagePreview, usePreviewContext), and rollout across welcome/commands/scheduled/leveling/tempvoice/automation. Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-07-08-variable-text-fields.md | 2063 +++++++++++++++++ 1 file changed, 2063 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-08-variable-text-fields.md diff --git a/docs/superpowers/plans/2026-07-08-variable-text-fields.md b/docs/superpowers/plans/2026-07-08-variable-text-fields.md new file mode 100644 index 0000000..5a506b4 --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-variable-text-fields.md @@ -0,0 +1,2063 @@ +# Variable Text Fields Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give every `{variable}` template text field in the dashboard `{`-triggered autocomplete, a searchable variable browser, inline highlighting, unknown-variable validation, and a live Discord-style preview — via one reusable component set. + +**Architecture:** A new module `apps/dashboard/src/client/shared/ui/variable-field/` holds three React components (`VariableEditor`, `VariableBrowser`, `DiscordMessagePreview`) plus pure, DOM-free helper modules (tokenizing, caret math, filtering, validation, preview resolution) and a per-scope variable registry. All logic lives in the pure helpers (unit-tested under the existing node vitest env); the components are thin wrappers (tested under a newly-added jsdom env). Rollout swaps the plain `Input`/`Textarea` in six feature areas for `VariableEditor` and adds a preview. + +**Tech Stack:** React 19, TypeScript (strict), Tailwind CSS 4 (Obsidian Engine tokens), Radix (`popover`, `scroll-area`), TanStack Query hooks (`useAuth`, `useGuilds`, `useConstants`), Vitest, `@testing-library/react` + `user-event` + `jsdom` (added in Task 1), react-i18next. + +## Global Constraints + +- **All `pnpm add`/`install` and all test runs execute inside Docker.** Canonical test command (abbreviated `` below): + `docker compose --profile bot run --rm bot pnpm --filter @fluxcore/dashboard test -- ` + Typecheck: `docker compose --profile bot run --rm --no-deps bot pnpm --filter @fluxcore/dashboard exec tsc -p tsconfig.client.json --noEmit`. +- **Strict TypeScript — no `any`.** Prefer `unknown` + narrowing. +- **UI components live at** `apps/dashboard/src/client/shared/ui/` (NOT `components/ui`). Import `cn` from `../lib/utils`. Client imports use **relative paths** (no `@/` alias). +- **Placeholder syntax is `{token}` (curly braces).** A token matches `/\{[\w.]+\}/`. +- **Do NOT rename/unify existing runtime tokens** across features (welcome `{user.name}`/`{membercount}` vs custom-commands `{username}`/`{memberCount}`). Each field surfaces its own set. +- **No new backend endpoints.** Preview uses already-loaded `useGuilds()` (id/name/icon) + `useAuth()` (userId/username/avatar); `memberCount` is not client-available and uses a sample value. +- **Design tokens:** accent = `text-accent`; destructive = `text-danger`; surfaces `bg-surface-lowest`/`bg-surface-container`; muted `text-text-muted`. Match existing `Input`/`Textarea` metrics: `text-sm`, `px-3`, input `py-1 h-9`, textarea `py-2 min-h-[60px]`. +- **Every task ends green** (its tests pass) and is committed. Commit messages: `feat(dashboard): …` or `test(dashboard): …`. +- **Locale source of truth:** `packages/i18n/src/locales/en/*.json` (never `dist`); other 47 langs fall back to `en`. + +--- + +## File Structure + +``` +apps/dashboard/src/client/shared/ui/variable-field/ + types.ts VariableDescriptor, Segment, UnknownToken, PreviewRealData, RealDataKey + tokens.ts TOKEN_REGEX, tokenize(), extractTokens() + validation.ts levenshtein(), detectUnknownTokens() + caret.ts insertToken(), getActiveQuery() + filterVariables.ts filterByQuery() + registry.ts descriptor arrays (welcome/customCommand/leveling/tempvoice), + SAMPLE/GROUP/REAL maps, knownTokenSet(), buildRealData(), buildTokenValues() + automationVariables.ts buildAutomationVariables() + resolvePreview.ts resolveTemplatePreview() + usePreviewContext.ts usePreviewContext(guildId) hook + VariableEditor.tsx the field (input/textarea + overlay + autocomplete + validation) + VariableBrowser.tsx "Insert variable" popover + DiscordMessagePreview.tsx Discord-style message/embed bubble + index.ts barrel + +apps/dashboard/tests/client/shared/ui/variable-field/ + tokens.test.ts validation.test.ts caret.test.ts filterVariables.test.ts + registry.test.ts automationVariables.test.ts resolvePreview.test.ts + VariableEditor.test.tsx VariableBrowser.test.tsx DiscordMessagePreview.test.tsx +``` + +--- + +## Phase 0 — Test toolchain + +### Task 1: Add client (jsdom) test toolchain + +**Files:** +- Modify: `apps/dashboard/package.json` (devDependencies) +- Modify: `apps/dashboard/vitest.config.ts` +- Test: `apps/dashboard/tests/client/smoke.test.tsx` + +**Interfaces:** +- Produces: a working jsdom test environment so `*.test.tsx` under `tests/client/**` render React components. + +- [ ] **Step 1: Install devDependencies (in Docker)** + +Run: +```bash +docker compose --profile bot run --rm --no-deps bot \ + pnpm --filter @fluxcore/dashboard add -D @testing-library/react @testing-library/user-event jsdom +``` +Expected: pnpm reports the three packages added to `apps/dashboard/package.json` devDependencies. + +- [ ] **Step 2: Update `vitest.config.ts` to include `.test.tsx`** + +Replace the `include` line so both server `.ts` and client `.tsx` tests are collected. New `test` block: + +```ts + test: { + globals: true, + include: ["tests/**/*.test.ts", "tests/**/*.test.tsx"], + alias: { + "../../src/server/": resolve(__dirname, "src/server") + "/", + }, + coverage: { + provider: "v8", + include: ["src/server/**/*.ts", "src/client/shared/ui/variable-field/**/*.ts"], + exclude: ["src/server/index.ts"], + }, + }, +``` + +(Global environment stays `node`; component test files opt into jsdom via a docblock in Step 3.) + +- [ ] **Step 3: Write the smoke component test** + +Create `apps/dashboard/tests/client/smoke.test.tsx`: + +```tsx +// @vitest-environment jsdom +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; + +function Hello() { + return ; +} + +describe("jsdom toolchain", () => { + it("renders a component into the DOM", () => { + render(); + expect(screen.getByRole("button", { name: "click me" })).toBeTruthy(); + }); +}); +``` + +- [ ] **Step 4: Run it and confirm it passes** + +Run: ` tests/client/smoke.test.tsx` +Expected: PASS (1 test). + +- [ ] **Step 5: Commit** + +```bash +git add apps/dashboard/package.json apps/dashboard/pnpm-lock.yaml ../../pnpm-lock.yaml apps/dashboard/vitest.config.ts apps/dashboard/tests/client/smoke.test.tsx +git commit -m "test(dashboard): add jsdom + testing-library client test toolchain" +``` +(If the lockfile lives only at repo root, adjust the `git add` to the actual changed lockfile path shown by `git status`.) + +--- + +## Phase 1 — Pure logic foundations (node env) + +### Task 2: Token types + tokenizer + +**Files:** +- Create: `apps/dashboard/src/client/shared/ui/variable-field/types.ts` +- Create: `apps/dashboard/src/client/shared/ui/variable-field/tokens.ts` +- Test: `apps/dashboard/tests/client/shared/ui/variable-field/tokens.test.ts` + +**Interfaces:** +- Produces: + - `type RealDataKey = "userMention"|"userName"|"userTag"|"userId"|"userAvatar"|"serverName"|"serverId"|"serverIcon"|"memberCount"` + - `type VariableGroup = "user"|"server"|"channel"|"role"|"message"|"event"|"misc"` + - `interface VariableDescriptor { token: string; labelKey?: string; description?: string; example: string; group: VariableGroup; realKey?: RealDataKey }` + - `interface Segment { type: "text"|"var"; value: string; known: boolean }` + - `interface UnknownToken { token: string; suggestion: string | null }` + - `interface PreviewRealData { userMention: string; userName: string; userTag: string; userId: string; userAvatar: string; serverName: string; serverId: string; serverIcon: string; memberCount: string }` + - `const TOKEN_REGEX: RegExp` (global, matches `\{[\w.]+\}`) + - `function tokenize(value: string, known: Set): Segment[]` + - `function extractTokens(value: string): string[]` + +- [ ] **Step 1: Write `types.ts`** + +```ts +export type RealDataKey = + | "userMention" | "userName" | "userTag" | "userId" | "userAvatar" + | "serverName" | "serverId" | "serverIcon" | "memberCount"; + +export type VariableGroup = + | "user" | "server" | "channel" | "role" | "message" | "event" | "misc"; + +export interface VariableDescriptor { + token: string; + labelKey?: string; + description?: string; + example: string; + group: VariableGroup; + realKey?: RealDataKey; +} + +export interface Segment { + type: "text" | "var"; + value: string; + known: boolean; +} + +export interface UnknownToken { + token: string; + suggestion: string | null; +} + +export interface PreviewRealData { + userMention: string; + userName: string; + userTag: string; + userId: string; + userAvatar: string; + serverName: string; + serverId: string; + serverIcon: string; + memberCount: string; +} +``` + +- [ ] **Step 2: Write the failing test** + +Create `tokens.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { tokenize, extractTokens } from "../../../../../src/client/shared/ui/variable-field/tokens"; + +const known = new Set(["{user}", "{server}"]); + +describe("tokenize", () => { + it("splits text and variable segments", () => { + const segs = tokenize("Hi {user}!", known); + expect(segs).toEqual([ + { type: "text", value: "Hi ", known: false }, + { type: "var", value: "{user}", known: true }, + { type: "text", value: "!", known: false }, + ]); + }); + + it("marks unknown variables as not known", () => { + const segs = tokenize("{nope}", known); + expect(segs).toEqual([{ type: "var", value: "{nope}", known: false }]); + }); + + it("returns a single text segment when there are no tokens", () => { + expect(tokenize("plain", known)).toEqual([{ type: "text", value: "plain", known: false }]); + }); +}); + +describe("extractTokens", () => { + it("returns every token occurrence", () => { + expect(extractTokens("{user} and {user} and {server}")).toEqual(["{user}", "{user}", "{server}"]); + }); + it("returns [] when none", () => { + expect(extractTokens("none here")).toEqual([]); + }); +}); +``` + +- [ ] **Step 3: Run it to confirm it fails** + +Run: ` tests/client/shared/ui/variable-field/tokens.test.ts` +Expected: FAIL (cannot find module `tokens`). + +- [ ] **Step 4: Write `tokens.ts`** + +```ts +import type { Segment } from "./types"; + +export const TOKEN_REGEX = /\{[\w.]+\}/g; + +export function tokenize(value: string, known: Set): Segment[] { + const segments: Segment[] = []; + let lastIndex = 0; + const re = new RegExp(TOKEN_REGEX.source, "g"); + let match: RegExpExecArray | null; + while ((match = re.exec(value)) !== null) { + if (match.index > lastIndex) { + segments.push({ type: "text", value: value.slice(lastIndex, match.index), known: false }); + } + segments.push({ type: "var", value: match[0], known: known.has(match[0]) }); + lastIndex = match.index + match[0].length; + } + if (lastIndex < value.length) { + segments.push({ type: "text", value: value.slice(lastIndex), known: false }); + } + return segments; +} + +export function extractTokens(value: string): string[] { + const re = new RegExp(TOKEN_REGEX.source, "g"); + const out: string[] = []; + let match: RegExpExecArray | null; + while ((match = re.exec(value)) !== null) out.push(match[0]); + return out; +} +``` + +- [ ] **Step 5: Run tests to confirm they pass** + +Run: ` tests/client/shared/ui/variable-field/tokens.test.ts` +Expected: PASS (5 tests). + +- [ ] **Step 6: Commit** + +```bash +git add apps/dashboard/src/client/shared/ui/variable-field/types.ts apps/dashboard/src/client/shared/ui/variable-field/tokens.ts apps/dashboard/tests/client/shared/ui/variable-field/tokens.test.ts +git commit -m "feat(dashboard): add variable-field token types and tokenizer" +``` + +--- + +### Task 3: Unknown-token validation with suggestions + +**Files:** +- Create: `apps/dashboard/src/client/shared/ui/variable-field/validation.ts` +- Test: `apps/dashboard/tests/client/shared/ui/variable-field/validation.test.ts` + +**Interfaces:** +- Consumes: `extractTokens` (Task 2), `UnknownToken` type. +- Produces: + - `function levenshtein(a: string, b: string): number` + - `function detectUnknownTokens(value: string, known: Set): UnknownToken[]` — each unknown token gets the closest known token as `suggestion` when edit distance ≤ 3, else `null`. De-duplicated by token string, in first-seen order. + +- [ ] **Step 1: Write the failing test** + +```ts +import { describe, it, expect } from "vitest"; +import { levenshtein, detectUnknownTokens } from "../../../../../src/client/shared/ui/variable-field/validation"; + +const known = new Set(["{user}", "{server}", "{membercount}"]); + +describe("levenshtein", () => { + it("computes edit distance", () => { + expect(levenshtein("kitten", "sitting")).toBe(3); + expect(levenshtein("same", "same")).toBe(0); + }); +}); + +describe("detectUnknownTokens", () => { + it("returns nothing when all tokens are known", () => { + expect(detectUnknownTokens("Hi {user} on {server}", known)).toEqual([]); + }); + + it("flags an unknown token and suggests the closest known one", () => { + expect(detectUnknownTokens("member #{membercont}", known)).toEqual([ + { token: "{membercont}", suggestion: "{membercount}" }, + ]); + }); + + it("gives null suggestion when nothing is close", () => { + expect(detectUnknownTokens("{zzzzzzzz}", known)).toEqual([ + { token: "{zzzzzzzz}", suggestion: null }, + ]); + }); + + it("de-duplicates repeated unknown tokens", () => { + expect(detectUnknownTokens("{foo} {foo}", known)).toEqual([ + { token: "{foo}", suggestion: null }, + ]); + }); +}); +``` + +- [ ] **Step 2: Run to confirm it fails** + +Run: ` tests/client/shared/ui/variable-field/validation.test.ts` +Expected: FAIL (module not found). + +- [ ] **Step 3: Write `validation.ts`** + +```ts +import type { UnknownToken } from "./types"; +import { extractTokens } from "./tokens"; + +export function levenshtein(a: string, b: string): number { + const m = a.length; + const n = b.length; + const dp: number[] = Array.from({ length: n + 1 }, (_, i) => i); + for (let i = 1; i <= m; i++) { + let prev = dp[0]; + dp[0] = i; + for (let j = 1; j <= n; j++) { + const tmp = dp[j]; + dp[j] = a[i - 1] === b[j - 1] ? prev : 1 + Math.min(prev, dp[j], dp[j - 1]); + prev = tmp; + } + } + return dp[n]; +} + +export function detectUnknownTokens(value: string, known: Set): UnknownToken[] { + const knownList = [...known]; + const seen = new Set(); + const result: UnknownToken[] = []; + for (const token of extractTokens(value)) { + if (known.has(token) || seen.has(token)) continue; + seen.add(token); + let best: string | null = null; + let bestDist = Infinity; + for (const candidate of knownList) { + const dist = levenshtein(token, candidate); + if (dist < bestDist) { + bestDist = dist; + best = candidate; + } + } + result.push({ token, suggestion: bestDist <= 3 ? best : null }); + } + return result; +} +``` + +- [ ] **Step 4: Run to confirm pass** + +Run: ` tests/client/shared/ui/variable-field/validation.test.ts` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add apps/dashboard/src/client/shared/ui/variable-field/validation.ts apps/dashboard/tests/client/shared/ui/variable-field/validation.test.ts +git commit -m "feat(dashboard): add unknown-variable detection with suggestions" +``` + +--- + +### Task 4: Caret helpers (insert + active query) + +**Files:** +- Create: `apps/dashboard/src/client/shared/ui/variable-field/caret.ts` +- Test: `apps/dashboard/tests/client/shared/ui/variable-field/caret.test.ts` + +**Interfaces:** +- Produces: + - `function insertToken(value: string, selStart: number, selEnd: number, token: string): { value: string; cursor: number }` — replaces `[selStart,selEnd)` with `token`, returns new value + caret positioned after the inserted token. + - `function getActiveQuery(value: string, caret: number): { query: string; start: number } | null` — if the caret sits inside an open, unclosed `{…` run of `[\w.]*`, returns the partial text after `{` and the index of the `{`. Otherwise `null`. + +- [ ] **Step 1: Write the failing test** + +```ts +import { describe, it, expect } from "vitest"; +import { insertToken, getActiveQuery } from "../../../../../src/client/shared/ui/variable-field/caret"; + +describe("insertToken", () => { + it("inserts at a collapsed caret", () => { + expect(insertToken("Hi ", 3, 3, "{user}")).toEqual({ value: "Hi {user}", cursor: 9 }); + }); + it("replaces a selection", () => { + expect(insertToken("Hi XXX!", 3, 6, "{user}")).toEqual({ value: "Hi {user}!", cursor: 9 }); + }); + it("replaces an open query when start passed as selStart", () => { + // caller replaces "{us" (indices 3..6) with the full token + expect(insertToken("Hi {us", 3, 6, "{user}")).toEqual({ value: "Hi {user}", cursor: 9 }); + }); +}); + +describe("getActiveQuery", () => { + it("detects an open query at the caret", () => { + expect(getActiveQuery("Hi {us", 6)).toEqual({ query: "us", start: 3 }); + }); + it("detects an empty query right after {", () => { + expect(getActiveQuery("Hi {", 4)).toEqual({ query: "", start: 3 }); + }); + it("returns null when the brace is already closed before the caret", () => { + expect(getActiveQuery("Hi {user} x", 11)).toBeNull(); + }); + it("returns null when there is no open brace", () => { + expect(getActiveQuery("plain text", 5)).toBeNull(); + }); + it("returns null when a space breaks the run", () => { + expect(getActiveQuery("{us er", 6)).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run to confirm it fails** + +Run: ` tests/client/shared/ui/variable-field/caret.test.ts` +Expected: FAIL (module not found). + +- [ ] **Step 3: Write `caret.ts`** + +```ts +export function insertToken( + value: string, + selStart: number, + selEnd: number, + token: string, +): { value: string; cursor: number } { + const next = value.slice(0, selStart) + token + value.slice(selEnd); + return { value: next, cursor: selStart + token.length }; +} + +export function getActiveQuery( + value: string, + caret: number, +): { query: string; start: number } | null { + const before = value.slice(0, caret); + const open = before.lastIndexOf("{"); + if (open === -1) return null; + const run = before.slice(open + 1); + // Valid partial token chars only; a closing brace or any other char breaks it. + if (!/^[\w.]*$/.test(run)) return null; + if (run.includes("}")) return null; + return { query: run, start: open }; +} +``` + +- [ ] **Step 4: Run to confirm pass** + +Run: ` tests/client/shared/ui/variable-field/caret.test.ts` +Expected: PASS (8 tests). + +- [ ] **Step 5: Commit** + +```bash +git add apps/dashboard/src/client/shared/ui/variable-field/caret.ts apps/dashboard/tests/client/shared/ui/variable-field/caret.test.ts +git commit -m "feat(dashboard): add caret insert + active-query helpers" +``` + +--- + +### Task 5: Autocomplete filtering + +**Files:** +- Create: `apps/dashboard/src/client/shared/ui/variable-field/filterVariables.ts` +- Test: `apps/dashboard/tests/client/shared/ui/variable-field/filterVariables.test.ts` + +**Interfaces:** +- Consumes: `VariableDescriptor` (Task 2). +- Produces: `function filterByQuery(descriptors: VariableDescriptor[], query: string): VariableDescriptor[]` — case-insensitive substring match against the token with `{`/`}` stripped; empty query returns all; exact prefix matches sort before mid-string matches; stable otherwise. + +- [ ] **Step 1: Write the failing test** + +```ts +import { describe, it, expect } from "vitest"; +import { filterByQuery } from "../../../../../src/client/shared/ui/variable-field/filterVariables"; +import type { VariableDescriptor } from "../../../../../src/client/shared/ui/variable-field/types"; + +const vars: VariableDescriptor[] = [ + { token: "{user}", example: "@Ada", group: "user" }, + { token: "{user.name}", example: "Ada", group: "user" }, + { token: "{server}", example: "Acme", group: "server" }, +]; + +describe("filterByQuery", () => { + it("returns all descriptors for an empty query", () => { + expect(filterByQuery(vars, "")).toHaveLength(3); + }); + it("matches by substring, case-insensitively", () => { + expect(filterByQuery(vars, "USER").map((v) => v.token)).toEqual(["{user}", "{user.name}"]); + }); + it("ranks prefix matches before mid-string matches", () => { + expect(filterByQuery(vars, "ser").map((v) => v.token)).toEqual(["{server}", "{user}", "{user.name}"]); + }); + it("returns [] when nothing matches", () => { + expect(filterByQuery(vars, "zzz")).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run to confirm it fails** + +Run: ` tests/client/shared/ui/variable-field/filterVariables.test.ts` +Expected: FAIL (module not found). + +- [ ] **Step 3: Write `filterVariables.ts`** + +```ts +import type { VariableDescriptor } from "./types"; + +function bareToken(token: string): string { + return token.replace(/[{}]/g, "").toLowerCase(); +} + +export function filterByQuery( + descriptors: VariableDescriptor[], + query: string, +): VariableDescriptor[] { + const q = query.toLowerCase(); + if (q === "") return [...descriptors]; + const matches = descriptors + .map((d, index) => ({ d, index, pos: bareToken(d.token).indexOf(q) })) + .filter((m) => m.pos !== -1); + matches.sort((a, b) => (a.pos !== b.pos ? a.pos - b.pos : a.index - b.index)); + return matches.map((m) => m.d); +} +``` + +- [ ] **Step 4: Run to confirm pass** + +Run: ` tests/client/shared/ui/variable-field/filterVariables.test.ts` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add apps/dashboard/src/client/shared/ui/variable-field/filterVariables.ts apps/dashboard/tests/client/shared/ui/variable-field/filterVariables.test.ts +git commit -m "feat(dashboard): add variable autocomplete filtering" +``` + +--- + +### Task 6: Registry (per-scope descriptors + real/sample maps + drift guard) + +**Files:** +- Create: `apps/dashboard/src/client/shared/ui/variable-field/registry.ts` +- Test: `apps/dashboard/tests/client/shared/ui/variable-field/registry.test.ts` + +**Interfaces:** +- Consumes: `VariableDescriptor`, `PreviewRealData`, `RealDataKey`. +- Produces: + - `const welcomeVariables: VariableDescriptor[]` + - `const customCommandVariables: VariableDescriptor[]` + - `const levelingVariables: VariableDescriptor[]` + - `const tempvoiceVariables: VariableDescriptor[]` + - `function knownTokenSet(descriptors: VariableDescriptor[]): Set` + - `function buildRealData(guild: { id: string; name: string; icon: string | null } | undefined, user: { userId: string; username: string; avatar: string | null } | undefined): PreviewRealData` + - `function buildTokenValues(descriptors: VariableDescriptor[], real: PreviewRealData): Map` + +**Notes:** `labelKey` values point at existing/added i18n keys (Task 20). Token **names** are asserted against the canonical `@fluxcore/systems` sources by the drift test; if that import fails to resolve, build the systems package first: `docker compose --profile bot run --rm --no-deps bot pnpm --filter @fluxcore/systems build`. + +- [ ] **Step 1: Write the failing test** + +```ts +import { describe, it, expect } from "vitest"; +import { + welcomeVariables, + customCommandVariables, + levelingVariables, + tempvoiceVariables, + knownTokenSet, + buildRealData, + buildTokenValues, +} from "../../../../../src/client/shared/ui/variable-field/registry"; +import { WELCOME_VARIABLES } from "@fluxcore/systems/welcome/constants"; +import { TEMPLATE_VARIABLES as CUSTOM_CMD_VARS } from "@fluxcore/systems/customCommands/variables"; + +describe("registry drift guard", () => { + it("welcome descriptors match the canonical WELCOME_VARIABLES tokens exactly", () => { + expect(new Set(welcomeVariables.map((v) => v.token))).toEqual(new Set(Object.keys(WELCOME_VARIABLES))); + }); + it("custom-command descriptors match the canonical customCommands TEMPLATE_VARIABLES tokens", () => { + expect(new Set(customCommandVariables.map((v) => v.token))).toEqual(new Set(Object.keys(CUSTOM_CMD_VARS))); + }); + it("leveling exposes {user} and {level}", () => { + expect(levelingVariables.map((v) => v.token).sort()).toEqual(["{level}", "{user}"]); + }); + it("tempvoice exposes at least {user}", () => { + expect(tempvoiceVariables.map((v) => v.token)).toContain("{user}"); + }); +}); + +describe("knownTokenSet", () => { + it("collects tokens into a Set", () => { + expect(knownTokenSet(levelingVariables).has("{level}")).toBe(true); + }); +}); + +describe("buildRealData", () => { + it("uses real guild/user data and builds CDN URLs", () => { + const real = buildRealData( + { id: "42", name: "Acme", icon: "abc" }, + { userId: "7", username: "Ada", avatar: "def" }, + ); + expect(real.serverName).toBe("Acme"); + expect(real.serverIcon).toBe("https://cdn.discordapp.com/icons/42/abc.png"); + expect(real.userName).toBe("Ada"); + expect(real.userMention).toBe("@Ada"); + expect(real.userAvatar).toBe("https://cdn.discordapp.com/avatars/7/def.png"); + expect(real.memberCount).toBe("1,234"); // sample: not available client-side + }); + it("falls back to defaults when data is missing", () => { + const real = buildRealData(undefined, undefined); + expect(real.serverName).toBe("My Server"); + expect(real.userName).toBe("User"); + expect(real.userAvatar).toBe("https://cdn.discordapp.com/embed/avatars/0.png"); + }); +}); + +describe("buildTokenValues", () => { + it("maps real values where realKey is set and samples otherwise", () => { + const real = buildRealData({ id: "42", name: "Acme", icon: null }, undefined); + const map = buildTokenValues(welcomeVariables, real); + expect(map.get("{server}")).toBe("Acme"); + expect(map.get("{membercount}")).toBe("1,234"); + }); +}); +``` + +- [ ] **Step 2: Run to confirm it fails** + +Run: ` tests/client/shared/ui/variable-field/registry.test.ts` +Expected: FAIL (module not found). + +- [ ] **Step 3: Write `registry.ts`** + +```ts +import type { PreviewRealData, RealDataKey, VariableDescriptor } from "./types"; + +export const welcomeVariables: VariableDescriptor[] = [ + { token: "{user}", labelKey: "variables.user", example: "@Ada", group: "user", realKey: "userMention" }, + { token: "{user.tag}", labelKey: "variables.userTag", example: "Ada#0001", group: "user", realKey: "userTag" }, + { token: "{user.name}", labelKey: "variables.userName", example: "Ada", group: "user", realKey: "userName" }, + { token: "{user.id}", labelKey: "variables.userId", example: "123456789012345678", group: "user", realKey: "userId" }, + { token: "{user.avatar}", labelKey: "variables.userAvatar", example: "https://cdn.discordapp.com/embed/avatars/0.png", group: "user", realKey: "userAvatar" }, + { token: "{server}", labelKey: "variables.server", example: "Acme", group: "server", realKey: "serverName" }, + { token: "{server.id}", labelKey: "variables.serverId", example: "987654321098765432", group: "server", realKey: "serverId" }, + { token: "{membercount}", labelKey: "variables.memberCount", example: "1,234", group: "server", realKey: "memberCount" }, + { token: "{server.icon}", labelKey: "variables.serverIcon", example: "https://cdn.discordapp.com/embed/avatars/0.png", group: "server", realKey: "serverIcon" }, +]; + +export const customCommandVariables: VariableDescriptor[] = [ + { token: "{user}", labelKey: "variables.entries.user", example: "@Ada", group: "user", realKey: "userMention" }, + { token: "{username}", labelKey: "variables.entries.username", example: "Ada", group: "user", realKey: "userName" }, + { token: "{userId}", labelKey: "variables.entries.userId", example: "123456789012345678", group: "user", realKey: "userId" }, + { token: "{server}", labelKey: "variables.entries.server", example: "Acme", group: "server", realKey: "serverName" }, + { token: "{channel}", labelKey: "variables.entries.channel", example: "#general", group: "channel" }, + { token: "{channelName}", labelKey: "variables.entries.channelName", example: "general", group: "channel" }, + { token: "{memberCount}", labelKey: "variables.entries.memberCount", example: "1,234", group: "server", realKey: "memberCount" }, +]; + +export const levelingVariables: VariableDescriptor[] = [ + { token: "{user}", labelKey: "variables.user", example: "@Ada", group: "user", realKey: "userMention" }, + { token: "{level}", labelKey: "variables.level", example: "5", group: "event" }, +]; + +export const tempvoiceVariables: VariableDescriptor[] = [ + { token: "{user}", labelKey: "variables.user", example: "Ada", group: "user", realKey: "userName" }, +]; + +export function knownTokenSet(descriptors: VariableDescriptor[]): Set { + return new Set(descriptors.map((d) => d.token)); +} + +export function buildRealData( + guild: { id: string; name: string; icon: string | null } | undefined, + user: { userId: string; username: string; avatar: string | null } | undefined, +): PreviewRealData { + const serverName = guild?.name ?? "My Server"; + const serverId = guild?.id ?? "987654321098765432"; + const serverIcon = + guild?.icon && guild.id + ? `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png` + : "https://cdn.discordapp.com/embed/avatars/0.png"; + const userName = user?.username ?? "User"; + const userId = user?.userId ?? "123456789012345678"; + const userAvatar = + user?.avatar && user.userId + ? `https://cdn.discordapp.com/avatars/${user.userId}/${user.avatar}.png` + : "https://cdn.discordapp.com/embed/avatars/0.png"; + return { + userMention: `@${userName}`, + userName, + userTag: userName, + userId, + userAvatar, + serverName, + serverId, + serverIcon, + memberCount: "1,234", + }; +} + +export function buildTokenValues( + descriptors: VariableDescriptor[], + real: PreviewRealData, +): Map { + const map = new Map(); + for (const d of descriptors) { + const value: string = d.realKey ? real[d.realKey as RealDataKey] : d.example; + map.set(d.token, value); + } + return map; +} +``` + +- [ ] **Step 4: Run to confirm pass** + +Run: ` tests/client/shared/ui/variable-field/registry.test.ts` +Expected: PASS. (If the two `@fluxcore/systems` imports fail to resolve, build systems first per the Notes above, then re-run.) + +- [ ] **Step 5: Commit** + +```bash +git add apps/dashboard/src/client/shared/ui/variable-field/registry.ts apps/dashboard/tests/client/shared/ui/variable-field/registry.test.ts +git commit -m "feat(dashboard): add per-scope variable registry with drift guard" +``` + +--- + +### Task 7: Automation variables builder + +**Files:** +- Create: `apps/dashboard/src/client/shared/ui/variable-field/automationVariables.ts` +- Test: `apps/dashboard/tests/client/shared/ui/variable-field/automationVariables.test.ts` + +**Interfaces:** +- Consumes: `VariableDescriptor`, `VariableGroup`, `RealDataKey`; the client `Constants` type from `../../lib/schemas` (fields used: `eventTypeVariables: Record`, `templateVariables: Record`). +- Produces: `function buildAutomationVariables(constants: { eventTypeVariables: Record; templateVariables: Record }, eventType: string): VariableDescriptor[]` — for each token available to `eventType`, a descriptor whose `description` is the English text from `templateVariables[token]`, with `group`/`example`/`realKey` from the static maps below; unknown `eventType` → `[]`. + +- [ ] **Step 1: Write the failing test** + +```ts +import { describe, it, expect } from "vitest"; +import { buildAutomationVariables } from "../../../../../src/client/shared/ui/variable-field/automationVariables"; + +const constants = { + eventTypeVariables: { + memberJoin: ["{user}", "{guild}", "{guild.memberCount}"], + memberBanned: ["{user}", "{ban.reason}"], + }, + templateVariables: { + "{user}": "User mention (e.g. @User)", + "{guild}": "Server name", + "{guild.memberCount}": "Server member count", + "{ban.reason}": "Ban reason", + }, +}; + +describe("buildAutomationVariables", () => { + it("returns descriptors for the event's tokens with descriptions from templateVariables", () => { + const vars = buildAutomationVariables(constants, "memberJoin"); + expect(vars.map((v) => v.token)).toEqual(["{user}", "{guild}", "{guild.memberCount}"]); + expect(vars[0].description).toBe("User mention (e.g. @User)"); + expect(vars.find((v) => v.token === "{guild}")?.realKey).toBe("serverName"); + }); + it("includes event-only tokens like {ban.reason} for memberBanned", () => { + expect(buildAutomationVariables(constants, "memberBanned").map((v) => v.token)).toContain("{ban.reason}"); + }); + it("excludes {ban.reason} for memberJoin (event scoping)", () => { + expect(buildAutomationVariables(constants, "memberJoin").map((v) => v.token)).not.toContain("{ban.reason}"); + }); + it("returns [] for an unknown event type", () => { + expect(buildAutomationVariables(constants, "nope")).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run to confirm it fails** + +Run: ` tests/client/shared/ui/variable-field/automationVariables.test.ts` +Expected: FAIL (module not found). + +- [ ] **Step 3: Write `automationVariables.ts`** + +```ts +import type { RealDataKey, VariableDescriptor, VariableGroup } from "./types"; + +const REAL_KEYS: Record = { + "{user}": "userMention", + "{user.name}": "userName", + "{user.tag}": "userTag", + "{user.id}": "userId", + "{guild}": "serverName", + "{guild.memberCount}": "memberCount", +}; + +const GROUPS: Record = { + "{user}": "user", "{user.name}": "user", "{user.tag}": "user", "{user.id}": "user", + "{channel}": "channel", "{channel.name}": "channel", "{channel.id}": "channel", + "{role}": "role", "{role.name}": "role", "{role.id}": "role", + "{guild}": "server", "{guild.memberCount}": "server", + "{message.content}": "message", "{message.id}": "message", "{message.url}": "message", + "{emoji}": "event", "{emoji.name}": "event", "{ban.reason}": "event", + "{old.nickname}": "event", "{new.nickname}": "event", "{boost.since}": "event", + "{timeout.until}": "event", "{voice.channel}": "event", "{voice.channel.name}": "event", + "{thread.name}": "event", "{thread.id}": "event", "{timestamp}": "misc", +}; + +const SAMPLES: Record = { + "{user}": "@Ada", "{user.name}": "Ada", "{user.tag}": "Ada#0001", "{user.id}": "123456789012345678", + "{channel}": "#general", "{channel.name}": "general", "{channel.id}": "112233445566778899", + "{role}": "@Members", "{role.name}": "Members", "{role.id}": "223344556677889900", + "{guild}": "Acme", "{guild.memberCount}": "1,234", "{timestamp}": "just now", + "{message.content}": "Hello world", "{message.id}": "334455667788990011", "{message.url}": "https://discord.com/channels/…", + "{emoji}": "🎉", "{emoji.name}": "tada", "{ban.reason}": "Spamming", + "{old.nickname}": "OldNick", "{new.nickname}": "NewNick", "{boost.since}": "2 days ago", + "{timeout.until}": "in 1 hour", "{voice.channel}": "General VC", "{voice.channel.name}": "General VC", + "{thread.name}": "help-thread", "{thread.id}": "445566778899001122", +}; + +export function buildAutomationVariables( + constants: { eventTypeVariables: Record; templateVariables: Record }, + eventType: string, +): VariableDescriptor[] { + const tokens = constants.eventTypeVariables[eventType] ?? []; + return tokens.map((token) => ({ + token, + description: constants.templateVariables[token] ?? token, + example: SAMPLES[token] ?? token, + group: GROUPS[token] ?? "misc", + realKey: REAL_KEYS[token], + })); +} +``` + +- [ ] **Step 4: Run to confirm pass** + +Run: ` tests/client/shared/ui/variable-field/automationVariables.test.ts` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add apps/dashboard/src/client/shared/ui/variable-field/automationVariables.ts apps/dashboard/tests/client/shared/ui/variable-field/automationVariables.test.ts +git commit -m "feat(dashboard): build event-scoped automation variables" +``` + +--- + +### Task 8: Preview resolver + +**Files:** +- Create: `apps/dashboard/src/client/shared/ui/variable-field/resolvePreview.ts` +- Test: `apps/dashboard/tests/client/shared/ui/variable-field/resolvePreview.test.ts` + +**Interfaces:** +- Produces: `function resolveTemplatePreview(template: string, values: Map): string` — replaces every token present in `values`; unknown tokens are left visible; `{user}` and `{user.name}` are treated as distinct exact strings (no partial overlap). + +- [ ] **Step 1: Write the failing test** + +```ts +import { describe, it, expect } from "vitest"; +import { resolveTemplatePreview } from "../../../../../src/client/shared/ui/variable-field/resolvePreview"; + +const values = new Map([ + ["{user}", "@Ada"], + ["{user.name}", "Ada"], + ["{server}", "Acme"], + ["{membercount}", "1,234"], +]); + +describe("resolveTemplatePreview", () => { + it("replaces known tokens with their values", () => { + expect(resolveTemplatePreview("Hey {user}, welcome to {server}!", values)).toBe("Hey @Ada, welcome to Acme!"); + }); + it("does not let {user} clobber {user.name}", () => { + expect(resolveTemplatePreview("{user} / {user.name}", values)).toBe("@Ada / Ada"); + }); + it("leaves unknown tokens visible", () => { + expect(resolveTemplatePreview("member #{membercont}", values)).toBe("member #{membercont}"); + }); + it("replaces every occurrence", () => { + expect(resolveTemplatePreview("{server} {server}", values)).toBe("Acme Acme"); + }); +}); +``` + +- [ ] **Step 2: Run to confirm it fails** + +Run: ` tests/client/shared/ui/variable-field/resolvePreview.test.ts` +Expected: FAIL (module not found). + +- [ ] **Step 3: Write `resolvePreview.ts`** + +```ts +import { tokenize } from "./tokens"; + +export function resolveTemplatePreview(template: string, values: Map): string { + const known = new Set(values.keys()); + return tokenize(template, known) + .map((seg) => (seg.type === "var" && seg.known ? values.get(seg.value)! : seg.value)) + .join(""); +} +``` + +- [ ] **Step 4: Run to confirm pass** + +Run: ` tests/client/shared/ui/variable-field/resolvePreview.test.ts` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add apps/dashboard/src/client/shared/ui/variable-field/resolvePreview.ts apps/dashboard/tests/client/shared/ui/variable-field/resolvePreview.test.ts +git commit -m "feat(dashboard): add pure preview template resolver" +``` + +--- + +## Phase 2 — Components, hook, i18n chrome + +### Task 9: `VariableEditor` component + +**Files:** +- Create: `apps/dashboard/src/client/shared/ui/variable-field/VariableEditor.tsx` +- Test: `apps/dashboard/tests/client/shared/ui/variable-field/VariableEditor.test.tsx` + +**Interfaces:** +- Consumes: `tokenize` (Task 2), `detectUnknownTokens` (Task 3), `insertToken`/`getActiveQuery` (Task 4), `filterByQuery` (Task 5), `knownTokenSet` (Task 6); `Popover`/`PopoverAnchor`/`PopoverContent` from `../popover`; `cn` from `../lib/utils`; `useTranslation` from `react-i18next`. +- Produces: default export `VariableEditor` with props: + ```ts + interface VariableEditorProps { + value: string; + onChange: (value: string) => void; + variables: VariableDescriptor[]; + multiline?: boolean; + rows?: number; + maxLength?: number; + placeholder?: string; + disabled?: boolean; + id?: string; + "aria-label"?: string; + className?: string; + } + ``` + +**Behavior:** overlay-mirror highlighting (transparent native text + colored backdrop), `{`-triggered combobox (ArrowUp/Down/Enter/Tab/Escape), unknown-token warnings listed under the field via `t`. Uses `common` namespace keys added in Task 20 (`variableField.unknown`, `variableField.didYouMean`, `variableField.noMatches`). + +- [ ] **Step 1: Write the failing component test** + +```tsx +// @vitest-environment jsdom +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import VariableEditor from "../../../../../src/client/shared/ui/variable-field/VariableEditor"; +import type { VariableDescriptor } from "../../../../../src/client/shared/ui/variable-field/types"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (k: string, o?: Record) => (o?.suggestion ? `${k}:${o.suggestion}` : k) }), +})); + +const vars: VariableDescriptor[] = [ + { token: "{user}", example: "@Ada", group: "user", description: "User mention" }, + { token: "{user.name}", example: "Ada", group: "user", description: "Username" }, + { token: "{server}", example: "Acme", group: "server", description: "Server name" }, +]; + +function Harness({ initial = "" }: { initial?: string }) { + const [v, setV] = (globalThis as unknown as { React: typeof import("react") }).React.useState(initial); + return ; +} + +describe("VariableEditor", () => { + it("opens the suggestion list when typing '{' and inserts on Enter", async () => { + const user = userEvent.setup(); + let current = ""; + const onChange = (val: string) => { current = val; }; + render(); + const input = screen.getByLabelText("field"); + await user.click(input); + await user.type(input, "{{"); + // listbox appears + expect(screen.getByRole("listbox")).toBeTruthy(); + await user.keyboard("{ArrowDown}{Enter}"); + expect(current).toContain("{user}"); + }); + + it("shows an unknown-variable warning with a suggestion", () => { + render( {}} variables={[{ token: "{membercount}", example: "1,234", group: "server" }]} aria-label="field" />); + expect(screen.getByText(/variableField\.didYouMean/)).toBeTruthy(); + }); +}); +``` + +Note on `user.type(input, "{{")`: in `@testing-library/user-event`, `{{` types a single literal `{`. The suggestion list opens on the resulting `{`. + +- [ ] **Step 2: Run to confirm it fails** + +Run: ` tests/client/shared/ui/variable-field/VariableEditor.test.tsx` +Expected: FAIL (module not found). + +- [ ] **Step 3: Write `VariableEditor.tsx`** + +```tsx +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { cn } from "../lib/utils"; +import { Popover, PopoverAnchor, PopoverContent } from "../popover"; +import { ScrollArea } from "../scroll-area"; +import type { VariableDescriptor } from "./types"; +import { tokenize } from "./tokens"; +import { detectUnknownTokens } from "./validation"; +import { insertToken, getActiveQuery } from "./caret"; +import { filterByQuery } from "./filterVariables"; +import { knownTokenSet } from "./registry"; + +interface VariableEditorProps { + value: string; + onChange: (value: string) => void; + variables: VariableDescriptor[]; + multiline?: boolean; + rows?: number; + maxLength?: number; + placeholder?: string; + disabled?: boolean; + id?: string; + "aria-label"?: string; + className?: string; +} + +const SHARED = + "w-full rounded-sm border border-transparent bg-surface-lowest px-3 text-sm text-text placeholder:text-outline focus-visible:outline-none focus-visible:border-accent focus-visible:shadow-[0_0_4px_rgba(163,166,255,0.10)] disabled:cursor-not-allowed disabled:opacity-50"; +const INPUT_BOX = "h-9 py-1"; +const AREA_BOX = "min-h-[60px] py-2"; + +export default function VariableEditor(props: VariableEditorProps) { + const { value, onChange, variables, multiline, rows = 3, maxLength, placeholder, disabled, id } = props; + const { t } = useTranslation("common"); + const known = React.useMemo(() => knownTokenSet(variables), [variables]); + const fieldRef = React.useRef(null); + const backdropRef = React.useRef(null); + + const [open, setOpen] = React.useState(false); + const [query, setQuery] = React.useState(""); + const [queryStart, setQueryStart] = React.useState(0); + const [active, setActive] = React.useState(0); + + const matches = React.useMemo(() => (open ? filterByQuery(variables, query) : []), [open, query, variables]); + const unknowns = React.useMemo(() => detectUnknownTokens(value, known), [value, known]); + const segments = React.useMemo(() => tokenize(value, known), [value, known]); + + React.useEffect(() => { + if (active >= matches.length) setActive(0); + }, [matches.length, active]); + + function syncQueryFromCaret() { + const el = fieldRef.current; + if (!el) return; + const caret = el.selectionStart ?? el.value.length; + const q = getActiveQuery(el.value, caret); + if (q) { + setQuery(q.query); + setQueryStart(q.start); + setOpen(true); + } else { + setOpen(false); + } + } + + function commit(token: string) { + const el = fieldRef.current; + if (!el) return; + const caret = el.selectionStart ?? el.value.length; + const { value: next, cursor } = insertToken(value, queryStart, caret, token); + onChange(next); + setOpen(false); + requestAnimationFrame(() => { + el.focus(); + el.setSelectionRange(cursor, cursor); + }); + } + + function handleChange(e: React.ChangeEvent) { + onChange(e.target.value); + // defer so selectionStart reflects the new value + requestAnimationFrame(syncQueryFromCaret); + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (!open || matches.length === 0) return; + if (e.key === "ArrowDown") { e.preventDefault(); setActive((a) => (a + 1) % matches.length); } + else if (e.key === "ArrowUp") { e.preventDefault(); setActive((a) => (a - 1 + matches.length) % matches.length); } + else if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); commit(matches[active].token); } + else if (e.key === "Escape") { e.preventDefault(); setOpen(false); } + } + + function handleScroll(e: React.UIEvent) { + if (backdropRef.current) { + backdropRef.current.scrollTop = e.currentTarget.scrollTop; + backdropRef.current.scrollLeft = e.currentTarget.scrollLeft; + } + } + + const listboxId = id ? `${id}-listbox` : undefined; + + return ( +
+ 0}> + +
+ + {multiline ? ( +