Cloud|

@k2b/ui

Settings

Accessible settings tabs, fields, and save state.

SettingsPage@k2b/uiSettingsSection@k2b/ui

A flat full-page settings shell with accessible paper sections, one heading, a scrolling body, optional actions, and a fixed save footer.

Project settings

Identity and defaults

Identity

Public service details

Endpoint

Public service URL

TSX

Copy
<SettingsPage
title="Project settings"
subtitle="Identity and defaults"
icon="ti ti-settings"
actions={<Button size="sm" variant="secondary">Test connection</Button>}
footer={
<SettingsPanelFooter
changeCount={() => (changed() ? 1 : 0)}
loading={() => false}
onDiscard={discard}
onSave={save}
/>
}
>
<SettingsSection title="Identity" subtitle="Public service details" icon="ti ti-id">
<SettingsField label="Endpoint" description="Public service URL" error={() => undefined} changed={changed}>
{(control) => <TextInput aria-label="Endpoint" aria-describedby={control.describedBy()} value={endpoint()} onValueChange={setEndpoint} />}
</SettingsField>
</SettingsSection>
</SettingsPage>
SettingsModal@k2b/uiSettingsGroup@k2b/uiSettingsCollection@k2b/uiSettingsField@k2b/uiSettingsPanelFooter@k2b/ui

Grouped category navigation, flat form groups, compact entity collections, boundary-aware reorder controls, and a panel-owned save footer.

Preferences

Defaults for this browser.

Reading

Choose how messages are displayed.

Message format

Choose the preferred representation for incoming messages.

Writing

Defaults for new messages and replies.

Compose format

Used when you open the composer.

Undo send

Delay delivery so a message can still be recalled.

TSX

Copy
<SettingsModal title="Mailbox settings" activeTab={active()} onTabChange={setActive}>
<SettingsModal.Group title="Personal">
<SettingsModal.Tab id="preferences" title="Preferences" icon="ti ti-adjustments" description="Defaults for this browser.">
<SettingsGroup title="Reading" description="Choose how messages are displayed.">
<SettingsField label="Message format" description="Choose the preferred representation for incoming messages." error={() => readingFormat() ? undefined : "Choose a message format."} changed={() => readingFormat() !== savedPreferences().readingFormat}>
{(control) => <Select aria-label="Message format" aria-describedby={control.describedBy()} value={readingFormat()} onValueChange={(value) => setReadingFormat(value ?? "")} clearable options={[{ value: "automatic", label: "Automatic — Recommended" }, { value: "html", label: "HTML" }, { value: "plain", label: "Plain text" }]} />}
</SettingsField>
</SettingsGroup>
<SettingsGroup title="Writing" description="Defaults for new messages and replies.">
<SettingsField label="Compose format" description="Used when you open the composer." error={() => undefined} changed={() => composeFormat() !== savedPreferences().composeFormat}>
<Select aria-label="Compose format" value={composeFormat()} onValueChange={(value) => value && setComposeFormat(value)} options={[{ value: "rich", label: "Rich text" }, { value: "plain", label: "Plain text" }]} />
</SettingsField>
<SettingsField label="Undo send" description="Delay delivery so a message can still be recalled." error={() => undefined} changed={() => undoSend() !== savedPreferences().undoSend}>
<Select aria-label="Undo send" value={undoSend()} onValueChange={(value) => value && setUndoSend(value)} options={[{ value: "0", label: "Off" }, { value: "5", label: "5 seconds" }, { value: "10", label: "10 seconds" }, { value: "30", label: "30 seconds" }]} />
</SettingsField>
</SettingsGroup>
<SettingsModal.Footer>
<SettingsPanelFooter changeCount={changeCount} loading={loading} saveDisabled={() => !readingFormat()} onDiscard={discard} onSave={save} />
</SettingsModal.Footer>
</SettingsModal.Tab>
</SettingsModal.Group>
<SettingsModal.Group title="Mailbox">
<SettingsModal.Tab id="organization" title="Organization" icon="ti ti-tags" description="Reusable views and shared vocabulary.">
<SettingsCollection title="Saved views" description="Reusable filters shown in navigation." empty="No saved views yet.">
<SettingsCollection.Action><Button size="xs">New view</Button></SettingsCollection.Action>
<SettingsCollection.Item title="Open conversations" description="Private view · 3 filters" icon={<i class="ti ti-filter" />}>
<SettingsCollection.Item.Status><StatusBadge tone="neutral" label="Private" variant="text" /></SettingsCollection.Item.Status>
<SettingsCollection.Item.Actions>
<SettingsCollection.Item.Reorder label="Open conversations" index={0} count={2} onMove={() => {}} />
<IconButton size="xs" label="Edit Open conversations"><i class="ti ti-pencil" /></IconButton>
</SettingsCollection.Item.Actions>
</SettingsCollection.Item>
<SettingsCollection.Item title="Waiting on customer" description="Shared view · 2 filters" icon={<i class="ti ti-filter" />}>
<SettingsCollection.Item.Status><StatusBadge tone="neutral" label="Shared" variant="text" /></SettingsCollection.Item.Status>
<SettingsCollection.Item.Actions>
<SettingsCollection.Item.Reorder label="Waiting on customer" index={1} count={2} onMove={() => {}} />
<IconButton size="xs" label="Edit Waiting on customer"><i class="ti ti-pencil" /></IconButton>
</SettingsCollection.Item.Actions>
</SettingsCollection.Item>
</SettingsCollection>
<SettingsCollection title="Conversation tags" description="Shared across views and automations." empty="No tags yet.">
<SettingsCollection.Action><Button size="xs" variant="secondary">Add tag</Button></SettingsCollection.Action>
</SettingsCollection>
</SettingsModal.Tab>
<SettingsModal.Tab id="delivery" title="Delivery" icon="ti ti-send" description="Accounts and sender identities.">
<SettingsCollection title="Connected accounts" empty="No account connected.">
<SettingsCollection.Item title="support@example.test" description="IMAP and SMTP" icon={<i class="ti ti-mail" />}>
<SettingsCollection.Item.Status><StatusBadge tone="ok" label="Connected" /></SettingsCollection.Item.Status>
<SettingsCollection.Item.Actions><Button size="xs" variant="ghost">Manage</Button></SettingsCollection.Item.Actions>
</SettingsCollection.Item>
</SettingsCollection>
</SettingsModal.Tab>
</SettingsModal.Group>
<SettingsModal.Group title="Lifecycle">
<SettingsModal.Tab id="danger" title="Danger zone" icon="ti ti-alert-triangle" tone="danger">
<SettingsGroup title="Disable mailbox" description="Stop new mail without deleting retained messages.">
<SettingsGroup.Action><Button size="sm" variant="danger">Disable mailbox</Button></SettingsGroup.Action>
</SettingsGroup>
</SettingsModal.Tab>
</SettingsModal.Group>
</SettingsModal>
SettingsSaveBar@k2b/ui

A sticky page-level save bar that appears only while settings have unsaved changes.

2 unsaved changes

TSX

Copy
<SettingsSaveBar
changeCount={() => 2}
loading={() => false}
onDiscard={discard}
onSave={save}
/>

SettingsPage is the flat shell for a dedicated settings route. It owns one page heading, a scrolling content region, optional header actions, and an optional fixed footer. It deliberately adds no outer card or dialog frame.

SettingsModal is a portable tabbed settings surface. It owns category navigation, keyboard behavior, and the active panel. The application owns loading, form state, validation, persistence, and the surrounding dialog or page.

Use SettingsModal

Use SettingsPage for a full settings route. Use SettingsModal when one resource has several settings categories. Use PanelDialog for an actual dialog or complex embedded editor and prompts.form for a small prompt.

Import

tsx
import {
  Button,
  readSettingsError,
  sameSettingValue,
  SettingsCollection,
  SettingsField,
  SettingsGroup,
  SettingsModal,
  SettingsPage,
  SettingsPanelFooter,
  SettingsSaveBar,
  SettingsSection,
  TextInput,
} from "@k2b/ui";

Compose a full settings page

Keep the page flat: place section cards directly in SettingsPage, and put save state in footer. Do not wrap a dedicated route in PanelDialog merely to create another outer surface.

tsx
<SettingsPage
  title="Project settings"
  subtitle="Identity and defaults"
  icon="ti ti-settings"
  actions={<Button variant="secondary">Test connection</Button>}
  footer={<SettingsPanelFooter {...saveState} />}
>
  <SettingsSection title="Identity" icon="ti ti-id">
    <SettingsField {...fieldProps}></SettingsField>
  </SettingsSection>
</SettingsPage>

SettingsSection is the page-level paper surface for a coherent settings group. It provides one accessible section heading, optional actions, and the same compact rhythm as Cloud admin data panels. Keep PanelDialog.Section inside dialogs; it intentionally has a different containment contract.

Inside SettingsModal, use SettingsGroup for a flat form group and SettingsCollection for compact entity management. Neither component owns a backend or adds another paper inside the modal.

Compose controlled tabs

Compose categories with SettingsModal.Tab. Each tab needs a stable id, title, and children. Wrap related tabs in SettingsModal.Group to add a short rail label without changing tab selection or keyboard order. The category rail remains visible at narrow widths instead of becoming a separate select.

activeTab and onTabChange make selection controlled; use defaultTab for local selection. An optional onClose adds a close action without deciding how the surrounding surface is opened.

Place SettingsModal.Footer inside a tab when that category has a persistent status and action row. The modal keeps the footer outside the scrolling panel; the application still owns dirty state, saving, discarding, and navigation guards.

subtitle and icon remain accepted on SettingsModal for source compatibility, but category titles, descriptions, and icons provide the visible context.

Compose flat groups and collections

SettingsGroup owns one subsection heading, description, named action slot, and a quiet content rhythm. Use SettingsGroup.Action for visual actions instead of passing JSX through an action prop.

SettingsCollection owns one entity-list heading, compact empty state, and semantic list. Add its primary action through SettingsCollection.Action. Each SettingsCollection.Item accepts scalar title and description data; put visual status and controls in SettingsCollection.Item.Status and SettingsCollection.Item.Actions.

Use SettingsCollection.Item.Reorder inside the actions slot for an ordered collection. Pass the current zero-based index, total count, and an onMove callback. It owns accessible move-up and move-down controls and disables moves at the collection boundaries. The application still owns the reordered state, persistence, optimistic updates, rollback, and announcements after a move.

The collection does not create, update, delete, sort, authorize, or confirm anything. The application supplies those behaviors through the controls it composes into the named slots. Use a specialized editor such as TagEditor or PermissionEditor when that complete interaction already exists.

Compose form state

SettingsField groups a label, required description, reactive error accessor, optional reactive dirty accessor, and a control. Use its render-function child and pass describedBy to the control's aria-describedby so screen readers announce the visible description and current validation error.

SettingsSaveBar uses reactive changeCount and loading accessors. It appears only while the count is greater than zero and disables its actions while loading. Use reactive saveDisabled when validation should disable Save without preventing Discard.

SettingsPanelFooter provides the same status and actions for a surrounding panel footer. It remains visible with No unsaved changes, disables both actions until a change exists, and accepts saveVariant for the shared button hierarchy. It also accepts saveDisabled for field validation.

sameSettingValue performs the JSON-based, order-sensitive comparison used by settings forms. readSettingsError(response, fallback) reads the shared message and per-field errors response shape. These helpers do not perform a request or select a persistence backend.

Accessibility

The category rail is a tab list. Group labels are presentational and do not add keyboard stops. Arrow keys move between all tabs; Home and End move to the first and last tab across groups. Every tab needs a stable id and concise title. Use tone="danger" only for destructive settings.

SettingsGroup, SettingsCollection, and collection items create their own heading and list semantics. Visual collection actions still need the accessible names required by their underlying controls.

Errors render as alerts, and the dirty state includes an explicit Unsaved label rather than relying on color.

Runtime

The active panel renders on the server. Tab selection, close controls, form callbacks, and saving require hydrated Solid code.

Example

tsx
const [active, setActive] = createSignal("general");
const [endpoint, setEndpoint] = createSignal("https://example.test");
const [initialEndpoint, setInitialEndpoint] = createSignal(endpoint());
const [loading, setLoading] = createSignal(false);
const changed = () => !sameSettingValue(endpoint(), initialEndpoint());
const save = async () => {
  setLoading(true);
  await saveEndpoint(endpoint());
  setInitialEndpoint(endpoint());
  setLoading(false);
};

<SettingsModal
  title="Application settings"
  activeTab={active()}
  onTabChange={setActive}
>
  <SettingsModal.Group title="Workspace">
    <SettingsModal.Tab id="general" title="General" icon="ti ti-settings">
      <SettingsGroup title="Connection" description="Public service settings.">
        <SettingsField
          label="Endpoint"
          description="Public service URL"
          error={() => errors().endpoint}
          changed={changed}
        >
          <TextInput value={endpoint()} onValueChange={setEndpoint} />
        </SettingsField>
      </SettingsGroup>

      <SettingsCollection title="Webhooks" empty="No webhooks yet.">
        <SettingsCollection.Action>
          <Button size="sm">Add webhook</Button>
        </SettingsCollection.Action>
      </SettingsCollection>

      <SettingsModal.Footer>
        <SettingsPanelFooter
          changeCount={() => changed() ? 1 : 0}
          loading={loading}
          onDiscard={() => setEndpoint(initialEndpoint())}
          onSave={save}
        />
      </SettingsModal.Footer>
    </SettingsModal.Tab>
  </SettingsModal.Group>

  <SettingsModal.Group title="Lifecycle">
    <SettingsModal.Tab id="danger" title="Danger" icon="ti ti-alert-triangle" tone="danger">
      <SettingsGroup title="Delete application" description="Permanently remove this application.">
        <SettingsGroup.Action>
          <Button variant="danger">Delete application</Button>
        </SettingsGroup.Action>
      </SettingsGroup>
    </SettingsModal.Tab>
  </SettingsModal.Group>
</SettingsModal>

Connect a coding agent

The Cloud skill provides compact working instructions. MCP supplies exact current documentation when details matter.

CLI command
bunx skills add https://docs.example.com

This command installs the working instructions published by this website in the selected coding agent.

For exact, current details, also connect the MCP server through one of the agent tabs.

Installation uses the open-source Vercel Skills CLI.

The skill and MCP complement each other: the skill describes workflows, while MCP supplies current documentation.