Client-Side UI Patterns

Reepolee renders pages on the server and adds browser behaviour only where an interaction needs it. There is no framework-specific DOM-binding layer. The current code uses four small building blocks:

NeedCurrent approach
Disclosure and expandable navigationNative <details> and <summary>
Confirmation and modal UINative <dialog> plus dialog-confirm.js where needed
Standard application behaviourFocused scripts such as form-controller.js and dialog-confirm.js
Stateful custom controlsalien-deepsignals directly with native DOM updates

This page shows how those pieces fit together. For the complete deepSignal(), watchEffect(), and watch() API, see Signals.

Start With Native HTML

Use the browser's built-in elements before adding JavaScript. The application sidebar, for example, uses <details> for collapsible module groups:

<details open>
    <summary>Administration</summary>
    <nav>
        <a href="/admin/users">Users</a>
        <a href="/admin/modules">Modules</a>
    </nav>
</details>

The browser owns the open state, keyboard interaction, and accessibility semantics. Reepolee's layout adds a small toggle listener only to remember collapsed navigation groups in a cookie.

Use native <dialog> for modal content. Reepolee's confirmation dialogs use invoker commands:

<button type="button" command="show-modal" commandfor="delete-dialog">
    Delete
</button>

<dialog id="delete-dialog">
    <p>Delete this record?</p>
    <button type="button" command="close" commandfor="delete-dialog">Cancel</button>
    <button type="button" command="--confirm" commandfor="delete-dialog">Delete</button>
</dialog>

Pages using this pattern load /dialog-confirm.js. The script makes show-modal reliably call showModal(). Application code listens for the dialog's command event to perform a custom --confirm action. See Dialogs for the full pattern.

Focused Scripts

Browser code in static/ is split by responsibility rather than hidden behind one client framework:

FileResponsibility
helpers-client.jsTheme handling, $/$ query helpers, bulk-selection helpers, and query-string navigation
form-controller.jsForm values, field validation, error rendering, and guarded submission
dialog-confirm.jsNative dialog opening and custom confirmation commands
checkbox-group.jsSelect-all and bulk-action state for record tables
web-components/*.jsFocused custom elements such as validation errors, toasts, and title display

Load only the script a page needs. The main layout already loads helpers-client.js and the shared web components. Form and dialog scripts are included by the templates that use them.

Reactive Widgets With alien-deepsignals

When a control has several related pieces of client-side state, import deepSignal and watchEffect directly from the vendored bundle:

<input id="counter-input" value="100" disabled />
<button id="plus-button" type="button">Add 1</button>

<script type="module">
    import { deepSignal, watchEffect } from "/alien-deepsignals.min.js";

    const state = deepSignal({
        count: parseInt(document.getElementById("counter-input").value, 10),
    });
    const counter_input = document.getElementById("counter-input");
    const plus_button = document.getElementById("plus-button");

    plus_button.onclick = () => state.count++;
    watchEffect(() => (counter_input.value = state.count));
</script>

The bundle is tracked at static/alien-deepsignals.min.js. The bun get:signals command refreshes it from the pinned 0.4.3 esm.sh URL. No bundler or runtime npm dependency is involved.

Real Component Patterns

Two shipped form components demonstrate the intended approach.

Tag Input

components/input-tags.ree keeps its tag array in a deep signal. Native keyboard handlers add tags, and one watchEffect() synchronizes the hidden form value and redraws the chips. The hidden input remains the value submitted to the server.

The important shape is:

const state = deepSignal({ tags: initial_tags });

draft.addEventListener("keydown", (event) => {
    // Validate the key and update state.tags.
});

watchEffect(() => {
    hidden.value = state.tags.join(",");
    // Redraw the tag chips from state.tags.
});

Star Rating

components/star-rating.ree tracks the chosen value and the temporary hover value:

const state = deepSignal({ value: initial_value, hover: 0 });

stars.forEach((button) => {
    const value = parseInt(button.dataset.ratingStar, 10);
    button.onclick = () => (state.value = value);
    button.onmouseenter = () => (state.hover = value);
    button.onmouseleave = () => (state.hover = 0);
});

watchEffect(() => {
    const visible_value = state.hover || state.value;
    // Update the hidden input, label, and star classes.
});

These examples keep the server-facing form value in a normal hidden input and use signals only for immediate browser feedback.

Passing Server State to JavaScript

Ree templates can initialize browser state from server-rendered values. Emit strings with escaped output and structured data as a JSON literal with raw output:

<script type="module">
    import { deepSignal } from "/alien-deepsignals.min.js";

    const state = deepSignal({
        count: {= props.initial_count },
        name: "{= props.user.name }",
        record: {~ JSON.stringify(props.record) },
    });
</script>

Use {~ } for the serialized object because JSON.stringify() already produces the JavaScript literal. Escaping it again would make the expression invalid.

Forms and Signals

Do not replace FormController merely to make an ordinary form reactive. It already reads input values, performs server-backed validation on focus changes, renders errors, and guards submission.

Use deepsignals when the interface has derived client-only state, such as a live total, tag editor, rating preview, or configurator. A component can still write its final value to a normal form input so FormController and native form submission continue to work.

When to Add JavaScript

Keep the default server-rendered:

  • Render record lists, headings, navigation, and validation responses on the server.
  • Use <details> and <dialog> when native HTML already owns the interaction.
  • Use an existing focused script or web component for standard application behaviour.
  • Reach for alien-deepsignals only when state must update immediately in the browser and several DOM values derive from it.

That keeps the application usable as ordinary server-rendered HTML while allowing richer controls where they earn their complexity.