@gxp-dev/tools 2.0.84 → 2.0.85
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -5
- package/bin/lib/cli.js +14 -0
- package/bin/lib/commands/index.js +2 -0
- package/bin/lib/commands/storybook.js +131 -0
- package/bin/lib/constants.js +2 -0
- package/bin/lib/utils/ai-scaffold.js +1 -1
- package/bin/lib/utils/index.js +2 -0
- package/bin/lib/utils/uikit.js +40 -0
- package/mcp/lib/server.js +7 -9
- package/package.json +2 -1
- package/template/mcp.json +4 -0
- package/template/src/DemoExperience.vue +660 -0
- package/template/src/DemoPage.vue +17 -0
- package/template/src/Plugin.vue +14 -1
- package/mcp/lib/uikit-tools.js +0 -181
package/README.md
CHANGED
|
@@ -93,12 +93,13 @@ It does **not** overwrite your source files (`src/`, `theme-layouts/`, etc.).
|
|
|
93
93
|
- **Config linting** — AJV-based JSON Schema validation of `configuration.json` (form-builder definitions) and `app-manifest.json` (plugin metadata + defaults).
|
|
94
94
|
- **Pre-commit hook** — `.githooks/pre-commit` runs Prettier, ESLint, and the GxP linter on staged files; configured automatically via the `prepare` npm script.
|
|
95
95
|
- **Unit testing** — Vitest + `@vue/test-utils` wired out of the box; scaffolded tests via the MCP server.
|
|
96
|
-
- **MCP server** —
|
|
96
|
+
- **MCP server** — 32 tools for AI coding assistants (see below). UIKit component/story tools are served by the uikit's own Storybook MCP, auto-registered when you run `gxdev storybook`.
|
|
97
97
|
- **AI scaffolding** — pre-wired configs for Claude Code, Codex, and Gemini during `init`.
|
|
98
|
+
- **Experience-flow demo** — `template/src/DemoExperience.vue` is an annotated kiosk flow built with `@gxp-dev/uikit`'s state-machine orchestrator: branching paths, default + inline actions, `callApi` adapter, slot overrides, and a live state inspector. Linked from `DemoPage.vue`.
|
|
98
99
|
|
|
99
100
|
## MCP Server for AI assistants
|
|
100
101
|
|
|
101
|
-
The toolkit ships `mcp-serve` (bin `@gxp-dev/tools/mcp/mcp-serve.js`), an MCP server exposing
|
|
102
|
+
The toolkit ships `mcp-serve` (bin `@gxp-dev/tools/mcp/mcp-serve.js`), an MCP server exposing 32 tools across six families. It speaks MCP over stdio via the official `@modelcontextprotocol/sdk` `StdioServerTransport`. Point your AI assistant at it to get API-aware, schema-aware, test-aware help inside plugin projects:
|
|
102
103
|
|
|
103
104
|
| Family | Tools |
|
|
104
105
|
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
@@ -108,7 +109,8 @@ The toolkit ships `mcp-serve` (bin `@gxp-dev/tools/mcp/mcp-serve.js`), an MCP se
|
|
|
108
109
|
| **Docs search** (3) | `docs_list_pages`, `docs_search`, `docs_get_page` — full-text across `docs.gxp.dev` via its sitemap |
|
|
109
110
|
| **Test helpers** (2) | `test_scaffold_component_test`, `test_api_route` |
|
|
110
111
|
| **Data models** (1) | `describe_data_models` — enumerate or detail OpenAPI `components.schemas` with $ref + allOf flattening |
|
|
111
|
-
|
|
112
|
+
|
|
113
|
+
UIKit component introspection (and story preview/run-tests) is served by the uikit itself via `@storybook/addon-mcp`. Run `gxdev storybook` from a plugin project and the addon exposes an HTTP MCP server at `http://localhost:6006/mcp` — `template/mcp.json` registers it as `gxp-uikit-storybook` alongside `gxp-api`, so AI assistants pick it up automatically when storybook is running.
|
|
112
114
|
|
|
113
115
|
The previous bin name `gxp-api-server` still ships as a deprecation shim — it prints a stderr notice and forwards to the same server. Update your `.mcp.json` / `.gemini/settings.json` to use `mcp-serve` going forward.
|
|
114
116
|
|
|
@@ -121,8 +123,9 @@ After `gxdev init`:
|
|
|
121
123
|
```
|
|
122
124
|
my-plugin/
|
|
123
125
|
├── src/
|
|
124
|
-
│ ├── Plugin.vue # App entry point
|
|
125
|
-
│ ├── DemoPage.vue # Example component
|
|
126
|
+
│ ├── Plugin.vue # App entry point + lightweight in-app routing
|
|
127
|
+
│ ├── DemoPage.vue # Example component (gxp-* directives, sockets, store)
|
|
128
|
+
│ ├── DemoExperience.vue # Annotated kiosk flow using @gxp-dev/uikit experience-flow
|
|
126
129
|
│ ├── public/ # Static assets (served at /src/public/*)
|
|
127
130
|
│ └── stores/
|
|
128
131
|
│ └── index.js # Pinia store setup
|
package/bin/lib/cli.js
CHANGED
|
@@ -25,6 +25,7 @@ const {
|
|
|
25
25
|
extractConfigCommand,
|
|
26
26
|
addDependencyCommand,
|
|
27
27
|
lintCommand,
|
|
28
|
+
storybookCommand,
|
|
28
29
|
} = require("./commands")
|
|
29
30
|
|
|
30
31
|
// Load global configuration
|
|
@@ -350,6 +351,19 @@ const cli = yargs
|
|
|
350
351
|
},
|
|
351
352
|
lintCommand,
|
|
352
353
|
)
|
|
354
|
+
.command(
|
|
355
|
+
"storybook",
|
|
356
|
+
"Run @gxp-dev/uikit Storybook from this project (port 6006)",
|
|
357
|
+
{
|
|
358
|
+
build: {
|
|
359
|
+
describe:
|
|
360
|
+
"Build a static Storybook bundle instead of starting the dev server",
|
|
361
|
+
type: "boolean",
|
|
362
|
+
default: false,
|
|
363
|
+
},
|
|
364
|
+
},
|
|
365
|
+
storybookCommand,
|
|
366
|
+
)
|
|
353
367
|
.command(
|
|
354
368
|
"add-dependency",
|
|
355
369
|
"Add an API dependency to app-manifest.json via interactive wizard",
|
|
@@ -21,6 +21,7 @@ const {
|
|
|
21
21
|
const { extractConfigCommand } = require("./extract-config")
|
|
22
22
|
const { addDependencyCommand } = require("./add-dependency")
|
|
23
23
|
const { lintCommand } = require("./lint")
|
|
24
|
+
const { storybookCommand } = require("./storybook")
|
|
24
25
|
|
|
25
26
|
module.exports = {
|
|
26
27
|
initCommand,
|
|
@@ -38,4 +39,5 @@ module.exports = {
|
|
|
38
39
|
extractConfigCommand,
|
|
39
40
|
addDependencyCommand,
|
|
40
41
|
lintCommand,
|
|
42
|
+
storybookCommand,
|
|
41
43
|
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storybook Command
|
|
3
|
+
*
|
|
4
|
+
* Runs the @gxp-dev/uikit Storybook instance from the plugin project. The
|
|
5
|
+
* uikit ships its own storybook config + @storybook/addon-mcp but declares
|
|
6
|
+
* the storybook ecosystem as *optional* peerDependencies, so plugin projects
|
|
7
|
+
* stay lean by default. This command resolves the installed uikit, detects
|
|
8
|
+
* any missing storybook peers, prompts to install them in the plugin
|
|
9
|
+
* project's node_modules, then delegates to the uikit's storybook script.
|
|
10
|
+
*
|
|
11
|
+
* When `storybook dev` is running, the addon-mcp also exposes an HTTP MCP
|
|
12
|
+
* server at http://localhost:6006/mcp — the template's mcp.json registers it
|
|
13
|
+
* as a second MCP server alongside the toolkit's stdio one.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const path = require("path")
|
|
17
|
+
const fs = require("fs")
|
|
18
|
+
const shell = require("shelljs")
|
|
19
|
+
const readline = require("readline")
|
|
20
|
+
const { findProjectRoot } = require("../utils")
|
|
21
|
+
const { resolveUikit } = require("../utils/uikit")
|
|
22
|
+
|
|
23
|
+
const STORYBOOK_PEERS = [
|
|
24
|
+
"storybook",
|
|
25
|
+
"@storybook/vue3-vite",
|
|
26
|
+
"@storybook/addon-mcp",
|
|
27
|
+
"@storybook/addon-a11y",
|
|
28
|
+
"@storybook/addon-themes",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
function isInstalled(pkgName, fromDir) {
|
|
32
|
+
let dir = path.resolve(fromDir)
|
|
33
|
+
while (dir) {
|
|
34
|
+
const candidate = path.join(dir, "node_modules", pkgName, "package.json")
|
|
35
|
+
if (fs.existsSync(candidate)) return true
|
|
36
|
+
const parent = path.dirname(dir)
|
|
37
|
+
if (parent === dir) break
|
|
38
|
+
dir = parent
|
|
39
|
+
}
|
|
40
|
+
return false
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function findMissingPeers(uikitRoot, pkg) {
|
|
44
|
+
const peerSpecs = (pkg && pkg.peerDependencies) || {}
|
|
45
|
+
return STORYBOOK_PEERS.filter((name) => {
|
|
46
|
+
if (!peerSpecs[name]) return false
|
|
47
|
+
return !isInstalled(name, uikitRoot)
|
|
48
|
+
}).map((name) => `${name}@${peerSpecs[name]}`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function confirm(question) {
|
|
52
|
+
return new Promise((resolve) => {
|
|
53
|
+
const rl = readline.createInterface({
|
|
54
|
+
input: process.stdin,
|
|
55
|
+
output: process.stdout,
|
|
56
|
+
})
|
|
57
|
+
rl.question(`${question} (Y/n) `, (answer) => {
|
|
58
|
+
rl.close()
|
|
59
|
+
const trimmed = (answer || "").trim().toLowerCase()
|
|
60
|
+
resolve(trimmed === "" || trimmed === "y" || trimmed === "yes")
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function storybookCommand(argv) {
|
|
66
|
+
const resolved = resolveUikit(process.cwd())
|
|
67
|
+
if (!resolved) {
|
|
68
|
+
console.error(
|
|
69
|
+
"❌ Could not find @gxp-dev/uikit in this project's node_modules.",
|
|
70
|
+
)
|
|
71
|
+
console.log("💡 Install it with: npm install @gxp-dev/uikit")
|
|
72
|
+
process.exit(1)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const { root: uikitRoot, pkg } = resolved
|
|
76
|
+
const wantsBuild = Boolean(argv.build)
|
|
77
|
+
const scriptName = wantsBuild ? "storybook:build" : "storybook"
|
|
78
|
+
const script = pkg.scripts && pkg.scripts[scriptName]
|
|
79
|
+
const storybookConfig = path.join(uikitRoot, ".storybook")
|
|
80
|
+
|
|
81
|
+
if (!script || !fs.existsSync(storybookConfig)) {
|
|
82
|
+
console.error(
|
|
83
|
+
`❌ @gxp-dev/uikit@${pkg.version || "?"} at ${uikitRoot} does not ship a Storybook setup.`,
|
|
84
|
+
)
|
|
85
|
+
console.log(
|
|
86
|
+
"💡 Upgrade the uikit to a version whose `files` field includes `.storybook` and `src`.",
|
|
87
|
+
)
|
|
88
|
+
process.exit(1)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const missing = findMissingPeers(uikitRoot, pkg)
|
|
92
|
+
if (missing.length > 0) {
|
|
93
|
+
console.log("📦 Storybook tooling is not installed yet.")
|
|
94
|
+
console.log(" The uikit lists these as optional peerDependencies:")
|
|
95
|
+
for (const spec of missing) {
|
|
96
|
+
console.log(` • ${spec}`)
|
|
97
|
+
}
|
|
98
|
+
const proceed = await confirm(
|
|
99
|
+
" Install them now in this project (npm install --save-dev)?",
|
|
100
|
+
)
|
|
101
|
+
if (!proceed) {
|
|
102
|
+
console.log(
|
|
103
|
+
"⏭ Skipped. Install manually and re-run: npm install --save-dev " +
|
|
104
|
+
missing.join(" "),
|
|
105
|
+
)
|
|
106
|
+
process.exit(1)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const projectRoot = findProjectRoot()
|
|
110
|
+
const installCmd = `npm install --save-dev --no-fund --no-audit ${missing.map((m) => `"${m}"`).join(" ")}`
|
|
111
|
+
console.log(`▶ ${installCmd}`)
|
|
112
|
+
const installResult = shell.exec(installCmd, { cwd: projectRoot })
|
|
113
|
+
if (installResult.code !== 0) {
|
|
114
|
+
console.error("❌ Install failed. Resolve the errors and try again.")
|
|
115
|
+
process.exit(installResult.code)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const label = wantsBuild
|
|
120
|
+
? "📚 Building Storybook from @gxp-dev/uikit..."
|
|
121
|
+
: "📚 Starting Storybook from @gxp-dev/uikit (http://localhost:6006)"
|
|
122
|
+
console.log(label)
|
|
123
|
+
console.log(`📁 UIKit path: ${uikitRoot}`)
|
|
124
|
+
|
|
125
|
+
const result = shell.exec(`npm run ${scriptName}`, { cwd: uikitRoot })
|
|
126
|
+
if (result.code !== 0) {
|
|
127
|
+
process.exit(result.code)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
module.exports = { storybookCommand }
|
package/bin/lib/constants.js
CHANGED
|
@@ -48,6 +48,8 @@ const DEFAULT_SCRIPTS = {
|
|
|
48
48
|
"datastore:add": "gxdev datastore add",
|
|
49
49
|
"datastore:scan": "gxdev datastore scan-strings",
|
|
50
50
|
"datastore:config": "gxdev datastore config",
|
|
51
|
+
storybook: "gxdev storybook",
|
|
52
|
+
"storybook:build": "gxdev storybook --build",
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
// Default ports
|
|
@@ -997,7 +997,7 @@ function buildInteractiveInitialPrompt(projectName, description, provider) {
|
|
|
997
997
|
"- **Docs search** — `docs_search`, `docs_get_page`, `docs_list_pages` (full-text search across docs.gxp.dev).",
|
|
998
998
|
"- **Test helpers** — `test_scaffold_component_test`, `test_api_route`.",
|
|
999
999
|
"- **Data models** — `describe_data_models` (enumerate or detail OpenAPI components.schemas; walks allOf and resolves $ref by name).",
|
|
1000
|
-
"- **UIKit** — `
|
|
1000
|
+
"- **UIKit (via Storybook MCP)** — when `npm run storybook` is running, the uikit's `@storybook/addon-mcp` exposes `preview-stories`, `get-storybook-story-instructions`, `get-documentation`, `list-all-documentation`, and `run-story-tests` at http://localhost:6006/mcp (registered in mcp.json as `gxp-uikit-storybook`). Use these to discover available components and stories.",
|
|
1001
1001
|
"",
|
|
1002
1002
|
"Follow the full workflow from the instructions: (1) understand the feature, (2) discover data sources via MCP, (3) plan including the admin configuration form, (4) implement, (5) **sync the manifest and build the admin form**, (6) test with real broadcasts, (7) final `gxdev lint --all`.",
|
|
1003
1003
|
"",
|
package/bin/lib/utils/index.js
CHANGED
|
@@ -11,6 +11,7 @@ const prompts = require("./prompts")
|
|
|
11
11
|
const aiScaffold = require("./ai-scaffold")
|
|
12
12
|
const extractConfig = require("./extract-config")
|
|
13
13
|
const versionCheck = require("./version-check")
|
|
14
|
+
const uikit = require("./uikit")
|
|
14
15
|
|
|
15
16
|
module.exports = {
|
|
16
17
|
...paths,
|
|
@@ -20,4 +21,5 @@ module.exports = {
|
|
|
20
21
|
...aiScaffold,
|
|
21
22
|
...extractConfig,
|
|
22
23
|
...versionCheck,
|
|
24
|
+
...uikit,
|
|
23
25
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UIKit resolution helpers.
|
|
3
|
+
*
|
|
4
|
+
* Resolves `@gxp-dev/uikit` from a plugin project's node_modules by walking
|
|
5
|
+
* up the directory tree (works with npm, pnpm, yarn workspaces). We mimic the
|
|
6
|
+
* walk-up rather than calling require.resolve("@gxp-dev/uikit/...") because
|
|
7
|
+
* the uikit's package.json declares a strict `exports` field with only
|
|
8
|
+
* `import` + `types` conditions; from a CJS context require.resolve fails
|
|
9
|
+
* against that exports map even though the directory exists on disk.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require("fs")
|
|
13
|
+
const path = require("path")
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @returns {{ root: string, pkg: object } | null}
|
|
17
|
+
*/
|
|
18
|
+
function resolveUikit(cwd = process.cwd()) {
|
|
19
|
+
let dir = path.resolve(cwd)
|
|
20
|
+
while (dir) {
|
|
21
|
+
const candidate = path.join(dir, "node_modules", "@gxp-dev", "uikit")
|
|
22
|
+
const pkgPath = path.join(candidate, "package.json")
|
|
23
|
+
if (fs.existsSync(pkgPath)) {
|
|
24
|
+
try {
|
|
25
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"))
|
|
26
|
+
if (pkg && pkg.name === "@gxp-dev/uikit") {
|
|
27
|
+
return { root: candidate, pkg }
|
|
28
|
+
}
|
|
29
|
+
} catch {
|
|
30
|
+
// malformed package.json — keep walking
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const parent = path.dirname(dir)
|
|
34
|
+
if (parent === dir) break
|
|
35
|
+
dir = parent
|
|
36
|
+
}
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = { resolveUikit }
|
package/mcp/lib/server.js
CHANGED
|
@@ -13,7 +13,12 @@
|
|
|
13
13
|
* - Docs tools (docs-tools.js)
|
|
14
14
|
* - Test tools (test-tools.js)
|
|
15
15
|
* - Model tools (model-tools.js)
|
|
16
|
-
*
|
|
16
|
+
*
|
|
17
|
+
* UIKit component introspection has moved out of this server. The uikit
|
|
18
|
+
* ships @storybook/addon-mcp; when developers run `gxdev storybook` the
|
|
19
|
+
* uikit's Storybook exposes its own HTTP MCP server at
|
|
20
|
+
* http://localhost:6006/mcp, registered as `gxp-uikit-storybook` in the
|
|
21
|
+
* plugin project's mcp.json.
|
|
17
22
|
*/
|
|
18
23
|
|
|
19
24
|
const { fetchSpec, getEnvironment, getEnvUrls } = require("./specs")
|
|
@@ -34,11 +39,6 @@ const {
|
|
|
34
39
|
handleModelToolCall,
|
|
35
40
|
isModelTool,
|
|
36
41
|
} = require("./model-tools")
|
|
37
|
-
const {
|
|
38
|
-
UIKIT_TOOLS,
|
|
39
|
-
handleUikitToolCall,
|
|
40
|
-
isUikitTool,
|
|
41
|
-
} = require("./uikit-tools")
|
|
42
42
|
|
|
43
43
|
const SERVER_INFO = {
|
|
44
44
|
name: "gxp-mcp-serve",
|
|
@@ -46,7 +46,7 @@ const SERVER_INFO = {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
const SERVER_DESCRIPTION =
|
|
49
|
-
"GxP toolkit MCP server: API specs, data models,
|
|
49
|
+
"GxP toolkit MCP server: API specs, data models, config/manifest editing, documentation search, and plugin test helpers for AI coding assistants. UIKit component/story tools are served by the uikit's own Storybook MCP (gxdev storybook)."
|
|
50
50
|
|
|
51
51
|
/* -------------------- API spec search helpers (in-file) ------------------- */
|
|
52
52
|
|
|
@@ -247,7 +247,6 @@ const TOOLS = [
|
|
|
247
247
|
...DOCS_TOOLS,
|
|
248
248
|
...TEST_TOOLS,
|
|
249
249
|
...MODEL_TOOLS,
|
|
250
|
-
...UIKIT_TOOLS,
|
|
251
250
|
]
|
|
252
251
|
|
|
253
252
|
/* ------------------------------ tool dispatch ----------------------------- */
|
|
@@ -258,7 +257,6 @@ async function handleToolCall(name, args = {}) {
|
|
|
258
257
|
if (isDocsTool(name)) return handleDocsToolCall(name, args)
|
|
259
258
|
if (isTestTool(name)) return handleTestToolCall(name, args)
|
|
260
259
|
if (isModelTool(name)) return handleModelToolCall(name, args)
|
|
261
|
-
if (isUikitTool(name)) return handleUikitToolCall(name, args)
|
|
262
260
|
|
|
263
261
|
switch (name) {
|
|
264
262
|
case "get_openapi_spec": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gxp-dev/tools",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.85",
|
|
4
4
|
"description": "Dev tools to create platform plugins",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"publishConfig": {
|
|
@@ -58,6 +58,7 @@
|
|
|
58
58
|
"dependencies": {
|
|
59
59
|
"@faker-js/faker": "^9.9.0",
|
|
60
60
|
"@fal-works/esbuild-plugin-global-externals": "^2.1.2",
|
|
61
|
+
"@gxp-dev/uikit": "^0.1.0",
|
|
61
62
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
62
63
|
"@vitejs/plugin-vue": "^6.0.6",
|
|
63
64
|
"adm-zip": "^0.5.17",
|
package/template/mcp.json
CHANGED
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
DemoExperience.vue — A teaching demo for @gxp-dev/uikit's experience-flow system.
|
|
3
|
+
|
|
4
|
+
WHAT IT IS
|
|
5
|
+
──────────
|
|
6
|
+
A state-machine orchestrator + a set of prebuilt page components for building
|
|
7
|
+
multi-stage interactive apps (kiosks, photo booths, AI flows, check-ins).
|
|
8
|
+
|
|
9
|
+
The whole flow below is driven by ONE configuration object passed to
|
|
10
|
+
`useExperience`. Pages emit data; the flow stores it in a reactive `context`;
|
|
11
|
+
optional `action`s run between pages to call APIs or do work.
|
|
12
|
+
|
|
13
|
+
WHAT THIS DEMO SHOWS
|
|
14
|
+
────────────────────
|
|
15
|
+
1. `useExperience({ pages, … })` — the orchestrator composable
|
|
16
|
+
2. `useExperienceApi({ callApi })` — adapter that maps named ops to `callApi`
|
|
17
|
+
3. `<ExperienceFlow :flow />` — renderer with built-in loading / error UI
|
|
18
|
+
4. Prebuilt pages (Welcome, Terms, Options, Camera, CameraReview, Drawing,
|
|
19
|
+
Notepad, Loading, Final) with their default slots overridden
|
|
20
|
+
5. Branching paths via `when: (ctx) => …`
|
|
21
|
+
6. Async actions in three forms: named, inline function, and disabled
|
|
22
|
+
7. Live state side-panel — current page, busy/error refs, context dump
|
|
23
|
+
8. Reset, replay, jump-to-page controls — to make the state model visible
|
|
24
|
+
|
|
25
|
+
PRODUCTION USE
|
|
26
|
+
──────────────
|
|
27
|
+
In a real plugin, `callApi` comes from `gxpStore.callApi(operationId, perm, data)`.
|
|
28
|
+
The uikit's adapter expects `(endpoint, payload) => Promise<T>` shape — we
|
|
29
|
+
bridge that below with a tiny lambda. Replace the mock endpoints with your
|
|
30
|
+
real operationIds and you're done.
|
|
31
|
+
-->
|
|
32
|
+
|
|
33
|
+
<template>
|
|
34
|
+
<div class="demo-experience-page">
|
|
35
|
+
<!-- Side panel with live flow state — great for understanding what's happening -->
|
|
36
|
+
<aside class="state-panel">
|
|
37
|
+
<header class="state-panel__header">
|
|
38
|
+
<button class="back-link" @click="$emit('navigate', 'home')">
|
|
39
|
+
← Back to Demo
|
|
40
|
+
</button>
|
|
41
|
+
<h2>Live Flow State</h2>
|
|
42
|
+
</header>
|
|
43
|
+
|
|
44
|
+
<section class="state-panel__section">
|
|
45
|
+
<h3>Current page</h3>
|
|
46
|
+
<code class="state-panel__value">{{ flow.pageName.value ?? "—" }}</code>
|
|
47
|
+
<div class="state-panel__meta">
|
|
48
|
+
index {{ flow.index.value }} / {{ flow.pages.value.length - 1 }} ·
|
|
49
|
+
<span :class="{ pill: true, 'pill--on': flow.busy.value }">
|
|
50
|
+
busy: {{ flow.busy.value }}
|
|
51
|
+
</span>
|
|
52
|
+
·
|
|
53
|
+
<span :class="{ pill: true, 'pill--err': flow.error.value }">
|
|
54
|
+
error: {{ flow.error.value?.message ?? "null" }}
|
|
55
|
+
</span>
|
|
56
|
+
</div>
|
|
57
|
+
</section>
|
|
58
|
+
|
|
59
|
+
<section class="state-panel__section">
|
|
60
|
+
<h3>Context (reactive)</h3>
|
|
61
|
+
<pre class="state-panel__dump">{{ contextSummary }}</pre>
|
|
62
|
+
</section>
|
|
63
|
+
|
|
64
|
+
<section class="state-panel__section">
|
|
65
|
+
<h3>Controls</h3>
|
|
66
|
+
<div class="state-panel__controls">
|
|
67
|
+
<button
|
|
68
|
+
class="ctrl"
|
|
69
|
+
@click="flow.back()"
|
|
70
|
+
:disabled="flow.isFirst.value"
|
|
71
|
+
>
|
|
72
|
+
← Back
|
|
73
|
+
</button>
|
|
74
|
+
<button class="ctrl" @click="flow.reset()">↺ Reset</button>
|
|
75
|
+
<button class="ctrl" @click="forceError">Force error</button>
|
|
76
|
+
</div>
|
|
77
|
+
<div class="state-panel__controls">
|
|
78
|
+
<button
|
|
79
|
+
v-for="p in flow.pages.value"
|
|
80
|
+
:key="p.name"
|
|
81
|
+
class="ctrl ctrl--sm"
|
|
82
|
+
:class="{ 'ctrl--active': flow.pageName.value === p.name }"
|
|
83
|
+
@click="safeGoTo(p.name)"
|
|
84
|
+
:title="`Jump to '${p.name}'`"
|
|
85
|
+
>
|
|
86
|
+
{{ p.name }}
|
|
87
|
+
</button>
|
|
88
|
+
</div>
|
|
89
|
+
</section>
|
|
90
|
+
|
|
91
|
+
<section class="state-panel__section">
|
|
92
|
+
<h3>Tips</h3>
|
|
93
|
+
<ul class="state-panel__tips">
|
|
94
|
+
<li>
|
|
95
|
+
Branching is via <code>when: (ctx) => …</code> on each page def.
|
|
96
|
+
</li>
|
|
97
|
+
<li>
|
|
98
|
+
Actions can be a string (named op), an inline function, or
|
|
99
|
+
<code>false</code> to disable.
|
|
100
|
+
</li>
|
|
101
|
+
<li>
|
|
102
|
+
The QR + final page show how to read back from
|
|
103
|
+
<code>flow.context</code>.
|
|
104
|
+
</li>
|
|
105
|
+
</ul>
|
|
106
|
+
</section>
|
|
107
|
+
</aside>
|
|
108
|
+
|
|
109
|
+
<!-- The renderer. Slots forward through to the current page's slots. -->
|
|
110
|
+
<main class="flow-area">
|
|
111
|
+
<ExperienceFlow :flow="flow">
|
|
112
|
+
<!--
|
|
113
|
+
Custom loading + error UI — replaces the default overlays.
|
|
114
|
+
These slots receive scoped props: `loading` gets nothing, `error`
|
|
115
|
+
gets `{ error, retry }`.
|
|
116
|
+
-->
|
|
117
|
+
<template #loading>
|
|
118
|
+
<div class="custom-loading">
|
|
119
|
+
<div class="custom-loading__spinner" />
|
|
120
|
+
<p>Talking to the platform…</p>
|
|
121
|
+
</div>
|
|
122
|
+
</template>
|
|
123
|
+
|
|
124
|
+
<template #error="{ error, retry }">
|
|
125
|
+
<div class="custom-error">
|
|
126
|
+
<h3>Something went wrong</h3>
|
|
127
|
+
<p>{{ error.message }}</p>
|
|
128
|
+
<button class="custom-error__btn" @click="retry">Dismiss</button>
|
|
129
|
+
</div>
|
|
130
|
+
</template>
|
|
131
|
+
</ExperienceFlow>
|
|
132
|
+
</main>
|
|
133
|
+
</div>
|
|
134
|
+
</template>
|
|
135
|
+
|
|
136
|
+
<script setup>
|
|
137
|
+
import { computed, reactive } from "vue"
|
|
138
|
+
import {
|
|
139
|
+
useExperience,
|
|
140
|
+
useExperienceApi,
|
|
141
|
+
withExperienceDefaults,
|
|
142
|
+
ExperienceFlow,
|
|
143
|
+
WelcomePage,
|
|
144
|
+
TermsPage,
|
|
145
|
+
OptionsPage,
|
|
146
|
+
CameraPage,
|
|
147
|
+
CameraReviewPage,
|
|
148
|
+
DrawingPage,
|
|
149
|
+
NotepadPage,
|
|
150
|
+
LoadingPage,
|
|
151
|
+
FinalPage,
|
|
152
|
+
} from "@gxp-dev/uikit"
|
|
153
|
+
// The uikit ships Tailwind utilities + theme tokens used by every page below.
|
|
154
|
+
// Imported here so the styles only load when the experience demo opens.
|
|
155
|
+
import "@gxp-dev/uikit/styles"
|
|
156
|
+
import { useGxpStore } from "@/stores/gxpPortalConfigStore"
|
|
157
|
+
|
|
158
|
+
defineEmits(["navigate"])
|
|
159
|
+
|
|
160
|
+
const gxpStore = useGxpStore()
|
|
161
|
+
|
|
162
|
+
/* ─── Step 1: bridge gxpStore.callApi to the uikit's callApi shape ──────────
|
|
163
|
+
*
|
|
164
|
+
* uikit expects: (endpoint, payload) => Promise<T>
|
|
165
|
+
* gxpStore offers: (operationId, permissionIdentifier, data) => Promise<T>
|
|
166
|
+
*
|
|
167
|
+
* For the demo we mock the network so it works offline. In production replace
|
|
168
|
+
* the mock with the real `gxpStore.callApi(endpoint, '', payload)` line below.
|
|
169
|
+
*/
|
|
170
|
+
const MOCK_LATENCY_MS = 800
|
|
171
|
+
|
|
172
|
+
async function mockCallApi(endpoint, payload) {
|
|
173
|
+
console.log("[demo] callApi", endpoint, payload)
|
|
174
|
+
await new Promise((r) => setTimeout(r, MOCK_LATENCY_MS))
|
|
175
|
+
|
|
176
|
+
// Simulate the {data: …} envelope the real platform returns.
|
|
177
|
+
if (endpoint === "social_stream.createPost") {
|
|
178
|
+
return {
|
|
179
|
+
data: {
|
|
180
|
+
id: Math.floor(Math.random() * 10_000),
|
|
181
|
+
file_url:
|
|
182
|
+
payload instanceof FormData
|
|
183
|
+
? URL.createObjectURL(payload.get("blob"))
|
|
184
|
+
: "https://placehold.co/600x400?text=Demo+Post",
|
|
185
|
+
caption: payload?.caption ?? null,
|
|
186
|
+
},
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return { data: { ok: true } }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Real wiring would look like this:
|
|
193
|
+
// const callApi = (endpoint, payload) => gxpStore.callApi(endpoint, "", payload)
|
|
194
|
+
const callApi = mockCallApi
|
|
195
|
+
|
|
196
|
+
/* ─── Step 2: build the API adapter ────────────────────────────────────────
|
|
197
|
+
*
|
|
198
|
+
* useExperienceApi turns `callApi` into typed named operations:
|
|
199
|
+
* api.publishPost(data) → callApi('social_stream.createPost', data)
|
|
200
|
+
* api.createPrintJob({…})
|
|
201
|
+
* api.processImage({blob, prompt})
|
|
202
|
+
* etc.
|
|
203
|
+
*
|
|
204
|
+
* You can override specific operations or remap endpoints — useful when your
|
|
205
|
+
* backend uses different operationIds than the defaults.
|
|
206
|
+
*/
|
|
207
|
+
const api = useExperienceApi({
|
|
208
|
+
callApi,
|
|
209
|
+
|
|
210
|
+
// Per-op override example: replace the whole publishPost impl.
|
|
211
|
+
// overrides: {
|
|
212
|
+
// publishPost: async (data) => ({ id: 1, file_url: '...' }),
|
|
213
|
+
// },
|
|
214
|
+
|
|
215
|
+
// Endpoint remap example: keep default behavior but call a different op.
|
|
216
|
+
// endpoints: {
|
|
217
|
+
// publishPost: 'legacy.posts.create',
|
|
218
|
+
// },
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
/* ─── Step 3: custom-tag a page with defaults ───────────────────────────────
|
|
222
|
+
*
|
|
223
|
+
* `withExperienceDefaults` attaches a default `action` + `resultKey` to a page
|
|
224
|
+
* component, so the flow knows what to do when that page emits `next(data)`.
|
|
225
|
+
*
|
|
226
|
+
* Built-in pages already declare their defaults. For your own pages, wrap them.
|
|
227
|
+
*/
|
|
228
|
+
const TaggedNotepad = withExperienceDefaults(NotepadPage, {
|
|
229
|
+
action: async (payload) => {
|
|
230
|
+
// Inline function action — perfect for one-off work or composing API calls.
|
|
231
|
+
console.log("[demo] note submitted:", payload.caption)
|
|
232
|
+
return { savedAt: Date.now(), caption: payload.caption }
|
|
233
|
+
},
|
|
234
|
+
resultKey: "note",
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
/* ─── Step 4: define the flow ──────────────────────────────────────────────
|
|
238
|
+
*
|
|
239
|
+
* `pages` is an ordered array. Each entry has:
|
|
240
|
+
* - name: string key (used by goTo and as default resultKey)
|
|
241
|
+
* - component: any Vue component (built-in or your own)
|
|
242
|
+
* - props: passed straight through to the page
|
|
243
|
+
* - when: (ctx) => boolean — skip the page when this returns false
|
|
244
|
+
* - action: 'publishPost' | (data, ctx, api) => Promise<T> | false
|
|
245
|
+
* - resultKey: where to stash the action's return value in `flow.context`
|
|
246
|
+
*
|
|
247
|
+
* Three action shapes are demonstrated below.
|
|
248
|
+
*/
|
|
249
|
+
const flow = useExperience({
|
|
250
|
+
api,
|
|
251
|
+
|
|
252
|
+
// Seed values on the context so pages can read from them.
|
|
253
|
+
initialContext: {
|
|
254
|
+
brand: "Acme",
|
|
255
|
+
attendeeName: gxpStore.getSetting("company_name") || "Friend",
|
|
256
|
+
},
|
|
257
|
+
|
|
258
|
+
pages: [
|
|
259
|
+
// ── Intro ────────────────────────────────────────────────────────────
|
|
260
|
+
{
|
|
261
|
+
name: "welcome",
|
|
262
|
+
component: WelcomePage,
|
|
263
|
+
props: {
|
|
264
|
+
title: "Welcome to the Demo Kiosk",
|
|
265
|
+
subtitle: "A guided tour of the experience-flow system",
|
|
266
|
+
ctaText: "Let's begin →",
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
|
|
270
|
+
{
|
|
271
|
+
name: "terms",
|
|
272
|
+
component: TermsPage,
|
|
273
|
+
// Skip terms entirely if the user already accepted them in a prior run.
|
|
274
|
+
when: (ctx) => !ctx.termsAccepted,
|
|
275
|
+
props: {
|
|
276
|
+
title: "Quick consent",
|
|
277
|
+
html: `
|
|
278
|
+
<p>By tapping <strong>Accept</strong> you agree this is a demo and
|
|
279
|
+
no real data leaves your browser.</p>
|
|
280
|
+
<p>Hit <em>Reset</em> in the side panel to start over at any time.</p>
|
|
281
|
+
`,
|
|
282
|
+
checkboxLabel: "I'm in",
|
|
283
|
+
},
|
|
284
|
+
// No action — TermsPage emits { accepted: true } and the flow stashes
|
|
285
|
+
// it under resultKey 'termsAccepted' (its built-in default).
|
|
286
|
+
},
|
|
287
|
+
|
|
288
|
+
// ── Path picker (branching) ──────────────────────────────────────────
|
|
289
|
+
{
|
|
290
|
+
name: "pick",
|
|
291
|
+
component: OptionsPage,
|
|
292
|
+
props: {
|
|
293
|
+
title: "Pick how you'd like to participate",
|
|
294
|
+
subtitle: "We'll show a different flow for each",
|
|
295
|
+
options: [
|
|
296
|
+
{ key: "photo", label: "📷 Take a photo" },
|
|
297
|
+
{ key: "drawing", label: "🎨 Draw something" },
|
|
298
|
+
{ key: "note", label: "✍️ Leave a note" },
|
|
299
|
+
],
|
|
300
|
+
emitKeyOnly: true, // stash just the key string in ctx.choice
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
|
|
304
|
+
// ── Photo branch ─────────────────────────────────────────────────────
|
|
305
|
+
{
|
|
306
|
+
name: "capture",
|
|
307
|
+
component: CameraPage,
|
|
308
|
+
when: (ctx) => ctx.choice === "photo",
|
|
309
|
+
props: {
|
|
310
|
+
countdown: 3,
|
|
311
|
+
mirrored: true,
|
|
312
|
+
},
|
|
313
|
+
// CameraPage emits next(blob: Blob) → ctx.photoBlob (its default resultKey)
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
name: "review",
|
|
317
|
+
component: CameraReviewPage,
|
|
318
|
+
when: (ctx) => ctx.choice === "photo",
|
|
319
|
+
props: { allowCaption: true },
|
|
320
|
+
// ⚡ This page's built-in default action is 'publishPost' — i.e. it
|
|
321
|
+
// looks up api.publishPost and calls it with the emitted payload. The
|
|
322
|
+
// result is stashed in ctx.post. No wiring needed here.
|
|
323
|
+
},
|
|
324
|
+
|
|
325
|
+
// ── Drawing branch ───────────────────────────────────────────────────
|
|
326
|
+
{
|
|
327
|
+
name: "draw",
|
|
328
|
+
component: DrawingPage,
|
|
329
|
+
when: (ctx) => ctx.choice === "drawing",
|
|
330
|
+
props: {
|
|
331
|
+
penColors: ["#000000", "#dc2626", "#2563eb", "#16a34a", "#facc15"],
|
|
332
|
+
backgroundColors: ["#ffffff", "#fef3c7", "#dbeafe"],
|
|
333
|
+
submitLabel: "Submit drawing",
|
|
334
|
+
},
|
|
335
|
+
// Inline function action that uploads the rendered blob.
|
|
336
|
+
action: async (blob, _ctx, apiArg) => {
|
|
337
|
+
return await apiArg.publishPost({ blob, caption: "From the easel" })
|
|
338
|
+
},
|
|
339
|
+
resultKey: "post",
|
|
340
|
+
},
|
|
341
|
+
|
|
342
|
+
// ── Note branch (uses our pre-tagged version) ────────────────────────
|
|
343
|
+
{
|
|
344
|
+
name: "note",
|
|
345
|
+
component: TaggedNotepad,
|
|
346
|
+
when: (ctx) => ctx.choice === "note",
|
|
347
|
+
props: { title: "Write your message", maxCharacters: 140 },
|
|
348
|
+
},
|
|
349
|
+
|
|
350
|
+
// ── Async wait (only for paths that produced media) ──────────────────
|
|
351
|
+
{
|
|
352
|
+
name: "saving",
|
|
353
|
+
component: LoadingPage,
|
|
354
|
+
// Only show this for paths where we produced a post; the 'note' path
|
|
355
|
+
// already finished its custom action.
|
|
356
|
+
when: (ctx) => Boolean(ctx.post),
|
|
357
|
+
props: {
|
|
358
|
+
messages: [
|
|
359
|
+
"Hanging your masterpiece in the gallery…",
|
|
360
|
+
"Almost there…",
|
|
361
|
+
"Polishing the frame…",
|
|
362
|
+
],
|
|
363
|
+
messageInterval: 1.4,
|
|
364
|
+
// The LoadingPage's `task` runs as soon as the page mounts. Resolves
|
|
365
|
+
// → next(result); rejects → flow.error. Great for polling.
|
|
366
|
+
task: async (ctx) => {
|
|
367
|
+
await new Promise((r) => setTimeout(r, 1800))
|
|
368
|
+
return { hangedAt: Date.now(), postId: ctx.post?.id }
|
|
369
|
+
},
|
|
370
|
+
},
|
|
371
|
+
},
|
|
372
|
+
|
|
373
|
+
// ── Outcome ─────────────────────────────────────────────────────────
|
|
374
|
+
{
|
|
375
|
+
name: "final",
|
|
376
|
+
component: FinalPage,
|
|
377
|
+
props: {
|
|
378
|
+
title: "✨ All done!",
|
|
379
|
+
qrCaption: "Scan to take it home",
|
|
380
|
+
qrUrl: "https://placehold.co/200x200/000/fff?text=QR",
|
|
381
|
+
actions: [
|
|
382
|
+
{ key: "email", label: "Email me", variant: "primary" },
|
|
383
|
+
{ key: "download", label: "Download", variant: "secondary" },
|
|
384
|
+
],
|
|
385
|
+
showRestart: true,
|
|
386
|
+
},
|
|
387
|
+
// FinalPage emits `next` (restart) and `action(key)`. The flow's
|
|
388
|
+
// `onComplete` (below) fires when we step past the last visible page.
|
|
389
|
+
},
|
|
390
|
+
],
|
|
391
|
+
|
|
392
|
+
// Fires when next() is called on the last visible page. Great place to
|
|
393
|
+
// analytics-log or kick off a post-flow handoff.
|
|
394
|
+
onComplete: (ctx) => {
|
|
395
|
+
console.log("[demo] flow complete; final context:", { ...ctx })
|
|
396
|
+
// Restart from the top so the kiosk is ready for the next user.
|
|
397
|
+
flow.reset()
|
|
398
|
+
},
|
|
399
|
+
|
|
400
|
+
// Optional global error trap. Return `true` to swallow so the flow stays
|
|
401
|
+
// on the current page (otherwise the error appears in the error overlay).
|
|
402
|
+
onError: (err, ctx) => {
|
|
403
|
+
console.warn("[demo] action threw:", err, { ctx: { ...ctx } })
|
|
404
|
+
// Return nothing → show the error overlay.
|
|
405
|
+
},
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
/* ─── Side-panel helpers ───────────────────────────────────────────────── */
|
|
409
|
+
|
|
410
|
+
const reactiveCtx = reactive(flow.context)
|
|
411
|
+
|
|
412
|
+
const contextSummary = computed(() => {
|
|
413
|
+
const snapshot = {}
|
|
414
|
+
for (const [k, v] of Object.entries(reactiveCtx)) {
|
|
415
|
+
if (v instanceof Blob) {
|
|
416
|
+
snapshot[k] = `Blob<${v.type || "binary"}, ${v.size}B>`
|
|
417
|
+
} else if (v && typeof v === "object") {
|
|
418
|
+
snapshot[k] = v
|
|
419
|
+
} else {
|
|
420
|
+
snapshot[k] = v
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return JSON.stringify(snapshot, null, 2)
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
async function safeGoTo(name) {
|
|
427
|
+
try {
|
|
428
|
+
await flow.goTo(name)
|
|
429
|
+
} catch (err) {
|
|
430
|
+
// goTo throws if the target's `when` predicate is currently false.
|
|
431
|
+
console.warn(err.message)
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function forceError() {
|
|
436
|
+
// Tap into the same surface a thrown action would. Useful for previewing
|
|
437
|
+
// the custom #error slot.
|
|
438
|
+
flow.error.value = new Error("Demo error — dismiss to continue")
|
|
439
|
+
}
|
|
440
|
+
</script>
|
|
441
|
+
|
|
442
|
+
<style scoped>
|
|
443
|
+
.demo-experience-page {
|
|
444
|
+
display: grid;
|
|
445
|
+
grid-template-columns: 320px 1fr;
|
|
446
|
+
height: 100vh;
|
|
447
|
+
min-height: 600px;
|
|
448
|
+
background: #0f172a;
|
|
449
|
+
color: #e2e8f0;
|
|
450
|
+
font-family:
|
|
451
|
+
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/* ─── State panel ─────────────────────────────────────────────────────── */
|
|
455
|
+
.state-panel {
|
|
456
|
+
background: #111827;
|
|
457
|
+
border-right: 1px solid #1f2937;
|
|
458
|
+
padding: 20px;
|
|
459
|
+
overflow-y: auto;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
.state-panel__header {
|
|
463
|
+
margin-bottom: 20px;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
.back-link {
|
|
467
|
+
background: none;
|
|
468
|
+
border: 0;
|
|
469
|
+
color: #93c5fd;
|
|
470
|
+
font-size: 13px;
|
|
471
|
+
cursor: pointer;
|
|
472
|
+
padding: 0;
|
|
473
|
+
margin-bottom: 12px;
|
|
474
|
+
}
|
|
475
|
+
.back-link:hover {
|
|
476
|
+
color: #bfdbfe;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
.state-panel h2 {
|
|
480
|
+
margin: 0;
|
|
481
|
+
font-size: 16px;
|
|
482
|
+
color: #f1f5f9;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
.state-panel__section {
|
|
486
|
+
margin-top: 20px;
|
|
487
|
+
padding-top: 16px;
|
|
488
|
+
border-top: 1px solid #1f2937;
|
|
489
|
+
}
|
|
490
|
+
.state-panel__section h3 {
|
|
491
|
+
margin: 0 0 8px 0;
|
|
492
|
+
font-size: 11px;
|
|
493
|
+
font-weight: 600;
|
|
494
|
+
text-transform: uppercase;
|
|
495
|
+
letter-spacing: 0.06em;
|
|
496
|
+
color: #94a3b8;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
.state-panel__value {
|
|
500
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
501
|
+
font-size: 14px;
|
|
502
|
+
color: #fbbf24;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
.state-panel__meta {
|
|
506
|
+
margin-top: 6px;
|
|
507
|
+
font-size: 11px;
|
|
508
|
+
color: #64748b;
|
|
509
|
+
line-height: 1.6;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
.pill {
|
|
513
|
+
display: inline-block;
|
|
514
|
+
padding: 1px 6px;
|
|
515
|
+
border-radius: 4px;
|
|
516
|
+
background: #1e293b;
|
|
517
|
+
color: #cbd5e1;
|
|
518
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
519
|
+
}
|
|
520
|
+
.pill--on {
|
|
521
|
+
background: #1e3a8a;
|
|
522
|
+
color: #bfdbfe;
|
|
523
|
+
}
|
|
524
|
+
.pill--err {
|
|
525
|
+
background: #7f1d1d;
|
|
526
|
+
color: #fecaca;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
.state-panel__dump {
|
|
530
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
531
|
+
font-size: 11px;
|
|
532
|
+
line-height: 1.5;
|
|
533
|
+
color: #cbd5e1;
|
|
534
|
+
background: #0b1220;
|
|
535
|
+
padding: 10px;
|
|
536
|
+
border-radius: 6px;
|
|
537
|
+
border: 1px solid #1f2937;
|
|
538
|
+
max-height: 220px;
|
|
539
|
+
overflow: auto;
|
|
540
|
+
white-space: pre-wrap;
|
|
541
|
+
margin: 0;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
.state-panel__controls {
|
|
545
|
+
display: flex;
|
|
546
|
+
flex-wrap: wrap;
|
|
547
|
+
gap: 6px;
|
|
548
|
+
margin-top: 8px;
|
|
549
|
+
}
|
|
550
|
+
.ctrl {
|
|
551
|
+
font-size: 12px;
|
|
552
|
+
background: #1f2937;
|
|
553
|
+
color: #e2e8f0;
|
|
554
|
+
border: 1px solid #334155;
|
|
555
|
+
border-radius: 5px;
|
|
556
|
+
padding: 5px 9px;
|
|
557
|
+
cursor: pointer;
|
|
558
|
+
}
|
|
559
|
+
.ctrl:hover {
|
|
560
|
+
background: #374151;
|
|
561
|
+
}
|
|
562
|
+
.ctrl:disabled {
|
|
563
|
+
opacity: 0.4;
|
|
564
|
+
cursor: not-allowed;
|
|
565
|
+
}
|
|
566
|
+
.ctrl--sm {
|
|
567
|
+
font-size: 11px;
|
|
568
|
+
padding: 3px 7px;
|
|
569
|
+
}
|
|
570
|
+
.ctrl--active {
|
|
571
|
+
background: #1e40af;
|
|
572
|
+
border-color: #3b82f6;
|
|
573
|
+
color: #dbeafe;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
.state-panel__tips {
|
|
577
|
+
font-size: 12px;
|
|
578
|
+
color: #94a3b8;
|
|
579
|
+
padding-left: 18px;
|
|
580
|
+
margin: 0;
|
|
581
|
+
line-height: 1.6;
|
|
582
|
+
}
|
|
583
|
+
.state-panel__tips code {
|
|
584
|
+
background: #0b1220;
|
|
585
|
+
padding: 1px 5px;
|
|
586
|
+
border-radius: 3px;
|
|
587
|
+
font-size: 11px;
|
|
588
|
+
color: #fbbf24;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/* ─── Flow area ──────────────────────────────────────────────────────── */
|
|
592
|
+
.flow-area {
|
|
593
|
+
background: var(--background, #f8fafc);
|
|
594
|
+
color: var(--foreground, #0f172a);
|
|
595
|
+
position: relative;
|
|
596
|
+
overflow: hidden;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/* ─── Custom loading / error overrides ───────────────────────────────── */
|
|
600
|
+
.custom-loading {
|
|
601
|
+
display: flex;
|
|
602
|
+
flex-direction: column;
|
|
603
|
+
align-items: center;
|
|
604
|
+
gap: 14px;
|
|
605
|
+
color: #1e40af;
|
|
606
|
+
font-weight: 500;
|
|
607
|
+
}
|
|
608
|
+
.custom-loading__spinner {
|
|
609
|
+
width: 48px;
|
|
610
|
+
height: 48px;
|
|
611
|
+
border: 4px solid #93c5fd;
|
|
612
|
+
border-right-color: transparent;
|
|
613
|
+
border-radius: 50%;
|
|
614
|
+
animation: demo-spin 0.9s linear infinite;
|
|
615
|
+
}
|
|
616
|
+
@keyframes demo-spin {
|
|
617
|
+
to {
|
|
618
|
+
transform: rotate(360deg);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
.custom-error {
|
|
623
|
+
max-width: 420px;
|
|
624
|
+
padding: 24px;
|
|
625
|
+
background: white;
|
|
626
|
+
border: 2px solid #dc2626;
|
|
627
|
+
border-radius: 10px;
|
|
628
|
+
text-align: center;
|
|
629
|
+
}
|
|
630
|
+
.custom-error h3 {
|
|
631
|
+
margin: 0 0 8px 0;
|
|
632
|
+
color: #b91c1c;
|
|
633
|
+
}
|
|
634
|
+
.custom-error p {
|
|
635
|
+
margin: 0 0 14px 0;
|
|
636
|
+
color: #374151;
|
|
637
|
+
}
|
|
638
|
+
.custom-error__btn {
|
|
639
|
+
background: #dc2626;
|
|
640
|
+
color: white;
|
|
641
|
+
border: 0;
|
|
642
|
+
padding: 8px 18px;
|
|
643
|
+
border-radius: 6px;
|
|
644
|
+
cursor: pointer;
|
|
645
|
+
font-weight: 500;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/* Stack on narrow screens (mobile preview in dev) */
|
|
649
|
+
@media (max-width: 720px) {
|
|
650
|
+
.demo-experience-page {
|
|
651
|
+
grid-template-columns: 1fr;
|
|
652
|
+
grid-template-rows: auto 1fr;
|
|
653
|
+
}
|
|
654
|
+
.state-panel {
|
|
655
|
+
border-right: 0;
|
|
656
|
+
border-bottom: 1px solid #1f2937;
|
|
657
|
+
max-height: 240px;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
</style>
|
|
@@ -22,6 +22,19 @@
|
|
|
22
22
|
</p>
|
|
23
23
|
</header>
|
|
24
24
|
|
|
25
|
+
<!-- Cross-page demo: navigate to the experience-flow demo -->
|
|
26
|
+
<section class="panel demo-link-panel">
|
|
27
|
+
<h2>Experience Flow Demo <span class="tag">@gxp-dev/uikit</span></h2>
|
|
28
|
+
<p class="hint">
|
|
29
|
+
A full state-machine-driven kiosk flow showing how to use
|
|
30
|
+
<code>useExperience</code>, the page library, async actions, branching
|
|
31
|
+
paths, and slot customization — all in one annotated file.
|
|
32
|
+
</p>
|
|
33
|
+
<button class="btn primary" @click="$emit('navigate', 'experience')">
|
|
34
|
+
Open Experience Demo →
|
|
35
|
+
</button>
|
|
36
|
+
</section>
|
|
37
|
+
|
|
25
38
|
<!-- gxp-settings + gxp-string: read from manifest.settings instead of strings -->
|
|
26
39
|
<section class="panel">
|
|
27
40
|
<h2>Settings <span class="tag">gxp-settings</span></h2>
|
|
@@ -154,6 +167,8 @@
|
|
|
154
167
|
|
|
155
168
|
.hero h1 {
|
|
156
169
|
margin: 0 0 8px 0;
|
|
170
|
+
font-size: 28px;
|
|
171
|
+
font-weight: 700;
|
|
157
172
|
color: v-bind('gxpStore.getSetting("primary_color")');
|
|
158
173
|
}
|
|
159
174
|
|
|
@@ -345,6 +360,8 @@ defineOptions({
|
|
|
345
360
|
inheritAttrs: false,
|
|
346
361
|
})
|
|
347
362
|
|
|
363
|
+
defineEmits(["navigate"])
|
|
364
|
+
|
|
348
365
|
import { ref, onMounted, onUnmounted } from "vue"
|
|
349
366
|
import { useGxpStore } from "@/stores/gxpPortalConfigStore"
|
|
350
367
|
|
package/template/src/Plugin.vue
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<!-- Your Custom Plugin Content -->
|
|
3
|
-
<DemoPage :router="mockRouter" />
|
|
3
|
+
<DemoPage v-if="route === 'home'" :router="mockRouter" @navigate="navigate" />
|
|
4
|
+
<DemoExperience v-else-if="route === 'experience'" @navigate="navigate" />
|
|
4
5
|
</template>
|
|
5
6
|
|
|
6
7
|
<script setup>
|
|
7
8
|
import { ref, shallowRef } from "vue"
|
|
8
9
|
import DemoPage from "@/DemoPage.vue"
|
|
10
|
+
import DemoExperience from "@/DemoExperience.vue"
|
|
11
|
+
|
|
12
|
+
// Note: @gxp-dev/uikit ships its own stylesheet (Tailwind + theme tokens). The
|
|
13
|
+
// experience-flow demo imports it inside DemoExperience.vue so it only loads
|
|
14
|
+
// when that page is opened.
|
|
9
15
|
|
|
10
16
|
// Initialize the GxP store
|
|
11
17
|
// This import is externalized to window.useGxpStore during build for platform compatibility
|
|
@@ -15,6 +21,13 @@ const gxpStore = useGxpStore()
|
|
|
15
21
|
gxpStore.sockets?.primary.listenForStateChange((event) => {
|
|
16
22
|
console.log("🔗 GXP Store: State change event received", event)
|
|
17
23
|
})
|
|
24
|
+
|
|
25
|
+
// Lightweight in-app routing between the two demo pages. Swap for vue-router
|
|
26
|
+
// in a real plugin if you need URL-aware nav.
|
|
27
|
+
const route = ref("home")
|
|
28
|
+
const navigate = (next) => {
|
|
29
|
+
route.value = next
|
|
30
|
+
}
|
|
18
31
|
</script>
|
|
19
32
|
|
|
20
33
|
<style>
|
package/mcp/lib/uikit-tools.js
DELETED
|
@@ -1,181 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* MCP tools for @gxp-dev/uikit introspection.
|
|
3
|
-
*
|
|
4
|
-
* - list_uikit_components : enumerate components exported by the version
|
|
5
|
-
* of @gxp-dev/uikit installed in the *plugin project's* node_modules
|
|
6
|
-
* (not the toolkit's). Resolves the package relative to process.cwd(),
|
|
7
|
-
* parses named exports out of dist/index.d.ts, and returns sorted
|
|
8
|
-
* PascalCase names plus the package version.
|
|
9
|
-
*
|
|
10
|
-
* We parse the .d.ts with a regex pair (no TS AST) because the file is the
|
|
11
|
-
* built output and is shaped by the package's own build, not by us. Edge
|
|
12
|
-
* cases that the regex misses (e.g. nested namespace exports) are
|
|
13
|
-
* acceptable: the agent gets a strong starting set and can fall back to
|
|
14
|
-
* docs_search for anything unusual.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
const fs = require("fs")
|
|
18
|
-
const path = require("path")
|
|
19
|
-
|
|
20
|
-
function contentResult(obj) {
|
|
21
|
-
return {
|
|
22
|
-
content: [{ type: "text", text: JSON.stringify(obj, null, 2) }],
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Resolve @gxp-dev/uikit from the given cwd. Returns { root, pkg } where
|
|
28
|
-
* root is the absolute path to the uikit package directory and pkg is the
|
|
29
|
-
* parsed package.json, or null if uikit is not installed at any level.
|
|
30
|
-
*
|
|
31
|
-
* We mimic Node's node_modules walk-up rather than calling
|
|
32
|
-
* require.resolve("@gxp-dev/uikit/...") because the uikit's package.json
|
|
33
|
-
* declares a strict `exports` field with only `import` and `types`
|
|
34
|
-
* conditions. From a CJS context, require.resolve fails against that
|
|
35
|
-
* exports map even though the directory exists on disk. Walking the
|
|
36
|
-
* filesystem directly sidesteps it and works under npm, pnpm (where
|
|
37
|
-
* @gxp-dev/uikit is a symlink into .pnpm), and yarn workspaces.
|
|
38
|
-
*/
|
|
39
|
-
function resolveUikit(cwd = process.cwd()) {
|
|
40
|
-
let dir = path.resolve(cwd)
|
|
41
|
-
while (dir) {
|
|
42
|
-
const candidate = path.join(dir, "node_modules", "@gxp-dev", "uikit")
|
|
43
|
-
const pkgPath = path.join(candidate, "package.json")
|
|
44
|
-
if (fs.existsSync(pkgPath)) {
|
|
45
|
-
try {
|
|
46
|
-
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"))
|
|
47
|
-
if (pkg && pkg.name === "@gxp-dev/uikit") {
|
|
48
|
-
return { root: candidate, pkg }
|
|
49
|
-
}
|
|
50
|
-
} catch {
|
|
51
|
-
// malformed package.json — keep walking
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
const parent = path.dirname(dir)
|
|
55
|
-
if (parent === dir) break
|
|
56
|
-
dir = parent
|
|
57
|
-
}
|
|
58
|
-
return null
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Pull named exports out of a .d.ts source string. Picks up:
|
|
63
|
-
* - export declare const/let/var Foo
|
|
64
|
-
* - export declare function Foo
|
|
65
|
-
* - export declare class Foo
|
|
66
|
-
* - export declare interface Foo
|
|
67
|
-
* - export declare type Foo
|
|
68
|
-
* - export declare enum Foo
|
|
69
|
-
* - export { Foo, Bar as Baz }
|
|
70
|
-
*/
|
|
71
|
-
function parseNamedExports(source) {
|
|
72
|
-
const names = new Set()
|
|
73
|
-
|
|
74
|
-
const declRe =
|
|
75
|
-
/export\s+(?:declare\s+)?(?:const|let|var|function|class|interface|type|enum)\s+([A-Za-z_$][A-Za-z0-9_$]*)/g
|
|
76
|
-
let m
|
|
77
|
-
while ((m = declRe.exec(source)) !== null) {
|
|
78
|
-
names.add(m[1])
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const braceRe = /export\s*\{([^}]+)\}/g
|
|
82
|
-
while ((m = braceRe.exec(source)) !== null) {
|
|
83
|
-
const inner = m[1]
|
|
84
|
-
for (const rawPart of inner.split(",")) {
|
|
85
|
-
const part = rawPart.trim()
|
|
86
|
-
if (!part) continue
|
|
87
|
-
const aliasMatch = part.match(
|
|
88
|
-
/^[A-Za-z_$][A-Za-z0-9_$]*\s+as\s+([A-Za-z_$][A-Za-z0-9_$]*)/,
|
|
89
|
-
)
|
|
90
|
-
if (aliasMatch) {
|
|
91
|
-
names.add(aliasMatch[1])
|
|
92
|
-
continue
|
|
93
|
-
}
|
|
94
|
-
const nameMatch = part.match(/^([A-Za-z_$][A-Za-z0-9_$]*)/)
|
|
95
|
-
if (nameMatch) names.add(nameMatch[1])
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
return Array.from(names)
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function listUikitComponents({ filter, cwd } = {}) {
|
|
103
|
-
const resolved = resolveUikit(cwd || process.cwd())
|
|
104
|
-
if (!resolved) {
|
|
105
|
-
return {
|
|
106
|
-
ok: false,
|
|
107
|
-
error:
|
|
108
|
-
"Could not resolve @gxp-dev/uikit from the current project. Install it with `npm install @gxp-dev/uikit` and re-run mcp-serve from the plugin project root.",
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
const { root, pkg } = resolved
|
|
112
|
-
const dtsPath = path.join(root, "dist", "index.d.ts")
|
|
113
|
-
if (!fs.existsSync(dtsPath)) {
|
|
114
|
-
return {
|
|
115
|
-
ok: false,
|
|
116
|
-
error: `@gxp-dev/uikit is installed at ${root} but dist/index.d.ts is missing. The package may not have been built.`,
|
|
117
|
-
resolved: root,
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
const src = fs.readFileSync(dtsPath, "utf-8")
|
|
122
|
-
const all = parseNamedExports(src)
|
|
123
|
-
const pascal = all.filter((n) => /^[A-Z]/.test(n)).sort()
|
|
124
|
-
|
|
125
|
-
let filtered = pascal
|
|
126
|
-
if (filter) {
|
|
127
|
-
const f = String(filter).toLowerCase()
|
|
128
|
-
filtered = pascal.filter((n) => n.toLowerCase().includes(f))
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
return {
|
|
132
|
-
ok: true,
|
|
133
|
-
package: {
|
|
134
|
-
name: pkg.name,
|
|
135
|
-
version: pkg.version || null,
|
|
136
|
-
root,
|
|
137
|
-
},
|
|
138
|
-
count: filtered.length,
|
|
139
|
-
components: filtered,
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
const UIKIT_TOOLS = [
|
|
144
|
-
{
|
|
145
|
-
name: "list_uikit_components",
|
|
146
|
-
description:
|
|
147
|
-
"Enumerate components exported by the @gxp-dev/uikit package installed in the current plugin project. Reads named exports from the built dist/index.d.ts and returns the PascalCase names plus the package version. Resolves uikit relative to the project (process.cwd()), not the toolkit, so the agent sees exactly what the project can import. Pass `filter` for a case-insensitive substring match.",
|
|
148
|
-
inputSchema: {
|
|
149
|
-
type: "object",
|
|
150
|
-
properties: {
|
|
151
|
-
filter: {
|
|
152
|
-
type: "string",
|
|
153
|
-
description:
|
|
154
|
-
"Case-insensitive substring filter applied to component names.",
|
|
155
|
-
},
|
|
156
|
-
},
|
|
157
|
-
},
|
|
158
|
-
},
|
|
159
|
-
]
|
|
160
|
-
|
|
161
|
-
async function handleUikitToolCall(name, args = {}) {
|
|
162
|
-
switch (name) {
|
|
163
|
-
case "list_uikit_components":
|
|
164
|
-
return contentResult(listUikitComponents(args))
|
|
165
|
-
default:
|
|
166
|
-
throw new Error(`Unknown uikit tool: ${name}`)
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function isUikitTool(name) {
|
|
171
|
-
return UIKIT_TOOLS.some((t) => t.name === name)
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
module.exports = {
|
|
175
|
-
UIKIT_TOOLS,
|
|
176
|
-
handleUikitToolCall,
|
|
177
|
-
isUikitTool,
|
|
178
|
-
listUikitComponents,
|
|
179
|
-
parseNamedExports,
|
|
180
|
-
resolveUikit,
|
|
181
|
-
}
|