@broberg/seti-client 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.
package/dist/preact.js ADDED
@@ -0,0 +1,157 @@
1
+ import { SetiClient, FrameAccumulator } from './chunk-VWTGZF3D.js';
2
+ export { SetiClient } from './chunk-VWTGZF3D.js';
3
+ import { useMemo, useState, useRef, useEffect } from 'preact/hooks';
4
+ import { jsxs, jsx } from 'preact/jsx-runtime';
5
+
6
+ var NAV_KEYS = [
7
+ { key: "Escape", label: "Esc", title: "Escape" },
8
+ { key: "Up", label: "\u2191", title: "Pil op" },
9
+ { key: "Down", label: "\u2193", title: "Pil ned" },
10
+ { key: "Left", label: "\u2190", title: "Pil venstre" },
11
+ { key: "Right", label: "\u2192", title: "Pil h\xF8jre" },
12
+ { key: "Enter", label: "\u23CE", title: "Enter" }
13
+ ];
14
+ var STYLE_ID = "broberg-seti-chat-style";
15
+ var CSS = `
16
+ .seti-chat{display:flex;flex-direction:column;height:100%;min-height:0;background:var(--seti-bg,#0b0e14);color:var(--seti-fg,#d7dce5);border:1px solid var(--seti-edge,#1e2430);border-radius:var(--seti-radius,12px);overflow:hidden}
17
+ .seti-chat__header{display:flex;align-items:center;gap:.55rem;padding:.6rem .9rem;background:var(--seti-panel,#11151f);border-bottom:1px solid var(--seti-edge,#1e2430);font-size:.82rem}
18
+ .seti-chat__dot{width:9px;height:9px;border-radius:50%;background:var(--seti-dim,#8a93a6);transition:background .2s;flex:none}
19
+ .seti-chat__dot.is-on{background:var(--seti-accent,#34d399);animation:seti-pulse 2s infinite}
20
+ .seti-chat__dot.is-bad{background:var(--seti-bad,#f87171)}
21
+ @keyframes seti-pulse{0%{box-shadow:0 0 0 0 rgba(52,211,153,.5)}70%{box-shadow:0 0 0 7px rgba(52,211,153,0)}100%{box-shadow:0 0 0 0 rgba(52,211,153,0)}}
22
+ .seti-chat__meta{color:var(--seti-dim,#8a93a6);font-family:var(--seti-mono,ui-monospace,Menlo,monospace);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
23
+ .seti-chat__screen{flex:1;margin:0;padding:.9rem 1rem;overflow:auto;white-space:pre-wrap;word-break:break-word;font-family:var(--seti-mono,ui-monospace,Menlo,monospace);font-size:12.5px;line-height:1.45;-webkit-overflow-scrolling:touch}
24
+ .seti-chat__screen.is-empty{color:var(--seti-dim,#8a93a6)}
25
+ .seti-chat__navkeys{display:flex;gap:.4rem;padding:.45rem .65rem .2rem;background:var(--seti-panel,#11151f);flex-wrap:wrap;border-top:1px solid var(--seti-edge,#1e2430)}
26
+ .seti-chat__navkeys button{padding:.45rem .7rem;background:var(--seti-edge,#1e2430);color:var(--seti-fg,#d7dce5);font-size:13px;min-width:2.6rem;min-height:2.2rem;font-weight:600;border:0;border-radius:8px;cursor:pointer;transition:transform .06s,background .15s,opacity .15s}
27
+ .seti-chat__navkeys button:hover{filter:brightness(1.25)}
28
+ .seti-chat__navkeys button:active{transform:translateY(1px) scale(.98)}
29
+ .seti-chat__navkeys button:disabled{opacity:.5;cursor:not-allowed}
30
+ .seti-chat__form{display:flex;gap:.5rem;padding:.6rem;background:var(--seti-panel,#11151f);border-top:1px solid var(--seti-edge,#1e2430)}
31
+ .seti-chat__input{flex:1;min-width:0;background:var(--seti-bg,#0b0e14);border:1px solid var(--seti-edge,#1e2430);color:var(--seti-fg,#d7dce5);border-radius:10px;padding:.65rem .85rem;font-family:var(--seti-mono,ui-monospace,Menlo,monospace);font-size:16px;outline:none}
32
+ .seti-chat__input:focus{border-color:var(--seti-accent,#34d399);box-shadow:0 0 0 3px rgba(52,211,153,.15)}
33
+ .seti-chat__send{background:var(--seti-accent,#34d399);color:#07120d;border:0;border-radius:10px;padding:.65rem 1.05rem;font-weight:650;cursor:pointer;transition:transform .06s,background .15s,opacity .15s}
34
+ .seti-chat__send:hover{filter:brightness(1.08)}
35
+ .seti-chat__send:active{transform:translateY(1px) scale(.99)}
36
+ .seti-chat__send:disabled{opacity:.5;cursor:not-allowed}
37
+ .seti-chat__send.is-sending{background:var(--seti-warn,#fbbf24);color:#2a1d00}
38
+ `;
39
+ function ensureStyle() {
40
+ if (typeof document === "undefined") return;
41
+ if (document.getElementById(STYLE_ID)) return;
42
+ const el = document.createElement("style");
43
+ el.id = STYLE_ID;
44
+ el.textContent = CSS;
45
+ document.head.appendChild(el);
46
+ }
47
+ function SetiChat(props) {
48
+ const client = useMemo(
49
+ () => props.client ?? new SetiClient({ baseUrl: props.baseUrl ?? "/api/seti" }),
50
+ [props.client, props.baseUrl]
51
+ );
52
+ const [text, setText] = useState("");
53
+ const [sending, setSending] = useState(false);
54
+ const [screenText, setScreenText] = useState("");
55
+ const [edgeOn, setEdgeOn] = useState(null);
56
+ const [streamState, setStreamState] = useState("connecting");
57
+ const [notice, setNotice] = useState(null);
58
+ const screenRef = useRef(null);
59
+ useEffect(ensureStyle, []);
60
+ useEffect(() => {
61
+ const acc = new FrameAccumulator();
62
+ setScreenText("");
63
+ setNotice(null);
64
+ const atBottom = () => {
65
+ const s = screenRef.current;
66
+ return !s || s.scrollHeight - s.scrollTop - s.clientHeight < 40;
67
+ };
68
+ const handle = client.openStream(props.edge, props.session, {
69
+ onHello: (h) => setEdgeOn(h.edgeConnected),
70
+ onPing: (p) => setEdgeOn(p.edgeConnected),
71
+ onStateChange: setStreamState,
72
+ onFrame: (content) => {
73
+ const stick = atBottom();
74
+ acc.feed(content);
75
+ setScreenText(acc.text);
76
+ if (stick) {
77
+ requestAnimationFrame(() => {
78
+ const s = screenRef.current;
79
+ if (s) s.scrollTop = s.scrollHeight;
80
+ });
81
+ }
82
+ }
83
+ });
84
+ return () => handle.close();
85
+ }, [client, props.edge, props.session]);
86
+ const meta = streamState === "open" ? edgeOn === false ? `${props.edge} \xB7 edge offline` : `${props.edge} \xB7 ${props.session}` : streamState === "closed" ? "lukket" : "forbinder\u2026";
87
+ const dotClass = "seti-chat__dot" + (streamState === "open" && edgeOn ? " is-on" : edgeOn === false ? " is-bad" : "");
88
+ const submit = async (ev) => {
89
+ ev.preventDefault();
90
+ if (!text.trim() || sending) return;
91
+ setSending(true);
92
+ setNotice(null);
93
+ const res = await client.sendText(props.edge, props.session, text);
94
+ if (res.ok) {
95
+ setText("");
96
+ } else {
97
+ setNotice("Ikke leveret \u2014 din tekst er bevaret");
98
+ }
99
+ setSending(false);
100
+ };
101
+ const pressKey = async (key) => {
102
+ await client.sendKey(props.edge, props.session, key);
103
+ };
104
+ return /* @__PURE__ */ jsxs("div", { class: "seti-chat" + (props.class ? ` ${props.class}` : ""), "data-testid": "seti-chat-root", children: [
105
+ /* @__PURE__ */ jsxs("div", { class: "seti-chat__header", "data-testid": "seti-chat-header", children: [
106
+ /* @__PURE__ */ jsx("span", { class: dotClass, "data-testid": "seti-chat-status-dot" }),
107
+ /* @__PURE__ */ jsx("span", { class: "seti-chat__meta", "data-testid": "seti-chat-meta", children: notice ?? meta })
108
+ ] }),
109
+ /* @__PURE__ */ jsx(
110
+ "pre",
111
+ {
112
+ ref: screenRef,
113
+ class: "seti-chat__screen" + (screenText ? "" : " is-empty"),
114
+ "data-testid": "seti-chat-screen",
115
+ children: screenText || "Venter p\xE5 den f\xF8rste frame fra edgen\u2026"
116
+ }
117
+ ),
118
+ /* @__PURE__ */ jsx("div", { class: "seti-chat__navkeys", "data-testid": "seti-chat-navkeys", children: NAV_KEYS.map((k) => /* @__PURE__ */ jsx(
119
+ "button",
120
+ {
121
+ type: "button",
122
+ title: k.title,
123
+ "data-testid": `seti-chat-key-${k.key.toLowerCase()}`,
124
+ onClick: () => void pressKey(k.key),
125
+ children: k.label
126
+ },
127
+ k.key
128
+ )) }),
129
+ /* @__PURE__ */ jsxs("form", { class: "seti-chat__form", "data-testid": "seti-chat-form", onSubmit: submit, children: [
130
+ /* @__PURE__ */ jsx(
131
+ "input",
132
+ {
133
+ class: "seti-chat__input",
134
+ "data-testid": "seti-chat-input",
135
+ value: text,
136
+ placeholder: props.placeholder ?? "Skriv til sessionen\u2026",
137
+ autocomplete: "off",
138
+ onInput: (e) => setText(e.target.value)
139
+ }
140
+ ),
141
+ /* @__PURE__ */ jsx(
142
+ "button",
143
+ {
144
+ type: "submit",
145
+ class: "seti-chat__send" + (sending ? " is-sending" : ""),
146
+ "data-testid": "seti-chat-send",
147
+ disabled: sending,
148
+ children: sending ? "\u2026" : "Send"
149
+ }
150
+ )
151
+ ] })
152
+ ] });
153
+ }
154
+
155
+ export { SetiChat };
156
+ //# sourceMappingURL=preact.js.map
157
+ //# sourceMappingURL=preact.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/preact.tsx"],"names":[],"mappings":";;;;;AA6BA,IAAM,QAAA,GAAkE;AAAA,EACtE,EAAE,GAAA,EAAK,QAAA,EAAU,KAAA,EAAO,KAAA,EAAO,OAAO,QAAA,EAAS;AAAA,EAC/C,EAAE,GAAA,EAAK,IAAA,EAAM,KAAA,EAAO,QAAA,EAAK,OAAO,QAAA,EAAS;AAAA,EACzC,EAAE,GAAA,EAAK,MAAA,EAAQ,KAAA,EAAO,QAAA,EAAK,OAAO,SAAA,EAAU;AAAA,EAC5C,EAAE,GAAA,EAAK,MAAA,EAAQ,KAAA,EAAO,QAAA,EAAK,OAAO,aAAA,EAAc;AAAA,EAChD,EAAE,GAAA,EAAK,OAAA,EAAS,KAAA,EAAO,QAAA,EAAK,OAAO,cAAA,EAAY;AAAA,EAC/C,EAAE,GAAA,EAAK,OAAA,EAAS,KAAA,EAAO,QAAA,EAAK,OAAO,OAAA;AACrC,CAAA;AAEA,IAAM,QAAA,GAAW,yBAAA;AACjB,IAAM,GAAA,GAAM;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,CAAA;AAyBZ,SAAS,WAAA,GAAoB;AAC3B,EAAA,IAAI,OAAO,aAAa,WAAA,EAAa;AACrC,EAAA,IAAI,QAAA,CAAS,cAAA,CAAe,QAAQ,CAAA,EAAG;AACvC,EAAA,MAAM,EAAA,GAAK,QAAA,CAAS,aAAA,CAAc,OAAO,CAAA;AACzC,EAAA,EAAA,CAAG,EAAA,GAAK,QAAA;AACR,EAAA,EAAA,CAAG,WAAA,GAAc,GAAA;AACjB,EAAA,QAAA,CAAS,IAAA,CAAK,YAAY,EAAE,CAAA;AAC9B;AAEO,SAAS,SAAS,KAAA,EAAsB;AAC7C,EAAA,MAAM,MAAA,GAAS,OAAA;AAAA,IACb,MAAM,KAAA,CAAM,MAAA,IAAU,IAAI,UAAA,CAAW,EAAE,OAAA,EAAS,KAAA,CAAM,OAAA,IAAW,WAAA,EAAa,CAAA;AAAA,IAC9E,CAAC,KAAA,CAAM,MAAA,EAAQ,KAAA,CAAM,OAAO;AAAA,GAC9B;AACA,EAAA,MAAM,CAAC,IAAA,EAAM,OAAO,CAAA,GAAI,SAAS,EAAE,CAAA;AACnC,EAAA,MAAM,CAAC,OAAA,EAAS,UAAU,CAAA,GAAI,SAAS,KAAK,CAAA;AAC5C,EAAA,MAAM,CAAC,UAAA,EAAY,aAAa,CAAA,GAAI,SAAS,EAAE,CAAA;AAC/C,EAAA,MAAM,CAAC,MAAA,EAAQ,SAAS,CAAA,GAAI,SAAyB,IAAI,CAAA;AACzD,EAAA,MAAM,CAAC,WAAA,EAAa,cAAc,CAAA,GAAI,SAA0B,YAAY,CAAA;AAC5E,EAAA,MAAM,CAAC,MAAA,EAAQ,SAAS,CAAA,GAAI,SAAwB,IAAI,CAAA;AACxD,EAAA,MAAM,SAAA,GAAY,OAAuB,IAAI,CAAA;AAE7C,EAAA,SAAA,CAAU,WAAA,EAAa,EAAE,CAAA;AAEzB,EAAA,SAAA,CAAU,MAAM;AACd,IAAA,MAAM,GAAA,GAAM,IAAI,gBAAA,EAAiB;AACjC,IAAA,aAAA,CAAc,EAAE,CAAA;AAChB,IAAA,SAAA,CAAU,IAAI,CAAA;AACd,IAAA,MAAM,WAAW,MAAe;AAC9B,MAAA,MAAM,IAAI,SAAA,CAAU,OAAA;AACpB,MAAA,OAAO,CAAC,CAAA,IAAK,CAAA,CAAE,eAAe,CAAA,CAAE,SAAA,GAAY,EAAE,YAAA,GAAe,EAAA;AAAA,IAC/D,CAAA;AACA,IAAA,MAAM,SAAS,MAAA,CAAO,UAAA,CAAW,KAAA,CAAM,IAAA,EAAM,MAAM,OAAA,EAAS;AAAA,MAC1D,OAAA,EAAS,CAAC,CAAA,KAAM,SAAA,CAAU,EAAE,aAAa,CAAA;AAAA,MACzC,MAAA,EAAQ,CAAC,CAAA,KAAM,SAAA,CAAU,EAAE,aAAa,CAAA;AAAA,MACxC,aAAA,EAAe,cAAA;AAAA,MACf,OAAA,EAAS,CAAC,OAAA,KAAY;AACpB,QAAA,MAAM,QAAQ,QAAA,EAAS;AACvB,QAAA,GAAA,CAAI,KAAK,OAAO,CAAA;AAChB,QAAA,aAAA,CAAc,IAAI,IAAI,CAAA;AACtB,QAAA,IAAI,KAAA,EAAO;AACT,UAAA,qBAAA,CAAsB,MAAM;AAC1B,YAAA,MAAM,IAAI,SAAA,CAAU,OAAA;AACpB,YAAA,IAAI,CAAA,EAAG,CAAA,CAAE,SAAA,GAAY,CAAA,CAAE,YAAA;AAAA,UACzB,CAAC,CAAA;AAAA,QACH;AAAA,MACF;AAAA,KACD,CAAA;AACD,IAAA,OAAO,MAAM,OAAO,KAAA,EAAM;AAAA,EAC5B,GAAG,CAAC,MAAA,EAAQ,MAAM,IAAA,EAAM,KAAA,CAAM,OAAO,CAAC,CAAA;AAEtC,EAAA,MAAM,OACJ,WAAA,KAAgB,MAAA,GACZ,WAAW,KAAA,GACT,CAAA,EAAG,MAAM,IAAI,CAAA,kBAAA,CAAA,GACb,CAAA,EAAG,KAAA,CAAM,IAAI,CAAA,MAAA,EAAM,KAAA,CAAM,OAAO,CAAA,CAAA,GAClC,WAAA,KAAgB,WACd,QAAA,GACA,iBAAA;AACR,EAAA,MAAM,QAAA,GACJ,oBAAoB,WAAA,KAAgB,MAAA,IAAU,SAAS,QAAA,GAAW,MAAA,KAAW,QAAQ,SAAA,GAAY,EAAA,CAAA;AAEnG,EAAA,MAAM,MAAA,GAAS,OAAO,EAAA,KAAc;AAClC,IAAA,EAAA,CAAG,cAAA,EAAe;AAClB,IAAA,IAAI,CAAC,IAAA,CAAK,IAAA,EAAK,IAAK,OAAA,EAAS;AAC7B,IAAA,UAAA,CAAW,IAAI,CAAA;AACf,IAAA,SAAA,CAAU,IAAI,CAAA;AACd,IAAA,MAAM,GAAA,GAAM,MAAM,MAAA,CAAO,QAAA,CAAS,MAAM,IAAA,EAAM,KAAA,CAAM,SAAS,IAAI,CAAA;AACjE,IAAA,IAAI,IAAI,EAAA,EAAI;AACV,MAAA,OAAA,CAAQ,EAAE,CAAA;AAAA,IACZ,CAAA,MAAO;AACL,MAAA,SAAA,CAAU,0CAAqC,CAAA;AAAA,IACjD;AACA,IAAA,UAAA,CAAW,KAAK,CAAA;AAAA,EAClB,CAAA;AAEA,EAAA,MAAM,QAAA,GAAW,OAAO,GAAA,KAAiB;AACvC,IAAA,MAAM,OAAO,OAAA,CAAQ,KAAA,CAAM,IAAA,EAAM,KAAA,CAAM,SAAS,GAAG,CAAA;AAAA,EACrD,CAAA;AAEA,EAAA,uBACE,IAAA,CAAC,KAAA,EAAA,EAAI,KAAA,EAAO,WAAA,IAAe,KAAA,CAAM,KAAA,GAAQ,CAAA,CAAA,EAAI,KAAA,CAAM,KAAK,CAAA,CAAA,GAAK,EAAA,CAAA,EAAK,aAAA,EAAY,gBAAA,EAC5E,QAAA,EAAA;AAAA,oBAAA,IAAA,CAAC,KAAA,EAAA,EAAI,KAAA,EAAM,mBAAA,EAAoB,aAAA,EAAY,kBAAA,EACzC,QAAA,EAAA;AAAA,sBAAA,GAAA,CAAC,MAAA,EAAA,EAAK,KAAA,EAAO,QAAA,EAAU,aAAA,EAAY,sBAAA,EAAuB,CAAA;AAAA,0BACzD,MAAA,EAAA,EAAK,KAAA,EAAM,mBAAkB,aAAA,EAAY,gBAAA,EACvC,oBAAU,IAAA,EACb;AAAA,KAAA,EACF,CAAA;AAAA,oBACA,GAAA;AAAA,MAAC,KAAA;AAAA,MAAA;AAAA,QACC,GAAA,EAAK,SAAA;AAAA,QACL,KAAA,EAAO,mBAAA,IAAuB,UAAA,GAAa,EAAA,GAAK,WAAA,CAAA;AAAA,QAChD,aAAA,EAAY,kBAAA;AAAA,QAEX,QAAA,EAAA,UAAA,IAAc;AAAA;AAAA,KACjB;AAAA,oBACA,GAAA,CAAC,SAAI,KAAA,EAAM,oBAAA,EAAqB,eAAY,mBAAA,EACzC,QAAA,EAAA,QAAA,CAAS,GAAA,CAAI,CAAC,CAAA,qBACb,GAAA;AAAA,MAAC,QAAA;AAAA,MAAA;AAAA,QAEC,IAAA,EAAK,QAAA;AAAA,QACL,OAAO,CAAA,CAAE,KAAA;AAAA,QACT,aAAA,EAAa,CAAA,cAAA,EAAiB,CAAA,CAAE,GAAA,CAAI,aAAa,CAAA,CAAA;AAAA,QACjD,OAAA,EAAS,MAAM,KAAK,QAAA,CAAS,EAAE,GAAG,CAAA;AAAA,QAEjC,QAAA,EAAA,CAAA,CAAE;AAAA,OAAA;AAAA,MANE,CAAA,CAAE;AAAA,KAQV,CAAA,EACH,CAAA;AAAA,yBACC,MAAA,EAAA,EAAK,KAAA,EAAM,mBAAkB,aAAA,EAAY,gBAAA,EAAiB,UAAU,MAAA,EACnE,QAAA,EAAA;AAAA,sBAAA,GAAA;AAAA,QAAC,OAAA;AAAA,QAAA;AAAA,UACC,KAAA,EAAM,kBAAA;AAAA,UACN,aAAA,EAAY,iBAAA;AAAA,UACZ,KAAA,EAAO,IAAA;AAAA,UACP,WAAA,EAAa,MAAM,WAAA,IAAe,2BAAA;AAAA,UAClC,YAAA,EAAa,KAAA;AAAA,UACb,SAAS,CAAC,CAAA,KAAM,OAAA,CAAS,CAAA,CAAE,OAA4B,KAAK;AAAA;AAAA,OAC9D;AAAA,sBACA,GAAA;AAAA,QAAC,QAAA;AAAA,QAAA;AAAA,UACC,IAAA,EAAK,QAAA;AAAA,UACL,KAAA,EAAO,iBAAA,IAAqB,OAAA,GAAU,aAAA,GAAgB,EAAA,CAAA;AAAA,UACtD,aAAA,EAAY,gBAAA;AAAA,UACZ,QAAA,EAAU,OAAA;AAAA,UAET,oBAAU,QAAA,GAAM;AAAA;AAAA;AACnB,KAAA,EACF;AAAA,GAAA,EACF,CAAA;AAEJ","file":"preact.js","sourcesContent":["import { useEffect, useMemo, useRef, useState } from \"preact/hooks\";\nimport { SetiClient } from \"./client\";\nimport { FrameAccumulator } from \"./frame-accumulator\";\nimport type { SetiKey, SetiStreamState } from \"./types\";\n\n/**\n * <SetiChat> — the complete mobile-first SET/SETI live chat surface:\n * status header, accumulated screen (FrameAccumulator), nav-keys bar\n * (Esc/↑/↓/←/→/⏎) and a text input with delivery feedback (text is preserved\n * when delivery fails).\n *\n * Self-contained styles, themeable via CSS vars (set them on a parent):\n * --seti-bg, --seti-panel, --seti-edge, --seti-fg, --seti-dim,\n * --seti-accent, --seti-warn, --seti-bad, --seti-mono, --seti-radius\n *\n * Every interactive element carries data-testid=\"seti-chat-*\".\n */\nexport interface SetiChatProps {\n /** The host app's proxy mount, e.g. \"/api/seti\". Ignored if `client` is given. */\n baseUrl?: string;\n client?: SetiClient;\n edge: string;\n session: string;\n /** Extra class on the root (sizing/layout belongs to the host). */\n class?: string;\n /** Placeholder for the text input. Default: \"Skriv til sessionen…\" */\n placeholder?: string;\n}\n\nconst NAV_KEYS: Array<{ key: SetiKey; label: string; title: string }> = [\n { key: \"Escape\", label: \"Esc\", title: \"Escape\" },\n { key: \"Up\", label: \"↑\", title: \"Pil op\" },\n { key: \"Down\", label: \"↓\", title: \"Pil ned\" },\n { key: \"Left\", label: \"←\", title: \"Pil venstre\" },\n { key: \"Right\", label: \"→\", title: \"Pil højre\" },\n { key: \"Enter\", label: \"⏎\", title: \"Enter\" },\n];\n\nconst STYLE_ID = \"broberg-seti-chat-style\";\nconst CSS = `\n.seti-chat{display:flex;flex-direction:column;height:100%;min-height:0;background:var(--seti-bg,#0b0e14);color:var(--seti-fg,#d7dce5);border:1px solid var(--seti-edge,#1e2430);border-radius:var(--seti-radius,12px);overflow:hidden}\n.seti-chat__header{display:flex;align-items:center;gap:.55rem;padding:.6rem .9rem;background:var(--seti-panel,#11151f);border-bottom:1px solid var(--seti-edge,#1e2430);font-size:.82rem}\n.seti-chat__dot{width:9px;height:9px;border-radius:50%;background:var(--seti-dim,#8a93a6);transition:background .2s;flex:none}\n.seti-chat__dot.is-on{background:var(--seti-accent,#34d399);animation:seti-pulse 2s infinite}\n.seti-chat__dot.is-bad{background:var(--seti-bad,#f87171)}\n@keyframes seti-pulse{0%{box-shadow:0 0 0 0 rgba(52,211,153,.5)}70%{box-shadow:0 0 0 7px rgba(52,211,153,0)}100%{box-shadow:0 0 0 0 rgba(52,211,153,0)}}\n.seti-chat__meta{color:var(--seti-dim,#8a93a6);font-family:var(--seti-mono,ui-monospace,Menlo,monospace);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.seti-chat__screen{flex:1;margin:0;padding:.9rem 1rem;overflow:auto;white-space:pre-wrap;word-break:break-word;font-family:var(--seti-mono,ui-monospace,Menlo,monospace);font-size:12.5px;line-height:1.45;-webkit-overflow-scrolling:touch}\n.seti-chat__screen.is-empty{color:var(--seti-dim,#8a93a6)}\n.seti-chat__navkeys{display:flex;gap:.4rem;padding:.45rem .65rem .2rem;background:var(--seti-panel,#11151f);flex-wrap:wrap;border-top:1px solid var(--seti-edge,#1e2430)}\n.seti-chat__navkeys button{padding:.45rem .7rem;background:var(--seti-edge,#1e2430);color:var(--seti-fg,#d7dce5);font-size:13px;min-width:2.6rem;min-height:2.2rem;font-weight:600;border:0;border-radius:8px;cursor:pointer;transition:transform .06s,background .15s,opacity .15s}\n.seti-chat__navkeys button:hover{filter:brightness(1.25)}\n.seti-chat__navkeys button:active{transform:translateY(1px) scale(.98)}\n.seti-chat__navkeys button:disabled{opacity:.5;cursor:not-allowed}\n.seti-chat__form{display:flex;gap:.5rem;padding:.6rem;background:var(--seti-panel,#11151f);border-top:1px solid var(--seti-edge,#1e2430)}\n.seti-chat__input{flex:1;min-width:0;background:var(--seti-bg,#0b0e14);border:1px solid var(--seti-edge,#1e2430);color:var(--seti-fg,#d7dce5);border-radius:10px;padding:.65rem .85rem;font-family:var(--seti-mono,ui-monospace,Menlo,monospace);font-size:16px;outline:none}\n.seti-chat__input:focus{border-color:var(--seti-accent,#34d399);box-shadow:0 0 0 3px rgba(52,211,153,.15)}\n.seti-chat__send{background:var(--seti-accent,#34d399);color:#07120d;border:0;border-radius:10px;padding:.65rem 1.05rem;font-weight:650;cursor:pointer;transition:transform .06s,background .15s,opacity .15s}\n.seti-chat__send:hover{filter:brightness(1.08)}\n.seti-chat__send:active{transform:translateY(1px) scale(.99)}\n.seti-chat__send:disabled{opacity:.5;cursor:not-allowed}\n.seti-chat__send.is-sending{background:var(--seti-warn,#fbbf24);color:#2a1d00}\n`;\n\nfunction ensureStyle(): void {\n if (typeof document === \"undefined\") return;\n if (document.getElementById(STYLE_ID)) return;\n const el = document.createElement(\"style\");\n el.id = STYLE_ID;\n el.textContent = CSS;\n document.head.appendChild(el);\n}\n\nexport function SetiChat(props: SetiChatProps) {\n const client = useMemo(\n () => props.client ?? new SetiClient({ baseUrl: props.baseUrl ?? \"/api/seti\" }),\n [props.client, props.baseUrl],\n );\n const [text, setText] = useState(\"\");\n const [sending, setSending] = useState(false);\n const [screenText, setScreenText] = useState(\"\");\n const [edgeOn, setEdgeOn] = useState<boolean | null>(null);\n const [streamState, setStreamState] = useState<SetiStreamState>(\"connecting\");\n const [notice, setNotice] = useState<string | null>(null);\n const screenRef = useRef<HTMLPreElement>(null);\n\n useEffect(ensureStyle, []);\n\n useEffect(() => {\n const acc = new FrameAccumulator();\n setScreenText(\"\");\n setNotice(null);\n const atBottom = (): boolean => {\n const s = screenRef.current;\n return !s || s.scrollHeight - s.scrollTop - s.clientHeight < 40;\n };\n const handle = client.openStream(props.edge, props.session, {\n onHello: (h) => setEdgeOn(h.edgeConnected),\n onPing: (p) => setEdgeOn(p.edgeConnected),\n onStateChange: setStreamState,\n onFrame: (content) => {\n const stick = atBottom();\n acc.feed(content);\n setScreenText(acc.text);\n if (stick) {\n requestAnimationFrame(() => {\n const s = screenRef.current;\n if (s) s.scrollTop = s.scrollHeight;\n });\n }\n },\n });\n return () => handle.close();\n }, [client, props.edge, props.session]);\n\n const meta =\n streamState === \"open\"\n ? edgeOn === false\n ? `${props.edge} · edge offline`\n : `${props.edge} · ${props.session}`\n : streamState === \"closed\"\n ? \"lukket\"\n : \"forbinder…\";\n const dotClass =\n \"seti-chat__dot\" + (streamState === \"open\" && edgeOn ? \" is-on\" : edgeOn === false ? \" is-bad\" : \"\");\n\n const submit = async (ev: Event) => {\n ev.preventDefault();\n if (!text.trim() || sending) return;\n setSending(true);\n setNotice(null);\n const res = await client.sendText(props.edge, props.session, text);\n if (res.ok) {\n setText(\"\"); // only clear when actually delivered — text survives failures\n } else {\n setNotice(\"Ikke leveret — din tekst er bevaret\");\n }\n setSending(false);\n };\n\n const pressKey = async (key: SetiKey) => {\n await client.sendKey(props.edge, props.session, key);\n };\n\n return (\n <div class={\"seti-chat\" + (props.class ? ` ${props.class}` : \"\")} data-testid=\"seti-chat-root\">\n <div class=\"seti-chat__header\" data-testid=\"seti-chat-header\">\n <span class={dotClass} data-testid=\"seti-chat-status-dot\" />\n <span class=\"seti-chat__meta\" data-testid=\"seti-chat-meta\">\n {notice ?? meta}\n </span>\n </div>\n <pre\n ref={screenRef}\n class={\"seti-chat__screen\" + (screenText ? \"\" : \" is-empty\")}\n data-testid=\"seti-chat-screen\"\n >\n {screenText || \"Venter på den første frame fra edgen…\"}\n </pre>\n <div class=\"seti-chat__navkeys\" data-testid=\"seti-chat-navkeys\">\n {NAV_KEYS.map((k) => (\n <button\n key={k.key}\n type=\"button\"\n title={k.title}\n data-testid={`seti-chat-key-${k.key.toLowerCase()}`}\n onClick={() => void pressKey(k.key)}\n >\n {k.label}\n </button>\n ))}\n </div>\n <form class=\"seti-chat__form\" data-testid=\"seti-chat-form\" onSubmit={submit}>\n <input\n class=\"seti-chat__input\"\n data-testid=\"seti-chat-input\"\n value={text}\n placeholder={props.placeholder ?? \"Skriv til sessionen…\"}\n autocomplete=\"off\"\n onInput={(e) => setText((e.target as HTMLInputElement).value)}\n />\n <button\n type=\"submit\"\n class={\"seti-chat__send\" + (sending ? \" is-sending\" : \"\")}\n data-testid=\"seti-chat-send\"\n disabled={sending}\n >\n {sending ? \"…\" : \"Send\"}\n </button>\n </form>\n </div>\n );\n}\n\nexport { SetiClient } from \"./client\";\nexport type { SetiKey } from \"./types\";\n"]}
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@broberg/seti-client",
3
+ "version": "0.1.0",
4
+ "description": "Typed client + frame-merge engine + Preact <SetiChat> component for buddycloud.cc SET/SETI live streaming chat (consumed through a host-app proxy from @broberg/seti-server).",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "sideEffects": false,
8
+ "files": ["dist", "README.md"],
9
+ "main": "./dist/index.cjs",
10
+ "module": "./dist/index.js",
11
+ "types": "./dist/index.d.ts",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.js",
16
+ "require": "./dist/index.cjs"
17
+ },
18
+ "./preact": {
19
+ "types": "./dist/preact.d.ts",
20
+ "import": "./dist/preact.js",
21
+ "require": "./dist/preact.cjs"
22
+ }
23
+ },
24
+ "scripts": {
25
+ "build": "tsup",
26
+ "test": "vitest run",
27
+ "typecheck": "tsc --noEmit"
28
+ },
29
+ "peerDependencies": {
30
+ "preact": "^10.0.0"
31
+ },
32
+ "peerDependenciesMeta": {
33
+ "preact": { "optional": true }
34
+ },
35
+ "devDependencies": {
36
+ "preact": "^10.24.3",
37
+ "tsup": "^8.3.0",
38
+ "typescript": "^5.6.0",
39
+ "vitest": "^2.1.0"
40
+ },
41
+ "keywords": [
42
+ "seti",
43
+ "set",
44
+ "streaming",
45
+ "terminal",
46
+ "tmux",
47
+ "sse",
48
+ "chat",
49
+ "preact",
50
+ "buddycloud",
51
+ "broberg"
52
+ ],
53
+ "repository": {
54
+ "type": "git",
55
+ "url": "https://github.com/broberg-ai/components",
56
+ "directory": "packages/seti-client"
57
+ },
58
+ "publishConfig": { "access": "public" }
59
+ }