@kahitsan/ksui 0.25.0 → 0.27.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kahitsan/ksui",
3
- "version": "0.25.0",
3
+ "version": "0.27.0",
4
4
  "description": "ksui is a standalone set of SolidJS UI components for KahitSan/Hilinga and any SolidJS app. Published to the public npm registry and consumed as a normal dependency. Ships source under a `solid` export condition so the consumer's vite-plugin-solid compiles it with only solid-js externalized; it depends on nothing but solid-js + lucide-solid and injects its own CSS.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
package/src/index.ts CHANGED
@@ -194,6 +194,9 @@ export { buildLogoSrc } from "./utils/account-logo-url";
194
194
 
195
195
  export { attachmentUrl, isResolvableAttachment } from "./utils/attachments";
196
196
 
197
+ export { createObjectUrlResource } from "./utils/object-url-resource";
198
+ export type { ObjectUrlOptions } from "./utils/object-url-resource";
199
+
197
200
  export { useAccountsIndex, resolveAccount, resolveAccountName } from "./utils/accounts-index";
198
201
 
199
202
  export { INPUT_CLASS } from "./utils/INPUT_CLASS";
@@ -1,5 +1,5 @@
1
1
  // flow-builder: node-step authoring → FlowDefinition. Tests linear chaining and
2
- // the condition fork (both arms captured).
2
+ // a real graph (branch + loop + join), which the builder must express 1:1.
3
3
  import { describe, expect, it } from "vitest";
4
4
  import { buildFlow } from "./flow-builder";
5
5
  import { flowToGraph } from "./flow-spec";
@@ -7,37 +7,46 @@ import { flowToGraph } from "./flow-spec";
7
7
  describe("buildFlow", () => {
8
8
  it("chains linear steps into a connected path", () => {
9
9
  const def = buildFlow("item.create", "Add Item", (f) => {
10
- f.trigger("Add Item button").modal("Item form").commit("Create", "POST /api/items");
10
+ const t = f.trigger("Add Item button");
11
+ const m = f.modal("Item form");
12
+ const c = f.commit("Create", "POST /api/items");
13
+ t.to(m).to(c);
11
14
  });
12
15
  expect(def.nodes.map((n) => n.kind)).toEqual(["trigger", "modal", "commit"]);
13
16
  const { edges } = flowToGraph(def);
14
- // trigger modal → commit
15
- expect(edges).toHaveLength(2);
16
- expect(edges[0].from).toBe(def.nodes[0].id);
17
- expect(edges[0].to).toBe(def.nodes[1].id);
18
- expect(edges[1].to).toBe(def.nodes[2].id);
17
+ expect(edges.map((e) => [e.from, e.to])).toEqual([
18
+ [def.nodes[0].id, def.nodes[1].id],
19
+ [def.nodes[1].id, def.nodes[2].id],
20
+ ]);
19
21
  });
20
22
 
21
- it("forks a condition into two labelled branches, both rendered", () => {
23
+ it("expresses a branch, a loop, and a join (a real graph, not a tree)", () => {
22
24
  const def = buildFlow("cart.checkout", "Checkout", (f) => {
23
- f.trigger("Checkout button")
24
- .modal("Coupon entry")
25
- .condition(
26
- "Coupon valid?",
27
- (yes) => yes.call("pricing:validate").compute("Apply discount").commit("Place order"),
28
- (no) => no.commit("Place order"),
29
- );
25
+ const list = f.data("Items");
26
+ const open = f.trigger("Checkout button");
27
+ const form = f.modal("Coupon entry");
28
+ const valid = f.condition("Coupon valid?");
29
+ const apply = f.compute("Apply discount");
30
+ const place = f.commit("Place order");
31
+ list.to(open).to(form).to(valid);
32
+ valid.to(apply, "yes").to(place); // yes arm
33
+ valid.to(form, "no"); // LOOP back to the form
34
+ apply.to(place); // (place already reached from apply) — keep single
35
+ place.to(list, "done"); // JOIN/loop back to the list
30
36
  });
37
+
31
38
  const cond = def.nodes.find((n) => n.kind === "condition")!;
32
- // exactly two outgoing branches, to DIFFERENT nodes, each labelled
33
- expect(cond.out).toHaveLength(2);
34
- expect(cond.out![0].to).not.toBe(cond.out![1].to);
35
- expect(cond.out!.map((p) => p.label)).toEqual(["yes", "no"]);
36
- // the "yes" arm carries the validate→compute→commit chain
37
- expect(def.nodes.some((n) => n.kind === "call" && n.detail === "pricing:validate")).toBe(true);
38
- expect(def.nodes.filter((n) => n.kind === "commit")).toHaveLength(2);
39
- // every edge resolves (defineFlow would have thrown otherwise)
39
+ expect(cond.out!.map((p) => p.label).sort()).toEqual(["no", "yes"]);
40
+
41
+ // a back-edge exists (loop): some edge points to an earlier node
42
+ const order = new Map(def.nodes.map((n, i) => [n.id, i] as const));
40
43
  const { nodes, edges } = flowToGraph(def);
44
+ expect(edges.some((e) => order.get(e.to)! <= order.get(e.from)!)).toBe(true);
45
+ // a join exists: some node has in-degree > 1
46
+ const indeg: Record<string, number> = {};
47
+ edges.forEach((e) => (indeg[e.to] = (indeg[e.to] ?? 0) + 1));
48
+ expect(Object.values(indeg).some((d) => d > 1)).toBe(true);
49
+ // every edge resolves (defineFlow would have thrown on a dangling one)
41
50
  const ids = new Set(nodes.map((n) => n.id));
42
51
  expect(edges.every((e) => ids.has(e.from) && ids.has(e.to))).toBe(true);
43
52
  });
@@ -1,14 +1,25 @@
1
- // Node-step authoring DSL — a fluent builder for a flow/node-graph definition.
2
- // An author wraps steps in calls (`f.trigger(...)`, `f.condition(label, onYes,
3
- // onNo)`, `f.call(...)`, …); each call appends a node wired from the previous
4
- // step, and a condition forks into two labelled branches. The whole thing lowers
5
- // to a `FlowDefinition` the FlowGraph renders.
1
+ // Node-step authoring DSL — a small, optional graph builder for composing a
2
+ // `FlowDefinition`. Each step creates a node and returns a handle; you wire
3
+ // handles together with `.to(target, label?)`, which returns the target so
4
+ // linear paths chain (`a.to(b).to(c)`) while joins, loops and multi-way
5
+ // branches fall out of connecting the same handle more than once:
6
6
  //
7
- // Authoring with steps — instead of hand-building node/edge data — is what keeps
8
- // the diagram parseable and in lockstep with the code that declares it: the same
9
- // call tree that (in a step-running runtime) drives the behaviour is what gets
10
- // rendered. This is optional, additive sugar over `defineFlow`; consumers can
11
- // still build a `FlowDefinition` directly with `node`/`edge` if they prefer.
7
+ // buildFlow("checkout", "Checkout", (f) => {
8
+ // const list = f.data("Items");
9
+ // const add = f.trigger("Add button");
10
+ // const form = f.modal("Item form");
11
+ // const valid = f.condition("Valid?");
12
+ // const save = f.commit("Save", "POST /api/items");
13
+ // list.to(add).to(form).to(valid);
14
+ // valid.to(save, "yes"); // branch
15
+ // valid.to(form, "no"); // loop back to the form
16
+ // save.to(list, "done"); // and back to the list (join)
17
+ // });
18
+ //
19
+ // It lowers to the same `FlowDefinition` the FlowGraph renders, so it is purely
20
+ // additive sugar over `defineFlow` — consumers can still build flows from
21
+ // `node`/`edge` directly. Domain-free: every step is a generic node kind, no app
22
+ // or transport assumptions.
12
23
 
13
24
  import {
14
25
  defineFlow,
@@ -17,99 +28,87 @@ import {
17
28
  type FlowNodeKind,
18
29
  } from "./flow-spec";
19
30
 
20
- /**
21
- * A node-step recorder. Linear steps chain (`f.trigger(...).load(...).commit(...)`);
22
- * `condition` forks into two labelled branches that recurse with the SAME node
23
- * list, so the full tree — both arms, not just the one a runtime would take — is
24
- * captured for the diagram. Branch builders share `nodes` (and thus the running
25
- * id sequence via `nodes.length`), so ids never collide across arms.
26
- */
31
+ /** A handle to a created node. `.to(target)` connects this node → target and
32
+ * returns target, so paths chain; call it again (or from another handle) to
33
+ * form branches, joins and loops. */
34
+ export interface FlowNode {
35
+ readonly id: string;
36
+ to(target: FlowNode, label?: string): FlowNode;
37
+ }
38
+
39
+ /** Graph builder: one method per node kind, each returning a connectable handle. */
27
40
  export class FlowSteps {
28
- constructor(
29
- readonly prefix: string,
30
- readonly nodes: FlowNodeDef[] = [],
31
- private tail: string | null = null,
32
- private pendingLabel?: string,
33
- ) {}
41
+ readonly nodes: FlowNodeDef[] = [];
42
+ constructor(readonly prefix: string) {}
34
43
 
35
- private step(kind: FlowNodeKind, label: string, detail?: string): this {
36
- const id = `${this.prefix}_${kind}_${this.nodes.length}`;
37
- this.nodes.push({ id, kind, label, ...(detail ? { detail } : {}) });
38
- if (this.tail) {
39
- const prev = this.nodes.find((n) => n.id === this.tail);
40
- if (prev) {
41
- prev.out = prev.out ?? [];
42
- const bl = this.pendingLabel;
43
- prev.out.push(bl ? { id: bl, to: id, label: bl } : { id: "out", to: id });
44
- }
45
- }
46
- this.tail = id;
47
- this.pendingLabel = undefined; // a fork label applies only to the first step of the arm
48
- return this;
44
+ private make(kind: FlowNodeKind, label: string, detail?: string): FlowNode {
45
+ const def: FlowNodeDef = {
46
+ id: `${this.prefix}_${kind}_${this.nodes.length}`,
47
+ kind,
48
+ label,
49
+ ...(detail ? { detail } : {}),
50
+ };
51
+ this.nodes.push(def);
52
+ const handle: FlowNode = {
53
+ id: def.id,
54
+ to(target: FlowNode, label?: string): FlowNode {
55
+ def.out = def.out ?? [];
56
+ def.out.push(label ? { id: label, to: target.id, label } : { id: "out", to: target.id });
57
+ return target;
58
+ },
59
+ };
60
+ return handle;
49
61
  }
50
62
 
51
63
  /** A UI event that starts/continues the flow (a button, a selection). */
52
- trigger(label: string): this {
53
- return this.step("trigger", label);
64
+ trigger(label: string): FlowNode {
65
+ return this.make("trigger", label);
54
66
  }
55
67
  /** A data source / list the screen shows. */
56
- data(label: string, detail?: string): this {
57
- return this.step("data", label, detail);
68
+ data(label: string, detail?: string): FlowNode {
69
+ return this.make("data", label, detail);
58
70
  }
59
71
  /** A fetch/load into the current screen. */
60
- load(label: string, detail?: string): this {
61
- return this.step("load", label, detail);
72
+ load(label: string, detail?: string): FlowNode {
73
+ return this.make("load", label, detail);
62
74
  }
63
75
  /** Opens an overlay / form. */
64
- modal(label: string): this {
65
- return this.step("modal", label);
76
+ modal(label: string): FlowNode {
77
+ return this.make("modal", label);
66
78
  }
67
79
  /** A call out to another service/capability; `target` is its identifier. */
68
- call(target: string, label?: string): this {
69
- return this.step("call", label ?? target, target);
80
+ call(target: string, label?: string): FlowNode {
81
+ return this.make("call", label ?? target, target);
70
82
  }
71
83
  /** A pure computation (apply a discount, total a cart). */
72
- compute(label: string): this {
73
- return this.step("compute", label);
84
+ compute(label: string): FlowNode {
85
+ return this.make("compute", label);
86
+ }
87
+ /** A branch — wire its outcomes with `.to(target, "yes")` / `.to(target, "no")`. */
88
+ condition(label: string): FlowNode {
89
+ return this.make("condition", label);
74
90
  }
75
91
  /** A write / command. */
76
- commit(label: string, detail?: string): this {
77
- return this.step("commit", label, detail);
92
+ commit(label: string, detail?: string): FlowNode {
93
+ return this.make("commit", label, detail);
78
94
  }
79
95
  /** Emits a domain event. */
80
- emit(event: string): this {
81
- return this.step("emit", event);
96
+ emit(event: string): FlowNode {
97
+ return this.make("emit", event);
82
98
  }
83
99
  /** A UI effect — refresh / toast / navigate / close. */
84
- effect(label: string): this {
85
- return this.step("effect", label);
100
+ effect(label: string): FlowNode {
101
+ return this.make("effect", label);
86
102
  }
87
103
  /** An end state. */
88
- terminal(label: string): this {
89
- return this.step("terminal", label);
90
- }
91
-
92
- /**
93
- * A two-way branch. `onYes`/`onNo` each receive a builder rooted at the
94
- * condition so both arms render; `labels` annotates the two out-edges
95
- * (default "yes"/"no").
96
- */
97
- condition(
98
- label: string,
99
- onYes: (yes: FlowSteps) => void,
100
- onNo: (no: FlowSteps) => void,
101
- labels: readonly [string, string] = ["yes", "no"],
102
- ): this {
103
- this.step("condition", label);
104
- const cond = this.tail as string;
105
- onYes(new FlowSteps(this.prefix, this.nodes, cond, labels[0]));
106
- onNo(new FlowSteps(this.prefix, this.nodes, cond, labels[1]));
107
- return this;
104
+ terminal(label: string): FlowNode {
105
+ return this.make("terminal", label);
108
106
  }
109
107
  }
110
108
 
111
- /** Author one flow from node steps. The returned definition is identity-checked
112
- * (`defineFlow` throws on a dangling edge) and renders on the FlowGraph canvas. */
109
+ /** Author one flow by connecting node steps. The returned definition is
110
+ * identity-checked (`defineFlow` throws on a dangling edge) and renders on the
111
+ * FlowGraph canvas. */
113
112
  export function buildFlow(
114
113
  id: string,
115
114
  title: string,
@@ -0,0 +1,53 @@
1
+ import { createResource, createEffect, onCleanup, type Resource } from "solid-js";
2
+
3
+ export interface ObjectUrlOptions {
4
+ /** Extra fetch init, merged after `credentials: "include"` (e.g. tenant headers). */
5
+ init?: RequestInit;
6
+ }
7
+
8
+ /**
9
+ * Fetch a (typically authed, same-origin) resource and expose it as an object
10
+ * URL (`blob:`) for an `<img src>` / `<a href>`. This is the proxy/blob pattern
11
+ * for a privately-stored asset: the bytes come back through an app route that
12
+ * enforces auth/ownership, never a public or signed third-party URL — so the
13
+ * rendered src is a clean same-origin `blob:`, the storage origin is never
14
+ * exposed, and there is no leakable bearer link.
15
+ *
16
+ * The href accessor is the resource source: when it changes, the new blob is
17
+ * fetched and the previous object URL is revoked; the final one is revoked on
18
+ * cleanup (a created object URL leaks until revoked). `url()` is null while
19
+ * loading or on any failure — the consumer gates its own render — and
20
+ * `url.loading` distinguishes the two.
21
+ *
22
+ * Domain-free: the consumer supplies the href and any init (headers/credentials);
23
+ * this primitive assumes nothing about auth, tenancy, or endpoints.
24
+ */
25
+ export function createObjectUrlResource(
26
+ href: () => string | null | undefined,
27
+ options: ObjectUrlOptions = {},
28
+ ): Resource<string | null> {
29
+ const [url] = createResource(
30
+ () => href() || null,
31
+ async (src) => {
32
+ const res = await fetch(src, { credentials: "include", ...options.init });
33
+ if (!res.ok) return null;
34
+ const blob = await res.blob();
35
+ return URL.createObjectURL(blob);
36
+ },
37
+ );
38
+
39
+ // Revoke the previous object URL when the resolved value changes, and the
40
+ // final one on unmount. During a refetch the resource holds its prior value
41
+ // (cur === prev), so an in-flight reload doesn't revoke a URL still on screen.
42
+ let prev: string | null = null;
43
+ createEffect(() => {
44
+ const cur = url() ?? null;
45
+ if (prev && prev !== cur) URL.revokeObjectURL(prev);
46
+ prev = cur;
47
+ });
48
+ onCleanup(() => {
49
+ if (prev) URL.revokeObjectURL(prev);
50
+ });
51
+
52
+ return url;
53
+ }