Skip to content

feat(webkit): [ENG-47059] add the password-requirements row with default rules - #848

Open
herbert-julio-azion wants to merge 11 commits into
mainfrom
feat/ENG-47059-field-password-requirements
Open

feat(webkit): [ENG-47059] add the password-requirements row with default rules#848
herbert-julio-azion wants to merge 11 commits into
mainfrom
feat/ENG-47059-field-password-requirements

Conversation

@herbert-julio-azion

@herbert-julio-azion herbert-julio-azion commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Closes ENG-47059. Implements the password-requirements row from Figma
(FieldPassword 2027:3212, _Must Contain 4770:31936, _Password Requirement Chip
4770:31930, _Score Password 4770:31947).

What the field gains

A captioned, wrapping row of rule chips under the input, enabled by prop. It keeps exactly
two capabilities and nothing else: it is enabled by a prop, and the consumer may
supply their own rules
. Everything about how it looks is fixed from the design.

<FieldPassword v-model="password" label="Password" requirements />

A bare requirements renders the built-in set; an array replaces it entirely; false (the
default) renders no row. Each built-in rule carries a stable key so a consumer can start
from the set and drop one without depending on the localizable label.

The chip has two treatments, straight from the design: unsatisfied is
var(--bg-surface-raised) + var(--text-muted) with no glyph, satisfied is
var(--success) + a 14px pi-check + var(--text-default). The chip is wider once
satisfied, which is exactly the 88px to 106px delta between the two Figma variants.

A rule carries its test, not its result: the field owns the value, so it evaluates every
rule against the current one and the chips track what is typed with no wiring on the
consumer side. A g-flagged pattern is rebuilt without the flag, because lastIndex would
otherwise make the same value alternate.

Also in here

  • A latent a11y bug: resolvedInputId used ?? against a '' default, so useId() never
    fired and every derived id collapsed to a bare suffix, duplicated across instances with a
    dangling label for and aria-describedby. Now ||, with a test that mounts two id-less
    fields in one app and asserts distinct ids.
  • The input's left padding moved from the container onto the <input>, so the strip before
    the text is part of the field and clicking it focuses. input-number already did this;
    input-password did not. group-data-[has-icon-left] keeps the icon-left layout
    pixel-identical.
  • The visibility toggle is the 28px small IconButton the frame specifies, and the trailing
    padding is var(--spacing-xs) per the reconciled frame.

The Ctrl+V report: measured, not a defect

Pasting a large text was reported as not counting towards "8-128 characters". Driven with a
real Playwright Ctrl+C/Ctrl+V (OS-level, isTrusted events): a 50-char paste turns all five
chips green; a 300-char paste turns four green and leaves only 8-128 characters grey,
which is what an inclusive 8..128 interval says. The other four lighting up is what makes
the single grey chip read as "the paste was not counted".

Worth recording: the naive probe (dispatchEvent(new ClipboardEvent('paste', …))) yields
isTrusted=false and leaves value.length at 0, so measuring that way would have falsely
confirmed the bug.

If the intent is that a 300-char paste should end up valid, the fix is a maxLength of 128
at the call site so the browser truncates; it is not a change to this component.

What a reviewer should weigh before merge

These are known and deliberate, not oversights:

  1. The rule semantics are inferred from the Figma labels, not from the product policy.
    The labels are the design's; the regexes are not in Figma. azion-console-kit enforces
    (?=.*[-_!@#$%^&*(),.?":{}|<>]) for the special character and a minimum of 8 with no
    ceiling, in src/templates/signup-block/login-with-email-block.vue. The shipped default
    uses [^A-Za-z0-9], which is weaker: a plain space satisfies it. Aligning the special
    set, and the key names (minLength / specialChar, which the console already uses in
    src/views/YourSettings/FormFields/FormFieldsYourSettings.vue), is a follow-up.
  2. The defaults are a public export (./password-requirements), which deviates from the
    house precedent: field-phone-number's countries.ts is internal, reachable only through
    the prop default. The deviation buys composability (filtering the built-in set by key);
    the precedent's trade is that overriding means passing the whole array.
  3. requirements is a union type (boolean | PasswordRequirement[]). No other component
    here uses a union prop; the house shape would be a boolean gate plus an overridable array,
    as calendar-period and field-phone-number do respectively.
  4. Four divergences from the design remain: the check glyph should carry
    var(--success-contrast) (the design binds it; the code lets it inherit), the chip height
    is fixed 20px in the design and a floor (min-h-5) in the code, the chip label is
    missing whitespace-nowrap, and the pending-glyph axis is code-only and unreachable with
    the shipped defaults.
  5. Client-side rules are guidance, not enforcement. The server is the authority; this row
    must not be read as the password policy.
  6. Visual baselines need a CI regeneration for the new and changed stories.

Verified

71 tests pass across field-password and input-password, lint clean, vue-tsc clean, the
89 story files comply with storybook-source.md, check-tests covers 81 components, and the
spec checksum matches.

@herbert-julio-azion herbert-julio-azion changed the title feat(webkit): [ENG-47059] add the password-requirements row, evaluated against the field value feat(webkit): [ENG-47059] add the password-requirements row with default rules Aug 3, 2026
@herbert-julio-azion
herbert-julio-azion marked this pull request as ready for review August 3, 2026 22:04
@herbert-julio-azion
herbert-julio-azion requested a review from a team as a code owner August 3, 2026 22:04
…assword from Figma

Implements the four Figma frames of the password evolution. Three of them
(_Password Requirement Chip, _Must Contain, _Score Password) are the internals
of one surface — a captioned, wrapping set of rule chips — and the fourth
(FieldPassword, states Default/Error/Required) is where that surface lives.
None of the four is the bare InputPassword, so the row lands on FieldPassword,
which the InputPassword spec already designates as the owner of strength meters.

FieldPassword gains two props: `requirements` (bulk data, one chip per entry,
`validated` switching the chip to the check glyph + success tokens) and
`requirementsTitle` (the caption, which also names the group for assistive
tech via role=group + aria-labelledby). Evaluating the rules stays in the
consuming application; the field renders the outcome.

Reconciles InputPassword against the frame as it renders inside FieldPassword:
trailing padding 4px -> var(--spacing-xs), and the visibility toggle from the
32px `medium` IconButton to the 28px `small` one the frame specifies. The
frame's 0.8px var(--border-width-default) is deliberately NOT adopted — the
spec pins InputPassword to InputText's borders and InputText is still on 1px,
so it is recorded as a theme gap for one coordinated PR across every input.

Also fixes a latent id bug found while verifying the render in a real browser:
`inputId` defaults to '', which is not nullish, so `props.inputId ?? generatedId`
never fell back to useId(). Every derived id collapsed to a bare suffix, the
label/aria-describedby associations silently dangled, and two id-less fields on
one page emitted duplicate ids. `||` restores the fallback the spec already
promised.

Both specs updated first (props, states, motion, tokens, a11y, stories) with
recomputed checksums; the FieldPassword story documents the new axis in one
Requirements story; 10 new browser-mode tests cover the row, the group naming
and the id fallback.

NOTE: the visual-regression baselines for InputPassword and FieldPassword need
regenerating in CI (/regen-baselines) — the toggle size, trailing padding and
the new row all change those snapshots.
…ue and animate the chips

The rule entries carried a pre-computed `validated` boolean, so the chips froze at
whatever the consumer passed and typing changed nothing. An entry now carries its
`test` (a RegExp or a predicate) and the field evaluates it against the value it
already owns, so the row tracks what is typed with no recomputation on the consumer
side. Regexes with the `g` flag are rebuilt without it, since `lastIndex` would make
`test()` alternate on the same value.

Satisfying a rule inserts its check glyph, which widens that chip and pushes the ones
after it along the row. That is a layout move, so `move-class` (Vue's FLIP hook)
animates the shift with `transform`; entering and leaving chips slide in on the same
axis. The row itself transitions height and width, with `interpolate-size:
allow-keywords` so the transition reaches `auto`.

Three regexes written inside JS string literals emitted `/d/` instead of `/\\d/`,
which the "Show code" snippet and one story field were using verbatim.
…mponent with layout-safe motion

The previous motion animated size, which is the one thing it must not do: the row
transitioned `height`/`width` (with `interpolate-size`) and the chips relied on
TransitionGroup's FLIP because mounting the check glyph on satisfaction widened a chip
and pushed the rest of the row along, so a line break could move mid-transition. The
leaving chip was taken out of flow with `absolute`, which shifted the row again.

The glyph is now always in flow inside a reserved fixed box, so a chip is the same width
whether its rule is met or not, and only `opacity` and `transform` animate: both are
composited and neither takes part in layout, so no motion can change a size or re-wrap
the row. The height/width transition and TransitionGroup are gone.

The row moves into an internal `field-password-requirements` sub-component, mounted only
when the `requirements` prop is non-empty. It owns the rule evaluation and declares
`PasswordRequirement`, which FieldPassword re-exports so the public type is unchanged.
FieldPassword stays monolithic; the consumer drives the row through the prop.
…d a clickable input edge

Three changes, all reported from review.

The chip's icon box was blank for unmet rules, which read as a gap before the label.
Every state now carries a glyph (met / unmet / failed), so the box is never empty and the
chip still never changes width: only opacity and transform animate.

The row gains the error surface. Setting `invalid` moves every unmet rule to `failed`
(danger tokens + X glyph) while satisfied rules stay met, so after a blocked submit the
reader sees which rules failed. All three glyphs are props, so the set is swappable.

The input's left padding lived on the container, so the 16px strip before the text was
not part of the input and clicking it did not focus the field. `input-number` pads the
input itself, which is why the other inputs behave. The padding moves onto the input,
with `group-data-[has-icon-left]` keeping the icon-left layout pixel-identical.
…tate and per rule

Nothing shows while a rule is being typed: `requirementsPendingIcon` defaults to empty and
the glyph is rendered only when its state resolves to one, so there is no reserved box and
no gap before the label. The chip grows when a glyph arrives, which is the accepted trade.

Customisation now sits on two levels. The `requirements*Icon` props set the row default;
any entry of the validation object overrides its own `icon` / `pendingIcon` /
`invalidIcon`. An empty string means render none, resolved with `??` so `''` is honoured
instead of falling back. A consumer that wants no X and no red simply leaves
`invalid` unset, or empties the glyph it does not want.
…input width

Mounting the glyph box on demand changed the chip's width, and any ancestor sized by its
content passed that to the input: measured 353.4px to 368.0px in an auto-width host as the
rules started passing. The 14px box is now always reserved and only the glyph inside it is
conditional, which holds the chip at a constant width; measured 368.0px to 368.0px.

Two attempts on the row itself did not work and are recorded so they are not retried:
`w-0 min-w-full` (the cyclic min-width is resolved from the content, so the row still fed
the ancestor) and `contain: inline-size`. The cost of the fix is a small gap before the
label while a rule shows no glyph, which is the price of the guarantee.
… and no width change

Both constraints hold at once now. The glyph is conditional again, so a rule that shows
nothing reserves no box and leaves no gap before the label, and the row declares
`contain: inline-size` so its contents never feed an ancestor's intrinsic width. Measured
on the input in an auto-width host: 353.4px to 368.0px without containment, 203.0px to
203.0px with it.

The consequence is deliberate: in an auto-width host the field is now sized by its label
and input rather than stretched by the rule chips, so it is narrower and the chips wrap
within it. In a definite-width container nothing changes.

`w-0 min-w-full` was tried first and does not work: the cyclic min-width is resolved from
the content, so the row kept feeding the ancestor. An earlier always-reserved box worked
for the width but reintroduced the leading gap.
…ree of empty space

`contain: inline-size` kept the input width constant but changed the field's resting width
in an auto-width host (353.4px to 203.0px), which is a width change of its own. Reverted:
nothing in the row touches the field's width mechanics now.

The two guarantees hold together in a field with a definite width, which is what a form
gives it: measured the input at 274.0px through the whole transition with no empty box in
any chip. In an auto-width host the field's width is derived from the row's content, so the
chip's growth reaches it (353.4px to 368.0px); a width on the field is what resolves it.
…rement chips

Read from Figma node 4770:31936 (_Must Contain) this session: the chip has exactly two
states. Not satisfied is var(--bg-surface-raised) with var(--text-muted) and no glyph at
all; satisfied is var(--success) with a 14px pi-check and var(--text-default). There is no
error chip in the design: in the frame's Error state only the input border and the
HelperText turn red, and the chips are unchanged.

So the failed state, its X glyph and its danger tokens were invented here, and they go,
along with the row's invalid and invalidIcon props, the per-rule invalidIcon override,
FieldPassword's requirementsInvalidIcon, the forwarding of the field's invalid into the row,
the RequirementsInvalid story and the tests that asserted that state. data-validated is now
the single switch; data-state is gone.

The field's own invalid prop is untouched: it drives the input border and the HelperText,
which IS in the design.

Tail cleaned up in the same pass: a comment and two spec passages still described the
reserved-glyph-box design that no longer exists, the animation table promised a transform
transition no state varies (now transition-opacity only), the token table credited the glyph
with var(--success-contrast) when it inherits the chip's var(--text-default), and one test
asserted the absence of the now-nonexistent data-state. Added the coverage a verifier found
missing: the g-flag RegExp guard and both branches of a predicate test.
…ery appearance knob

The requirements row keeps exactly two capabilities: it is enabled by a prop, and the
consumer may supply their own rules. Everything about how it looks is fixed from Figma
node 4770:31936 — two chip treatments, unsatisfied being the muted surface with no glyph
and satisfied the success surface with a pi-check. So the icon knobs are gone: the row's
icon and pendingIcon, the per-rule overrides, and FieldPassword's requirementsIcon and
requirementsPendingIcon.

The five rules ship as data in a co-located password-requirements.ts with a stable key
each, so a bare `requirements` renders the built-in set and an array replaces it. Both the
Storybook docs and the spec explain using the defaults, replacing them, and composing from
them.

The Ctrl+V report was measured and is not a defect: driven with a real Playwright
Ctrl+C/Ctrl+V, a 50-char paste turns all five chips green and a 300-char paste turns four
green, leaving only '8-128 characters' grey — which is what the interval says. The naive
probe (dispatching an untrusted ClipboardEvent) would have falsely confirmed the bug.
@herbert-julio-azion
herbert-julio-azion force-pushed the feat/ENG-47059-field-password-requirements branch from d952b52 to ed7cf0e Compare August 4, 2026 13:55
@CLAassistant

CLAassistant commented Aug 4, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants