Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: FieldGrid lays out a row of form fields on one grid for the forms that each hand-rolled it

Classifier settings, the try-out target row and classification rows, and the
schedule cadence each wrote their own flex row with its own flex basis and
gap. FieldGrid is one grid with equal columns and one gap, FieldGridFull
spans a row, and the four forms use it, so a lone last field keeps its column
width and the columns share their edges in every row.
This commit is contained in:
iris 2026-11-06 19:53:31 +01:00 • committed by mara
commit a9dd276935
10 changed files with 99 additions and 57 deletions

View file

@ -41,6 +41,7 @@ import {
ExpandableCardListSkeleton,
} from "../ui/expandable-card/ExpandableCard.js";
import { ExternalLink } from "../ui/external-link/ExternalLink.js";
import { FieldGrid, FieldGridFull } from "../ui/field-grid/FieldGrid.js";
import { FormStack } from "../ui/form-stack/FormStack.js";
import { Hint, type HintTone } from "../ui/hint/Hint.js";
import { FormResult } from "../ui/submit/FormResult.js";
@ -649,6 +650,39 @@ export function ComponentsPage() {
</Sample>
</Section>
<Section title="FieldGrid">
<Sample label="FieldGrid: equal columns, one gap; FieldGridFull spans the row">
<FieldGrid>
<TextField
id="components-grid-a"
label="input"
value=""
onInput={() => {}}
/>
<TextField
id="components-grid-b"
label="output"
value=""
onInput={() => {}}
/>
<TextField
id="components-grid-c"
label="cache read"
value=""
onInput={() => {}}
/>
<FieldGridFull>
<TextField
id="components-grid-d"
label="URL"
value=""
onInput={() => {}}
/>
</FieldGridFull>
</FieldGrid>
</Sample>
</Section>
<Section title="FormStack and FormResult">
<Sample label="FormStack: fields and buttons in one column">
<FormStack onSubmit={(e) => e.preventDefault()}>

View file

@ -1,23 +1,11 @@
/* The settings form: the fields wrap in a row, the provider fields on a row of
* their own under the ACP runtime, then the save action. */
/* The settings form: the fields sit on a `FieldGrid`, the provider fields in a
* group of their own under the ACP runtime, then the save action. */
.ui-classifier-settings {
display: flex;
flex-direction: column;
gap: 1em;
margin-top: 1em;
}
/* One grid for every group of fields: each field has the same width and the
same edges at any width, and a label that wraps cannot push one input lower
than its neighbour's. */
.ui-classifier-settings-fields {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(14em, 1fr));
gap: 0.75em;
}
/* A field that needs the whole row (a URL, a model name). */
.ui-classifier-settings-wide {
grid-column: 1 / -1;
}
/* The provider fields, under their own heading. */
.ui-classifier-settings-group {
display: flex;

View file

@ -11,6 +11,7 @@ import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { ActionRow } from "../../ui/action-row/ActionRow.js";
import { Button } from "../../ui/button/Button.js";
import { FieldGrid, FieldGridFull } from "../../ui/field-grid/FieldGrid.js";
import { Hint } from "../../ui/hint/Hint.js";
import { Disclosure } from "../../ui/disclosure/Disclosure.js";
import { SelectField } from "../../ui/select-field/SelectField.js";
@ -143,7 +144,7 @@ export function ClassifierSettings() {
) : null}
{draft ? (
<form class="ui-classifier-settings" onSubmit={(e) => void save(e)}>
<div class="ui-classifier-settings-fields">
<FieldGrid>
<SelectField
id="classifier-runtime"
label="runtime"
@ -176,12 +177,12 @@ export function ClassifierSettings() {
pattern="[0-9]+"
onInput={(max) => edit({ ...draft, max })}
/>
</div>
</FieldGrid>
{draft.runtime === "acp" ? (
<fieldset class="ui-classifier-settings-group">
<legend>provider</legend>
<div class="ui-classifier-settings-fields">
<div class="ui-classifier-settings-wide">
<FieldGrid>
<FieldGridFull>
<TextField
id="classifier-acp-url"
label="URL"
@ -189,8 +190,8 @@ export function ClassifierSettings() {
placeholder="https://llm.example/v1"
onInput={(acpUrl) => edit({ ...draft, acpUrl })}
/>
</div>
<div class="ui-classifier-settings-wide">
</FieldGridFull>
<FieldGridFull>
<TextField
id="classifier-acp-model"
label="model"
@ -198,7 +199,7 @@ export function ClassifierSettings() {
placeholder="Qwen/Qwen3-35B"
onInput={(acpModel) => edit({ ...draft, acpModel })}
/>
</div>
</FieldGridFull>
<TextField
id="classifier-acp-price-input"
label="input price"
@ -227,7 +228,7 @@ export function ClassifierSettings() {
edit({ ...draft, priceCacheWrite })
}
/>
</div>
</FieldGrid>
<p class="ui-classifier-settings-note">
Prices are in USD per million tokens.
</p>

View file

@ -2,6 +2,7 @@
// description (the prompt the model answers), which answer passes, and how
// certain the model has to be for its answer to count.
import { Button } from "../../ui/button/Button.js";
import { FieldGrid } from "../../ui/field-grid/FieldGrid.js";
import { SelectField } from "../../ui/select-field/SelectField.js";
import { TextAreaField } from "../../ui/text-area-field/TextAreaField.js";
import { TextField } from "../../ui/text-field/TextField.js";
@ -41,7 +42,7 @@ export function ClassificationEditor({
placeholder="what to look for in the PR, e.g. does it add a naive datetime?"
onInput={(description) => onChange({ ...draft, description })}
/>
<div class="ui-tryout-classification-row">
<FieldGrid min="12em" class="ui-tryout-classification-row">
<SelectField
id={`${id}-polarity`}
label="polarity"
@ -59,7 +60,7 @@ export function ClassificationEditor({
onInput={(threshold) => onChange({ ...draft, threshold })}
/>
{onRemove ? <Button onClick={onRemove}>remove</Button> : null}
</div>
</FieldGrid>
</div>
);
}

View file

@ -12,17 +12,10 @@
gap: 1em;
margin-top: 1em;
}
.ui-tryout-target {
display: flex;
flex-wrap: wrap;
gap: 0.75em;
}
.ui-tryout-target > * {
flex: 1 1 14em;
}
/* The button sits at the foot of its cell, beside the inputs. */
.ui-tryout-target > button {
flex: none;
align-self: flex-end;
align-self: end;
justify-self: start;
}
.ui-tryout-loaded {
margin: 0;
@ -37,16 +30,10 @@
border-radius: 0.5em;
}
.ui-tryout-classification-row {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 0.75em;
}
.ui-tryout-classification-row > * {
flex: 1 1 12em;
align-items: end;
}
.ui-tryout-classification-row > button {
flex: none;
justify-self: start;
}
.ui-tryout-hint {
margin: 0;

View file

@ -27,6 +27,7 @@ import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { ActionRow } from "../../ui/action-row/ActionRow.js";
import { Button } from "../../ui/button/Button.js";
import { FieldGrid } from "../../ui/field-grid/FieldGrid.js";
import { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js";
import { Disclosure } from "../../ui/disclosure/Disclosure.js";
import { ExternalLink } from "../../ui/external-link/ExternalLink.js";
@ -332,7 +333,7 @@ export function ReviewTryOut() {
the repo's rules.
</p>
<form class="ui-tryout-form" onSubmit={(e) => void run(e)}>
<div class="ui-tryout-target">
<FieldGrid class="ui-tryout-target">
<TextField
id="tryout-repo"
label="repo"
@ -358,7 +359,7 @@ export function ReviewTryOut() {
>
{loadingRules ? "loading…" : "load the repo's rules"}
</Button>
</div>
</FieldGrid>
{repoChoicesError ? (
<ApiErrorPanel
context="the repos with an open PR could not be listed"

View file

@ -1,6 +1,6 @@
/* The form fills its card; fields stack with the page's usual gap. The
cadence controls sit in one wrapping row so "repeats / every / unit /
starts at" read as one sentence on a wide card and stack on a narrow one. */
cadence controls sit on a `FieldGrid`, so "repeats / every / unit / starts
at" read as one row on a wide card and stack on a narrow one. */
.ui-schedule-form {
display: flex;
flex-direction: column;
@ -20,15 +20,6 @@
max-height: none;
overflow-y: visible;
}
.ui-schedule-form-cadence {
display: flex;
flex-wrap: wrap;
gap: 0.75em;
}
.ui-schedule-form-cadence > .ui-form-field {
width: auto;
flex: 1 1 9em;
}
.ui-schedule-form-hint {
font-size: 0.85em;
}

View file

@ -13,6 +13,7 @@ import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { ActionRow } from "../../ui/action-row/ActionRow.js";
import { Button } from "../../ui/button/Button.js";
import { FieldGrid } from "../../ui/field-grid/FieldGrid.js";
import { Card } from "../../ui/card/Card.js";
import { Hint } from "../../ui/hint/Hint.js";
import {
@ -212,7 +213,7 @@ export function ScheduleForm({
required
onInput={setBody}
/>
<div class="ui-schedule-form-cadence">
<FieldGrid min="12em">
<SelectField
id={`${idPrefix}-repeat`}
label="repeats"
@ -250,7 +251,7 @@ export function ScheduleForm({
required
onInput={setStart}
/>
</div>
</FieldGrid>
{fires.length > 0 ? (
<Hint class="ui-schedule-form-hint">
{repeat ? "next fires: " : "fires: "}

View file

@ -0,0 +1,8 @@
.ui-field-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(var(--ui-field-min), 1fr));
gap: 0.75em;
}
.ui-field-grid-full {
grid-column: 1 / -1;
}

View file

@ -0,0 +1,30 @@
// <FieldGrid> — a row or block of form fields on one grid: every field is the
// same width and the columns have the same edges in every row, so a field's
// width depends on the container and not on the siblings it wraps with. The
// columns are as many as fit `min` wide; a lone last field keeps its column
// width instead of stretching across the form. One gap, the same in every form.
// A field that needs a whole row (a URL, a model name) goes in `FieldGridFull`.
import type { ComponentChildren } from "preact";
import "./FieldGrid.css";
export function FieldGrid({
min = "14em",
class: extraClass,
children,
}: {
/** The narrowest a column gets, any CSS length. */
min?: string;
class?: string;
children: ComponentChildren;
}) {
const classes = ["ui-field-grid", extraClass].filter(Boolean).join(" ");
return (
<div class={classes} style={{ "--ui-field-min": min }}>
{children}
</div>
);
}
export function FieldGridFull({ children }: { children: ComponentChildren }) {
return <div class="ui-field-grid-full">{children}</div>;
}