@cosmicdrift/kumiko-renderer-web 0.48.0 → 0.50.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "0.48.0",
3
+ "version": "0.50.0",
4
4
  "description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -63,7 +63,7 @@ export function createBrowserLocaleResolver(
63
63
 
64
64
  return {
65
65
  translate: (key) => key,
66
- locale: () => store.getSnapshot(),
66
+ locale: () => store.getSnapshot(), // @wrapper-known semantic-alias
67
67
  timeZone: () => timeZone,
68
68
  subscribe: store.subscribe,
69
69
  setLocale: (next) => {
@@ -0,0 +1,170 @@
1
+ // End-to-end Settings-Hub visibility on the REAL boot path:
2
+ // createRegistry → buildAppSchema → buildNavRegistrySliceForApp → resolveNavigation.
3
+ // These are the actual functions the shell runs at boot; no stubs. Proves a
4
+ // config key declared with `mask` surfaces as a role-filtered nav tree that
5
+ // only shows inside its own synthetic "settings" workspace.
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import {
9
+ access,
10
+ buildAppSchema,
11
+ createRegistry,
12
+ createSystemConfig,
13
+ createTenantConfig,
14
+ createUserConfig,
15
+ defineFeature,
16
+ } from "@cosmicdrift/kumiko-framework/engine";
17
+ import { resolveNavigation } from "@cosmicdrift/kumiko-headless";
18
+ import { qualifyScreenId } from "@cosmicdrift/kumiko-renderer";
19
+ import { buildNavRegistrySliceForApp } from "../nav-tree";
20
+
21
+ const billing = defineFeature("billing", (r) => {
22
+ r.config({
23
+ keys: {
24
+ stripeKey: createTenantConfig("text", { mask: { title: "billing.stripe-key" } }),
25
+ // system-scope default write = ["system"] (internal actor) → human-hidden
26
+ platformFee: createSystemConfig("number", { mask: { title: "billing.platform-fee" } }),
27
+ },
28
+ });
29
+ });
30
+ const notify = defineFeature("notify", (r) => {
31
+ r.config({
32
+ keys: { digest: createUserConfig("boolean", { mask: { title: "notify.digest" } }) },
33
+ });
34
+ });
35
+ // A system-scope key that explicitly opts a HUMAN role into write → its
36
+ // settings entry becomes visible to that admin (the opt-in path).
37
+ const ops = defineFeature("ops", (r) => {
38
+ r.config({
39
+ keys: {
40
+ maintenanceMode: createSystemConfig("boolean", {
41
+ write: access.systemAdmin,
42
+ mask: { title: "ops.maintenance" },
43
+ }),
44
+ },
45
+ });
46
+ });
47
+ // A real work-workspace, so the app is in workspace (filter) mode.
48
+ const shell = defineFeature("shell", (r) => {
49
+ r.entity("thing", { fields: { label: { type: "text" } } });
50
+ r.screen({ id: "home", type: "entityList", entity: "thing", columns: ["label"] });
51
+ r.nav({ id: "home", label: "Home", screen: "home" });
52
+ r.workspace({ id: "main", label: "Main", nav: ["shell:nav:home"] });
53
+ });
54
+
55
+ const app = buildAppSchema(createRegistry([shell, billing, notify, ops]));
56
+
57
+ function navMembersOf(workspaceId: string): ReadonlySet<string> {
58
+ const ws = app.workspaces?.find((w) => w.definition.id === workspaceId);
59
+ if (ws === undefined) throw new Error(`no workspace "${workspaceId}"`);
60
+ return new Set(ws.navMembers);
61
+ }
62
+
63
+ function qualifiedNames(tree: ReturnType<typeof resolveNavigation>): string[] {
64
+ const out: string[] = [];
65
+ const walk = (nodes: ReturnType<typeof resolveNavigation>): void => {
66
+ for (const n of nodes) {
67
+ out.push(n.qualifiedName);
68
+ walk(n.children);
69
+ }
70
+ };
71
+ walk(tree);
72
+ return out;
73
+ }
74
+
75
+ // Mirrors the exact screen lookup KumikoScreen runs (kumiko-screen.tsx:102) —
76
+ // the REAL qualifyScreenId against the FeatureSchema's short screen ids. Proves
77
+ // a nav's screen-QN resolves to a renderable definition, not just that the ref
78
+ // string matches a convention.
79
+ function resolveScreenDef(qn: string) {
80
+ for (const f of app.features) {
81
+ const hit = f.screens.find((s) => qualifyScreenId(f.featureName, s.id) === qn);
82
+ if (hit !== undefined) return hit;
83
+ }
84
+ return undefined;
85
+ }
86
+
87
+ function screenRefsIn(tree: ReturnType<typeof resolveNavigation>): string[] {
88
+ const out: string[] = [];
89
+ const walk = (nodes: ReturnType<typeof resolveNavigation>): void => {
90
+ for (const n of nodes) {
91
+ if (n.screen !== undefined) out.push(n.screen);
92
+ walk(n.children);
93
+ }
94
+ };
95
+ walk(tree);
96
+ return out;
97
+ }
98
+
99
+ describe("Settings-Hub visibility — full boot pipeline", () => {
100
+ test("privileged user sees the human-relevant hub; system-internal keys stay hidden", () => {
101
+ const slice = buildNavRegistrySliceForApp(app, navMembersOf("settings"));
102
+ const tree = resolveNavigation({
103
+ source: slice,
104
+ user: { id: "u-1", roles: ["TenantAdmin", "SystemAdmin"] },
105
+ });
106
+ const names = qualifiedNames(tree);
107
+
108
+ expect(names).toContain("config:nav:audience-tenant");
109
+ expect(names).toContain("config:nav:audience-user");
110
+ // system audience surfaces ONLY because `ops` opted a human (SystemAdmin)
111
+ // into write; its child ops-system shows, billing's system-internal key
112
+ // (write ["system"]) stays hidden from the same admin.
113
+ expect(names).toContain("config:nav:audience-system");
114
+ expect(names).toContain("config:nav:ops-system");
115
+ expect(names).not.toContain("config:nav:billing-system");
116
+
117
+ // schema-level completeness: the hidden key IS generated (a system actor
118
+ // could resolve it) — it's gated at resolve-time, not omitted at build-time.
119
+ expect(navMembersOf("settings").has("config:nav:billing-system")).toBe(true);
120
+
121
+ // hierarchy: the billing-tenant screen hangs under the tenant audience
122
+ const tenantAudience = tree.find((n) => n.qualifiedName === "config:nav:audience-tenant");
123
+ expect(tenantAudience?.children.map((c) => c.qualifiedName)).toContain(
124
+ "config:nav:billing-tenant",
125
+ );
126
+ expect(tenantAudience?.children[0]?.screen).toBe("config:screen:billing-tenant");
127
+ });
128
+
129
+ test("anonymous user sees only the openToAll (user-scope) audience", () => {
130
+ const slice = buildNavRegistrySliceForApp(app, navMembersOf("settings"));
131
+ const tree = resolveNavigation({ source: slice }); // no user
132
+ const names = qualifiedNames(tree);
133
+
134
+ // digest is createUserConfig → write `all` → openToAll → visible to anyone
135
+ expect(names).toContain("config:nav:audience-user");
136
+ // admin/system audiences are role-gated → hidden from anonymous
137
+ expect(names).not.toContain("config:nav:audience-tenant");
138
+ expect(names).not.toContain("config:nav:audience-system");
139
+ });
140
+
141
+ test("every visible leaf nav resolves to a real configEdit screen (nav → screen → definition)", () => {
142
+ const slice = buildNavRegistrySliceForApp(app, navMembersOf("settings"));
143
+ const tree = resolveNavigation({
144
+ source: slice,
145
+ user: { id: "u-1", roles: ["TenantAdmin", "SystemAdmin"] },
146
+ });
147
+ const refs = screenRefsIn(tree);
148
+ expect(refs.length).toBeGreaterThan(0); // audiences have no screen; children do
149
+
150
+ // the short screen ids in the config FeatureSchema must qualify back to the
151
+ // exact QN the nav carries — qualifyScreenId is NOT idempotent, so a mismatch
152
+ // would render "Screen not found" on every settings click.
153
+ for (const qn of refs) {
154
+ expect(resolveScreenDef(qn)?.type).toBe("configEdit");
155
+ }
156
+ expect(resolveScreenDef("config:screen:billing-tenant")?.type).toBe("configEdit");
157
+ });
158
+
159
+ test("the work workspace does NOT leak the settings hub", () => {
160
+ const slice = buildNavRegistrySliceForApp(app, navMembersOf("main"));
161
+ const tree = resolveNavigation({
162
+ source: slice,
163
+ user: { id: "u-1", roles: ["TenantAdmin", "SystemAdmin"] },
164
+ });
165
+ const names = qualifiedNames(tree);
166
+
167
+ expect(names).toContain("shell:nav:home");
168
+ expect(names.some((n) => n.startsWith("config:nav:"))).toBe(false);
169
+ });
170
+ });
package/src/styles.css CHANGED
@@ -19,12 +19,17 @@
19
19
  Defaults bleiben rdp's eigenes Stylesheet. */
20
20
  @import "react-day-picker/style.css";
21
21
 
22
- /* Tailwind v4 scannt per default vom Import-Ort aus; da das CSS
23
- aus einem temp-dir kompiliert wird, findet es sonst keine
24
- Source-Dateien. Explizite @source-Direktiven decken alle
25
- Workspace-Sources ab der CLI scannt dann unsere Primitives,
26
- Layout-Components UND die Sample-App. */
27
- @source "../../renderer-web/src/**/*.{ts,tsx}";
22
+ /* Tailwind v4 scannt per default vom Import-Ort aus; da das CSS aus
23
+ einem temp-dir kompiliert wird, findet es sonst keine Source-Dateien.
24
+ Die erste Zeile ist self-relativ (`./` = dieses src/) und MUSS es
25
+ bleiben: das Paket shippt `src`, also löst sie in jedem Install-Layout
26
+ auf — Workspace UND Standalone-Consumer (node_modules). Ein monorepo-
27
+ relativer Pfad fand im Standalone-Build die eigenen Shell-Klassen
28
+ nicht → unstyled prod, 15KB statt 48KB (#359). Die restlichen Zeilen
29
+ sind Monorepo-Build-Quellen (renderer hat keine eigenen Klassen,
30
+ samples nur für den In-Monorepo-Sample-Build) — im Consumer matchen
31
+ sie nichts und sind dort ein harmloser No-op. */
32
+ @source "./**/*.{ts,tsx}";
28
33
  @source "../../renderer/src/**/*.{ts,tsx}";
29
34
  @source "../../../samples/**/src/**/*.{ts,tsx}";
30
35
 
package/src/tokens.ts CHANGED
@@ -35,10 +35,12 @@ function readCurrentMode(): ThemeMode {
35
35
  return document.documentElement.classList.contains("dark") ? "dark" : "light";
36
36
  }
37
37
 
38
+ // @wrapper-known semantic-alias
38
39
  function notifyThemeChange(): void {
39
40
  themeTick.setState((t) => t + 1);
40
41
  }
41
42
 
43
+ // @wrapper-known semantic-alias
42
44
  function persistMode(mode: ThemeMode): void {
43
45
  try {
44
46
  window.localStorage.setItem(THEME_STORAGE_KEY, mode);