@laconius/ui 0.1.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.
Files changed (85) hide show
  1. package/LICENSE +73 -0
  2. package/README.md +22 -0
  3. package/lib/module/button.js +76 -0
  4. package/lib/module/button.js.map +1 -0
  5. package/lib/module/config.js +33 -0
  6. package/lib/module/config.js.map +1 -0
  7. package/lib/module/feedback.js +81 -0
  8. package/lib/module/feedback.js.map +1 -0
  9. package/lib/module/hooks.js +107 -0
  10. package/lib/module/hooks.js.map +1 -0
  11. package/lib/module/index.js +18 -0
  12. package/lib/module/index.js.map +1 -0
  13. package/lib/module/kratos.js +130 -0
  14. package/lib/module/kratos.js.map +1 -0
  15. package/lib/module/media.js +61 -0
  16. package/lib/module/media.js.map +1 -0
  17. package/lib/module/package.json +1 -0
  18. package/lib/module/price.js +34 -0
  19. package/lib/module/price.js.map +1 -0
  20. package/lib/module/quantity-stepper.js +60 -0
  21. package/lib/module/quantity-stepper.js.map +1 -0
  22. package/lib/module/store.js +25 -0
  23. package/lib/module/store.js.map +1 -0
  24. package/lib/module/text.js +55 -0
  25. package/lib/module/text.js.map +1 -0
  26. package/lib/module/theme.js +40 -0
  27. package/lib/module/theme.js.map +1 -0
  28. package/lib/module/translations.js +15 -0
  29. package/lib/module/translations.js.map +1 -0
  30. package/lib/module/use-gallery.js +22 -0
  31. package/lib/module/use-gallery.js.map +1 -0
  32. package/lib/module/use-product-card.js +36 -0
  33. package/lib/module/use-product-card.js.map +1 -0
  34. package/lib/module/use-quantity-stepper.js +20 -0
  35. package/lib/module/use-quantity-stepper.js.map +1 -0
  36. package/lib/typescript/package.json +1 -0
  37. package/lib/typescript/src/button.d.ts +18 -0
  38. package/lib/typescript/src/button.d.ts.map +1 -0
  39. package/lib/typescript/src/config.d.ts +40 -0
  40. package/lib/typescript/src/config.d.ts.map +1 -0
  41. package/lib/typescript/src/feedback.d.ts +24 -0
  42. package/lib/typescript/src/feedback.d.ts.map +1 -0
  43. package/lib/typescript/src/hooks.d.ts +31 -0
  44. package/lib/typescript/src/hooks.d.ts.map +1 -0
  45. package/lib/typescript/src/index.d.ts +16 -0
  46. package/lib/typescript/src/index.d.ts.map +1 -0
  47. package/lib/typescript/src/kratos.d.ts +8 -0
  48. package/lib/typescript/src/kratos.d.ts.map +1 -0
  49. package/lib/typescript/src/media.d.ts +37 -0
  50. package/lib/typescript/src/media.d.ts.map +1 -0
  51. package/lib/typescript/src/price.d.ts +15 -0
  52. package/lib/typescript/src/price.d.ts.map +1 -0
  53. package/lib/typescript/src/quantity-stepper.d.ts +13 -0
  54. package/lib/typescript/src/quantity-stepper.d.ts.map +1 -0
  55. package/lib/typescript/src/store.d.ts +27 -0
  56. package/lib/typescript/src/store.d.ts.map +1 -0
  57. package/lib/typescript/src/text.d.ts +15 -0
  58. package/lib/typescript/src/text.d.ts.map +1 -0
  59. package/lib/typescript/src/theme.d.ts +71 -0
  60. package/lib/typescript/src/theme.d.ts.map +1 -0
  61. package/lib/typescript/src/translations.d.ts +13 -0
  62. package/lib/typescript/src/translations.d.ts.map +1 -0
  63. package/lib/typescript/src/use-gallery.d.ts +13 -0
  64. package/lib/typescript/src/use-gallery.d.ts.map +1 -0
  65. package/lib/typescript/src/use-product-card.d.ts +24 -0
  66. package/lib/typescript/src/use-product-card.d.ts.map +1 -0
  67. package/lib/typescript/src/use-quantity-stepper.d.ts +18 -0
  68. package/lib/typescript/src/use-quantity-stepper.d.ts.map +1 -0
  69. package/package.json +73 -0
  70. package/src/button.tsx +95 -0
  71. package/src/config.ts +53 -0
  72. package/src/feedback.tsx +95 -0
  73. package/src/hooks.ts +130 -0
  74. package/src/index.ts +62 -0
  75. package/src/kratos.ts +86 -0
  76. package/src/media.tsx +96 -0
  77. package/src/price.tsx +30 -0
  78. package/src/quantity-stepper.tsx +70 -0
  79. package/src/store.ts +35 -0
  80. package/src/text.tsx +49 -0
  81. package/src/theme.ts +90 -0
  82. package/src/translations.ts +12 -0
  83. package/src/use-gallery.ts +35 -0
  84. package/src/use-product-card.ts +47 -0
  85. package/src/use-quantity-stepper.ts +35 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"quantity-stepper.d.ts","sourceRoot":"","sources":["../../../src/quantity-stepper.tsx"],"names":[],"mappings":"AACA,OAAO,EAAoB,KAAK,SAAS,EAAE,KAAK,SAAS,EAAE,KAAK,SAAS,EAAE,MAAM,cAAc,CAAC;AAIhG,OAAO,EAAsB,KAAK,sBAAsB,EAAE,MAAM,2BAAwB,CAAC;AAIzF,MAAM,MAAM,oBAAoB,GAAG,sBAAsB,GAAG;IAC1D,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC7B,MAAM,CAAC,EAAE;QACP,SAAS,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;QACjC,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;QAC7B,MAAM,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;KAC/B,CAAC;CACH,CAAC;AAcF,wBAAgB,eAAe,CAAC,EAC9B,QAAgB,EAChB,KAAK,EACL,MAAM,EAAE,KAAK,EACb,GAAG,OAAO,EACX,EAAE,oBAAoB,+BAiCtB"}
@@ -0,0 +1,27 @@
1
+ import type { ColorSchemePreference } from './theme.js';
2
+ export type ThemeState = {
3
+ /** Unset means "whatever `ui.defaultTheme` says". */
4
+ activeThemeId?: string;
5
+ /** Unset means "whatever `ui.colorScheme` says". */
6
+ colorScheme?: ColorSchemePreference;
7
+ setTheme: (themeId: string) => void;
8
+ setColorScheme: (scheme: ColorSchemePreference) => void;
9
+ };
10
+ /**
11
+ * The runtime half of Spartacus' two-layer theming. React Native has no build-time SCSS swap, so
12
+ * the choice is state, and it survives a restart like any other preference.
13
+ */
14
+ export declare const useThemeStore: import("zustand").UseBoundStore<Omit<import("zustand").StoreApi<ThemeState>, "setState" | "persist"> & {
15
+ setState(partial: ThemeState | Partial<ThemeState> | ((state: ThemeState) => ThemeState | Partial<ThemeState>), replace?: false | undefined): unknown;
16
+ setState(state: ThemeState | ((state: ThemeState) => ThemeState), replace: true): unknown;
17
+ persist: {
18
+ setOptions: (options: Partial<import("zustand/middleware").PersistOptions<ThemeState, unknown, unknown>>) => void;
19
+ clearStorage: () => void;
20
+ rehydrate: () => Promise<void> | void;
21
+ hasHydrated: () => boolean;
22
+ onHydrate: (fn: (state: ThemeState) => void) => () => void;
23
+ onFinishHydration: (fn: (state: ThemeState) => void) => () => void;
24
+ getOptions: () => Partial<import("zustand/middleware").PersistOptions<ThemeState, unknown, unknown>>;
25
+ };
26
+ }>;
27
+ //# sourceMappingURL=store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../../src/store.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,YAAS,CAAC;AAErD,MAAM,MAAM,UAAU,GAAG;IACvB,qDAAqD;IACrD,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,oDAAoD;IACpD,WAAW,CAAC,EAAE,qBAAqB,CAAC;IACpC,QAAQ,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,cAAc,EAAE,CAAC,MAAM,EAAE,qBAAqB,KAAK,IAAI,CAAC;CACzD,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,aAAa;;;;;;;;;;;;EAezB,CAAC"}
@@ -0,0 +1,15 @@
1
+ import { type TextProps as RNTextProps } from 'react-native';
2
+ import type { TypographyRole } from './theme.js';
3
+ /**
4
+ * Primitives extend the platform prop type and spread the rest: a closed prop set broke on the
5
+ * first `numberOfLines` written in an app file.
6
+ */
7
+ export type TextProps = RNTextProps & {
8
+ variant?: TypographyRole;
9
+ muted?: boolean;
10
+ /** For text sitting on `colors.primary`. */
11
+ inverse?: boolean;
12
+ color?: string;
13
+ };
14
+ export declare function Text({ variant, muted, inverse, color, style, ...rest }: TextProps): import("react").JSX.Element;
15
+ //# sourceMappingURL=text.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"text.d.ts","sourceRoot":"","sources":["../../../src/text.tsx"],"names":[],"mappings":"AAAA,OAAO,EAA8B,KAAK,SAAS,IAAI,WAAW,EAAE,MAAM,cAAc,CAAC;AAGzF,OAAO,KAAK,EAAS,cAAc,EAAE,MAAM,YAAS,CAAC;AAErD;;;GAGG;AACH,MAAM,MAAM,SAAS,GAAG,WAAW,GAAG;IACpC,OAAO,CAAC,EAAE,cAAc,CAAC;IACzB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,4CAA4C;IAC5C,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAeF,wBAAgB,IAAI,CAAC,EACnB,OAAgB,EAChB,KAAK,EACL,OAAO,EACP,KAAK,EACL,KAAK,EACL,GAAG,IAAI,EACR,EAAE,SAAS,+BAWX"}
@@ -0,0 +1,71 @@
1
+ import type { TextStyle, ViewStyle } from 'react-native';
2
+ export type ThemeColors = {
3
+ background: string;
4
+ surface: string;
5
+ surfaceMuted: string;
6
+ text: string;
7
+ textMuted: string;
8
+ textInverse: string;
9
+ border: string;
10
+ primary: string;
11
+ onPrimary: string;
12
+ danger: string;
13
+ success: string;
14
+ };
15
+ export type TypographyRole = 'display' | 'heading' | 'subheading' | 'body' | 'label' | 'caption';
16
+ export type TypographyToken = Pick<TextStyle, 'fontSize' | 'lineHeight' | 'fontWeight' | 'letterSpacing'>;
17
+ /** Roles only, no numeric palette: the brand already has one and remapping it is work nobody asked for. */
18
+ export type Theme = {
19
+ colors: ThemeColors;
20
+ spacing: {
21
+ xs: number;
22
+ sm: number;
23
+ md: number;
24
+ lg: number;
25
+ xl: number;
26
+ xxl: number;
27
+ };
28
+ typography: Record<TypographyRole, TypographyToken>;
29
+ radii: {
30
+ sm: number;
31
+ md: number;
32
+ pill: number;
33
+ };
34
+ /** Two levels. `shadow*` on iOS, `elevation` on Android; both are set on each. */
35
+ elevation: {
36
+ card: ViewStyle;
37
+ modal: ViewStyle;
38
+ };
39
+ media: {
40
+ aspectRatio: {
41
+ product: number;
42
+ banner: number;
43
+ thumbnail: number;
44
+ };
45
+ };
46
+ fontFamily?: {
47
+ regular: string;
48
+ medium: string;
49
+ bold: string;
50
+ };
51
+ };
52
+ /** One theme, two schemes. `dark` is optional and falls back to `light`. */
53
+ export type ThemeEntry = {
54
+ name: string;
55
+ light: Theme;
56
+ dark?: Theme;
57
+ };
58
+ export type ColorSchemePreference = 'system' | 'light' | 'dark';
59
+ /**
60
+ * A complete theme, inheriting nothing. The compiler enumerates what is missing, which is why
61
+ * this is a separate function rather than a flag on `extendTheme`.
62
+ */
63
+ export declare function defineTheme(theme: Theme): Theme;
64
+ /** Deep merge onto an existing theme. */
65
+ export declare function extendTheme(base: Theme, overrides: ThemeOverrides): Theme;
66
+ export type ThemeOverrides = {
67
+ [K in keyof Theme]?: Theme[K] extends object ? {
68
+ [P in keyof Theme[K]]?: Partial<Theme[K][P]> | Theme[K][P];
69
+ } : Theme[K];
70
+ };
71
+ //# sourceMappingURL=theme.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"theme.d.ts","sourceRoot":"","sources":["../../../src/theme.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAEzD,MAAM,MAAM,WAAW,GAAG;IACxB,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,EAAE,MAAM,CAAC;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;CACjB,CAAC;AAEF,MAAM,MAAM,cAAc,GACtB,SAAS,GACT,SAAS,GACT,YAAY,GACZ,MAAM,GACN,OAAO,GACP,SAAS,CAAC;AAEd,MAAM,MAAM,eAAe,GAAG,IAAI,CAChC,SAAS,EACT,UAAU,GAAG,YAAY,GAAG,YAAY,GAAG,eAAe,CAC3D,CAAC;AAEF,2GAA2G;AAC3G,MAAM,MAAM,KAAK,GAAG;IAClB,MAAM,EAAE,WAAW,CAAC;IACpB,OAAO,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,CAAC;IACrF,UAAU,EAAE,MAAM,CAAC,cAAc,EAAE,eAAe,CAAC,CAAC;IACpD,KAAK,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC;IAChD,kFAAkF;IAClF,SAAS,EAAE;QAAE,IAAI,EAAE,SAAS,CAAC;QAAC,KAAK,EAAE,SAAS,CAAA;KAAE,CAAC;IACjD,KAAK,EAAE;QAAE,WAAW,EAAE;YAAE,OAAO,EAAE,MAAM,CAAC;YAAC,MAAM,EAAE,MAAM,CAAC;YAAC,SAAS,EAAE,MAAM,CAAA;SAAE,CAAA;KAAE,CAAC;IAC/E,UAAU,CAAC,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC;CAChE,CAAC;AAEF,4EAA4E;AAC5E,MAAM,MAAM,UAAU,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,KAAK,CAAC;IAAC,IAAI,CAAC,EAAE,KAAK,CAAA;CAAE,CAAC;AAEtE,MAAM,MAAM,qBAAqB,GAAG,QAAQ,GAAG,OAAO,GAAG,MAAM,CAAC;AAEhE;;;GAGG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,KAAK,GAAG,KAAK,CAE/C;AAED,yCAAyC;AACzC,wBAAgB,WAAW,CAAC,IAAI,EAAE,KAAK,EAAE,SAAS,EAAE,cAAc,GAAG,KAAK,CAEzE;AAED,MAAM,MAAM,cAAc,GAAG;KAC1B,CAAC,IAAI,MAAM,KAAK,CAAC,CAAC,EAAE,KAAK,CAAC,CAAC,CAAC,SAAS,MAAM,GACxC;SAAG,CAAC,IAAI,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;KAAE,GAC9D,KAAK,CAAC,CAAC,CAAC;CACb,CAAC"}
@@ -0,0 +1,13 @@
1
+ /** English strings for the defaults in this package. Other languages are the app's. */
2
+ export declare const defaultUiTranslations: {
3
+ en: {
4
+ ui: {
5
+ quantity: {
6
+ label: string;
7
+ increase: string;
8
+ decrease: string;
9
+ };
10
+ };
11
+ };
12
+ };
13
+ //# sourceMappingURL=translations.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"translations.d.ts","sourceRoot":"","sources":["../../../src/translations.ts"],"names":[],"mappings":"AAAA,uFAAuF;AACvF,eAAO,MAAM,qBAAqB;;;;;;;;;;CAUjC,CAAC"}
@@ -0,0 +1,13 @@
1
+ import { type ImageGroup, type Images } from '@laconius/core';
2
+ export type GalleryApi = {
3
+ /** One entry per slide, each a group keyed by format code. */
4
+ items: ImageGroup[];
5
+ index: number;
6
+ selected: ImageGroup | undefined;
7
+ select: (index: number) => void;
8
+ next: () => void;
9
+ previous: () => void;
10
+ };
11
+ /** The selected slide — the only state a gallery owns. Paging is the caller's layout decision. */
12
+ export declare function useGallery(images: Images | undefined): GalleryApi;
13
+ //# sourceMappingURL=use-gallery.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-gallery.d.ts","sourceRoot":"","sources":["../../../src/use-gallery.ts"],"names":[],"mappings":"AAAA,OAAO,EAAoB,KAAK,UAAU,EAAE,KAAK,MAAM,EAAE,MAAM,gBAAgB,CAAC;AAGhF,MAAM,MAAM,UAAU,GAAG;IACvB,8DAA8D;IAC9D,KAAK,EAAE,UAAU,EAAE,CAAC;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,UAAU,GAAG,SAAS,CAAC;IACjC,MAAM,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC,IAAI,EAAE,MAAM,IAAI,CAAC;IACjB,QAAQ,EAAE,MAAM,IAAI,CAAC;CACtB,CAAC;AAEF,kGAAkG;AAClG,wBAAgB,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,GAAG,UAAU,CAoBjE"}
@@ -0,0 +1,24 @@
1
+ import { type MediaContainer, type Price, type Product } from '@laconius/core';
2
+ /**
3
+ * Semantic, not a translation key: the key space belongs to `@laconius/product`, and this hook
4
+ * lives in the UI kit, which never reaches into a feature package's namespace.
5
+ */
6
+ export type ProductCardBadge = 'outOfStock' | 'lowStock';
7
+ export type ProductCardView = {
8
+ code?: string;
9
+ title: string;
10
+ price?: Price;
11
+ media: MediaContainer;
12
+ badge?: ProductCardBadge;
13
+ averageRating?: number;
14
+ numberOfReviews?: number;
15
+ };
16
+ /**
17
+ * Derivation only — no fetch, no navigation. A card is rendered by the row of a `FlatList`, so
18
+ * everything it needs comes off the product it was handed.
19
+ *
20
+ * `priceRange.minPrice` covers the multidimensional products whose `price` is absent, which is
21
+ * what the search endpoint returns for apparel.
22
+ */
23
+ export declare function useProductCard(product: Product | undefined): ProductCardView;
24
+ //# sourceMappingURL=use-product-card.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-product-card.d.ts","sourceRoot":"","sources":["../../../src/use-product-card.ts"],"names":[],"mappings":"AAAA,OAAO,EAAmB,KAAK,cAAc,EAAE,KAAK,KAAK,EAAE,KAAK,OAAO,EAAE,MAAM,gBAAgB,CAAC;AAGhG;;;GAGG;AACH,MAAM,MAAM,gBAAgB,GAAG,YAAY,GAAG,UAAU,CAAC;AAEzD,MAAM,MAAM,eAAe,GAAG;IAC5B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,KAAK,CAAC;IACd,KAAK,EAAE,cAAc,CAAC;IACtB,KAAK,CAAC,EAAE,gBAAgB,CAAC;IACzB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B,CAAC;AASF;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,OAAO,GAAG,SAAS,GAAG,eAAe,CAa5E"}
@@ -0,0 +1,18 @@
1
+ export type QuantityStepperOptions = {
2
+ value: number;
3
+ /** Defaults to 1: zero quantity is a removal, which is the cart's decision, not the stepper's. */
4
+ min?: number;
5
+ max?: number;
6
+ onChange: (value: number) => void;
7
+ };
8
+ export type QuantityStepperApi = {
9
+ value: number;
10
+ canIncrement: boolean;
11
+ canDecrement: boolean;
12
+ increment: () => void;
13
+ decrement: () => void;
14
+ setValue: (value: number) => void;
15
+ };
16
+ /** Clamping and the disabled edges — the only state a stepper owns. It does not know what a cart is. */
17
+ export declare function useQuantityStepper({ value, min, max, onChange, }: QuantityStepperOptions): QuantityStepperApi;
18
+ //# sourceMappingURL=use-quantity-stepper.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-quantity-stepper.d.ts","sourceRoot":"","sources":["../../../src/use-quantity-stepper.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,sBAAsB,GAAG;IACnC,KAAK,EAAE,MAAM,CAAC;IACd,kGAAkG;IAClG,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;CACnC,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,YAAY,EAAE,OAAO,CAAC;IACtB,YAAY,EAAE,OAAO,CAAC;IACtB,SAAS,EAAE,MAAM,IAAI,CAAC;IACtB,SAAS,EAAE,MAAM,IAAI,CAAC;IACtB,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;CACnC,CAAC;AAEF,wGAAwG;AACxG,wBAAgB,kBAAkB,CAAC,EACjC,KAAK,EACL,GAAO,EACP,GAAG,EACH,QAAQ,GACT,EAAE,sBAAsB,GAAG,kBAAkB,CAW7C"}
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "@laconius/ui",
3
+ "version": "0.1.0",
4
+ "description": "Laconius UI kit: the Kratos theme, runtime theming hooks, the component registry and the eight primitives.",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "main": "./lib/module/index.js",
8
+ "types": "./lib/typescript/src/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "source": "./src/index.ts",
12
+ "types": "./lib/typescript/src/index.d.ts",
13
+ "default": "./lib/module/index.js"
14
+ },
15
+ "./package.json": "./package.json"
16
+ },
17
+ "files": [
18
+ "src",
19
+ "lib",
20
+ "!**/*.test.*"
21
+ ],
22
+ "sideEffects": false,
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "peerDependencies": {
27
+ "@laconius/core": "0.1.0",
28
+ "expo": "*",
29
+ "expo-image": "*",
30
+ "expo-sqlite": "*",
31
+ "react": ">=19.0.0",
32
+ "react-native": "*",
33
+ "zustand": "^5"
34
+ },
35
+ "devDependencies": {
36
+ "@laconius/core": "0.1.0",
37
+ "@types/react": "~19.2.2",
38
+ "expo": "^57.0.18",
39
+ "expo-image": "^57.0.3",
40
+ "expo-sqlite": "^57.0.2",
41
+ "react": "19.2.3",
42
+ "react-native": "0.86.3",
43
+ "react-native-builder-bob": "^0.43.0",
44
+ "typescript": "~6.0.3",
45
+ "vitest": "^3.2.4",
46
+ "zustand": "^5.0.15"
47
+ },
48
+ "react-native-builder-bob": {
49
+ "source": "src",
50
+ "output": "lib",
51
+ "targets": [
52
+ [
53
+ "module",
54
+ {
55
+ "esm": true
56
+ }
57
+ ],
58
+ [
59
+ "typescript",
60
+ {
61
+ "project": "tsconfig.build.json"
62
+ }
63
+ ]
64
+ ],
65
+ "exclude": "**/*.test.{ts,tsx}"
66
+ },
67
+ "scripts": {
68
+ "build": "bob build",
69
+ "clean": "rm -rf lib",
70
+ "typecheck": "tsc --noEmit",
71
+ "test": "vitest run"
72
+ }
73
+ }
package/src/button.tsx ADDED
@@ -0,0 +1,95 @@
1
+ import type { ReactNode } from 'react';
2
+ import {
3
+ ActivityIndicator,
4
+ Pressable,
5
+ StyleSheet,
6
+ type PressableProps,
7
+ type StyleProp,
8
+ type TextStyle,
9
+ type ViewStyle,
10
+ } from 'react-native';
11
+
12
+ import { useStyles, useTheme, useUiComponent } from './hooks';
13
+ import { Text } from './text';
14
+ import type { Theme } from './theme';
15
+
16
+ export type ButtonVariant = 'primary' | 'secondary' | 'ghost';
17
+
18
+ export type ButtonProps = Omit<PressableProps, 'style' | 'children'> & {
19
+ title?: string;
20
+ variant?: ButtonVariant;
21
+ loading?: boolean;
22
+ /** Root style. */
23
+ style?: StyleProp<ViewStyle>;
24
+ /** Named inner parts — public, versioned API. */
25
+ styles?: { container?: StyleProp<ViewStyle>; label?: StyleProp<TextStyle> };
26
+ children?: ReactNode;
27
+ };
28
+
29
+ const createStyles = (theme: Theme) =>
30
+ StyleSheet.create({
31
+ base: {
32
+ minHeight: 44,
33
+ paddingHorizontal: theme.spacing.lg,
34
+ paddingVertical: theme.spacing.sm,
35
+ borderRadius: theme.radii.md,
36
+ borderWidth: StyleSheet.hairlineWidth,
37
+ borderColor: 'transparent',
38
+ flexDirection: 'row',
39
+ alignItems: 'center',
40
+ justifyContent: 'center',
41
+ gap: theme.spacing.sm,
42
+ },
43
+ primary: { backgroundColor: theme.colors.primary },
44
+ secondary: {
45
+ backgroundColor: theme.colors.surface,
46
+ borderColor: theme.colors.border,
47
+ },
48
+ ghost: { backgroundColor: 'transparent' },
49
+ pressed: { opacity: 0.7 },
50
+ disabled: { opacity: 0.5 },
51
+ });
52
+
53
+ export function Button({
54
+ title,
55
+ variant = 'primary',
56
+ loading = false,
57
+ disabled = false,
58
+ style,
59
+ styles: parts,
60
+ children,
61
+ ...rest
62
+ }: ButtonProps) {
63
+ const theme = useTheme();
64
+ const styles = useStyles(createStyles);
65
+ const Label = useUiComponent('Text', Text);
66
+ const isDisabled = disabled || loading;
67
+ const color = variant === 'primary' ? theme.colors.onPrimary : theme.colors.primary;
68
+
69
+ return (
70
+ <Pressable
71
+ accessibilityRole="button"
72
+ accessibilityLabel={title}
73
+ accessibilityState={{ disabled: isDisabled, busy: loading }}
74
+ disabled={isDisabled}
75
+ hitSlop={8}
76
+ style={({ pressed }) => [
77
+ styles.base,
78
+ styles[variant],
79
+ pressed && !isDisabled ? styles.pressed : null,
80
+ isDisabled ? styles.disabled : null,
81
+ parts?.container,
82
+ style,
83
+ ]}
84
+ {...rest}
85
+ >
86
+ {loading ? <ActivityIndicator color={color} size="small" /> : null}
87
+ {title ? (
88
+ <Label variant="label" color={color} style={parts?.label}>
89
+ {title}
90
+ </Label>
91
+ ) : null}
92
+ {children}
93
+ </Pressable>
94
+ );
95
+ }
package/src/config.ts ADDED
@@ -0,0 +1,53 @@
1
+ import type { LaconiusConfigChunk } from '@laconius/core';
2
+ import type { ComponentType } from 'react';
3
+
4
+ import { kratos } from './kratos';
5
+ import type { ColorSchemePreference, ThemeEntry } from './theme';
6
+ import { defaultUiTranslations } from './translations';
7
+
8
+ /**
9
+ * The registry key space, closed and typed — unlike `cms.components`, whose keys are open backend
10
+ * type codes ([ADR-0007](../../../docs/adr/0007-two-registries-two-contracts.md)). Feature
11
+ * packages add their own keys by declaration merging:
12
+ *
13
+ * ```ts
14
+ * declare module '@laconius/ui' {
15
+ * interface LaconiusUiComponents { ProductCard: unknown }
16
+ * }
17
+ * ```
18
+ *
19
+ * The values are unused: only the keys are the contract, and the registry stores
20
+ * `ComponentType<any>` because a replacement is checked against the props it is handed, not here.
21
+ */
22
+ export interface LaconiusUiComponents {
23
+ Text: unknown;
24
+ Button: unknown;
25
+ Media: unknown;
26
+ Price: unknown;
27
+ Divider: unknown;
28
+ Spinner: unknown;
29
+ EmptyState: unknown;
30
+ QuantityStepper: unknown;
31
+ }
32
+
33
+ export type LaconiusUiComponent = keyof LaconiusUiComponents;
34
+
35
+ declare module '@laconius/core' {
36
+ interface LaconiusUiConfig {
37
+ /** Deep-merged: an app adds a theme without erasing Kratos, and replaces Kratos by key. */
38
+ themes?: Record<string, ThemeEntry>;
39
+ defaultTheme?: string;
40
+ /** `'system'` reads `useColorScheme()`. Overridable at runtime through `useThemeSwitcher`. */
41
+ colorScheme?: ColorSchemePreference;
42
+ components?: Partial<Record<LaconiusUiComponent, ComponentType<any>>>;
43
+ }
44
+ }
45
+
46
+ export const defaultUiConfig: LaconiusConfigChunk = {
47
+ ui: {
48
+ themes: { kratos },
49
+ defaultTheme: 'kratos',
50
+ colorScheme: 'system',
51
+ },
52
+ i18n: { translations: defaultUiTranslations },
53
+ };
@@ -0,0 +1,95 @@
1
+ import type { ReactNode } from 'react';
2
+ import {
3
+ ActivityIndicator,
4
+ StyleSheet,
5
+ View,
6
+ type ActivityIndicatorProps,
7
+ type StyleProp,
8
+ type TextStyle,
9
+ type ViewProps,
10
+ type ViewStyle,
11
+ } from 'react-native';
12
+
13
+ import { Button, type ButtonProps } from './button';
14
+ import { useStyles, useTheme, useUiComponent } from './hooks';
15
+ import { Text } from './text';
16
+ import type { Theme } from './theme';
17
+
18
+ export type DividerProps = ViewProps & { inset?: number };
19
+
20
+ const createDividerStyles = (theme: Theme) =>
21
+ StyleSheet.create({
22
+ root: { height: StyleSheet.hairlineWidth, backgroundColor: theme.colors.border },
23
+ });
24
+
25
+ export function Divider({ inset = 0, style, ...rest }: DividerProps) {
26
+ const styles = useStyles(createDividerStyles);
27
+ return (
28
+ <View
29
+ accessibilityElementsHidden
30
+ importantForAccessibility="no-hide-descendants"
31
+ style={[styles.root, inset ? { marginHorizontal: inset } : null, style]}
32
+ {...rest}
33
+ />
34
+ );
35
+ }
36
+
37
+ export type SpinnerProps = ActivityIndicatorProps;
38
+
39
+ export function Spinner({ color, ...rest }: SpinnerProps) {
40
+ const theme = useTheme();
41
+ return <ActivityIndicator color={color ?? theme.colors.primary} {...rest} />;
42
+ }
43
+
44
+ export type EmptyStateProps = {
45
+ title: string;
46
+ message?: string;
47
+ /** Rendered as a `Button`; omit it for a purely informational state. */
48
+ action?: Pick<ButtonProps, 'title' | 'onPress' | 'variant' | 'loading'>;
49
+ icon?: ReactNode;
50
+ style?: StyleProp<ViewStyle>;
51
+ styles?: {
52
+ container?: StyleProp<ViewStyle>;
53
+ title?: StyleProp<TextStyle>;
54
+ message?: StyleProp<TextStyle>;
55
+ };
56
+ };
57
+
58
+ const createEmptyStateStyles = (theme: Theme) =>
59
+ StyleSheet.create({
60
+ root: {
61
+ alignItems: 'center',
62
+ justifyContent: 'center',
63
+ gap: theme.spacing.sm,
64
+ padding: theme.spacing.xl,
65
+ },
66
+ text: { textAlign: 'center' },
67
+ action: { marginTop: theme.spacing.sm },
68
+ });
69
+
70
+ export function EmptyState({
71
+ title,
72
+ message,
73
+ action,
74
+ icon,
75
+ style,
76
+ styles: parts,
77
+ }: EmptyStateProps) {
78
+ const styles = useStyles(createEmptyStateStyles);
79
+ const Label = useUiComponent('Text', Text);
80
+ const Action = useUiComponent('Button', Button);
81
+ return (
82
+ <View style={[styles.root, parts?.container, style]}>
83
+ {icon}
84
+ <Label variant="subheading" style={[styles.text, parts?.title]}>
85
+ {title}
86
+ </Label>
87
+ {message ? (
88
+ <Label muted style={[styles.text, parts?.message]}>
89
+ {message}
90
+ </Label>
91
+ ) : null}
92
+ {action ? <Action variant="secondary" style={styles.action} {...action} /> : null}
93
+ </View>
94
+ );
95
+ }
package/src/hooks.ts ADDED
@@ -0,0 +1,130 @@
1
+ import { useLaconiusConfig, type LaconiusConfig } from '@laconius/core';
2
+ import type { ComponentType } from 'react';
3
+ import { useColorScheme } from 'react-native';
4
+
5
+ import type { LaconiusUiComponent } from './config';
6
+ import { kratos } from './kratos';
7
+ import { useThemeStore } from './store';
8
+ import type { ColorSchemePreference, Theme, ThemeEntry } from './theme';
9
+
10
+ const resolved = new WeakMap<LaconiusConfig, Map<string, Theme>>();
11
+ const warned = new Set<string>();
12
+
13
+ function warnOnce(message: string): void {
14
+ if (process.env.NODE_ENV === 'production' || warned.has(message)) return;
15
+ warned.add(message);
16
+ console.warn(`[laconius] ${message}`);
17
+ }
18
+
19
+ /**
20
+ * Cached per config and per `themeId:scheme`, because `useStyles` memoizes on theme *identity*:
21
+ * a theme rebuilt on every render would rebuild every `StyleSheet` with it.
22
+ */
23
+ function resolveTheme(
24
+ config: LaconiusConfig,
25
+ themeId: string,
26
+ scheme: 'light' | 'dark',
27
+ ): Theme {
28
+ let cache = resolved.get(config);
29
+ if (!cache) {
30
+ cache = new Map();
31
+ resolved.set(config, cache);
32
+ }
33
+ const key = `${themeId}:${scheme}`;
34
+ const hit = cache.get(key);
35
+ if (hit) return hit;
36
+
37
+ const themes = config.ui?.themes;
38
+ let entry: ThemeEntry | undefined = themes?.[themeId];
39
+ if (!entry) {
40
+ warnOnce(`Unknown theme "${themeId}"; falling back to the first configured theme.`);
41
+ entry = themes ? Object.values(themes)[0] : undefined;
42
+ }
43
+ const resolvedEntry = entry ?? kratos;
44
+ const theme = (scheme === 'dark' ? resolvedEntry.dark : undefined) ?? resolvedEntry.light;
45
+ cache.set(key, theme);
46
+ return theme;
47
+ }
48
+
49
+ function useActiveScheme(): 'light' | 'dark' {
50
+ const config = useLaconiusConfig();
51
+ const system = useColorScheme();
52
+ const override = useThemeStore((state) => state.colorScheme);
53
+ const preference: ColorSchemePreference = override ?? config.ui?.colorScheme ?? 'system';
54
+ if (preference === 'system') return system === 'dark' ? 'dark' : 'light';
55
+ return preference;
56
+ }
57
+
58
+ function useActiveThemeId(): string {
59
+ const config = useLaconiusConfig();
60
+ const activeThemeId = useThemeStore((state) => state.activeThemeId);
61
+ return activeThemeId ?? config.ui?.defaultTheme ?? 'kratos';
62
+ }
63
+
64
+ /** The configured theme resolved against the effective colour scheme. */
65
+ export function useTheme(): Theme {
66
+ const config = useLaconiusConfig();
67
+ return resolveTheme(config, useActiveThemeId(), useActiveScheme());
68
+ }
69
+
70
+ const styles = new WeakMap<object, WeakMap<Theme, unknown>>();
71
+
72
+ /**
73
+ * Style factories are declared outside the component and memoized per theme identity, so a
74
+ * `FlatList` of a hundred rows builds one `StyleSheet`, not a hundred.
75
+ */
76
+ export function useStyles<T>(factory: (theme: Theme) => T): T {
77
+ const theme = useTheme();
78
+ let perTheme = styles.get(factory);
79
+ if (!perTheme) {
80
+ perTheme = new WeakMap();
81
+ styles.set(factory, perTheme);
82
+ }
83
+ let sheet = perTheme.get(theme) as T | undefined;
84
+ if (sheet === undefined) {
85
+ sheet = factory(theme);
86
+ perTheme.set(theme, sheet);
87
+ }
88
+ return sheet;
89
+ }
90
+
91
+ export type ThemeSwitcher = {
92
+ themes: { id: string; name: string }[];
93
+ activeId: string;
94
+ setTheme: (themeId: string) => void;
95
+ colorScheme: ColorSchemePreference;
96
+ setColorScheme: (scheme: ColorSchemePreference) => void;
97
+ };
98
+
99
+ /**
100
+ * Laconius ships no `<ThemeSwitcher>` component: picking a theme is one row in an app's settings
101
+ * screen, and the row belongs to the app.
102
+ */
103
+ export function useThemeSwitcher(): ThemeSwitcher {
104
+ const config = useLaconiusConfig();
105
+ const setTheme = useThemeStore((state) => state.setTheme);
106
+ const setColorScheme = useThemeStore((state) => state.setColorScheme);
107
+ const override = useThemeStore((state) => state.colorScheme);
108
+ return {
109
+ themes: Object.entries(config.ui?.themes ?? {}).map(([id, entry]) => ({
110
+ id,
111
+ name: entry.name,
112
+ })),
113
+ activeId: useActiveThemeId(),
114
+ setTheme,
115
+ colorScheme: override ?? config.ui?.colorScheme ?? 'system',
116
+ setColorScheme,
117
+ };
118
+ }
119
+
120
+ /**
121
+ * The registry lookup, used by the components Laconius renders itself. Anything the app mounts
122
+ * directly it imports directly, and the registry never sees it.
123
+ */
124
+ export function useUiComponent<P>(
125
+ key: LaconiusUiComponent,
126
+ fallback: ComponentType<P>,
127
+ ): ComponentType<P> {
128
+ const config = useLaconiusConfig();
129
+ return (config.ui?.components?.[key] as ComponentType<P> | undefined) ?? fallback;
130
+ }