@cosmicdrift/kumiko-renderer 0.317.0 → 0.319.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 +5 -5
- package/src/__tests__/i18n.test.tsx +43 -0
- package/src/__tests__/share-links.test.ts +26 -0
- package/src/changes.json +18 -0
- package/src/components/render-edit.tsx +8 -1
- package/src/i18n-defaults.ts +3 -0
- package/src/i18n.tsx +67 -46
- package/src/index.ts +13 -0
- package/src/primitives.tsx +75 -3
- package/src/share-links.ts +12 -0
- package/src/tokens.tsx +28 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.319.0",
|
|
4
4
|
"description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -15,9 +15,9 @@
|
|
|
15
15
|
}
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
19
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
20
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
18
|
+
"@cosmicdrift/kumiko-framework": "0.319.0",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.319.0",
|
|
20
|
+
"@cosmicdrift/kumiko-types": "0.319.0",
|
|
21
21
|
"react": "^19.2.6",
|
|
22
22
|
"temporal-polyfill": "^0.3.2",
|
|
23
23
|
"zod": "^4.4.3"
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"@types/react-dom": "^19.2.3",
|
|
29
29
|
"jsdom": "^29.1.1",
|
|
30
30
|
"react-dom": "^19.2.6",
|
|
31
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
31
|
+
"@cosmicdrift/kumiko-locale-de": "0.319.0"
|
|
32
32
|
},
|
|
33
33
|
"repository": {
|
|
34
34
|
"type": "git",
|
|
@@ -5,6 +5,7 @@ import type { ReactNode } from "react";
|
|
|
5
5
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
6
6
|
import {
|
|
7
7
|
createStaticLocaleResolver,
|
|
8
|
+
FormalityProvider,
|
|
8
9
|
LocaleProvider,
|
|
9
10
|
type TranslationsByLocale,
|
|
10
11
|
translationsByLocaleFromKeys,
|
|
@@ -88,6 +89,48 @@ describe("useTranslation — lookup order", () => {
|
|
|
88
89
|
expect(result.current("greet", { name: "Marc" })).toBe("Hallo Marc!");
|
|
89
90
|
});
|
|
90
91
|
});
|
|
92
|
+
describe("useTranslation — formality", () => {
|
|
93
|
+
const bundles: TranslationsByLocale[] = [
|
|
94
|
+
{ de: { greet: "Hallo, wie geht es dir?", save: "Speichern" } },
|
|
95
|
+
{ "de-x-formal": { greet: "Guten Tag, wie geht es Ihnen?" } },
|
|
96
|
+
];
|
|
97
|
+
const wrapFormal =
|
|
98
|
+
(formality: "formal" | "informal", locale = "de") =>
|
|
99
|
+
({ children }: { readonly children: ReactNode }): ReactNode => (
|
|
100
|
+
<LocaleProvider resolver={createStaticLocaleResolver({ locale })} fallbackBundles={bundles}>
|
|
101
|
+
<FormalityProvider formality={formality}>{children}</FormalityProvider>
|
|
102
|
+
</LocaleProvider>
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
test("without a FormalityProvider the plain locale wording is used", () => {
|
|
106
|
+
const { result } = renderHook(() => useTranslation(), {
|
|
107
|
+
wrapper: wrap(createStaticLocaleResolver({ locale: "de" }), bundles),
|
|
108
|
+
});
|
|
109
|
+
expect(result.current("greet")).toBe("Hallo, wie geht es dir?");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("formal prefers a -x-formal entry from any bundle over a plain one, also for de-AT", () => {
|
|
113
|
+
for (const locale of ["de", "de-AT"]) {
|
|
114
|
+
const { result } = renderHook(() => useTranslation(), {
|
|
115
|
+
wrapper: wrapFormal("formal", locale),
|
|
116
|
+
});
|
|
117
|
+
expect(result.current("greet")).toBe("Guten Tag, wie geht es Ihnen?");
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("formal falls back to the plain wording for keys without a formal variant", () => {
|
|
122
|
+
const { result } = renderHook(() => useTranslation(), { wrapper: wrapFormal("formal") });
|
|
123
|
+
expect(result.current("save")).toBe("Speichern");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("useOptionalTranslation honors formality too", () => {
|
|
127
|
+
const { result } = renderHook(() => useOptionalTranslation(), {
|
|
128
|
+
wrapper: wrapFormal("formal"),
|
|
129
|
+
});
|
|
130
|
+
expect(result.current?.("greet")).toBe("Guten Tag, wie geht es Ihnen?");
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
91
134
|
describe("useTranslation — re-render on locale change", () => {
|
|
92
135
|
test("subscribe fires when setLocale runs, hook returns new value", () => {
|
|
93
136
|
const resolver = makeStatefulResolver("de");
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { buildWhatsAppShareUrl } from "../share-links";
|
|
3
|
+
|
|
4
|
+
describe("buildWhatsAppShareUrl", () => {
|
|
5
|
+
test("without a phone number the link opens the contact picker with the text prefilled", () => {
|
|
6
|
+
expect(buildWhatsAppShareUrl("Hallo Welt")).toBe("https://wa.me/?text=Hallo%20Welt");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("encodes line breaks, umlauts and reserved characters so the text arrives unchanged", () => {
|
|
10
|
+
const text = "Škoda für 17.490 €\nProbefahrt? & mehr #1";
|
|
11
|
+
const url = new URL(buildWhatsAppShareUrl(text));
|
|
12
|
+
expect(url.searchParams.get("text")).toBe(text);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("normalizes a formatted international number to digits only", () => {
|
|
16
|
+
expect(buildWhatsAppShareUrl("x", "+49 (171) 234-5678")).toBe(
|
|
17
|
+
"https://wa.me/491712345678?text=x",
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("treats a 00 prefix like +", () => {
|
|
22
|
+
expect(buildWhatsAppShareUrl("x", "0049 171 2345678")).toBe(
|
|
23
|
+
"https://wa.me/491712345678?text=x",
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
});
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,22 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.318.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "App theme as one Tailwind plugin instead of duplicated @theme and :root blocks",
|
|
6
|
+
"detail": "An app declares its colors (a string, or `{ light, dark }`), base radius, fonts, card shadow and card padding once in `defineAppTheme({...})`, default-exports `createThemePlugin(theme)` from e.g. `src/theme.ts`, and adds `@plugin \"./theme.ts\";` after the renderer-web import in its styles.css. The plugin writes into the same base layer after the framework palette, so the app values win in light and dark mode without repeating them in `:root`/`.dark`; colors the framework does not know get utilities (`bg-brand-soft`). `fonts.sans` also sets the body font. The framework's `--card-padding/--card-radius/--card-shadow` defaults moved into `@layer base` (an app's unlayered `:root` still overrides them, an app `@theme` value still does not). CoreTokens gain `font`, `shadow.card` and `spacing.card`. Apps that already duplicate their palette in unlayered `:root`/`.dark` blocks must drop those blocks when switching to the plugin, since unlayered CSS beats it."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.318.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "Formal or informal address per surface",
|
|
12
|
+
"detail": "`FormalityProvider` (and `createPublicSurface({ formality: \"formal\" })`) makes `t()` look up `<locale>-x-formal` across all plugin bundles before the plain locale, so public pages can say \"Sie\" while the app keeps \"du\". `formalLocaleTag(locale)` builds the tag. locale-de now ships `de-x-formal` overrides for its framework strings. Only bundle lookups are affected: an app resolver that already knows a key answers first, and an app that overrides a framework key in plain \"de\" needs a matching \"de-x-formal\" entry for formal surfaces."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.318.0",
|
|
16
|
+
"type": "improvement",
|
|
17
|
+
"title": "Public-page building blocks (StepBar back navigation, sticky actions, copy/share, promo, photo slots, public shell)",
|
|
18
|
+
"detail": "StepBar takes `onStepSelect` (completed steps become buttons, so a wizard can jump back) and `narrowLayout` (\"label\" or \"steps\"); wizard forms wire it to jump back without validating. ProgressBar gains `tone: \"success\"`. New optional primitives: StickyActionBar (bottom-pinned action row with safe-area padding and an optional back action), CopyButton (clipboard with a copied state), ShareButton (`target: \"whatsapp\"` opens wa.me, `target: \"system\"` opens the share sheet and renders nothing where the browser has none, so pair it with a CopyButton) plus `buildWhatsAppShareUrl`, and PromoPanel (offer surface on its own `--color-promo*` tokens instead of an info banner). New widgets: PhotoSlots (one tile per required shot with thumbnail, per-slot upload and error, optional camera capture; UploadZone forwards `capture` too) and PublicShell (`variant` \"marketing\", \"focus\" with a progress slot, or \"card\"). The new primitives are optional in CorePrimitives, so existing registries keep compiling."
|
|
19
|
+
},
|
|
2
20
|
{
|
|
3
21
|
"version": "0.316.0",
|
|
4
22
|
"type": "fix",
|
|
@@ -865,7 +865,13 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
865
865
|
}
|
|
866
866
|
|
|
867
867
|
function handleWizardBack(): void {
|
|
868
|
-
|
|
868
|
+
handleWizardJumpBack(currentStep - 1);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// Only backwards: a forward jump would skip the per-step validate() gate.
|
|
872
|
+
function handleWizardJumpBack(targetStep: number): void {
|
|
873
|
+
if (targetStep >= currentStep) return;
|
|
874
|
+
const previous = Math.max(targetStep, 0);
|
|
869
875
|
hasNavigatedRef.current = true;
|
|
870
876
|
if (disabled) {
|
|
871
877
|
setRawStep(previous);
|
|
@@ -1356,6 +1362,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1356
1362
|
steps={filteredSections.map((section) => section.title ?? "")}
|
|
1357
1363
|
currentIndex={currentStep}
|
|
1358
1364
|
compactLabel={compactLabel}
|
|
1365
|
+
onStepSelect={handleWizardJumpBack}
|
|
1359
1366
|
testId="render-edit-wizard-steps"
|
|
1360
1367
|
compactTestId="render-edit-wizard-step-label"
|
|
1361
1368
|
/>
|
package/src/i18n-defaults.ts
CHANGED
|
@@ -96,6 +96,9 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
|
|
|
96
96
|
"kumiko.widget.drawer.maximize": "Maximize drawer width",
|
|
97
97
|
"kumiko.widget.drawer.resize": "Resize drawer",
|
|
98
98
|
|
|
99
|
+
"kumiko.planCard.current": "Current plan",
|
|
100
|
+
"kumiko.planCard.priceUnavailable": "Price not available",
|
|
101
|
+
|
|
99
102
|
"kumiko.nav.expand": "Expand",
|
|
100
103
|
"kumiko.nav.collapse": "Collapse",
|
|
101
104
|
"kumiko.nav.search": "Search navigation…",
|
package/src/i18n.tsx
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// resolver's `subscribe()` — app code can switch language mid-session
|
|
15
15
|
// without a reload.
|
|
16
16
|
|
|
17
|
+
import { type Formality, formalLocaleTag } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
17
18
|
import type { LocaleResolver } from "@cosmicdrift/kumiko-headless";
|
|
18
19
|
import {
|
|
19
20
|
createContext,
|
|
@@ -68,6 +69,7 @@ type LocaleContextValue = {
|
|
|
68
69
|
};
|
|
69
70
|
|
|
70
71
|
const LocaleContext = createContext<LocaleContextValue | undefined>(undefined);
|
|
72
|
+
const FormalityContext = createContext<Formality>("informal");
|
|
71
73
|
|
|
72
74
|
// Stabile Referenz statt `fallbackBundles = []` als Default-Parameter:
|
|
73
75
|
// ein Literal-Default wird bei JEDEM Aufruf neu allokiert und würde die
|
|
@@ -125,14 +127,13 @@ export function useLocale(): LocaleResolver {
|
|
|
125
127
|
return ctx.resolver;
|
|
126
128
|
}
|
|
127
129
|
|
|
128
|
-
/**
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
* 3. Plugin
|
|
133
|
-
* 4.
|
|
134
|
-
*
|
|
135
|
-
* Source — auch Fallback-Strings können parameters nutzen. */
|
|
130
|
+
/** `t("key", params)` resolves in this order:
|
|
131
|
+
* 1. App resolver (e.g. i18next)
|
|
132
|
+
* 2. Plugin fallback bundles for the current locale (under a "formal"
|
|
133
|
+
* FormalityProvider, `<locale>-x-formal` across all bundles first)
|
|
134
|
+
* 3. Plugin fallback bundles for fallbackLocale
|
|
135
|
+
* 4. The key as-is
|
|
136
|
+
* `{name}` interpolation applies to every source, fallback strings included. */
|
|
136
137
|
export function useTranslation(): (
|
|
137
138
|
key: string,
|
|
138
139
|
params?: Readonly<Record<string, unknown>>,
|
|
@@ -155,34 +156,65 @@ export function useTranslation(): (
|
|
|
155
156
|
// (z.B. um Queries neu zu laden wenn sich die Sprache ändert). Ein neu
|
|
156
157
|
// erzeugtes `t` pro Render führt sonst zu einem Render/Effect-Endlos-
|
|
157
158
|
// Loop (siehe admin-shell Overview-Screens, Prod-Incident 2026-07-07).
|
|
159
|
+
const formality = useContext(FormalityContext);
|
|
158
160
|
return useCallback(
|
|
159
|
-
(key: string, params?: Readonly<Record<string, unknown>>): string =>
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
const resolved = ctx.resolver.translate(key, params);
|
|
165
|
-
if (resolved !== key) return resolved;
|
|
161
|
+
(key: string, params?: Readonly<Record<string, unknown>>): string =>
|
|
162
|
+
translateWithFallbacks(ctx, locale, formality, key, params),
|
|
163
|
+
[ctx, locale, formality],
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
166
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
167
|
+
/** Wording for the subtree: `formal` makes `t` prefer the `<locale>-x-formal`
|
|
168
|
+
* bundle entries (e.g. "Sie" on public pages) over plain `<locale>`. */
|
|
169
|
+
export function FormalityProvider({
|
|
170
|
+
formality,
|
|
171
|
+
children,
|
|
172
|
+
}: {
|
|
173
|
+
readonly formality: Formality;
|
|
174
|
+
readonly children: ReactNode;
|
|
175
|
+
}): ReactNode {
|
|
176
|
+
return <FormalityContext.Provider value={formality}>{children}</FormalityContext.Provider>;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function useFormality(): Formality {
|
|
180
|
+
return useContext(FormalityContext);
|
|
181
|
+
}
|
|
173
182
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
183
|
+
// Lookup order: (1) app resolver — it returns the key itself when it has no
|
|
184
|
+
// entry, which is the invitation to try the plugin bundles; (2) the bundles in
|
|
185
|
+
// array order per locale tier; (3) the key as-is. Under "formal" the formal
|
|
186
|
+
// tier is searched across ALL bundles before any plain tier, so a plain "de"
|
|
187
|
+
// app override never puts "du" on a formal page when locale-de has a formal
|
|
188
|
+
// variant; an app rewording such a key must also ship its "de-x-formal" text.
|
|
189
|
+
function translateWithFallbacks(
|
|
190
|
+
ctx: LocaleContextValue,
|
|
191
|
+
locale: string,
|
|
192
|
+
formality: Formality,
|
|
193
|
+
key: string,
|
|
194
|
+
params?: Readonly<Record<string, unknown>>,
|
|
195
|
+
): string {
|
|
196
|
+
const resolved = ctx.resolver.translate(key, params);
|
|
197
|
+
if (resolved !== key) return resolved;
|
|
198
|
+
for (const localesToTry of bundleLocaleTiers(locale, ctx.fallbackLocale, formality)) {
|
|
199
|
+
for (const bundle of ctx.fallbackBundles) {
|
|
200
|
+
for (const localeToTry of localesToTry) {
|
|
201
|
+
const value = bundle[localeToTry]?.[key];
|
|
202
|
+
if (value !== undefined) return interpolate(value, params);
|
|
179
203
|
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return key;
|
|
207
|
+
}
|
|
180
208
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
209
|
+
function bundleLocaleTiers(
|
|
210
|
+
locale: string,
|
|
211
|
+
fallbackLocale: string,
|
|
212
|
+
formality: Formality,
|
|
213
|
+
): readonly (readonly string[])[] {
|
|
214
|
+
const languageRoot = locale.split("-")[0] ?? locale;
|
|
215
|
+
const plainTier = [locale, languageRoot, fallbackLocale];
|
|
216
|
+
if (formality === "informal") return [plainTier];
|
|
217
|
+
return [[formalLocaleTag(locale), formalLocaleTag(languageRoot)], plainTier];
|
|
186
218
|
}
|
|
187
219
|
|
|
188
220
|
function interpolate(template: string, params?: Readonly<Record<string, unknown>>): string {
|
|
@@ -226,22 +258,11 @@ export function useOptionalTranslation():
|
|
|
226
258
|
() => (ctx ? ctx.resolver.locale() : "en"),
|
|
227
259
|
() => (ctx ? ctx.resolver.locale() : "en"),
|
|
228
260
|
);
|
|
261
|
+
const formality = useContext(FormalityContext);
|
|
229
262
|
const t = useCallback(
|
|
230
|
-
(key: string, params?: Readonly<Record<string, unknown>>): string =>
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
if (resolved !== key) return resolved;
|
|
234
|
-
const languageRoot = locale.split("-")[0] ?? locale;
|
|
235
|
-
const localesToTry = [locale, languageRoot, ctx.fallbackLocale];
|
|
236
|
-
for (const bundle of ctx.fallbackBundles) {
|
|
237
|
-
for (const localeToTry of localesToTry) {
|
|
238
|
-
const value = bundle[localeToTry]?.[key];
|
|
239
|
-
if (value !== undefined) return interpolate(value, params);
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
return key;
|
|
243
|
-
},
|
|
244
|
-
[ctx, locale],
|
|
263
|
+
(key: string, params?: Readonly<Record<string, unknown>>): string =>
|
|
264
|
+
ctx === undefined ? key : translateWithFallbacks(ctx, locale, formality, key, params),
|
|
265
|
+
[ctx, locale, formality],
|
|
245
266
|
);
|
|
246
267
|
return ctx === undefined ? undefined : t;
|
|
247
268
|
}
|
package/src/index.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// eigenen Bootstrap schreiben will. Normale Samples gehen über
|
|
10
10
|
// @cosmicdrift/kumiko-renderer-web/createKumikoApp, das alle Provider verdrahtet.
|
|
11
11
|
|
|
12
|
+
export { type Formality, formalLocaleTag } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
12
13
|
export { synthesizeActionFormEntity, synthesizeActionFormScreen } from "./app/action-form-shim";
|
|
13
14
|
export type { AppFeaturesProviderProps } from "./app/app-features-context";
|
|
14
15
|
export { AppFeaturesProvider, useAppFeatures } from "./app/app-features-context";
|
|
@@ -176,9 +177,11 @@ export type {
|
|
|
176
177
|
} from "./i18n";
|
|
177
178
|
export {
|
|
178
179
|
createStaticLocaleResolver,
|
|
180
|
+
FormalityProvider,
|
|
179
181
|
LocaleProvider,
|
|
180
182
|
mergeTranslations,
|
|
181
183
|
translationsByLocaleFromKeys,
|
|
184
|
+
useFormality,
|
|
182
185
|
useLocale,
|
|
183
186
|
useOptionalLocale,
|
|
184
187
|
useOptionalTranslation,
|
|
@@ -194,6 +197,7 @@ export type {
|
|
|
194
197
|
CardOptions,
|
|
195
198
|
CardProps,
|
|
196
199
|
CardSlots,
|
|
200
|
+
CopyButtonProps,
|
|
197
201
|
CorePrimitives,
|
|
198
202
|
DataTableDateRangeFacet,
|
|
199
203
|
DataTableFacet,
|
|
@@ -224,13 +228,18 @@ export type {
|
|
|
224
228
|
PrimitivesProviderProps,
|
|
225
229
|
PrimitivesRegistry,
|
|
226
230
|
ProgressProps,
|
|
231
|
+
ProgressTone,
|
|
232
|
+
PromoPanelAction,
|
|
233
|
+
PromoPanelProps,
|
|
227
234
|
RuntimeRenderer,
|
|
228
235
|
SecretRevealProps,
|
|
229
236
|
SecretRevealValue,
|
|
230
237
|
SectionProps,
|
|
238
|
+
ShareButtonProps,
|
|
231
239
|
StatusBadgeProps,
|
|
232
240
|
StatusTone,
|
|
233
241
|
StepBarProps,
|
|
242
|
+
StickyActionBarProps,
|
|
234
243
|
StickyPrimaryActionMarker,
|
|
235
244
|
TabsProps,
|
|
236
245
|
TextProps,
|
|
@@ -243,6 +252,7 @@ export {
|
|
|
243
252
|
statusToneForValue,
|
|
244
253
|
usePrimitives,
|
|
245
254
|
} from "./primitives";
|
|
255
|
+
export { buildWhatsAppShareUrl } from "./share-links";
|
|
246
256
|
export { sortByAccessor } from "./sort-by-accessor";
|
|
247
257
|
export type { LiveEvent, LiveEventSubscriber, LiveEventsProviderProps } from "./sse/live-events";
|
|
248
258
|
export { LiveEventsProvider, useLiveEvents } from "./sse/live-events";
|
|
@@ -250,7 +260,10 @@ export type {
|
|
|
250
260
|
AppTokens,
|
|
251
261
|
ColorTokens,
|
|
252
262
|
CoreTokens,
|
|
263
|
+
FontTokens,
|
|
253
264
|
RadiusTokens,
|
|
265
|
+
ShadowTokens,
|
|
266
|
+
SpacingTokens,
|
|
254
267
|
ThemeMode,
|
|
255
268
|
Tokens,
|
|
256
269
|
TokensApi,
|
package/src/primitives.tsx
CHANGED
|
@@ -1096,15 +1096,19 @@ export type CardProps = {
|
|
|
1096
1096
|
* 0..1 fraction, not a percentage — implementations scale for display.
|
|
1097
1097
|
* `tone` controls the fill color; omitted defaults to "default" (backward
|
|
1098
1098
|
* compatible with callers predating this field). */
|
|
1099
|
+
export type ProgressTone = "default" | "success" | "warn" | "danger";
|
|
1100
|
+
|
|
1099
1101
|
export type ProgressProps = {
|
|
1100
1102
|
readonly value: number;
|
|
1101
|
-
readonly tone?:
|
|
1103
|
+
readonly tone?: ProgressTone;
|
|
1102
1104
|
readonly testId?: string;
|
|
1103
1105
|
};
|
|
1104
1106
|
|
|
1105
1107
|
/** Wizard step overview — numbered chips, one per step label, with the
|
|
1106
|
-
* active step highlighted.
|
|
1107
|
-
*
|
|
1108
|
+
* active step highlighted. With `onStepSelect`, completed steps (index
|
|
1109
|
+
* below `currentIndex`) become buttons for going back; forward jumps stay
|
|
1110
|
+
* impossible because step-jump validation is a separate concern
|
|
1111
|
+
* (kumiko-framework#1966). Implementations render a
|
|
1108
1112
|
* narrow-viewport fallback showing `compactLabel` instead (caller
|
|
1109
1113
|
* supplies it pre-translated, e.g. "Step 2 of 5 · Industry") — which of
|
|
1110
1114
|
* the two is visible is a responsive layout choice owned by the
|
|
@@ -1113,6 +1117,11 @@ export type StepBarProps = {
|
|
|
1113
1117
|
readonly steps: readonly string[];
|
|
1114
1118
|
readonly currentIndex: number;
|
|
1115
1119
|
readonly compactLabel: string;
|
|
1120
|
+
readonly onStepSelect?: (index: number) => void;
|
|
1121
|
+
/** "label" (default) swaps the step row for `compactLabel` on narrow
|
|
1122
|
+
* viewports; "steps" keeps the row there too — for short wizards whose
|
|
1123
|
+
* labels fit a phone and whose done steps must stay tappable. */
|
|
1124
|
+
readonly narrowLayout?: "label" | "steps";
|
|
1116
1125
|
readonly testId?: string;
|
|
1117
1126
|
readonly compactTestId?: string;
|
|
1118
1127
|
};
|
|
@@ -1259,6 +1268,59 @@ export type SecretRevealProps = {
|
|
|
1259
1268
|
readonly testId?: string;
|
|
1260
1269
|
};
|
|
1261
1270
|
|
|
1271
|
+
/** Thumb-reach action row for custom screens: pinned to the bottom edge on
|
|
1272
|
+
* narrow viewports (above the device's safe-area inset), inline otherwise.
|
|
1273
|
+
* `children` are the forward actions and share the remaining width; `back`
|
|
1274
|
+
* renders an icon-only back control in front of them. */
|
|
1275
|
+
export type StickyActionBarProps = {
|
|
1276
|
+
readonly children: ReactNode;
|
|
1277
|
+
readonly back?: { readonly onBack: () => void; readonly label: string };
|
|
1278
|
+
readonly testId?: string;
|
|
1279
|
+
};
|
|
1280
|
+
|
|
1281
|
+
/** Copies `text` to the clipboard; the label flips to `copiedLabel` once
|
|
1282
|
+
* the platform confirmed the write. */
|
|
1283
|
+
export type CopyButtonProps = {
|
|
1284
|
+
readonly text: string;
|
|
1285
|
+
readonly label: string;
|
|
1286
|
+
readonly copiedLabel: string;
|
|
1287
|
+
readonly variant?: "primary" | "secondary";
|
|
1288
|
+
/** Layout extras — web merges via cn(), native impls ignore it. */
|
|
1289
|
+
readonly className?: string;
|
|
1290
|
+
readonly testId?: string;
|
|
1291
|
+
};
|
|
1292
|
+
|
|
1293
|
+
/** Hands `text` to another app. "whatsapp" opens a WhatsApp chat prefilled
|
|
1294
|
+
* with the text (to `phone` when given, otherwise the contact picker);
|
|
1295
|
+
* "system" opens the platform share sheet and renders nothing where the
|
|
1296
|
+
* platform has none. */
|
|
1297
|
+
export type ShareButtonProps = {
|
|
1298
|
+
readonly text: string;
|
|
1299
|
+
readonly label: string;
|
|
1300
|
+
readonly variant?: "primary" | "secondary";
|
|
1301
|
+
/** Layout extras — web merges via cn(), native impls ignore it. */
|
|
1302
|
+
readonly className?: string;
|
|
1303
|
+
readonly testId?: string;
|
|
1304
|
+
} & (
|
|
1305
|
+
| { readonly target: "whatsapp"; readonly phone?: string }
|
|
1306
|
+
| { readonly target: "system"; readonly title?: string; readonly url?: string }
|
|
1307
|
+
);
|
|
1308
|
+
|
|
1309
|
+
export type PromoPanelAction =
|
|
1310
|
+
| { readonly label: string; readonly href: string; readonly testId?: string }
|
|
1311
|
+
| { readonly label: string; readonly onClick: () => void; readonly testId?: string };
|
|
1312
|
+
|
|
1313
|
+
/** Offer surface for upsells (e.g. "create an account"): its own tinted
|
|
1314
|
+
* panel with eyebrow, headline, benefit content and one call to action, so
|
|
1315
|
+
* an offer never reads like an info or error banner. */
|
|
1316
|
+
export type PromoPanelProps = {
|
|
1317
|
+
readonly title: string;
|
|
1318
|
+
readonly eyebrow?: string;
|
|
1319
|
+
readonly children?: ReactNode;
|
|
1320
|
+
readonly action?: PromoPanelAction;
|
|
1321
|
+
readonly testId?: string;
|
|
1322
|
+
};
|
|
1323
|
+
|
|
1262
1324
|
// ---- Core-Registry (Kumiko-eigene Primitives) ----
|
|
1263
1325
|
|
|
1264
1326
|
export type CorePrimitives = {
|
|
@@ -1327,6 +1389,16 @@ export type CorePrimitives = {
|
|
|
1327
1389
|
* CorePrimitives mocks in tests keep compiling — additive rollout of
|
|
1328
1390
|
* a new primitive shouldn't force every test double to grow a stub. */
|
|
1329
1391
|
readonly SecretReveal?: ComponentType<SecretRevealProps>;
|
|
1392
|
+
/** Optional (unlike the other Core-Primitives) so existing partial
|
|
1393
|
+
* CorePrimitives mocks in tests keep compiling — additive rollout of
|
|
1394
|
+
* a new primitive shouldn't force every test double to grow a stub. */
|
|
1395
|
+
readonly StickyActionBar?: ComponentType<StickyActionBarProps>;
|
|
1396
|
+
/** Optional — additive rollout, see StickyActionBar. */
|
|
1397
|
+
readonly CopyButton?: ComponentType<CopyButtonProps>;
|
|
1398
|
+
/** Optional — additive rollout, see StickyActionBar. */
|
|
1399
|
+
readonly ShareButton?: ComponentType<ShareButtonProps>;
|
|
1400
|
+
/** Optional — additive rollout, see StickyActionBar. */
|
|
1401
|
+
readonly PromoPanel?: ComponentType<PromoPanelProps>;
|
|
1330
1402
|
};
|
|
1331
1403
|
|
|
1332
1404
|
/** Offene Extension-Zone für App-eigene Primitives. Devs erweitern
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** wa.me link that opens WhatsApp with `text` prefilled — addressed to
|
|
2
|
+
* `phone` when given (any formatting; international form required, a
|
|
3
|
+
* leading "+" or "00" is accepted), otherwise WhatsApp's contact picker. */
|
|
4
|
+
export function buildWhatsAppShareUrl(text: string, phone?: string): string {
|
|
5
|
+
const recipient = phone === undefined ? "" : whatsAppRecipientDigits(phone);
|
|
6
|
+
return `https://wa.me/${recipient}?text=${encodeURIComponent(text)}`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function whatsAppRecipientDigits(phone: string): string {
|
|
10
|
+
const digits = phone.replace(/\D/g, "");
|
|
11
|
+
return digits.startsWith("00") ? digits.slice(2) : digits;
|
|
12
|
+
}
|
package/src/tokens.tsx
CHANGED
|
@@ -46,9 +46,26 @@ export type RadiusTokens = {
|
|
|
46
46
|
readonly xl: string;
|
|
47
47
|
};
|
|
48
48
|
|
|
49
|
+
export type FontTokens = {
|
|
50
|
+
readonly sans: string;
|
|
51
|
+
readonly heading: string;
|
|
52
|
+
readonly mono: string;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type ShadowTokens = {
|
|
56
|
+
readonly card: string;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export type SpacingTokens = {
|
|
60
|
+
readonly card: string;
|
|
61
|
+
};
|
|
62
|
+
|
|
49
63
|
export type CoreTokens = {
|
|
50
64
|
readonly color: ColorTokens;
|
|
51
65
|
readonly radius: RadiusTokens;
|
|
66
|
+
readonly font: FontTokens;
|
|
67
|
+
readonly shadow: ShadowTokens;
|
|
68
|
+
readonly spacing: SpacingTokens;
|
|
52
69
|
};
|
|
53
70
|
|
|
54
71
|
/** Erweiterung für App-eigene Token-Kategorien. Devs augmentieren:
|
|
@@ -139,4 +156,15 @@ export const cssVarTokens: Tokens = {
|
|
|
139
156
|
lg: "var(--radius-lg)",
|
|
140
157
|
xl: "var(--radius-xl)",
|
|
141
158
|
},
|
|
159
|
+
font: {
|
|
160
|
+
sans: "var(--font-sans)",
|
|
161
|
+
heading: "var(--font-heading)",
|
|
162
|
+
mono: "var(--font-mono)",
|
|
163
|
+
},
|
|
164
|
+
shadow: {
|
|
165
|
+
card: "var(--card-shadow)",
|
|
166
|
+
},
|
|
167
|
+
spacing: {
|
|
168
|
+
card: "var(--card-padding)",
|
|
169
|
+
},
|
|
142
170
|
} as Tokens;
|