Skip to content

[Remove Vuetify from Studio] Sign-in page - #6056

Open
LightCreator1007 wants to merge 3 commits into
learningequality:unstablefrom
LightCreator1007:remove-vuetify-from-sign-in
Open

[Remove Vuetify from Studio] Sign-in page#6056
LightCreator1007 wants to merge 3 commits into
learningequality:unstablefrom
LightCreator1007:remove-vuetify-from-sign-in

Conversation

@LightCreator1007

@LightCreator1007 LightCreator1007 commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Summary

Removes Vuetify from the sign-in page.

  • VCardStudioRaisedBox, BannerStudioBanner, EmailField / PasswordFieldStudioEmailField / StudioPasswordField
  • VForm → native <form> with generateFormMixin; VApp / VLayout / VDivider → plain elements + scoped styles
  • Vuetify spacing/colour helpers → scoped CSS and $themePalette / $themeTokens
  • Errors show on blur or after a failed submit, not on every keystroke (the mixin validates on every input)
  • Password sent untrimmed the mixin's clean() would otherwise strip leading/trailing spaces
  • Heading is now <h1>, styled the same, so the page doesn't start at level 2

References

Fixes #5930

Reviewer guidance

Screen.Recording.2026-07-31.at.12.56.04.AM.mov

Note

theme--light on the page root is retained. Vuetify generates a .link class that collides with KDS's basic-link class, main.scss only neutralises it under .theme--light, which VApp used to supply. Without it, links render as blue blocks after any VApp page mounts.
Alternative is un-nesting that rule in main.scss, fixes it centrally for all of the pages/components, happy to switch.

AI usage

Used Claude Code in a review-and-iterate loop.

  • Wrote a first pass myself, had Claude research the issue/KDS docs and propose replacements before any code
  • made it justify each scoped style, removed a dead class and a needless rename.
  • Found the blue-block bug myself during manual testing, had Claude diagnose it, rejected its shared/styles/main.scss fix (because it lies outside the scope of this issue), and applied the in-scope one.
  • Reviewed every diff, ran tests and linter, verified manually in the browser

Replace VCard with StudioRaisedBox, Banner with StudioBanner, and
EmailField / PasswordField with StudioEmailField / StudioPasswordField.
Drop VApp, VLayout and VDivider in favour of plain elements and scoped
styles, and swap VForm for a native form driven by generateFormMixin.

Field errors are surfaced on blur or after a failed submit, preserving
the previous validate-on-blur behaviour. Vuetify spacing and colour
helpers are replaced with scoped CSS and KDS theme tokens.
Extend the sign-in suite for the behaviour introduced by the move to
generateFormMixin: submission is blocked while the form is invalid,
field errors stay hidden until a field is blurred or a submit fails,
and the password is sent without its surrounding whitespace trimmed.
Also assert the offline banner renders.
@learning-equality-bot

Copy link
Copy Markdown

👋 Hi @LightCreator1007, thanks for contributing!

For the review process to begin, please verify that the following is satisfied:

  • Contribution is aligned with our contributing guidelines

  • Pull request description has correctly filled AI usage section & follows our AI guidance:

    AI guidance

    State explicitly whether you didn't use or used AI & how.

    If you used it, ensure that the PR is aligned with Using AI as well as our DEEP framework. DEEP asks you:

    • Disclose — Be open about when you've used AI for support.
    • Engage critically — Question what is generated. Review code for correctness and unnecessary complexity.
    • Edit — Review and refine AI output. Remove unnecessary code and verify it still works after your edits.
    • Process sharing — Explain how you used the AI so others can learn.

    Examples of good disclosures:

    "I used Claude Code to implement the component, prompting it to follow the pattern in ComponentX. I reviewed the generated code, removed unnecessary error handling, and verified the tests pass."

    "I brainstormed the approach with Gemini, then had it write failing tests for the feature. After reviewing the tests, I used Claude Code to generate the implementation. I refactored the output to reduce verbosity and ran the full test suite."

Also check that issue requirements are satisfied & you ran pre-commit locally.

Pull requests that don't follow the guidelines will be closed.

Reviewer assignment can take up to 2 weeks.

@akolson
akolson requested a review from rtibblesbot August 4, 2026 12:30
@rtibblesbot

rtibblesbot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

🔵 Review posted

Last updated: 2026-08-04 12:46 UTC

@learning-equality-bot

Copy link
Copy Markdown

📢✨ Before we assign a reviewer, we'll turn on @rtibblesbot to pre-review. Its comments are generated by an LLM, and should be evaluated accordingly.

@rtibblesbot rtibblesbot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR #6056 — the Vuetify removal is faithful: spacing helpers map to the same pixel values, --v-backgroundColor-base and $themePalette.grey.v_100 resolve identically, and the form now follows the generateFormMixin + Studio*Field pattern Create.vue established. No new Vuetify, no ::v-deep, no inline directional styles. CI passing.

Main gaps are accessibility — the three feedback paths (login failure, offline, validation failure) are all silent to a screen reader — plus a few load-bearing decisions that survive only in the PR description and will be undone by the next reader.

  • important: error banners render with no live region (inline, line 27); validation failure gives no announcement and no focus move (inline, line 211)
  • suggestion: theme--light retention, the this.password bypass, and the bare return on network errors all need in-code comments (inline)
  • suggestion: the touched blur gate diverges from Create.vue — worth an epic-level decision (inline, line 52)
  • nitpick: fireEvent.blur in an otherwise userEvent suite (inline, line 112)

Not verified: manual QA did not run, so nothing here rests on how the page actually renders. Worth eyeballing the fixed width: 300px card and the bullet-separated footer links at ~320px, and confirming PolicyModals / LanguageSwitcherModal still centre now that they sit in a flex container rather than a VApp.

Comments on lines not in diff:

AccountsMain.vue:154nitpick: validEmailMessage (/.+@.+\..+/) duplicates Create.vue's emailValidationMessage (/\S+@\S+\.\S+/) with identical English text and a slightly different notion of validity. Matching legacy EmailField is a fair justification for this PR; worth aligning when the accounts pages are next touched together.

AccountsMain.vue:266nitpick: overflow: auto carried over from .main, where it sat on a Vuetify fill-height layout. .page grows with its content, so it never scrolls.


@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly

How was this generated?

Ran a phased review pipeline over the pull request diff:

  • Classified the diff to select review passes (core, frontend, backend) and whether manual QA was required
  • Core review pass checked correctness, design, architecture, testing, completeness, and DRY/SRP/Rule-of-Three principles
  • Specialized frontend/backend review passes applied framework-specific lenses where those files changed
  • For UI changes: manual QA and an accessibility audit against a live dev server, when available
  • Checked CI status and linked issue acceptance criteria
  • Synthesized one review from those passes and chose the verdict from the findings, CI status, and QA evidence

</template>
<template #main>
<div class="card-body">
<StudioBanner

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

important: These banners are gated by v-if and StudioBanner renders a plain <div class="banner"> with no role/aria-live (shared/views/StudioBanner.vue:3-9), so the node and its text are inserted together. Submit wrong credentials and focus stays on the sign-in button — a screen reader user gets nothing. Same for the offline banner, which can appear mid-session when shared/vuex/connectionPlugin/index.js:17-19 dispatches handleDisconnection.

Because v-if rules out a pre-existing live region, role="alert" at the call site is the cheap fix — StudioBanner's root is a plain div with default inheritAttrs, so it lands without touching the shared component:

<StudioBanner
  v-if="loginFailed"
  role="alert"
  error
>

Applies to the offline banner too. The loginToProceed banner is present at mount and correctly needs no role.

}
return Promise.resolve();
// eslint-disable-next-line vue/no-unused-properties
onValidationFailed() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

important: onValidationFailed only flips the touched flags — no announcement, no focus move. Tab to "Sign in" on an empty form and press Enter: both KTextboxes render their invalid text, but invalidText is associated with its own input, so it reaches a user focused on the field and not a user standing on the button. The press appears to do nothing.

Create.vue:503-512 handles the equivalent case by setting valid = false (rendering a summary StudioBanner) and scrolling ref="top" into view. With only two fields here, moving focus to the first invalid one is more direct:

onValidationFailed() {
  this.touched.username = true;
  this.touched.password = true;
  this.$nextTick(() => {
    const firstInvalid = this.$el.querySelector('[aria-invalid="true"]');
    if (firstInvalid) firstInvalid.focus();
  });
},

A summary banner carrying role="alert", matching Create.vue, works too.

style="width: 300px; margin: 0 auto"
>
<div
class="page theme--light"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: theme--light is load-bearing and non-obviously so — it's the only occurrence in the whole frontend, on a page whose stated purpose is to be Vuetify-free. shared/vuetify/theme.js spreads KDS themeTokens() into the Vuetify theme, so Vuetify 1.5 generates a global .link { background-color } helper that collides with the five appearance="basic-link" KButtons here; the only suppression is shared/styles/main.scss:100-115, nested under .theme--light. The theme stylesheet is still injected because accounts/components/MessageLayout.vue:3 uses VApp, so the failure only shows after sign-in → forgot-password → back. A cleanup pass will read this as leftover and delete it. One comment naming main.scss and the .link collision prevents that.

On the alternative you offered in the description: I'd take the main.scss un-nesting. Every remaining sub-issue of #5060 hits this same wall, and the outcomes are either theme--light copied onto every de-Vuetified page root or a central fix. Un-nesting is one line on a rule that exists solely to undo Vuetify damage, and the blast radius is bounded (no dark theme, KDS links carry no background). Keeping this PR strictly in-scope is defensible — but then file the main.scss fix and reference it in the comment so the workaround has an expiry date.

this.busy = true;
const credentials = {
username: formData.username,
password: this.password,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: formData.username for one field and this.password for the other reads as an inconsistency, and someone will "tidy" it. The reason — clean() in shared/mixins.js:433-446 trims every non-multiSelect value — deserves a one-line comment here.

Worth a clause in the same comment: validate() (mixins.js:447) runs against the cleaned data, so password's default Boolean(v) validator (mixins.js:362-364) now tests the trimmed value. An all-whitespace password is rejected client-side where the old PasswordField required-rule accepted it. Vanishingly rare, but it's the one input the trimming still touches.

.catch(err => {
this.busy = false;
if (err.message === 'Network Error') {
return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Dropping loginFailedOffline is a genuine cleanup — it was dead state, the template bound the offline banner to offline from the store. But an empty branch with no explanation is indistinguishable from an accidentally-eaten error. A comment ("the offline banner is driven by state.connection.online, no local flag needed") and dropping the else after the return would make that legible.

<StudioEmailField
v-model="username"
autofocus
:errorMessages="touched.username && errors.username ? [usernameErrorText] : []"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: The blur gate is well-motivated — the legacy fields passed :validate-on-blur="!validate", whereas the mixin's computed setters (shared/mixins.js:406-413) mark errors on every keystroke, so without touched this page would regress to live per-character errors.

The consistency problem is that Create.vue — same directory, same Studio*Field components, same mixin, same epic — has no gate and does show email errors while typing. Two adjacent account forms now validate differently, and each future migration re-hand-rolls this touched object. Not something to fix by expanding this PR, but worth deciding at the epic level: does the gate belong inside StudioEmailField/StudioPasswordField, which already own hasError/errorText? A note on #5060 would be enough.

One behavioural note either way: once blurred, touched stays true, so errors then update on every keystroke — different from the old validate-on-blur, and arguably better. Flagging only so it's deliberate.

const emailField = screen.getByLabelText(/email/i);

await user.type(emailField, 'not-an-email');
await fireEvent.blur(emailField);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: fireEvent.blur dispatches a synthetic blur without moving focus, so document.activeElement is still the email input after the test claims the field lost focus — it would pass even if the real blur path broke for focus-related reasons. await user.tab() blurs for real and keeps the suite on one interaction model. It's also the only reason fireEvent was added to the import on line 1.

While in here: line 83's comment still says "from EmailField and PasswordField components", which this branch renamed.

expect(screen.getByText(/you seem to be offline/i)).toBeInTheDocument();
});

it('should preserve leading and trailing whitespace in the password', async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: Pins exactly the trap the clean()-vs-this.password asymmetry creates, asserting on the dispatched payload rather than component internals — this is what stops a future consistency cleanup from locking users out.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Remove Vuetify from Studio] Sign-in page

2 participants