@cosmicdrift/kumiko-renderer-web 0.4.0 → 0.4.1
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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# @cosmicdrift/kumiko-renderer-web
|
|
2
2
|
|
|
3
|
+
## 0.4.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 010b410: feat(auth-email-password): "Bestätigungs-Mail erneut senden" im LoginScreen
|
|
8
|
+
|
|
9
|
+
LoginScreen bietet bei reason=email_not_verified jetzt einen Resend-Link
|
|
10
|
+
im Fehler-Banner — der existierende `requestEmailVerification`-Endpoint
|
|
11
|
+
wird direkt aufgerufen, der Banner wechselt nach Erfolg zum Info-Variant
|
|
12
|
+
("Wir haben dir eine neue Bestätigungs-Mail geschickt.").
|
|
13
|
+
|
|
14
|
+
UX-Details:
|
|
15
|
+
|
|
16
|
+
- Bei 429 → inline-Hint "Bitte warte kurz und versuche es erneut."
|
|
17
|
+
- Bei Netzwerk/sonstigen Fehlern → inline-Hint "Konnte nicht senden."
|
|
18
|
+
- Anti-Typo-Gate: ändert der User die Email-Eingabe nach dem Login-Fail,
|
|
19
|
+
verschwindet der Resend-Link — sonst würde Resend silent-success an die
|
|
20
|
+
geänderte (potentiell typoed) Adresse gehen ohne User-Feedback.
|
|
21
|
+
- Andere Failure-Codes (invalid_credentials etc.) zeigen weiterhin keinen
|
|
22
|
+
Resend-Link.
|
|
23
|
+
|
|
24
|
+
i18n: 4 neue Keys (DE+EN) im `auth.login.resend*`-Namespace, additive.
|
|
25
|
+
Apps die ihre Translations override-en müssen nichts ändern.
|
|
26
|
+
|
|
27
|
+
Additive UI-Feature — keine API-Breaks, keine Schema-Migration.
|
|
28
|
+
|
|
29
|
+
- Updated dependencies [010b410]
|
|
30
|
+
- @cosmicdrift/kumiko-dispatcher-live@0.4.1
|
|
31
|
+
- @cosmicdrift/kumiko-headless@0.4.1
|
|
32
|
+
- @cosmicdrift/kumiko-renderer@0.4.1
|
|
33
|
+
|
|
3
34
|
## 0.4.0
|
|
4
35
|
|
|
5
36
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
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.4.
|
|
20
|
-
"@cosmicdrift/kumiko-headless": "0.4.
|
|
21
|
-
"@cosmicdrift/kumiko-renderer": "0.4.
|
|
19
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.4.1",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.4.1",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.4.1",
|
|
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",
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ConfigCascade,
|
|
3
|
+
ConfigCascadeLevel,
|
|
4
|
+
ConfigScope,
|
|
5
|
+
ConfigValueSource,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import { useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
8
|
+
import type { ReactNode } from "react";
|
|
9
|
+
import { useState } from "react";
|
|
10
|
+
|
|
11
|
+
const SOURCE_I18N_KEY: Record<ConfigValueSource, string> = {
|
|
12
|
+
"user-row": "config.source.user",
|
|
13
|
+
"tenant-row": "config.source.tenant",
|
|
14
|
+
"system-row": "config.source.system",
|
|
15
|
+
"app-override": "config.source.appOverride",
|
|
16
|
+
computed: "config.source.computed",
|
|
17
|
+
default: "config.source.default",
|
|
18
|
+
missing: "config.source.missing",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const SOURCE_COLORS: Record<ConfigValueSource, string> = {
|
|
22
|
+
"user-row": "text-blue-600 bg-blue-50 border-blue-200",
|
|
23
|
+
"tenant-row": "text-green-600 bg-green-50 border-green-200",
|
|
24
|
+
"system-row": "text-purple-600 bg-purple-50 border-purple-200",
|
|
25
|
+
"app-override": "text-orange-600 bg-orange-50 border-orange-200",
|
|
26
|
+
computed: "text-teal-600 bg-teal-50 border-teal-200",
|
|
27
|
+
default: "text-gray-500 bg-gray-50 border-gray-200",
|
|
28
|
+
missing: "text-red-500 bg-red-50 border-red-200",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function SourceBadge({ source }: { source: ConfigValueSource }): ReactNode {
|
|
32
|
+
const t = useTranslation();
|
|
33
|
+
return (
|
|
34
|
+
<span
|
|
35
|
+
className={`inline-flex items-center rounded border px-1.5 py-0.5 text-xs font-medium ${SOURCE_COLORS[source]}`}
|
|
36
|
+
>
|
|
37
|
+
{t(SOURCE_I18N_KEY[source])}
|
|
38
|
+
</span>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function formatValue(value: string | number | boolean | undefined, hasValue: boolean): string {
|
|
43
|
+
if (!hasValue || value === undefined) return "—";
|
|
44
|
+
return String(value);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function scopeToSource(scope: ConfigScope): ConfigValueSource {
|
|
48
|
+
if (scope === "user") return "user-row";
|
|
49
|
+
if (scope === "tenant") return "tenant-row";
|
|
50
|
+
return "system-row";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type ConfigCascadeViewProps = {
|
|
54
|
+
readonly cascade: ConfigCascade;
|
|
55
|
+
readonly screenScope: ConfigScope;
|
|
56
|
+
readonly onReset?: (key: string, scope: ConfigScope) => void;
|
|
57
|
+
readonly qualifiedKey?: string;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export function ConfigCascadeView({
|
|
61
|
+
cascade,
|
|
62
|
+
screenScope,
|
|
63
|
+
onReset,
|
|
64
|
+
qualifiedKey,
|
|
65
|
+
}: ConfigCascadeViewProps): ReactNode {
|
|
66
|
+
const t = useTranslation();
|
|
67
|
+
const [expanded, setExpanded] = useState(false);
|
|
68
|
+
|
|
69
|
+
// Safety net: callers should already filter malformed cascades, but
|
|
70
|
+
// a missing levels-array (e.g. from a partial mock) shouldn't crash
|
|
71
|
+
// the screen.
|
|
72
|
+
if (!Array.isArray(cascade?.levels)) return null;
|
|
73
|
+
|
|
74
|
+
const activeLevel = cascade.levels.find((l) => l.isActive);
|
|
75
|
+
const screenScopeSource = scopeToSource(screenScope);
|
|
76
|
+
const hasOverride = activeLevel?.source === screenScopeSource;
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div className="mt-1 text-xs">
|
|
80
|
+
<button
|
|
81
|
+
type="button"
|
|
82
|
+
onClick={() => setExpanded(!expanded)}
|
|
83
|
+
className="flex items-center gap-1 text-gray-500 hover:text-gray-700 cursor-pointer"
|
|
84
|
+
>
|
|
85
|
+
<span className="text-[10px]">{expanded ? "▼" : "▶"}</span>
|
|
86
|
+
{activeLevel ? (
|
|
87
|
+
<>
|
|
88
|
+
<SourceBadge source={activeLevel.source} />
|
|
89
|
+
<span className="text-gray-400">
|
|
90
|
+
{formatValue(activeLevel.value, activeLevel.hasValue)}
|
|
91
|
+
</span>
|
|
92
|
+
</>
|
|
93
|
+
) : (
|
|
94
|
+
<span className="text-gray-400">{t("config.cascade.noValue")}</span>
|
|
95
|
+
)}
|
|
96
|
+
</button>
|
|
97
|
+
|
|
98
|
+
{expanded ? (
|
|
99
|
+
<div className="mt-1 flex flex-col gap-0.5 pl-3 border-l-2 border-gray-100">
|
|
100
|
+
{cascade.levels.map((level) => (
|
|
101
|
+
<CascadeLevelRow key={level.source} level={level} />
|
|
102
|
+
))}
|
|
103
|
+
|
|
104
|
+
{hasOverride && onReset && qualifiedKey ? (
|
|
105
|
+
<button
|
|
106
|
+
type="button"
|
|
107
|
+
onClick={() => onReset(qualifiedKey, screenScope)}
|
|
108
|
+
className="mt-1 self-start text-[10px] text-orange-500 hover:text-orange-700 cursor-pointer underline"
|
|
109
|
+
>
|
|
110
|
+
{t("config.cascade.resetTo", { scope: t(SOURCE_I18N_KEY[screenScopeSource]) })}
|
|
111
|
+
</button>
|
|
112
|
+
) : null}
|
|
113
|
+
</div>
|
|
114
|
+
) : null}
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function CascadeLevelRow({ level }: { level: ConfigCascadeLevel }): ReactNode {
|
|
120
|
+
const t = useTranslation();
|
|
121
|
+
return (
|
|
122
|
+
<div
|
|
123
|
+
className={`flex items-center gap-1.5 ${level.isActive ? "font-medium" : "text-gray-400"}`}
|
|
124
|
+
>
|
|
125
|
+
<SourceBadge source={level.source} />
|
|
126
|
+
<span>{formatValue(level.value, level.hasValue)}</span>
|
|
127
|
+
{level.isActive ? (
|
|
128
|
+
<span className="text-[10px] text-gray-400">{t("config.cascade.activeMarker")}</span>
|
|
129
|
+
) : null}
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ConfigValueSource } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
const SOURCE_CONFIG: Record<ConfigValueSource, { label: string; bg: string; text: string }> = {
|
|
5
|
+
"user-row": { label: "User", bg: "#dbeafe", text: "#1e40af" },
|
|
6
|
+
"tenant-row": { label: "Tenant", bg: "#dcfce7", text: "#166534" },
|
|
7
|
+
"system-row": { label: "System", bg: "#f3e8ff", text: "#6b21a8" },
|
|
8
|
+
"app-override": { label: "Override", bg: "#ffedd5", text: "#9a3412" },
|
|
9
|
+
computed: { label: "Computed", bg: "#ccfbf1", text: "#115e59" },
|
|
10
|
+
default: { label: "Default", bg: "#f3f4f6", text: "#4b5563" },
|
|
11
|
+
missing: { label: "Missing", bg: "#fee2e2", text: "#991b1b" },
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function ConfigSourceBadge({ source }: { readonly source: ConfigValueSource }): ReactNode {
|
|
15
|
+
const cfg = SOURCE_CONFIG[source];
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<span
|
|
19
|
+
style={{
|
|
20
|
+
display: "inline-flex",
|
|
21
|
+
alignItems: "center",
|
|
22
|
+
padding: "0 6px",
|
|
23
|
+
fontSize: "11px",
|
|
24
|
+
fontWeight: 500,
|
|
25
|
+
lineHeight: "18px",
|
|
26
|
+
borderRadius: "4px",
|
|
27
|
+
backgroundColor: cfg.bg,
|
|
28
|
+
color: cfg.text,
|
|
29
|
+
marginLeft: "6px",
|
|
30
|
+
whiteSpace: "nowrap",
|
|
31
|
+
}}
|
|
32
|
+
>
|
|
33
|
+
{cfg.label}
|
|
34
|
+
</span>
|
|
35
|
+
);
|
|
36
|
+
}
|
package/src/primitives/index.tsx
CHANGED
|
@@ -130,7 +130,16 @@ function DefaultBanner({
|
|
|
130
130
|
|
|
131
131
|
// ---- Field (Label + Error) ----
|
|
132
132
|
|
|
133
|
-
function DefaultField({
|
|
133
|
+
function DefaultField({
|
|
134
|
+
id,
|
|
135
|
+
label,
|
|
136
|
+
required,
|
|
137
|
+
issues,
|
|
138
|
+
labelAppendix,
|
|
139
|
+
fieldAppendix,
|
|
140
|
+
children,
|
|
141
|
+
testId,
|
|
142
|
+
}: FieldProps): ReactNode {
|
|
134
143
|
const t = useTranslation();
|
|
135
144
|
const hasError = issues !== undefined && issues.length > 0;
|
|
136
145
|
return (
|
|
@@ -148,9 +157,11 @@ function DefaultField({ id, label, required, issues, children, testId }: FieldPr
|
|
|
148
157
|
)}
|
|
149
158
|
>
|
|
150
159
|
{label}
|
|
160
|
+
{labelAppendix !== undefined && <>{labelAppendix}</>}
|
|
151
161
|
{required === true && <span className="ml-0.5 text-destructive">*</span>}
|
|
152
162
|
</LabelPrimitive.Root>
|
|
153
163
|
{children}
|
|
164
|
+
{fieldAppendix !== undefined && <div className="mt-1">{fieldAppendix}</div>}
|
|
154
165
|
{hasError && (
|
|
155
166
|
<div
|
|
156
167
|
role="alert"
|
|
@@ -1255,6 +1266,9 @@ function DefaultHeading({ variant = "page", children, testId }: HeadingProps): R
|
|
|
1255
1266
|
);
|
|
1256
1267
|
}
|
|
1257
1268
|
|
|
1269
|
+
import { ConfigCascadeView as DefaultConfigCascadeView } from "../components/config-cascade";
|
|
1270
|
+
import { ConfigSourceBadge as DefaultConfigSourceBadge } from "../components/config-source-badge";
|
|
1271
|
+
|
|
1258
1272
|
export const defaultPrimitives: CorePrimitives = {
|
|
1259
1273
|
Button: DefaultButton,
|
|
1260
1274
|
Banner: DefaultBanner,
|
|
@@ -1268,4 +1282,6 @@ export const defaultPrimitives: CorePrimitives = {
|
|
|
1268
1282
|
Text: DefaultText,
|
|
1269
1283
|
Heading: DefaultHeading,
|
|
1270
1284
|
Dialog: DefaultDialog,
|
|
1285
|
+
ConfigSourceBadge: DefaultConfigSourceBadge,
|
|
1286
|
+
ConfigCascadeView: DefaultConfigCascadeView,
|
|
1271
1287
|
};
|