Design system

Patterns

Recurring page-level compositions. Where Components describes single primitives, Patterns documents the way several primitives combine to solve a common UX problem.

Forms

Two production form patterns ship today, each a different shape of input-to-result interaction. The contact form is a classic submit-and-confirm flow: four fields stacked in a glass panel, a single primary button, and an inline status line that resolves to a success or error message. The API demo request panel is a single-input live-query interface: the user types a URL or text body and sees a structured response render in place. Both lean on the same input recipes documented under Components › Inputs and the same status-line recipe under Components › Feedback — this section focuses on how the primitives compose into a working flow.

1 · Contact form

Submit-and-confirm flow on /contact/, rendered from src/contact.njk:127, styled in src/css/contact/contact.css, behaviour wired up in src/js/contact-form.js. Posts FormData to a Google Apps Script endpoint as application/x-www-form-urlencoded; the script queues the message in a Google Sheet. Three states drive the UI: idle (rest), sending (button disabled, label swapped to “Sending…”), and resolved (button label flips to “Sent” on success or restores on error, while .contact-form__status renders the inline message with data-state="success" or "error").

Full form — idle state

.contact-form-wrap · .contact-form · .contact-form__field

 

Layout. Fields stack in a flex column inside .contact-form-wrap, a glass panel that wraps the whole form. Each .contact-form__field is itself a flex column — label first, input second, gap from variables.css. Required fields prepend an asterisk inside the label text rather than relying on browser default styling, so the visual hint is consistent across browsers. Select uses an inline-SVG chevron background-image so it doesn’t rely on the OS dropdown chrome. Submit button is the standard .btn.btn--solid with a .contact-form__submit hook for the disabled-state opacity (0.6).

Submission states

.contact-form__submit[disabled] · .contact-form__status[data-state]
Idle

 

Sending

 

Sent

Thanks — we will be in touch shortly.

Error

Something went wrong. Please email us directly.

Behaviour. JS sets button.disabled = true and swaps the text node to Sending… on submit. On a 2xx response the button label becomes Sent and stays disabled (the form has been reset, so resubmitting would just send blanks); status renders with data-state="success" in --color-green. On a non-2xx response the button is re-enabled with its original label so the user can retry; status renders with data-state="error" in red. The status line uses min-height: 1.25 rem so the layout doesn’t shift when the message appears or clears — same recipe documented under Components › Feedback.

2 · API demo request panel

Live-query flow on /api-demo/, rendered from src/api-demo.njk:45, styled in src/css/api-demo/api-demo.css, behaviour in src/js/api-demo.js. Two near-identical panels (Edge — classify a URL, REST — classify raw text) sit behind a tab switcher; each panel is a single input plus two action buttons. The contract is different from the contact form: there is no submit-and-redirect, the result renders inline below the input, and a second Sample data button bypasses the network so the user can preview the response shape without waiting on a real request.

Single-input request panel

.api-demo__input · .api-demo__run · .api-demo__sample

Layout. Input on its own row, action buttons on the next row. Primary button (.btn--solid) runs the live query; secondary button (.btn--ghost) loads pre-bundled JSON from a <script type="application/json"> block embedded in the page so the response shape is always available even when the API is down. Both buttons share the data-state="loading" hook for the in-flight spinner. Input uses the monospace recipe (--font-family-mono, faint blue tint) so URLs and code snippets read at a glance — documented under Components › Inputs.

Validation flash

.api-demo__input--error

If the user hits run with an empty input, JS adds .api-demo__input--error for 900 ms, removes it on the timeout. The class swaps the border to red and triggers a 0.45 s shake keyframe so the empty field grabs attention without a separate error label. Cheap-and-cheerful client-side validation: the API call is never made, no inline status appears.

Loading button

.api-demo__run[data-state="loading"]

data-state="loading" on the button reveals an inline spinner and drops opacity to 0.85; the response panel below simultaneously renders the scan-bar loader documented under Components › Feedback. Stays in this state until the fetch resolves or rejects.

Empty states

Two production empty / fallback patterns ship today, both inside the API demo response panel: an initial empty state (the panel before any request runs) and an error state (when a request fails or returns a malformed shape). Beyond these the site has gaps — the blog index, for instance, would render an empty <ul> with no fallback if no posts existed — documented at the bottom so they don’t get forgotten.

1 · Initial empty state

Renders inside both API-demo response panels on first load, before the user has clicked run or sample data. Two-line invitation centred in a tall placeholder zone — a heading-weight title and a sentence pointing at the two action buttons above. Replaced by the actual response (or by the error / loading states) once the user kicks off a request.

API demo — initial state

.api-demo__empty · .api-demo__empty-title · .api-demo__empty-body

Ready when you are.

Hit Try Neuwo Analysis to see live classification, or start with Sample data to preview the shape.

src/css/api-demo/api-demo.css:846. Flex column, centred, min-height: 22 rem so the panel reserves space for the eventual response — layout doesn’t reflow when the result lands. Title uses --font-size-lg, weight 500, heading colour; body uses --font-size-sm, muted, max-width: 24 rem so the line length stays readable. Pulls double duty as an empty state and a soft call-to-action, which is why the body explicitly names both buttons — users land on the page mid-scroll and need to be told what to click.

2 · Error state

Replaces the empty / response content when a request fails — network error, non-2xx status, or malformed JSON. Same panel position as the initial state, but with red-tinted chrome and a three-line hierarchy: title (label), message (what failed), hint (what to try next). The hint copy differs by mode: live runs suggest checking the URL or trying sample data; sample-data failures point at a likely build-pipeline issue.

API demo — error

.api-demo__error · .api-demo__error-title · .api-demo__error-message · .api-demo__error-hint

Request failed

503 — service temporarily unavailable.

Double-check the URL or try the sample data button to preview the response shape.

src/css/api-demo/api-demo.css:365. Padded glass surface with a red-tinted border, min-height: 16 rem. Title is uppercase, weight 700, red. Message is --font-size-sm in body colour. Hint is --font-size-xs in muted colour. The component carries no dismiss affordance — the next successful request replaces the error in place, which feels more like a temporary banner than a modal.

3 · Coverage gaps

Surfaces that could render an empty state today but don’t. Documented here so they get a real treatment when production shows up rather than a blank container.

  • Blog indexsrc/blog.njk renders {% for post in collections.blog | reverse %} with no {% else %} branch. With zero posts the page would render an empty .post-list and nothing else. Add a small “No posts yet” placeholder in the same shape as the API-demo empty state when the collection is empty.
  • Search — the site has no search affordance today; if one ships, the zero-results state should follow the API-demo empty recipe rather than inventing a third one.
  • Solution-page live demos — the bid-enrichment and contextual-audiences cards use skeleton placeholders while loading, then swap to populated content. They don’t have an explicit empty state because they’re hard-coded to populated data; if either becomes data-driven, reach for the API-demo empty recipe.

Onboarding

First-visit and persistent-dismissal surfaces. The site has exactly one today — the announcement bar — documented as a persistence pattern here. The visual recipe (markup, layout, dismiss button) lives under Components › Navigation; this section captures the storage contract and the lifecycle so future first-visit surfaces (product tours, cookie consent, feature callouts) can reuse the same shape.

1 · Persistent dismissal — announcement bar

The announcement bar is initially hidden at the markup level. JS in src/js/announcement.js reads localStorage.getItem("neuwo-announcement-dismissed") on load: if a key is present, the bar stays hidden for the rest of the session — if not, the hidden attribute is removed and the bar appears. Two click targets dismiss it and write the key: the close button (explicit dismissal) and the announcement link (implicit dismissal — once the user has acted on the offer, there’s no value in showing it again).

// src/js/announcement.js
const STORAGE_KEY = "neuwo-announcement-dismissed";

if (localStorage.getItem(STORAGE_KEY)) return;       // never seen again
bar.hidden = false;

closeBtn.addEventListener("click", () => {
  localStorage.setItem(STORAGE_KEY, "1");
  bar.hidden = true;
});
linkBtn.addEventListener("click", () => {
  localStorage.setItem(STORAGE_KEY, "1");             // implicit dismissal
});

Lifecycle

localStorage["neuwo-announcement-dismissed"]
  1. First visit — key absent. Bar reveals; hidden attribute removed.
  2. User dismisses — click on close or on the announcement link. Key set to "1"; bar re-hidden.
  3. Subsequent visits — key present. JS exits early, bar stays hidden.
  4. New campaign — bumping the storage key (e.g. "neuwo-announcement-dismissed-2026q2") resets the audience: every visitor sees the next bar exactly once.

Why localStorage and not a cookie. The dismissal carries no server-side significance — the announcement is purely a marketing surface, and the persistence is a kindness to repeat visitors rather than a tracking signal. localStorage is per-origin, persists across sessions until the user clears site data, and never leaves the browser, which is the right contract for this kind of UI state. Pattern reuse. The same shape (single key, set on dismissal, checked on load) covers any future first-visit nudge — product tour, cookie banner, feature callout. Pick a stable key per surface, namespace it under neuwo-, and bump the suffix when the message changes.

Cross-refs. Visual recipe for the bar itself — markup, layout, breakpoint behaviour, dismiss button — lives under Components › Navigation › Announcement bar. The link recipe inside the bar is documented under Components › Links.

2 · Coverage gaps

No other first-visit / onboarding surfaces ship today — no cookie banner, no product tour, no feature callouts, no “what’s new” modal. If any of these land later, document them here against the same persistence contract above so the keys are namespaced consistently.