Skip to content
Closed
Changes from 11 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
66a8845
Roughing out rfc for component model
gordonbrander May 21, 2024
f97139d
More updates
gordonbrander May 21, 2024
0903b63
More notes
gordonbrander May 21, 2024
2b68687
Formatting
gordonbrander May 21, 2024
f9f20eb
Formatting
gordonbrander May 21, 2024
b0487bd
Notes on encapsulation
gordonbrander May 21, 2024
ccb9f3b
Typo
gordonbrander May 21, 2024
ded0b6d
More details
gordonbrander May 21, 2024
bbd2b28
First alternative
gordonbrander May 21, 2024
a166698
Add example of sanitizing
gordonbrander May 21, 2024
52a82cd
More details in example
gordonbrander May 21, 2024
9ae1229
Add more reqs
gordonbrander May 22, 2024
8800702
Note on terminology
gordonbrander May 22, 2024
7421a85
Sketching out alternatives
gordonbrander May 22, 2024
763af0e
Bold keywords for easy skimming
gordonbrander May 22, 2024
df71acf
Add priority
gordonbrander May 22, 2024
0c315a7
Add note about serializability
gordonbrander May 22, 2024
db1f508
Add P1
gordonbrander May 22, 2024
29c645e
More notes on spellcaster style
gordonbrander May 22, 2024
d8b3432
Fix typo
gordonbrander May 22, 2024
d825e5c
Clearer language
gordonbrander May 22, 2024
ad8ad40
Add example of fluent-style
gordonbrander May 22, 2024
e816968
Fix heading level
gordonbrander May 22, 2024
c7245b8
Fix markup
gordonbrander May 22, 2024
19a0757
Component inputs and outputs are serializable data
gordonbrander May 22, 2024
1b54ad4
Update component requirement
gordonbrander May 22, 2024
5d38835
More notes on template approach
gordonbrander May 23, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions rfcs/2024-05-21-ui-component-model.md
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.
Copy link
Contributor

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.


### Technical goals

- Components are **encapsulated** (P1)
- A component may control its child tree, but not its siblings or parent
Copy link
Contributor

Choose a reason for hiding this comment

The 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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not props as output (or behaviors as output).

E.g. a checkbox is I think best modeled as behavior, and it feels natural that it would be a module that outputs that.

- 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
Copy link
Contributor

Choose a reason for hiding this comment

The 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:

  1. presumably passing state to them is just recipes wiring
  2. where is the child component chosen? If it's in the component, then I think we're moving towards returning a recipe (this isn't difficult I think, and I think might look quite natural in the code). If it's in the recipe, then that's quite natural, but we are moving into a space where the UI assembly happens in recipes.

- E.g. the React **[“lifting state up” pattern](https://legacy.reactjs.org/docs/lifting-state-up.html)**.
- Local state may be **persisted**.
Copy link
Contributor

Choose a reason for hiding this comment

The 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
Copy link
Contributor

@bfollington bfollington May 21, 2024

Choose a reason for hiding this comment

The 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:

  1. totally encapsulated: no-one but the component knows this state exists (standard React)
  2. wrapped & exported: state is held in an inner Behaviour but is exported as a readonly Event
  3. inversion-of-control: UI components only declare their state, they do not own it

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 html is internal state here, as no-one cares about it except this components render pipeline, but it can still be exported as an output.

Copy link
Contributor

Choose a reason for hiding this comment

The 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 prompt and localState coming from when invoked in a recipe?

- 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
Copy link
Contributor

Choose a reason for hiding this comment

The 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).

Copy link
Contributor

Choose a reason for hiding this comment

The 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
Copy link
Contributor

Choose a reason for hiding this comment

The 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".

Copy link
Collaborator

Choose a reason for hiding this comment

The 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
Copy link
Contributor

Choose a reason for hiding this comment

The 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 count output?

(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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

interesting, so this could be seen in two ways:

  1. this is internal to the module (and the runtime just understands vdom + bound event streams)

  2. this is actually two modules wrapped in a mini recipe

  • the script part which is exports count and setClicks
  • the template part, which reads count and receives setClicks so it can issue events on it

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 setClicks and the code module to read from it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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:

  • compiling a static template
  • importing signals and wiring them up to the template
  • sanitizing the resulting UI description tree
  • rendering that tree without the module having any direct access to the DOM

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)