@clicktap/ui 2.3.3 → 2.4.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.
@@ -1,3 +1,3 @@
1
1
  import { PasswordCheckProps } from './PasswordCheck.types';
2
- export declare function PasswordCheck({ value, variant, requirements, }: PasswordCheckProps): import("react/jsx-runtime").JSX.Element | null;
2
+ export declare function PasswordCheck({ value, variant, requirements, schema, }: PasswordCheckProps): import("react/jsx-runtime").JSX.Element | null;
3
3
  export default PasswordCheck;
@@ -1 +1 @@
1
- "use client";import{jsxs as n,jsx as t}from"react/jsx-runtime";import{cn as a}from"../../utils/cn.js";import{checkStrength as d,getProgressText as c}from"./PasswordCheck.utils.js";const g=[{label:"At least 8 characters",test:s=>s.length>=8},{label:"At least one uppercase letter",test:s=>/[A-Z]/.test(s)},{label:"At least one digit",test:s=>/\d/.test(s)},{label:"At least one special character",test:s=>/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?~]/.test(s)}];function h({value:s,variant:i="default",requirements:o=g}){const e=d(s);return i==="requirements"?s?n("div",{className:"mt-4 flex flex-col gap-2",children:[t("ul",{className:"flex flex-col gap-1 text-xs text-left",children:o.map(r=>{const l=r.test(s);return n("li",{className:"flex items-center gap-1.5",children:[t("svg",{xmlns:"http://www.w3.org/2000/svg",width:"12",height:"12",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2.5",strokeLinecap:"round",strokeLinejoin:"round",className:a("shrink-0 transition-colors",l?"text-success":"text-fg-subtle"),children:l?t("path",{d:"M20 6 9 17l-5-5"}):t("circle",{cx:"12",cy:"12",r:"4"})}),t("span",{className:a("transition-colors",l?"text-success":"text-fg-muted"),children:r.label})]},r.label)})}),n("div",{className:"flex flex-col",children:[t("div",{className:"w-full flex gap-x-2",children:Array.from({length:5},(r,l)=>l).map((r,l)=>t("div",{className:a("grow shrink basis-1/5 h-1 rounded-full transition-colors duration-700 ease bg-border-subtle",e<=l&&"bg-fg-muted",(e===1||e===2)&&"bg-danger",e===3&&"bg-warning",e===4&&"bg-success",e===5&&"bg-info")},r))}),n("div",{className:a("inline-flex gap-x-1 text-xs text-fg-strong transition-opacity mt-1",e?"opacity-100":"opacity-0"),children:[t("span",{children:"Password Strength:"}),t("span",{className:a((e===1||e===2)&&"text-danger",e===3&&"text-warning",e===4&&"text-success",e===5&&"text-info"),children:c(e)})]})]})]}):null:t("div",{children:n("div",{className:a("relative w-full flex gap-x-2",i==="default"&&"mb-8 -mt-1"),children:[Array.from({length:5},(r,l)=>l).map((r,l)=>t("div",{className:a("grow shrink basis-1/5 h-1 rounded-full transition-colors duration-700 ease bg-border-subtle",e<=l&&"bg-fg-muted",(e===1||e===2)&&"bg-danger",e===3&&"bg-warning",e===4&&"bg-success",e===5&&"bg-info")},r)),i==="default"&&n("div",{className:a("inline-flex gap-x-1 absolute top-0 left-0 text-xs translate-y-2/4 text-fg-strong",e?"opacity-100":"opacity-0"),children:[t("span",{children:"Password Strength:"}),t("span",{className:a((e===1||e===2)&&"text-danger",e===3&&"text-warning",e===4&&"text-success",e===5&&"text-info"),children:c(e)})]})]})})}export{h as PasswordCheck,h as default};
1
+ "use client";import{jsxs as n,jsx as t}from"react/jsx-runtime";import{cn as a}from"../../utils/cn.js";import{trimEdgeWhitespace as h,checkStrength as p,requirementsFromSchema as b,getProgressText as m}from"./PasswordCheck.utils.js";const w=[{label:"At least 8 characters",test:s=>s.length>=8},{label:"At least one uppercase letter",test:s=>/[A-Z]/.test(s)},{label:"At least one lowercase letter",test:s=>/[a-z]/.test(s)},{label:"At least one digit",test:s=>/\d/.test(s)},{label:"At least one special character",test:s=>/[^A-Za-z0-9]/.test(s)}];function k({value:s,variant:c="default",requirements:x,schema:f}){const o=h(s),e=p(o),d=b(f),u=x??(d.length>0?d:w);return c==="requirements"?s?n("div",{className:"mt-4 flex flex-col gap-2",children:[t("ul",{className:"flex flex-col gap-1 text-xs text-left",children:u.map(r=>{const l=r.test,g=l!==void 0,i=l?l(o):!1;return n("li",{className:"flex items-center gap-1.5",children:[n("svg",{xmlns:"http://www.w3.org/2000/svg",width:"12",height:"12",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2.5",strokeLinecap:"round",strokeLinejoin:"round",className:a("shrink-0 transition-colors",i?"text-success":"text-fg-subtle"),children:[i&&t("path",{d:"M20 6 9 17l-5-5"}),!i&&g&&t("circle",{cx:"12",cy:"12",r:"4"}),!g&&t("circle",{cx:"12",cy:"12",r:"2.5",fill:"currentColor"})]}),t("span",{className:a("transition-colors",i?"text-success":"text-fg-muted"),children:r.label})]},r.label)})}),n("div",{className:"flex flex-col",children:[t("div",{className:"w-full flex gap-x-2",children:Array.from({length:5},(r,l)=>l).map((r,l)=>t("div",{className:a("grow shrink basis-1/5 h-1 rounded-full transition-colors duration-700 ease bg-border-subtle",e<=l&&"bg-fg-muted",(e===1||e===2)&&"bg-danger",e===3&&"bg-warning",e===4&&"bg-success",e===5&&"bg-info")},r))}),n("div",{className:a("inline-flex gap-x-1 text-xs text-fg-strong transition-opacity mt-1",e?"opacity-100":"opacity-0"),children:[t("span",{children:"Password Strength:"}),t("span",{className:a((e===1||e===2)&&"text-danger",e===3&&"text-warning",e===4&&"text-success",e===5&&"text-info"),children:m(e)})]})]})]}):null:t("div",{children:n("div",{className:a("relative w-full flex gap-x-2",c==="default"&&"mb-8 -mt-1"),children:[Array.from({length:5},(r,l)=>l).map((r,l)=>t("div",{className:a("grow shrink basis-1/5 h-1 rounded-full transition-colors duration-700 ease bg-border-subtle",e<=l&&"bg-fg-muted",(e===1||e===2)&&"bg-danger",e===3&&"bg-warning",e===4&&"bg-success",e===5&&"bg-info")},r)),c==="default"&&n("div",{className:a("inline-flex gap-x-1 absolute top-0 left-0 text-xs translate-y-2/4 text-fg-strong",e?"opacity-100":"opacity-0"),children:[t("span",{children:"Password Strength:"}),t("span",{className:a((e===1||e===2)&&"text-danger",e===3&&"text-warning",e===4&&"text-success",e===5&&"text-info"),children:m(e)})]})]})})}export{k as PasswordCheck,k as default};
@@ -1,9 +1,53 @@
1
1
  export type PasswordRequirement = {
2
2
  label: string;
3
- test: (value: string) => boolean;
3
+ /**
4
+ * Whether the value satisfies this rule.
5
+ *
6
+ * Optional since CTAP-1908. A rule derived from the backend schema may be one this component
7
+ * cannot evaluate in the browser — an arbitrary regular expression, a rule a module added after
8
+ * this file was written. Such a rule renders as a neutral bullet rather than being dropped:
9
+ * a rule that is enforced but not displayed refuses the user with no explanation, which is the
10
+ * whole defect this work exists to end.
11
+ */
12
+ test?: (value: string) => boolean;
13
+ };
14
+ /**
15
+ * One thing a backend schema says about a value — CTAP-1908.
16
+ *
17
+ * Declared structurally rather than imported from an app's generated types, in keeping with the
18
+ * rest of this library: a codegen `SchemaDescriptionClause` is assignable to it without `ui`
19
+ * depending on any app.
20
+ */
21
+ export type SchemaDescriptionClause = {
22
+ /**
23
+ * A stable machine code (`min_length`, `at_least_one_lowercase`, …). An OPEN vocabulary — a module
24
+ * that owns a constraint coins its own, so never assume this component knows them all.
25
+ */
26
+ code: string;
27
+ /** `rule` — a condition the value must satisfy. `note` — information about it, not a check. */
28
+ kind: string;
29
+ /** The rule in English, ready to render. */
30
+ text: string;
31
+ /** The rule's arguments, e.g. `{ min: 8 }`. Read these; never parse `text`. */
32
+ parameters?: Record<string, unknown> | null;
33
+ };
34
+ /** A validation schema described in English, as served by `passwordSchemaDescription`. */
35
+ export type SchemaDescription = {
36
+ clauses?: readonly (SchemaDescriptionClause | null)[] | null;
4
37
  };
5
38
  export type PasswordCheckProps = {
6
39
  value: string;
7
40
  variant?: 'default' | 'short' | 'requirements';
41
+ /**
42
+ * The rules to show. Overrides everything — a consumer with its own policy keeps full control.
43
+ */
8
44
  requirements?: PasswordRequirement[];
45
+ /**
46
+ * The password policy as the backend describes it (`passwordSchemaDescription`) — CTAP-1908.
47
+ *
48
+ * Pass it and the rule list is derived from the schema that actually enforces the password, so
49
+ * the two cannot drift. Without it the component falls back to {@link defaultRequirements}, which
50
+ * is a static copy and has been wrong before.
51
+ */
52
+ schema?: SchemaDescription | null;
9
53
  };
@@ -1,3 +1,19 @@
1
+ import { PasswordRequirement, SchemaDescription } from './PasswordCheck.types';
2
+ /**
3
+ * Strip edge whitespace the way the backend does before it validates a password.
4
+ *
5
+ * The policy lives in PHP — `PasswordService::getPasswordSchema()` in
6
+ * `libs/mezzio-modules/user/src/Service/PasswordService.php` — and the schema opens with
7
+ * `->trim()`, which is `Rmg\Validator\EdgeWhitespace`, not PHP's `trim()`. Every rule after it,
8
+ * `min(8)` included, sees the trimmed value. Measuring the raw one here told a visitor that
9
+ * `" abcdef1A! "` was 13 characters long while the server counted 9.
10
+ *
11
+ * The character class mirrors that PHP constant: JavaScript's `\s` already covers NBSP, the whole
12
+ * `Zs` category and U+FEFF, so only NUL and U+200B ZERO WIDTH SPACE have to be named (U+FEFF is
13
+ * listed anyway, to keep the two classes readable side by side). Interior whitespace is part of
14
+ * the value and is left alone, on both sides.
15
+ */
16
+ export declare const trimEdgeWhitespace: (value: string) => string;
1
17
  /** @todo these functions should probably be passed as props to be able to extend */
2
18
  export declare const checkStrength: (value: string) => number;
3
19
  export declare const getProgressText: (strength: number) => "" | "Very Strong" | "Strong" | "Good" | "Weak" | "Very Weak";
@@ -7,3 +23,24 @@ export declare const getPasswordRequirements: (value?: string) => {
7
23
  upLowCaseLetters: boolean;
8
24
  oneNumber: boolean;
9
25
  };
26
+ /**
27
+ * The rules a described backend schema states, as things this component can render — CTAP-1908.
28
+ *
29
+ * ## Why this exists
30
+ *
31
+ * `PasswordService::getPasswordSchema()` enforces a minimum length plus uppercase, **lowercase**,
32
+ * number and special-character rules. The hand-written list in this file showed four of them and
33
+ * omitted lowercase, so a user could watch every tick turn green and still be refused, with no
34
+ * explanation, because the rule that rejected them was never displayed. Deriving the list from the
35
+ * schema that enforces it is what stops that happening again.
36
+ *
37
+ * ## Two rules about unrecognised clauses
38
+ *
39
+ * 1. **An unrecognised `code` is still rendered.** It comes back with no `test`, so it shows as a
40
+ * neutral bullet rather than a tick that can never turn green. Dropping it would recreate the
41
+ * exact bug.
42
+ * 2. **A `note` is not rendered.** "Leading and trailing whitespace is removed" is true and worth
43
+ * saying on a configuration screen; under a password field it is noise. The backend marks the
44
+ * difference so this does not have to guess.
45
+ */
46
+ export declare const requirementsFromSchema: (schema: SchemaDescription | null | undefined) => PasswordRequirement[];
@@ -1 +1 @@
1
- const s=t=>{const r=t.length>=8;let e=0;return/^(?=.*[a-z])(?=.*[A-Z])/.test(t)&&r&&(e+=1),/\d/.test(t)&&r&&(e+=1),t.length>=8&&(e+=1),t.length>=11&&(e+=1),/[ `!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?~]/.test(t)&&r&&(e+=1),e},n=t=>{switch(t){case 5:return"Very Strong";case 4:return"Strong";case 3:return"Good";case 2:return"Weak";case 1:return"Very Weak";default:return""}},c=(t="")=>({eightСharacters:t.length>=8,upLowCaseLetters:/^(?=.*[a-z])(?=.*[A-Z])/.test(t),oneNumber:/\d/.test(t)});export{s as checkStrength,c as getPasswordRequirements,n as getProgressText};
1
+ const a=t=>t.replace(/^[\s\u0000\u200B\uFEFF]+|[\s\u0000\u200B\uFEFF]+$/g,""),o=t=>{const e=t.length>=8;let r=0;return/^(?=.*[a-z])(?=.*[A-Z])/.test(t)&&e&&(r+=1),/\d/.test(t)&&e&&(r+=1),t.length>=8&&(r+=1),t.length>=11&&(r+=1),/[ `!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?~]/.test(t)&&e&&(r+=1),r},c=t=>{switch(t){case 5:return"Very Strong";case 4:return"Strong";case 3:return"Good";case 2:return"Weak";case 1:return"Very Weak";default:return""}},i=(t="")=>({eightСharacters:t.length>=8,upLowCaseLetters:/^(?=.*[a-z])(?=.*[A-Z])/.test(t),oneNumber:/\d/.test(t)}),u=t=>t?.clauses?t.clauses.filter(e=>!!e&&e.kind==="rule").map(e=>({label:e.text,test:s(e)})):[],n=(t,e)=>{const r=t.parameters?.[e];return typeof r=="number"?r:void 0},s=t=>{switch(t.code){case"min_length":{const e=n(t,"min");return e===void 0?void 0:r=>r.length>=e}case"max_length":{const e=n(t,"max");return e===void 0?void 0:r=>r.length<=e}case"exact_length":{const e=n(t,"length");return e===void 0?void 0:r=>r.length===e}case"at_least_one_lowercase":return e=>/[a-z]/.test(e);case"at_least_one_uppercase":return e=>/[A-Z]/.test(e);case"at_least_one_number":return e=>/\d/.test(e);case"at_least_one_special_character":return e=>/[\W_]/.test(e);case"not_blank":return e=>e.trim().length>0;default:return}};export{o as checkStrength,i as getPasswordRequirements,c as getProgressText,u as requirementsFromSchema,a as trimEdgeWhitespace};
@@ -1 +1,3 @@
1
1
  export { PasswordCheck } from './PasswordCheck';
2
+ export { requirementsFromSchema } from './PasswordCheck.utils';
3
+ export type { PasswordCheckProps, PasswordRequirement, SchemaDescription, SchemaDescriptionClause, } from './PasswordCheck.types';
@@ -1,2 +1,2 @@
1
1
  "use client";
2
- import{PasswordCheck as i}from"./PasswordCheck.js";import"react/jsx-runtime";import"../../utils/cn.js";import"./PasswordCheck.utils.js";export{i as PasswordCheck};
2
+ import{PasswordCheck as t}from"./PasswordCheck.js";import{requirementsFromSchema as i}from"./PasswordCheck.utils.js";import"react/jsx-runtime";import"../../utils/cn.js";export{t as PasswordCheck,i as requirementsFromSchema};
@@ -0,0 +1,81 @@
1
+ # BUGFIX: `PasswordCheck` states the password policy the backend actually enforces
2
+
3
+ **Date:** 2026-09-24
4
+ **Package:** `@clicktap/ui`
5
+ **Affects:** `src/components/PasswordCheck/PasswordCheck.tsx`, `PasswordCheck.utils.ts` — every `<PasswordCheck variant="requirements">` that relies on the default `requirements`.
6
+ **Classification:** BUGFIX
7
+ **Breaking:** No. No API change: `requirements`, `value` and `variant` are untouched, and a consumer passing its own `requirements` is unaffected. **Displayed:** the default list grows from four rules to five, and all five are now evaluated against the edge-trimmed value.
8
+ **Ticket:** CTAP-1906
9
+
10
+ ## Problem
11
+
12
+ `PasswordCheck` restated the password policy in its own words, and the words were wrong.
13
+
14
+ The policy lives in PHP — `PasswordService::getPasswordSchema()`,
15
+ `libs/mezzio-modules/user/src/Service/PasswordService.php`:
16
+
17
+ ```php
18
+ $sb->string()->trim()->min(8)
19
+ ->withValidator(new AtLeastOneUppercaseValidator()) // /[A-Z]/
20
+ ->withValidator(new AtLeastOneLowercaseValidator()) // /[a-z]/
21
+ ->withValidator(new AtLeastOneNumberValidator()) // /\d/
22
+ ->withValidator(new AtLeastOneSpecialCharacterValidator()); // /[\W_]/
23
+ ```
24
+
25
+ Five rules. The component displayed four — the lowercase rule was missing. So `PASSWORD1!` turned
26
+ **every displayed tick green** and the server refused the save, and because the rule that rejected
27
+ the visitor was never on screen, the form could not tell them what to change.
28
+
29
+ Two narrower mismatches came with it:
30
+
31
+ - **Length was measured on the raw value** while the schema measures the trimmed one. `" abcdef1A! "`
32
+ showed as 13 characters and was validated as 9. The schema's `->trim()` is
33
+ `Rmg\Validator\EdgeWhitespace`, which is wider than PHP's `trim()`: NUL, U+200B and U+FEFF too.
34
+ - **The special-character rule was narrower than the validator.** The component tested a fixed
35
+ punctuation list; `AtLeastOneSpecialCharacterValidator` is `/[\W_]/` — anything outside
36
+ `[A-Za-z0-9]`. A password whose only special character was `£`, a non-ASCII letter or an interior
37
+ space was refused a tick the server would have granted.
38
+
39
+ ## What changed
40
+
41
+ `defaultRequirements` is now the five rules above, in schema order, each one carrying a comment
42
+ naming the validator it mirrors:
43
+
44
+ | # | Displayed rule | Backend rule | Before |
45
+ | --- | --- | --- | --- |
46
+ | 1 | At least 8 characters | `->min(8)` (post-trim) | measured untrimmed |
47
+ | 2 | At least one uppercase letter | `AtLeastOneUppercaseValidator` | unchanged |
48
+ | 3 | At least one lowercase letter | `AtLeastOneLowercaseValidator` | **missing** |
49
+ | 4 | At least one digit | `AtLeastOneNumberValidator` | unchanged |
50
+ | 5 | At least one special character | `AtLeastOneSpecialCharacterValidator` (`/[\W_]/`) | fixed punctuation list |
51
+
52
+ The component trims once and hands the trimmed value to every rule and to `checkStrength`, because
53
+ the schema's `->trim()` runs before `->min(8)` and before every validator. The new
54
+ `trimEdgeWhitespace` export in `PasswordCheck.utils.ts` mirrors `Rmg\Validator\EdgeWhitespace`:
55
+ JavaScript's `\s` (which already covers NBSP, the `Zs` category and U+FEFF) plus NUL and U+200B.
56
+ Interior whitespace is untouched, on both sides.
57
+
58
+ `PasswordCheck.requirements.test.tsx` pins the rendered rule set and names
59
+ `PasswordService::getPasswordSchema()` as the source of truth, so the next change to the policy on
60
+ either side lands as a failing test pointing at the other half.
61
+
62
+ This is the hand-mirroring fix. Generating the list from the serialized schema, so the two cannot
63
+ drift again, is **CTAP-1908**.
64
+
65
+ ## Consumer Impact
66
+
67
+ **No action needed.** No prop, export or type changed, and the extra rule appears on its own.
68
+
69
+ **A consumer that passes its own `requirements` keeps exactly what it passes** — the prop still
70
+ overrides the default outright. But that list has the same hole this fix closed, so check it against
71
+ the table above. One such copy existed in the storefront and was fixed in the same change:
72
+ `apps/frontend/components/Account/Auth/Authenticate/constanst.ts` (`passwordValidation`, the zod gate
73
+ behind signup, reset-password and update-password) was missing the lowercase rule and carried the
74
+ same narrow special-character list.
75
+
76
+ **Rules now receive the trimmed value.** If you pass custom `requirements`, your `test` callbacks
77
+ are called with the edge-trimmed string, not the raw one. A rule that deliberately inspected
78
+ leading/trailing whitespace would stop seeing it — nothing in this repo did.
79
+
80
+ **Visually, the block is one line taller** in the `requirements` variant. Nothing else about its
81
+ layout, the strength meter or the hide-until-typed behavior moved.
@@ -0,0 +1,109 @@
1
+ # `PasswordCheck` renders the rules the server actually enforces
2
+
3
+ **Date:** 2026-09-24
4
+ **Package:** `@clicktap/ui`
5
+ **Affects:** `libs/frontend-modules/ui/src/components/PasswordCheck/*` — new `schema` prop, new `requirementsFromSchema` export, `PasswordRequirement.test` is now optional
6
+ **Requires:** a middleware serving `passwordSchemaDescription` (CTAP-1908) to use the new prop; without one nothing changes
7
+ **Classification:** IMPROVEMENT
8
+ **Breaking:** No. Every existing call site keeps working, and a consumer passing `requirements` still overrides everything.
9
+ **Ticket:** CTAP-1908
10
+
11
+ ## Problem
12
+
13
+ `PasswordCheck`'s rule list was a hard-coded array in the component:
14
+
15
+ ```tsx
16
+ const defaultRequirements = [
17
+ { label: 'At least 8 characters', ... },
18
+ { label: 'At least one uppercase letter', ... },
19
+ { label: 'At least one digit', ... },
20
+ { label: 'At least one special character', ... },
21
+ ];
22
+ ```
23
+
24
+ The middleware enforces those four **and a lowercase rule**. So a visitor could type `PASSW0RD!`,
25
+ watch all four ticks turn green, submit, and be refused — by a rule that was never on the screen.
26
+ Four hand-written lines against five enforced rules, with nothing to keep them equal.
27
+
28
+ ## Fix
29
+
30
+ ```tsx
31
+ const passwordSchema = usePasswordSchemaDescription(); // passwordSchemaDescription query
32
+
33
+ <PasswordCheck value={value} variant="requirements" schema={passwordSchema} />
34
+ ```
35
+
36
+ The list is now derived from the schema that enforces the password. Five rules where there were
37
+ four, and a sixth rule added on the backend appears with no frontend change.
38
+
39
+ ## Precedence
40
+
41
+ | Given | Rules shown |
42
+ |---|---|
43
+ | `requirements` prop | exactly that list — a consumer with its own policy keeps full control |
44
+ | `schema` with at least one `rule` clause | derived from the schema |
45
+ | neither, or a schema stating no rules | `defaultRequirements`, the static list |
46
+
47
+ The static list stays as the fallback because the component cannot tell "this policy has no rules"
48
+ from "the query has not resolved yet", and a password field showing nothing is worse than one
49
+ showing a stale list. It is a floor, not a claim of completeness — it has been wrong before, which
50
+ is the whole reason `schema` exists.
51
+
52
+ ## Two rules the component follows, and yours should too
53
+
54
+ **An unrecognised clause is rendered, not dropped.** Clause codes are an open vocabulary: a module
55
+ that owns a constraint coins its own. `requirementsFromSchema` returns a rule with no `test` for a
56
+ code it cannot evaluate, and the component shows it with a small filled dot instead of a tick —
57
+ present, never claimed satisfied, never claimed failed. Dropping it would recreate the original bug
58
+ exactly.
59
+
60
+ **Notes are not rules.** The backend marks `kind: 'note'` for things like "Leading and trailing
61
+ whitespace is removed" — true, worth showing on an admin screen, noise under a password field. The
62
+ component skips them without needing to know what they say.
63
+
64
+ ## API
65
+
66
+ ```ts
67
+ type PasswordRequirement = {
68
+ label: string;
69
+ test?: (value: string) => boolean; // OPTIONAL since this change
70
+ };
71
+
72
+ requirementsFromSchema(schema): PasswordRequirement[]
73
+ ```
74
+
75
+ `test` became optional so an unevaluable rule can still be listed. Every existing consumer supplies
76
+ it, so this is a widening.
77
+
78
+ `PasswordCheckProps`, `PasswordRequirement`, `SchemaDescription` and `SchemaDescriptionClause` are
79
+ now exported from `@clicktap/ui/components/PasswordCheck` — they were not, so a consumer building a
80
+ rule list from server data could not type it.
81
+
82
+ `SchemaDescription` is declared structurally in this library, mirroring the GraphQL shape, so a
83
+ codegen type is assignable to it without `ui` depending on any app.
84
+
85
+ ## ⚠️ The strength meter still measures the old policy
86
+
87
+ `checkStrength()` is independent of `requirements` and always was: it hard-codes 8/11 characters,
88
+ mixed case, digit and special. Override the rule list or pass a schema and the five-bar meter keeps
89
+ scoring the built-in policy. That was true before this change and is unchanged by it —
90
+ deriving the meter from the schema needs its own ticket.
91
+
92
+ ## Consumer checklist
93
+
94
+ - **Nothing to do** if you are happy with the static list.
95
+ - **To adopt:** take a middleware serving `passwordSchemaDescription`, add the query, and pass the
96
+ result as `schema` at every `PasswordCheck` call site. In this monorepo that is three components,
97
+ four call sites: `Signup`, `ResetPassword` and `UpdatePasswordForm` (twice).
98
+ - **If your `*-ui` fork copied `defaultRequirements`,** it is almost certainly missing the lowercase
99
+ rule too. Copy `PasswordCheck.utils.ts`'s `requirementsFromSchema` and the `schema` prop rather
100
+ than adding the one missing line.
101
+
102
+ ## Verification
103
+
104
+ `libs/frontend-modules/ui/src/components/PasswordCheck/PasswordCheck.spec.tsx` — 9 tests. The
105
+ load-bearing two assert five rules from the real `passwordSchemaDescription` payload and the
106
+ presence of the lowercase rule by name. Others pin that only satisfied rules tick, that `min` is
107
+ read from the clause parameters rather than assumed to be 8, that an unrecognised code is rendered
108
+ but never ticked, that notes are not rendered, that `requirements` still overrides, and both
109
+ fallback paths.
@@ -9,6 +9,26 @@
9
9
  "breaking": "No API change, and no prop, export or class-name contract moves. **Visual:** 76 of the 118 swaps resolve to the exact same light-theme value and render identically. The other 42 do not — they are listed component by component under \"Rendering changes\" and are all one step along a neutral ramp, except `UploadImage`, whose hover background **appears for the first time** because the class it had was a typo.",
10
10
  "affects": "54 files under `src/components/**` — every component listed in \"What changed\" below — plus the new root-level `eslint.rules.no-literal-palette-color.mjs` and the two eslint configs that reference it."
11
11
  },
12
+ {
13
+ "path": "2026/09/ui-2026-09-24-password-check-renders-from-the-schema.md",
14
+ "module": "libs/frontend-modules/ui",
15
+ "package": "@clicktap/ui",
16
+ "date": "2026-09-24",
17
+ "title": "`PasswordCheck` renders the rules the server actually enforces",
18
+ "classification": "IMPROVEMENT",
19
+ "breaking": "No. Every existing call site keeps working, and a consumer passing `requirements` still overrides everything.",
20
+ "affects": "`libs/frontend-modules/ui/src/components/PasswordCheck/*` — new `schema` prop, new `requirementsFromSchema` export, `PasswordRequirement.test` is now optional"
21
+ },
22
+ {
23
+ "path": "2026/09/ui-2026-09-24-password-check-lowercase-and-trim.md",
24
+ "module": "libs/frontend-modules/ui",
25
+ "package": "@clicktap/ui",
26
+ "date": "2026-09-24",
27
+ "title": "BUGFIX: `PasswordCheck` states the password policy the backend actually enforces",
28
+ "classification": "BUGFIX",
29
+ "breaking": "No. No API change: `requirements`, `value` and `variant` are untouched, and a consumer passing its own `requirements` is unaffected. **Displayed:** the default list grows from four rules to five, and all five are now evaluated against the edge-trimmed value.",
30
+ "affects": "`src/components/PasswordCheck/PasswordCheck.tsx`, `PasswordCheck.utils.ts` — every `<PasswordCheck variant=\"requirements\">` that relies on the default `requirements`."
31
+ },
12
32
  {
13
33
  "path": "2026/09/ui-2026-09-24-dialog-semantic-surface-tokens.md",
14
34
  "module": "libs/frontend-modules/ui",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clicktap/ui",
3
- "version": "2.3.3",
3
+ "version": "2.4.0",
4
4
  "private": false,
5
5
  "author": "Clicktap",
6
6
  "description": "A library of React UI components and low-level hooks.",