@greensecurity/javascript-sdk 0.46.0-beta.9 → 0.47.0-beta.0

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.
Files changed (223) hide show
  1. package/dist/commonjs/__tests__/apps/contract.test.js +288 -4
  2. package/dist/commonjs/__tests__/apps/contract.test.js.map +1 -1
  3. package/dist/commonjs/__tests__/apps/devpreview-routes.test.d.ts +2 -0
  4. package/dist/commonjs/__tests__/apps/devpreview-routes.test.d.ts.map +1 -0
  5. package/dist/commonjs/__tests__/apps/devpreview-routes.test.js +86 -0
  6. package/dist/commonjs/__tests__/apps/devpreview-routes.test.js.map +1 -0
  7. package/dist/commonjs/__tests__/apps/kit/example.test.js +84 -4
  8. package/dist/commonjs/__tests__/apps/kit/example.test.js.map +1 -1
  9. package/dist/commonjs/__tests__/apps/kit/pages.test.d.ts +2 -0
  10. package/dist/commonjs/__tests__/apps/kit/pages.test.d.ts.map +1 -0
  11. package/dist/commonjs/__tests__/apps/kit/pages.test.js +119 -0
  12. package/dist/commonjs/__tests__/apps/kit/pages.test.js.map +1 -0
  13. package/dist/commonjs/__tests__/apps/kit/render.test.js +24 -0
  14. package/dist/commonjs/__tests__/apps/kit/render.test.js.map +1 -1
  15. package/dist/commonjs/__tests__/apps/kit/store.test.d.ts +2 -0
  16. package/dist/commonjs/__tests__/apps/kit/store.test.d.ts.map +1 -0
  17. package/dist/commonjs/__tests__/apps/kit/store.test.js +123 -0
  18. package/dist/commonjs/__tests__/apps/kit/store.test.js.map +1 -0
  19. package/dist/commonjs/__tests__/apps/post-install-page.test.d.ts +2 -0
  20. package/dist/commonjs/__tests__/apps/post-install-page.test.d.ts.map +1 -0
  21. package/dist/commonjs/__tests__/apps/post-install-page.test.js +110 -0
  22. package/dist/commonjs/__tests__/apps/post-install-page.test.js.map +1 -0
  23. package/dist/commonjs/__tests__/apps/routes.test.d.ts +2 -0
  24. package/dist/commonjs/__tests__/apps/routes.test.d.ts.map +1 -0
  25. package/dist/commonjs/__tests__/apps/routes.test.js +238 -0
  26. package/dist/commonjs/__tests__/apps/routes.test.js.map +1 -0
  27. package/dist/commonjs/__tests__/vendors.test.js +1 -1
  28. package/dist/commonjs/__tests__/zoneDevicesCreate.test.d.ts +2 -0
  29. package/dist/commonjs/__tests__/zoneDevicesCreate.test.d.ts.map +1 -0
  30. package/dist/commonjs/__tests__/zoneDevicesCreate.test.js +47 -0
  31. package/dist/commonjs/__tests__/zoneDevicesCreate.test.js.map +1 -0
  32. package/dist/commonjs/__tests__/zones.test.js +4 -28
  33. package/dist/commonjs/__tests__/zones.test.js.map +1 -1
  34. package/dist/commonjs/admin-funcs/zoneDevicesCreate.d.ts +11 -0
  35. package/dist/commonjs/admin-funcs/zoneDevicesCreate.d.ts.map +1 -0
  36. package/dist/commonjs/admin-funcs/zoneDevicesCreate.js +14 -0
  37. package/dist/commonjs/admin-funcs/zoneDevicesCreate.js.map +1 -0
  38. package/dist/commonjs/admin-funcs/zoneDevicesMacAvailability.d.ts +3 -0
  39. package/dist/commonjs/admin-funcs/zoneDevicesMacAvailability.d.ts.map +1 -0
  40. package/dist/commonjs/admin-funcs/zoneDevicesMacAvailability.js +11 -0
  41. package/dist/commonjs/admin-funcs/zoneDevicesMacAvailability.js.map +1 -0
  42. package/dist/commonjs/apps/components.d.ts +321 -0
  43. package/dist/commonjs/apps/components.d.ts.map +1 -1
  44. package/dist/commonjs/apps/components.js +168 -2
  45. package/dist/commonjs/apps/components.js.map +1 -1
  46. package/dist/commonjs/apps/context.d.ts +24 -0
  47. package/dist/commonjs/apps/context.d.ts.map +1 -1
  48. package/dist/commonjs/apps/context.js.map +1 -1
  49. package/dist/commonjs/apps/devpreview.d.ts +20 -3
  50. package/dist/commonjs/apps/devpreview.d.ts.map +1 -1
  51. package/dist/commonjs/apps/devpreview.js +18 -7
  52. package/dist/commonjs/apps/devpreview.js.map +1 -1
  53. package/dist/commonjs/apps/hooks/index.d.ts +33 -2
  54. package/dist/commonjs/apps/hooks/index.d.ts.map +1 -1
  55. package/dist/commonjs/apps/hooks/index.js +41 -1
  56. package/dist/commonjs/apps/hooks/index.js.map +1 -1
  57. package/dist/commonjs/apps/hooks/store.d.ts +48 -0
  58. package/dist/commonjs/apps/hooks/store.d.ts.map +1 -0
  59. package/dist/commonjs/apps/hooks/store.js +87 -0
  60. package/dist/commonjs/apps/hooks/store.js.map +1 -0
  61. package/dist/commonjs/apps/manifest.d.ts +64 -3
  62. package/dist/commonjs/apps/manifest.d.ts.map +1 -1
  63. package/dist/commonjs/apps/manifest.js +128 -27
  64. package/dist/commonjs/apps/manifest.js.map +1 -1
  65. package/dist/commonjs/apps/protocol.d.ts +1 -1
  66. package/dist/commonjs/apps/protocol.d.ts.map +1 -1
  67. package/dist/commonjs/apps/route.d.ts +11 -1
  68. package/dist/commonjs/apps/route.d.ts.map +1 -1
  69. package/dist/commonjs/apps/route.js +13 -7
  70. package/dist/commonjs/apps/route.js.map +1 -1
  71. package/dist/commonjs/apps/routes.d.ts +65 -0
  72. package/dist/commonjs/apps/routes.d.ts.map +1 -0
  73. package/dist/commonjs/apps/routes.js +202 -0
  74. package/dist/commonjs/apps/routes.js.map +1 -0
  75. package/dist/commonjs/apps/surfaces.d.ts +20 -2
  76. package/dist/commonjs/apps/surfaces.d.ts.map +1 -1
  77. package/dist/commonjs/apps/surfaces.js +45 -7
  78. package/dist/commonjs/apps/surfaces.js.map +1 -1
  79. package/dist/commonjs/apps/testing/index.d.ts +17 -1
  80. package/dist/commonjs/apps/testing/index.d.ts.map +1 -1
  81. package/dist/commonjs/apps/testing/index.js +50 -1
  82. package/dist/commonjs/apps/testing/index.js.map +1 -1
  83. package/dist/commonjs/apps/ui/index.d.ts +13 -0
  84. package/dist/commonjs/apps/ui/index.d.ts.map +1 -1
  85. package/dist/commonjs/apps/ui/index.js +19 -2
  86. package/dist/commonjs/apps/ui/index.js.map +1 -1
  87. package/dist/commonjs/index.extras.d.ts +4 -0
  88. package/dist/commonjs/index.extras.d.ts.map +1 -1
  89. package/dist/commonjs/index.extras.js +5 -1
  90. package/dist/commonjs/index.extras.js.map +1 -1
  91. package/dist/commonjs/lib/config.d.ts +3 -3
  92. package/dist/commonjs/lib/config.js +3 -3
  93. package/dist/commonjs/models/components/vendorcredentialsummary.d.ts +7 -0
  94. package/dist/commonjs/models/components/vendorcredentialsummary.d.ts.map +1 -1
  95. package/dist/commonjs/models/components/vendorcredentialsummary.js +2 -0
  96. package/dist/commonjs/models/components/vendorcredentialsummary.js.map +1 -1
  97. package/dist/esm/__tests__/apps/contract.test.js +289 -5
  98. package/dist/esm/__tests__/apps/contract.test.js.map +1 -1
  99. package/dist/esm/__tests__/apps/devpreview-routes.test.d.ts +2 -0
  100. package/dist/esm/__tests__/apps/devpreview-routes.test.d.ts.map +1 -0
  101. package/dist/esm/__tests__/apps/devpreview-routes.test.js +84 -0
  102. package/dist/esm/__tests__/apps/devpreview-routes.test.js.map +1 -0
  103. package/dist/esm/__tests__/apps/kit/example.test.js +85 -5
  104. package/dist/esm/__tests__/apps/kit/example.test.js.map +1 -1
  105. package/dist/esm/__tests__/apps/kit/pages.test.d.ts +2 -0
  106. package/dist/esm/__tests__/apps/kit/pages.test.d.ts.map +1 -0
  107. package/dist/esm/__tests__/apps/kit/pages.test.js +117 -0
  108. package/dist/esm/__tests__/apps/kit/pages.test.js.map +1 -0
  109. package/dist/esm/__tests__/apps/kit/render.test.js +25 -1
  110. package/dist/esm/__tests__/apps/kit/render.test.js.map +1 -1
  111. package/dist/esm/__tests__/apps/kit/store.test.d.ts +2 -0
  112. package/dist/esm/__tests__/apps/kit/store.test.d.ts.map +1 -0
  113. package/dist/esm/__tests__/apps/kit/store.test.js +121 -0
  114. package/dist/esm/__tests__/apps/kit/store.test.js.map +1 -0
  115. package/dist/esm/__tests__/apps/post-install-page.test.d.ts +2 -0
  116. package/dist/esm/__tests__/apps/post-install-page.test.d.ts.map +1 -0
  117. package/dist/esm/__tests__/apps/post-install-page.test.js +108 -0
  118. package/dist/esm/__tests__/apps/post-install-page.test.js.map +1 -0
  119. package/dist/esm/__tests__/apps/routes.test.d.ts +2 -0
  120. package/dist/esm/__tests__/apps/routes.test.d.ts.map +1 -0
  121. package/dist/esm/__tests__/apps/routes.test.js +236 -0
  122. package/dist/esm/__tests__/apps/routes.test.js.map +1 -0
  123. package/dist/esm/__tests__/vendors.test.js +1 -1
  124. package/dist/esm/__tests__/zoneDevicesCreate.test.d.ts +2 -0
  125. package/dist/esm/__tests__/zoneDevicesCreate.test.d.ts.map +1 -0
  126. package/dist/esm/__tests__/zoneDevicesCreate.test.js +45 -0
  127. package/dist/esm/__tests__/zoneDevicesCreate.test.js.map +1 -0
  128. package/dist/esm/__tests__/zones.test.js +4 -28
  129. package/dist/esm/__tests__/zones.test.js.map +1 -1
  130. package/dist/esm/admin-funcs/zoneDevicesCreate.d.ts +11 -0
  131. package/dist/esm/admin-funcs/zoneDevicesCreate.d.ts.map +1 -0
  132. package/dist/esm/admin-funcs/zoneDevicesCreate.js +11 -0
  133. package/dist/esm/admin-funcs/zoneDevicesCreate.js.map +1 -0
  134. package/dist/esm/admin-funcs/zoneDevicesMacAvailability.d.ts +3 -0
  135. package/dist/esm/admin-funcs/zoneDevicesMacAvailability.d.ts.map +1 -0
  136. package/dist/esm/admin-funcs/zoneDevicesMacAvailability.js +8 -0
  137. package/dist/esm/admin-funcs/zoneDevicesMacAvailability.js.map +1 -0
  138. package/dist/esm/apps/components.d.ts +321 -0
  139. package/dist/esm/apps/components.d.ts.map +1 -1
  140. package/dist/esm/apps/components.js +163 -1
  141. package/dist/esm/apps/components.js.map +1 -1
  142. package/dist/esm/apps/context.d.ts +24 -0
  143. package/dist/esm/apps/context.d.ts.map +1 -1
  144. package/dist/esm/apps/context.js.map +1 -1
  145. package/dist/esm/apps/devpreview.d.ts +20 -3
  146. package/dist/esm/apps/devpreview.d.ts.map +1 -1
  147. package/dist/esm/apps/devpreview.js +19 -8
  148. package/dist/esm/apps/devpreview.js.map +1 -1
  149. package/dist/esm/apps/hooks/index.d.ts +33 -2
  150. package/dist/esm/apps/hooks/index.d.ts.map +1 -1
  151. package/dist/esm/apps/hooks/index.js +37 -1
  152. package/dist/esm/apps/hooks/index.js.map +1 -1
  153. package/dist/esm/apps/hooks/store.d.ts +48 -0
  154. package/dist/esm/apps/hooks/store.d.ts.map +1 -0
  155. package/dist/esm/apps/hooks/store.js +84 -0
  156. package/dist/esm/apps/hooks/store.js.map +1 -0
  157. package/dist/esm/apps/manifest.d.ts +64 -3
  158. package/dist/esm/apps/manifest.d.ts.map +1 -1
  159. package/dist/esm/apps/manifest.js +116 -26
  160. package/dist/esm/apps/manifest.js.map +1 -1
  161. package/dist/esm/apps/protocol.d.ts +1 -1
  162. package/dist/esm/apps/protocol.d.ts.map +1 -1
  163. package/dist/esm/apps/route.d.ts +11 -1
  164. package/dist/esm/apps/route.d.ts.map +1 -1
  165. package/dist/esm/apps/route.js +14 -8
  166. package/dist/esm/apps/route.js.map +1 -1
  167. package/dist/esm/apps/routes.d.ts +65 -0
  168. package/dist/esm/apps/routes.d.ts.map +1 -0
  169. package/dist/esm/apps/routes.js +194 -0
  170. package/dist/esm/apps/routes.js.map +1 -0
  171. package/dist/esm/apps/surfaces.d.ts +20 -2
  172. package/dist/esm/apps/surfaces.d.ts.map +1 -1
  173. package/dist/esm/apps/surfaces.js +39 -2
  174. package/dist/esm/apps/surfaces.js.map +1 -1
  175. package/dist/esm/apps/testing/index.d.ts +17 -1
  176. package/dist/esm/apps/testing/index.d.ts.map +1 -1
  177. package/dist/esm/apps/testing/index.js +49 -1
  178. package/dist/esm/apps/testing/index.js.map +1 -1
  179. package/dist/esm/apps/ui/index.d.ts +13 -0
  180. package/dist/esm/apps/ui/index.d.ts.map +1 -1
  181. package/dist/esm/apps/ui/index.js +17 -1
  182. package/dist/esm/apps/ui/index.js.map +1 -1
  183. package/dist/esm/index.extras.d.ts +4 -0
  184. package/dist/esm/index.extras.d.ts.map +1 -1
  185. package/dist/esm/index.extras.js +5 -1
  186. package/dist/esm/index.extras.js.map +1 -1
  187. package/dist/esm/lib/config.d.ts +3 -3
  188. package/dist/esm/lib/config.js +3 -3
  189. package/dist/esm/models/components/vendorcredentialsummary.d.ts +7 -0
  190. package/dist/esm/models/components/vendorcredentialsummary.d.ts.map +1 -1
  191. package/dist/esm/models/components/vendorcredentialsummary.js +2 -0
  192. package/dist/esm/models/components/vendorcredentialsummary.js.map +1 -1
  193. package/examples/apps/hello-green/src/store.ts +15 -0
  194. package/jsr.json +1 -1
  195. package/package.json +1 -1
  196. package/src/__tests__/apps/contract.test.ts +305 -4
  197. package/src/__tests__/apps/devpreview-routes.test.ts +103 -0
  198. package/src/__tests__/apps/kit/example.test.ts +94 -5
  199. package/src/__tests__/apps/kit/pages.test.ts +137 -0
  200. package/src/__tests__/apps/kit/render.test.ts +35 -1
  201. package/src/__tests__/apps/kit/store.test.ts +149 -0
  202. package/src/__tests__/apps/post-install-page.test.ts +126 -0
  203. package/src/__tests__/apps/routes.test.ts +318 -0
  204. package/src/__tests__/vendors.test.ts +1 -1
  205. package/src/__tests__/zoneDevicesCreate.test.ts +56 -0
  206. package/src/__tests__/zones.test.ts +4 -28
  207. package/src/admin-funcs/zoneDevicesCreate.ts +22 -0
  208. package/src/admin-funcs/zoneDevicesMacAvailability.ts +8 -0
  209. package/src/apps/components.ts +183 -1
  210. package/src/apps/context.ts +25 -0
  211. package/src/apps/devpreview.ts +20 -7
  212. package/src/apps/hooks/index.ts +42 -2
  213. package/src/apps/hooks/store.ts +115 -0
  214. package/src/apps/manifest.ts +151 -27
  215. package/src/apps/protocol.ts +1 -1
  216. package/src/apps/route.ts +22 -10
  217. package/src/apps/routes.ts +230 -0
  218. package/src/apps/surfaces.ts +45 -12
  219. package/src/apps/testing/index.ts +48 -2
  220. package/src/apps/ui/index.ts +18 -1
  221. package/src/index.extras.ts +5 -1
  222. package/src/lib/config.ts +3 -3
  223. package/src/models/components/vendorcredentialsummary.ts +9 -0
@@ -0,0 +1,318 @@
1
+ /*
2
+ * Hand-written. Tests for app page routes (`ui_extensions.routes`): the grammar and uniqueness
3
+ * rules in the manifest schema, the shared matcher, the `page` surface and the `appPage`
4
+ * descriptor. The route sets and match cases come from `fixtures/routes.json`, which the server
5
+ * specs read too; see docs/third-party-apps/fullpageview-plan.md.
6
+ */
7
+
8
+ import { readFileSync } from "node:fs";
9
+ import { resolve } from "node:path";
10
+ import { describe, expect, test } from "vitest";
11
+ import {
12
+ APP_PAGES_SEGMENT,
13
+ APP_ROUTES_MAX,
14
+ appPagePathname,
15
+ effectivePostInstallAction,
16
+ getSurface,
17
+ isConcreteRoutePath,
18
+ isRouteDescriptor,
19
+ isRoutePath,
20
+ isSurfaceId,
21
+ manifestJsonSchema,
22
+ matchAppRoute,
23
+ normalizeRoutePath,
24
+ ROUTE_PATH_MAX_SEGMENTS,
25
+ ROUTE_PATH_PATTERN,
26
+ SURFACES,
27
+ surfacesForPortal,
28
+ validateManifest,
29
+ type AppManifestV2,
30
+ type ExtensionContext,
31
+ type PageRoute,
32
+ type ViewType,
33
+ } from "../../apps/index.js";
34
+ import { NON_BLANK_PATTERN } from "../../apps/manifest.js";
35
+
36
+ // Resolved from the package root (vitest's cwd), as contract.test.ts does for shared/app-platform.
37
+ const FIXTURES = resolve(process.cwd(), "src", "__tests__", "apps", "fixtures", "routes.json");
38
+
39
+ interface RouteSet {
40
+ name: string;
41
+ routes: unknown[];
42
+ }
43
+ interface RejectedSet extends RouteSet {
44
+ reason: string;
45
+ issue: string;
46
+ }
47
+ interface MatchCase {
48
+ name: string;
49
+ routes: string[];
50
+ pathname: string;
51
+ expected: { index: number; path: string; params: Record<string, string> } | null;
52
+ }
53
+
54
+ function isRecord(value: unknown): value is Record<string, unknown> {
55
+ return typeof value === "object" && value !== null && !Array.isArray(value);
56
+ }
57
+
58
+ function loadFixtures(): { accepted: RouteSet[]; rejected: RejectedSet[]; matches: MatchCase[] } {
59
+ const parsed: unknown = JSON.parse(readFileSync(FIXTURES, "utf8"));
60
+ if (
61
+ !isRecord(parsed) ||
62
+ !Array.isArray(parsed["accepted"]) ||
63
+ !Array.isArray(parsed["rejected"]) ||
64
+ !Array.isArray(parsed["matches"])
65
+ ) {
66
+ throw new Error("fixtures/routes.json needs accepted, rejected and matches arrays");
67
+ }
68
+ return {
69
+ accepted: parsed["accepted"] as RouteSet[],
70
+ rejected: parsed["rejected"] as RejectedSet[],
71
+ matches: parsed["matches"] as MatchCase[],
72
+ };
73
+ }
74
+
75
+ const fixtures = loadFixtures();
76
+
77
+ function manifestWithRoutes(routes: unknown, overrides: Record<string, unknown> = {}): Record<string, unknown> {
78
+ return {
79
+ id: 42,
80
+ name: "Acme Reports",
81
+ icon: "https://cdn.acme.com/icon.png",
82
+ short_description: "Reports about your vendors.",
83
+ version: "1.0.0",
84
+ configuration_instructions: "Install and sign in.",
85
+ support_instructions: "support@acme.com",
86
+ ui_extensions: {
87
+ bundle: {
88
+ url: "https://cdn.acme.com/gs-app/1.0.0/app.js",
89
+ integrity: "sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC",
90
+ },
91
+ routes,
92
+ },
93
+ ...overrides,
94
+ };
95
+ }
96
+
97
+ function issuePaths(input: unknown): string[] {
98
+ const result = validateManifest(input);
99
+ return result.ok ? [] : result.issues.map((i) => i.path);
100
+ }
101
+
102
+ const page = (path: string) => ({ path, component: "Page", title: "Page" });
103
+
104
+ describe("route fixtures: manifest schema", () => {
105
+ test("the fixture file has cases of every kind", () => {
106
+ expect(fixtures.accepted.length).toBeGreaterThan(0);
107
+ expect(fixtures.rejected.length).toBeGreaterThan(0);
108
+ expect(fixtures.matches.length).toBeGreaterThan(0);
109
+ });
110
+
111
+ test.each(fixtures.accepted.map((set) => [set.name, set] as const))("accepts %s", (_name, set) => {
112
+ const result = validateManifest(manifestWithRoutes(set.routes));
113
+ expect(result.ok ? [] : result.issues).toEqual([]);
114
+ });
115
+
116
+ test.each(fixtures.rejected.map((set) => [set.name, set] as const))("rejects %s", (_name, set) => {
117
+ expect(issuePaths(manifestWithRoutes(set.routes))).toContain(`ui_extensions.${set.issue}`);
118
+ });
119
+
120
+ test.each(
121
+ fixtures.rejected
122
+ .filter((set) => /^(path_format|path_too_long|too_many_segments)$/.test(set.reason))
123
+ .map((set) => [set.name, set] as const),
124
+ )("isRoutePath refuses the path of %s", (_name, set) => {
125
+ const route = set.routes[0];
126
+ expect(isRecord(route) && isRoutePath(route["path"])).toBe(false);
127
+ });
128
+
129
+ test("every accepted path is a route path, and every static-only one is concrete", () => {
130
+ for (const set of fixtures.accepted) {
131
+ for (const route of set.routes) {
132
+ const path = isRecord(route) ? route["path"] : undefined;
133
+ expect(isRoutePath(path), String(path)).toBe(true);
134
+ if (typeof path === "string") expect(isConcreteRoutePath(path)).toBe(!path.includes(":"));
135
+ }
136
+ }
137
+ });
138
+ });
139
+
140
+ describe("route fixtures: matchAppRoute", () => {
141
+ test.each(fixtures.matches.map((c) => [c.name, c] as const))("%s", (_name, c) => {
142
+ const routes = c.routes.map((path) => ({ path }));
143
+ const match = matchAppRoute(routes, c.pathname);
144
+ if (c.expected === null) {
145
+ expect(match).toBeNull();
146
+ } else {
147
+ expect(match).toEqual({ route: routes[c.expected.index], params: c.expected.params, index: c.expected.index });
148
+ expect(match?.route.path).toBe(c.expected.path);
149
+ }
150
+ });
151
+
152
+ test("returns the caller's own route object", () => {
153
+ const routes = [{ path: "/reports/:id", component: "Report", title: "Report" }];
154
+ expect(matchAppRoute(routes, "/reports/7")?.route).toBe(routes[0]);
155
+ });
156
+ });
157
+
158
+ describe("route rules", () => {
159
+ test("NON_BLANK_PATTERN is JavaScript's \\S on every code point, spelled out for Ruby", () => {
160
+ const pattern = new RegExp(NON_BLANK_PATTERN);
161
+ const differ: string[] = [];
162
+ for (let code = 0; code <= 0x10ffff; code++) {
163
+ if (code >= 0xd800 && code <= 0xdfff) continue;
164
+ const char = String.fromCodePoint(code);
165
+ if (pattern.test(char) !== /\S/.test(char)) differ.push(code.toString(16));
166
+ }
167
+ expect(differ).toEqual([]);
168
+ });
169
+
170
+ test("normalizeRoutePath renames every param to :_", () => {
171
+ expect(normalizeRoutePath("/")).toBe("/");
172
+ expect(normalizeRoutePath("/reports")).toBe("/reports");
173
+ expect(normalizeRoutePath("/vendors/:id/edit")).toBe("/vendors/:_/edit");
174
+ expect(normalizeRoutePath("/:a/:b")).toBe(normalizeRoutePath("/:x/:y"));
175
+ });
176
+
177
+ test("the JSON Schema carries the path grammar", () => {
178
+ const schema = manifestJsonSchema() as {
179
+ properties: {
180
+ ui_extensions: {
181
+ properties: { routes: { items: { properties: { path: { pattern: string } }; required: string[] } } };
182
+ };
183
+ };
184
+ };
185
+ const item = schema.properties.ui_extensions.properties.routes.items;
186
+ // zod writes `/` as `\/`; the two patterns are the same regular expression.
187
+ expect(item.properties.path.pattern.replace(/\\\//g, "/")).toBe(ROUTE_PATH_PATTERN);
188
+ expect(item.required).toEqual(["path", "component", "title"]);
189
+ expect(ROUTE_PATH_MAX_SEGMENTS).toBe(8);
190
+ });
191
+
192
+ test(`a manifest takes at most ${APP_ROUTES_MAX} routes`, () => {
193
+ const routes = Array.from({ length: APP_ROUTES_MAX + 1 }, (_, i) => page(`/p${i}`));
194
+ expect(validateManifest(manifestWithRoutes(routes.slice(0, APP_ROUTES_MAX))).ok).toBe(true);
195
+ expect(issuePaths(manifestWithRoutes(routes))).toContain("ui_extensions.routes");
196
+ });
197
+
198
+ test("routes need a bundle and a version, like views", () => {
199
+ const noBundle = manifestWithRoutes([page("/reports")]);
200
+ noBundle["ui_extensions"] = { routes: [page("/reports")] };
201
+ expect(issuePaths(noBundle)).toContain("ui_extensions.bundle");
202
+
203
+ const noVersion = manifestWithRoutes([page("/reports")]);
204
+ delete noVersion["version"];
205
+ noVersion["ui_extensions"] = { routes: [page("/reports")] };
206
+ expect(issuePaths(noVersion)).toEqual(expect.arrayContaining(["ui_extensions.bundle", "version"]));
207
+ });
208
+
209
+ test("a root route is a main view; any other route is not", () => {
210
+ const root = manifestWithRoutes([page("/")]);
211
+ expect(effectivePostInstallAction(root as AppManifestV2)).toEqual({ type: "app_main_view" });
212
+ expect(validateManifest({ ...root, post_install_action: { type: "app_main_view" } }).ok).toBe(true);
213
+
214
+ const nested = manifestWithRoutes([page("/reports")]);
215
+ expect(effectivePostInstallAction(nested as AppManifestV2)).toBeNull();
216
+ expect(issuePaths({ ...nested, post_install_action: { type: "app_main_view" } })).toContain(
217
+ "post_install_action.type",
218
+ );
219
+ });
220
+ });
221
+
222
+ describe("the page surface", () => {
223
+ test("is portal-less, wave 1, under the reserved prefix", () => {
224
+ expect(getSurface("page")).toEqual({
225
+ id: "page",
226
+ portal: null,
227
+ kind: "page",
228
+ objectType: null,
229
+ route: `/[portal]/apps/[appId]/${APP_PAGES_SEGMENT}/[...path]`,
230
+ idParam: "appId",
231
+ wave: 1,
232
+ });
233
+ expect(isSurfaceId("page")).toBe(true);
234
+ });
235
+
236
+ test("no other surface lives under the app pages prefix", () => {
237
+ // Literal portal segments too: `/company/apps/[appId]/pages` is the same place for a company user.
238
+ const reserved = new RegExp(`^/[^/]+/apps/\\[appId\\]/${APP_PAGES_SEGMENT}(/|$)`);
239
+ const offenders = SURFACES.filter((s) => s.id !== "page" && reserved.test(s.route)).map((s) => s.id);
240
+ expect(offenders).toEqual([]);
241
+ });
242
+
243
+ test("is never listed as a portal surface", () => {
244
+ for (const portal of ["company", "vendor", "facility", "system"] as const) {
245
+ expect(surfacesForPortal(portal, 2).map((s) => s.id)).not.toContain("page");
246
+ }
247
+ });
248
+
249
+ test("is not a view viewport: pages are declared as routes", () => {
250
+ const m = manifestWithRoutes([page("/reports")]);
251
+ (m["ui_extensions"] as Record<string, unknown>)["views"] = [{ viewport: "page", component: "Page" }];
252
+ expect(issuePaths(m)).toContain("ui_extensions.views.0.viewport");
253
+ });
254
+
255
+ test("appPagePathname puts the root page at the prefix itself", () => {
256
+ expect(appPagePathname("company", 42, "/")).toBe("/company/apps/42/pages");
257
+ expect(appPagePathname("vendor", 42, "/reports/7")).toBe("/vendor/apps/42/pages/reports/7");
258
+ });
259
+
260
+ test.each(["/../settings", "/reports/../../settings", "//evil.example", "/reports/:id", "reports", "", "/Reports"])(
261
+ "appPagePathname rejects %j, which is not a concrete route path",
262
+ (path) => {
263
+ expect(() => appPagePathname("company", 42, path)).toThrow(RangeError);
264
+ },
265
+ );
266
+
267
+ test.each([0, -1, 1.5, Number.NaN])("appPagePathname rejects app id %p", (appId) => {
268
+ expect(() => appPagePathname("company", appId, "/")).toThrow(RangeError);
269
+ });
270
+ });
271
+
272
+ describe("appPage route descriptors", () => {
273
+ test("take a concrete path in the route grammar", () => {
274
+ expect(isRouteDescriptor({ type: "appPage", path: "/" })).toBe(true);
275
+ expect(isRouteDescriptor({ type: "appPage", path: "/reports/7" })).toBe(true);
276
+ expect(isRouteDescriptor({ type: "appPage", path: "/pages/foo", envParams: { tab: "a" } })).toBe(true);
277
+ expect(isRouteDescriptor({ type: "appPage", path: "/settings" })).toBe(true);
278
+ });
279
+
280
+ test.each([
281
+ "/vendors/:id",
282
+ "/../settings",
283
+ "/reports/..",
284
+ "/%2e%2e/settings",
285
+ "/Reports",
286
+ "/reports/",
287
+ "/reports//7",
288
+ "reports",
289
+ "",
290
+ "/reports?x=1",
291
+ "https://evil.example/x",
292
+ `/${"a".repeat(200)}`,
293
+ "/a/b/c/d/e/f/g/h/i",
294
+ ])("refuse %j", (path) => {
295
+ expect(isRouteDescriptor({ type: "appPage", path })).toBe(false);
296
+ });
297
+
298
+ test("refuse a missing path, a non-string path and bad envParams", () => {
299
+ expect(isRouteDescriptor({ type: "appPage" })).toBe(false);
300
+ expect(isRouteDescriptor({ type: "appPage", path: 7 })).toBe(false);
301
+ expect(isRouteDescriptor({ type: "appPage", path: "/reports", envParams: { a: 1 } })).toBe(false);
302
+ });
303
+
304
+ test("the page surface is not a surface target, like settings", () => {
305
+ expect(isRouteDescriptor({ type: "surface", surfaceId: "page" })).toBe(false);
306
+ expect(isRouteDescriptor({ type: "surface", surfaceId: "page", objectId: 7 })).toBe(false);
307
+ });
308
+ });
309
+
310
+ describe("page context types", () => {
311
+ test("environment.route and the page view type type-check", () => {
312
+ const route: PageRoute = { path: "/reports/:id", pathname: "/reports/7", params: { id: "7" } };
313
+ const env: Pick<ExtensionContext["environment"], "surfaceId" | "route"> = { surfaceId: "page", route };
314
+ const viewType: ViewType = "page";
315
+ expect(env.route?.params["id"]).toBe("7");
316
+ expect(viewType).toBe("page");
317
+ });
318
+ });
@@ -121,7 +121,7 @@ test("Vendors List Vendor Credits", async () => {
121
121
  page: 1,
122
122
  useUrlParams: false,
123
123
  },
124
- creditBalance: 6004.18,
124
+ creditBalance: 6030.32,
125
125
  });
126
126
  });
127
127
 
@@ -0,0 +1,56 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ // Typed parameters so mock.calls is the argument tuple; see smartBadgeGetPsks.test.ts.
4
+ const { doPostMock, doGetSingleMock } = vi.hoisted(() => ({
5
+ doPostMock: vi.fn((_route: string, _body: unknown) => Promise.resolve([{ ok: true }, { status: "complete" }])),
6
+ doGetSingleMock: vi.fn((_route: string, _query: unknown) =>
7
+ Promise.resolve([{ ok: true }, { status: "complete" }])
8
+ ),
9
+ }));
10
+
11
+ vi.mock("../admin-funcs/doAdmin.js", () => ({
12
+ $doPost: doPostMock,
13
+ $doGetSingle: doGetSingleMock,
14
+ }));
15
+
16
+ import { __internal } from "../index.extras.js";
17
+
18
+ describe("__internal.zoneDevices", () => {
19
+ beforeEach(() => {
20
+ doPostMock.mockClear();
21
+ doGetSingleMock.mockClear();
22
+ });
23
+
24
+ it("posts the gateway body verbatim to /zone-devices", () => {
25
+ const body = {
26
+ zone_id: 7,
27
+ network_id: 3,
28
+ connection_type: "ethernet" as const,
29
+ mac_address: "AA:BB:CC:DD:EE:FF",
30
+ name: "Lobby gateway",
31
+ };
32
+
33
+ __internal.zoneDevices.create(body);
34
+
35
+ expect(doPostMock).toHaveBeenCalledWith("/zone-devices", body);
36
+ });
37
+
38
+ it("sends the MAC as a query parameter to the availability check", () => {
39
+ __internal.zoneDevices.macAvailability("AA:BB:CC:DD:EE:FF");
40
+
41
+ expect(doGetSingleMock).toHaveBeenCalledWith("/zone-devices/mac-availability", {
42
+ mac_address: "AA:BB:CC:DD:EE:FF",
43
+ });
44
+ });
45
+
46
+ // a5d10295d4 once dropped these from __internal while gatekeeper-client still called them.
47
+ it("keeps the gatekeeper networks and zone device calls exported", () => {
48
+ expect(__internal.networks).toEqual({
49
+ list: expect.any(Function),
50
+ get: expect.any(Function),
51
+ create: expect.any(Function),
52
+ });
53
+ expect(__internal.zoneDevices.list).toEqual(expect.any(Function));
54
+ expect(__internal.zoneDevices.get).toEqual(expect.any(Function));
55
+ });
56
+ });
@@ -39,32 +39,7 @@ test("Zones List Or Search Security Zones", async () => {
39
39
  facility: {},
40
40
  },
41
41
  {
42
- facility: {
43
- contacts: {
44
- contact: {
45
- status: "Active",
46
- department: 123,
47
- },
48
- adminContact: {
49
- status: "Inactive",
50
- department: 123,
51
- },
52
- techContact: {
53
- status: "Active",
54
- department: 123,
55
- },
56
- greenSecurityCsm: {
57
- status: "Active",
58
- department: 123,
59
- },
60
- accountManager: null,
61
- },
62
- vendorGuestPolicy: {
63
- enabled: false,
64
- vendorGuestLimitAndOr: "and",
65
- vendorGuestRequireEmail: true,
66
- },
67
- },
42
+ facility: {},
68
43
  },
69
44
  ],
70
45
  });
@@ -82,8 +57,9 @@ test("Zones Security Zone Checkin", async () => {
82
57
  });
83
58
 
84
59
  const result = await greenSecurity.zones.securityZoneCheckin({
85
- qrCode: "<value>",
86
- zoneId: 776072,
60
+ longitude: 5032.22,
61
+ latitude: 327.09,
62
+ zoneId: 542452,
87
63
  });
88
64
  expect(result).toBeDefined();
89
65
  expect(result).toEqual({});
@@ -0,0 +1,22 @@
1
+ import { APIPromise } from "../types/async.js";
2
+ import { $doPost, internalApiResult } from "./doAdmin.js";
3
+
4
+ export type zoneDeviceConnectionType = "ethernet" | "wifi";
5
+
6
+ // Wire names, posted verbatim like networksCreateRequest.
7
+ export type zoneDevicesCreateRequest = {
8
+ zone_id: number;
9
+ network_id: number;
10
+ connection_type: zoneDeviceConnectionType;
11
+ mac_address: string;
12
+ name: string;
13
+ };
14
+
15
+ // Saves an MG6 gateway from the Gatekeeper create-gateway flow (GSSD-7286). Provisioning is a
16
+ // separate call.
17
+ //
18
+ // Internal for the same reason as zoneDevicesList. 409 means the MAC is already a gateway
19
+ // somewhere; the server never says where.
20
+ export function zoneDevicesCreate(request: zoneDevicesCreateRequest): internalApiResult {
21
+ return new APIPromise($doPost("/zone-devices", request));
22
+ }
@@ -0,0 +1,8 @@
1
+ import { APIPromise } from "../types/async.js";
2
+ import { $doGetSingle, internalApiResult } from "./doAdmin.js";
3
+
4
+ // Whether a scanned MAC is free to become a gateway: `{ mac_address, available }`, with the MAC
5
+ // normalized. A query parameter rather than a path segment, so no separator needs escaping.
6
+ export function zoneDevicesMacAvailability(macAddress: string): internalApiResult {
7
+ return new APIPromise($doGetSingle("/zone-devices/mac-availability", { mac_address: macAddress }));
8
+ }
@@ -163,11 +163,176 @@ const isConfirmCloseMessages = (v: unknown): v is ConfirmCloseMessages =>
163
163
 
164
164
  const TABLE_CELL = { align: p.enum(["start", "center", "end"] as const), width: p.enum(WIDTHS) };
165
165
 
166
+ // ---------------------------------------------------------------------------------------------
167
+ // Charts
168
+ //
169
+ // Charts take data only. Colours come from a named `theme`; there are no colour, font, size or
170
+ // style props. Every value is a finite number >= 0 (v1 draws no negative axis), and every label
171
+ // and series name is visible text the host draws on an axis, legend or tooltip.
172
+
173
+ /** Named palettes the host draws charts with. The host uses `"green"` when `theme` is omitted. */
174
+ export const CHART_THEMES = ["green", "grayscale", "vibrant"] as const;
175
+
176
+ /** Most series a stacked, line or area chart may draw: one per palette colour. */
177
+ export const MAX_CHART_SERIES = 6;
178
+ /**
179
+ * Largest accepted chart value. Every chart caps its item and series counts, so with each value at
180
+ * most this, any total a chart computes (a stack, a pie) stays finite; two values near
181
+ * `Number.MAX_VALUE` would otherwise sum to `Infinity`.
182
+ */
183
+ export const MAX_CHART_VALUE = Number.MAX_SAFE_INTEGER;
184
+
185
+ /** Longest label or series name, in characters. */
186
+ export const MAX_CHART_LABEL_LENGTH = 60;
187
+
188
+ /** One bar or slice: `label` names it, `value` is its size. */
189
+ export interface ChartDatum { label: string; value: number }
190
+ /** A named series with one value per label of the chart it belongs to. */
191
+ export interface ChartSeries { name: string; values: number[] }
192
+ /** Labels along the category axis and the series drawn against them. */
193
+ export interface SeriesChartData { labels: string[]; series: ChartSeries[] }
194
+ /** Bars and a line drawn over the same labels. */
195
+ export interface ComboChartData { labels: string[]; bars: ChartSeries; line: ChartSeries }
196
+ /**
197
+ * The detail of a chart's `onClick`: the position and label of the pressed bar, point or slice,
198
+ * its value, and, on a multi-series chart, the name of its series.
199
+ */
200
+ export interface ChartClickEvent { index: number; label: string; value: number; series?: string }
201
+
202
+ /** Item-count bounds for {@link isChartData}. */
203
+ export interface ChartDataLimits { minItems: number; maxItems: number }
204
+ /** Label- and series-count bounds for {@link isSeriesChartData}; `maxSeries` defaults to {@link MAX_CHART_SERIES}. */
205
+ export interface SeriesChartDataLimits { minLabels: number; maxLabels: number; maxSeries?: number }
206
+ /** Label-count bounds for {@link isComboChartData}. */
207
+ export interface ComboChartDataLimits { minLabels: number; maxLabels: number }
208
+
209
+ /** The bounds each chart component's `data` is checked against. */
210
+ export const CHART_DATA_LIMITS = {
211
+ BarChart: { minItems: 1, maxItems: 31 },
212
+ HorizontalBarChart: { minItems: 1, maxItems: 25 },
213
+ StackedBarChart: { minLabels: 1, maxLabels: 31, maxSeries: MAX_CHART_SERIES },
214
+ LineChart: { minLabels: 2, maxLabels: 60, maxSeries: MAX_CHART_SERIES },
215
+ AreaChart: { minLabels: 2, maxLabels: 60, maxSeries: MAX_CHART_SERIES },
216
+ ComboChart: { minLabels: 1, maxLabels: 31 },
217
+ // The host folds everything past the sixth slice into one "Other" slice.
218
+ PieChart: { minItems: 1, maxItems: 50 },
219
+ } as const satisfies Record<string, ChartDataLimits | SeriesChartDataLimits | ComboChartDataLimits>;
220
+
221
+ // Plain data only, as JSON and remote-dom carry it: no class instances (Date, Map, a subclassed
222
+ // Array) whose prototype the host would otherwise have to trust.
223
+ const isPlainObject = (v: unknown): v is Record<string, unknown> => {
224
+ if (typeof v !== "object" || v === null || Array.isArray(v)) return false;
225
+ const proto: unknown = Object.getPrototypeOf(v);
226
+ return proto === Object.prototype || proto === null;
227
+ };
228
+ const isPlainArray = (v: unknown): v is unknown[] => Array.isArray(v) && Object.getPrototypeOf(v) === Array.prototype;
229
+ // Exactly these keys: the shapes are closed, which also bounds their nesting depth.
230
+ const hasExactKeys = (v: Record<string, unknown>, keys: readonly string[]): boolean => {
231
+ const own = Object.keys(v);
232
+ return own.length === keys.length && keys.every((k) => Object.prototype.hasOwnProperty.call(v, k));
233
+ };
234
+ const isChartValue = (v: unknown): v is number =>
235
+ typeof v === "number" && Number.isFinite(v) && v >= 0 && v <= MAX_CHART_VALUE;
236
+ const isChartLabel = (v: unknown): v is string => isVisibleText(v, MAX_CHART_LABEL_LENGTH);
237
+
238
+ // Indexed loops rather than `every`, which skips the holes of a sparse array.
239
+ function isLabelList(v: unknown, min: number, max: number): v is string[] {
240
+ if (!isPlainArray(v) || v.length < min || v.length > max) return false;
241
+ const seen = new Set<string>();
242
+ for (let i = 0; i < v.length; i++) {
243
+ const label = v[i];
244
+ if (!isChartLabel(label) || seen.has(label)) return false;
245
+ seen.add(label);
246
+ }
247
+ return true;
248
+ }
249
+
250
+ function isChartSeries(v: unknown, length: number): v is ChartSeries {
251
+ if (!isPlainObject(v) || !hasExactKeys(v, ["name", "values"]) || !isChartLabel(v["name"])) return false;
252
+ const values = v["values"];
253
+ if (!isPlainArray(values) || values.length !== length) return false;
254
+ for (let i = 0; i < values.length; i++) if (!isChartValue(values[i])) return false;
255
+ return true;
256
+ }
257
+
258
+ /**
259
+ * `ChartDatum[]` with between `minItems` and `maxItems` items, each a visible, unique label of at
260
+ * most {@link MAX_CHART_LABEL_LENGTH} characters and a value from 0 to {@link MAX_CHART_VALUE}.
261
+ */
262
+ export function isChartData(value: unknown, limits: ChartDataLimits): value is ChartDatum[] {
263
+ if (!isPlainArray(value) || value.length < limits.minItems || value.length > limits.maxItems) return false;
264
+ const seen = new Set<string>();
265
+ for (let i = 0; i < value.length; i++) {
266
+ const item = value[i];
267
+ if (!isPlainObject(item) || !hasExactKeys(item, ["label", "value"])) return false;
268
+ const label = item["label"];
269
+ if (!isChartLabel(label) || seen.has(label) || !isChartValue(item["value"])) return false;
270
+ seen.add(label);
271
+ }
272
+ return true;
273
+ }
274
+
275
+ /** Label of the slice a pie chart creates when it combines items beyond {@link MAX_CHART_SERIES}. */
276
+ export const CHART_OTHER_LABEL = "Other";
277
+
278
+ /**
279
+ * Pie chart data: {@link isChartData} with `CHART_DATA_LIMITS.PieChart`. When there are more
280
+ * items than {@link MAX_CHART_SERIES}, the host combines the extras into a {@link CHART_OTHER_LABEL}
281
+ * slice, so a caller's own `Other` label (any case, ignoring surrounding spaces) is rejected:
282
+ * the chart would show two slices with that name and their click events could not be told apart.
283
+ */
284
+ export function isPieChartData(value: unknown): value is ChartDatum[] {
285
+ if (!isChartData(value, CHART_DATA_LIMITS.PieChart)) return false;
286
+ if (value.length <= MAX_CHART_SERIES) return true;
287
+ const reserved = CHART_OTHER_LABEL.toLowerCase();
288
+ return value.every((item) => item.label.trim().toLowerCase() !== reserved);
289
+ }
290
+
291
+ /**
292
+ * `SeriesChartData` with between `minLabels` and `maxLabels` unique labels and between 1 and
293
+ * `maxSeries` uniquely named series, each with exactly one value from 0 to {@link MAX_CHART_VALUE} per label.
294
+ */
295
+ export function isSeriesChartData(value: unknown, limits: SeriesChartDataLimits): value is SeriesChartData {
296
+ if (!isPlainObject(value) || !hasExactKeys(value, ["labels", "series"])) return false;
297
+ const labels = value["labels"];
298
+ const series = value["series"];
299
+ const maxSeries = limits.maxSeries ?? MAX_CHART_SERIES;
300
+ if (!isLabelList(labels, limits.minLabels, limits.maxLabels)) return false;
301
+ if (!isPlainArray(series) || series.length < 1 || series.length > maxSeries) return false;
302
+ const names = new Set<string>();
303
+ for (let i = 0; i < series.length; i++) {
304
+ const s = series[i];
305
+ if (!isChartSeries(s, labels.length) || names.has(s.name)) return false;
306
+ names.add(s.name);
307
+ }
308
+ return true;
309
+ }
310
+
311
+ /**
312
+ * `ComboChartData` with between `minLabels` and `maxLabels` unique labels, and a `bars` and a
313
+ * `line` series with different names, each with exactly one value from 0 to {@link MAX_CHART_VALUE} per label.
314
+ */
315
+ export function isComboChartData(value: unknown, limits: ComboChartDataLimits): value is ComboChartData {
316
+ if (!isPlainObject(value) || !hasExactKeys(value, ["labels", "bars", "line"])) return false;
317
+ const labels = value["labels"];
318
+ const bars = value["bars"];
319
+ const line = value["line"];
320
+ return isLabelList(labels, limits.minLabels, limits.maxLabels) &&
321
+ isChartSeries(bars, labels.length) && isChartSeries(line, labels.length) && bars.name !== line.name;
322
+ }
323
+
324
+ const chart = <T>(check: (v: unknown) => v is T) => ({
325
+ props: { data: p.json(check, { required: true }), theme: p.enum(CHART_THEMES), interactive: p.boolean() },
326
+ events: { onClick: ev<ChartClickEvent>("click") },
327
+ children: [] as const,
328
+ });
329
+
166
330
  // ---------------------------------------------------------------------------------------------
167
331
  // Catalog
168
332
 
169
333
  export const COMPONENTS = {
170
- // View roots. An app's view component must render exactly one of these at its top level.
334
+ // View roots. An app's view component must render exactly one of these at its top level:
335
+ // ContextView on a surface, SettingsView on the settings page, PageView on an app page.
171
336
  ContextView: spec("ui-context-view", {
172
337
  root: true,
173
338
  props: { title: p.string({ required: true, maxLength: 100 }), description: p.string({ maxLength: 300 }), externalLink: p.json(isExternalLink) },
@@ -190,6 +355,13 @@ export const COMPONENTS = {
190
355
  // `values` maps every form field's `name` to its current value.
191
356
  events: { onSave: ev<Record<string, string>>("save") },
192
357
  }),
358
+ // An app page (`ui_extensions.routes`), rendered full width inside the portal's own chrome. The
359
+ // title is the page header; `actions` join the portal header's actions, `banner` sits above the body.
360
+ PageView: spec("ui-page-view", {
361
+ root: true,
362
+ props: { title: p.string({ required: true, maxLength: 100 }), description: p.string({ maxLength: 300 }) },
363
+ slots: ["actions", "banner"] as const,
364
+ }),
193
365
 
194
366
  // Layout
195
367
  Box: spec("ui-box", { props: { ...layout, ...typography }, text: true }),
@@ -368,6 +540,16 @@ export const COMPONENTS = {
368
540
  FormFieldGroup: spec("ui-form-field-group", {
369
541
  props: { legend: p.string({ maxLength: 200 }), description: p.string({ maxLength: 500 }), layout: p.enum(["row", "column"] as const) },
370
542
  }),
543
+
544
+ // Charts. Data only: `theme` picks the palette, `interactive` turns on press feedback and
545
+ // tooltips, and `onClick` receives a `ChartClickEvent`. Bounds are in `CHART_DATA_LIMITS`.
546
+ BarChart: spec("ui-bar-chart", chart((v): v is ChartDatum[] => isChartData(v, CHART_DATA_LIMITS.BarChart))),
547
+ HorizontalBarChart: spec("ui-horizontal-bar-chart", chart((v): v is ChartDatum[] => isChartData(v, CHART_DATA_LIMITS.HorizontalBarChart))),
548
+ StackedBarChart: spec("ui-stacked-bar-chart", chart((v): v is SeriesChartData => isSeriesChartData(v, CHART_DATA_LIMITS.StackedBarChart))),
549
+ LineChart: spec("ui-line-chart", chart((v): v is SeriesChartData => isSeriesChartData(v, CHART_DATA_LIMITS.LineChart))),
550
+ AreaChart: spec("ui-area-chart", chart((v): v is SeriesChartData => isSeriesChartData(v, CHART_DATA_LIMITS.AreaChart))),
551
+ ComboChart: spec("ui-combo-chart", chart((v): v is ComboChartData => isComboChartData(v, CHART_DATA_LIMITS.ComboChart))),
552
+ PieChart: spec("ui-pie-chart", chart(isPieChartData)),
371
553
  } as const;
372
554
 
373
555
  // Checked by assignment rather than `satisfies`: `satisfies` would hand `ComponentSpec` to every