summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorojacobson <ojacobson@noreply.codeberg.org>2025-07-10 03:35:32 +0200
committerojacobson <ojacobson@noreply.codeberg.org>2025-07-10 03:35:32 +0200
commitf74b82450aa15b3e3e47617839c297cd1d60780e (patch)
treeb9091f1fa4f73de40fb7b530798ce2d3b94aee09
parent223b39a57ef6ca6b8288f5a8645183c41301f411 (diff)
parent01ed82ac4f89810161fbc3aa1cb8e4691fb8938b (diff)
Create swatches for Svelte components.
A swatch is a live, and ideally editable, example of an element of the service. They serve as: * Documentation: what is this element, how do you use it, what does it do? * Demonstration: what does this element look like? * Manual test scaffolding: when I change this element like _so_, what happens? Swatches are collectively available under `/.swatch/` on a running instance. They do not require setup or login for simplicity's sake and because they don't _do_ anything that requires either of those things. Swatches are manually curated. First, we lack the technical infrastructure needed to do this based on static analysis, and second, manual curation lets us include affordances like "interesting values," that would be tricky to express as part of the type or schema for the component. The tradeoff, however, is that they will fall out of step with the components if not reviewed regularly. Swatches are _possible_ because we've gone to efforts to avoid global data access or direct side effects (including API requests) in our components, delegating that upwards to `+page`s and `+layout`s. However, the isolation is imperfect. For example, the swatch for `Conversation`, which renders the conversation sidebar entries, causes actual attempts to boot the app as browsers pre-fetch the links on mouseover, and clicking them will take the user to the "real" application because they really are links. Merges swatch into main.
-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;
+}