@namiml/cli 3.4.11-dev.202609230009 → 3.4.11-dev.202609231629

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,101 @@
1
+ import { NamiPaywallAction } from "@namiml/sdk-core";
2
+ /**
3
+ * The one mapping from a component's `onTap.function` to the paywall action the
4
+ * SDK reports — the CLI's copy of what the web SDK does.
5
+ *
6
+ * The strings below are redeclared from `sdk/web/src/components/utils/eventConst.ts`
7
+ * (`NAMI_CLOSE_PAYWALL = "namiClosePaywall"` and friends); the CLI must not
8
+ * import from `sdk/web`, so that file is the source and this is the copy.
9
+ *
10
+ * The mapping itself is `handleAction` in `sdk/web/src/components/utils/actionTap.ts`.
11
+ * It initialises `action = UNKNOWN`, runs one switch, and emits on
12
+ * `PAYWALL_ACTION_EVENT` **only when the switch moved `action` off `UNKNOWN`**.
13
+ * So the table here is not "every function web handles" — it is every function
14
+ * that leaves web with an action to emit:
15
+ *
16
+ * | onTap.function | NamiPaywallAction |
17
+ * |---------------------------|-------------------------|
18
+ * | namiClosePaywall | CLOSE_PAYWALL |
19
+ * | namiRestorePurchases | RESTORE_PURCHASES |
20
+ * | namiSignIn | SIGN_IN |
21
+ * | namiBuySKU | BUY_SKU (with a SKU) |
22
+ * | namiSelectSKU | SELECT_SKU (with a SKU) |
23
+ * | namiPurchaseSelectedSKU | PURCHASE_SELECTED_SKU (with a SKU) |
24
+ * | namiNavigateToScreen | PAGE_CHANGE |
25
+ * | setState | TOGGLE_CHANGE |
26
+ *
27
+ * Everything else deliberately emits nothing: `namiDeeplink` reports through
28
+ * `NamiPaywallManager.registerDeeplinkActionHandler` instead, the four video
29
+ * functions go to web's VideoService (which emits its own video events),
30
+ * `namiReloadProducts` reloads store products, `""` is web's `UNSUPPORTED`, and
31
+ * an unrecognised function falls through web's `default:` branch untouched.
32
+ */
33
+ /** `onTap.function` values, from `sdk/web/src/components/utils/eventConst.ts`. */
34
+ const NAMI_CLOSE_PAYWALL = "namiClosePaywall";
35
+ const NAMI_RESTORE_PURCHASES = "namiRestorePurchases";
36
+ const NAMI_SIGN_IN = "namiSignIn";
37
+ const NAMI_BUY_SKU = "namiBuySKU";
38
+ const SET_STATE = "setState";
39
+ const NAMI_SELECT_SKU = "namiSelectSKU";
40
+ const NAMI_PURCHASE_SELECTED_SKU = "namiPurchaseSelectedSKU";
41
+ const NAMI_DEEP_LINK = "namiDeeplink";
42
+ const NAMI_NAVIGATE_TO_SCREEN = "namiNavigateToScreen";
43
+ const NAMI_RELOAD_PRODUCTS = "namiReloadProducts";
44
+ const UNSUPPORTED = "";
45
+ const NAMI_PLAY_VIDEO = "namiPlayVideo";
46
+ const NAMI_PAUSE_VIDEO = "namiPauseVideo";
47
+ const NAMI_MUTE_VIDEO = "namiMuteVideo";
48
+ const NAMI_UNMUTE_VIDEO = "namiUnmuteVideo";
49
+ /** The actions web guards behind `if (sku)`. */
50
+ const SKU_ACTIONS = {
51
+ [NAMI_BUY_SKU]: NamiPaywallAction.BUY_SKU,
52
+ [NAMI_SELECT_SKU]: NamiPaywallAction.SELECT_SKU,
53
+ [NAMI_PURCHASE_SELECTED_SKU]: NamiPaywallAction.PURCHASE_SELECTED_SKU,
54
+ };
55
+ /** The actions web emits unconditionally. */
56
+ const PLAIN_ACTIONS = {
57
+ [NAMI_CLOSE_PAYWALL]: NamiPaywallAction.CLOSE_PAYWALL,
58
+ [NAMI_RESTORE_PURCHASES]: NamiPaywallAction.RESTORE_PURCHASES,
59
+ [NAMI_SIGN_IN]: NamiPaywallAction.SIGN_IN,
60
+ [SET_STATE]: NamiPaywallAction.TOGGLE_CHANGE,
61
+ };
62
+ /** null when this onTap emits no paywall action event — see the table in the header comment. */
63
+ export function actionForOnTap(onTap) {
64
+ const fn = onTap?.function;
65
+ if (fn === undefined)
66
+ return null;
67
+ const withSku = SKU_ACTIONS[fn];
68
+ if (withSku)
69
+ return { action: withSku, requiresSku: true };
70
+ if (fn === NAMI_NAVIGATE_TO_SCREEN) {
71
+ const screen = onTap?.parameters?.screen;
72
+ return {
73
+ action: NamiPaywallAction.PAGE_CHANGE,
74
+ // Web sets `componentChange = { name: onTap.parameters.screen }`.
75
+ ...(screen === undefined ? {} : { componentChange: { name: screen } }),
76
+ requiresSku: false,
77
+ };
78
+ }
79
+ const plain = PLAIN_ACTIONS[fn];
80
+ return plain ? { action: plain, requiresSku: false } : null;
81
+ }
82
+ /** Why a press produced no event, for the "pressed, no event" note in the UI. */
83
+ export function noEventReason(fn) {
84
+ switch (fn) {
85
+ case NAMI_DEEP_LINK:
86
+ return "namiDeeplink reports through NamiPaywallManager.registerDeeplinkActionHandler, not the paywall action event — watch for a deeplink row instead.";
87
+ case NAMI_RELOAD_PRODUCTS:
88
+ return "namiReloadProducts re-fetches store products; the SDK sends no paywall action for it.";
89
+ case NAMI_PLAY_VIDEO:
90
+ case NAMI_PAUSE_VIDEO:
91
+ case NAMI_MUTE_VIDEO:
92
+ case NAMI_UNMUTE_VIDEO:
93
+ return `${fn} is handled by the platform's video service, which reports its own video events; the CLI has no video to play.`;
94
+ case UNSUPPORTED:
95
+ return "This control carries no action — the paywall marked it unsupported, so nothing is sent.";
96
+ default:
97
+ return actionForOnTap({ function: fn })
98
+ ? undefined
99
+ : `${fn} is not one of the actions the SDK maps to a paywall event, so pressing it sends nothing.`;
100
+ }
101
+ }
@@ -0,0 +1,161 @@
1
+ /** Nodes one composite may carry; the rest are counted into `truncated`. */
2
+ export const COMPOSITE_MAX_NODES = 200;
3
+ /** Page keys in the order a page reads, paired with the section they become. */
4
+ const SECTIONS = [
5
+ { name: "header", key: "header" },
6
+ { name: "content", key: "contentContainer" },
7
+ { name: "footer", key: "footer" },
8
+ { name: "background", key: "backgroundContainer" },
9
+ ];
10
+ /** Child arrays, in the order the SDKs render them. */
11
+ const CHILD_LIST_KEYS = [
12
+ "components",
13
+ "productFeaturedComponents",
14
+ "productBaseComponents",
15
+ "pausedComponents",
16
+ "playingComponents",
17
+ "mutedComponents",
18
+ "volumeComponents",
19
+ ];
20
+ /** Single-child slots. */
21
+ const CHILD_NODE_KEYS = ["collapseHeader", "groupHeaderTemplate"];
22
+ /**
23
+ * Walk one page of a paywall template into a flat, depth-tagged outline.
24
+ *
25
+ * The walk follows the TV full-page announcement contract documented on
26
+ * `TPages.screenreaderText`: source order throughout, containers recurse,
27
+ * `text` / `text-list` carry their strings. It differs in two ways, because a
28
+ * pane has to show structure rather than speak it: media components render a
29
+ * placeholder naming the media instead of being skipped, and a `hidden: true`
30
+ * node (and its subtree) is emitted flagged rather than dropped, so the pane
31
+ * can dim it. A hidden node is never focusable.
32
+ *
33
+ * This is a structural outline, not a layout engine — no positions, sizes or
34
+ * colours are computed.
35
+ */
36
+ export function compositeForPage(paywall, pageName) {
37
+ const page = pagesOf(paywall).find((candidate) => candidate.name === pageName);
38
+ if (!page)
39
+ return { pageName, sections: [], truncated: 0 };
40
+ const walked = SECTIONS.map(({ name, key }) => {
41
+ const nodes = [];
42
+ for (const root of rootsOf(page[key]))
43
+ walk(root, 0, false, nodes);
44
+ return { name, nodes };
45
+ });
46
+ const sections = [];
47
+ let remaining = COMPOSITE_MAX_NODES;
48
+ let truncated = 0;
49
+ for (const section of walked) {
50
+ const kept = section.nodes.slice(0, Math.max(remaining, 0));
51
+ truncated += section.nodes.length - kept.length;
52
+ remaining -= kept.length;
53
+ if (kept.length > 0)
54
+ sections.push({ name: section.name, nodes: kept });
55
+ }
56
+ return { pageName: typeof page.name === "string" ? page.name : pageName, sections, truncated };
57
+ }
58
+ /** The page the live state says is showing, falling back to template.initialState.currentPage. */
59
+ export function currentPageName(paywall, state) {
60
+ const live = text(state?.currentPage);
61
+ if (live)
62
+ return live;
63
+ const initial = text(paywall?.template?.initialState?.currentPage);
64
+ if (initial)
65
+ return initial;
66
+ return text(pagesOf(paywall)[0]?.name) ?? "";
67
+ }
68
+ function walk(node, depth, inheritedHidden, out) {
69
+ const hidden = inheritedHidden || node.hidden === true;
70
+ const onTap = userAction(node.onTap);
71
+ const entry = { depth, kind: text(node.component) ?? "unknown" };
72
+ const content = labelFor(node, onTap !== undefined);
73
+ if (content)
74
+ entry.content = content;
75
+ if (onTap && !hidden)
76
+ entry.onTap = onTap;
77
+ const id = text(node.id);
78
+ if (id)
79
+ entry.id = id;
80
+ const testId = text(node.testId);
81
+ if (testId)
82
+ entry.testId = testId;
83
+ if (hidden)
84
+ entry.hidden = true;
85
+ out.push(entry);
86
+ for (const key of CHILD_LIST_KEYS) {
87
+ for (const child of rootsOf(node[key]))
88
+ walk(child, depth + 1, hidden, out);
89
+ }
90
+ for (const key of CHILD_NODE_KEYS) {
91
+ const child = node[key];
92
+ if (isNode(child))
93
+ walk(child, depth + 1, hidden, out);
94
+ }
95
+ }
96
+ /** A focusable node is labelled by its screenreaderText when it has one. */
97
+ function labelFor(node, focusable) {
98
+ if (focusable) {
99
+ const spoken = text(node.screenreaderText);
100
+ if (spoken)
101
+ return spoken;
102
+ }
103
+ return contentFor(node);
104
+ }
105
+ function contentFor(node) {
106
+ switch (text(node.component)) {
107
+ case "text-list":
108
+ return lines(node.texts);
109
+ case "image":
110
+ case "svgImage":
111
+ return `[image: ${mediaName(node)}]`;
112
+ case "videoUrl":
113
+ return `[video: ${mediaName(node)}]`;
114
+ case "symbol":
115
+ return `[icon: ${text(node.name) ?? "symbol"}]`;
116
+ case "productContainer":
117
+ case "flexProductContainer":
118
+ return `[products: ${text(node.subsetGroup) ?? text(node.products) ?? "all"}]`;
119
+ default:
120
+ return text(node.text);
121
+ }
122
+ }
123
+ /** The media's own name: its alt text, else the last segment of its url. */
124
+ function mediaName(node) {
125
+ const alt = text(node.alt);
126
+ if (alt)
127
+ return alt;
128
+ const url = text(node.url);
129
+ if (!url)
130
+ return "media";
131
+ const path = url.split(/[?#]/)[0];
132
+ const last = path.split("/").filter(Boolean).pop();
133
+ return last ?? url;
134
+ }
135
+ function lines(value) {
136
+ if (!Array.isArray(value))
137
+ return undefined;
138
+ const items = value.map(text).filter((item) => item !== undefined);
139
+ return items.length > 0 ? items.join(" · ") : undefined;
140
+ }
141
+ function userAction(value) {
142
+ if (!isNode(value))
143
+ return undefined;
144
+ return typeof value.function === "string" ? value : undefined;
145
+ }
146
+ function pagesOf(paywall) {
147
+ const pages = paywall?.template?.pages;
148
+ return Array.isArray(pages) ? pages.filter(isNode) : [];
149
+ }
150
+ /** A section's roots: an array of components, a single container, or nothing. */
151
+ function rootsOf(value) {
152
+ if (Array.isArray(value))
153
+ return value.filter(isNode);
154
+ return isNode(value) ? [value] : [];
155
+ }
156
+ function isNode(value) {
157
+ return typeof value === "object" && value !== null && !Array.isArray(value);
158
+ }
159
+ function text(value) {
160
+ return typeof value === "string" && value.trim() !== "" ? value : undefined;
161
+ }
@@ -0,0 +1,135 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { palette } from "../theme.js";
4
+ /**
5
+ * The known `NamiPaywallEvent` fields, in the order the pane shows them.
6
+ * Copied from `sdk/core/src/types/externals/paywall.ts` — the whole point of
7
+ * the expanded view is that a field the SDK did **not** send is still visible,
8
+ * so this list has to be the SDK's, not whatever happened to arrive.
9
+ */
10
+ export const PAYWALL_EVENT_KEYS = [
11
+ "sku",
12
+ "campaignId",
13
+ "campaignName",
14
+ "campaignType",
15
+ "campaignLabel",
16
+ "campaignUrl",
17
+ "paywallId",
18
+ "paywallName",
19
+ "componentChange",
20
+ "segmentId",
21
+ "externalSegmentId",
22
+ "deeplinkUrl",
23
+ "purchaseError",
24
+ "purchases",
25
+ "timeSpentOnPaywall",
26
+ "videoMetaData",
27
+ "purchaseChannel",
28
+ ];
29
+ /** A handoff carries these three; a flow event has no schema at all. */
30
+ export const HANDOFF_KEYS = ["tag", "data", "outcome"];
31
+ /** Stands in for a field the SDK did not send. */
32
+ const ABSENT = "—";
33
+ const SOURCE_COLOR = {
34
+ paywall: palette.secondaryBlue,
35
+ flow: palette.aqua,
36
+ handoff: palette.primaryBlue,
37
+ };
38
+ const pad2 = (n) => String(n).padStart(2, "0");
39
+ /** `hh:mm:ss` in the developer's own zone — they are watching this live. */
40
+ function formatTime(at) {
41
+ const d = new Date(at);
42
+ if (Number.isNaN(d.getTime()))
43
+ return "--:--:--";
44
+ return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
45
+ }
46
+ /** `null` means "the SDK did not send this", which the caller renders as a dash. */
47
+ function formatValue(value) {
48
+ if (value === undefined || value === null)
49
+ return null;
50
+ if (typeof value === "string")
51
+ return value === "" ? '""' : value;
52
+ if (typeof value === "number" || typeof value === "boolean")
53
+ return String(value);
54
+ try {
55
+ return JSON.stringify(value);
56
+ }
57
+ catch {
58
+ return String(value);
59
+ }
60
+ }
61
+ function clip(text, max) {
62
+ if (max <= 1 || text.length <= max)
63
+ return text;
64
+ return `${text.slice(0, max - 1)}…`;
65
+ }
66
+ /** Read a nested string-ish field, e.g. an object's `name` or `id`. */
67
+ function nameOf(value) {
68
+ if (typeof value === "string")
69
+ return value;
70
+ if (value && typeof value === "object") {
71
+ const record = value;
72
+ for (const key of ["skuId", "name", "id", "displayName"]) {
73
+ const inner = record[key];
74
+ if (typeof inner === "string" && inner !== "")
75
+ return inner;
76
+ }
77
+ }
78
+ return null;
79
+ }
80
+ /**
81
+ * The one field worth putting on a collapsed row. Never guesses at a schema for
82
+ * a flow event: it just shows that event's first author-defined key.
83
+ */
84
+ export function salientField(event) {
85
+ const { payload } = event;
86
+ if (event.source === "paywall") {
87
+ for (const key of ["sku", "purchaseError", "deeplinkUrl", "componentChange", "paywallName", "campaignName"]) {
88
+ const value = nameOf(payload[key]) ?? formatValue(payload[key]);
89
+ if (value)
90
+ return value;
91
+ }
92
+ return null;
93
+ }
94
+ if (event.source === "handoff") {
95
+ return formatValue(payload.tag);
96
+ }
97
+ const [firstKey] = Object.keys(payload);
98
+ if (firstKey === undefined)
99
+ return null;
100
+ const value = formatValue(payload[firstKey]);
101
+ return value === null ? firstKey : `${firstKey}=${value}`;
102
+ }
103
+ /** The known keys for a source, then anything extra the payload carried. */
104
+ function fieldsFor(event) {
105
+ const known = event.source === "paywall" ? PAYWALL_EVENT_KEYS : event.source === "handoff" ? HANDOFF_KEYS : [];
106
+ const extras = Object.keys(event.payload).filter((key) => !known.includes(key));
107
+ return [...known, ...extras].map((key) => [key, formatValue(event.payload[key])]);
108
+ }
109
+ function EventRow({ event, selected, width }) {
110
+ const head = `${formatTime(event.at)} · ${event.source} · ${event.name}`;
111
+ // "simulated" is part of the head: it must never be the thing that gets dropped.
112
+ const mark = event.simulated ? " · simulated" : "";
113
+ const salient = salientField(event);
114
+ const room = width - 2 - head.length - mark.length;
115
+ // A narrow row drops the salient column rather than clipping the name into ambiguity.
116
+ const tail = salient !== null && room >= salient.length + 3 ? ` · ${salient}` : "";
117
+ return (_jsxs(Text, { children: [_jsx(Text, { color: palette.aqua, children: selected ? "›" : " " }), " ", _jsx(Text, { dimColor: true, children: formatTime(event.at) }), " · ", _jsx(Text, { color: SOURCE_COLOR[event.source], children: event.source }), " · ", _jsx(Text, { bold: selected, children: event.name }), event.simulated && _jsx(Text, { color: palette.coral, children: " \u00B7 simulated" }), tail && _jsx(Text, { dimColor: true, children: tail })] }));
118
+ }
119
+ function ExpandedEvent({ event, width }) {
120
+ const fields = fieldsFor(event);
121
+ const labelWidth = fields.reduce((longest, [key]) => Math.max(longest, key.length), 0);
122
+ const valueWidth = Math.max(12, width - labelWidth - 4);
123
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, color: palette.aqua, children: event.name }), _jsxs(Text, { dimColor: true, children: [" \u00B7 seq ", event.seq, " \u00B7 ", formatTime(event.at), " \u00B7 ", event.source] })] }), event.simulated && (_jsx(Text, { color: palette.coral, children: "simulated \u00B7 the CLI's purchase adapter produced this, not a real store" })), fields.length === 0 ? (_jsx(Text, { dimColor: true, children: "this event carried no fields" })) : (fields.map(([key, value]) => (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: key.padEnd(labelWidth) }), " ", value === null ? _jsx(Text, { dimColor: true, children: ABSENT }) : _jsx(Text, { children: clip(value, valueWidth) })] }, key))))] }));
124
+ }
125
+ /**
126
+ * What the SDK just sent, as it sent it.
127
+ *
128
+ * Pure: it renders the props it is handed and nothing else. A field the SDK
129
+ * omitted still gets a row with a dash, because "did my paywall send this?" is
130
+ * the question this pane exists to answer, and an omitted row cannot answer it.
131
+ */
132
+ export function EventsPane({ events, selectedSeq, paused, expanded, width }) {
133
+ const selected = selectedSeq === null ? undefined : events.find((event) => event.seq === selectedSeq);
134
+ return (_jsxs(Box, { flexDirection: "column", width: width, children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, children: "Events" }), " ", _jsx(Text, { bold: true, children: events.length }), paused && _jsx(Text, { color: palette.coral, children: " \u00B7 paused" })] }), events.length === 0 ? (_jsx(Text, { dimColor: true, children: "No events yet \u2014 interact with the placement and what it sends shows up here." })) : (events.map((event) => (_jsx(EventRow, { event: event, selected: event.seq === selectedSeq, width: width }, event.seq)))), expanded && selected && _jsx(ExpandedEvent, { event: selected, width: width })] }));
135
+ }
@@ -0,0 +1,74 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text, useInput } from "ink";
3
+ import { palette } from "../theme.js";
4
+ import { EventsPane } from "./EventsPane.js";
5
+ /**
6
+ * A launched placement: the page outline on one side, what the SDK reported on
7
+ * the other.
8
+ *
9
+ * Pure presentation plus input — it renders the props it is handed and turns
10
+ * keys into callbacks. The subscriptions, the bus and the focus index live in
11
+ * `src/placements/launch.ts` and `Shell`, so this file can be rendered from a
12
+ * test with nothing configured.
13
+ */
14
+ /** At or above this many columns the two panes sit side by side; below it they stack. */
15
+ export const SIDE_BY_SIDE_MIN_WIDTH = 100;
16
+ /** Columns between the two panes when they sit side by side. */
17
+ const GUTTER = 2;
18
+ /**
19
+ * What the arrows move between: a node the paywall gave an action to, that is
20
+ * actually on screen. The walker already drops `onTap` from a hidden subtree;
21
+ * the `hidden` check here means a change there can never make an invisible
22
+ * control pressable.
23
+ */
24
+ export function focusableNodes(composite) {
25
+ return composite.sections.flatMap((section) => section.nodes).filter((node) => node.onTap && !node.hidden);
26
+ }
27
+ function clip(text, max) {
28
+ if (max <= 1 || text.length <= max)
29
+ return text;
30
+ return `${text.slice(0, max - 1)}…`;
31
+ }
32
+ /** One outline row: `kind content → action`, indented by depth. */
33
+ function nodeLine(node) {
34
+ const parts = [node.kind];
35
+ if (node.content)
36
+ parts.push(node.content);
37
+ if (node.onTap)
38
+ parts.push(`→ ${node.onTap.function}`);
39
+ // A word, never colour alone — the dim is a second signal, not the only one.
40
+ if (node.hidden)
41
+ parts.push("· hidden");
42
+ return `${" ".repeat(node.depth)}${parts.join(" ")}`;
43
+ }
44
+ function CompositePane({ composite, placement, focused, width, deviceName }) {
45
+ const empty = composite.sections.length === 0;
46
+ return (_jsxs(Box, { flexDirection: "column", width: width, children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, children: "Page" }), " ", _jsx(Text, { children: composite.pageName || "—" })] }), empty && placement.kind === "flow" && (_jsx(Text, { dimColor: true, children: "A flow has no one page to outline \u2014 its screens and steps report as flow events in the pane." })), empty && placement.kind !== "flow" && composite.unresolved && (_jsxs(Text, { dimColor: true, children: ["No paywall for this placement on ", deviceName ? `a ${deviceName}` : "the configured device", " \u2014 it is probably authored for another form factor. Switch device (d on the home screen, or --device) and launch again."] })), empty && placement.kind !== "flow" && !composite.unresolved && (_jsx(Text, { dimColor: true, children: "Nothing to outline on this page \u2014 the paywall resolved but served no components." })), composite.sections.map((section) => (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: section.name }), section.nodes.map((node, index) => {
47
+ const isFocused = node === focused;
48
+ return (_jsxs(Text, { dimColor: node.hidden, children: [_jsx(Text, { color: palette.aqua, children: isFocused ? "›" : " " }), " ", _jsx(Text, { bold: isFocused, children: clip(nodeLine(node), Math.max(width - 2, 1)) })] }, `${section.name}-${index}`));
49
+ })] }, section.name))), composite.truncated > 0 && _jsxs(Text, { dimColor: true, children: ["+", composite.truncated, " more"] })] }));
50
+ }
51
+ export function LaunchScreen({ placement, composite, events, width, paused, expanded, selectedSeq, focusIndex, onPress, onMove, onTogglePause, onClear, onToggleExpand, onBack, notice, deviceName }) {
52
+ const focusable = focusableNodes(composite);
53
+ const focused = focusable[focusIndex];
54
+ useInput((input, key) => {
55
+ if (key.upArrow || key.leftArrow)
56
+ return onMove(-1);
57
+ if (key.downArrow || key.rightArrow)
58
+ return onMove(1);
59
+ if (key.return)
60
+ return void (focused && onPress(focused));
61
+ if (input === "p")
62
+ return onTogglePause();
63
+ if (input === "c")
64
+ return onClear();
65
+ if (input === "x")
66
+ return onToggleExpand();
67
+ if (input === "q" || key.escape)
68
+ return onBack();
69
+ });
70
+ const sideBySide = width >= SIDE_BY_SIDE_MIN_WIDTH;
71
+ const paneWidth = sideBySide ? Math.floor(width / 2) - 1 : width;
72
+ const eventsWidth = sideBySide ? width - paneWidth - GUTTER : width;
73
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, children: placement.label }), " ", _jsxs(Text, { color: palette.secondaryBlue, children: ["[", placement.kind, "]"] }), placement.campaignName && _jsxs(Text, { dimColor: true, children: [" \u00B7 ", placement.campaignName] })] }), _jsxs(Box, { flexDirection: sideBySide ? "row" : "column", marginTop: 1, children: [_jsx(CompositePane, { composite: composite, placement: placement, focused: focused, width: paneWidth, deviceName: deviceName }), _jsx(Box, { flexDirection: "column", marginLeft: sideBySide ? GUTTER : 0, marginTop: sideBySide ? 0 : 1, children: _jsx(EventsPane, { events: events, selectedSeq: selectedSeq ?? null, paused: paused, expanded: expanded, width: eventsWidth }) })] }), notice && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: palette.coral, children: notice }) })), _jsx(Text, { dimColor: true, children: "\u2191\u2193 move \u00B7 enter press \u00B7 p pause \u00B7 c clear \u00B7 x expand \u00B7 q back" })] }));
74
+ }
@@ -56,7 +56,7 @@ export function LoginFlow({ auth, store, host, totpCode, org, onDone, onFail, ex
56
56
  return void finish(signedIn, match);
57
57
  }
58
58
  if (orgs.length === 0)
59
- return fail(new AuthError("org_forbidden", "Your account does not belong to any organization yet."));
59
+ return fail(new AuthError("org_forbidden", `Your account does not belong to any organization yet. Ask an organization admin to invite you, or create one at ${host.baseUrl}.`));
60
60
  if (orgs.length === 1)
61
61
  return void finish(signedIn, orgs[0]);
62
62
  setStep({ kind: "org", signedIn });
@@ -0,0 +1,27 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ import { openUrl as openUrlInBrowser } from "../utils/open-url.js";
5
+ const STEPS = [
6
+ "Create an account in the Control Center",
7
+ "Create an app",
8
+ "Add a platform to it (Apple, Google, Amazon, Roku, Web)",
9
+ "Copy that platform's App Platform ID",
10
+ ];
11
+ /**
12
+ * The explainer for a developer with no Nami account yet: the four steps that
13
+ * end in an App Platform ID, and the Control Center to do them in. Pure — the
14
+ * only side effect is the browser handoff the caller injects.
15
+ */
16
+ export function NewToNami({ host, onContinue, onBack, openUrl = openUrlInBrowser }) {
17
+ const [opened, setOpened] = useState(null);
18
+ useInput((input, key) => {
19
+ if (key.escape)
20
+ return onBack();
21
+ if (key.return)
22
+ return onContinue();
23
+ if (input.toLowerCase() === "o")
24
+ setOpened(openUrl(host.baseUrl));
25
+ });
26
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "New to Nami" }), _jsx(Text, { children: "Four steps in the Control Center and the CLI can connect:" }), STEPS.map((step, i) => (_jsxs(Text, { children: [" ", i + 1, ". ", step] }, step))), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: ["Control Center: ", host.baseUrl] }) }), opened === true && _jsx(Text, { dimColor: true, children: "Control Center opened." }), opened === false && _jsxs(Text, { children: ["Open ", host.baseUrl, " in your browser."] }), _jsx(Text, { dimColor: true, children: "o open the Control Center \u00B7 Enter continue \u00B7 Esc back" })] }));
27
+ }
@@ -1,9 +1,9 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  import { SelectList } from "../components/SelectList.js";
4
4
  export function PickOrg({ orgs, currentOrgId, onSelect, onCancel }) {
5
5
  if (orgs.length === 0) {
6
- return (_jsx(Box, { flexDirection: "column", children: _jsx(Text, { bold: true, children: "Your account does not belong to any organization yet." }) }));
6
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Your account does not belong to any organization yet." }), _jsx(Text, { children: "Ask an organization admin to invite you, or create one in the Control Center." })] }));
7
7
  }
8
8
  return (_jsx(SelectList, { title: "Choose an organization", items: orgs.map((o) => ({ key: o.id, label: o.name })), initialKey: currentOrgId ?? undefined, onSelect: (key) => { const org = orgs.find((o) => o.id === key); if (org)
9
9
  onSelect(org); }, onCancel: onCancel }));
@@ -0,0 +1,11 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { SelectList } from "../components/SelectList.js";
4
+ /** Pure picker over what `listPlacements()` found — it never fetches. */
5
+ export function PickPlacement({ placements, onSelect, onCancel }) {
6
+ if (placements.length === 0) {
7
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "No placements yet." }), _jsx(Text, { children: "Add a campaign in the Control Center, then run nami again." })] }));
8
+ }
9
+ return (_jsx(SelectList, { title: "Placements", items: placements.map((p) => ({ key: p.label, label: p.label, badge: p.kind, hint: p.campaignName })), onSelect: (key) => { const placement = placements.find((p) => p.label === key); if (placement)
10
+ onSelect(placement); }, onCancel: onCancel }));
11
+ }