diff --git a/static/frp-graph-04/apiKey.js b/static/frp-graph-04/apiKey.js
new file mode 100644
index 000000000..16bc5420e
--- /dev/null
+++ b/static/frp-graph-04/apiKey.js
@@ -0,0 +1,19 @@
+export function fetchApiKey() {
+ let apiKey = localStorage.getItem("apiKey");
+
+ if (!apiKey) {
+ // Prompt the user for the API key if it doesn't exist
+ const userApiKey = prompt("Please enter your API key:");
+
+ if (userApiKey) {
+ // Save the API key in localStorage
+ localStorage.setItem("apiKey", userApiKey);
+ apiKey = userApiKey;
+ } else {
+ // Handle the case when the user cancels or doesn't provide an API key
+ alert("API key not provided. Some features may not work.");
+ }
+ }
+
+ return apiKey;
+}
diff --git a/static/frp-graph-04/connect.js b/static/frp-graph-04/connect.js
new file mode 100644
index 000000000..59b297faf
--- /dev/null
+++ b/static/frp-graph-04/connect.js
@@ -0,0 +1,22 @@
+import {
+ distinctUntilChanged,
+ share,
+ tap,
+ Subject,
+} from "https://cdn.jsdelivr.net/npm/rxjs@7.8.1/+esm";
+import { applyPolicy } from "./policy.js";
+
+export function connect(output, input) {
+ output
+ .pipe(
+ distinctUntilChanged(),
+ applyPolicy(),
+ tap((v) => input.next(v)),
+ share(),
+ )
+ .subscribe();
+}
+
+export function ground(output) {
+ connect(output, new Subject());
+}
diff --git a/static/frp-graph-04/demo.js b/static/frp-graph-04/demo.js
new file mode 100644
index 000000000..baa68cb30
--- /dev/null
+++ b/static/frp-graph-04/demo.js
@@ -0,0 +1,79 @@
+const name$ = BehaviourNode("");
+const nameUi$ = GeneratedNameUI();
+const nameTagUi$ = GeneratedNameTagUI();
+const danger$ = DangerousUI();
+const cursor$ = Cursor();
+const combined$ = CombinedDataUI();
+const backstoryUi$ = GeneratedBackstoryUI();
+const portraitUi$ = PortraitUI();
+const statsUi$ = GeneratedStatsUI();
+
+const character$ = combineLatest([
+ nameUi$.out.name,
+ statsUi$.out.str,
+ statsUi$.out.dex,
+ statsUi$.out.con,
+ statsUi$.out.int,
+ statsUi$.out.wis,
+ statsUi$.out.cha,
+]);
+
+const backstory$ = character$.pipe(
+ filter(
+ (v) =>
+ v.name.length > 0 &&
+ (v.stats.str > 0 ||
+ v.stats.dex > 0 ||
+ v.stats.con > 0 ||
+ v.stats.int > 0 ||
+ v.stats.wis > 0 ||
+ v.stats.cha > 0),
+ ),
+ debounceTime(1000),
+ distinctUntilChanged(),
+ switchMap((character) => {
+ console.log("character", character);
+ return from(
+ doLLM(
+ JSON.stringify(character),
+ "Write a possible backstory for this fantasy character in 280 characters or less.",
+ ),
+ );
+ }),
+ map(extractResponse),
+ share(),
+);
+
+const image$ = backstory$.pipe(
+ debounceTime(1000),
+ distinctUntilChanged(),
+ switchMap((backstory) => {
+ console.log("backstory", backstory);
+ return from(
+ generateImage(
+ "Create a fantasy portrait of character based on this bio: " +
+ backstory,
+ ),
+ );
+ }),
+ share(),
+);
+
+connect(name$.out.value, nameUi$.in.name);
+
+connect(nameUi$.out.name, nameTagUi$.in.name);
+connect(nameUi$.out.name, nameTagUi$.in.render);
+connect(backstory$, backstoryUi$.in.backstory);
+connect(backstory$, backstoryUi$.in.render);
+connect(image$, portraitUi$.in.img);
+connect(image$, portraitUi$.in.render);
+
+connect(character$, combined$.in.data);
+connect(character$, combined$.in.render);
+
+ground(nameUi$.out.ui);
+ground(nameTagUi$.out.ui);
+ground(combined$.out.ui);
+ground(backstoryUi$.out.ui);
+ground(portraitUi$.out.ui);
+ground(statsUi$.out.ui);
diff --git a/static/frp-graph-04/graph.js b/static/frp-graph-04/graph.js
new file mode 100644
index 000000000..613f3a75a
--- /dev/null
+++ b/static/frp-graph-04/graph.js
@@ -0,0 +1,138 @@
+import {
+ combineLatest,
+ debounceTime,
+ delay,
+ distinctUntilChanged,
+ filter,
+ from,
+ fromEvent,
+ map,
+ mergeMap,
+ share,
+ switchMap,
+ tap,
+} from "https://cdn.jsdelivr.net/npm/rxjs@7.8.1/+esm";
+import { connect, ground } from "./connect.js";
+import { doLLM, extractResponse, generateImage } from "./llm.js";
+import { BehaviourNode } from "./nodes/BehaviourNode.js";
+import { CombinedDataUI } from "./nodes/CombinedDataUI.js";
+import { Cursor } from "./nodes/Cursor.js";
+import { DangerousUI } from "./nodes/DangerousUI.js";
+import { GeneratedBackstoryUI } from "./nodes/GeneratedBackstoryUI.js";
+import { GeneratedNameTagUI } from "./nodes/GeneratedNameTagUI.js";
+import { GeneratedNameUI } from "./nodes/GeneratedNameUI.js";
+import { PortraitUI } from "./nodes/PortraitUI.js";
+import { GeneratedStatsUI } from "./nodes/GeneratedStatsUI.js";
+
+const startButton = document.getElementById("startWorkflow");
+
+const name$ = BehaviourNode("");
+const nameUi$ = GeneratedNameUI();
+const nameTagUi$ = GeneratedNameTagUI();
+const danger$ = DangerousUI();
+const cursor$ = Cursor();
+const combined$ = CombinedDataUI();
+const backstoryUi$ = GeneratedBackstoryUI();
+const portraitUi$ = PortraitUI();
+const statsUi$ = GeneratedStatsUI();
+
+const character$ = combineLatest([
+ nameUi$.out.name,
+ statsUi$.out.str,
+ statsUi$.out.dex,
+ statsUi$.out.con,
+ statsUi$.out.int,
+ statsUi$.out.wis,
+ statsUi$.out.cha,
+]).pipe(
+ map(([name, str, dex, con, int, wis, cha]) => ({
+ name,
+ stats: {
+ str,
+ dex,
+ con,
+ int,
+ wis,
+ cha,
+ },
+ })),
+);
+
+const backstory$ = character$.pipe(
+ filter(
+ (v) =>
+ v.name.length > 0 &&
+ (v.stats.str > 0 ||
+ v.stats.dex > 0 ||
+ v.stats.con > 0 ||
+ v.stats.int > 0 ||
+ v.stats.wis > 0 ||
+ v.stats.cha > 0),
+ ),
+ debounceTime(1000),
+ distinctUntilChanged(),
+ switchMap((character) => {
+ console.log("character", character);
+ return from(
+ doLLM(
+ JSON.stringify(character),
+ "Write a possible backstory for this fantasy character in 280 characters or less.",
+ ),
+ );
+ }),
+ map(extractResponse),
+ share(),
+);
+
+const image$ = backstory$.pipe(
+ debounceTime(1000),
+ distinctUntilChanged(),
+ switchMap((backstory) => {
+ console.log("backstory", backstory);
+ return from(
+ generateImage(
+ "Create a fantasy portrait of character based on this bio: " +
+ backstory,
+ ),
+ );
+ }),
+ share(),
+);
+
+connect(name$.out.value, nameUi$.in.name);
+
+connect(character$, combined$.in.data);
+connect(character$, combined$.in.render);
+
+connect(nameUi$.out.name, nameTagUi$.in.name);
+connect(nameUi$.out.name, nameTagUi$.in.render);
+connect(backstory$, backstoryUi$.in.backstory);
+connect(backstory$, backstoryUi$.in.render);
+connect(image$, portraitUi$.in.img);
+connect(image$, portraitUi$.in.render);
+
+ground(nameUi$.out.ui);
+ground(nameTagUi$.out.ui);
+ground(combined$.out.ui);
+// ground(danger$.out.ui);
+ground(backstoryUi$.out.ui);
+ground(portraitUi$.out.ui);
+ground(statsUi$.out.ui);
+
+character$.subscribe(console.log);
+
+ground(
+ fromEvent(startButton, "click").pipe(
+ tap(() => {
+ // name$.in.value.next("Ben" + Math.floor(Math.random() * 1000));
+ nameUi$.in.generate.next();
+ nameTagUi$.in.generate.next();
+ // danger$.in.generate.next();
+ backstoryUi$.in.generate.next();
+ statsUi$.in.generate.next();
+
+ cursor$.in.render.next();
+ combined$.in.render.next();
+ }),
+ ),
+);
diff --git a/static/frp-graph-04/imagine.js b/static/frp-graph-04/imagine.js
new file mode 100644
index 000000000..0feb6f664
--- /dev/null
+++ b/static/frp-graph-04/imagine.js
@@ -0,0 +1,31 @@
+import {
+ mergeMap,
+ map,
+ from,
+ tap,
+} from "https://cdn.jsdelivr.net/npm/rxjs@7.8.1/+esm";
+import { doLLM, extractResponse, grabViewTemplate, uiPrompt } from "./llm.js";
+import { applyPolicy } from "./policy.js";
+import { render } from "./render.js";
+
+export function placeholder(id) {
+ return tap((description) => {
+ render(id, `
{{description}}
`, {
+ description,
+ });
+ });
+}
+
+export function imagine(id, prompt) {
+ return (v) =>
+ v.pipe(
+ map(() => prompt),
+ placeholder(id),
+ mergeMap((description) =>
+ from(doLLM(description + "Return only the code.", uiPrompt)),
+ ),
+ map(extractResponse),
+ map(grabViewTemplate),
+ applyPolicy(),
+ );
+}
diff --git a/static/frp-graph-04/index.html b/static/frp-graph-04/index.html
new file mode 100644
index 000000000..7e68d7d1f
--- /dev/null
+++ b/static/frp-graph-04/index.html
@@ -0,0 +1,56 @@
+
+
+
+ rxjs
+
+
+
+
+
+
+
+
+
+
diff --git a/static/frp-graph-04/llm.js b/static/frp-graph-04/llm.js
new file mode 100644
index 000000000..c6bceec43
--- /dev/null
+++ b/static/frp-graph-04/llm.js
@@ -0,0 +1,65 @@
+import Instructor from "https://cdn.jsdelivr.net/npm/@instructor-ai/instructor@1.2.1/+esm";
+import OpenAI from "https://cdn.jsdelivr.net/npm/openai@4.40.1/+esm";
+import { fetchApiKey } from "./apiKey.js";
+
+const apiKey = fetchApiKey();
+
+const openai = new OpenAI({
+ apiKey: apiKey,
+ dangerouslyAllowBrowser: true,
+});
+
+let model = "gpt-4o";
+// let model = "gpt-4-turbo-preview";
+export const client = Instructor({
+ client: openai,
+ mode: "JSON",
+});
+
+export async function generateImage(prompt) {
+ const response = await openai.images.generate({
+ model: "dall-e-3",
+ prompt: prompt,
+ n: 1,
+ size: "1024x1024",
+ });
+ return response.data[0].url;
+}
+
+export async function doLLM(input, system, response_model) {
+ try {
+ return await client.chat.completions.create({
+ messages: [
+ { role: "system", content: system },
+ { role: "user", content: input },
+ ],
+ model,
+ });
+ } catch (error) {
+ console.error("Error analyzing text:", error);
+ }
+}
+
+export function grabViewTemplate(txt) {
+ return txt.match(/```vue\n([\s\S]+?)```/)[1];
+}
+
+export function extractResponse(data) {
+ return data.choices[0].message.content;
+}
+
+export function extractImage(data) {
+ return data.data[0].url;
+}
+
+export const uiPrompt = `Your task is to generate user interfaces using a petite-vue compatible format. Here is an example component + state combo:
+
+ \`\`\`vue
+
+
+
+
+ \`\`\
+
+ Extend this pattern, preferring simple unstyled html unless otherwise instructed. Do not include a template tag, surround all components in a \`\`\`vue\`\`\` block.
+ `;
diff --git a/static/frp-graph-04/nodes/BehaviourNode.js b/static/frp-graph-04/nodes/BehaviourNode.js
new file mode 100644
index 000000000..e0f6e101e
--- /dev/null
+++ b/static/frp-graph-04/nodes/BehaviourNode.js
@@ -0,0 +1,29 @@
+import {
+ mergeMap,
+ map,
+ fromEvent,
+ distinct,
+ distinctUntilChanged,
+ from,
+ of,
+ filter,
+ combineLatest,
+ debounceTime,
+ share,
+ tap,
+ Subject,
+ BehaviorSubject,
+} from "https://cdn.jsdelivr.net/npm/rxjs@7.8.1/+esm";
+
+export function BehaviourNode(constant) {
+ const value$ = new BehaviorSubject(constant);
+
+ return {
+ in: {
+ value: value$,
+ },
+ out: {
+ value: value$,
+ },
+ };
+}
diff --git a/static/frp-graph-04/nodes/CombinedDataUI.js b/static/frp-graph-04/nodes/CombinedDataUI.js
new file mode 100644
index 000000000..657b12d6b
--- /dev/null
+++ b/static/frp-graph-04/nodes/CombinedDataUI.js
@@ -0,0 +1,69 @@
+import {
+ mergeMap,
+ map,
+ fromEvent,
+ distinct,
+ distinctUntilChanged,
+ from,
+ of,
+ filter,
+ combineLatest,
+ debounceTime,
+ share,
+ tap,
+ Subject,
+ BehaviorSubject,
+} from "https://cdn.jsdelivr.net/npm/rxjs@7.8.1/+esm";
+import { render, html, debug, log, state, ui } from "../render.js";
+
+export function CombinedDataUI() {
+ const render$ = new Subject();
+ const data$ = new BehaviorSubject({ name: "", stats: {} });
+
+ const ui$ = render$.pipe(
+ ui(
+ "combinedData",
+ html`
+
+
{{data.name}}
+
+
+ | Strength: |
+ {{data.stats.str}} |
+
+
+ | Dexterity: |
+ {{data.stats.dex}} |
+
+
+ | Constitution: |
+ {{data.stats.con}} |
+
+
+ | Intelligence: |
+ {{data.stats.int}} |
+
+
+ | Wisdom: |
+ {{data.stats.wis}} |
+
+
+ | Charisma: |
+ {{data.stats.cha}} |
+
+
+
`,
+ state({ data: data$ }),
+ ),
+ );
+
+ return {
+ in: {
+ render: render$,
+ data: data$,
+ },
+ out: {
+ ui: ui$,
+ },
+ };
+}
diff --git a/static/frp-graph-04/nodes/Cursor.js b/static/frp-graph-04/nodes/Cursor.js
new file mode 100644
index 000000000..a02743524
--- /dev/null
+++ b/static/frp-graph-04/nodes/Cursor.js
@@ -0,0 +1,67 @@
+import {
+ mergeMap,
+ map,
+ fromEvent,
+ distinct,
+ distinctUntilChanged,
+ from,
+ of,
+ filter,
+ combineLatest,
+ debounceTime,
+ share,
+ tap,
+ Subject,
+ BehaviorSubject,
+} from "https://cdn.jsdelivr.net/npm/rxjs@7.8.1/+esm";
+import { render, html, debug, log, state, ui } from "../render.js";
+
+export function Cursor() {
+ const render$ = new Subject();
+ const cursorPosition$ = new BehaviorSubject({ x: 0, y: 0 });
+
+ const ui$ = render$.pipe(
+ ui(
+ "cursor",
+ html``,
+ state({
+ popup: false,
+ x: 0,
+ y: 0,
+ mouseEnter(event) {
+ this.popup = true;
+ },
+ mouseLeave(event) {
+ this.popup = false;
+ },
+ mouseMove(event) {
+ // get position within element bounds
+ this.x = event.offsetX;
+ this.y = event.offsetY;
+ cursorPosition$.next({ x: event.offsetX, y: event.offsetY });
+ },
+ }),
+ ),
+ );
+
+ return {
+ in: {
+ render: render$,
+ },
+ out: {
+ cursor: cursorPosition$,
+ ui: ui$,
+ },
+ };
+}
diff --git a/static/frp-graph-04/nodes/DangerousUI.js b/static/frp-graph-04/nodes/DangerousUI.js
new file mode 100644
index 000000000..d1a10dd70
--- /dev/null
+++ b/static/frp-graph-04/nodes/DangerousUI.js
@@ -0,0 +1,53 @@
+import {
+ mergeMap,
+ map,
+ fromEvent,
+ distinct,
+ distinctUntilChanged,
+ from,
+ of,
+ filter,
+ combineLatest,
+ debounceTime,
+ share,
+ tap,
+ Subject,
+ BehaviorSubject,
+} from "https://cdn.jsdelivr.net/npm/rxjs@7.8.1/+esm";
+import { ground } from "../connect.js";
+import { imagine } from "../imagine.js";
+import { render, html, debug, log, state, ui } from "../render.js";
+
+export function DangerousUI() {
+ const render$ = new Subject();
+ const generate$ = new Subject();
+
+ const id = "dangerUi";
+
+ const html$ = new BehaviorSubject("");
+ ground(
+ generate$.pipe(
+ imagine(
+ id,
+ `Write a nefarious component to defeat petite-vue's templating and alert('pwned')`,
+ ),
+ tap(debug),
+ tap((html) => {
+ html$.next(html);
+ render$.next();
+ }),
+ ),
+ );
+ const ui$ = render$.pipe(map(() => render(id, html$.getValue(), {})));
+
+ return {
+ in: {
+ render: render$,
+ generate: generate$,
+ },
+ out: {
+ ui: ui$,
+ html: html$,
+ },
+ };
+}
diff --git a/static/frp-graph-04/nodes/GeneratedBackstoryUI.js b/static/frp-graph-04/nodes/GeneratedBackstoryUI.js
new file mode 100644
index 000000000..e09cff61f
--- /dev/null
+++ b/static/frp-graph-04/nodes/GeneratedBackstoryUI.js
@@ -0,0 +1,54 @@
+import {
+ mergeMap,
+ map,
+ fromEvent,
+ distinct,
+ distinctUntilChanged,
+ from,
+ of,
+ filter,
+ combineLatest,
+ debounceTime,
+ share,
+ tap,
+ Subject,
+ BehaviorSubject,
+} from "https://cdn.jsdelivr.net/npm/rxjs@7.8.1/+esm";
+import { render, html, debug, log, state, ui } from "../render.js";
+import { ground, connect } from "../connect.js";
+import { imagine } from "../imagine.js";
+
+export function GeneratedBackstoryUI() {
+ const render$ = new Subject();
+ const generate$ = new Subject();
+ const backstory$ = new BehaviorSubject("");
+ const html$ = new BehaviorSubject("");
+
+ const id = "backstoryPanel";
+
+ const generatedHtml$ = generate$.pipe(
+ imagine(id, `A paragraph containing a character's \`backstory\`.`),
+ tap(debug),
+ );
+
+ const ui$ = render$.pipe(
+ map(() => render(id, html$.getValue(), state({ backstory: backstory$ }))),
+ );
+
+ connect(backstory$, render$);
+ connect(html$, render$);
+ connect(generatedHtml$, html$);
+
+ return {
+ in: {
+ render: render$,
+ generate: generate$,
+ backstory: backstory$,
+ },
+ out: {
+ backstory: backstory$,
+ ui: ui$,
+ html: html$,
+ },
+ };
+}
diff --git a/static/frp-graph-04/nodes/GeneratedNameTagUI.js b/static/frp-graph-04/nodes/GeneratedNameTagUI.js
new file mode 100644
index 000000000..937739b9f
--- /dev/null
+++ b/static/frp-graph-04/nodes/GeneratedNameTagUI.js
@@ -0,0 +1,57 @@
+import {
+ mergeMap,
+ map,
+ fromEvent,
+ distinct,
+ distinctUntilChanged,
+ from,
+ of,
+ filter,
+ combineLatest,
+ debounceTime,
+ share,
+ tap,
+ Subject,
+ BehaviorSubject,
+} from "https://cdn.jsdelivr.net/npm/rxjs@7.8.1/+esm";
+import { render, html, debug, log, state, ui } from "../render.js";
+import { ground } from "../connect.js";
+import { imagine } from "../imagine.js";
+
+export function GeneratedNameTagUI() {
+ const render$ = new Subject();
+ const generate$ = new Subject();
+ const name$ = new BehaviorSubject("");
+ const id = "nameTag";
+
+ const html$ = new BehaviorSubject("");
+ ground(
+ generate$.pipe(
+ imagine(
+ id,
+ `A header displaying the character's name in extremely fancy rainbowcolor animated text, do not use script. Assume it is called \`name\`.`,
+ ),
+ tap(debug),
+ tap((html) => {
+ html$.next(html);
+ render$.next();
+ }),
+ ),
+ );
+ const ui$ = render$.pipe(
+ map(() => render(id, html$.getValue(), state({ name: name$ }))),
+ );
+
+ return {
+ in: {
+ render: render$,
+ generate: generate$,
+ name: name$,
+ },
+ out: {
+ name: name$,
+ ui: ui$,
+ html: html$,
+ },
+ };
+}
diff --git a/static/frp-graph-04/nodes/GeneratedNameUI.js b/static/frp-graph-04/nodes/GeneratedNameUI.js
new file mode 100644
index 000000000..fe6751914
--- /dev/null
+++ b/static/frp-graph-04/nodes/GeneratedNameUI.js
@@ -0,0 +1,59 @@
+import {
+ mergeMap,
+ map,
+ fromEvent,
+ distinct,
+ distinctUntilChanged,
+ from,
+ of,
+ filter,
+ combineLatest,
+ debounceTime,
+ share,
+ tap,
+ Subject,
+ BehaviorSubject,
+} from "https://cdn.jsdelivr.net/npm/rxjs@7.8.1/+esm";
+import { render, html, debug, log, state, ui } from "../render.js";
+import { ground } from "../connect.js";
+import { imagine } from "../imagine.js";
+
+export function GeneratedNameUI() {
+ const render$ = new Subject();
+ const generate$ = new Subject();
+ const name$ = new BehaviorSubject("");
+
+ const id = "nameForm";
+
+ const html$ = new BehaviorSubject("");
+ ground(
+ generate$.pipe(
+ imagine(
+ id,
+ `Text input for the character name. Assume it is called \`name\`.`,
+ ),
+ tap(debug),
+ tap((html) => {
+ html$.next(html);
+ render$.next();
+ }),
+ ),
+ );
+
+ const ui$ = render$.pipe(
+ map(() => render(id, html$.getValue(), state({ name: name$ }))),
+ );
+
+ return {
+ in: {
+ render: render$,
+ generate: generate$,
+ name: name$,
+ },
+ out: {
+ name: name$,
+ ui: ui$,
+ html: html$,
+ },
+ };
+}
diff --git a/static/frp-graph-04/nodes/GeneratedStatsUI.js b/static/frp-graph-04/nodes/GeneratedStatsUI.js
new file mode 100644
index 000000000..c51724ff5
--- /dev/null
+++ b/static/frp-graph-04/nodes/GeneratedStatsUI.js
@@ -0,0 +1,65 @@
+import {
+ mergeMap,
+ map,
+ fromEvent,
+ distinct,
+ distinctUntilChanged,
+ from,
+ of,
+ filter,
+ combineLatest,
+ debounceTime,
+ share,
+ tap,
+ Subject,
+ BehaviorSubject,
+} from "https://cdn.jsdelivr.net/npm/rxjs@7.8.1/+esm";
+import { render, html, debug, log, state, ui } from "../render.js";
+import { ground } from "../connect.js";
+import { imagine } from "../imagine.js";
+
+export function GeneratedStatsUI() {
+ const render$ = new Subject();
+ const generate$ = new Subject();
+ const attributes$ = {
+ str: new BehaviorSubject(10),
+ dex: new BehaviorSubject(10),
+ con: new BehaviorSubject(10),
+ int: new BehaviorSubject(10),
+ wis: new BehaviorSubject(10),
+ cha: new BehaviorSubject(10),
+ };
+
+ const id = "statsForm";
+
+ const html$ = new BehaviorSubject("");
+ ground(
+ generate$.pipe(
+ imagine(
+ id,
+ `UI with Sliders to adjust STR, DEX, CON, INT, WIS, CHA for the character, assume these are available as \`str\`, \`dex\`, \`con\`, \`int\`, \`wis\`, \`cha\` in the template.`,
+ ),
+ tap(debug),
+ tap((html) => {
+ html$.next(html);
+ render$.next();
+ }),
+ ),
+ );
+
+ const ui$ = render$.pipe(
+ map(() => render(id, html$.getValue(), state({ ...attributes$ }))),
+ );
+
+ return {
+ in: {
+ render: render$,
+ generate: generate$,
+ },
+ out: {
+ ui: ui$,
+ html: html$,
+ ...attributes$,
+ },
+ };
+}
diff --git a/static/frp-graph-04/nodes/NameTagUI.js b/static/frp-graph-04/nodes/NameTagUI.js
new file mode 100644
index 000000000..094b62fe6
--- /dev/null
+++ b/static/frp-graph-04/nodes/NameTagUI.js
@@ -0,0 +1,42 @@
+import {
+ mergeMap,
+ map,
+ fromEvent,
+ distinct,
+ distinctUntilChanged,
+ from,
+ of,
+ filter,
+ combineLatest,
+ debounceTime,
+ share,
+ tap,
+ Subject,
+ BehaviorSubject,
+} from "https://cdn.jsdelivr.net/npm/rxjs@7.8.1/+esm";
+import { render, html, debug, log, state, ui } from "../render.js";
+
+export function NameTagUI() {
+ const render$ = new Subject();
+ const name$ = new BehaviorSubject("");
+
+ const ui$ = render$.pipe(
+ ui(
+ "nameTag",
+ html`
+
{{name}}
+ `,
+ state({ name: name$ }),
+ ),
+ );
+
+ return {
+ in: {
+ render: render$,
+ name: name$,
+ },
+ out: {
+ ui: ui$,
+ },
+ };
+}
diff --git a/static/frp-graph-04/nodes/NameUI.js b/static/frp-graph-04/nodes/NameUI.js
new file mode 100644
index 000000000..f77a81d85
--- /dev/null
+++ b/static/frp-graph-04/nodes/NameUI.js
@@ -0,0 +1,44 @@
+import {
+ mergeMap,
+ map,
+ fromEvent,
+ distinct,
+ distinctUntilChanged,
+ from,
+ of,
+ filter,
+ combineLatest,
+ debounceTime,
+ share,
+ tap,
+ Subject,
+ BehaviorSubject,
+} from "https://cdn.jsdelivr.net/npm/rxjs@7.8.1/+esm";
+import { render, html, debug, log, state, ui } from "../render.js";
+
+export function NameUI() {
+ const render$ = new Subject();
+ const name$ = new BehaviorSubject("");
+
+ const ui$ = render$.pipe(
+ ui(
+ "nameForm",
+ html`
+
+
+
`,
+ state({ name: name$ }),
+ ),
+ );
+
+ return {
+ in: {
+ render: render$,
+ name: name$,
+ },
+ out: {
+ name: name$,
+ ui: ui$,
+ },
+ };
+}
diff --git a/static/frp-graph-04/nodes/PortraitUI.js b/static/frp-graph-04/nodes/PortraitUI.js
new file mode 100644
index 000000000..7be3f6031
--- /dev/null
+++ b/static/frp-graph-04/nodes/PortraitUI.js
@@ -0,0 +1,42 @@
+import {
+ mergeMap,
+ map,
+ fromEvent,
+ distinct,
+ distinctUntilChanged,
+ from,
+ of,
+ filter,
+ combineLatest,
+ debounceTime,
+ share,
+ tap,
+ Subject,
+ BehaviorSubject,
+} from "https://cdn.jsdelivr.net/npm/rxjs@7.8.1/+esm";
+import { render, html, debug, log, state, ui } from "../render.js";
+
+export function PortraitUI() {
+ const render$ = new Subject();
+ const img$ = new BehaviorSubject("");
+
+ const ui$ = render$.pipe(
+ ui(
+ "portrait",
+ html`
+
![]()
+
`,
+ state({ img: img$ }),
+ ),
+ );
+
+ return {
+ in: {
+ render: render$,
+ img: img$,
+ },
+ out: {
+ ui: ui$,
+ },
+ };
+}
diff --git a/static/frp-graph-04/policy.js b/static/frp-graph-04/policy.js
new file mode 100644
index 000000000..7a0a2722f
--- /dev/null
+++ b/static/frp-graph-04/policy.js
@@ -0,0 +1,23 @@
+import { map } from "https://cdn.jsdelivr.net/npm/rxjs@7.8.1/+esm";
+
+export function policy(v) {
+ console.log("policy scan", v);
+
+ if (v === "illegal value") return;
+
+ if (typeof v === "string") {
+ return v.indexOf("
+