diff options
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() +} @@ -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; +} |
