@sublime-ui/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 (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +40 -0
  3. package/dist/index.d.ts +7 -0
  4. package/dist/index.js +1 -0
  5. package/dist/layout/Row.d.ts +6 -0
  6. package/dist/layout/Row.js +29 -0
  7. package/dist/layout/Screen.d.ts +6 -0
  8. package/dist/layout/Screen.js +7 -0
  9. package/dist/layout/Screen.types.d.ts +10 -0
  10. package/dist/layout/Screen.types.js +0 -0
  11. package/dist/layout/Spacer.d.ts +6 -0
  12. package/dist/layout/Spacer.js +12 -0
  13. package/dist/layout/Stack.d.ts +6 -0
  14. package/dist/layout/Stack.js +28 -0
  15. package/dist/layout/Stack.types.d.ts +19 -0
  16. package/dist/layout/Stack.types.js +0 -0
  17. package/dist/layout/index.d.ts +7 -0
  18. package/dist/layout/index.js +6 -0
  19. package/dist/navigation/book.d.ts +16 -0
  20. package/dist/navigation/book.js +14 -0
  21. package/dist/navigation/bridge.native.d.ts +5 -0
  22. package/dist/navigation/bridge.native.js +14 -0
  23. package/dist/navigation/bridge.web.d.ts +5 -0
  24. package/dist/navigation/bridge.web.js +15 -0
  25. package/dist/navigation/index.d.ts +5 -0
  26. package/dist/navigation/index.js +4 -0
  27. package/dist/navigation/nav.types.d.ts +15 -0
  28. package/dist/navigation/nav.types.js +0 -0
  29. package/dist/navigation/types.d.ts +30 -0
  30. package/dist/navigation/types.js +0 -0
  31. package/dist/navigation/use-nav.d.ts +13 -0
  32. package/dist/navigation/use-nav.js +15 -0
  33. package/package.json +61 -0
  34. package/src/index.ts +1 -0
  35. package/src/layout/Row.native.tsx +34 -0
  36. package/src/layout/Row.tsx +34 -0
  37. package/src/layout/Screen.native.tsx +12 -0
  38. package/src/layout/Screen.tsx +9 -0
  39. package/src/layout/Screen.types.ts +8 -0
  40. package/src/layout/Spacer.native.tsx +12 -0
  41. package/src/layout/Spacer.tsx +11 -0
  42. package/src/layout/Stack.native.tsx +33 -0
  43. package/src/layout/Stack.tsx +33 -0
  44. package/src/layout/Stack.types.ts +26 -0
  45. package/src/layout/index.ts +6 -0
  46. package/src/navigation/book.ts +35 -0
  47. package/src/navigation/bridge.native.ts +16 -0
  48. package/src/navigation/bridge.web.ts +17 -0
  49. package/src/navigation/index.ts +4 -0
  50. package/src/navigation/nav.types.ts +21 -0
  51. package/src/navigation/types.ts +37 -0
  52. package/src/navigation/use-nav.ts +15 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aaron Mkandawire and Sublime UI contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,40 @@
1
+ # @sublime-ui/ui
2
+
3
+ Cross-platform **navigation** and **layout primitives** for
4
+ [Sublime UI](https://sublime-ui.github.io/sublime-ui/).
5
+
6
+ Navigation is authored as a **storybook** — a `book` of `page`s, written once per
7
+ platform — and compiled ahead of time (by `@sublime-ui/devkit`'s `build:nav`)
8
+ into idiomatic React Navigation (mobile) and react-router (web), with a fully
9
+ typed route map.
10
+
11
+ ```ts
12
+ import { book, page, link } from '@sublime-ui/ui/navigation';
13
+ import { Home } from '../screens/web/Home';
14
+
15
+ export default book({
16
+ format: 'sidebar', // web: 'sidebar' | 'stack' | 'tabs'
17
+ pages: {
18
+ home: page(Home, { title: 'Home' }),
19
+ },
20
+ });
21
+ ```
22
+
23
+ At runtime, `useNav()` gives you type-checked navigation: `turnTo`, `turnBack`,
24
+ `current`, and `params<T>()`. Layout primitives (`Screen`, `Stack`, `Row`,
25
+ `Spacer`) render natively on each platform.
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ npm install @sublime-ui/ui
31
+ ```
32
+
33
+ ## Documentation
34
+
35
+ The storybook model and typed navigation:
36
+ **https://sublime-ui.github.io/sublime-ui/docs/navigation/storybook**
37
+
38
+ ## License
39
+
40
+ MIT
@@ -0,0 +1,7 @@
1
+ export { Screen } from './layout/Screen.js';
2
+ export { ScreenProps } from './layout/Screen.types.js';
3
+ export { Stack } from './layout/Stack.js';
4
+ export { Row } from './layout/Row.js';
5
+ export { Spacer } from './layout/Spacer.js';
6
+ export { FlexAlign, FlexJustify, FlexProps, RowProps, SpacerProps } from './layout/Stack.types.js';
7
+ import 'react';
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export * from "./layout";
@@ -0,0 +1,6 @@
1
+ import * as react from 'react';
2
+ import { RowProps } from './Stack.types.js';
3
+
4
+ declare function Row({ children, gap, align, justify, wrap, testID }: RowProps): react.JSX.Element;
5
+
6
+ export { Row };
@@ -0,0 +1,29 @@
1
+ import { jsx } from "react/jsx-runtime";
2
+ const ALIGN = {
3
+ start: "flex-start",
4
+ center: "center",
5
+ end: "flex-end",
6
+ stretch: "stretch"
7
+ };
8
+ const JUSTIFY = {
9
+ start: "flex-start",
10
+ center: "center",
11
+ end: "flex-end",
12
+ between: "space-between",
13
+ around: "space-around",
14
+ evenly: "space-evenly"
15
+ };
16
+ function Row({ children, gap, align, justify, wrap, testID }) {
17
+ const style = {
18
+ display: "flex",
19
+ flexDirection: "row",
20
+ flexWrap: wrap ? "wrap" : void 0,
21
+ gap,
22
+ alignItems: align ? ALIGN[align] : void 0,
23
+ justifyContent: justify ? JUSTIFY[justify] : void 0
24
+ };
25
+ return /* @__PURE__ */ jsx("div", { "data-testid": testID, style, children });
26
+ }
27
+ export {
28
+ Row
29
+ };
@@ -0,0 +1,6 @@
1
+ import * as react from 'react';
2
+ import { ScreenProps } from './Screen.types.js';
3
+
4
+ declare function Screen({ children, padded, testID }: ScreenProps): react.JSX.Element;
5
+
6
+ export { Screen };
@@ -0,0 +1,7 @@
1
+ import { jsx } from "react/jsx-runtime";
2
+ function Screen({ children, padded = true, testID }) {
3
+ return /* @__PURE__ */ jsx("main", { "data-testid": testID, style: { padding: padded ? 16 : 0, minHeight: "100%" }, children });
4
+ }
5
+ export {
6
+ Screen
7
+ };
@@ -0,0 +1,10 @@
1
+ import { ReactNode } from 'react';
2
+
3
+ interface ScreenProps {
4
+ children: ReactNode;
5
+ scroll?: boolean;
6
+ padded?: boolean;
7
+ testID?: string;
8
+ }
9
+
10
+ export type { ScreenProps };
File without changes
@@ -0,0 +1,6 @@
1
+ import * as react from 'react';
2
+ import { SpacerProps } from './Stack.types.js';
3
+
4
+ declare function Spacer({ size }: SpacerProps): react.JSX.Element;
5
+
6
+ export { Spacer };
@@ -0,0 +1,12 @@
1
+ import { jsx } from "react/jsx-runtime";
2
+ function Spacer({ size }) {
3
+ const style = {
4
+ flex: size === void 0 ? 1 : void 0,
5
+ height: size,
6
+ width: size
7
+ };
8
+ return /* @__PURE__ */ jsx("div", { style });
9
+ }
10
+ export {
11
+ Spacer
12
+ };
@@ -0,0 +1,6 @@
1
+ import * as react from 'react';
2
+ import { FlexProps } from './Stack.types.js';
3
+
4
+ declare function Stack({ children, gap, align, justify, testID }: FlexProps): react.JSX.Element;
5
+
6
+ export { Stack };
@@ -0,0 +1,28 @@
1
+ import { jsx } from "react/jsx-runtime";
2
+ const ALIGN = {
3
+ start: "flex-start",
4
+ center: "center",
5
+ end: "flex-end",
6
+ stretch: "stretch"
7
+ };
8
+ const JUSTIFY = {
9
+ start: "flex-start",
10
+ center: "center",
11
+ end: "flex-end",
12
+ between: "space-between",
13
+ around: "space-around",
14
+ evenly: "space-evenly"
15
+ };
16
+ function Stack({ children, gap, align, justify, testID }) {
17
+ const style = {
18
+ display: "flex",
19
+ flexDirection: "column",
20
+ gap,
21
+ alignItems: align ? ALIGN[align] : void 0,
22
+ justifyContent: justify ? JUSTIFY[justify] : void 0
23
+ };
24
+ return /* @__PURE__ */ jsx("div", { "data-testid": testID, style, children });
25
+ }
26
+ export {
27
+ Stack
28
+ };
@@ -0,0 +1,19 @@
1
+ import { ReactNode } from 'react';
2
+
3
+ type FlexAlign = 'start' | 'center' | 'end' | 'stretch';
4
+ type FlexJustify = 'start' | 'center' | 'end' | 'between' | 'around' | 'evenly';
5
+ interface FlexProps {
6
+ children: ReactNode;
7
+ gap?: number;
8
+ align?: FlexAlign;
9
+ justify?: FlexJustify;
10
+ testID?: string;
11
+ }
12
+ interface RowProps extends FlexProps {
13
+ wrap?: boolean;
14
+ }
15
+ interface SpacerProps {
16
+ size?: number;
17
+ }
18
+
19
+ export type { FlexAlign, FlexJustify, FlexProps, RowProps, SpacerProps };
File without changes
@@ -0,0 +1,7 @@
1
+ export { Screen } from './Screen.js';
2
+ export { ScreenProps } from './Screen.types.js';
3
+ export { Stack } from './Stack.js';
4
+ export { Row } from './Row.js';
5
+ export { Spacer } from './Spacer.js';
6
+ export { FlexAlign, FlexJustify, FlexProps, RowProps, SpacerProps } from './Stack.types.js';
7
+ import 'react';
@@ -0,0 +1,6 @@
1
+ export * from "./Screen";
2
+ export * from "./Screen.types";
3
+ export * from "./Stack";
4
+ export * from "./Row";
5
+ export * from "./Spacer";
6
+ export * from "./Stack.types";
@@ -0,0 +1,16 @@
1
+ import { PrintFormat, Entry, BookDef, PageDef, LinkDef, RouteMap, PageOptions } from './types.js';
2
+
3
+ declare function page<P = void>(component: unknown, options?: PageOptions): PageDef<P>;
4
+ declare function link<RM extends RouteMap>(book: BookDef<PrintFormat, RM>, options?: PageOptions): LinkDef<RM>;
5
+ type RoutesOf<Pages extends Record<string, Entry>> = {
6
+ [K in keyof Pages as Pages[K] extends PageDef<any> ? K : never]: Pages[K] extends PageDef<infer P> ? P : never;
7
+ } & UnionToIntersection<{
8
+ [K in keyof Pages]: Pages[K] extends LinkDef<infer RM> ? RM : {};
9
+ }[keyof Pages]>;
10
+ type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never;
11
+ declare function book<F extends PrintFormat, Pages extends Record<string, Entry>>(def: {
12
+ format: F;
13
+ pages: Pages;
14
+ }): BookDef<F, RoutesOf<Pages> & RouteMap>;
15
+
16
+ export { book, link, page };
@@ -0,0 +1,14 @@
1
+ function page(component, options = {}) {
2
+ return { kind: "page", component, options };
3
+ }
4
+ function link(book2, options = {}) {
5
+ return { kind: "link", book: book2, options };
6
+ }
7
+ function book(def) {
8
+ return { kind: "book", format: def.format, pages: def.pages };
9
+ }
10
+ export {
11
+ book,
12
+ link,
13
+ page
14
+ };
@@ -0,0 +1,5 @@
1
+ import { Nav } from './nav.types.js';
2
+
3
+ declare function useNativeNav(): Nav;
4
+
5
+ export { useNativeNav };
@@ -0,0 +1,14 @@
1
+ import { useNavigation, useRoute } from "@react-navigation/native";
2
+ function useNativeNav() {
3
+ const navigation = useNavigation();
4
+ const route = useRoute();
5
+ return {
6
+ turnTo: (name, params) => navigation.navigate(name, params),
7
+ turnBack: () => navigation.goBack(),
8
+ current: () => route.name,
9
+ params: () => route.params ?? {}
10
+ };
11
+ }
12
+ export {
13
+ useNativeNav
14
+ };
@@ -0,0 +1,5 @@
1
+ import { Nav } from './nav.types.js';
2
+
3
+ declare function useWebNav(pathOf: (name: string, params?: unknown) => string, nameOf: (path: string) => string): Nav;
4
+
5
+ export { useWebNav };
@@ -0,0 +1,15 @@
1
+ import { useNavigate, useLocation, useParams } from "react-router-dom";
2
+ function useWebNav(pathOf, nameOf) {
3
+ const navigate = useNavigate();
4
+ const location = useLocation();
5
+ const params = useParams();
6
+ return {
7
+ turnTo: (name, p) => navigate(pathOf(name, p)),
8
+ turnBack: () => navigate(-1),
9
+ current: () => nameOf(location.pathname),
10
+ params: () => params
11
+ };
12
+ }
13
+ export {
14
+ useWebNav
15
+ };
@@ -0,0 +1,5 @@
1
+ export { book, link, page } from './book.js';
2
+ export { NavContext, NavProvider, useNav } from './use-nav.js';
3
+ export { BookDef, Entry, LinkDef, MobileFormat, PageDef, PageOptions, PrintFormat, RouteMap, WebFormat } from './types.js';
4
+ export { Nav, TypedNav } from './nav.types.js';
5
+ import 'react';
@@ -0,0 +1,4 @@
1
+ export * from "./book";
2
+ export * from "./use-nav";
3
+ export * from "./types";
4
+ export * from "./nav.types";
@@ -0,0 +1,15 @@
1
+ interface Nav {
2
+ turnTo(name: string, params?: unknown): void;
3
+ turnBack(): void;
4
+ current(): string;
5
+ params<T = unknown>(): T;
6
+ }
7
+ type NoParams<P> = [P] extends [void] ? true : P extends undefined ? true : false;
8
+ interface TypedNav<RM extends object> {
9
+ turnTo<K extends keyof RM & string>(...args: NoParams<RM[K]> extends true ? [name: K] : [name: K, params: RM[K]]): void;
10
+ turnBack(): void;
11
+ current(): keyof RM & string;
12
+ params<K extends keyof RM & string>(): RM[K];
13
+ }
14
+
15
+ export type { Nav, TypedNav };
File without changes
@@ -0,0 +1,30 @@
1
+ type MobileFormat = 'drawer' | 'stack' | 'bottomNav';
2
+ type WebFormat = 'sidebar' | 'stack' | 'tabs';
3
+ type PrintFormat = MobileFormat | WebFormat;
4
+ interface PageOptions {
5
+ title?: string;
6
+ icon?: string;
7
+ path?: string;
8
+ initial?: boolean;
9
+ }
10
+ interface PageDef<P = void> {
11
+ readonly kind: 'page';
12
+ readonly component: unknown;
13
+ readonly options: PageOptions;
14
+ readonly __params?: P;
15
+ }
16
+ interface LinkDef<RM extends RouteMap = RouteMap> {
17
+ readonly kind: 'link';
18
+ readonly book: BookDef<PrintFormat, RM>;
19
+ readonly options: PageOptions;
20
+ }
21
+ type Entry = PageDef<any> | LinkDef<any>;
22
+ type RouteMap = Record<string, unknown>;
23
+ interface BookDef<F extends PrintFormat, RM extends RouteMap> {
24
+ readonly kind: 'book';
25
+ readonly format: F;
26
+ readonly pages: Record<string, Entry>;
27
+ readonly __routes?: RM;
28
+ }
29
+
30
+ export type { BookDef, Entry, LinkDef, MobileFormat, PageDef, PageOptions, PrintFormat, RouteMap, WebFormat };
File without changes
@@ -0,0 +1,13 @@
1
+ import * as react from 'react';
2
+ import { ReactNode } from 'react';
3
+ import { Nav, TypedNav } from './nav.types.js';
4
+ import { RouteMap } from './types.js';
5
+
6
+ declare const NavContext: react.Context<Nav | null>;
7
+ declare function NavProvider(props: {
8
+ value: Nav;
9
+ children: ReactNode;
10
+ }): react.FunctionComponentElement<react.ProviderProps<Nav | null>>;
11
+ declare function useNav<RM extends object = RouteMap>(): TypedNav<RM>;
12
+
13
+ export { NavContext, NavProvider, useNav };
@@ -0,0 +1,15 @@
1
+ import { createContext, createElement, useContext } from "react";
2
+ const NavContext = createContext(null);
3
+ function NavProvider(props) {
4
+ return createElement(NavContext.Provider, { value: props.value }, props.children);
5
+ }
6
+ function useNav() {
7
+ const nav = useContext(NavContext);
8
+ if (nav === null) throw new Error("useNav() must be used within <Navigation> (NavProvider missing)");
9
+ return nav;
10
+ }
11
+ export {
12
+ NavContext,
13
+ NavProvider,
14
+ useNav
15
+ };
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@sublime-ui/ui",
3
+ "version": "0.1.0",
4
+ "description": "Cross-platform navigation (storybook → React Navigation / react-router) and layout primitives for Sublime UI.",
5
+ "keywords": ["sublime-ui", "navigation", "react-navigation", "react-router", "layout", "storybook", "cross-platform", "typescript"],
6
+ "homepage": "https://sublime-ui.github.io/sublime-ui/",
7
+ "bugs": "https://github.com/sublime-ui/sublime-ui/issues",
8
+ "repository": { "type": "git", "url": "git+https://github.com/sublime-ui/sublime-ui.git", "directory": "ui" },
9
+ "license": "MIT",
10
+ "author": "Aaron Mkandawire",
11
+ "publishConfig": { "access": "public" },
12
+ "type": "module",
13
+ "exports": {
14
+ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
15
+ "./navigation": { "types": "./dist/navigation/index.d.ts", "import": "./dist/navigation/index.js" },
16
+ "./navigation/bridge.native": { "types": "./dist/navigation/bridge.native.d.ts", "import": "./dist/navigation/bridge.native.js" },
17
+ "./navigation/bridge.web": { "types": "./dist/navigation/bridge.web.d.ts", "import": "./dist/navigation/bridge.web.js" }
18
+ },
19
+ "react-native": "./src/index.ts",
20
+ "files": ["dist", "src"],
21
+ "scripts": {
22
+ "build": "tsup",
23
+ "typecheck": "tsc --noEmit",
24
+ "test": "vitest run --passWithNoTests",
25
+ "lint": "eslint src"
26
+ },
27
+ "peerDependencies": {
28
+ "react": ">=18",
29
+ "react-native": ">=0.74",
30
+ "@react-navigation/native": ">=6",
31
+ "@react-navigation/native-stack": ">=6",
32
+ "@react-navigation/bottom-tabs": ">=6",
33
+ "@react-navigation/drawer": ">=6",
34
+ "react-native-safe-area-context": ">=4",
35
+ "react-router-dom": ">=6"
36
+ },
37
+ "peerDependenciesMeta": {
38
+ "react-native": { "optional": true },
39
+ "@react-navigation/native": { "optional": true },
40
+ "@react-navigation/native-stack": { "optional": true },
41
+ "@react-navigation/bottom-tabs": { "optional": true },
42
+ "@react-navigation/drawer": { "optional": true },
43
+ "react-native-safe-area-context": { "optional": true },
44
+ "react-router-dom": { "optional": true }
45
+ },
46
+ "devDependencies": {
47
+ "@react-navigation/native": "^6.1.18",
48
+ "@react-navigation/native-stack": "^6.11.0",
49
+ "@react-navigation/bottom-tabs": "^6.6.1",
50
+ "@react-navigation/drawer": "^6.7.2",
51
+ "@testing-library/react": "^16.0.1",
52
+ "@types/node": "^22",
53
+ "@types/react": "^18.3.12",
54
+ "jsdom": "^25.0.1",
55
+ "react": "^18.3.1",
56
+ "react-dom": "^18.3.1",
57
+ "react-native": "^0.76.1",
58
+ "react-native-safe-area-context": "^4.14.0",
59
+ "react-router-dom": "^6.27.0"
60
+ }
61
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * from './layout';
@@ -0,0 +1,34 @@
1
+ import { View } from 'react-native';
2
+ import type { ViewStyle } from 'react-native';
3
+ import type { FlexAlign, FlexJustify, RowProps } from './Stack.types';
4
+
5
+ const ALIGN: Record<FlexAlign, ViewStyle['alignItems']> = {
6
+ start: 'flex-start',
7
+ center: 'center',
8
+ end: 'flex-end',
9
+ stretch: 'stretch',
10
+ };
11
+
12
+ const JUSTIFY: Record<FlexJustify, ViewStyle['justifyContent']> = {
13
+ start: 'flex-start',
14
+ center: 'center',
15
+ end: 'flex-end',
16
+ between: 'space-between',
17
+ around: 'space-around',
18
+ evenly: 'space-evenly',
19
+ };
20
+
21
+ export function Row({ children, gap, align, justify, wrap, testID }: RowProps) {
22
+ const style: ViewStyle = {
23
+ flexDirection: 'row',
24
+ flexWrap: wrap ? 'wrap' : undefined,
25
+ gap,
26
+ alignItems: align ? ALIGN[align] : undefined,
27
+ justifyContent: justify ? JUSTIFY[justify] : undefined,
28
+ };
29
+ return (
30
+ <View testID={testID} style={style}>
31
+ {children}
32
+ </View>
33
+ );
34
+ }
@@ -0,0 +1,34 @@
1
+ import type { CSSProperties } from 'react';
2
+ import type { FlexAlign, FlexJustify, RowProps } from './Stack.types';
3
+
4
+ const ALIGN: Record<FlexAlign, CSSProperties['alignItems']> = {
5
+ start: 'flex-start',
6
+ center: 'center',
7
+ end: 'flex-end',
8
+ stretch: 'stretch',
9
+ };
10
+
11
+ const JUSTIFY: Record<FlexJustify, CSSProperties['justifyContent']> = {
12
+ start: 'flex-start',
13
+ center: 'center',
14
+ end: 'flex-end',
15
+ between: 'space-between',
16
+ around: 'space-around',
17
+ evenly: 'space-evenly',
18
+ };
19
+
20
+ export function Row({ children, gap, align, justify, wrap, testID }: RowProps) {
21
+ const style: CSSProperties = {
22
+ display: 'flex',
23
+ flexDirection: 'row',
24
+ flexWrap: wrap ? 'wrap' : undefined,
25
+ gap,
26
+ alignItems: align ? ALIGN[align] : undefined,
27
+ justifyContent: justify ? JUSTIFY[justify] : undefined,
28
+ };
29
+ return (
30
+ <div data-testid={testID} style={style}>
31
+ {children}
32
+ </div>
33
+ );
34
+ }
@@ -0,0 +1,12 @@
1
+ import { SafeAreaView } from 'react-native-safe-area-context';
2
+ import { ScrollView, View } from 'react-native';
3
+ import type { ScreenProps } from './Screen.types';
4
+
5
+ export function Screen({ children, scroll, padded = true, testID }: ScreenProps) {
6
+ const inner = <View style={{ padding: padded ? 16 : 0, flex: 1 }}>{children}</View>;
7
+ return (
8
+ <SafeAreaView style={{ flex: 1 }} testID={testID}>
9
+ {scroll ? <ScrollView>{inner}</ScrollView> : inner}
10
+ </SafeAreaView>
11
+ );
12
+ }
@@ -0,0 +1,9 @@
1
+ import type { ScreenProps } from './Screen.types';
2
+
3
+ export function Screen({ children, padded = true, testID }: ScreenProps) {
4
+ return (
5
+ <main data-testid={testID} style={{ padding: padded ? 16 : 0, minHeight: '100%' }}>
6
+ {children}
7
+ </main>
8
+ );
9
+ }
@@ -0,0 +1,8 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ export interface ScreenProps {
4
+ children: ReactNode;
5
+ scroll?: boolean;
6
+ padded?: boolean;
7
+ testID?: string;
8
+ }
@@ -0,0 +1,12 @@
1
+ import { View } from 'react-native';
2
+ import type { ViewStyle } from 'react-native';
3
+ import type { SpacerProps } from './Stack.types';
4
+
5
+ export function Spacer({ size }: SpacerProps) {
6
+ const style: ViewStyle = {
7
+ flex: size === undefined ? 1 : undefined,
8
+ height: size,
9
+ width: size,
10
+ };
11
+ return <View style={style} />;
12
+ }
@@ -0,0 +1,11 @@
1
+ import type { CSSProperties } from 'react';
2
+ import type { SpacerProps } from './Stack.types';
3
+
4
+ export function Spacer({ size }: SpacerProps) {
5
+ const style: CSSProperties = {
6
+ flex: size === undefined ? 1 : undefined,
7
+ height: size,
8
+ width: size,
9
+ };
10
+ return <div style={style} />;
11
+ }
@@ -0,0 +1,33 @@
1
+ import { View } from 'react-native';
2
+ import type { ViewStyle } from 'react-native';
3
+ import type { FlexAlign, FlexJustify, FlexProps } from './Stack.types';
4
+
5
+ const ALIGN: Record<FlexAlign, ViewStyle['alignItems']> = {
6
+ start: 'flex-start',
7
+ center: 'center',
8
+ end: 'flex-end',
9
+ stretch: 'stretch',
10
+ };
11
+
12
+ const JUSTIFY: Record<FlexJustify, ViewStyle['justifyContent']> = {
13
+ start: 'flex-start',
14
+ center: 'center',
15
+ end: 'flex-end',
16
+ between: 'space-between',
17
+ around: 'space-around',
18
+ evenly: 'space-evenly',
19
+ };
20
+
21
+ export function Stack({ children, gap, align, justify, testID }: FlexProps) {
22
+ const style: ViewStyle = {
23
+ flexDirection: 'column',
24
+ gap,
25
+ alignItems: align ? ALIGN[align] : undefined,
26
+ justifyContent: justify ? JUSTIFY[justify] : undefined,
27
+ };
28
+ return (
29
+ <View testID={testID} style={style}>
30
+ {children}
31
+ </View>
32
+ );
33
+ }
@@ -0,0 +1,33 @@
1
+ import type { CSSProperties } from 'react';
2
+ import type { FlexAlign, FlexJustify, FlexProps } from './Stack.types';
3
+
4
+ const ALIGN: Record<FlexAlign, CSSProperties['alignItems']> = {
5
+ start: 'flex-start',
6
+ center: 'center',
7
+ end: 'flex-end',
8
+ stretch: 'stretch',
9
+ };
10
+
11
+ const JUSTIFY: Record<FlexJustify, CSSProperties['justifyContent']> = {
12
+ start: 'flex-start',
13
+ center: 'center',
14
+ end: 'flex-end',
15
+ between: 'space-between',
16
+ around: 'space-around',
17
+ evenly: 'space-evenly',
18
+ };
19
+
20
+ export function Stack({ children, gap, align, justify, testID }: FlexProps) {
21
+ const style: CSSProperties = {
22
+ display: 'flex',
23
+ flexDirection: 'column',
24
+ gap,
25
+ alignItems: align ? ALIGN[align] : undefined,
26
+ justifyContent: justify ? JUSTIFY[justify] : undefined,
27
+ };
28
+ return (
29
+ <div data-testid={testID} style={style}>
30
+ {children}
31
+ </div>
32
+ );
33
+ }
@@ -0,0 +1,26 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ export type FlexAlign = 'start' | 'center' | 'end' | 'stretch';
4
+ export type FlexJustify =
5
+ | 'start'
6
+ | 'center'
7
+ | 'end'
8
+ | 'between'
9
+ | 'around'
10
+ | 'evenly';
11
+
12
+ export interface FlexProps {
13
+ children: ReactNode;
14
+ gap?: number;
15
+ align?: FlexAlign;
16
+ justify?: FlexJustify;
17
+ testID?: string;
18
+ }
19
+
20
+ export interface RowProps extends FlexProps {
21
+ wrap?: boolean;
22
+ }
23
+
24
+ export interface SpacerProps {
25
+ size?: number;
26
+ }
@@ -0,0 +1,6 @@
1
+ export * from './Screen';
2
+ export * from './Screen.types';
3
+ export * from './Stack';
4
+ export * from './Row';
5
+ export * from './Spacer';
6
+ export * from './Stack.types';
@@ -0,0 +1,35 @@
1
+ import type {
2
+ BookDef, Entry, LinkDef, PageDef, PageOptions, PrintFormat, RouteMap,
3
+ } from './types';
4
+
5
+ export function page<P = void>(component: unknown, options: PageOptions = {}): PageDef<P> {
6
+ return { kind: 'page', component, options };
7
+ }
8
+
9
+ export function link<RM extends RouteMap>(
10
+ book: BookDef<PrintFormat, RM>,
11
+ options: PageOptions = {},
12
+ ): LinkDef<RM> {
13
+ return { kind: 'link', book, options };
14
+ }
15
+
16
+ // Flatten Pages (page params + nested link routes) into one RouteMap at type level.
17
+ /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-empty-object-type --
18
+ type-level matchers: `any` is the structural wildcard for the conditional checks and
19
+ `{}` is the identity element when a Page is not a LinkDef (intersected away). */
20
+ type RoutesOf<Pages extends Record<string, Entry>> =
21
+ { [K in keyof Pages as Pages[K] extends PageDef<any> ? K : never]:
22
+ Pages[K] extends PageDef<infer P> ? P : never }
23
+ & UnionToIntersection<
24
+ { [K in keyof Pages]: Pages[K] extends LinkDef<infer RM> ? RM : {} }[keyof Pages]
25
+ >;
26
+
27
+ type UnionToIntersection<U> =
28
+ (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never;
29
+ /* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-empty-object-type */
30
+
31
+ export function book<F extends PrintFormat, Pages extends Record<string, Entry>>(
32
+ def: { format: F; pages: Pages },
33
+ ): BookDef<F, RoutesOf<Pages> & RouteMap> {
34
+ return { kind: 'book', format: def.format, pages: def.pages };
35
+ }
@@ -0,0 +1,16 @@
1
+ import { useNavigation, useRoute } from '@react-navigation/native';
2
+ import type { Nav } from './nav.types';
3
+
4
+ export function useNativeNav(): Nav {
5
+ // react-navigation's hook is generic over the app's param list, which is only
6
+ // known at the call site; `any` defers that binding to the typed `Nav` facade.
7
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
8
+ const navigation = useNavigation<any>();
9
+ const route = useRoute();
10
+ return {
11
+ turnTo: (name, params) => navigation.navigate(name as never, params as never),
12
+ turnBack: () => navigation.goBack(),
13
+ current: () => route.name,
14
+ params: <T,>() => (route.params ?? {}) as T,
15
+ };
16
+ }
@@ -0,0 +1,17 @@
1
+ import { useNavigate, useLocation, useParams } from 'react-router-dom';
2
+ import type { Nav } from './nav.types';
3
+
4
+ export function useWebNav(
5
+ pathOf: (name: string, params?: unknown) => string,
6
+ nameOf: (path: string) => string,
7
+ ): Nav {
8
+ const navigate = useNavigate();
9
+ const location = useLocation();
10
+ const params = useParams();
11
+ return {
12
+ turnTo: (name, p) => navigate(pathOf(name, p)),
13
+ turnBack: () => navigate(-1),
14
+ current: () => nameOf(location.pathname),
15
+ params: <T,>() => params as unknown as T,
16
+ };
17
+ }
@@ -0,0 +1,4 @@
1
+ export * from './book';
2
+ export * from './use-nav';
3
+ export * from './types';
4
+ export * from './nav.types';
@@ -0,0 +1,21 @@
1
+ export interface Nav {
2
+ turnTo(name: string, params?: unknown): void;
3
+ turnBack(): void;
4
+ current(): string;
5
+ params<T = unknown>(): T;
6
+ }
7
+
8
+ type NoParams<P> = [P] extends [void] ? true : P extends undefined ? true : false;
9
+
10
+ // `RM extends object` (not `Record<string, unknown>`) so a generated `interface
11
+ // AppRoutes { ... }` satisfies it. TypeScript interfaces lack an implicit index
12
+ // signature and so do NOT extend `Record<string, unknown>`, but the route map is
13
+ // always a concrete object type here, and `keyof`/indexing work on either.
14
+ export interface TypedNav<RM extends object> {
15
+ turnTo<K extends keyof RM & string>(
16
+ ...args: NoParams<RM[K]> extends true ? [name: K] : [name: K, params: RM[K]]
17
+ ): void;
18
+ turnBack(): void;
19
+ current(): keyof RM & string;
20
+ params<K extends keyof RM & string>(): RM[K];
21
+ }
@@ -0,0 +1,37 @@
1
+ export type MobileFormat = 'drawer' | 'stack' | 'bottomNav';
2
+ export type WebFormat = 'sidebar' | 'stack' | 'tabs';
3
+ export type PrintFormat = MobileFormat | WebFormat;
4
+
5
+ export interface PageOptions {
6
+ title?: string;
7
+ icon?: string;
8
+ path?: string; // web URL segment; defaults to kebab-cased key
9
+ initial?: boolean;
10
+ }
11
+
12
+ export interface PageDef<P = void> {
13
+ readonly kind: 'page';
14
+ readonly component: unknown; // a React component; typed structurally at author site
15
+ readonly options: PageOptions;
16
+ readonly __params?: P; // phantom: carries the page's param type
17
+ }
18
+
19
+ export interface LinkDef<RM extends RouteMap = RouteMap> {
20
+ readonly kind: 'link';
21
+ readonly book: BookDef<PrintFormat, RM>;
22
+ readonly options: PageOptions;
23
+ }
24
+
25
+ // `any` here is the structural wildcard: Entry must accept a PageDef/LinkDef of
26
+ // any param or route-map shape before the flattening pass narrows them.
27
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
28
+ export type Entry = PageDef<any> | LinkDef<any>;
29
+
30
+ export type RouteMap = Record<string, unknown>;
31
+
32
+ export interface BookDef<F extends PrintFormat, RM extends RouteMap> {
33
+ readonly kind: 'book';
34
+ readonly format: F;
35
+ readonly pages: Record<string, Entry>;
36
+ readonly __routes?: RM; // phantom: flattened route map
37
+ }
@@ -0,0 +1,15 @@
1
+ import { createContext, createElement, useContext, type ReactNode } from 'react';
2
+ import type { Nav, TypedNav } from './nav.types';
3
+ import type { RouteMap } from './types';
4
+
5
+ export const NavContext = createContext<Nav | null>(null);
6
+
7
+ export function NavProvider(props: { value: Nav; children: ReactNode }) {
8
+ return createElement(NavContext.Provider, { value: props.value }, props.children);
9
+ }
10
+
11
+ export function useNav<RM extends object = RouteMap>(): TypedNav<RM> {
12
+ const nav = useContext(NavContext);
13
+ if (nav === null) throw new Error('useNav() must be used within <Navigation> (NavProvider missing)');
14
+ return nav as unknown as TypedNav<RM>;
15
+ }