@mahiraltinkaya/me-ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +99 -0
  3. package/bin/me-ui.js +60 -0
  4. package/package.json +34 -0
  5. package/registry/assets/breadcrumb_arrow.svg +3 -0
  6. package/registry/components/stepper-provider.tsx +88 -0
  7. package/registry/components/stepper-view.tsx +40 -0
  8. package/registry/components/steps/form-input.tsx +68 -0
  9. package/registry/components/steps/form-select.tsx +42 -0
  10. package/registry/components/steps/step-field.tsx +43 -0
  11. package/registry/components/ui/button.tsx +69 -0
  12. package/registry/components/ui/field-hint.tsx +68 -0
  13. package/registry/components/ui/input.tsx +87 -0
  14. package/registry/components/ui/select.tsx +128 -0
  15. package/registry/components/ui/stepper/index.tsx +24 -0
  16. package/registry/components/ui/stepper/step-divider.tsx +20 -0
  17. package/registry/components/ui/stepper/step-indicator.tsx +33 -0
  18. package/registry/components/ui/stepper/step-label.tsx +29 -0
  19. package/registry/components/ui/stepper/stepper-progress.tsx +40 -0
  20. package/registry/components/ui/stepper/stepper-rail.tsx +107 -0
  21. package/registry/components/ui/stepper/types.ts +26 -0
  22. package/registry/components/ui/tooltip.tsx +54 -0
  23. package/registry/lib/normalize.ts +2 -0
  24. package/registry/lib/quote-schema.ts +45 -0
  25. package/registry/lib/tckn.ts +31 -0
  26. package/registry/lib/utils.ts +6 -0
  27. package/registry.json +252 -0
  28. package/src/args.js +47 -0
  29. package/src/commands/add.js +118 -0
  30. package/src/commands/list.js +20 -0
  31. package/src/css.js +80 -0
  32. package/src/deps.js +52 -0
  33. package/src/jsonc.js +44 -0
  34. package/src/log.js +32 -0
  35. package/src/manifest.js +58 -0
  36. package/src/paths.js +65 -0
  37. package/src/project.js +100 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mahir Altınkaya
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,99 @@
1
+ # me-ui
2
+
3
+ Copies the design system's components into your project. They land as your files —
4
+ edit them, restyle them, delete the parts you do not need. Nothing is imported from
5
+ `node_modules` at runtime, so there is no version to keep in step and no wrapper to
6
+ fight when a screen needs something slightly different.
7
+
8
+ ```bash
9
+ npx @mahiraltinkaya/me-ui add input
10
+ ```
11
+
12
+ ## Commands
13
+
14
+ ```bash
15
+ npx @mahiraltinkaya/me-ui add <item…> # copy items and everything they depend on
16
+ npx @mahiraltinkaya/me-ui list # show every item
17
+ ```
18
+
19
+ | Option | |
20
+ | ------------------ | ---------------------------------------------------- |
21
+ | `-c, --cwd <path>` | project to install into (default: current directory) |
22
+ | `-o, --overwrite` | replace files that already exist |
23
+ | `--dry-run` | show what would happen, write nothing |
24
+ | `--skip-install` | do not run the package manager |
25
+
26
+ Existing files are never replaced silently. A file you have edited is reported and
27
+ left alone until you pass `--overwrite`, so re-running `add` to pick up a dependency
28
+ cannot cost you local changes.
29
+
30
+ ## Items
31
+
32
+ | Item | What it is |
33
+ | ------------------ | ------------------------------------------------------------------ |
34
+ | `utils` | `cn` — clsx piped through tailwind-merge. |
35
+ | `brand-theme` | The `--brand` token the fields paint focus and error states with. |
36
+ | `field-hint` | Focus-driven explanatory panel anchored to a field. |
37
+ | `input` | Text field with optional leading icon and hint. |
38
+ | `select` | Dropdown over a string list, matching `input`'s affordances. |
39
+ | `button` | Button with `cva` variant and size sets. |
40
+ | `tooltip` | Tooltip provider, root, trigger and content. |
41
+ | `stepper` | Progress rail for multi-step flows. **Requires Next.js.** |
42
+ | `stepper-provider` | Step state, next/back guards and the `useStepper` hook. |
43
+ | `step-field` | Label, control and validation message. |
44
+ | `form-fields` | `FormInput` and `FormSelect`, bound to react-hook-form. |
45
+ | `normalize` | `digits` — strips everything that is not a digit. |
46
+ | `tckn` | T.C. kimlik no validation, check digits included. |
47
+ | `quote-schema` | Zod schemas for the quote flow, plus per-step fields and defaults. |
48
+
49
+ Dependencies resolve on their own — `me-ui add form-fields` also brings `input`,
50
+ `select`, `step-field`, `cn`, the npm packages and the `--brand` token.
51
+
52
+ ## Where files land
53
+
54
+ A `components.json` is honoured when the project has one, so files follow the
55
+ aliases the rest of its UI already uses. Otherwise the CLI reads `tsconfig.json`
56
+ paths, falling back to the `src/` convention:
57
+
58
+ | Registry path | Default destination |
59
+ | ----------------- | ------------------------ |
60
+ | `components/ui/…` | `src/components/ui/…` |
61
+ | `components/…` | `src/components/…` |
62
+ | `lib/…` | `src/lib/…` |
63
+ | `assets/…` | `public/assets/images/…` |
64
+
65
+ Imports inside the copied files are rewritten to match, whatever the aliases are.
66
+
67
+ ## What the project needs first
68
+
69
+ - **React 19** and **Tailwind CSS v4**.
70
+ - **A shadcn base theme.** The components style against `--border`, `--input`,
71
+ `--muted-foreground` and friends. `add` warns about any that are missing —
72
+ `npx shadcn@latest init` is the quickest way to get them.
73
+ - **Next.js**, but only for `stepper`, which renders its divider with `next/image`.
74
+
75
+ ## Publishing
76
+
77
+ Public package on npmjs.com — anyone can run `npx @mahiraltinkaya/me-ui` without an account or a
78
+ token.
79
+
80
+ ```bash
81
+ bun run ui:sync # from the repo root, after changing any component
82
+ cd packages/me-ui
83
+ npm version patch # npm refuses to overwrite a published version
84
+ npm publish
85
+ ```
86
+
87
+ `prepublishOnly` re-checks that `registry/` still matches the app it was copied
88
+ from, so a stale component cannot ship.
89
+
90
+ Copied files do not update themselves — that is the point of copying rather than
91
+ importing. To take a newer version of a component:
92
+
93
+ ```bash
94
+ npx @mahiraltinkaya/me-ui@latest add input --overwrite
95
+ ```
96
+
97
+ `@latest` matters because npx will otherwise happily reuse a cached older build,
98
+ and `--overwrite` matters because `add` protects existing files by default. Run it
99
+ with `--dry-run` first if the file has local edits worth keeping.
package/bin/me-ui.js ADDED
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ import { parseArgs } from "../src/args.js";
8
+ import { add } from "../src/commands/add.js";
9
+ import { list } from "../src/commands/list.js";
10
+ import { bold, cyan, dim, fail, line } from "../src/log.js";
11
+
12
+ const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
13
+ const { version } = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
14
+
15
+ const HELP = `
16
+ ${bold("me-ui")} ${dim(`v${version}`)}
17
+
18
+ Copies components into your project. They become your files — edit them freely.
19
+
20
+ ${bold("Usage")}
21
+ ${cyan("npx @mahiraltinkaya/me-ui add")} <item…> copy items and their dependencies
22
+ ${cyan("npx @mahiraltinkaya/me-ui list")} show every item
23
+
24
+ ${bold("Options")}
25
+ -c, --cwd <path> project to install into ${dim("(default: current directory)")}
26
+ -o, --overwrite replace files that already exist
27
+ --dry-run show what would happen, write nothing
28
+ --skip-install do not run the package manager
29
+ -h, --help this text
30
+ -v, --version print the version
31
+
32
+ ${bold("Examples")}
33
+ ${dim("$")} npx @mahiraltinkaya/me-ui add input
34
+ ${dim("$")} npx @mahiraltinkaya/me-ui add form-fields quote-schema
35
+ ${dim("$")} npx @mahiraltinkaya/me-ui add stepper --dry-run
36
+ `;
37
+
38
+ function main() {
39
+ const options = parseArgs(process.argv.slice(2));
40
+
41
+ if (options.version) return line(version);
42
+ if (options.help || !options.command) return line(HELP);
43
+
44
+ switch (options.command) {
45
+ case "add":
46
+ return add(options);
47
+ case "list":
48
+ case "ls":
49
+ return list();
50
+ default:
51
+ throw new Error(`Unknown command "${options.command}". Try \`me-ui --help\`.`);
52
+ }
53
+ }
54
+
55
+ try {
56
+ main();
57
+ } catch (error) {
58
+ fail(error.message);
59
+ process.exit(1);
60
+ }
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@mahiraltinkaya/me-ui",
3
+ "version": "0.1.0",
4
+ "description": "Copy React + Tailwind form components into any project — they become your files, not a dependency.",
5
+ "type": "module",
6
+ "bin": {
7
+ "me-ui": "bin/me-ui.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "registry",
13
+ "registry.json"
14
+ ],
15
+ "engines": {
16
+ "node": ">=20.11"
17
+ },
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "scripts": {
22
+ "prepublishOnly": "node ../../scripts/sync-registry.mjs --check"
23
+ },
24
+ "keywords": [
25
+ "ui",
26
+ "components",
27
+ "react",
28
+ "tailwind",
29
+ "base-ui",
30
+ "shadcn",
31
+ "cli"
32
+ ],
33
+ "license": "MIT"
34
+ }
@@ -0,0 +1,3 @@
1
+ <svg width="25" height="70" viewBox="0 0 25 70" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <path id="Vector 269" d="M1 1L23.6667 35L1 69" stroke="#CBD6DE"/>
3
+ </svg>
@@ -0,0 +1,88 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+
5
+ import type { StepItem } from "@/components/ui/stepper";
6
+
7
+ export interface StepperContextValue {
8
+ steps: StepItem[];
9
+ currentStep: number;
10
+ totalSteps: number;
11
+ isFirstStep: boolean;
12
+ isLastStep: boolean;
13
+ goNext: () => Promise<boolean>;
14
+ goBack: () => void;
15
+ goToStep: (index: number) => void;
16
+ }
17
+
18
+ const StepperContext = React.createContext<StepperContextValue | null>(null);
19
+
20
+ export function useStepper() {
21
+ const context = React.useContext(StepperContext);
22
+ if (!context) {
23
+ throw new Error("useStepper must be used within a <StepperProvider>");
24
+ }
25
+ return context;
26
+ }
27
+
28
+ export interface StepperProviderProps {
29
+ steps: StepItem[];
30
+ initialStep?: number;
31
+ onStepChange?: (index: number) => void;
32
+ beforeNext?: (index: number) => boolean | Promise<boolean>;
33
+ children: React.ReactNode;
34
+ }
35
+
36
+ /**
37
+ * Step state and nothing else — no markup of its own.
38
+ *
39
+ * The rail and the active step are rendered by `<StepperView>`, which reads
40
+ * this context. Keeping them apart means a consumer can drive step state from
41
+ * a layout this component knows nothing about.
42
+ */
43
+ function StepperProvider({
44
+ steps,
45
+ initialStep = 0,
46
+ onStepChange,
47
+ beforeNext,
48
+ children,
49
+ }: StepperProviderProps) {
50
+ const [currentStep, setCurrentStep] = React.useState(initialStep);
51
+ const totalSteps = steps.length;
52
+
53
+ const goToStep = React.useCallback(
54
+ (index: number) => {
55
+ const clamped = Math.min(Math.max(index, 0), totalSteps - 1);
56
+ setCurrentStep(clamped);
57
+ onStepChange?.(clamped);
58
+ },
59
+ [totalSteps, onStepChange],
60
+ );
61
+
62
+ const goNext = React.useCallback(async () => {
63
+ const allowed = (await beforeNext?.(currentStep)) ?? true;
64
+ if (!allowed) return false;
65
+ goToStep(currentStep + 1);
66
+ return true;
67
+ }, [beforeNext, currentStep, goToStep]);
68
+
69
+ const goBack = React.useCallback(() => goToStep(currentStep - 1), [currentStep, goToStep]);
70
+
71
+ const value = React.useMemo<StepperContextValue>(
72
+ () => ({
73
+ steps,
74
+ currentStep,
75
+ totalSteps,
76
+ isFirstStep: currentStep === 0,
77
+ isLastStep: currentStep === totalSteps - 1,
78
+ goNext,
79
+ goBack,
80
+ goToStep,
81
+ }),
82
+ [steps, currentStep, totalSteps, goNext, goBack, goToStep],
83
+ );
84
+
85
+ return <StepperContext.Provider value={value}>{children}</StepperContext.Provider>;
86
+ }
87
+
88
+ export { StepperProvider };
@@ -0,0 +1,40 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+
5
+ import { useStepper } from "@/components/stepper-provider";
6
+ import { Stepper } from "@/components/ui/stepper";
7
+ import { cn } from "@/lib/utils";
8
+
9
+ export interface StepperViewProps {
10
+ className?: string;
11
+ /** One child per step, in the same order as the provider's `steps`. */
12
+ children: React.ReactNode;
13
+ }
14
+
15
+ /**
16
+ * Renders the progress rail and the child sitting at the active index.
17
+ *
18
+ * The live region is what tells a screen reader the step changed: the rail is
19
+ * `aria-hidden` decoration and swapping the panel underneath it is otherwise a
20
+ * silent DOM update.
21
+ */
22
+ function StepperView({ className, children }: StepperViewProps) {
23
+ const { steps, currentStep, totalSteps, goToStep } = useStepper();
24
+
25
+ // Children only change when the caller re-renders with a different set, so
26
+ // the array is not rebuilt on every step change.
27
+ const stepChildren = React.useMemo(() => React.Children.toArray(children), [children]);
28
+
29
+ return (
30
+ <div className={cn("flex w-full flex-col gap-10", className)}>
31
+ <Stepper steps={steps} currentStep={currentStep} onStepChange={goToStep} />
32
+ <p aria-live="polite" className="sr-only">
33
+ {`Adım ${currentStep + 1} / ${totalSteps}: ${steps[currentStep]?.label ?? ""}`}
34
+ </p>
35
+ {stepChildren[currentStep]}
36
+ </div>
37
+ );
38
+ }
39
+
40
+ export { StepperView };
@@ -0,0 +1,68 @@
1
+ "use client";
2
+
3
+ import { useCallback, type ChangeEvent } from "react";
4
+ import { useController, type FieldValues, type Path } from "react-hook-form";
5
+
6
+ import { Input, type InputProps } from "@/components/ui/input";
7
+ import { StepField, errorIdFor } from "@/components/steps/step-field";
8
+
9
+ export interface FormInputProps<T extends FieldValues> extends Omit<
10
+ InputProps,
11
+ "name" | "defaultValue" | "value" | "onChange" | "onBlur"
12
+ > {
13
+ name: Path<T>;
14
+ label: string;
15
+ /**
16
+ * Applied to every keystroke and paste before the value reaches the form, so
17
+ * what is stored is already canonical. When set, `maxLength` counts
18
+ * normalized characters rather than typed ones.
19
+ */
20
+ normalize?: (value: string) => string;
21
+ }
22
+
23
+ /**
24
+ * Text field bound to the surrounding react-hook-form context.
25
+ *
26
+ * `useController` subscribes to this field alone, so a validation error on one
27
+ * field re-renders only that field — never its siblings or the step card.
28
+ */
29
+ function FormInput<T extends FieldValues>({
30
+ name,
31
+ label,
32
+ normalize,
33
+ maxLength,
34
+ ...props
35
+ }: FormInputProps<T>) {
36
+ const { field, fieldState } = useController<T>({ name });
37
+ const error = fieldState.error?.message;
38
+ const { onChange } = field;
39
+
40
+ const handleChange = useCallback(
41
+ (event: ChangeEvent<HTMLInputElement>) => {
42
+ if (!normalize) return onChange(event);
43
+ const next = normalize(event.target.value);
44
+ onChange(maxLength === undefined ? next : next.slice(0, maxLength));
45
+ },
46
+ [normalize, maxLength, onChange],
47
+ );
48
+
49
+ return (
50
+ <StepField label={label} htmlFor={name} error={error}>
51
+ <Input
52
+ id={name}
53
+ {...props}
54
+ {...field}
55
+ value={field.value ?? ""}
56
+ onChange={handleChange}
57
+ // Enforced after normalizing rather than by the DOM. The browser clips
58
+ // the raw text on paste, so `0532 123 45 67` would lose its last digits
59
+ // before the spaces were ever removed.
60
+ maxLength={normalize ? undefined : maxLength}
61
+ aria-invalid={Boolean(error)}
62
+ aria-describedby={error ? errorIdFor(name) : undefined}
63
+ />
64
+ </StepField>
65
+ );
66
+ }
67
+
68
+ export { FormInput };
@@ -0,0 +1,42 @@
1
+ "use client";
2
+
3
+ import { useController, type FieldValues, type Path } from "react-hook-form";
4
+
5
+ import { Select, type SelectProps } from "@/components/ui/select";
6
+ import { StepField, errorIdFor } from "@/components/steps/step-field";
7
+
8
+ export interface FormSelectProps<T extends FieldValues> extends Omit<
9
+ SelectProps,
10
+ "value" | "onValueChange" | "onClose" | "id" | "name" | "invalid" | "describedBy"
11
+ > {
12
+ name: Path<T>;
13
+ label: string;
14
+ }
15
+
16
+ /**
17
+ * Dropdown bound to the surrounding react-hook-form context.
18
+ *
19
+ * Closing the popup counts as the blur, which is what makes `onTouched`
20
+ * validation behave the same here as it does for text fields.
21
+ */
22
+ function FormSelect<T extends FieldValues>({ name, label, ...props }: FormSelectProps<T>) {
23
+ const { field, fieldState } = useController<T>({ name });
24
+ const error = fieldState.error?.message;
25
+
26
+ return (
27
+ <StepField label={label} htmlFor={name} error={error}>
28
+ <Select
29
+ {...props}
30
+ id={name}
31
+ name={field.name}
32
+ value={field.value ?? null}
33
+ invalid={Boolean(error)}
34
+ describedBy={error ? errorIdFor(name) : undefined}
35
+ onValueChange={field.onChange}
36
+ onClose={field.onBlur}
37
+ />
38
+ </StepField>
39
+ );
40
+ }
41
+
42
+ export { FormSelect };
@@ -0,0 +1,43 @@
1
+ "use client";
2
+
3
+ import type { ReactNode } from "react";
4
+ import { Info } from "lucide-react";
5
+
6
+ export interface StepFieldProps {
7
+ label: string;
8
+ children: ReactNode;
9
+ /** Associates the label with the control it wraps, and names its error node. */
10
+ htmlFor?: string;
11
+ /** Validation message rendered under the control. */
12
+ error?: string;
13
+ }
14
+
15
+ /** Id the control should point `aria-describedby` at when this field is invalid. */
16
+ export const errorIdFor = (name: string) => `${name}-error`;
17
+
18
+ /** Label + control + validation message. Layout only — it holds no form state. */
19
+ function StepField({ label, children, htmlFor, error }: StepFieldProps) {
20
+ return (
21
+ <div className="flex flex-col gap-1.5">
22
+ <label
23
+ htmlFor={htmlFor}
24
+ className="text-muted-foreground text-xs font-medium tracking-wide uppercase"
25
+ >
26
+ {label}
27
+ </label>
28
+ {children}
29
+ {error ? (
30
+ <p
31
+ id={htmlFor ? errorIdFor(htmlFor) : undefined}
32
+ role="alert"
33
+ className="text-brand flex items-center gap-1.5 text-xs font-medium"
34
+ >
35
+ <Info className="size-3.5 shrink-0" />
36
+ {error}
37
+ </p>
38
+ ) : null}
39
+ </div>
40
+ );
41
+ }
42
+
43
+ export { StepField };
@@ -0,0 +1,69 @@
1
+ import { Button as ButtonPrimitive } from "@base-ui/react/button";
2
+ import { cva, type VariantProps } from "class-variance-authority";
3
+
4
+ import { cn } from "@/lib/utils";
5
+
6
+ const buttonVariants = cva(
7
+ "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
8
+ {
9
+ variants: {
10
+ variant: {
11
+ default: "bg-primary text-primary-foreground hover:bg-primary/80",
12
+ brand: "bg-brand text-white hover:bg-[color-mix(in_oklab,var(--brand)_88%,white)]",
13
+ ink: "bg-cta text-white hover:bg-cta-hover",
14
+ outline:
15
+ "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground",
16
+ secondary:
17
+ "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
18
+ ghost:
19
+ "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground",
20
+ destructive:
21
+ "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20",
22
+ link: "text-primary underline-offset-4 hover:underline",
23
+ },
24
+ size: {
25
+ default:
26
+ "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
27
+ xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
28
+ sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
29
+ lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
30
+ // The form and purchase controls the quote flow uses — a full-height
31
+ // touch target, unlike the compact toolbar sizes above.
32
+ xl: "h-12 gap-2 px-6",
33
+ icon: "size-8",
34
+ "icon-xs":
35
+ "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
36
+ "icon-sm":
37
+ "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
38
+ "icon-lg": "size-9",
39
+ },
40
+ shape: {
41
+ default: "",
42
+ pill: "rounded-full",
43
+ },
44
+ },
45
+ defaultVariants: {
46
+ variant: "default",
47
+ size: "default",
48
+ shape: "default",
49
+ },
50
+ },
51
+ );
52
+
53
+ function Button({
54
+ className,
55
+ variant = "default",
56
+ size = "default",
57
+ shape = "default",
58
+ ...props
59
+ }: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
60
+ return (
61
+ <ButtonPrimitive
62
+ data-slot="button"
63
+ className={cn(buttonVariants({ variant, size, shape, className }))}
64
+ {...props}
65
+ />
66
+ );
67
+ }
68
+
69
+ export { Button, buttonVariants };
@@ -0,0 +1,68 @@
1
+ "use client";
2
+
3
+ import { Tooltip } from "@base-ui/react/tooltip";
4
+ import { Info } from "lucide-react";
5
+
6
+ import { cn } from "@/lib/utils";
7
+
8
+ /**
9
+ * Solid triangle on the popup edge nearest the field. Base UI places the element
10
+ * along the correct edge; the rotations below point it outward per side.
11
+ */
12
+ const ARROW_CLASS =
13
+ "relative block h-1.5 w-3 overflow-clip data-[side=bottom]:top-[-6px] data-[side=left]:right-[-9px] data-[side=left]:rotate-90 data-[side=right]:left-[-9px] data-[side=right]:-rotate-90 data-[side=top]:bottom-[-6px] data-[side=top]:rotate-180 before:absolute before:bottom-0 before:left-1/2 before:h-[calc(6px*sqrt(2))] before:w-[calc(6px*sqrt(2))] before:bg-ink before:content-[''] before:[transform:translate(-50%,50%)_rotate(45deg)]";
14
+
15
+ export interface FieldHintContent {
16
+ title: string;
17
+ description: string;
18
+ }
19
+
20
+ export interface FieldHintProps {
21
+ hint: FieldHintContent;
22
+ /** Driven by the field's focus state — the field is the only trigger. */
23
+ open: boolean;
24
+ /** Ties the popup to the field through `aria-describedby`. */
25
+ id: string;
26
+ className?: string;
27
+ }
28
+
29
+ /**
30
+ * Explanatory panel anchored to a field's trailing info mark.
31
+ *
32
+ * Built on Tooltip rather than Popover on purpose: a popover moves focus into
33
+ * itself when it opens, which would pull focus straight back out of the field
34
+ * that opened it. A tooltip never takes focus, and `role="tooltip"` is what this
35
+ * content actually is.
36
+ *
37
+ * The mark is a plain span with pointer events off, so hovering it does nothing;
38
+ * `open` is the single source of truth.
39
+ */
40
+ function FieldHint({ hint, open, id, className }: FieldHintProps) {
41
+ return (
42
+ <Tooltip.Root open={open}>
43
+ <Tooltip.Trigger
44
+ render={<span aria-hidden />}
45
+ className={cn(
46
+ "group text-muted-foreground pointer-events-none absolute top-1/2 -translate-y-1/2",
47
+ className,
48
+ )}
49
+ >
50
+ <Info className="group-data-[popup-open]:fill-brand size-4 transition-colors group-data-[popup-open]:text-white" />
51
+ </Tooltip.Trigger>
52
+ <Tooltip.Portal>
53
+ <Tooltip.Positioner side="right" sideOffset={28} collisionPadding={16} className="z-50">
54
+ <Tooltip.Popup
55
+ id={id}
56
+ className="bg-ink w-72 max-w-[calc(100vw-2rem)] origin-[var(--transform-origin)] rounded-xl px-5 py-4 text-left shadow-xl transition-[opacity,scale] duration-150 data-ending-style:scale-98 data-ending-style:opacity-0 data-starting-style:scale-98 data-starting-style:opacity-0"
57
+ >
58
+ <Tooltip.Arrow className={ARROW_CLASS} />
59
+ <p className="text-sm font-semibold text-white">{hint.title}</p>
60
+ <p className="mt-1.5 text-xs leading-relaxed text-white/70">{hint.description}</p>
61
+ </Tooltip.Popup>
62
+ </Tooltip.Positioner>
63
+ </Tooltip.Portal>
64
+ </Tooltip.Root>
65
+ );
66
+ }
67
+
68
+ export { FieldHint };