PixelDosa UI

Smart Field

An AI-proposed value for a single field — confidence, source, explicit accept/reject, and one-step undo before it ever becomes real data.

Click the sparkle icon to request a suggestion, review its confidence and source, then accept or reject. Undo is available briefly after accepting.

Installation

bash
npx shadcn@latest add @pixeldosa/smart-field

value/onValueChange are required — this is a controlled component only, matching ghost-input's contract. fetchProposal must resolve to the FULL proposed value (not a delta) plus optional confidence/provenance; return null/undefined for 'no proposal available' rather than throwing. triggerIcon is required with no bundled default — pass any size-4 SVG/icon element. autoPropose only fires once, on mount, and only when value is empty, so it can never silently overwrite existing data. Drops into <FieldControl> directly since id/aria-invalid/aria-describedby/ref all forward to the underlying <input>.

Usage

"use client";

import * as React from "react";
import { SmartField, type SmartFieldProposal } from "@/components/ui/smart-field";

async function fetchProposal(
  currentValue: string,
  signal: AbortSignal
): Promise<SmartFieldProposal | null> {
  const response = await fetch(`/api/enrich?value=${currentValue}`, { signal });
  if (!response.ok) return null;
  return response.json(); // { value, confidence, provenance }
}

export function CompanyField() {
  const [value, setValue] = React.useState("");

  return (
    <SmartField
      id="company"
      value={value}
      onValueChange={setValue}
      fetchProposal={fetchProposal}
      triggerIcon={<SparkleIcon />}
      triggerLabel="Suggest company name"
    />
  );
}

The field's value never changes until the proposal is explicitly accepted. Typing manually at any point — while a fetch is in flight, or once a proposal is shown but not yet accepted — cancels the request or dismisses the proposal, since manual input always wins over an AI suggestion.

Confidence and provenance

return {
  value: "Acme Robotics, Inc.",
  confidence: "medium",
  provenance: "acme-robotics.com",
};

confidence is a three-tier enum (low / medium / high), not a raw percentage — a fabricated number would claim precision the model doesn't actually have. provenance is free text rendered as via {provenance}; omit it if you have nothing honest to show.

Auto-propose on an empty field

<SmartField autoPropose value={value} onValueChange={setValue} fetchProposal={fetchProposal} triggerIcon={<SparkleIcon />} />

Fires once, on mount, and only when value is empty — it can never silently reconsider a value that's already there. Off by default; most fields should wait for an explicit click.

Features

  • Nothing commits silently. The field's value never changes until the proposal is explicitly accepted, and a one-step Undo stays available afterwards (DESIGN.md §3.7).
  • Manual input always wins. Typing at any point — mid-fetch, or with a proposal on screen — cancels the request or dismisses the proposal, so the AI can never overwrite what someone is actively doing.
  • Confidence as a three-tier enum, not a percentage, shown as text with a supplementary bar rather than a colour-coded badge.
  • Provenance next to the proposal, so a value arrives with its evidence instead of asking to be trusted on its own.
  • Auto-propose is opt-in and fires once, only on mount and only into an empty field — it can never silently reconsider a value that's already there.
  • Drops into <FieldControl> unchanged, forwarding id, aria-invalid and aria-describedby to the real <input>.

Examples

Auto-proposing into an empty field, for a form where the enrichment is the whole point and waiting for a click is pure friction:

<SmartField
  autoPropose
  value={value}
  onValueChange={setValue}
  fetchProposal={fetchProposal}
  triggerIcon={<SparkleIcon />}
/>

Reacting to an acceptance — the honest place to measure whether proposals are actually good, rather than counting how often they were merely shown:

<SmartField
  value={value}
  onValueChange={setValue}
  fetchProposal={fetchProposal}
  triggerIcon={<SparkleIcon />}
  onAcceptProposal={(value, proposal) =>
    analytics.track("proposal_accepted", { confidence: proposal.confidence })
  }
/>

Returning null is a first-class answer — say nothing rather than propose noise:

async function fetchProposal(currentValue, signal) {
  const match = await lookup(currentValue, signal);
  if (!match || match.score < 0.6) return null;
  return { value: match.name, confidence: match.tier, provenance: match.domain };
}

Composed inside a Field, where label, description and error wiring come for free:

<Field invalid={Boolean(error)}>
  <FieldLabel>Company</FieldLabel>
  <FieldControl>
    <SmartField
      value={value}
      onValueChange={setValue}
      fetchProposal={fetchProposal}
      triggerIcon={<SparkleIcon />}
    />
  </FieldControl>
  <FieldError>{error}</FieldError>
</Field>

Props

PropTypeDefaultDescription
valuestring—Required. Controlled field value.
onValueChange(value: string) => void—Required.
fetchProposal(currentValue: string, signal: AbortSignal) => Promise<SmartFieldProposal | null | undefined>—Required. Return null/undefined for "no proposal."
onAcceptProposal(value: string, proposal: SmartFieldProposal) => void—Fires after the user accepts.
triggerIconReact.ReactNode—Required. No bundled icon set — pass a size-4 icon.
triggerLabelstring"Suggest a value with AI"Accessible label for the trigger button.
autoProposebooleanfalseFetch once on mount if value is empty.
undoWindowMsnumber6000How long the Undo affordance stays visible after accepting.

SmartFieldProposal is { value: string; confidence?: "low" | "medium" | "high"; provenance?: string }.

Accessibility

  • Every state change (proposal arrived, accepted, undone) is announced via aria-live="polite".
  • Confidence is communicated as text, never colour alone — the tiered bar indicator next to the label is supplementary, not the only signal.
  • Trigger, Accept, Reject and Undo are all real <button> elements, keyboard-reachable by default, with a minimum 44×44px hit target.
  • Drops into <FieldControl> directly — id, aria-invalid and aria-describedby all forward to the underlying <input>.
  • Under prefers-reduced-motion, the proposal panel's expand/collapse and every colour transition lose their animation but not the underlying state change.

When not to use

Inline predictive continuation while typing is Ghost Input, not this — Smart Field proposes a complete replacement value reviewed before commit, not a tail appended as you type. Multi-field or long-text diffing belongs to Diff Accept; a consequential or risky action needing full review belongs to AI Approval Gate.

why it's built this way

Engineering Notes

Mirrors ghost-input's contract shape (value/onValueChange controlled-only, a fetchX behaviour injected by the consumer, an AbortSignal for cancellation) deliberately, so the AI tier reads as one consistent family rather than each component inventing its own pattern — the two differ only in what they return: a plain continuation string for ghost-input versus a structured { value, confidence, provenance } proposal here, because a complete replacement value needs that context attached and a continuation tail does not. Confidence rendering composes the shared `confidence-meter` primitive (extracted after Selection Actions needed the identical tiered indicator) rather than hand-rolling its own bars a second time — a three-tier enum (low/medium/high), not a raw number, because a fabricated percentage would claim precision the underlying model doesn't actually have, and because the system has no colour scale to spend on a red/yellow/green treatment anyway. autoPropose is opt-in and gated to empty fields only, adopted directly from CRM-enrichment research (Clay/HubSpot/Attio): confidence gates whether a proposal is allowed to overwrite existing data, so the safest default auto-behaviour is 'only ever fill a blank,' never 'silently reconsider what's already there.' Manual typing at any point cancels an in-flight fetch or dismisses a shown-but-unaccepted proposal, reusing ghost-input's staleness philosophy: a suggestion answering a question the field no longer asks is worse than no suggestion. The proposal panel animates open/closed via a CSS grid-template-rows trick (0fr to 1fr) rather than measuring and animating an explicit pixel height, which is the standard token-free way to animate a height that depends on variable content without a ResizeObserver.

how it moves, and when it doesn't

Motion Notes

The proposal panel's height expansion and the field's colour tint both use --pd-duration-fast/--pd-ease-standard; the trigger's busy spinner and accept/reject colour changes use --pd-duration-instant, matching Button's press-feedback timing, because those are immediate state confirmations rather than content arriving. The proposed value itself never animates in character-by-character — the full value already exists the moment it's returned, so a typewriter reveal would misrepresent it as being generated live, the same reasoning ghost-input's plan already established. Confidence Meter (composed, not reimplemented here) has no motion of its own — under prefers-reduced-motion, the panel's grid-template-rows transition and the colour transitions are dropped via motion-reduce:transition-none, leaving the underlying state (panel present/absent, tint present/absent) fully legible without animation.