@cosmicdrift/kumiko-renderer-web 0.201.0 → 0.202.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.
|
|
3
|
+
"version": "0.202.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>",
|
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
"./styles.css": "./src/styles.css"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
20
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
21
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
19
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.202.0",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.202.0",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.202.0",
|
|
22
22
|
"@radix-ui/react-dialog": "^1.1.15",
|
|
23
23
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
24
24
|
"@radix-ui/react-label": "^2.1.8",
|
|
@@ -576,10 +576,12 @@ describe("createKumikoApp", () => {
|
|
|
576
576
|
route: { screenId: "task-list" },
|
|
577
577
|
navigate: () => {},
|
|
578
578
|
replace: () => {},
|
|
579
|
-
hrefFor: (target) =>
|
|
580
|
-
target
|
|
579
|
+
hrefFor: (target) => {
|
|
580
|
+
if (!("screenId" in target)) return "";
|
|
581
|
+
return target.entityId !== undefined
|
|
581
582
|
? `/${target.screenId}/${target.entityId}`
|
|
582
|
-
: `/${target.screenId}
|
|
583
|
+
: `/${target.screenId}`;
|
|
584
|
+
},
|
|
583
585
|
searchParams: {},
|
|
584
586
|
setSearchParams: () => {},
|
|
585
587
|
};
|
|
@@ -6,7 +6,7 @@ import type {
|
|
|
6
6
|
EntityListScreenDefinition,
|
|
7
7
|
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
8
8
|
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
9
|
-
import type { FeatureSchema, NavApi } from "@cosmicdrift/kumiko-renderer";
|
|
9
|
+
import type { FeatureSchema, NavApi, NavTarget } from "@cosmicdrift/kumiko-renderer";
|
|
10
10
|
import {
|
|
11
11
|
DispatcherProvider,
|
|
12
12
|
ExtensionSectionsProvider,
|
|
@@ -812,12 +812,12 @@ describe("KumikoScreen", () => {
|
|
|
812
812
|
const searchParamUpdates: Record<string, string | null>[] = [];
|
|
813
813
|
const memoryNav = {
|
|
814
814
|
route: { screenId: "task-list" },
|
|
815
|
-
navigate: (target:
|
|
815
|
+
navigate: (target: NavTarget) => {
|
|
816
816
|
calls.push({ kind: "navigate", value: target });
|
|
817
|
-
navigateCalls.push(target);
|
|
817
|
+
if ("screenId" in target) navigateCalls.push(target);
|
|
818
818
|
},
|
|
819
819
|
replace: () => undefined,
|
|
820
|
-
hrefFor: (t:
|
|
820
|
+
hrefFor: (t: NavTarget) => ("screenId" in t ? `/${t.screenId}` : ""),
|
|
821
821
|
searchParams: {},
|
|
822
822
|
setSearchParams: (u: Record<string, string | null>) => {
|
|
823
823
|
calls.push({ kind: "setSearchParams", value: u });
|
|
@@ -941,9 +941,11 @@ describe("KumikoScreen", () => {
|
|
|
941
941
|
const searchParamUpdates: Record<string, string | null>[] = [];
|
|
942
942
|
const memoryNav = {
|
|
943
943
|
route: { screenId: "task-list" },
|
|
944
|
-
navigate: (target:
|
|
944
|
+
navigate: (target: NavTarget) => {
|
|
945
|
+
if ("screenId" in target) navigateCalls.push(target);
|
|
946
|
+
},
|
|
945
947
|
replace: () => undefined,
|
|
946
|
-
hrefFor: (t:
|
|
948
|
+
hrefFor: (t: NavTarget) => ("screenId" in t ? `/${t.screenId}` : ""),
|
|
947
949
|
searchParams: {},
|
|
948
950
|
setSearchParams: (u: Record<string, string | null>) => searchParamUpdates.push(u),
|
|
949
951
|
};
|
|
@@ -1000,9 +1002,11 @@ describe("KumikoScreen", () => {
|
|
|
1000
1002
|
const navigateCalls: { screenId: string; entityId?: string }[] = [];
|
|
1001
1003
|
const memoryNav = {
|
|
1002
1004
|
route: { screenId: "task-list" },
|
|
1003
|
-
navigate: (target:
|
|
1005
|
+
navigate: (target: NavTarget) => {
|
|
1006
|
+
if ("screenId" in target) navigateCalls.push(target);
|
|
1007
|
+
},
|
|
1004
1008
|
replace: () => undefined,
|
|
1005
|
-
hrefFor: (t:
|
|
1009
|
+
hrefFor: (t: NavTarget) => ("screenId" in t ? `/${t.screenId}` : ""),
|
|
1006
1010
|
searchParams: {},
|
|
1007
1011
|
setSearchParams: () => undefined,
|
|
1008
1012
|
};
|
|
@@ -1055,9 +1059,11 @@ describe("KumikoScreen", () => {
|
|
|
1055
1059
|
const navigateCalls: { screenId: string; entityId?: string }[] = [];
|
|
1056
1060
|
const memoryNav = {
|
|
1057
1061
|
route: { screenId: "task-list" },
|
|
1058
|
-
navigate: (target:
|
|
1062
|
+
navigate: (target: NavTarget) => {
|
|
1063
|
+
if ("screenId" in target) navigateCalls.push(target);
|
|
1064
|
+
},
|
|
1059
1065
|
replace: () => undefined,
|
|
1060
|
-
hrefFor: (t:
|
|
1066
|
+
hrefFor: (t: NavTarget) => ("screenId" in t ? `/${t.screenId}` : ""),
|
|
1061
1067
|
searchParams: {},
|
|
1062
1068
|
setSearchParams: () => undefined,
|
|
1063
1069
|
};
|
|
@@ -1114,9 +1120,11 @@ describe("KumikoScreen", () => {
|
|
|
1114
1120
|
const searchParamUpdates: Record<string, string | null>[] = [];
|
|
1115
1121
|
const memoryNav = {
|
|
1116
1122
|
route: { screenId: "task-list" },
|
|
1117
|
-
navigate: (target:
|
|
1123
|
+
navigate: (target: NavTarget) => {
|
|
1124
|
+
if ("screenId" in target) navigateCalls.push(target);
|
|
1125
|
+
},
|
|
1118
1126
|
replace: () => undefined,
|
|
1119
|
-
hrefFor: (t:
|
|
1127
|
+
hrefFor: (t: NavTarget) => ("screenId" in t ? `/${t.screenId}` : ""),
|
|
1120
1128
|
searchParams: {},
|
|
1121
1129
|
setSearchParams: (u: Record<string, string | null>) => searchParamUpdates.push(u),
|
|
1122
1130
|
};
|
|
@@ -1170,9 +1178,11 @@ describe("KumikoScreen", () => {
|
|
|
1170
1178
|
});
|
|
1171
1179
|
const memoryNav = {
|
|
1172
1180
|
route: { screenId: "task-list" },
|
|
1173
|
-
navigate: (target:
|
|
1181
|
+
navigate: (target: NavTarget) => {
|
|
1182
|
+
if ("screenId" in target) navigateCalls.push(target);
|
|
1183
|
+
},
|
|
1174
1184
|
replace: () => undefined,
|
|
1175
|
-
hrefFor: (t:
|
|
1185
|
+
hrefFor: (t: NavTarget) => ("screenId" in t ? `/${t.screenId}` : ""),
|
|
1176
1186
|
searchParams: {},
|
|
1177
1187
|
setSearchParams: () => undefined,
|
|
1178
1188
|
};
|
|
@@ -1306,7 +1316,7 @@ describe("KumikoScreen", () => {
|
|
|
1306
1316
|
route: undefined,
|
|
1307
1317
|
navigate: () => {},
|
|
1308
1318
|
replace: () => {},
|
|
1309
|
-
hrefFor: (t) => `/${t.screenId}
|
|
1319
|
+
hrefFor: (t) => ("screenId" in t ? `/${t.screenId}` : ""),
|
|
1310
1320
|
searchParams: { "task-list.f.id": "r1,r2" },
|
|
1311
1321
|
setSearchParams: () => {},
|
|
1312
1322
|
};
|
|
@@ -1448,9 +1458,11 @@ describe("KumikoScreen", () => {
|
|
|
1448
1458
|
});
|
|
1449
1459
|
const memoryNav = {
|
|
1450
1460
|
route: { screenId: "quick-add" },
|
|
1451
|
-
navigate: (target:
|
|
1461
|
+
navigate: (target: NavTarget) => {
|
|
1462
|
+
if ("screenId" in target) navigateCalls.push(target);
|
|
1463
|
+
},
|
|
1452
1464
|
replace: () => undefined,
|
|
1453
|
-
hrefFor: (t:
|
|
1465
|
+
hrefFor: (t: NavTarget) => ("screenId" in t ? `/${t.screenId}` : ""),
|
|
1454
1466
|
searchParams: {},
|
|
1455
1467
|
setSearchParams: () => undefined,
|
|
1456
1468
|
};
|
|
@@ -1492,9 +1504,11 @@ describe("KumikoScreen", () => {
|
|
|
1492
1504
|
});
|
|
1493
1505
|
const memoryNav = {
|
|
1494
1506
|
route: { screenId: "quick-add" },
|
|
1495
|
-
navigate: (target:
|
|
1507
|
+
navigate: (target: NavTarget) => {
|
|
1508
|
+
if ("screenId" in target) navigateCalls.push(target);
|
|
1509
|
+
},
|
|
1496
1510
|
replace: () => undefined,
|
|
1497
|
-
hrefFor: (t:
|
|
1511
|
+
hrefFor: (t: NavTarget) => ("screenId" in t ? `/${t.screenId}` : ""),
|
|
1498
1512
|
searchParams: {},
|
|
1499
1513
|
setSearchParams: () => undefined,
|
|
1500
1514
|
};
|
|
@@ -1576,9 +1590,11 @@ describe("KumikoScreen", () => {
|
|
|
1576
1590
|
const navigateCalls: { screenId: string }[] = [];
|
|
1577
1591
|
const memoryNav = {
|
|
1578
1592
|
route: { screenId: "quick-add" },
|
|
1579
|
-
navigate: (target:
|
|
1593
|
+
navigate: (target: NavTarget) => {
|
|
1594
|
+
if ("screenId" in target) navigateCalls.push(target);
|
|
1595
|
+
},
|
|
1580
1596
|
replace: () => undefined,
|
|
1581
|
-
hrefFor: (t:
|
|
1597
|
+
hrefFor: (t: NavTarget) => ("screenId" in t ? `/${t.screenId}` : ""),
|
|
1582
1598
|
searchParams: {},
|
|
1583
1599
|
setSearchParams: () => undefined,
|
|
1584
1600
|
};
|
|
@@ -1609,9 +1625,11 @@ describe("KumikoScreen", () => {
|
|
|
1609
1625
|
const navigateCalls: { screenId: string }[] = [];
|
|
1610
1626
|
const memoryNav = {
|
|
1611
1627
|
route: { screenId: "quick-add" },
|
|
1612
|
-
navigate: (target:
|
|
1628
|
+
navigate: (target: NavTarget) => {
|
|
1629
|
+
if ("screenId" in target) navigateCalls.push(target);
|
|
1630
|
+
},
|
|
1613
1631
|
replace: () => undefined,
|
|
1614
|
-
hrefFor: (t:
|
|
1632
|
+
hrefFor: (t: NavTarget) => ("screenId" in t ? `/${t.screenId}` : ""),
|
|
1615
1633
|
searchParams: {},
|
|
1616
1634
|
setSearchParams: () => undefined,
|
|
1617
1635
|
};
|
|
@@ -1747,9 +1765,11 @@ describe("KumikoScreen", () => {
|
|
|
1747
1765
|
});
|
|
1748
1766
|
const memoryNav = {
|
|
1749
1767
|
route: { screenId: "quick-add" },
|
|
1750
|
-
navigate: (target:
|
|
1768
|
+
navigate: (target: NavTarget) => {
|
|
1769
|
+
if ("screenId" in target) navigateCalls.push(target);
|
|
1770
|
+
},
|
|
1751
1771
|
replace: () => undefined,
|
|
1752
|
-
hrefFor: (t:
|
|
1772
|
+
hrefFor: (t: NavTarget) => ("screenId" in t ? `/${t.screenId}` : ""),
|
|
1753
1773
|
searchParams: {},
|
|
1754
1774
|
setSearchParams: () => undefined,
|
|
1755
1775
|
};
|
|
@@ -1825,9 +1845,11 @@ describe("KumikoScreen", () => {
|
|
|
1825
1845
|
const navigateCalls: { screenId: string }[] = [];
|
|
1826
1846
|
const memoryNav = {
|
|
1827
1847
|
route: { screenId: "task-list" },
|
|
1828
|
-
navigate: (target:
|
|
1848
|
+
navigate: (target: NavTarget) => {
|
|
1849
|
+
if ("screenId" in target) navigateCalls.push(target);
|
|
1850
|
+
},
|
|
1829
1851
|
replace: () => undefined,
|
|
1830
|
-
hrefFor: (t:
|
|
1852
|
+
hrefFor: (t: NavTarget) => ("screenId" in t ? `/${t.screenId}` : ""),
|
|
1831
1853
|
searchParams: {},
|
|
1832
1854
|
setSearchParams: () => undefined,
|
|
1833
1855
|
};
|
|
@@ -1864,9 +1886,11 @@ describe("KumikoScreen", () => {
|
|
|
1864
1886
|
const navigateCalls: { screenId: string }[] = [];
|
|
1865
1887
|
const memoryNav = {
|
|
1866
1888
|
route: { screenId: "task-list" },
|
|
1867
|
-
navigate: (target:
|
|
1889
|
+
navigate: (target: NavTarget) => {
|
|
1890
|
+
if ("screenId" in target) navigateCalls.push(target);
|
|
1891
|
+
},
|
|
1868
1892
|
replace: () => undefined,
|
|
1869
|
-
hrefFor: (t:
|
|
1893
|
+
hrefFor: (t: NavTarget) => ("screenId" in t ? `/${t.screenId}` : ""),
|
|
1870
1894
|
searchParams: {},
|
|
1871
1895
|
setSearchParams: () => undefined,
|
|
1872
1896
|
};
|
|
@@ -2209,7 +2233,7 @@ describe("KumikoScreen: actionForm extension-section", () => {
|
|
|
2209
2233
|
route: { screenId: "post-update" },
|
|
2210
2234
|
navigate: () => undefined,
|
|
2211
2235
|
replace: () => undefined,
|
|
2212
|
-
hrefFor: (t:
|
|
2236
|
+
hrefFor: (t: NavTarget) => ("screenId" in t ? `/${t.screenId}` : ""),
|
|
2213
2237
|
searchParams: { incidentId: "inc-7" },
|
|
2214
2238
|
setSearchParams: () => undefined,
|
|
2215
2239
|
};
|
|
@@ -53,10 +53,12 @@ const stubNav: NavApi = {
|
|
|
53
53
|
route: undefined,
|
|
54
54
|
navigate: () => {},
|
|
55
55
|
replace: () => {},
|
|
56
|
-
hrefFor: (target) =>
|
|
57
|
-
target
|
|
56
|
+
hrefFor: (target) => {
|
|
57
|
+
if (!("screenId" in target)) return "";
|
|
58
|
+
return target.entityId !== undefined
|
|
58
59
|
? `/${target.screenId}/${target.entityId}`
|
|
59
|
-
: `/${target.screenId}
|
|
60
|
+
: `/${target.screenId}`;
|
|
61
|
+
},
|
|
60
62
|
searchParams: {},
|
|
61
63
|
setSearchParams: () => {},
|
|
62
64
|
};
|
package/src/app/create-app.tsx
CHANGED
|
@@ -177,8 +177,14 @@ export type CreateKumikoAppOptions = {
|
|
|
177
177
|
* `{ hasWorkspaces: true }` aufgerufen — der Default-Hook nutzt das
|
|
178
178
|
* um das URL-Pattern auf `/<workspace>/<screen>[/<entityId>]`
|
|
179
179
|
* umzustellen. Eigene Adapter dürfen die Option ignorieren wenn ihr
|
|
180
|
-
* Router das anders löst.
|
|
181
|
-
|
|
180
|
+
* Router das anders löst. `features` ist `app.features` — der
|
|
181
|
+
* Default-Hook braucht sie für resolveTarget() (ObjectTarget →
|
|
182
|
+
* ScreenTarget); eigene Adapter die nie ein ObjectTarget bekommen,
|
|
183
|
+
* dürfen sie ignorieren. */
|
|
184
|
+
readonly navAdapter?: (options?: {
|
|
185
|
+
readonly hasWorkspaces?: boolean;
|
|
186
|
+
readonly features?: readonly FeatureSchema[];
|
|
187
|
+
}) => NavApi;
|
|
182
188
|
};
|
|
183
189
|
|
|
184
190
|
// Reads the dev-server-injected schema from the global. Guarded for
|
|
@@ -474,7 +480,10 @@ function BrowserNavBoot({
|
|
|
474
480
|
}: {
|
|
475
481
|
readonly app: AppSchema;
|
|
476
482
|
readonly fallbackQn: string;
|
|
477
|
-
readonly useNavApi: (options?: {
|
|
483
|
+
readonly useNavApi: (options?: {
|
|
484
|
+
readonly hasWorkspaces?: boolean;
|
|
485
|
+
readonly features?: readonly FeatureSchema[];
|
|
486
|
+
}) => NavApi;
|
|
478
487
|
readonly hasWorkspaces: boolean;
|
|
479
488
|
readonly translate?: Translate;
|
|
480
489
|
readonly onRowClick?: (row: ListRowViewModel, entityName: string) => void;
|
|
@@ -483,7 +492,7 @@ function BrowserNavBoot({
|
|
|
483
492
|
readonly schema: AppSchema;
|
|
484
493
|
}) => ReactNode;
|
|
485
494
|
}): ReactNode {
|
|
486
|
-
const navApi = useNavApi({ hasWorkspaces });
|
|
495
|
+
const navApi = useNavApi({ hasWorkspaces, features: app.features });
|
|
487
496
|
const Shell = shell;
|
|
488
497
|
const screen = (
|
|
489
498
|
<RoutedScreen
|
package/src/app/nav.tsx
CHANGED
|
@@ -11,10 +11,13 @@
|
|
|
11
11
|
// Notify-Trigger (eigenes pushPath, popstate-Event), nicht generalisierbar.
|
|
12
12
|
|
|
13
13
|
import {
|
|
14
|
+
type FeatureSchema,
|
|
14
15
|
formatPath,
|
|
15
16
|
type NavApi,
|
|
16
17
|
type NavTarget,
|
|
17
18
|
parsePath,
|
|
19
|
+
resolveTarget,
|
|
20
|
+
type ScreenTarget,
|
|
18
21
|
useNav,
|
|
19
22
|
} from "@cosmicdrift/kumiko-renderer";
|
|
20
23
|
import {
|
|
@@ -66,6 +69,11 @@ function prependBasePath(path: string, basePath: string): string {
|
|
|
66
69
|
const listeners = new Set<() => void>();
|
|
67
70
|
let popstateWired = false;
|
|
68
71
|
|
|
72
|
+
// Stable identity for the omitted-features case — `?? []` would create a
|
|
73
|
+
// fresh array every render, busting the useMemo below on every render for
|
|
74
|
+
// every existing useBrowserNavApi() call site (none of which pass features).
|
|
75
|
+
const NO_FEATURES: readonly FeatureSchema[] = [];
|
|
76
|
+
|
|
69
77
|
function ensurePopstateWired(): void {
|
|
70
78
|
if (popstateWired) return;
|
|
71
79
|
if (typeof window === "undefined") return;
|
|
@@ -174,6 +182,10 @@ function replacePath(path: string): void {
|
|
|
174
182
|
export function useBrowserNavApi(options?: {
|
|
175
183
|
readonly hasWorkspaces?: boolean;
|
|
176
184
|
readonly basePath?: string;
|
|
185
|
+
/** Feature list ObjectTarget resolution needs (resolveTarget). Omit when
|
|
186
|
+
* the caller never passes an ObjectTarget — a ScreenTarget resolves
|
|
187
|
+
* without looking at the feature list at all. */
|
|
188
|
+
readonly features?: readonly FeatureSchema[];
|
|
177
189
|
}): NavApi {
|
|
178
190
|
const path = useSyncExternalStore(subscribe, readPath, () => "/");
|
|
179
191
|
// Search wird über denselben Listener-Set notifiziert (popstate +
|
|
@@ -185,6 +197,7 @@ export function useBrowserNavApi(options?: {
|
|
|
185
197
|
const basePath = useMemo(() => normalizeBasePath(options?.basePath), [options?.basePath]);
|
|
186
198
|
const searchParams = useMemo(() => parseSearchParams(search), [search]);
|
|
187
199
|
const inAppPath = useMemo(() => stripBasePath(path, basePath), [path, basePath]);
|
|
200
|
+
const features = options?.features ?? NO_FEATURES;
|
|
188
201
|
return useMemo<NavApi>(() => {
|
|
189
202
|
const route = inAppPath === undefined ? undefined : parsePath(inAppPath, hasWorkspaces);
|
|
190
203
|
// NavTarget-Contract: workspaceId weglassen = aktueller Workspace
|
|
@@ -192,21 +205,24 @@ export function useBrowserNavApi(options?: {
|
|
|
192
205
|
// landet `/screen-id` in parsePath(hasWorkspaces) als workspaceId,
|
|
193
206
|
// WorkspaceShell sieht einen unbekannten Workspace und revertet
|
|
194
207
|
// sofort auf den Default-Screen ("Klick tut nichts"-Prod-Bug).
|
|
195
|
-
const inCurrentWorkspace = (target:
|
|
208
|
+
const inCurrentWorkspace = (target: ScreenTarget): ScreenTarget =>
|
|
196
209
|
hasWorkspaces && target.workspaceId === undefined && route?.workspaceId !== undefined
|
|
197
210
|
? { ...target, workspaceId: route.workspaceId }
|
|
198
211
|
: target;
|
|
212
|
+
// resolveTarget runs first so an ObjectTarget never reaches formatPath —
|
|
213
|
+
// everything downstream (workspace-injection, path-building) only ever
|
|
214
|
+
// sees the ScreenTarget shape it already knew about.
|
|
215
|
+
const toPath = (target: NavTarget): string =>
|
|
216
|
+
prependBasePath(formatPath(inCurrentWorkspace(resolveTarget(features, target))), basePath);
|
|
199
217
|
return {
|
|
200
218
|
route,
|
|
201
|
-
navigate: (target) =>
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
replacePath(prependBasePath(formatPath(inCurrentWorkspace(target)), basePath)),
|
|
205
|
-
hrefFor: (target) => prependBasePath(formatPath(inCurrentWorkspace(target)), basePath),
|
|
219
|
+
navigate: (target) => pushPath(toPath(target)),
|
|
220
|
+
replace: (target) => replacePath(toPath(target)),
|
|
221
|
+
hrefFor: (target) => toPath(target),
|
|
206
222
|
searchParams,
|
|
207
223
|
setSearchParams: applySearchParamUpdates,
|
|
208
224
|
};
|
|
209
|
-
}, [inAppPath, hasWorkspaces, basePath, searchParams]);
|
|
225
|
+
}, [inAppPath, hasWorkspaces, basePath, searchParams, features]);
|
|
210
226
|
}
|
|
211
227
|
|
|
212
228
|
// ---- KumikoLink (Anchor-basiert, nur Web) ----
|