diff --git a/bun.lock b/bun.lock
index 2d837f7..a864034 100644
--- a/bun.lock
+++ b/bun.lock
@@ -110,6 +110,14 @@
"vite-tsconfig-paths": "^5.1.4",
},
},
+ "packages/aelis-components": {
+ "name": "@aelis/components",
+ "version": "0.0.0",
+ "peerDependencies": {
+ "@json-render/core": "*",
+ "@nym.sh/jrx": "*",
+ },
+ },
"packages/aelis-core": {
"name": "@aelis/core",
"version": "0.0.0",
@@ -189,6 +197,8 @@
"@aelis/backend": ["@aelis/backend@workspace:apps/aelis-backend"],
+ "@aelis/components": ["@aelis/components@workspace:packages/aelis-components"],
+
"@aelis/core": ["@aelis/core@workspace:packages/aelis-core"],
"@aelis/data-source-weatherkit": ["@aelis/data-source-weatherkit@workspace:packages/aelis-data-source-weatherkit"],
diff --git a/packages/aelis-components/package.json b/packages/aelis-components/package.json
new file mode 100644
index 0000000..dd379d3
--- /dev/null
+++ b/packages/aelis-components/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "@aelis/components",
+ "version": "0.0.0",
+ "type": "module",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "scripts": {
+ "test": "bun test ./src"
+ },
+ "peerDependencies": {
+ "@json-render/core": "*",
+ "@nym.sh/jrx": "*"
+ }
+}
diff --git a/packages/aelis-components/src/button.ts b/packages/aelis-components/src/button.ts
new file mode 100644
index 0000000..a5c944f
--- /dev/null
+++ b/packages/aelis-components/src/button.ts
@@ -0,0 +1,15 @@
+import type { JrxNode } from "@nym.sh/jrx"
+
+import { jsx } from "@nym.sh/jrx/jsx-runtime"
+
+export type ButtonProps = {
+ label: string
+ leadingIcon?: string
+ trailingIcon?: string
+ style?: string
+ children?: JrxNode | JrxNode[]
+}
+
+export function Button(props: ButtonProps): JrxNode {
+ return jsx("Button", props)
+}
diff --git a/packages/aelis-components/src/components.test.tsx b/packages/aelis-components/src/components.test.tsx
new file mode 100644
index 0000000..3c20848
--- /dev/null
+++ b/packages/aelis-components/src/components.test.tsx
@@ -0,0 +1,155 @@
+/** @jsxImportSource @nym.sh/jrx */
+
+import { render } from "@nym.sh/jrx"
+import { describe, expect, test } from "bun:test"
+
+import { Button } from "./button.ts"
+import { FeedCard } from "./feed-card.ts"
+import { MonospaceText } from "./monospace-text.ts"
+import { SansSerifText } from "./sans-serif-text.ts"
+import { SerifText } from "./serif-text.ts"
+
+describe("Button", () => {
+ test("renders with label", () => {
+ const spec = render()
+
+ expect(spec.root).toStartWith("button-")
+ const root = spec.elements[spec.root]!
+ expect(root.type).toBe("Button")
+ expect(root.props).toEqual({ label: "Press me" })
+ })
+
+ test("renders with icon props", () => {
+ const spec = render()
+
+ const root = spec.elements[spec.root]!
+ expect(root.type).toBe("Button")
+ expect(root.props).toEqual({
+ label: "Add",
+ leadingIcon: "plus",
+ trailingIcon: "arrow-right",
+ })
+ })
+
+ test("passes style as string prop", () => {
+ const spec = render()
+
+ const root = spec.elements[spec.root]!
+ expect(root.props.style).toBe("px-4 py-2")
+ })
+})
+
+describe("FeedCard", () => {
+ test("renders as container", () => {
+ const spec = render()
+
+ expect(spec.root).toStartWith("feedcard-")
+ const root = spec.elements[spec.root]!
+ expect(root.type).toBe("FeedCard")
+ })
+
+ test("renders with a single child", () => {
+ const spec = render(
+
+
+ ,
+ )
+
+ const root = spec.elements[spec.root]!
+ expect(root.children).toHaveLength(1)
+ const child = spec.elements[root.children![0]!]!
+ expect(child.type).toBe("SansSerifText")
+ expect(child.props).toEqual({ content: "Only child" })
+ })
+
+ test("passes style as string prop", () => {
+ const spec = render()
+
+ const root = spec.elements[spec.root]!
+ expect(root.props.style).toBe("p-4 border rounded-lg")
+ })
+})
+
+describe("SansSerifText", () => {
+ test("renders with content prop", () => {
+ const spec = render()
+
+ expect(spec.root).toStartWith("sansseriftext-")
+ const root = spec.elements[spec.root]!
+ expect(root.type).toBe("SansSerifText")
+ expect(root.props).toEqual({ content: "Hello" })
+ })
+
+ test("passes style as string prop", () => {
+ const spec = render()
+
+ const root = spec.elements[spec.root]!
+ expect(root.props.style).toBe("text-sm text-stone-500")
+ })
+})
+
+describe("SerifText", () => {
+ test("renders with content prop", () => {
+ const spec = render()
+
+ expect(spec.root).toStartWith("seriftext-")
+ const root = spec.elements[spec.root]!
+ expect(root.type).toBe("SerifText")
+ expect(root.props).toEqual({ content: "Title" })
+ })
+
+ test("passes style as string prop", () => {
+ const spec = render()
+
+ const root = spec.elements[spec.root]!
+ expect(root.props.style).toBe("text-xl")
+ })
+})
+
+describe("MonospaceText", () => {
+ test("renders with content prop", () => {
+ const spec = render()
+
+ expect(spec.root).toStartWith("monospacetext-")
+ const root = spec.elements[spec.root]!
+ expect(root.type).toBe("MonospaceText")
+ expect(root.props).toEqual({ content: "code()" })
+ })
+
+ test("passes style as string prop", () => {
+ const spec = render()
+
+ const root = spec.elements[spec.root]!
+ expect(root.props.style).toBe("text-xs")
+ })
+})
+
+describe("composite", () => {
+ test("FeedCard with nested children", () => {
+ const spec = render(
+
+
+
+
+ ,
+ )
+
+ const root = spec.elements[spec.root]!
+ expect(root.type).toBe("FeedCard")
+ expect(root.children).toHaveLength(3)
+
+ const childKeys = root.children!
+ const child0 = spec.elements[childKeys[0]!]!
+ const child1 = spec.elements[childKeys[1]!]!
+ const child2 = spec.elements[childKeys[2]!]!
+
+ expect(child0.type).toBe("SerifText")
+ expect(child0.props).toEqual({ content: "Weather" })
+
+ expect(child1.type).toBe("SansSerifText")
+ expect(child1.props).toEqual({ content: "Sunny, 22C" })
+
+ expect(child2.type).toBe("Button")
+ expect(child2.props).toEqual({ label: "Details" })
+ })
+})
diff --git a/packages/aelis-components/src/feed-card.ts b/packages/aelis-components/src/feed-card.ts
new file mode 100644
index 0000000..81925e4
--- /dev/null
+++ b/packages/aelis-components/src/feed-card.ts
@@ -0,0 +1,12 @@
+import type { JrxNode } from "@nym.sh/jrx"
+
+import { jsx } from "@nym.sh/jrx/jsx-runtime"
+
+export type FeedCardProps = {
+ style?: string
+ children?: JrxNode | JrxNode[]
+}
+
+export function FeedCard(props: FeedCardProps): JrxNode {
+ return jsx("FeedCard", props)
+}
diff --git a/packages/aelis-components/src/index.ts b/packages/aelis-components/src/index.ts
new file mode 100644
index 0000000..ebfcb9a
--- /dev/null
+++ b/packages/aelis-components/src/index.ts
@@ -0,0 +1,14 @@
+export type { ButtonProps } from "./button.ts"
+export { Button } from "./button.ts"
+
+export type { FeedCardProps } from "./feed-card.ts"
+export { FeedCard } from "./feed-card.ts"
+
+export type { SansSerifTextProps } from "./sans-serif-text.ts"
+export { SansSerifText } from "./sans-serif-text.ts"
+
+export type { SerifTextProps } from "./serif-text.ts"
+export { SerifText } from "./serif-text.ts"
+
+export type { MonospaceTextProps } from "./monospace-text.ts"
+export { MonospaceText } from "./monospace-text.ts"
diff --git a/packages/aelis-components/src/monospace-text.ts b/packages/aelis-components/src/monospace-text.ts
new file mode 100644
index 0000000..4692d16
--- /dev/null
+++ b/packages/aelis-components/src/monospace-text.ts
@@ -0,0 +1,13 @@
+import type { JrxNode } from "@nym.sh/jrx"
+
+import { jsx } from "@nym.sh/jrx/jsx-runtime"
+
+export type MonospaceTextProps = {
+ content?: string
+ style?: string
+ children?: JrxNode | JrxNode[]
+}
+
+export function MonospaceText(props: MonospaceTextProps): JrxNode {
+ return jsx("MonospaceText", props)
+}
diff --git a/packages/aelis-components/src/sans-serif-text.ts b/packages/aelis-components/src/sans-serif-text.ts
new file mode 100644
index 0000000..81b01ac
--- /dev/null
+++ b/packages/aelis-components/src/sans-serif-text.ts
@@ -0,0 +1,13 @@
+import type { JrxNode } from "@nym.sh/jrx"
+
+import { jsx } from "@nym.sh/jrx/jsx-runtime"
+
+export type SansSerifTextProps = {
+ content?: string
+ style?: string
+ children?: JrxNode | JrxNode[]
+}
+
+export function SansSerifText(props: SansSerifTextProps): JrxNode {
+ return jsx("SansSerifText", props)
+}
diff --git a/packages/aelis-components/src/serif-text.ts b/packages/aelis-components/src/serif-text.ts
new file mode 100644
index 0000000..3fceafa
--- /dev/null
+++ b/packages/aelis-components/src/serif-text.ts
@@ -0,0 +1,13 @@
+import type { JrxNode } from "@nym.sh/jrx"
+
+import { jsx } from "@nym.sh/jrx/jsx-runtime"
+
+export type SerifTextProps = {
+ content?: string
+ style?: string
+ children?: JrxNode | JrxNode[]
+}
+
+export function SerifText(props: SerifTextProps): JrxNode {
+ return jsx("SerifText", props)
+}
diff --git a/packages/aelis-components/tsconfig.json b/packages/aelis-components/tsconfig.json
new file mode 100644
index 0000000..8d65648
--- /dev/null
+++ b/packages/aelis-components/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "jsxImportSource": "@nym.sh/jrx"
+ },
+ "include": ["src"]
+}