@ahmd-sh/hntui 0.3.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.
@@ -0,0 +1,133 @@
1
+ import { TextAttributes } from "@opentui/core"
2
+ import { selectionColors, useTheme } from "../theme"
3
+
4
+ interface Binding {
5
+ keys: string
6
+ desc: string
7
+ }
8
+
9
+ interface Group {
10
+ title: string
11
+ bindings: Binding[]
12
+ }
13
+
14
+ const NAVIGATION: Binding[] = [
15
+ { keys: "j k ↑ ↓", desc: "move down / up" },
16
+ { keys: "g g / G", desc: "jump to top / bottom" },
17
+ { keys: "Ctrl+d / Ctrl+u", desc: "half-page down / up" },
18
+ ]
19
+
20
+ const LIST_GROUPS: Group[] = [
21
+ {
22
+ title: "Navigation",
23
+ bindings: [
24
+ ...NAVIGATION,
25
+ { keys: "h / l", desc: "previous / next category" },
26
+ { keys: "Tab / ⇧Tab", desc: "cycle categories" },
27
+ { keys: "1 – 6", desc: "jump to category" },
28
+ ],
29
+ },
30
+ {
31
+ title: "Actions",
32
+ bindings: [
33
+ { keys: "c / ⏎", desc: "open comments" },
34
+ { keys: "o", desc: "open post link in browser" },
35
+ { keys: "y", desc: "open HN page in browser" },
36
+ { keys: "s", desc: "save / unsave" },
37
+ { keys: "r", desc: "refresh feed" },
38
+ { keys: "x", desc: "clear history (History view)" },
39
+ ],
40
+ },
41
+ ]
42
+
43
+ const DETAIL_GROUPS: Group[] = [
44
+ {
45
+ title: "Navigation",
46
+ bindings: NAVIGATION,
47
+ },
48
+ {
49
+ title: "Actions",
50
+ bindings: [
51
+ { keys: "space", desc: "collapse / expand" },
52
+ { keys: "⏎", desc: "comment links (HN posts open in-app)" },
53
+ { keys: "o", desc: "open post link in browser" },
54
+ { keys: "y", desc: "open HN page in browser" },
55
+ { keys: "s", desc: "save / unsave" },
56
+ { keys: "h / esc", desc: "go back" },
57
+ ],
58
+ },
59
+ ]
60
+
61
+ const GENERAL: Group = {
62
+ title: "General",
63
+ bindings: [
64
+ { keys: "S", desc: "saved posts" },
65
+ { keys: "H", desc: "view history" },
66
+ { keys: "t", desc: "toggle theme" },
67
+ { keys: "?", desc: "toggle this help" },
68
+ { keys: "q", desc: "quit" },
69
+ ],
70
+ }
71
+
72
+ interface Props {
73
+ view: "list" | "detail"
74
+ onClose: () => void
75
+ }
76
+
77
+ export function HelpOverlay({ view, onClose }: Props) {
78
+ const t = useTheme()
79
+ const groups = [...(view === "list" ? LIST_GROUPS : DETAIL_GROUPS), GENERAL]
80
+ const keyWidth = groups.reduce(
81
+ (w, g) => g.bindings.reduce((m, b) => Math.max(m, b.keys.length), w),
82
+ 0,
83
+ )
84
+
85
+ return (
86
+ <box
87
+ position="absolute"
88
+ top={0}
89
+ left={0}
90
+ width="100%"
91
+ height="100%"
92
+ alignItems="center"
93
+ justifyContent="center"
94
+ zIndex={150}
95
+ backgroundColor="#00000088"
96
+ onMouseDown={onClose}
97
+ >
98
+ <box
99
+ flexDirection="column"
100
+ width="60%"
101
+ backgroundColor={t.body}
102
+ border={true}
103
+ borderStyle="rounded"
104
+ borderColor={t.accent}
105
+ paddingTop={1}
106
+ paddingBottom={1}
107
+ paddingLeft={2}
108
+ paddingRight={2}
109
+ title=" Keyboard shortcuts "
110
+ onMouseDown={(ev) => ev.stopPropagation()}
111
+ >
112
+ {groups.map((group, gi) => (
113
+ <box key={group.title} flexDirection="column" marginTop={gi === 0 ? 0 : 1}>
114
+ <text fg={t.accent} {...selectionColors(t)} attributes={TextAttributes.BOLD}>
115
+ {group.title}
116
+ </text>
117
+ {group.bindings.map((b, bi) => (
118
+ <text key={bi} {...selectionColors(t)}>
119
+ <span fg={t.text}>{` ${b.keys.padEnd(keyWidth)}`}</span>
120
+ <span fg={t.textMuted}>{` ${b.desc}`}</span>
121
+ </text>
122
+ ))}
123
+ </box>
124
+ ))}
125
+ <box marginTop={1}>
126
+ <text fg={t.statusHint} {...selectionColors(t)}>
127
+ esc / ? close
128
+ </text>
129
+ </box>
130
+ </box>
131
+ </box>
132
+ )
133
+ }
@@ -0,0 +1,101 @@
1
+ import { TextAttributes } from "@opentui/core"
2
+ import type { Link } from "../utils/format"
3
+ import { selectionColors, useTheme } from "../theme"
4
+
5
+ interface Props {
6
+ links: Link[]
7
+ cursor: number
8
+ onSelect: (idx: number) => void
9
+ onActivate: (idx: number) => void
10
+ onClose: () => void
11
+ }
12
+
13
+ export function LinksPopup({ links, cursor, onSelect, onActivate, onClose }: Props) {
14
+ const t = useTheme()
15
+
16
+ return (
17
+ <box
18
+ position="absolute"
19
+ top={0}
20
+ left={0}
21
+ width="100%"
22
+ height="100%"
23
+ alignItems="center"
24
+ justifyContent="center"
25
+ zIndex={100}
26
+ backgroundColor="#00000088"
27
+ onMouseDown={onClose}
28
+ >
29
+ <box
30
+ flexDirection="column"
31
+ width="70%"
32
+ backgroundColor={t.body}
33
+ border={true}
34
+ borderStyle="rounded"
35
+ borderColor={t.accent}
36
+ paddingTop={1}
37
+ paddingBottom={1}
38
+ paddingLeft={2}
39
+ paddingRight={2}
40
+ title={` Links (${links.length}) `}
41
+ onMouseDown={(ev) => ev.stopPropagation()}
42
+ >
43
+ {links.map((link, idx) => (
44
+ <LinkRow
45
+ key={`${idx}-${link.url}`}
46
+ link={link}
47
+ index={idx}
48
+ selected={idx === cursor}
49
+ isFirst={idx === 0}
50
+ onSelect={() => onSelect(idx)}
51
+ onActivate={() => onActivate(idx)}
52
+ />
53
+ ))}
54
+ <box marginTop={1}>
55
+ <text fg={t.statusHint} {...selectionColors(t)}>j/k move · ⏎ open · o browser · esc close</text>
56
+ </box>
57
+ </box>
58
+ </box>
59
+ )
60
+ }
61
+
62
+ interface RowProps {
63
+ link: Link
64
+ index: number
65
+ selected: boolean
66
+ isFirst: boolean
67
+ onSelect: () => void
68
+ onActivate: () => void
69
+ }
70
+
71
+ function LinkRow({ link, index, selected, isFirst, onSelect, onActivate }: RowProps) {
72
+ const t = useTheme()
73
+ let lastClick = 0
74
+ const handleClick = () => {
75
+ const now = Date.now()
76
+ if (selected || now - lastClick < 400) {
77
+ onActivate()
78
+ } else {
79
+ onSelect()
80
+ }
81
+ lastClick = now
82
+ }
83
+ return (
84
+ <box
85
+ flexDirection="column"
86
+ marginTop={isFirst ? 0 : 1}
87
+ backgroundColor={selected ? t.rowHighlight : undefined}
88
+ paddingLeft={1}
89
+ paddingRight={1}
90
+ onMouseDown={handleClick}
91
+ >
92
+ <text {...selectionColors(t)}>
93
+ <span fg={selected ? t.accent : t.textDim}>{selected ? "▶ " : " "}</span>
94
+ <span fg={t.text} attributes={selected ? TextAttributes.BOLD : TextAttributes.NONE}>
95
+ {`${index + 1}. ${link.text}`}
96
+ </span>
97
+ </text>
98
+ <text fg={t.link} {...selectionColors(t)}>{` ${link.url}`}</text>
99
+ </box>
100
+ )
101
+ }
@@ -0,0 +1,30 @@
1
+ import { RGBA } from "@opentui/core"
2
+ import { createColors, createFrames } from "../spinner"
3
+
4
+ const HN_ORANGE_RAMP = [
5
+ RGBA.fromHex("#ff6600"),
6
+ RGBA.fromHex("#ffaa66"),
7
+ RGBA.fromHex("#dd5500"),
8
+ RGBA.fromHex("#aa4400"),
9
+ RGBA.fromHex("#773300"),
10
+ RGBA.fromHex("#442200"),
11
+ ]
12
+
13
+ const HN_INACTIVE = RGBA.fromHex("#2a1500")
14
+
15
+ const OPTIONS = {
16
+ style: "blocks" as const,
17
+ colors: HN_ORANGE_RAMP,
18
+ defaultColor: HN_INACTIVE,
19
+ }
20
+
21
+ const FRAMES = createFrames(OPTIONS)
22
+ const COLOR = createColors(OPTIONS)
23
+
24
+ interface Props {
25
+ interval?: number
26
+ }
27
+
28
+ export function Loader({ interval = 40 }: Props) {
29
+ return <spinner frames={FRAMES} color={COLOR} interval={interval} />
30
+ }
@@ -0,0 +1,40 @@
1
+ import { Loader } from "./Loader"
2
+ import type { Category } from "../api/types"
3
+ import { selectionColors, useTheme } from "../theme"
4
+
5
+ interface Props {
6
+ view: "list" | "detail"
7
+ category?: Category
8
+ loading?: boolean
9
+ message?: string
10
+ }
11
+
12
+ export function StatusBar({ view, category, loading, message }: Props) {
13
+ const t = useTheme()
14
+ const hints =
15
+ view === "list"
16
+ ? category === "history"
17
+ ? "j/k move · c/⏎ open · x clear history · q quit"
18
+ : "j/k move · c/⏎ open · s save · q quit"
19
+ : "j/k move · space collapse · ⏎ links · h/esc back"
20
+ return (
21
+ <box
22
+ flexDirection="row"
23
+ flexShrink={0}
24
+ height={2}
25
+ paddingLeft={1}
26
+ paddingRight={1}
27
+ gap={1}
28
+ alignItems="center"
29
+ backgroundColor={t.strip}
30
+ border={["top"]}
31
+ borderStyle="single"
32
+ borderColor={t.border}
33
+ >
34
+ {loading ? <Loader /> : null}
35
+ <text fg={t.statusHint} {...selectionColors(t)}>{message ?? hints}</text>
36
+ <box flexGrow={1} />
37
+ <text fg={t.statusHint} {...selectionColors(t)}>? help</text>
38
+ </box>
39
+ )
40
+ }
@@ -0,0 +1,76 @@
1
+ import type { MouseEvent } from "@opentui/core"
2
+ import { TextAttributes } from "@opentui/core"
3
+ import type { Item } from "../api/types"
4
+ import { hostname, relativeTime } from "../utils/format"
5
+ import { selectionColors, useTheme } from "../theme"
6
+
7
+ interface Props {
8
+ rank: number
9
+ item: Item
10
+ selected: boolean
11
+ saved?: boolean
12
+ visited?: boolean
13
+ onSelect: () => void
14
+ onActivate: () => void
15
+ onContextMenu?: (ev: MouseEvent) => void
16
+ }
17
+
18
+ export function StoryRow({ rank, item, selected, saved, visited, onSelect, onActivate, onContextMenu }: Props) {
19
+ const t = useTheme()
20
+ const host = hostname(item.url)
21
+ const title = item.title ?? "(untitled)"
22
+ const score = item.score ?? 0
23
+ const author = item.by ?? "?"
24
+ const age = relativeTime(item.time)
25
+ const comments = item.descendants ?? 0
26
+ const bg = selected ? t.rowHighlight : undefined
27
+ const titleFg = visited ? t.textVisited : selected ? t.text : t.textBody
28
+ const rankFg = visited ? t.textVisited : selected ? t.accent : t.textDim
29
+ const voteFg = visited ? t.textVisited : t.accent
30
+ const dimFg = visited ? t.textVisited : t.textDim
31
+ const mutedFg = visited ? t.textVisited : t.textMuted
32
+
33
+ let lastClick = 0
34
+ const handleClick = (ev: MouseEvent) => {
35
+ if (ev.button === 2) {
36
+ onSelect()
37
+ onContextMenu?.(ev)
38
+ return
39
+ }
40
+ const now = Date.now()
41
+ if (selected || now - lastClick < 400) {
42
+ onActivate()
43
+ } else {
44
+ onSelect()
45
+ }
46
+ lastClick = now
47
+ }
48
+
49
+ return (
50
+ <box
51
+ id={`row-${rank}`}
52
+ flexDirection="column"
53
+ paddingLeft={1}
54
+ paddingRight={1}
55
+ backgroundColor={bg}
56
+ onMouseDown={handleClick}
57
+ >
58
+ <text {...selectionColors(t)}>
59
+ <span fg={rankFg}>{`${String(rank).padStart(3, " ")}. `}</span>
60
+ {saved ? <span fg={t.accent}>{"★ "}</span> : null}
61
+ <span fg={titleFg} attributes={selected ? TextAttributes.BOLD : TextAttributes.NONE}>
62
+ {title}
63
+ </span>
64
+ {host ? <span fg={dimFg}>{` (${host})`}</span> : null}
65
+ </text>
66
+ <text {...selectionColors(t)}>
67
+ <span fg={t.textDim}> </span>
68
+ <span fg={voteFg}>▲ {score}</span>
69
+ <span fg={dimFg}>{` by `}</span>
70
+ <span fg={mutedFg}>{author}</span>
71
+ <span fg={dimFg}>{` ${age} | `}</span>
72
+ <span fg={mutedFg}>{`${comments} comment${comments === 1 ? "" : "s"}`}</span>
73
+ </text>
74
+ </box>
75
+ )
76
+ }
@@ -0,0 +1,95 @@
1
+ import { useEffect, useState } from "react"
2
+ import { Effect, Fiber } from "effect"
3
+ import { AppRuntime } from "../runtime"
4
+ import { fetchItem } from "../api/hn"
5
+ import type { HnApi } from "../api/hn"
6
+ import type { Item } from "../api/types"
7
+
8
+ export interface CommentNode {
9
+ item: Item
10
+ children: CommentNode[]
11
+ }
12
+
13
+ export interface FlatComment {
14
+ node: CommentNode
15
+ depth: number
16
+ hiddenChildren: number
17
+ }
18
+
19
+ const loadTree = (
20
+ id: number,
21
+ depth: number,
22
+ maxDepth: number,
23
+ ): Effect.Effect<CommentNode | null, never, HnApi> =>
24
+ Effect.gen(function* () {
25
+ // a comment that fails to load is simply omitted, like before
26
+ const item = yield* fetchItem(id).pipe(Effect.orElseSucceed(() => null))
27
+ if (!item || item.deleted || item.dead) return null
28
+ const kidIds = item.kids ?? []
29
+ let children: CommentNode[] = []
30
+ if (depth < maxDepth && kidIds.length > 0) {
31
+ const loaded = yield* Effect.forEach(
32
+ kidIds,
33
+ (kid) => loadTree(kid, depth + 1, maxDepth),
34
+ { concurrency: "unbounded" },
35
+ )
36
+ children = loaded.filter((x): x is CommentNode => x !== null)
37
+ }
38
+ return { item, children }
39
+ })
40
+
41
+ export function useCommentTree(rootIds: number[] | undefined, maxDepth = 8, refreshKey = 0) {
42
+ const idsKey = rootIds && rootIds.length > 0 ? rootIds.join(",") : ""
43
+ // refreshKey re-runs the load for the same story: failed fetches were
44
+ // evicted from the item cache, so a retry actually refetches them
45
+ const key = idsKey === "" ? "" : `${refreshKey}|${idsKey}`
46
+ // The tree is tagged with the key it was loaded for, so switching stories
47
+ // DERIVES empty+loading state on the very same render
48
+ const [result, setResult] = useState<{ key: string; tree: CommentNode[] }>({
49
+ key: "",
50
+ tree: [],
51
+ })
52
+
53
+ useEffect(() => {
54
+ if (key === "") return
55
+ const ids = key.slice(key.indexOf("|") + 1).split(",").map(Number)
56
+ const fiber = AppRuntime.runFork(
57
+ Effect.forEach(ids, (id) => loadTree(id, 0, maxDepth), {
58
+ concurrency: "unbounded",
59
+ }).pipe(
60
+ Effect.andThen((nodes) =>
61
+ Effect.sync(() => {
62
+ setResult({ key, tree: nodes.filter((x): x is CommentNode => x !== null) })
63
+ }),
64
+ ),
65
+ ),
66
+ )
67
+ return () => {
68
+ // interrupts the ENTIRE tree of pending comment fetches at once
69
+ AppRuntime.runFork(Fiber.interrupt(fiber))
70
+ }
71
+ }, [key, maxDepth])
72
+
73
+ const fresh = key !== "" && result.key === key
74
+ return { tree: fresh ? result.tree : [], loading: key !== "" && !fresh }
75
+ }
76
+
77
+ export function flattenTree(tree: CommentNode[], collapsed: Set<number>): FlatComment[] {
78
+ const out: FlatComment[] = []
79
+ const walk = (nodes: CommentNode[], depth: number) => {
80
+ for (const node of nodes) {
81
+ const isCollapsed = collapsed.has(node.item.id)
82
+ const hidden = isCollapsed ? countAll(node) : 0
83
+ out.push({ node, depth, hiddenChildren: hidden })
84
+ if (!isCollapsed) walk(node.children, depth + 1)
85
+ }
86
+ }
87
+ walk(tree, 0)
88
+ return out
89
+ }
90
+
91
+ function countAll(node: CommentNode): number {
92
+ let n = node.children.length
93
+ for (const c of node.children) n += countAll(c)
94
+ return n
95
+ }
@@ -0,0 +1,40 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react"
2
+ import type { HistoryEntry } from "../utils/historyStore"
3
+ import { HISTORY_CAP, loadHistory, persistHistory } from "../utils/historyStore"
4
+
5
+ export function useHistory() {
6
+ const [entries, setEntries] = useState<HistoryEntry[]>([])
7
+ const [loaded, setLoaded] = useState(false)
8
+ const firstLoad = useRef(true)
9
+
10
+ useEffect(() => {
11
+ loadHistory().then((data) => {
12
+ setEntries(data)
13
+ setLoaded(true)
14
+ })
15
+ }, [])
16
+
17
+ useEffect(() => {
18
+ if (firstLoad.current) {
19
+ firstLoad.current = false
20
+ return
21
+ }
22
+ if (loaded) void persistHistory(entries)
23
+ }, [entries, loaded])
24
+
25
+ const idSet = useMemo(() => new Set(entries.map((e) => e.id)), [entries])
26
+
27
+ const isViewed = useCallback((id: number) => idSet.has(id), [idSet])
28
+
29
+ // Move the post to the front (most recent) and evict the oldest beyond the cap.
30
+ const markViewed = useCallback((id: number) => {
31
+ setEntries((prev) => {
32
+ const rest = prev.filter((e) => e.id !== id)
33
+ return [{ id, viewedAt: Date.now() }, ...rest].slice(0, HISTORY_CAP)
34
+ })
35
+ }, [])
36
+
37
+ const clear = useCallback(() => setEntries([]), [])
38
+
39
+ return { entries, idSet, isViewed, markViewed, clear, loaded }
40
+ }
@@ -0,0 +1,34 @@
1
+ import { useEffect, useState } from "react"
2
+ import { Effect, Fiber } from "effect"
3
+ import { AppRuntime } from "../runtime"
4
+ import { fetchItems } from "../api/hn"
5
+ import type { Item } from "../api/types"
6
+
7
+ export function useItems(ids: number[]) {
8
+ const [items, setItems] = useState<Item[]>([])
9
+ const [loading, setLoading] = useState(false)
10
+
11
+ useEffect(() => {
12
+ if (ids.length === 0) {
13
+ setItems([])
14
+ return
15
+ }
16
+ setItems([])
17
+ setLoading(true)
18
+ const fiber = AppRuntime.runFork(
19
+ fetchItems(ids).pipe(
20
+ Effect.andThen((data) =>
21
+ Effect.sync(() => {
22
+ setItems(data)
23
+ setLoading(false)
24
+ }),
25
+ ),
26
+ ),
27
+ )
28
+ return () => {
29
+ AppRuntime.runFork(Fiber.interrupt(fiber))
30
+ }
31
+ }, [ids.join(",")])
32
+
33
+ return { items, loading }
34
+ }
@@ -0,0 +1,37 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react"
2
+ import type { SavedEntry } from "../utils/savedStore"
3
+ import { loadSaved, persistSaved } from "../utils/savedStore"
4
+
5
+ export function useSaved() {
6
+ const [entries, setEntries] = useState<SavedEntry[]>([])
7
+ const [loaded, setLoaded] = useState(false)
8
+ const firstLoad = useRef(true)
9
+
10
+ useEffect(() => {
11
+ loadSaved().then((data) => {
12
+ setEntries(data)
13
+ setLoaded(true)
14
+ })
15
+ }, [])
16
+
17
+ useEffect(() => {
18
+ if (firstLoad.current) {
19
+ firstLoad.current = false
20
+ return
21
+ }
22
+ if (loaded) void persistSaved(entries)
23
+ }, [entries, loaded])
24
+
25
+ const idSet = useMemo(() => new Set(entries.map((e) => e.id)), [entries])
26
+
27
+ const isSaved = useCallback((id: number) => idSet.has(id), [idSet])
28
+
29
+ const toggle = useCallback((id: number) => {
30
+ setEntries((prev) => {
31
+ if (prev.some((e) => e.id === id)) return prev.filter((e) => e.id !== id)
32
+ return [{ id, savedAt: Date.now() }, ...prev]
33
+ })
34
+ }, [])
35
+
36
+ return { entries, idSet, isSaved, toggle, loaded }
37
+ }
@@ -0,0 +1,38 @@
1
+ import { useEffect, useState } from "react"
2
+ import { Effect, Fiber } from "effect"
3
+ import { AppRuntime } from "../runtime"
4
+ import { fetchIds } from "../api/hn"
5
+ import type { HnError } from "../api/hn"
6
+ import type { FeedCategory } from "../api/types"
7
+
8
+ export function useStoryIds(category: FeedCategory, refreshKey = 0) {
9
+ const [ids, setIds] = useState<number[]>([])
10
+ const [loading, setLoading] = useState(true)
11
+ const [error, setError] = useState<HnError | null>(null)
12
+
13
+ useEffect(() => {
14
+ setIds([])
15
+ setLoading(true)
16
+ setError(null)
17
+ const fiber = AppRuntime.runFork(
18
+ fetchIds(category).pipe(
19
+ Effect.match({
20
+ onSuccess: (data) => {
21
+ setIds(data)
22
+ setLoading(false)
23
+ },
24
+ onFailure: (err) => {
25
+ setError(err)
26
+ setLoading(false)
27
+ },
28
+ }),
29
+ ),
30
+ )
31
+ return () => {
32
+ // interrupting the fiber aborts the in-flight HTTP request
33
+ AppRuntime.runFork(Fiber.interrupt(fiber))
34
+ }
35
+ }, [category, refreshKey])
36
+
37
+ return { ids, loading, error }
38
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bun
2
+ import { createCliRenderer } from "@opentui/core"
3
+ import { createRoot } from "@opentui/react"
4
+ import "opentui-spinner/react"
5
+ import { App } from "./App"
6
+
7
+ const renderer = await createCliRenderer({ useMouse: true })
8
+ createRoot(renderer).render(<App />)
package/src/runtime.ts ADDED
@@ -0,0 +1,12 @@
1
+ import { Layer, ManagedRuntime } from "effect"
2
+ import { HnApiLive, HnTransportLive } from "./api/hn"
3
+ import { HnTransportLinkDemo } from "./api/hnDemo"
4
+
5
+ // The app's single composition point: the whole layer graph is decided here,
6
+ // once, at the edge. Everything below runs against whatever this provides —
7
+ // HN_DEMO=1 swaps the transport and no other file knows or cares.
8
+ // (A real app would keep demo code out of the production bundle via separate
9
+ // entry points; for a TUI this trade is fine.)
10
+ const transport = process.env.HN_DEMO === "1" ? HnTransportLinkDemo : HnTransportLive
11
+
12
+ export const AppRuntime = ManagedRuntime.make(HnApiLive.pipe(Layer.provide(transport)))