Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/MAINTAINERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ The demo pages load `dist/sonar-quiz.iife.js` and provide the hidden config span
| Bundle size | `npm run build && npm run size-check` | CI, 40 KB gzip on the IIFE |
| E2E | `npm run test:e2e` and `npm run test:e2e:encrypted` | CI matrix |
| Storybook build | `npm run build-storybook` | CI, on every push and PR |
| Release | Tag `vX.Y.Z` on `main` | `release.yml` builds and publishes; see `docs/RELEASE.md` |
| Release | Tag `vX.Y.Z` on `main` | `release.yml` builds and publishes; see `docs/RELEASE.md`. Last release: `v0.2.0`, 2025-11-29. Bump `package.json` first — `VERSION` in the bundle is injected from it. |

**Raising the ratchets.** The coverage thresholds and `--max-gaps` value are set to the measured
level. When you add tests, raise them in the same PR so the improvement is locked in.
Expand Down
17 changes: 13 additions & 4 deletions docs/PROJECT_STATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ Written for an incoming maintainer. Every number below comes from commands run o
2026-09-05 against `main` at `5383ca2` (last commit 2026-06-19), on Node 22.22 / npm 10.9.
Existing documents were audited, not rewritten.

> **Correction (2026-09-05).** This report originally stated that the release workflow had never
> run and that no tag had ever been pushed. That is false. Tag `v0.2.0` and a GitHub Release,
> with `sonar-quiz.iife.js` and its source map attached, were published on 2025-11-29 by the
> workflow itself. The error came from checking `git tag` in a clone whose fetch refspec is
> `+refs/heads/*:refs/remotes/origin/*` — heads only, no tags — and concluding from an empty
> local list that no releases existed, without checking the remote. Any claim in a future audit
> about tags or releases should be verified with `git ls-remote --tags origin` or the Releases
> API, not `git tag`. The affected rows below have been corrected in place.

## 1. Summary

- **The code is healthy; the project's picture of itself is not.** Type-check, lint, format,
Expand Down Expand Up @@ -43,7 +52,7 @@ Existing documents were audited, not rewritten.
| Tests | 52 unit, 12 integration, 16 E2E spec files (~20,400 LOC) |
| TODO/FIXME in `src/` | 0 |
| CI on `main` | green (lint, unit+integration, build+size); E2E disabled; Chromatic masked |
| Releases | `release.yml` triggers on `v*` tags; no tag has ever been pushed, so it is unexercised |
| Releases | `release.yml` triggers on `v*` tags. `v0.2.0` was published 2025-11-29 with both bundle assets attached, so the automation is proven. Nothing has been released since. |

Activity came in two bursts: Nov 2025 (all design/security docs, phases 0–7) and June 2026
(specs 005–012, a large refactor). Only `CLAUDE.md` was updated during the second burst.
Expand Down Expand Up @@ -149,7 +158,7 @@ Status key: **Current** = safe to trust; **Historical** = accurate for its time,
| `docs/COMPONENT_SPEC.md` | 14 | 2025-11-18 | Stale | Component list predates the qd-instructor split and PIN/help components | Re-baseline or archive |
| `docs/INSTRUCTOR_PASSWORD_IMPLEMENTATION.md` | 35 | 2025-11-18 | Historical | Design notes; does not mention the duplicate hash id | Archive |
| `docs/CODE_REDUCTION_PROPOSAL.md` | 3 | 2025-11-24 | Historical | Executed as spec 005 | Archive |
| `docs/RELEASE.md` | 3 | 2025-11-29 | Current (untested) | Process never exercised; no tags exist | Keep; exercise once |
| `docs/RELEASE.md` | 3 | 2025-11-29 | Current | Matches the workflow; the v0.2.0 release confirms it works | Keep |
| `docs/CHROMATIC_SETUP.md` | 2 | 2025-11-15 | Stale | Says "Chromatic is configured"; build is broken and token status unknown | Update after fix |
| `docs/test-coverage-report.md` | 2 | regenerated today | Generated | See §3 caveat on scope | Keep regenerating; do not hand-edit |

Expand Down Expand Up @@ -207,7 +216,7 @@ What an incoming maintainer needs, and where it is today:
| Config contract (hidden spans, DITA XSL) | Exists, with a hole | `CLAUDE.md` Constitution VII; second hash id undocumented |
| Data model and storage layout | Partial | `Contracts.md`, `CLAUDE.md`; object-store count wrong; obfuscation key derivation only in code |
| Event catalogue | Wrong | `Technical_Design.md` §19 and `ARCHITECTURE_FLOWS.md` disagree with code; no single list |
| CI, deploy, PR preview, release | Exists | `CLAUDE.md` GitHub Pages section; `docs/RELEASE.md` (never exercised) |
| CI, deploy, PR preview, release | Exists | `CLAUDE.md` GitHub Pages section; `docs/RELEASE.md` |
| Chromatic / visual testing | Wrong | `docs/CHROMATIC_SETUP.md` |
| Known issues / deferred work | Missing | No list anywhere; this report is the first |
| Which documents to trust | Missing | 13 root docs, no index; this report's §5 is the first classification |
Expand Down Expand Up @@ -242,7 +251,7 @@ Ranked by risk. Effort: S (< half a day), M (1–2 days), L (more).
| 14 | Add unit tests for `src/init/*` and `src/services/state-calculator.ts`; make `test:gaps` path-aware or move the 7 flat tests, then run it `--strict` in CI | Bootstrap and state calculation are the two least-covered critical paths | M | M | Done: 124 unit tests for `src/init/*` and `state-calculator.ts` (init 0% → 99%); gap script accepts flat paths; `--max-gaps` ratchet in CI |
| 15 | Write `docs/MAINTAINERS.md` from §7 and a document index from §5 | No maintainer-facing entry point exists | M | S | Done: `docs/MAINTAINERS.md` and `docs/README.md` index |
| 16 | Backfill plan/tasks for specs 003 and 004, or mark them "implemented without plan" | Two shipped features have no implementation record | L | S | Done: `STATUS.md` in specs 003 and 004 |
| 17 | Exercise the release workflow once with a `v0.2.0` tag | Release automation has never run | L | S | **After merge**: tag `v0.2.0` on `main` (owner approved) |
| 17 | Exercise the release workflow once with a `v0.2.0` tag | Release automation has never run | L | S | **Withdrawn — the premise was wrong.** See the correction below: `v0.2.0` shipped in November 2025. What remains is a version bump for the unreleased work, which is the owner's call. |
| 18 | Budget the ESM bundle or state it is unbudgeted; note IIFE headroom is 2.4 KB | Any new feature will hit the limit | L | S | Done: ESM stated as unbudgeted in `Technical_Design.md` §4; headroom noted |

Trivial fixes applied in the first consolidation PR (#95): regenerated
Expand Down
15 changes: 15 additions & 0 deletions src/components/qd-login.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ export const loginStyles = css`
}

input.pin-input {
/* Kept narrow deliberately: the header bar has only a few pixels of
slack at 1280px, and a wider field wraps the Instructor button onto a
second row. The "4 digits" rule is carried by the hint message and the
field's title/aria-label instead. */
width: 45px;
min-width: 45px;
max-width: 45px;
Expand Down Expand Up @@ -104,6 +108,17 @@ export const loginStyles = css`
background: #5a6268;
}

.hint-message {
width: 100%;
color: #555;
font-size: 11px;
margin-top: 3px;
padding: 4px 8px;
background: #f1f3f4;
border-radius: 3px;
border-left: 3px solid #9aa0a6;
}

.error-message {
width: 100%;
color: #d32f2f;
Expand Down
26 changes: 25 additions & 1 deletion src/components/qd-login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ export class QdLogin extends LitElement {
name="pin"
class="pin-input"
placeholder="PIN"
title="4-digit PIN"
inputmode="numeric"
pattern="[0-9]*"
maxlength="4"
Expand All @@ -183,7 +184,11 @@ export class QdLogin extends LitElement {
Login
</button>
<qd-instructor-login ?disabled=${this.isSubmitting}></qd-instructor-login>
${this.errorMessage ? html`<div class="error-message">${this.errorMessage}</div>` : ''}
${this.errorMessage
? html`<div class="error-message">${this.errorMessage}</div>`
: this.validationHint
? html`<div class="hint-message" role="status">${this.validationHint}</div>`
: ''}
<qd-lockout-banner
.untilMs=${this.lockoutUntil}
@qd:lockout-expired=${this.handleLockoutExpired}
Expand Down Expand Up @@ -243,6 +248,25 @@ export class QdLogin extends LitElement {
return validateStudentForm(this.name, this.serviceId, this.pin).length === 0;
}

/**
* First outstanding validation problem, for display while the user types.
*
* The Login button is disabled until the form is valid, which on its own
* leaves no way to discover what is missing: the submit handler's error
* message is unreachable because submitting is exactly what is blocked.
* This surfaces the same message live instead. A pristine form stays quiet
* so the user is not scolded before typing anything.
*
* @returns The first validation error, or an empty string when the form is
* untouched or valid
*/
private get validationHint(): string {
if (!this.name && !this.serviceId && !this.pin) {
return '';
}
return validateStudentForm(this.name, this.serviceId, this.pin)[0] ?? '';
}

/**
* Handle student login: validate, resolve release/db-name, delegate to
* {@link AuthService}, and apply the result (or open the migration dialog).
Expand Down
2 changes: 1 addition & 1 deletion src/config/help-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export interface HelpContent {
export const HELP_CONTENT: Record<HelpPanelType, HelpContent> = {
login: {
title: 'Login Help',
body: '<p>Enter <strong>Name</strong> and <strong>Service ID</strong> to log in. Provide a new <strong>PIN</strong> if this is your first visit to this release of this document, otherwise use the PIN you previously created. Your instructor is able to reset PINs. See the <b>Feedback</b> page for more support.</p><p> <strong>Instructors:</strong> click "Instructor" for instructor login page (password accompanies distribution).</p>',
body: '<p>Enter <strong>Name</strong> and <strong>Service ID</strong> to log in. Provide a new <strong>4-digit PIN</strong> if this is your first visit to this release of this document, otherwise use the PIN you previously created. Your instructor is able to reset PINs. See the <b>Feedback</b> page for more support.</p><p> <strong>Instructors:</strong> click "Instructor" for instructor login page (password accompanies distribution).</p>',
},

status: {
Expand Down
60 changes: 60 additions & 0 deletions tests/unit/qd-login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,66 @@ describe('QdLogin Component', () => {
// Note: Instructor Modal tests moved to tests/unit/components/qd-password-modal.test.ts
// The old imperative modal was replaced with <qd-password-modal> component

describe('Validation hint', () => {
const hintText = () =>
element.shadowRoot?.querySelector('.hint-message')?.textContent?.trim() ?? null;

it('should stay quiet on a pristine form', () => {
// Nothing typed yet: do not scold the user before they start
expect(hintText()).toBeNull();
});

it('should say what is missing once the user starts typing', async () => {
const nameInput = element.shadowRoot?.querySelector('input[name="name"]') as HTMLInputElement;
nameInput.value = 'J Smith';
nameInput.dispatchEvent(new Event('input'));
await element.updateComplete;

expect(hintText()).toBe('Service ID required');
});

it('should name the PIN length rule when the PIN is too short', async () => {
const set = (name: string, value: string) => {
const input = element.shadowRoot?.querySelector(
`input[name="${name}"]`,
) as HTMLInputElement;
input.value = value;
input.dispatchEvent(new Event('input'));
};
set('name', 'J Smith');
set('serviceId', '30012345');
set('pin', '123');
await element.updateComplete;

expect(hintText()).toBe('PIN must be exactly 4 digits');
});

it('should clear the hint once the form is valid', async () => {
const set = (name: string, value: string) => {
const input = element.shadowRoot?.querySelector(
`input[name="${name}"]`,
) as HTMLInputElement;
input.value = value;
input.dispatchEvent(new Event('input'));
};
set('name', 'J Smith');
set('serviceId', '30012345');
set('pin', '1234');
await element.updateComplete;

expect(hintText()).toBeNull();
});

it('should state the 4-digit requirement on the PIN field', () => {
// The field stays narrow to keep the header on one row, so the rule is
// carried by the tooltip and aria-label rather than the placeholder.
const pinInput = element.shadowRoot?.querySelector('input[name="pin"]') as HTMLInputElement;

expect(pinInput.title).toBe('4-digit PIN');
expect(pinInput.getAttribute('aria-label')).toContain('4-digit PIN');
});
});

describe('UI Behavior', () => {
it('should disable Login button when form is invalid', () => {
const buttons = element.shadowRoot?.querySelectorAll('button');
Expand Down
Loading