@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.317.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.317.0",
19
- "@cosmicdrift/kumiko-headless": "0.317.0",
20
- "@cosmicdrift/kumiko-types": "0.317.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.317.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
- const previous = Math.max(currentStep - 1, 0);
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
  />
@@ -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
- /** Primäre API für Feature-UI. `t("key", params)` versucht in dieser
129
- * Reihenfolge:
130
- * 1. App-Resolver (z.B. i18next)
131
- * 2. Plugin-Fallback-Bundles für current-locale
132
- * 3. Plugin-Fallback-Bundles für fallbackLocale
133
- * 4. Key as-is
134
- * Interpolation für Platzhalter `{name}` passiert unabhängig von der
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
- // 1. App-provided resolver zuerst. Convention: wenn der App-Resolver
161
- // den Key nicht kennt, gibt er den Key zurück — das ist die
162
- // Fallback-Einladung an Plugin-Bundles. i18next verhält sich
163
- // exakt so per default.
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
- // 2. + 3. Plugin-Bundles durchlaufen für current + fallback-locale.
168
- const primaryLookup = locale;
169
- // `primaryLookup` könnte z.B. "de-AT" sein — in den Bundles stehen
170
- // oft nur die Language-Roots ("de"). Wir versuchen beide.
171
- const languageRoot = primaryLookup.split("-")[0] ?? primaryLookup;
172
- const localesToTry = [primaryLookup, languageRoot, ctx.fallbackLocale];
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
- for (const bundle of ctx.fallbackBundles) {
175
- for (const localeToTry of localesToTry) {
176
- const value = bundle[localeToTry]?.[key];
177
- if (value !== undefined) return interpolate(value, params);
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
- // 4. Nichts gefunden — key zurück, wie der Default-Resolver auch.
182
- return key;
183
- },
184
- [ctx, locale],
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
- if (ctx === undefined) return key;
232
- const resolved = ctx.resolver.translate(key, params);
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,
@@ -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?: "default" | "warn" | "danger";
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. Not clickable (step-jump validation is a
1107
- * separate concern, kumiko-framework#1966). Implementations render a
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;