summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/developer/SUMMARY.md4
-rw-r--r--docs/developer/client/swatches.md26
-rw-r--r--src/routes.rs1
-rw-r--r--src/ui/handlers/mod.rs2
-rw-r--r--src/ui/handlers/swatch.rs8
-rw-r--r--ui/app.css1
-rw-r--r--ui/lib/components/swatch/EventLog.svelte37
-rw-r--r--ui/lib/swatch/derive.js20
-rw-r--r--ui/lib/swatch/event-capture.svelte.js22
-rw-r--r--ui/routes/(swatch)/.swatch/+page.js1
-rw-r--r--ui/routes/(swatch)/.swatch/+page.svelte23
-rw-r--r--ui/routes/(swatch)/.swatch/ChangePassword/+page.svelte23
-rw-r--r--ui/routes/(swatch)/.swatch/Conversation/+page.svelte31
-rw-r--r--ui/routes/(swatch)/.swatch/ConversationList/+page.svelte54
-rw-r--r--ui/routes/(swatch)/.swatch/CreateConversationForm/+page.svelte23
-rw-r--r--ui/routes/(swatch)/.swatch/Invite/+page.svelte21
-rw-r--r--ui/routes/(swatch)/.swatch/Invites/+page.svelte35
-rw-r--r--ui/routes/(swatch)/.swatch/LogIn/+page.svelte31
-rw-r--r--ui/routes/(swatch)/.swatch/LogOut/+page.svelte23
-rw-r--r--ui/routes/(swatch)/.swatch/Message/+page.svelte91
-rw-r--r--ui/routes/(swatch)/.swatch/MessageInput/+page.svelte23
-rw-r--r--ui/routes/(swatch)/.swatch/MessageRun/+page.svelte65
-rw-r--r--ui/routes/(swatch)/.swatch/swatch/EventLog/+page.svelte38
-rw-r--r--ui/styles/swatches.css24
24 files changed, 627 insertions, 0 deletions
diff --git a/docs/developer/SUMMARY.md b/docs/developer/SUMMARY.md
index 6b0ce6a..71f85d1 100644
--- a/docs/developer/SUMMARY.md
+++ b/docs/developer/SUMMARY.md
@@ -14,6 +14,10 @@
- [Running Pilcrow locally](server/running.md)
- [Debian packaging](server/debian-packaging.md)
+# The client
+
+- [Swatches](client/swatches.md)
+
# Development tools
- [Formatting](tools/formatting.md)
diff --git a/docs/developer/client/swatches.md b/docs/developer/client/swatches.md
new file mode 100644
index 0000000..ad11381
--- /dev/null
+++ b/docs/developer/client/swatches.md
@@ -0,0 +1,26 @@
+# Swatches
+
+To make it easier to experiment with the client's component framework, the client exposes "swatches" - pages demonstrating individual components in isolation.
+
+Swatches are available from a running client at the `/.swatch/` URL. This URL is not linked in the client; you Just Need To Know.
+
+## Writing Swatches
+
+Swatches are manually curated. When adding a component, add a swatch if you can.
+
+Things to consider:
+
+- For complex values, use a parser, so that the user reading your swatch can edit the structure as text.
+- For freeform values whose meaning is significant, provide buttons to let the user rapidly enter and experiment with interesting values.
+- Be thorough. Let the user experiment with values, even if you think those values may be nonsensical.
+- Try to show the component _without_ supporting markup, as far as is possible. However, if a component generates markup that requires context - a table row component needs a table, for example, or a list element needs a list - then include that markup in the swatch.
+
+## Swatches and XSS
+
+⚠ Swatches **should not** accept arbitrary HTML as user input and render it. Use something less expressive, instead.
+
+Swatches are an interface _intended for_ developers, but _available to_ everyone - including users who may not be familiar with the Web platform or the level of access a page might have to their credentials or data. Putting HTML inputs on a swatch invites the risk that a user may be mislead into running code in that swatch that then has access to the Pilcrow API, or to their locally-stored data.
+
+If a component takes HTML as an argument, then the best compromise we've found between allowing a swatch user to experiment with that HTML and protecting that user from these risks is to provide some kind of structured input. For example, a swatch for a message might instead allow test data to be entered using the same Markdown dialect real messages are entered with.
+
+For components where there isn't an easy way to do that, providing one or more predetermined test bodies is also a workable alternative.
diff --git a/src/routes.rs b/src/routes.rs
index 49d9fb6..6993070 100644
--- a/src/routes.rs
+++ b/src/routes.rs
@@ -10,6 +10,7 @@ pub fn routes(app: &App) -> Router<App> {
// UI routes that can be accessed before the administrator completes setup.
let ui_bootstrap = Router::new()
.route("/{*path}", get(ui::handlers::asset))
+ .route("/.swatch/{*path}", get(ui::handlers::swatch))
.route("/setup", get(ui::handlers::setup));
// UI routes that require the administrator to complete setup first.
diff --git a/src/ui/handlers/mod.rs b/src/ui/handlers/mod.rs
index ed0c14e..bcc65a1 100644
--- a/src/ui/handlers/mod.rs
+++ b/src/ui/handlers/mod.rs
@@ -5,6 +5,7 @@ mod invite;
mod login;
mod me;
mod setup;
+mod swatch;
pub use asset::handler as asset;
pub use conversation::handler as conversation;
@@ -13,3 +14,4 @@ pub use invite::handler as invite;
pub use login::handler as login;
pub use me::handler as me;
pub use setup::handler as setup;
+pub use swatch::handler as swatch;
diff --git a/src/ui/handlers/swatch.rs b/src/ui/handlers/swatch.rs
new file mode 100644
index 0000000..4562b04
--- /dev/null
+++ b/src/ui/handlers/swatch.rs
@@ -0,0 +1,8 @@
+use crate::{
+ error::Internal,
+ ui::assets::{Asset, Assets},
+};
+
+pub async fn handler() -> Result<Asset, Internal> {
+ Assets::index()
+}
diff --git a/ui/app.css b/ui/app.css
index 34a74b7..714c9f7 100644
--- a/ui/app.css
+++ b/ui/app.css
@@ -9,6 +9,7 @@
@import url('styles/messages.css');
@import url('styles/textarea.css');
@import url('styles/forms.css');
+@import url('styles/swatches.css');
@import url('styles/invites.css');
body {
diff --git a/ui/lib/components/swatch/EventLog.svelte b/ui/lib/components/swatch/EventLog.svelte
new file mode 100644
index 0000000..2d8c5c0
--- /dev/null
+++ b/ui/lib/components/swatch/EventLog.svelte
@@ -0,0 +1,37 @@
+<script>
+ /*
+ * The interface exposed by this component is designed to be compatible with the interface
+ * exposed by the `EventCapture` class, so that you can do this:
+ *
+ * let capture = $state(new EventCapture());
+ * const someEvent = capture.on('someEvent');
+ *
+ * // …
+ *
+ * <EventLog events={capture.events} clear={capture.clear.bind(capture)} />
+ */
+
+ let { events, clear = () => {} } = $props();
+
+ function onclick() {
+ clear();
+ }
+</script>
+
+<button {onclick}>clear</button>
+<table>
+ <thead>
+ <tr>
+ <th>event</th>
+ <th>arguments</th>
+ </tr>
+ </thead>
+ <tbody>
+ {#each events as { event, args }}
+ <tr>
+ <td>{event}</td>
+ <td><code>{JSON.stringify(args)}</code></td>
+ </tr>
+ {/each}
+ </tbody>
+</table>
diff --git a/ui/lib/swatch/derive.js b/ui/lib/swatch/derive.js
new file mode 100644
index 0000000..85547e8
--- /dev/null
+++ b/ui/lib/swatch/derive.js
@@ -0,0 +1,20 @@
+// A fun fact about $derive: if the deriving expression throws an error, then Svelte (at least as of
+// 5.20.2) _permanently_ stops evaluating it, even if the inputs change. To prevent this, you need
+// to ensure that derive expressions never raise an exception.
+function tryDerive(args, func, fallback) {
+ try {
+ return func(...args);
+ } catch (e) {
+ console.debug('deriver threw exception', e, func, args);
+ return fallback;
+ }
+}
+
+// A "deriver" is a function that never raises; if the underlying function would raise, the
+// corresponding deriver instead returns a fallback value (or `undefined`).
+export function makeDeriver(func, fallback) {
+ return (...args) => tryDerive(args, func, fallback);
+}
+
+// Some widely-used derivers, for convenience.
+export const json = makeDeriver(JSON.parse);
diff --git a/ui/lib/swatch/event-capture.svelte.js b/ui/lib/swatch/event-capture.svelte.js
new file mode 100644
index 0000000..32c0f39
--- /dev/null
+++ b/ui/lib/swatch/event-capture.svelte.js
@@ -0,0 +1,22 @@
+/*
+ * The interface exposed by this class is designed to closely match the interface expected by
+ * the `EventLog` component, so that you can do this:
+ *
+ * let capture = $state(new EventCapture());
+ * const someEvent = capture.on('someEvent');
+ *
+ * // …
+ *
+ * <EventLog events={capture.events} clear={capture.clear.bind(capture)} />
+ */
+export default class EventCapture {
+ events = $state([]);
+
+ on(event) {
+ return (...args) => this.events.push({ event, args });
+ }
+
+ clear() {
+ this.events = [];
+ }
+}
diff --git a/ui/routes/(swatch)/.swatch/+page.js b/ui/routes/(swatch)/.swatch/+page.js
new file mode 100644
index 0000000..d3c3250
--- /dev/null
+++ b/ui/routes/(swatch)/.swatch/+page.js
@@ -0,0 +1 @@
+export const trailingSlash = 'always';
diff --git a/ui/routes/(swatch)/.swatch/+page.svelte b/ui/routes/(swatch)/.swatch/+page.svelte
new file mode 100644
index 0000000..5334438
--- /dev/null
+++ b/ui/routes/(swatch)/.swatch/+page.svelte
@@ -0,0 +1,23 @@
+<h1>swatches</h1>
+
+<p>
+ Swatches are "live, but disconnected" elements of the application, designed specifically to
+ support live editing and iteration.
+</p>
+
+<h2>components</h2>
+
+<ul>
+ <li><a href="ChangePassword">ChangePassword</a></li>
+ <li><a href="Conversation">Conversation</a></li>
+ <li><a href="ConversationList">ConversationList</a></li>
+ <li><a href="CreateConversationForm">CreateConversationForm</a></li>
+ <li><a href="Invite">Invite</a></li>
+ <li><a href="Invites">Invites</a></li>
+ <li><a href="LogIn">LogIn</a></li>
+ <li><a href="LogOut">LogOut</a></li>
+ <li><a href="MessageRun">MessageRun</a></li>
+ <li><a href="MessageInput">MessageInput</a></li>
+ <li><a href="Message">Message</a></li>
+ <li><a href="swatch/EventLog">swatch/EventLog</a></li>
+</ul>
diff --git a/ui/routes/(swatch)/.swatch/ChangePassword/+page.svelte b/ui/routes/(swatch)/.swatch/ChangePassword/+page.svelte
new file mode 100644
index 0000000..8ef6792
--- /dev/null
+++ b/ui/routes/(swatch)/.swatch/ChangePassword/+page.svelte
@@ -0,0 +1,23 @@
+<script>
+ import EventCapture from '$lib/swatch/event-capture.svelte.js';
+
+ import ChangePassword from '$lib/components/ChangePassword.svelte';
+ import EventLog from '$lib/components/swatch/EventLog.svelte';
+
+ let capture = $state(new EventCapture());
+ const changePassword = capture.on('changePassword');
+</script>
+
+<h1><code>ChangePassword</code></h1>
+
+<nav><p><a href=".">Back to swatches</a></p></nav>
+
+<h2>rendered</h2>
+
+<div class="component-preview">
+ <ChangePassword {changePassword} />
+</div>
+
+<h2>events</h2>
+
+<EventLog events={capture.events} clear={capture.clear.bind(capture)} />
diff --git a/ui/routes/(swatch)/.swatch/Conversation/+page.svelte b/ui/routes/(swatch)/.swatch/Conversation/+page.svelte
new file mode 100644
index 0000000..0b98204
--- /dev/null
+++ b/ui/routes/(swatch)/.swatch/Conversation/+page.svelte
@@ -0,0 +1,31 @@
+<script>
+ import Conversation from '$lib/components/Conversation.svelte';
+
+ let id = $state('Czero');
+ let name = $state('a long conversation');
+ let active = $state(false);
+ let hasUnreads = $state(false);
+</script>
+
+<h1><code>Conversation</code></h1>
+
+<nav><p><a href=".">Back to swatches</a></p></nav>
+
+<h2>properties</h2>
+
+<div class="component-properties">
+ <label>id <input type="text" bind:value={id} /></label>
+ <label>name <input type="text" bind:value={name} /></label>
+ <label>active <input type="checkbox" bind:checked={active} /></label>
+ <label>has unreads <input type="checkbox" bind:checked={hasUnreads} /></label>
+</div>
+
+<h2>rendered</h2>
+
+<div class="component-preview">
+ <nav class="list-nav">
+ <ul>
+ <Conversation {id} {name} {active} {hasUnreads} />
+ </ul>
+ </nav>
+</div>
diff --git a/ui/routes/(swatch)/.swatch/ConversationList/+page.svelte b/ui/routes/(swatch)/.swatch/ConversationList/+page.svelte
new file mode 100644
index 0000000..1a56966
--- /dev/null
+++ b/ui/routes/(swatch)/.swatch/ConversationList/+page.svelte
@@ -0,0 +1,54 @@
+<script>
+ import { json } from '$lib/swatch/derive.js';
+
+ import ConversationList from '$lib/components/ConversationList.svelte';
+
+ let conversationsInput = $state(
+ JSON.stringify(
+ [
+ {
+ id: 'Czero',
+ name: 'A long conversation',
+ hasUnreads: false,
+ },
+ {
+ id: 'Cone',
+ name: 'A shorter conversation',
+ hasUnreads: true,
+ },
+ ],
+ /* replacer */ null,
+ /* space */ 2,
+ ),
+ );
+ let conversations = $derived(json(conversationsInput));
+
+ let active = $state(null);
+</script>
+
+<h1><code>ConversationList</code></h1>
+
+<nav><p><a href=".">Back to swatches</a></p></nav>
+
+<h2>properties</h2>
+
+<div class="component-properties">
+ <label
+ ><p>conversations (json)</p>
+ <textarea class="html" bind:value={conversationsInput}></textarea>
+ </label>
+
+ <label>active <input type="text" bind:value={active} /></label>
+ <div class="suggestion">
+ interesting values:
+ <button onclick={() => (active = null)}>(none)</button>
+ <button onclick={() => (active = 'Czero')}>Czero</button>
+ <button onclick={() => (active = 'Cone')}>Cone</button>
+ </div>
+</div>
+
+<h2>rendered</h2>
+
+<div class="component-preview">
+ <ConversationList {conversations} {active} />
+</div>
diff --git a/ui/routes/(swatch)/.swatch/CreateConversationForm/+page.svelte b/ui/routes/(swatch)/.swatch/CreateConversationForm/+page.svelte
new file mode 100644
index 0000000..f089969
--- /dev/null
+++ b/ui/routes/(swatch)/.swatch/CreateConversationForm/+page.svelte
@@ -0,0 +1,23 @@
+<script>
+ import EventCapture from '$lib/swatch/event-capture.svelte.js';
+
+ import CreateConversationForm from '$lib/components/CreateConversationForm.svelte';
+ import EventLog from '$lib/components/swatch/EventLog.svelte';
+
+ let capture = $state(new EventCapture());
+ const createConversation = capture.on('createConversation');
+</script>
+
+<h1><code>CreateConversationForm</code></h1>
+
+<nav><p><a href=".">Back to swatches</a></p></nav>
+
+<h2>rendered</h2>
+
+<div class="component-preview">
+ <CreateConversationForm {createConversation} />
+</div>
+
+<h2>events</h2>
+
+<EventLog events={capture.events} clear={capture.clear.bind(capture)} />
diff --git a/ui/routes/(swatch)/.swatch/Invite/+page.svelte b/ui/routes/(swatch)/.swatch/Invite/+page.svelte
new file mode 100644
index 0000000..0786194
--- /dev/null
+++ b/ui/routes/(swatch)/.swatch/Invite/+page.svelte
@@ -0,0 +1,21 @@
+<script>
+ import Invite from '$lib/components/Invite.svelte';
+
+ let id = $state('Iaaaa');
+</script>
+
+<h1><code>Invite</code></h1>
+
+<nav><p><a href=".">Back to swatches</a></p></nav>
+
+<h2>properties</h2>
+
+<div class="component-properties">
+ <label>id <input type="text" bind:value={id} /></label>
+</div>
+
+<h2>rendered</h2>
+
+<div class="component-preview">
+ <Invite {id} />
+</div>
diff --git a/ui/routes/(swatch)/.swatch/Invites/+page.svelte b/ui/routes/(swatch)/.swatch/Invites/+page.svelte
new file mode 100644
index 0000000..8c24627
--- /dev/null
+++ b/ui/routes/(swatch)/.swatch/Invites/+page.svelte
@@ -0,0 +1,35 @@
+<script>
+ import { json } from '$lib/swatch/derive.js';
+ import EventCapture from '$lib/swatch/event-capture.svelte.js';
+
+ import EventLog from '$lib/components/swatch/EventLog.svelte';
+ import Invites from '$lib/components/Invites.svelte';
+
+ let invitesInput = $state(
+ JSON.stringify([{ id: 'Iaaaa' }, { id: 'Ibbbb' }], /* replacer */ null, /* space */ 2),
+ );
+ let invites = $derived(json(invitesInput));
+
+ let capture = $state(new EventCapture());
+ const createInvite = capture.on('createInvite');
+</script>
+
+<h1><code>Invites</code></h1>
+
+<nav><p><a href=".">Back to swatches</a></p></nav>
+
+<h2>properties</h2>
+
+<div class="component-properties">
+ <label>invites (json) <textarea bind:value={invitesInput}></textarea></label>
+</div>
+
+<h2>rendered</h2>
+
+<div class="component-preview">
+ <Invites {invites} {createInvite} />
+</div>
+
+<h2>events</h2>
+
+<EventLog events={capture.events} clear={capture.clear.bind(capture)} />
diff --git a/ui/routes/(swatch)/.swatch/LogIn/+page.svelte b/ui/routes/(swatch)/.swatch/LogIn/+page.svelte
new file mode 100644
index 0000000..8501a6c
--- /dev/null
+++ b/ui/routes/(swatch)/.swatch/LogIn/+page.svelte
@@ -0,0 +1,31 @@
+<script>
+ import EventCapture from '$lib/swatch/event-capture.svelte.js';
+
+ import EventLog from '$lib/components/swatch/EventLog.svelte';
+ import LogIn from '$lib/components/LogIn.svelte';
+
+ let legend = $state('sign in');
+
+ let capture = $state(new EventCapture());
+ const logIn = capture.on('logIn');
+</script>
+
+<h1><code>LogIn</code></h1>
+
+<nav><p><a href=".">Back to swatches</a></p></nav>
+
+<h2>properties</h2>
+
+<div class="component-properties">
+ <label>legend <input type="text" bind:value={legend} /></label>
+</div>
+
+<h2>rendered</h2>
+
+<div class="component-preview">
+ <LogIn {legend} {logIn} />
+</div>
+
+<h2>events</h2>
+
+<EventLog events={capture.events} clear={capture.clear.bind(capture)} />
diff --git a/ui/routes/(swatch)/.swatch/LogOut/+page.svelte b/ui/routes/(swatch)/.swatch/LogOut/+page.svelte
new file mode 100644
index 0000000..5eebcde
--- /dev/null
+++ b/ui/routes/(swatch)/.swatch/LogOut/+page.svelte
@@ -0,0 +1,23 @@
+<script>
+ import EventCapture from '$lib/swatch/event-capture.svelte.js';
+
+ import LogOut from '$lib/components/LogOut.svelte';
+ import EventLog from '$lib/components/swatch/EventLog.svelte';
+
+ let capture = $state(new EventCapture());
+ const logOut = capture.on('logOut');
+</script>
+
+<h1><code>LogOut</code></h1>
+
+<nav><p><a href=".">Back to swatches</a></p></nav>
+
+<h2>rendered</h2>
+
+<div class="component-preview">
+ <LogOut {logOut} />
+</div>
+
+<h2>events</h2>
+
+<EventLog events={capture.events} clear={capture.clear.bind(capture)} />
diff --git a/ui/routes/(swatch)/.swatch/Message/+page.svelte b/ui/routes/(swatch)/.swatch/Message/+page.svelte
new file mode 100644
index 0000000..6faf3bc
--- /dev/null
+++ b/ui/routes/(swatch)/.swatch/Message/+page.svelte
@@ -0,0 +1,91 @@
+<script>
+ import { DateTime } from 'luxon';
+
+ import EventCapture from '$lib/swatch/event-capture.svelte.js';
+ import { render } from '$lib/markdown.js';
+
+ import Message from '$lib/components/Message.svelte';
+ import EventLog from '$lib/components/swatch/EventLog.svelte';
+
+ let id = $state('Mplayspelunky');
+ let atInput = $state('2025-07-07T15:19:00Z');
+ // Astonishingly, `DateTime.fromISO` does not throw on invalid inputs. It generates an "Invalid
+ // DateTime" sentinel value, instead.
+ let at = $derived(DateTime.fromISO(atInput));
+ let renderedBodyInput = $state(
+ `Lorem ipsum \`dolor\` sit amet, consectetur adipiscing elit. Nunc quis ante ac leo tristique
+iaculis vel in tortor. Praesent sed interdum ipsum. Pellentesque blandit, sapien at mattis
+facilisis, leo mi gravida erat, in euismod mi lectus non dui. Praesent at justo vel mauris pulvinar
+sodales ut sed nisl. Aliquam aliquet justo vel cursus imperdiet. Suspendisse potenti. Duis varius
+tortor finibus, rutrum justo ac, tincidunt enim.
+
+Donec velit dui, bibendum a augue sit amet, tempus condimentum neque. Integer nibh tortor, imperdiet
+at aliquet eu, rutrum eget ligula. Donec porttitor nisi lacus, eu bibendum augue maximus eget. Class
+aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Maecenas in
+est eget lectus dapibus tincidunt. Ut ut nisi egestas, posuere libero laoreet, venenatis erat. Nulla
+maximus, nisl eget interdum ornare, enim turpis semper ligula, sed ultricies sem sem quis arcu. Ut a
+dapibus augue. Pellentesque nec tincidunt sem.
+`,
+ );
+ /*
+ * Even though `Message` is notionally a generic container for markup, we restrict the swatch to
+ * message-flavoured Markdown. Swatches are available to all users, including
+ * technically-unsophisticated ones, and anything rendered in a swatch runs in the same origin
+ * context and the same cookie context as the rest of the client.
+ *
+ * This makes it possible that a user would be persuaded to enter something into a swatch that
+ * then runs _as them_, interacting with Pilcrow via its API or accessing client-stored data.
+ *
+ * As a proof of concept, `<img src="x" onerror="console.log('oh no')">` should not run the log
+ * statement. With generic HTML entry, it would do so. With our markdown processing, it does not
+ * (the `onerror` attribute is removed). Similarly, `script` elements are prohibited.
+ *
+ * Users who want to experiment with free HTML are encouraged to edit the swatch for themselves.
+ */
+ let renderedBody = $derived(render(renderedBodyInput));
+ let editable = $state(true);
+ let cssClass = $state('');
+
+ let capture = $state(new EventCapture());
+ const deleteMessage = capture.on('deleteMessage');
+</script>
+
+<h1><code>Message</code></h1>
+
+<nav><p><a href=".">Back to swatches</a></p></nav>
+
+<h2>properties</h2>
+
+<div class="component-properties">
+ <label>id <input type="text" bind:value={id} /></label>
+ <label>at (iso-8601)<input type="text" bind:value={atInput} /></label>
+ <div class="suggestion">
+ interesting values:
+ <button onclick={() => (atInput = DateTime.now().toISO())}>Now</button>
+ </div>
+
+ <label>css class <input type="text" bind:value={cssClass} /></label>
+ <div class="suggestion">
+ interesting values:
+ <button onclick={() => (cssClass = '')}>(none)</button>
+ <button onclick={() => (cssClass = 'unsent')}>unsent</button>
+ <button onclick={() => (cssClass = 'deleted')}>deleted</button>
+ </div>
+
+ <label>editable <input type="checkbox" bind:checked={editable} /></label>
+
+ <label
+ ><p>rendered body (markdown)</p>
+ <textarea class="html" bind:value={renderedBodyInput}></textarea>
+ </label>
+</div>
+
+<h2>rendered</h2>
+
+<div class="component-preview">
+ <Message {id} {at} {renderedBody} {editable} class={cssClass} {deleteMessage} />
+</div>
+
+<h2>events</h2>
+
+<EventLog events={capture.events} clear={capture.clear.bind(capture)} />
diff --git a/ui/routes/(swatch)/.swatch/MessageInput/+page.svelte b/ui/routes/(swatch)/.swatch/MessageInput/+page.svelte
new file mode 100644
index 0000000..b1d5864
--- /dev/null
+++ b/ui/routes/(swatch)/.swatch/MessageInput/+page.svelte
@@ -0,0 +1,23 @@
+<script>
+ import EventCapture from '$lib/swatch/event-capture.svelte.js';
+
+ import MessageInput from '$lib/components/MessageInput.svelte';
+ import EventLog from '$lib/components/swatch/EventLog.svelte';
+
+ let capture = $state(new EventCapture());
+ const sendMessage = capture.on('sendMessage');
+</script>
+
+<h1><code>MessageInput</code></h1>
+
+<nav><p><a href=".">Back to swatches</a></p></nav>
+
+<h2>rendered</h2>
+
+<div class="component-preview">
+ <MessageInput {sendMessage} />
+</div>
+
+<h2>events</h2>
+
+<EventLog events={capture.events} clear={capture.clear.bind(capture)} />
diff --git a/ui/routes/(swatch)/.swatch/MessageRun/+page.svelte b/ui/routes/(swatch)/.swatch/MessageRun/+page.svelte
new file mode 100644
index 0000000..34118ec
--- /dev/null
+++ b/ui/routes/(swatch)/.swatch/MessageRun/+page.svelte
@@ -0,0 +1,65 @@
+<script>
+ import { DateTime } from 'luxon';
+
+ import { render } from '$lib/markdown.js';
+
+ import MessageRun from '$lib/components/MessageRun.svelte';
+ import Message from '$lib/components/Message.svelte';
+
+ let sender = $state('wlonk');
+ let cssClass = $state('own-message');
+
+ /*
+ * Even though `MessageRun` is notionally a generic container for markup, we restrict the swatch
+ * to precomosed test messages. Swatches are available to all users, including
+ * technically-unsophisticated ones, and anything rendered in a swatch runs in the same origin
+ * context and the same cookie context as the rest of the client.
+ *
+ * This makes it possible that a user would be persuaded to enter something into a swatch that
+ * then runs _as them_, interacting with Pilcrow via its API or accessing client-stored data.
+ *
+ * As a proof of concept, `<img src="x" onerror="console.log('oh no')">` should not run the log
+ * statement. With generic HTML entry, it would do so.
+ *
+ * Users who want to experiment with free HTML are encouraged to edit the swatch for themselves.
+ */
+ let messages = [
+ `Lorem ipsum \`dolor\` sit amet, consectetur adipiscing elit. Nunc quis ante ac leo tristique
+iaculis vel in tortor. Praesent sed interdum ipsum. Pellentesque blandit, sapien at mattis
+facilisis, leo mi gravida erat, in euismod mi lectus non dui. Praesent at justo vel mauris pulvinar
+sodales ut sed nisl. Aliquam aliquet justo vel cursus imperdiet. Suspendisse potenti. Duis varius
+tortor finibus, rutrum justo ac, tincidunt enim.`,
+ `Donec velit dui, bibendum a augue sit amet, tempus condimentum neque. Integer nibh tortor,
+imperdiet at aliquet eu, rutrum eget ligula. Donec porttitor nisi lacus, eu bibendum augue maximus
+eget. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos.
+Maecenas in est eget lectus dapibus tincidunt. Ut ut nisi egestas, posuere libero laoreet, venenatis
+erat. Nulla maximus, nisl eget interdum ornare, enim turpis semper ligula, sed ultricies sem sem
+quis arcu. Ut a dapibus augue. Pellentesque nec tincidunt sem.`,
+ ];
+</script>
+
+<h1><code>MessageRun</code></h1>
+
+<nav><p><a href=".">Back to swatches</a></p></nav>
+
+<h2>properties</h2>
+
+<div class="component-properties">
+ <label>sender <input type="text" bind:value={sender} /></label>
+ <label>css class <input type="text" bind:value={cssClass} /></label>
+ <div class="suggestion">
+ interesting values:
+ <button onclick={() => (cssClass = 'own-message')}>own-message</button>
+ <button onclick={() => (cssClass = 'other-message')}>other-message</button>
+ </div>
+</div>
+
+<h2>rendered</h2>
+
+<div class="component-preview">
+ <MessageRun {sender} class={cssClass}>
+ {#each messages.entries() as [index, message]}
+ <Message id="Mplaceholder-{index}" at={DateTime.now()} renderedBody={render(message)} />
+ {/each}
+ </MessageRun>
+</div>
diff --git a/ui/routes/(swatch)/.swatch/swatch/EventLog/+page.svelte b/ui/routes/(swatch)/.swatch/swatch/EventLog/+page.svelte
new file mode 100644
index 0000000..a751ca3
--- /dev/null
+++ b/ui/routes/(swatch)/.swatch/swatch/EventLog/+page.svelte
@@ -0,0 +1,38 @@
+<script>
+ import EventCapture from '$lib/swatch/event-capture.svelte.js';
+ import { json } from '$lib/swatch/derive.js';
+
+ import EventLog from '$lib/components/swatch/EventLog.svelte';
+
+ let eventsInput = $state(
+ JSON.stringify(
+ [{ event: 'example', args: ['argument a', 'argument b'] }],
+ /* replacer */ null,
+ /* space */ 2,
+ ),
+ );
+ let events = $derived(json(eventsInput));
+
+ let capture = $state(new EventCapture());
+ const clear = capture.on('clear');
+</script>
+
+<h1><code>swatch/EventLog</code></h1>
+
+<nav><p><a href="..">Back to swatches</a></p></nav>
+
+<h2>properties</h2>
+
+<div class="component-properties">
+ <label>events (json) <textarea bind:value={eventsInput}></textarea></label>
+</div>
+
+<h2>rendered</h2>
+
+<div class="component-preview">
+ <EventLog {events} {clear} />
+</div>
+
+<h2>events</h2>
+
+<EventLog events={capture.events} clear={capture.clear.bind(capture)} />
diff --git a/ui/styles/swatches.css b/ui/styles/swatches.css
new file mode 100644
index 0000000..f2a9f99
--- /dev/null
+++ b/ui/styles/swatches.css
@@ -0,0 +1,24 @@
+.component-preview {
+ border: 1px solid grey;
+ margin: 1rem 2rem;
+}
+
+.component-properties {
+ margin: 1rem 2rem;
+}
+
+.component-properties textarea {
+ width: 100%;
+ height: 10em;
+}
+
+.component-properties textarea.html {
+ font-family: FiraCode, monospace;
+}
+
+.component-properties .suggestion {
+ font-size: 80%;
+ margin-left: 1rem;
+ margin-right: 1rem;
+ margin-bottom: 0.5em;
+}