-
Notifications
You must be signed in to change notification settings - Fork 9
rfc: UI component model #8
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 11 commits
66a8845
f97139d
0903b63
2b68687
f9f20eb
b0487bd
ccb9f3b
ded0b6d
bbd2b28
a166698
52a82cd
9ae1229
8800702
7421a85
763af0e
df71acf
0c315a7
db1f508
29c645e
d8b3432
d825e5c
ad8ad40
e816968
c7245b8
19a0757
1b54ad4
5d38835
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,140 @@ | ||
| # UI component model | ||
|
|
||
| Authors: Gordon Brander | ||
|
|
||
| ## Overview | ||
|
|
||
| Context: converge on a default UI component model for LLM-generated UI. | ||
|
|
||
| ## Goals | ||
|
|
||
| ### Product goals | ||
|
|
||
| - Easy for LLMs to generate | ||
| - Leverages patterns and/or frameworks that are widely present in the training data, or can be learned within a small context window. | ||
| - Easy for humans to edit | ||
| - [Maybe it’s not even code?](https://x.com/threepointone/status/1792930000766677034) Or maybe it’s a hybrid of code and plain language? | ||
| - Leverages familiar or established patterns for UI development | ||
| - Conformable with existing web FE toolchains. | ||
|
|
||
| ### Technical goals | ||
|
|
||
| - Components are **encapsulated** (P1) | ||
| - A component may control its child tree, but not its siblings or parent | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It should also have limited control over its children. In particular I don't think it should be able to modify their output. And generally speaking being able to say "no information flows from child elements to their parents" will be important. Event bubbling is the tricky case there. |
||
| - Components have **inputs**, and **output UI** and **events** (P1) | ||
|
||
| - Components can be understood as pure-ish functions that receive props and return a view description (we may allow cheats for local component state ala hooks) | ||
| - Components are **black boxes** | ||
| - Components are decoupled and only communicate via input and output channels. | ||
| - Component **inputs** and **outputs** are **statically-typed** (P1) | ||
| - E.g. via TypeScript | ||
| - Allows the runtime to enforce data policies on component | ||
| - Components have **local state** (P1) | ||
| - State is encapsulated within component | ||
| - Components may pass state down to child components as input | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you spell out whether child components are internal to it or other nodes in the recipe. I assume it's the latter, but then I have two questions:
|
||
| - E.g. the React **[“lifting state up” pattern](https://legacy.reactjs.org/docs/lifting-state-up.html)**. | ||
| - Local state may be **persisted**. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we do just via outputs and inputs? The "pass the state to yourself" pattern? (DX might hide that of course). Spelled out as a requirement: To persist state it must be exposed as outputs & inputs. |
||
| - If it isn’t, it is **ephemeral**, and lasts for the lifetime of the component. | ||
|
Comment on lines
+35
to
+40
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've been wondering about the semantics around "local" here. If you have a Module named "Dropdown" expressing a UI component you have a few options for handling the "open" state:
If we're going to implement persisted internal state it seems like we need (3) regardless of the skinning layer that goes on top? Is there a benefit to "hiding" state vs. simply ignoring it? An excerpt from my experiments: // Module to generate a UI from a prompt + initial state payload
export function GeneratedUI(id, prompt, localState) {
const render$ = new Subject();
const generate$ = new BehaviorSubject(prompt);
const html$ = new BehaviorSubject("");
// map over state and create a new BehaviorSubject for each key
const state$ = Object.keys(localState).reduce((acc, key) => {
acc[key] = new BehaviorSubject(localState[key]);
return acc;
}, {});
const generatedHtml$ = generate$.pipe(imagine(id), tap(debug));
const ui$ = render$.pipe(
map(() => render(id, html$.getValue(), state(state$))),
);
connect(generatedHtml$, html$);
connect(html$, render$);
Object.keys(state$).forEach((key) => {
connect(state$[key], render$);
});
return {
in: {
render: render$,
generate: generate$,
},
out: {
ui: ui$,
html: html$,
...state$,
},
};
}You could argue that the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. to clarify, is this code that generates a module, or on its own a module? if the latter, where are |
||
| - Components are **islands** (P1) | ||
| - Components can be used free-standing, or within a larger component tree. | ||
| - Note: this is in contrast to something like the Elm App Architecture Pattern, where models, views, and update functions are “zippered” together, meaning components are “some assembly required”. This would fall short of this goal without some additional means of making an individual component a free-standing island. | ||
| - Components are **composable** (P1) | ||
| - Components can be combined together like lego to create larger components | ||
| - Composing plugins should be as easy as plugging together component inputs, outputs, and events, and arranging a UI tree. It shouldn’t be more complicated than that. | ||
|
Comment on lines
+44
to
+46
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do you envision composition where we get back one big vdom expression containing all nested children, or something more like Web Components where you register named tags and use them by name? (my expectation is the former, just asking out loud). There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. is composition at the recipe level (i.e. the children vdom is fed into the parent component by the runtime) or within the component (i.e. it's a big black box to the runtime)? also, we'll want to not allow parents to inspect or change the child vdom for security reasons, but we can implement that later, e.g. by treating the children vdom as a passthrough signal and referencing that in the output vdom. that would also optimize any later vdom updates. |
||
| - Components can have **holes**, allowing you to slot in an arbitrary component of the right shape. (P1) | ||
| - Inversion of control for templates. | ||
| - The shape of the hole is determined by the data’s input and output types | ||
| - Example mechanisms | ||
| - [slots](https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_templates_and_slots) | ||
| - [passing down closures that evaluate to components](https://swiftwithmajid.com/2019/11/06/the-power-of-closures-in-swiftui/) | ||
| - [overridable blocks](https://mustache.github.io/mustache.5.html#Blocks) | ||
| - Components have **[high locality of behavior](https://github.com/gordonbrander/generative-ui-playbook?tab=readme-ov-file#llms-work-best-with-high-locality)** (P1) | ||
| - All component behavior is colocated, including structure, style, and behavior | ||
| - Components are relatively **small** | ||
| - LLMs will have higher accuracy generating small islands of interactivity vs whole apps | ||
| - Small composable components are easier to understand and debug | ||
| - UI **templates** are **pure functions** (P1) | ||
| - Templates take inputs, and output a UI tree and events | ||
| - Templates produce a UI tree that is easy for the runtime to analyze and sanitize (probably a VDOM, probably not raw DOM). | ||
| - Components are renderable to web (P1) | ||
| - Other platforms may be supported in future, but web platform is primary | ||
|
|
||
| Soft goals: | ||
|
|
||
| - UI templates are static (P3) | ||
| - They are compiled once at program start, and produce a static tree with specific “binding points” in the tree, where dynamic values and dynamic lists are rendered. | ||
| - This may have a performance advantage over a totally dynamic VDOM tree, since it would allow us to analyze and enforce policies on the tree once, rather than after every render | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Throwing it out there: if we know the types for all inputs of a control we could use a "fuzzer" approach for static policy validation even with dynamic subtrees, try a huge range of inputs etc. and ensure the output looks "safe". There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. My understanding is (static) policy is enforced at RecipeModule boundaries by the Runtime, not within a JavaScript Context/CodeModule |
||
|
|
||
| ### Non-goals | ||
|
|
||
| - Separation of concerns. At odds with high locality of behavior. | ||
|
|
||
| ## Proposal | ||
|
|
||
| ## Alternatives | ||
|
|
||
| ### React-style functional components | ||
|
|
||
| ### Stateless templates | ||
|
|
||
| Borrowing ideas from Mustache, Vue, and Svelte, we could separate logic from template. This would make the template a pure function. It would also encourage factoring out the logic into signal transformations. | ||
|
|
||
| Key features: | ||
|
|
||
| - All domain logic is pulled out of the template and is performed as signal graph transformations outside of the template. | ||
| - Signals, values, and callbacks are exported from the “script” portion of the module | ||
| - Mustache-style static templates | ||
| - Ordinary values are rendered statically | ||
| - Signals are rendered reactively | ||
| - Callbacks can be used to send messages up from the template | ||
|
|
||
| A simple counter example: | ||
|
|
||
| ```html | ||
| <script> | ||
| const [count, setCount] = signal(0) | ||
| export count | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. is that also exporting to the recipe, i.e. ability to bind some other module to the (and how would it look like if it receives data) |
||
|
|
||
| const [clicks, setClicks] = stream() | ||
| export setClicks | ||
|
|
||
| clicks.sink(_ => setCount(count() + 1)) | ||
| </script> | ||
|
|
||
| <template> | ||
| <a onclick="{{setClicks}}">The count is: {{count}}</a> | ||
| </template> | ||
| ``` | ||
|
|
||
| Under the hood, the system might be doing something like this: | ||
|
|
||
| ```js | ||
| // ...Somewhere in the runtime, invisible to the module | ||
|
|
||
| // System somehow compiles the component definition | ||
| const {env, template} = compile(component) | ||
|
|
||
| // `template` contains the string contents of the template block. | ||
| // `env` contains the exports from the script block, e.g. | ||
| // const {setClicks, count} = env | ||
|
|
||
| // Template is turned into a UI tree with dynamic bindings | ||
| // at specific locations in the tree | ||
| const vdom = populate(template, env) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. interesting, so this could be seen in two ways:
it looks a bit like (2), but i have a few questions on the lifecycle then (it might actually be easier for example for the template to export There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As noted in the code snippet, I'm attempting to show hypothetical "code behind the curtain" in the runtime to illustrate that this approach supports:
This code snippet was not intended to illustrate what is happening at the module or recipe level, just the kind of thing that might be happening behind the scenes in the runtime when validating/rendering the output. |
||
|
|
||
| // Prune or sanitize as needed | ||
| const cleanVdom = sanitize(vdom) | ||
|
|
||
| // System manages rendering. Modules never have direct access to DOM | ||
| render(dom, cleanVdom) | ||
| ``` | ||
|
|
||
| ## Open questions | ||
|
|
||
| ## Prior art | ||
|
|
||
| - [Svelt Runes](https://svelte.dev/blog/runes) | ||
| - [Vue templates](https://vuejs.org/examples/#hello-world) | ||
| - [Mustache](https://mustache.github.io/mustache.5.html) | ||
| - [WICG Template Instantiation Proposal](https://github.com/WICG/webcomponents/blob/gh-pages/proposals/Template-Instantiation.md) | ||
| - [Template Instantiation Proposal on CSS Tricks](https://css-tricks.com/apples-proposal-html-template-instantiation/) | ||
| - [WICG DOM Parts Proposal](https://github.com/WICG/webcomponents/blob/gh-pages/proposals/DOM-Parts.md) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What does that mean / what kind of tool chains?
At a broad level I agree, but I also imagine that we'll quickly create a happy path for simple components that are whatever toolchain we pick.