# Password Strength (Lumen Halo): prompt.md (v1.0.0)

- id: `lumen-password-strength` · version 1.0.0 · component · free
- category: Inputs
- build: Base UI (one set of files; its dependencies follow the build)
- install (this build): `npx shadcn@latest add https://beautiful-ui.dev/r/lumen-password-strength.json`
- npm dependencies: none
- registry dependencies: utils, https://beautiful-ui.dev/r/lumen-foundation.json
- docs: https://beautiful-ui.dev/components/lumen-password-strength
- The install command carries everything this item needs (files, CSS, tokens, npm and registry dependencies). Prefer it to copying source by hand.

Four cells and the reason in words, for any password input you already have: it rates the value you pass, says what would make it stronger, and announces changes politely.

## Build it
- Stack: React 19 (`ref` is a plain prop), TypeScript, Tailwind CSS v4 utilities, a shadcn-initialised project with the `@/*` alias.
- Packages: none beyond React.
- Files: `components/ui/lumen/controls/password-strength.tsx`; shared code: `lib/beautiful-ui/core/fields.ts`.
- Registry dependencies, installed with it automatically: shadcn `utils` (cn), `lumen-foundation`.
- Builds: one set of files for both, but its dependencies come in Base UI and Radix builds. Install the one that matches the project (see Install): a free item's bare URL installs the Base UI build of it and its dependencies.
- Exports to keep: `PasswordStrength`, and every exported type.
- CSS: the install merges this item's rules (the registry `css` field) into your global stylesheet, in `@layer components`, and adds the lumen foundation (tokens, keyframes, motion levels) once. Nothing to import by hand.
- Re-running `add` (or `--overwrite`) re-applies those rules: put overrides in your own CSS, never in the installed rules.
- Tokens: retheme with the `--lumen-*` custom properties (`--lumen-accent`, `--lumen-accent-foreground`, `--lumen-accent-text`, `--lumen-bad`, `--lumen-bad-text`, `--lumen-chart-accent`, `--lumen-chart-accent-foreground`, `--lumen-chart-accent-text`, `--lumen-focus`, `--lumen-good`, `--lumen-good-text`, `--lumen-hairline`, `--lumen-ink`, `--lumen-muted-ink`, `--lumen-series-1`, `--lumen-series-2`, `--lumen-series-3`, `--lumen-series-4`, `--lumen-series-5`, `--lumen-series-6`, `--lumen-warn`, `--lumen-warn-text`); this item's CSS also reads `--lumen-font-mono`, `--lumen-font-sans`, `--lumen-radius-k`. Never add Tailwind colour classes inside the component.

```tsx
import { Input } from "@/components/ui/lumen/input";
```

## Parts

| Part | data-slot | What it is for |
|---|---|---|
| `PasswordStrength` | — | The meter and its words. |

## Sound
- Keep every `data-slot` and `data-sound` attribute: the sound layer reads them.
- Installing this item adds no audio. Nothing plays until the app mounts `GlassSoundProvider` once (install: `npx shadcn@latest add https://beautiful-ui.dev/r/glass-sound.json`, import from `@/components/beautiful-ui/glass-sound`); `GlassSoundToggle` is its mute control. Without a provider the audio engine never loads.

## Match the original
- Read `components/ui/lumen/controls/password-strength.tsx` as the reference implementation before changing or recreating anything, and match it: sizes, colours per theme, motion timings, copy and behaviour.
- If you deviate (a prop you can't honour, a style you changed, a dependency you swapped), say so in your reply, part by part.
- Keep the accessibility contract, the keyboard map and the motion levels listed below.

## Use it when
- password strength, password meter, strength indicator, sign up form, new password, Lumen
- Sign-up and change-password forms, under the input you already use

### Not when
- Sign-in forms: rating an existing password helps no one

## Mistakes
- Rate on the client for guidance only; enforce your rules on the server

## Usage

```tsx
"use client";

import * as React from "react";
import { Input } from "@/components/ui/lumen/input";
import { PasswordStrength } from "@/components/ui/lumen/controls/password-strength";

export function NewPasswordField() {
  const [pw, setPw] = React.useState("");
  return (
    <div className="grid gap-2">
      <label htmlFor="pw">New password</label>
      <Input id="pw" type="password" autoComplete="new-password" value={pw} onChange={(e) => setPw(e.target.value)} aria-describedby="pw-strength" />
      <PasswordStrength id="pw-strength" value={pw} />
    </div>
  );
}
```

## Props

| Prop | Type | Default | What it does |
|---|---|---|---|
| `value` | `string` |  | The password to rate. |
| `emptyHint` | `string` | `"Use 8 or more characters, …"` | Shown before anything is typed. |
| `id` | `string` |  | Point the input's aria-describedby at it so the verdict is read with the field. |

Full docs: https://beautiful-ui.dev/components/lumen-password-strength

## Customising
- Colours: set the `--lumen-*` tokens on `:root`, or on a container with the `lumen-scope` class to retheme one area. Add the `lumen-inherit` class to follow your shadcn palette instead (`--chart-N`, `--destructive`).
- Dark mode follows the `.dark` class on an ancestor (the shadcn and next-themes convention).
- Update later by re-running the install with `--overwrite` (review the diff if you edited it). Changelog: https://beautiful-ui.dev/r/changelog.json

## Keyboard

| Keys | Action |
|---|---|
| Tab | Not focusable: it describes the input you link it to |

## Motion inventory

| Interaction | What moves |
|---|---|
| Typing | Cells light as the score rises; colour moves from coral to amber to green |

## Accessibility contract (preserve when editing)
- The verdict is in words, not only colour
- aria-live=polite: changes are announced without interrupting typing

## Install

Base UI project (a base-* style in components.json):

```bash
npx shadcn@latest add https://beautiful-ui.dev/r/lumen-password-strength.json
```

Radix project (a radix-*, new-york or default style):

```bash
npx shadcn@latest add https://beautiful-ui.dev/r/radix-nova/lumen-password-strength.json
```

Or add the `@beautiful-ui` registry to components.json and run `npx shadcn@latest add @beautiful-ui/lumen-password-strength`: the CLI picks the build from your style.

## Credits
- Built on shadcn/ui (https://ui.shadcn.com)

## Source (Base UI build)

### components/ui/lumen/controls/password-strength.tsx

```tsx
"use client";
// Generated from src/registry/core/controls/password-strength.tsx by scripts/gen-systems.ts. Edit the core file, not this one.

import * as React from "react";
import { cn } from "@/lib/utils";
import { passwordStrength } from "@/lib/beautiful-ui/core/fields";

/*
 * Password Strength: four cells and the reason in words, for any password input you already have
 * (yours, a form library's, Password Field's). It reads the value you pass; link it to the input with
 * aria-describedby={id} and changes are announced politely.
 *
 *   <Input type="password" value={pw} onChange={…} aria-describedby="pw-strength" />
 *   <PasswordStrength id="pw-strength" value={pw} />
 */

function PasswordStrength({
  value,
  emptyHint = "Use 8 or more characters, with a mix of letters, numbers and symbols.",
  className,
  ...props
}: Omit<React.ComponentProps<"div">, "children"> & {
  /** The password to rate. */
  value: string;
  /** Shown before anything is typed. */
  emptyHint?: string;
}) {
  const strength = passwordStrength(value);
  return (
    <div data-slot="password-strength" className={cn(`lumen-password-strength`, className)} data-score={strength.score} aria-live="polite" {...props}>
      <span aria-hidden className={`lumen-password-strength-cells`}>
        {[1, 2, 3, 4].map((i) => (
          <i key={i} data-on={strength.score >= i || undefined} />
        ))}
      </span>
      <span className={`lumen-password-strength-words`}>{value ? `${strength.words}${strength.hints[0] ? `. ${strength.hints[0]}.` : "."}` : emptyHint}</span>
    </div>
  );
}

export { PasswordStrength };
```

### lib/beautiful-ui/core/fields.ts

```tsx
/*
 * The maths behind the form fields: plain functions, no React, pinned by tests. Number stepping and
 * clamping (with floating-point cleanup), password strength in words, tag parsing, time parsing
 * and formatting, and what each field sends with a native form.
 */

/**
 * The form-facing props every field takes and hands to its input. `name` goes on a hidden input
 * carrying the serialised value (numberFormValue, timeFormValue; Tag Input sends one hidden input per
 * tag, read with formData.getAll(name)), so a plain <form> gets the value, not the text on screen.
 */
export type FieldFormProps = {
  name?: string | undefined;
  required?: boolean | undefined;
  form?: string | undefined;
  autoFocus?: boolean | undefined;
  "aria-describedby"?: string | undefined;
  "aria-labelledby"?: string | undefined;
  "aria-invalid"?: boolean | "true" | "false" | undefined;
};

/** Decimal places in a step (0.25 → 2, 1e-7 → 7), so sums round back to what the step can reach. */
function decimalsOf(step: number): number {
  const [mantissa = "", exp = "0"] = String(step).split("e");
  return Math.max(0, (mantissa.split(".")[1] ?? "").length - Number(exp));
}

export type NumberBounds = { min?: number | undefined; max?: number | undefined; step?: number | undefined; snap?: boolean | undefined };

/**
 * Step a number and keep it in range. The result is rounded to the step's precision (0.1 + 0.2 reads
 * 0.3; 1.25 committed with step 0.5 reads 1.3). With `snap` it also lands on a multiple of the step,
 * counted from min when there is one (1.25 with step 0.5 reads 1.5). Clamping to min and max comes last.
 */
export function stepNumber(value: number | null, by: number, { min = -Infinity, max = Infinity, step = 1, snap = false }: NumberBounds = {}): number {
  const decimals = decimalsOf(step);
  const base = value ?? (min > -Infinity ? min : 0);
  let next = base + by * step;
  if (snap && step > 0) {
    const origin = min > -Infinity ? min : 0;
    next = origin + Math.round((next - origin) / step) * step;
  }
  next = Number(next.toFixed(decimals));
  return Math.min(max, Math.max(min, next));
}

// Digits people type from other scripts: Arabic-Indic, Extended Arabic-Indic (Persian, Urdu) and
// fullwidth (CJK keyboards), plus the unambiguous separators, signs and colon that come with them.
const DIGIT_ZEROS = [0x0660, 0x06f0, 0xff10];
const SEPARATORS: Record<string, string> = { "\u066b": ".", "\u066c": ",", "\uff0e": ".", "\uff0c": ",", "\uff0d": "-", "\u2212": "-", "\uff0b": "+", "\uff1a": ":" };

/** "١٢٣", "۱۲۳" and "１２３" → "123"; everything else is left as it is. */
export function normalizeDigits(text: string): string {
  let out = "";
  for (const ch of text) {
    const code = ch.codePointAt(0) ?? 0;
    const zero = DIGIT_ZEROS.find((z) => code >= z && code <= z + 9);
    out += zero != null ? String(code - zero) : (SEPARATORS[ch] ?? ch);
  }
  return out;
}

/** A typed number, forgiving of grouping, spaces and non-Latin digits; null when it isn't one. */
export function parseNumber(text: string, locale?: string): number | null {
  // A seven-digit sample, since some locales (es) only group from five digits up.
  const parts = new Intl.NumberFormat(locale).formatToParts(1111111.1);
  const group = normalizeDigits(parts.find((p) => p.type === "group")?.value ?? ",");
  const decimal = normalizeDigits(parts.find((p) => p.type === "decimal")?.value ?? ".");
  const clean = normalizeDigits(text.trim()).split(group).join("").replace(/\s/g, "").replace(decimal, ".").replace(/[^\d.+-eE]/g, "");
  if (!clean || clean === "-" || clean === ".") return null;
  const n = Number(clean);
  return Number.isFinite(n) ? n : null;
}

/** What a Number Field sends with a form: the number as JavaScript writes it ("1250.5"), or "" when empty. */
export function numberFormValue(value: number | null): string {
  return value == null ? "" : String(value);
}

const STRENGTH_WORDS = ["Too weak", "Weak", "Fair", "Good", "Strong"] as const;

export type Strength = { score: 0 | 1 | 2 | 3 | 4; words: string; hints: string[] };

/**
 * Password strength from length and variety, with the reason in words (never only a colour). Common
 * patterns (a repeated character, a keyboard run, "password") knock it down.
 */
export function passwordStrength(pw: string): Strength {
  if (!pw) return { score: 0, words: "Enter a password", hints: [] };
  const hints: string[] = [];
  let points = 0;
  if (pw.length >= 8) points++;
  else hints.push("Use at least 8 characters");
  if (pw.length >= 14) points++;
  const kinds = [/[a-z]/, /[A-Z]/, /\d/, /[^A-Za-z0-9]/].filter((r) => r.test(pw)).length;
  if (kinds >= 3) points++;
  else hints.push("Mix letters, numbers and symbols");
  if (kinds === 4 && pw.length >= 10) points++;
  const weak = /(.)\1{3,}/.test(pw) || /password|qwerty|12345|letmein|admin/i.test(pw);
  if (weak) {
    points = Math.max(0, points - 2);
    hints.unshift("Avoid common words and repeated characters");
  }
  const score = Math.min(4, points) as Strength["score"];
  const words = STRENGTH_WORDS[score];
  return { score, words, hints };
}

/** Tags from typed or pasted text: split on commas and new lines, trimmed, without empties. */
export function parseTags(text: string): string[] {
  return text
    .split(/[,\n\t]/)
    .map((t) => t.trim())
    .filter(Boolean);
}

/** Adds tags, skipping duplicates (case-insensitive) and anything past max. Returns what was refused too. */
export function addTags(current: readonly string[], incoming: readonly string[], max = Infinity): { tags: string[]; duplicates: string[]; overflow: string[] } {
  const tags = [...current];
  const duplicates: string[] = [];
  const overflow: string[] = [];
  for (const t of incoming) {
    if (tags.some((x) => x.toLowerCase() === t.toLowerCase())) duplicates.push(t);
    else if (tags.length >= max) overflow.push(t);
    else tags.push(t);
  }
  return { tags, duplicates, overflow };
}

/** "9:30 pm", "21:30", "930p", "9" → minutes after midnight, or null. */
export function parseTime(text: string): number | null {
  const t = normalizeDigits(text).trim().toLowerCase().replace(/\s+/g, "");
  const m = /^(\d{1,2})(?::?(\d{2}))?(a|am|p|pm)?$/.exec(t);
  if (!m) return null;
  let h = Number(m[1]);
  const min = m[2] ? Number(m[2]) : 0;
  if (min > 59) return null;
  if (m[3]) {
    if (h < 1 || h > 12) return null;
    if (h === 12) h = 0;
    if (m[3].startsWith("p")) h += 12;
  } else if (h > 23) return null;
  return h * 60 + min;
}

/** Minutes after midnight in words: "9:30 PM" or "21:30". */
export function formatTime(minutes: number, hour12: boolean, locale?: string): string {
  const d = new Date(Date.UTC(2026, 0, 1, Math.floor(minutes / 60), minutes % 60));
  return new Intl.DateTimeFormat(locale ?? "en-US", { hour: "numeric", minute: "2-digit", hour12, timeZone: "UTC" }).format(d);
}

/** What a Time Picker sends with a form: 24-hour "HH:MM" (what <input type="time"> sends), or "" when empty. */
export function timeFormValue(minutes: number | null): string {
  return minutes == null ? "" : `${String(Math.floor(minutes / 60)).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")}`;
}

/**
 * What typed time text commits to: blank clears (null), a time in [min, max] is that time, and
 * anything else is invalid, so the field keeps the text and says so instead of quietly reverting.
 */
export function readTime(text: string, min = 0, max = 24 * 60 - 1): { ok: true; value: number | null } | { ok: false } {
  if (!text.trim()) return { ok: true, value: null };
  const m = parseTime(text);
  return m != null && m >= min && m <= max ? { ok: true, value: m } : { ok: false };
}

/** Every step minutes through the day, within [min, max]. */
export function timeSlots(step = 30, min = 0, max = 24 * 60 - 1): number[] {
  const out: number[] = [];
  for (let m = Math.ceil(min / step) * step; m <= max; m += step) out.push(m);
  return out;
}

/**
 * Where a key moves the choice in a radio group with one tab stop (roving tabindex): arrows move and
 * wrap, Home and End go to the ends. Null for any other key.
 */
export function rovingIndex(key: string, current: number, count: number): number | null {
  if (count <= 0) return null;
  const at = current < 0 ? 0 : current;
  if (key === "ArrowRight" || key === "ArrowDown") return (at + 1) % count;
  if (key === "ArrowLeft" || key === "ArrowUp") return (at - 1 + count) % count;
  if (key === "Home") return 0;
  if (key === "End") return count - 1;
  return null;
}
```

### CSS (the registry `css` / `cssVars`, merged into the global stylesheet by the shadcn CLI)

```css
@layer components {
  .lumen-number-field .lumen-number-field-input {
    text-align: center;
    font-variant-numeric: tabular-nums;
  }
  .lumen-number-field-unit {
    font: 500 11px/1 var(--lumen-font-mono, var(--font-mono, var(--font-geist-mono, 'Geist Mono'))), ui-monospace, monospace;
    color: var(--gc-sec);
  }
  .lumen-number-field-grip {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 2px;
    min-width: 28px;
    height: 28px;
    padding: 0;
    border-radius: calc(6px * var(--lumen-radius-k, 1));
    cursor: ew-resize;
    touch-action: none;
    user-select: none;
  }
  .lumen-number-field-grip>i {
    width: 2px;
    height: 10px;
    border-radius: calc(1px * var(--lumen-radius-k, 1));
    background: var(--gc-sec);
    transition: background-color min(0.12s, var(--gcp-cm)) ease;
  }
  @media (pointer:coarse) {
    .lumen-number-field-grip {
      min-width: 44px;
      height: 44px;
    }
  }
  .lumen-number-field[data-scrubbing] .lumen-number-field-grip>i {
    background: var(--gc-ink);
  }
  .lumen-number-field[data-scrubbing] {
    cursor: ew-resize;
  }
  .lumen-password-field {
    display: flex;
    flex-direction: column;
    gap: 6px;
    min-width: 0;
  }
  .lumen-password-strength {
    display: flex;
    align-items: center;
    gap: 10px;
    font-size: 12px;
    color: var(--gc-sec);
  }
  .lumen-password-strength-cells {
    display: inline-flex;
    gap: 3px;
  }
  .lumen-password-strength-cells>i {
    width: 18px;
    height: 5px;
    border-radius: calc(2px * var(--lumen-radius-k, 1));
    background: var(--gc-cellOff);
  }
  .lumen-password-strength[data-score="1"] i[data-on] {
    background: var(--gc-bad);
  }
  .lumen-password-strength[data-score="2"] i[data-on] {
    background: var(--gc-warn);
  }
  .lumen-password-strength:is([data-score="3"],[data-score="4"]) i[data-on] {
    background: var(--gc-good);
    box-shadow: 0 0 6px color-mix(in srgb,var(--gc-good) 55%,transparent);
  }
  .lumen-tag-input {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 6px;
    min-width: 0;
    min-height: 36px;
    padding: 4px 6px;
    border-radius: calc(12px * var(--lumen-radius-k, 1));
    background: color-mix(in srgb,var(--gc-ink) 4%,transparent);
    box-shadow: inset 0 0 0 1px var(--gc-hair);
    cursor: text;
    transition: box-shadow min(0.2s, var(--gcp-cm)) ease;
  }
  .lumen-tag-input:focus-within {
    box-shadow: inset 0 0 0 1px var(--gc-hair),0 0 0 2px var(--gc-gap), 0 0 0 4px var(--gc-p-focus), 0 0 #0000;
  }
  .lumen-tag-input[data-disabled] {
    opacity: .55;
    cursor: not-allowed;
  }
  .lumen-tag-input-list {
    display: contents;
    margin: 0;
    padding: 0;
    list-style: none;
  }
  .lumen-tag-input-chip {
    display: inline-flex;
    align-items: center;
    gap: 4px;
    height: 26px;
    padding: 0 4px 0 9px;
    border-radius: calc(8px * var(--lumen-radius-k, 1));
    background: color-mix(in srgb,var(--gc-acc) 14%,transparent);
    box-shadow: inset 0 0 0 1px color-mix(in srgb,var(--gc-acc) 30%,transparent);
    font: 500 12.5px/1 var(--lumen-font-sans, var(--font-sans, var(--font-geist, var(--font-geist-sans, 'Geist')))), system-ui, sans-serif;
  }
  .lumen-tag-input-remove {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 18px;
    height: 18px;
    margin: 0;
    padding: 0;
    border: 0;
    border-radius: calc(5px * var(--lumen-radius-k, 1));
    background: none;
    color: var(--gc-sec);
    font: 500 14px/1 var(--lumen-font-sans, var(--font-sans, var(--font-geist, var(--font-geist-sans, 'Geist')))), system-ui, sans-serif;
    cursor: pointer;
    outline: none;
  }
  @media (hover:hover) {
    .lumen-number-field-grip:hover>i {
      background: var(--gc-ink);
    }
    .lumen-tag-input-remove:hover {
      background: color-mix(in srgb,var(--gc-ink) 10%,transparent);
      color: var(--gc-ink);
    }
  }
  .lumen-tag-input-remove:focus-visible {
    box-shadow: 0 0 0 2px var(--gc-gap), 0 0 0 4px var(--gc-p-focus), 0 0 #0000;
  }
  .lumen-tag-input-field {
    flex: 1 1 90px;
    min-width: 90px;
    height: 26px;
    margin: 0;
    padding: 0 4px;
    border: 0;
    background: none;
    color: var(--gc-ink);
    font: 400 13.5px/1 var(--lumen-font-sans, var(--font-sans, var(--font-geist, var(--font-geist-sans, 'Geist')))), system-ui, sans-serif;
    outline: none;
  }
  .lumen-tag-input-field::placeholder {
    color: var(--gc-sec);
  }
  .lumen-time-picker {
    position: relative;
    display: inline-block;
    min-width: 0;
    width: 100%;
    max-width: 220px;
  }
  .lumen-time-picker-input {
    box-sizing: border-box;
    width: 100%;
    height: 36px;
    margin: 0;
    padding: 0 12px;
    border: 0;
    border-radius: calc(12px * var(--lumen-radius-k, 1));
    background: color-mix(in srgb,var(--gc-ink) 4%,transparent);
    box-shadow: inset 0 0 0 1px var(--gc-hair);
    color: var(--gc-ink);
    font: 400 13.5px/1 var(--lumen-font-sans, var(--font-sans, var(--font-geist, var(--font-geist-sans, 'Geist')))), system-ui, sans-serif;
    font-variant-numeric: tabular-nums;
    outline: none;
  }
  .lumen-time-picker-input:focus-visible {
    box-shadow: inset 0 0 0 1px var(--gc-hair),0 0 0 2px var(--gc-gap), 0 0 0 4px var(--gc-p-focus), 0 0 #0000;
  }
  .lumen-time-picker-input::placeholder {
    color: var(--gc-sec);
  }
  .lumen-time-picker-input[data-invalid] {
    box-shadow: inset 0 0 0 1px color-mix(in srgb,var(--gc-bad) 70%,var(--gc-hair));
  }
  .lumen-time-picker-list {
    position: absolute;
    z-index: 30;
    top: calc(100% + 6px);
    inset-inline: 0;
    max-height: 220px;
    margin: 0;
    padding: 4px;
    overflow: auto;
    border-radius: calc(12px * var(--lumen-radius-k, 1));
    background: var(--gc-tipBg,var(--popover,#16171c));
    box-shadow: inset 0 0 0 1px var(--gc-hair),0 16px 40px -16px rgba(0,0,0,.5);
    list-style: none;
    overscroll-behavior: contain;
  }
  .lumen-time-picker-option {
    padding: 7px 10px;
    border-radius: calc(8px * var(--lumen-radius-k, 1));
    font: 400 13px/1 var(--lumen-font-sans, var(--font-sans, var(--font-geist, var(--font-geist-sans, 'Geist')))), system-ui, sans-serif;
    font-variant-numeric: tabular-nums;
    cursor: pointer;
  }
  .lumen-time-picker-option:is([data-highlighted],:hover) {
    background: color-mix(in srgb,var(--gc-ink) 8%,transparent);
  }
  .lumen-time-picker-option[aria-selected="true"] {
    color: var(--gc-acc);
    font-weight: 600;
  }
}
```

The lumen foundation (the tokens listed above, keyframes and motion levels) installs once with the first component; its CSS is public at https://beautiful-ui.dev/r/lumen-foundation.json.
