Skip to content

Commit cf189c4

Browse files
mkotelnikovclaude
andcommitted
feat(models-config): FieldInput primitive + collapsible Headers + heading row
Addresses three Connections-tab UX gaps surfaced from screenshots: 1. **Browser autofill cross-pollution survives per-name uniqueness.** Even with `name="${type}-apiKey"` etc., Chrome / Safari still propagated the same value across the four tabs' sibling password and text fields. The reliable opt-out is explicit `autocomplete="new-password"` (for secrets) / `autocomplete="off"` (for plain text), which the shadcn json-render `Input` doesn't expose. Solution: add a catalog-local `FieldInput` primitive that wraps shadcn `Input` with the right autoComplete + 1Password / LastPass opt-out data attributes, and use it for the form's Name / API Key / URL fields. Stock `Input` is retained for the per-header rows since their `$bindItem` values aren't autofill targets. 2. **No way to see the API key after typing it.** `FieldInput` renders an inline eye / eye-off toggle on `type: "password"` that flips the input between masked and plain. Catalog declaration mirrors `Input` but limits `type` to `"text" | "password"` (no email / number) since those are the only modes used here. 3. **Heading and description stacked vertically on Connections form.** Per the screenshots, the title "Add Google connection" and its description "Enter your Google API key…" should share a row. Wrap them in a horizontal Stack (`align: "baseline"`) so they sit beside each other like a panel title + subtitle. 4. **Headers section folded by default.** Headers is a rare-use sub-section; the form was making the user scroll past an empty list + Add button on every connection. Wrap the Headers content (label, repeater, Add button) in a shadcn `Collapsible` with `defaultOpen: false`. Click the disclosure to expand only when actually needed. Touches: * `catalog.ts`: declare `FieldInput` primitive * `field-input.tsx`: React binding (autocomplete + show/hide) * `build-react-catalog.tsx`: register FieldInput in the registry * `connections-tab-spec.ts`: swap Input → FieldInput for the three form fields; new heading row + collapsible structure Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent aa14947 commit cf189c4

4 files changed

Lines changed: 129 additions & 20 deletions

File tree

packages/models-config-react/src/internal/build-react-catalog.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { Actions, Components } from "@json-render/react";
22
import { type DefineRegistryResult, defineRegistry } from "@json-render/react";
33
import { shadcnComponents } from "@json-render/shadcn";
44
import { type ModelsConfigCatalog, modelsConfigCatalog } from "@statewalker/models-config";
5+
import { FieldInput } from "./field-input.js";
56
import { MarkdownText } from "./markdown-text.js";
67

78
export interface BuildRegistryOptions {
@@ -19,6 +20,7 @@ export function buildModelsConfigRegistry(options: BuildRegistryOptions): Define
1920
const components = {
2021
...shadcnComponents,
2122
Markdown: MarkdownText,
23+
FieldInput,
2224
} as unknown as Components<ModelsConfigCatalog>;
2325
const actions = options.actions as unknown as Actions<ModelsConfigCatalog>;
2426
return defineRegistry(modelsConfigCatalog, { components, actions });
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { useBoundProp } from "@json-render/react";
2+
import { Button, Input, Label, cn } from "@statewalker/shadcn-react";
3+
import { Eye, EyeOff } from "lucide-react";
4+
import { type ReactElement, useState } from "react";
5+
6+
interface FieldInputProps {
7+
props: {
8+
label?: string | null;
9+
name: string;
10+
type?: "text" | "password" | null;
11+
placeholder?: string | null;
12+
value?: string | null;
13+
};
14+
bindings?: {
15+
value?: string;
16+
};
17+
}
18+
19+
/**
20+
* Bound to the `models-config` catalog's `FieldInput` primitive.
21+
* Used in place of shadcn `Input` for the Connections form fields so
22+
* each tab's input is isolated from browser autofill cross-pollution
23+
* and the API Key field can reveal its value on demand.
24+
*
25+
* Stock shadcn `Input` (via `@json-render/shadcn`) lets the browser
26+
* see four sibling fields with the same logical role
27+
* (`type="password"` for API Key, `type="text"` for Name) and offer
28+
* to autofill the same saved value across them. Per-`name` uniqueness
29+
* helps but the browser still cross-pollutes within the same form
30+
* context. Setting `autoComplete` explicitly is the reliable opt-out.
31+
*/
32+
export function FieldInput({ props, bindings }: FieldInputProps): ReactElement {
33+
const [showSecret, setShowSecret] = useState(false);
34+
const [boundValue, setBoundValue] = useBoundProp(props.value ?? "", bindings?.value);
35+
const isPassword = props.type === "password";
36+
const inputType = isPassword && !showSecret ? "password" : "text";
37+
return (
38+
<div className="space-y-2">
39+
{props.label ? <Label htmlFor={props.name}>{props.label}</Label> : null}
40+
<div className="relative">
41+
<Input
42+
id={props.name}
43+
name={props.name}
44+
type={inputType}
45+
placeholder={props.placeholder ?? ""}
46+
value={boundValue ?? ""}
47+
onChange={(e) => setBoundValue(e.target.value)}
48+
autoComplete={isPassword ? "new-password" : "off"}
49+
// Belt-and-braces: discourage popular password managers
50+
// (1Password, LastPass) from offering inline UI on these
51+
// fields — they treat the Settings form as a credential
52+
// capture target otherwise.
53+
data-1p-ignore="true"
54+
data-lpignore="true"
55+
className={cn(isPassword && "pr-9")}
56+
/>
57+
{isPassword ? (
58+
<Button
59+
type="button"
60+
variant="ghost"
61+
size="icon"
62+
aria-label={showSecret ? "Hide value" : "Show value"}
63+
className="absolute top-1/2 right-1 h-7 w-7 -translate-y-1/2"
64+
onClick={() => setShowSecret(!showSecret)}
65+
>
66+
{showSecret ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
67+
</Button>
68+
) : null}
69+
</div>
70+
</div>
71+
);
72+
}

packages/models-config/src/public/catalog.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,27 @@ export const modelsConfigCatalog = defineCatalog(schema, {
1818
props: z.object({ source: z.string() }),
1919
description: "Rendered markdown text (formatted lists, headings, etc.)",
2020
},
21+
/**
22+
* Replacement for shadcn `Input` for the Connections form. Two
23+
* differences from the stock primitive:
24+
* 1. Disables browser autofill (`autocomplete="off"` for text;
25+
* `autocomplete="new-password"` for password). Stock `Input`
26+
* lets the browser cross-populate same-named fields across
27+
* sibling form sections (the four tabs).
28+
* 2. For `type: "password"`, renders an eye / eye-off toggle
29+
* that flips the input between masked and plain.
30+
*/
31+
FieldInput: {
32+
props: z.object({
33+
label: z.string().nullable(),
34+
name: z.string(),
35+
type: z.enum(["text", "password"]).nullable(),
36+
placeholder: z.string().nullable(),
37+
value: z.string().nullable(),
38+
}),
39+
description:
40+
"Form input with autofill disabled and a show/hide toggle when type=password.",
41+
},
2142
},
2243
actions: {
2344
saveConnection: {

packages/models-config/src/public/connections-tab-spec.ts

Lines changed: 34 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -126,29 +126,32 @@ function tabBodyElements(type: ConnectionType): Record<string, unknown> {
126126
},
127127

128128
// ── Add new Connection form (per-tab) ────────────────────
129-
// Obsidian-style: section heading + muted description, separator,
130-
// form fields, separator before the optional Headers section,
131-
// error + primary button at the bottom. No Card chrome — padding
132-
// comes from the outer body Stack className.
129+
// Section heading + description sit on a single horizontal row
130+
// (Stack horizontal) so they read like a panel title rather than
131+
// two stacked text lines. The Headers section lives inside a
132+
// shadcn Collapsible, folded by default — it's the rare-use
133+
// sub-section, the user shouldn't have to scroll past it on
134+
// every connection.
133135
[`${type}_formCard`]: {
134136
type: "Stack",
135137
props: { direction: "vertical", gap: "md" },
136138
children: [
137-
`${type}_formHeading`,
138-
`${type}_formDescription`,
139+
`${type}_formHeadingRow`,
139140
`${type}_formSep1`,
140141
`${type}_formName`,
141142
`${type}_formApiKey`,
142143
`${type}_formUrl`,
143144
`${type}_formSep2`,
144-
`${type}_formHeadersHeading`,
145-
`${type}_formHeadersLabel`,
146-
`${type}_formHeadersList`,
147-
`${type}_formAddHeader`,
145+
`${type}_formHeadersCollapsible`,
148146
`${type}_formError`,
149147
`${type}_formConnect`,
150148
],
151149
},
150+
[`${type}_formHeadingRow`]: {
151+
type: "Stack",
152+
props: { direction: "horizontal", gap: "md", align: "baseline" },
153+
children: [`${type}_formHeading`, `${type}_formDescription`],
154+
},
152155
[`${type}_formHeading`]: {
153156
type: "Heading",
154157
props: { text: `Add ${TYPE_LABEL[type]} connection`, level: "h3" },
@@ -168,16 +171,27 @@ function tabBodyElements(type: ConnectionType): Record<string, unknown> {
168171
type: "Separator",
169172
props: { orientation: "horizontal" },
170173
},
171-
[`${type}_formHeadersHeading`]: {
172-
type: "Heading",
173-
props: { text: "Headers", level: "h4" },
174+
[`${type}_formHeadersCollapsible`]: {
175+
type: "Collapsible",
176+
props: { title: "Headers (optional)", defaultOpen: false },
177+
children: [`${type}_formHeadersInner`],
174178
},
175-
// Input `name` attributes are per-type-prefixed so the browser's
176-
// autofill heuristics don't share the value across the four
177-
// sub-tabs (otherwise the OpenAI API Key field would be
178-
// auto-filled with the value the user typed into Google).
179+
[`${type}_formHeadersInner`]: {
180+
type: "Stack",
181+
props: { direction: "vertical", gap: "sm" },
182+
children: [
183+
`${type}_formHeadersLabel`,
184+
`${type}_formHeadersList`,
185+
`${type}_formAddHeader`,
186+
],
187+
},
188+
// FieldInput (catalog-local primitive) replaces shadcn `Input`
189+
// here: it explicitly opts out of browser autofill (per-type
190+
// `name=` alone wasn't enough — Chrome/Safari still propagated
191+
// the same value across sibling tabs' password and text fields)
192+
// and adds a show/hide eye toggle on `type: "password"`.
179193
[`${type}_formName`]: {
180-
type: "Input",
194+
type: "FieldInput",
181195
props: {
182196
label: "Name",
183197
name: `${type}-name`,
@@ -187,7 +201,7 @@ function tabBodyElements(type: ConnectionType): Record<string, unknown> {
187201
},
188202
},
189203
[`${type}_formApiKey`]: {
190-
type: "Input",
204+
type: "FieldInput",
191205
props: {
192206
label: "API Key",
193207
name: `${type}-apiKey`,
@@ -197,7 +211,7 @@ function tabBodyElements(type: ConnectionType): Record<string, unknown> {
197211
},
198212
},
199213
[`${type}_formUrl`]: {
200-
type: "Input",
214+
type: "FieldInput",
201215
props: {
202216
label: urlLabel,
203217
name: `${type}-url`,

0 commit comments

Comments
 (0)